diff --git a/README.md b/README.md index 3bbaa10..e203430 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ The local validation path currently used for embodied data testing is: You can register that directory from the Assets page or via `POST /api/assets/register`. The workflow editor currently requires selecting at least one registered asset before a run can be created. -The Runs workspace now shows project-scoped run history, and each run detail view links task artifacts into Explore. +The Runs workspace now shows project-scoped run history, and each run detail view surfaces persisted task summaries, log lines, result previews, and artifact links into Explore. ## Repository Structure diff --git a/apps/api/src/common/mongo/schemas/run-task.schema.ts b/apps/api/src/common/mongo/schemas/run-task.schema.ts index 089ee4f..4762c60 100644 --- a/apps/api/src/common/mongo/schemas/run-task.schema.ts +++ b/apps/api/src/common/mongo/schemas/run-task.schema.ts @@ -5,9 +5,19 @@ export const runTaskSchemaDefinition = { workflowVersionId: { type: "string", required: true }, nodeId: { type: "string", required: true }, nodeType: { type: "string", required: true }, + executorType: { type: "string", required: true }, status: { type: "string", required: true }, attempt: { type: "number", required: true, default: 1 }, - inputRefs: { type: "array", required: true, default: [] }, - outputRefs: { type: "array", required: true, default: [] }, + assetIds: { type: "array", required: true, default: [] }, + upstreamNodeIds: { type: "array", required: true, default: [] }, + outputArtifactIds: { type: "array", required: true, default: [] }, + logLines: { type: "array", required: true, default: [] }, + errorMessage: { type: "string", required: false, default: null }, + summary: { type: "object", required: false, default: null }, + lastResultPreview: { type: "object", required: false, default: null }, + startedAt: { type: "date", required: false, default: null }, + finishedAt: { type: "date", required: false, default: null }, + durationMs: { type: "number", required: false, default: null }, createdAt: { type: "date", required: true }, + updatedAt: { type: "date", required: true }, } as const; diff --git a/apps/api/src/runtime/mongo-store.ts b/apps/api/src/runtime/mongo-store.ts index 0cf217f..7486dfb 100644 --- a/apps/api/src/runtime/mongo-store.ts +++ b/apps/api/src/runtime/mongo-store.ts @@ -5,7 +5,10 @@ import type { Db, Document, WithId } from "mongodb"; import type { AssetType } from "../../../../packages/contracts/src/domain.ts"; import { DELIVERY_NODE_DEFINITIONS } from "../modules/plugins/builtin/delivery-nodes.ts"; import { probeLocalSourcePath } from "./local-source-probe.ts"; -import type { ExecutorType } from "../../../worker/src/contracts/execution-context.ts"; +import type { + ExecutorType, + TaskExecutionSummary, +} from "../../../worker/src/contracts/execution-context.ts"; type Timestamped = { createdAt: string; @@ -111,6 +114,13 @@ type RunTaskDocument = Timestamped & { assetIds: string[]; upstreamNodeIds: string[]; outputArtifactIds: string[]; + startedAt?: string; + finishedAt?: string; + durationMs?: number; + logLines?: string[]; + summary?: TaskExecutionSummary; + lastResultPreview?: Record; + errorMessage?: string; }; type ArtifactDocument = Timestamped & { @@ -491,6 +501,7 @@ export class MongoAppStore { .filter((edge) => edge.to === node.id) .map((edge) => edge.from), outputArtifactIds: [], + logLines: [], createdAt: nowIso(), updatedAt: nowIso(), })); diff --git a/apps/api/test/runtime-http.integration.spec.ts b/apps/api/test/runtime-http.integration.spec.ts index 6a5d18f..b25cae0 100644 --- a/apps/api/test/runtime-http.integration.spec.ts +++ b/apps/api/test/runtime-http.integration.spec.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import { MongoMemoryServer } from "mongodb-memory-server"; +import { MongoClient } from "mongodb"; import { createApiRuntime, type ApiRuntimeConfig } from "../src/runtime/server.ts"; @@ -421,3 +422,170 @@ test("mongo-backed runtime lists recent runs for a project", async (t) => { assert.deepEqual(runs[0]?.assetIds, [asset._id]); assert.equal(runs[0]?.status, "queued"); }); + +test("mongo-backed runtime exposes persisted task execution summaries and logs", async (t) => { + const mongod = await MongoMemoryServer.create({ + instance: { + ip: "127.0.0.1", + port: 27121, + }, + }); + t.after(async () => { + await mongod.stop(); + }); + + const database = "emboflow-runtime-task-summaries"; + const server = await startRuntimeServer({ + host: "127.0.0.1", + port: 0, + mongoUri: mongod.getUri(), + database, + corsOrigin: "http://127.0.0.1:3000", + }); + t.after(async () => { + await server.close(); + }); + + const bootstrap = await readJson<{ + workspace: { _id: string }; + project: { _id: string }; + }>( + await fetch(`${server.baseUrl}/api/dev/bootstrap`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ userId: "task-summary-user", projectName: "Task Summary Project" }), + }), + ); + + const workflow = await readJson<{ _id: string }>( + await fetch(`${server.baseUrl}/api/workflows`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + workspaceId: bootstrap.workspace._id, + projectId: bootstrap.project._id, + name: "Task Summary Flow", + }), + }), + ); + + const version = await readJson<{ _id: string }>( + await fetch(`${server.baseUrl}/api/workflows/${workflow._id}/versions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + visualGraph: { viewport: { x: 0, y: 0, zoom: 1 } }, + logicGraph: { + nodes: [{ id: "source-asset", type: "source" }], + edges: [], + }, + runtimeGraph: { selectedPreset: "delivery-normalization" }, + pluginRefs: ["builtin:delivery-nodes"], + }), + }), + ); + + const assetId = "asset-task-summary"; + const client = new MongoClient(mongod.getUri()); + await client.connect(); + t.after(async () => { + await client.close(); + }); + const db = client.db(database); + await db.collection("assets").insertOne({ + _id: assetId, + workspaceId: bootstrap.workspace._id, + projectId: bootstrap.project._id, + type: "folder", + sourceType: "local_path", + displayName: "Summary Asset", + status: "probed", + storageRef: {}, + topLevelPaths: ["DJI_001"], + detectedFormats: ["delivery_package"], + fileCount: 1, + summary: {}, + createdBy: "task-summary-user", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const run = await readJson<{ _id: string }>( + await fetch(`${server.baseUrl}/api/runs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + workflowDefinitionId: workflow._id, + workflowVersionId: version._id, + assetIds: [assetId], + }), + }), + ); + + const [task] = await db + .collection("run_tasks") + .find({ workflowRunId: run._id }) + .sort({ createdAt: 1 }) + .toArray(); + + await db.collection("run_tasks").updateOne( + { _id: task?._id }, + { + $set: { + status: "success", + startedAt: "2026-03-27T10:00:00.000Z", + finishedAt: "2026-03-27T10:00:02.500Z", + durationMs: 2500, + logLines: ["Task claimed by worker", "Executor completed successfully"], + summary: { + outcome: "success", + executorType: "python", + assetCount: 1, + artifactIds: ["artifact-1"], + }, + lastResultPreview: { + taskId: task?._id, + executor: "python", + }, + }, + }, + ); + + const tasks = await readJson< + Array<{ + _id: string; + status: string; + startedAt?: string; + finishedAt?: string; + durationMs?: number; + logLines?: string[]; + summary?: { + outcome?: string; + executorType?: string; + assetCount?: number; + artifactIds?: string[]; + }; + lastResultPreview?: { + taskId?: string; + executor?: string; + }; + }> + >(await fetch(`${server.baseUrl}/api/runs/${run._id}/tasks`)); + + assert.equal(tasks.length, 1); + assert.equal(tasks[0]?._id, task?._id); + assert.equal(tasks[0]?.status, "success"); + assert.equal(tasks[0]?.durationMs, 2500); + assert.deepEqual(tasks[0]?.logLines, [ + "Task claimed by worker", + "Executor completed successfully", + ]); + assert.equal(tasks[0]?.summary?.outcome, "success"); + assert.equal(tasks[0]?.summary?.executorType, "python"); + assert.equal(tasks[0]?.summary?.assetCount, 1); + assert.deepEqual(tasks[0]?.summary?.artifactIds, ["artifact-1"]); + assert.deepEqual(tasks[0]?.lastResultPreview, { + taskId: task?._id, + executor: "python", + }); +}); diff --git a/apps/web/src/features/runs/components/run-graph-view.tsx b/apps/web/src/features/runs/components/run-graph-view.tsx index dde7219..3466e3a 100644 --- a/apps/web/src/features/runs/components/run-graph-view.tsx +++ b/apps/web/src/features/runs/components/run-graph-view.tsx @@ -5,6 +5,9 @@ export type RunTaskView = { status: string; assetIds?: string[]; artifactIds?: string[]; + durationMs?: number; + summaryLabel?: string; + errorMessage?: string; logLines: string[]; }; diff --git a/apps/web/src/features/runs/components/task-log-panel.tsx b/apps/web/src/features/runs/components/task-log-panel.tsx index a192e48..1dd802d 100644 --- a/apps/web/src/features/runs/components/task-log-panel.tsx +++ b/apps/web/src/features/runs/components/task-log-panel.tsx @@ -15,6 +15,9 @@ export function renderTaskLogPanel(

