From ce7ec0aee47dbfdfb8393dde3aeb3743694d32aa Mon Sep 17 00:00:00 2001 From: eust-w Date: Thu, 26 Mar 2026 22:08:29 +0800 Subject: [PATCH] :sparkles: feat: add project run history surfaces --- README.md | 1 + apps/api/src/runtime/mongo-store.ts | 41 ++++- apps/api/src/runtime/server.ts | 15 ++ .../api/test/runtime-http.integration.spec.ts | 115 +++++++++++++ .../runs/components/run-graph-view.tsx | 2 + .../runs/components/task-log-panel.tsx | 9 + .../web/src/features/runs/run-detail-page.tsx | 2 + apps/web/src/features/runs/runs-page.tsx | 44 +++++ .../workflows/workflow-editor-page.test.tsx | 35 ++++ apps/web/src/runtime/api-client.ts | 16 ++ apps/web/src/runtime/app.tsx | 161 ++++++++++++++++-- .../03-workflows/workflow-execution-model.md | 3 + ...nformation-architecture-and-key-screens.md | 5 +- design/05-data/mongodb-data-model.md | 1 + ...26-03-26-emboflow-v1-foundation-and-mvp.md | 1 + 15 files changed, 434 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/features/runs/runs-page.tsx diff --git a/README.md b/README.md index 2a5b144..3bbaa10 100644 --- a/README.md +++ b/README.md @@ -66,6 +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. ## Repository Structure diff --git a/apps/api/src/runtime/mongo-store.ts b/apps/api/src/runtime/mongo-store.ts index 11c2705..0cf217f 100644 --- a/apps/api/src/runtime/mongo-store.ts +++ b/apps/api/src/runtime/mongo-store.ts @@ -92,6 +92,8 @@ type WorkflowRunDocument = Timestamped & { _id: string; workflowDefinitionId: string; workflowVersionId: string; + workspaceId: string; + projectId: string; status: "queued"; triggeredBy: string; assetIds: string[]; @@ -464,6 +466,8 @@ export class MongoAppStore { _id: `run-${randomUUID()}`, workflowDefinitionId: input.workflowDefinitionId, workflowVersionId: input.workflowVersionId, + workspaceId: version.workspaceId, + projectId: version.projectId, status: "queued", triggeredBy: input.triggeredBy, assetIds, @@ -499,7 +503,42 @@ export class MongoAppStore { } async getRun(runId: string) { - return this.db.collection("workflow_runs").findOne({ _id: runId }); + const run = await this.db.collection("workflow_runs").findOne({ _id: runId }); + if (!run) { + return null; + } + const definition = await this.getWorkflowDefinition(run.workflowDefinitionId); + return { + ...run, + workflowName: definition?.name ?? run.workflowDefinitionId, + }; + } + + async listRuns(input: { projectId: string; workflowDefinitionId?: string }) { + const filter: Record = { projectId: input.projectId }; + if (input.workflowDefinitionId) { + filter.workflowDefinitionId = input.workflowDefinitionId; + } + + const runs = await this.db + .collection("workflow_runs") + .find(filter) + .sort({ createdAt: -1 }) + .toArray(); + + const definitionIds = Array.from(new Set(runs.map((run) => run.workflowDefinitionId))); + const definitions = definitionIds.length + ? await this.db + .collection("workflow_definitions") + .find({ _id: { $in: definitionIds } }) + .toArray() + : []; + const workflowNames = new Map(definitions.map((definition) => [definition._id, definition.name])); + + return runs.map((run) => ({ + ...run, + workflowName: workflowNames.get(run.workflowDefinitionId) ?? run.workflowDefinitionId, + })); } async listRunTasks(runId: string) { diff --git a/apps/api/src/runtime/server.ts b/apps/api/src/runtime/server.ts index 9d8decf..2633534 100644 --- a/apps/api/src/runtime/server.ts +++ b/apps/api/src/runtime/server.ts @@ -238,6 +238,21 @@ export async function createApiRuntime(config = resolveApiRuntimeConfig()) { } }); + app.get("/api/runs", async (request, response, next) => { + try { + response.json( + await store.listRuns({ + projectId: String(request.query.projectId), + workflowDefinitionId: request.query.workflowDefinitionId + ? String(request.query.workflowDefinitionId) + : undefined, + }), + ); + } catch (error) { + next(error); + } + }); + app.get("/api/runs/:runId", async (request, response, next) => { try { const run = await store.getRun(request.params.runId); diff --git a/apps/api/test/runtime-http.integration.spec.ts b/apps/api/test/runtime-http.integration.spec.ts index ec777a7..6a5d18f 100644 --- a/apps/api/test/runtime-http.integration.spec.ts +++ b/apps/api/test/runtime-http.integration.spec.ts @@ -306,3 +306,118 @@ test("mongo-backed runtime rejects workflow runs without bound assets", async (t assert.equal(response.status, 400); assert.match(await response.text(), /assetIds/i); }); + +test("mongo-backed runtime lists recent runs for a project", async (t) => { + const sourceDir = await mkdtemp(path.join(os.tmpdir(), "emboflow-runtime-runs-")); + await mkdir(path.join(sourceDir, "DJI_001")); + await writeFile(path.join(sourceDir, "meta.json"), "{}"); + await writeFile(path.join(sourceDir, "intrinsics.json"), "{}"); + await writeFile(path.join(sourceDir, "video_meta.json"), "{}"); + + const mongod = await MongoMemoryServer.create({ + instance: { + ip: "127.0.0.1", + port: 27120, + }, + }); + t.after(async () => { + await mongod.stop(); + }); + + const server = await startRuntimeServer({ + host: "127.0.0.1", + port: 0, + mongoUri: mongod.getUri(), + database: "emboflow-runtime-run-list", + 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: "run-list-user", projectName: "Run List Project" }), + }), + ); + + const asset = await readJson<{ _id: string }>( + await fetch(`${server.baseUrl}/api/assets/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + workspaceId: bootstrap.workspace._id, + projectId: bootstrap.project._id, + sourcePath: sourceDir, + }), + }), + ); + await readJson(await fetch(`${server.baseUrl}/api/assets/${asset._id}/probe`, { method: "POST" })); + + const workflow = await readJson<{ _id: string; name: 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: "Recent Runs 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"], + }), + }), + ); + + await readJson( + await fetch(`${server.baseUrl}/api/runs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + workflowDefinitionId: workflow._id, + workflowVersionId: version._id, + assetIds: [asset._id], + }), + }), + ); + + const runs = await readJson< + Array<{ + _id: string; + projectId: string; + workflowDefinitionId: string; + workflowName: string; + assetIds: string[]; + status: string; + }> + >( + await fetch( + `${server.baseUrl}/api/runs?projectId=${encodeURIComponent(bootstrap.project._id)}`, + ), + ); + + assert.equal(runs.length, 1); + assert.equal(runs[0]?.projectId, bootstrap.project._id); + assert.equal(runs[0]?.workflowDefinitionId, workflow._id); + assert.equal(runs[0]?.workflowName, "Recent Runs Flow"); + assert.deepEqual(runs[0]?.assetIds, [asset._id]); + assert.equal(runs[0]?.status, "queued"); +}); 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 e1efc16..dde7219 100644 --- a/apps/web/src/features/runs/components/run-graph-view.tsx +++ b/apps/web/src/features/runs/components/run-graph-view.tsx @@ -3,6 +3,8 @@ export type RunTaskView = { nodeId: string; nodeName: string; status: string; + assetIds?: string[]; + artifactIds?: 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 7e969c8..a192e48 100644 --- a/apps/web/src/features/runs/components/task-log-panel.tsx +++ b/apps/web/src/features/runs/components/task-log-panel.tsx @@ -14,6 +14,15 @@ export function renderTaskLogPanel( `; diff --git a/apps/web/src/features/runs/run-detail-page.tsx b/apps/web/src/features/runs/run-detail-page.tsx index 745171f..89c8e43 100644 --- a/apps/web/src/features/runs/run-detail-page.tsx +++ b/apps/web/src/features/runs/run-detail-page.tsx @@ -12,6 +12,7 @@ export type RunDetailPageInput = { id: string; workflowName: string; status: string; + assetIds?: string[]; }; tasks: RunTaskView[]; selectedTaskId?: string; @@ -28,6 +29,7 @@ export function renderRunDetailPage(input: RunDetailPageInput): string {

