@shipfox/api-workflows 14.0.0 → 16.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-workflows",
3
3
  "license": "MIT",
4
- "version": "14.0.0",
4
+ "version": "16.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -23,30 +23,30 @@
23
23
  "drizzle-orm": "^0.45.2",
24
24
  "js-yaml": "^4.1.0",
25
25
  "zod": "^4.4.3",
26
- "@shipfox/api-auth-context": "12.2.0",
27
- "@shipfox/api-auth-dto": "12.0.0",
26
+ "@shipfox/api-auth-context": "15.0.0",
27
+ "@shipfox/api-auth-dto": "15.0.0",
28
+ "@shipfox/api-agent-dto": "16.0.0",
28
29
  "@shipfox/annotations-dto": "12.3.0",
29
- "@shipfox/api-agent-dto": "14.0.0",
30
- "@shipfox/api-definitions-dto": "14.0.0",
31
- "@shipfox/api-integration-core-dto": "14.0.0",
32
- "@shipfox/api-runners-dto": "14.0.0",
33
- "@shipfox/api-projects-dto": "14.0.0",
34
- "@shipfox/api-workflows-dto": "14.0.0",
30
+ "@shipfox/api-definitions-dto": "16.0.0",
31
+ "@shipfox/api-projects-dto": "15.0.0",
32
+ "@shipfox/api-integration-core-dto": "16.0.0",
33
+ "@shipfox/api-runners-dto": "16.0.0",
35
34
  "@shipfox/api-secrets-dto": "12.0.0",
36
- "@shipfox/api-workspaces-dto": "12.0.0",
35
+ "@shipfox/api-workflows-dto": "16.0.0",
36
+ "@shipfox/api-workspaces-dto": "15.0.0",
37
+ "@shipfox/config": "1.2.4",
37
38
  "@shipfox/inter-module": "0.2.3",
38
- "@shipfox/expression": "2.2.1",
39
+ "@shipfox/expression": "2.4.0",
39
40
  "@shipfox/node-drizzle": "0.3.5",
40
41
  "@shipfox/node-error-monitoring": "0.3.0",
41
- "@shipfox/node-fastify": "0.4.2",
42
- "@shipfox/node-module": "1.0.6",
43
- "@shipfox/node-opentelemetry": "0.6.4",
42
+ "@shipfox/node-fastify": "0.4.3",
43
+ "@shipfox/node-module": "1.0.7",
44
+ "@shipfox/node-opentelemetry": "0.6.5",
44
45
  "@shipfox/node-outbox": "0.2.6",
45
- "@shipfox/config": "1.2.4",
46
46
  "@shipfox/node-postgres": "0.5.0",
47
- "@shipfox/node-temporal": "0.4.5",
47
+ "@shipfox/node-temporal": "0.4.6",
48
48
  "@shipfox/runner-labels": "0.2.0",
49
- "@shipfox/workflow-document": "3.1.0"
49
+ "@shipfox/workflow-document": "3.3.0"
50
50
  },
51
51
  "imports": {
52
52
  "#*": "./dist/*"
@@ -58,14 +58,16 @@ export type TriggerPayload =
58
58
  source: 'manual';
59
59
  provider?: 'manual' | undefined;
60
60
  event: 'fire';
61
- subscriptionId: string;
61
+ // A dev trigger has no subscription row, so the id is optional here.
62
+ subscriptionId?: string | undefined;
62
63
  userId: string;
63
64
  }
64
65
  | {
65
66
  source: 'cron';
66
67
  provider?: 'cron' | undefined;
67
68
  event: 'tick';
68
- scheduleId: string;
69
+ // A dev trigger has no schedule row, so the id is optional here.
70
+ scheduleId?: string | undefined;
69
71
  }
70
72
  // Integration sources (github, gitlab, sentry, …) flow through opaquely: the
71
73
  // run records what fired it and carries the raw event payload, without the
package/src/core/index.ts CHANGED
@@ -35,8 +35,8 @@ export {
35
35
  deriveJobSuccess,
36
36
  type JobActivationDecision,
37
37
  } from './job-transition/index.js';
