@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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-workflows",
3
3
  "license": "MIT",
4
- "version": "12.1.0",
4
+ "version": "12.3.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -21,30 +21,32 @@
21
21
  "@temporalio/common": "1.18.1",
22
22
  "@temporalio/workflow": "1.18.1",
23
23
  "drizzle-orm": "^0.45.2",
24
+ "js-yaml": "^4.1.0",
24
25
  "zod": "^4.4.3",
25
- "@shipfox/api-auth-context": "12.0.0",
26
+ "@shipfox/api-auth-context": "12.2.0",
26
27
  "@shipfox/api-auth-dto": "12.0.0",
27
- "@shipfox/api-agent-dto": "12.0.0",
28
- "@shipfox/annotations-dto": "12.0.0",
29
- "@shipfox/api-definitions-dto": "12.0.0",
30
- "@shipfox/api-projects-dto": "12.0.0",
31
- "@shipfox/api-integration-core-dto": "12.0.0",
32
- "@shipfox/api-runners-dto": "12.0.0",
28
+ "@shipfox/api-agent-dto": "12.2.0",
29
+ "@shipfox/annotations-dto": "12.3.0",
30
+ "@shipfox/api-definitions-dto": "12.3.0",
31
+ "@shipfox/api-projects-dto": "12.2.0",
32
+ "@shipfox/api-integration-core-dto": "12.2.0",
33
+ "@shipfox/api-runners-dto": "12.2.0",
33
34
  "@shipfox/api-secrets-dto": "12.0.0",
34
- "@shipfox/api-workflows-dto": "12.1.0",
35
+ "@shipfox/api-workflows-dto": "12.3.0",
35
36
  "@shipfox/api-workspaces-dto": "12.0.0",
37
+ "@shipfox/config": "1.2.4",
36
38
  "@shipfox/inter-module": "0.2.3",
37
- "@shipfox/expression": "2.0.0",
39
+ "@shipfox/expression": "2.2.0",
38
40
  "@shipfox/node-drizzle": "0.3.5",
39
41
  "@shipfox/node-error-monitoring": "0.3.0",
40
- "@shipfox/node-fastify": "0.4.1",
41
- "@shipfox/node-module": "1.0.5",
42
- "@shipfox/node-opentelemetry": "0.6.3",
42
+ "@shipfox/node-fastify": "0.4.2",
43
+ "@shipfox/node-module": "1.0.6",
44
+ "@shipfox/node-opentelemetry": "0.6.4",
43
45
  "@shipfox/node-outbox": "0.2.6",
44
46
  "@shipfox/node-postgres": "0.5.0",
45
- "@shipfox/node-temporal": "0.4.4",
46
- "@shipfox/runner-labels": "0.1.3",
47
- "@shipfox/workflow-document": "3.0.0"
47
+ "@shipfox/node-temporal": "0.4.5",
48
+ "@shipfox/runner-labels": "0.2.0",
49
+ "@shipfox/workflow-document": "3.0.1"
48
50
  },
