@shipfox/api-workflows 13.0.0 → 14.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +46 -0
  3. package/README.md +12 -5
  4. package/dist/core/checkout.d.ts +4 -2
  5. package/dist/core/checkout.d.ts.map +1 -1
  6. package/dist/core/checkout.js +7 -2
  7. package/dist/core/checkout.js.map +1 -1
  8. package/dist/core/entities/job.d.ts +1 -1
  9. package/dist/core/entities/job.d.ts.map +1 -1
  10. package/dist/core/entities/job.js.map +1 -1
  11. package/dist/core/entities/workflow-run.d.ts +23 -4
  12. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  13. package/dist/core/entities/workflow-run.js.map +1 -1
  14. package/dist/core/errors.d.ts +9 -1
  15. package/dist/core/errors.d.ts.map +1 -1
  16. package/dist/core/errors.js +5 -1
  17. package/dist/core/errors.js.map +1 -1
  18. package/dist/core/job-execution.d.ts.map +1 -1
  19. package/dist/core/job-execution.js +12 -2
  20. package/dist/core/job-execution.js.map +1 -1
  21. package/dist/core/run-workflow.js +1 -1
  22. package/dist/core/run-workflow.js.map +1 -1
  23. package/dist/core/step-config/agent.d.ts.map +1 -1
  24. package/dist/core/step-config/agent.js +9 -1
  25. package/dist/core/step-config/agent.js.map +1 -1
  26. package/dist/db/db.d.ts +72 -0
  27. package/dist/db/db.d.ts.map +1 -1
  28. package/dist/db/schema/workflow-runs.d.ts +45 -1
  29. package/dist/db/schema/workflow-runs.d.ts.map +1 -1
  30. package/dist/db/schema/workflow-runs.js +42 -1
  31. package/dist/db/schema/workflow-runs.js.map +1 -1
  32. package/dist/db/workflow-runs/jobs.d.ts +3 -1
  33. package/dist/db/workflow-runs/jobs.d.ts.map +1 -1
  34. package/dist/db/workflow-runs/jobs.js +12 -3
  35. package/dist/db/workflow-runs/jobs.js.map +1 -1
  36. package/dist/db/workflow-runs/queries.d.ts +3 -2
  37. package/dist/db/workflow-runs/queries.d.ts.map +1 -1
  38. package/dist/db/workflow-runs/queries.js +3 -0
  39. package/dist/db/workflow-runs/queries.js.map +1 -1
  40. package/dist/presentation/dto/step.d.ts.map +1 -1
  41. package/dist/presentation/dto/step.js +14 -0
  42. package/dist/presentation/dto/step.js.map +1 -1
  43. package/dist/presentation/dto/workflow-run.d.ts.map +1 -1
  44. package/dist/presentation/dto/workflow-run.js +14 -0
  45. package/dist/presentation/dto/workflow-run.js.map +1 -1
  46. package/dist/presentation/routes/agent-runtime-config.d.ts.map +1 -1
  47. package/dist/presentation/routes/agent-runtime-config.js +22 -0
  48. package/dist/presentation/routes/agent-runtime-config.js.map +1 -1
  49. package/dist/presentation/routes/checkout-token.d.ts.map +1 -1
  50. package/dist/presentation/routes/checkout-token.js +2 -1
  51. package/dist/presentation/routes/checkout-token.js.map +1 -1
  52. package/dist/presentation/routes/get-run-aggregates.d.ts.map +1 -1
  53. package/dist/presentation/routes/get-run-aggregates.js +2 -1
  54. package/dist/presentation/routes/get-run-aggregates.js.map +1 -1
  55. package/dist/presentation/routes/leased-step.d.ts +3 -1
  56. package/dist/presentation/routes/leased-step.d.ts.map +1 -1
  57. package/dist/presentation/routes/leased-step.js +2 -1
  58. package/dist/presentation/routes/leased-step.js.map +1 -1
  59. package/dist/presentation/routes/list-runs.d.ts.map +1 -1
  60. package/dist/presentation/routes/list-runs.js +2 -1
  61. package/dist/presentation/routes/list-runs.js.map +1 -1
  62. package/dist/presentation/routes/project-access.d.ts +1 -0
  63. package/dist/presentation/routes/project-access.d.ts.map +1 -1
  64. package/dist/tsconfig.test.tsbuildinfo +1 -1
  65. package/drizzle/0004_dev_run_origin.sql +12 -0
  66. package/drizzle/meta/0004_snapshot.json +1773 -0
  67. package/drizzle/meta/_journal.json +7 -0
  68. package/package.json +10 -10
  69. package/src/core/checkout.test.ts +198 -1
  70. package/src/core/checkout.ts +18 -5
  71. package/src/core/entities/job.ts +1 -1
  72. package/src/core/entities/workflow-run.ts +22 -4
  73. package/src/core/errors.ts +17 -2
  74. package/src/core/job-execution.test.ts +54 -0
  75. package/src/core/job-execution.ts +6 -1
  76. package/src/core/job-transition/decide-job-activation.test.ts +2 -0
  77. package/src/core/run-workflow.test.ts +50 -1
  78. package/src/core/run-workflow.ts +1 -1
  79. package/src/core/step-config/agent.ts +8 -1
  80. package/src/core/step-config/complete-step-dispatch-config.test.ts +37 -0
  81. package/src/core/workflow-run-creation.test.ts +2 -0
  82. package/src/db/job-listeners.test.ts +17 -0
  83. package/src/db/schema/workflow-runs.ts +58 -0
  84. package/src/db/workflow-runs/jobs.ts +17 -3
  85. package/src/db/workflow-runs/queries.ts +6 -1
  86. package/src/db/workflow-runs/run-create.test.ts +39 -0
  87. package/src/presentation/dto/step.test.ts +8 -0
  88. package/src/presentation/dto/step.ts +9 -0
  89. package/src/presentation/dto/workflow-run.ts +23 -1
  90. package/src/presentation/routes/agent-runtime-config.test.ts +84 -2
  91. package/src/presentation/routes/agent-runtime-config.ts +24 -0
  92. package/src/presentation/routes/checkout-token.test.ts +45 -0
  93. package/src/presentation/routes/checkout-token.ts +2 -1
  94. package/src/presentation/routes/get-run-aggregates.test.ts +56 -0
  95. package/src/presentation/routes/get-run-aggregates.ts +2 -0
  96. package/src/presentation/routes/get-step-secrets.test.ts +1 -0
  97. package/src/presentation/routes/leased-step.ts +7 -1
  98. package/src/presentation/routes/list-runs.test.ts +152 -0
  99. package/src/presentation/routes/list-runs.ts +2 -0
  100. package/test/factories/workflow-run.ts +2 -0
  101. package/tsconfig.build.tsbuildinfo +1 -1
