@shipfox/api-workflows 13.1.0 → 14.0.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 (76) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +31 -0
  3. package/README.md +12 -5
  4. package/dist/core/checkout.d.ts +4 -2
  5. package/dist/core/checkout.d.ts.map +1 -1
  6. package/dist/core/checkout.js +7 -2
  7. package/dist/core/checkout.js.map +1 -1
  8. package/dist/core/entities/job.d.ts +1 -1
  9. package/dist/core/entities/job.d.ts.map +1 -1
  10. package/dist/core/entities/job.js.map +1 -1
  11. package/dist/core/entities/workflow-run.d.ts +23 -4
  12. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  13. package/dist/core/entities/workflow-run.js.map +1 -1
  14. package/dist/core/run-workflow.js +1 -1
  15. package/dist/core/run-workflow.js.map +1 -1
  16. package/dist/db/db.d.ts +72 -0
  17. package/dist/db/db.d.ts.map +1 -1
  18. package/dist/db/schema/workflow-runs.d.ts +45 -1
  19. package/dist/db/schema/workflow-runs.d.ts.map +1 -1
  20. package/dist/db/schema/workflow-runs.js +42 -1
  21. package/dist/db/schema/workflow-runs.js.map +1 -1
  22. package/dist/db/workflow-runs/jobs.d.ts +3 -1
  23. package/dist/db/workflow-runs/jobs.d.ts.map +1 -1
  24. package/dist/db/workflow-runs/jobs.js +12 -3
  25. package/dist/db/workflow-runs/jobs.js.map +1 -1
  26. package/dist/db/workflow-runs/queries.d.ts +3 -2
  27. package/dist/db/workflow-runs/queries.d.ts.map +1 -1
  28. package/dist/db/workflow-runs/queries.js +3 -0
  29. package/dist/db/workflow-runs/queries.js.map +1 -1
  30. package/dist/presentation/dto/workflow-run.d.ts.map +1 -1
  31. package/dist/presentation/dto/workflow-run.js +14 -0
  32. package/dist/presentation/dto/workflow-run.js.map +1 -1
  33. package/dist/presentation/routes/checkout-token.d.ts.map +1 -1
  34. package/dist/presentation/routes/checkout-token.js +2 -1
  35. package/dist/presentation/routes/checkout-token.js.map +1 -1
  36. package/dist/presentation/routes/get-run-aggregates.d.ts.map +1 -1
  37. package/dist/presentation/routes/get-run-aggregates.js +2 -1
  38. package/dist/presentation/routes/get-run-aggregates.js.map +1 -1
  39. package/dist/presentation/routes/leased-step.d.ts +3 -1
  40. package/dist/presentation/routes/leased-step.d.ts.map +1 -1
  41. package/dist/presentation/routes/leased-step.js +2 -1
  42. package/dist/presentation/routes/leased-step.js.map +1 -1
  43. package/dist/presentation/routes/list-runs.d.ts.map +1 -1
  44. package/dist/presentation/routes/list-runs.js +2 -1
  45. package/dist/presentation/routes/list-runs.js.map +1 -1
  46. package/dist/presentation/routes/project-access.d.ts +1 -0
  47. package/dist/presentation/routes/project-access.d.ts.map +1 -1
  48. package/dist/tsconfig.test.tsbuildinfo +1 -1
  49. package/drizzle/0004_dev_run_origin.sql +12 -0
  50. package/drizzle/meta/0004_snapshot.json +1773 -0
  51. package/drizzle/meta/_journal.json +7 -0
  52. package/package.json +10 -10
  53. package/src/core/checkout.test.ts +198 -1
  54. package/src/core/checkout.ts +18 -5
  55. package/src/core/entities/job.ts +1 -1
  56. package/src/core/entities/workflow-run.ts +22 -4
  57. package/src/core/job-transition/decide-job-activation.test.ts +2 -0
  58. package/src/core/run-workflow.test.ts +50 -1
  59. package/src/core/run-workflow.ts +1 -1
  60. package/src/core/workflow-run-creation.test.ts +2 -0
  61. package/src/db/job-listeners.test.ts +17 -0
  62. package/src/db/schema/workflow-runs.ts +58 -0
  63. package/src/db/workflow-runs/jobs.ts +17 -3
  64. package/src/db/workflow-runs/queries.ts +6 -1
  65. package/src/db/workflow-runs/run-create.test.ts +39 -0
  66. package/src/presentation/dto/workflow-run.ts +23 -1
  67. package/src/presentation/routes/checkout-token.test.ts +45 -0
  68. package/src/presentation/routes/checkout-token.ts +2 -1
  69. package/src/presentation/routes/get-run-aggregates.test.ts +56 -0
  70. package/src/presentation/routes/get-run-aggregates.ts +2 -0
  71. package/src/presentation/routes/get-step-secrets.test.ts +1 -0
  72. package/src/presentation/routes/leased-step.ts +7 -1
  73. package/src/presentation/routes/list-runs.test.ts +152 -0
  74. package/src/presentation/routes/list-runs.ts +2 -0
  75. package/test/factories/workflow-run.ts +2 -0
  76. package/tsconfig.build.tsbuildinfo +1 -1
