diff --git a/contracts/workflow-api.manifest.json b/contracts/workflow-api.manifest.json index 0331a7ac..8bf4f81f 100644 --- a/contracts/workflow-api.manifest.json +++ b/contracts/workflow-api.manifest.json @@ -246,6 +246,198 @@ ], "type": "object" }, + "AuthoringContractInventoryPayload": { + "description": "Revision-scoped readable sources and writable authoring targets.", + "properties": { + "entry_steps": { + "items": { + "$ref": "#/components/schemas/AuthoringStepContractPayload" + }, + "type": "array" + }, + "readable_sources": { + "items": { + "$ref": "#/components/schemas/AuthoringPathOptionPayload" + }, + "type": "array" + }, + "revision": { + "type": "integer" + }, + "selected_step_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "state_targets": { + "items": { + "$ref": "#/components/schemas/AuthoringPathOptionPayload" + }, + "type": "array" + }, + "step_input_targets": { + "items": { + "$ref": "#/components/schemas/AuthoringPathOptionPayload" + }, + "type": "array" + }, + "step_output_sources": { + "items": { + "$ref": "#/components/schemas/AuthoringPathOptionPayload" + }, + "type": "array" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + }, + "workflow_outcomes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "workflow_output_targets": { + "items": { + "$ref": "#/components/schemas/AuthoringPathOptionPayload" + }, + "type": "array" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "revision", + "selected_step_id", + "readable_sources", + "step_input_targets", + "step_output_sources", + "state_targets", + "workflow_output_targets", + "entry_steps", + "workflow_outcomes", + "warnings" + ], + "type": "object" + }, + "AuthoringPathAvailability": { + "enum": [ + "available", + "conditional" + ], + "type": "string" + }, + "AuthoringPathOptionPayload": { + "description": "One schema-derived source or target available to an author.", + "properties": { + "availability": { + "$ref": "#/components/schemas/AuthoringPathAvailability" + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "origin": { + "$ref": "#/components/schemas/AuthoringPathOrigin" + }, + "path": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "schema": { + "$ref": "#/components/schemas/JsonObject" + }, + "uses": { + "items": { + "$ref": "#/components/schemas/AuthoringPathUse" + }, + "type": "array" + } + }, + "required": [ + "path", + "label", + "origin", + "schema", + "required", + "availability", + "uses" + ], + "type": "object" + }, + "AuthoringPathOrigin": { + "enum": [ + "workflow_input", + "workflow_state", + "runtime_context", + "step_input", + "step_output", + "workflow_output" + ], + "type": "string" + }, + "AuthoringPathUse": { + "enum": [ + "step_input", + "step_output_source", + "state_target", + "workflow_output" + ], + "type": "string" + }, + "AuthoringStepContractPayload": { + "description": "Compact executable-step choice used by authoring inventories.", + "properties": { + "description": { + "type": "string" + }, + "input_targets": { + "items": { + "$ref": "#/components/schemas/AuthoringPathOptionPayload" + }, + "type": "array" + }, + "label": { + "type": "string" + }, + "outcomes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "output_sources": { + "items": { + "$ref": "#/components/schemas/AuthoringPathOptionPayload" + }, + "type": "array" + }, + "step_id": { + "type": "string" + } + }, + "required": [ + "step_id", + "label" + ], + "type": "object" + }, "BinaryCondition": { "description": "Condition that compares two operands.", "properties": { @@ -6953,6 +7145,65 @@ } } }, + { + "action": "inspect_authoring_contract", + "errors": [ + { + "$ref": "#/components/errors/5000" + } + ], + "method": "workflow.draft_workspaces.inspect_authoring_contract", + "namespace": [ + "workflow", + "draft_workspaces" + ], + "params": [ + { + "name": "workspace_id", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "name": "revision", + "required": true, + "schema": { + "minimum": 1, + "type": "integer" + } + }, + { + "name": "selected_step_id", + "required": false, + "schema": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + } + } + ], + "result": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthoringContractInventoryPayload" + }, + { + "$ref": "#/components/schemas/DraftWorkspaceResult" + } + ] + } + } + }, { "action": "list", "errors": [ diff --git a/src/wf_contract_manifest/normalize.py b/src/wf_contract_manifest/normalize.py index 6b8642e0..59585503 100644 --- a/src/wf_contract_manifest/normalize.py +++ b/src/wf_contract_manifest/normalize.py @@ -255,9 +255,7 @@ def manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest: params.append( { "name": _string(parameter.get("name"), f"{parameter_path}.name"), - "required": _boolean( - raw_required, f"{parameter_path}.required" - ), + "required": _boolean(raw_required, f"{parameter_path}.required"), "schema": _schema( parameter.get("schema"), f"{parameter_path}.schema" ), @@ -271,12 +269,21 @@ def manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest: f"{result_path}.schema", "missing success result schema" ) raw_result_schema = result["schema"] - if not isinstance(raw_result_schema, Mapping) or ( - set(raw_result_schema) != {"$ref"} - or not ( - isinstance(raw_result_schema.get("$ref"), str) - and raw_result_schema["$ref"].startswith("#/components/schemas/") + if not isinstance(raw_result_schema, Mapping): + raise ManifestError( + f"{result_path}.schema", + "success result must be a schema object", ) + named_result = ( + set(raw_result_schema) == {"$ref"} + and isinstance(raw_result_schema.get("$ref"), str) + and raw_result_schema["$ref"].startswith("#/components/schemas/") + ) + # This inspection RPC returns either its inventory or the standard + # revision-conflict workspace payload, so its OpenRPC result is a union. + if ( + not named_result + and method_name != "workflow.draft_workspaces.inspect_authoring_contract" ): raise ManifestError( f"{result_path}.schema", diff --git a/tests/wf_contract_manifest/test_generate.py b/tests/wf_contract_manifest/test_generate.py index 33470df1..f0f6ed56 100644 --- a/tests/wf_contract_manifest/test_generate.py +++ b/tests/wf_contract_manifest/test_generate.py @@ -89,14 +89,24 @@ def test_generates_the_complete_real_workflow_contract() -> None: manifest = generate_manifest() schemas = manifest["components"]["schemas"] - assert len(manifest["operations"]) == 70 - assert len({operation["method"] for operation in manifest["operations"]}) == 70 - assert len(schemas) == 134 + assert len(manifest["operations"]) == 71 + assert len({operation["method"] for operation in manifest["operations"]}) == 71 + assert len(schemas) == 140 assert len(manifest["components"]["errors"]) == 1 assert all( set(operation["result"]["schema"]) == {"$ref"} + or operation["method"] == "workflow.draft_workspaces.inspect_authoring_contract" for operation in manifest["operations"] ) + inspection = next( + operation + for operation in manifest["operations"] + if operation["method"] == "workflow.draft_workspaces.inspect_authoring_contract" + ) + assert inspection["result"]["schema"]["anyOf"] == [ + {"$ref": "#/components/schemas/AuthoringContractInventoryPayload"}, + {"$ref": "#/components/schemas/DraftWorkspaceResult"}, + ] assert {name for name in UNION_RESULTS if "anyOf" in schemas[name]} == UNION_RESULTS @@ -175,6 +185,17 @@ def test_manifest_contains_the_two_focused_step_binding_operations() -> None: } <= methods +def test_manifest_contains_the_four_browser_authoring_operations() -> None: + methods = {operation["method"] for operation in generate_manifest()["operations"]} + + assert { + "workflow.draft_workspaces.inspect_authoring_contract", + "workflow.draft_workspaces.set_contract", + "workflow.draft_workspaces.set_start", + "workflow.draft_workspaces.set_workflow_output_bindings", + } <= methods + + def test_generated_contract_preserves_security_and_extension_boundaries() -> None: manifest = generate_manifest() schemas = manifest["components"]["schemas"] diff --git a/web/apps/console/src/connection/contracts.ts b/web/apps/console/src/connection/contracts.ts index 094a74ba..871c353d 100644 --- a/web/apps/console/src/connection/contracts.ts +++ b/web/apps/console/src/connection/contracts.ts @@ -48,14 +48,18 @@ const OperationNameSchema = v.union([ v.literal("workflow.capabilities.call"), v.literal("workflow.draft_workspaces.list"), v.literal("workflow.draft_workspaces.get"), + v.literal("workflow.draft_workspaces.inspect_authoring_contract"), v.literal("workflow.draft_workspaces.create_empty"), v.literal("workflow.draft_workspaces.create_from_capability"), v.literal("workflow.draft_workspaces.add_step_from_capability"), v.literal("workflow.draft_workspaces.update_capability_step"), v.literal("workflow.draft_workspaces.set_route"), + v.literal("workflow.draft_workspaces.set_contract"), + v.literal("workflow.draft_workspaces.set_start"), v.literal("workflow.draft_workspaces.set_step_input_bindings"), v.literal("workflow.draft_workspaces.set_step_output_bindings"), v.literal("workflow.draft_workspaces.validate"), + v.literal("workflow.draft_workspaces.set_workflow_output_bindings"), v.literal("workflow.artifacts.list"), v.literal("workflow.artifacts.inspect"), v.literal("workflow.deployments.list"), diff --git a/web/apps/server/src/app.test.ts b/web/apps/server/src/app.test.ts index 85f70e73..309caae6 100644 --- a/web/apps/server/src/app.test.ts +++ b/web/apps/server/src/app.test.ts @@ -260,6 +260,10 @@ describe("POST /api/rpc", () => { operation: "workflow.draft_workspaces.get", params: { workspace_id: "draft-1" }, }, + { + operation: "workflow.draft_workspaces.inspect_authoring_contract", + params: { workspace_id: "draft-1", revision: 1 }, + }, { operation: "workflow.draft_workspaces.create_empty", params: { workspace_id: "draft-1", name: "draft.workflow" }, @@ -299,6 +303,14 @@ describe("POST /api/rpc", () => { target: "__end__", }, }, + { + operation: "workflow.draft_workspaces.set_contract", + params: { workspace_id: "draft-1", revision: 1, outcomes: ["ok"] }, + }, + { + operation: "workflow.draft_workspaces.set_start", + params: { workspace_id: "draft-1", revision: 1, step_id: "echo" }, + }, { operation: "workflow.draft_workspaces.set_step_input_bindings", params: { @@ -321,6 +333,14 @@ describe("POST /api/rpc", () => { operation: "workflow.draft_workspaces.validate", params: { workspace_id: "draft-1" }, }, + { + operation: "workflow.draft_workspaces.set_workflow_output_bindings", + params: { + workspace_id: "draft-1", + revision: 1, + bindings: [{ source: "result", target: "state.result" }], + }, + }, { operation: "workflow.runs.start", params: {} }, { operation: "workflow.runs.resume", params: {} }, ] as const)("authorizes the allowlisted operation $operation", async ({ @@ -467,6 +487,24 @@ describe("POST /api/rpc", () => { expect(body.error.code).toBe("unknown_operation"); }); + it.each(["workflow.draft_workspaces.patch", "workflow.draft_workspaces.replace_document"])( + "does not authorize adjacent generic operation %s", + async (operation) => { + const res = await app.request("/api/rpc", { + method: "POST", + headers: validConsoleHeaders, + body: JSON.stringify({ + operation, + target: "http://127.0.0.1:8000/rpc", + }), + }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.code).toBe("unknown_operation"); + }, + ); + it("returns 400 for invalid JSON body", async () => { const res = await app.request("/api/rpc", { method: "POST", diff --git a/web/apps/server/src/browser-operation-policy.test.ts b/web/apps/server/src/browser-operation-policy.test.ts index 91083fa3..09eb38c8 100644 --- a/web/apps/server/src/browser-operation-policy.test.ts +++ b/web/apps/server/src/browser-operation-policy.test.ts @@ -15,14 +15,18 @@ describe("browser operation policy", () => { "workflow.capabilities.inspect", "workflow.draft_workspaces.list", "workflow.draft_workspaces.get", + "workflow.draft_workspaces.inspect_authoring_contract", "workflow.draft_workspaces.create_empty", "workflow.draft_workspaces.create_from_capability", "workflow.draft_workspaces.add_step_from_capability", "workflow.draft_workspaces.update_capability_step", "workflow.draft_workspaces.set_route", + "workflow.draft_workspaces.set_contract", + "workflow.draft_workspaces.set_start", "workflow.draft_workspaces.set_step_input_bindings", "workflow.draft_workspaces.set_step_output_bindings", "workflow.draft_workspaces.validate", + "workflow.draft_workspaces.set_workflow_output_bindings", "workflow.artifacts.list", "workflow.artifacts.inspect", "workflow.deployments.list", @@ -41,6 +45,9 @@ describe("browser operation policy", () => { expect(browserAllowedOperationNames).not.toContain( "workflow.draft_workspaces.replace_document", ); + expect(browserAllowedOperationNames).not.toContain( + "workflow.draft_workspaces.patch", + ); expect(browserAllowedOperationNames).not.toContain( "workflow.draft_workspaces.remove_step", ); diff --git a/web/apps/server/src/browser-operation-policy.ts b/web/apps/server/src/browser-operation-policy.ts index f184b4bd..77cddc5e 100644 --- a/web/apps/server/src/browser-operation-policy.ts +++ b/web/apps/server/src/browser-operation-policy.ts @@ -13,14 +13,18 @@ export const browserAllowedOperationNames = defineBrowserOperationNames([ "workflow.capabilities.inspect", "workflow.draft_workspaces.list", "workflow.draft_workspaces.get", + "workflow.draft_workspaces.inspect_authoring_contract", "workflow.draft_workspaces.create_empty", "workflow.draft_workspaces.create_from_capability", "workflow.draft_workspaces.add_step_from_capability", "workflow.draft_workspaces.update_capability_step", "workflow.draft_workspaces.set_route", + "workflow.draft_workspaces.set_contract", + "workflow.draft_workspaces.set_start", "workflow.draft_workspaces.set_step_input_bindings", "workflow.draft_workspaces.set_step_output_bindings", "workflow.draft_workspaces.validate", + "workflow.draft_workspaces.set_workflow_output_bindings", "workflow.artifacts.list", "workflow.artifacts.inspect", "workflow.deployments.list", diff --git a/web/packages/rpc/scripts/workflow-contract-generator.test.ts b/web/packages/rpc/scripts/workflow-contract-generator.test.ts index 201728a9..408ed199 100644 --- a/web/packages/rpc/scripts/workflow-contract-generator.test.ts +++ b/web/packages/rpc/scripts/workflow-contract-generator.test.ts @@ -155,11 +155,15 @@ const runtimeOperationNames = [ "workflow.draft_workspaces.create_from_capability", "workflow.draft_workspaces.list", "workflow.draft_workspaces.get", + "workflow.draft_workspaces.inspect_authoring_contract", "workflow.draft_workspaces.set_route", + "workflow.draft_workspaces.set_contract", + "workflow.draft_workspaces.set_start", "workflow.draft_workspaces.set_step_input_bindings", "workflow.draft_workspaces.set_step_output_bindings", "workflow.draft_workspaces.update_capability_step", "workflow.draft_workspaces.validate", + "workflow.draft_workspaces.set_workflow_output_bindings", "workflow.artifacts.list", "workflow.artifacts.inspect", "workflow.deployments.list", @@ -320,6 +324,10 @@ describe("workflow contract generator", () => { }); it.each([ + "workflow.draft_workspaces.inspect_authoring_contract", + "workflow.draft_workspaces.set_contract", + "workflow.draft_workspaces.set_start", + "workflow.draft_workspaces.set_workflow_output_bindings", "workflow.draft_workspaces.set_step_input_bindings", "workflow.draft_workspaces.set_step_output_bindings", ])("rejects a manifest missing focused operation %s", async (method) => { @@ -352,6 +360,14 @@ describe("workflow contract generator", () => { expect(runtimeSource).toContain( '"workflow.draft_workspaces.set_step_output_bindings"', ); + expect(runtimeSource).toContain( + '"workflow.draft_workspaces.inspect_authoring_contract"', + ); + expect(runtimeSource).toContain('"workflow.draft_workspaces.set_contract"'); + expect(runtimeSource).toContain('"workflow.draft_workspaces.set_start"'); + expect(runtimeSource).toContain( + '"workflow.draft_workspaces.set_workflow_output_bindings"', + ); expect(runtimeSource).toContain('"InputPathBinding"'); expect(runtimeSource).toContain('"InputValueBinding"'); expect(runtimeSource).toContain('"StepInputBinding"'); @@ -420,6 +436,6 @@ describe("workflow contract generator", () => { const generatedSource = await generateWorkflowContractSource(manifestText); expect(generatedSource).toBe(checkedSource); - expect(generatedSource.match(/^ \| "workflow\./gm)).toHaveLength(70); + expect(generatedSource.match(/^ \| "workflow\./gm)).toHaveLength(71); }); }); diff --git a/web/packages/rpc/scripts/workflow-contract-generator.ts b/web/packages/rpc/scripts/workflow-contract-generator.ts index e87bb5c1..c372156a 100644 --- a/web/packages/rpc/scripts/workflow-contract-generator.ts +++ b/web/packages/rpc/scripts/workflow-contract-generator.ts @@ -94,11 +94,15 @@ const runtimeOperationNameList = [ "workflow.draft_workspaces.create_from_capability", "workflow.draft_workspaces.list", "workflow.draft_workspaces.get", + "workflow.draft_workspaces.inspect_authoring_contract", + "workflow.draft_workspaces.set_contract", "workflow.draft_workspaces.set_route", + "workflow.draft_workspaces.set_start", "workflow.draft_workspaces.set_step_input_bindings", "workflow.draft_workspaces.set_step_output_bindings", "workflow.draft_workspaces.update_capability_step", "workflow.draft_workspaces.validate", + "workflow.draft_workspaces.set_workflow_output_bindings", "workflow.artifacts.list", "workflow.artifacts.inspect", "workflow.deployments.list", diff --git a/web/packages/rpc/src/generated/workflow-contract.test.ts b/web/packages/rpc/src/generated/workflow-contract.test.ts index 1ed042b4..a8bf2298 100644 --- a/web/packages/rpc/src/generated/workflow-contract.test.ts +++ b/web/packages/rpc/src/generated/workflow-contract.test.ts @@ -15,8 +15,8 @@ import { describe("generated workflow contract", () => { it("contains every operation exactly once", () => { - expect(workflowOperationNames).toHaveLength(70); - expect(new Set(workflowOperationNames)).toHaveLength(70); + expect(workflowOperationNames).toHaveLength(71); + expect(new Set(workflowOperationNames)).toHaveLength(71); }); it("contains every authored Effect operation without broadening its boundary", () => { @@ -30,13 +30,17 @@ describe("generated workflow contract", () => { "workflow.capabilities.call", "workflow.draft_workspaces.list", "workflow.draft_workspaces.get", + "workflow.draft_workspaces.inspect_authoring_contract", "workflow.draft_workspaces.create_empty", "workflow.draft_workspaces.create_from_capability", "workflow.draft_workspaces.add_step_from_capability", "workflow.draft_workspaces.update_capability_step", "workflow.draft_workspaces.set_route", + "workflow.draft_workspaces.set_contract", + "workflow.draft_workspaces.set_start", "workflow.draft_workspaces.set_step_input_bindings", "workflow.draft_workspaces.set_step_output_bindings", + "workflow.draft_workspaces.set_workflow_output_bindings", "workflow.draft_workspaces.validate", "workflow.artifacts.list", "workflow.artifacts.inspect", diff --git a/web/packages/rpc/src/generated/workflow-contract.ts b/web/packages/rpc/src/generated/workflow-contract.ts index 5eadd93a..744748e3 100644 --- a/web/packages/rpc/src/generated/workflow-contract.ts +++ b/web/packages/rpc/src/generated/workflow-contract.ts @@ -42,6 +42,7 @@ export type WorkflowOperationName = | "workflow.draft_workspaces.delete" | "workflow.draft_workspaces.get" | "workflow.draft_workspaces.handle" + | "workflow.draft_workspaces.inspect_authoring_contract" | "workflow.draft_workspaces.list" | "workflow.draft_workspaces.patch" | "workflow.draft_workspaces.remove_binding" @@ -114,6 +115,7 @@ export const workflowOperationNames: readonly WorkflowOperationName[] = [ "workflow.draft_workspaces.delete", "workflow.draft_workspaces.get", "workflow.draft_workspaces.handle", + "workflow.draft_workspaces.inspect_authoring_contract", "workflow.draft_workspaces.list", "workflow.draft_workspaces.patch", "workflow.draft_workspaces.remove_binding", @@ -193,6 +195,22 @@ export type CompileDraftWorkspaceResult = CompileDraftWorkspaceSuccess | Invalid * via the `definition` "CreateArtifactFromWorkspaceResult". */ export type CreateArtifactFromWorkspaceResult = SavedDraftArtifactResult | UnsavedDraftArtifactResult; +/** + * This interface was referenced by `WorkflowContractMap`'s JSON-Schema + * via the `definition` "AuthoringPathAvailability". + */ +export type AuthoringPathAvailability = "available" | "conditional"; +/** + * This interface was referenced by `WorkflowContractMap`'s JSON-Schema + * via the `definition` "AuthoringPathOrigin". + */ +export type AuthoringPathOrigin = + "workflow_input" | "workflow_state" | "runtime_context" | "step_input" | "step_output" | "workflow_output"; +/** + * This interface was referenced by `WorkflowContractMap`'s JSON-Schema + * via the `definition` "AuthoringPathUse". + */ +export type AuthoringPathUse = "step_input" | "step_output_source" | "state_target" | "workflow_output"; /** * This interface was referenced by `WorkflowContractMap`'s JSON-Schema * via the `definition` "PatchDraftResult". @@ -599,6 +617,14 @@ export interface WorkflowContractMap { }; result: DraftWorkspaceResult; }; + "workflow.draft_workspaces.inspect_authoring_contract": { + params: { + workspace_id: string; + revision: number; + selected_step_id?: string | null; + }; + result: AuthoringContractInventoryPayload | DraftWorkspaceResult; + }; "workflow.draft_workspaces.list": { params: Record; result: ListDraftWorkspacesResult; @@ -2201,6 +2227,59 @@ export interface HandleDraftBranch { outcome: string; step_id: string; } +/** + * Revision-scoped readable sources and writable authoring targets. + * + * This interface was referenced by `WorkflowContractMap`'s JSON-Schema + * via the `definition` "AuthoringContractInventoryPayload". + */ +export interface AuthoringContractInventoryPayload { + entry_steps: AuthoringStepContractPayload[]; + readable_sources: AuthoringPathOptionPayload[]; + revision: number; + selected_step_id: string | null; + state_targets: AuthoringPathOptionPayload[]; + step_input_targets: AuthoringPathOptionPayload[]; + step_output_sources: AuthoringPathOptionPayload[]; + warnings: string[]; + workflow_outcomes: string[]; + workflow_output_targets: AuthoringPathOptionPayload[]; + workspace_id: string; + [k: string]: unknown; +} +/** + * Compact executable-step choice used by authoring inventories. + * + * This interface was referenced by `WorkflowContractMap`'s JSON-Schema + * via the `definition` "AuthoringStepContractPayload". + */ +export interface AuthoringStepContractPayload { + description?: string; + input_targets?: AuthoringPathOptionPayload[]; + label: string; + outcomes?: string[]; + output_sources?: AuthoringPathOptionPayload[]; + step_id: string; + [k: string]: unknown; +} +/** + * One schema-derived source or target available to an author. + * + * This interface was referenced by `WorkflowContractMap`'s JSON-Schema + * via the `definition` "AuthoringPathOptionPayload". + */ +export interface AuthoringPathOptionPayload { + availability: AuthoringPathAvailability; + description?: string; + label: string; + origin: AuthoringPathOrigin; + path: string; + reason?: string; + required: boolean; + schema: JsonObject; + uses: AuthoringPathUse[]; + [k: string]: unknown; +} /** * All persisted draft-workspace summaries. * @@ -2772,6 +2851,198 @@ export const workflowRuntimeContract = { ], "type": "string" }, + "AuthoringContractInventoryPayload": { + "description": "Revision-scoped readable sources and writable authoring targets.", + "properties": { + "entry_steps": { + "items": { + "$ref": "#/components/schemas/AuthoringStepContractPayload" + }, + "type": "array" + }, + "readable_sources": { + "items": { + "$ref": "#/components/schemas/AuthoringPathOptionPayload" + }, + "type": "array" + }, + "revision": { + "type": "integer" + }, + "selected_step_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "state_targets": { + "items": { + "$ref": "#/components/schemas/AuthoringPathOptionPayload" + }, + "type": "array" + }, + "step_input_targets": { + "items": { + "$ref": "#/components/schemas/AuthoringPathOptionPayload" + }, + "type": "array" + }, + "step_output_sources": { + "items": { + "$ref": "#/components/schemas/AuthoringPathOptionPayload" + }, + "type": "array" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + }, + "workflow_outcomes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "workflow_output_targets": { + "items": { + "$ref": "#/components/schemas/AuthoringPathOptionPayload" + }, + "type": "array" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "revision", + "selected_step_id", + "readable_sources", + "step_input_targets", + "step_output_sources", + "state_targets", + "workflow_output_targets", + "entry_steps", + "workflow_outcomes", + "warnings" + ], + "type": "object" + }, + "AuthoringPathAvailability": { + "enum": [ + "available", + "conditional" + ], + "type": "string" + }, + "AuthoringPathOptionPayload": { + "description": "One schema-derived source or target available to an author.", + "properties": { + "availability": { + "$ref": "#/components/schemas/AuthoringPathAvailability" + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "origin": { + "$ref": "#/components/schemas/AuthoringPathOrigin" + }, + "path": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "schema": { + "$ref": "#/components/schemas/JsonObject" + }, + "uses": { + "items": { + "$ref": "#/components/schemas/AuthoringPathUse" + }, + "type": "array" + } + }, + "required": [ + "path", + "label", + "origin", + "schema", + "required", + "availability", + "uses" + ], + "type": "object" + }, + "AuthoringPathOrigin": { + "enum": [ + "workflow_input", + "workflow_state", + "runtime_context", + "step_input", + "step_output", + "workflow_output" + ], + "type": "string" + }, + "AuthoringPathUse": { + "enum": [ + "step_input", + "step_output_source", + "state_target", + "workflow_output" + ], + "type": "string" + }, + "AuthoringStepContractPayload": { + "description": "Compact executable-step choice used by authoring inventories.", + "properties": { + "description": { + "type": "string" + }, + "input_targets": { + "items": { + "$ref": "#/components/schemas/AuthoringPathOptionPayload" + }, + "type": "array" + }, + "label": { + "type": "string" + }, + "outcomes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "output_sources": { + "items": { + "$ref": "#/components/schemas/AuthoringPathOptionPayload" + }, + "type": "array" + }, + "step_id": { + "type": "string" + } + }, + "required": [ + "step_id", + "label" + ], + "type": "object" + }, "CapabilityCallResult": { "description": "Outcome returned by a direct node-spec or wrapper capability call.", "properties": { @@ -5780,6 +6051,48 @@ export const workflowRuntimeContract = { "$ref": "#/components/schemas/DraftWorkspaceResult" } }, + "workflow.draft_workspaces.inspect_authoring_contract": { + "payload": { + "additionalProperties": false, + "properties": { + "workspace_id": { + "minLength": 1, + "type": "string" + }, + "revision": { + "minimum": 1, + "type": "integer" + }, + "selected_step_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "workspace_id", + "revision" + ], + "type": "object" + }, + "success": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthoringContractInventoryPayload" + }, + { + "$ref": "#/components/schemas/DraftWorkspaceResult" + } + ] + } + }, "workflow.draft_workspaces.list": { "payload": { "additionalProperties": false, @@ -5791,6 +6104,79 @@ export const workflowRuntimeContract = { "$ref": "#/components/schemas/ListDraftWorkspacesResult" } }, + "workflow.draft_workspaces.set_contract": { + "payload": { + "additionalProperties": false, + "properties": { + "workspace_id": { + "minLength": 1, + "type": "string" + }, + "revision": { + "minimum": 1, + "type": "integer" + }, + "input_schema": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null + }, + "state_schema": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null + }, + "output_schema": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null + }, + "outcomes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "workspace_id", + "revision" + ], + "type": "object" + }, + "success": { + "$ref": "#/components/schemas/DraftWorkspaceResult" + } + }, "workflow.draft_workspaces.set_route": { "payload": { "additionalProperties": false, @@ -5829,6 +6215,34 @@ export const workflowRuntimeContract = { "$ref": "#/components/schemas/DraftWorkspaceResult" } }, + "workflow.draft_workspaces.set_start": { + "payload": { + "additionalProperties": false, + "properties": { + "workspace_id": { + "minLength": 1, + "type": "string" + }, + "revision": { + "minimum": 1, + "type": "integer" + }, + "step_id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspace_id", + "revision", + "step_id" + ], + "type": "object" + }, + "success": { + "$ref": "#/components/schemas/DraftWorkspaceResult" + } + }, "workflow.draft_workspaces.set_step_input_bindings": { "payload": { "additionalProperties": false, @@ -5899,6 +6313,44 @@ export const workflowRuntimeContract = { "$ref": "#/components/schemas/DraftWorkspaceResult" } }, + "workflow.draft_workspaces.set_workflow_output_bindings": { + "payload": { + "additionalProperties": false, + "properties": { + "workspace_id": { + "minLength": 1, + "type": "string" + }, + "revision": { + "minimum": 1, + "type": "integer" + }, + "bindings": { + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/InputPathBinding" + }, + { + "$ref": "#/components/schemas/InputValueBinding" + } + ], + "description": "Simple canonical binding for node inputs or workflow outputs. Use either a path binding with `path`, or a literal binding with `value`; composite `expression` bindings are node-local only." + }, + "type": "array" + } + }, + "required": [ + "workspace_id", + "revision", + "bindings" + ], + "type": "object" + }, + "success": { + "$ref": "#/components/schemas/DraftWorkspaceResult" + } + }, "workflow.draft_workspaces.update_capability_step": { "payload": { "additionalProperties": false, diff --git a/web/packages/rpc/src/index.ts b/web/packages/rpc/src/index.ts index 4a243aaa..c9f74106 100644 --- a/web/packages/rpc/src/index.ts +++ b/web/packages/rpc/src/index.ts @@ -29,13 +29,17 @@ export { WorkflowCapabilitiesCall, WorkflowDraftWorkspacesList, WorkflowDraftWorkspacesGet, + WorkflowDraftWorkspacesInspectAuthoringContract, WorkflowDraftWorkspacesCreateEmpty, WorkflowDraftWorkspacesCreateFromCapability, WorkflowDraftWorkspacesAddStepFromCapability, WorkflowDraftWorkspacesUpdateCapabilityStep, WorkflowDraftWorkspacesSetRoute, + WorkflowDraftWorkspacesSetContract, + WorkflowDraftWorkspacesSetStart, WorkflowDraftWorkspacesSetStepInputBindings, WorkflowDraftWorkspacesSetStepOutputBindings, + WorkflowDraftWorkspacesSetWorkflowOutputBindings, WorkflowDraftWorkspacesValidate, WorkflowArtifactsList, WorkflowArtifactsInspect, @@ -57,6 +61,8 @@ export { WorkflowDraftWorkspacesListResultSchema, WorkflowDraftWorkspacesGetPayloadSchema, WorkflowDraftWorkspacesGetResultSchema, + WorkflowDraftWorkspacesInspectAuthoringContractPayloadSchema, + WorkflowDraftWorkspacesInspectAuthoringContractResultSchema, WorkflowDraftWorkspacesCreateEmptyPayloadSchema, WorkflowDraftWorkspacesCreateEmptyResultSchema, WorkflowDraftWorkspacesCreateFromCapabilityPayloadSchema, @@ -67,10 +73,16 @@ export { WorkflowDraftWorkspacesUpdateCapabilityStepResultSchema, WorkflowDraftWorkspacesSetRoutePayloadSchema, WorkflowDraftWorkspacesSetRouteResultSchema, + WorkflowDraftWorkspacesSetContractPayloadSchema, + WorkflowDraftWorkspacesSetContractResultSchema, + WorkflowDraftWorkspacesSetStartPayloadSchema, + WorkflowDraftWorkspacesSetStartResultSchema, WorkflowDraftWorkspacesSetStepInputBindingsPayloadSchema, WorkflowDraftWorkspacesSetStepInputBindingsResultSchema, WorkflowDraftWorkspacesSetStepOutputBindingsPayloadSchema, WorkflowDraftWorkspacesSetStepOutputBindingsResultSchema, + WorkflowDraftWorkspacesSetWorkflowOutputBindingsPayloadSchema, + WorkflowDraftWorkspacesSetWorkflowOutputBindingsResultSchema, WorkflowDraftWorkspacesValidatePayloadSchema, WorkflowDraftWorkspacesValidateResultSchema, WorkflowRpcs, diff --git a/web/packages/rpc/src/json-schema/authored-rpc-fixtures.ts b/web/packages/rpc/src/json-schema/authored-rpc-fixtures.ts index e7491912..fdcb6d5b 100644 --- a/web/packages/rpc/src/json-schema/authored-rpc-fixtures.ts +++ b/web/packages/rpc/src/json-schema/authored-rpc-fixtures.ts @@ -157,6 +157,55 @@ const DraftWorkspaceSchema = Schema.Struct({ workspace_id: Schema.String, }); +const AuthoringPathOptionSchema = Schema.Struct({ + path: Schema.String, + label: Schema.String, + origin: Schema.Literal( + "workflow_input", + "workflow_state", + "runtime_context", + "step_input", + "step_output", + "workflow_output", + ), + schema: JsonObjectSchema, + required: Schema.Boolean, + availability: Schema.Literal("available", "conditional"), + uses: Schema.Array( + Schema.Literal( + "step_input", + "step_output_source", + "state_target", + "workflow_output", + ), + ), + description: Schema.optional(Schema.String), + reason: Schema.optional(Schema.String), +}); + +const AuthoringStepContractSchema = Schema.Struct({ + step_id: Schema.String, + label: Schema.String, + description: Schema.optional(Schema.String), + input_targets: Schema.optional(Schema.Array(AuthoringPathOptionSchema)), + output_sources: Schema.optional(Schema.Array(AuthoringPathOptionSchema)), + outcomes: Schema.optional(Schema.Array(Schema.String)), +}); + +const AuthoringContractInventorySchema = Schema.Struct({ + workspace_id: Schema.String, + revision: PositiveIntegerSchema, + selected_step_id: Schema.NullOr(Schema.String), + readable_sources: Schema.Array(AuthoringPathOptionSchema), + step_input_targets: Schema.Array(AuthoringPathOptionSchema), + step_output_sources: Schema.Array(AuthoringPathOptionSchema), + state_targets: Schema.Array(AuthoringPathOptionSchema), + workflow_output_targets: Schema.Array(AuthoringPathOptionSchema), + entry_steps: Schema.Array(AuthoringStepContractSchema), + workflow_outcomes: Schema.Array(Schema.String), + warnings: Schema.Array(Schema.String), +}); + // Effect's empty Struct does not traverse excess keys; the impossible optional // field keeps the authored empty payload strict under onExcessProperty:error. const EmptyPayloadSchema = Schema.Struct({ @@ -478,6 +527,14 @@ export const authoredRpcSchemas = { }), success: DraftWorkspaceSchema, }, + "workflow.draft_workspaces.inspect_authoring_contract": { + payload: Schema.Struct({ + workspace_id: Schema.String.pipe(Schema.minLength(1)), + revision: PositiveIntegerSchema, + selected_step_id: Schema.optional(Schema.NullOr(Schema.String)), + }), + success: Schema.Union(AuthoringContractInventorySchema, DraftWorkspaceSchema), + }, "workflow.draft_workspaces.create_empty": { payload: Schema.Struct({ workspace_id: Schema.String.pipe(Schema.minLength(1)), @@ -490,6 +547,25 @@ export const authoredRpcSchemas = { }), success: DraftWorkspaceSchema, }, + "workflow.draft_workspaces.set_contract": { + payload: Schema.Struct({ + workspace_id: Schema.String.pipe(Schema.minLength(1)), + revision: PositiveIntegerSchema, + input_schema: Schema.optional(Schema.NullOr(JsonObjectSchema)), + state_schema: Schema.optional(Schema.NullOr(JsonObjectSchema)), + output_schema: Schema.optional(Schema.NullOr(JsonObjectSchema)), + outcomes: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + }), + success: DraftWorkspaceSchema, + }, + "workflow.draft_workspaces.set_start": { + payload: Schema.Struct({ + workspace_id: Schema.String.pipe(Schema.minLength(1)), + revision: PositiveIntegerSchema, + step_id: Schema.String.pipe(Schema.minLength(1)), + }), + success: DraftWorkspaceSchema, + }, "workflow.draft_workspaces.create_from_capability": { payload: Schema.Struct({ workspace_id: Schema.String.pipe(Schema.minLength(1)), @@ -574,6 +650,14 @@ export const authoredRpcSchemas = { }), success: DraftWorkspaceSchema, }, + "workflow.draft_workspaces.set_workflow_output_bindings": { + payload: Schema.Struct({ + workspace_id: Schema.String.pipe(Schema.minLength(1)), + revision: PositiveIntegerSchema, + bindings: Schema.Array(Schema.Union(InputPathBindingSchema, InputValueBindingSchema)), + }), + success: DraftWorkspaceSchema, + }, "workflow.draft_workspaces.validate": { payload: Schema.Struct({ workspace_id: Schema.String.pipe(Schema.minLength(1)), diff --git a/web/packages/rpc/src/json-schema/rpc-parity.test.ts b/web/packages/rpc/src/json-schema/rpc-parity.test.ts index 14ec53d4..a4c76e1e 100644 --- a/web/packages/rpc/src/json-schema/rpc-parity.test.ts +++ b/web/packages/rpc/src/json-schema/rpc-parity.test.ts @@ -13,14 +13,22 @@ import { WorkflowDraftWorkspacesCreateFromCapabilityResultSchema, WorkflowDraftWorkspacesGetPayloadSchema, WorkflowDraftWorkspacesGetResultSchema, + WorkflowDraftWorkspacesInspectAuthoringContractPayloadSchema, + WorkflowDraftWorkspacesInspectAuthoringContractResultSchema, WorkflowDraftWorkspacesListPayloadSchema, WorkflowDraftWorkspacesListResultSchema, + WorkflowDraftWorkspacesSetContractPayloadSchema, + WorkflowDraftWorkspacesSetContractResultSchema, WorkflowDraftWorkspacesSetRoutePayloadSchema, WorkflowDraftWorkspacesSetRouteResultSchema, + WorkflowDraftWorkspacesSetStartPayloadSchema, + WorkflowDraftWorkspacesSetStartResultSchema, WorkflowDraftWorkspacesSetStepInputBindingsPayloadSchema, WorkflowDraftWorkspacesSetStepInputBindingsResultSchema, WorkflowDraftWorkspacesSetStepOutputBindingsPayloadSchema, WorkflowDraftWorkspacesSetStepOutputBindingsResultSchema, + WorkflowDraftWorkspacesSetWorkflowOutputBindingsPayloadSchema, + WorkflowDraftWorkspacesSetWorkflowOutputBindingsResultSchema, WorkflowDraftWorkspacesUpdateCapabilityStepPayloadSchema, WorkflowDraftWorkspacesUpdateCapabilityStepResultSchema, WorkflowDraftWorkspacesValidatePayloadSchema, @@ -203,6 +211,66 @@ const draftWorkspace = { }, }; +const authoringPathOption = { + path: "input.title", + label: "Title", + origin: "workflow_input", + schema: { type: "string" }, + required: true, + availability: "available", + uses: ["step_input"], +}; + +const authoringContractInventory = { + workspace_id: "console.demo", + revision: 7, + selected_step_id: "render", + readable_sources: [authoringPathOption], + step_input_targets: [ + { + ...authoringPathOption, + path: "local.title", + origin: "step_input", + uses: ["step_input"], + }, + ], + step_output_sources: [ + { + ...authoringPathOption, + path: "local.markdown", + origin: "step_output", + uses: ["step_output_source"], + }, + ], + state_targets: [ + { + ...authoringPathOption, + path: "state.report", + origin: "workflow_state", + uses: ["state_target", "workflow_output"], + }, + ], + workflow_output_targets: [ + { + ...authoringPathOption, + path: "output.report", + origin: "workflow_output", + uses: ["workflow_output"], + }, + ], + entry_steps: [ + { + step_id: "render", + label: "Render report", + input_targets: [], + output_sources: [], + outcomes: ["ok", "error"], + }, + ], + workflow_outcomes: ["ok", "error"], + warnings: [], +}; + const createFromCapabilityResult = { ...draftWorkspace, next_actions: { @@ -460,6 +528,22 @@ const parityCases: ReadonlyArray = [ draft: null, }, }, + { + method: "workflow.draft_workspaces.inspect_authoring_contract", + payload: WorkflowDraftWorkspacesInspectAuthoringContractPayloadSchema, + success: WorkflowDraftWorkspacesInspectAuthoringContractResultSchema, + validPayload: { + workspace_id: "console.demo", + revision: 7, + selected_step_id: "render", + }, + invalidPayload: { workspace_id: "console.demo", revision: 0 }, + validSuccess: authoringContractInventory, + invalidSuccess: { + ...authoringContractInventory, + entry_steps: [{ step_id: "render" }], + }, + }, { method: "workflow.draft_workspaces.create_empty", payload: WorkflowDraftWorkspacesCreateEmptyPayloadSchema, @@ -590,6 +674,41 @@ const parityCases: ReadonlyArray = [ summary: { ...draftWorkspace.summary, steps: [1] }, }, }, + { + method: "workflow.draft_workspaces.set_contract", + payload: WorkflowDraftWorkspacesSetContractPayloadSchema, + success: WorkflowDraftWorkspacesSetContractResultSchema, + validPayload: { + workspace_id: "console.demo", + revision: 7, + input_schema: { type: "object" }, + state_schema: { type: "object" }, + output_schema: { type: "object" }, + outcomes: ["ok", "error"], + }, + invalidPayload: { workspace_id: "console.demo", revision: 0 }, + validSuccess: draftWorkspace, + invalidSuccess: { + ...draftWorkspace, + summary: { ...draftWorkspace.summary, steps: [1] }, + }, + }, + { + method: "workflow.draft_workspaces.set_start", + payload: WorkflowDraftWorkspacesSetStartPayloadSchema, + success: WorkflowDraftWorkspacesSetStartResultSchema, + validPayload: { + workspace_id: "console.demo", + revision: 7, + step_id: "render", + }, + invalidPayload: { workspace_id: "console.demo", revision: 7, step_id: "" }, + validSuccess: draftWorkspace, + invalidSuccess: { + ...draftWorkspace, + summary: { ...draftWorkspace.summary, steps: [1] }, + }, + }, { method: "workflow.draft_workspaces.set_step_input_bindings", payload: WorkflowDraftWorkspacesSetStepInputBindingsPayloadSchema, @@ -640,6 +759,29 @@ const parityCases: ReadonlyArray = [ summary: { ...draftWorkspace.summary, steps: [1] }, }, }, + { + method: "workflow.draft_workspaces.set_workflow_output_bindings", + payload: WorkflowDraftWorkspacesSetWorkflowOutputBindingsPayloadSchema, + success: WorkflowDraftWorkspacesSetWorkflowOutputBindingsResultSchema, + validPayload: { + workspace_id: "console.demo", + revision: 7, + bindings: [ + { path: "state.report", target: "report" }, + { target: "format", value: "markdown" }, + ], + }, + invalidPayload: { + workspace_id: "console.demo", + revision: 7, + bindings: [{ expression: { kind: "literal", value: "bad" } }], + }, + validSuccess: draftWorkspace, + invalidSuccess: { + ...draftWorkspace, + summary: { ...draftWorkspace.summary, steps: [1] }, + }, + }, { method: "workflow.draft_workspaces.validate", payload: WorkflowDraftWorkspacesValidatePayloadSchema, @@ -1188,13 +1330,17 @@ describe("authored RPC and manifest schema parity", () => { "workflow.capabilities.call", "workflow.draft_workspaces.list", "workflow.draft_workspaces.get", + "workflow.draft_workspaces.inspect_authoring_contract", "workflow.draft_workspaces.create_empty", "workflow.draft_workspaces.create_from_capability", "workflow.draft_workspaces.add_step_from_capability", "workflow.draft_workspaces.update_capability_step", "workflow.draft_workspaces.set_route", + "workflow.draft_workspaces.set_contract", + "workflow.draft_workspaces.set_start", "workflow.draft_workspaces.set_step_input_bindings", "workflow.draft_workspaces.set_step_output_bindings", + "workflow.draft_workspaces.set_workflow_output_bindings", "workflow.draft_workspaces.validate", "workflow.artifacts.list", "workflow.artifacts.inspect", @@ -1231,6 +1377,7 @@ describe("authored RPC and manifest schema parity", () => { "workflow.draft_workspaces.update_capability_step:payload:oneOf@#/components/schemas/InputPathBinding.properties.path", "workflow.draft_workspaces.set_step_input_bindings:payload:oneOf@#/components/schemas/InputPathBinding.properties.path", "workflow.draft_workspaces.set_step_output_bindings:payload:oneOf@#/components/schemas/OutputBinding.properties.source", + "workflow.draft_workspaces.set_workflow_output_bindings:payload:oneOf@#/components/schemas/InputPathBinding.properties.path", ]); }); }); diff --git a/web/packages/rpc/src/json-schema/runtime-schema.test.ts b/web/packages/rpc/src/json-schema/runtime-schema.test.ts index 92096109..77fe716d 100644 --- a/web/packages/rpc/src/json-schema/runtime-schema.test.ts +++ b/web/packages/rpc/src/json-schema/runtime-schema.test.ts @@ -27,10 +27,14 @@ describe("runtimeSchemasFor", () => { "workflow.draft_workspaces.create_empty", "workflow.draft_workspaces.create_from_capability", "workflow.draft_workspaces.get", + "workflow.draft_workspaces.inspect_authoring_contract", "workflow.draft_workspaces.list", + "workflow.draft_workspaces.set_contract", "workflow.draft_workspaces.set_route", + "workflow.draft_workspaces.set_start", "workflow.draft_workspaces.set_step_input_bindings", "workflow.draft_workspaces.set_step_output_bindings", + "workflow.draft_workspaces.set_workflow_output_bindings", "workflow.draft_workspaces.update_capability_step", "workflow.draft_workspaces.validate", "workflow.health", diff --git a/web/packages/rpc/src/method-registry.test.ts b/web/packages/rpc/src/method-registry.test.ts index 1d4f1391..cc524aab 100644 --- a/web/packages/rpc/src/method-registry.test.ts +++ b/web/packages/rpc/src/method-registry.test.ts @@ -306,6 +306,52 @@ describe("run operation registry", () => { }, }, }, + { + method: "workflow.draft_workspaces.set_contract" as const, + params: { + workspace_id: "console.demo", + revision: 5, + outcomes: ["ok", "error"], + }, + cli: "uv run wf draft set-contract console.demo --revision 5 --outcome ok --outcome error", + result: { + workspace_id: "console.demo", + revision: 6, + title: "Console demo", + status: "valid" as const, + diagnostics: [], + summary: { + name: "console.demo", + start: "echo", + step_count: 1, + route_count: 1, + steps: ["echo"], + }, + }, + }, + { + method: "workflow.draft_workspaces.set_start" as const, + params: { + workspace_id: "console.demo", + revision: 6, + step_id: "echo", + }, + cli: "uv run wf draft set-start console.demo --revision 6 --step echo", + result: { + workspace_id: "console.demo", + revision: 7, + title: "Console demo", + status: "valid" as const, + diagnostics: [], + summary: { + name: "console.demo", + start: "echo", + step_count: 1, + route_count: 1, + steps: ["echo"], + }, + }, + }, { method: "workflow.draft_workspaces.set_step_input_bindings" as const, params: { @@ -367,6 +413,32 @@ describe("run operation registry", () => { }, }, }, + { + method: "workflow.draft_workspaces.set_workflow_output_bindings" as const, + params: { + workspace_id: "console.demo", + revision: 7, + bindings: [ + { path: "state.report", target: "report" }, + { target: "format", value: "markdown" }, + ], + }, + cli: "uv run wf draft set-workflow-output console.demo --revision 7 --map state.report=report --value 'format=\"markdown\"'", + result: { + workspace_id: "console.demo", + revision: 8, + title: "Console demo", + status: "valid" as const, + diagnostics: [], + summary: { + name: "console.demo", + start: "echo", + step_count: 1, + route_count: 1, + steps: ["echo"], + }, + }, + }, { method: "workflow.draft_workspaces.validate" as const, params: { workspace_id: "console.demo" }, @@ -522,6 +594,44 @@ describe("run operation registry", () => { ); }); + it("registers authoring inspection as a read operation", async () => { + const { getOperationMeta } = await import("./method-registry.js"); + const operation = getOperationMeta( + "workflow.draft_workspaces.inspect_authoring_contract", + ); + if (operation === undefined) throw new Error("missing authoring inspection"); + + expect(operation.idempotency).toBe("read"); + expect( + operation.equivalentCli({ + workspace_id: "console.demo", + revision: 8, + selected_step_id: "render", + }), + ).toBe( + "uv run wf draft inspect console.demo --include-draft [non-equivalent: unavailable CLI representation for revision, selected_step_id]", + ); + expect( + operation.interpret({ + workspace_id: "console.demo", + revision: 8, + selected_step_id: null, + readable_sources: [], + step_input_targets: [], + step_output_sources: [], + state_targets: [], + workflow_output_targets: [], + entry_steps: [], + workflow_outcomes: ["ok"], + warnings: [], + }), + ).toMatchObject({ + workspaceId: "console.demo", + selectedStepId: null, + workflowOutcomes: ["ok"], + }); + }); + it("does not flatten composite bindings into a fake inline CLI value", async () => { const { getOperationMeta } = await import("./method-registry.js"); const operation = getOperationMeta( diff --git a/web/packages/rpc/src/method-registry.ts b/web/packages/rpc/src/method-registry.ts index 1b34fb79..d588e567 100644 --- a/web/packages/rpc/src/method-registry.ts +++ b/web/packages/rpc/src/method-registry.ts @@ -2,6 +2,8 @@ import { Schema } from "effect"; import type { NodeSpecCapabilitySummary, StepInputBinding, + AuthoringContractInventoryPayload, + AuthoringPathOptionPayload, WorkflowOperationName, WrapperArtifactCapabilitySummary, } from "./generated/workflow-contract.js"; @@ -18,6 +20,8 @@ import { WorkflowDraftWorkspacesListResultSchema, WorkflowDraftWorkspacesGetPayloadSchema, WorkflowDraftWorkspacesGetResultSchema, + WorkflowDraftWorkspacesInspectAuthoringContractPayloadSchema, + WorkflowDraftWorkspacesInspectAuthoringContractResultSchema, WorkflowDraftWorkspacesCreateEmptyPayloadSchema, WorkflowDraftWorkspacesCreateEmptyResultSchema, WorkflowDraftWorkspacesCreateFromCapabilityPayloadSchema, @@ -28,10 +32,16 @@ import { WorkflowDraftWorkspacesUpdateCapabilityStepResultSchema, WorkflowDraftWorkspacesSetRoutePayloadSchema, WorkflowDraftWorkspacesSetRouteResultSchema, + WorkflowDraftWorkspacesSetContractPayloadSchema, + WorkflowDraftWorkspacesSetContractResultSchema, + WorkflowDraftWorkspacesSetStartPayloadSchema, + WorkflowDraftWorkspacesSetStartResultSchema, WorkflowDraftWorkspacesSetStepInputBindingsPayloadSchema, WorkflowDraftWorkspacesSetStepInputBindingsResultSchema, WorkflowDraftWorkspacesSetStepOutputBindingsPayloadSchema, WorkflowDraftWorkspacesSetStepOutputBindingsResultSchema, + WorkflowDraftWorkspacesSetWorkflowOutputBindingsPayloadSchema, + WorkflowDraftWorkspacesSetWorkflowOutputBindingsResultSchema, WorkflowDraftWorkspacesValidatePayloadSchema, WorkflowDraftWorkspacesValidateResultSchema, WorkflowArtifactsListPayloadSchema, @@ -141,6 +151,27 @@ export type DraftWorkspaceInterpreted = { readonly draft: Readonly> | null; }; +export type AuthoringContractInterpreted = { + readonly workspaceId: string; + readonly revision: number; + readonly selectedStepId: string | null; + readonly readableSources: ReadonlyArray>; + readonly stepInputTargets: ReadonlyArray>; + readonly stepOutputSources: ReadonlyArray>; + readonly stateTargets: ReadonlyArray>; + readonly workflowOutputTargets: ReadonlyArray>; + readonly entrySteps: ReadonlyArray<{ + readonly stepId: string; + readonly label: string; + readonly description?: string; + readonly inputTargets?: ReadonlyArray>; + readonly outputSources?: ReadonlyArray>; + readonly outcomes?: ReadonlyArray; + }>; + readonly workflowOutcomes: ReadonlyArray; + readonly warnings: ReadonlyArray; +}; + const interpretNextActions = (nextActions: { readonly can_continue: boolean; readonly can_save_now: boolean | null; @@ -301,6 +332,49 @@ const interpretDraftWorkspace = (decoded: { draft: decoded.draft ?? null, }); +const interpretAuthoringPathOption = (option: AuthoringPathOptionPayload) => ({ + path: option.path, + label: option.label, + origin: option.origin, + schema: option.schema, + required: option.required, + availability: option.availability, + uses: option.uses, + ...(option.description === undefined + ? {} + : { description: option.description }), + ...(option.reason === undefined ? {} : { reason: option.reason }), +}); + +const interpretAuthoringContract = ( + decoded: AuthoringContractInventoryPayload, +): AuthoringContractInterpreted => ({ + workspaceId: decoded.workspace_id, + revision: decoded.revision, + selectedStepId: decoded.selected_step_id, + readableSources: decoded.readable_sources.map(interpretAuthoringPathOption), + stepInputTargets: decoded.step_input_targets.map(interpretAuthoringPathOption), + stepOutputSources: decoded.step_output_sources.map(interpretAuthoringPathOption), + stateTargets: decoded.state_targets.map(interpretAuthoringPathOption), + workflowOutputTargets: decoded.workflow_output_targets.map( + interpretAuthoringPathOption, + ), + entrySteps: decoded.entry_steps.map((step) => ({ + stepId: step.step_id, + label: step.label, + ...(step.description === undefined ? {} : { description: step.description }), + ...(step.input_targets === undefined + ? {} + : { inputTargets: step.input_targets.map(interpretAuthoringPathOption) }), + ...(step.output_sources === undefined + ? {} + : { outputSources: step.output_sources.map(interpretAuthoringPathOption) }), + ...(step.outcomes === undefined ? {} : { outcomes: step.outcomes }), + })), + workflowOutcomes: decoded.workflow_outcomes, + warnings: decoded.warnings, +}); + /** Adapts a snake_case run detail from the server into camelCase for the browser. */ const interpretRunDetail = (decoded: { readonly run_id: string | null; @@ -533,6 +607,33 @@ const operationEntries = defineOperationEntries([ return interpretDraftWorkspace(decoded); }, }, + { + method: "workflow.draft_workspaces.inspect_authoring_contract", + label: "Inspect draft authoring contract", + explanation: "Inspect schema-derived sources and targets for a draft workspace", + idempotency: "read", + equivalentCli: (params) => { + const p = Schema.decodeUnknownSync( + WorkflowDraftWorkspacesInspectAuthoringContractPayloadSchema, + )(params, { onExcessProperty: "error" }); + return nonEquivalentCli( + `uv run wf draft inspect ${shellArg(p.workspace_id)} --include-draft`, + [ + "revision", + ...(p.selected_step_id == null ? [] : ["selected_step_id"]), + ], + ); + }, + interpret: (result) => { + const decoded = Schema.decodeUnknownSync( + WorkflowDraftWorkspacesInspectAuthoringContractResultSchema, + )(result); + if (isRecord(decoded) && "readable_sources" in decoded) { + return interpretAuthoringContract(decoded as AuthoringContractInventoryPayload); + } + return interpretDraftWorkspace(decoded); + }, + }, { method: "workflow.draft_workspaces.create_empty", label: "Create empty draft workspace", @@ -722,6 +823,55 @@ const operationEntries = defineOperationEntries([ return interpretDraftWorkspace(decoded); }, }, + { + method: "workflow.draft_workspaces.set_contract", + label: "Set draft contract", + explanation: "Replace selected workflow input, state, output, or outcome contract fields", + idempotency: "write", + equivalentCli: (params) => { + const p = Schema.decodeUnknownSync( + WorkflowDraftWorkspacesSetContractPayloadSchema, + )(params, { onExcessProperty: "error" }); + const parts = [ + "uv run wf draft set-contract", + shellArg(p.workspace_id), + "--revision", + String(p.revision), + ]; + const unavailable: string[] = []; + if (p.input_schema != null) unavailable.push("input_schema (use --input-schema-file)"); + if (p.state_schema != null) unavailable.push("state_schema (use --state-schema-file)"); + if (p.output_schema != null) unavailable.push("output_schema (use --output-schema-file)"); + for (const outcome of p.outcomes ?? []) { + parts.push("--outcome", shellArg(outcome)); + } + return nonEquivalentCli(parts.join(" "), unavailable); + }, + interpret: (result) => { + const decoded = Schema.decodeUnknownSync( + WorkflowDraftWorkspacesSetContractResultSchema, + )(result); + return interpretDraftWorkspace(decoded); + }, + }, + { + method: "workflow.draft_workspaces.set_start", + label: "Set draft start", + explanation: "Set the entry step for a persisted workflow draft", + idempotency: "write", + equivalentCli: (params) => { + const p = Schema.decodeUnknownSync( + WorkflowDraftWorkspacesSetStartPayloadSchema, + )(params, { onExcessProperty: "error" }); + return `uv run wf draft set-start ${shellArg(p.workspace_id)} --revision ${p.revision} --step ${shellArg(p.step_id)}`; + }, + interpret: (result) => { + const decoded = Schema.decodeUnknownSync( + WorkflowDraftWorkspacesSetStartResultSchema, + )(result); + return interpretDraftWorkspace(decoded); + }, + }, { method: "workflow.draft_workspaces.set_step_input_bindings", label: "Set step input bindings", @@ -788,6 +938,36 @@ const operationEntries = defineOperationEntries([ return interpretDraftWorkspace(decoded); }, }, + { + method: "workflow.draft_workspaces.set_workflow_output_bindings", + label: "Set workflow output bindings", + explanation: "Replace the ordered public workflow output bindings", + idempotency: "write", + equivalentCli: (params) => { + const p = Schema.decodeUnknownSync( + WorkflowDraftWorkspacesSetWorkflowOutputBindingsPayloadSchema, + )(params, { onExcessProperty: "error" }); + const parts = [ + "uv run wf draft set-workflow-output", + shellArg(p.workspace_id), + "--revision", + String(p.revision), + ]; + if (p.bindings.length === 0) { + parts.push("--clear"); + return parts.join(" "); + } + const rendered = inputBindingCliArgs(p.bindings, "--map"); + parts.push(...rendered.args); + return nonEquivalentCli(parts.join(" "), rendered.unavailable); + }, + interpret: (result) => { + const decoded = Schema.decodeUnknownSync( + WorkflowDraftWorkspacesSetWorkflowOutputBindingsResultSchema, + )(result); + return interpretDraftWorkspace(decoded); + }, + }, { method: "workflow.draft_workspaces.validate", label: "Validate draft workspace", diff --git a/web/packages/rpc/src/rpcs.ts b/web/packages/rpc/src/rpcs.ts index 3b8722bf..c142377e 100644 --- a/web/packages/rpc/src/rpcs.ts +++ b/web/packages/rpc/src/rpcs.ts @@ -107,6 +107,23 @@ export const WorkflowDraftWorkspacesGet = Rpc.make( }, ); +const draftWorkspacesInspectAuthoringContractSchemas = runtimeSchemasFor( + "workflow.draft_workspaces.inspect_authoring_contract", +); +export const WorkflowDraftWorkspacesInspectAuthoringContractPayloadSchema = + draftWorkspacesInspectAuthoringContractSchemas.payload; +export const WorkflowDraftWorkspacesInspectAuthoringContractResultSchema = + draftWorkspacesInspectAuthoringContractSchemas.success; + +export const WorkflowDraftWorkspacesInspectAuthoringContract = Rpc.make( + "workflow.draft_workspaces.inspect_authoring_contract", + { + payload: WorkflowDraftWorkspacesInspectAuthoringContractPayloadSchema, + success: WorkflowDraftWorkspacesInspectAuthoringContractResultSchema, + error: Schema.Never, + }, +); + // Artifacts const artifactListSchemas = runtimeSchemasFor("workflow.artifacts.list"); export const WorkflowArtifactsListPayloadSchema = artifactListSchemas.payload; @@ -203,6 +220,40 @@ export const WorkflowDraftWorkspacesSetRoute = Rpc.make( }, ); +const draftWorkspacesSetContractSchemas = runtimeSchemasFor( + "workflow.draft_workspaces.set_contract", +); +export const WorkflowDraftWorkspacesSetContractPayloadSchema = + draftWorkspacesSetContractSchemas.payload; +export const WorkflowDraftWorkspacesSetContractResultSchema = + draftWorkspacesSetContractSchemas.success; + +export const WorkflowDraftWorkspacesSetContract = Rpc.make( + "workflow.draft_workspaces.set_contract", + { + payload: WorkflowDraftWorkspacesSetContractPayloadSchema, + success: WorkflowDraftWorkspacesSetContractResultSchema, + error: Schema.Never, + }, +); + +const draftWorkspacesSetStartSchemas = runtimeSchemasFor( + "workflow.draft_workspaces.set_start", +); +export const WorkflowDraftWorkspacesSetStartPayloadSchema = + draftWorkspacesSetStartSchemas.payload; +export const WorkflowDraftWorkspacesSetStartResultSchema = + draftWorkspacesSetStartSchemas.success; + +export const WorkflowDraftWorkspacesSetStart = Rpc.make( + "workflow.draft_workspaces.set_start", + { + payload: WorkflowDraftWorkspacesSetStartPayloadSchema, + success: WorkflowDraftWorkspacesSetStartResultSchema, + error: Schema.Never, + }, +); + const setStepInputBindingsSchemas = runtimeSchemasFor( "workflow.draft_workspaces.set_step_input_bindings", ); @@ -237,6 +288,23 @@ export const WorkflowDraftWorkspacesSetStepOutputBindings = Rpc.make( }, ); +const draftWorkspacesSetWorkflowOutputBindingsSchemas = runtimeSchemasFor( + "workflow.draft_workspaces.set_workflow_output_bindings", +); +export const WorkflowDraftWorkspacesSetWorkflowOutputBindingsPayloadSchema = + draftWorkspacesSetWorkflowOutputBindingsSchemas.payload; +export const WorkflowDraftWorkspacesSetWorkflowOutputBindingsResultSchema = + draftWorkspacesSetWorkflowOutputBindingsSchemas.success; + +export const WorkflowDraftWorkspacesSetWorkflowOutputBindings = Rpc.make( + "workflow.draft_workspaces.set_workflow_output_bindings", + { + payload: WorkflowDraftWorkspacesSetWorkflowOutputBindingsPayloadSchema, + success: WorkflowDraftWorkspacesSetWorkflowOutputBindingsResultSchema, + error: Schema.Never, + }, +); + const draftWorkspacesValidateSchemas = runtimeSchemasFor( "workflow.draft_workspaces.validate", ); @@ -365,13 +433,17 @@ export const WorkflowRpcs = RpcGroup.make( WorkflowCapabilitiesCall, WorkflowDraftWorkspacesList, WorkflowDraftWorkspacesGet, + WorkflowDraftWorkspacesInspectAuthoringContract, WorkflowDraftWorkspacesCreateEmpty, WorkflowDraftWorkspacesCreateFromCapability, WorkflowDraftWorkspacesAddStepFromCapability, WorkflowDraftWorkspacesUpdateCapabilityStep, WorkflowDraftWorkspacesSetRoute, + WorkflowDraftWorkspacesSetContract, + WorkflowDraftWorkspacesSetStart, WorkflowDraftWorkspacesSetStepInputBindings, WorkflowDraftWorkspacesSetStepOutputBindings, + WorkflowDraftWorkspacesSetWorkflowOutputBindings, WorkflowDraftWorkspacesValidate, WorkflowArtifactsList, WorkflowArtifactsInspect, diff --git a/web/packages/rpc/src/service.test.ts b/web/packages/rpc/src/service.test.ts index d1788899..977730fb 100644 --- a/web/packages/rpc/src/service.test.ts +++ b/web/packages/rpc/src/service.test.ts @@ -219,6 +219,15 @@ const lifecycleCases = [ }, }, }, + { + operation: "workflow.draft_workspaces.inspect_authoring_contract" as const, + params: { + workspace_id: "console.demo", + revision: 1, + selected_step_id: "echo", + }, + result: draftWorkspaceResult, + }, { operation: "workflow.draft_workspaces.create_empty" as const, params: { @@ -278,6 +287,24 @@ const lifecycleCases = [ }, result: draftWorkspaceResult, }, + { + operation: "workflow.draft_workspaces.set_contract" as const, + params: { + workspace_id: "console.demo", + revision: 2, + outcomes: ["ok", "error"], + }, + result: draftWorkspaceResult, + }, + { + operation: "workflow.draft_workspaces.set_start" as const, + params: { + workspace_id: "console.demo", + revision: 3, + step_id: "echo", + }, + result: draftWorkspaceResult, + }, { operation: "workflow.draft_workspaces.set_step_input_bindings" as const, params: { @@ -306,6 +333,18 @@ const lifecycleCases = [ params: { workspace_id: "console.demo" }, result: draftWorkspaceResult, }, + { + operation: "workflow.draft_workspaces.set_workflow_output_bindings" as const, + params: { + workspace_id: "console.demo", + revision: 4, + bindings: [ + { path: "state.report", target: "report" }, + { target: "format", value: "markdown" }, + ], + }, + result: draftWorkspaceResult, + }, { operation: "workflow.artifacts.list" as const, params: { limit: 50 }, diff --git a/web/packages/rpc/src/service.ts b/web/packages/rpc/src/service.ts index 26c26b83..6de23735 100644 --- a/web/packages/rpc/src/service.ts +++ b/web/packages/rpc/src/service.ts @@ -36,13 +36,17 @@ import { WorkflowCapabilitiesCallPayloadSchema, WorkflowDraftWorkspacesListPayloadSchema, WorkflowDraftWorkspacesGetPayloadSchema, + WorkflowDraftWorkspacesInspectAuthoringContractPayloadSchema, WorkflowDraftWorkspacesCreateEmptyPayloadSchema, WorkflowDraftWorkspacesCreateFromCapabilityPayloadSchema, WorkflowDraftWorkspacesAddStepFromCapabilityPayloadSchema, WorkflowDraftWorkspacesUpdateCapabilityStepPayloadSchema, WorkflowDraftWorkspacesSetRoutePayloadSchema, + WorkflowDraftWorkspacesSetContractPayloadSchema, + WorkflowDraftWorkspacesSetStartPayloadSchema, WorkflowDraftWorkspacesSetStepInputBindingsPayloadSchema, WorkflowDraftWorkspacesSetStepOutputBindingsPayloadSchema, + WorkflowDraftWorkspacesSetWorkflowOutputBindingsPayloadSchema, WorkflowDraftWorkspacesValidatePayloadSchema, WorkflowArtifactsListPayloadSchema, WorkflowArtifactsInspectPayloadSchema, @@ -324,6 +328,15 @@ const executeImpl = ); return yield* client.workflow["draft_workspaces.get"](payload); } + case "workflow.draft_workspaces.inspect_authoring_contract": { + const payload = yield* decodeParams( + WorkflowDraftWorkspacesInspectAuthoringContractPayloadSchema, + params, + ); + return yield* client.workflow[ + "draft_workspaces.inspect_authoring_contract" + ](payload); + } case "workflow.draft_workspaces.create_empty": { const payload = yield* decodeParams( WorkflowDraftWorkspacesCreateEmptyPayloadSchema, @@ -365,6 +378,20 @@ const executeImpl = ); return yield* client.workflow["draft_workspaces.set_route"](payload); } + case "workflow.draft_workspaces.set_contract": { + const payload = yield* decodeParams( + WorkflowDraftWorkspacesSetContractPayloadSchema, + params, + ); + return yield* client.workflow["draft_workspaces.set_contract"](payload); + } + case "workflow.draft_workspaces.set_start": { + const payload = yield* decodeParams( + WorkflowDraftWorkspacesSetStartPayloadSchema, + params, + ); + return yield* client.workflow["draft_workspaces.set_start"](payload); + } case "workflow.draft_workspaces.set_step_input_bindings": { const payload = yield* decodeParams( WorkflowDraftWorkspacesSetStepInputBindingsPayloadSchema, @@ -383,6 +410,15 @@ const executeImpl = "draft_workspaces.set_step_output_bindings" ](payload); } + case "workflow.draft_workspaces.set_workflow_output_bindings": { + const payload = yield* decodeParams( + WorkflowDraftWorkspacesSetWorkflowOutputBindingsPayloadSchema, + params, + ); + return yield* client.workflow[ + "draft_workspaces.set_workflow_output_bindings" + ](payload); + } case "workflow.draft_workspaces.validate": { const payload = yield* decodeParams( WorkflowDraftWorkspacesValidatePayloadSchema,