@shipfox/api-workflows 12.1.0 → 12.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +45 -0
  3. package/README.md +20 -0
  4. package/dist/config.d.ts +11 -0
  5. package/dist/config.d.ts.map +1 -0
  6. package/dist/config.js +56 -0
  7. package/dist/config.js.map +1 -0
  8. package/dist/core/entities/job.d.ts +1 -1
  9. package/dist/core/entities/job.d.ts.map +1 -1
  10. package/dist/core/entities/step.d.ts +2 -2
  11. package/dist/core/entities/step.d.ts.map +1 -1
  12. package/dist/core/errors.d.ts +18 -1
  13. package/dist/core/errors.d.ts.map +1 -1
  14. package/dist/core/errors.js +21 -2
  15. package/dist/core/errors.js.map +1 -1
  16. package/dist/core/step-config/assemble-run-context.d.ts +15 -5
  17. package/dist/core/step-config/assemble-run-context.d.ts.map +1 -1
  18. package/dist/core/step-config/assemble-run-context.js +77 -27
  19. package/dist/core/step-config/assemble-run-context.js.map +1 -1
  20. package/dist/core/step-config/fields.d.ts +5 -0
  21. package/dist/core/step-config/fields.d.ts.map +1 -1
  22. package/dist/core/step-config/fields.js +21 -3
  23. package/dist/core/step-config/fields.js.map +1 -1
  24. package/dist/core/step-config/index.d.ts +1 -1
  25. package/dist/core/step-config/index.d.ts.map +1 -1
  26. package/dist/core/step-config/index.js +1 -1
  27. package/dist/core/step-config/index.js.map +1 -1
  28. package/dist/core/step-config/job-output-limits.d.ts +11 -0
  29. package/dist/core/step-config/job-output-limits.d.ts.map +1 -0
  30. package/dist/core/step-config/job-output-limits.js +88 -0
  31. package/dist/core/step-config/job-output-limits.js.map +1 -0
  32. package/dist/core/step-config/materialize-workflow-model.d.ts +1 -1
  33. package/dist/core/step-config/materialize-workflow-model.d.ts.map +1 -1
  34. package/dist/core/step-config/materialize-workflow-model.js +47 -18
  35. package/dist/core/step-config/materialize-workflow-model.js.map +1 -1
  36. package/dist/db/db.d.ts +50 -50
  37. package/dist/db/index.d.ts +2 -2
  38. package/dist/db/index.d.ts.map +1 -1
  39. package/dist/db/index.js +1 -1
  40. package/dist/db/index.js.map +1 -1
  41. package/dist/db/job-listeners.d.ts.map +1 -1
  42. package/dist/db/job-listeners.js +6 -5
  43. package/dist/db/job-listeners.js.map +1 -1
  44. package/dist/db/schema/job-executions.d.ts +3 -3
  45. package/dist/db/schema/job-listener-events.d.ts +1 -1
  46. package/dist/db/schema/jobs.d.ts +7 -7
  47. package/dist/db/schema/outbox.d.ts +1 -1
  48. package/dist/db/schema/step-attempts.d.ts +2 -2
  49. package/dist/db/schema/steps.d.ts +5 -5
  50. package/dist/db/schema/workflow-run-attempts.d.ts +3 -3
  51. package/dist/db/schema/workflow-run-counters.d.ts +1 -1
  52. package/dist/db/schema/workflow-runs.d.ts +2 -2
  53. package/dist/db/workflow-runs/job-executions.d.ts.map +1 -1
  54. package/dist/db/workflow-runs/job-executions.js +4 -2
  55. package/dist/db/workflow-runs/job-executions.js.map +1 -1
  56. package/dist/db/workflow-runs/jobs.d.ts.map +1 -1
  57. package/dist/db/workflow-runs/jobs.js +33 -3
  58. package/dist/db/workflow-runs/jobs.js.map +1 -1
  59. package/dist/db/workflow-runs/outbox.d.ts +1 -0
  60. package/dist/db/workflow-runs/outbox.d.ts.map +1 -1
  61. package/dist/db/workflow-runs/outbox.js +1 -0
  62. package/dist/db/workflow-runs/outbox.js.map +1 -1
  63. package/dist/db/workflow-runs/steps.d.ts +28 -0
  64. package/dist/db/workflow-runs/steps.d.ts.map +1 -1
  65. package/dist/db/workflow-runs/steps.js +49 -1
  66. package/dist/db/workflow-runs/steps.js.map +1 -1
  67. package/dist/db/workflow-runs.d.ts +2 -2
  68. package/dist/db/workflow-runs.d.ts.map +1 -1
  69. package/dist/db/workflow-runs.js +1 -1
  70. package/dist/db/workflow-runs.js.map +1 -1
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +4 -2
  73. package/dist/index.js.map +1 -1
  74. package/dist/metrics/instance.d.ts +1 -0
  75. package/dist/metrics/instance.d.ts.map +1 -1
  76. package/dist/metrics/instance.js +8 -0
  77. package/dist/metrics/instance.js.map +1 -1
  78. package/dist/presentation/dto/evaluation-trace.d.ts +4 -0
  79. package/dist/presentation/dto/evaluation-trace.d.ts.map +1 -0
  80. package/dist/presentation/dto/evaluation-trace.js +39 -0
  81. package/dist/presentation/dto/evaluation-trace.js.map +1 -0
  82. package/dist/presentation/dto/index.d.ts +2 -1
  83. package/dist/presentation/dto/index.d.ts.map +1 -1
  84. package/dist/presentation/dto/index.js +2 -1
  85. package/dist/presentation/dto/index.js.map +1 -1
  86. package/dist/presentation/dto/job.d.ts.map +1 -1
  87. package/dist/presentation/dto/job.js +6 -0
  88. package/dist/presentation/dto/job.js.map +1 -1
  89. package/dist/presentation/dto/step.d.ts +2 -1
  90. package/dist/presentation/dto/step.d.ts.map +1 -1
  91. package/dist/presentation/dto/step.js +12 -0
  92. package/dist/presentation/dto/step.js.map +1 -1
  93. package/dist/presentation/index.d.ts +1 -1
  94. package/dist/presentation/index.d.ts.map +1 -1
  95. package/dist/presentation/index.js +1 -1
  96. package/dist/presentation/index.js.map +1 -1
  97. package/dist/presentation/routes/get-step-attempt-detail.d.ts +3 -0
  98. package/dist/presentation/routes/get-step-attempt-detail.d.ts.map +1 -0
  99. package/dist/presentation/routes/get-step-attempt-detail.js +38 -0
  100. package/dist/presentation/routes/get-step-attempt-detail.js.map +1 -0
  101. package/dist/presentation/routes/index.d.ts.map +1 -1
  102. package/dist/presentation/routes/index.js +2 -0
  103. package/dist/presentation/routes/index.js.map +1 -1
  104. package/dist/presentation/routes/project-access.d.ts +1 -1
  105. package/dist/presentation/subscribers/index.d.ts +1 -0
  106. package/dist/presentation/subscribers/index.d.ts.map +1 -1
  107. package/dist/presentation/subscribers/index.js +1 -0
  108. package/dist/presentation/subscribers/index.js.map +1 -1
  109. package/dist/presentation/subscribers/on-failure-annotations.d.ts +5 -0
  110. package/dist/presentation/subscribers/on-failure-annotations.d.ts.map +1 -0
  111. package/dist/presentation/subscribers/on-failure-annotations.js +160 -0
  112. package/dist/presentation/subscribers/on-failure-annotations.js.map +1 -0
  113. package/dist/temporal/activities/index.d.ts +1 -1
  114. package/dist/temporal/activities/index.d.ts.map +1 -1
  115. package/dist/temporal/activities/orchestration-activities.d.ts +1 -1
  116. package/dist/temporal/activities/orchestration-activities.d.ts.map +1 -1
  117. package/dist/temporal/workflows/test-env.d.ts +2 -2
  118. package/dist/tsconfig.test.tsbuildinfo +1 -1
  119. package/package.json +18 -16
  120. package/src/config.test.ts +85 -0
  121. package/src/config.ts +71 -0
  122. package/src/core/errors.ts +44 -2
  123. package/src/core/job-execution.test.ts +1 -0
  124. package/src/core/step-config/assemble-run-context.test.ts +232 -20
  125. package/src/core/step-config/assemble-run-context.ts +126 -28
  126. package/src/core/step-config/fields.ts +42 -3
  127. package/src/core/step-config/index.ts +2 -0
  128. package/src/core/step-config/job-output-limits.ts +130 -0
  129. package/src/core/step-config/materialize-workflow-model.catalog.test.ts +117 -0
  130. package/src/core/step-config/materialize-workflow-model.test.ts +306 -1
  131. package/src/core/step-config/materialize-workflow-model.ts +65 -18
  132. package/src/db/index.ts +4 -0
  133. package/src/db/job-listeners.ts +14 -3
  134. package/src/db/workflow-runs/job-executions.test.ts +207 -1
  135. package/src/db/workflow-runs/job-executions.ts +15 -2
  136. package/src/db/workflow-runs/job-status.test.ts +10 -3
  137. package/src/db/workflow-runs/jobs.test.ts +49 -0
  138. package/src/db/workflow-runs/jobs.ts +53 -6
  139. package/src/db/workflow-runs/outbox.ts +7 -1
  140. package/src/db/workflow-runs/run-create.test.ts +56 -0
  141. package/src/db/workflow-runs/steps.test.ts +148 -0
  142. package/src/db/workflow-runs/steps.ts +105 -1
  143. package/src/db/workflow-runs.ts +4 -0
  144. package/src/index.ts +9 -0
  145. package/src/metrics/instance.ts +10 -0
  146. package/src/presentation/dto/evaluation-trace.ts +27 -0
  147. package/src/presentation/dto/index.ts +2 -1
  148. package/src/presentation/dto/job.ts +6 -0
  149. package/src/presentation/dto/step.test.ts +96 -1
  150. package/src/presentation/dto/step.ts +17 -0
  151. package/src/presentation/index.ts +2 -0
  152. package/src/presentation/routes/get-step-attempt-detail.test.ts +159 -0
  153. package/src/presentation/routes/get-step-attempt-detail.ts +33 -0
  154. package/src/presentation/routes/index.ts +2 -0
  155. package/src/presentation/subscribers/index.ts +4 -0
  156. package/src/presentation/subscribers/on-failure-annotations.test.ts +510 -0
  157. package/src/presentation/subscribers/on-failure-annotations.ts +237 -0
  158. package/test/factories/workflow-model.ts +2 -0
  159. package/tsconfig.build.tsbuildinfo +1 -1
