feat: compose draft authoring workbench

This commit is contained in:
lda
2026-08-09 06:16:15 +07:00 Verified
parent bc634d000a
commit c037e73bee
13 changed files with 1089 additions and 204 deletions
@@ -0,0 +1,64 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import { AuthoringGraph } from "./AuthoringGraph.js";
import type { WorkbenchSelection } from "./authoring-graph.js";
const workspace: DraftWorkspace = {
workspaceId: "draft-review",
revision: 2,
title: "Review workflow",
status: "invalid",
diagnostics: [],
summary: {
name: "review-workflow",
start: "collect",
stepCount: 2,
routeCount: 2,
steps: ["collect", "review"],
},
draft: {
name: "review-workflow",
start: "collect",
steps: {
collect: { use: "demo.collect" },
review: { interrupt: { kind: "approval" } },
},
routes: { collect: { ok: "review" }, review: { submitted: "__end__" } },
},
};
afterEach(() => cleanup());
describe("AuthoringGraph", () => {
it("renders the projected graph and marks the selected node", () => {
const selection: WorkbenchSelection = { kind: "node", nodeId: "review" };
const { container } = render(
<AuthoringGraph draft={workspace.draft} selection={selection} onSelectionChange={vi.fn()} />,
);
expect(screen.getAllByText("collect")).not.toHaveLength(0);
expect(screen.getByText("approval")).toBeInTheDocument();
expect(container.querySelector('[data-node-id="review"]')).toHaveAttribute(
"data-active",
"true",
);
});
it("selects a connector by its source step and outcome", () => {
const onSelectionChange = vi.fn<(selection: WorkbenchSelection) => void>();
const { container } = render(
<AuthoringGraph draft={workspace.draft} selection={{ kind: "canvas" }} onSelectionChange={onSelectionChange} />,
);
const edgeButton = container.querySelector('[data-edge-id="e-collect-review-0"]');
expect(edgeButton).not.toBeNull();
fireEvent.click(edgeButton!);
expect(onSelectionChange).toHaveBeenCalledWith({
kind: "edge",
stepId: "collect",
outcome: "ok",
});
});
});
@@ -0,0 +1,78 @@
import { useMemo } from "react";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import { WorkflowGraph } from "../../graph/WorkflowGraph.js";
import { projectAuthoringGraph, type WorkbenchSelection } from "./authoring-graph.js";
type AuthoringGraphProps = {
readonly draft: DraftWorkspace["draft"];
readonly selection: WorkbenchSelection;
readonly onSelectionChange: (selection: WorkbenchSelection) => void;
};
export const AuthoringGraph = ({
draft,
selection,
onSelectionChange,
}: AuthoringGraphProps) => {
const model = useMemo(() => projectAuthoringGraph(draft), [draft]);
const activeEdgeId =
selection.kind === "edge"
? model.edges.find(
(edge) => edge.source === selection.stepId && edge.label === selection.outcome,
)?.id ?? null
: null;
const selectEdge = (edgeId: string): void => {
const edge = model.edges.find((candidate) => candidate.id === edgeId);
if (edge) {
onSelectionChange({
kind: "edge",
stepId: edge.source,
outcome: edge.label,
});
}
};
return (
<section aria-label="Workflow graph" className="authoring-graph">
<div className="authoring-graph__heading">
<div>
<p className="workspace-route-pending__eyebrow">Authoring canvas</p>
<h2>Workflow graph</h2>
</div>
<span className="authoring-graph__selection" aria-live="polite">
{selection.kind === "canvas" ? "Canvas" : selection.kind}
</span>
</div>
<WorkflowGraph
activeEdgeId={activeEdgeId}
activeNodeId={selection.kind === "node" ? selection.nodeId : null}
model={model}
onCanvasSelect={() => onSelectionChange({ kind: "canvas" })}
onEdgeSelect={selectEdge}
onNodeSelect={(nodeId) => onSelectionChange({ kind: "node", nodeId })}
/>
<div aria-label="Route outcomes" className="authoring-graph__routes">
<h3>Route outcomes</h3>
{model.edges.length > 0 ? (
<ul>
{model.edges.map((edge) => (
<li key={edge.id}>
<button
aria-pressed={activeEdgeId === edge.id}
className="authoring-graph__route"
data-edge-id={edge.id}
onClick={() => selectEdge(edge.id)}
type="button"
>
<span>{edge.source}</span>
<strong>{edge.label || "unnamed"}</strong>
<span>{edge.target}</span>
</button>
</li>
))}
</ul>
) : <p>No routes in this draft.</p>}
</div>
</section>
);
};
@@ -0,0 +1,51 @@
import type { CapabilitySummary } from "../domain/capability-models.js";
import type { WorkbenchSelection } from "./authoring-graph.js";
type CapabilityPaletteProps = {
readonly capabilities?: ReadonlyArray<CapabilitySummary>;
readonly selection: WorkbenchSelection;
readonly onSelectionChange: (selection: WorkbenchSelection) => void;
};
const EMPTY_CAPABILITIES: ReadonlyArray<CapabilitySummary> = [];
export const CapabilityPalette = ({
capabilities = EMPTY_CAPABILITIES,
selection,
onSelectionChange,
}: CapabilityPaletteProps) => (
<aside aria-label="Capability palette" className="capability-palette" role="region">
<header className="capability-palette__header">
<p className="workspace-route-pending__eyebrow">Available interfaces</p>
<h2>Capabilities</h2>
<p>Choose a capability to inspect its contract before adding it in the next authoring slice.</p>
</header>
{capabilities.length > 0 ? (
<ul className="capability-palette__list">
{capabilities.map((capability) => {
const isSelected =
selection.kind === "capability" && selection.qualifiedName === capability.name;
return (
<li key={capability.name}>
<button
aria-label={capability.name}
aria-pressed={isSelected}
className="capability-palette__item"
data-selected={isSelected}
onClick={() =>
onSelectionChange({ kind: "capability", qualifiedName: capability.name })
}
type="button"
>
<strong>{capability.name}</strong>
<span>{capability.kind === "node_spec" ? "Node spec" : "Wrapper artifact"}</span>
</button>
</li>
);
})}
</ul>
) : (
<p className="capability-palette__empty">No capability catalog is loaded for this draft yet.</p>
)}
</aside>
);
@@ -0,0 +1,264 @@
import type { ReactNode } from "react";
import type { DraftDiagnostic, DraftWorkspace } from "../domain/draft-workspace-models.js";
import type { CapabilitySummary } from "../domain/capability-models.js";
import { projectAuthoringGraph, type WorkbenchSelection } from "./authoring-graph.js";
const MAX_RAW_DRAFT_CHARS = 12_000;
const TRUNCATION_MARKER = "... truncated ...";
type ContextInspectorProps = {
readonly draft: DraftWorkspace;
readonly capabilities: ReadonlyArray<CapabilitySummary>;
readonly selection: WorkbenchSelection;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const formatStatus = (status: DraftWorkspace["status"]): string =>
status.charAt(0).toUpperCase() + status.slice(1);
const formatValue = (value: unknown): string => {
if (typeof value === "string") return value;
const encoded = JSON.stringify(value);
return encoded ?? String(value);
};
export const formatBoundedJson = (
value: unknown,
maxChars = MAX_RAW_DRAFT_CHARS,
): string => {
const truncationMarker = TRUNCATION_MARKER.slice(0, Math.max(0, maxChars));
const contentLimit = Math.max(0, maxChars);
let output = "";
let truncated = false;
const activeObjects = new WeakSet<object>();
const append = (chunk: string): void => {
if (truncated) return;
if (output.length + chunk.length > contentLimit) {
output += chunk.slice(0, Math.max(0, contentLimit - output.length));
truncated = true;
return;
}
output += chunk;
};
const appendJsonString = (text: string): void => {
append('"');
for (let index = 0; index < text.length; index++) {
if (truncated) return;
const code = text.charCodeAt(index);
if (code === 0x22) append('\\"');
else if (code === 0x5c) append("\\\\");
else if (code < 0x20) append(`\\u${code.toString(16).padStart(4, "0")}`);
else if (code >= 0xd800 && code <= 0xdbff) {
const nextCode = text.charCodeAt(index + 1);
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
append(text.slice(index, index + 2));
index++;
} else append(`\\u${code.toString(16).padStart(4, "0")}`);
} else if (code >= 0xdc00 && code <= 0xdfff) {
append(`\\u${code.toString(16).padStart(4, "0")}`);
} else append(text.charAt(index));
}
if (!truncated) append('"');
};
const visit = (current: unknown, depth: number): void => {
if (truncated) return;
if (current === null || typeof current !== "object") {
if (typeof current === "string") appendJsonString(current);
else if (typeof current === "number") append(Number.isFinite(current) ? String(current) : "null");
else if (typeof current === "boolean") append(current ? "true" : "false");
else append("null");
return;
}
if (activeObjects.has(current)) {
append('"[Circular]"');
return;
}
activeObjects.add(current);
const indent = " ".repeat(depth);
const childIndent = " ".repeat(depth + 1);
if (Array.isArray(current)) {
append("[");
let first = true;
for (const item of current) {
if (truncated) break;
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
visit(item, depth + 1);
first = false;
}
if (!truncated) append(first ? "]" : `\n${indent}]`);
} else {
if (!isRecord(current)) {
activeObjects.delete(current);
return;
}
const record = current;
append("{");
let first = true;
for (const key in record) {
if (!Object.prototype.hasOwnProperty.call(record, key) || truncated) continue;
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
appendJsonString(key);
append(": ");
visit(record[key], depth + 1);
first = false;
}
if (!truncated) append(first ? "}" : `\n${indent}}`);
}
activeObjects.delete(current);
};
visit(value, 0);
if (!truncated) return output;
const markerStart = Math.max(0, contentLimit - truncationMarker.length);
return `${output.slice(0, markerStart)}${truncationMarker}`;
};
const Fact = ({ label, value }: { readonly label: string; readonly value: string }) => (
<div>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
);
const Diagnostic = ({ diagnostic }: { readonly diagnostic: DraftDiagnostic }) => (
<li className="draft-detail__diagnostic">
<dl>
<Fact label="Code" value={diagnostic.code} />
<Fact label="Path" value={diagnostic.path} />
<Fact label="Message" value={diagnostic.message} />
<Fact label="Step id" value={diagnostic.stepId ?? "none"} />
<Fact label="Repair hint" value={diagnostic.repairHint ?? "none"} />
</dl>
</li>
);
const DraftSummary = ({ draft }: { readonly draft: DraftWorkspace }) => (
<section aria-labelledby="draft-detail-summary-heading" className="draft-detail__panel">
<h2 id="draft-detail-summary-heading">Draft summary</h2>
<dl className="draft-detail__facts">
<Fact label="Status" value={formatStatus(draft.status)} />
<Fact label="Revision" value={`Revision ${draft.revision}`} />
<Fact label="Start step" value={formatValue(draft.summary.start)} />
<Fact label="Step count" value={String(draft.summary.stepCount)} />
<Fact label="Route count" value={String(draft.summary.routeCount)} />
</dl>
<h3>Step ids</h3>
<ul className="draft-detail__steps">
{draft.summary.steps.map((stepId) => <li key={stepId}>{stepId}</li>)}
</ul>
</section>
);
const Diagnostics = ({ diagnostics }: { readonly diagnostics: ReadonlyArray<DraftDiagnostic> }) => (
<section aria-labelledby="draft-detail-diagnostics-heading" className="draft-detail__panel">
<h2 id="draft-detail-diagnostics-heading">Diagnostics</h2>
{diagnostics.length > 0 ? (
<ol className="draft-detail__diagnostics">
{diagnostics.map((diagnostic, index) => (
<Diagnostic key={`${diagnostic.code}-${diagnostic.path}-${index}`} diagnostic={diagnostic} />
))}
</ol>
) : <p>No diagnostics reported.</p>}
</section>
);
const RawDraft = ({ draft }: { readonly draft: DraftWorkspace["draft"] }) => (
<details className="draft-detail__raw">
<summary>Raw draft document</summary>
{draft ? (
<pre aria-label="Raw draft JSON, horizontally scrollable" role="region" tabIndex={0}>
{formatBoundedJson(draft)}
</pre>
) : <p>Full draft document was not returned</p>}
</details>
);
const DeferredActions = () => (
<section className="authoring-inspector__deferred" aria-labelledby="deferred-actions-heading">
<h3 id="deferred-actions-heading">Deferred actions</h3>
<div className="authoring-inspector__deferred-actions">
{[
"Undo — Later",
"Redo — Later",
"Delete node — Later",
"Delete route — Later",
"Add other step — Later",
"Create artifact — Later",
].map((label) => <button disabled key={label} type="button">{label}</button>)}
</div>
</section>
);
export const ContextInspector = ({ draft, capabilities, selection }: ContextInspectorProps) => {
const graph = projectAuthoringGraph(draft.draft);
let content: ReactNode;
if (selection.kind === "canvas") {
content = (
<div className="draft-detail__panels">
<DraftSummary draft={draft} />
<Diagnostics diagnostics={draft.diagnostics} />
</div>
);
} else if (selection.kind === "capability") {
const capability = capabilities.find((item) => item.name === selection.qualifiedName);
content = (
<section className="authoring-inspector__selection" aria-labelledby="capability-selection-heading">
<p className="workspace-route-pending__eyebrow">Selected capability</p>
<h2 id="capability-selection-heading">{selection.qualifiedName}</h2>
<p>{capability?.description ?? "Inspect this contract before configuring a new node."}</p>
<dl className="authoring-inspector__facts">
<Fact label="Kind" value={capability?.kind === "wrapper_artifact" ? "Wrapper artifact" : "Node spec"} />
<Fact label="Outcomes" value={capability?.outcomes.join(", ") || "none"} />
</dl>
</section>
);
} else if (selection.kind === "edge") {
const edge = graph.edges.find(
(candidate) => candidate.source === selection.stepId && candidate.label === selection.outcome,
);
content = (
<section className="authoring-inspector__selection" aria-labelledby="route-selection-heading">
<p className="workspace-route-pending__eyebrow">Selected connector</p>
<h2 id="route-selection-heading">Route inspector</h2>
<dl className="authoring-inspector__facts">
<Fact label="Source step" value={selection.stepId} />
<Fact label="Outcome" value={selection.outcome || "unnamed"} />
<Fact label="Target" value={edge?.target ?? "unknown"} />
</dl>
</section>
);
} else {
const node = graph.nodes.find((candidate) => candidate.id === selection.nodeId);
const unsupported = node?.data.kind === "unsupported";
content = (
<section className="authoring-inspector__selection" aria-labelledby="node-selection-heading">
<p className="workspace-route-pending__eyebrow">Selected step</p>
<h2 id="node-selection-heading">{selection.nodeId}</h2>
<dl className="authoring-inspector__facts">
<Fact label="Kind" value={node?.data.kind ?? "unknown"} />
<Fact label="Reference" value={node?.data.nodeRef ?? "none"} />
</dl>
{unsupported && <p role="status">Read-only: unsupported step kind.</p>}
{!unsupported && <p>Capability configuration will be available in the next authoring slice.</p>}
</section>
);
}
return (
<aside aria-label="Context inspector" className="context-inspector" role="region">
<div className="context-inspector__heading">
<p className="workspace-route-pending__eyebrow">Selection context</p>
<h2>Inspector</h2>
</div>
{content}
<DeferredActions />
<RawDraft draft={draft.draft} />
</aside>
);
};
@@ -0,0 +1,81 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import { DraftWorkbench } from "./DraftWorkbench.js";
const workspace: DraftWorkspace = {
workspaceId: "draft-review",
revision: 2,
title: "Review workflow",
status: "invalid",
diagnostics: [
{
code: "missing_route",
path: "routes.review",
message: "Review needs a route.",
stepId: "review",
repairHint: "Add a submitted route.",
details: {},
},
],
summary: {
name: "review-workflow",
start: "collect",
stepCount: 2,
routeCount: 1,
steps: ["collect", "review"],
},
draft: {
name: "review-workflow",
start: "collect",
steps: { collect: { use: "demo.collect" }, review: { interrupt: { kind: "approval" } } },
routes: { collect: { ok: "review" } },
},
};
afterEach(() => cleanup());
describe("DraftWorkbench", () => {
it("keeps palette, graph, and inspector visible in the desktop shell", () => {
render(
<DraftWorkbench
draft={workspace}
capabilities={[
{
kind: "node_spec",
name: "demo.collect",
sourceId: "demo",
description: "Collect source material.",
outcomes: ["ok"],
inputFields: [],
outputFields: [],
},
]}
/>,
);
expect(screen.getByRole("region", { name: "Capability palette" })).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Workflow graph" })).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Context inspector" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "demo.collect" })).toBeInTheDocument();
expect(screen.getByText("Draft summary")).toBeInTheDocument();
expect(screen.getByText("Review needs a route.")).toBeInTheDocument();
});
it("keeps the raw draft collapsed and exposes all deferred actions without handlers", () => {
render(<DraftWorkbench draft={workspace} />);
const raw = screen.getByText("Raw draft document").closest("details");
expect(raw).not.toHaveAttribute("open");
for (const label of [
"Undo — Later",
"Redo — Later",
"Delete node — Later",
"Delete route — Later",
"Add other step — Later",
"Create artifact — Later",
]) {
expect(screen.getByRole("button", { name: label })).toBeDisabled();
}
});
});
@@ -0,0 +1,39 @@
import { useState } from "react";
import type { CapabilitySummary } from "../domain/capability-models.js";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import { AuthoringGraph } from "./AuthoringGraph.js";
import { CapabilityPalette } from "./CapabilityPalette.js";
import { ContextInspector } from "./ContextInspector.js";
import type { WorkbenchSelection } from "./authoring-graph.js";
type DraftWorkbenchProps = {
readonly draft: DraftWorkspace;
readonly capabilities?: ReadonlyArray<CapabilitySummary>;
readonly initialSelection?: WorkbenchSelection;
readonly onSelectionChange?: (selection: WorkbenchSelection) => void;
};
export const DraftWorkbench = ({
draft,
capabilities = [],
initialSelection = { kind: "canvas" },
onSelectionChange,
}: DraftWorkbenchProps) => {
const [selection, setSelection] = useState<WorkbenchSelection>(initialSelection);
const select = (nextSelection: WorkbenchSelection): void => {
setSelection(nextSelection);
onSelectionChange?.(nextSelection);
};
return (
<div className="draft-workbench" data-selection-kind={selection.kind}>
<CapabilityPalette
capabilities={capabilities}
onSelectionChange={select}
selection={selection}
/>
<AuthoringGraph draft={draft.draft} onSelectionChange={select} selection={selection} />
<ContextInspector capabilities={capabilities} draft={draft} selection={selection} />
</div>
);
};
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
deriveInsertionContext,
projectAuthoringGraph,
type WorkbenchSelection,
} from "./authoring-graph.js";
const draft = {
name: "review-workflow",
start: "collect",
steps: {
collect: { use: "demo.collect", desc: "Collect the source material." },
review: {
interrupt: {
kind: "approval",
outcomes: ["approved", "needs_changes"],
},
},
},
routes: {
collect: { ok: "review" },
review: { approved: "__end__", needs_changes: "collect" },
},
};
describe("projectAuthoringGraph", () => {
it("projects normal, interrupt, and terminal nodes with labelled routes", () => {
const model = projectAuthoringGraph(draft);
expect(model.nodes.map((node) => [node.id, node.data.kind])).toEqual([
["__end__", "end"],
["collect", "use"],
["review", "interrupt"],
]);
expect(model.edges.map((edge) => [edge.id, edge.source, edge.label, edge.target])).toEqual([
["e-collect-review-0", "collect", "ok", "review"],
["e-review-__end__-1", "review", "approved", "__end__"],
["e-review-collect-2", "review", "needs_changes", "collect"],
]);
expect(model.nodes.find((node) => node.id === "collect")?.data.nodeRef).toBe(
"demo.collect",
);
});
it("keeps projection ids and positions stable when step insertion order changes", () => {
const reordered = {
...draft,
steps: { review: draft.steps.review, collect: draft.steps.collect },
routes: { review: draft.routes.review, collect: draft.routes.collect },
};
expect(projectAuthoringGraph(reordered)).toEqual(projectAuthoringGraph(draft));
});
});
describe("WorkbenchSelection", () => {
it("derives explicit route insertion only from a selected connector", () => {
const edgeSelection: WorkbenchSelection = {
kind: "edge",
stepId: "review",
outcome: "approved",
};
expect(deriveInsertionContext(edgeSelection)).toEqual({
routeFromStep: "review",
routeFromOutcome: "approved",
});
});
it("keeps node insertion connected to a step without inventing an outcome", () => {
expect(deriveInsertionContext({ kind: "node", nodeId: "collect" })).toEqual({
routeFromStep: "collect",
});
});
it("does not derive insertion context from canvas or capability selection", () => {
expect(deriveInsertionContext({ kind: "canvas" })).toBeNull();
expect(
deriveInsertionContext({ kind: "capability", qualifiedName: "demo.collect" }),
).toBeNull();
});
});
@@ -0,0 +1,172 @@
import { buildWorkflowGraph, type WorkflowGraphModel } from "../../graph/graph-model.js";
type JsonRecord = Readonly<Record<string, unknown>>;
export type WorkbenchSelection =
| { readonly kind: "canvas" }
| { readonly kind: "capability"; readonly qualifiedName: string }
| { readonly kind: "node"; readonly nodeId: string }
| { readonly kind: "edge"; readonly stepId: string; readonly outcome: string };
export type InsertionContext = {
readonly routeFromStep: string;
readonly routeFromOutcome?: string;
};
const EMPTY_GRAPH: WorkflowGraphModel = { nodes: [], edges: [] };
const isRecord = (value: unknown): value is JsonRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
const recordValue = (value: unknown): JsonRecord | null =>
isRecord(value) ? value : null;
const stringValue = (value: unknown): string | null =>
typeof value === "string" && value.length > 0 ? value : null;
const stringList = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
const stepKind = (step: JsonRecord): string => {
for (const kind of [
"use",
"interrupt",
"subgraph",
"condition",
"when",
"choose",
"match",
"foreach",
"join",
"end",
]) {
if (kind in step) return kind;
}
return "unsupported";
};
const nodeForStep = (id: string, step: JsonRecord): JsonRecord => {
const kind = stepKind(step);
const payload = recordValue(step[kind]);
const graphType =
kind === "use"
? "node"
: kind === "when" || kind === "choose" || kind === "match"
? "condition"
: kind;
const node: Record<string, unknown> = {
id,
type: graphType,
detail: stringValue(step.desc),
};
if (kind === "use") node.node = stringValue(step.use) ?? id;
if (kind === "interrupt") {
node.kind = stringValue(payload?.kind) ?? "Interrupt";
node.outcomes = stringList(payload?.outcomes);
}
if (kind === "end") {
node.outcome = stringValue(payload?.outcome) ?? "ok";
}
if (kind === "subgraph") {
const workflow = recordValue(payload?.workflow);
node.workflow = stringValue(workflow?.name) ?? stringValue(workflow?.artifact_id);
}
return node;
};
const sortedRecords = (value: JsonRecord | null): Array<[string, JsonRecord]> =>
value === null
? []
: Object.entries(value)
.filter((entry): entry is [string, JsonRecord] => isRecord(entry[1]))
.toSorted(([left], [right]) => left.localeCompare(right));
const sortedEntries = (value: JsonRecord | null): Array<[string, unknown]> =>
value === null
? []
: Object.entries(value).toSorted(([left], [right]) => left.localeCompare(right));
const routesForSteps = (routes: JsonRecord | null): Array<Record<string, unknown>> => {
if (routes === null) return [];
const edges: Array<Record<string, unknown>> = [];
for (const [from, outcomes] of sortedEntries(routes)) {
for (const [outcome, target] of sortedEntries(recordValue(outcomes))) {
const targetId = stringValue(target);
if (targetId === null) continue;
edges.push({ from, outcome, to: targetId });
}
}
return edges;
};
const compiledPlan = (draft: JsonRecord): {
readonly nodes: Array<JsonRecord>;
readonly edges: Array<Record<string, unknown>>;
} => {
const rawNodes = Array.isArray(draft.nodes)
? draft.nodes.filter(isRecord)
: [];
const rawEdges = Array.isArray(draft.edges)
? draft.edges.filter(isRecord).map((edge) => ({ ...edge }))
: routesForSteps(recordValue(draft.routes));
const nodes = rawNodes.map((node) => ({ ...node }));
const nodeIds = new Set(nodes.map((node) => stringValue(node.id)).filter((id): id is string => id !== null));
for (const edge of rawEdges) {
const target = stringValue(edge.to);
if (target === "__end__" && !nodeIds.has(target)) {
nodes.push({ id: "__end__", type: "end", outcome: "ok" });
nodeIds.add(target);
}
}
return { nodes, edges: rawEdges };
};
const keyedPlan = (draft: JsonRecord): {
readonly nodes: Array<JsonRecord>;
readonly edges: Array<Record<string, unknown>>;
} => {
const steps = recordValue(draft.steps);
if (steps === null) return { nodes: [], edges: [] };
const nodes = sortedRecords(steps).map(([id, step]) => nodeForStep(id, step));
const edges = routesForSteps(recordValue(draft.routes));
const nodeIds = new Set(nodes.map((node) => stringValue(node.id)).filter((id): id is string => id !== null));
if (edges.some((edge) => edge.to === "__end__") && !nodeIds.has("__end__")) {
nodes.push({ id: "__end__", type: "end", outcome: "ok" });
}
return { nodes, edges };
};
/** Project the stored draft into the existing Dagre-backed graph model.
*
* Draft workspaces store keyed authoring steps while lifecycle views receive a
* compiled `nodes`/`edges` plan. Keeping both lowerings here lets the graph
* boundary stay singular and keeps browser selection separate from draft data.
*/
export const projectAuthoringGraph = (draft: JsonRecord | null): WorkflowGraphModel => {
if (draft === null) return EMPTY_GRAPH;
const plan = Array.isArray(draft.nodes) || Array.isArray(draft.edges)
? compiledPlan(draft)
: keyedPlan(draft);
const edges = plan.edges.toSorted((left, right) => {
const leftKey = `${String(left.from)}\u0000${String(left.outcome)}\u0000${String(left.to)}`;
const rightKey = `${String(right.from)}\u0000${String(right.outcome)}\u0000${String(right.to)}`;
return leftKey.localeCompare(rightKey);
});
return buildWorkflowGraph({ nodes: plan.nodes, edges });
};
export const deriveInsertionContext = (
selection: WorkbenchSelection,
): InsertionContext | null => {
if (selection.kind === "edge") {
return {
routeFromStep: selection.stepId,
routeFromOutcome: selection.outcome,
};
}
if (selection.kind === "node") return { routeFromStep: selection.nodeId };
return null;
};
@@ -97,13 +97,14 @@ describe("DraftDetailRoute", () => {
expect(screen.getByText("Route summarize.ok to __end__.")).toBeInTheDocument();
});
it("keeps the raw draft closed by default and exposes no mutation controls", () => {
it("keeps the raw draft closed and marks deferred mutation controls unavailable", () => {
const { container } = renderRoute();
const details = container.querySelector("details");
expect(details).not.toBeNull();
expect(details).not.toHaveAttribute("open");
expect(screen.queryByRole("button")).toBeNull();
expect(screen.getByRole("button", { name: "Undo — Later" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Delete node — Later" })).toBeDisabled();
expect(screen.queryByRole("link", { name: /compile|artifact|save|edit|mutate/i })).toBeNull();
});
@@ -1,194 +1,17 @@
import { Link, useParams } from "react-router-dom";
import type {
DraftDiagnostic,
DraftWorkspace,
} from "../domain/draft-workspace-models.js";
import { DraftWorkbench } from "../authoring/DraftWorkbench.js";
export { formatBoundedJson } from "../authoring/ContextInspector.js";
import { useDraftWorkspace } from "./useDraftWorkspace.js";
const MAX_RAW_DRAFT_CHARS = 12_000;
const TRUNCATION_MARKER = "... truncated ...";
const titleFor = (workspace: DraftWorkspace): string =>
workspace.title?.trim() || workspace.workspaceId;
const formatStatus = (status: DraftWorkspace["status"]): string =>
status.charAt(0).toUpperCase() + status.slice(1);
const formatValue = (value: unknown): string => {
if (typeof value === "string") return value;
const encoded = JSON.stringify(value);
return encoded ?? String(value);
};
// Traverse until the display budget is exhausted so a large remote object is
// never fully materialized just to produce a clipped escape-hatch preview.
export const formatBoundedJson = (value: unknown, maxChars = MAX_RAW_DRAFT_CHARS): string => {
const truncationMarker = TRUNCATION_MARKER.slice(0, Math.max(0, maxChars));
const contentLimit = Math.max(0, maxChars);
let output = "";
let truncated = false;
const activeObjects = new WeakSet<object>();
const append = (chunk: string): void => {
if (truncated) return;
if (output.length + chunk.length > contentLimit) {
output += chunk.slice(0, Math.max(0, contentLimit - output.length));
truncated = true;
return;
}
output += chunk;
};
const appendJsonString = (value: string): void => {
append('"');
for (let index = 0; index < value.length; index++) {
if (truncated) return;
const code = value.charCodeAt(index);
if (code === 0x22) append('\\"');
else if (code === 0x5c) append("\\\\");
else if (code < 0x20) append(`\\u${code.toString(16).padStart(4, "0")}`);
else if (code >= 0xd800 && code <= 0xdbff) {
const nextCode = value.charCodeAt(index + 1);
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
append(value.slice(index, index + 2));
index++;
} else {
append(`\\u${code.toString(16).padStart(4, "0")}`);
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
append(`\\u${code.toString(16).padStart(4, "0")}`);
} else {
append(value.charAt(index));
}
}
if (!truncated) append('"');
};
const visit = (current: unknown, depth: number): void => {
if (truncated) return;
if (current === null || typeof current !== "object") {
if (typeof current === "string") {
appendJsonString(current);
} else if (typeof current === "number") {
append(Number.isFinite(current) ? String(current) : "null");
} else if (typeof current === "boolean") {
append(current ? "true" : "false");
} else {
append("null");
}
return;
}
if (activeObjects.has(current)) {
append('"[Circular]"');
return;
}
activeObjects.add(current);
const indent = " ".repeat(depth);
const childIndent = " ".repeat(depth + 1);
if (Array.isArray(current)) {
append("[");
let first = true;
for (const item of current) {
if (truncated) break;
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
visit(item, depth + 1);
first = false;
}
if (!truncated) append(first ? "]" : `\n${indent}]`);
} else {
const record = current as Record<string, unknown>;
append("{");
let first = true;
for (const key in record) {
if (!Object.prototype.hasOwnProperty.call(current, key) || truncated) continue;
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
appendJsonString(key);
append(": ");
visit(record[key], depth + 1);
first = false;
}
if (!truncated) append(first ? "}" : `\n${indent}}`);
}
activeObjects.delete(current);
};
visit(value, 0);
if (!truncated) return output;
const markerStart = Math.max(0, contentLimit - truncationMarker.length);
return `${output.slice(0, markerStart)}${truncationMarker}`;
};
const Fact = ({ label, value }: { readonly label: string; readonly value: string }) => (
<div>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
);
const Diagnostic = ({ diagnostic }: { readonly diagnostic: DraftDiagnostic }) => (
<li className="draft-detail__diagnostic">
<dl>
<Fact label="Code" value={diagnostic.code} />
<Fact label="Path" value={diagnostic.path} />
<Fact label="Message" value={diagnostic.message} />
<Fact label="Step id" value={diagnostic.stepId ?? "none"} />
<Fact label="Repair hint" value={diagnostic.repairHint ?? "none"} />
</dl>
</li>
);
const DraftFacts = ({ draft }: { readonly draft: DraftWorkspace }) => (
<section aria-labelledby="draft-detail-summary-heading" className="draft-detail__panel">
<h2 id="draft-detail-summary-heading">Draft summary</h2>
<dl className="draft-detail__facts">
<Fact label="Status" value={formatStatus(draft.status)} />
<Fact label="Revision" value={`Revision ${draft.revision}`} />
<Fact label="Start step" value={formatValue(draft.summary.start)} />
<Fact label="Step count" value={String(draft.summary.stepCount)} />
<Fact label="Route count" value={String(draft.summary.routeCount)} />
</dl>
<h3>Step ids</h3>
<ul className="draft-detail__steps">
{draft.summary.steps.map((stepId) => <li key={stepId}>{stepId}</li>)}
</ul>
</section>
);
const Diagnostics = ({ diagnostics }: { readonly diagnostics: ReadonlyArray<DraftDiagnostic> }) => (
<section aria-labelledby="draft-detail-diagnostics-heading" className="draft-detail__panel">
<h2 id="draft-detail-diagnostics-heading">Diagnostics</h2>
{diagnostics.length > 0 ? (
<ol className="draft-detail__diagnostics">
{diagnostics.map((diagnostic, index) => (
<Diagnostic key={`${diagnostic.code}-${diagnostic.path}-${index}`} diagnostic={diagnostic} />
))}
</ol>
) : (
<p>No diagnostics reported.</p>
)}
</section>
);
const RawDraft = ({ draft }: { readonly draft: Record<string, unknown> | null }) => (
<details className="draft-detail__raw">
<summary>Raw draft document</summary>
{draft ? (
<pre
aria-label="Raw draft JSON, horizontally scrollable"
role="region"
tabIndex={0}
>
{formatBoundedJson(draft)}
</pre>
) : (
<p>Full draft document was not returned</p>
)}
</details>
);
export const DraftDetailRoute = () => {
const { workspaceId = null } = useParams<{ workspaceId: string }>();
const drafts = useDraftWorkspace(workspaceId);
@@ -215,7 +38,7 @@ export const DraftDetailRoute = () => {
{draft && (
<>
<header className="draft-detail__header">
<p className="workspace-route-pending__eyebrow">Read-only draft</p>
<p className="workspace-route-pending__eyebrow">Draft authoring workbench</p>
<h1>{titleFor(draft)}</h1>
<p className="draft-detail__workspace-id">{draft.workspaceId}</p>
<p className="draft-detail__status-line">
@@ -226,11 +49,7 @@ export const DraftDetailRoute = () => {
</p>
</header>
<div className="draft-detail__panels">
<DraftFacts draft={draft} />
<Diagnostics diagnostics={draft.diagnostics} />
</div>
<RawDraft draft={draft.draft} />
<DraftWorkbench draft={draft} />
</>
)}
</div>