@shipfox/api-workflows 12.4.0 → 12.5.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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +13 -0
- package/dist/core/entities/job-execution.d.ts +1 -0
- package/dist/core/entities/job-execution.d.ts.map +1 -1
- package/dist/core/entities/job-execution.js.map +1 -1
- package/dist/core/entities/job.d.ts +1 -1
- package/dist/core/entities/job.d.ts.map +1 -1
- package/dist/core/entities/job.js +3 -1
- package/dist/core/entities/job.js.map +1 -1
- package/dist/core/errors.d.ts +3 -1
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +4 -2
- package/dist/core/errors.js.map +1 -1
- package/dist/core/step-config/job-output-limits.d.ts.map +1 -1
- package/dist/core/step-config/job-output-limits.js +2 -2
- package/dist/core/step-config/job-output-limits.js.map +1 -1
- package/dist/core/step-config/materialize-workflow-model.js +2 -2
- package/dist/core/step-config/materialize-workflow-model.js.map +1 -1
- package/dist/db/db.d.ts +42 -8
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/schema/job-executions.d.ts +19 -2
- package/dist/db/schema/job-executions.d.ts.map +1 -1
- package/dist/db/schema/job-executions.js +2 -0
- package/dist/db/schema/job-executions.js.map +1 -1
- package/dist/db/schema/jobs.d.ts +3 -3
- package/dist/db/schema/jobs.d.ts.map +1 -1
- package/dist/db/workflow-runs/job-executions.d.ts +7 -0
- package/dist/db/workflow-runs/job-executions.d.ts.map +1 -1
- package/dist/db/workflow-runs/job-executions.js +26 -4
- package/dist/db/workflow-runs/job-executions.js.map +1 -1
- package/dist/db/workflow-runs/jobs.d.ts.map +1 -1
- package/dist/db/workflow-runs/jobs.js +4 -2
- package/dist/db/workflow-runs/jobs.js.map +1 -1
- package/dist/presentation/dto/job.d.ts.map +1 -1
- package/dist/presentation/dto/job.js +1 -0
- package/dist/presentation/dto/job.js.map +1 -1
- package/dist/presentation/subscribers/on-failure-annotations.d.ts.map +1 -1
- package/dist/presentation/subscribers/on-failure-annotations.js +6 -3
- package/dist/presentation/subscribers/on-failure-annotations.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0002_output_failure_details.sql +2 -0
- package/drizzle/0003_output_invalid_reason.sql +1 -0
- package/drizzle/meta/0002_snapshot.json +1718 -0
- package/drizzle/meta/0003_snapshot.json +1719 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/src/core/entities/job-execution.ts +1 -0
- package/src/core/entities/job.ts +2 -0
- package/src/core/errors.test.ts +18 -0
- package/src/core/errors.ts +9 -2
- package/src/core/step-config/job-output-limits.ts +2 -2
- package/src/core/step-config/materialize-workflow-model.test.ts +67 -3
- package/src/core/step-config/materialize-workflow-model.ts +2 -2
- package/src/db/schema/job-executions.ts +2 -0
- package/src/db/workflow-runs/job-executions.test.ts +149 -4
- package/src/db/workflow-runs/job-executions.ts +44 -9
- package/src/db/workflow-runs/job-status.test.ts +30 -0
- package/src/db/workflow-runs/jobs.ts +5 -1
- package/src/presentation/dto/job.ts +1 -0
- package/src/presentation/subscribers/on-failure-annotations.test.ts +47 -1
- package/src/presentation/subscribers/on-failure-annotations.ts +10 -2
- package/test/helpers/workflow-runs.ts +1 -0
- 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
|
+
"version": "12.5.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.
|
|
35
|
+
"@shipfox/api-workflows-dto": "12.5.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;
|
package/src/core/entities/job.ts
CHANGED
|
@@ -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];
|
package/src/core/errors.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/core/errors.ts
CHANGED
|
@@ -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}
|
|
159
|
-
|
|
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 =
|
|
10
|
-
export const MAX_JOB_OUTPUT_VALUE_BYTES =
|
|
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(
|
|
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}
|
|
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}
|
|
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
|
|
|
@@ -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,
|
|
@@ -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
|
-
|
|
218
|
-
|
|
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({
|
|
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({
|
|
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
|
-
|
|
217
|
-
|
|
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 =
|
|
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({
|
|
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
|
}
|
|
@@ -56,6 +56,7 @@ export function toJobExecutionDto(jobExecution: JobExecution): JobExecutionDto {
|
|
|
56
56
|
name: jobExecution.name,
|
|
57
57
|
status: jobExecution.status,
|
|
58
58
|
status_reason: jobExecution.statusReason,
|
|
59
|
+
status_reason_message: jobExecution.statusReasonMessage,
|
|
59
60
|
runner: jobExecution.runner,
|
|
60
61
|
trigger_events: jobExecution.triggerEvents,
|
|
61
62
|
outputs: jobExecution.outputs,
|