@shipfox/api-workflows 13.1.0 → 15.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 (94) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +61 -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 +25 -6
  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/index.d.ts +2 -2
  15. package/dist/core/index.d.ts.map +1 -1
  16. package/dist/core/index.js +1 -1
  17. package/dist/core/index.js.map +1 -1
  18. package/dist/core/run-workflow.d.ts +26 -1
  19. package/dist/core/run-workflow.d.ts.map +1 -1
  20. package/dist/core/run-workflow.js +27 -1
  21. package/dist/core/run-workflow.js.map +1 -1
  22. package/dist/db/db.d.ts +72 -0
  23. package/dist/db/db.d.ts.map +1 -1
  24. package/dist/db/schema/workflow-runs.d.ts +45 -1
  25. package/dist/db/schema/workflow-runs.d.ts.map +1 -1
  26. package/dist/db/schema/workflow-runs.js +42 -1
  27. package/dist/db/schema/workflow-runs.js.map +1 -1
  28. package/dist/db/workflow-runs/jobs.d.ts +3 -1
  29. package/dist/db/workflow-runs/jobs.d.ts.map +1 -1
  30. package/dist/db/workflow-runs/jobs.js +12 -3
  31. package/dist/db/workflow-runs/jobs.js.map +1 -1
  32. package/dist/db/workflow-runs/queries.d.ts +3 -2
  33. package/dist/db/workflow-runs/queries.d.ts.map +1 -1
  34. package/dist/db/workflow-runs/queries.js +3 -0
  35. package/dist/db/workflow-runs/queries.js.map +1 -1
  36. package/dist/db/workflow-runs/run-create.d.ts +4 -1
  37. package/dist/db/workflow-runs/run-create.d.ts.map +1 -1
  38. package/dist/db/workflow-runs/run-create.js +14 -1
  39. package/dist/db/workflow-runs/run-create.js.map +1 -1
  40. package/dist/presentation/dto/workflow-run.d.ts.map +1 -1
  41. package/dist/presentation/dto/workflow-run.js +14 -0
  42. package/dist/presentation/dto/workflow-run.js.map +1 -1
  43. package/dist/presentation/inter-module.d.ts +1 -0
  44. package/dist/presentation/inter-module.d.ts.map +1 -1
  45. package/dist/presentation/inter-module.js +41 -4
  46. package/dist/presentation/inter-module.js.map +1 -1
  47. package/dist/presentation/routes/checkout-token.d.ts.map +1 -1
  48. package/dist/presentation/routes/checkout-token.js +2 -1
  49. package/dist/presentation/routes/checkout-token.js.map +1 -1
  50. package/dist/presentation/routes/get-run-aggregates.d.ts.map +1 -1
  51. package/dist/presentation/routes/get-run-aggregates.js +2 -1
  52. package/dist/presentation/routes/get-run-aggregates.js.map +1 -1
  53. package/dist/presentation/routes/leased-step.d.ts +3 -1
  54. package/dist/presentation/routes/leased-step.d.ts.map +1 -1
  55. package/dist/presentation/routes/leased-step.js +2 -1
  56. package/dist/presentation/routes/leased-step.js.map +1 -1
  57. package/dist/presentation/routes/list-runs.d.ts.map +1 -1
  58. package/dist/presentation/routes/list-runs.js +2 -1
  59. package/dist/presentation/routes/list-runs.js.map +1 -1
  60. package/dist/presentation/routes/project-access.d.ts +1 -0
  61. package/dist/presentation/routes/project-access.d.ts.map +1 -1
  62. package/dist/tsconfig.test.tsbuildinfo +1 -1
  63. package/drizzle/0004_dev_run_origin.sql +12 -0
  64. package/drizzle/meta/0004_snapshot.json +1773 -0
  65. package/drizzle/meta/_journal.json +7 -0
  66. package/package.json +16 -16
  67. package/src/core/checkout.test.ts +198 -1
  68. package/src/core/checkout.ts +18 -5
  69. package/src/core/entities/job.ts +1 -1
  70. package/src/core/entities/workflow-run.ts +26 -6
  71. package/src/core/index.ts +2 -2
  72. package/src/core/job-transition/decide-job-activation.test.ts +2 -0
  73. package/src/core/run-workflow.test.ts +151 -3
  74. package/src/core/run-workflow.ts +59 -3
  75. package/src/core/workflow-run-creation.test.ts +2 -0
  76. package/src/db/job-listeners.test.ts +17 -0
  77. package/src/db/schema/workflow-runs.ts +58 -0
  78. package/src/db/workflow-runs/jobs.ts +17 -3
  79. package/src/db/workflow-runs/queries.ts +6 -1
  80. package/src/db/workflow-runs/run-create.test.ts +87 -0
  81. package/src/db/workflow-runs/run-create.ts +23 -1
  82. package/src/presentation/dto/workflow-run.ts +23 -1
  83. package/src/presentation/inter-module.test.ts +131 -1
  84. package/src/presentation/inter-module.ts +48 -3
  85. package/src/presentation/routes/checkout-token.test.ts +45 -0
  86. package/src/presentation/routes/checkout-token.ts +2 -1
  87. package/src/presentation/routes/get-run-aggregates.test.ts +56 -0
  88. package/src/presentation/routes/get-run-aggregates.ts +2 -0
  89. package/src/presentation/routes/get-step-secrets.test.ts +1 -0
  90. package/src/presentation/routes/leased-step.ts +7 -1
  91. package/src/presentation/routes/list-runs.test.ts +152 -0
  92. package/src/presentation/routes/list-runs.ts +2 -0
  93. package/test/factories/workflow-run.ts +2 -0
  94. package/tsconfig.build.tsbuildinfo +1 -1
