@shipfox/api-workflows 12.4.0 → 12.6.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 (84) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +24 -0
  3. package/dist/core/entities/job-execution.d.ts +1 -0
  4. package/dist/core/entities/job-execution.d.ts.map +1 -1
  5. package/dist/core/entities/job-execution.js.map +1 -1
  6. package/dist/core/entities/job.d.ts +1 -1
  7. package/dist/core/entities/job.d.ts.map +1 -1
  8. package/dist/core/entities/job.js +3 -1
  9. package/dist/core/entities/job.js.map +1 -1
  10. package/dist/core/errors.d.ts +3 -1
  11. package/dist/core/errors.d.ts.map +1 -1
  12. package/dist/core/errors.js +4 -2
  13. package/dist/core/errors.js.map +1 -1
  14. package/dist/core/step-config/job-output-limits.d.ts.map +1 -1
  15. package/dist/core/step-config/job-output-limits.js +2 -2
  16. package/dist/core/step-config/job-output-limits.js.map +1 -1
  17. package/dist/core/step-config/materialize-workflow-model.js +2 -2
  18. package/dist/core/step-config/materialize-workflow-model.js.map +1 -1
  19. package/dist/db/db.d.ts +42 -8
  20. package/dist/db/db.d.ts.map +1 -1
  21. package/dist/db/index.d.ts +1 -1
  22. package/dist/db/index.d.ts.map +1 -1
  23. package/dist/db/index.js.map +1 -1
  24. package/dist/db/schema/job-executions.d.ts +19 -2
  25. package/dist/db/schema/job-executions.d.ts.map +1 -1
  26. package/dist/db/schema/job-executions.js +2 -0
  27. package/dist/db/schema/job-executions.js.map +1 -1
  28. package/dist/db/schema/jobs.d.ts +3 -3
  29. package/dist/db/schema/jobs.d.ts.map +1 -1
  30. package/dist/db/schema/jobs.js +3 -0
  31. package/dist/db/schema/jobs.js.map +1 -1
  32. package/dist/db/workflow-runs/job-executions.d.ts +7 -0
  33. package/dist/db/workflow-runs/job-executions.d.ts.map +1 -1
  34. package/dist/db/workflow-runs/job-executions.js +26 -4
  35. package/dist/db/workflow-runs/job-executions.js.map +1 -1
  36. package/dist/db/workflow-runs/jobs.d.ts.map +1 -1
  37. package/dist/db/workflow-runs/jobs.js +4 -2
  38. package/dist/db/workflow-runs/jobs.js.map +1 -1
  39. package/dist/db/workflow-runs/queries.d.ts +14 -2
  40. package/dist/db/workflow-runs/queries.d.ts.map +1 -1
  41. package/dist/db/workflow-runs/queries.js +52 -13
  42. package/dist/db/workflow-runs/queries.js.map +1 -1
  43. package/dist/db/workflow-runs.d.ts +1 -1
  44. package/dist/db/workflow-runs.d.ts.map +1 -1
  45. package/dist/db/workflow-runs.js.map +1 -1
  46. package/dist/presentation/dto/job.d.ts.map +1 -1
  47. package/dist/presentation/dto/job.js +1 -0
  48. package/dist/presentation/dto/job.js.map +1 -1
  49. package/dist/presentation/dto/workflow-run.d.ts.map +1 -1
  50. package/dist/presentation/dto/workflow-run.js +10 -2
  51. package/dist/presentation/dto/workflow-run.js.map +1 -1
  52. package/dist/presentation/subscribers/on-failure-annotations.d.ts.map +1 -1
  53. package/dist/presentation/subscribers/on-failure-annotations.js +6 -3
  54. package/dist/presentation/subscribers/on-failure-annotations.js.map +1 -1
  55. package/dist/tsconfig.test.tsbuildinfo +1 -1
  56. package/drizzle/0002_output_failure_details.sql +2 -0
  57. package/drizzle/0003_output_invalid_reason.sql +1 -0
  58. package/drizzle/meta/0002_snapshot.json +1718 -0
  59. package/drizzle/meta/0003_snapshot.json +1719 -0
  60. package/drizzle/meta/_journal.json +14 -0
  61. package/package.json +2 -2
  62. package/src/core/entities/job-execution.ts +1 -0
  63. package/src/core/entities/job.ts +2 -0
  64. package/src/core/errors.test.ts +18 -0
  65. package/src/core/errors.ts +9 -2
  66. package/src/core/step-config/job-output-limits.ts +2 -2
  67. package/src/core/step-config/materialize-workflow-model.test.ts +67 -3
  68. package/src/core/step-config/materialize-workflow-model.ts +2 -2
  69. package/src/db/index.ts +1 -0
  70. package/src/db/schema/job-executions.ts +2 -0
  71. package/src/db/schema/jobs.ts +3 -0
  72. package/src/db/workflow-runs/job-executions.test.ts +149 -4
  73. package/src/db/workflow-runs/job-executions.ts +44 -9
  74. package/src/db/workflow-runs/job-status.test.ts +30 -0
  75. package/src/db/workflow-runs/jobs.ts +5 -1
  76. package/src/db/workflow-runs/queries.test.ts +169 -2
  77. package/src/db/workflow-runs/queries.ts +86 -11
  78. package/src/db/workflow-runs.ts +1 -0
  79. package/src/presentation/dto/job.ts +1 -0
  80. package/src/presentation/dto/workflow-run.ts +6 -2
  81. package/src/presentation/subscribers/on-failure-annotations.test.ts +47 -1
  82. package/src/presentation/subscribers/on-failure-annotations.ts +10 -2
  83. package/test/helpers/workflow-runs.ts +1 -0
  84. package/tsconfig.build.tsbuildinfo +1 -1
