diff --git a/contracts/workflow-api.manifest.json b/contracts/workflow-api.manifest.json index 030cccc6..6b5af7e8 100644 --- a/contracts/workflow-api.manifest.json +++ b/contracts/workflow-api.manifest.json @@ -4347,6 +4347,16 @@ "state_changes": { "$ref": "#/components/schemas/JsonObject" }, + "step_number": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, "step_type": { "type": "string" } @@ -4355,6 +4365,7 @@ "frame_id", "node_id", "step_type", + "step_number", "resolved_input", "outcome", "next_node_id", diff --git a/src/wf_api/models/runs.py b/src/wf_api/models/runs.py index 7fe2160e..ead53f21 100644 --- a/src/wf_api/models/runs.py +++ b/src/wf_api/models/runs.py @@ -48,6 +48,7 @@ class TraceEntryPayload(TypedDict): frame_id: str node_id: str step_type: str + step_number: int | None resolved_input: JsonObject outcome: str next_node_id: str diff --git a/src/wf_transport_rpc_http/models.py b/src/wf_transport_rpc_http/models.py index f5b026cf..fb623e84 100644 --- a/src/wf_transport_rpc_http/models.py +++ b/src/wf_transport_rpc_http/models.py @@ -438,6 +438,7 @@ class StartRunParams(RpcParamsModel): max_steps: int | None = Field( default=None, ge=1, + strict=True, description=( "Optional run step budget. The server default applies when omitted; " "resume never accepts a replacement." diff --git a/tests/wf_client/test_codec.py b/tests/wf_client/test_codec.py index f46d0942..15929374 100644 --- a/tests/wf_client/test_codec.py +++ b/tests/wf_client/test_codec.py @@ -163,6 +163,7 @@ def test_decode_trace_result_decodes_bounded_trace() -> None: "frame_id": "root", "node_id": "constant", "step_type": "node", + "step_number": 1, "resolved_input": {}, "outcome": "ok", "next_node_id": "__end__", @@ -176,3 +177,4 @@ def test_decode_trace_result_decodes_bounded_trace() -> None: assert result.trace_start == 0 assert result.trace is not None assert result.trace[0]["node_id"] == "constant" + assert result.trace[0]["step_number"] == 1 diff --git a/tests/wf_client/test_runs.py b/tests/wf_client/test_runs.py index f32ceb2c..cdd34cb3 100644 --- a/tests/wf_client/test_runs.py +++ b/tests/wf_client/test_runs.py @@ -65,6 +65,7 @@ class _Port: "frame_id": "root", "node_id": "approve", "step_type": "node", + "step_number": 1, "resolved_input": {}, "outcome": "ok", "next_node_id": "__end__", @@ -101,6 +102,7 @@ async def test_interrupted_run_resumes_and_reads_bounded_trace() -> None: assert trace.start == 0 assert trace.limit == 25 assert len(trace.frames) == 1 + assert trace.frames[0].step_number == 1 @pytest.mark.asyncio diff --git a/tests/wf_transport_rpc_http/test_app.py b/tests/wf_transport_rpc_http/test_app.py index d2bb29cb..315c58fb 100644 --- a/tests/wf_transport_rpc_http/test_app.py +++ b/tests/wf_transport_rpc_http/test_app.py @@ -1199,6 +1199,7 @@ async def test_rpc_runs_deployment_and_reads_bounded_trace(tmp_path) -> None: assert trace["result"]["trace_start"] == 0 assert trace["result"]["trace_limit"] == 1 assert len(trace["result"]["trace"]) == 1 + assert trace["result"]["trace"][0]["step_number"] == 1 async def test_rpc_run_list_method(tmp_path) -> None: @@ -2309,6 +2310,28 @@ async def test_rpc_runs_start_rejects_non_positive_step_budget(tmp_path) -> None assert rejected["error"]["code"] == -32602 +@pytest.mark.parametrize("max_steps", [True, "5"]) +async def test_rpc_runs_start_rejects_non_integer_step_budget( + tmp_path, max_steps: object +) -> None: + server = build_local_static_workflow_server(tmp_path / "store") + deployment_id = await _seed_step_budget_deployment(server) + app = create_rpc_app(server) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + rejected = await _rpc( + client, + "workflow.runs.start", + { + "deployment_id": deployment_id, + "workflow_input": {}, + "max_steps": max_steps, + }, + ) + + assert rejected["error"]["code"] == -32602 + + async def test_rpc_runs_resume_rejects_step_budget_replacement(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store") app = create_rpc_app(server) diff --git a/web/packages/rpc/src/generated/workflow-contract.ts b/web/packages/rpc/src/generated/workflow-contract.ts index f519dc79..c760e269 100644 --- a/web/packages/rpc/src/generated/workflow-contract.ts +++ b/web/packages/rpc/src/generated/workflow-contract.ts @@ -2481,6 +2481,7 @@ export interface TraceEntryPayload { output: JsonObject; resolved_input: JsonObject; state_changes: JsonObject; + step_number: number | null; step_type: string; [k: string]: unknown; } @@ -5018,6 +5019,16 @@ export const workflowRuntimeContract = { "state_changes": { "$ref": "#/components/schemas/JsonObject" }, + "step_number": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, "step_type": { "type": "string" } @@ -5026,6 +5037,7 @@ export const workflowRuntimeContract = { "frame_id", "node_id", "step_type", + "step_number", "resolved_input", "outcome", "next_node_id", 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 d36b5abb..20aaa23a 100644 --- a/web/packages/rpc/src/json-schema/authored-rpc-fixtures.ts +++ b/web/packages/rpc/src/json-schema/authored-rpc-fixtures.ts @@ -301,11 +301,15 @@ const RunResultSchema = Schema.Struct({ output: Schema.NullOr(JsonObjectSchema), diagnostics: Schema.Array(Schema.Unknown), trace_count: NonNegativeIntegerSchema, + max_steps: PositiveIntegerSchema, + steps_executed: NonNegativeIntegerSchema, + steps_remaining: NonNegativeIntegerSchema, next_actions: RunNextActionsSchema, }); const TraceFrameSchema = Schema.Struct({ node_id: Schema.String, step_type: Schema.String, + step_number: Schema.NullOr(PositiveIntegerSchema), resolved_input: JsonObjectSchema, outcome: Schema.String, output: JsonObjectSchema, @@ -764,6 +768,7 @@ export const authoredRpcSchemas = { deployment_id: Schema.String, workflow_input: JsonObjectSchema, trace_range: Schema.optional(Schema.NullOr(TraceRangeSchema)), + max_steps: Schema.optional(PositiveIntegerSchema), }), success: RunResultSchema, }, 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 ef1e1f00..435291bd 100644 --- a/web/packages/rpc/src/json-schema/rpc-parity.test.ts +++ b/web/packages/rpc/src/json-schema/rpc-parity.test.ts @@ -141,6 +141,9 @@ const interruptedRun = { output: null, diagnostics: [], trace_count: 1, + max_steps: 10_000, + steps_executed: 1, + steps_remaining: 9_999, next_actions: nextActions, }; @@ -173,11 +176,14 @@ const completedRun = { outcome: "completed", output: { report: "# Completed report" }, trace_count: 4, + steps_executed: 4, + steps_remaining: 9_996, }; const authoredTraceFrame = { node_id: "review_issues", step_type: "interrupt", + step_number: 1, resolved_input: { report: "# Draft report" }, outcome: "submitted", output: {}, 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 77fe716d..3793d453 100644 --- a/web/packages/rpc/src/json-schema/runtime-schema.test.ts +++ b/web/packages/rpc/src/json-schema/runtime-schema.test.ts @@ -315,6 +315,9 @@ describe("runtimeSchemasFor", () => { output: null, diagnostics: [], trace_count: 1, + max_steps: 10_000, + steps_executed: 1, + steps_remaining: 9_999, next_actions: { can_continue: true, can_save_now: null, @@ -381,6 +384,7 @@ describe("runtimeSchemasFor", () => { frame_id: "frame_1", node_id: "review_issues", step_type: "interrupt", + step_number: 1, resolved_input: {}, outcome: "submitted", next_node_id: "create_issues", @@ -401,6 +405,9 @@ describe("runtimeSchemasFor", () => { output: {}, diagnostics: [], trace_count: 1, + max_steps: 10_000, + steps_executed: 1, + steps_remaining: 9_999, next_actions: { can_continue: false, can_save_now: null, diff --git a/web/packages/rpc/src/json-schema/translator.manifest.test.ts b/web/packages/rpc/src/json-schema/translator.manifest.test.ts index 69510ba2..1fd32592 100644 --- a/web/packages/rpc/src/json-schema/translator.manifest.test.ts +++ b/web/packages/rpc/src/json-schema/translator.manifest.test.ts @@ -65,6 +65,9 @@ describe("checked workflow manifest translation", () => { run_id: "run_123", status: "completed", trace_count: 9, + max_steps: 10_000, + steps_executed: 9, + steps_remaining: 9_991, }), ), ).toBe(true); diff --git a/web/packages/rpc/src/method-registry.ts b/web/packages/rpc/src/method-registry.ts index 3b9a27dd..0f7aced8 100644 --- a/web/packages/rpc/src/method-registry.ts +++ b/web/packages/rpc/src/method-registry.ts @@ -389,6 +389,9 @@ const interpretRunDetail = (decoded: { readonly output: Record | null; readonly diagnostics: ReadonlyArray; readonly trace_count: number; + readonly max_steps: number; + readonly steps_executed: number; + readonly steps_remaining: number; readonly next_actions: Parameters[0]; }) => ({ runId: decoded.run_id, @@ -403,6 +406,9 @@ const interpretRunDetail = (decoded: { output: decoded.output, diagnostics: decoded.diagnostics, traceCount: decoded.trace_count, + maxSteps: decoded.max_steps, + stepsExecuted: decoded.steps_executed, + stepsRemaining: decoded.steps_remaining, nextActions: interpretNextActions(decoded.next_actions), }); @@ -1268,6 +1274,7 @@ const operationEntries = defineOperationEntries([ frameId: entry.frame_id, nodeId: entry.node_id, stepType: entry.step_type, + stepNumber: entry.step_number, outcome: entry.outcome, nextNodeId: entry.next_node_id, resolvedInput: entry.resolved_input, diff --git a/web/packages/rpc/src/service.test.ts b/web/packages/rpc/src/service.test.ts index 977730fb..5e012a73 100644 --- a/web/packages/rpc/src/service.test.ts +++ b/web/packages/rpc/src/service.test.ts @@ -483,6 +483,9 @@ const lifecycleCases = [ output: null, diagnostics: [], trace_count: 0, + max_steps: 10_000, + steps_executed: 1, + steps_remaining: 9_999, next_actions: { can_continue: false, can_save_now: null, @@ -550,6 +553,9 @@ const lifecycleCases = [ output: null, diagnostics: [], trace_count: 1, + max_steps: 10_000, + steps_executed: 1, + steps_remaining: 9_999, next_actions: { can_continue: true, can_save_now: null, @@ -597,6 +603,9 @@ const lifecycleCases = [ }, diagnostics: [], trace_count: 4, + max_steps: 10_000, + steps_executed: 4, + steps_remaining: 9_996, next_actions: { can_continue: false, can_save_now: null, @@ -635,6 +644,9 @@ const lifecycleCases = [ output: null, diagnostics: [], trace_count: 1, + max_steps: 10_000, + steps_executed: 1, + steps_remaining: 9_999, next_actions: { can_continue: true, can_save_now: null, @@ -651,6 +663,7 @@ const lifecycleCases = [ frame_id: "frame_1", node_id: "review", step_type: "interrupt", + step_number: 1, resolved_input: { report: "..." }, outcome: "submitted", output: {}, @@ -958,6 +971,7 @@ describe("lifecycle operations", () => { frameId: "frame_1", nodeId: "review", stepType: "interrupt", + stepNumber: 1, outcome: "submitted", nextNodeId: "create_issues", }, @@ -1019,6 +1033,9 @@ describe("lifecycle operations", () => { expect(exchange.interpreted).toMatchObject({ runId: "run_demo", status: "interrupted", + maxSteps: 10_000, + stepsExecuted: 1, + stepsRemaining: 9_999, interrupt: { kind: "issue_review", typed: true,