✨ feat: add project run history surfaces
This commit is contained in:
parent
6a3ce185f1
commit
ce7ec0aee4
@ -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`.
|
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 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
|
## Repository Structure
|
||||||
|
|
||||||
|
|||||||
@ -92,6 +92,8 @@ type WorkflowRunDocument = Timestamped & {
|
|||||||
_id: string;
|
_id: string;
|
||||||
workflowDefinitionId: string;
|
workflowDefinitionId: string;
|
||||||
workflowVersionId: string;
|
workflowVersionId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
projectId: string;
|
||||||
status: "queued";
|
status: "queued";
|
||||||
triggeredBy: string;
|
triggeredBy: string;
|
||||||
assetIds: string[];
|
assetIds: string[];
|
||||||
@ -464,6 +466,8 @@ export class MongoAppStore {
|
|||||||
_id: `run-${randomUUID()}`,
|
_id: `run-${randomUUID()}`,
|
||||||
workflowDefinitionId: input.workflowDefinitionId,
|
workflowDefinitionId: input.workflowDefinitionId,
|
||||||
workflowVersionId: input.workflowVersionId,
|
workflowVersionId: input.workflowVersionId,
|
||||||
|
workspaceId: version.workspaceId,
|
||||||
|
projectId: version.projectId,
|
||||||
status: "queued",
|
status: "queued",
|
||||||
triggeredBy: input.triggeredBy,
|
triggeredBy: input.triggeredBy,
|
||||||
assetIds,
|
assetIds,
|
||||||
@ -499,7 +503,42 @@ export class MongoAppStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getRun(runId: string) {
|
async getRun(runId: string) {
|
||||||
return this.db.collection<WorkflowRunDocument>("workflow_runs").findOne({ _id: runId });
|
const run = await this.db.collection<WorkflowRunDocument>("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<string, string> = { projectId: input.projectId };
|
||||||
|
if (input.workflowDefinitionId) {
|
||||||
|
filter.workflowDefinitionId = input.workflowDefinitionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runs = await this.db
|
||||||
|
.collection<WorkflowRunDocument>("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<WorkflowDefinitionDocument>("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) {
|
async listRunTasks(runId: string) {
|
||||||
|
|||||||
@ -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) => {
|
app.get("/api/runs/:runId", async (request, response, next) => {
|
||||||
try {
|
try {
|
||||||
const run = await store.getRun(request.params.runId);
|
const run = await store.getRun(request.params.runId);
|
||||||
|
|||||||
@ -306,3 +306,118 @@ test("mongo-backed runtime rejects workflow runs without bound assets", async (t
|
|||||||
assert.equal(response.status, 400);
|
assert.equal(response.status, 400);
|
||||||
assert.match(await response.text(), /assetIds/i);
|
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");
|
||||||
|
});
|
||||||
|
|||||||
@ -3,6 +3,8 @@ export type RunTaskView = {
|
|||||||
nodeId: string;
|
nodeId: string;
|
||||||
nodeName: string;
|
nodeName: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
assetIds?: string[];
|
||||||
|
artifactIds?: string[];
|
||||||
logLines: string[];
|
logLines: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,15 @@ export function renderTaskLogPanel(
|
|||||||
<aside data-view="task-log-panel">
|
<aside data-view="task-log-panel">
|
||||||
<h2>${task.nodeName}</h2>
|
<h2>${task.nodeName}</h2>
|
||||||
<p>Status: ${task.status}</p>
|
<p>Status: ${task.status}</p>
|
||||||
|
<p>Input assets: ${(task.assetIds ?? []).join(", ") || "none"}</p>
|
||||||
|
<p>Artifacts: ${(task.artifactIds ?? []).length}</p>
|
||||||
|
${
|
||||||
|
(task.artifactIds ?? []).length > 0
|
||||||
|
? `<ul>${(task.artifactIds ?? [])
|
||||||
|
.map((artifactId) => `<li><a href="/explore/${artifactId}">${artifactId}</a></li>`)
|
||||||
|
.join("")}</ul>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
<ul>${lines || "<li>No logs</li>"}</ul>
|
<ul>${lines || "<li>No logs</li>"}</ul>
|
||||||
</aside>
|
</aside>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export type RunDetailPageInput = {
|
|||||||
id: string;
|
id: string;
|
||||||
workflowName: string;
|
workflowName: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
assetIds?: string[];
|
||||||
};
|
};
|
||||||
tasks: RunTaskView[];
|
tasks: RunTaskView[];
|
||||||
selectedTaskId?: string;
|
selectedTaskId?: string;
|
||||||
@ -28,6 +29,7 @@ export function renderRunDetailPage(input: RunDetailPageInput): string {
|
|||||||
<h1>${input.run.workflowName}</h1>
|
<h1>${input.run.workflowName}</h1>
|
||||||
<p>Run ${input.run.id}</p>
|
<p>Run ${input.run.id}</p>
|
||||||
<p>Status: ${input.run.status}</p>
|
<p>Status: ${input.run.status}</p>
|
||||||
|
<p>Input assets: ${(input.run.assetIds ?? []).join(", ") || "none"}</p>
|
||||||
</header>
|
</header>
|
||||||
${renderRunGraphView(input.tasks)}
|
${renderRunGraphView(input.tasks)}
|
||||||
${renderTaskLogPanel(input.tasks, input.selectedTaskId)}
|
${renderTaskLogPanel(input.tasks, input.selectedTaskId)}
|
||||||
|
|||||||
44
apps/web/src/features/runs/runs-page.tsx
Normal file
44
apps/web/src/features/runs/runs-page.tsx
Normal file
@ -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
|
||||||
|
? `<p>No workflow runs yet.</p>`
|
||||||
|
: input.runs
|
||||||
|
.map(
|
||||||
|
(run) => `
|
||||||
|
<article data-run-id="${run.id}">
|
||||||
|
<a href="/runs/${run.id}"><strong>${run.workflowName}</strong></a>
|
||||||
|
<p>Status: ${run.status}</p>
|
||||||
|
<p>Input assets: ${run.assetIds.join(", ") || "none"}</p>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return renderAppShell({
|
||||||
|
workspaceName: input.workspaceName,
|
||||||
|
projectName: input.projectName,
|
||||||
|
activeItem: "Runs",
|
||||||
|
content: `
|
||||||
|
<section data-view="runs-page">
|
||||||
|
<header>
|
||||||
|
<h1>Runs</h1>
|
||||||
|
<p>Recent workflow executions for the current project.</p>
|
||||||
|
</header>
|
||||||
|
${content}
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import assert from "node:assert/strict";
|
|||||||
import { renderNodeLibrary } from "./components/node-library.tsx";
|
import { renderNodeLibrary } from "./components/node-library.tsx";
|
||||||
import { renderWorkflowEditorPage } from "./workflow-editor-page.tsx";
|
import { renderWorkflowEditorPage } from "./workflow-editor-page.tsx";
|
||||||
import { renderRunDetailPage } from "../runs/run-detail-page.tsx";
|
import { renderRunDetailPage } from "../runs/run-detail-page.tsx";
|
||||||
|
import { renderRunsPage } from "../runs/runs-page.tsx";
|
||||||
|
|
||||||
test("node library renders categories", () => {
|
test("node library renders categories", () => {
|
||||||
const html = renderNodeLibrary();
|
const html = renderNodeLibrary();
|
||||||
@ -35,6 +36,7 @@ test("run detail view shows node status badges from run data", () => {
|
|||||||
id: "run-1",
|
id: "run-1",
|
||||||
workflowName: "Delivery Normalize",
|
workflowName: "Delivery Normalize",
|
||||||
status: "running",
|
status: "running",
|
||||||
|
assetIds: ["asset-1"],
|
||||||
},
|
},
|
||||||
tasks: [
|
tasks: [
|
||||||
{
|
{
|
||||||
@ -42,6 +44,8 @@ test("run detail view shows node status badges from run data", () => {
|
|||||||
nodeId: "source-asset",
|
nodeId: "source-asset",
|
||||||
nodeName: "Source Asset",
|
nodeName: "Source Asset",
|
||||||
status: "success",
|
status: "success",
|
||||||
|
assetIds: ["asset-1"],
|
||||||
|
artifactIds: ["artifact-1"],
|
||||||
logLines: ["Asset loaded"],
|
logLines: ["Asset loaded"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -49,6 +53,8 @@ test("run detail view shows node status badges from run data", () => {
|
|||||||
nodeId: "validate-structure",
|
nodeId: "validate-structure",
|
||||||
nodeName: "Validate Structure",
|
nodeName: "Validate Structure",
|
||||||
status: "running",
|
status: "running",
|
||||||
|
assetIds: ["asset-1"],
|
||||||
|
artifactIds: ["artifact-2"],
|
||||||
logLines: ["Checking metadata"],
|
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, /Validate Structure/);
|
||||||
assert.match(html, /running/);
|
assert.match(html, /running/);
|
||||||
assert.match(html, /Checking metadata/);
|
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/);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -125,6 +125,17 @@ export class ApiClient {
|
|||||||
return readJson<any>(await fetch(`${this.baseUrl}/api/runs/${runId}`));
|
return readJson<any>(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<any[]>(await fetch(`${this.baseUrl}/api/runs?${search.toString()}`));
|
||||||
|
}
|
||||||
|
|
||||||
async listRunTasks(runId: string) {
|
async listRunTasks(runId: string) {
|
||||||
return readJson<any[]>(await fetch(`${this.baseUrl}/api/runs/${runId}/tasks`));
|
return readJson<any[]>(await fetch(`${this.baseUrl}/api/runs/${runId}/tasks`));
|
||||||
}
|
}
|
||||||
@ -148,4 +159,9 @@ export class ApiClient {
|
|||||||
async getArtifact(artifactId: string) {
|
async getArtifact(artifactId: string) {
|
||||||
return readJson<any>(await fetch(`${this.baseUrl}/api/artifacts/${artifactId}`));
|
return readJson<any>(await fetch(`${this.baseUrl}/api/artifacts/${artifactId}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listArtifactsByProducer(producerType: string, producerId: string) {
|
||||||
|
const search = new URLSearchParams({ producerType, producerId });
|
||||||
|
return readJson<any[]>(await fetch(`${this.baseUrl}/api/artifacts?${search.toString()}`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -533,12 +533,71 @@ function WorkflowEditorPage(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RunsIndexPage() {
|
function RunsIndexPage(props: {
|
||||||
|
api: ApiClient;
|
||||||
|
bootstrap: BootstrapContext;
|
||||||
|
}) {
|
||||||
|
const [runs, setRuns] = useState<any[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | 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 (
|
return (
|
||||||
<section className="panel">
|
<div className="page-stack">
|
||||||
<h1>Runs</h1>
|
<section className="panel">
|
||||||
<p className="empty-state">Open a specific run from the workflow editor.</p>
|
<h1>Runs</h1>
|
||||||
</section>
|
<p>Recent workflow executions for the current project.</p>
|
||||||
|
{error ? <p>{error}</p> : null}
|
||||||
|
</section>
|
||||||
|
<section className="panel">
|
||||||
|
<div className="list-grid">
|
||||||
|
{runs.length === 0 ? (
|
||||||
|
<p className="empty-state">No workflow runs yet.</p>
|
||||||
|
) : (
|
||||||
|
runs.map((run) => (
|
||||||
|
<article key={run._id} className="asset-card">
|
||||||
|
<a href={`/runs/${run._id}`}>
|
||||||
|
<strong>{run.workflowName ?? run.workflowDefinitionId}</strong>
|
||||||
|
</a>
|
||||||
|
<p>Status: {run.status}</p>
|
||||||
|
<p>Input assets: {(run.assetIds ?? []).join(", ") || "none"}</p>
|
||||||
|
<p>Created at: {run.createdAt}</p>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -548,22 +607,76 @@ function RunDetailPage(props: {
|
|||||||
}) {
|
}) {
|
||||||
const [run, setRun] = useState<any | null>(null);
|
const [run, setRun] = useState<any | null>(null);
|
||||||
const [tasks, setTasks] = useState<any[]>([]);
|
const [tasks, setTasks] = useState<any[]>([]);
|
||||||
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||||
|
const [artifacts, setArtifacts] = useState<any[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const [runData, taskData] = await Promise.all([
|
const [runData, taskData] = await Promise.all([
|
||||||
props.api.getRun(props.runId),
|
props.api.getRun(props.runId),
|
||||||
props.api.listRunTasks(props.runId),
|
props.api.listRunTasks(props.runId),
|
||||||
]);
|
]);
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setRun(runData);
|
setRun(runData);
|
||||||
setTasks(taskData);
|
setTasks(taskData);
|
||||||
|
setError(null);
|
||||||
|
if (runData.status === "queued" || runData.status === "running") {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
void load();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
} catch (loadError) {
|
} 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) {
|
if (error) {
|
||||||
return <section className="panel">{error}</section>;
|
return <section className="panel">{error}</section>;
|
||||||
@ -577,6 +690,7 @@ function RunDetailPage(props: {
|
|||||||
<section className="panel">
|
<section className="panel">
|
||||||
<h1>Run Detail</h1>
|
<h1>Run Detail</h1>
|
||||||
<p>Run ID: {run._id}</p>
|
<p>Run ID: {run._id}</p>
|
||||||
|
<p>Workflow: {run.workflowName ?? run.workflowDefinitionId}</p>
|
||||||
<p>Status: {run.status}</p>
|
<p>Status: {run.status}</p>
|
||||||
<p>
|
<p>
|
||||||
Input assets:{" "}
|
Input assets:{" "}
|
||||||
@ -590,7 +704,13 @@ function RunDetailPage(props: {
|
|||||||
<h2>Run Graph</h2>
|
<h2>Run Graph</h2>
|
||||||
<div className="list-grid">
|
<div className="list-grid">
|
||||||
{tasks.map((task) => (
|
{tasks.map((task) => (
|
||||||
<article key={task._id} className="task-card">
|
<article
|
||||||
|
key={task._id}
|
||||||
|
className="task-card"
|
||||||
|
data-selected={String(selectedTask?._id === task._id)}
|
||||||
|
onClick={() => setSelectedTaskId(task._id)}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<strong>{task.nodeId}</strong>
|
<strong>{task.nodeId}</strong>
|
||||||
<span className="status-pill" data-status={task.status}>
|
<span className="status-pill" data-status={task.status}>
|
||||||
@ -605,11 +725,24 @@ function RunDetailPage(props: {
|
|||||||
</div>
|
</div>
|
||||||
<aside className="panel">
|
<aside className="panel">
|
||||||
<h2>Selected Task</h2>
|
<h2>Selected Task</h2>
|
||||||
{tasks[0] ? (
|
{selectedTask ? (
|
||||||
<>
|
<>
|
||||||
<p>Node: {tasks[0].nodeId}</p>
|
<p>Node: {selectedTask.nodeId}</p>
|
||||||
<p>Status: {tasks[0].status}</p>
|
<p>Status: {selectedTask.status}</p>
|
||||||
<pre className="mono-block">{JSON.stringify(tasks[0], null, 2)}</pre>
|
<p>Input assets: {(selectedTask.assetIds ?? []).join(", ") || "none"}</p>
|
||||||
|
<p>Artifacts: {artifacts.length}</p>
|
||||||
|
{artifacts.length > 0 ? (
|
||||||
|
<ul>
|
||||||
|
{artifacts.map((artifact) => (
|
||||||
|
<li key={artifact._id}>
|
||||||
|
<a href={`/explore/${artifact._id}`}>{artifact.title ?? artifact._id}</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="empty-state">No task artifacts yet.</p>
|
||||||
|
)}
|
||||||
|
<pre className="mono-block">{JSON.stringify(selectedTask, null, 2)}</pre>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="empty-state">No tasks created.</p>
|
<p className="empty-state">No tasks created.</p>
|
||||||
@ -709,7 +842,7 @@ export function App(props: AppProps) {
|
|||||||
content = <WorkflowEditorPage api={api} workflowId={workflowMatch[1]} />;
|
content = <WorkflowEditorPage api={api} workflowId={workflowMatch[1]} />;
|
||||||
} else if (pathname === "/runs") {
|
} else if (pathname === "/runs") {
|
||||||
active = "Runs";
|
active = "Runs";
|
||||||
content = <RunsIndexPage />;
|
content = <RunsIndexPage api={api} bootstrap={bootstrap} />;
|
||||||
} else if (runMatch) {
|
} else if (runMatch) {
|
||||||
active = "Runs";
|
active = "Runs";
|
||||||
content = <RunDetailPage api={api} runId={runMatch[1]} />;
|
content = <RunDetailPage api={api} runId={runMatch[1]} />;
|
||||||
|
|||||||
@ -272,6 +272,7 @@ The persisted local runtime now covers:
|
|||||||
- workflow definition and immutable version snapshots
|
- workflow definition and immutable version snapshots
|
||||||
- workflow runs and task creation with worker-consumable dependency snapshots
|
- workflow runs and task creation with worker-consumable dependency snapshots
|
||||||
- workflow run asset bindings persisted on both runs and tasks
|
- workflow run asset bindings persisted on both runs and tasks
|
||||||
|
- project-scoped run history queries from Mongo-backed `workflow_runs`
|
||||||
- worker polling of queued tasks from Mongo-backed `run_tasks`
|
- worker polling of queued tasks from Mongo-backed `run_tasks`
|
||||||
- run-task status transitions from `queued/pending` to `running/success/failed`
|
- run-task status transitions from `queued/pending` to `running/success/failed`
|
||||||
- downstream task promotion when upstream nodes succeed
|
- downstream task promotion when upstream nodes succeed
|
||||||
@ -280,6 +281,8 @@ The persisted local runtime now covers:
|
|||||||
|
|
||||||
The React workflow editor now loads the latest persisted version from the Mongo-backed API instead of rendering only a fixed starter graph. Draft edits are local editor state until the user saves, at which point the draft is serialized into a new workflow version document. Before a run is created, the editor loads project assets, requires one to be selected, and passes that binding to the API.
|
The React workflow editor now loads the latest persisted version from the Mongo-backed API instead of rendering only a fixed starter graph. Draft edits are local editor state until the user saves, at which point the draft is serialized into a new workflow version document. Before a run is created, the editor loads project assets, requires one to be selected, and passes that binding to the API.
|
||||||
|
|
||||||
|
The runtime Runs workspace now loads recent runs for the active project. Run detail views poll active runs until they settle and let the operator inspect task-level artifacts directly through Explore links.
|
||||||
|
|
||||||
The API and worker runtimes now both have direct integration coverage against a real Mongo runtime through `mongodb-memory-server`, in addition to the older in-memory contract tests.
|
The API and worker runtimes now both have direct integration coverage against a real Mongo runtime through `mongodb-memory-server`, in addition to the older in-memory contract tests.
|
||||||
|
|
||||||
The first web authoring surface already follows the three-pane layout contract with:
|
The first web authoring surface already follows the three-pane layout contract with:
|
||||||
|
|||||||
@ -275,12 +275,13 @@ The current local runtime now exposes these surfaces as a real React application
|
|||||||
|
|
||||||
- Assets list and asset detail
|
- Assets list and asset detail
|
||||||
- Workflows list and workflow editor
|
- Workflows list and workflow editor
|
||||||
|
- Runs list with project-scoped history
|
||||||
- Run detail
|
- Run detail
|
||||||
- Explore artifact detail
|
- Explore artifact detail
|
||||||
|
|
||||||
The current implementation uses direct API-driven page loads and lightweight route handling instead of a deeper client-side state framework.
|
The current implementation uses direct API-driven page loads, lightweight route handling, and incremental polling for active run detail views instead of a deeper client-side state framework.
|
||||||
|
|
||||||
The workflow editor surface now reflects persisted workflow versions instead of a hardcoded sample graph. It exposes draft status, node add and remove actions, reload-latest behavior, asset selection for run binding, and version-save / run-trigger controls against the live API.
|
The workflow editor surface now reflects persisted workflow versions instead of a hardcoded sample graph. It exposes draft status, node add and remove actions, reload-latest behavior, asset selection for run binding, and version-save / run-trigger controls against the live API. The Runs workspace now exposes project-scoped run history and selected-task artifact links into Explore.
|
||||||
|
|
||||||
Do not rename the same concept differently across pages.
|
Do not rename the same concept differently across pages.
|
||||||
|
|
||||||
|
|||||||
@ -283,6 +283,7 @@ Purpose:
|
|||||||
|
|
||||||
- store execution runs
|
- store execution runs
|
||||||
- snapshot the asset bindings chosen at run creation time
|
- snapshot the asset bindings chosen at run creation time
|
||||||
|
- support project-scoped run history queries without re-reading workflow versions
|
||||||
|
|
||||||
Core fields:
|
Core fields:
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
- `2026-03-26`: The next runtime pass adds a Mongo-backed HTTP API, a real React and Vite web runtime, and local data validation against `/Users/longtaowu/workspace/emboldata/data`.
|
- `2026-03-26`: The next runtime pass adds a Mongo-backed HTTP API, a real React and Vite web runtime, and local data validation against `/Users/longtaowu/workspace/emboldata/data`.
|
||||||
- `2026-03-26`: The follow-up runtime pass adds Mongo-backed HTTP integration tests and converts the workflow editor from a fixed sample graph to a persisted draft-and-version model.
|
- `2026-03-26`: The follow-up runtime pass adds Mongo-backed HTTP integration tests and converts the workflow editor from a fixed sample graph to a persisted draft-and-version model.
|
||||||
- `2026-03-26`: The current runtime pass binds workflow runs to registered project assets so run snapshots, run tasks, worker execution context, and the editor all agree on the concrete input asset being processed.
|
- `2026-03-26`: The current runtime pass binds workflow runs to registered project assets so run snapshots, run tasks, worker execution context, and the editor all agree on the concrete input asset being processed.
|
||||||
|
- `2026-03-26`: The current UI/runtime pass turns Runs into a real project-scoped workspace with run history queries, active-run polling, and task artifact links into Explore.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user