@shipfox/api-workflows 12.2.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 (104) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +16 -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/errors.d.ts +2 -1
  9. package/dist/core/errors.d.ts.map +1 -1
  10. package/dist/core/errors.js +3 -2
  11. package/dist/core/errors.js.map +1 -1
  12. package/dist/core/step-config/materialize-workflow-model.d.ts.map +1 -1
  13. package/dist/core/step-config/materialize-workflow-model.js +6 -4
  14. package/dist/core/step-config/materialize-workflow-model.js.map +1 -1
  15. package/dist/db/index.d.ts +2 -2
  16. package/dist/db/index.d.ts.map +1 -1
  17. package/dist/db/index.js +1 -1
  18. package/dist/db/index.js.map +1 -1
  19. package/dist/db/workflow-runs/jobs.d.ts.map +1 -1
  20. package/dist/db/workflow-runs/jobs.js +4 -0
  21. package/dist/db/workflow-runs/jobs.js.map +1 -1
  22. package/dist/db/workflow-runs/outbox.d.ts +1 -0
  23. package/dist/db/workflow-runs/outbox.d.ts.map +1 -1
  24. package/dist/db/workflow-runs/outbox.js +1 -0
  25. package/dist/db/workflow-runs/outbox.js.map +1 -1
  26. package/dist/db/workflow-runs/steps.d.ts +28 -0
  27. package/dist/db/workflow-runs/steps.d.ts.map +1 -1
  28. package/dist/db/workflow-runs/steps.js +49 -1
  29. package/dist/db/workflow-runs/steps.js.map +1 -1
  30. package/dist/db/workflow-runs.d.ts +2 -2
  31. package/dist/db/workflow-runs.d.ts.map +1 -1
  32. package/dist/db/workflow-runs.js +1 -1
  33. package/dist/db/workflow-runs.js.map +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +4 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/metrics/instance.d.ts +1 -0
  38. package/dist/metrics/instance.d.ts.map +1 -1
  39. package/dist/metrics/instance.js +8 -0
  40. package/dist/metrics/instance.js.map +1 -1
  41. package/dist/presentation/dto/evaluation-trace.d.ts +4 -0
  42. package/dist/presentation/dto/evaluation-trace.d.ts.map +1 -0
  43. package/dist/presentation/dto/evaluation-trace.js +39 -0
  44. package/dist/presentation/dto/evaluation-trace.js.map +1 -0
  45. package/dist/presentation/dto/index.d.ts +2 -1
  46. package/dist/presentation/dto/index.d.ts.map +1 -1
  47. package/dist/presentation/dto/index.js +2 -1
  48. package/dist/presentation/dto/index.js.map +1 -1
  49. package/dist/presentation/dto/job.d.ts.map +1 -1
  50. package/dist/presentation/dto/job.js +6 -0
  51. package/dist/presentation/dto/job.js.map +1 -1
  52. package/dist/presentation/dto/step.d.ts +2 -1
  53. package/dist/presentation/dto/step.d.ts.map +1 -1
  54. package/dist/presentation/dto/step.js +12 -0
  55. package/dist/presentation/dto/step.js.map +1 -1
  56. package/dist/presentation/index.d.ts +1 -1
  57. package/dist/presentation/index.d.ts.map +1 -1
  58. package/dist/presentation/index.js +1 -1
  59. package/dist/presentation/index.js.map +1 -1
  60. package/dist/presentation/routes/get-step-attempt-detail.d.ts +3 -0
  61. package/dist/presentation/routes/get-step-attempt-detail.d.ts.map +1 -0
  62. package/dist/presentation/routes/get-step-attempt-detail.js +38 -0
  63. package/dist/presentation/routes/get-step-attempt-detail.js.map +1 -0
  64. package/dist/presentation/routes/index.d.ts.map +1 -1
  65. package/dist/presentation/routes/index.js +2 -0
  66. package/dist/presentation/routes/index.js.map +1 -1
  67. package/dist/presentation/subscribers/index.d.ts +1 -0
  68. package/dist/presentation/subscribers/index.d.ts.map +1 -1
  69. package/dist/presentation/subscribers/index.js +1 -0
  70. package/dist/presentation/subscribers/index.js.map +1 -1
  71. package/dist/presentation/subscribers/on-failure-annotations.d.ts +5 -0
  72. package/dist/presentation/subscribers/on-failure-annotations.d.ts.map +1 -0
  73. package/dist/presentation/subscribers/on-failure-annotations.js +160 -0
  74. package/dist/presentation/subscribers/on-failure-annotations.js.map +1 -0
  75. package/dist/tsconfig.test.tsbuildinfo +1 -1
  76. package/package.json +10 -8
  77. package/src/config.test.ts +85 -0
  78. package/src/config.ts +71 -0
  79. package/src/core/errors.ts +9 -2
  80. package/src/core/job-execution.test.ts +1 -0
  81. package/src/core/step-config/materialize-workflow-model.catalog.test.ts +117 -0
  82. package/src/core/step-config/materialize-workflow-model.ts +5 -3
  83. package/src/db/index.ts +4 -0
  84. package/src/db/workflow-runs/job-status.test.ts +10 -3
  85. package/src/db/workflow-runs/jobs.ts +7 -0
  86. package/src/db/workflow-runs/outbox.ts +7 -1
  87. package/src/db/workflow-runs/steps.test.ts +148 -0
  88. package/src/db/workflow-runs/steps.ts +105 -1
  89. package/src/db/workflow-runs.ts +4 -0
  90. package/src/index.ts +9 -0
  91. package/src/metrics/instance.ts +10 -0
  92. package/src/presentation/dto/evaluation-trace.ts +27 -0
  93. package/src/presentation/dto/index.ts +2 -1
  94. package/src/presentation/dto/job.ts +6 -0
  95. package/src/presentation/dto/step.test.ts +96 -1
  96. package/src/presentation/dto/step.ts +17 -0
  97. package/src/presentation/index.ts +2 -0
  98. package/src/presentation/routes/get-step-attempt-detail.test.ts +159 -0
  99. package/src/presentation/routes/get-step-attempt-detail.ts +33 -0
  100. package/src/presentation/routes/index.ts +2 -0
  101. package/src/presentation/subscribers/index.ts +4 -0
  102. package/src/presentation/subscribers/on-failure-annotations.test.ts +510 -0
  103. package/src/presentation/subscribers/on-failure-annotations.ts +237 -0
  104. 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.2.0",
