@shipfox/api-workflows 12.1.0 → 12.2.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 +29 -0
- package/dist/core/entities/job.d.ts +1 -1
- package/dist/core/entities/job.d.ts.map +1 -1
- package/dist/core/entities/step.d.ts +2 -2
- package/dist/core/entities/step.d.ts.map +1 -1
- package/dist/core/errors.d.ts +16 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +18 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/step-config/assemble-run-context.d.ts +15 -5
- package/dist/core/step-config/assemble-run-context.d.ts.map +1 -1
- package/dist/core/step-config/assemble-run-context.js +77 -27
- package/dist/core/step-config/assemble-run-context.js.map +1 -1
- package/dist/core/step-config/fields.d.ts +5 -0
- package/dist/core/step-config/fields.d.ts.map +1 -1
- package/dist/core/step-config/fields.js +21 -3
- package/dist/core/step-config/fields.js.map +1 -1
- package/dist/core/step-config/index.d.ts +1 -1
- package/dist/core/step-config/index.d.ts.map +1 -1
- package/dist/core/step-config/index.js +1 -1
- package/dist/core/step-config/index.js.map +1 -1
- package/dist/core/step-config/job-output-limits.d.ts +11 -0
- package/dist/core/step-config/job-output-limits.d.ts.map +1 -0
- package/dist/core/step-config/job-output-limits.js +88 -0
- package/dist/core/step-config/job-output-limits.js.map +1 -0
- package/dist/core/step-config/materialize-workflow-model.d.ts +1 -1
- package/dist/core/step-config/materialize-workflow-model.d.ts.map +1 -1
- package/dist/core/step-config/materialize-workflow-model.js +41 -14
- package/dist/core/step-config/materialize-workflow-model.js.map +1 -1
- package/dist/db/db.d.ts +50 -50
- package/dist/db/job-listeners.d.ts.map +1 -1
- package/dist/db/job-listeners.js +6 -5
- package/dist/db/job-listeners.js.map +1 -1
- package/dist/db/schema/job-executions.d.ts +3 -3
- package/dist/db/schema/job-listener-events.d.ts +1 -1
- package/dist/db/schema/jobs.d.ts +7 -7
- package/dist/db/schema/outbox.d.ts +1 -1
- package/dist/db/schema/step-attempts.d.ts +2 -2
- package/dist/db/schema/steps.d.ts +5 -5
- package/dist/db/schema/workflow-run-attempts.d.ts +3 -3
- package/dist/db/schema/workflow-run-counters.d.ts +1 -1
- package/dist/db/schema/workflow-runs.d.ts +2 -2
- package/dist/db/workflow-runs/job-executions.d.ts.map +1 -1
- package/dist/db/workflow-runs/job-executions.js +4 -2
- 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 +29 -3
- package/dist/db/workflow-runs/jobs.js.map +1 -1
- package/dist/presentation/routes/project-access.d.ts +1 -1
- package/dist/temporal/activities/index.d.ts +1 -1
- package/dist/temporal/activities/index.d.ts.map +1 -1
- package/dist/temporal/activities/orchestration-activities.d.ts +1 -1
- package/dist/temporal/activities/orchestration-activities.d.ts.map +1 -1
- package/dist/temporal/workflows/test-env.d.ts +2 -2
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +16 -16
- package/src/core/errors.ts +35 -0
- package/src/core/step-config/assemble-run-context.test.ts +232 -20
- package/src/core/step-config/assemble-run-context.ts +126 -28
- package/src/core/step-config/fields.ts +42 -3
- package/src/core/step-config/index.ts +2 -0
- package/src/core/step-config/job-output-limits.ts +130 -0
- package/src/core/step-config/materialize-workflow-model.test.ts +306 -1
- package/src/core/step-config/materialize-workflow-model.ts +60 -15
- package/src/db/job-listeners.ts +14 -3
- package/src/db/workflow-runs/job-executions.test.ts +207 -1
- package/src/db/workflow-runs/job-executions.ts +15 -2
- package/src/db/workflow-runs/jobs.test.ts +49 -0
- package/src/db/workflow-runs/jobs.ts +46 -6
- package/src/db/workflow-runs/run-create.test.ts +56 -0
- package/test/factories/workflow-model.ts +2 -0
- 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:
|
|
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) =>
|
|
147
|
+
executions: executions.map((execution, index) =>
|
|
148
|
+
assembleExecutionContext(execution, index, outputTypes, options),
|
|
149
|
+
),
|
|
134
150
|
};
|
|
135
151
|
}
|
|
136
152
|
|
|
137
|
-
function assembleExecutionContext(
|
|
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:
|
|
145
|
-
|
|
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(
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
364
|
-
executions: assembleExecutionsContext(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|