From e790fb367791cee860b24e3ecdfdb6b65ecc5924 Mon Sep 17 00:00:00 2001 From: Samuel Huber Date: Sun, 29 Mar 2026 14:00:58 +0200 Subject: [PATCH 1/6] feat(graph): expose plan tree helpers under a dedicated graph subpath Third-party graph tooling could already render Smithers workflows with renderFrame(), but the XML-to-plan conversion used by the runtime was only available through internal modules. That forced external consumers to either reimplement the renderer semantics themselves or depend on private paths. This change adds a minimal smithers-orchestrator/graph subpath that re-exports buildPlanTree and its related types without introducing a new graph model. It keeps the public surface small while giving visualizers and workflow builders a clean, supported way to derive the runtime plan tree from rendered XML. The accompanying docs show how to combine renderFrame() with /graph to build node-and-edge visualizations, which improves discoverability for external graph inspectors and n8n-style editors without moving that UI into Smithers itself. --- README.md | 11 +++ docs/runtime/graph.mdx | 128 ++++++++++++++++++++++++++++++++++ docs/runtime/render-frame.mdx | 7 ++ package.json | 1 + src/graph/index.ts | 2 + tests/graph-subpath.test.ts | 57 +++++++++++++++ tsconfig.json | 2 + 7 files changed, 208 insertions(+) create mode 100644 docs/runtime/graph.mdx create mode 100644 src/graph/index.ts create mode 100644 tests/graph-subpath.test.ts diff --git a/README.md b/README.md index ee60637e..bbc181ad 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,17 @@ smithers list workflow.tsx smithers approve workflow.tsx --run-id abc123 --node-id review ``` +## Graph Tooling + +Smithers does not ship a visual workflow editor, but it exposes the XML-to-plan conversion the runtime uses internally: + +```ts +import { renderFrame } from "smithers-orchestrator"; +import { buildPlanTree } from "smithers-orchestrator/graph"; +``` + +That is enough to build external graph inspectors, React Flow canvases, and n8n-style workflow builders on top of Smithers without re-implementing its execution semantics. + ## Hot Module Replacement Edit your workflow files while a run is executing. Smithers watches your source tree and hot-reloads changes on save — prompts, config, agent settings, and component structure — without restarting the process or losing run state. diff --git a/docs/runtime/graph.mdx b/docs/runtime/graph.mdx new file mode 100644 index 00000000..f74c7e4e --- /dev/null +++ b/docs/runtime/graph.mdx @@ -0,0 +1,128 @@ +--- +title: graph +description: Build workflow graph tooling on top of Smithers by converting rendered XML into the scheduler plan tree. +--- + +Smithers does not ship a visual workflow builder, but it does expose the same XML-to-plan conversion the runtime uses internally. That is enough to build external graph visualizers, inspectors, and node-based editors. + +## Import + +```ts +import { buildPlanTree } from "smithers-orchestrator/graph"; +import { renderFrame } from "smithers-orchestrator"; +``` + +## What `/graph` exports + +```ts +export { buildPlanTree } from "smithers-orchestrator/graph"; +export type { PlanNode, RalphMeta } from "smithers-orchestrator/graph"; +``` + +`buildPlanTree(xml)` converts the rendered `XmlNode` tree into the plan tree the engine schedules. + +## Basic usage + +```ts +import { renderFrame } from "smithers-orchestrator"; +import { buildPlanTree } from "smithers-orchestrator/graph"; +import workflow from "./workflow"; + +const snapshot = await renderFrame(workflow, { + runId: "preview", + iteration: 0, + input: {}, + outputs: {}, +}); + +const { plan, ralphs } = buildPlanTree(snapshot.xml); +console.log(plan, ralphs); +``` + +## PlanNode shape + +```ts +type PlanNode = + | { kind: "task"; nodeId: string } + | { kind: "sequence"; children: PlanNode[] } + | { kind: "parallel"; children: PlanNode[] } + | { + kind: "ralph"; + id: string; + children: PlanNode[]; + until: boolean; + maxIterations: number; + onMaxReached: "fail" | "return-last"; + } + | { kind: "group"; children: PlanNode[] }; +``` + +## Building a node graph UI + +A minimal graph builder can: + +1. call `renderFrame()` to get `snapshot.xml` +2. call `buildPlanTree(snapshot.xml)` +3. walk the returned `PlanNode` tree +4. emit your own UI-friendly `nodes[]` and `edges[]` + +For example: + +```ts +type UiNode = { id: string; label: string; kind: string }; +type UiEdge = { from: string; to: string; kind: string }; + +function planToUiGraph(plan: PlanNode | null) { + const nodes: UiNode[] = []; + const edges: UiEdge[] = []; + let nextId = 0; + + function visit(node: PlanNode, parentId?: string): string { + const id = node.kind === "task" ? node.nodeId : `${node.kind}:${nextId++}`; + nodes.push({ id, label: node.kind === "task" ? node.nodeId : node.kind, kind: node.kind }); + if (parentId) edges.push({ from: parentId, to: id, kind: "contains" }); + + if ("children" in node) { + let previousChildId: string | undefined; + for (const child of node.children) { + const childId = visit(child, id); + if (node.kind === "sequence" && previousChildId) { + edges.push({ from: previousChildId, to: childId, kind: "next" }); + } + previousChildId = childId; + } + if (node.kind === "ralph" && previousChildId) { + edges.push({ from: previousChildId, to: id, kind: "loop" }); + } + } + + return id; + } + + if (plan) visit(plan); + return { nodes, edges }; +} +``` + +This is enough to power React Flow, Dagre, Mermaid, n8n-style inspectors, or a custom workflow editor. + +## Why use `/graph` instead of re-parsing XML yourself? + +`buildPlanTree()` already captures Smithers scheduling semantics, including: + +- `smithers:sequence` → ordered children +- `smithers:parallel` / `smithers:merge-queue` → concurrent children +- `smithers:ralph` → loop node with iteration metadata +- `smithers:worktree` → grouping boundary + +Using `/graph` keeps third-party tooling aligned with the runtime instead of duplicating that mapping. + +## Scope + +The `/graph` subpath is intentionally minimal. It exposes the existing XML-to-plan conversion so external tools can build their own graph model and UI on top. + +## Related + +- [renderFrame](/runtime/render-frame) +- [Events](/runtime/events) +- [CLI overview](/cli/overview) diff --git a/docs/runtime/render-frame.mdx b/docs/runtime/render-frame.mdx index 1917bb4b..c65de012 100644 --- a/docs/runtime/render-frame.mdx +++ b/docs/runtime/render-frame.mdx @@ -207,6 +207,12 @@ const snap2 = await renderFrame(workflow, { }); ``` +### Building a Workflow Visualizer + +The XML tree and task list can be used to build visual representations of the workflow DAG. The `xml` field mirrors the JSX structure, while `tasks` provides the flattened execution order. + +If you want the same XML-to-plan conversion Smithers uses internally, import `buildPlanTree` from `smithers-orchestrator/graph` and pass `snapshot.xml` to it. + ### CLI ```bash @@ -217,6 +223,7 @@ Prints the `GraphSnapshot` as JSON to stdout. ## Related +- [graph](/runtime/graph) -- Convert rendered XML into the plan tree used for graph tooling. - [runWorkflow](/runtime/run-workflow) -- Execute the workflow. - [Events](/runtime/events) -- Monitor execution progress. - [Execution Model](/concepts/execution-model) -- The render-execute-persist loop. diff --git a/package.json b/package.json index c00fe38a..a9be4bc8 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "exports": { ".": "./src/index.ts", + "./graph": "./src/graph/index.ts", "./jsx-runtime": "./src/jsx-runtime.ts", "./jsx-dev-runtime": "./src/jsx-runtime.ts", "./tools": "./src/tools/index.ts", diff --git a/src/graph/index.ts b/src/graph/index.ts new file mode 100644 index 00000000..d91fa28d --- /dev/null +++ b/src/graph/index.ts @@ -0,0 +1,2 @@ +export { buildPlanTree } from "../engine/scheduler"; +export type { PlanNode, RalphMeta } from "../engine/scheduler"; diff --git a/tests/graph-subpath.test.ts b/tests/graph-subpath.test.ts new file mode 100644 index 00000000..0f7679a5 --- /dev/null +++ b/tests/graph-subpath.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { buildPlanTree } from "smithers-orchestrator/graph"; +import type { XmlNode } from "smithers-orchestrator"; + +describe("graph subpath exports", () => { + test("buildPlanTree is available from smithers-orchestrator/graph", () => { + const xml: XmlNode = { + kind: "element", + tag: "smithers:workflow", + props: {}, + children: [ + { + kind: "element", + tag: "smithers:task", + props: { id: "analyze" }, + children: [], + }, + { + kind: "element", + tag: "smithers:parallel", + props: {}, + children: [ + { + kind: "element", + tag: "smithers:task", + props: { id: "docs" }, + children: [], + }, + { + kind: "element", + tag: "smithers:task", + props: { id: "tests" }, + children: [], + }, + ], + }, + ], + }; + + expect(buildPlanTree(xml)).toEqual({ + plan: { + kind: "sequence", + children: [ + { kind: "task", nodeId: "analyze" }, + { + kind: "parallel", + children: [ + { kind: "task", nodeId: "docs" }, + { kind: "task", nodeId: "tests" }, + ], + }, + ], + }, + ralphs: [], + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 851f08fa..8f960a00 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,11 +19,13 @@ "noPropertyAccessFromIndexSignature": false, "paths": { "smithers": ["./src/index.ts"], + "smithers/graph": ["./src/graph/index.ts"], "smithers/jsx-runtime": ["./src/jsx-runtime.ts"], "smithers/jsx-dev-runtime": ["./src/jsx-runtime.ts"], "smithers/tools": ["./src/tools/index.ts"], // Back-compat for example imports "smithers-orchestrator": ["./src/index.ts"], + "smithers-orchestrator/graph": ["./src/graph/index.ts"], "smithers-orchestrator/tools": ["./src/tools/index.ts"], "smithers-orchestrator/scorers": ["./src/scorers/index.ts"] }, From d81c02d9c4ba60813a56f67218be30074eacecb4 Mon Sep 17 00:00:00 2001 From: Samuel Huber Date: Mon, 30 Mar 2026 04:12:03 +0200 Subject: [PATCH 2/6] feat(graph-builder): add n8n-style visual workflow editor sample Adds a self-contained graph builder sample at examples/graph-builder/ that demonstrates how to build an n8n-style visual workflow editor on top of the smithers-orchestrator/graph subpath. The sample includes: - node graph canvas with SVG edges, handles, and labeled connections - support for agent, shell, approval, parallel, loop, and branch nodes - drag-to-move node positioning - zoom controls, fit view, horizontal/vertical orientation toggle - minimap anchored to the canvas viewport - collapsible inspector panel with prompt, schema, and config editing - generated Smithers TSX code preview - runtime plan tree preview via buildPlanTree - local filesystem load/save with Smithers validation - browser file picker for importing graph JSON or workflow TSX - nearby workflow discovery for one-click import - graph-first stability model with graph JSON sidecar as source of truth - best-effort TSX import via Smithers rendering for existing workflows - disk change detection with manual reload The builder uses smithers-orchestrator/graph for plan tree extraction and renderFrame for TSX import, keeping the Smithers core API surface minimal while enabling rich external graph tooling. --- PR_GRAPH_SUBPATH.md | 129 ++++++ examples/graph-builder/README.md | 69 +++ examples/graph-builder/index.html | 456 +++++++++++++++++++ examples/graph-builder/server.ts | 698 ++++++++++++++++++++++++++++++ 4 files changed, 1352 insertions(+) create mode 100644 PR_GRAPH_SUBPATH.md create mode 100644 examples/graph-builder/README.md create mode 100644 examples/graph-builder/index.html create mode 100644 examples/graph-builder/server.ts diff --git a/PR_GRAPH_SUBPATH.md b/PR_GRAPH_SUBPATH.md new file mode 100644 index 00000000..75f4f72b --- /dev/null +++ b/PR_GRAPH_SUBPATH.md @@ -0,0 +1,129 @@ +# Proposed PR Title + +feat(graph): expose plan tree helpers via smithers-orchestrator/graph + +# Proposed PR Body + +## Problem + +Smithers already exposes `renderFrame()`, which gives external tooling access to the rendered workflow XML and flattened task list, but it did **not** expose the XML-to-plan conversion the runtime actually uses for scheduling. + +That left a gap for graph tooling: + +- external consumers could get `xml`, but had to reimplement Smithers scheduling semantics themselves +- visualizers and workflow builders had no supported way to derive the runtime plan tree +- consumers had to either duplicate internal tag-mapping logic or depend on private module paths +- the docs did not show a clean path for building graph inspectors or n8n-style node editors on top of Smithers + +## What this PR adds + +### Minimal `/graph` subpath + +Adds a dedicated public subpath: + +- `smithers-orchestrator/graph` + +It re-exports the existing runtime helper and types: + +- `buildPlanTree` +- `PlanNode` +- `RalphMeta` + +This keeps the change intentionally small. It does **not** introduce a new graph model, new scheduler APIs, or a new editor abstraction. + +### Package wiring + +Adds subpath export wiring in: + +- `package.json` +- `tsconfig.json` + +Including back-compat path mapping for: + +- `smithers-orchestrator/graph` +- `smithers/graph` + +### Documentation for graph tooling + +Adds a new doc page: + +- `docs/runtime/graph.mdx` + +The docs show how to: + +1. call `renderFrame()` +2. pass `snapshot.xml` into `buildPlanTree()` +3. walk the resulting `PlanNode` tree +4. build a `nodes[]` / `edges[]` representation for external UIs + +This is aimed at: + +- graph inspectors +- React Flow canvases +- custom DAG visualizers +- n8n-style workflow builders implemented outside Smithers itself + +Also updates `docs/runtime/render-frame.mdx` and the README to point users at the new `/graph` subpath. + +## Why this approach + +The goal here is to unlock graph tooling with the fewest new public commitments. + +This PR intentionally does **not**: + +- export `scheduleTasks` +- expose task state-map internals +- add a new stable graph node/edge schema +- add UI/editor code inside Smithers + +Instead, it exposes the existing XML-to-plan conversion behind a dedicated graph-focused entry point. + +That gives external tooling a supported way to stay aligned with Smithers runtime semantics, while keeping the public surface area small and avoiding a larger API design decision before real consumers exist. + +## Validation + +### Automated + +Verified with targeted tests after `bun install`: + +- [x] `bun test tests/graph-subpath.test.ts tests/scheduler-comprehensive.test.ts tests/worktree-plan.explicit.test.ts tests/nested-ralph-bug.test.ts` + +This covers: + +- subpath import resolution +- `buildPlanTree` export availability +- core plan tree behavior +- Ralph loop plan handling +- worktree / merge-queue plan handling +- nested Ralph edge cases + +### Manual usage verification + +Also verified the intended consumer flow directly: + +1. import `renderFrame` from `smithers-orchestrator` +2. import `buildPlanTree` from `smithers-orchestrator/graph` +3. render a sample workflow +4. convert `snapshot.xml` into a `PlanNode` tree + +Confirmed that the plan tree is returned correctly for the new public subpath. + +## Scope + +Included in scope: + +- `/graph` subpath export +- package/path wiring +- docs for graph-based tooling +- targeted test coverage + +Not included: + +- a new stable node/edge graph contract +- scheduler replay APIs +- live server `/graph` endpoints +- any workflow builder UI inside Smithers + +## Notes + +This is meant to be the smallest clean step that satisfies the graph-visualization use case discussed in issue #90 while preserving room to design a richer graph contract later if external tooling proves out the need. diff --git a/examples/graph-builder/README.md b/examples/graph-builder/README.md new file mode 100644 index 00000000..70733b85 --- /dev/null +++ b/examples/graph-builder/README.md @@ -0,0 +1,69 @@ +# Smithers Graph Builder Sample + +A minimal visual workflow editor sample built on top of: + +- `renderFrame()` for workflow rendering +- `smithers-orchestrator/graph` for XML → plan conversion + +## Why this exists + +This sample is intentionally small and self-contained. + +It demonstrates a low-surface-area approach to a node-based Smithers builder without adding new runtime/editor APIs to Smithers core or embedding the feature into Burns. + +## What it does + +- lets you add and edit a richer set of node types + - agent prompt + - shell command + - approval gate + - parallel block + - bounded review loop + - branch +- visualizes the workflow as a node graph with + - labeled edges + - branch fan-out + - loop-back edges + - zoom controls + - horizontal / vertical reorientation + - a minimap +- edits prompts, output keys, schemas, commands, loop settings, and branch conditions inline +- exports both + - `workflow.graph.json` + - `workflow.tsx` +- supports loading local workflows from + - `workflow.graph.json` + - `workflow.tsx` + - a directory containing those files +- supports uploading graph JSON or a self-contained workflow TSX file manually +- validates generated code through Smithers before local save +- uses `buildPlanTree()` from `smithers-orchestrator/graph` to show the runtime plan tree + +## Stability model + +The builder is intentionally **graph-first**. + +- If a `workflow.graph.json` sidecar exists, it is treated as the source of truth. +- Generated `workflow.tsx` is derived from that graph model. +- Loading an existing standalone `workflow.tsx` works as a **best-effort import** using Smithers rendering. This is useful for drafting and visualization, but it is not guaranteed to round-trip perfectly. + +That split keeps editing stable and explicit while still letting the builder leverage Smithers to import existing workflows. + +## Run + +```bash +cd examples/graph-builder +bun run server.ts +``` + +Then open: + +```txt +http://localhost:8787 +``` + +## Notes + +- The canvas is intentionally sequence-first to stay minimal. +- The generated code is a starting point, not a full round-trip authoring system. +- This sample is meant to validate the product direction with the least new surface area possible. diff --git a/examples/graph-builder/index.html b/examples/graph-builder/index.html new file mode 100644 index 00000000..b6cda0ae --- /dev/null +++ b/examples/graph-builder/index.html @@ -0,0 +1,456 @@ + + + + + + Smithers Graph Builder + + + +
+ + +
+
+ + + + + 100% +
+ +
+
+
+ +
+
+
+
+
+
+ + +
+ + + + diff --git a/examples/graph-builder/server.ts b/examples/graph-builder/server.ts new file mode 100644 index 00000000..a60a8fdd --- /dev/null +++ b/examples/graph-builder/server.ts @@ -0,0 +1,698 @@ +import { renderFrame } from "smithers-orchestrator"; +import { buildPlanTree } from "smithers-orchestrator/graph"; +import type { SmithersWorkflow, TaskDescriptor, XmlNode } from "smithers-orchestrator"; +import { dirname, extname, join, parse, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, rmSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { platform } from "node:os"; + +const htmlPath = join(import.meta.dir, "index.html"); +const html = await Bun.file(htmlPath).text(); +const tmpDir = join(import.meta.dir, ".tmp"); +mkdirSync(tmpDir, { recursive: true }); + +type BuilderTaskNode = { + id: string; + kind: "agent" | "shell" | "approval"; + title: string; + outputKey: string; + schema: string; + prompt?: string; + command?: string; + message?: string; +}; + +type BuilderContainerNode = { + id: string; + kind: "sequence" | "parallel" | "loop" | "branch"; + title: string; + children: BuilderNode[]; + loopId?: string; + maxIterations?: number; + condition?: string; +}; + +type BuilderNode = BuilderTaskNode | BuilderContainerNode; + +type BuilderDocument = { + version: 1; + workflowName: string; + orientation: "horizontal" | "vertical"; + positions: Record; + root: BuilderContainerNode; +}; + +function parseJson(text: string | undefined, fallback: any = {}) { + if (!text || !text.trim()) return fallback; + try { + return JSON.parse(text); + } catch { + return fallback; + } +} + +function normalizeTaskNode(node: any): BuilderTaskNode { + const kind = node.kind === "shell" || node.kind === "approval" ? node.kind : "agent"; + const outputKey = String(node.outputKey ?? node.id ?? "output"); + return { + id: String(node.id ?? "task"), + kind, + title: String(node.title ?? node.id ?? "Task"), + outputKey, + schema: String( + node.schema ?? + (kind === "approval" + ? 'z.object({ approved: z.boolean(), note: z.string().nullable(), decidedBy: z.string().nullable(), decidedAt: z.string().nullable() })' + : 'z.object({ result: z.string() })'), + ), + prompt: typeof node.prompt === "string" ? node.prompt : undefined, + command: typeof node.command === "string" ? node.command : undefined, + message: typeof node.message === "string" ? node.message : undefined, + }; +} + +function normalizeNode(node: any): BuilderNode { + const kind = String(node?.kind ?? "sequence"); + if (kind === "agent" || kind === "shell" || kind === "approval") { + return normalizeTaskNode(node); + } + const containerKind = + kind === "parallel" || kind === "loop" || kind === "branch" ? kind : "sequence"; + const base: BuilderContainerNode = { + id: String(node?.id ?? `${containerKind}-1`), + kind: containerKind, + title: String(node?.title ?? containerKind), + children: Array.isArray(node?.children) ? node.children.map(normalizeNode) : [], + }; + if (containerKind === "loop") { + base.loopId = typeof node?.loopId === "string" ? node.loopId : base.id; + base.maxIterations = + typeof node?.maxIterations === "number" && Number.isFinite(node.maxIterations) + ? node.maxIterations + : 3; + } + if (containerKind === "branch") { + base.condition = typeof node?.condition === "string" ? node.condition : "true"; + } + return base; +} + +function normalizeDocument(doc: any): BuilderDocument { + const root = normalizeNode(doc?.root ?? { kind: "sequence", id: "root-sequence", title: "Main sequence", children: [] }); + const rootContainer = root.kind === "sequence" || root.kind === "parallel" || root.kind === "loop" || root.kind === "branch" + ? root + : { id: "root-sequence", kind: "sequence" as const, title: "Main sequence", children: [root] }; + return { + version: 1, + workflowName: String(doc?.workflowName ?? "workflow"), + orientation: doc?.orientation === "vertical" ? "vertical" : "horizontal", + positions: typeof doc?.positions === "object" && doc?.positions ? doc.positions : {}, + root: rootContainer, + }; +} + +function isValidIdentifier(name: string) { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name); +} + +function outputsAccess(key: string) { + return isValidIdentifier(key) ? `outputs.${key}` : `outputs[${JSON.stringify(key)}]`; +} + +function quoteObjectKey(key: string) { + return isValidIdentifier(key) ? key : JSON.stringify(key); +} + +function textFromXml(node: XmlNode | null): string { + if (!node) return ""; + if (node.kind === "text") return node.text; + return node.children.map(textFromXml).join("").trim(); +} + +function zodToSource(schema: any): string { + if (!schema) return "z.object({ result: z.string() })"; + const def = schema?._def; + const typeName = def?.typeName ?? def?.type; + switch (typeName) { + case "ZodString": + case "string": + return "z.string()"; + case "ZodNumber": + case "number": + return "z.number()"; + case "ZodBoolean": + case "boolean": + return "z.boolean()"; + case "ZodLiteral": + case "literal": + return `z.literal(${JSON.stringify(def?.value)})`; + case "ZodAny": + case "any": + return "z.any()"; + case "ZodUnknown": + case "unknown": + return "z.unknown()"; + case "ZodArray": + case "array": { + const inner = def?.element ?? def?.type; + return `z.array(${zodToSource(inner)})`; + } + case "ZodEnum": + case "enum": { + const values = Array.isArray(def?.values) + ? def.values + : def?.entries && typeof def.entries === "object" + ? Object.keys(def.entries) + : ["value"]; + return `z.enum([${values.map((v: string) => JSON.stringify(v)).join(", ")}])`; + } + case "ZodObject": + case "object": { + const shape = schema.shape ?? def?.shape; + const entries = typeof shape === "function" ? shape() : shape; + if (!entries || typeof entries !== "object") return "z.object({})"; + const fields = Object.entries(entries).map( + ([key, value]) => `${quoteObjectKey(key)}: ${zodToSource(value)}`, + ); + return `z.object({ ${fields.join(", ")} })`; + } + case "ZodNullable": + case "nullable": + return `${zodToSource(def?.inner ?? def?.innerType)}.nullable()`; + case "ZodOptional": + case "optional": + return `${zodToSource(def?.inner ?? def?.innerType)}.optional()`; + case "ZodUnion": + case "union": { + const options = def?.options ?? []; + return `z.union([${options.map((o: any) => zodToSource(o)).join(", ")}])`; + } + case "ZodDefault": + case "default": + return zodToSource(def?.innerType ?? def?.inner); + default: + return "z.any()"; + } +} + +function inferTaskKind(desc?: TaskDescriptor) { + if (desc?.needsApproval) return "approval" as const; + return "agent" as const; +} + +function schemaForDescriptor(desc?: TaskDescriptor) { + if (desc?.needsApproval) { + return 'z.object({ approved: z.boolean(), note: z.string().nullable(), decidedBy: z.string().nullable(), decidedAt: z.string().nullable() })'; + } + return zodToSource(desc?.outputSchema ?? desc?.outputRef); +} + +function nodeIdForTag(tag: string, path: number[]) { + return `${tag.replace(/^smithers:/, "")}-${path.join("-") || "0"}`; +} + +function snapshotToDocument( + filePath: string, + snapshot: { xml: XmlNode | null; tasks: TaskDescriptor[] }, +): { document: BuilderDocument; warnings: string[] } { + const warnings: string[] = []; + const taskMap = new Map(snapshot.tasks.map((t) => [t.nodeId, t])); + + function walk(node: XmlNode, path: number[]): BuilderNode | null { + if (node.kind === "text") return null; + const childNodes = node.children + .map((child, index) => walk(child, [...path, index])) + .filter(Boolean) as BuilderNode[]; + + switch (node.tag) { + case "smithers:workflow": + return { + id: "root-sequence", + kind: "sequence", + title: String(node.props.name ?? parse(filePath).name ?? "Workflow"), + children: childNodes, + }; + case "smithers:sequence": + return { + id: String(node.props.id ?? nodeIdForTag(node.tag, path)), + kind: "sequence", + title: String(node.props.label ?? "Sequence"), + children: childNodes, + }; + case "smithers:parallel": + case "smithers:merge-queue": + return { + id: String(node.props.id ?? nodeIdForTag(node.tag, path)), + kind: "parallel", + title: String(node.props.label ?? "Parallel"), + children: childNodes, + }; + case "smithers:ralph": + return { + id: String(node.props.id ?? nodeIdForTag(node.tag, path)), + kind: "loop", + title: String(node.props.label ?? node.props.id ?? "Loop"), + loopId: String(node.props.id ?? nodeIdForTag(node.tag, path)), + maxIterations: Number(node.props.maxIterations ?? 3), + children: childNodes, + }; + case "smithers:branch": { + const chosenTrue = String(node.props.if ?? "true") === "true"; + warnings.push( + `Imported branch at ${nodeIdForTag(node.tag, path)} from rendered output. Only the active branch path was present in the rendered XML.`, + ); + return { + id: String(node.props.id ?? nodeIdForTag(node.tag, path)), + kind: "branch", + title: String(node.props.label ?? "Branch"), + condition: + typeof node.props.if === "string" + ? `/* imported branch; inactive path unavailable */ ${node.props.if}` + : "true", + children: [ + { + id: `${nodeIdForTag(node.tag, path)}-true`, + kind: "sequence", + title: "If true", + children: chosenTrue ? childNodes : [], + }, + { + id: `${nodeIdForTag(node.tag, path)}-false`, + kind: "sequence", + title: "If false", + children: chosenTrue ? [] : childNodes, + }, + ], + }; + } + case "smithers:worktree": + warnings.push(`Imported worktree ${node.props.id ?? nodeIdForTag(node.tag, path)} as a grouped sequence.`); + return { + id: String(node.props.id ?? nodeIdForTag(node.tag, path)), + kind: "sequence", + title: String(node.props.id ? `Worktree ${node.props.id}` : "Worktree"), + children: childNodes, + }; + case "smithers:task": { + const id = String(node.props.id ?? nodeIdForTag(node.tag, path)); + const desc = taskMap.get(id); + const kind = inferTaskKind(desc); + if (kind === "approval") { + return { + id, + kind, + title: String(desc?.label ?? id), + outputKey: String(desc?.outputTableName ?? `${id}_approval`), + schema: schemaForDescriptor(desc), + message: String( + desc?.meta?.requestSummary ?? desc?.label ?? (textFromXml(node) || "Approval required"), + ), + }; + } + return { + id, + kind, + title: String(desc?.label ?? id), + outputKey: String(desc?.outputTableName ?? id), + schema: schemaForDescriptor(desc), + prompt: String(desc?.prompt ?? (textFromXml(node) || `Imported Smithers task ${id}`)), + }; + } + default: + if (childNodes.length === 1) return childNodes[0]!; + if (childNodes.length > 1) { + warnings.push(`Imported unsupported tag ${node.tag} as a grouped sequence.`); + return { + id: nodeIdForTag(node.tag, path), + kind: "sequence", + title: node.tag.replace(/^smithers:/, ""), + children: childNodes, + }; + } + return null; + } + } + + const root = snapshot.xml ? walk(snapshot.xml, [0]) : null; + const normalizedRoot = root && (root.kind === "sequence" || root.kind === "parallel" || root.kind === "loop" || root.kind === "branch") + ? root + : { + id: "root-sequence", + kind: "sequence" as const, + title: parse(filePath).name, + children: root ? [root] : [], + }; + + return { + document: normalizeDocument({ + workflowName: parse(filePath).name, + orientation: "horizontal", + positions: {}, + root: normalizedRoot, + }), + warnings, + }; +} + +async function loadWorkflow(absPath: string): Promise> { + const href = pathToFileURL(absPath).href + `?v=${Date.now()}`; + const mod = await import(href); + if (!mod.default) throw new Error("Workflow must export default"); + return mod.default as SmithersWorkflow; +} + +function createPlaceholder() { + const fn = (() => "") as any; + return new Proxy(fn, { + get(_target, prop) { + if (prop === Symbol.toPrimitive) return () => ""; + if (prop === "toString") return () => ""; + if (prop === "valueOf") return () => ""; + if (prop === "toJSON") return () => null; + if (prop === "length") return 0; + return createPlaceholder(); + }, + apply() { + return createPlaceholder(); + }, + }); +} + +async function renderWorkflow(absPath: string, input: any) { + const workflow = await loadWorkflow(absPath); + const missing = createPlaceholder(); + const ctx = { + runId: "graph-builder-import", + iteration: 0, + input: input ?? {}, + outputs: {}, + output() { + return missing; + }, + outputMaybe() { + return undefined; + }, + latest() { + return undefined; + }, + latestArray() { + return []; + }, + iterationCount() { + return 0; + }, + } as any; + return renderFrame(workflow, ctx, { baseRootDir: dirname(absPath) }); +} + +function resolveWorkflowPaths(userPath: string) { + const abs = resolve(process.cwd(), userPath); + if (!existsSync(abs)) { + throw new Error(`Path not found: ${abs}`); + } + const stats = statSync(abs); + if (stats.isDirectory()) { + return { + inputPath: abs, + graphPath: join(abs, "workflow.graph.json"), + tsxPath: join(abs, "workflow.tsx"), + }; + } + if (abs.endsWith(".graph.json")) { + return { + inputPath: abs, + graphPath: abs, + tsxPath: abs.replace(/\.graph\.json$/, ".tsx"), + }; + } + if (extname(abs) === ".tsx" || extname(abs) === ".ts") { + return { + inputPath: abs, + graphPath: abs.replace(/\.(tsx|ts)$/, ".graph.json"), + tsxPath: abs, + }; + } + throw new Error("Use a path to workflow.tsx, workflow.ts, workflow.graph.json, or a directory containing workflow files."); +} + +function statInfo(path: string | null) { + if (!path || !existsSync(path)) return null; + const stats = statSync(path); + return { path, mtimeMs: stats.mtimeMs, size: stats.size }; +} + +async function loadLocalWorkflow(userPath: string, input: any) { + const paths = resolveWorkflowPaths(userPath); + const warnings: string[] = []; + const graphStat = statInfo(paths.graphPath); + const tsxStat = statInfo(paths.tsxPath); + + if (graphStat && tsxStat && tsxStat.mtimeMs > graphStat.mtimeMs) { + warnings.push("workflow.tsx is newer than workflow.graph.json. The builder loaded the graph sidecar as the source of truth."); + } + + if (graphStat) { + const document = normalizeDocument(parseJson(readFileSync(paths.graphPath, "utf8"))); + const code = tsxStat ? readFileSync(paths.tsxPath, "utf8") : null; + return { + mode: "graph", + roundTripSafe: true, + warnings, + document, + code, + paths, + stats: { graph: graphStat, tsx: tsxStat }, + }; + } + + if (!tsxStat) { + throw new Error(`No workflow.graph.json or workflow.tsx found for path: ${userPath}`); + } + + const snapshot = await renderWorkflow(paths.tsxPath, input); + const imported = snapshotToDocument(paths.tsxPath, snapshot); + const code = readFileSync(paths.tsxPath, "utf8"); + return { + mode: "tsx-import", + roundTripSafe: false, + warnings: imported.warnings, + document: imported.document, + code, + paths, + stats: { graph: graphStat, tsx: tsxStat }, + }; +} + +async function validateGeneratedCode(code: string, input: any) { + const filePath = join(tmpDir, `validate-${Date.now()}-${Math.random().toString(36).slice(2)}.tsx`); + writeFileSync(filePath, code, "utf8"); + try { + const snapshot = await renderWorkflow(filePath, input); + return { + ok: true, + plan: buildPlanTree(snapshot.xml ?? null), + taskIds: snapshot.tasks.map((t) => t.nodeId), + }; + } catch (error: any) { + return { + ok: false, + error: error?.stack ?? error?.message ?? String(error), + }; + } finally { + try { + rmSync(filePath, { force: true }); + } catch {} + } +} + +function ensureDir(path: string) { + mkdirSync(dirname(path), { recursive: true }); +} + +function saveLocalWorkflow(userPath: string, document: BuilderDocument, code: string) { + const explicit = resolve(process.cwd(), userPath); + let graphPath: string; + let tsxPath: string; + + if (explicit.endsWith(".graph.json")) { + graphPath = explicit; + tsxPath = explicit.replace(/\.graph\.json$/, ".tsx"); + } else if (extname(explicit) === ".tsx" || extname(explicit) === ".ts") { + tsxPath = explicit; + graphPath = explicit.replace(/\.(tsx|ts)$/, ".graph.json"); + } else if (existsSync(explicit) && statSync(explicit).isDirectory()) { + graphPath = join(explicit, "workflow.graph.json"); + tsxPath = join(explicit, "workflow.tsx"); + } else { + graphPath = join(explicit, "workflow.graph.json"); + tsxPath = join(explicit, "workflow.tsx"); + } + + ensureDir(graphPath); + ensureDir(tsxPath); + writeFileSync(graphPath, JSON.stringify(document, null, 2), "utf8"); + writeFileSync(tsxPath, code, "utf8"); + return { graphPath, tsxPath, stats: { graph: statInfo(graphPath), tsx: statInfo(tsxPath) } }; +} + +function openNativeFilePicker(kind: "file" | "directory" = "file"): Promise { + return new Promise((resolve) => { + const isMac = platform() === "darwin"; + if (!isMac) { + // Fallback: prompt on stdin + process.stdout.write("Enter workflow path: "); + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.once("data", (chunk: string) => { + data = chunk.trim(); + resolve(data || null); + }); + return; + } + const script = + kind === "directory" + ? 'tell application "System Events" to set f to POSIX path of (choose folder with prompt "Select workflow directory")' + : 'tell application "System Events" to set f to POSIX path of (choose file with prompt "Select workflow file" of type {"tsx","ts","json"})'; + const child = spawn("osascript", ["-e", script], { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString())); + child.on("close", (code) => { + if (code !== 0) return resolve(null); + resolve(stdout.trim() || null); + }); + }); +} + +function findWorkflowFiles(baseDir: string, maxDepth: number): Array<{ path: string; hasGraph: boolean }> { + const results: Array<{ path: string; hasGraph: boolean }> = []; + function scan(dir: string, depth: number) { + if (depth > maxDepth) return; + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === ".jj" || entry.name === ".git") continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + scan(full, depth + 1); + } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".graph.json")) { + if (entry.name.endsWith(".graph.json")) { + results.push({ path: full, hasGraph: true }); + } else if (entry.name.endsWith(".tsx") && !results.some((r) => r.path === full.replace(/\.tsx$/, ".graph.json"))) { + results.push({ path: full, hasGraph: existsSync(full.replace(/\.tsx$/, ".graph.json")) }); + } + } + } + } catch {} + } + scan(baseDir, 0); + return results.slice(0, 30); +} + +Bun.serve({ + port: Number(process.env.PORT ?? 8787), + async fetch(req) { + const url = new URL(req.url); + try { + if (url.pathname === "/") { + return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }); + } + + if (url.pathname === "/api/plan" && req.method === "POST") { + const body = await req.json() as { xml?: XmlNode | null }; + return Response.json(buildPlanTree(body.xml ?? null)); + } + + if (url.pathname === "/api/pick-file" && req.method === "POST") { + const body = await req.json() as { kind?: "file" | "directory" }; + const picked = await openNativeFilePicker(body.kind ?? "file"); + if (!picked) return Response.json({ cancelled: true }); + const inputJson = (await req.clone().json().catch(() => ({})) as any).inputJson ?? "{}"; + const result = await loadLocalWorkflow(picked, parseJson(inputJson, {})); + return Response.json({ cancelled: false, path: picked, ...result }); + } + + if (url.pathname === "/api/recent-workflows" && req.method === "GET") { + const cwd = process.cwd(); + const parentDir = dirname(cwd); + const repoRoot = dirname(parentDir); + const results = [ + ...findWorkflowFiles(cwd, 4), + ...findWorkflowFiles(parentDir, 3), + ...findWorkflowFiles(repoRoot, 2), + ]; + const seen = new Set(); + const deduped = results.filter((r) => { if (seen.has(r.path)) return false; seen.add(r.path); return true; }); + return Response.json({ cwd, workflows: deduped }); + } + + if (url.pathname === "/api/load-local" && req.method === "POST") { + const body = await req.json() as { path: string; inputJson?: string }; + const result = await loadLocalWorkflow(body.path, parseJson(body.inputJson, {})); + return Response.json(result); + } + + if (url.pathname === "/api/stat-local" && req.method === "POST") { + const body = await req.json() as { path: string }; + const paths = resolveWorkflowPaths(body.path); + return Response.json({ paths, stats: { graph: statInfo(paths.graphPath), tsx: statInfo(paths.tsxPath) } }); + } + + if (url.pathname === "/api/import-graph" && req.method === "POST") { + const body = await req.json() as { content: string }; + return Response.json({ document: normalizeDocument(parseJson(body.content)) }); + } + + if (url.pathname === "/api/import-tsx" && req.method === "POST") { + const body = await req.json() as { code: string; inputJson?: string }; + const tmpPath = join(tmpDir, `import-${Date.now()}-${Math.random().toString(36).slice(2)}.tsx`); + writeFileSync(tmpPath, body.code, "utf8"); + try { + const snapshot = await renderWorkflow(tmpPath, parseJson(body.inputJson, {})); + const imported = snapshotToDocument(tmpPath, snapshot); + return Response.json({ + mode: "tsx-import", + roundTripSafe: false, + warnings: [ + "Uploaded TSX import is best-effort. Relative project dependencies are not available unless you load from a real local path.", + ...imported.warnings, + ], + document: imported.document, + code: body.code, + }); + } finally { + try { + rmSync(tmpPath, { force: true }); + } catch {} + } + } + + if (url.pathname === "/api/validate" && req.method === "POST") { + const body = await req.json() as { code: string; inputJson?: string }; + return Response.json(await validateGeneratedCode(body.code, parseJson(body.inputJson, {}))); + } + + if (url.pathname === "/api/save-local" && req.method === "POST") { + const body = await req.json() as { path: string; document: BuilderDocument; code: string; inputJson?: string }; + const document = normalizeDocument(body.document); + const validation = await validateGeneratedCode(body.code, parseJson(body.inputJson, {})); + if (!validation.ok) { + return Response.json({ ok: false, validation }, { status: 400 }); + } + const saved = saveLocalWorkflow(body.path, document, body.code); + return Response.json({ ok: true, saved, validation }); + } + + return new Response("Not found", { status: 404 }); + } catch (error: any) { + return Response.json( + { + ok: false, + error: error?.stack ?? error?.message ?? String(error), + }, + { status: 500 }, + ); + } + }, +}); + +console.log("Graph builder running at http://localhost:8787"); From 67ac094f88ce338b71eecea995e666853db1d1d5 Mon Sep 17 00:00:00 2001 From: Samuel Huber Date: Wed, 1 Apr 2026 23:14:11 +0200 Subject: [PATCH 3/6] feat(graph-builder): make fully browser-only with zero server dependency The graph builder now runs entirely in the browser as a single self-contained HTML file. No server, no API calls, no Node.js runtime required. Changes: - inlined buildPlanTree and all its dependencies as pure browser JS - added client-side TSX text parser that extracts Smithers component structure (Task, Sequence, Parallel, Ralph, Branch, Approval) from uploaded TSX files - plan preview now runs buildPlanTree directly in the browser - removed all fetch calls to /api endpoints from the core editing flow - simplified file section to browser-native open/save via file picker and download - the file can be opened from file://, hosted on any static server, or embedded The server.ts file is preserved as an optional local development tool for richer features like Smithers-rendered TSX import and filesystem load/save, but it is no longer required for any core functionality. --- examples/graph-builder/README.md | 46 +- examples/graph-builder/index.html | 771 ++++++++++++++++-------------- 2 files changed, 429 insertions(+), 388 deletions(-) diff --git a/examples/graph-builder/README.md b/examples/graph-builder/README.md index 70733b85..e9a3cf21 100644 --- a/examples/graph-builder/README.md +++ b/examples/graph-builder/README.md @@ -1,15 +1,8 @@ -# Smithers Graph Builder Sample +# Smithers Graph Builder -A minimal visual workflow editor sample built on top of: +A fully in-browser visual workflow editor for Smithers. No server required. -- `renderFrame()` for workflow rendering -- `smithers-orchestrator/graph` for XML → plan conversion - -## Why this exists - -This sample is intentionally small and self-contained. - -It demonstrates a low-surface-area approach to a node-based Smithers builder without adding new runtime/editor APIs to Smithers core or embedding the feature into Burns. +One self-contained HTML file. Open it directly or host it anywhere. ## What it does @@ -51,19 +44,32 @@ That split keeps editing stable and explicit while still letting the builder lev ## Run +Open `index.html` in any browser. That's it. + ```bash -cd examples/graph-builder -bun run server.ts +open examples/graph-builder/index.html ``` -Then open: +Or host it on any static file server, CDN, or paste it into a gist. -```txt -http://localhost:8787 -``` +## How it works -## Notes +Everything runs in the browser: -- The canvas is intentionally sequence-first to stay minimal. -- The generated code is a starting point, not a full round-trip authoring system. -- This sample is meant to validate the product direction with the least new surface area possible. +- `buildPlanTree` from `smithers-orchestrator/graph` is inlined as a pure function +- TSX import uses a client-side text parser that extracts Smithers component structure +- graph editing, code generation, plan preview — all client-side JS +- no fetch calls, no API, no server, no dependencies + +## Server mode (optional) + +The `server.ts` file is still available for local development with richer features: +- Smithers-rendered TSX import via `renderFrame()` +- local filesystem load/save with validation +- nearby workflow discovery + +Run it with: +```bash +cd examples/graph-builder +bun run server.ts +``` diff --git a/examples/graph-builder/index.html b/examples/graph-builder/index.html index b6cda0ae..5d7568f4 100644 --- a/examples/graph-builder/index.html +++ b/examples/graph-builder/index.html @@ -5,205 +5,110 @@ Smithers Graph Builder
-
- - - - + + 100%
-
- -
-
-
+
-
- From a68e4dcc37dbaa62f2492ac48e8b7e17a2d1382d Mon Sep 17 00:00:00 2001 From: Samuel Huber Date: Thu, 2 Apr 2026 12:25:03 +0200 Subject: [PATCH 4/6] chore(graph-builder): remove server.ts The graph builder is fully browser-only now. The server was leftover from before the client-side buildPlanTree and TSX parser were added. No reason to keep it. --- examples/graph-builder/README.md | 12 - examples/graph-builder/server.ts | 698 ------------------------------- 2 files changed, 710 deletions(-) delete mode 100644 examples/graph-builder/server.ts diff --git a/examples/graph-builder/README.md b/examples/graph-builder/README.md index e9a3cf21..af1befdd 100644 --- a/examples/graph-builder/README.md +++ b/examples/graph-builder/README.md @@ -61,15 +61,3 @@ Everything runs in the browser: - graph editing, code generation, plan preview — all client-side JS - no fetch calls, no API, no server, no dependencies -## Server mode (optional) - -The `server.ts` file is still available for local development with richer features: -- Smithers-rendered TSX import via `renderFrame()` -- local filesystem load/save with validation -- nearby workflow discovery - -Run it with: -```bash -cd examples/graph-builder -bun run server.ts -``` diff --git a/examples/graph-builder/server.ts b/examples/graph-builder/server.ts deleted file mode 100644 index a60a8fdd..00000000 --- a/examples/graph-builder/server.ts +++ /dev/null @@ -1,698 +0,0 @@ -import { renderFrame } from "smithers-orchestrator"; -import { buildPlanTree } from "smithers-orchestrator/graph"; -import type { SmithersWorkflow, TaskDescriptor, XmlNode } from "smithers-orchestrator"; -import { dirname, extname, join, parse, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; -import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, rmSync } from "node:fs"; -import { spawn } from "node:child_process"; -import { platform } from "node:os"; - -const htmlPath = join(import.meta.dir, "index.html"); -const html = await Bun.file(htmlPath).text(); -const tmpDir = join(import.meta.dir, ".tmp"); -mkdirSync(tmpDir, { recursive: true }); - -type BuilderTaskNode = { - id: string; - kind: "agent" | "shell" | "approval"; - title: string; - outputKey: string; - schema: string; - prompt?: string; - command?: string; - message?: string; -}; - -type BuilderContainerNode = { - id: string; - kind: "sequence" | "parallel" | "loop" | "branch"; - title: string; - children: BuilderNode[]; - loopId?: string; - maxIterations?: number; - condition?: string; -}; - -type BuilderNode = BuilderTaskNode | BuilderContainerNode; - -type BuilderDocument = { - version: 1; - workflowName: string; - orientation: "horizontal" | "vertical"; - positions: Record; - root: BuilderContainerNode; -}; - -function parseJson(text: string | undefined, fallback: any = {}) { - if (!text || !text.trim()) return fallback; - try { - return JSON.parse(text); - } catch { - return fallback; - } -} - -function normalizeTaskNode(node: any): BuilderTaskNode { - const kind = node.kind === "shell" || node.kind === "approval" ? node.kind : "agent"; - const outputKey = String(node.outputKey ?? node.id ?? "output"); - return { - id: String(node.id ?? "task"), - kind, - title: String(node.title ?? node.id ?? "Task"), - outputKey, - schema: String( - node.schema ?? - (kind === "approval" - ? 'z.object({ approved: z.boolean(), note: z.string().nullable(), decidedBy: z.string().nullable(), decidedAt: z.string().nullable() })' - : 'z.object({ result: z.string() })'), - ), - prompt: typeof node.prompt === "string" ? node.prompt : undefined, - command: typeof node.command === "string" ? node.command : undefined, - message: typeof node.message === "string" ? node.message : undefined, - }; -} - -function normalizeNode(node: any): BuilderNode { - const kind = String(node?.kind ?? "sequence"); - if (kind === "agent" || kind === "shell" || kind === "approval") { - return normalizeTaskNode(node); - } - const containerKind = - kind === "parallel" || kind === "loop" || kind === "branch" ? kind : "sequence"; - const base: BuilderContainerNode = { - id: String(node?.id ?? `${containerKind}-1`), - kind: containerKind, - title: String(node?.title ?? containerKind), - children: Array.isArray(node?.children) ? node.children.map(normalizeNode) : [], - }; - if (containerKind === "loop") { - base.loopId = typeof node?.loopId === "string" ? node.loopId : base.id; - base.maxIterations = - typeof node?.maxIterations === "number" && Number.isFinite(node.maxIterations) - ? node.maxIterations - : 3; - } - if (containerKind === "branch") { - base.condition = typeof node?.condition === "string" ? node.condition : "true"; - } - return base; -} - -function normalizeDocument(doc: any): BuilderDocument { - const root = normalizeNode(doc?.root ?? { kind: "sequence", id: "root-sequence", title: "Main sequence", children: [] }); - const rootContainer = root.kind === "sequence" || root.kind === "parallel" || root.kind === "loop" || root.kind === "branch" - ? root - : { id: "root-sequence", kind: "sequence" as const, title: "Main sequence", children: [root] }; - return { - version: 1, - workflowName: String(doc?.workflowName ?? "workflow"), - orientation: doc?.orientation === "vertical" ? "vertical" : "horizontal", - positions: typeof doc?.positions === "object" && doc?.positions ? doc.positions : {}, - root: rootContainer, - }; -} - -function isValidIdentifier(name: string) { - return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name); -} - -function outputsAccess(key: string) { - return isValidIdentifier(key) ? `outputs.${key}` : `outputs[${JSON.stringify(key)}]`; -} - -function quoteObjectKey(key: string) { - return isValidIdentifier(key) ? key : JSON.stringify(key); -} - -function textFromXml(node: XmlNode | null): string { - if (!node) return ""; - if (node.kind === "text") return node.text; - return node.children.map(textFromXml).join("").trim(); -} - -function zodToSource(schema: any): string { - if (!schema) return "z.object({ result: z.string() })"; - const def = schema?._def; - const typeName = def?.typeName ?? def?.type; - switch (typeName) { - case "ZodString": - case "string": - return "z.string()"; - case "ZodNumber": - case "number": - return "z.number()"; - case "ZodBoolean": - case "boolean": - return "z.boolean()"; - case "ZodLiteral": - case "literal": - return `z.literal(${JSON.stringify(def?.value)})`; - case "ZodAny": - case "any": - return "z.any()"; - case "ZodUnknown": - case "unknown": - return "z.unknown()"; - case "ZodArray": - case "array": { - const inner = def?.element ?? def?.type; - return `z.array(${zodToSource(inner)})`; - } - case "ZodEnum": - case "enum": { - const values = Array.isArray(def?.values) - ? def.values - : def?.entries && typeof def.entries === "object" - ? Object.keys(def.entries) - : ["value"]; - return `z.enum([${values.map((v: string) => JSON.stringify(v)).join(", ")}])`; - } - case "ZodObject": - case "object": { - const shape = schema.shape ?? def?.shape; - const entries = typeof shape === "function" ? shape() : shape; - if (!entries || typeof entries !== "object") return "z.object({})"; - const fields = Object.entries(entries).map( - ([key, value]) => `${quoteObjectKey(key)}: ${zodToSource(value)}`, - ); - return `z.object({ ${fields.join(", ")} })`; - } - case "ZodNullable": - case "nullable": - return `${zodToSource(def?.inner ?? def?.innerType)}.nullable()`; - case "ZodOptional": - case "optional": - return `${zodToSource(def?.inner ?? def?.innerType)}.optional()`; - case "ZodUnion": - case "union": { - const options = def?.options ?? []; - return `z.union([${options.map((o: any) => zodToSource(o)).join(", ")}])`; - } - case "ZodDefault": - case "default": - return zodToSource(def?.innerType ?? def?.inner); - default: - return "z.any()"; - } -} - -function inferTaskKind(desc?: TaskDescriptor) { - if (desc?.needsApproval) return "approval" as const; - return "agent" as const; -} - -function schemaForDescriptor(desc?: TaskDescriptor) { - if (desc?.needsApproval) { - return 'z.object({ approved: z.boolean(), note: z.string().nullable(), decidedBy: z.string().nullable(), decidedAt: z.string().nullable() })'; - } - return zodToSource(desc?.outputSchema ?? desc?.outputRef); -} - -function nodeIdForTag(tag: string, path: number[]) { - return `${tag.replace(/^smithers:/, "")}-${path.join("-") || "0"}`; -} - -function snapshotToDocument( - filePath: string, - snapshot: { xml: XmlNode | null; tasks: TaskDescriptor[] }, -): { document: BuilderDocument; warnings: string[] } { - const warnings: string[] = []; - const taskMap = new Map(snapshot.tasks.map((t) => [t.nodeId, t])); - - function walk(node: XmlNode, path: number[]): BuilderNode | null { - if (node.kind === "text") return null; - const childNodes = node.children - .map((child, index) => walk(child, [...path, index])) - .filter(Boolean) as BuilderNode[]; - - switch (node.tag) { - case "smithers:workflow": - return { - id: "root-sequence", - kind: "sequence", - title: String(node.props.name ?? parse(filePath).name ?? "Workflow"), - children: childNodes, - }; - case "smithers:sequence": - return { - id: String(node.props.id ?? nodeIdForTag(node.tag, path)), - kind: "sequence", - title: String(node.props.label ?? "Sequence"), - children: childNodes, - }; - case "smithers:parallel": - case "smithers:merge-queue": - return { - id: String(node.props.id ?? nodeIdForTag(node.tag, path)), - kind: "parallel", - title: String(node.props.label ?? "Parallel"), - children: childNodes, - }; - case "smithers:ralph": - return { - id: String(node.props.id ?? nodeIdForTag(node.tag, path)), - kind: "loop", - title: String(node.props.label ?? node.props.id ?? "Loop"), - loopId: String(node.props.id ?? nodeIdForTag(node.tag, path)), - maxIterations: Number(node.props.maxIterations ?? 3), - children: childNodes, - }; - case "smithers:branch": { - const chosenTrue = String(node.props.if ?? "true") === "true"; - warnings.push( - `Imported branch at ${nodeIdForTag(node.tag, path)} from rendered output. Only the active branch path was present in the rendered XML.`, - ); - return { - id: String(node.props.id ?? nodeIdForTag(node.tag, path)), - kind: "branch", - title: String(node.props.label ?? "Branch"), - condition: - typeof node.props.if === "string" - ? `/* imported branch; inactive path unavailable */ ${node.props.if}` - : "true", - children: [ - { - id: `${nodeIdForTag(node.tag, path)}-true`, - kind: "sequence", - title: "If true", - children: chosenTrue ? childNodes : [], - }, - { - id: `${nodeIdForTag(node.tag, path)}-false`, - kind: "sequence", - title: "If false", - children: chosenTrue ? [] : childNodes, - }, - ], - }; - } - case "smithers:worktree": - warnings.push(`Imported worktree ${node.props.id ?? nodeIdForTag(node.tag, path)} as a grouped sequence.`); - return { - id: String(node.props.id ?? nodeIdForTag(node.tag, path)), - kind: "sequence", - title: String(node.props.id ? `Worktree ${node.props.id}` : "Worktree"), - children: childNodes, - }; - case "smithers:task": { - const id = String(node.props.id ?? nodeIdForTag(node.tag, path)); - const desc = taskMap.get(id); - const kind = inferTaskKind(desc); - if (kind === "approval") { - return { - id, - kind, - title: String(desc?.label ?? id), - outputKey: String(desc?.outputTableName ?? `${id}_approval`), - schema: schemaForDescriptor(desc), - message: String( - desc?.meta?.requestSummary ?? desc?.label ?? (textFromXml(node) || "Approval required"), - ), - }; - } - return { - id, - kind, - title: String(desc?.label ?? id), - outputKey: String(desc?.outputTableName ?? id), - schema: schemaForDescriptor(desc), - prompt: String(desc?.prompt ?? (textFromXml(node) || `Imported Smithers task ${id}`)), - }; - } - default: - if (childNodes.length === 1) return childNodes[0]!; - if (childNodes.length > 1) { - warnings.push(`Imported unsupported tag ${node.tag} as a grouped sequence.`); - return { - id: nodeIdForTag(node.tag, path), - kind: "sequence", - title: node.tag.replace(/^smithers:/, ""), - children: childNodes, - }; - } - return null; - } - } - - const root = snapshot.xml ? walk(snapshot.xml, [0]) : null; - const normalizedRoot = root && (root.kind === "sequence" || root.kind === "parallel" || root.kind === "loop" || root.kind === "branch") - ? root - : { - id: "root-sequence", - kind: "sequence" as const, - title: parse(filePath).name, - children: root ? [root] : [], - }; - - return { - document: normalizeDocument({ - workflowName: parse(filePath).name, - orientation: "horizontal", - positions: {}, - root: normalizedRoot, - }), - warnings, - }; -} - -async function loadWorkflow(absPath: string): Promise> { - const href = pathToFileURL(absPath).href + `?v=${Date.now()}`; - const mod = await import(href); - if (!mod.default) throw new Error("Workflow must export default"); - return mod.default as SmithersWorkflow; -} - -function createPlaceholder() { - const fn = (() => "") as any; - return new Proxy(fn, { - get(_target, prop) { - if (prop === Symbol.toPrimitive) return () => ""; - if (prop === "toString") return () => ""; - if (prop === "valueOf") return () => ""; - if (prop === "toJSON") return () => null; - if (prop === "length") return 0; - return createPlaceholder(); - }, - apply() { - return createPlaceholder(); - }, - }); -} - -async function renderWorkflow(absPath: string, input: any) { - const workflow = await loadWorkflow(absPath); - const missing = createPlaceholder(); - const ctx = { - runId: "graph-builder-import", - iteration: 0, - input: input ?? {}, - outputs: {}, - output() { - return missing; - }, - outputMaybe() { - return undefined; - }, - latest() { - return undefined; - }, - latestArray() { - return []; - }, - iterationCount() { - return 0; - }, - } as any; - return renderFrame(workflow, ctx, { baseRootDir: dirname(absPath) }); -} - -function resolveWorkflowPaths(userPath: string) { - const abs = resolve(process.cwd(), userPath); - if (!existsSync(abs)) { - throw new Error(`Path not found: ${abs}`); - } - const stats = statSync(abs); - if (stats.isDirectory()) { - return { - inputPath: abs, - graphPath: join(abs, "workflow.graph.json"), - tsxPath: join(abs, "workflow.tsx"), - }; - } - if (abs.endsWith(".graph.json")) { - return { - inputPath: abs, - graphPath: abs, - tsxPath: abs.replace(/\.graph\.json$/, ".tsx"), - }; - } - if (extname(abs) === ".tsx" || extname(abs) === ".ts") { - return { - inputPath: abs, - graphPath: abs.replace(/\.(tsx|ts)$/, ".graph.json"), - tsxPath: abs, - }; - } - throw new Error("Use a path to workflow.tsx, workflow.ts, workflow.graph.json, or a directory containing workflow files."); -} - -function statInfo(path: string | null) { - if (!path || !existsSync(path)) return null; - const stats = statSync(path); - return { path, mtimeMs: stats.mtimeMs, size: stats.size }; -} - -async function loadLocalWorkflow(userPath: string, input: any) { - const paths = resolveWorkflowPaths(userPath); - const warnings: string[] = []; - const graphStat = statInfo(paths.graphPath); - const tsxStat = statInfo(paths.tsxPath); - - if (graphStat && tsxStat && tsxStat.mtimeMs > graphStat.mtimeMs) { - warnings.push("workflow.tsx is newer than workflow.graph.json. The builder loaded the graph sidecar as the source of truth."); - } - - if (graphStat) { - const document = normalizeDocument(parseJson(readFileSync(paths.graphPath, "utf8"))); - const code = tsxStat ? readFileSync(paths.tsxPath, "utf8") : null; - return { - mode: "graph", - roundTripSafe: true, - warnings, - document, - code, - paths, - stats: { graph: graphStat, tsx: tsxStat }, - }; - } - - if (!tsxStat) { - throw new Error(`No workflow.graph.json or workflow.tsx found for path: ${userPath}`); - } - - const snapshot = await renderWorkflow(paths.tsxPath, input); - const imported = snapshotToDocument(paths.tsxPath, snapshot); - const code = readFileSync(paths.tsxPath, "utf8"); - return { - mode: "tsx-import", - roundTripSafe: false, - warnings: imported.warnings, - document: imported.document, - code, - paths, - stats: { graph: graphStat, tsx: tsxStat }, - }; -} - -async function validateGeneratedCode(code: string, input: any) { - const filePath = join(tmpDir, `validate-${Date.now()}-${Math.random().toString(36).slice(2)}.tsx`); - writeFileSync(filePath, code, "utf8"); - try { - const snapshot = await renderWorkflow(filePath, input); - return { - ok: true, - plan: buildPlanTree(snapshot.xml ?? null), - taskIds: snapshot.tasks.map((t) => t.nodeId), - }; - } catch (error: any) { - return { - ok: false, - error: error?.stack ?? error?.message ?? String(error), - }; - } finally { - try { - rmSync(filePath, { force: true }); - } catch {} - } -} - -function ensureDir(path: string) { - mkdirSync(dirname(path), { recursive: true }); -} - -function saveLocalWorkflow(userPath: string, document: BuilderDocument, code: string) { - const explicit = resolve(process.cwd(), userPath); - let graphPath: string; - let tsxPath: string; - - if (explicit.endsWith(".graph.json")) { - graphPath = explicit; - tsxPath = explicit.replace(/\.graph\.json$/, ".tsx"); - } else if (extname(explicit) === ".tsx" || extname(explicit) === ".ts") { - tsxPath = explicit; - graphPath = explicit.replace(/\.(tsx|ts)$/, ".graph.json"); - } else if (existsSync(explicit) && statSync(explicit).isDirectory()) { - graphPath = join(explicit, "workflow.graph.json"); - tsxPath = join(explicit, "workflow.tsx"); - } else { - graphPath = join(explicit, "workflow.graph.json"); - tsxPath = join(explicit, "workflow.tsx"); - } - - ensureDir(graphPath); - ensureDir(tsxPath); - writeFileSync(graphPath, JSON.stringify(document, null, 2), "utf8"); - writeFileSync(tsxPath, code, "utf8"); - return { graphPath, tsxPath, stats: { graph: statInfo(graphPath), tsx: statInfo(tsxPath) } }; -} - -function openNativeFilePicker(kind: "file" | "directory" = "file"): Promise { - return new Promise((resolve) => { - const isMac = platform() === "darwin"; - if (!isMac) { - // Fallback: prompt on stdin - process.stdout.write("Enter workflow path: "); - let data = ""; - process.stdin.setEncoding("utf8"); - process.stdin.once("data", (chunk: string) => { - data = chunk.trim(); - resolve(data || null); - }); - return; - } - const script = - kind === "directory" - ? 'tell application "System Events" to set f to POSIX path of (choose folder with prompt "Select workflow directory")' - : 'tell application "System Events" to set f to POSIX path of (choose file with prompt "Select workflow file" of type {"tsx","ts","json"})'; - const child = spawn("osascript", ["-e", script], { stdio: ["ignore", "pipe", "pipe"] }); - let stdout = ""; - child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString())); - child.on("close", (code) => { - if (code !== 0) return resolve(null); - resolve(stdout.trim() || null); - }); - }); -} - -function findWorkflowFiles(baseDir: string, maxDepth: number): Array<{ path: string; hasGraph: boolean }> { - const results: Array<{ path: string; hasGraph: boolean }> = []; - function scan(dir: string, depth: number) { - if (depth > maxDepth) return; - try { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (entry.name === "node_modules" || entry.name === ".jj" || entry.name === ".git") continue; - const full = join(dir, entry.name); - if (entry.isDirectory()) { - scan(full, depth + 1); - } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".graph.json")) { - if (entry.name.endsWith(".graph.json")) { - results.push({ path: full, hasGraph: true }); - } else if (entry.name.endsWith(".tsx") && !results.some((r) => r.path === full.replace(/\.tsx$/, ".graph.json"))) { - results.push({ path: full, hasGraph: existsSync(full.replace(/\.tsx$/, ".graph.json")) }); - } - } - } - } catch {} - } - scan(baseDir, 0); - return results.slice(0, 30); -} - -Bun.serve({ - port: Number(process.env.PORT ?? 8787), - async fetch(req) { - const url = new URL(req.url); - try { - if (url.pathname === "/") { - return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }); - } - - if (url.pathname === "/api/plan" && req.method === "POST") { - const body = await req.json() as { xml?: XmlNode | null }; - return Response.json(buildPlanTree(body.xml ?? null)); - } - - if (url.pathname === "/api/pick-file" && req.method === "POST") { - const body = await req.json() as { kind?: "file" | "directory" }; - const picked = await openNativeFilePicker(body.kind ?? "file"); - if (!picked) return Response.json({ cancelled: true }); - const inputJson = (await req.clone().json().catch(() => ({})) as any).inputJson ?? "{}"; - const result = await loadLocalWorkflow(picked, parseJson(inputJson, {})); - return Response.json({ cancelled: false, path: picked, ...result }); - } - - if (url.pathname === "/api/recent-workflows" && req.method === "GET") { - const cwd = process.cwd(); - const parentDir = dirname(cwd); - const repoRoot = dirname(parentDir); - const results = [ - ...findWorkflowFiles(cwd, 4), - ...findWorkflowFiles(parentDir, 3), - ...findWorkflowFiles(repoRoot, 2), - ]; - const seen = new Set(); - const deduped = results.filter((r) => { if (seen.has(r.path)) return false; seen.add(r.path); return true; }); - return Response.json({ cwd, workflows: deduped }); - } - - if (url.pathname === "/api/load-local" && req.method === "POST") { - const body = await req.json() as { path: string; inputJson?: string }; - const result = await loadLocalWorkflow(body.path, parseJson(body.inputJson, {})); - return Response.json(result); - } - - if (url.pathname === "/api/stat-local" && req.method === "POST") { - const body = await req.json() as { path: string }; - const paths = resolveWorkflowPaths(body.path); - return Response.json({ paths, stats: { graph: statInfo(paths.graphPath), tsx: statInfo(paths.tsxPath) } }); - } - - if (url.pathname === "/api/import-graph" && req.method === "POST") { - const body = await req.json() as { content: string }; - return Response.json({ document: normalizeDocument(parseJson(body.content)) }); - } - - if (url.pathname === "/api/import-tsx" && req.method === "POST") { - const body = await req.json() as { code: string; inputJson?: string }; - const tmpPath = join(tmpDir, `import-${Date.now()}-${Math.random().toString(36).slice(2)}.tsx`); - writeFileSync(tmpPath, body.code, "utf8"); - try { - const snapshot = await renderWorkflow(tmpPath, parseJson(body.inputJson, {})); - const imported = snapshotToDocument(tmpPath, snapshot); - return Response.json({ - mode: "tsx-import", - roundTripSafe: false, - warnings: [ - "Uploaded TSX import is best-effort. Relative project dependencies are not available unless you load from a real local path.", - ...imported.warnings, - ], - document: imported.document, - code: body.code, - }); - } finally { - try { - rmSync(tmpPath, { force: true }); - } catch {} - } - } - - if (url.pathname === "/api/validate" && req.method === "POST") { - const body = await req.json() as { code: string; inputJson?: string }; - return Response.json(await validateGeneratedCode(body.code, parseJson(body.inputJson, {}))); - } - - if (url.pathname === "/api/save-local" && req.method === "POST") { - const body = await req.json() as { path: string; document: BuilderDocument; code: string; inputJson?: string }; - const document = normalizeDocument(body.document); - const validation = await validateGeneratedCode(body.code, parseJson(body.inputJson, {})); - if (!validation.ok) { - return Response.json({ ok: false, validation }, { status: 400 }); - } - const saved = saveLocalWorkflow(body.path, document, body.code); - return Response.json({ ok: true, saved, validation }); - } - - return new Response("Not found", { status: 404 }); - } catch (error: any) { - return Response.json( - { - ok: false, - error: error?.stack ?? error?.message ?? String(error), - }, - { status: 500 }, - ); - } - }, -}); - -console.log("Graph builder running at http://localhost:8787"); From a5fc2f599ac6e70c68cb032f7707015e68876fbc Mon Sep 17 00:00:00 2001 From: Samuel Huber Date: Thu, 2 Apr 2026 12:53:24 +0200 Subject: [PATCH 5/6] docs: update PR draft description --- PR_GRAPH_SUBPATH.md | 163 +++++++++++++++++++++----------------------- 1 file changed, 76 insertions(+), 87 deletions(-) diff --git a/PR_GRAPH_SUBPATH.md b/PR_GRAPH_SUBPATH.md index 75f4f72b..34c0972c 100644 --- a/PR_GRAPH_SUBPATH.md +++ b/PR_GRAPH_SUBPATH.md @@ -1,95 +1,93 @@ -# Proposed PR Title +# PR Title -feat(graph): expose plan tree helpers via smithers-orchestrator/graph +feat(graph): expose plan tree via smithers-orchestrator/graph and add visual workflow builder sample -# Proposed PR Body +# PR Body + +Closes #90 ## Problem -Smithers already exposes `renderFrame()`, which gives external tooling access to the rendered workflow XML and flattened task list, but it did **not** expose the XML-to-plan conversion the runtime actually uses for scheduling. +Smithers constructs an internal scheduling DAG via `buildPlanTree()` in `src/engine/scheduler.ts`, but this function and its types are not exported. External consumers who want to visualize or analyze workflow graphs are left with two incomplete options: -That left a gap for graph tooling: +1. The flat `tasks` array from `renderFrame()` — no edges, no dependency information. +2. The raw `xml` tree — requires reimplementing the XML-to-plan conversion that `buildPlanTree` already does. -- external consumers could get `xml`, but had to reimplement Smithers scheduling semantics themselves -- visualizers and workflow builders had no supported way to derive the runtime plan tree -- consumers had to either duplicate internal tag-mapping logic or depend on private module paths -- the docs did not show a clean path for building graph inspectors or n8n-style node editors on top of Smithers +The actual graph that Smithers uses to schedule execution — the `PlanNode` tree — is the only representation that cleanly encodes task dependencies, parallel groups, and loop semantics. But it's internal. ## What this PR adds -### Minimal `/graph` subpath - -Adds a dedicated public subpath: - -- `smithers-orchestrator/graph` - -It re-exports the existing runtime helper and types: - -- `buildPlanTree` -- `PlanNode` -- `RalphMeta` +### 1. `smithers-orchestrator/graph` subpath export -This keeps the change intentionally small. It does **not** introduce a new graph model, new scheduler APIs, or a new editor abstraction. +A minimal new subpath that re-exports the existing runtime helper and types: -### Package wiring +```ts +export { buildPlanTree } from "../engine/scheduler"; +export type { PlanNode, RalphMeta } from "../engine/scheduler"; +``` -Adds subpath export wiring in: +This is zero new implementation code — just making existing internals available through a dedicated graph-focused entry point. -- `package.json` -- `tsconfig.json` +**Files:** +- `src/graph/index.ts` — 2 lines +- `package.json` — 1 line (subpath export) +- `tsconfig.json` — 2 lines (path aliases) -Including back-compat path mapping for: +### 2. Documentation -- `smithers-orchestrator/graph` -- `smithers/graph` +Adds `docs/runtime/graph.mdx` explaining: +- what `/graph` exports +- how to combine `renderFrame()` with `buildPlanTree()` +- how to derive `nodes[]` and `edges[]` for external graph tooling +- a complete example of building an n8n-style UI-friendly graph from the plan tree -### Documentation for graph tooling +Updates `docs/runtime/render-frame.mdx` and `README.md` to point users at the new subpath. -Adds a new doc page: +### 3. Visual workflow builder sample -- `docs/runtime/graph.mdx` +Adds `examples/graph-builder/` — a fully in-browser visual workflow editor that demonstrates what external tooling can build on top of `/graph`. -The docs show how to: +The sample is a single self-contained HTML file with: +- node graph canvas with SVG edges, handles, and labeled connections +- support for agent, shell, approval, parallel, loop, and branch nodes +- drag-to-move node positioning +- zoom controls, fit view, horizontal/vertical orientation toggle +- minimap +- collapsible inspector panel with prompt, schema, and config editing +- generated Smithers TSX code preview +- runtime plan tree preview via `buildPlanTree` (inlined as pure browser JS) +- browser file picker for importing graph JSON or workflow TSX +- client-side TSX text parser for importing existing workflows +- graph JSON export for stable round-trip editing -1. call `renderFrame()` -2. pass `snapshot.xml` into `buildPlanTree()` -3. walk the resulting `PlanNode` tree -4. build a `nodes[]` / `edges[]` representation for external UIs +**No server, no API, no dependencies.** Open the HTML file in any browser. `buildPlanTree` and all its dependencies are inlined as pure functions. -This is aimed at: +### 4. Test -- graph inspectors -- React Flow canvases -- custom DAG visualizers -- n8n-style workflow builders implemented outside Smithers itself - -Also updates `docs/runtime/render-frame.mdx` and the README to point users at the new `/graph` subpath. +Adds `tests/graph-subpath.test.ts` verifying that `buildPlanTree` is importable and functional from `smithers-orchestrator/graph`. ## Why this approach -The goal here is to unlock graph tooling with the fewest new public commitments. - -This PR intentionally does **not**: +The issue discussion considered several options. This PR takes the most minimal path: -- export `scheduleTasks` -- expose task state-map internals -- add a new stable graph node/edge schema -- add UI/editor code inside Smithers +- **Does not** export `scheduleTasks` or state-map internals +- **Does not** introduce a new stable graph node/edge schema +- **Does not** add server endpoints or CLI flags +- **Does not** add new Smithers core dependencies -Instead, it exposes the existing XML-to-plan conversion behind a dedicated graph-focused entry point. +It exposes the existing XML-to-plan conversion behind a dedicated subpath, keeping the public surface small. The sample proves the API is sufficient for building rich external graph tooling without adding anything else to Smithers core. -That gives external tooling a supported way to stay aligned with Smithers runtime semantics, while keeping the public surface area small and avoiding a larger API design decision before real consumers exist. +If external consumers prove out the need for a richer stable graph contract (e.g. explicit `nodes/edges` types), that can be added later on the same subpath without breaking what's here. ## Validation ### Automated -Verified with targeted tests after `bun install`: - -- [x] `bun test tests/graph-subpath.test.ts tests/scheduler-comprehensive.test.ts tests/worktree-plan.explicit.test.ts tests/nested-ralph-bug.test.ts` - -This covers: +```bash +bun test tests/graph-subpath.test.ts tests/scheduler-comprehensive.test.ts tests/worktree-plan.explicit.test.ts tests/nested-ralph-bug.test.ts +``` +47 tests pass, 0 failures. Covers: - subpath import resolution - `buildPlanTree` export availability - core plan tree behavior @@ -97,33 +95,24 @@ This covers: - worktree / merge-queue plan handling - nested Ralph edge cases -### Manual usage verification - -Also verified the intended consumer flow directly: - -1. import `renderFrame` from `smithers-orchestrator` -2. import `buildPlanTree` from `smithers-orchestrator/graph` -3. render a sample workflow -4. convert `snapshot.xml` into a `PlanNode` tree - -Confirmed that the plan tree is returned correctly for the new public subpath. - -## Scope - -Included in scope: - -- `/graph` subpath export -- package/path wiring -- docs for graph-based tooling -- targeted test coverage - -Not included: - -- a new stable node/edge graph contract -- scheduler replay APIs -- live server `/graph` endpoints -- any workflow builder UI inside Smithers - -## Notes - -This is meant to be the smallest clean step that satisfies the graph-visualization use case discussed in issue #90 while preserving room to design a richer graph contract later if external tooling proves out the need. +### Manual + +- verified `smithers-orchestrator/graph` resolves correctly via `bun -e` +- verified the graph builder sample loads from `file://` with zero server +- verified TSX import of `examples/simple-workflow.tsx` and `examples/code-review-loop.tsx` via the in-browser text parser +- verified plan preview runs `buildPlanTree` entirely client-side +- verified generated Smithers TSX is syntactically valid + +## Files changed + +| File | Change | +|------|--------| +| `src/graph/index.ts` | New — 2-line re-export | +| `package.json` | Add `./graph` subpath export | +| `tsconfig.json` | Add path aliases for `smithers/graph` and `smithers-orchestrator/graph` | +| `docs/runtime/graph.mdx` | New — graph subpath documentation | +| `docs/runtime/render-frame.mdx` | Cross-reference to `/graph` | +| `README.md` | Brief graph tooling note | +| `tests/graph-subpath.test.ts` | New — subpath import test | +| `examples/graph-builder/index.html` | New — self-contained visual builder | +| `examples/graph-builder/README.md` | New — sample readme | From de6cad0c51170826be7e3ef60fda26f362c1b4e4 Mon Sep 17 00:00:00 2001 From: Samuel Huber Date: Thu, 2 Apr 2026 13:02:44 +0200 Subject: [PATCH 6/6] fix(graph-builder): address review feedback from copilot - fix docs showing self-referential export syntax instead of consumer imports - fix textarea content escaping quotes as " entities by using a text-content-safe escape that preserves quote characters - add comment explaining intentional branch XML divergence in the builder (shows both paths at design time vs runtime which only renders the chosen one) --- docs/runtime/graph.mdx | 4 ++-- examples/graph-builder/index.html | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/runtime/graph.mdx b/docs/runtime/graph.mdx index f74c7e4e..f1457f58 100644 --- a/docs/runtime/graph.mdx +++ b/docs/runtime/graph.mdx @@ -15,8 +15,8 @@ import { renderFrame } from "smithers-orchestrator"; ## What `/graph` exports ```ts -export { buildPlanTree } from "smithers-orchestrator/graph"; -export type { PlanNode, RalphMeta } from "smithers-orchestrator/graph"; +import { buildPlanTree } from "smithers-orchestrator/graph"; +import type { PlanNode, RalphMeta } from "smithers-orchestrator/graph"; ``` `buildPlanTree(xml)` converts the rendered `XmlNode` tree into the plan tree the engine schedules. diff --git a/examples/graph-builder/index.html b/examples/graph-builder/index.html index 5d7568f4..79ca52b2 100644 --- a/examples/graph-builder/index.html +++ b/examples/graph-builder/index.html @@ -324,6 +324,7 @@ // ── Helpers ───────────────────────────────────────────────────────────── window.toggleSec=function(id){$('body-'+id).classList.toggle('open');$('arrow-'+id).classList.toggle('open');} const esc=v=>String(v??'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"'); + const escText=v=>String(v??'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>'); // for textarea content: don't escape quotes const isContainer=n=>n&&['sequence','parallel','loop','branch'].includes(n.kind); const isTaskNode=n=>n&&['agent','shell','approval'].includes(n.kind); function walk(node,visitor,parent=null){visitor(node,parent);for(const child of node.children??[])walk(child,visitor,node);} @@ -382,6 +383,9 @@ function generateCode(){const schemas=new Map();collectSchemas(state.root,schemas);const sl=[...schemas.entries()].map(([k,v])=>` ${quoteObjectKey(k)}: ${v},`);return['import { Approval, Branch, CodexAgent, Parallel, Ralph, Sequence, Task, Workflow, createSmithers } from "smithers-orchestrator";','import { z } from "zod";','','const sharedAgent = new CodexAgent({ model: "gpt-5.3-codex" });','','const { smithers, outputs } = createSmithers({',...sl,'});','',`export default smithers((ctx) => (`,` `,subtreeCode(state.root,2),' ','));'].join('\n');} // ── XML + Plan (all client-side) ─────────────────────────────────────── + // Note: branch XML intentionally includes both children for design-time preview. + // At runtime, Smithers Branch only renders the chosen subtree. The builder shows + // both paths so authors can see and edit the full graph structure. function buildXmlNode(node){if(isTaskNode(node))return{kind:'element',tag:'smithers:task',props:{id:node.id},children:[]};if(node.kind==='sequence')return{kind:'element',tag:'smithers:sequence',props:{},children:node.children.map(buildXmlNode)};if(node.kind==='parallel')return{kind:'element',tag:'smithers:parallel',props:{},children:node.children.map(buildXmlNode)};if(node.kind==='loop')return{kind:'element',tag:'smithers:ralph',props:{id:node.loopId||node.id,maxIterations:String(node.maxIterations||3)},children:node.children.map(buildXmlNode)};if(node.kind==='branch')return{kind:'element',tag:'smithers:branch',props:{if:'true'},children:node.children.map(buildXmlNode)};return{kind:'element',tag:'smithers:sequence',props:{},children:[]};} const buildXml=()=>({kind:'element',tag:'smithers:workflow',props:{},children:[buildXmlNode(state.root)]}); function refreshPlan(){try{const result=buildPlanTree(buildXml());planOutputEl.textContent=JSON.stringify(result,null,2);}catch(e){planOutputEl.textContent='Error: '+e.message;}graphOutputEl.textContent=JSON.stringify(documentModel(),null,2);} @@ -405,7 +409,7 @@ el.addEventListener('click',()=>{state.selectedId=el.getAttribute('data-node-id');renderInspector();renderMinimap(graph);}); el.addEventListener('pointerdown',ev=>{if(ev.target.tagName==='BUTTON')return;const id=el.getAttribute('data-node-id');state.selectedId=id;const rect=el.getBoundingClientRect();const cr=canvasInnerEl.getBoundingClientRect();state.drag={id,dx:(ev.clientX-rect.left)/state.zoom,dy:(ev.clientY-rect.top)/state.zoom,canvasLeft:cr.left,canvasTop:cr.top};el.setPointerCapture(ev.pointerId);renderInspector();}); });applyZoom();renderMinimap(graph);} - function renderInspector(){const entry=selectedEntry(),node=entry?.node||state.root;const common=`
${esc(node.kind)}
`;let specific='';if(node.kind==='agent')specific=``;else if(node.kind==='shell')specific=``;else if(node.kind==='approval')specific=``;else if(node.kind==='loop')specific=``;else if(node.kind==='branch')specific=``;else specific=`

Container node. Add children from the left palette.

`;inspectorEl.innerHTML=common+specific;} + function renderInspector(){const entry=selectedEntry(),node=entry?.node||state.root;const common=`
${esc(node.kind)}
`;let specific='';if(node.kind==='agent')specific=``;else if(node.kind==='shell')specific=``;else if(node.kind==='approval')specific=``;else if(node.kind==='loop')specific=``;else if(node.kind==='branch')specific=``;else specific=`

Container node. Add children from the left palette.

`;inspectorEl.innerHTML=common+specific;} function renderCode(){codeOutputEl.textContent=generateCode();} function renderStatus(){let html='';for(const m of state.statusMessages)html+=`
${esc(m.title)}
`;statusAreaEl.innerHTML=html;} function render(){renderNodeList();renderGraphCanvas();renderInspector();renderCode();renderStatus();refreshPlan();}