✨ feat: persist workflow drafts and runtime tests
This commit is contained in:
parent
38ddecbe20
commit
c59fba1af1
@ -6,11 +6,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/main.ts",
|
"dev": "tsx watch src/main.ts",
|
||||||
"start": "tsx src/main.ts",
|
"start": "tsx src/main.ts",
|
||||||
"test": "node --test --experimental-strip-types"
|
"test": "tsx --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"mongodb": "^7.1.1"
|
"mongodb": "^7.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"mongodb-memory-server": "^11.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
209
apps/api/test/runtime-http.integration.spec.ts
Normal file
209
apps/api/test/runtime-http.integration.spec.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { MongoMemoryServer } from "mongodb-memory-server";
|
||||||
|
|
||||||
|
import { createApiRuntime, type ApiRuntimeConfig } from "../src/runtime/server.ts";
|
||||||
|
|
||||||
|
async function readJson<T>(response: Response): Promise<T> {
|
||||||
|
const payload = (await response.json()) as T;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`request failed with status ${response.status}: ${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRuntimeServer(config: ApiRuntimeConfig) {
|
||||||
|
const runtime = await createApiRuntime(config);
|
||||||
|
const server = await new Promise<import("node:http").Server>((resolve) => {
|
||||||
|
const listening = runtime.app.listen(0, config.host, () => resolve(listening));
|
||||||
|
});
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("failed to start runtime server");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: `http://${config.host}:${address.port}`,
|
||||||
|
close: async () => {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await runtime.client.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("mongo-backed runtime reuses bootstrapped workspace and project across restarts", async (t) => {
|
||||||
|
const mongod = await MongoMemoryServer.create();
|
||||||
|
t.after(async () => {
|
||||||
|
await mongod.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
const config: ApiRuntimeConfig = {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
mongoUri: mongod.getUri(),
|
||||||
|
database: "emboflow-runtime-reuse",
|
||||||
|
corsOrigin: "http://127.0.0.1:3000",
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = await startRuntimeServer(config);
|
||||||
|
|
||||||
|
const bootstrap = await readJson<{
|
||||||
|
workspace: { _id: string; name: string };
|
||||||
|
project: { _id: string; name: string };
|
||||||
|
}>(
|
||||||
|
await fetch(`${first.baseUrl}/api/dev/bootstrap`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId: "runtime-user", projectName: "Runtime Demo" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await first.close();
|
||||||
|
|
||||||
|
const second = await startRuntimeServer(config);
|
||||||
|
t.after(async () => {
|
||||||
|
await second.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaces = await readJson<Array<{ _id: string }>>(
|
||||||
|
await fetch(`${second.baseUrl}/api/workspaces?ownerId=runtime-user`),
|
||||||
|
);
|
||||||
|
const projects = await readJson<Array<{ _id: string }>>(
|
||||||
|
await fetch(`${second.baseUrl}/api/projects?workspaceId=${bootstrap.workspace._id}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(workspaces.length, 1);
|
||||||
|
assert.equal(projects.length, 1);
|
||||||
|
assert.equal(workspaces[0]?._id, bootstrap.workspace._id);
|
||||||
|
assert.equal(projects[0]?._id, bootstrap.project._id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mongo-backed runtime persists probed assets and workflow runs through the HTTP API", async (t) => {
|
||||||
|
const sourceDir = await mkdtemp(path.join(os.tmpdir(), "emboflow-runtime-"));
|
||||||
|
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"), "{}");
|
||||||
|
await writeFile(path.join(sourceDir, "DJI_001", "DJI_001.mp4"), "");
|
||||||
|
|
||||||
|
const mongod = await MongoMemoryServer.create();
|
||||||
|
t.after(async () => {
|
||||||
|
await mongod.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = await startRuntimeServer({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
mongoUri: mongod.getUri(),
|
||||||
|
database: "emboflow-runtime-flow",
|
||||||
|
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: "flow-user", projectName: "Flow 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,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const probe = await readJson<{ detectedFormatCandidates: string[]; recommendedNextNodes: string[] }>(
|
||||||
|
await fetch(`${server.baseUrl}/api/assets/${asset._id}/probe`, { method: "POST" }),
|
||||||
|
);
|
||||||
|
const assets = await readJson<Array<{ _id: string; status: string; detectedFormats: string[] }>>(
|
||||||
|
await fetch(
|
||||||
|
`${server.baseUrl}/api/assets?projectId=${encodeURIComponent(bootstrap.project._id)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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: "Delivery Flow",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const version = await readJson<{ _id: string; versionNumber: number }>(
|
||||||
|
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" },
|
||||||
|
{ id: "validate-structure", type: "inspect" },
|
||||||
|
{ id: "export-delivery-package", type: "export" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: "source-asset", to: "validate-structure" },
|
||||||
|
{ from: "validate-structure", to: "export-delivery-package" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
runtimeGraph: { selectedPreset: "delivery-normalization" },
|
||||||
|
pluginRefs: ["builtin:delivery-nodes"],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const run = await readJson<{ _id: string; status: string }>(
|
||||||
|
await fetch(`${server.baseUrl}/api/runs`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
workflowDefinitionId: workflow._id,
|
||||||
|
workflowVersionId: version._id,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const tasks = await readJson<Array<{ nodeId: string; status: string }>>(
|
||||||
|
await fetch(`${server.baseUrl}/api/runs/${run._id}/tasks`),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(probe.detectedFormatCandidates.includes("delivery_package"), true);
|
||||||
|
assert.deepEqual(probe.recommendedNextNodes.includes("validate_structure"), true);
|
||||||
|
assert.equal(assets[0]?._id, asset._id);
|
||||||
|
assert.equal(assets[0]?.status, "probed");
|
||||||
|
assert.deepEqual(assets[0]?.detectedFormats.includes("delivery_package"), true);
|
||||||
|
assert.equal(version.versionNumber, 1);
|
||||||
|
assert.equal(run.status, "queued");
|
||||||
|
assert.equal(tasks.length, 3);
|
||||||
|
assert.equal(tasks[0]?.nodeId, "source-asset");
|
||||||
|
assert.equal(tasks[0]?.status, "queued");
|
||||||
|
assert.equal(tasks[1]?.status, "pending");
|
||||||
|
});
|
||||||
@ -1,6 +1,15 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { ApiClient } from "./api-client.ts";
|
import { ApiClient } from "./api-client.ts";
|
||||||
|
import {
|
||||||
|
addNodeToDraft,
|
||||||
|
createDefaultWorkflowDraft,
|
||||||
|
removeNodeFromDraft,
|
||||||
|
resolveDefinitionIdForNode,
|
||||||
|
serializeWorkflowDraft,
|
||||||
|
workflowDraftFromVersion,
|
||||||
|
type WorkflowDraft,
|
||||||
|
} from "./workflow-editor-state.ts";
|
||||||
|
|
||||||
type NavItem = "Assets" | "Workflows" | "Runs" | "Explore" | "Labels" | "Admin";
|
type NavItem = "Assets" | "Workflows" | "Runs" | "Explore" | "Labels" | "Admin";
|
||||||
|
|
||||||
@ -264,29 +273,6 @@ function AssetDetailPage(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAMPLE_WORKFLOW_VERSION = {
|
|
||||||
visualGraph: {
|
|
||||||
viewport: { x: 0, y: 0, zoom: 1 },
|
|
||||||
},
|
|
||||||
logicGraph: {
|
|
||||||
nodes: [
|
|
||||||
{ id: "source-asset", type: "source" },
|
|
||||||
{ id: "rename-folder", type: "transform" },
|
|
||||||
{ id: "validate-structure", type: "inspect" },
|
|
||||||
{ id: "export-delivery-package", type: "export" },
|
|
||||||
],
|
|
||||||
edges: [
|
|
||||||
{ from: "source-asset", to: "rename-folder" },
|
|
||||||
{ from: "rename-folder", to: "validate-structure" },
|
|
||||||
{ from: "validate-structure", to: "export-delivery-package" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
runtimeGraph: {
|
|
||||||
selectedPreset: "delivery-normalization",
|
|
||||||
},
|
|
||||||
pluginRefs: ["builtin:delivery-nodes"],
|
|
||||||
};
|
|
||||||
|
|
||||||
function WorkflowsPage(props: {
|
function WorkflowsPage(props: {
|
||||||
api: ApiClient;
|
api: ApiClient;
|
||||||
bootstrap: BootstrapContext;
|
bootstrap: BootstrapContext;
|
||||||
@ -355,8 +341,10 @@ function WorkflowEditorPage(props: {
|
|||||||
}) {
|
}) {
|
||||||
const [nodes, setNodes] = useState<any[]>([]);
|
const [nodes, setNodes] = useState<any[]>([]);
|
||||||
const [versions, setVersions] = useState<any[]>([]);
|
const [versions, setVersions] = useState<any[]>([]);
|
||||||
|
const [draft, setDraft] = useState<WorkflowDraft>(() => createDefaultWorkflowDraft());
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState("rename-folder");
|
const [selectedNodeId, setSelectedNodeId] = useState("rename-folder");
|
||||||
const [lastRunId, setLastRunId] = useState<string | null>(null);
|
const [lastRunId, setLastRunId] = useState<string | null>(null);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -368,6 +356,10 @@ function WorkflowEditorPage(props: {
|
|||||||
]);
|
]);
|
||||||
setNodes(nodeDefs);
|
setNodes(nodeDefs);
|
||||||
setVersions(workflowVersions);
|
setVersions(workflowVersions);
|
||||||
|
const nextDraft = workflowDraftFromVersion(workflowVersions[0] ?? null);
|
||||||
|
setDraft(nextDraft);
|
||||||
|
setSelectedNodeId(nextDraft.logicGraph.nodes[0]?.id ?? "rename-folder");
|
||||||
|
setDirty(false);
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(loadError instanceof Error ? loadError.message : "Failed to load workflow");
|
setError(loadError instanceof Error ? loadError.message : "Failed to load workflow");
|
||||||
}
|
}
|
||||||
@ -375,10 +367,21 @@ function WorkflowEditorPage(props: {
|
|||||||
}, [props.workflowId]);
|
}, [props.workflowId]);
|
||||||
|
|
||||||
const selectedNode = useMemo(
|
const selectedNode = useMemo(
|
||||||
() => nodes.find((node) => node.id === selectedNodeId) ?? null,
|
() =>
|
||||||
[nodes, selectedNodeId],
|
nodes.find((node) => node.id === resolveDefinitionIdForNode(draft, selectedNodeId)) ?? null,
|
||||||
|
[draft, nodes, selectedNodeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function saveCurrentDraft() {
|
||||||
|
const version = await props.api.saveWorkflowVersion(
|
||||||
|
props.workflowId,
|
||||||
|
serializeWorkflowDraft(draft),
|
||||||
|
);
|
||||||
|
setVersions((previous) => [version, ...previous.filter((item) => item._id !== version._id)]);
|
||||||
|
setDirty(false);
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-stack">
|
<div className="page-stack">
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
@ -387,11 +390,7 @@ function WorkflowEditorPage(props: {
|
|||||||
<button
|
<button
|
||||||
className="button-primary"
|
className="button-primary"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const version = await props.api.saveWorkflowVersion(
|
await saveCurrentDraft();
|
||||||
props.workflowId,
|
|
||||||
SAMPLE_WORKFLOW_VERSION,
|
|
||||||
);
|
|
||||||
setVersions([version, ...versions]);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Save Workflow Version
|
Save Workflow Version
|
||||||
@ -399,8 +398,9 @@ function WorkflowEditorPage(props: {
|
|||||||
<button
|
<button
|
||||||
className="button-secondary"
|
className="button-secondary"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const latestVersion = versions[0] ??
|
const latestVersion = dirty || versions.length === 0
|
||||||
(await props.api.saveWorkflowVersion(props.workflowId, SAMPLE_WORKFLOW_VERSION));
|
? await saveCurrentDraft()
|
||||||
|
: versions[0];
|
||||||
const run = await props.api.createRun({
|
const run = await props.api.createRun({
|
||||||
workflowDefinitionId: props.workflowId,
|
workflowDefinitionId: props.workflowId,
|
||||||
workflowVersionId: latestVersion._id,
|
workflowVersionId: latestVersion._id,
|
||||||
@ -410,6 +410,17 @@ function WorkflowEditorPage(props: {
|
|||||||
>
|
>
|
||||||
Trigger Workflow Run
|
Trigger Workflow Run
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="button-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
const latestDraft = workflowDraftFromVersion(versions[0] ?? null);
|
||||||
|
setDraft(latestDraft);
|
||||||
|
setSelectedNodeId(latestDraft.logicGraph.nodes[0]?.id ?? "rename-folder");
|
||||||
|
setDirty(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reload Latest Saved
|
||||||
|
</button>
|
||||||
{lastRunId ? <a href={`/runs/${lastRunId}`}>Open Latest Run</a> : null}
|
{lastRunId ? <a href={`/runs/${lastRunId}`}>Open Latest Run</a> : null}
|
||||||
</div>
|
</div>
|
||||||
{error ? <p>{error}</p> : null}
|
{error ? <p>{error}</p> : null}
|
||||||
@ -423,7 +434,12 @@ function WorkflowEditorPage(props: {
|
|||||||
<button
|
<button
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className="button-secondary"
|
className="button-secondary"
|
||||||
onClick={() => setSelectedNodeId(node.id)}
|
onClick={() => {
|
||||||
|
const result = addNodeToDraft(draft, node);
|
||||||
|
setDraft(result.draft);
|
||||||
|
setSelectedNodeId(result.nodeId);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{node.name}
|
{node.name}
|
||||||
</button>
|
</button>
|
||||||
@ -434,16 +450,37 @@ function WorkflowEditorPage(props: {
|
|||||||
<section className="panel">
|
<section className="panel">
|
||||||
<h2>Canvas</h2>
|
<h2>Canvas</h2>
|
||||||
<div className="list-grid">
|
<div className="list-grid">
|
||||||
{SAMPLE_WORKFLOW_VERSION.logicGraph.nodes.map((node) => (
|
{draft.logicGraph.nodes.map((node) => (
|
||||||
<div key={node.id} className="node-card">
|
<div
|
||||||
|
key={node.id}
|
||||||
|
className="node-card"
|
||||||
|
data-selected={String(selectedNodeId === node.id)}
|
||||||
|
onClick={() => setSelectedNodeId(node.id)}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
<strong>{node.id}</strong>
|
<strong>{node.id}</strong>
|
||||||
<p>Type: {node.type}</p>
|
<p>Type: {node.type}</p>
|
||||||
|
<p>Definition: {resolveDefinitionIdForNode(draft, node.id)}</p>
|
||||||
|
<button
|
||||||
|
className="button-secondary"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const next = removeNodeFromDraft(draft, node.id);
|
||||||
|
setDraft(next);
|
||||||
|
setSelectedNodeId(next.logicGraph.nodes[0]?.id ?? "");
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove Node
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ marginTop: 12 }}>
|
<p style={{ marginTop: 12 }}>
|
||||||
Latest saved versions: {versions.length > 0 ? versions.map((item) => item.versionNumber).join(", ") : "none"}
|
Latest saved versions: {versions.length > 0 ? versions.map((item) => item.versionNumber).join(", ") : "none"}
|
||||||
</p>
|
</p>
|
||||||
|
<p>Draft status: {dirty ? "unsaved changes" : "synced"}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside className="panel">
|
<aside className="panel">
|
||||||
|
|||||||
72
apps/web/src/runtime/workflow-editor-state.test.ts
Normal file
72
apps/web/src/runtime/workflow-editor-state.test.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import {
|
||||||
|
addNodeToDraft,
|
||||||
|
createDefaultWorkflowDraft,
|
||||||
|
removeNodeFromDraft,
|
||||||
|
serializeWorkflowDraft,
|
||||||
|
workflowDraftFromVersion,
|
||||||
|
} from "./workflow-editor-state.ts";
|
||||||
|
|
||||||
|
test("load persisted workflow version into an editable draft", () => {
|
||||||
|
const draft = workflowDraftFromVersion({
|
||||||
|
visualGraph: { viewport: { x: 10, y: 20, zoom: 1.2 } },
|
||||||
|
logicGraph: {
|
||||||
|
nodes: [
|
||||||
|
{ id: "source-asset", type: "source" },
|
||||||
|
{ id: "validate-structure", type: "inspect" },
|
||||||
|
],
|
||||||
|
edges: [{ from: "source-asset", to: "validate-structure" }],
|
||||||
|
},
|
||||||
|
runtimeGraph: { selectedPreset: "delivery-normalization" },
|
||||||
|
pluginRefs: ["builtin:delivery-nodes"],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(draft.logicGraph.nodes.length, 2);
|
||||||
|
assert.equal(draft.logicGraph.edges.length, 1);
|
||||||
|
assert.equal(draft.runtimeGraph.selectedPreset, "delivery-normalization");
|
||||||
|
assert.deepEqual(draft.pluginRefs, ["builtin:delivery-nodes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add node appends a unique node id and a sequential edge by default", () => {
|
||||||
|
const base = createDefaultWorkflowDraft();
|
||||||
|
const result = addNodeToDraft(base, {
|
||||||
|
id: "export-delivery-package",
|
||||||
|
name: "Export Delivery Package",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.draft.logicGraph.nodes.at(-1)?.type, "export");
|
||||||
|
assert.equal(result.draft.logicGraph.nodes.at(-1)?.id, "export-delivery-package-1");
|
||||||
|
assert.deepEqual(result.draft.logicGraph.edges.at(-1), {
|
||||||
|
from: "validate-structure",
|
||||||
|
to: "export-delivery-package-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remove node prunes attached edges and serialize emits workflow version payload", () => {
|
||||||
|
const draft = workflowDraftFromVersion({
|
||||||
|
visualGraph: { viewport: { x: 0, y: 0, zoom: 1 } },
|
||||||
|
logicGraph: {
|
||||||
|
nodes: [
|
||||||
|
{ id: "source-asset", type: "source" },
|
||||||
|
{ id: "rename-folder", type: "transform" },
|
||||||
|
{ id: "validate-structure", type: "inspect" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: "source-asset", to: "rename-folder" },
|
||||||
|
{ from: "rename-folder", to: "validate-structure" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
runtimeGraph: { selectedPreset: "delivery-normalization" },
|
||||||
|
pluginRefs: ["builtin:delivery-nodes"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const next = removeNodeFromDraft(draft, "rename-folder");
|
||||||
|
const payload = serializeWorkflowDraft(next);
|
||||||
|
|
||||||
|
assert.equal(next.logicGraph.nodes.length, 2);
|
||||||
|
assert.equal(next.logicGraph.edges.length, 0);
|
||||||
|
assert.equal(payload.runtimeGraph.selectedPreset, "delivery-normalization");
|
||||||
|
assert.equal(payload.logicGraph.nodes[1]?.id, "validate-structure");
|
||||||
|
});
|
||||||
177
apps/web/src/runtime/workflow-editor-state.ts
Normal file
177
apps/web/src/runtime/workflow-editor-state.ts
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
export type WorkflowLogicNode = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowLogicEdge = {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowNodeDefinitionSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowDraft = {
|
||||||
|
visualGraph: Record<string, unknown>;
|
||||||
|
logicGraph: {
|
||||||
|
nodes: WorkflowLogicNode[];
|
||||||
|
edges: WorkflowLogicEdge[];
|
||||||
|
};
|
||||||
|
runtimeGraph: Record<string, unknown> & {
|
||||||
|
selectedPreset?: string;
|
||||||
|
nodeBindings?: Record<string, string>;
|
||||||
|
};
|
||||||
|
pluginRefs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkflowVersionLike = Partial<WorkflowDraft>;
|
||||||
|
|
||||||
|
function cloneDraft(draft: WorkflowDraft): WorkflowDraft {
|
||||||
|
return {
|
||||||
|
visualGraph: { ...draft.visualGraph },
|
||||||
|
logicGraph: {
|
||||||
|
nodes: draft.logicGraph.nodes.map((node) => ({ ...node })),
|
||||||
|
edges: draft.logicGraph.edges.map((edge) => ({ ...edge })),
|
||||||
|
},
|
||||||
|
runtimeGraph: {
|
||||||
|
...draft.runtimeGraph,
|
||||||
|
nodeBindings: { ...(draft.runtimeGraph.nodeBindings ?? {}) },
|
||||||
|
},
|
||||||
|
pluginRefs: [...draft.pluginRefs],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferNodeType(definition: WorkflowNodeDefinitionSummary): string {
|
||||||
|
const category = definition.category?.toLowerCase();
|
||||||
|
if (category === "source" || category === "transform" || category === "inspect" || category === "export") {
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.id.startsWith("source")) {
|
||||||
|
return "source";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
definition.id.startsWith("extract") ||
|
||||||
|
definition.id.startsWith("rename") ||
|
||||||
|
definition.id.startsWith("transform")
|
||||||
|
) {
|
||||||
|
return "transform";
|
||||||
|
}
|
||||||
|
if (definition.id.startsWith("validate") || definition.id.startsWith("inspect")) {
|
||||||
|
return "inspect";
|
||||||
|
}
|
||||||
|
if (definition.id.startsWith("export")) {
|
||||||
|
return "export";
|
||||||
|
}
|
||||||
|
return "utility";
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferDefinitionId(nodeId: string): string {
|
||||||
|
return nodeId.replace(/-\d+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultWorkflowDraft(): WorkflowDraft {
|
||||||
|
return {
|
||||||
|
visualGraph: {
|
||||||
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
},
|
||||||
|
logicGraph: {
|
||||||
|
nodes: [
|
||||||
|
{ id: "source-asset", type: "source" },
|
||||||
|
{ id: "rename-folder", type: "transform" },
|
||||||
|
{ id: "validate-structure", type: "inspect" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: "source-asset", to: "rename-folder" },
|
||||||
|
{ from: "rename-folder", to: "validate-structure" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
runtimeGraph: {
|
||||||
|
selectedPreset: "delivery-normalization",
|
||||||
|
nodeBindings: {
|
||||||
|
"source-asset": "source-asset",
|
||||||
|
"rename-folder": "rename-folder",
|
||||||
|
"validate-structure": "validate-structure",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pluginRefs: ["builtin:delivery-nodes"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workflowDraftFromVersion(version?: WorkflowVersionLike | null): WorkflowDraft {
|
||||||
|
if (!version?.logicGraph?.nodes?.length) {
|
||||||
|
return createDefaultWorkflowDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeBindings = {
|
||||||
|
...(version.runtimeGraph?.nodeBindings ?? {}),
|
||||||
|
} as Record<string, string>;
|
||||||
|
|
||||||
|
for (const node of version.logicGraph.nodes) {
|
||||||
|
nodeBindings[node.id] ??= inferDefinitionId(node.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
visualGraph: { ...(version.visualGraph ?? { viewport: { x: 0, y: 0, zoom: 1 } }) },
|
||||||
|
logicGraph: {
|
||||||
|
nodes: version.logicGraph.nodes.map((node) => ({ ...node })),
|
||||||
|
edges: (version.logicGraph.edges ?? []).map((edge) => ({ ...edge })),
|
||||||
|
},
|
||||||
|
runtimeGraph: {
|
||||||
|
...(version.runtimeGraph ?? {}),
|
||||||
|
nodeBindings,
|
||||||
|
},
|
||||||
|
pluginRefs: [...(version.pluginRefs ?? [])],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addNodeToDraft(
|
||||||
|
draft: WorkflowDraft,
|
||||||
|
definition: WorkflowNodeDefinitionSummary,
|
||||||
|
): { draft: WorkflowDraft; nodeId: string } {
|
||||||
|
const next = cloneDraft(draft);
|
||||||
|
let suffix = 1;
|
||||||
|
let nodeId = `${definition.id}-${suffix}`;
|
||||||
|
const existingIds = new Set(next.logicGraph.nodes.map((node) => node.id));
|
||||||
|
while (existingIds.has(nodeId)) {
|
||||||
|
suffix += 1;
|
||||||
|
nodeId = `${definition.id}-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node: WorkflowLogicNode = {
|
||||||
|
id: nodeId,
|
||||||
|
type: inferNodeType(definition),
|
||||||
|
};
|
||||||
|
const previousNode = next.logicGraph.nodes.at(-1);
|
||||||
|
next.logicGraph.nodes.push(node);
|
||||||
|
if (previousNode) {
|
||||||
|
next.logicGraph.edges.push({ from: previousNode.id, to: nodeId });
|
||||||
|
}
|
||||||
|
next.runtimeGraph.nodeBindings ??= {};
|
||||||
|
next.runtimeGraph.nodeBindings[nodeId] = definition.id;
|
||||||
|
|
||||||
|
return { draft: next, nodeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeNodeFromDraft(draft: WorkflowDraft, nodeId: string): WorkflowDraft {
|
||||||
|
const next = cloneDraft(draft);
|
||||||
|
next.logicGraph.nodes = next.logicGraph.nodes.filter((node) => node.id !== nodeId);
|
||||||
|
next.logicGraph.edges = next.logicGraph.edges.filter(
|
||||||
|
(edge) => edge.from !== nodeId && edge.to !== nodeId,
|
||||||
|
);
|
||||||
|
if (next.runtimeGraph.nodeBindings) {
|
||||||
|
delete next.runtimeGraph.nodeBindings[nodeId];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefinitionIdForNode(draft: WorkflowDraft, nodeId: string): string {
|
||||||
|
return draft.runtimeGraph.nodeBindings?.[nodeId] ?? inferDefinitionId(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeWorkflowDraft(draft: WorkflowDraft): WorkflowDraft {
|
||||||
|
return cloneDraft(draft);
|
||||||
|
}
|
||||||
@ -207,4 +207,4 @@ The current repository runtime now includes:
|
|||||||
- a React and Vite web application that reads those APIs
|
- a React and Vite web application that reads those APIs
|
||||||
- a local-path asset registration flow for development and dataset inspection
|
- a local-path asset registration flow for development and dataset inspection
|
||||||
|
|
||||||
The repository still keeps some in-memory module tests for contract stability, but the executable local stack now runs through Mongo-backed runtime services.
|
The repository still keeps some in-memory module tests for contract stability, but the executable local stack now runs through Mongo-backed runtime services and adds HTTP integration coverage against a real Mongo runtime.
|
||||||
|
|||||||
@ -59,6 +59,8 @@ Used for execution:
|
|||||||
|
|
||||||
Visual changes must not change workflow semantics. Runtime changes must produce a new workflow version.
|
Visual changes must not change workflow semantics. Runtime changes must produce a new workflow version.
|
||||||
|
|
||||||
|
The current V1 editor implementation keeps a mutable local draft that is initialized from the latest saved workflow version. Saving the draft creates a new immutable workflow version. Triggering a run from a dirty draft first saves a fresh workflow version, then creates the run from that saved snapshot.
|
||||||
|
|
||||||
## Node Categories
|
## Node Categories
|
||||||
|
|
||||||
V1 node categories:
|
V1 node categories:
|
||||||
@ -218,6 +220,12 @@ Recommended cache key inputs:
|
|||||||
- upstream reference summary
|
- upstream reference summary
|
||||||
- config summary
|
- config summary
|
||||||
- code hook digest
|
- code hook digest
|
||||||
|
|
||||||
|
## Current V1 Runtime Notes
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- The API runtime now has direct HTTP integration coverage against a real Mongo runtime through `mongodb-memory-server`, in addition to the older in-memory contract tests.
|
||||||
- plugin version
|
- plugin version
|
||||||
- executor version
|
- executor version
|
||||||
|
|
||||||
|
|||||||
@ -139,6 +139,14 @@ Supports:
|
|||||||
- node badges for validation status
|
- node badges for validation status
|
||||||
- run-state overlays when viewing an executed version
|
- run-state overlays when viewing an executed version
|
||||||
|
|
||||||
|
The current V1 implementation is simpler than the target canvas UX, but it already follows the same persistence model:
|
||||||
|
|
||||||
|
- load the latest saved workflow version when the editor opens
|
||||||
|
- keep an unsaved draft in local editor state
|
||||||
|
- allow node add and remove operations on the draft
|
||||||
|
- save the current draft as a new workflow version
|
||||||
|
- auto-save a dirty draft before triggering a run
|
||||||
|
|
||||||
### Right Configuration Panel
|
### Right Configuration Panel
|
||||||
|
|
||||||
The right panel is schema-driven.
|
The right panel is schema-driven.
|
||||||
@ -269,7 +277,8 @@ The current local runtime now exposes these surfaces as a real React application
|
|||||||
- 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 and lightweight route handling instead of a deeper client-side state framework.
|
||||||
- Plugin
|
|
||||||
|
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, and version-save / run-trigger controls against the live API.
|
||||||
|
|
||||||
Do not rename the same concept differently across pages.
|
Do not rename the same concept differently across pages.
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
**Architecture:** Use a TypeScript monorepo with a React web app, a Node.js API control plane, and a separate Node.js worker. Use MongoDB as the only database, object storage abstraction for cloud storage or MinIO, and a local scheduler with Python and Docker executor contracts.
|
**Architecture:** Use a TypeScript monorepo with a React web app, a Node.js API control plane, and a separate Node.js worker. Use MongoDB as the only database, object storage abstraction for cloud storage or MinIO, and a local scheduler with Python and Docker executor contracts.
|
||||||
|
|
||||||
**Tech Stack:** pnpm workspace, React, TypeScript, React Flow, NestJS, Mongoose, MongoDB, Docker Compose, Python runtime hooks, Python unittest, and Node 22 built-in test runner with TypeScript stripping for early package-level tests
|
**Tech Stack:** pnpm workspace, React, TypeScript, React Flow, NestJS, Mongoose, MongoDB, Docker Compose, Python runtime hooks, Python unittest, and `tsx --test` for package-level TypeScript tests, including Mongo-backed runtime integration coverage
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -17,6 +17,7 @@
|
|||||||
- `2026-03-26`: Package-level verification continues to use the Node 22 built-in test runner with direct file targets such as `pnpm --filter api test test/projects.e2e-spec.ts` and `pnpm --filter worker test test/task-runner.spec.ts`.
|
- `2026-03-26`: Package-level verification continues to use the Node 22 built-in test runner with direct file targets such as `pnpm --filter api test test/projects.e2e-spec.ts` and `pnpm --filter worker test test/task-runner.spec.ts`.
|
||||||
- `2026-03-26`: Tasks 7 through 10 add the first web shell, workflow editor surfaces, artifact explore renderers, developer entry commands, and CI/pre-push test execution through `make test`.
|
- `2026-03-26`: Tasks 7 through 10 add the first web shell, workflow editor surfaces, artifact explore renderers, developer entry commands, and CI/pre-push test execution through `make test`.
|
||||||
- `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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
1043
pnpm-lock.yaml
generated
1043
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user