@@ -7,10 +7,24 @@ import {
7
7
  import {parseWorkflowTemplate, planInterpolationField} from '@shipfox/expression';
8
8
  import {createInterModuleKnownError} from '@shipfox/inter-module';
9
9
  import type {AgentDefaultsResolver} from '#core/agent-defaults.js';
10
- import {AgentConfigUnresolvableError, InterpolationUnresolvableError} from '#core/errors.js';
10
+ import {
11
+ AgentConfigUnresolvableError,
12
+ InterpolationUnresolvableError,
13
+ JobOutputNotJsonSafeError,
14
+ JobOutputTooLargeError,
15
+ JobOutputTooManyEntriesError,
16
+ } from '#core/errors.js';
11
17
  import {resolveTestAgentDefaults} from '#test/fixtures/agent-inter-module.js';
12
18
  import {workflowModel} from '#test/index.js';
13
19
  import {
20
+ MAX_JOB_OUTPUT_ENTRIES,
21
+ MAX_JOB_OUTPUT_NESTING_DEPTH,
22
+ MAX_JOB_OUTPUT_VALUE_BYTES,
23
+ MAX_JOB_OUTPUTS_TOTAL_BYTES,
24
+ normalizeJobOutputValue,
25
+ } from './job-output-limits.js';
26
+ import {
27
+ materializeJobOutputs,
14
28
  materializeJobRunner,
15
29
  materializeWorkflowModel as materializeWorkflowModelImpl,
16
30
  modelHasAgentStep,
@@ -71,6 +85,38 @@ function creationContext(
71
85
  return {site: 'run-creation', values};
72
86
  }
73
87
 
88
+ function outputJob(
89
+ outputs: Readonly<Record<string, string>>,
90
+ outputTypes?: WorkflowModel['jobs'][number]['outputTypes'],
91
+ ): WorkflowModel['jobs'][number] {
92
+ const model = workflowModel({
93
+ jobs: {
94
+ build: {
95
+ steps: [{key: 'collect', run: 'echo collect'}],
96
+ outputs,
97
+ ...(outputTypes === undefined ? {} : {outputTypes}),
98
+ },
99
+ },
100
+ });
101
+ const job = model.jobs[0];
102
+ if (!job) throw new Error('Test model created no jobs');
103
+ return job;
104
+ }
105
+
106
+ function outputContext(outputs: Record<string, unknown>): WorkflowEvaluationContext {
107
+ return {
108
+ site: 'execution-resolution',
109
+ values: {
110
+ steps: {
111
+ collect: {
112
+ status: 'succeeded',
113
+ outputs,
114
+ },
115
+ },
116
+ },
117
+ };
118
+ }
119
+
74
120
  describe('materializeWorkflowModel', () => {
75
121
  it('keeps literal job names in runtime jobs', async () => {
76
122
  const model = workflowModel({
@@ -1429,3 +1475,262 @@ describe('materializeJobRunner', () => {
1429
1475
  expect(act).toThrow('Job runner labels are invalid: not valid');
1430
1476
  });
1431
1477
  });
1478
+
1479
+ describe('materializeJobOutputs', () => {
1480
+ it('preserves structured values from typed single-expression mappings', () => {
1481
+ const value = [{severity: 'high'}, {severity: 'medium'}];
1482
+ const job = outputJob(
1483
+ {findings: template('steps.collect.outputs.findings')},
1484
+ {
1485
+ findings: {
1486
+ kind: 'list',
1487
+ element: {kind: 'object', fields: {severity: 'string'}},
1488
+ },
1489
+ },
1490
+ );
1491
+
1492
+ const result = materializeJobOutputs({
1493
+ job,
1494
+ context: outputContext({findings: value}),
1495
+ definitionId: 'definition-1',
1496
+ });
1497
+
1498
+ expect(result).toEqual({findings: value});
1499
+ });
1500
+
1501
+ it('preserves number and boolean values from typed mappings', () => {
1502
+ const job = outputJob(
1503
+ {
1504
+ count: template('steps.collect.outputs.count'),
1505
+ ready: template('steps.collect.outputs.ready'),
1506
+ },
1507
+ {count: 'double', ready: 'bool'},
1508
+ );
1509
+
1510
+ const result = materializeJobOutputs({
1511
+ job,
1512
+ context: outputContext({count: 42, ready: true}),
1513
+ definitionId: 'definition-1',
1514
+ });
1515
+
1516
+ expect(result).toEqual({count: 42, ready: true});
1517
+ });
1518
+
1519
+ it('normalizes typed integer values into JSON-safe values', () => {
1520
+ const job = outputJob(
1521
+ {
1522
+ count: template('steps.collect.outputs.count'),
1523
+ preciseCount: template('steps.collect.outputs.preciseCount'),
1524
+ },
1525
+ {count: 'int', preciseCount: 'int'},
1526
+ );
1527
+
1528
+ const result = materializeJobOutputs({
1529
+ job,
1530
+ context: outputContext({count: 42n, preciseCount: 9007199254740993n}),
1531
+ definitionId: 'definition-1',
1532
+ });
1533
+
1534
+ expect(result).toEqual({count: 42, preciseCount: '9007199254740993'});
1535
+ expect(JSON.parse(JSON.stringify(result))).toEqual(result);
1536
+ });
1537
+
1538
+ it('normalizes typed timestamps into JSON-safe ISO strings', () => {
1539
+ const job = outputJob(
1540
+ {createdAt: template('steps.collect.outputs.createdAt')},
1541
+ {createdAt: 'timestamp'},
1542
+ );
1543
+
1544
+ const result = materializeJobOutputs({
1545
+ job,
1546
+ context: outputContext({createdAt: new Date('2026-08-06T12:34:56.000Z')}),
1547
+ definitionId: 'definition-1',
1548
+ });
1549
+
1550
+ expect(result).toEqual({createdAt: '2026-08-06T12:34:56.000Z'});
1551
+ expect(JSON.parse(JSON.stringify(result))).toEqual(result);
1552
+ });
1553
+
1554
+ it('stringifies mixed templates', () => {
1555
+ const job = outputJob({value: `prefix-${template('steps.collect.outputs.value')}`});
1556
+
1557
+ const result = materializeJobOutputs({
1558
+ job,
1559
+ context: outputContext({value: 42}),
1560
+ definitionId: 'definition-1',
1561
+ });
1562
+
1563
+ expect(result).toEqual({value: 'prefix-42'});
1564
+ });
1565
+
1566
+ it('stringifies outputs whose inferred type is string', () => {
1567
+ const job = outputJob({value: template('steps.collect.outputs.value')}, {value: 'string'});
1568
+
1569
+ const result = materializeJobOutputs({
1570
+ job,
1571
+ context: outputContext({value: 42}),
1572
+ definitionId: 'definition-1',
1573
+ });
1574
+
1575
+ expect(result).toEqual({value: '42'});
1576
+ });
1577
+
1578
+ it('stringifies exact mappings without an inferred type', () => {
1579
+ const job = outputJob({value: template('steps.collect.outputs.value')});
1580
+
1581
+ const result = materializeJobOutputs({
1582
+ job,
1583
+ context: outputContext({value: {severity: 'high'}}),
1584
+ definitionId: 'definition-1',
1585
+ });
1586
+
1587
+ expect(result).toEqual({value: '{"severity":"high"}'});
1588
+ });
1589
+
1590
+ it('keeps prototype-named untyped outputs as JSON strings', () => {
1591
+ const outputs = Object.fromEntries([
1592
+ ['toString', template('steps.collect.outputs.value')],
1593
+ ['__proto__', template('steps.collect.outputs.value')],
1594
+ ]);
1595
+
1596
+ const result = materializeJobOutputs({
1597
+ job: outputJob(outputs),
1598
+ context: outputContext({value: {value: 'structured'}}),
1599
+ definitionId: 'definition-1',
1600
+ });
1601
+
1602
+ expect(Object.keys(result ?? {})).toEqual(['toString', '__proto__']);
1603
+ expect(Object.getOwnPropertyDescriptor(result ?? {}, 'toString')?.value).toBe(
1604
+ '{"value":"structured"}',
1605
+ );
1606
+ expect(Object.getOwnPropertyDescriptor(result ?? {}, '__proto__')?.value).toBe(
1607
+ '{"value":"structured"}',
1608
+ );
1609
+ });
1610
+
1611
+ it('measures structured values as JSON bytes for the per-value cap', () => {
1612
+ const job = outputJob(
1613
+ {payload: template('steps.collect.outputs.payload')},
1614
+ {payload: {kind: 'list', element: 'string'}},
1615
+ );
1616
+ const payload = ['x'.repeat(MAX_JOB_OUTPUT_VALUE_BYTES - 1)];
1617
+
1618
+ const materialize = () =>
1619
+ materializeJobOutputs({
1620
+ job,
1621
+ context: outputContext({payload}),
1622
+ definitionId: 'definition-1',
1623
+ });
1624
+
1625
+ expect(materialize).toThrow(JobOutputTooLargeError);
1626
+ expect(materialize).toThrow(`Job output "payload" exceeds the ${MAX_JOB_OUTPUT_VALUE_BYTES}`);
1627
+ });
1628
+
1629
+ it('measures string values as UTF-8 bytes for the per-value cap', () => {
1630
+ const job = outputJob({payload: template('steps.collect.outputs.payload')});
1631
+ const payload = 'é'.repeat(Math.ceil(MAX_JOB_OUTPUT_VALUE_BYTES / 2) + 1);
1632
+
1633
+ const materialize = () =>
1634
+ materializeJobOutputs({
1635
+ job,
1636
+ context: outputContext({payload}),
1637
+ definitionId: 'definition-1',
1638
+ });
1639
+
1640
+ expect(materialize).toThrow(JobOutputTooLargeError);
1641
+ });
1642
+
1643
+ it('rejects the total materialized job output cap', () => {
1644
+ const keys = ['one', 'two', 'three', 'four', 'five'];
1645
+ const outputs = Object.fromEntries(
1646
+ keys.map((key) => [key, template(`steps.collect.outputs.${key}`)]),
1647
+ );
1648
+ const values = Object.fromEntries(
1649
+ keys.map((key) => [key, 'x'.repeat(MAX_JOB_OUTPUT_VALUE_BYTES)]),
1650
+ );
1651
+ const job = outputJob(outputs);
1652
+
1653
+ const materialize = () =>
1654
+ materializeJobOutputs({job, context: outputContext(values), definitionId: 'definition-1'});
1655
+
1656
+ expect(materialize).toThrow(JobOutputTooLargeError);
1657
+ expect(materialize).toThrow(
1658
+ `Job outputs exceed the ${MAX_JOB_OUTPUTS_TOTAL_BYTES}-byte total limit (at "four")`,
1659
+ );
1660
+ });
1661
+
1662
+ it('counts output keys and JSON encoding overhead against the total cap', () => {
1663
+ const keys = ['a', 'b', 'c', 'd'].map((suffix) => `output${suffix}${'x'.repeat(256)}`);
1664
+ const outputs = Object.fromEntries(
1665
+ keys.map((key) => [key, template(`steps.collect.outputs.${key}`)]),
1666
+ );
1667
+ const values = Object.fromEntries(
1668
+ keys.map((key) => [key, 'x'.repeat(MAX_JOB_OUTPUT_VALUE_BYTES - 16)]),
1669
+ );
1670
+ const job = outputJob(outputs);
1671
+
1672
+ const materialize = () =>
1673
+ materializeJobOutputs({job, context: outputContext(values), definitionId: 'definition-1'});
1674
+
1675
+ expect(materialize).toThrow(JobOutputTooLargeError);
1676
+ expect(materialize).toThrow(
1677
+ `Job outputs exceed the ${MAX_JOB_OUTPUTS_TOTAL_BYTES}-byte total limit`,
1678
+ );
1679
+ });
1680
+
1681
+ it('rejects job output maps with too many entries', () => {
1682
+ const outputs = Object.fromEntries(
1683
+ Array.from({length: MAX_JOB_OUTPUT_ENTRIES + 1}, (_, index) => {
1684
+ const key = `output${index}`;
1685
+ return [key, template(`steps.collect.outputs.${key}`)];
1686
+ }),
1687
+ );
1688
+ const job = outputJob(outputs);
1689
+
1690
+ const materialize = () =>
1691
+ materializeJobOutputs({job, context: outputContext({}), definitionId: 'definition-1'});
1692
+
1693
+ expect(materialize).toThrow(JobOutputTooManyEntriesError);
1694
+ expect(materialize).toThrow(
1695
+ `Job outputs cannot define more than ${MAX_JOB_OUTPUT_ENTRIES} entries`,
1696
+ );
1697
+ });
1698
+
1699
+ it('rejects unsupported values with a JSON persistence error', () => {
1700
+ const circular: Record<string, unknown> = {};
1701
+ circular.self = circular;
1702
+
1703
+ expect(() => normalizeJobOutputValue(circular, 'payload')).toThrow(JobOutputNotJsonSafeError);
1704
+ expect(() => normalizeJobOutputValue(circular, 'payload')).toThrow(
1705
+ 'Job output "payload" cannot be persisted as JSON',
1706
+ );
1707
+ });
1708
+
1709
+ it('rejects deeply nested values with a JSON persistence error', () => {
1710
+ let value: unknown = 'leaf';
1711
+ for (let index = 0; index <= MAX_JOB_OUTPUT_NESTING_DEPTH; index += 1) {
1712
+ value = {value};
1713
+ }
1714
+
1715
+ expect(() => normalizeJobOutputValue(value, 'payload')).toThrow(JobOutputNotJsonSafeError);
1716
+ expect(() => normalizeJobOutputValue(value, 'payload')).toThrow(
1717
+ `values cannot be nested deeper than ${MAX_JOB_OUTPUT_NESTING_DEPTH} levels`,
1718
+ );
1719
+ });
1720
+
1721
+ it('fails when a typed output path is missing', () => {
1722
+ const job = outputJob(
1723
+ {findings: template('steps.collect.outputs.findings')},
1724
+ {findings: {kind: 'list', element: 'string'}},
1725
+ );
1726
+
1727
+ const materialize = () =>
1728
+ materializeJobOutputs({
1729
+ job,
1730
+ context: outputContext({}),
1731
+ definitionId: 'definition-1',
1732
+ });
1733
+
1734
+ expect(materialize).toThrow(InterpolationUnresolvableError);
1735
+ });
1736
+ });
@@ -1,12 +1,26 @@
1
1
  import {DEFAULT_JOB_CHECKOUT, type WorkflowModel} from '@shipfox/api-definitions-dto';
2
- import {canonicalizeLabels, findInvalidLabels, MAX_RUNNER_LABELS} from '@shipfox/runner-labels';
2
+ import {findInvalidLabels, MAX_RUNNER_LABELS, resolveRunnerLabels} from '@shipfox/runner-labels';
3
+ import {runnerCatalog} from '#config.js';
3
4
  import type {AgentDefaultsResolver} from '#core/agent-defaults.js';
4
5
  import type {
5
6
  AgentToolMaterializationContext,
6
7
  AgentToolMaterializationSnapshot,
7
8
  } from '#core/agent-tools.js';
8
- import {InvalidJobRunnerLabelsError} from '#core/errors.js';
9
- import {completeStepField} from './fields.js';
9
+ import {
10
+ InvalidJobRunnerLabelsError,
11
+ JobOutputTooLargeError,
12
+ JobOutputTooManyEntriesError,
13
+ } from '#core/errors.js';
14
+ import {completeStepField, completeStepFieldWithType} from './fields.js';
15
+ import {
16
+ type JsonSafeJobOutputValue,
17
+ jobOutputRecordEntryByteLength,
18
+ jobOutputValueByteLength,
19
+ MAX_JOB_OUTPUT_ENTRIES,
20
+ MAX_JOB_OUTPUT_VALUE_BYTES,
21
+ MAX_JOB_OUTPUTS_TOTAL_BYTES,
22
+ normalizeJobOutputValue,
23
+ } from './job-output-limits.js';
10
24
  import {
11
25
  type MaterializedWorkflowStep,
12
26
  materializeJobExecutionSteps,
@@ -99,10 +113,11 @@ export function materializeJobRunner(params: {
99
113
  definitionId: params.definitionId,
100
114
  }),
101
115
  );
102
- const labels = canonicalizeLabels([...params.job.runner, ...resolvedLabels]);
116
+ const requestedLabels = [...params.job.runner, ...resolvedLabels];
117
+ const labels = resolveRunnerLabels(requestedLabels, runnerCatalog);
103
118
  const invalidLabels = findInvalidLabels(labels);
104
119
  if (labels.length === 0 || labels.length > MAX_RUNNER_LABELS || invalidLabels.length > 0) {
105
- throw new InvalidJobRunnerLabelsError(labels);
120
+ throw new InvalidJobRunnerLabelsError(labels, requestedLabels);
106
121
  }
107
122
  return labels;
108
123
  }
@@ -111,22 +126,54 @@ export function materializeJobOutputs(params: {
111
126
  readonly job: WorkflowModelJob;
112
127
  readonly context: WorkflowEvaluationContext;
113
128
  readonly definitionId: string;
114
- }): Record<string, string> | null {
129
+ }): Record<string, unknown> | null {
115
130
  const outputs = params.job.outputs;
116
131
  if (outputs === undefined) return null;
117
132
 
118
- return Object.fromEntries(
119
- Object.entries(outputs).map(([key, template]) => [
120
- key,
121
- completeStepField({
122
- field: 'job.outputs',
123
- errorField: 'job.outputs',
124
- template: {segments: template},
125
- context: params.context,
126
- definitionId: params.definitionId,
127
- }),
128
- ]),
129
- );
133
+ const outputEntries = Object.entries(outputs);
134
+ if (outputEntries.length > MAX_JOB_OUTPUT_ENTRIES) {
135
+ throw new JobOutputTooManyEntriesError(outputEntries.length, MAX_JOB_OUTPUT_ENTRIES);
136
+ }
137
+
138
+ const materialized: Record<string, JsonSafeJobOutputValue> = {};
139
+ // This is exactly JSON.stringify(materialized): start with "{}", then add each
140
+ // key/value pair and its comma (except for the first entry).
141
+ let recordBytes = 2;
142
+
143
+ for (const [index, [key, template]] of outputEntries.entries()) {
144
+ const outputTypes = params.job.outputTypes;
145
+ const outputType =
146
+ outputTypes !== undefined && Object.hasOwn(outputTypes, key) ? outputTypes[key] : undefined;
147
+ const completionParams = {
148
+ field: 'job.outputs' as const,
149
+ errorField: 'job.outputs' as const,
150
+ template: {segments: template},
151
+ context: params.context,
152
+ definitionId: params.definitionId,
153
+ };
154
+ const value =
155
+ outputType === undefined || outputType === 'string'
156
+ ? completeStepField(completionParams)
157
+ : completeStepFieldWithType(completionParams);
158
+ const normalizedValue = normalizeJobOutputValue(value, key);
159
+ const valueBytes = jobOutputValueByteLength(normalizedValue);
160
+ if (valueBytes > MAX_JOB_OUTPUT_VALUE_BYTES) {
161
+ throw new JobOutputTooLargeError(key, MAX_JOB_OUTPUT_VALUE_BYTES, 'value');
162
+ }
163
+
164
+ recordBytes += (index === 0 ? 0 : 1) + jobOutputRecordEntryByteLength(key, normalizedValue);
165
+ Object.defineProperty(materialized, key, {
166
+ configurable: true,
167
+ enumerable: true,
168
+ value: normalizedValue,
169
+ writable: true,
170
+ });
171
+ if (recordBytes > MAX_JOB_OUTPUTS_TOTAL_BYTES) {
172
+ throw new JobOutputTooLargeError(key, MAX_JOB_OUTPUTS_TOTAL_BYTES, 'total');
173
+ }
174
+ }
175
+
176
+ return materialized;
130
177
  }
131
178
 
132
179
  export function modelHasAgentStep(model: WorkflowModel): boolean {
package/src/db/index.ts CHANGED
@@ -34,6 +34,7 @@ export type {
34
34
  JobActivationDecision,
35
35
  ListWorkflowRunsParams,
36
36
  ListWorkflowRunsResult,
37
+ StepAttemptDetail,
37
38
  UpdateJobExecutionStatusParams,
38
39
  UpdateJobStatusParams,
39
40
  UpdateWorkflowRunStatusParams,
@@ -58,12 +59,15 @@ export {
58
59
  getJobById,
59
60
  getJobExecutionById,
60
61
  getJobExecutionDetail,
62
+ getJobExecutionFailureOrigin,
61
63
  getJobExecutionsByJobId,
62
64
  getJobExecutionsByWorkflowRunAttemptId,
63
65
  getJobScope,
64
66
  getJobsByWorkflowRunAttemptId,
65
67
  getJobsByWorkflowRunId,
66
68
  getLatestAttempt,
69
+ getLatestJobExecutionByJobId,
70
+ getStepAttemptDetail,
67
71
  getStepAttempts,
68
72
  getStepAttemptsByJobIds,
69
73
  getStepById,
@@ -27,6 +27,7 @@ import {
27
27
  applyListenerFilterSnapshots,
28
28
  assembleListenerSnapshotContext,
29
29
  type ListenerTriggerWithSnapshot,
30
+ listenerFilterOutputTypesForJobs,
30
31
  planListenerFilterSnapshots,
31
32
  } from '#core/step-config/assemble-run-context.js';
32
33
  import {
@@ -73,8 +74,9 @@ type JobActivatedListenerMatcher = Extract<
73
74
  function applyActivatedListenerFilterSnapshots(
74
75
  plans: Parameters<typeof applyListenerFilterSnapshots>[0],
75
76
  context: Parameters<typeof applyListenerFilterSnapshots>[1],
77
+ outputTypes: Parameters<typeof applyListenerFilterSnapshots>[2],
76
78
  ): JobActivatedListenerMatcher[] {
77
- return applyListenerFilterSnapshots(plans, context);
79
+ return applyListenerFilterSnapshots(plans, context, outputTypes);
78
80
  }
79
81
 
80
82
  export async function activateJobListener(
@@ -150,6 +152,7 @@ export async function activateJobListener(
150
152
  plan: snapshotPlan,
151
153
  dependencyJobs,
152
154
  });
155
+ const listenerOutputTypes = listenerFilterOutputTypesForJobs(dependencyJobs);
153
156
 
154
157
  await writeWorkflowsOutboxEvent(tx, {
155
158
  type: WORKFLOWS_JOB_ACTIVATED,
@@ -158,11 +161,19 @@ export async function activateJobListener(
158
161
  workflowRunId: target.run.id,
159
162
  workspaceId: target.run.workspaceId,
160
163
  mode: 'listening',
161
- on: applyActivatedListenerFilterSnapshots(snapshotPlan.on, snapshotContext),
164
+ on: applyActivatedListenerFilterSnapshots(
165
+ snapshotPlan.on,
166
+ snapshotContext,
167
+ listenerOutputTypes,
168
+ ),
162
169
  until:
163
170
  matchers.until === null
164
171
  ? null
165
- : applyActivatedListenerFilterSnapshots(snapshotPlan.until, snapshotContext),
172
+ : applyActivatedListenerFilterSnapshots(
173
+ snapshotPlan.until,
174
+ snapshotContext,
175
+ listenerOutputTypes,
176
+ ),
166
177
  },
167
178
  });
168
179
  }