38
- export type {RunWorkflowParams} from './run-workflow.js';
39
- export {runWorkflow} from './run-workflow.js';
38
+ export type {RunDevWorkflowParams, RunWorkflowParams} from './run-workflow.js';
39
+ export {runDevWorkflow, runWorkflow} from './run-workflow.js';
40
40
  export {
41
41
  type MaterializedWorkflowJob,
42
42
  type MaterializedWorkflowStep,
@@ -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({
@@ -248,3 +248,102 @@ describe('runWorkflow', () => {
248
248
  expect(run.inputs).toEqual({env: 'staging'});
249
249
  });
250
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
 
@@ -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
+ }
@@ -87,6 +87,54 @@ describe('workflow run queries', () => {
87
87
  .where(eq(workflowRuns.id, run.id));
88
88
  });
89
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
+
90
138
  test('persists normalized integration trigger facts alongside the payload', async () => {
91
139
  const triggerConnectionId = crypto.randomUUID();
92
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;
@@ -1,3 +1,4 @@
1
+ import {createWorkflowModelSnapshot} from '@shipfox/api-definitions-dto';
1
2
  import {workflowsInterModuleContract} from '@shipfox/api-workflows-dto/inter-module';
2
3
  import {workspacesInterModuleContract} from '@shipfox/api-workspaces-dto/inter-module';
3
4
  import {createInterModuleKnownError, isInterModuleKnownError} from '@shipfox/inter-module';
@@ -9,7 +10,13 @@ import {
9
10
  InterpolationUnresolvableError,
10
11
  ProjectMismatchError,
11
12
  } from '#core/index.js';
12
- import {createWorkflowsInterModulePresentation, toStartRunKnownError} from './inter-module.js';
13
+ import {getWorkflowRunById} from '#db/workflow-runs.js';
14
+ import {workflowModel} from '#test/index.js';
15
+ import {
16
+ createWorkflowsInterModulePresentation,
17
+ toStartDevRunKnownError,
18
+ toStartRunKnownError,
19
+ } from './inter-module.js';
13
20
 
14
21
  const mocks = vi.hoisted(() => ({
15
22
  deliverEventToListener: vi.fn(),
@@ -188,6 +195,129 @@ describe('Workflows inter-module presentation', () => {
188
195
  ).toBe('workspace-not-found');
189
196
  });
190
197
 
198
+ const devInput = {
199
+ workspaceId: input.workspaceId,
200
+ projectId: input.projectId,
201
+ workflowId: '00000000-0000-4000-8000-000000000006',
202
+ model: createWorkflowModelSnapshot(
203
+ workflowModel({name: 'Dev Workflow', jobs: {build: {steps: [{run: 'echo dev'}]}}}),
204
+ ),
205
+ sourceSnapshot: {content: 'name: Dev Workflow\n', format: 'yaml' as const},
206
+ devSource: {
207
+ ref: 'fix-triage-prompt',
208
+ commit: 'a'.repeat(40),
209
+ configPath: '.shipfox/workflows/triage-sentry.yml',
210
+ initiatedByUserId: input.triggerPayload.userId,
211
+ },
212
+ triggerPayload: {
213
+ source: 'manual' as const,
214
+ event: 'fire' as const,
215
+ userId: input.triggerPayload.userId,
216
+ },
217
+ };
218
+
219
+ test.each([
220
+ ['agent-config-unresolvable', () => new AgentConfigUnresolvableError(devInput.workflowId)],
221
+ [
222
+ 'agent-integration-materialization-failed',
223
+ () => new AgentIntegrationMaterializationError('integration unavailable'),
224
+ ],
225
+ [
226
+ 'interpolation-unresolvable',
227
+ () =>
228
+ new InterpolationUnresolvableError(devInput.workflowId, {
229
+ field: 'env',
230
+ source: 'event.ref',
231
+ envKey: 'REF',
232
+ }),
233
+ ],
234
+ ['invalid-job-runner-labels', () => new InvalidJobRunnerLabelsError(['gpu'])],
235
+ ] as const)('maps %s to the published dev-run contract error', (code, error) => {
236
+ const result = toStartDevRunKnownError(error());
237
+
238
+ expect(
239
+ isInterModuleKnownError(workflowsInterModuleContract.methods.startDevRun, result) &&
240
+ result.code,
241
+ ).toBe(code);
242
+ });
243
+
244
+ test.each([
245
+ ['definition-not-found', () => new DefinitionNotFoundError(devInput.workflowId)],
246
+ ['project-mismatch', () => new ProjectMismatchError(devInput.projectId, devInput.workflowId)],
247
+ ] as const)('does not map %s on the dev-run contract', (_code, error) => {
248
+ const result = toStartDevRunKnownError(error());
249
+
250
+ expect(isInterModuleKnownError(workflowsInterModuleContract.methods.startDevRun, result)).toBe(
251
+ false,
252
+ );
253
+ });
254
+
255
+ test.each([
256
+ ['suspended', 'workspace-suspended'],
257
+ ['deleted', 'workspace-deleted'],
258
+ ] as const)('rejects dev runs for %s workspaces before creating a run', async (status, code) => {
259
+ const getWorkspaceOperatingState = vi.fn().mockResolvedValue({status});
260
+ const presentation = createWorkflowsInterModulePresentation({
261
+ agent: {} as never,
262
+ definitions: {} as never,
263
+ integrations: {} as never,
264
+ projects: {} as never,
265
+ runners: {} as never,
266
+ secrets: {} as never,
267
+ workspaces: {getWorkspaceOperatingState} as never,
268
+ });
269
+
270
+ const error = await Promise.resolve(
271
+ presentation.handlers.startDevRun(devInput, {
272
+ signal: new AbortController().signal,
273
+ }),
274
+ ).catch((caught: unknown) => caught);
275
+
276
+ expect(getWorkspaceOperatingState).toHaveBeenCalledWith({workspaceId: devInput.workspaceId});
277
+ expect(
278
+ isInterModuleKnownError(workflowsInterModuleContract.methods.startDevRun, error) &&
279
+ error.code,
280
+ ).toBe(code);
281
+ });
282
+
283
+ test('creates a dev run with dev provenance and lineage numbering', async () => {
284
+ const presentation = createWorkflowsInterModulePresentation({
285
+ agent: {} as never,
286
+ definitions: {} as never,
287
+ integrations: {} as never,
288
+ projects: {} as never,
289
+ runners: {} as never,
290
+ secrets: {} as never,
291
+ workspaces: {
292
+ getWorkspaceOperatingState: vi.fn().mockResolvedValue({status: 'active'}),
293
+ } as never,
294
+ });
295
+ // A fresh lineage id keeps the assertion independent of counter rows left
296
+ // behind by earlier suite runs against the shared test database.
297
+ const workflowId = crypto.randomUUID();
298
+ const runInput = {...devInput, workflowId};
299
+
300
+ const first = await presentation.handlers.startDevRun(runInput, {
301
+ signal: new AbortController().signal,
302
+ });
303
+ const second = await presentation.handlers.startDevRun(runInput, {
304
+ signal: new AbortController().signal,
305
+ });
306
+
307
+ const firstRun = await getWorkflowRunById(first.id);
308
+ const secondRun = await getWorkflowRunById(second.id);
309
+ expect(firstRun).toMatchObject({
310
+ definitionId: workflowId,
311
+ origin: 'dev',
312
+ devSource: {...devInput.devSource, replayOfEventId: null},
313
+ number: 1,
314
+ name: 'Dev Workflow',
315
+ sourceSnapshot: devInput.sourceSnapshot,
316
+ });
317
+ // Each call is a distinct intent, so the lineage number sequence continues.
318
+ expect(secondRun).toMatchObject({definitionId: workflowId, origin: 'dev', number: 2});
319
+ });
320
+
191
321
  test.each([
192
322
  ['suspended', 'workspace-suspended'],
193
323
  ['deleted', 'workspace-deleted'],
@@ -27,6 +27,7 @@ import {
27
27
  DefinitionNotFoundError,
28
28
  InterpolationUnresolvableError,
29
29
  ProjectMismatchError,
30
+ runDevWorkflow,
30
31
  runWorkflow,
31
32
  } from '#core/index.js';
32
33
  import {resolveWorkflowRunTriggerReference} from '#core/resolve-trigger-reference.js';
@@ -72,6 +73,34 @@ export function createWorkflowsInterModulePresentation(params: {
72
73
  throw toStartRunKnownError(error, input.definitionId);
73
74
  }
74
75
  },
76
+ startDevRun: async (input) => {
77
+ try {
78
+ await assertWorkspaceAdmitsNewJobs(params.workspaces, input.workspaceId);
79
+ const run = await runDevWorkflow(
80
+ params.agent,
81
+ {
82
+ workspaceId: input.workspaceId,
83
+ projectId: input.projectId,
84
+ workflowId: input.workflowId,
85
+ model: input.model,
86
+ sourceSnapshot: input.sourceSnapshot,
87
+ devSource: {
88
+ ...input.devSource,
89
+ replayOfEventId: input.devSource.replayOfEventId ?? null,
90
+ },
91
+ triggerPayload: input.triggerPayload,
92
+ triggerConnectionId: input.triggerConnectionId,
93
+ inputs: input.inputs,
94
+ integrations: params.integrations,
95
+ projects: params.projects,
96
+ },
97
+ {secrets: params.secrets},
98
+ );
99
+ return {id: run.id, name: run.name};
100
+ } catch (error) {
101
+ throw toStartDevRunKnownError(error);
102
+ }
103
+ },
75
104
  resolveWorkflowRunTriggerReference: async (input) =>
76
105
  await resolveWorkflowRunTriggerReference({
77
106
  workspaceId: input.workspaceId,
@@ -158,14 +187,29 @@ export function createWorkflowsInterModulePresentation(params: {
158
187
 
159
188
  export function toStartRunKnownError(error: unknown, definitionId: string): unknown {
160
189
  const method = workflowsInterModuleContract.methods.startRunFromTrigger;
161
- const workspaceError = toWorkspaceAdmissionKnownError(method, error);
162
- if (workspaceError !== undefined) return workspaceError;
190
+ const mapped = toRunCreationKnownError(method, error);
191
+ if (mapped !== undefined) return mapped;
163
192
  if (error instanceof DefinitionNotFoundError) {
164
193
  return createInterModuleKnownError(method, 'definition-not-found', {definitionId});
165
194
  }
166
195
  if (error instanceof ProjectMismatchError) {
167
196
  return createInterModuleKnownError(method, 'project-mismatch', {});
168
197
  }
198
+ return error;
199
+ }
200
+
201
+ export function toStartDevRunKnownError(error: unknown): unknown {
202
+ return toRunCreationKnownError(workflowsInterModuleContract.methods.startDevRun, error) ?? error;
203
+ }
204
+
205
+ function toRunCreationKnownError(
206
+ method:
207
+ | typeof workflowsInterModuleContract.methods.startRunFromTrigger
208
+ | typeof workflowsInterModuleContract.methods.startDevRun,
209
+ error: unknown,
210
+ ): unknown {
211
+ const workspaceError = toWorkspaceAdmissionKnownError(method, error);
212
+ if (workspaceError !== undefined) return workspaceError;
169
213
  if (error instanceof AgentConfigUnresolvableError) {
170
214
  return createInterModuleKnownError(method, 'agent-config-unresolvable', {
171
215
  definitionId: error.definitionId,
@@ -187,12 +231,13 @@ export function toStartRunKnownError(error: unknown, definitionId: string): unkn
187
231
  labels: [...error.labels],
188
232
  });
189
233
  }
190
- return error;
234
+ return undefined;
191
235
  }
192
236
 
193
237
  function toWorkspaceAdmissionKnownError(
194
238
  method:
195
239
  | typeof workflowsInterModuleContract.methods.startRunFromTrigger
240
+ | typeof workflowsInterModuleContract.methods.startDevRun
196
241
  | typeof workflowsInterModuleContract.methods.deliverEventToJobListener,
197
242
  error: unknown,
198
243
  ): WorkspaceAdmissionKnownError | undefined {