@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
@@ -1,12 +1,14 @@
1
1
  import {
2
2
  analyzeContextRootKeyAccess,
3
+ type ExpressionType,
3
4
  extractExactContextRoots,
4
5
  getWorkflowPredicateContextRoots,
6
+ rehydrateJsonExpressionRecord,
5
7
  type WorkflowExpressionEvaluationContext,
6
8
  type WorkflowPredicateContextRoot,
7
9
  } from '@shipfox/expression';
8
10
  import type {Job, JobListeningTrigger} from '#core/entities/job.js';
9
- import type {JobExecution} from '#core/entities/job-execution.js';
11
+ import type {JobExecution, WorkflowExecutionEvent} from '#core/entities/job-execution.js';
10
12
  import type {Step, StepAttempt, StepStatus} from '#core/entities/step.js';
11
13
  import type {
12
14
  TriggerPayload,
@@ -17,9 +19,15 @@ import type {WorkflowEvaluationContext} from './workflow-evaluation-context.js';
17
19
 
18
20
  export interface JobContextInput {
19
21
  readonly job: Pick<Job, 'key' | 'status' | 'outputs'>;
22
+ readonly outputTypes?: Readonly<Record<string, ExpressionType>>;
20
23
  readonly executions: readonly JobExecution[];
21
24
  }
22
25
 
26
+ export interface AssembleJobsContextOptions {
27
+ readonly skipTypedOutputRehydration?: boolean;
28
+ readonly skipCelNativeRehydration?: boolean;
29
+ }
30
+
23
31
  export interface AssembleWorkflowRunContextParams {
24
32
  readonly run: Pick<
25
33
  WorkflowRun,
@@ -37,8 +45,11 @@ export interface AssembleWorkflowRunContextParams {
37
45
  readonly vars?: Record<string, string> | undefined;
38
46
  }
39
47
 
48
+ interface AssembleContextOptions extends AssembleJobsContextOptions {}
49
+
40
50
  export function assembleWorkflowRunContext(
41
51
  params: AssembleWorkflowRunContextParams,
52
+ options: AssembleContextOptions = {},
42
53
  ): WorkflowExpressionEvaluationContext {
43
54
  const triggerReference = params.run.triggerReference;
44
55
  return {
@@ -48,7 +59,8 @@ export function assembleWorkflowRunContext(
48
59
  },
49
60
  run: {
50
61
  id: params.run.id,
51
- number: params.run.number,
62
+ number:
63
+ options.skipCelNativeRehydration === true ? params.run.number : BigInt(params.run.number),
52
64
  name: params.run.name,
53
65
  project_id: params.run.projectId,
54
66
  workspace_id: params.run.workspaceId,
@@ -128,29 +140,62 @@ export function assembleExecutionCreationContext(
128
140
  */
129
141
  export function assembleExecutionsContext(
130
142
  executions: readonly JobExecution[],
143
+ outputTypes?: Readonly<Record<string, ExpressionType>>,
144
+ options: AssembleContextOptions = {},
131
145
  ): WorkflowExpressionEvaluationContext {
132
146
  return {
133
- executions: executions.map((execution, index) => assembleExecutionContext(execution, index)),
147
+ executions: executions.map((execution, index) =>
148
+ assembleExecutionContext(execution, index, outputTypes, options),
149
+ ),
134
150
  };
135
151
  }
136
152
 
137
- function assembleExecutionContext(execution: JobExecution, index: number): Record<string, unknown> {
153
+ function assembleExecutionContext(
154
+ execution: JobExecution,
155
+ index: number,
156
+ outputTypes?: Readonly<Record<string, ExpressionType>>,
157
+ options: AssembleContextOptions = {},
158
+ ): Record<string, unknown> {
159
+ const skipCelNativeRehydration = options.skipCelNativeRehydration === true;
138
160
  return {
139
- index,
161
+ index: skipCelNativeRehydration ? index : BigInt(index),
140
162
  name: execution.name,
141
163
  status: execution.status,
142
164
  started_at: execution.startedAt,
143
165
  finished_at: execution.finishedAt,
144
- events: execution.triggerEvents,
145
- outputs: execution.outputs ?? {},
166
+ events: skipCelNativeRehydration
167
+ ? execution.triggerEvents
168
+ : execution.triggerEvents.map(assembleExecutionEventContext),
169
+ outputs:
170
+ options.skipTypedOutputRehydration === true
171
+ ? (execution.outputs ?? {})
172
+ : rehydrateJsonExpressionRecord(execution.outputs, outputTypes),
173
+ };
174
+ }
175
+
176
+ function assembleExecutionEventContext(event: WorkflowExecutionEvent): Record<string, unknown> {
177
+ const receivedAt = new Date(event.received_at);
178
+ /**
179
+ * An unparseable stored timestamp would become an `Invalid Date`, which compares
180
+ * false against every CEL `timestamp` and throws on `toISOString` during template
181
+ * resolution. Keeping the raw value fails loudly as a type mismatch instead.
182
+ */
183
+ if (Number.isNaN(receivedAt.getTime())) return {...event};
184
+
185
+ return {
186
+ ...event,
187
+ received_at: receivedAt,
146
188
  };
147
189
  }
148
190
 
149
191
  export function assembleJobsContext(
150
192
  jobs: readonly JobContextInput[],
193
+ options: AssembleJobsContextOptions = {},
151
194
  ): WorkflowExpressionEvaluationContext & {readonly jobs: Record<string, unknown>} {
152
195
  return {
153
- jobs: Object.fromEntries(jobs.map((input) => [input.job.key, assembleJobContext(input)])),
196
+ jobs: Object.fromEntries(
197
+ jobs.map((input) => [input.job.key, assembleJobContext(input, options)]),
198
+ ),
154
199
  };
155
200
  }
156
201
 
@@ -166,7 +211,7 @@ export function assembleJobActivationContext(
166
211
  values: {
167
212
  ...assembleWorkflowRunContext(params),
168
213
  ...assembleJobsContext(params.jobs),
169
- needs: params.jobs.map(assembleJobContext),
214
+ needs: params.jobs.map((input) => assembleJobContext(input, {})),
170
215
  vars: params.vars ?? {},
171
216
  },
172
217
  };
@@ -188,6 +233,8 @@ export interface ListenerSnapshotPlan {
188
233
  readonly jobKeys: ReadonlySet<string>;
189
234
  }
190
235
 
236
+ export type ListenerFilterOutputTypes = Record<string, Record<string, ExpressionType>>;
237
+
191
238
  export function planListenerFilterSnapshots(params: {
192
239
  readonly on: readonly JobListeningTrigger[];
193
240
  readonly until: readonly JobListeningTrigger[] | null;
@@ -262,12 +309,15 @@ export function assembleListenerSnapshotContext(params: {
262
309
  params.plan.roots.has('run') ||
263
310
  params.plan.roots.has('trigger')
264
311
  ) {
265
- const runContext = assembleWorkflowRunContext({
266
- run: params.run,
267
- triggerPayload: params.triggerPayload,
268
- inputs: params.inputs,
269
- vars: params.vars,
270
- });
312
+ const runContext = assembleWorkflowRunContext(
313
+ {
314
+ run: params.run,
315
+ triggerPayload: params.triggerPayload,
316
+ inputs: params.inputs,
317
+ vars: params.vars,
318
+ },
319
+ {skipCelNativeRehydration: true},
320
+ );
271
321
  if (params.plan.roots.has('workflow')) context.workflow = runContext.workflow;
272
322
  if (params.plan.roots.has('run')) context.run = runContext.run;
273
323
  if (params.plan.roots.has('trigger')) context.trigger = runContext.trigger;
@@ -296,27 +346,69 @@ function requestedJobsContext(
296
346
  dependencyJobs: readonly JobContextInput[],
297
347
  jobKeys: ReadonlySet<string>,
298
348
  ): Record<string, unknown> {
299
- if (jobKeys.size === 0) return assembleJobsContext(dependencyJobs).jobs;
349
+ // Listener snapshots cross a JSON outbox boundary; their type metadata is persisted separately.
350
+ const options: AssembleJobsContextOptions = {
351
+ skipCelNativeRehydration: true,
352
+ skipTypedOutputRehydration: true,
353
+ };
354
+ if (jobKeys.size === 0) {
355
+ return assembleJobsContext(dependencyJobs, options).jobs;
356
+ }
300
357
 
301
358
  const filtered = dependencyJobs.filter(({job}) => jobKeys.has(job.key));
302
359
 
303
- return assembleJobsContext(filtered).jobs;
360
+ return assembleJobsContext(filtered, options).jobs;
304
361
  }
305
362
 
306
363
  export type ListenerTriggerWithSnapshot = JobListeningTrigger & {
307
364
  readonly filter_snapshot?: Record<string, unknown>;
365
+ readonly filter_output_types?: ListenerFilterOutputTypes;
308
366
  };
309
367
 
310
368
  export function applyListenerFilterSnapshots(
311
369
  plans: readonly MatcherSnapshotPlan[],
312
370
  context: WorkflowExpressionEvaluationContext,
371
+ outputTypes?: ListenerFilterOutputTypes,
313
372
  ): ListenerTriggerWithSnapshot[] {
314
373
  return plans.map((plan) => {
315
374
  const filterSnapshot = filterSnapshotForPlan(plan, context);
316
375
  if (filterSnapshot === undefined) return plan.matcher;
317
376
 
318
- return {...plan.matcher, filter_snapshot: filterSnapshot};
377
+ const filterOutputTypes = filterOutputTypesForPlan(plan, context, outputTypes);
378
+
379
+ return {
380
+ ...plan.matcher,
381
+ filter_snapshot: filterSnapshot,
382
+ ...(filterOutputTypes === undefined ? {} : {filter_output_types: filterOutputTypes}),
383
+ };
384
+ });
385
+ }
386
+
387
+ export function listenerFilterOutputTypesForJobs(
388
+ jobs: readonly JobContextInput[],
389
+ ): ListenerFilterOutputTypes {
390
+ return Object.fromEntries(
391
+ jobs.flatMap(({job, outputTypes}) =>
392
+ outputTypes === undefined ? [] : [[job.key, {...outputTypes}] as const],
393
+ ),
394
+ );
395
+ }
396
+
397
+ function filterOutputTypesForPlan(
398
+ plan: MatcherSnapshotPlan,
399
+ context: WorkflowExpressionEvaluationContext,
400
+ outputTypes: ListenerFilterOutputTypes | undefined,
401
+ ): ListenerFilterOutputTypes | undefined {
402
+ if (outputTypes === undefined || !plan.roots.has('jobs')) return undefined;
403
+
404
+ const jobsSnapshot = jobsSnapshotForPlan(plan, context);
405
+ if (jobsSnapshot === undefined) return undefined;
406
+
407
+ const entries = Object.keys(jobsSnapshot).flatMap((jobKey) => {
408
+ const types = outputTypes[jobKey];
409
+ return types === undefined ? [] : [[jobKey, {...types}] as const];
319
410
  });
411
+ return entries.length === 0 ? undefined : Object.fromEntries(entries);
320
412
  }
321
413
 
322
414
  function filterSnapshotForPlan(
@@ -356,12 +448,24 @@ function jobsSnapshotForPlan(
356
448
  return snapshot;
357
449
  }
358
450
 
359
- function assembleJobContext({job, executions}: JobContextInput): Record<string, unknown> {
451
+ function assembleJobContext(
452
+ {job, outputTypes, executions}: JobContextInput,
453
+ options: AssembleJobsContextOptions,
454
+ ): Record<string, unknown> {
455
+ const outputs =
456
+ options.skipTypedOutputRehydration === true
457
+ ? (job.outputs ?? {})
458
+ : rehydrateJsonExpressionRecord(job.outputs, outputTypes);
459
+
360
460
  return {
361
461
  key: job.key,
362
462
  status: job.status,
363
- outputs: job.outputs ?? {},
364
- executions: assembleExecutionsContext(executions).executions,
463
+ outputs,
464
+ executions: assembleExecutionsContext(
465
+ executions,
466
+ options.skipTypedOutputRehydration === true ? undefined : outputTypes,
467
+ options,
468
+ ).executions,
365
469
  };
366
470
  }
367
471
 
@@ -448,14 +552,8 @@ export function assembleStepDispatchContext(params: {
448
552
  ? {}
449
553
  : {
450
554
  execution: {
451
- index: params.jobExecution.sequence,
452
- name: params.jobExecution.name,
453
- status: params.jobExecution.status,
555
+ ...assembleExecutionContext(params.jobExecution, params.jobExecution.sequence - 1),
454
556
  failed: stepAttemptContext.stepsFailed,
455
- started_at: params.jobExecution.startedAt,
456
- finished_at: params.jobExecution.finishedAt,
457
- events: params.jobExecution.triggerEvents,
458
- outputs: params.jobExecution.outputs ?? {},
459
557
  },
460
558
  }),
461
559
  ...(targetStep === undefined
@@ -35,13 +35,35 @@ export interface ResolveStepFieldParams {
35
35
  }
36
36
 
37
37
  export function resolveStepField(params: ResolveStepFieldParams): SiteResolvedField {
38
+ return resolveStepFieldAtSite(params, false);
39
+ }
40
+
41
+ function resolveStepFieldWithType(params: ResolveStepFieldParams): SiteResolvedField<unknown> {
42
+ return resolveStepFieldAtSite(params, true);
43
+ }
44
+
45
+ function resolveStepFieldAtSite(
46
+ params: ResolveStepFieldParams,
47
+ preserveSingleExpressionType: true,
48
+ ): SiteResolvedField<unknown>;
49
+ function resolveStepFieldAtSite(
50
+ params: ResolveStepFieldParams,
51
+ preserveSingleExpressionType: false,
52
+ ): SiteResolvedField;
53
+ function resolveStepFieldAtSite(
54
+ params: ResolveStepFieldParams,
55
+ preserveSingleExpressionType: boolean,
56
+ ): SiteResolvedField<unknown> {
38
57
  try {
39
- return resolveFieldAtSite({
58
+ const resolveParams = {
40
59
  field: params.template,
41
60
  context: params.context.values,
42
61
  site: params.context.site,
43
62
  failurePolicy: getWorkflowInterpolationFieldFailurePolicy(params.field),
44
- });
63
+ };
64
+ return preserveSingleExpressionType
65
+ ? resolveFieldAtSite({...resolveParams, preserveSingleExpressionType: true})
66
+ : resolveFieldAtSite(resolveParams);
45
67
  } catch (error) {
46
68
  if (error instanceof WorkflowTemplateResolutionError) {
47
69
  throw stepConfigInterpolationError(params, error);
@@ -74,11 +96,28 @@ export function completeStepField(params: ResolveStepFieldParams): string {
74
96
  return completeStepFieldWithTrace(params).value;
75
97
  }
76
98
 
99
+ export function completeStepFieldWithType(params: ResolveStepFieldParams): unknown {
100
+ return completeStepFieldWithTypeAndTrace(params).value;
101
+ }
102
+
77
103
  export function completeStepFieldWithTrace(params: ResolveStepFieldParams): {
78
104
  readonly value: string;
79
105
  readonly trace: SiteResolvedField['trace'];
80
106
  } {
81
- const resolved = resolveStepField(params);
107
+ return completeResolvedStepField(params, resolveStepField(params));
108
+ }
109
+
110
+ export function completeStepFieldWithTypeAndTrace(params: ResolveStepFieldParams): {
111
+ readonly value: unknown;
112
+ readonly trace: SiteResolvedField<unknown>['trace'];
113
+ } {
114
+ return completeResolvedStepField(params, resolveStepFieldWithType(params));
115
+ }
116
+
117
+ function completeResolvedStepField<Value>(
118
+ params: ResolveStepFieldParams,
119
+ resolved: SiteResolvedField<Value>,
120
+ ): {readonly value: Value; readonly trace: SiteResolvedField<Value>['trace']} {
82
121
  if (resolved.kind === 'frozen') return {value: resolved.value, trace: resolved.trace};
83
122
 
84
123
  const source = resolved.field.segments.find((segment) => segment.kind === 'deferred')?.expression
@@ -14,8 +14,10 @@ export {
14
14
  assembleStepDispatchContext,
15
15
  assembleWorkflowRunContext,
16
16
  type JobContextInput,
17
+ type ListenerFilterOutputTypes,
17
18
  type ListenerSnapshotPlan,
18
19
  type ListenerTriggerWithSnapshot,
20
+ listenerFilterOutputTypesForJobs,
19
21
  type MatcherSnapshotPlan,
20
22
  planListenerFilterSnapshots,
21
23
  } from './assemble-run-context.js';
@@ -0,0 +1,130 @@
1
+ import {
2
+ WORKFLOW_DOCUMENT_JOB_OUTPUTS_MAX_ENTRIES,
3
+ WORKFLOW_DOCUMENT_STEP_OUTPUT_SCHEMA_MAX_DEPTH,
4
+ } from '@shipfox/workflow-document';
5
+ import {JobOutputNotJsonSafeError} from '#core/errors.js';
6
+
7
+ export const MAX_JOB_OUTPUT_ENTRIES = WORKFLOW_DOCUMENT_JOB_OUTPUTS_MAX_ENTRIES;
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;
11
+
12
+ const textEncoder = new TextEncoder();
13
+
14
+ export type JsonSafeJobOutputValue =
15
+ | null
16
+ | string
17
+ | number
18
+ | boolean
19
+ | readonly JsonSafeJobOutputValue[]
20
+ | {[key: string]: JsonSafeJobOutputValue};
21
+
22
+ export function normalizeJobOutputValue(value: unknown, outputKey: string): JsonSafeJobOutputValue {
23
+ return normalize(value, outputKey, new WeakSet<object>(), 0);
24
+ }
25
+
26
+ export function jobOutputValueByteLength(value: unknown): number {
27
+ if (typeof value === 'string') return textEncoder.encode(value).byteLength;
28
+
29
+ return jsonByteLength(value);
30
+ }
31
+
32
+ export function jobOutputRecordEntryByteLength(key: string, value: JsonSafeJobOutputValue): number {
33
+ return textEncoder.encode(JSON.stringify(key)).byteLength + 1 + jsonByteLength(value);
34
+ }
35
+
36
+ function jsonByteLength(value: unknown): number {
37
+ const serialized = JSON.stringify(value);
38
+ return textEncoder.encode(serialized ?? '').byteLength;
39
+ }
40
+
41
+ function normalize(
42
+ value: unknown,
43
+ outputKey: string,
44
+ ancestors: WeakSet<object>,
45
+ depth: number,
46
+ ): JsonSafeJobOutputValue {
47
+ if (value === null) return null;
48
+
49
+ switch (typeof value) {
50
+ case 'string':
51
+ case 'boolean':
52
+ return value;
53
+ case 'number':
54
+ if (!Number.isFinite(value)) {
55
+ throw new JobOutputNotJsonSafeError(outputKey, 'numbers must be finite');
56
+ }
57
+ return value;
58
+ case 'bigint': {
59
+ const numberValue = Number(value);
60
+ return Number.isSafeInteger(numberValue) ? numberValue : value.toString();
61
+ }
62
+ case 'undefined':
63
+ throw new JobOutputNotJsonSafeError(outputKey, 'undefined is not a JSON value');
64
+ case 'function':
65
+ case 'symbol':
66
+ throw new JobOutputNotJsonSafeError(
67
+ outputKey,
68
+ `values of type ${typeof value} are not JSON values`,
69
+ );
70
+ case 'object':
71
+ return normalizeObject(value, outputKey, ancestors, depth);
72
+ }
73
+
74
+ throw new JobOutputNotJsonSafeError(outputKey, 'the value has an unsupported type');
75
+ }
76
+
77
+ function normalizeObject(
78
+ value: object,
79
+ outputKey: string,
80
+ ancestors: WeakSet<object>,
81
+ depth: number,
82
+ ): JsonSafeJobOutputValue {
83
+ if (value instanceof Date) {
84
+ if (Number.isNaN(value.getTime())) {
85
+ throw new JobOutputNotJsonSafeError(outputKey, 'dates must be valid');
86
+ }
87
+ return value.toISOString();
88
+ }
89
+
90
+ if (ancestors.has(value)) {
91
+ throw new JobOutputNotJsonSafeError(outputKey, 'the value contains a circular reference');
92
+ }
93
+
94
+ if (depth >= MAX_JOB_OUTPUT_NESTING_DEPTH) {
95
+ throw new JobOutputNotJsonSafeError(
96
+ outputKey,
97
+ `values cannot be nested deeper than ${MAX_JOB_OUTPUT_NESTING_DEPTH} levels`,
98
+ );
99
+ }
100
+
101
+ if (Array.isArray(value)) {
102
+ ancestors.add(value);
103
+ try {
104
+ const normalized: JsonSafeJobOutputValue[] = [];
105
+ for (const item of value) {
106
+ normalized.push(normalize(item, outputKey, ancestors, depth + 1));
107
+ }
108
+ return normalized;
109
+ } finally {
110
+ ancestors.delete(value);
111
+ }
112
+ }
113
+
114
+ const prototype = Object.getPrototypeOf(value);
115
+ if (prototype !== Object.prototype && prototype !== null) {
116
+ throw new JobOutputNotJsonSafeError(outputKey, 'the value has an unsupported object type');
117
+ }
118
+
119
+ ancestors.add(value);
120
+ try {
121
+ return Object.fromEntries(
122
+ Object.keys(value).map((key) => [
123
+ key,
124
+ normalize((value as Record<string, unknown>)[key], outputKey, ancestors, depth + 1),
125
+ ]),
126
+ ) as {[key: string]: JsonSafeJobOutputValue};
127
+ } finally {
128
+ ancestors.delete(value);
129
+ }
130
+ }
@@ -0,0 +1,117 @@
1
+ import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
2
+ import {tmpdir} from 'node:os';
3
+ import {join} from 'node:path';
4
+ import {workflowModel} from '#test/index.js';
5
+
6
+ let dir: string;
7
+
8
+ beforeEach(() => {
9
+ dir = mkdtempSync(join(tmpdir(), 'api-workflows-runner-catalog-'));
10
+ });
11
+
12
+ afterEach(() => {
13
+ vi.unstubAllEnvs();
14
+ vi.resetModules();
15
+ rmSync(dir, {recursive: true, force: true});
16
+ });
17
+
18
+ function template(source: string): string {
19
+ return `\${{ ${source} }}`;
20
+ }
21
+
22
+ function writeCatalog(contents: string): string {
23
+ const path = join(dir, 'runner-catalog.yaml');
24
+ writeFileSync(path, contents);
25
+ return path;
26
+ }
27
+
28
+ function executionContext(runner: string) {
29
+ return {
30
+ site: 'execution-creation' as const,
31
+ values: {
32
+ run: {
33
+ id: 'run-1',
34
+ name: 'Build',
35
+ definition_id: 'definition-1',
36
+ project_id: 'project-1',
37
+ workspace_id: 'workspace-1',
38
+ created_at: new Date('2026-06-30T12:00:00.000Z'),
39
+ },
40
+ trigger: {source: 'manual', event: 'fire'},
41
+ event: null,
42
+ inputs: null,
43
+ execution: {
44
+ index: 1,
45
+ name: 'build #1',
46
+ status: 'pending',
47
+ started_at: null,
48
+ finished_at: null,
49
+ events: [{data: {runner}, source: 'github', event: 'push'}],
50
+ },
51
+ },
52
+ };
53
+ }
54
+
55
+ describe('materializeJobRunner with a catalog', () => {
56
+ it('expands an interpolated catalog name and keeps free-form labels', async () => {
57
+ const path = writeCatalog(`
58
+ shipfox-4cpu:
59
+ - arch.amd64
60
+ - cpu.4
61
+ `);
62
+ vi.stubEnv('RUNNER_CATALOG_PATH', path);
63
+ vi.resetModules();
64
+
65
+ const {materializeJobRunner} = await import('./materialize-workflow-model.js');
66
+ const model = workflowModel({
67
+ jobs: {
68
+ build: {
69
+ runner: ['internal-network'],
70
+ runnerTemplates: [template('execution.events[0].data.runner')],
71
+ steps: [{run: 'npm test'}],
72
+ },
73
+ },
74
+ });
75
+ const [job] = model.jobs;
76
+ if (!job) throw new Error('Test model created no jobs');
77
+
78
+ const result = materializeJobRunner({
79
+ job,
80
+ context: executionContext('SHIPFOX-4CPU'),
81
+ definitionId: 'definition-1',
82
+ });
83
+
84
+ expect(result).toEqual(['arch.amd64', 'cpu.4', 'internal-network']);
85
+ });
86
+
87
+ it('validates the combined expanded label count', async () => {
88
+ const labels = (suffix: string) =>
89
+ Array.from({length: 8}, (_, index) => ` - label-${index}${suffix}`).join('\n');
90
+ const path = writeCatalog(
91
+ `first:\n${labels('')}\nsecond:\n${labels('-second')}\nthird:\n${labels('-third')}\n`,
92
+ );
93
+ vi.stubEnv('RUNNER_CATALOG_PATH', path);
94
+ vi.resetModules();
95
+
96
+ const {materializeJobRunner} = await import('./materialize-workflow-model.js');
97
+ const model = workflowModel({
98
+ jobs: {
99
+ build: {
100
+ runner: [],
101
+ runnerTemplates: [template('"first"'), template('"second"'), template('"third"')],
102
+ steps: [{run: 'npm test'}],
103
+ },
104
+ },
105
+ });
106
+ const [job] = model.jobs;
107
+ if (!job) throw new Error('Test model created no jobs');
108
+
109
+ expect(() =>
110
+ materializeJobRunner({
111
+ job,
112
+ context: executionContext('unused'),
113
+ definitionId: 'definition-1',
114
+ }),
115
+ ).toThrow('requested: first, second, third');
116
+ });
117
+ });