@shipfox/api-workflows 12.5.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.
Files changed (37) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +25 -0
  3. package/dist/core/entities/workflow-run.d.ts +3 -0
  4. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  5. package/dist/core/entities/workflow-run.js.map +1 -1
  6. package/dist/db/index.d.ts +1 -1
  7. package/dist/db/index.d.ts.map +1 -1
  8. package/dist/db/index.js.map +1 -1
  9. package/dist/db/schema/jobs.d.ts.map +1 -1
  10. package/dist/db/schema/jobs.js +3 -0
  11. package/dist/db/schema/jobs.js.map +1 -1
  12. package/dist/db/workflow-runs/queries.d.ts +19 -5
  13. package/dist/db/workflow-runs/queries.d.ts.map +1 -1
  14. package/dist/db/workflow-runs/queries.js +70 -17
  15. package/dist/db/workflow-runs/queries.js.map +1 -1
  16. package/dist/db/workflow-runs.d.ts +1 -1
  17. package/dist/db/workflow-runs.d.ts.map +1 -1
  18. package/dist/db/workflow-runs.js.map +1 -1
  19. package/dist/presentation/dto/workflow-run.d.ts.map +1 -1
  20. package/dist/presentation/dto/workflow-run.js +13 -3
  21. package/dist/presentation/dto/workflow-run.js.map +1 -1
  22. package/dist/presentation/routes/get-run.d.ts.map +1 -1
  23. package/dist/presentation/routes/get-run.js +2 -1
  24. package/dist/presentation/routes/get-run.js.map +1 -1
  25. package/dist/tsconfig.test.tsbuildinfo +1 -1
  26. package/package.json +2 -2
  27. package/src/core/entities/workflow-run.ts +3 -0
  28. package/src/db/index.ts +1 -0
  29. package/src/db/schema/jobs.ts +3 -0
  30. package/src/db/workflow-runs/queries.test.ts +196 -2
  31. package/src/db/workflow-runs/queries.ts +116 -15
  32. package/src/db/workflow-runs.ts +1 -0
  33. package/src/presentation/dto/workflow-run.ts +12 -2
  34. package/src/presentation/routes/get-run.test.ts +31 -0
  35. package/src/presentation/routes/get-run.ts +1 -0
  36. package/src/presentation/routes/list-runs.test.ts +5 -1
  37. 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.5.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.5.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
  }