@@ -16,6 +16,7 @@ import {
16
16
  import type {
17
17
  TriggerPayload,
18
18
  WorkflowRun,
19
+ WorkflowRunOriginState,
19
20
  WorkflowRunTriggerReference,
20
21
  WorkflowSourceSnapshot,
21
22
  } from '#core/entities/workflow-run.js';
@@ -31,6 +32,14 @@ export const workflowRunStatusEnum = pgEnum('workflows_run_status', [
31
32
 
32
33
  export const workflowRunRerunModeEnum = pgEnum('workflows_rerun_mode', ['all', 'failed']);
33
34
 
35
+ export interface WorkflowRunDevSourceDb {
36
+ ref: string;
37
+ commit: string;
38
+ config_path: string;
39
+ initiated_by_user_id: string;
40
+ replay_of_event_id: string | null;
41
+ }
42
+
34
43
  export const workflowRuns = pgTable(
35
44
  'workflow_runs',
36
45
  {
@@ -42,6 +51,8 @@ export const workflowRuns = pgTable(
42
51
  name: text('name'),
43
52
  workflowName: text('workflow_name').notNull(),
44
53
  status: workflowRunStatusEnum('status').notNull().default('pending'),
54
+ origin: text('origin').notNull().default('synced'),
55
+ devSource: jsonb('dev_source').$type<WorkflowRunDevSourceDb>(),
45
56
  currentAttempt: integer('current_attempt').notNull().default(1),
46
57
  triggerProvider: text('trigger_provider'),
47
58
  triggerSource: text('trigger_source').notNull(),
@@ -62,6 +73,12 @@ export const workflowRuns = pgTable(
62
73
  uniqueIndex('workflows_wr_trigger_idempotency_key_unique').on(table.triggerIdempotencyKey),
63
74
  uniqueIndex('workflows_wr_definition_number_unique').on(table.definitionId, table.number),
64
75
  index('workflows_wr_project_created_id_idx').on(table.projectId, table.createdAt, table.id),
76
+ index('workflows_wr_project_origin_created_id_idx').on(
77
+ table.projectId,
78
+ table.origin,
79
+ table.createdAt,
80
+ table.id,
81
+ ),
65
82
  index('workflows_wr_project_status_created_id_idx').on(
66
83
  table.projectId,
67
84
  table.status,
@@ -85,6 +102,18 @@ export const workflowRuns = pgTable(
85
102
  // historical table grows.
86
103
  index('workflows_wr_running_idx').on(table.status).where(sql`${table.status} = 'running'`),
87
104
  check('workflows_wr_current_attempt_positive_ck', sql`${table.currentAttempt} > 0`),
105
+ check('workflows_wr_origin_ck', sql`${table.origin} in ('synced', 'dev')`),
106
+ check(
107
+ 'workflows_wr_dev_source_ck',
108
+ sql`(
109
+ (${table.origin} = 'synced' and ${table.devSource} is null)
110
+ or (
111
+ ${table.origin} = 'dev'
112
+ and ${table.devSource} is not null
113
+ and jsonb_typeof(${table.devSource}) = 'object'
114
+ )
115
+ )`,
116
+ ),
88
117
  ],
89
118
  );
90
119
 
@@ -92,6 +121,7 @@ export type WorkflowRunDb = typeof workflowRuns.$inferSelect;
92
121
  export type WorkflowRunCreateDb = typeof workflowRuns.$inferInsert;
93
122
 
94
123
  export function toWorkflowRun(row: WorkflowRunDb): WorkflowRun {
124
+ const originState = toWorkflowRunOriginState(row);
95
125
  return {
96
126
  id: row.id,
97
127
  workspaceId: row.workspaceId,
@@ -102,6 +132,7 @@ export function toWorkflowRun(row: WorkflowRunDb): WorkflowRun {
102
132
  workflowName: row.workflowName,
103
133
  nameOverride: row.name,
104
134
  status: row.status,
135
+ ...originState,
105
136
  currentAttempt: row.currentAttempt,
106
137
  triggerProvider: row.triggerProvider,
107
138
  triggerSource: row.triggerSource,
@@ -119,3 +150,30 @@ export function toWorkflowRun(row: WorkflowRunDb): WorkflowRun {
119
150
  finishedAt: row.finishedAt,
120
151
  };
121
152
  }
153
+
154
+ export function toWorkflowRunOriginState(
155
+ row: Pick<WorkflowRunDb, 'origin' | 'devSource'>,
156
+ ): WorkflowRunOriginState {
157
+ if (row.origin === 'synced') {
158
+ if (row.devSource !== null) {
159
+ throw new Error(`Synced workflow run has unexpected dev source`);
160
+ }
161
+ return {origin: 'synced', devSource: null};
162
+ }
163
+ if (row.origin !== 'dev') {
164
+ throw new Error(`Unknown workflow run origin: ${row.origin}`);
165
+ }
166
+ if (!row.devSource) {
167
+ throw new Error('Dev workflow run is missing its source');
168
+ }
169
+ return {
170
+ origin: 'dev',
171
+ devSource: {
172
+ ref: row.devSource.ref,
173
+ commit: row.devSource.commit,
174
+ configPath: row.devSource.config_path,
175
+ initiatedByUserId: row.devSource.initiated_by_user_id,
176
+ replayOfEventId: row.devSource.replay_of_event_id,
177
+ },
178
+ };
179
+ }
@@ -5,7 +5,10 @@ import {and, asc, desc, eq, inArray, notInArray, sql} from 'drizzle-orm';
5
5
  import {isJobTerminal, type Job, type JobStatus, type JobStatusReason} from '#core/entities/job.js';
6
6
  import type {JobExecution} from '#core/entities/job-execution.js';
7
7
  import type {PersistedEvaluationTraceEntry} from '#core/entities/step.js';
8
- import type {WorkflowRunTriggerReference} from '#core/entities/workflow-run.js';
8
+ import type {
9
+ WorkflowRunOriginState,
10
+ WorkflowRunTriggerReference,
11
+ } from '#core/entities/workflow-run.js';
9
12
  import {JobNotFoundError} from '#core/errors.js';
10
13
  import {
11
14
  type DeriveJobSuccessResult,
@@ -21,7 +24,7 @@ import {writeWorkflowsOutboxEvent} from '../outbox-writes.js';
21
24
  import {jobExecutions, toJobExecution} from '../schema/job-executions.js';
22
25
  import {jobs, toJob} from '../schema/jobs.js';
23
26
  import {workflowRunAttempts} from '../schema/workflow-run-attempts.js';
24
- import {toWorkflowRun, workflowRuns} from '../schema/workflow-runs.js';
27
+ import {toWorkflowRun, toWorkflowRunOriginState, workflowRuns} from '../schema/workflow-runs.js';
25
28
  import {getWorkflowRunById} from './queries.js';
26
29
  import {getWorkflowContextForJob, optimisticLockRetry, TERMINAL_JOB_STATUSES} from './shared.js';
27
30
 
@@ -71,6 +74,8 @@ export interface JobScope {
71
74
  workspaceId: string;
72
75
  projectId: string;
73
76
  triggerReference: WorkflowRunTriggerReference | null;
77
+ /** The run's origin state, so checkout fallbacks can read the dev source. */
78
+ run: WorkflowRunOriginState;
74
79
  }
75
80
 
76
81
  export async function getJobScope(jobId: string): Promise<JobScope | undefined> {
@@ -79,13 +84,22 @@ export async function getJobScope(jobId: string): Promise<JobScope | undefined>
79
84
  workspaceId: workflowRuns.workspaceId,
80
85
  projectId: workflowRuns.projectId,
81
86
  triggerReference: workflowRuns.triggerReference,
87
+ origin: workflowRuns.origin,
88
+ devSource: workflowRuns.devSource,
82
89
  })
83
90
  .from(jobs)
84
91
  .innerJoin(workflowRunAttempts, eq(jobs.workflowRunAttemptId, workflowRunAttempts.id))
85
92
  .innerJoin(workflowRuns, eq(workflowRunAttempts.workflowRunId, workflowRuns.id))
86
93
  .where(eq(jobs.id, jobId))
87
94
  .limit(1);
88
- return rows[0];
95
+ const row = rows[0];
96
+ if (!row) return undefined;
97
+ return {
98
+ workspaceId: row.workspaceId,
99
+ projectId: row.projectId,
100
+ triggerReference: row.triggerReference,
101
+ run: toWorkflowRunOriginState(row),
102
+ };
89
103
  }
90
104
 
91
105
  export async function getDirectDependencyJobContexts(
@@ -13,6 +13,7 @@ import type {
13
13
  WorkflowJobDetail,
14
14
  WorkflowRun,
15
15
  WorkflowRunDetail,
16
+ WorkflowRunOrigin,
16
17
  WorkflowRunStatus,
17
18
  } from '#core/entities/workflow-run.js';
18
19
  import {db} from '../db.js';
@@ -29,6 +30,7 @@ export interface WorkflowRunFilters {
29
30
  status?: WorkflowRunStatus | undefined;
30
31
  definitionId?: string | undefined;
31
32
  triggerSource?: string | undefined;
33
+ origin?: WorkflowRunOrigin | undefined;
32
34
  createdFrom?: Date | undefined;
33
35
  createdTo?: Date | undefined;
34
36
  }
@@ -145,7 +147,7 @@ export function buildWorkflowRunListConditions(params: {
145
147
  projectId: string;
146
148
  filters?: WorkflowRunFilters | undefined;
147
149
  cursor?: WorkflowRunCursor | undefined;
148
- omit?: 'status' | 'definitionId' | 'triggerSource' | undefined;
150
+ omit?: 'status' | 'definitionId' | 'triggerSource' | 'origin' | undefined;
149
151
  }): SQL[] {
150
152
  const filters = params.filters;
151
153
  const conditions: SQL[] = [eq(workflowRuns.projectId, params.projectId)];
@@ -164,6 +166,9 @@ export function buildWorkflowRunListConditions(params: {
164
166
  if (filters?.triggerSource && params.omit !== 'triggerSource') {
165
167
  conditions.push(eq(workflowRuns.triggerSource, filters.triggerSource));
166
168
  }
169
+ if (filters?.origin && params.omit !== 'origin') {
170
+ conditions.push(eq(workflowRuns.origin, filters.origin));
171
+ }
167
172
  if (filters?.createdFrom) {
168
173
  conditions.push(gte(workflowRuns.createdAt, filters.createdFrom));
169
174
  }
@@ -48,6 +48,45 @@ describe('workflow run queries', () => {
48
48
  });
49
49
 
50
50
  describe('createWorkflowRun', () => {
51
+ test('enforces the workflow run origin and dev source relationship at the database boundary', async () => {
52
+ const run = await createWorkflowRun({
53
+ workspaceId,
54
+ projectId,
55
+ definitionId,
56
+ model: buildModel(),
57
+ triggerPayload: {
58
+ source: 'manual',
59
+ event: 'fire',
60
+ subscriptionId: crypto.randomUUID(),
61
+ userId: crypto.randomUUID(),
62
+ },
63
+ });
64
+
65
+ await expect(
66
+ db().execute(
67
+ sql`UPDATE ${workflowRuns} SET origin = 'dev', dev_source = '{}'::jsonb WHERE ${workflowRuns.id} = ${run.id}`,
68
+ ),
69
+ ).resolves.toBeDefined();
70
+
71
+ await expect(
72
+ db().update(workflowRuns).set({origin: 'synced'}).where(eq(workflowRuns.id, run.id)),
73
+ ).rejects.toThrow();
74
+
75
+ await db()
76
+ .update(workflowRuns)
77
+ .set({
78
+ origin: 'dev',
79
+ devSource: {
80
+ ref: 'main',
81
+ commit: 'abc123',
82
+ config_path: '.shipfox/workflows.yml',
83
+ initiated_by_user_id: crypto.randomUUID(),
84
+ replay_of_event_id: null,
85
+ },
86
+ })
87
+ .where(eq(workflowRuns.id, run.id));
88
+ });
89
+
51
90
  test('persists normalized integration trigger facts alongside the payload', async () => {
52
91
  const triggerConnectionId = crypto.randomUUID();
53
92
  const integrations = {
@@ -1,10 +1,15 @@
1
1
  import type {
2
2
  WorkflowRunAttemptDto,
3
+ WorkflowRunDevSourceDto,
3
4
  WorkflowRunDto,
4
5
  WorkflowRunListItemDto,
5
6
  WorkflowRunTriggerReferenceDto,
6
7
  } from '@shipfox/api-workflows-dto';
7
- import type {WorkflowRun, WorkflowRunTriggerReference} from '#core/entities/workflow-run.js';
8
+ import type {
9
+ WorkflowRun,
10
+ WorkflowRunDevSource,
11
+ WorkflowRunTriggerReference,
12
+ } from '#core/entities/workflow-run.js';
8
13
  import type {WorkflowRunAttempt} from '#core/entities/workflow-run-attempt.js';
9
14
  import type {WorkflowRunJobsSummary} from '#db/index.js';
10
15
 
@@ -17,6 +22,8 @@ export function toRunDto(run: WorkflowRun, latestAttempt = run.currentAttempt):
17
22
  name: run.name,
18
23
  workflow_name: run.workflowName,
19
24
  status: run.status,
25
+ origin: run.origin,
26
+ dev_source: toDevSourceDto(run.devSource),
20
27
  current_attempt: run.currentAttempt,
21
28
  latest_attempt: latestAttempt,
22
29
  trigger_provider: run.triggerProvider,
@@ -76,6 +83,21 @@ function toTriggerReferenceDto(
76
83
  };
77
84
  }
78
85
 
86
+ // The persisted dev source uses camelCase field names while the API contract is snake_case,
87
+ // so each field is mapped explicitly rather than spread onto the response.
88
+ function toDevSourceDto(
89
+ devSource: WorkflowRunDevSource | null | undefined,
90
+ ): WorkflowRunDevSourceDto | null {
91
+ if (!devSource) return null;
92
+ return {
93
+ ref: devSource.ref,
94
+ commit: devSource.commit,
95
+ config_path: devSource.configPath,
96
+ initiated_by_user_id: devSource.initiatedByUserId,
97
+ replay_of_event_id: devSource.replayOfEventId,
98
+ };
99
+ }
100
+
79
101
  export function toRunAttemptDto(attempt: WorkflowRunAttempt): WorkflowRunAttemptDto {
80
102
  return {
81
103
  id: attempt.id,
@@ -179,6 +179,35 @@ describe('POST /runs/jobs/current/steps/:stepId/checkout-token', () => {
179
179
  });
180
180
  });
181
181
 
182
+ test('defaults a dev run checkout to its pinned source commit', async () => {
183
+ const devCommit = 'b'.repeat(40);
184
+ const {project, job, step} = await createRunningCheckoutStep({devCommit});
185
+ getProjectById.mockResolvedValue({project});
186
+ resolveCheckoutTarget.mockResolvedValue({
187
+ projectId: project.id,
188
+ connectionId: project.sourceConnectionId,
189
+ externalRepositoryId: project.sourceExternalRepositoryId,
190
+ });
191
+ createCheckoutSpec.mockResolvedValue({...githubSpec('ghs-dev-token'), ref: devCommit});
192
+ const token = await mintActiveLeaseToken({jobId: job.id});
193
+
194
+ const res = await app.inject({
195
+ method: 'POST',
196
+ url: checkoutUrl(step.id, step.currentAttempt),
197
+ headers: {authorization: `Bearer ${token}`},
198
+ });
199
+
200
+ expect(res.statusCode).toBe(200);
201
+ expect(res.json()).toMatchObject({ref: devCommit});
202
+ expect(createCheckoutSpec).toHaveBeenCalledWith({
203
+ workspaceId: project.workspaceId,
204
+ connectionId: project.sourceConnectionId,
205
+ externalRepositoryId: project.sourceExternalRepositoryId,
206
+ ref: devCommit,
207
+ permissions: {contents: 'read'},
208
+ });
209
+ });
210
+
182
211
  test('returns checkout-unavailable when the run project no longer resolves', async () => {
183
212
  const {job, step} = await createRunningCheckoutStep({kind: 'checkout'});
184
213
  getProjectById.mockResolvedValue({project: null});
@@ -521,6 +550,7 @@ async function createRunningCheckoutStep(
521
550
  options: {
522
551
  kind?: 'setup' | 'checkout' | 'run';
523
552
  checkout?: CheckoutConfig;
553
+ devCommit?: string;
524
554
  status?: StepStatus;
525
555
  } = {},
526
556
  ) {
@@ -546,6 +576,21 @@ async function createRunningCheckoutStep(
546
576
  userId: crypto.randomUUID(),
547
577
  },
548
578
  });
579
+ if (options.devCommit) {
580
+ await db()
581
+ .update(workflowRuns)
582
+ .set({
583
+ origin: 'dev',
584
+ devSource: {
585
+ ref: 'feature/checkout',
586
+ commit: options.devCommit,
587
+ config_path: '.shipfox/workflows/checkout.yml',
588
+ initiated_by_user_id: crypto.randomUUID(),
589
+ replay_of_event_id: null,
590
+ },
591
+ })
592
+ .where(eq(workflowRuns.id, run.id));
593
+ }
549
594
  const [job] = await getJobsByWorkflowRunId(run.id);
550
595
  if (!job) throw new Error('Expected workflow job');
551
596
  await db().update(jobsTable).set({status: 'running'}).where(eq(jobsTable.id, job.id));
@@ -122,7 +122,7 @@ export function createCheckoutTokenRoute(clients: {
122
122
  handler: async (request, reply) => {
123
123
  const {stepId} = request.params;
124
124
  const {attempt} = request.query;
125
- const {step, workspaceId, projectId, triggerReference} = await loadRunningLeasedStep({
125
+ const {step, workspaceId, projectId, triggerReference, run} = await loadRunningLeasedStep({
126
126
  runners: clients.runners,
127
127
  request,
128
128
  stepId,
@@ -138,6 +138,7 @@ export function createCheckoutTokenRoute(clients: {
138
138
  workspaceId,
139
139
  projectId,
140
140
  triggerReference,
141
+ run,
141
142
  integrations: clients.integrations,
142
143
  projects: clients.projects,
143
144
  });
@@ -1,8 +1,11 @@
1
1
  import {buildUserContext, setUserContext} from '@shipfox/api-auth-context';
2
2
  import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
3
+ import {eq} from 'drizzle-orm';
3
4
  import type {FastifyInstance} from 'fastify';
4
5
  import Fastify from 'fastify';
5
6
  import {serializerCompiler, validatorCompiler} from 'fastify-type-provider-zod';
7
+ import {db} from '#db/db.js';
8
+ import {workflowRuns} from '#db/schema/workflow-runs.js';
6
9
  import {createWorkflowRun, updateWorkflowRunStatus} from '#db/workflow-runs.js';
7
10
  import {workflowModel} from '#test/index.js';
8
11
  import {getRunAggregatesRoute} from './get-run-aggregates.js';
@@ -117,4 +120,57 @@ describe('GET /api/workflows/runs/aggregates', () => {
117
120
  expect(body.trigger_source).toEqual([{value: 'manual', count: 1}]);
118
121
  expect(body.workflow).toEqual([{value: deployDefinitionId, count: 1}]);
119
122
  });
123
+
124
+ test('filters faceted counts by origin so the Runs tab facet follows', async () => {
125
+ await createWorkflowRun({
126
+ workspaceId,
127
+ projectId,
128
+ definitionId: crypto.randomUUID(),
129
+ name: 'Synced',
130
+ model: workflowModel({name: 'Synced'}),
131
+ triggerPayload: {
132
+ source: 'manual',
133
+ event: 'fire',
134
+ subscriptionId: crypto.randomUUID(),
135
+ userId: crypto.randomUUID(),
136
+ },
137
+ });
138
+ const devRun = await createWorkflowRun({
139
+ workspaceId,
140
+ projectId,
141
+ definitionId: crypto.randomUUID(),
142
+ name: 'Dev',
143
+ model: workflowModel({name: 'Dev'}),
144
+ triggerPayload: {
145
+ source: 'manual',
146
+ event: 'fire',
147
+ subscriptionId: crypto.randomUUID(),
148
+ userId: crypto.randomUUID(),
149
+ },
150
+ });
151
+ await db()
152
+ .update(workflowRuns)
153
+ .set({
154
+ origin: 'dev',
155
+ devSource: {
156
+ ref: 'fix-triage-prompt',
157
+ commit: 'abc123',
158
+ config_path: '.shipfox/workflows/triage-sentry.yml',
159
+ initiated_by_user_id: crypto.randomUUID(),
160
+ replay_of_event_id: null,
161
+ },
162
+ })
163
+ .where(eq(workflowRuns.id, devRun.id));
164
+
165
+ const res = await app.inject({
166
+ method: 'GET',
167
+ url: `/api/workflows/runs/aggregates?project_id=${projectId}&origin=dev`,
168
+ });
169
+
170
+ expect(res.statusCode).toBe(200);
171
+ const body = res.json();
172
+ expect(body.status).toEqual([{value: 'pending', count: 1}]);
173
+ expect(body.trigger_source).toEqual([{value: 'manual', count: 1}]);
174
+ expect(body.workflow).toEqual([{value: devRun.definitionId, count: 1}]);
175
+ });
120
176
  });
@@ -26,6 +26,7 @@ export function getRunAggregatesRoute(projects: ProjectsModuleClient) {
26
26
  status,
27
27
  definition_id: definitionId,
28
28
  trigger_source: triggerSource,
29
+ origin,
29
30
  created_from: createdFrom,
30
31
  created_to: createdTo,
31
32
  } = request.query;
@@ -35,6 +36,7 @@ export function getRunAggregatesRoute(projects: ProjectsModuleClient) {
35
36
  status,
36
37
  definitionId,
37
38
  triggerSource,
39
+ origin,
38
40
  createdFrom: createdFrom ? new Date(createdFrom) : undefined,
39
41
  createdTo: createdTo ? new Date(createdTo) : undefined,
40
42
  };
@@ -210,6 +210,7 @@ describe('GET /runs/jobs/current/steps/:stepId/secrets', () => {
210
210
  workspaceId: run.workspaceId,
211
211
  projectId: run.projectId,
212
212
  triggerReference: null,
213
+ run: {origin: 'synced', devSource: null},
213
214
  });
214
215
  });
215
216
  });
@@ -2,7 +2,10 @@ import {type LeasedJobContext, requireLeasedJobContext} from '@shipfox/api-auth-
2
2
  import type {RunnersInterModuleClient} from '@shipfox/api-runners-dto/inter-module';
3
3
  import {ClientError} from '@shipfox/node-fastify';
4
4
  import type {Step} from '#core/entities/step.js';
5
- import type {WorkflowRunTriggerReference} from '#core/entities/workflow-run.js';
5
+ import type {
6
+ WorkflowRunOriginState,
7
+ WorkflowRunTriggerReference,
8
+ } from '#core/entities/workflow-run.js';
6
9
  import {getJobScope, getStepByIdForJobExecution} from '#db/index.js';
7
10
 
8
11
  export interface LoadedRunningLeasedStep {
@@ -11,6 +14,8 @@ export interface LoadedRunningLeasedStep {
11
14
  workspaceId: string;
12
15
  projectId: string;
13
16
  triggerReference: WorkflowRunTriggerReference | null;
17
+ /** The run's origin state, forwarded for checkout fallbacks. */
18
+ run: WorkflowRunOriginState;
14
19
  }
15
20
 
16
21
  export async function loadRunningLeasedStep(params: {
@@ -59,5 +64,6 @@ export async function loadRunningLeasedStep(params: {
59
64
  workspaceId: scope.workspaceId,
60
65
  projectId: scope.projectId,
61
66
  triggerReference: scope.triggerReference,
67
+ run: scope.run,
62
68
  };
63
69
  }
@@ -164,6 +164,81 @@ describe('GET /api/workflows/runs', () => {
164
164
  expect(res.json().runs[0].trigger_reference).toBeNull();
165
165
  });
166
166
 
167
+ test('labels runs with the synced origin and no dev source by default', async () => {
168
+ await createWorkflowRun({
169
+ workspaceId,
170
+ projectId,
171
+ definitionId: crypto.randomUUID(),
172
+ name: 'Manual',
173
+ model: workflowModel({name: 'Manual'}),
174
+ triggerPayload: {
175
+ source: 'manual',
176
+ event: 'fire',
177
+ subscriptionId: crypto.randomUUID(),
178
+ userId: crypto.randomUUID(),
179
+ },
180
+ });
181
+
182
+ const res = await app.inject({
183
+ method: 'GET',
184
+ url: `/api/workflows/runs?project_id=${projectId}`,
185
+ });
186
+
187
+ expect(res.statusCode).toBe(200);
188
+ expect(res.json().runs[0]).toMatchObject({origin: 'synced', dev_source: null});
189
+ });
190
+
191
+ test('filters runs by origin', async () => {
192
+ await createWorkflowRun({
193
+ workspaceId,
194
+ projectId,
195
+ definitionId: crypto.randomUUID(),
196
+ name: 'Synced',
197
+ model: workflowModel({name: 'Synced'}),
198
+ triggerPayload: {
199
+ source: 'manual',
200
+ event: 'fire',
201
+ subscriptionId: crypto.randomUUID(),
202
+ userId: crypto.randomUUID(),
203
+ },
204
+ });
205
+ const devRun = await createWorkflowRun({
206
+ workspaceId,
207
+ projectId,
208
+ definitionId: crypto.randomUUID(),
209
+ name: 'Dev',
210
+ model: workflowModel({name: 'Dev'}),
211
+ triggerPayload: {
212
+ source: 'manual',
213
+ event: 'fire',
214
+ subscriptionId: crypto.randomUUID(),
215
+ userId: crypto.randomUUID(),
216
+ },
217
+ });
218
+ const initiatedByUserId = crypto.randomUUID();
219
+ await markDevRun(devRun.id, initiatedByUserId);
220
+
221
+ const res = await app.inject({
222
+ method: 'GET',
223
+ url: `/api/workflows/runs?project_id=${projectId}&origin=dev`,
224
+ });
225
+
226
+ expect(res.statusCode).toBe(200);
227
+ const body = res.json();
228
+ expect(body.runs.map((run: {id: string}) => run.id)).toEqual([devRun.id]);
229
+ expect(body.filtered_total_count).toBe(1);
230
+ expect(body.runs[0]).toMatchObject({
231
+ origin: 'dev',
232
+ dev_source: {
233
+ ref: 'fix-triage-prompt',
234
+ commit: 'abc123',
235
+ config_path: '.shipfox/workflows/triage-sentry.yml',
236
+ initiated_by_user_id: initiatedByUserId,
237
+ replay_of_event_id: null,
238
+ },
239
+ });
240
+ });
241
+
167
242
  test('returns empty array for project with no runs', async () => {
168
243
  const res = await app.inject({
169
244
  method: 'GET',
@@ -257,6 +332,67 @@ describe('GET /api/workflows/runs', () => {
257
332
  expect(next.json().runs.map((run: {id: string}) => run.id)).toEqual([first.id]);
258
333
  });
259
334
 
335
+ test('keeps the origin filter across cursor pages', async () => {
336
+ const devFirst = await createRunAt({
337
+ workspaceId,
338
+ projectId,
339
+ name: 'Dev first',
340
+ createdAt: new Date('2026-05-07T00:00:00.000Z'),
341
+ });
342
+ await markDevRun(devFirst.id);
343
+ await createRunAt({
344
+ workspaceId,
345
+ projectId,
346
+ name: 'Synced middle one',
347
+ createdAt: new Date('2026-05-07T01:00:00.000Z'),
348
+ });
349
+ const devSecond = await createRunAt({
350
+ workspaceId,
351
+ projectId,
352
+ name: 'Dev second',
353
+ createdAt: new Date('2026-05-07T02:00:00.000Z'),
354
+ });
355
+ await markDevRun(devSecond.id);
356
+ await createRunAt({
357
+ workspaceId,
358
+ projectId,
359
+ name: 'Synced middle two',
360
+ createdAt: new Date('2026-05-07T03:00:00.000Z'),
361
+ });
362
+ const devThird = await createRunAt({
363
+ workspaceId,
364
+ projectId,
365
+ name: 'Dev third',
366
+ createdAt: new Date('2026-05-07T04:00:00.000Z'),
367
+ });
368
+ await markDevRun(devThird.id);
369
+
370
+ const firstPage = await app.inject({
371
+ method: 'GET',
372
+ url: `/api/workflows/runs?project_id=${projectId}&origin=dev&limit=2`,
373
+ });
374
+
375
+ expect(firstPage.statusCode).toBe(200);
376
+ const firstBody = firstPage.json();
377
+ expect(firstBody.runs.map((run: {name: string}) => run.name)).toEqual([
378
+ 'Dev third',
379
+ 'Dev second',
380
+ ]);
381
+ expect(firstBody.runs.every((run: {origin: string}) => run.origin === 'dev')).toBe(true);
382
+ expect(firstBody.filtered_total_count).toBe(3);
383
+
384
+ const secondPage = await app.inject({
385
+ method: 'GET',
386
+ url: `/api/workflows/runs?project_id=${projectId}&origin=dev&limit=2&cursor=${firstBody.next_cursor}`,
387
+ });
388
+
389
+ expect(secondPage.statusCode).toBe(200);
390
+ const secondBody = secondPage.json();
391
+ expect(secondBody.runs.map((run: {name: string}) => run.name)).toEqual(['Dev first']);
392
+ expect(secondBody.runs.every((run: {origin: string}) => run.origin === 'dev')).toBe(true);
393
+ expect(secondBody.next_cursor).toBeNull();
394
+ });
395
+
260
396
  test('invalid cursor returns stable client error', async () => {
261
397
  const res = await app.inject({
262
398
  method: 'GET',
@@ -309,3 +445,19 @@ async function createRunAt({
309
445
 
310
446
  return {...run, createdAt, updatedAt: createdAt};
311
447
  }
448
+
449
+ async function markDevRun(runId: string, initiatedByUserId = crypto.randomUUID()) {
450
+ await db()
451
+ .update(workflowRuns)
452
+ .set({
453
+ origin: 'dev',
454
+ devSource: {
455
+ ref: 'fix-triage-prompt',
456
+ commit: 'abc123',
457
+ config_path: '.shipfox/workflows/triage-sentry.yml',
458
+ initiated_by_user_id: initiatedByUserId,
459
+ replay_of_event_id: null,
460
+ },
461
+ })
462
+ .where(eq(workflowRuns.id, runId));
463
+ }
@@ -30,6 +30,7 @@ export function listRunsRoute(projects: ProjectsModuleClient) {
30
30
  status,
31
31
  definition_id: definitionId,
32
32
  trigger_source: triggerSource,
33
+ origin,
33
34
  created_from: createdFrom,
34
35
  created_to: createdTo,
35
36
  } = request.query;
@@ -44,6 +45,7 @@ export function listRunsRoute(projects: ProjectsModuleClient) {
44
45
  status,
45
46
  definitionId,
46
47
  triggerSource,
48
+ origin,
47
49
  createdFrom: createdFrom ? new Date(createdFrom) : undefined,
48
50
  createdTo: createdTo ? new Date(createdTo) : undefined,
49
51
  };