@@ -44,7 +44,7 @@ export async function runWorkflow(
44
44
  return createWorkflowRun({
45
45
  workspaceId: params.workspaceId,
46
46
  projectId: params.projectId,
47
- definitionId: definition.id,
47
+ definitionId: definition.workflowId,
48
48
  name: definition.name,
49
49
  model,
50
50
  triggerPayload: params.triggerPayload,
@@ -185,7 +185,14 @@ export async function completeAgentDefaults(params: {
185
185
  isInterModuleKnownError(agentInterModuleContract.methods.resolveAgentConfig, error) &&
186
186
  error.code === 'agent-config-invalid'
187
187
  ) {
188
- throw new AgentConfigUnresolvableError(params.definitionId, {cause: error});
188
+ const managedProviderId = error.details.managed_provider_id;
189
+ throw new AgentConfigUnresolvableError(params.definitionId, {
190
+ cause: error,
191
+ ...(error.details.message === undefined ? {} : {message: error.details.message}),
192
+ ...(managedProviderId === undefined
193
+ ? {}
194
+ : {code: 'workspace-providers-disabled', managedProviderId}),
195
+ });
189
196
  }
190
197
  throw error;
191
198
  }
@@ -453,6 +453,43 @@ describe('completeStepDispatchConfig', () => {
453
453
  await expect(act()).rejects.toThrow(AgentConfigUnresolvableError);
454
454
  });
455
455
 
456
+ it('preserves managed provider policy details in unresolvable agent config errors', async () => {
457
+ const pending = step({
458
+ type: 'agent',
459
+ config: {},
460
+ configPlan: {
461
+ agent: {
462
+ prompt: plannedField('Review the change.'),
463
+ },
464
+ },
465
+ });
466
+ const failingResolver: AgentDefaultsResolver = () => {
467
+ throw createInterModuleKnownError(
468
+ agentInterModuleContract.methods.resolveAgentConfig,
469
+ 'agent-config-invalid',
470
+ {
471
+ message: 'This instance only supports provider `shipfox`.',
472
+ managed_provider_id: 'shipfox',
473
+ },
474
+ );
475
+ };
476
+
477
+ const act = () =>
478
+ completeStepDispatchConfig({
479
+ step: pending,
480
+ context,
481
+ resolveAgentDefaults: failingResolver,
482
+ definitionId: 'def-1',
483
+ });
484
+
485
+ await expect(act()).rejects.toMatchObject({
486
+ name: 'AgentConfigUnresolvableError',
487
+ message: 'This instance only supports provider `shipfox`.',
488
+ code: 'workspace-providers-disabled',
489
+ managedProviderId: 'shipfox',
490
+ });
491
+ });
492
+
456
493
  it('throws when a server-side segment still survives dispatch', async () => {
457
494
  const pending = step({
458
495
  config: {},
@@ -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,45 @@ describe('workflow run queries', () => {
48
48
  });
49
49
 
50
50
  describe('createWorkflowRun', () => {
51
+ test('enforces the workflow run origin and dev source relationship at the database boundary', async () => {
52
+ const run = await createWorkflowRun({
53
+ workspaceId,
54
+ projectId,
55
+ definitionId,
56
+ model: buildModel(),
57
+ triggerPayload: {
58
+ source: 'manual',
59
+ event: 'fire',
60
+ subscriptionId: crypto.randomUUID(),
61
+ userId: crypto.randomUUID(),
62
+ },
63
+ });
64
+
65
+ await expect(
66
+ db().execute(
67
+ sql`UPDATE ${workflowRuns} SET origin = 'dev', dev_source = '{}'::jsonb WHERE ${workflowRuns.id} = ${run.id}`,
68
+ ),
69
+ ).resolves.toBeDefined();
70
+
71
+ await expect(
72
+ db().update(workflowRuns).set({origin: 'synced'}).where(eq(workflowRuns.id, run.id)),
73
+ ).rejects.toThrow();
74
+
75
+ await db()
76
+ .update(workflowRuns)
77
+ .set({
78
+ origin: 'dev',
79
+ devSource: {
80
+ ref: 'main',
81
+ commit: 'abc123',
82
+ config_path: '.shipfox/workflows.yml',
83
+ initiated_by_user_id: crypto.randomUUID(),
84
+ replay_of_event_id: null,
85
+ },
86
+ })
87
+ .where(eq(workflowRuns.id, run.id));
88
+ });
89
+
51
90
  test('persists normalized integration trigger facts alongside the payload', async () => {
52
91
  const triggerConnectionId = crypto.randomUUID();
53
92
  const integrations = {
@@ -43,12 +43,16 @@ describe('fromStepErrorDto', () => {
43
43
  it('persists the machine-readable agent config issue in camelCase', () => {
44
44
  const persisted = fromStepErrorDto({
45
45
  message: 'Missing credentials',
46
+ code: 'workspace-providers-disabled',
47
+ managed_provider_id: 'shipfox',
46
48
  reason: 'agent_config_invalid',
47
49
  agent_config_issue: 'provider_not_configured',
48
50
  });
49
51
 
50
52
  expect(persisted).toEqual({
51
53
  message: 'Missing credentials',
54
+ code: 'workspace-providers-disabled',
55
+ managedProviderId: 'shipfox',
52
56
  reason: 'agent_config_invalid',
53
57
  agentConfigIssue: 'provider_not_configured',
54
58
  });
@@ -180,6 +184,8 @@ describe('toStepDto error category', () => {
180
184
  type: 'agent',
181
185
  error: {
182
186
  message: 'Unknown provider "foo" for agent step.',
187
+ code: 'workspace-providers-disabled',
188
+ managedProviderId: 'shipfox',
183
189
  reason: 'agent_config_invalid',
184
190
  agentConfigIssue: 'provider_unsupported',
185
191
  },
@@ -188,6 +194,8 @@ describe('toStepDto error category', () => {
188
194
 
189
195
  expect(dto.error).toEqual({
190
196
  message: 'Unknown provider "foo" for agent step.',
197
+ code: 'workspace-providers-disabled',
198
+ managed_provider_id: 'shipfox',
191
199
  reason: 'agent_config_invalid',
192
200
  agent_config_issue: 'provider_unsupported',
193
201
  category: 'user',
@@ -22,6 +22,9 @@ function toStepErrorDto(
22
22
  ): StepErrorDto {
23
23
  if (error === null) return null;
24
24
  const message = typeof error.message === 'string' ? error.message : '';
25
+ const code = typeof error.code === 'string' ? error.code : undefined;
26
+ const managedProviderId =
27
+ typeof error.managedProviderId === 'string' ? error.managedProviderId : undefined;
25
28
  const exitCode = error.exitCode;
26
29
  const signal = typeof error.signal === 'string' ? error.signal : undefined;
27
30
  const field = typeof error.field === 'string' ? error.field : undefined;
@@ -30,6 +33,8 @@ function toStepErrorDto(
30
33
  const agentConfigIssue = agentConfigIssueSchema.safeParse(error.agentConfigIssue);
31
34
  return {
32
35
  message,
36
+ ...(code === undefined ? {} : {code}),
37
+ ...(managedProviderId === undefined ? {} : {managed_provider_id: managedProviderId}),
33
38
  ...(exitCode === null || typeof exitCode === 'number' ? {exit_code: exitCode} : {}),
34
39
  ...(signal === undefined ? {} : {signal}),
35
40
  ...(reason.success ? {reason: reason.data} : {}),
@@ -48,6 +53,10 @@ export function fromStepErrorDto(error: StepErrorDto | undefined): Record<string
48
53
  if (!error) return null;
49
54
  return {
50
55
  message: error.message,
56
+ ...(error.code === undefined ? {} : {code: error.code}),
57
+ ...(error.managed_provider_id === undefined
58
+ ? {}
59
+ : {managedProviderId: error.managed_provider_id}),
51
60
  ...(error.exit_code === null || typeof error.exit_code === 'number'
52
61
  ? {exitCode: error.exit_code}
53
62
  : {}),
@@ -1,10 +1,15 @@
1
1
  import type {
2
2
  WorkflowRunAttemptDto,
3
+ WorkflowRunDevSourceDto,
3
4
  WorkflowRunDto,
4
5
  WorkflowRunListItemDto,
5
6
  WorkflowRunTriggerReferenceDto,
6
7
  } from '@shipfox/api-workflows-dto';
7
- import type {WorkflowRun, WorkflowRunTriggerReference} from '#core/entities/workflow-run.js';
8
+ import type {
9
+ WorkflowRun,
10
+ WorkflowRunDevSource,
11
+ WorkflowRunTriggerReference,
12
+ } from '#core/entities/workflow-run.js';
8
13
  import type {WorkflowRunAttempt} from '#core/entities/workflow-run-attempt.js';
9
14
  import type {WorkflowRunJobsSummary} from '#db/index.js';
10
15
 
@@ -17,6 +22,8 @@ export function toRunDto(run: WorkflowRun, latestAttempt = run.currentAttempt):
17
22
  name: run.name,
18
23
  workflow_name: run.workflowName,
19
24
  status: run.status,
25
+ origin: run.origin,
26
+ dev_source: toDevSourceDto(run.devSource),
20
27
  current_attempt: run.currentAttempt,
21
28
  latest_attempt: latestAttempt,
22
29
  trigger_provider: run.triggerProvider,
@@ -76,6 +83,21 @@ function toTriggerReferenceDto(
76
83
  };
77
84
  }
78
85
 
86
+ // The persisted dev source uses camelCase field names while the API contract is snake_case,
87
+ // so each field is mapped explicitly rather than spread onto the response.
88
+ function toDevSourceDto(
89
+ devSource: WorkflowRunDevSource | null | undefined,
90
+ ): WorkflowRunDevSourceDto | null {
91
+ if (!devSource) return null;
92
+ return {
93
+ ref: devSource.ref,
94
+ commit: devSource.commit,
95
+ config_path: devSource.configPath,
96
+ initiated_by_user_id: devSource.initiatedByUserId,
97
+ replay_of_event_id: devSource.replayOfEventId,
98
+ };
99
+ }
100
+
79
101
  export function toRunAttemptDto(attempt: WorkflowRunAttempt): WorkflowRunAttemptDto {
80
102
  return {
81
103
  id: attempt.id,
@@ -8,10 +8,15 @@ import {closeApp, createApp, type FastifyInstance} from '@shipfox/node-fastify';
8
8
  import {createCapturingLogger} from '@shipfox/node-log/test';
9
9
  import {eq} from 'drizzle-orm';
10
10
  import type {StepStatus} from '#core/entities/step.js';
11
- import {db} from '#db/db.js';
11
+ import {db, withTransaction} from '#db/db.js';
12
12
  import {jobs} from '#db/schema/jobs.js';
13
13
  import {steps as stepsTable} from '#db/schema/steps.js';
14
- import {createWorkflowRun, getJobsByWorkflowRunId, getStepsByJobId} from '#db/workflow-runs.js';
14
+ import {
15
+ createWorkflowRun,
16
+ getJobsByWorkflowRunId,
17
+ getStepsByJobId,
18
+ insertRunningStepAttempt,
19
+ } from '#db/workflow-runs.js';
15
20
  import {workflowModel} from '#test/factories/workflow-model.js';
16
21
  import {insertRunningJobLease, mintActiveLeaseToken} from '#test/fixtures/active-lease-token.js';
17
22
  import {resolveTestAgentDefaults} from '#test/fixtures/agent-inter-module.js';
@@ -115,6 +120,13 @@ describe('GET /runs/jobs/current/agent-runtime-config', () => {
115
120
  thinking: 'xhigh',
116
121
  credentials: {api_key: 'sk-workspace-secret'},
117
122
  });
123
+ expect(resolveRuntimeCredentials).toHaveBeenCalledWith(
124
+ expect.objectContaining({
125
+ workspaceId: run.workspaceId,
126
+ runId: run.id,
127
+ stepAttemptId: expect.any(String),
128
+ }),
129
+ );
118
130
  });
119
131
 
120
132
  test('derives the credential workspace from the leased job instead of hostile lease claims', async () => {
@@ -288,6 +300,63 @@ describe('GET /runs/jobs/current/agent-runtime-config', () => {
288
300
  expect(res.json().code).toBe('model-provider-not-configured');
289
301
  });
290
302
 
303
+ test('returns managed provider policy details when workspace providers are disabled', async () => {
304
+ const {job, step} = await createRunningAgentStep();
305
+ resolveRuntimeCredentials.mockRejectedValueOnce(
306
+ createInterModuleKnownError(
307
+ agentInterModuleContract.methods.resolveRuntimeCredentials,
308
+ 'workspace-providers-disabled',
309
+ {
310
+ message: 'This instance only supports provider `shipfox`.',
311
+ managed_provider_id: 'shipfox',
312
+ },
313
+ ),
314
+ );
315
+ const token = await mintActiveLeaseToken({jobId: job.id});
316
+
317
+ const res = await app.inject({
318
+ method: 'GET',
319
+ url: runtimeConfigUrl(step.id, step.currentAttempt),
320
+ headers: {authorization: `Bearer ${token}`},
321
+ });
322
+
323
+ expect(res.statusCode).toBe(422);
324
+ expect(res.json()).toMatchObject({
325
+ code: 'workspace-providers-disabled',
326
+ details: {
327
+ message: 'This instance only supports provider `shipfox`.',
328
+ managed_provider_id: 'shipfox',
329
+ },
330
+ });
331
+ });
332
+
333
+ test('includes a fallback message when managed provider details omit one', async () => {
334
+ const {job, step} = await createRunningAgentStep();
335
+ resolveRuntimeCredentials.mockRejectedValueOnce(
336
+ createInterModuleKnownError(
337
+ agentInterModuleContract.methods.resolveRuntimeCredentials,
338
+ 'workspace-providers-disabled',
339
+ {managed_provider_id: 'shipfox'},
340
+ ),
341
+ );
342
+ const token = await mintActiveLeaseToken({jobId: job.id});
343
+
344
+ const res = await app.inject({
345
+ method: 'GET',
346
+ url: runtimeConfigUrl(step.id, step.currentAttempt),
347
+ headers: {authorization: `Bearer ${token}`},
348
+ });
349
+
350
+ expect(res.statusCode).toBe(422);
351
+ expect(res.json()).toMatchObject({
352
+ code: 'workspace-providers-disabled',
353
+ details: {
354
+ message: 'Workspace provider configuration is disabled',
355
+ managed_provider_id: 'shipfox',
356
+ },
357
+ });
358
+ });
359
+
291
360
  test('returns 409 and reports when workspace credentials cannot be decrypted', async () => {
292
361
  const {job, step} = await createRunningAgentStep();
293
362
  resolveRuntimeCredentials.mockRejectedValueOnce(
@@ -420,6 +489,19 @@ async function createStep(params: {
420
489
  const step = stepRows.find((candidate) => candidate.type === params.targetType);
421
490
  if (!step) throw new Error(`createStep: ${params.targetType} step not found`);
422
491
  await db().update(stepsTable).set({status: params.status}).where(eq(stepsTable.id, step.id));
492
+ if (params.status === 'running') {
493
+ await withTransaction((tx) =>
494
+ insertRunningStepAttempt(
495
+ {
496
+ jobExecutionId: step.jobExecutionId,
497
+ stepId: step.id,
498
+ attempt: step.currentAttempt,
499
+ config: step.config,
500
+ },
501
+ tx,
502
+ ),
503
+ );
504
+ }
423
505
 
424
506
  return {
425
507
  run,
@@ -13,6 +13,7 @@ import {isInterModuleKnownError} from '@shipfox/inter-module';
13
13
  import {captureException} from '@shipfox/node-error-monitoring';
14
14
  import {ClientError, defineRoute} from '@shipfox/node-fastify';
15
15
  import {ZodError} from 'zod';
16
+ import {getStepAttemptDetail} from '#db/index.js';
16
17
  import {loadRunningLeasedStep} from './leased-step.js';
17
18
 
18
19
  export function createAgentRuntimeConfigRoute(params: {
@@ -63,6 +64,22 @@ export function createAgentRuntimeConfigRoute(params: {
63
64
  },
64
65
  );
65
66
  }
67
+ if (
68
+ isInterModuleKnownError(
69
+ agentInterModuleContract.methods.resolveRuntimeCredentials,
70
+ error,
71
+ ) &&
72
+ error.code === 'workspace-providers-disabled'
73
+ ) {
74
+ const message = error.details.message ?? 'Workspace provider configuration is disabled';
75
+ throw new ClientError(message, 'workspace-providers-disabled', {
76
+ status: 422,
77
+ details: {
78
+ managed_provider_id: error.details.managed_provider_id,
79
+ message,
80
+ },
81
+ });
82
+ }
66
83
  throw error;
67
84
  },
68
85
  handler: async (request, reply) => {
@@ -91,8 +108,15 @@ export function createAgentRuntimeConfigRoute(params: {
91
108
  throw error;
92
109
  }
93
110
 
111
+ const stepAttempt = await getStepAttemptDetail({stepId, attempt});
112
+ if (!stepAttempt) {
113
+ throw new ClientError('Step attempt not found', 'step-attempt-not-found', {status: 409});
114
+ }
115
+
94
116
  const runtimeConfig = await params.agent.resolveRuntimeCredentials({
95
117
  workspaceId,
118
+ runId: stepAttempt.workflowRunId,
119
+ stepAttemptId: stepAttempt.attempt.id,
96
120
  harness: agentConfig.harness,
97
121
  provider: agentConfig.provider,
98
122
  model: agentConfig.model,