@@ -15,6 +15,20 @@
15
15
  "when": 1785771111717,
16
16
  "tag": "0001_windy_sunfire",
17
17
  "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1786203875223,
23
+ "tag": "0002_output_failure_details",
24
+ "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "7",
29
+ "when": 1786266649202,
30
+ "tag": "0003_output_invalid_reason",
31
+ "breakpoints": true
18
32
  }
19
33
  ]
20
34
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-workflows",
3
3
  "license": "MIT",
4
- "version": "12.4.0",
4
+ "version": "12.6.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -32,7 +32,7 @@
32
32
  "@shipfox/api-integration-core-dto": "12.2.0",
33
33
  "@shipfox/api-runners-dto": "12.4.0",
34
34
  "@shipfox/api-secrets-dto": "12.0.0",
35
- "@shipfox/api-workflows-dto": "12.3.0",
35
+ "@shipfox/api-workflows-dto": "12.6.0",
36
36
  "@shipfox/api-workspaces-dto": "12.0.0",
37
37
  "@shipfox/config": "1.2.4",
38
38
  "@shipfox/inter-module": "0.2.3",
@@ -43,6 +43,7 @@ export interface JobExecution {
43
43
  runner: string[] | null;
44
44
  status: JobExecutionStatus;
45
45
  statusReason: JobStatusReason | null;
46
+ statusReasonMessage?: string | null;
46
47
  triggerEvents: WorkflowExecutionEvent[];
47
48
  outputs: Record<string, unknown> | null;
48
49
  evaluationTrace?: readonly PersistedEvaluationTraceEntry[] | null;
@@ -25,8 +25,10 @@ export const JOB_STATUS_REASONS = [
25
25
  'run_cancelled',
26
26
  'timed_out',
27
27
  'runner_lost',
28
+ 'output_too_large',
28
29
  'step_failed',
29
30
  'unknown',
31
+ 'output_invalid',
30
32
  ] as const;
31
33
 
32
34
  export type JobStatusReason = (typeof JOB_STATUS_REASONS)[number];
@@ -3,6 +3,7 @@ import {
3
3
  DefinitionNotFoundError,
4
4
  InvalidJobRunnerLabelsError,
5
5
  isPermanentRunWorkflowError,
6
+ JobOutputTooLargeError,
6
7
  ProjectMismatchError,
7
8
  } from './errors.js';
8
9
 
@@ -43,3 +44,20 @@ describe('isPermanentRunWorkflowError', () => {
43
44
  expect(result).toBe(false);
44
45
  });
45
46
  });
47
+
48
+ describe('JobOutputTooLargeError', () => {
49
+ test('reports per-value measurements and overshoot', () => {
50
+ const error = new JobOutputTooLargeError('payload', 100, 150, 'value');
51
+
52
+ expect(error.name).toBe('JobOutputTooLargeError');
53
+ expect(error.overshootBytes).toBe(50);
54
+ expect(error.message).toContain('measured 150 bytes; overshoot 50 bytes');
55
+ });
56
+
57
+ test('reports total measurements and overshoot', () => {
58
+ const error = new JobOutputTooLargeError('payload', 100, 175, 'total');
59
+
60
+ expect(error.overshootBytes).toBe(75);
61
+ expect(error.message).toContain('measured 175 bytes; overshoot 75 bytes');
62
+ });
63
+ });
@@ -148,17 +148,24 @@ export class InvalidJobRunnerLabelsError extends Error {
148
148
  }
149
149
 
