feat: edit workflow contracts in console
This commit is contained in:
@@ -1978,6 +1978,89 @@ tbody tr:hover {
|
|||||||
margin: 0;
|
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 {
|
.selected-capability-inspector {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ const controller = {
|
|||||||
updateCapability: vi.fn(),
|
updateCapability: vi.fn(),
|
||||||
setStepInputs: vi.fn(),
|
setStepInputs: vi.fn(),
|
||||||
setStepOutputs: vi.fn(),
|
setStepOutputs: vi.fn(),
|
||||||
|
setContract: vi.fn(),
|
||||||
|
setStart: vi.fn(),
|
||||||
|
setWorkflowOutputBindings: vi.fn(),
|
||||||
updateSetup: vi.fn(),
|
updateSetup: vi.fn(),
|
||||||
setRoute: vi.fn(),
|
setRoute: vi.fn(),
|
||||||
validate: vi.fn(),
|
validate: vi.fn(),
|
||||||
@@ -115,6 +118,30 @@ const controller = {
|
|||||||
} satisfies DraftAuthoringController;
|
} satisfies DraftAuthoringController;
|
||||||
|
|
||||||
describe("ContextInspector", () => {
|
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", () => {
|
it("binds the inspected capability schema and canonical node values", () => {
|
||||||
render(
|
render(
|
||||||
<ContextInspector
|
<ContextInspector
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { CapabilityNodeForm } from "./CapabilityNodeForm.js";
|
|||||||
import { SelectedCapabilityInspector } from "./SelectedCapabilityInspector.js";
|
import { SelectedCapabilityInspector } from "./SelectedCapabilityInspector.js";
|
||||||
import { RouteForm } from "./RouteForm.js";
|
import { RouteForm } from "./RouteForm.js";
|
||||||
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
|
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
|
||||||
|
import { useAuthoringContract } from "./useAuthoringContract.js";
|
||||||
|
import { WorkflowContractInspector } from "./WorkflowContractInspector.js";
|
||||||
|
|
||||||
type ContextInspectorProps = {
|
type ContextInspectorProps = {
|
||||||
readonly draft: DraftWorkspace;
|
readonly draft: DraftWorkspace;
|
||||||
@@ -137,6 +139,36 @@ const DeferredActions = () => (
|
|||||||
</section>
|
</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 = ({
|
export const ContextInspector = ({
|
||||||
draft,
|
draft,
|
||||||
capabilities,
|
capabilities,
|
||||||
@@ -225,21 +257,7 @@ export const ContextInspector = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
} else if (selection.kind === "contract") {
|
} else if (selection.kind === "contract") {
|
||||||
const contractNode = graph.nodes.find(
|
content = <ContractInspector contract={selection.contract} controller={controller} draft={draft} />;
|
||||||
(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>
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const node = graph.nodes.find((candidate) => candidate.id === selection.nodeId);
|
const node = graph.nodes.find((candidate) => candidate.id === selection.nodeId);
|
||||||
content = (
|
content = (
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ const authoringClient: DraftAuthoringClient = {
|
|||||||
updateCapabilityStep: vi.fn(),
|
updateCapabilityStep: vi.fn(),
|
||||||
setStepInputBindings: vi.fn(),
|
setStepInputBindings: vi.fn(),
|
||||||
setStepOutputBindings: vi.fn(),
|
setStepOutputBindings: vi.fn(),
|
||||||
|
setContract: vi.fn(),
|
||||||
|
setStart: vi.fn(),
|
||||||
|
setWorkflowOutputBindings: vi.fn(),
|
||||||
setRoute: vi.fn(),
|
setRoute: vi.fn(),
|
||||||
validate: vi.fn(),
|
validate: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -142,6 +142,9 @@ const controllerFor = (workspace: DraftWorkspace): DraftAuthoringController => (
|
|||||||
updateCapability: vi.fn(),
|
updateCapability: vi.fn(),
|
||||||
setStepInputs: vi.fn(),
|
setStepInputs: vi.fn(),
|
||||||
setStepOutputs: vi.fn(),
|
setStepOutputs: vi.fn(),
|
||||||
|
setContract: vi.fn(),
|
||||||
|
setStart: vi.fn(),
|
||||||
|
setWorkflowOutputBindings: vi.fn(),
|
||||||
updateSetup: vi.fn(),
|
updateSetup: vi.fn(),
|
||||||
setRoute: vi.fn(),
|
setRoute: vi.fn(),
|
||||||
validate: 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 updateCapabilityStep = vi.fn<DraftAuthoringClient["updateCapabilityStep"]>();
|
||||||
const setStepInputBindings = vi.fn<DraftAuthoringClient["setStepInputBindings"]>();
|
const setStepInputBindings = vi.fn<DraftAuthoringClient["setStepInputBindings"]>();
|
||||||
const setStepOutputBindings = vi.fn<DraftAuthoringClient["setStepOutputBindings"]>();
|
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 setRoute = vi.fn<DraftAuthoringClient["setRoute"]>();
|
||||||
const validate = vi.fn<DraftAuthoringClient["validate"]>();
|
const validate = vi.fn<DraftAuthoringClient["validate"]>();
|
||||||
const list = vi.fn<DraftWorkspaceClient["list"]>();
|
const list = vi.fn<DraftWorkspaceClient["list"]>();
|
||||||
@@ -75,6 +78,9 @@ const authoringClient = {
|
|||||||
updateCapabilityStep,
|
updateCapabilityStep,
|
||||||
setStepInputBindings,
|
setStepInputBindings,
|
||||||
setStepOutputBindings,
|
setStepOutputBindings,
|
||||||
|
setContract,
|
||||||
|
setStart,
|
||||||
|
setWorkflowOutputBindings,
|
||||||
setRoute,
|
setRoute,
|
||||||
validate,
|
validate,
|
||||||
} satisfies DraftAuthoringClient;
|
} satisfies DraftAuthoringClient;
|
||||||
@@ -121,6 +127,62 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("useDraftAuthoring", () => {
|
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 () => {
|
it("adds an unconnected capability without inventing route information", async () => {
|
||||||
const initial = workspace();
|
const initial = workspace();
|
||||||
const canonical = workspace({
|
const canonical = workspace({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "../domain/draft-workspace-client.js";
|
} from "../domain/draft-workspace-client.js";
|
||||||
import type {
|
import type {
|
||||||
DraftWorkspace,
|
DraftWorkspace,
|
||||||
|
InputBinding,
|
||||||
OutputBinding,
|
OutputBinding,
|
||||||
StepInputBinding,
|
StepInputBinding,
|
||||||
} from "../domain/draft-workspace-models.js";
|
} from "../domain/draft-workspace-models.js";
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
type WorkbenchSelection,
|
type WorkbenchSelection,
|
||||||
} from "./authoring-graph.js";
|
} from "./authoring-graph.js";
|
||||||
import type { CapabilitySetupPatch } from "./selected-step-dataflow.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";
|
export type DraftAuthoringPhase = "idle" | "saving" | "conflict" | "error";
|
||||||
|
|
||||||
@@ -38,6 +40,9 @@ export interface DraftAuthoringController {
|
|||||||
readonly updateCapability: (input: CapabilityNodeFormValue) => Promise<void>;
|
readonly updateCapability: (input: CapabilityNodeFormValue) => Promise<void>;
|
||||||
readonly setStepInputs: (bindings: ReadonlyArray<StepInputBinding>) => Promise<void>;
|
readonly setStepInputs: (bindings: ReadonlyArray<StepInputBinding>) => Promise<void>;
|
||||||
readonly setStepOutputs: (bindings: ReadonlyArray<OutputBinding>) => 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 updateSetup: (patch: CapabilitySetupPatch) => Promise<void>;
|
||||||
readonly setRoute: (input: RouteFormValue) => Promise<void>;
|
readonly setRoute: (input: RouteFormValue) => Promise<void>;
|
||||||
readonly validate: () => Promise<void>;
|
readonly validate: () => Promise<void>;
|
||||||
@@ -108,6 +113,9 @@ type LastSubmission =
|
|||||||
readonly targetStepId: string;
|
readonly targetStepId: string;
|
||||||
readonly bindings: ReadonlyArray<OutputBinding>;
|
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;
|
| null;
|
||||||
|
|
||||||
type MutationOptions = {
|
type MutationOptions = {
|
||||||
@@ -183,6 +191,42 @@ const copyOutputBindings = (
|
|||||||
bindings: ReadonlyArray<OutputBinding>,
|
bindings: ReadonlyArray<OutputBinding>,
|
||||||
): ReadonlyArray<OutputBinding> => bindings.map(copyOutputBinding);
|
): 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 => ({
|
const copySetupPatch = (patch: CapabilitySetupPatch): CapabilitySetupPatch => ({
|
||||||
...(patch.description !== undefined ? { description: patch.description } : {}),
|
...(patch.description !== undefined ? { description: patch.description } : {}),
|
||||||
...(patch.retry !== undefined ? { retry: patch.retry } : {}),
|
...(patch.retry !== undefined ? { retry: patch.retry } : {}),
|
||||||
@@ -624,6 +668,66 @@ export const useDraftAuthoring = ({
|
|||||||
[missingSelectedStep, selectedStepId, submitStepOutputs],
|
[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(
|
const setRoute = useCallback(
|
||||||
(input: RouteFormValue): Promise<void> => {
|
(input: RouteFormValue): Promise<void> => {
|
||||||
return runMutation(
|
return runMutation(
|
||||||
@@ -740,7 +844,10 @@ export const useDraftAuthoring = ({
|
|||||||
if (last.kind === "route") return setRoute(last.input);
|
if (last.kind === "route") return setRoute(last.input);
|
||||||
if (last.kind === "setup") return submitSetup(last.targetStepId, last.patch, true);
|
if (last.kind === "setup") return submitSetup(last.targetStepId, last.patch, true);
|
||||||
if (last.kind === "inputs") return submitStepInputs(last.targetStepId, last.bindings, 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,
|
setRoute,
|
||||||
submitCapabilityAdd,
|
submitCapabilityAdd,
|
||||||
@@ -748,6 +855,9 @@ export const useDraftAuthoring = ({
|
|||||||
submitSetup,
|
submitSetup,
|
||||||
submitStepInputs,
|
submitStepInputs,
|
||||||
submitStepOutputs,
|
submitStepOutputs,
|
||||||
|
submitContract,
|
||||||
|
submitStart,
|
||||||
|
submitWorkflowOutputBindings,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const rememberCapabilityForm = useCallback(
|
const rememberCapabilityForm = useCallback(
|
||||||
@@ -792,6 +902,9 @@ export const useDraftAuthoring = ({
|
|||||||
updateCapability,
|
updateCapability,
|
||||||
setStepInputs,
|
setStepInputs,
|
||||||
setStepOutputs,
|
setStepOutputs,
|
||||||
|
setContract,
|
||||||
|
setStart,
|
||||||
|
setWorkflowOutputBindings,
|
||||||
updateSetup,
|
updateSetup,
|
||||||
setRoute,
|
setRoute,
|
||||||
validate,
|
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 SetDraftRouteInput,
|
||||||
type SetStepInputBindingsInput,
|
type SetStepInputBindingsInput,
|
||||||
type SetStepOutputBindingsInput,
|
type SetStepOutputBindingsInput,
|
||||||
|
type SetWorkflowContractInput,
|
||||||
|
type SetWorkflowOutputBindingsInput,
|
||||||
|
type SetWorkflowStartInput,
|
||||||
type UpdateCapabilityStepInput,
|
type UpdateCapabilityStepInput,
|
||||||
} from "./draft-workspace-models.js";
|
} from "./draft-workspace-models.js";
|
||||||
import { createDraftAuthoringClient } from "./draft-authoring-client.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 () => {
|
it("preserves recursive expression bindings when sending node-local inputs", async () => {
|
||||||
const { executor: writeExecutor, run } = createExecutor();
|
const { executor: writeExecutor, run } = createExecutor();
|
||||||
const client = createDraftAuthoringClient(writeExecutor);
|
const client = createDraftAuthoringClient(writeExecutor);
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
type SetDraftRouteInput,
|
type SetDraftRouteInput,
|
||||||
type SetStepInputBindingsInput,
|
type SetStepInputBindingsInput,
|
||||||
type SetStepOutputBindingsInput,
|
type SetStepOutputBindingsInput,
|
||||||
|
type SetWorkflowContractInput,
|
||||||
|
type SetWorkflowOutputBindingsInput,
|
||||||
|
type SetWorkflowStartInput,
|
||||||
type UpdateCapabilityStepInput,
|
type UpdateCapabilityStepInput,
|
||||||
} from "./draft-workspace-models.js";
|
} from "./draft-workspace-models.js";
|
||||||
import { ConsoleClientError } from "./errors.js";
|
import { ConsoleClientError } from "./errors.js";
|
||||||
@@ -26,6 +29,9 @@ export interface DraftAuthoringClient {
|
|||||||
updateCapabilityStep(input: UpdateCapabilityStepInput): Promise<DraftWorkspace>;
|
updateCapabilityStep(input: UpdateCapabilityStepInput): Promise<DraftWorkspace>;
|
||||||
setStepInputBindings(input: SetStepInputBindingsInput): Promise<DraftWorkspace>;
|
setStepInputBindings(input: SetStepInputBindingsInput): Promise<DraftWorkspace>;
|
||||||
setStepOutputBindings(input: SetStepOutputBindingsInput): 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>;
|
setRoute(input: SetDraftRouteInput): Promise<DraftWorkspace>;
|
||||||
validate(workspaceId: string): 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) => {
|
setRoute: async (input) => {
|
||||||
const operation = "workflow.draft_workspaces.set_route";
|
const operation = "workflow.draft_workspaces.set_route";
|
||||||
return executor.run(
|
return executor.run(
|
||||||
|
|||||||
@@ -92,6 +92,27 @@ export type SetStepOutputBindingsInput = {
|
|||||||
readonly bindings: ReadonlyArray<OutputBinding>;
|
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 = {
|
export type CreateEmptyDraftInput = {
|
||||||
readonly workspaceId: string;
|
readonly workspaceId: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ const authoringClient: DraftAuthoringClient = {
|
|||||||
updateCapabilityStep: vi.fn(),
|
updateCapabilityStep: vi.fn(),
|
||||||
setStepInputBindings: vi.fn(),
|
setStepInputBindings: vi.fn(),
|
||||||
setStepOutputBindings: vi.fn(),
|
setStepOutputBindings: vi.fn(),
|
||||||
|
setContract: vi.fn(),
|
||||||
|
setStart: vi.fn(),
|
||||||
|
setWorkflowOutputBindings: vi.fn(),
|
||||||
setRoute: vi.fn(),
|
setRoute: vi.fn(),
|
||||||
validate: vi.fn(),
|
validate: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ const authoringClient: DraftAuthoringClient = {
|
|||||||
updateCapabilityStep: vi.fn(),
|
updateCapabilityStep: vi.fn(),
|
||||||
setStepInputBindings: vi.fn(),
|
setStepInputBindings: vi.fn(),
|
||||||
setStepOutputBindings: vi.fn(),
|
setStepOutputBindings: vi.fn(),
|
||||||
|
setContract: vi.fn(),
|
||||||
|
setStart: vi.fn(),
|
||||||
|
setWorkflowOutputBindings: vi.fn(),
|
||||||
setRoute: vi.fn(),
|
setRoute: vi.fn(),
|
||||||
validate: vi.fn(),
|
validate: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user