49
51
  "imports": {
50
52
  "#*": "./dist/*"
@@ -0,0 +1,85 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
3
+ import {tmpdir} from 'node:os';
4
+ import {join} from 'node:path';
5
+ import {loadRunnerCatalog} from './config.js';
6
+
7
+ let dir: string;
8
+
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(join(tmpdir(), 'api-workflows-'));
11
+ });
12
+
13
+ afterEach(() => {
14
+ rmSync(dir, {recursive: true, force: true});
15
+ });
16
+
17
+ function writeCatalog(contents: string): string {
18
+ const path = join(dir, `${randomUUID()}.yaml`);
19
+ writeFileSync(path, contents);
20
+ return path;
21
+ }
22
+
23
+ describe('loadRunnerCatalog', () => {
24
+ it('returns an empty catalog when no path is configured', () => {
25
+ expect(loadRunnerCatalog('')).toEqual({});
26
+ });
27
+
28
+ it.each(['', ' \n\t'])('treats an empty YAML document as empty', (contents) => {
29
+ expect(loadRunnerCatalog(writeCatalog(contents))).toEqual({});
30
+ });
31
+
32
+ it.each([
33
+ '# just a comment',
34
+ '---\n',
35
+ 'null\n',
36
+ '~\n',
37
+ ])('rejects a null YAML document (%j)', (contents) => {
38
+ const path = writeCatalog(contents);
39
+
40
+ expect(() => loadRunnerCatalog(path)).toThrow(`Invalid runner catalog config at ${path}`);
41
+ });
42
+
43
+ it('loads and validates a YAML catalog', () => {
44
+ const path = writeCatalog(`
45
+ ShipFox-4CPU:
46
+ - OS.Ubuntu-Latest
47
+ - CPU.4
48
+ `);
49
+
50
+ expect(loadRunnerCatalog(path)).toEqual({
51
+ 'shipfox-4cpu': ['cpu.4', 'os.ubuntu-latest'],
52
+ });
53
+ });
54
+
55
+ it('includes the file path when the catalog file is missing', () => {
56
+ const path = join(dir, 'missing.yaml');
57
+
58
+ expect(() => loadRunnerCatalog(path)).toThrow(`Cannot read runner catalog config at ${path}`);
59
+ });
60
+
61
+ it('includes the file path when YAML is malformed', () => {
62
+ const path = writeCatalog('runner: [unclosed');
63
+
64
+ expect(() => loadRunnerCatalog(path)).toThrow(`Cannot parse runner catalog config at ${path}`);
65
+ });
66
+
67
+ it.each([
68
+ ['bad name', 'label'],
69
+ ['name', 'not a label'],
70
+ ])('includes the file path when the catalog contains an invalid %s', (name, value) => {
71
+ const path = writeCatalog(`${name}:\n - ${value}\n`);
72
+
73
+ expect(() => loadRunnerCatalog(path)).toThrow(`Invalid runner catalog config at ${path}`);
74
+ });
75
+
76
+ it('rejects oversized entries at startup', () => {
77
+ const path = writeCatalog(
78
+ `too-many:\n${Array.from({length: 21}, (_, index) => ` - label-${index}`).join('\n')}\n`,
79
+ );
80
+
81
+ expect(() => loadRunnerCatalog(path)).toThrow(
82
+ `Runner catalog entry "too-many" in ${path} has 21 labels`,
83
+ );
84
+ });
85
+ });
package/src/config.ts ADDED
@@ -0,0 +1,71 @@
1
+ import {readFileSync} from 'node:fs';
2
+ import {createConfig, str} from '@shipfox/config';
3
+ import {MAX_RUNNER_LABELS, parseRunnerCatalog, type RunnerCatalog} from '@shipfox/runner-labels';
4
+ import yaml from 'js-yaml';
5
+
6
+ export const config = createConfig({
7
+ RUNNER_CATALOG_PATH: str({
8
+ desc: 'Path to the YAML file that maps runner catalog names to complete label sets. Leave it empty to use every job runner value as a literal label. The file is loaded at startup; restart the API after changing it.',
9
+ default: '',
10
+ }),
11
+ });
12
+
13
+ /** Raised when the configured runner catalog cannot be read, parsed, or validated. */
14
+ export class RunnerCatalogConfigError extends Error {
15
+ constructor(message: string, options?: ErrorOptions) {
16
+ super(message, options);
17
+ this.name = 'RunnerCatalogConfigError';
18
+ }
19
+ }
20
+
21
+ export function loadRunnerCatalog(filePath: string): RunnerCatalog {
22
+ if (filePath === '') return {};
23
+
24
+ let contents: string;
25
+ try {
26
+ contents = readFileSync(filePath, 'utf8');
27
+ } catch (error) {
28
+ throw new RunnerCatalogConfigError(
29
+ `Cannot read runner catalog config at ${filePath}: ${errorMessage(error)}`,
30
+ {cause: error},
31
+ );
32
+ }
33
+
34
+ if (contents.trim() === '') return {};
35
+
36
+ let raw: unknown;
37
+ try {
38
+ raw = yaml.load(contents);
39
+ } catch (error) {
40
+ throw new RunnerCatalogConfigError(
41
+ `Cannot parse runner catalog config at ${filePath}: ${errorMessage(error)}`,
42
+ {cause: error},
43
+ );
44
+ }
45
+
46
+ let catalog: RunnerCatalog;
47
+ try {
48
+ catalog = parseRunnerCatalog(raw);
49
+ } catch (error) {
50
+ throw new RunnerCatalogConfigError(
51
+ `Invalid runner catalog config at ${filePath}: ${errorMessage(error)}`,
52
+ {cause: error},
53
+ );
54
+ }
55
+
56
+ for (const [name, labels] of Object.entries(catalog)) {
57
+ if (labels.length > MAX_RUNNER_LABELS) {
58
+ throw new RunnerCatalogConfigError(
59
+ `Runner catalog entry "${name}" in ${filePath} has ${labels.length} labels; the maximum is ${MAX_RUNNER_LABELS}.`,
60
+ );
61
+ }
62
+ }
63
+
64
+ return catalog;
65
+ }
66
+
67
+ export const runnerCatalog = loadRunnerCatalog(config.RUNNER_CATALOG_PATH);
68
+
69
+ function errorMessage(error: unknown): string {
70
+ return error instanceof Error ? error.message : String(error);
71
+ }
@@ -134,12 +134,54 @@ export class JobNotFoundError extends Error {
134
134
  }
