@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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +25 -0
- package/dist/core/entities/workflow-run.d.ts +3 -0
- package/dist/core/entities/workflow-run.d.ts.map +1 -1
- package/dist/core/entities/workflow-run.js.map +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/schema/jobs.d.ts.map +1 -1
- package/dist/db/schema/jobs.js +3 -0
- package/dist/db/schema/jobs.js.map +1 -1
- package/dist/db/workflow-runs/queries.d.ts +19 -5
- package/dist/db/workflow-runs/queries.d.ts.map +1 -1
- package/dist/db/workflow-runs/queries.js +70 -17
- package/dist/db/workflow-runs/queries.js.map +1 -1
- package/dist/db/workflow-runs.d.ts +1 -1
- package/dist/db/workflow-runs.d.ts.map +1 -1
- package/dist/db/workflow-runs.js.map +1 -1
- package/dist/presentation/dto/workflow-run.d.ts.map +1 -1
- package/dist/presentation/dto/workflow-run.js +13 -3
- package/dist/presentation/dto/workflow-run.js.map +1 -1
- package/dist/presentation/routes/get-run.d.ts.map +1 -1
- package/dist/presentation/routes/get-run.js +2 -1
- package/dist/presentation/routes/get-run.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/core/entities/workflow-run.ts +3 -0
- package/src/db/index.ts +1 -0
- package/src/db/schema/jobs.ts +3 -0
- package/src/db/workflow-runs/queries.test.ts +196 -2
- package/src/db/workflow-runs/queries.ts +116 -15
- package/src/db/workflow-runs.ts +1 -0
- package/src/presentation/dto/workflow-run.ts +12 -2
- package/src/presentation/routes/get-run.test.ts +31 -0
- package/src/presentation/routes/get-run.ts +1 -0
- package/src/presentation/routes/list-runs.test.ts +5 -1
- 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.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.
|
|
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
package/src/db/schema/jobs.ts
CHANGED
|
@@ -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:
|
|
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 ?? [])
|
|
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,
|
|
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
|
|
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
|
-
|
|
274
|
-
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
314
|
-
|
|
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 = {
|
|
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>();
|
package/src/db/workflow-runs.ts
CHANGED
|
@@ -33,7 +33,12 @@ export function toRunDto(run: WorkflowRun, latestAttempt = run.currentAttempt):
|
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
const EMPTY_JOBS: WorkflowRunJobsSummary = {
|
|
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.
|
|
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({
|
|
@@ -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({
|
|
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
|
});
|