4
+ "version": "12.3.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -21,20 +21,22 @@
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
26
  "@shipfox/api-auth-context": "12.2.0",
26
27
  "@shipfox/api-auth-dto": "12.0.0",
27
28
  "@shipfox/api-agent-dto": "12.2.0",
28
- "@shipfox/annotations-dto": "12.0.0",
29
- "@shipfox/api-definitions-dto": "12.2.0",
29
+ "@shipfox/annotations-dto": "12.3.0",
30
+ "@shipfox/api-definitions-dto": "12.3.0",
30
31
  "@shipfox/api-projects-dto": "12.2.0",
31
32
  "@shipfox/api-integration-core-dto": "12.2.0",
32
33
  "@shipfox/api-runners-dto": "12.2.0",
33
34
  "@shipfox/api-secrets-dto": "12.0.0",
34
- "@shipfox/api-workflows-dto": "12.2.0",
35
- "@shipfox/inter-module": "0.2.3",
35
+ "@shipfox/api-workflows-dto": "12.3.0",
36
36
  "@shipfox/api-workspaces-dto": "12.0.0",
37
- "@shipfox/expression": "2.1.0",
37
+ "@shipfox/config": "1.2.4",
38
+ "@shipfox/inter-module": "0.2.3",
39
+ "@shipfox/expression": "2.2.0",
38
40
  "@shipfox/node-drizzle": "0.3.5",
39
41
  "@shipfox/node-error-monitoring": "0.3.0",
40
42
  "@shipfox/node-fastify": "0.4.2",
@@ -43,8 +45,8 @@
43
45
  "@shipfox/node-outbox": "0.2.6",
44
46
  "@shipfox/node-postgres": "0.5.0",
45
47
  "@shipfox/node-temporal": "0.4.5",
46
- "@shipfox/workflow-document": "3.0.1",
47
- "@shipfox/runner-labels": "0.2.0"
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,8 +134,15 @@ 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
  }
@@ -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
  ]);
@@ -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
+ });
@@ -1,5 +1,6 @@
1
1
  import {DEFAULT_JOB_CHECKOUT, type WorkflowModel} from '@shipfox/api-definitions-dto';
2
- import {canonicalizeLabels, findInvalidLabels, MAX_RUNNER_LABELS} from '@shipfox/runner-labels';
2
+ import {findInvalidLabels, MAX_RUNNER_LABELS, resolveRunnerLabels} from '@shipfox/runner-labels';
3
+ import {runnerCatalog} from '#config.js';
3
4
  import type {AgentDefaultsResolver} from '#core/agent-defaults.js';