135
135
 
136
136
  export class InvalidJobRunnerLabelsError extends Error {
137
- constructor(readonly labels: readonly string[]) {
138
- super(`Job runner labels are invalid: ${labels.join(', ')}`);
137
+ constructor(
138
+ readonly labels: readonly string[],
139
+ readonly requestedLabels: readonly string[] = labels,
140
+ ) {
141
+ const requestedSuffix =
142
+ requestedLabels.join(', ') === labels.join(', ')
143
+ ? ''
144
+ : ` (requested: ${requestedLabels.join(', ')})`;
145
+ super(`Job runner labels are invalid: ${labels.join(', ')}${requestedSuffix}`);
139
146
  this.name = 'InvalidJobRunnerLabelsError';
140
147
  }
141
148
  }
142
149
 
150
+ export class JobOutputTooLargeError extends Error {
151
+ constructor(
152
+ readonly outputKey: string,
153
+ readonly limitBytes: number,
154
+ readonly scope: 'value' | 'total',
155
+ ) {
156
+ super(
157
+ scope === 'total'
158
+ ? `Job outputs exceed the ${limitBytes}-byte total limit (at "${outputKey}")`
159
+ : `Job output "${outputKey}" exceeds the ${limitBytes}-byte size limit`,
160
+ );
161
+ this.name = 'JobOutputTooLargeError';
162
+ }
163
+ }
164
+
165
+ export class JobOutputTooManyEntriesError extends Error {
166
+ constructor(
167
+ readonly entryCount: number,
168
+ readonly limitEntries: number,
169
+ ) {
170
+ super(`Job outputs cannot define more than ${limitEntries} entries (found ${entryCount})`);
171
+ this.name = 'JobOutputTooManyEntriesError';
172
+ }
173
+ }
174
+
175
+ export class JobOutputNotJsonSafeError extends Error {
176
+ constructor(
177
+ readonly outputKey: string,
178
+ readonly reason: string,
179
+ ) {
180
+ super(`Job output "${outputKey}" cannot be persisted as JSON: ${reason}`);
181
+ this.name = 'JobOutputNotJsonSafeError';
182
+ }
183
+ }
184
+
143
185
  // The job named by a lease is terminal, so it must not exchange its lease for
144
186
  // fresh checkout credentials. Server state is the final gate, not the token.
145
187
  export class JobNotActiveError extends Error {
@@ -1139,6 +1139,7 @@ describe('step attempts', () => {
1139
1139
  projectId: expect.any(String),
1140
1140
  stepId,
1141
1141
  attempt: 1,
1142
+ status: 'failed',
1142
1143
  logOutcome: 'abandoned',
1143
1144
  },
1144
1145
  ]);
@@ -1,4 +1,8 @@
1
- import {getWorkflowPredicateContextRoots} from '@shipfox/expression';
1
+ import {
2
+ createWorkflowExpression,
3
+ evaluateWorkflowExpression,
4
+ getWorkflowPredicateContextRoots,
5
+ } from '@shipfox/expression';
2
6
  import type {JobExecution} from '#core/entities/job-execution.js';
3
7
  import type {Step, StepAttempt} from '#core/entities/step.js';
4
8
  import {
@@ -9,14 +13,94 @@ import {
9
13
  assembleGateContext,
10
14
  assembleJobActivationContext,
11
15
  assembleJobResolutionContext,
16
+ assembleJobsContext,
12
17
  assembleListenerSnapshotContext,
13
18
  assembleStepDispatchContext,
14
19
  assembleWorkflowRunContext,
20
+ listenerFilterOutputTypesForJobs,
15
21
  planListenerFilterSnapshots,
16
22
  } from './assemble-run-context.js';
17
23
 
18
24
  const date = new Date('2026-06-30T12:00:00.000Z');
19
25
 
26
+ describe('assembleJobsContext', () => {
27
+ it.each([
28
+ {persistedValue: 42, expectedValue: 43n},
29
+ {persistedValue: '9007199254740993', expectedValue: 9007199254740994n},
30
+ ])('rehydrates persisted int outputs for CEL arithmetic', ({persistedValue, expectedValue}) => {
31
+ const context = assembleJobsContext([
32
+ {
33
+ job: {key: 'review', status: 'succeeded', outputs: {count: persistedValue}},
34
+ outputTypes: {count: 'int'},
35
+ executions: [],
36
+ },
37
+ ]);
38
+ const expression = createWorkflowExpression({
39
+ source: `jobs.review.outputs.count + 1 == ${expectedValue.toString()}`,
40
+ check: {mode: 'syntax'},
41
+ });
42
+
43
+ const result = evaluateWorkflowExpression(expression, context);
44
+
45
+ expect(result).toBe(true);
46
+ });
47
+
48
+ it('rehydrates persisted timestamp outputs for CEL comparison', () => {
49
+ const persistedValue = '2026-06-30T12:00:00.000Z';
50
+ const context = assembleJobsContext([
51
+ {
52
+ job: {key: 'review', status: 'succeeded', outputs: {createdAt: persistedValue}},
53
+ outputTypes: {createdAt: 'timestamp'},
54
+ executions: [],
55
+ },
56
+ ]);
57
+ const expression = createWorkflowExpression({
58
+ source: 'jobs.review.outputs.createdAt < timestamp("2026-07-01T00:00:00Z")',
59
+ check: {mode: 'syntax'},
60
+ });
61
+
62
+ const result = evaluateWorkflowExpression(expression, context);
63
+
64
+ expect(result).toBe(true);
65
+ });
66
+
67
+ it('rehydrates persisted execution int outputs for CEL arithmetic', () => {
68
+ const context = assembleJobsContext([
69
+ {
70
+ job: {key: 'review', status: 'succeeded', outputs: {}},
71
+ outputTypes: {count: 'int'},
72
+ executions: [jobExecution({outputs: {count: 42}})],
73
+ },
74
+ ]);
75
+ const expression = createWorkflowExpression({
76
+ source: 'jobs.review.executions[0].outputs.count + 1 == 43',
77
+ check: {mode: 'syntax'},
78
+ });
79
+
80
+ const result = evaluateWorkflowExpression(expression, context);
81
+
82
+ expect(result).toBe(true);
83
+ });
84
+
85
+ it('rehydrates persisted execution timestamp outputs for CEL comparison', () => {
86
+ const context = assembleJobsContext([
87
+ {
88
+ job: {key: 'review', status: 'succeeded', outputs: {}},
89
+ outputTypes: {createdAt: 'timestamp'},
90
+ executions: [jobExecution({outputs: {createdAt: '2026-06-30T12:00:00.000Z'}})],
91
+ },
92
+ ]);
93
+ const expression = createWorkflowExpression({
94
+ source: 'jobs.review.executions[0].outputs.createdAt < timestamp("2026-07-01T00:00:00Z")',
95
+ check: {mode: 'syntax'},
96
+ });
97
+
98
+ const result = evaluateWorkflowExpression(expression, context);
99
+
100
+ expect(result).toBe(true);
101
+ });
102
+ });
103
+
20
104
  describe('assembleWorkflowRunContext', () => {
21
105
  const run = {
22
106
  id: 'run-1',
@@ -48,7 +132,7 @@ describe('assembleWorkflowRunContext', () => {
48
132
  },
49
133
  run: {
50
134
  id: 'run-1',
51
- number: 1,
135
+ number: 1n,
52
136
  name: 'Build',
53
137
  project_id: 'proj-1',
54
138
  workspace_id: 'workspace-1',
@@ -224,16 +308,16 @@ describe('assembleExecutionCreationContext', () => {
224
308
  }),
225
309
  executions: [
226
310
  {
227
- index: 0,
311
+ index: 0n,
228
312
  name: 'Build #1',
229
313
  status: 'failed',
230
314
  started_at: date,
231
315
  finished_at: date,
232
- events: prior.triggerEvents,
316
+ events: prior.triggerEvents.map((event) => ({...event, received_at: date})),
233
317
  outputs: {},
234
318
  },
235
319
  {
236
- index: 1,
320
+ index: 1n,
237
321
  name: 'Build #2',
238
322
  status: 'pending',
239
323
  started_at: null,
@@ -243,7 +327,7 @@ describe('assembleExecutionCreationContext', () => {
243
327
  source: 'github',
244
328
  event: 'deployment',
245
329
  delivery_id: 'delivery-2',
246
- received_at: '2026-06-30T12:01:00.000Z',
330
+ received_at: new Date('2026-06-30T12:01:00.000Z'),
247
331
  project: null,
248
332
  repository: null,
249
333
  ref: null,
@@ -256,7 +340,7 @@ describe('assembleExecutionCreationContext', () => {
256
340
  ],
257
341
  job: {key: 'build', name: 'Build'},
258
342
  execution: {
259
- index: 1,
343
+ index: 1n,
260
344
  name: 'Build #2',
261
345
  status: 'pending',
262
346
  started_at: null,
@@ -266,7 +350,7 @@ describe('assembleExecutionCreationContext', () => {
266
350
  source: 'github',
267
351
  event: 'deployment',
268
352
  delivery_id: 'delivery-2',
269
- received_at: '2026-06-30T12:01:00.000Z',
353
+ received_at: new Date('2026-06-30T12:01:00.000Z'),
270
354
  project: null,
271
355
  repository: null,
272
356
  ref: null,
@@ -346,12 +430,15 @@ describe('assembleJobActivationContext', () => {
346
430
  outputs: {image: 'app:123'},
347
431
  executions: [
348
432
  {
349
- index: 0,
433
+ index: 0n,
350
434
  name: 'Build #1',
351
435
  status: 'succeeded',
352
436
  started_at: date,
353
437
  finished_at: null,
354
- events: buildExecution.triggerEvents,
438
+ events: buildExecution.triggerEvents.map((event) => ({
439
+ ...event,
440
+ received_at: date,
441
+ })),
355
442
  outputs: {sha: 'abc123'},
356
443
  },
357
444
  ],
@@ -370,12 +457,15 @@ describe('assembleJobActivationContext', () => {
370
457
  outputs: {image: 'app:123'},
371
458
  executions: [
372
459
  {
373
- index: 0,
460
+ index: 0n,
374
461
  name: 'Build #1',
375
462
  status: 'succeeded',
376
463
  started_at: date,
377
464
  finished_at: null,
378
- events: buildExecution.triggerEvents,
465
+ events: buildExecution.triggerEvents.map((event) => ({
466
+ ...event,
467
+ received_at: date,
468
+ })),
379
469
  outputs: {sha: 'abc123'},
380
470
  },
381
471
  ],
@@ -501,6 +591,59 @@ describe('listener filter snapshots', () => {
501
591
  expect(matcher?.filter_snapshot).toEqual({vars: {ENABLED: 'true'}});
502
592
  });
503
593
 
594
+ it('persists output types beside JSON-safe listener snapshots', () => {
595
+ const plan = planListenerFilterSnapshots({
596
+ on: [
597
+ {
598
+ source: 'github',
599
+ event: 'pull_request',
600
+ filter: 'jobs.build.outputs.details.count + 1 == 43',
601
+ },
602
+ ],
603
+ until: null,
604
+ });
605
+ const dependencyJobs = [
606
+ {
607
+ job: {
608
+ key: 'build',
609
+ status: 'succeeded' as const,
610
+ outputs: {details: {count: 42}},
611
+ },
612
+ outputTypes: {
613
+ details: {kind: 'object' as const, fields: {count: 'int' as const}},
614
+ },
615
+ executions: [],
616
+ },
617
+ ];
618
+ const context = assembleListenerSnapshotContext({
619
+ job: {key: 'await'},
620
+ run,
621
+ triggerPayload,
622
+ plan,
623
+ dependencyJobs,
624
+ });
625
+
626
+ const [matcher] = applyListenerFilterSnapshots(
627
+ plan.on,
628
+ context,
629
+ listenerFilterOutputTypesForJobs(dependencyJobs),
630
+ );
631
+
632
+ expect(matcher).toEqual({
633
+ source: 'github',
634
+ event: 'pull_request',
635
+ filter: 'jobs.build.outputs.details.count + 1 == 43',
636
+ filter_snapshot: {
637
+ jobs: {
638
+ build: expect.objectContaining({outputs: {details: {count: 42}}}),
639
+ },
640
+ },
641
+ filter_output_types: {
642
+ build: {details: {kind: 'object', fields: {count: 'int'}}},
643
+ },
644
+ });
645
+ });
646
+
504
647
  it('omits snapshots for event-only filters and malformed filters', () => {
505
648
  const plan = planListenerFilterSnapshots({
506
649
  on: [
@@ -606,6 +749,39 @@ describe('listener filter snapshots', () => {
606
749
  },
607
750
  });
608
751
  });
752
+
753
+ it('keeps snapshots JSON-serializable so they survive the outbox payload', () => {
754
+ const plan = planListenerFilterSnapshots({
755
+ on: [
756
+ {
757
+ source: 'github',
758
+ event: 'pull_request',
759
+ filter: 'jobs.build.executions[0].index == 0 && run.number == 1',
760
+ },
761
+ ],
762
+ until: null,
763
+ });
764
+ const context = assembleListenerSnapshotContext({
765
+ job: {key: 'await'},
766
+ run,
767
+ triggerPayload,
768
+ plan,
769
+ dependencyJobs: [
770
+ {
771
+ job: {key: 'build', status: 'succeeded', outputs: {}},
772
+ executions: [jobExecution({id: 'exec-build', jobId: 'job-build'})],
773
+ },
774
+ ],
775
+ });
776
+
777
+ const [matcher] = applyListenerFilterSnapshots(plan.on, context);
778
+ const jobs = matcher?.filter_snapshot?.jobs as Record<string, {executions: {index: unknown}[]}>;
779
+ const snapshotRun = matcher?.filter_snapshot?.run as {number: unknown};
780
+
781
+ expect(typeof jobs.build?.executions[0]?.index).toBe('number');
782
+ expect(typeof snapshotRun.number).toBe('number');
783
+ expect(() => JSON.stringify(matcher?.filter_snapshot)).not.toThrow();
784
+ });
609
785
  });
610
786
 
611
787
  describe('assembleStepDispatchContext', () => {
@@ -642,7 +818,7 @@ describe('assembleStepDispatchContext', () => {
642
818
  site: 'step-dispatch',
643
819
  values: {
644
820
  execution: {
645
- index: 2,
821
+ index: 1n,
646
822
  name: 'Deploy',
647
823
  status: 'running',
648
824
  failed: false,
@@ -653,7 +829,7 @@ describe('assembleStepDispatchContext', () => {
653
829
  source: 'github',
654
830
  event: 'push',
655
831
  delivery_id: 'delivery-1',
656
- received_at: '2026-06-30T12:00:00.000Z',
832
+ received_at: date,
657
833
  project: null,
658
834
  repository: null,
659
835
  ref: null,
@@ -980,7 +1156,7 @@ describe('assembleJobResolutionContext', () => {
980
1156
  vars: {},
981
1157
  executions: [
982
1158
  {
983
- index: 0,
1159
+ index: 0n,
984
1160
  name: 'First',
985
1161
  status: 'failed',
986
1162
  started_at: date,
@@ -990,7 +1166,7 @@ describe('assembleJobResolutionContext', () => {
990
1166
  source: 'github',
991
1167
  event: 'push',
992
1168
  delivery_id: 'delivery-1',
993
- received_at: '2026-06-30T12:00:00.000Z',
1169
+ received_at: date,
994
1170
  project: null,
995
1171
  repository: null,
996
1172
  ref: null,
@@ -1001,7 +1177,7 @@ describe('assembleJobResolutionContext', () => {
1001
1177
  outputs: {},
1002
1178
  },
1003
1179
  {
1004
- index: 1,
1180
+ index: 1n,
1005
1181
  name: 'Second',
1006
1182
  status: 'succeeded',
1007
1183
  started_at: date,
@@ -1011,7 +1187,7 @@ describe('assembleJobResolutionContext', () => {
1011
1187
  source: 'github',
1012
1188
  event: 'push',
1013
1189
  delivery_id: 'delivery-1',
1014
- received_at: '2026-06-30T12:00:00.000Z',
1190
+ received_at: date,
1015
1191
  project: null,
1016
1192
  repository: null,
1017
1193
  ref: null,
@@ -1069,15 +1245,51 @@ describe('assembleExecutionResolutionContext', () => {
1069
1245
  });
1070
1246
 
1071
1247
  expect(context.values.execution).toEqual({
1072
- index: 0,
1248
+ index: 0n,
1073
1249
  name: 'Build #2',
1074
1250
  status: 'running',
1075
1251
  started_at: date,
1076
1252
  finished_at: null,
1077
- events: targetExecution.triggerEvents,
1253
+ events: targetExecution.triggerEvents.map((event) => ({...event, received_at: date})),
1078
1254
  outputs: {sha: 'target'},
1079
1255
  });
1080
1256
  });
1257
+
1258
+ it('exposes execution indices and event timestamps as CEL-native values', () => {
1259
+ const targetExecution = jobExecution({
1260
+ id: 'exec-2',
1261
+ sequence: 2,
1262
+ outputs: {sha: 'target'},
1263
+ });
1264
+
1265
+ const context = assembleExecutionResolutionContext({
1266
+ run,
1267
+ triggerPayload: {
1268
+ source: 'manual',
1269
+ event: 'fire',
1270
+ subscriptionId: 'sub-1',
1271
+ userId: 'user-1',
1272
+ },
1273
+ job: {key: 'build'},
1274
+ jobExecution: targetExecution,
1275
+ executions: [targetExecution],
1276
+ steps: [],
1277
+ attempts: [],
1278
+ });
1279
+
1280
+ const expressions = [
1281
+ 'executions[0].index + 1 == 1',
1282
+ 'execution.index + 1 == 1',
1283
+ 'run.number + 1 == 2',
1284
+ 'executions[0].events[0].received_at < timestamp("2026-07-01T00:00:00Z")',
1285
+ ];
1286
+
1287
+ for (const source of expressions) {
1288
+ const expression = createWorkflowExpression({source, check: {mode: 'syntax'}});
1289
+
1290
+ expect(evaluateWorkflowExpression(expression, context.values)).toBe(true);
1291
+ }
1292
+ });
1081
1293
  });
1082
1294
 
1083
1295
  function step(overrides: Partial<Step> = {}): Step {