${input.run.workflowName}

Run ${input.run.id}

Status: ${input.run.status}

+

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

${renderRunGraphView(input.tasks)} ${renderTaskLogPanel(input.tasks, input.selectedTaskId)} diff --git a/apps/web/src/features/runs/runs-page.tsx b/apps/web/src/features/runs/runs-page.tsx new file mode 100644 index 0000000..c50ef68 --- /dev/null +++ b/apps/web/src/features/runs/runs-page.tsx @@ -0,0 +1,44 @@ +import { renderAppShell } from "../layout/app-shell.tsx"; + +export type RunsPageInput = { + workspaceName: string; + projectName: string; + runs: Array<{ + id: string; + workflowName: string; + status: string; + assetIds: string[]; + }>; +}; + +export function renderRunsPage(input: RunsPageInput): string { + const content = + input.runs.length === 0 + ? `

No workflow runs yet.

` + : input.runs + .map( + (run) => ` +
+ ${run.workflowName} +

Status: ${run.status}

+

Input assets: ${run.assetIds.join(", ") || "none"}

+
+ `, + ) + .join(""); + + return renderAppShell({ + workspaceName: input.workspaceName, + projectName: input.projectName, + activeItem: "Runs", + content: ` +
+
+

Runs

+

Recent workflow executions for the current project.

+
+ ${content} +
+ `, + }); +} 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 4aa439d..f9fda09 100644 --- a/apps/web/src/features/workflows/workflow-editor-page.test.tsx +++ b/apps/web/src/features/workflows/workflow-editor-page.test.tsx @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { renderNodeLibrary } from "./components/node-library.tsx"; import { renderWorkflowEditorPage } from "./workflow-editor-page.tsx"; import { renderRunDetailPage } from "../runs/run-detail-page.tsx"; +import { renderRunsPage } from "../runs/runs-page.tsx"; test("node library renders categories", () => { const html = renderNodeLibrary(); @@ -35,6 +36,7 @@ test("run detail view shows node status badges from run data", () => { id: "run-1", workflowName: "Delivery Normalize", status: "running", + assetIds: ["asset-1"], }, tasks: [ { @@ -42,6 +44,8 @@ test("run detail view shows node status badges from run data", () => { nodeId: "source-asset", nodeName: "Source Asset", status: "success", + assetIds: ["asset-1"], + artifactIds: ["artifact-1"], logLines: ["Asset loaded"], }, { @@ -49,6 +53,8 @@ test("run detail view shows node status badges from run data", () => { nodeId: "validate-structure", nodeName: "Validate Structure", status: "running", + assetIds: ["asset-1"], + artifactIds: ["artifact-2"], logLines: ["Checking metadata"], }, ], @@ -60,4 +66,33 @@ test("run detail view shows node status badges from run data", () => { assert.match(html, /Validate Structure/); assert.match(html, /running/); assert.match(html, /Checking metadata/); + assert.match(html, /Input assets: asset-1/); + assert.match(html, /\/explore\/artifact-2/); +}); + +test("runs page renders project-scoped run history with workflow links", () => { + const html = renderRunsPage({ + workspaceName: "Team Workspace", + projectName: "Pipeline Project", + runs: [ + { + id: "run-1", + workflowName: "Delivery Normalize", + status: "success", + assetIds: ["asset-1"], + }, + { + id: "run-2", + workflowName: "Archive Extract", + status: "running", + assetIds: ["asset-2", "asset-3"], + }, + ], + }); + + assert.match(html, /Recent workflow executions/); + assert.match(html, /Delivery Normalize/); + assert.match(html, /Archive Extract/); + assert.match(html, /Input assets: asset-2, asset-3/); + assert.match(html, /\/runs\/run-2/); }); diff --git a/apps/web/src/runtime/api-client.ts b/apps/web/src/runtime/api-client.ts index 8816d79..3c6e58b 100644 --- a/apps/web/src/runtime/api-client.ts +++ b/apps/web/src/runtime/api-client.ts @@ -125,6 +125,17 @@ export class ApiClient { return readJson(await fetch(`${this.baseUrl}/api/runs/${runId}`)); } + async listRuns(input: { + projectId: string; + workflowDefinitionId?: string; + }) { + const search = new URLSearchParams({ projectId: input.projectId }); + if (input.workflowDefinitionId) { + search.set("workflowDefinitionId", input.workflowDefinitionId); + } + return readJson(await fetch(`${this.baseUrl}/api/runs?${search.toString()}`)); + } + async listRunTasks(runId: string) { return readJson(await fetch(`${this.baseUrl}/api/runs/${runId}/tasks`)); } @@ -148,4 +159,9 @@ export class ApiClient { async getArtifact(artifactId: string) { return readJson(await fetch(`${this.baseUrl}/api/artifacts/${artifactId}`)); } + + async listArtifactsByProducer(producerType: string, producerId: string) { + const search = new URLSearchParams({ producerType, producerId }); + return readJson(await fetch(`${this.baseUrl}/api/artifacts?${search.toString()}`)); + } } diff --git a/apps/web/src/runtime/app.tsx b/apps/web/src/runtime/app.tsx index 90d5522..cc66e41 100644 --- a/apps/web/src/runtime/app.tsx +++ b/apps/web/src/runtime/app.tsx @@ -533,12 +533,71 @@ function WorkflowEditorPage(props: { ); } -function RunsIndexPage() { +function RunsIndexPage(props: { + api: ApiClient; + bootstrap: BootstrapContext; +}) { + const [runs, setRuns] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType | undefined; + + const load = async () => { + try { + const nextRuns = await props.api.listRuns({ + projectId: props.bootstrap.project._id, + }); + if (!cancelled) { + setRuns(nextRuns); + setError(null); + timer = setTimeout(() => { + void load(); + }, 1500); + } + } catch (loadError) { + if (!cancelled) { + setError(loadError instanceof Error ? loadError.message : "Failed to load runs"); + } + } + }; + + void load(); + return () => { + cancelled = true; + if (timer) { + clearTimeout(timer); + } + }; + }, [props.api, props.bootstrap.project._id]); + return ( -
-

Runs

-

Open a specific run from the workflow editor.

-
+
+
+

Runs

+

Recent workflow executions for the current project.

+ {error ?

{error}

: null} +
+
+
+ {runs.length === 0 ? ( +

No workflow runs yet.

+ ) : ( + runs.map((run) => ( + + )) + )} +
+
+
); } @@ -548,22 +607,76 @@ function RunDetailPage(props: { }) { const [run, setRun] = useState(null); const [tasks, setTasks] = useState([]); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [artifacts, setArtifacts] = useState([]); const [error, setError] = useState(null); useEffect(() => { - void (async () => { + let cancelled = false; + let timer: ReturnType | undefined; + + const load = async () => { try { const [runData, taskData] = await Promise.all([ props.api.getRun(props.runId), props.api.listRunTasks(props.runId), ]); + if (cancelled) { + return; + } setRun(runData); setTasks(taskData); + setError(null); + if (runData.status === "queued" || runData.status === "running") { + timer = setTimeout(() => { + void load(); + }, 1000); + } } catch (loadError) { - setError(loadError instanceof Error ? loadError.message : "Failed to load run detail"); + if (!cancelled) { + setError(loadError instanceof Error ? loadError.message : "Failed to load run detail"); + } + } + }; + + void load(); + return () => { + cancelled = true; + if (timer) { + clearTimeout(timer); + } + }; + }, [props.api, props.runId]); + + useEffect(() => { + if (tasks.length === 0) { + setSelectedTaskId(null); + return; + } + if (selectedTaskId && tasks.some((task) => task._id === selectedTaskId)) { + return; + } + setSelectedTaskId(tasks[0]?._id ?? null); + }, [tasks, selectedTaskId]); + + const selectedTask = useMemo( + () => tasks.find((task) => task._id === selectedTaskId) ?? tasks[0] ?? null, + [tasks, selectedTaskId], + ); + + useEffect(() => { + if (!selectedTask?._id) { + setArtifacts([]); + return; + } + void (async () => { + try { + setArtifacts(await props.api.listArtifactsByProducer("run_task", selectedTask._id)); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "Failed to load task artifacts"); } })(); - }, [props.runId]); + }, [props.api, selectedTask?._id, (selectedTask?.outputArtifactIds ?? []).join(",")]); if (error) { return
{error}
; @@ -577,6 +690,7 @@ function RunDetailPage(props: {

Run Detail

Run ID: {run._id}

+

Workflow: {run.workflowName ?? run.workflowDefinitionId}

Status: {run.status}

Input assets:{" "} @@ -590,7 +704,13 @@ function RunDetailPage(props: {

Run Graph

{tasks.map((task) => ( -
+
setSelectedTaskId(task._id)} + style={{ cursor: "pointer" }} + >
{task.nodeId} @@ -605,11 +725,24 @@ function RunDetailPage(props: {