feat: edit workflow contracts in console

This commit is contained in:
lda
2026-08-16 00:21:43 +07:00 Verified
parent 737689d015
commit 84b521ecc7
18 changed files with 1386 additions and 16 deletions
+83
View File
@@ -1978,6 +1978,89 @@ tbody tr:hover {
margin: 0;
}
.workflow-contract-inspector,
.workflow-schema-fields-form,
.workflow-output-bindings,
.workflow-outcomes-form {
display: grid;
gap: 0.75rem;
min-width: 0;
}
.workflow-contract-inspector {
padding: 0.75rem;
border: 1px solid var(--color-border);
background: #fff;
}
.workflow-contract-inspector h2,
.workflow-contract-inspector h3,
.workflow-contract-inspector h4 {
margin: 0;
}
.workflow-contract-inspector__status,
.workflow-contract-inspector__impact {
padding: 0.55rem 0.65rem;
border-left: 3px solid var(--color-signal-amber);
background: #fff8e8;
color: var(--color-ink);
font-size: 0.8rem;
}
.workflow-schema-field,
.workflow-output-bindings fieldset {
display: grid;
gap: 0.55rem;
min-width: 0;
padding: 0.7rem;
border: 1px solid var(--color-border);
}
.workflow-schema-field > label,
.workflow-schema-field__state > label,
.workflow-contract-entry label,
.workflow-output-bindings fieldset > label,
.workflow-outcomes-form label {
display: grid;
gap: 0.25rem;
color: var(--color-slate);
font-size: 0.76rem;
font-weight: 650;
}
.workflow-schema-field__state,
.workflow-schema-field__children {
display: grid;
gap: 0.55rem;
padding-left: 0.65rem;
border-left: 2px solid var(--color-signal-green);
}
.workflow-schema-field--unsupported {
border-color: var(--color-signal-amber);
background: #fffaf0;
}
.workflow-schema-field pre,
.workflow-output-bindings textarea {
max-width: 100%;
overflow: auto;
}
.workflow-contract-entry,
.workflow-output-bindings__actions,
.workflow-outcomes-form > div {
display: flex;
align-items: end;
gap: 0.5rem;
}
.workflow-contract-entry label,
.workflow-outcomes-form label {
flex: 1;
}
.selected-capability-inspector {
display: grid;
align-content: start;
@@ -102,6 +102,9 @@ const controller = {
updateCapability: vi.fn(),
setStepInputs: vi.fn(),
setStepOutputs: vi.fn(),
setContract: vi.fn(),
setStart: vi.fn(),
setWorkflowOutputBindings: vi.fn(),
updateSetup: vi.fn(),
setRoute: vi.fn(),
validate: vi.fn(),
@@ -115,6 +118,30 @@ const controller = {
} satisfies DraftAuthoringController;
describe("ContextInspector", () => {
it("routes contract selections to the focused editor without deferred actions", () => {
render(
<ContextInspector
capabilities={[]}
capabilityDetail={null}
capabilityDetailMessage={null}
capabilityDetailPhase="ready"
controller={controller}
draft={{
...draft,
draft: {
...draft.draft,
input_schema: { type: "object", properties: { query: { type: "string" } } },
},
}}
selection={{ kind: "contract", contract: "input" }}
/>,
);
expect(screen.getByRole("heading", { name: "Input contract" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save input schema" })).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Deferred actions" })).not.toBeInTheDocument();
});
it("binds the inspected capability schema and canonical node values", () => {
render(
<ContextInspector
@@ -8,6 +8,8 @@ import { CapabilityNodeForm } from "./CapabilityNodeForm.js";
import { SelectedCapabilityInspector } from "./SelectedCapabilityInspector.js";
import { RouteForm } from "./RouteForm.js";
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
import { useAuthoringContract } from "./useAuthoringContract.js";
import { WorkflowContractInspector } from "./WorkflowContractInspector.js";
type ContextInspectorProps = {
readonly draft: DraftWorkspace;
@@ -137,6 +139,36 @@ const DeferredActions = () => (
</section>
);
const ContractInspector = ({
contract,
controller,
draft,
}: {
readonly contract: Extract<WorkbenchSelection, { readonly kind: "contract" }>["contract"];
readonly controller: DraftAuthoringController;
readonly draft: DraftWorkspace;
}) => {
const authoringContract = useAuthoringContract({
workspaceId: draft.workspaceId,
revision: draft.revision,
selectedStepId: null,
});
return (
<>
{authoringContract.phase === "loading" && <p role="status">Loading authoring choices...</p>}
{authoringContract.phase === "error" && (
<p role="alert">{authoringContract.message ?? "Authoring choices failed to load. Advanced repair remains available."}</p>
)}
<WorkflowContractInspector
contract={contract}
controller={controller}
draft={draft}
inventory={authoringContract.inventory}
/>
</>
);
};
export const ContextInspector = ({
draft,
capabilities,
@@ -225,21 +257,7 @@ export const ContextInspector = ({
</>
);
} else if (selection.kind === "contract") {
const contractNode = graph.nodes.find(
(candidate) => candidate.data.contract === selection.contract,
);
const title = selection.contract.charAt(0).toUpperCase() + selection.contract.slice(1);
content = (
<section
aria-labelledby="contract-selection-heading"
className="authoring-inspector__selection"
>
<p className="workspace-route-pending__eyebrow">Workflow projection</p>
<h2 id="contract-selection-heading">{title} contract</h2>
<p>{contractNode?.data.summary ?? "No contract fields are declared."}</p>
<p>This read-only projection is derived from the canonical draft.</p>
</section>
);
content = <ContractInspector contract={selection.contract} controller={controller} draft={draft} />;
} else {
const node = graph.nodes.find((candidate) => candidate.id === selection.nodeId);
content = (
@@ -71,6 +71,9 @@ const authoringClient: DraftAuthoringClient = {
updateCapabilityStep: vi.fn(),
setStepInputBindings: vi.fn(),
setStepOutputBindings: vi.fn(),
setContract: vi.fn(),
setStart: vi.fn(),
setWorkflowOutputBindings: vi.fn(),
setRoute: vi.fn(),
validate: vi.fn(),
};
@@ -142,6 +142,9 @@ const controllerFor = (workspace: DraftWorkspace): DraftAuthoringController => (
updateCapability: vi.fn(),
setStepInputs: vi.fn(),
setStepOutputs: vi.fn(),
setContract: vi.fn(),
setStart: vi.fn(),
setWorkflowOutputBindings: vi.fn(),
updateSetup: vi.fn(),
setRoute: vi.fn(),
validate: vi.fn(),
@@ -0,0 +1,98 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AuthoringContractInventory } from "../domain/authoring-contract-models.js";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
import { WorkflowContractInspector } from "./WorkflowContractInspector.js";
afterEach(cleanup);
const draft: DraftWorkspace = {
workspaceId: "draft-report",
revision: 4,
title: "Report",
status: "invalid",
diagnostics: [],
summary: { name: "report", start: "read", stepCount: 2, routeCount: 0, steps: ["read", "render"] },
draft: {
input_schema: { type: "object", properties: { query: { type: "string" } } },
state_schema: { type: "object", properties: { report: { type: "string", reducer: "wf.std.replace" } } },
output_schema: { type: "object", properties: { report: { type: "string" } } },
outcomes: ["ok", "cancelled"],
output: [{ path: "state.report", target: "report" }],
steps: {},
routes: {},
},
};
const option = (path: string, origin: "workflow_input" | "workflow_state" | "runtime_context" | "workflow_output") => ({
path,
label: path,
origin,
schema: {},
required: false,
availability: "available" as const,
uses: ["workflow_output" as const],
});
const inventory: AuthoringContractInventory = {
workspaceId: "draft-report",
revision: 4,
selectedStepId: null,
readableSources: [option("input.query", "workflow_input"), option("state.report", "workflow_state"), option("context.request_id", "runtime_context")],
stepInputTargets: [],
stepOutputSources: [],
stateTargets: [],
workflowOutputTargets: [option("output.report", "workflow_output")],
entrySteps: [
{ stepId: "read", label: "Read" },
{ stepId: "render", label: "Render" },
],
workflowOutcomes: ["ok", "cancelled"],
warnings: [],
};
const controller = {
draft,
selection: { kind: "contract", contract: "input" },
insertionContext: null,
dirty: false,
phase: "idle",
message: null,
resetGeneration: 0,
preservedCapabilityForm: null,
addCapability: vi.fn(), updateCapability: vi.fn(), setStepInputs: vi.fn(), setStepOutputs: vi.fn(),
setContract: vi.fn(), setStart: vi.fn(), setWorkflowOutputBindings: vi.fn(),
updateSetup: vi.fn(), setRoute: vi.fn(), validate: vi.fn(), reload: vi.fn(), reapply: vi.fn(),
rememberCapabilityForm: vi.fn(), rememberRouteForm: vi.fn(), select: vi.fn(), markDirty: vi.fn(),
} satisfies DraftAuthoringController;
describe("WorkflowContractInspector", () => {
it("edits the input schema and entry step from inventory", async () => {
const user = userEvent.setup();
render(<WorkflowContractInspector contract="input" controller={controller} draft={draft} inventory={inventory} />);
expect(screen.getByRole("textbox", { name: "Field name" })).toHaveValue("query");
await user.selectOptions(screen.getByRole("combobox", { name: "Entry step" }), "render");
await user.click(screen.getByRole("button", { name: "Save entry step" }));
expect(controller.setStart).toHaveBeenCalledWith("render");
});
it("offers output sources without presenting runtime context as a normal choice", () => {
render(<WorkflowContractInspector contract="output" controller={controller} draft={draft} inventory={inventory} />);
expect(screen.getByRole("button", { name: /state\.report/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /context\.request_id/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save output bindings" })).toBeInTheDocument();
});
it("submits ordered, unique, non-blank outcomes", async () => {
const user = userEvent.setup();
render(<WorkflowContractInspector contract="outcomes" controller={controller} draft={draft} inventory={inventory} />);
fireEvent.change(screen.getByRole("textbox", { name: "Outcome 2" }), { target: { value: "ok" } });
await user.click(screen.getByRole("button", { name: "Save outcomes" }));
expect(controller.setContract).toHaveBeenCalledWith({ outcomes: ["ok"] });
});
});
@@ -0,0 +1,177 @@
import { useRef, useState, type FormEvent } from "react";
import type { AuthoringContractInventory } from "../domain/authoring-contract-models.js";
import type { DraftWorkspace, InputBinding, JsonObject } from "../domain/draft-workspace-models.js";
import { AuthoringPathPicker } from "./AuthoringPathPicker.js";
import { WorkflowSchemaFieldsForm } from "./WorkflowSchemaFieldsForm.js";
import { normalizeOutcomes, type WorkflowContractKind } from "./workflow-contract-editor.js";
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
type WorkflowContractInspectorProps = {
readonly contract: WorkflowContractKind;
readonly controller: DraftAuthoringController;
readonly draft: DraftWorkspace;
readonly inventory: AuthoringContractInventory | null;
};
type OutputRow = {
readonly id: string;
readonly kind: "path" | "value";
readonly source: string;
readonly value: string;
readonly target: string;
};
const isObject = (value: unknown): value is JsonObject =>
typeof value === "object" && value !== null && !Array.isArray(value);
const draftObject = (draft: DraftWorkspace): JsonObject => isObject(draft.draft) ? draft.draft : {};
const pathText = (value: unknown): string => {
if (typeof value === "string") return value;
if (!isObject(value) || !Array.isArray(value.parts) || typeof value.root !== "string") return "";
return [value.root, ...value.parts.map(String)].join(".");
};
const outputRows = (draft: DraftWorkspace): ReadonlyArray<OutputRow> => {
const raw = draftObject(draft).output;
if (!Array.isArray(raw)) return [];
const rows: OutputRow[] = [];
raw.forEach((item, index) => {
if (!isObject(item)) return;
const target = pathText(item.target);
if ("path" in item) {
rows.push({ id: `output-${index}`, kind: "path", source: pathText(item.path), value: "", target });
return;
}
if ("value" in item) {
rows.push({ id: `output-${index}`, kind: "value", source: "", value: JSON.stringify(item.value) ?? "null", target });
}
});
return rows;
};
const parseLiteral = (value: string): unknown => {
try {
return JSON.parse(value);
} catch {
return value;
}
};
const schemaFor = (draft: DraftWorkspace, contract: "input" | "state" | "output"): unknown =>
draftObject(draft)[`${contract}_schema`];
const contractPatch = (
contract: "input" | "state" | "output",
schema: JsonObject,
) => contract === "input"
? { inputSchema: schema }
: contract === "state"
? { stateSchema: schema }
: { outputSchema: schema };
const StatusTruth = ({ controller, draft }: Pick<WorkflowContractInspectorProps, "controller" | "draft">) => (
<div className="workflow-contract-inspector__status" aria-live="polite">
{controller.dirty && controller.phase === "idle" && <span>Unsaved changes</span>}
{controller.phase === "saving" && <span>Saving canonical draft...</span>}
{draft.status === "invalid" && controller.phase === "idle" && <span>Saved with validation diagnostics</span>}
</div>
);
const EntryStepForm = ({ controller, draft, inventory }: WorkflowContractInspectorProps) => {
const [stepId, setStepId] = useState(() => pathText(draft.summary.start));
return (
<form className="workflow-contract-entry" onSubmit={(event) => {
event.preventDefault();
if (stepId !== "") void controller.setStart(stepId);
}}>
<label>
Entry step
<select onChange={(event) => { setStepId(event.target.value); controller.markDirty(); }} value={stepId}>
<option value="">Choose a step</option>
{(inventory?.entrySteps ?? []).map((step) => <option key={step.stepId} value={step.stepId}>{step.label} ({step.stepId})</option>)}
</select>
</label>
<button disabled={stepId === ""} type="submit">Save entry step</button>
</form>
);
};
const WorkflowOutputBindingsForm = ({ controller, draft, inventory }: WorkflowContractInspectorProps) => {
const [rows, setRows] = useState(() => outputRows(draft));
const nextId = useRef(rows.length);
const sources = (inventory?.readableSources ?? []).filter(
(option) => option.origin !== "runtime_context" && option.uses.includes("workflow_output"),
);
const targets = inventory?.workflowOutputTargets ?? [];
const update = (id: string, patch: Partial<OutputRow>): void => {
setRows((current) => current.map((row) => row.id === id ? { ...row, ...patch } : row));
controller.markDirty();
};
const submit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
const bindings: InputBinding[] = rows.filter((row) => row.target.trim() !== "").map((row) => row.kind === "path"
? { path: row.source, target: row.target }
: { value: parseLiteral(row.value), target: row.target });
void controller.setWorkflowOutputBindings(bindings);
};
return (
<form className="workflow-output-bindings" onSubmit={submit}>
<h3>Final output bindings</h3>
{rows.map((row, index) => (
<fieldset key={row.id}>
<legend>Output binding {index + 1}</legend>
<label>Source kind<select value={row.kind} onChange={(event) => update(row.id, { kind: event.target.value as OutputRow["kind"] })}><option value="path">Path</option><option value="value">Literal</option></select></label>
{row.kind === "path" ? (
<AuthoringPathPicker allowCustom label={`Source for output binding ${index + 1}`} onChange={(source) => update(row.id, { source })} options={sources} uses="workflow_output" value={row.source} />
) : <label>Literal JSON<textarea value={row.value} onChange={(event) => update(row.id, { value: event.target.value })} /></label>}
<label>Output target<select value={row.target} onChange={(event) => update(row.id, { target: event.target.value })}><option value="">Choose output field</option>{targets.map((target) => <option key={target.path} value={target.path.replace(/^output\./, "")}>{target.label}</option>)}</select></label>
<div className="workflow-output-bindings__actions">
<button disabled={index === 0} onClick={() => setRows((current) => { const copy = [...current]; [copy[index - 1], copy[index]] = [copy[index]!, copy[index - 1]!]; return copy; })} type="button">Move up</button>
<button onClick={() => { setRows((current) => current.filter((item) => item.id !== row.id)); controller.markDirty(); }} type="button">Remove binding</button>
</div>
</fieldset>
))}
<button onClick={() => { setRows((current) => [...current, { id: `output-new-${nextId.current++}`, kind: "path", source: "", value: "", target: "" }]); controller.markDirty(); }} type="button">Add output binding</button>
<button type="submit">Save output bindings</button>
</form>
);
};
const OutcomesForm = ({ controller, draft }: WorkflowContractInspectorProps) => {
const raw = draftObject(draft).outcomes;
const [rows, setRows] = useState<ReadonlyArray<string>>(() => Array.isArray(raw) ? raw.map(String) : []);
return (
<form className="workflow-outcomes-form" onSubmit={(event) => { event.preventDefault(); void controller.setContract({ outcomes: normalizeOutcomes(rows) }); }}>
{rows.map((outcome, index) => <div key={index}><label>Outcome {index + 1}<input value={outcome} onChange={(event) => { setRows((current) => current.map((item, itemIndex) => itemIndex === index ? event.target.value : item)); controller.markDirty(); }} /></label><button onClick={() => setRows((current) => current.filter((_, itemIndex) => itemIndex !== index))} type="button">Remove</button></div>)}
<button onClick={() => { setRows((current) => [...current, ""]); controller.markDirty(); }} type="button">Add outcome</button>
<button type="submit">Save outcomes</button>
</form>
);
};
export const WorkflowContractInspector = (props: WorkflowContractInspectorProps) => {
const { contract, controller, draft } = props;
const title = contract.charAt(0).toUpperCase() + contract.slice(1);
return (
<section aria-labelledby="workflow-contract-heading" className="workflow-contract-inspector">
<p className="workspace-route-pending__eyebrow">Workflow projection</p>
<h2 id="workflow-contract-heading">{title} contract</h2>
<StatusTruth controller={controller} draft={draft} />
{contract === "outcomes" ? <OutcomesForm {...props} /> : (
<>
<WorkflowSchemaFieldsForm
contract={contract}
key={`${contract}:${controller.resetGeneration}`}
onDirtyChange={controller.markDirty}
onSubmit={(schema) => controller.setContract(contractPatch(contract, schema))}
schema={schemaFor(draft, contract)}
/>
{contract === "input" && <EntryStepForm {...props} />}
{contract === "state" && <p className="workflow-contract-inspector__impact">Removing state fields can invalidate existing bindings. Review diagnostics after saving.</p>}
{contract === "output" && <WorkflowOutputBindingsForm {...props} />}
</>
)}
</section>
);
};
@@ -0,0 +1,82 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WorkflowSchemaFieldsForm } from "./WorkflowSchemaFieldsForm.js";
afterEach(cleanup);
describe("WorkflowSchemaFieldsForm", () => {
it("edits nested fields without exposing state metadata on input", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(
<WorkflowSchemaFieldsForm
contract="input"
onSubmit={onSubmit}
schema={{
type: "object",
properties: {
request: {
type: "object",
properties: { title: { type: "string" } },
},
},
}}
/>,
);
expect(screen.queryByRole("textbox", { name: "Reducer" })).toBeNull();
const descriptions = screen.getAllByRole("textbox", { name: "Description" });
await user.type(descriptions[1]!, "Report title");
await user.click(screen.getByRole("button", { name: "Save input schema" }));
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
properties: expect.objectContaining({
request: expect.objectContaining({
properties: { title: { type: "string", description: "Report title" } },
}),
}),
}),
);
});
it("shows state default and reducer controls", () => {
render(
<WorkflowSchemaFieldsForm
contract="state"
onSubmit={vi.fn()}
schema={{
type: "object",
properties: { count: { type: "integer", default: 0, reducer: "wf.std.add" } },
}}
/>,
);
expect(screen.getByRole("textbox", { name: "Reducer" })).toHaveValue("wf.std.add");
expect(screen.getByRole("textbox", { name: "Default JSON" })).toHaveValue("0");
});
it("keeps unsupported fields visible until explicitly removed", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(
<WorkflowSchemaFieldsForm
contract="output"
onSubmit={onSubmit}
schema={{
type: "object",
properties: { choice: { oneOf: [{ type: "string" }, { type: "number" }] } },
}}
/>,
);
expect(screen.getByRole("group", { name: "Unsupported field choice" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Save output schema" }));
expect(onSubmit).toHaveBeenLastCalledWith(
expect.objectContaining({ properties: { choice: expect.objectContaining({ oneOf: expect.any(Array) }) } }),
);
await user.click(screen.getByRole("button", { name: "Remove unsupported field" }));
await user.click(screen.getByRole("button", { name: "Save output schema" }));
expect(onSubmit).toHaveBeenLastCalledWith(expect.objectContaining({ properties: {} }));
});
});
@@ -0,0 +1,271 @@
import { useRef, useState, type FormEvent } from "react";
import { formatBoundedJson } from "../domain/format-bounded-json.js";
import type { JsonObject } from "../domain/draft-workspace-models.js";
import {
projectWorkflowSchema,
serializeWorkflowSchema,
type WorkflowContractKind,
type WorkflowSchemaFieldRow,
type WorkflowSchemaFieldType,
} from "./workflow-contract-editor.js";
type WorkflowSchemaFieldsFormProps = {
readonly contract: Extract<WorkflowContractKind, "input" | "state" | "output">;
readonly schema: unknown;
readonly onSubmit: (schema: JsonObject) => void | Promise<void>;
readonly onDirtyChange?: (dirty: boolean) => void;
};
const FIELD_TYPES: ReadonlyArray<WorkflowSchemaFieldType> = [
"value",
"string",
"integer",
"number",
"boolean",
"object",
"array",
];
const updateTree = (
rows: ReadonlyArray<WorkflowSchemaFieldRow>,
id: string,
update: (row: WorkflowSchemaFieldRow) => WorkflowSchemaFieldRow,
): ReadonlyArray<WorkflowSchemaFieldRow> => rows.map((row) => {
if (row.id === id) return update(row);
const children = updateTree(row.children, id, update);
const itemRows = row.item === null ? [] : updateTree([row.item], id, update);
const item = itemRows[0] ?? null;
return children === row.children && item === row.item ? row : { ...row, children, item };
});
const removeFromTree = (
rows: ReadonlyArray<WorkflowSchemaFieldRow>,
id: string,
): ReadonlyArray<WorkflowSchemaFieldRow> => rows
.filter((row) => row.id !== id)
.map((row) => ({
...row,
children: removeFromTree(row.children, id),
item: row.item === null ? null : removeFromTree([row.item], id)[0] ?? null,
}));
const emptyRow = (id: string, name = "field"): WorkflowSchemaFieldRow => ({
id,
name,
type: "string",
required: false,
description: "",
hasDefault: false,
defaultValue: undefined,
reducer: undefined,
children: [],
item: null,
unsupportedReason: null,
raw: {},
});
const parseJsonValue = (value: string): unknown => {
if (value.trim() === "") return undefined;
try {
return JSON.parse(value);
} catch {
return value;
}
};
type FieldEditorProps = {
readonly row: WorkflowSchemaFieldRow;
readonly state: boolean;
readonly onUpdate: (id: string, update: (row: WorkflowSchemaFieldRow) => WorkflowSchemaFieldRow) => void;
readonly onRemove: (id: string) => void;
readonly createId: () => string;
};
const FieldEditor = ({ row, state, onUpdate, onRemove, createId }: FieldEditorProps) => {
if (row.unsupportedReason !== null) {
return (
<fieldset aria-label={`Unsupported field ${row.name}`} className="workflow-schema-field workflow-schema-field--unsupported">
<legend>{row.name}</legend>
<p>{row.unsupportedReason}</p>
<details>
<summary>Advanced field details</summary>
<pre role="region" tabIndex={0}>{formatBoundedJson(row.raw)}</pre>
</details>
<button onClick={() => onRemove(row.id)} type="button">Remove unsupported field</button>
</fieldset>
);
}
const set = (patch: Partial<WorkflowSchemaFieldRow>): void =>
onUpdate(row.id, (current) => ({ ...current, ...patch }));
return (
<fieldset aria-label={`Schema field ${row.name}`} className="workflow-schema-field">
<legend>{row.name || "Unnamed field"}</legend>
<label>
Field name
<input onChange={(event) => set({ name: event.target.value })} value={row.name} />
</label>
<label>
Type
<select
onChange={(event) => {
const type = event.target.value as WorkflowSchemaFieldType;
set({
type,
children: type === "object" ? row.children : [],
item: type === "array" ? row.item ?? emptyRow(`${row.id}.items`, "item") : null,
});
}}
value={row.type}
>
{FIELD_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
</label>
<label>
<input
checked={row.required}
onChange={(event) => set({ required: event.target.checked })}
type="checkbox"
/>
Required
</label>
<label>
Description
<input onChange={(event) => set({ description: event.target.value })} value={row.description} />
</label>
{state && (
<div className="workflow-schema-field__state">
<label>
<input
checked={row.hasDefault}
onChange={(event) => set({ hasDefault: event.target.checked })}
type="checkbox"
/>
Has default
</label>
{row.hasDefault && (
<label>
Default JSON
<textarea
onChange={(event) => set({ defaultValue: parseJsonValue(event.target.value) })}
value={JSON.stringify(row.defaultValue) ?? ""}
/>
</label>
)}
<label>
Reducer
<input
onChange={(event) => set({ reducer: parseJsonValue(event.target.value) })}
placeholder="wf.std.replace"
value={typeof row.reducer === "string" ? row.reducer : JSON.stringify(row.reducer) ?? ""}
/>
</label>
</div>
)}
{row.type === "object" && (
<div className="workflow-schema-field__children">
<h4>Object fields</h4>
{row.children.map((child) => (
<FieldEditor
createId={createId}
key={child.id}
onRemove={onRemove}
onUpdate={onUpdate}
row={child}
state={state}
/>
))}
<button
onClick={() => onUpdate(row.id, (current) => ({
...current,
children: [...current.children, emptyRow(createId())],
}))}
type="button"
>
Add nested field
</button>
</div>
)}
{row.type === "array" && row.item !== null && (
<div className="workflow-schema-field__children">
<h4>Array item</h4>
<FieldEditor
createId={createId}
onRemove={() => set({ item: emptyRow(`${row.id}.items`, "item") })}
onUpdate={onUpdate}
row={row.item}
state={state}
/>
</div>
)}
<button onClick={() => onRemove(row.id)} type="button">Remove field</button>
</fieldset>
);
};
export const WorkflowSchemaFieldsForm = ({
contract,
schema,
onSubmit,
onDirtyChange,
}: WorkflowSchemaFieldsFormProps) => {
const [projection] = useState(() => projectWorkflowSchema(schema));
const [rows, setRows] = useState(projection.rows);
const nextId = useRef(0);
const markDirty = (): void => onDirtyChange?.(true);
const createId = (): string => `new-field-${nextId.current++}`;
const update = (
id: string,
updater: (row: WorkflowSchemaFieldRow) => WorkflowSchemaFieldRow,
): void => {
setRows((current) => updateTree(current, id, updater));
markDirty();
};
const remove = (id: string): void => {
setRows((current) => removeFromTree(current, id));
markDirty();
};
const submit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
void Promise.resolve(
onSubmit(serializeWorkflowSchema(projection, rows, { state: contract === "state" })),
).catch(() => undefined);
};
return (
<form className="workflow-schema-fields-form" noValidate onSubmit={submit}>
{projection.rootUnsupportedReason !== null ? (
<section aria-label="Unsupported root schema" className="workflow-schema-field--unsupported">
<p>{projection.rootUnsupportedReason}</p>
<details>
<summary>Advanced schema details</summary>
<pre role="region" tabIndex={0}>{formatBoundedJson(projection.schema)}</pre>
</details>
</section>
) : (
<>
{rows.length === 0 && <p>No fields declared.</p>}
{rows.map((row) => (
<FieldEditor
createId={createId}
key={row.id}
onRemove={remove}
onUpdate={update}
row={row}
state={contract === "state"}
/>
))}
<button
onClick={() => {
setRows((current) => [...current, emptyRow(createId())]);
markDirty();
}}
type="button"
>
Add field
</button>
<button type="submit">Save {contract} schema</button>
</>
)}
</form>
);
};
@@ -64,6 +64,9 @@ const addCapabilityStep = vi.fn<DraftAuthoringClient["addCapabilityStep"]>();
const updateCapabilityStep = vi.fn<DraftAuthoringClient["updateCapabilityStep"]>();
const setStepInputBindings = vi.fn<DraftAuthoringClient["setStepInputBindings"]>();
const setStepOutputBindings = vi.fn<DraftAuthoringClient["setStepOutputBindings"]>();
const setContract = vi.fn<DraftAuthoringClient["setContract"]>();
const setStart = vi.fn<DraftAuthoringClient["setStart"]>();
const setWorkflowOutputBindings = vi.fn<DraftAuthoringClient["setWorkflowOutputBindings"]>();
const setRoute = vi.fn<DraftAuthoringClient["setRoute"]>();
const validate = vi.fn<DraftAuthoringClient["validate"]>();
const list = vi.fn<DraftWorkspaceClient["list"]>();
@@ -75,6 +78,9 @@ const authoringClient = {
updateCapabilityStep,
setStepInputBindings,
setStepOutputBindings,
setContract,
setStart,
setWorkflowOutputBindings,
setRoute,
validate,
} satisfies DraftAuthoringClient;
@@ -121,6 +127,62 @@ beforeEach(() => {
});
describe("useDraftAuthoring", () => {
it("sets a focused contract and reapplies its immutable snapshot after conflict", async () => {
const initial = workspace({ revision: 3 });
const conflict = workspace({ revision: 3, status: "conflict" });
const reloaded = workspace({ revision: 4, status: "invalid" });
const canonical = workspace({ revision: 5 });
const inputSchema = { type: "object", properties: { query: { type: "string" } } };
setContract.mockResolvedValueOnce(conflict).mockResolvedValueOnce(canonical);
load.mockResolvedValue(reloaded);
const { result } = renderHook(() => useDraftAuthoring({
draft: initial,
initialSelection: { kind: "contract", contract: "input" },
}));
await act(async () => result.current.setContract({ inputSchema }));
inputSchema.properties.query.type = "number";
await act(async () => result.current.reload());
await act(async () => result.current.reapply());
expect(setContract).toHaveBeenLastCalledWith({
workspaceId: "draft-report",
revision: 4,
inputSchema: { type: "object", properties: { query: { type: "string" } } },
});
expect(result.current.selection).toEqual({ kind: "contract", contract: "input" });
});
it("sets the entry step through the focused start operation", async () => {
setStart.mockResolvedValue(workspace({ revision: 4 }));
const { result } = renderHook(() => useDraftAuthoring({ draft: workspace() }));
await act(async () => result.current.setStart("render"));
expect(setStart).toHaveBeenCalledWith({
workspaceId: "draft-report",
revision: 3,
stepId: "render",
});
});
it("sets ordered workflow output bindings through the focused operation", async () => {
setWorkflowOutputBindings.mockResolvedValue(workspace({ revision: 4 }));
const bindings: InputBinding[] = [
{ path: "state.report", target: "report" },
{ value: "markdown", target: "format" },
];
const { result } = renderHook(() => useDraftAuthoring({ draft: workspace() }));
await act(async () => result.current.setWorkflowOutputBindings(bindings));
expect(setWorkflowOutputBindings).toHaveBeenCalledWith({
workspaceId: "draft-report",
revision: 3,
bindings,
});
});
it("adds an unconnected capability without inventing route information", async () => {
const initial = workspace();
const canonical = workspace({
@@ -11,6 +11,7 @@ import {
} from "../domain/draft-workspace-client.js";
import type {
DraftWorkspace,
InputBinding,
OutputBinding,
StepInputBinding,
} from "../domain/draft-workspace-models.js";
@@ -22,6 +23,7 @@ import {
type WorkbenchSelection,
} from "./authoring-graph.js";
import type { CapabilitySetupPatch } from "./selected-step-dataflow.js";
import { copyJson, type WorkflowContractPatch } from "./workflow-contract-editor.js";
export type DraftAuthoringPhase = "idle" | "saving" | "conflict" | "error";
@@ -38,6 +40,9 @@ export interface DraftAuthoringController {
readonly updateCapability: (input: CapabilityNodeFormValue) => Promise<void>;
readonly setStepInputs: (bindings: ReadonlyArray<StepInputBinding>) => Promise<void>;
readonly setStepOutputs: (bindings: ReadonlyArray<OutputBinding>) => Promise<void>;
readonly setContract: (patch: WorkflowContractPatch) => Promise<void>;
readonly setStart: (stepId: string) => Promise<void>;
readonly setWorkflowOutputBindings: (bindings: ReadonlyArray<InputBinding>) => Promise<void>;
readonly updateSetup: (patch: CapabilitySetupPatch) => Promise<void>;
readonly setRoute: (input: RouteFormValue) => Promise<void>;
readonly validate: () => Promise<void>;
@@ -108,6 +113,9 @@ type LastSubmission =
readonly targetStepId: string;
readonly bindings: ReadonlyArray<OutputBinding>;
}
| { readonly kind: "contract"; readonly patch: WorkflowContractPatch }
| { readonly kind: "start"; readonly stepId: string }
| { readonly kind: "workflow_outputs"; readonly bindings: ReadonlyArray<InputBinding> }
| null;
type MutationOptions = {
@@ -183,6 +191,42 @@ const copyOutputBindings = (
bindings: ReadonlyArray<OutputBinding>,
): ReadonlyArray<OutputBinding> => bindings.map(copyOutputBinding);
const copyWorkflowOutputBindings = (
bindings: ReadonlyArray<InputBinding>,
): ReadonlyArray<InputBinding> => bindings.map((binding) => (
"path" in binding
? {
path:
typeof binding.path === "string"
? binding.path
: { root: binding.path.root, parts: [...binding.path.parts] },
target:
typeof binding.target === "string"
? binding.target
: { root: binding.target.root, parts: [...binding.target.parts] },
}
: {
value: copyJson(binding.value),
target:
typeof binding.target === "string"
? binding.target
: { root: binding.target.root, parts: [...binding.target.parts] },
}
));
const copyContractPatch = (patch: WorkflowContractPatch): WorkflowContractPatch => ({
...(patch.inputSchema !== undefined
? { inputSchema: copyJson(patch.inputSchema) as typeof patch.inputSchema }
: {}),
...(patch.stateSchema !== undefined
? { stateSchema: copyJson(patch.stateSchema) as typeof patch.stateSchema }
: {}),
...(patch.outputSchema !== undefined
? { outputSchema: copyJson(patch.outputSchema) as typeof patch.outputSchema }
: {}),
...(patch.outcomes !== undefined ? { outcomes: [...patch.outcomes] } : {}),
});
const copySetupPatch = (patch: CapabilitySetupPatch): CapabilitySetupPatch => ({
...(patch.description !== undefined ? { description: patch.description } : {}),
...(patch.retry !== undefined ? { retry: patch.retry } : {}),
@@ -624,6 +668,66 @@ export const useDraftAuthoring = ({
[missingSelectedStep, selectedStepId, submitStepOutputs],
);
const submitContract = useCallback(
(patch: WorkflowContractPatch): Promise<void> => {
const submittedPatch = copyContractPatch(patch);
return runMutation(
"contract",
submittedPatch,
(client, requestDraft) => client.setContract({
workspaceId: requestDraft.workspaceId,
revision: requestDraft.revision,
...submittedPatch,
}),
{ submission: { kind: "contract", patch: submittedPatch } },
);
},
[runMutation],
);
const setContract = useCallback(
(patch: WorkflowContractPatch): Promise<void> => submitContract(patch),
[submitContract],
);
const submitStart = useCallback(
(stepId: string): Promise<void> => runMutation(
"start",
{ stepId },
(client, requestDraft) => client.setStart({
workspaceId: requestDraft.workspaceId,
revision: requestDraft.revision,
stepId,
}),
{ submission: { kind: "start", stepId } },
),
[runMutation],
);
const setStart = useCallback((stepId: string): Promise<void> => submitStart(stepId), [submitStart]);
const submitWorkflowOutputBindings = useCallback(
(bindings: ReadonlyArray<InputBinding>): Promise<void> => {
const submittedBindings = copyWorkflowOutputBindings(bindings);
return runMutation(
"workflow_outputs",
submittedBindings,
(client, requestDraft) => client.setWorkflowOutputBindings({
workspaceId: requestDraft.workspaceId,
revision: requestDraft.revision,
bindings: submittedBindings,
}),
{ submission: { kind: "workflow_outputs", bindings: submittedBindings } },
);
},
[runMutation],
);
const setWorkflowOutputBindings = useCallback(
(bindings: ReadonlyArray<InputBinding>): Promise<void> => submitWorkflowOutputBindings(bindings),
[submitWorkflowOutputBindings],
);
const setRoute = useCallback(
(input: RouteFormValue): Promise<void> => {
return runMutation(
@@ -740,7 +844,10 @@ export const useDraftAuthoring = ({
if (last.kind === "route") return setRoute(last.input);
if (last.kind === "setup") return submitSetup(last.targetStepId, last.patch, true);
if (last.kind === "inputs") return submitStepInputs(last.targetStepId, last.bindings, true);
return submitStepOutputs(last.targetStepId, last.bindings, true);
if (last.kind === "outputs") return submitStepOutputs(last.targetStepId, last.bindings, true);
if (last.kind === "contract") return submitContract(last.patch);
if (last.kind === "start") return submitStart(last.stepId);
return submitWorkflowOutputBindings(last.bindings);
}, [
setRoute,
submitCapabilityAdd,
@@ -748,6 +855,9 @@ export const useDraftAuthoring = ({
submitSetup,
submitStepInputs,
submitStepOutputs,
submitContract,
submitStart,
submitWorkflowOutputBindings,
]);
const rememberCapabilityForm = useCallback(
@@ -792,6 +902,9 @@ export const useDraftAuthoring = ({
updateCapability,
setStepInputs,
setStepOutputs,
setContract,
setStart,
setWorkflowOutputBindings,
updateSetup,
setRoute,
validate,
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
normalizeOutcomes,
projectWorkflowSchema,
serializeWorkflowSchema,
} from "./workflow-contract-editor.js";
describe("workflow contract schema projection", () => {
it("round-trips nested fields while preserving root and unknown metadata", () => {
const schema = {
type: "object",
title: "Input",
$defs: { Tag: { type: "string" } },
"x-root": { kept: true },
required: ["request"],
properties: {
request: {
type: "object",
description: "Request details",
"x-field": "keep",
required: ["title"],
properties: {
title: { type: "string" },
tags: { type: "array", items: { type: "string", minLength: 1 } },
},
},
},
};
const projection = projectWorkflowSchema(schema);
expect(projection.rows[0]).toMatchObject({
name: "request",
type: "object",
required: true,
description: "Request details",
});
expect(projection.rows[0]?.children[1]?.item).toMatchObject({ type: "string" });
expect(serializeWorkflowSchema(projection, projection.rows)).toEqual(schema);
});
it("projects and preserves state defaults and reducer references", () => {
const schema = {
type: "object",
properties: {
issues: {
type: "array",
items: { type: "string" },
default: [],
reducer: { capability: "wf.std.append", config: { unique: true } },
},
},
};
const projection = projectWorkflowSchema(schema);
expect(projection.rows[0]).toMatchObject({ hasDefault: true, defaultValue: [] });
expect(serializeWorkflowSchema(projection, projection.rows, { state: true })).toEqual(schema);
});
it("keeps unsupported composition intact while another field changes", () => {
const schema = {
type: "object",
properties: {
choice: { oneOf: [{ type: "string" }, { type: "number" }], "x-note": "keep" },
title: { type: "string" },
},
};
const projection = projectWorkflowSchema(schema);
const choice = projection.rows[0];
expect(choice?.unsupportedReason).toMatch(/oneOf/);
const rows = projection.rows.map((row) =>
row.name === "title" ? { ...row, description: "Updated" } : row,
);
expect(serializeWorkflowSchema(projection, rows).properties).toEqual({
choice: schema.properties.choice,
title: { type: "string", description: "Updated" },
});
});
it("normalizes ordered outcomes without blanks or duplicates", () => {
expect(normalizeOutcomes(["ok", " ", "cancelled", "ok"])).toEqual(["ok", "cancelled"]);
});
});
@@ -0,0 +1,224 @@
import type { JsonObject } from "../domain/draft-workspace-models.js";
export type WorkflowContractKind = "input" | "state" | "output" | "outcomes";
export type WorkflowSchemaFieldType =
| "value"
| "string"
| "integer"
| "number"
| "boolean"
| "object"
| "array";
export type WorkflowSchemaFieldRow = {
readonly id: string;
readonly name: string;
readonly type: WorkflowSchemaFieldType;
readonly required: boolean;
readonly description: string;
readonly hasDefault: boolean;
readonly defaultValue: unknown;
readonly reducer: unknown;
readonly children: ReadonlyArray<WorkflowSchemaFieldRow>;
readonly item: WorkflowSchemaFieldRow | null;
readonly unsupportedReason: string | null;
readonly raw: JsonObject;
};
export type WorkflowSchemaProjection = {
readonly schema: JsonObject;
readonly rows: ReadonlyArray<WorkflowSchemaFieldRow>;
readonly rootUnsupportedReason: string | null;
};
export type WorkflowOutputBindingRow = {
readonly id: string;
readonly source: string;
readonly target: string;
readonly value?: unknown;
};
export type WorkflowContractPatch = {
readonly inputSchema?: JsonObject;
readonly stateSchema?: JsonObject;
readonly outputSchema?: JsonObject;
readonly outcomes?: ReadonlyArray<string>;
};
const MAX_DEPTH = 64;
const COMPOSITION_KEYS = ["oneOf", "anyOf", "allOf", "not", "if", "then", "else"] as const;
const isRecord = (value: unknown): value is JsonObject =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const copyJson = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(copyJson);
if (!isRecord(value)) return value;
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyJson(item)]));
};
const copyObject = (value: JsonObject): JsonObject => copyJson(value) as JsonObject;
const unsupportedReason = (schema: JsonObject, depth: number): string | null => {
if (depth >= MAX_DEPTH) return "Schema editor depth limit exceeded.";
const composition = COMPOSITION_KEYS.find((key) => Object.hasOwn(schema, key));
if (composition !== undefined) {
return `The field uses ${composition}, which the focused editor cannot represent.`;
}
if (Object.hasOwn(schema, "$ref")) {
return "The field uses a reference, which remains available in Advanced details.";
}
const type = schema.type;
if (
type !== undefined &&
type !== "string" &&
type !== "integer" &&
type !== "number" &&
type !== "boolean" &&
type !== "object" &&
type !== "array"
) return "The field type is not supported by the focused editor.";
return null;
};
const fieldType = (schema: JsonObject): WorkflowSchemaFieldType => {
const type = schema.type;
return type === "string" ||
type === "integer" ||
type === "number" ||
type === "boolean" ||
type === "object" ||
type === "array"
? type
: "value";
};
const projectField = (
name: string,
schema: unknown,
required: boolean,
path: string,
depth: number,
): WorkflowSchemaFieldRow => {
const raw = isRecord(schema) ? copyObject(schema) : {};
const reason = isRecord(schema)
? unsupportedReason(schema, depth)
: "The field schema is not an object.";
const type = fieldType(raw);
const requiredNames = new Set(
Array.isArray(raw.required)
? raw.required.filter((value): value is string => typeof value === "string")
: [],
);
const properties = isRecord(raw.properties) ? raw.properties : {};
const children = reason === null && type === "object"
? Object.entries(properties).map(([childName, childSchema]) =>
projectField(
childName,
childSchema,
requiredNames.has(childName),
`${path}.${childName}`,
depth + 1,
),
)
: [];
const item = reason === null && type === "array" && raw.items !== undefined
? projectField("item", raw.items, true, `${path}.items`, depth + 1)
: null;
return {
id: path,
name,
type,
required,
description: typeof raw.description === "string" ? raw.description : "",
hasDefault: Object.hasOwn(raw, "default"),
defaultValue: copyJson(raw.default),
reducer: copyJson(raw.reducer),
children,
item,
unsupportedReason: reason,
raw,
};
};
export const projectWorkflowSchema = (schema: unknown): WorkflowSchemaProjection => {
const root = isRecord(schema) ? copyObject(schema) : { type: "object", properties: {} };
const rootReason = unsupportedReason(root, 0);
const requiredNames = new Set(
Array.isArray(root.required)
? root.required.filter((value): value is string => typeof value === "string")
: [],
);
const properties = isRecord(root.properties) ? root.properties : {};
return {
schema: root,
rows: rootReason === null
? Object.entries(properties).map(([name, field]) =>
projectField(name, field, requiredNames.has(name), name, 1),
)
: [],
rootUnsupportedReason: rootReason,
};
};
const serializeField = (row: WorkflowSchemaFieldRow, state: boolean): JsonObject => {
if (row.unsupportedReason !== null) return copyObject(row.raw);
const next = copyObject(row.raw);
if (row.type === "value") delete next.type;
else next.type = row.type;
if (row.description.trim() === "") delete next.description;
else next.description = row.description;
if (row.type === "object") {
next.properties = Object.fromEntries(
row.children.map((child) => [child.name, serializeField(child, state)]),
);
const required = row.children.filter((child) => child.required).map((child) => child.name);
if (required.length === 0) delete next.required;
else next.required = required;
} else {
delete next.properties;
delete next.required;
}
if (row.type === "array") {
next.items = row.item === null ? {} : serializeField(row.item, state);
} else {
delete next.items;
}
if (state) {
if (row.hasDefault) next.default = copyJson(row.defaultValue);
else delete next.default;
if (row.reducer === undefined || row.reducer === null || row.reducer === "") delete next.reducer;
else next.reducer = copyJson(row.reducer);
}
return next;
};
export const serializeWorkflowSchema = (
projection: WorkflowSchemaProjection,
rows: ReadonlyArray<WorkflowSchemaFieldRow>,
options: { readonly state?: boolean } = {},
): JsonObject => {
if (projection.rootUnsupportedReason !== null) return copyObject(projection.schema);
const next = copyObject(projection.schema);
next.type = "object";
next.properties = Object.fromEntries(
rows.map((row) => [row.name, serializeField(row, options.state === true)]),
);
const required = rows.filter((row) => row.required).map((row) => row.name);
if (required.length === 0) delete next.required;
else next.required = required;
return next;
};
export const normalizeOutcomes = (values: ReadonlyArray<string>): ReadonlyArray<string> => {
const seen = new Set<string>();
const outcomes: string[] = [];
for (const value of values) {
const normalized = value.trim();
if (normalized === "" || seen.has(normalized)) continue;
seen.add(normalized);
outcomes.push(normalized);
}
return outcomes;
};
@@ -13,6 +13,9 @@ import {
type SetDraftRouteInput,
type SetStepInputBindingsInput,
type SetStepOutputBindingsInput,
type SetWorkflowContractInput,
type SetWorkflowOutputBindingsInput,
type SetWorkflowStartInput,
type UpdateCapabilityStepInput,
} from "./draft-workspace-models.js";
import { createDraftAuthoringClient } from "./draft-authoring-client.js";
@@ -478,6 +481,59 @@ describe("DraftAuthoringClient", () => {
);
});
it("lowers focused workflow contract operations with exact copied payloads", async () => {
const { executor: writeExecutor, run } = createExecutor();
const client = createDraftAuthoringClient(writeExecutor);
const contract = {
workspaceId: " report ",
revision: 7,
inputSchema: { type: "object", properties: { title: { type: "string" } } },
outcomes: ["ok", "cancelled"],
} satisfies SetWorkflowContractInput;
const start = { workspaceId: "report", revision: 8, stepId: " collect " } satisfies SetWorkflowStartInput;
const outputs = {
workspaceId: "report",
revision: 9,
bindings: [
{ path: { root: "state", parts: ["report"] }, target: "text" },
{ target: "format", value: { kind: "markdown" } },
],
} satisfies SetWorkflowOutputBindingsInput;
await client.setContract(contract);
await client.setStart(start);
await client.setWorkflowOutputBindings(outputs);
expect(run).toHaveBeenNthCalledWith(1, "workflow.draft_workspaces.set_contract", {
workspace_id: "report",
revision: 7,
input_schema: contract.inputSchema,
outcomes: ["ok", "cancelled"],
}, decodeDraftWorkspace);
expect(run).toHaveBeenNthCalledWith(2, "workflow.draft_workspaces.set_start", {
workspace_id: "report",
revision: 8,
step_id: "collect",
}, decodeDraftWorkspace);
expect(run).toHaveBeenNthCalledWith(3, "workflow.draft_workspaces.set_workflow_output_bindings", {
workspace_id: "report",
revision: 9,
bindings: outputs.bindings,
}, decodeDraftWorkspace);
(contract.inputSchema.properties as Record<string, unknown>).title = { type: "number" };
(outputs.bindings[0]!.path as { parts: string[] }).parts[0] = "mutated";
expect(run.mock.calls[0]?.[1]).toEqual(expect.objectContaining({
input_schema: { type: "object", properties: { title: { type: "string" } } },
}));
expect(run.mock.calls[2]?.[1]).toEqual(expect.objectContaining({
bindings: [
{ path: { root: "state", parts: ["report"] }, target: "text" },
{ target: "format", value: { kind: "markdown" } },
],
}));
});
it("preserves recursive expression bindings when sending node-local inputs", async () => {
const { executor: writeExecutor, run } = createExecutor();
const client = createDraftAuthoringClient(writeExecutor);
@@ -14,6 +14,9 @@ import {
type SetDraftRouteInput,
type SetStepInputBindingsInput,
type SetStepOutputBindingsInput,
type SetWorkflowContractInput,
type SetWorkflowOutputBindingsInput,
type SetWorkflowStartInput,
type UpdateCapabilityStepInput,
} from "./draft-workspace-models.js";
import { ConsoleClientError } from "./errors.js";
@@ -26,6 +29,9 @@ export interface DraftAuthoringClient {
updateCapabilityStep(input: UpdateCapabilityStepInput): Promise<DraftWorkspace>;
setStepInputBindings(input: SetStepInputBindingsInput): Promise<DraftWorkspace>;
setStepOutputBindings(input: SetStepOutputBindingsInput): Promise<DraftWorkspace>;
setContract(input: SetWorkflowContractInput): Promise<DraftWorkspace>;
setStart(input: SetWorkflowStartInput): Promise<DraftWorkspace>;
setWorkflowOutputBindings(input: SetWorkflowOutputBindingsInput): Promise<DraftWorkspace>;
setRoute(input: SetDraftRouteInput): Promise<DraftWorkspace>;
validate(workspaceId: string): Promise<DraftWorkspace>;
}
@@ -271,6 +277,45 @@ export const createDraftAuthoringClient = (
);
},
setContract: async (input) => {
const operation = "workflow.draft_workspaces.set_contract";
const params: Record<string, unknown> = {
workspace_id: requireIdentifier(operation, input.workspaceId, "workspace id"),
revision: input.revision,
};
ifDefined(params, "input_schema", input.inputSchema === undefined ? undefined : copyJsonValue(input.inputSchema));
ifDefined(params, "state_schema", input.stateSchema === undefined ? undefined : copyJsonValue(input.stateSchema));
ifDefined(params, "output_schema", input.outputSchema === undefined ? undefined : copyJsonValue(input.outputSchema));
ifDefined(params, "outcomes", input.outcomes === undefined ? undefined : [...input.outcomes]);
return executor.run(operation, params, decodeDraftWorkspace);
},
setStart: async (input) => {
const operation = "workflow.draft_workspaces.set_start";
return executor.run(
operation,
{
workspace_id: requireIdentifier(operation, input.workspaceId, "workspace id"),
revision: input.revision,
step_id: requireIdentifier(operation, input.stepId, "step id"),
},
decodeDraftWorkspace,
);
},
setWorkflowOutputBindings: async (input) => {
const operation = "workflow.draft_workspaces.set_workflow_output_bindings";
return executor.run(
operation,
{
workspace_id: requireIdentifier(operation, input.workspaceId, "workspace id"),
revision: input.revision,
bindings: copyInputBindings(input.bindings),
},
decodeDraftWorkspace,
);
},
setRoute: async (input) => {
const operation = "workflow.draft_workspaces.set_route";
return executor.run(
@@ -92,6 +92,27 @@ export type SetStepOutputBindingsInput = {
readonly bindings: ReadonlyArray<OutputBinding>;
};
export type SetWorkflowContractInput = {
readonly workspaceId: string;
readonly revision: number;
readonly inputSchema?: JsonObject;
readonly stateSchema?: JsonObject;
readonly outputSchema?: JsonObject;
readonly outcomes?: ReadonlyArray<string>;
};
export type SetWorkflowStartInput = {
readonly workspaceId: string;
readonly revision: number;
readonly stepId: string;
};
export type SetWorkflowOutputBindingsInput = {
readonly workspaceId: string;
readonly revision: number;
readonly bindings: ReadonlyArray<InputBinding>;
};
export type CreateEmptyDraftInput = {
readonly workspaceId: string;
readonly name: string;
@@ -99,6 +99,9 @@ const authoringClient: DraftAuthoringClient = {
updateCapabilityStep: vi.fn(),
setStepInputBindings: vi.fn(),
setStepOutputBindings: vi.fn(),
setContract: vi.fn(),
setStart: vi.fn(),
setWorkflowOutputBindings: vi.fn(),
setRoute: vi.fn(),
validate: vi.fn(),
};
@@ -69,6 +69,9 @@ const authoringClient: DraftAuthoringClient = {
updateCapabilityStep: vi.fn(),
setStepInputBindings: vi.fn(),
setStepOutputBindings: vi.fn(),
setContract: vi.fn(),
setStart: vi.fn(),
setWorkflowOutputBindings: vi.fn(),
setRoute: vi.fn(),
validate: vi.fn(),
};