@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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +45 -0
- package/README.md +20 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +56 -0
- package/dist/config.js.map +1 -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 +18 -1
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +21 -2
- 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 +47 -18
- package/dist/core/step-config/materialize-workflow-model.js.map +1 -1
- package/dist/db/db.d.ts +50 -50
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/index.js.map +1 -1
- 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 +33 -3
- package/dist/db/workflow-runs/jobs.js.map +1 -1
- package/dist/db/workflow-runs/outbox.d.ts +1 -0
- package/dist/db/workflow-runs/outbox.d.ts.map +1 -1
- package/dist/db/workflow-runs/outbox.js +1 -0
- package/dist/db/workflow-runs/outbox.js.map +1 -1
- package/dist/db/workflow-runs/steps.d.ts +28 -0
- package/dist/db/workflow-runs/steps.d.ts.map +1 -1
- package/dist/db/workflow-runs/steps.js +49 -1
- package/dist/db/workflow-runs/steps.js.map +1 -1
- package/dist/db/workflow-runs.d.ts +2 -2
- package/dist/db/workflow-runs.d.ts.map +1 -1
- package/dist/db/workflow-runs.js +1 -1
- package/dist/db/workflow-runs.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/metrics/instance.d.ts +1 -0
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +8 -0
- package/dist/metrics/instance.js.map +1 -1
- package/dist/presentation/dto/evaluation-trace.d.ts +4 -0
- package/dist/presentation/dto/evaluation-trace.d.ts.map +1 -0
- package/dist/presentation/dto/evaluation-trace.js +39 -0
- package/dist/presentation/dto/evaluation-trace.js.map +1 -0
- package/dist/presentation/dto/index.d.ts +2 -1
- package/dist/presentation/dto/index.d.ts.map +1 -1
- package/dist/presentation/dto/index.js +2 -1
- package/dist/presentation/dto/index.js.map +1 -1
- package/dist/presentation/dto/job.d.ts.map +1 -1
- package/dist/presentation/dto/job.js +6 -0
- package/dist/presentation/dto/job.js.map +1 -1
- package/dist/presentation/dto/step.d.ts +2 -1
- package/dist/presentation/dto/step.d.ts.map +1 -1
- package/dist/presentation/dto/step.js +12 -0
- package/dist/presentation/dto/step.js.map +1 -1
- package/dist/presentation/index.d.ts +1 -1
- package/dist/presentation/index.d.ts.map +1 -1
- package/dist/presentation/index.js +1 -1
- package/dist/presentation/index.js.map +1 -1
- package/dist/presentation/routes/get-step-attempt-detail.d.ts +3 -0
- package/dist/presentation/routes/get-step-attempt-detail.d.ts.map +1 -0
- package/dist/presentation/routes/get-step-attempt-detail.js +38 -0
- package/dist/presentation/routes/get-step-attempt-detail.js.map +1 -0
- package/dist/presentation/routes/index.d.ts.map +1 -1
- package/dist/presentation/routes/index.js +2 -0
- package/dist/presentation/routes/index.js.map +1 -1
- package/dist/presentation/routes/project-access.d.ts +1 -1
- package/dist/presentation/subscribers/index.d.ts +1 -0
- package/dist/presentation/subscribers/index.d.ts.map +1 -1
- package/dist/presentation/subscribers/index.js +1 -0
- package/dist/presentation/subscribers/index.js.map +1 -1
- package/dist/presentation/subscribers/on-failure-annotations.d.ts +5 -0
- package/dist/presentation/subscribers/on-failure-annotations.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-failure-annotations.js +160 -0
- package/dist/presentation/subscribers/on-failure-annotations.js.map +1 -0
- 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 +18 -16
- package/src/config.test.ts +85 -0
- package/src/config.ts +71 -0
- package/src/core/errors.ts +44 -2
- package/src/core/job-execution.test.ts +1 -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.catalog.test.ts +117 -0
- package/src/core/step-config/materialize-workflow-model.test.ts +306 -1
- package/src/core/step-config/materialize-workflow-model.ts +65 -18
- package/src/db/index.ts +4 -0
- 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/job-status.test.ts +10 -3
- package/src/db/workflow-runs/jobs.test.ts +49 -0
- package/src/db/workflow-runs/jobs.ts +53 -6
- package/src/db/workflow-runs/outbox.ts +7 -1
- package/src/db/workflow-runs/run-create.test.ts +56 -0
- package/src/db/workflow-runs/steps.test.ts +148 -0
- package/src/db/workflow-runs/steps.ts +105 -1
- package/src/db/workflow-runs.ts +4 -0
- package/src/index.ts +9 -0
- package/src/metrics/instance.ts +10 -0
- package/src/presentation/dto/evaluation-trace.ts +27 -0
- package/src/presentation/dto/index.ts +2 -1
- package/src/presentation/dto/job.ts +6 -0
- package/src/presentation/dto/step.test.ts +96 -1
- package/src/presentation/dto/step.ts +17 -0
- package/src/presentation/index.ts +2 -0
- package/src/presentation/routes/get-step-attempt-detail.test.ts +159 -0
- package/src/presentation/routes/get-step-attempt-detail.ts +33 -0
- package/src/presentation/routes/index.ts +2 -0
- package/src/presentation/subscribers/index.ts +4 -0
- package/src/presentation/subscribers/on-failure-annotations.test.ts +510 -0
- package/src/presentation/subscribers/on-failure-annotations.ts +237 -0
- package/test/factories/workflow-model.ts +2 -0
- 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.
|
|
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.
|
|
26
|
+
"@shipfox/api-auth-context": "12.2.0",
|
|
26
27
|
"@shipfox/api-auth-dto": "12.0.0",
|
|
27
|
-
"@shipfox/api-agent-dto": "12.
|
|
28
|
-
"@shipfox/annotations-dto": "12.
|
|
29
|
-
"@shipfox/api-definitions-dto": "12.
|
|
30
|
-
"@shipfox/api-projects-dto": "12.
|
|
31
|
-
"@shipfox/api-integration-core-dto": "12.
|
|
32
|
-
"@shipfox/api-runners-dto": "12.
|
|
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.
|
|
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.
|
|
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.
|
|
41
|
-
"@shipfox/node-module": "1.0.
|
|
42
|
-
"@shipfox/node-opentelemetry": "0.6.
|
|
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.
|
|
46
|
-
"@shipfox/runner-labels": "0.
|
|
47
|
-
"@shipfox/workflow-document": "3.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
|
+
}
|
package/src/core/errors.ts
CHANGED
|
@@ -134,12 +134,54 @@ export class JobNotFoundError extends Error {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
export class InvalidJobRunnerLabelsError extends Error {
|
|
137
|
-
constructor(
|
|
138
|
-
|
|
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 {
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|