@@ -3,9 +3,9 @@ import {createWorkflowModelSnapshot, type WorkflowModel} from '@shipfox/api-defi
3
3
  import type {DefinitionsInterModuleClient} from '@shipfox/api-definitions-dto/inter-module';
4
4
  import {agentValidationCatalog} from '#test/fixtures/agent-inter-module.js';
5
5
  import {workflowModel} from '#test/index.js';
6
- import type {TriggerPayload} from './entities/workflow-run.js';
6
+ import type {TriggerPayload, WorkflowRunDevSource} from './entities/workflow-run.js';
7
7
  import {DefinitionNotFoundError, ProjectMismatchError} from './errors.js';
8
- import {runWorkflow} from './run-workflow.js';
8
+ import {runDevWorkflow, runWorkflow} from './run-workflow.js';
9
9
 
10
10
  const mockResolveAgentConfig = vi.hoisted(() =>
11
11
  vi.fn().mockResolvedValue({
@@ -23,6 +23,12 @@ type DefinitionForWorkflowRun = NonNullable<
23
23
  let definitionResponse: DefinitionForWorkflowRun | null = null;
24
24
  const definitions: DefinitionsInterModuleClient = {
25
25
  getDefinitionForWorkflowRun: async () => ({definition: definitionResponse}),
26
+ resolveDefinitionAtRef: () => {
27
+ throw new Error('resolveDefinitionAtRef is not used by runWorkflow');
28
+ },
29
+ listDefinitionsAtRef: () => {
30
+ throw new Error('listDefinitionsAtRef is not used by runWorkflow');
31
+ },
26
32
  };
27
33
 
28
34
  function buildDefinition(
@@ -31,6 +37,7 @@ function buildDefinition(
31
37
  const {model = workflowModel(), ...definitionOverrides} = overrides;
32
38
  return {
33
39
  id: crypto.randomUUID(),
40
+ workflowId: crypto.randomUUID(),
34
41
  projectId: crypto.randomUUID(),
35
42
  name: 'Test Workflow',
36
43
  model: createWorkflowModelSnapshot(model),
@@ -79,7 +86,7 @@ describe('runWorkflow', () => {
79
86
 
80
87
  expect(run.id).toBeDefined();
81
88
  expect(run.projectId).toBe(projectId);
82
- expect(run.definitionId).toBe(definition.id);
89
+ expect(run.definitionId).toBe(definition.workflowId);
83
90
  expect(run.name).toBe(definition.name);
84
91
  expect(run.status).toBe('pending');
85
92
  expect(run.triggerProvider).toBeNull();
@@ -151,6 +158,48 @@ describe('runWorkflow', () => {
151
158
  expect(run.sourceSnapshot).toEqual(sourceSnapshot);
152
159
  });
153
160
 
161
+ test('numbers runs by the workflow lineage id, not the definition row id', async () => {
162
+ const lineageId = crypto.randomUUID();
163
+ // A lineage whose id differs from the definition row id, as happens after
164
+ // the backfill when a lineage id diverges from the synced row it was born from.
165
+ const definition = buildDefinition({projectId, id: crypto.randomUUID(), workflowId: lineageId});
166
+ definitionResponse = definition;
167
+
168
+ const first = await runWorkflow(definitions, {
169
+ workspaceId,
170
+ projectId,
171
+ definitionId: definition.id,
172
+ triggerPayload: manualPayload(),
173
+ agent,
174
+ });
175
+ const second = await runWorkflow(definitions, {
176
+ workspaceId,
177
+ projectId,
178
+ definitionId: definition.id,
179
+ triggerPayload: manualPayload(),
180
+ agent,
181
+ });
182
+
183
+ expect(first.definitionId).toBe(lineageId);
184
+ expect(second.definitionId).toBe(lineageId);
185
+ expect(first.number).toBe(1);
186
+ expect(second.number).toBe(2);
187
+
188
+ // A later definition row that resolves to the same lineage continues the sequence.
189
+ const resynced = buildDefinition({projectId, id: crypto.randomUUID(), workflowId: lineageId});
190
+ definitionResponse = resynced;
191
+ const third = await runWorkflow(definitions, {
192
+ workspaceId,
193
+ projectId,
194
+ definitionId: resynced.id,
195
+ triggerPayload: manualPayload(),
196
+ agent,
197
+ });
198
+
199
+ expect(third.definitionId).toBe(lineageId);
200
+ expect(third.number).toBe(3);
201
+ });
202
+
154
203
  test('throws DefinitionNotFoundError for unknown definition', async () => {
155
204
  definitionResponse = null;
156
205
 
@@ -199,3 +248,102 @@ describe('runWorkflow', () => {
199
248
  expect(run.inputs).toEqual({env: 'staging'});
200
249
  });
201
250
  });
251
+
252
+ describe('runDevWorkflow', () => {
253
+ let workspaceId: string;
254
+ let projectId: string;
255
+ const agent: AgentInterModuleClient = {
256
+ getValidationCatalog: vi.fn().mockResolvedValue(agentValidationCatalog),
257
+ resolveAgentConfig: mockResolveAgentConfig,
258
+ resolveRuntimeCredentials: vi.fn(),
259
+ };
260
+
261
+ beforeEach(() => {
262
+ workspaceId = crypto.randomUUID();
263
+ projectId = crypto.randomUUID();
264
+ mockResolveAgentConfig.mockClear();
265
+ });
266
+
267
+ const devSource: WorkflowRunDevSource = {
268
+ ref: 'fix-triage-prompt',
269
+ commit: 'a'.repeat(40),
270
+ configPath: '.shipfox/workflows/triage-sentry.yml',
271
+ initiatedByUserId: crypto.randomUUID(),
272
+ replayOfEventId: null,
273
+ };
274
+
275
+ function devPayload(): TriggerPayload {
276
+ return {source: 'manual', event: 'fire', userId: crypto.randomUUID()};
277
+ }
278
+
279
+ function buildDevRun(workflowId: string, triggerPayload: TriggerPayload = devPayload()) {
280
+ return runDevWorkflow(agent, {
281
+ workspaceId,
282
+ projectId,
283
+ workflowId,
284
+ model: createWorkflowModelSnapshot(workflowModel({name: 'Dev Workflow'})),
285
+ sourceSnapshot: {content: 'name: Dev Workflow\n', format: 'yaml'},
286
+ devSource,
287
+ triggerPayload,
288
+ });
289
+ }
290
+
291
+ test('creates a dev run with the lineage id as definition_id and dev provenance', async () => {
292
+ const workflowId = crypto.randomUUID();
293
+ const triggerPayload = devPayload();
294
+ const run = await buildDevRun(workflowId, triggerPayload);
295
+
296
+ expect(run.definitionId).toBe(workflowId);
297
+ expect(run.origin).toBe('dev');
298
+ expect(run.devSource).toEqual(devSource);
299
+ expect(run.name).toBe('Dev Workflow');
300
+ expect(run.workflowName).toBe('Dev Workflow');
301
+ expect(run.sourceSnapshot).toEqual({content: 'name: Dev Workflow\n', format: 'yaml'});
302
+ expect(run.triggerPayload).toEqual(triggerPayload);
303
+ expect(run.triggerIdempotencyKey).toBeNull();
304
+ });
305
+
306
+ test('continues the lineage number sequence across dev and synced runs of one workflow file', async () => {
307
+ const workflowId = crypto.randomUUID();
308
+ // The file exists on the branch only: the dev run opens the sequence.
309
+ const devRun = await buildDevRun(workflowId);
310
+ expect(devRun.number).toBe(1);
311
+
312
+ // After merge, the synced run of the same lineage continues the sequence.
313
+ const definition = buildDefinition({
314
+ projectId,
315
+ id: crypto.randomUUID(),
316
+ workflowId,
317
+ model: workflowModel({name: 'Dev Workflow'}),
318
+ });
319
+ definitionResponse = definition;
320
+ const syncedRun = await runWorkflow(definitions, {
321
+ workspaceId,
322
+ projectId,
323
+ definitionId: definition.id,
324
+ triggerPayload: manualPayload(),
325
+ agent,
326
+ });
327
+ expect(syncedRun.definitionId).toBe(workflowId);
328
+ expect(syncedRun.origin).toBe('synced');
329
+ expect(syncedRun.number).toBe(2);
330
+
331
+ // And a later dev run of the same file keeps the sequence going.
332
+ const secondDevRun = await buildDevRun(workflowId);
333
+ expect(secondDevRun.number).toBe(3);
334
+ });
335
+
336
+ test('accepts a manual payload without a subscription id, as a dev trigger has no row', async () => {
337
+ const workflowId = crypto.randomUUID();
338
+ const triggerPayload = {
339
+ source: 'manual' as const,
340
+ event: 'fire' as const,
341
+ userId: crypto.randomUUID(),
342
+ };
343
+ const run = await buildDevRun(workflowId, triggerPayload);
344
+
345
+ expect(run.triggerProvider).toBeNull();
346
+ expect(run.triggerSource).toBe('manual');
347
+ expect(run.triggerPayload).toEqual(triggerPayload);
348
+ });
349
+ });
@@ -1,12 +1,17 @@
1
1
  import type {AgentInterModuleClient} from '@shipfox/api-agent-dto/inter-module';
2
- import {workflowModelFromSnapshot} from '@shipfox/api-definitions-dto';
2
+ import {type WorkflowModelSnapshot, workflowModelFromSnapshot} from '@shipfox/api-definitions-dto';
3
3
  import type {DefinitionsInterModuleClient} from '@shipfox/api-definitions-dto/inter-module';
4
4
  import type {IntegrationsModuleClient} from '@shipfox/api-integration-core-dto/inter-module';
5
5
  import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
6
6
  import type {SecretsInterModuleClient} from '@shipfox/api-secrets-dto/inter-module';
7
7
  import {createWorkflowRun} from '#db/workflow-runs.js';
8
8
  import {createAgentDefaultsResolver} from './agent-defaults.js';
9
- import type {TriggerPayload, WorkflowRun} from './entities/workflow-run.js';
9
+ import type {
10
+ TriggerPayload,
11
+ WorkflowRun,
12
+ WorkflowRunDevSource,
13
+ WorkflowSourceSnapshot,
14
+ } from './entities/workflow-run.js';
10
15
  import {DefinitionNotFoundError, ProjectMismatchError} from './errors.js';
11
16
  import {modelHasAgentStep} from './step-config/materialize-workflow-model.js';
12
17
 
@@ -44,7 +49,7 @@ export async function runWorkflow(
44
49
  return createWorkflowRun({
45
50
  workspaceId: params.workspaceId,
46
51
  projectId: params.projectId,
47
- definitionId: definition.id,
52
+ definitionId: definition.workflowId,
48
53
  name: definition.name,
49
54
  model,
50
55
  triggerPayload: params.triggerPayload,
@@ -58,3 +63,54 @@ export async function runWorkflow(
58
63
  projects: params.projects,
59
64
  });
60
65
  }
66
+
67
+ export interface RunDevWorkflowParams {
68
+ workspaceId: string;
69
+ projectId: string;
70
+ /** Workflow lineage id, becomes the run's definition_id. */
71
+ workflowId: string;
72
+ model: WorkflowModelSnapshot;
73
+ sourceSnapshot: WorkflowSourceSnapshot;
74
+ devSource: WorkflowRunDevSource;
75
+ triggerPayload: TriggerPayload;
76
+ triggerConnectionId?: string | undefined;
77
+ inputs?: Record<string, unknown> | undefined;
78
+ integrations?: IntegrationsModuleClient | undefined;
79
+ projects?: ProjectsModuleClient | undefined;
80
+ }
81
+
82
+ /**
83
+ * Creates a dev run from an inline model and snapshot. Unlike
84
+ * trigger-originated runs, dev runs do not carry an idempotency key because
85
+ * each call represents a distinct intent. The lineage id is used as the run's
86
+ * definition_id so dev runs share the workflow numbering sequence before and
87
+ * after the file merges.
88
+ */
89
+ export function runDevWorkflow(
90
+ agent: AgentInterModuleClient,
91
+ params: RunDevWorkflowParams,
92
+ options: {secrets?: Pick<SecretsInterModuleClient, 'getVariablesByNamespace'>} = {},
93
+ ): Promise<WorkflowRun> {
94
+ const model = workflowModelFromSnapshot(params.model);
95
+ const resolveAgentDefaults = createAgentDefaultsResolver(
96
+ agent,
97
+ modelHasAgentStep(model) ? params.workspaceId : null,
98
+ );
99
+
100
+ return createWorkflowRun({
101
+ workspaceId: params.workspaceId,
102
+ projectId: params.projectId,
103
+ definitionId: params.workflowId,
104
+ model,
105
+ triggerPayload: params.triggerPayload,
106
+ triggerConnectionId: params.triggerConnectionId,
107
+ inputs: params.inputs,
108
+ sourceSnapshot: params.sourceSnapshot,
109
+ origin: 'dev',
110
+ devSource: params.devSource,
111
+ resolveAgentDefaults,
112
+ secrets: options.secrets,
113
+ integrations: params.integrations,
114
+ projects: params.projects,
115
+ });
116
+ }
@@ -144,6 +144,8 @@ function workflowRun(params: {inputs?: Record<string, unknown> | null} = {}): Wo
144
144
  workflowName: 'Test workflow',
145
145
  nameOverride: null,
146
146
  status: 'pending',
147
+ origin: 'synced',
148
+ devSource: null,
147
149
  currentAttempt: 1,
148
150
  triggerProvider: null,
149
151
  triggerSource: triggerPayload.source,
@@ -278,6 +278,23 @@ describe('activateJobListener', () => {
278
278
  expect(result.executionCount).toBe(2);
279
279
  });
280
280
 
281
+ it('projects matchers with the event omitted', async () => {
282
+ const job = await createInactiveListeningJobWithMatchers({
283
+ on: [{source: 'github_acme'}],
284
+ until: [{source: 'github_acme', filter: 'event.action == "closed"'}],
285
+ });
286
+
287
+ await activateJobListener({jobId: job.id, expectedVersion: job.version});
288
+
289
+ const payload = await activatedPayload(job.id);
290
+ expectListeningPayload(payload);
291
+ expect(payload.on[0]).toEqual({source: 'github_acme'});
292
+ expect(payload.until?.[0]).toEqual({
293
+ source: 'github_acme',
294
+ filter: 'event.action == "closed"',
295
+ });
296
+ });
297
+
281
298
  it('omits filter snapshots for matchers without non-event roots', async () => {
282
299
  const job = await createInactiveListeningJobWithMatchers({
283
300
  on: [{source: 'github', event: 'pull_request'}],
@@ -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,93 @@ 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
+
90
+ test('persists a dev origin with its dev source', async () => {
91
+ const devSource = {
92
+ ref: 'fix-triage-prompt',
93
+ commit: 'a'.repeat(40),
94
+ configPath: '.shipfox/workflows/triage-sentry.yml',
95
+ initiatedByUserId: crypto.randomUUID(),
96
+ replayOfEventId: null,
97
+ };
98
+ const run = await createWorkflowRun({
99
+ workspaceId,
100
+ projectId,
101
+ definitionId,
102
+ model: buildModel(),
103
+ triggerPayload: {
104
+ source: 'manual',
105
+ event: 'fire',
106
+ userId: crypto.randomUUID(),
107
+ },
108
+ origin: 'dev',
109
+ devSource,
110
+ });
111
+
112
+ expect(run.origin).toBe('dev');
113
+ expect(run.devSource).toEqual(devSource);
114
+ await expect(getWorkflowRunById(run.id)).resolves.toMatchObject({
115
+ origin: 'dev',
116
+ devSource,
117
+ });
118
+ // The persisted jsonb carries snake_case keys, matching the read boundary.
119
+ await expect(
120
+ db()
121
+ .select({origin: workflowRuns.origin, devSource: workflowRuns.devSource})
122
+ .from(workflowRuns)
123
+ .where(eq(workflowRuns.id, run.id)),
124
+ ).resolves.toEqual([
125
+ {
126
+ origin: 'dev',
127
+ devSource: {
128
+ ref: devSource.ref,
129
+ commit: devSource.commit,
130
+ config_path: devSource.configPath,
131
+ initiated_by_user_id: devSource.initiatedByUserId,
132
+ replay_of_event_id: devSource.replayOfEventId,
133
+ },
134
+ },
135
+ ]);
136
+ });
137
+
51
138
  test('persists normalized integration trigger facts alongside the payload', async () => {
52
139
  const triggerConnectionId = crypto.randomUUID();
53
140
  const integrations = {
@@ -21,6 +21,8 @@ import {
21
21
  import type {
22
22
  TriggerPayload,
23
23
  WorkflowRun,
24
+ WorkflowRunDevSource,
25
+ WorkflowRunOrigin,
24
26
  WorkflowSourceSnapshot,
25
27
  } from '#core/entities/workflow-run.js';
26
28
  import {InterpolationUnresolvableError} from '#core/errors.js';
@@ -40,7 +42,7 @@ import {
40
42
  import {db, type Tx} from '../db.js';
41
43
  import {workflowRunAttempts} from '../schema/workflow-run-attempts.js';
42
44
  import {workflowRunCounters} from '../schema/workflow-run-counters.js';
43
- import {toWorkflowRun, workflowRuns} from '../schema/workflow-runs.js';
45
+ import {toWorkflowRun, type WorkflowRunDevSourceDb, workflowRuns} from '../schema/workflow-runs.js';
44
46
  import {type MaterializedRunGraphJob, persistMaterializedRunGraph} from './run-graph.js';
45
47
 
46
48
  export type WorkflowModelJob = WorkflowModel['jobs'][number];
@@ -63,6 +65,9 @@ export interface CreateWorkflowRunParams {
63
65
  inputs?: Record<string, unknown> | undefined;
64
66
  sourceSnapshot?: WorkflowSourceSnapshot | null | undefined;
65
67
  triggerIdempotencyKey?: string | undefined;
68
+ /** Run provenance. Defaults to a synced run with no dev source. */
69
+ origin?: WorkflowRunOrigin | undefined;
70
+ devSource?: WorkflowRunDevSource | null | undefined;
66
71
  resolveAgentDefaults?: AgentDefaultsResolver | undefined;
67
72
  secrets?: Pick<SecretsInterModuleClient, 'getVariablesByNamespace'> | undefined;
68
73
  integrations?: IntegrationsModuleClient | undefined;
@@ -132,6 +137,11 @@ export async function createWorkflowRun(params: CreateWorkflowRunParams): Promis
132
137
  inputs: params.inputs ?? null,
133
138
  sourceSnapshot: params.sourceSnapshot ?? null,
134
139
  triggerIdempotencyKey: params.triggerIdempotencyKey ?? null,
140
+ origin: params.origin ?? 'synced',
141
+ devSource:
142
+ params.devSource === undefined || params.devSource === null
143
+ ? null
144
+ : toWorkflowRunDevSourceDb(params.devSource),
135
145
  })
136
146
  .onConflictDoNothing({target: workflowRuns.triggerIdempotencyKey})
137
147
  .returning();
@@ -281,6 +291,18 @@ async function allocateWorkflowRunNumber(tx: Tx, definitionId: string): Promise<
281
291
  return counterRow.number;
282
292
  }
283
293
 
294
+ // The run entity carries camelCase dev-source fields while the persisted jsonb uses
295
+ // snake_case keys, so the write boundary maps explicitly rather than spreading.
296
+ function toWorkflowRunDevSourceDb(source: WorkflowRunDevSource): WorkflowRunDevSourceDb {
297
+ return {
298
+ ref: source.ref,
299
+ commit: source.commit,
300
+ config_path: source.configPath,
301
+ initiated_by_user_id: source.initiatedByUserId,
302
+ replay_of_event_id: source.replayOfEventId,
303
+ };
304
+ }
305
+
284
306
  export async function loadReferencedVariables(params: {
285
307
  readonly model: WorkflowModel;
286
308
  readonly jobs?: readonly WorkflowModelJob[] | undefined;