150
150
  export class JobOutputTooLargeError extends Error {
151
+ readonly overshootBytes: number;
152
+
151
153
  constructor(
152
154
  readonly outputKey: string,
153
155
  readonly limitBytes: number,
156
+ readonly measuredBytes: number,
154
157
  readonly scope: 'value' | 'total',
155
158
  ) {
159
+ const overshootBytes = measuredBytes - limitBytes;
156
160
  super(
157
161
  scope === 'total'
158
- ? `Job outputs exceed the ${limitBytes}-byte total limit (at "${outputKey}")`
159
- : `Job output "${outputKey}" exceeds the ${limitBytes}-byte size limit`,
162
+ ? `Job outputs exceed the total size limit of ${limitBytes} bytes at "${outputKey}" ` +
163
+ `(measured ${measuredBytes} bytes; overshoot ${overshootBytes} bytes).`
164
+ : `Job output "${outputKey}" exceeds the per-value size limit of ${limitBytes} bytes ` +
165
+ `(measured ${measuredBytes} bytes; overshoot ${overshootBytes} bytes).`,
160
166
  );
161
167
  this.name = 'JobOutputTooLargeError';
168
+ this.overshootBytes = overshootBytes;
162
169
  }
163
170
  }
164
171
 
@@ -6,8 +6,8 @@ import {JobOutputNotJsonSafeError} from '#core/errors.js';
6
6
 
7
7
  export const MAX_JOB_OUTPUT_ENTRIES = WORKFLOW_DOCUMENT_JOB_OUTPUTS_MAX_ENTRIES;
8
8
  export const MAX_JOB_OUTPUT_NESTING_DEPTH = WORKFLOW_DOCUMENT_STEP_OUTPUT_SCHEMA_MAX_DEPTH;
9
- export const MAX_JOB_OUTPUTS_TOTAL_BYTES = 64 * 1024;
10
- export const MAX_JOB_OUTPUT_VALUE_BYTES = 16 * 1024;
9
+ export const MAX_JOB_OUTPUTS_TOTAL_BYTES = 256 * 1024;
10
+ export const MAX_JOB_OUTPUT_VALUE_BYTES = 64 * 1024;
11
11
 
12
12
  const textEncoder = new TextEncoder();
13
13
 
