@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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +61 -0
- package/README.md +12 -5
- package/dist/core/checkout.d.ts +4 -2
- package/dist/core/checkout.d.ts.map +1 -1
- package/dist/core/checkout.js +7 -2
- package/dist/core/checkout.js.map +1 -1
- package/dist/core/entities/job.d.ts +1 -1
- package/dist/core/entities/job.d.ts.map +1 -1
- package/dist/core/entities/job.js.map +1 -1
- package/dist/core/entities/workflow-run.d.ts +25 -6
- package/dist/core/entities/workflow-run.d.ts.map +1 -1
- package/dist/core/entities/workflow-run.js.map +1 -1
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/run-workflow.d.ts +26 -1
- package/dist/core/run-workflow.d.ts.map +1 -1
- package/dist/core/run-workflow.js +27 -1
- package/dist/core/run-workflow.js.map +1 -1
- package/dist/db/db.d.ts +72 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/schema/workflow-runs.d.ts +45 -1
- package/dist/db/schema/workflow-runs.d.ts.map +1 -1
- package/dist/db/schema/workflow-runs.js +42 -1
- package/dist/db/schema/workflow-runs.js.map +1 -1
- package/dist/db/workflow-runs/jobs.d.ts +3 -1
- package/dist/db/workflow-runs/jobs.d.ts.map +1 -1
- package/dist/db/workflow-runs/jobs.js +12 -3
- package/dist/db/workflow-runs/jobs.js.map +1 -1
- package/dist/db/workflow-runs/queries.d.ts +3 -2
- package/dist/db/workflow-runs/queries.d.ts.map +1 -1
- package/dist/db/workflow-runs/queries.js +3 -0
- package/dist/db/workflow-runs/queries.js.map +1 -1
- package/dist/db/workflow-runs/run-create.d.ts +4 -1
- package/dist/db/workflow-runs/run-create.d.ts.map +1 -1
- package/dist/db/workflow-runs/run-create.js +14 -1
- package/dist/db/workflow-runs/run-create.js.map +1 -1
- package/dist/presentation/dto/workflow-run.d.ts.map +1 -1
- package/dist/presentation/dto/workflow-run.js +14 -0
- package/dist/presentation/dto/workflow-run.js.map +1 -1
- package/dist/presentation/inter-module.d.ts +1 -0
- package/dist/presentation/inter-module.d.ts.map +1 -1
- package/dist/presentation/inter-module.js +41 -4
- package/dist/presentation/inter-module.js.map +1 -1
- package/dist/presentation/routes/checkout-token.d.ts.map +1 -1
- package/dist/presentation/routes/checkout-token.js +2 -1
- package/dist/presentation/routes/checkout-token.js.map +1 -1
- package/dist/presentation/routes/get-run-aggregates.d.ts.map +1 -1
- package/dist/presentation/routes/get-run-aggregates.js +2 -1
- package/dist/presentation/routes/get-run-aggregates.js.map +1 -1
- package/dist/presentation/routes/leased-step.d.ts +3 -1
- package/dist/presentation/routes/leased-step.d.ts.map +1 -1
- package/dist/presentation/routes/leased-step.js +2 -1
- package/dist/presentation/routes/leased-step.js.map +1 -1
- package/dist/presentation/routes/list-runs.d.ts.map +1 -1
- package/dist/presentation/routes/list-runs.js +2 -1
- package/dist/presentation/routes/list-runs.js.map +1 -1
- package/dist/presentation/routes/project-access.d.ts +1 -0
- package/dist/presentation/routes/project-access.d.ts.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0004_dev_run_origin.sql +12 -0
- package/drizzle/meta/0004_snapshot.json +1773 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +16 -16
- package/src/core/checkout.test.ts +198 -1
- package/src/core/checkout.ts +18 -5
- package/src/core/entities/job.ts +1 -1
- package/src/core/entities/workflow-run.ts +26 -6
- package/src/core/index.ts +2 -2
- package/src/core/job-transition/decide-job-activation.test.ts +2 -0
- package/src/core/run-workflow.test.ts +151 -3
- package/src/core/run-workflow.ts +59 -3
- package/src/core/workflow-run-creation.test.ts +2 -0
- package/src/db/job-listeners.test.ts +17 -0
- package/src/db/schema/workflow-runs.ts +58 -0
- package/src/db/workflow-runs/jobs.ts +17 -3
- package/src/db/workflow-runs/queries.ts +6 -1
- package/src/db/workflow-runs/run-create.test.ts +87 -0
- package/src/db/workflow-runs/run-create.ts +23 -1
- package/src/presentation/dto/workflow-run.ts +23 -1
- package/src/presentation/inter-module.test.ts +131 -1
- package/src/presentation/inter-module.ts +48 -3
- package/src/presentation/routes/checkout-token.test.ts +45 -0
- package/src/presentation/routes/checkout-token.ts +2 -1
- package/src/presentation/routes/get-run-aggregates.test.ts +56 -0
- package/src/presentation/routes/get-run-aggregates.ts +2 -0
- package/src/presentation/routes/get-step-secrets.test.ts +1 -0
- package/src/presentation/routes/leased-step.ts +7 -1
- package/src/presentation/routes/list-runs.test.ts +152 -0
- package/src/presentation/routes/list-runs.ts +2 -0
- package/test/factories/workflow-run.ts +2 -0
- 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.
|
|
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
|
+
});
|
package/src/core/run-workflow.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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;
|