@shipfox/api-workflows 12.6.0 → 12.7.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-workflows",
3
3
  "license": "MIT",
4
- "version": "12.6.0",
4
+ "version": "12.7.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -32,7 +32,7 @@
32
32
  "@shipfox/api-integration-core-dto": "12.2.0",
33
33
  "@shipfox/api-runners-dto": "12.4.0",
34
34
  "@shipfox/api-secrets-dto": "12.0.0",
35
- "@shipfox/api-workflows-dto": "12.6.0",
35
+ "@shipfox/api-workflows-dto": "12.7.0",
36
36
  "@shipfox/api-workspaces-dto": "12.0.0",
37
37
  "@shipfox/config": "1.2.4",
38
38
  "@shipfox/inter-module": "0.2.3",
@@ -107,4 +107,7 @@ export interface WorkflowRunDetail extends WorkflowRun {
107
107
  runAttempt: WorkflowRunAttempt;
108
108
  latestAttempt: number;
109
109
  jobs: WorkflowJobDetail[];
110
+ /** Whether any job execution of this attempt reached its runner, decided here so the detail
111
+ * and the list cannot answer it differently. */
112
+ hasStartedJobExecution: boolean;
110
113
  }
@@ -14,6 +14,7 @@ import {
14
14
  listRunAttempts,
15
15
  listWorkflowRunJobSummaries,
16
16
  listWorkflowRunsByProject,
17
+ recordJobExecutionStartedAt,
17
18
  updateJobExecutionStatus,
18
19
  updateJobStatus,
19
20
  updateWorkflowRunStatus,
@@ -273,6 +274,32 @@ describe('workflow run queries', () => {
273
274
  ]);
274
275
 
275
276
  expect(summary.get(run.id)?.statusCounts).toEqual([{status: 'pending', count: 2}]);
277
+ expect(summary.get(run.id)?.hasStartedJobExecution).toBe(false);
278
+ });
279
+
280
+ test('reports when any current-attempt job execution has started', async () => {
281
+ const run = await createWorkflowRun({
282
+ workspaceId,
283
+ projectId,
284
+ definitionId,
285
+ model: buildModel({jobs: {build: {steps: [{run: 'echo build'}]}}}),
286
+ triggerPayload: manualTrigger(),
287
+ });
288
+ const [job] = await getJobsByWorkflowRunId(run.id);
289
+ if (!job) throw new Error('Expected workflow job');
290
+ const execution = await getFirstJobExecutionByJobId(job.id);
291
+ if (!execution) throw new Error('Expected workflow job execution');
292
+
293
+ await recordJobExecutionStartedAt({
294
+ jobExecutionId: execution.id,
295
+ startedAt: new Date('2026-05-07T01:00:05.000Z'),
296
+ });
297
+
298
+ const summary = await listWorkflowRunJobSummaries([
299
+ {id: run.id, currentAttempt: run.currentAttempt},
300
+ ]);
301
+
302
+ expect(summary.get(run.id)?.hasStartedJobExecution).toBe(true);
276
303
  });
277
304
 