@@ -1614,6 +1614,7 @@ describe('materializeJobOutputs', () => {
1614
1614
  {payload: {kind: 'list', element: 'string'}},
1615
1615
  );
1616
1616
  const payload = ['x'.repeat(MAX_JOB_OUTPUT_VALUE_BYTES - 1)];
1617
+ const measuredBytes = Buffer.byteLength(JSON.stringify(payload), 'utf8');
1617
1618
 
1618
1619
  const materialize = () =>
1619
1620
  materializeJobOutputs({
@@ -1623,12 +1624,16 @@ describe('materializeJobOutputs', () => {
1623
1624
  });
1624
1625
 
1625
1626
  expect(materialize).toThrow(JobOutputTooLargeError);
1626
- expect(materialize).toThrow(`Job output "payload" exceeds the ${MAX_JOB_OUTPUT_VALUE_BYTES}`);
1627
+ expect(materialize).toThrow(
1628
+ `Job output "payload" exceeds the per-value size limit of ${MAX_JOB_OUTPUT_VALUE_BYTES} bytes ` +
1629
+ `(measured ${measuredBytes} bytes; overshoot ${measuredBytes - MAX_JOB_OUTPUT_VALUE_BYTES} bytes).`,
1630
+ );
1627
1631
  });
1628
1632
 
1629
1633
  it('measures string values as UTF-8 bytes for the per-value cap', () => {
1630
1634
  const job = outputJob({payload: template('steps.collect.outputs.payload')});
1631
1635
  const payload = 'é'.repeat(Math.ceil(MAX_JOB_OUTPUT_VALUE_BYTES / 2) + 1);
1636
+ const measuredBytes = Buffer.byteLength(payload, 'utf8');
1632
1637
 
1633
1638
  const materialize = () =>
1634
1639
  materializeJobOutputs({
@@ -1638,6 +1643,58 @@ describe('materializeJobOutputs', () => {
1638
1643
  });
1639
1644
 
1640
1645
  expect(materialize).toThrow(JobOutputTooLargeError);
1646
+ expect(materialize).toThrow(
1647
+ `Job output "payload" exceeds the per-value size limit of ${MAX_JOB_OUTPUT_VALUE_BYTES} bytes ` +
1648
+ `(measured ${measuredBytes} bytes; overshoot ${measuredBytes - MAX_JOB_OUTPUT_VALUE_BYTES} bytes).`,
1649
+ );
1650
+ });
1651
+
1652
+ it('accepts a 64 KiB value and rejects one byte over', () => {
1653
+ const maxValueBytes = MAX_JOB_OUTPUT_VALUE_BYTES;
1654
+ expect(maxValueBytes).toBe(64 * 1024);
1655
+ const job = outputJob({payload: template('steps.collect.outputs.payload')});
1656
+ const materialize = (payload: string) =>
1657
+ materializeJobOutputs({
1658
+ job,
1659
+ context: outputContext({payload}),
1660
+ definitionId: 'definition-1',
1661
+ });
1662
+
1663
+ const payload = 'x'.repeat(maxValueBytes);
1664
+
1665
+ expect(materialize(payload)).toEqual({payload});
1666
+ expect(() => materialize(`${payload}x`)).toThrow(JobOutputTooLargeError);
1667
+ });
1668
+
1669
+ it('accepts a serialized total at 256 KiB and rejects one byte over', () => {
1670
+ const maxValueBytes = MAX_JOB_OUTPUT_VALUE_BYTES;
1671
+ const maxTotalBytes = MAX_JOB_OUTPUTS_TOTAL_BYTES;
1672
+ expect(maxValueBytes).toBe(64 * 1024);
1673
+ expect(maxTotalBytes).toBe(256 * 1024);
1674
+ const keys = ['one', 'two', 'three', 'four'];
1675
+ const outputs = Object.fromEntries(
1676
+ keys.map((key) => [key, template(`steps.collect.outputs.${key}`)]),
1677
+ );
1678
+ const values = {
1679
+ one: 'x'.repeat(maxValueBytes),
1680
+ two: 'x'.repeat(maxValueBytes),
1681
+ three: 'x'.repeat(maxValueBytes),
1682
+ four: 'x'.repeat(maxValueBytes - 40),
1683
+ };
1684
+ const job = outputJob(outputs);
1685
+ const materialize = (contextValues: Record<string, unknown>) =>
1686
+ materializeJobOutputs({
1687
+ job,
1688
+ context: outputContext(contextValues),
1689
+ definitionId: 'definition-1',
1690
+ });
1691
+
1692
+ expect(Buffer.byteLength(JSON.stringify(values), 'utf8')).toBe(maxTotalBytes);
1693
+ expect(materialize(values)).toEqual(values);
1694
+
1695
+ const overLimitValues = {...values, four: `${values.four}x`};
1696
+ expect(Buffer.byteLength(JSON.stringify(overLimitValues), 'utf8')).toBe(maxTotalBytes + 1);
1697
+ expect(() => materialize(overLimitValues)).toThrow(JobOutputTooLargeError);
1641
1698
  });
1642
1699
 
1643
1700
  it('rejects the total materialized job output cap', () => {
@@ -1648,6 +1705,10 @@ describe('materializeJobOutputs', () => {
1648
1705
  const values = Object.fromEntries(
1649
1706
  keys.map((key) => [key, 'x'.repeat(MAX_JOB_OUTPUT_VALUE_BYTES)]),
1650
1707
  );
1708
+ const measuredBytes = Buffer.byteLength(
1709
+ JSON.stringify(Object.fromEntries(keys.slice(0, 4).map((key) => [key, values[key]]))),
1710
+ 'utf8',
1711
+ );
1651
1712
  const job = outputJob(outputs);
1652
1713
 
1653
1714
  const materialize = () =>
@@ -1655,7 +1716,8 @@ describe('materializeJobOutputs', () => {
1655
1716
 
1656
1717
  expect(materialize).toThrow(JobOutputTooLargeError);
1657
1718
  expect(materialize).toThrow(
1658
- `Job outputs exceed the ${MAX_JOB_OUTPUTS_TOTAL_BYTES}-byte total limit (at "four")`,
1719
+ `Job outputs exceed the total size limit of ${MAX_JOB_OUTPUTS_TOTAL_BYTES} bytes at "four" ` +
1720
+ `(measured ${measuredBytes} bytes; overshoot ${measuredBytes - MAX_JOB_OUTPUTS_TOTAL_BYTES} bytes).`,
1659
1721
  );
1660
1722
  });
1661
1723
 
@@ -1667,6 +1729,7 @@ describe('materializeJobOutputs', () => {
1667
1729
  const values = Object.fromEntries(
1668
1730
  keys.map((key) => [key, 'x'.repeat(MAX_JOB_OUTPUT_VALUE_BYTES - 16)]),
1669
1731
  );
1732
+ const measuredBytes = Buffer.byteLength(JSON.stringify(values), 'utf8');
1670
1733
  const job = outputJob(outputs);
1671
1734
 
1672
1735
  const materialize = () =>
@@ -1674,7 +1737,8 @@ describe('materializeJobOutputs', () => {
1674
1737
 
1675
1738
  expect(materialize).toThrow(JobOutputTooLargeError);
1676
1739
  expect(materialize).toThrow(
1677
- `Job outputs exceed the ${MAX_JOB_OUTPUTS_TOTAL_BYTES}-byte total limit`,
1740
+ `Job outputs exceed the total size limit of ${MAX_JOB_OUTPUTS_TOTAL_BYTES} bytes at "${keys[3]}" ` +
1741
+ `(measured ${measuredBytes} bytes; overshoot ${measuredBytes - MAX_JOB_OUTPUTS_TOTAL_BYTES} bytes).`,
1678
1742
  );
1679
1743
  });
1680
1744
 
@@ -158,7 +158,7 @@ export function materializeJobOutputs(params: {
158
158
  const normalizedValue = normalizeJobOutputValue(value, key);
159
159
  const valueBytes = jobOutputValueByteLength(normalizedValue);
160
160
  if (valueBytes > MAX_JOB_OUTPUT_VALUE_BYTES) {
161
- throw new JobOutputTooLargeError(key, MAX_JOB_OUTPUT_VALUE_BYTES, 'value');
161
+ throw new JobOutputTooLargeError(key, MAX_JOB_OUTPUT_VALUE_BYTES, valueBytes, 'value');
162
162
  }
163
163
 
164
164
  recordBytes += (index === 0 ? 0 : 1) + jobOutputRecordEntryByteLength(key, normalizedValue);
@@ -169,7 +169,7 @@ export function materializeJobOutputs(params: {
169
169
  writable: true,
170
170
  });
171
171
  if (recordBytes > MAX_JOB_OUTPUTS_TOTAL_BYTES) {
172
- throw new JobOutputTooLargeError(key, MAX_JOB_OUTPUTS_TOTAL_BYTES, 'total');
172
+ throw new JobOutputTooLargeError(key, MAX_JOB_OUTPUTS_TOTAL_BYTES, recordBytes, 'total');
173
173
  }
174
174
  }
175
175
 
package/src/db/index.ts CHANGED
@@ -42,6 +42,7 @@ export type {
42
42
  WorkflowJobExecutionDepthParams,
43
43
  WorkflowRunAggregates,
44
44
  WorkflowRunFilters,
45
+ WorkflowRunJobRawStatusCount,
45
46
  WorkflowRunJobStatusCount,
46
47
  WorkflowRunJobSummary,
47
48
  WorkflowRunJobSummaryTarget,
@@ -42,6 +42,7 @@ export const jobExecutions = pgTable(
42
42
  runner: jsonb('runner').$type<string[]>(),
43
43
  status: jobExecutionStatusEnum('status').notNull().default('pending'),
44
44
  statusReason: jobStatusReasonEnum('status_reason'),
45
+ statusReasonMessage: text('status_reason_message'),
45
46
  triggerEvents: jsonb('trigger_events').notNull().default([]).$type<WorkflowExecutionEvent[]>(),
46
47
  outputs: jsonb('outputs').$type<Record<string, unknown> | null>(),
47
48
  evaluationTrace: jsonb('evaluation_trace').$type<readonly PersistedEvaluationTraceEntry[]>(),
@@ -78,6 +79,7 @@ export function toJobExecution(row: JobExecutionDb, fallbackName: string): JobEx
78
79
  runner: row.runner as string[] | null,
79
80
  status: row.status,
80
81
  statusReason: toJobStatusReason(row.statusReason),
82
+ statusReasonMessage: row.statusReasonMessage,
81
83
  triggerEvents: row.triggerEvents.map(normalizeWorkflowExecutionEvent),
82
84
  outputs: row.outputs,
83
85
  evaluationTrace: row.evaluationTrace ?? null,
@@ -61,6 +61,9 @@ export const jobs = pgTable(
61
61
  key: text('key').notNull(),
62
62
  mode: jobModeEnum('mode').notNull().default('one_shot'),
63
63
  name: text('name'),
64
+ // This is the job's execution verdict, not its runtime lifecycle. Active execution state
65
+ // lives on job_executions and must be read there when a surface needs to distinguish
66
+ // waiting from executing.
64
67
  status: jobStatusEnum('status').notNull().default('pending'),
65
68
  statusReason: jobStatusReasonEnum('status_reason'),
66
69
  carriedOver: boolean('carried_over').notNull().default(false),
@@ -1,5 +1,11 @@
1
1
  import {WORKFLOWS_JOB_EXECUTION_TIMED_OUT} from '@shipfox/api-workflows-dto';
2
2
  import {and, eq, sql} from 'drizzle-orm';
3
+ import {
4
+ InterpolationUnresolvableError,
5
+ JobOutputNotJsonSafeError,
6
+ JobOutputTooLargeError,
7
+ JobOutputTooManyEntriesError,
8
+ } from '#core/errors.js';
3
9
  import {
4
10
  MAX_JOB_OUTPUT_ENTRIES,
5
11
  MAX_JOB_OUTPUT_VALUE_BYTES,
@@ -19,6 +25,7 @@ import {
19
25
  resolveJobExecutionAfterLeaseExpiry,
20
26
  updateJobExecutionStatus,
21
27
  } from '../workflow-runs.js';
28
+ import {classifyJobOutputFailure} from './job-executions.js';
22
29
 
23
30
  describe('workflow run job executions', () => {
24
31
  let workspaceId: string;
@@ -148,6 +155,50 @@ describe('workflow run job executions', () => {
148
155
  expect(persisted?.outputs).toEqual({findings: [{severity: 'high'}]});
149
156
  });
150
157
 
158
+ test('persists job outputs in the raised size band when execution succeeds', async () => {
159
+ const outputKeys = ['one', 'two', 'three'];
160
+ const outputValues = Object.fromEntries(
161
+ outputKeys.map((key) => [key, 'x'.repeat(MAX_JOB_OUTPUT_VALUE_BYTES)]),
162
+ );
163
+ const outputTemplates = Object.fromEntries(
164
+ outputKeys.map((key) => [key, template(`steps.collect.outputs.${key}`)]),
165
+ );
166
+ const run = await createWorkflowRun({
167
+ workspaceId,
168
+ projectId,
169
+ definitionId,
170
+ model: buildModel({
171
+ jobs: {
172
+ build: {
173
+ steps: [{key: 'collect', run: 'echo build'}],
174
+ outputs: outputTemplates,
175
+ },
176
+ },
177
+ }),
178
+ triggerPayload: {
179
+ source: 'manual',
180
+ event: 'fire',
181
+ subscriptionId: crypto.randomUUID(),
182
+ userId: crypto.randomUUID(),
183
+ },
184
+ });
185
+ const [job] = await getJobsByWorkflowRunId(run.id);
186
+ if (!job) throw new Error('Expected workflow job');
187
+ const execution = await getFirstJobExecutionByJobId(job.id);
188
+ if (!execution) throw new Error('Expected job execution');
189
+ await finishCollectedStep(job.id, outputValues);
190
+
191
+ const resolved = await updateJobExecutionStatus({
192
+ jobExecutionId: execution.id,
193
+ status: 'succeeded',
194
+ expectedVersion: execution.version,
195
+ });
196
+
197
+ expect(resolved).toMatchObject({status: 'succeeded', outputs: outputValues});
198
+ const persisted = await getFirstJobExecutionByJobId(job.id);
199
+ expect(persisted?.outputs).toEqual(outputValues);
200
+ });
201
+
151
202
  test('persists JSON-safe typed integer and timestamp outputs when execution succeeds', async () => {
152
203
  const run = await createWorkflowRun({
153
204
  workspaceId,
@@ -214,17 +265,66 @@ describe('workflow run job executions', () => {
214
265
  if (!job) throw new Error('Expected workflow job');
215
266
  const execution = await getFirstJobExecutionByJobId(job.id);
216
267
  if (!execution) throw new Error('Expected job execution');
217
- await finishCollectedStep(job.id, {
218
- payload: ['x'.repeat(MAX_JOB_OUTPUT_VALUE_BYTES - 1)],
268
+ const payload = ['x'.repeat(MAX_JOB_OUTPUT_VALUE_BYTES - 1)];
269
+ const measuredBytes = Buffer.byteLength(JSON.stringify(payload), 'utf8');
270
+ await finishCollectedStep(job.id, {payload});
271
+
272
+ const resolved = await updateJobExecutionStatus({
273
+ jobExecutionId: execution.id,
274
+ status: 'succeeded',
275
+ expectedVersion: execution.version,
219
276
  });
220
277
 
278
+ expect(resolved).toMatchObject({
279
+ status: 'failed',
280
+ statusReason: 'output_too_large',
281
+ statusReasonMessage:
282
+ `Job output "payload" exceeds the per-value size limit of ${MAX_JOB_OUTPUT_VALUE_BYTES} bytes ` +
283
+ `(measured ${measuredBytes} bytes; overshoot ${measuredBytes - MAX_JOB_OUTPUT_VALUE_BYTES} bytes).`,
284
+ outputs: null,
285
+ });
286
+ });
287
+
288
+ test('fails a successful execution when a materialized job output cannot be resolved', async () => {
289
+ const run = await createWorkflowRun({
290
+ workspaceId,
291
+ projectId,
292
+ definitionId,
293
+ model: buildModel({
294
+ jobs: {
295
+ build: {
296
+ steps: [{key: 'collect', run: 'echo build'}],
297
+ outputs: {payload: template('steps.collect.outputs.missing')},
298
+ },
299
+ },
300
+ }),
301
+ triggerPayload: {
302
+ source: 'manual',
303
+ event: 'fire',
304
+ subscriptionId: crypto.randomUUID(),
305
+ userId: crypto.randomUUID(),
306
+ },
307
+ });
308
+ const [job] = await getJobsByWorkflowRunId(run.id);
309
+ if (!job) throw new Error('Expected workflow job');
310
+ const execution = await getFirstJobExecutionByJobId(job.id);
311
+ if (!execution) throw new Error('Expected job execution');
312
+ await finishCollectedStep(job.id, {});
313
+
221
314
  const resolved = await updateJobExecutionStatus({
222
315
  jobExecutionId: execution.id,
223
316
  status: 'succeeded',
224
317
  expectedVersion: execution.version,
225
318
  });
226
319
 
227
- expect(resolved).toMatchObject({status: 'failed', statusReason: 'unknown', outputs: null});
320
+ expect(resolved).toMatchObject({
321
+ status: 'failed',
322
+ statusReason: 'output_invalid',
323
+ statusReasonMessage: expect.stringContaining(
324
+ 'job.outputs uses `steps.collect.outputs.missing`',
325
+ ),
326
+ outputs: null,
327
+ });
228
328
  });
229
329
 
230
330
  test('fails a successful execution when the persisted model has too many job outputs', async () => {
@@ -265,7 +365,52 @@ describe('workflow run job executions', () => {
265
365
  expectedVersion: execution.version,
266
366
  });
267
367
 
268
- expect(resolved).toMatchObject({status: 'failed', statusReason: 'unknown', outputs: null});
368
+ expect(resolved).toMatchObject({
369
+ status: 'failed',
370
+ statusReason: 'output_invalid',
371
+ statusReasonMessage: `Job outputs cannot define more than ${MAX_JOB_OUTPUT_ENTRIES} entries (found ${MAX_JOB_OUTPUT_ENTRIES + 1})`,
372
+ outputs: null,
373
+ });
374
+ });
375
+ });
376
+
377
+ describe('classifyJobOutputFailure', () => {
378
+ test.each([
379
+ [
380
+ new InterpolationUnresolvableError('definition-1', {
381
+ field: 'job.outputs',
382
+ source: 'steps.collect.outputs.payload',
383
+ }),
384
+ 'output_invalid',
385
+ ],
386
+ [new JobOutputNotJsonSafeError('payload', 'undefined is not a JSON value'), 'output_invalid'],
387
+ [new JobOutputTooManyEntriesError(11, 10), 'output_invalid'],
388
+ [new JobOutputTooLargeError('payload', 16 * 1024, 16 * 1024 + 1, 'value'), 'output_too_large'],
389
+ ] as const)('classifies %s with the persisted reason', (error, statusReason) => {
390
+ expect(classifyJobOutputFailure(error)).toMatchObject({statusReason});
391
+ });
392
+
393
+ test('does not classify interpolation failures outside job outputs', () => {
394
+ const error = new InterpolationUnresolvableError('definition-1', {
395
+ field: 'env',
396
+ source: 'event.ref',
397
+ envKey: 'REF',
398
+ });
399
+
400
+ expect(classifyJobOutputFailure(error)).toBeNull();
401
+ });
402
+
403
+ test('does not classify unexpected failures', () => {
404
+ expect(classifyJobOutputFailure(new Error('database unavailable'))).toBeNull();
405
+ });
406
+
407
+ test('bounds the persisted message', () => {
408
+ const failure = classifyJobOutputFailure(
409
+ new JobOutputNotJsonSafeError('payload', 'x'.repeat(4096)),
410
+ );
411
+
412
+ expect(failure?.statusReasonMessage).toHaveLength(2048);
413
+ expect(failure?.statusReasonMessage.endsWith('…')).toBe(true);
269
414
  });
270
415
  });
271
416
 
@@ -117,10 +117,46 @@ export interface UpdateJobExecutionStatusAtVersionParams {
117
117
  status: JobExecutionStatus;
118
118
  expectedVersion: number;
119
119
  statusReason?: JobStatusReason | null | undefined;
120
+ statusReasonMessage?: string | null | undefined;
120
121
  markTimedOut?: boolean;
121
122
  secrets?: Pick<SecretsInterModuleClient, 'getVariablesByNamespace'> | undefined;
122
123
  }
123
124
 
125
+ const MAX_STATUS_REASON_MESSAGE_LENGTH = 2048;
126
+
127
+ export type JobOutputFailure = {
128
+ statusReason: Extract<JobStatusReason, 'output_invalid' | 'output_too_large'>;
129
+ statusReasonMessage: string;
130
+ };
131
+
132
+ export function classifyJobOutputFailure(error: unknown): JobOutputFailure | null {
133
+ if (error instanceof JobOutputTooLargeError) {
134
+ return {
135
+ statusReason: 'output_too_large',
136
+ statusReasonMessage: boundedStatusReasonMessage(error.message),
137
+ };
138
+ }
139
+
140
+ if (
141
+ (error instanceof InterpolationUnresolvableError && error.field === 'job.outputs') ||
142
+ error instanceof JobOutputNotJsonSafeError ||
143
+ error instanceof JobOutputTooManyEntriesError
144
+ ) {
145
+ return {
146
+ statusReason: 'output_invalid',
147
+ statusReasonMessage: boundedStatusReasonMessage(error.message),
148
+ };
149
+ }
150
+
151
+ return null;
152
+ }
153
+
154
+ function boundedStatusReasonMessage(message: string): string {
155
+ return message.length <= MAX_STATUS_REASON_MESSAGE_LENGTH
156
+ ? message
157
+ : `${message.slice(0, MAX_STATUS_REASON_MESSAGE_LENGTH - 1)}…`;
158
+ }
159
+
124
160
  async function resolveJobExecutionOutputs(
125
161
  tx: Tx,
126
162
  params: {
@@ -200,6 +236,7 @@ async function updateJobExecutionStatusAtVersion(
200
236
  ): Promise<{execution: JobExecution; changed: boolean} | null> {
201
237
  let status = params.status;
202
238
  let statusReason = params.statusReason ?? null;
239
+ let statusReasonMessage = params.statusReasonMessage ?? null;
203
240
  let outputs: Record<string, unknown> | null | undefined;
204
241
  if (TERMINAL_EXECUTION_STATUSES.includes(status)) {
205
242
  outputs = null;
@@ -213,16 +250,11 @@ async function updateJobExecutionStatusAtVersion(
213
250
  secrets: params.secrets,
214
251
  });
215
252
  } catch (error) {
216
- if (
217
- !(error instanceof InterpolationUnresolvableError) &&
218
- !(error instanceof JobOutputNotJsonSafeError) &&
219
- !(error instanceof JobOutputTooLargeError) &&
220
- !(error instanceof JobOutputTooManyEntriesError)
221
- ) {
222
- throw error;
223
- }
253
+ const outputFailure = classifyJobOutputFailure(error);
254
+ if (outputFailure === null) throw error;
224
255
  status = 'failed';
225
- statusReason = 'unknown';
256
+ statusReason = outputFailure.statusReason;
257
+ statusReasonMessage = outputFailure.statusReasonMessage;
226
258
  outputs = null;
227
259
  }
228
260
  }
@@ -232,6 +264,7 @@ async function updateJobExecutionStatusAtVersion(
232
264
  .set({
233
265
  status,
234
266
  statusReason,
267
+ statusReasonMessage,
235
268
  ...(outputs === undefined ? {} : {outputs}),
236
269
  version: sql`${jobExecutions.version} + 1`,
237
270
  updatedAt: new Date(),
@@ -260,6 +293,7 @@ export interface UpdateJobExecutionStatusParams {
260
293
  status: JobExecutionStatus;
261
294
  expectedVersion: number;
262
295
  statusReason?: JobStatusReason | null | undefined;
296
+ statusReasonMessage?: string | null | undefined;
263
297
  secrets?: Pick<SecretsInterModuleClient, 'getVariablesByNamespace'> | undefined;
264
298
  }
265
299
 
@@ -275,6 +309,7 @@ export async function updateJobExecutionStatus(
275
309
  status: params.status,
276
310
  expectedVersion: params.expectedVersion,
277
311
  statusReason,
312
+ statusReasonMessage: params.statusReasonMessage,
278
313
  secrets: params.secrets,
279
314
  }),
280
315
  fetchFn: async () => {
@@ -518,6 +518,36 @@ describe('workflow run queries', () => {
518
518
  });
519
519
  });
520
520
 
521
+ test('writes execution failure details on the terminated event', async () => {
522
+ const {run, jobId, jobExecutionId} = await seedPendingJob();
523
+ const statusReasonMessage =
524
+ 'Job output "payload" exceeds the per-value size limit of 65536 bytes (measured 65537 bytes; overshoot 1 bytes).';
525
+
526
+ await updateJobExecutionStatus({
527
+ jobExecutionId: jobExecutionId as string,
528
+ status: 'failed',
529
+ expectedVersion: 1,
530
+ statusReason: 'output_too_large',
531
+ statusReasonMessage,
532
+ });
533
+ await updateJobStatus({
534
+ jobId,
535
+ status: 'failed',
536
+ expectedVersion: 1,
537
+ statusReason: 'output_too_large',
538
+ });
539
+
540
+ const events = await jobTerminatedEvents(jobId);
541
+ expect(events).toHaveLength(1);
542
+ expect(events[0]).toMatchObject({
543
+ jobId,
544
+ workflowRunId: run.id,
545
+ status: 'failed',
546
+ statusReason: 'output_too_large',
547
+ statusReasonMessage,
548
+ });
549
+ });
550
+
521
551
  test('writes no terminated event for a non-terminal transition', async () => {
522
552
  const {jobId} = await seedPendingJob();
523
553
 
@@ -348,7 +348,10 @@ export async function updateJobStatusAtVersion(
348
348
  // makes the terminal fact fire exactly once across all paths.
349
349
  if (isJobTerminal(job.status)) {
350
350
  const [currentExecution] = await tx
351
- .select({id: jobExecutions.id})
351
+ .select({
352
+ id: jobExecutions.id,
353
+ statusReasonMessage: jobExecutions.statusReasonMessage,
354
+ })
352
355
  .from(jobExecutions)
353
356
  .where(eq(jobExecutions.jobId, job.id))
354
357
  .orderBy(desc(jobExecutions.sequence), desc(jobExecutions.id))
@@ -363,6 +366,7 @@ export async function updateJobStatusAtVersion(
363
366
  workflowRunAttemptId: identity.workflowRunAttemptId,
364
367
  status: job.status,
365
368
  statusReason: job.statusReason,
369
+ statusReasonMessage: currentExecution?.statusReasonMessage ?? null,
366
370
  },
367
371
  });
368
372
  }