4
5
  import type {
5
6
  AgentToolMaterializationContext,
@@ -112,10 +113,11 @@ export function materializeJobRunner(params: {
112
113
  definitionId: params.definitionId,
113
114
  }),
114
115
  );
115
- const labels = canonicalizeLabels([...params.job.runner, ...resolvedLabels]);
116
+ const requestedLabels = [...params.job.runner, ...resolvedLabels];
117
+ const labels = resolveRunnerLabels(requestedLabels, runnerCatalog);
116
118
  const invalidLabels = findInvalidLabels(labels);
117
119
  if (labels.length === 0 || labels.length > MAX_RUNNER_LABELS || invalidLabels.length > 0) {
118
- throw new InvalidJobRunnerLabelsError(labels);
120
+ throw new InvalidJobRunnerLabelsError(labels, requestedLabels);
119
121
  }
120
122
  return labels;
121
123
  }
package/src/db/index.ts CHANGED
@@ -34,6 +34,7 @@ export type {
34
34
  JobActivationDecision,
35
35
  ListWorkflowRunsParams,
36
36
  ListWorkflowRunsResult,
37
+ StepAttemptDetail,
37
38
  UpdateJobExecutionStatusParams,
38
39
  UpdateJobStatusParams,
39
40
  UpdateWorkflowRunStatusParams,
@@ -58,12 +59,15 @@ export {
58
59
  getJobById,
59
60
  getJobExecutionById,
60
61
  getJobExecutionDetail,
62
+ getJobExecutionFailureOrigin,
61
63
  getJobExecutionsByJobId,
62
64
  getJobExecutionsByWorkflowRunAttemptId,
63
65
  getJobScope,
64
66
  getJobsByWorkflowRunAttemptId,
65
67
  getJobsByWorkflowRunId,
66
68
  getLatestAttempt,
69
+ getLatestJobExecutionByJobId,
70
+ getStepAttemptDetail,
67
71
  getStepAttempts,
68
72
  getStepAttemptsByJobIds,
69
73
  getStepById,
@@ -473,7 +473,8 @@ describe('workflow run queries', () => {
473
473
  async function seedPendingJob() {
474
474
  const run = await createTestRun({workspaceId, projectId, definitionId});
475
475
  const jobId = (await getJobsByWorkflowRunId(run.id))[0]?.id as string;
476
- return {run, jobId};
476
+ const execution = await getFirstJobExecutionByJobId(jobId);
477
+ return {run, jobId, jobExecutionId: execution?.id};
477
478
  }
478
479
 
479
480
  test.each([
@@ -482,13 +483,19 @@ describe('workflow run queries', () => {
482
483
  'cancelled',
483
484
  'skipped',
484
485
  ] as const)('writes one terminated event when a job becomes %s', async (status) => {
485
- const {run, jobId} = await seedPendingJob();
486
+ const {run, jobId, jobExecutionId} = await seedPendingJob();
486
487
 
487
488
  await updateJobStatus({jobId, status, expectedVersion: 1});
488
489
 
489
490
  const events = await jobTerminatedEvents(jobId);
490
491
  expect(events).toHaveLength(1);
491
- expect(events[0]).toMatchObject({jobId, workflowRunId: run.id, status, statusReason: null});
492
+ expect(events[0]).toMatchObject({
493
+ jobId,
494
+ jobExecutionId,
495
+ workflowRunId: run.id,
496
+ status,
497
+ statusReason: null,
498
+ });
492
499
  });
493
500
 
494
501
  test('writes status reason on the terminated event', async () => {
@@ -347,11 +347,18 @@ export async function updateJobStatusAtVersion(
347
347
  // version match lets a single caller win. Emitting here, in the same transaction,
348
348
  // makes the terminal fact fire exactly once across all paths.
349
349
  if (isJobTerminal(job.status)) {
350
+ const [currentExecution] = await tx
351
+ .select({id: jobExecutions.id})
352
+ .from(jobExecutions)
353
+ .where(eq(jobExecutions.jobId, job.id))
354
+ .orderBy(desc(jobExecutions.sequence), desc(jobExecutions.id))
355
+ .limit(1);
350
356
  const identity = await getWorkflowContextForJob(job.id, tx);
351
357
  await writeWorkflowsOutboxEvent(tx, {
352
358
  type: WORKFLOWS_JOB_TERMINATED,
353
359
  payload: {
354
360
  jobId: job.id,
361
+ jobExecutionId: currentExecution?.id ?? null,
355
362
  workflowRunId: identity.workflowRunId,
356
363
  workflowRunAttemptId: identity.workflowRunAttemptId,
357
364
  status: job.status,
@@ -35,7 +35,12 @@ export async function writeJobStepsSettledOutbox(
35
35
 
36
36
  export async function writeStepAttemptTerminatedOutbox(
37
37
  tx: Tx,
38
- params: {stepId: string; attempt: number; logOutcome: LogOutcomeDto},
38
+ params: {
39
+ stepId: string;
40
+ attempt: number;
41
+ status: 'succeeded' | 'failed' | 'cancelled';
42
+ logOutcome: LogOutcomeDto;
43
+ },
39
44
  ): Promise<void> {
40
45
  const identity = await getStepAttemptTerminatedOutboxIdentity(tx, params.stepId);
41
46
 
@@ -49,6 +54,7 @@ export async function writeStepAttemptTerminatedOutbox(
49
54
  projectId: identity.projectId,
50
55
  stepId: params.stepId,
51
56
  attempt: params.attempt,
57
+ status: params.status,
52
58
  logOutcome: params.logOutcome,
53
59
  },
54
60
  });
@@ -11,7 +11,10 @@ import {stepAttempts as stepAttemptsTable} from '../schema/step-attempts.js';
11
11
  import {steps as stepsTable} from '../schema/steps.js';
12
12
  import {
13
13
  createWorkflowRun,
14
+ getFirstJobExecutionByJobId,
15
+ getJobExecutionFailureOrigin,
14
16
  getJobsByWorkflowRunId,
17
+ getStepAttemptDetail,
15
18
  getStepAttempts,
16
19
  getStepsByJobId,
17
20
  } from '../workflow-runs.js';
@@ -60,6 +63,150 @@ describe('workflow run queries', () => {
60
63
  });
61
64
  });
62
65
 
66
+ describe('getStepAttemptDetail', () => {
67
+ test('joins a step attempt to its run for lazy troubleshooting reads', async () => {
68
+ const run = await createWorkflowRun({
69
+ workspaceId,
70
+ projectId,
71
+ definitionId,
72
+ model: buildModel({jobs: {build: {steps: [{run: 'echo hello'}]}}}),
73
+ triggerPayload: {
74
+ source: 'manual',
75
+ event: 'fire',
76
+ subscriptionId: crypto.randomUUID(),
77
+ userId: crypto.randomUUID(),
78
+ },
79
+ });
80
+ const [job] = await getJobsByWorkflowRunId(run.id);
81
+ if (!job) throw new Error('Expected a workflow job');
82
+ await stripSetupStep(job.id);
83
+ await nextStepForJob(job.id);
84
+
85
+ const [attempt] = await getStepAttempts(job.id);
86
+ if (!attempt) throw new Error('Expected a dispatched step attempt');
87
+
88
+ const detail = await getStepAttemptDetail({stepId: attempt.stepId, attempt: attempt.attempt});
89
+
90
+ expect(detail).toMatchObject({
91
+ workflowRunId: run.id,
92
+ workflowRunAttemptId: job.workflowRunAttemptId,
93
+ step: {id: attempt.stepId},
94
+ attempt: {id: attempt.id, attempt: attempt.attempt},
95
+ });
96
+ });
97
+ });
98
+
99
+ describe('getJobExecutionFailureOrigin', () => {
100
+ test('selects the current failed attempt instead of loading attempt history', async () => {
101
+ const run = await createWorkflowRun({
102
+ workspaceId,
103
+ projectId,
104
+ definitionId,
105
+ model: buildModel({
106
+ jobs: {build: {steps: [{run: 'echo hello'}, {run: 'echo goodbye'}]}},
107
+ }),
108
+ triggerPayload: {
109
+ source: 'manual',
110
+ event: 'fire',
111
+ subscriptionId: crypto.randomUUID(),
112
+ userId: crypto.randomUUID(),
113
+ },
114
+ });
115
+ const [job] = await getJobsByWorkflowRunId(run.id);
116
+ if (!job) throw new Error('Expected a workflow job');
117
+ await stripSetupStep(job.id);
118
+ const execution = await getFirstJobExecutionByJobId(job.id);
119
+ if (!execution) throw new Error('Expected a job execution');
120
+ await nextStepForJob(job.id);
121
+
122
+ const [step] = await getStepsByJobId(job.id);
123
+ const [attempt] = await getStepAttempts(job.id);
124
+ if (!step || !attempt) throw new Error('Expected a current step attempt');
125
+ await db()
126
+ .update(stepsTable)
127
+ .set({status: 'failed', error: {reason: 'command_failed', message: 'current failure'}})
128
+ .where(eq(stepsTable.id, step.id));
129
+ await db()
130
+ .update(stepAttemptsTable)
131
+ .set({
132
+ status: 'failed',
133
+ error: {reason: 'command_failed', message: 'current failure'},
134
+ finishedAt: new Date(),
135
+ })
136
+ .where(eq(stepAttemptsTable.id, attempt.id));
137
+
138
+ await expect(getJobExecutionFailureOrigin(execution.id)).resolves.toMatchObject({
139
+ jobExecutionId: execution.id,
140
+ stepId: step.id,
141
+ stepAttempt: attempt.attempt,
142
+ attemptStatus: 'failed',
143
+ attemptError: {message: 'current failure'},
144
+ });
145
+ });
146
+
147
+ test('returns the first step when the execution fails before any attempt starts', async () => {
148
+ const run = await createWorkflowRun({
149
+ workspaceId,
150
+ projectId,
151
+ definitionId,
152
+ model: buildModel({jobs: {build: {steps: [{run: 'echo hello'}]}}}),
153
+ triggerPayload: {
154
+ source: 'manual',
155
+ event: 'fire',
156
+ subscriptionId: crypto.randomUUID(),
157
+ userId: crypto.randomUUID(),
158
+ },
159
+ });
160
+ const [job] = await getJobsByWorkflowRunId(run.id);
161
+ if (!job) throw new Error('Expected a workflow job');
162
+ const execution = await getFirstJobExecutionByJobId(job.id);
163
+ if (!execution) throw new Error('Expected a job execution');
164
+
165
+ await expect(getJobExecutionFailureOrigin(execution.id)).resolves.toMatchObject({
166
+ jobExecutionId: execution.id,
167
+ stepAttempt: 1,
168
+ attemptStatus: null,
169
+ });
170
+ });
171
+
172
+ test('selects a condition-errored step before an earlier pending step', async () => {
173
+ const run = await createWorkflowRun({
174
+ workspaceId,
175
+ projectId,
176
+ definitionId,
177
+ model: buildModel({
178
+ jobs: {build: {steps: [{run: 'echo first'}, {run: 'echo second'}]}},
179
+ }),
180
+ triggerPayload: {
181
+ source: 'manual',
182
+ event: 'fire',
183
+ subscriptionId: crypto.randomUUID(),
184
+ userId: crypto.randomUUID(),
185
+ },
186
+ });
187
+ const [job] = await getJobsByWorkflowRunId(run.id);
188
+ if (!job) throw new Error('Expected a workflow job');
189
+ await stripSetupStep(job.id);
190
+ const execution = await getFirstJobExecutionByJobId(job.id);
191
+ if (!execution) throw new Error('Expected a job execution');
192
+
193
+ const steps = await getStepsByJobId(job.id);
194
+ const firstStep = steps[0];
195
+ const conditionErroredStep = steps[1];
196
+ if (!firstStep || !conditionErroredStep) throw new Error('Expected two workflow steps');
197
+ await db()
198
+ .update(stepsTable)
199
+ .set({status: 'skipped', statusReason: 'condition_errored'})
200
+ .where(eq(stepsTable.id, conditionErroredStep.id));
201
+
202
+ await expect(getJobExecutionFailureOrigin(execution.id)).resolves.toMatchObject({
203
+ stepId: conditionErroredStep.id,
204
+ stepStatus: 'skipped',
205
+ });
206
+ expect(firstStep.position).toBeLessThan(conditionErroredStep.position);
207
+ });
208
+ });
209
+
63
210
  describe('bulkUpdateStepStatuses', () => {
64
211
  test('updates all steps for a job to the given sweep status', async () => {
65
212
  const run = await createWorkflowRun({
@@ -183,6 +330,7 @@ describe('workflow run queries', () => {
183
330
  projectId,
184
331
  stepId: attempt?.stepId,
185
332
  attempt: 1,
333
+ status: 'cancelled',
186
334
  logOutcome: 'abandoned',
187
335
  },
188
336
  ]);