feat: edit workflow contracts in console
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user