${task.nodeName}

Status: ${task.status}

Input assets: ${(task.assetIds ?? []).join(", ") || "none"}

+

Duration: ${typeof task.durationMs === "number" ? `${task.durationMs} ms` : "n/a"}

+ ${task.summaryLabel ? `

Summary: ${task.summaryLabel}

` : ""} + ${task.errorMessage ? `

Error: ${task.errorMessage}

` : ""}

Artifacts: ${(task.artifactIds ?? []).length}

${ (task.artifactIds ?? []).length > 0 diff --git a/apps/web/src/features/workflows/workflow-editor-page.test.tsx b/apps/web/src/features/workflows/workflow-editor-page.test.tsx index f9fda09..6881c57 100644 --- a/apps/web/src/features/workflows/workflow-editor-page.test.tsx +++ b/apps/web/src/features/workflows/workflow-editor-page.test.tsx @@ -46,6 +46,8 @@ test("run detail view shows node status badges from run data", () => { status: "success", assetIds: ["asset-1"], artifactIds: ["artifact-1"], + durationMs: 1200, + summaryLabel: "Processed 1 asset", logLines: ["Asset loaded"], }, { @@ -55,6 +57,8 @@ test("run detail view shows node status badges from run data", () => { status: "running", assetIds: ["asset-1"], artifactIds: ["artifact-2"], + durationMs: 2450, + summaryLabel: "Validated delivery package structure", logLines: ["Checking metadata"], }, ], @@ -67,6 +71,8 @@ test("run detail view shows node status badges from run data", () => { assert.match(html, /running/); assert.match(html, /Checking metadata/); assert.match(html, /Input assets: asset-1/); + assert.match(html, /Duration: 2450 ms/); + assert.match(html, /Validated delivery package structure/); assert.match(html, /\/explore\/artifact-2/); }); diff --git a/apps/web/src/runtime/app.tsx b/apps/web/src/runtime/app.tsx index cc66e41..f8b637e 100644 --- a/apps/web/src/runtime/app.tsx +++ b/apps/web/src/runtime/app.tsx @@ -23,6 +23,17 @@ type AppProps = { apiBaseUrl: string; }; +function formatTaskSummary(task: any) { + if (task?.summary?.errorMessage) { + return task.summary.errorMessage; + } + const outcome = task?.summary?.outcome ?? task?.status ?? "unknown"; + const executor = task?.summary?.executorType ?? task?.executorType ?? "unknown"; + const assetCount = task?.summary?.assetCount ?? task?.assetIds?.length ?? 0; + const artifactCount = task?.summary?.artifactIds?.length ?? task?.outputArtifactIds?.length ?? 0; + return `${outcome} via ${executor}; assets ${assetCount}; artifacts ${artifactCount}`; +} + function usePathname() { const [pathname, setPathname] = useState( typeof window === "undefined" ? "/assets" : window.location.pathname || "/assets", @@ -729,7 +740,18 @@ function RunDetailPage(props: { <>

Node: {selectedTask.nodeId}

Status: {selectedTask.status}

+

Executor: {selectedTask.executorType}

Input assets: {(selectedTask.assetIds ?? []).join(", ") || "none"}

+

Started at: {selectedTask.startedAt ?? "n/a"}

+

Finished at: {selectedTask.finishedAt ?? "n/a"}

+

+ Duration:{" "} + {typeof selectedTask.durationMs === "number" + ? `${selectedTask.durationMs} ms` + : "n/a"} +

+

Summary: {formatTaskSummary(selectedTask)}

+ {selectedTask.errorMessage ?

Error: {selectedTask.errorMessage}

: null}

Artifacts: {artifacts.length}

{artifacts.length > 0 ? (