|
9 | 9 | ConcurrencyPolicySchema, |
10 | 10 | ScheduleStateSchema, |
11 | 11 | FlowRunSummarySchema, |
| 12 | + FlowRunNodeSummarySchema, |
| 13 | + ExecutionStepMetricsSchema, |
12 | 14 | } from './execution.zod'; |
13 | 15 |
|
14 | 16 | // ========================================== |
@@ -47,6 +49,39 @@ describe('ExecutionStatus', () => { |
47 | 49 | // Execution Step Log |
48 | 50 | // ========================================== |
49 | 51 |
|
| 52 | +describe('ExecutionStepMetricsSchema', () => { |
| 53 | + // #15617 — the failure slot a delegating node rolls its child's contained |
| 54 | + // failures through. `z.object` STRIPS an undeclared key on parse, so keeping |
| 55 | + // the value through the parse is what proves the declaration exists. |
| 56 | + it('declares `failures` — a delegating step keeps its child\'s contained-failure count through the parse', () => { |
| 57 | + const metrics = ExecutionStepMetricsSchema.parse({ selected: 5, acted: 4, failures: 1 }); |
| 58 | + expect(metrics.failures).toBe(1); |
| 59 | + expect(metrics).toEqual({ selected: 5, acted: 4, failures: 1 }); |
| 60 | + }); |
| 61 | + |
| 62 | + it('leaves `failures` absent on a step that delegated nothing — absent is not zero, and is not defaulted', () => { |
| 63 | + const metrics = ExecutionStepMetricsSchema.parse({ selected: 1, acted: 1 }); |
| 64 | + expect(metrics.failures).toBeUndefined(); |
| 65 | + expect(Object.keys(metrics)).not.toContain('failures'); |
| 66 | + }); |
| 67 | + |
| 68 | + it('rejects a negative or fractional `failures`', () => { |
| 69 | + expect(ExecutionStepMetricsSchema.safeParse({ failures: -1 }).success).toBe(false); |
| 70 | + expect(ExecutionStepMetricsSchema.safeParse({ failures: 1.5 }).success).toBe(false); |
| 71 | + }); |
| 72 | + |
| 73 | + it('says at the point of use which shape it is for, and that it is not the step\'s own outcome', () => { |
| 74 | + // The carve-out has to be readable where a consumer reads the field, not |
| 75 | + // only in a paragraph above the schema: the describe names both delegating |
| 76 | + // node kinds and separates the slot from the step's own `status`. |
| 77 | + const doc = ExecutionStepMetricsSchema.shape.failures.description ?? ''; |
| 78 | + expect(doc).toContain('`subflow`'); |
| 79 | + expect(doc).toContain('`map`'); |
| 80 | + expect(doc).toContain('`summary.failed`'); |
| 81 | + expect(doc).toContain('NOT this execution'); |
| 82 | + }); |
| 83 | +}); |
| 84 | + |
50 | 85 | describe('ExecutionStepLogSchema', () => { |
51 | 86 | it('should accept a valid step log', () => { |
52 | 87 | const step = ExecutionStepLogSchema.parse({ |
@@ -157,6 +192,24 @@ describe('ExecutionStepLogSchema', () => { |
157 | 192 | expect(step.branch).toBe(1); |
158 | 193 | }); |
159 | 194 |
|
| 195 | + it('a `success` step that delegated to a child carries the child\'s contained failures on `metrics.failures` (#15617)', () => { |
| 196 | + // The card's shape: `loop { subflow(child) }`, one iteration whose child |
| 197 | + // COMPLETED while losing a row. The subflow step itself succeeded — the |
| 198 | + // failure is the child's, contained — so `status` stays `success` and the |
| 199 | + // count rides on the metrics, not on the step's own outcome. |
| 200 | + const step = ExecutionStepLogSchema.parse({ |
| 201 | + nodeId: 'call', |
| 202 | + nodeType: 'subflow', |
| 203 | + status: 'success', |
| 204 | + startedAt: '2026-09-05T00:00:00Z', |
| 205 | + iteration: 4, |
| 206 | + regionKind: 'loop-body', |
| 207 | + metrics: { selected: 1, acted: 0, failures: 1 }, |
| 208 | + }); |
| 209 | + expect(step.status).toBe('success'); |
| 210 | + expect(step.metrics?.failures).toBe(1); |
| 211 | + }); |
| 212 | + |
160 | 213 | it('`branch: 0` — the first branch — survives the parse as 0', () => { |
161 | 214 | // Zero is a real index; a falsy-check anywhere on the way would drop it. |
162 | 215 | const step = ExecutionStepLogSchema.parse({ |
@@ -350,6 +403,68 @@ describe('FlowRunSummarySchema', () => { |
350 | 403 | expect(summary.failed).toBe(summary.nodes.reduce((sum, node) => sum + node.failures, 0)); |
351 | 404 | }); |
352 | 405 |
|
| 406 | + it('`failed` is the fold INCLUDING what a delegating node rolled up from its child — the card\'s measured shape, as ruled (#15617)', () => { |
| 407 | + // Parent `loop { subflow(child) }` over five rows; the child COMPLETED on |
| 408 | + // every iteration and contained one failure on the last. The subflow node |
| 409 | + // succeeded five times — `status: success`, its own executions never |
| 410 | + // failed — and carries the rolled-up count on `failures`, exactly as it |
| 411 | + // carries the child's writes on `acted`. The fold then sees it, so the |
| 412 | + // parent no longer reads `failed: 0` while a child lost a row. |
| 413 | + const summary = FlowRunSummarySchema.parse({ |
| 414 | + selected: 5, acted: 4, skipped: 0, failed: 1, |
| 415 | + nodes: [ |
| 416 | + { nodeId: 'each', nodeType: 'loop', status: 'success' as const, runs: 1, failures: 0, skipped: 0 }, |
| 417 | + { nodeId: 'call', nodeType: 'subflow', status: 'success' as const, runs: 5, failures: 1, skipped: 0, selected: 5, acted: 4 }, |
| 418 | + ], |
| 419 | + gates: [], |
| 420 | + }); |
| 421 | + expect(summary.failed).toBe(1); |
| 422 | + expect(summary.failed).toBe(summary.nodes.reduce((sum, node) => sum + node.failures, 0)); |
| 423 | + // Declared shape: a delegating node's status is judged on its OWN |
| 424 | + // executions, so `success` beside `failures: 1` is the contract, not a |
| 425 | + // contradiction. |
| 426 | + const call = summary.nodes.find((node) => node.nodeId === 'call'); |
| 427 | + expect(call?.status).toBe('success'); |
| 428 | + expect(call?.failures).toBe(1); |
| 429 | + }); |
| 430 | + |
| 431 | + it('the control keeps counting as before: a child that FAILED is the delegating step\'s own failure, counted once (#15617)', () => { |
| 432 | + // Same parent, but the child FAILED on the last row rather than containing |
| 433 | + // the failure. That is the subflow step's own `status: failure` — one |
| 434 | + // execution failed — and nothing rides up on top of it: the child's own |
| 435 | + // `failed` stays on the child's run row, so the parent reads 1, not 2. |
| 436 | + const summary = FlowRunSummarySchema.parse({ |
| 437 | + selected: 5, acted: 4, skipped: 0, failed: 1, |
| 438 | + nodes: [ |
| 439 | + { nodeId: 'each', nodeType: 'loop', status: 'success' as const, runs: 1, failures: 0, skipped: 0 }, |
| 440 | + { nodeId: 'call', nodeType: 'subflow', status: 'failure' as const, runs: 5, failures: 1, skipped: 0, selected: 5, acted: 4 }, |
| 441 | + ], |
| 442 | + gates: [], |
| 443 | + }); |
| 444 | + expect(summary.failed).toBe(1); |
| 445 | + expect(summary.failed).toBe(summary.nodes.reduce((sum, node) => sum + node.failures, 0)); |
| 446 | + }); |
| 447 | + |
| 448 | + it('declares the roll-up at the point of use — the field describes say so, not only the paragraph above the schema (#15617)', () => { |
| 449 | + // Triage's explicit failure mode for this card: a reconciliation that |
| 450 | + // leaves `failed`'s own `.describe()` saying the narrow thing. A consumer |
| 451 | + // reads the field's description, so the widened rule has to be there. |
| 452 | + const failedDoc = FlowRunSummarySchema.shape.failed.description ?? ''; |
| 453 | + expect(failedDoc).toContain('a fold of `nodes[].failures`'); |
| 454 | + expect(failedDoc).toContain('INCLUDING'); |
| 455 | + expect(failedDoc).toContain('`subflow`'); |
| 456 | + expect(failedDoc).toContain('`map`'); |
| 457 | + expect(failedDoc).toContain('what did this run cause'); |
| 458 | + |
| 459 | + const nodeFailuresDoc = FlowRunNodeSummarySchema.shape.failures.description ?? ''; |
| 460 | + expect(nodeFailuresDoc).toContain('`metrics.failures`'); |
| 461 | + expect(nodeFailuresDoc).toContain('the run-level `failed` is the sum of this across `nodes`'); |
| 462 | + |
| 463 | + const nodeStatusDoc = FlowRunNodeSummarySchema.shape.status.description ?? ''; |
| 464 | + expect(nodeStatusDoc).toContain('OWN executions'); |
| 465 | + expect(nodeStatusDoc).toContain('`failures > 0`'); |
| 466 | + }); |
| 467 | + |
353 | 468 | it('leaves `failed` absent on a run that never tracked it — absent is not zero, and is not defaulted', () => { |
354 | 469 | // Same convention as `unmeasured`: a run recorded before the field existed |
355 | 470 | // did not count contained failures, and a `0` here would tell an operator |
|
0 commit comments