diff --git a/web/apps/console/src/styles/global.css b/web/apps/console/src/styles/global.css index a99432e8..a617e71c 100644 --- a/web/apps/console/src/styles/global.css +++ b/web/apps/console/src/styles/global.css @@ -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; diff --git a/web/apps/console/src/workspace/authoring/ContextInspector.test.tsx b/web/apps/console/src/workspace/authoring/ContextInspector.test.tsx index 5632594b..4595c884 100644 --- a/web/apps/console/src/workspace/authoring/ContextInspector.test.tsx +++ b/web/apps/console/src/workspace/authoring/ContextInspector.test.tsx @@ -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( + , + ); + + 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( ( ); +const ContractInspector = ({ + contract, + controller, + draft, +}: { + readonly contract: Extract["contract"]; + readonly controller: DraftAuthoringController; + readonly draft: DraftWorkspace; +}) => { + const authoringContract = useAuthoringContract({ + workspaceId: draft.workspaceId, + revision: draft.revision, + selectedStepId: null, + }); + return ( + <> + {authoringContract.phase === "loading" &&

Loading authoring choices...

} + {authoringContract.phase === "error" && ( +

{authoringContract.message ?? "Authoring choices failed to load. Advanced repair remains available."}

+ )} + + + ); +}; + 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 = ( -
-

Workflow projection

-

{title} contract

-

{contractNode?.data.summary ?? "No contract fields are declared."}

-

This read-only projection is derived from the canonical draft.

-
- ); + content = ; } else { const node = graph.nodes.find((candidate) => candidate.id === selection.nodeId); content = ( diff --git a/web/apps/console/src/workspace/authoring/CreateDraftDialog.test.tsx b/web/apps/console/src/workspace/authoring/CreateDraftDialog.test.tsx index f10ea1d2..4c57911b 100644 --- a/web/apps/console/src/workspace/authoring/CreateDraftDialog.test.tsx +++ b/web/apps/console/src/workspace/authoring/CreateDraftDialog.test.tsx @@ -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(), }; diff --git a/web/apps/console/src/workspace/authoring/SelectedCapabilityInspector.test.tsx b/web/apps/console/src/workspace/authoring/SelectedCapabilityInspector.test.tsx index 619c3ea9..55c0a429 100644 --- a/web/apps/console/src/workspace/authoring/SelectedCapabilityInspector.test.tsx +++ b/web/apps/console/src/workspace/authoring/SelectedCapabilityInspector.test.tsx @@ -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(), diff --git a/web/apps/console/src/workspace/authoring/WorkflowContractInspector.test.tsx b/web/apps/console/src/workspace/authoring/WorkflowContractInspector.test.tsx new file mode 100644 index 00000000..2b45d571 --- /dev/null +++ b/web/apps/console/src/workspace/authoring/WorkflowContractInspector.test.tsx @@ -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(); + + 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(); + + 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(); + + 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"] }); + }); +}); diff --git a/web/apps/console/src/workspace/authoring/WorkflowContractInspector.tsx b/web/apps/console/src/workspace/authoring/WorkflowContractInspector.tsx new file mode 100644 index 00000000..821ccac7 --- /dev/null +++ b/web/apps/console/src/workspace/authoring/WorkflowContractInspector.tsx @@ -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 => { + 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) => ( +
+ {controller.dirty && controller.phase === "idle" && Unsaved changes} + {controller.phase === "saving" && Saving canonical draft...} + {draft.status === "invalid" && controller.phase === "idle" && Saved with validation diagnostics} +
+); + +const EntryStepForm = ({ controller, draft, inventory }: WorkflowContractInspectorProps) => { + const [stepId, setStepId] = useState(() => pathText(draft.summary.start)); + return ( +
{ + event.preventDefault(); + if (stepId !== "") void controller.setStart(stepId); + }}> + + +
+ ); +}; + +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): void => { + setRows((current) => current.map((row) => row.id === id ? { ...row, ...patch } : row)); + controller.markDirty(); + }; + const submit = (event: FormEvent): 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 ( +
+

Final output bindings

+ {rows.map((row, index) => ( +
+ Output binding {index + 1} + + {row.kind === "path" ? ( + update(row.id, { source })} options={sources} uses="workflow_output" value={row.source} /> + ) :