package/src/db/index.ts CHANGED
@@ -42,6 +42,7 @@ export type {
42
42
  WorkflowJobExecutionDepthParams,
43
43
  WorkflowRunAggregates,
44
44
  WorkflowRunFilters,
45
+ WorkflowRunJobRawStatusCount,
45
46
  WorkflowRunJobStatusCount,
46
47
  WorkflowRunJobSummary,
47
48
  WorkflowRunJobSummaryTarget,
@@ -61,6 +61,9 @@ export const jobs = pgTable(
61
61
  key: text('key').notNull(),
62
62
  mode: jobModeEnum('mode').notNull().default('one_shot'),
63
63
  name: text('name'),
64
+ // This is the job's execution verdict, not its runtime lifecycle. Active execution state
65
+ // lives on job_executions and must be read there when a surface needs to distinguish
66
+ // waiting from executing.
64
67
  status: jobStatusEnum('status').notNull().default('pending'),
65
68
  statusReason: jobStatusReasonEnum('status_reason'),
66
69
  carriedOver: boolean('carried_over').notNull().default(false),
@@ -1,5 +1,8 @@
1
1
  import {WORKFLOW_RUN_JOB_PREVIEW_LIMIT} from '@shipfox/api-workflows-dto';
2
+ import {eq} from 'drizzle-orm';
2
3
  import {buildModel, createTestRun} from '#test/helpers/workflow-runs.js';
4
+ import {db} from '../db.js';
5
+ import {jobs} from '../schema/jobs.js';
3
6
  import {
4
7
  createRerunWorkflowRun,
5
8
  createWorkflowRun,
@@ -11,6 +14,7 @@ import {
11
14
  listRunAttempts,
12
15
  listWorkflowRunJobSummaries,
13
16
  listWorkflowRunsByProject,
17
+ recordJobExecutionStartedAt,
14
18
  updateJobExecutionStatus,
15
19
  updateJobStatus,
16
20
  updateWorkflowRunStatus,
@@ -270,6 +274,173 @@ describe('workflow run queries', () => {
270
274
  ]);
271
275
 
272
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);
303
+ });
304
+
305
+ test('returns execution evidence and counts its display status', async () => {
306
+ const run = await createWorkflowRun({
307
+ workspaceId,
308
+ projectId,
309
+ definitionId,
310
+ model: buildModel({jobs: {build: {steps: [{run: 'echo build'}]}}}),
311
+ triggerPayload: manualTrigger(),
312
+ });
313
+ const [job] = await getJobsByWorkflowRunId(run.id);
314
+ if (!job) throw new Error('expected the run to have a job');
315
+ const execution = await getFirstJobExecutionByJobId(job.id);
316
+ if (!execution) throw new Error('expected the job to have an execution');
317
+
318
+ await updateJobExecutionStatus({
319
+ jobExecutionId: execution.id,
320
+ status: 'running',
321
+ expectedVersion: execution.version,
322
+ });
323
+
324
+ const summary = await listWorkflowRunJobSummaries([
325
+ {id: run.id, currentAttempt: run.currentAttempt},
326
+ ]);
327
+
328
+ expect(summary.get(run.id)?.preview).toMatchObject([
329
+ {
330
+ status: 'pending',
331
+ mode: 'one_shot',
332
+ listenerStatus: 'inactive',
333
+ executionStatus: 'running',
334
+ },
335
+ ]);
336
+ expect(summary.get(run.id)?.statusCounts).toEqual([{status: 'running', count: 1}]);
337
+ expect(summary.get(run.id)?.rawStatusCounts).toEqual([{status: 'pending', count: 1}]);
338
+ });
339
+
340
+ test('counts an active listener without an execution', async () => {
341
+ const run = await createWorkflowRun({
342
+ workspaceId,
343
+ projectId,
344
+ definitionId,
345
+ model: buildModel({
346
+ jobs: {
347
+ listen: {
348
+ listening: {
349
+ on: [{source: 'github', event: 'push'}],
350
+ onResolve: 'finish',
351
+ },
352
+ steps: [{run: 'echo listen'}],
353
+ },
354
+ },
355
+ }),
356
+ triggerPayload: manualTrigger(),
357
+ });
358
+ const [job] = await getJobsByWorkflowRunId(run.id);
359
+ if (!job) throw new Error('expected the run to have a listener job');
360
+
361
+ await db().update(jobs).set({listenerStatus: 'listening'}).where(eq(jobs.id, job.id));
362
+
363
+ const summary = await listWorkflowRunJobSummaries([
364
+ {id: run.id, currentAttempt: run.currentAttempt},
365
+ ]);
366
+
367
+ expect(summary.get(run.id)?.preview).toMatchObject([
368
+ {
369
+ mode: 'listening',
370
+ listenerStatus: 'listening',
371
+ executionStatus: null,
372
+ },
373
+ ]);
374
+ expect(summary.get(run.id)?.statusCounts).toEqual([{status: 'listening', count: 1}]);
375
+ expect(summary.get(run.id)?.rawStatusCounts).toEqual([{status: 'pending', count: 1}]);
376
+ });
377
+
378
+ test('keeps a terminal verdict ahead of a running execution', async () => {
379
+ const run = await createWorkflowRun({
380
+ workspaceId,
381
+ projectId,
382
+ definitionId,
383
+ model: buildModel({jobs: {build: {steps: [{run: 'echo build'}]}}}),
384
+ triggerPayload: manualTrigger(),
385
+ });
386
+ const [job] = await getJobsByWorkflowRunId(run.id);
387
+ if (!job) throw new Error('expected the run to have a job');
388
+ const execution = await getFirstJobExecutionByJobId(job.id);
389
+ if (!execution) throw new Error('expected the job to have an execution');
390
+
391
+ await updateJobExecutionStatus({
392
+ jobExecutionId: execution.id,
393
+ status: 'running',
394
+ expectedVersion: execution.version,
395
+ });
396
+ await updateJobStatus({jobId: job.id, status: 'failed', expectedVersion: job.version});
397
+
398
+ const summary = await listWorkflowRunJobSummaries([
399
+ {id: run.id, currentAttempt: run.currentAttempt},
400
+ ]);
401
+
402
+ expect(summary.get(run.id)?.preview).toMatchObject([
403
+ {status: 'failed', executionStatus: 'running'},
404
+ ]);
405
+ expect(summary.get(run.id)?.statusCounts).toEqual([{status: 'failed', count: 1}]);
406
+ expect(summary.get(run.id)?.rawStatusCounts).toEqual([{status: 'failed', count: 1}]);
407
+ });
408
+
409
+ test('counts a skipped zero-execution job from its terminal verdict', async () => {
410
+ const run = await createWorkflowRun({
411
+ workspaceId,
412
+ projectId,
413
+ definitionId,
414
+ model: buildModel({
415
+ jobs: {
416
+ listen: {
417
+ listening: {
418
+ on: [{source: 'github', event: 'push'}],
419
+ onResolve: 'finish',
420
+ },
421
+ steps: [{run: 'echo listen'}],
422
+ },
423
+ },
424
+ }),
425
+ triggerPayload: manualTrigger(),
426
+ });
427
+ const [job] = await getJobsByWorkflowRunId(run.id);
428
+ if (!job) throw new Error('expected the run to have a listener job');
429
+
430
+ await db()
431
+ .update(jobs)
432
+ .set({status: 'skipped', listenerStatus: 'resolved'})
433
+ .where(eq(jobs.id, job.id));
434
+
435
+ const summary = await listWorkflowRunJobSummaries([
436
+ {id: run.id, currentAttempt: run.currentAttempt},
437
+ ]);
438
+
439
+ expect(summary.get(run.id)?.preview).toMatchObject([
440
+ {status: 'skipped', executionStatus: null},
441
+ ]);
442
+ expect(summary.get(run.id)?.statusCounts).toEqual([{status: 'skipped', count: 1}]);
443
+ expect(summary.get(run.id)?.rawStatusCounts).toEqual([{status: 'skipped', count: 1}]);
273
444
  });
274
445
 
275
446
  // Checks the invariant the snapshot exists to protect: for a run inside the preview
@@ -445,13 +616,36 @@ function totalOf(summary: {statusCounts: Array<{count: number}>} | undefined): n
445
616
 
446
617
  /** Statuses the preview actually drew, counted. */
447
618
  function previewCounts(
448
- summary: {preview: Array<{status: string}>} | undefined,
619
+ summary:
620
+ | {
621
+ preview: Array<{
622
+ status: string;
623
+ mode: string;
624
+ listenerStatus: string;
625
+ executionStatus: string | null;
626
+ }>;
627
+ }
628
+ | undefined,
449
629
  ): Record<string, number> {
450
630
  const counts: Record<string, number> = {};
451
- for (const job of summary?.preview ?? []) counts[job.status] = (counts[job.status] ?? 0) + 1;
631
+ for (const job of summary?.preview ?? []) {
632
+ const status = previewDisplayStatus(job);
633
+ counts[status] = (counts[status] ?? 0) + 1;
634
+ }
452
635
  return counts;
453
636
  }
454
637
 
638
+ function previewDisplayStatus(job: {
639
+ status: string;
640
+ mode: string;
641
+ listenerStatus: string;
642
+ executionStatus: string | null;
643
+ }): string {
644
+ if (['succeeded', 'failed', 'cancelled', 'skipped'].includes(job.status)) return job.status;
645
+ if (job.mode === 'listening' && job.listenerStatus === 'listening') return 'listening';
646
+ return job.executionStatus ?? 'pending';
647
+ }
648
+
455
649
  /** The same shape read off the totals, so the two halves can be compared directly. */
456
650
  function statusCountMap(
457
651
  summary: {statusCounts: Array<{status: string; count: number}>} | undefined,
@@ -5,7 +5,8 @@ import {
5
5
  timestampIdCursorWhere,
6
6
  } from '@shipfox/node-drizzle';
7
7
  import {and, asc, count, desc, eq, gte, lte, type SQL, sql} from 'drizzle-orm';
8
- import type {JobStatus} from '#core/entities/job.js';
8
+ import type {JobMode, JobStatus, ListenerStatus} from '#core/entities/job.js';
9
+ import type {JobExecutionStatus} from '#core/entities/job-execution.js';
9
10
  import type {
10
11
  JobExecutionDetail,
11
12
  StepDetail,
@@ -52,6 +53,9 @@ export interface WorkflowRunJobSummary {
52
53
  key: string;
53
54
  name: string | null;
54
55
  status: JobStatus;
56
+ mode: JobMode;
57
+ listenerStatus: ListenerStatus;
58
+ executionStatus: JobExecutionStatus | null;
55
59
  position: number;
56
60
  }
57
61
 
@@ -209,17 +213,28 @@ export interface WorkflowRunJobSummaryTarget {
209
213
  }
210
214
 
211
215
  /**
212
- * 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.
213
218
  *
214
219
  * The preview is what a row can show; `statusCounts` is what it can say. Keeping the counts
215
- * server-side is what lets a row report a failure that sits past the preview.
220
+ * server-side is what lets a row report a failure that sits past the preview. Counts use the
221
+ * display status derived from the job verdict, listener state, and selected execution state;
222
+ * the row keeps the verdict and evidence separate so the client can apply the same display
223
+ * rule as run detail.
216
224
  */
217
225
  export interface WorkflowRunJobsSummary {
218
226
  preview: WorkflowRunJobSummary[];
219
227
  statusCounts: WorkflowRunJobStatusCount[];
228
+ rawStatusCounts: WorkflowRunJobRawStatusCount[];
229
+ hasStartedJobExecution: boolean;
220
230
  }
221
231
 
222
232
  export interface WorkflowRunJobStatusCount {
233
+ status: JobStatus | 'listening';
234
+ count: number;
235
+ }
236
+
237
+ export interface WorkflowRunJobRawStatusCount {
223
238
  status: JobStatus;
224
239
  count: number;
225
240
  }
@@ -240,8 +255,8 @@ export interface WorkflowRunJobStatusCount {
240
255
  * attempt 1's run metadata with attempt 2's jobs, and the row would report a status its strip
241
256
  * contradicts.
242
257
  *
243
- * The two reads share one repeatable-read snapshot. They describe the same jobs at the same
244
- * 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
245
260
  * read-committed isolation a job settling between the statements would draw a pending glyph
246
261
  * beside a summary counting it as failed. Sequential inside one transaction is the cost of
247
262
  * that; at a four-second poll the extra round trip does not register.
@@ -262,23 +277,72 @@ export async function listWorkflowRunJobSummaries(
262
277
 
263
278
  const {previewRows, countRows} = await db().transaction(
264
279
  async (tx) => {
280
+ // Pick the execution the detail view would display once per job before either list
281
+ // statement touches it. The existing (job_id, sequence) index supports the latest
282
+ // execution ordering, while the status-first expression preserves the rule that an
283
+ // active execution wins over a newer completed retry.
284
+ const selectedExecution = tx.$with('selected_execution').as(
285
+ tx
286
+ .selectDistinctOn([jobExecutions.jobId], {
287
+ jobId: jobExecutions.jobId,
288
+ executionStatus: sql<JobExecutionStatus>`${jobExecutions.status}`.as(
289
+ 'execution_status',
290
+ ),
291
+ })
292
+ .from(jobExecutions)
293
+ .innerJoin(jobs, eq(jobExecutions.jobId, jobs.id))
294
+ .innerJoin(workflowRunAttempts, eq(jobs.workflowRunAttemptId, workflowRunAttempts.id))
295
+ .where(attemptFilter)
296
+ .orderBy(
297
+ asc(jobExecutions.jobId),
298
+ sql`case when ${jobExecutions.status} = 'running' then 0 else 1 end`,
299
+ desc(jobExecutions.sequence),
300
+ desc(jobExecutions.id),
301
+ ),
302
+ );
303
+
265
304
  const ranked = tx
305
+ .with(selectedExecution)
266
306
  .select({
267
307
  workflowRunId: workflowRunAttempts.workflowRunId,
268
308
  id: jobs.id,
269
309
  key: jobs.key,
270
310
  name: jobs.name,
271
311
  status: jobs.status,
312
+ mode: jobs.mode,
313
+ listenerStatus: jobs.listenerStatus,
314
+ executionStatus: selectedExecution.executionStatus,
272
315
  position: jobs.position,
273
- rank: sql<number>`row_number() over (partition by ${workflowRunAttempts.workflowRunId} order by ${jobs.position} asc, ${jobs.id} asc)`.as(
274
- 'rank',
275
- ),
316
+ jobRank:
317
+ sql<number>`row_number() over (partition by ${workflowRunAttempts.workflowRunId} order by ${jobs.position} asc, ${jobs.id} asc)`.as(
318
+ 'job_rank',
319
+ ),
276
320
  })
277
321
  .from(jobs)
278
322
  .innerJoin(workflowRunAttempts, eq(jobs.workflowRunAttemptId, workflowRunAttempts.id))
323
+ .leftJoin(selectedExecution, eq(selectedExecution.jobId, jobs.id))
279
324
  .where(attemptFilter)
280
325
  .as('ranked');
281
-
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
+ ))`;
334
+
335
+ const executionDisplayStatus = sql<JobExecutionStatus | null>`
336
+ ${selectedExecution.executionStatus}
337
+ `;
338
+ const displayStatus = sql<WorkflowRunJobStatusCount['status']>`
339
+ case
340
+ when ${jobs.status} in ('succeeded', 'failed', 'cancelled', 'skipped') then ${jobs.status}::text
341
+ when ${jobs.mode} = 'listening' and ${jobs.listenerStatus} = 'listening' then 'listening'
342
+ when ${executionDisplayStatus} is not null then ${executionDisplayStatus}::text
343
+ else 'pending'
344
+ end
345
+ `;
282
346
  return {
283
347
  previewRows: await tx
284
348
  .select({
@@ -287,21 +351,28 @@ export async function listWorkflowRunJobSummaries(
287
351
  key: ranked.key,
288
352
  name: ranked.name,
289
353
  status: ranked.status,
354
+ mode: ranked.mode,
355
+ listenerStatus: ranked.listenerStatus,
356
+ executionStatus: ranked.executionStatus,
290
357
  position: ranked.position,
291
358
  })
292
359
  .from(ranked)
293
- .where(lte(ranked.rank, WORKFLOW_RUN_JOB_PREVIEW_LIMIT))
360
+ .where(lte(ranked.jobRank, WORKFLOW_RUN_JOB_PREVIEW_LIMIT))
294
361
  .orderBy(asc(ranked.workflowRunId), asc(ranked.position), asc(ranked.id)),
295
362
  countRows: await tx
363
+ .with(selectedExecution)
296
364
  .select({
297
365
  workflowRunId: workflowRunAttempts.workflowRunId,
298
- status: jobs.status,
366
+ rawStatus: jobs.status,
367
+ status: displayStatus,
299
368
  count: count(),
369
+ hasStartedJobExecution,
300
370
  })
301
371
  .from(jobs)
302
372
  .innerJoin(workflowRunAttempts, eq(jobs.workflowRunAttemptId, workflowRunAttempts.id))
373
+ .leftJoin(selectedExecution, eq(selectedExecution.jobId, jobs.id))
303
374
  .where(attemptFilter)
304
- .groupBy(workflowRunAttempts.workflowRunId, jobs.status),
375
+ .groupBy(workflowRunAttempts.workflowRunId, jobs.status, displayStatus),
305
376
  };
306
377
  },
307
378
  {isolationLevel: 'repeatable read', accessMode: 'read only'},
@@ -310,8 +381,18 @@ export async function listWorkflowRunJobSummaries(
310
381
  for (const {workflowRunId, ...summary} of previewRows) {
311
382
  summaryFor(summaries, workflowRunId).preview.push(summary);
312
383
  }
313
- for (const {workflowRunId, status, count: statusCount} of countRows) {
314
- summaryFor(summaries, workflowRunId).statusCounts.push({status, count: statusCount});
384
+ for (const {
385
+ workflowRunId,
386
+ rawStatus,
387
+ status,
388
+ count: statusCount,
389
+ hasStartedJobExecution,
390
+ } of countRows) {
391
+ const summary = summaryFor(summaries, workflowRunId);
392
+ appendStatusCount(summary.rawStatusCounts, rawStatus, statusCount);
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;
315
396
  }
316
397
 
317
398
  return summaries;
@@ -323,11 +404,29 @@ function summaryFor(
323
404
  ): WorkflowRunJobsSummary {
324
405
  const existing = summaries.get(workflowRunId);
325
406
  if (existing) return existing;
326
- const created: WorkflowRunJobsSummary = {preview: [], statusCounts: []};
407
+ const created: WorkflowRunJobsSummary = {
408
+ preview: [],
409
+ statusCounts: [],
410
+ rawStatusCounts: [],
411
+ hasStartedJobExecution: false,
412
+ };
327
413
  summaries.set(workflowRunId, created);
328
414
  return created;
329
415
  }
330
416
 
417
+ function appendStatusCount<T extends string>(
418
+ counts: Array<{status: T; count: number}>,
419
+ status: T,
420
+ count: number,
421
+ ): void {
422
+ const existing = counts.find((entry) => entry.status === status);
423
+ if (existing) {
424
+ existing.count += count;
425
+ } else {
426
+ counts.push({status, count});
427
+ }
428
+ }
429
+
331
430
  export async function listWorkflowRunsByProject(projectId: string): Promise<WorkflowRun[]> {
332
431
  const result = await listWorkflowRuns({projectId, limit: 100});
333
432
  return result.runs;
@@ -531,6 +630,8 @@ function hydrateWorkflowRunDetail(
531
630
  runAttempt: toWorkflowRunAttempt(attempt),
532
631
  latestAttempt,
533
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),
534
635
  };
535
636
  const jobById = new Map<string, WorkflowJobDetail>();
536
637
  const jobExecutionById = new Map<string, JobExecutionDetail>();
@@ -47,6 +47,7 @@ export type {
47
47
  WorkflowRunAggregates,
48
48
  WorkflowRunCursor,
49
49
  WorkflowRunFilters,
50
+ WorkflowRunJobRawStatusCount,
50
51
  WorkflowRunJobStatusCount,
51
52
  WorkflowRunJobSummary,
52
53
  WorkflowRunJobSummaryTarget,
@@ -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: []};
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,
@@ -46,9 +51,14 @@ export function toRunListItemDto(
46
51
  key: job.key,
47
52
  name: job.name,
48
53
  status: job.status,
54
+ mode: job.mode,
55
+ listener_status: job.listenerStatus,
56
+ execution_status: job.executionStatus,
49
57
  position: job.position,
50
58
  })),
51
- job_status_counts: jobs.statusCounts.map(({status, count}) => ({status, count})),
59
+ job_status_counts: jobs.rawStatusCounts.map(({status, count}) => ({status, count})),
60
+ job_display_status_counts: jobs.statusCounts.map(({status, count}) => ({status, count})),
61
+ has_started_job_execution: jobs.hasStartedJobExecution,
52
62
  };
53
63
  }
54
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
  });