278
305
  test('returns execution evidence and counts its display status', async () => {
@@ -213,7 +213,8 @@ export interface WorkflowRunJobSummaryTarget {
213
213
  }
214
214
 
215
215
  /**
216
- * A page row's jobs: a bounded slice to draw, and totals describing all of them.
216
+ * A page row's jobs: a bounded slice to draw, totals describing all of them, and whether any
217
+ * execution has reached a runner.
217
218
  *
218
219
  * The preview is what a row can show; `statusCounts` is what it can say. Keeping the counts
219
220
  * server-side is what lets a row report a failure that sits past the preview. Counts use the
@@ -225,6 +226,7 @@ export interface WorkflowRunJobsSummary {
225
226
  preview: WorkflowRunJobSummary[];
226
227
  statusCounts: WorkflowRunJobStatusCount[];
227
228
  rawStatusCounts: WorkflowRunJobRawStatusCount[];
229
+ hasStartedJobExecution: boolean;
228
230
  }
229
231
 
230
232
  export interface WorkflowRunJobStatusCount {
@@ -253,8 +255,8 @@ export interface WorkflowRunJobRawStatusCount {
253
255
  * attempt 1's run metadata with attempt 2's jobs, and the row would report a status its strip
254
256
  * contradicts.
255
257
  *
256
- * The two reads share one repeatable-read snapshot. They describe the same jobs at the same
257
- * instant, and the strip combines them into a single glyph row, so under the default
258
+ * The reads share one repeatable-read snapshot. They describe the same jobs and executions at
259
+ * the same instant, and the strip combines them into a single glyph row, so under the default
258
260
  * read-committed isolation a job settling between the statements would draw a pending glyph
259
261
  * beside a summary counting it as failed. Sequential inside one transaction is the cost of
260
262
  * that; at a four-second poll the extra round trip does not register.
@@ -321,6 +323,14 @@ export async function listWorkflowRunJobSummaries(
321
323
  .leftJoin(selectedExecution, eq(selectedExecution.jobId, jobs.id))
322
324
  .where(attemptFilter)
323
325
  .as('ranked');
326
+ // Correlated per job rather than a grouped scan of `job_executions`: an aggregate over the
327
+ // whole table cannot have the page's filter pushed into it, so it would read every execution
328
+ // ever recorded on each poll and grow with history rather than with the page.
329
+ const hasStartedJobExecution = sql<boolean>`bool_or(exists (
330
+ select 1
331
+ from ${jobExecutions}
332
+ where ${jobExecutions.jobId} = ${jobs.id} and ${jobExecutions.startedAt} is not null
333
+ ))`;
324
334
 
325
335
  const executionDisplayStatus = sql<JobExecutionStatus | null>`
326
336
  ${selectedExecution.executionStatus}
@@ -356,6 +366,7 @@ export async function listWorkflowRunJobSummaries(
356
366
  rawStatus: jobs.status,
357
367
  status: displayStatus,
358
368
  count: count(),
369
+ hasStartedJobExecution,
359
370
  })
360
371
  .from(jobs)
361
372
  .innerJoin(workflowRunAttempts, eq(jobs.workflowRunAttemptId, workflowRunAttempts.id))
@@ -370,10 +381,18 @@ export async function listWorkflowRunJobSummaries(
370
381
  for (const {workflowRunId, ...summary} of previewRows) {
371
382
  summaryFor(summaries, workflowRunId).preview.push(summary);
372
383
  }
373
- for (const {workflowRunId, rawStatus, status, count: statusCount} of countRows) {
384
+ for (const {
385
+ workflowRunId,
386
+ rawStatus,
387
+ status,
388
+ count: statusCount,
389
+ hasStartedJobExecution,
390
+ } of countRows) {
374
391
  const summary = summaryFor(summaries, workflowRunId);
375
392
  appendStatusCount(summary.rawStatusCounts, rawStatus, statusCount);
376
393
  appendStatusCount(summary.statusCounts, status, statusCount);
394
+ // One row per (run, verdict, display status), so the run's answer is the OR of its groups.
395
+ summary.hasStartedJobExecution ||= hasStartedJobExecution;
377
396
  }
378
397
 
379
398
  return summaries;
@@ -385,7 +404,12 @@ function summaryFor(
385
404
  ): WorkflowRunJobsSummary {
386
405
  const existing = summaries.get(workflowRunId);
387
406
  if (existing) return existing;
388
- const created: WorkflowRunJobsSummary = {preview: [], statusCounts: [], rawStatusCounts: []};
407
+ const created: WorkflowRunJobsSummary = {
408
+ preview: [],
409
+ statusCounts: [],
410
+ rawStatusCounts: [],
411
+ hasStartedJobExecution: false,
412
+ };
389
413
  summaries.set(workflowRunId, created);
390
414
  return created;
391
415
  }
@@ -606,6 +630,8 @@ function hydrateWorkflowRunDetail(
606
630
  runAttempt: toWorkflowRunAttempt(attempt),
607
631
  latestAttempt,
608
632
  jobs: [],
633
+ // Read off the same rows the executions come from, so the flag cannot contradict them.
634
+ hasStartedJobExecution: rows.some((row) => row.jobExecution?.startedAt != null),
609
635
  };
610
636
  const jobById = new Map<string, WorkflowJobDetail>();
611
637
  const jobExecutionById = new Map<string, JobExecutionDetail>();
@@ -33,7 +33,12 @@ export function toRunDto(run: WorkflowRun, latestAttempt = run.currentAttempt):
33
33
  };
34
34
  }
35
35
 
36
- const EMPTY_JOBS: WorkflowRunJobsSummary = {preview: [], statusCounts: [], rawStatusCounts: []};
36
+ const EMPTY_JOBS: WorkflowRunJobsSummary = {
37
+ preview: [],
38
+ statusCounts: [],
39
+ rawStatusCounts: [],
40
+ hasStartedJobExecution: false,
41
+ };
37
42
 
38
43
  export function toRunListItemDto(
39
44
  run: WorkflowRun,
@@ -53,6 +58,7 @@ export function toRunListItemDto(
53
58
  })),
54
59
  job_status_counts: jobs.rawStatusCounts.map(({status, count}) => ({status, count})),
55
60
  job_display_status_counts: jobs.statusCounts.map(({status, count}) => ({status, count})),
61
+ has_started_job_execution: jobs.hasStartedJobExecution,
56
62
  };
57
63
  }
58
64
 
@@ -21,6 +21,7 @@ import {
21
21
  getJobExecutionsByJobId,
22
22
  getJobsByWorkflowRunId,
23
23
  getStepsByJobId,
24
+ recordJobExecutionStartedAt,
24
25
  updateWorkflowRunStatus,
25
26
  } from '#db/workflow-runs.js';
26
27
  import {workflowModel} from '#test/index.js';
@@ -137,6 +138,36 @@ describe('GET /api/workflows/runs/:id', () => {
137
138
  expect(body.jobs[0]).not.toHaveProperty('finished_at');
138
139
  });
139
140
 
141
+ // The run list decides this from the same executions, so a run detail that answered it from
142
+ // its own payload could drift from the row the user clicked through.
143
+ test('reports whether any job execution of the attempt reached its runner', async () => {
144
+ const run = await createWorkflowRun({
145
+ workspaceId,
146
+ projectId: crypto.randomUUID(),
147
+ definitionId: crypto.randomUUID(),
148
+ model: workflowModel({name: 'Test', jobs: {build: {steps: [{run: 'npm build'}]}}}),
149
+ triggerPayload: {
150
+ source: 'manual',
151
+ event: 'fire',
152
+ subscriptionId: crypto.randomUUID(),
153
+ userId: crypto.randomUUID(),
154
+ },
155
+ });
156
+
157
+ const before = await app.inject({method: 'GET', url: `/api/workflows/runs/${run.id}`});
158
+ expect(before.json().has_started_job_execution).toBe(false);
159
+
160
+ const [job] = await getJobsByWorkflowRunId(run.id);
161
+ const [execution] = await getJobExecutionsByJobId(job?.id ?? '');
162
+ await recordJobExecutionStartedAt({
163
+ jobExecutionId: execution?.id ?? '',
164
+ startedAt: new Date('2026-05-07T01:00:05.000Z'),
165
+ });
166
+
167
+ const after = await app.inject({method: 'GET', url: `/api/workflows/runs/${run.id}`});
168
+ expect(after.json().has_started_job_execution).toBe(true);
169
+ });
170
+
140
171
  test('does not aggregate latest_attempt for a first attempt without lineage', async () => {
141
172
  const latestAttemptSpy = vi.spyOn(dbIndex, 'getLatestAttempt');
142
173
  const run = await createWorkflowRun({
@@ -63,6 +63,7 @@ export function getRunRoute(projects: ProjectsModuleClient) {
63
63
  ...toRunDto(run, run.latestAttempt),
64
64
  run_attempt: toRunAttemptDto(run.runAttempt),
65
65
  jobs: jobDtos,
66
+ has_started_job_execution: run.hasStartedJobExecution,
66
67
  };
67
68
  },
68
69
  });
@@ -99,7 +99,11 @@ describe('GET /api/workflows/runs', () => {
99
99
  expect(body.runs[0].workflow_name).toBeDefined();
100
100
  expect(body.runs[0].trigger_source).toBe('manual');
101
101
  // The runs list carries run-level timing (null until the run starts).
102
- expect(body.runs[0]).toMatchObject({started_at: null, finished_at: null});
102
+ expect(body.runs[0]).toMatchObject({
103
+ started_at: null,
104
+ finished_at: null,
105
+ has_started_job_execution: false,
106
+ });
103
107
  expect(body.next_cursor).toBeNull();
104
108
  expect(body.filtered_total_count).toBe(2);
105
109
  });