@shipfox/api-workflows 16.0.0 → 17.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 (62) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +47 -0
  3. package/dist/core/agent-tools.js +8 -3
  4. package/dist/core/agent-tools.js.map +1 -1
  5. package/dist/core/entities/step.d.ts +1 -1
  6. package/dist/core/entities/step.d.ts.map +1 -1
  7. package/dist/core/entities/step.js +2 -1
  8. package/dist/core/entities/step.js.map +1 -1
  9. package/dist/core/errors.d.ts +1 -1
  10. package/dist/core/errors.d.ts.map +1 -1
  11. package/dist/core/errors.js.map +1 -1
  12. package/dist/core/step-config/materialize-job-execution-steps.js +2 -0
  13. package/dist/core/step-config/materialize-job-execution-steps.js.map +1 -1
  14. package/dist/core/step-config/resolve-step-config.js +3 -1
  15. package/dist/core/step-config/resolve-step-config.js.map +1 -1
  16. package/dist/db/db.d.ts +8 -8
  17. package/dist/db/index.d.ts +1 -1
  18. package/dist/db/index.d.ts.map +1 -1
  19. package/dist/db/index.js +1 -1
  20. package/dist/db/index.js.map +1 -1
  21. package/dist/db/schema/steps.d.ts +2 -2
  22. package/dist/db/schema/workflow-run-attempts.d.ts +2 -2
  23. package/dist/db/workflow-runs/outbox.d.ts +1 -0
  24. package/dist/db/workflow-runs/outbox.d.ts.map +1 -1
  25. package/dist/db/workflow-runs/outbox.js +2 -1
  26. package/dist/db/workflow-runs/outbox.js.map +1 -1
  27. package/dist/db/workflow-runs/run-create.d.ts.map +1 -1
  28. package/dist/db/workflow-runs/run-create.js +49 -1
  29. package/dist/db/workflow-runs/run-create.js.map +1 -1
  30. package/dist/db/workflow-runs/steps.d.ts +6 -0
  31. package/dist/db/workflow-runs/steps.d.ts.map +1 -1
  32. package/dist/db/workflow-runs/steps.js +14 -0
  33. package/dist/db/workflow-runs/steps.js.map +1 -1
  34. package/dist/db/workflow-runs.d.ts +1 -1
  35. package/dist/db/workflow-runs.d.ts.map +1 -1
  36. package/dist/db/workflow-runs.js +1 -1
  37. package/dist/db/workflow-runs.js.map +1 -1
  38. package/dist/presentation/inter-module.d.ts.map +1 -1
  39. package/dist/presentation/inter-module.js +85 -19
  40. package/dist/presentation/inter-module.js.map +1 -1
  41. package/dist/tsconfig.test.tsbuildinfo +1 -1
  42. package/package.json +11 -11
  43. package/src/core/agent-tools.test.ts +85 -0
  44. package/src/core/agent-tools.ts +11 -3
  45. package/src/core/entities/step.ts +1 -1
  46. package/src/core/errors.ts +2 -0
  47. package/src/core/job-execution.test.ts +1 -0
  48. package/src/core/run-workflow.test.ts +4 -0
  49. package/src/core/step-config/materialize-job-execution-steps.ts +2 -0
  50. package/src/core/step-config/resolve-step-config.ts +4 -1
  51. package/src/db/index.ts +1 -0
  52. package/src/db/workflow-runs/outbox.ts +2 -0
  53. package/src/db/workflow-runs/run-create.test.ts +41 -0
  54. package/src/db/workflow-runs/run-create.ts +81 -3
  55. package/src/db/workflow-runs/steps.ts +19 -0
  56. package/src/db/workflow-runs.ts +1 -0
  57. package/src/presentation/inter-module.test.ts +223 -0
  58. package/src/presentation/inter-module.ts +106 -24
  59. package/src/presentation/routes/agent-runtime-config.test.ts +2 -0
  60. package/test/factories/workflow-model.ts +74 -2
  61. package/test/fixtures/agent-inter-module.ts +2 -0
  62. package/tsconfig.build.tsbuildinfo +1 -1
@@ -21,8 +21,10 @@ import {
21
21
  const mocks = vi.hoisted(() => ({
22
22
  deliverEventToListener: vi.fn(),
23
23
  getJobScope: vi.fn(),
24
+ getStepAttemptDetail: vi.fn(),
24
25
  getStepById: vi.fn(),
25
26
  getStepByIdForJobExecution: vi.fn(),
27
+ listStepAttemptIdsByJobId: vi.fn(),
26
28
  }));
27
29
 
28
30
  vi.mock('#db/index.js', () => mocks);
@@ -47,12 +49,38 @@ const input = {
47
49
  describe('Workflows inter-module presentation', () => {
48
50
  beforeEach(() => {
49
51
  mocks.getJobScope.mockReset();
52
+ mocks.listStepAttemptIdsByJobId.mockReset();
53
+ mocks.getStepAttemptDetail.mockReset();
50
54
  mocks.getStepById.mockReset();
51
55
  mocks.getStepByIdForJobExecution.mockReset();
52
56
  mocks.deliverEventToListener.mockReset();
53
57
  mocks.deliverEventToListener.mockResolvedValue({buffered: true, skipped: false});
54
58
  });
55
59
 
60
+ it('returns the step attempt ids of a job for the session release sweep', async () => {
61
+ const stepAttemptId = '00000000-0000-4000-8000-000000000010';
62
+ mocks.listStepAttemptIdsByJobId.mockResolvedValue([stepAttemptId]);
63
+ const presentation = createWorkflowsInterModulePresentation({
64
+ agent: {} as never,
65
+ definitions: {} as never,
66
+ integrations: {} as never,
67
+ projects: {} as never,
68
+ runners: {} as never,
69
+ secrets: {} as never,
70
+ workspaces: {getWorkspaceOperatingState: vi.fn()} as never,
71
+ });
72
+
73
+ const result = await presentation.handlers.listJobStepAttempts(
74
+ {jobId: '00000000-0000-4000-8000-000000000006'},
75
+ {signal: new AbortController().signal},
76
+ );
77
+
78
+ expect(mocks.listStepAttemptIdsByJobId).toHaveBeenCalledWith(
79
+ '00000000-0000-4000-8000-000000000006',
80
+ );
81
+ expect(result).toEqual({stepAttemptIds: [stepAttemptId]});
82
+ });
83
+
56
84
  it('returns only the resolved harness for Logs', async () => {
57
85
  mocks.getStepById.mockResolvedValue({config: {harness: 'claude'}});
58
86
  const presentation = createWorkflowsInterModulePresentation({
@@ -136,6 +164,201 @@ describe('Workflows inter-module presentation', () => {
136
164
  });
137
165
  });
138
166
 
167
+ describe('getLeasedAgentSessionContext', () => {
168
+ const input = {
169
+ jobId: '00000000-0000-4000-8000-000000000006',
170
+ jobExecutionId: '00000000-0000-4000-8000-000000000007',
171
+ runnerSessionId: '00000000-0000-4000-8000-000000000008',
172
+ stepId: '00000000-0000-4000-8000-000000000009',
173
+ attempt: 1,
174
+ };
175
+
176
+ const method = workflowsInterModuleContract.methods.getLeasedAgentSessionContext;
177
+
178
+ function presentation(runners: {getLeaseState: ReturnType<typeof vi.fn>}) {
179
+ return createWorkflowsInterModulePresentation({
180
+ agent: {} as never,
181
+ definitions: {} as never,
182
+ integrations: {} as never,
183
+ projects: {} as never,
184
+ runners: runners as never,
185
+ secrets: {} as never,
186
+ workspaces: {getWorkspaceOperatingState: vi.fn()} as never,
187
+ });
188
+ }
189
+
190
+ async function expectKnownError(call: Promise<unknown> | unknown, code: string): Promise<void> {
191
+ try {
192
+ await call;
193
+ throw new Error(`Expected contract error ${code}, but the call succeeded`);
194
+ } catch (error) {
195
+ expect(isInterModuleKnownError(method, error) && (error as {code: string}).code).toBe(code);
196
+ }
197
+ }
198
+
199
+ function arrangeRunningAgentStep() {
200
+ mocks.getStepByIdForJobExecution.mockResolvedValue({
201
+ currentAttempt: input.attempt,
202
+ status: 'running',
203
+ type: 'agent',
204
+ config: {},
205
+ });
206
+ mocks.getJobScope.mockResolvedValue({
207
+ workspaceId: '00000000-0000-4000-8000-000000000010',
208
+ projectId: '00000000-0000-4000-8000-000000000011',
209
+ });
210
+ }
211
+
212
+ it('returns the recorded session descriptor with the resolved scope', async () => {
213
+ const stepAttemptId = '00000000-0000-4000-8000-000000000012';
214
+ const sessionId = '00000000-0000-4000-8000-000000000013';
215
+ arrangeRunningAgentStep();
216
+ mocks.getStepAttemptDetail.mockResolvedValue({
217
+ workflowRunId: '00000000-0000-4000-8000-000000000014',
218
+ workflowRunAttemptId: '00000000-0000-4000-8000-000000000015',
219
+ step: {},
220
+ attempt: {
221
+ id: stepAttemptId,
222
+ config: {
223
+ session: {id: sessionId, key: 'main', mode: 'resume', segment: 3},
224
+ },
225
+ },
226
+ });
227
+ const runners = {getLeaseState: vi.fn().mockResolvedValue({active: true})};
228
+
229
+ const result = await presentation(runners).handlers.getLeasedAgentSessionContext(input, {
230
+ signal: new AbortController().signal,
231
+ });
232
+
233
+ expect(result).toEqual({
234
+ workspaceId: '00000000-0000-4000-8000-000000000010',
235
+ projectId: '00000000-0000-4000-8000-000000000011',
236
+ workflowRunAttemptId: '00000000-0000-4000-8000-000000000015',
237
+ stepAttemptId,
238
+ session: {id: sessionId, key: 'main', mode: 'resume', segment: 3},
239
+ });
240
+ expect(mocks.getStepAttemptDetail).toHaveBeenCalledWith({
241
+ stepId: input.stepId,
242
+ attempt: input.attempt,
243
+ });
244
+ });
245
+
246
+ it('returns a null session when the step has no recorded descriptor', async () => {
247
+ arrangeRunningAgentStep();
248
+ mocks.getStepAttemptDetail.mockResolvedValue({
249
+ workflowRunId: '00000000-0000-4000-8000-000000000014',
250
+ workflowRunAttemptId: '00000000-0000-4000-8000-000000000015',
251
+ step: {},
252
+ attempt: {id: '00000000-0000-4000-8000-000000000012', config: null},
253
+ });
254
+ const runners = {getLeaseState: vi.fn().mockResolvedValue({active: true})};
255
+
256
+ const result = await presentation(runners).handlers.getLeasedAgentSessionContext(input, {
257
+ signal: new AbortController().signal,
258
+ });
259
+
260
+ expect(result.session).toBeNull();
261
+ });
262
+
263
+ it('fails fast when the lease is not active', async () => {
264
+ arrangeRunningAgentStep();
265
+ const runners = {getLeaseState: vi.fn().mockResolvedValue({active: false})};
266
+
267
+ await expectKnownError(
268
+ presentation(runners).handlers.getLeasedAgentSessionContext(input, {
269
+ signal: new AbortController().signal,
270
+ }),
271
+ 'lease-not-active',
272
+ );
273
+ expect(mocks.getStepAttemptDetail).not.toHaveBeenCalled();
274
+ });
275
+
276
+ it('rejects a malformed recorded descriptor', async () => {
277
+ arrangeRunningAgentStep();
278
+ mocks.getStepAttemptDetail.mockResolvedValue({
279
+ workflowRunId: '00000000-0000-4000-8000-000000000014',
280
+ workflowRunAttemptId: '00000000-0000-4000-8000-000000000015',
281
+ step: {},
282
+ attempt: {
283
+ id: '00000000-0000-4000-8000-000000000012',
284
+ config: {session: {id: 'not-a-uuid', key: 'main', mode: 'resume', segment: 1}},
285
+ },
286
+ });
287
+ const runners = {getLeaseState: vi.fn().mockResolvedValue({active: true})};
288
+
289
+ await expectKnownError(
290
+ presentation(runners).handlers.getLeasedAgentSessionContext(input, {
291
+ signal: new AbortController().signal,
292
+ }),
293
+ 'step-session-config-invalid',
294
+ );
295
+ });
296
+
297
+ test.each([
298
+ ['step-not-found', undefined],
299
+ ['step-attempt-mismatch', {currentAttempt: 2, status: 'running', type: 'agent'}],
300
+ ['step-not-running', {currentAttempt: 1, status: 'succeeded', type: 'agent'}],
301
+ ['leased-step-not-agent', {currentAttempt: 1, status: 'running', type: 'run'}],
302
+ ] as const)('maps a %s step to the published contract error', async (code, step) => {
303
+ const runners = {getLeaseState: vi.fn().mockResolvedValue({active: true})};
304
+ mocks.getStepByIdForJobExecution.mockResolvedValue(step);
305
+ if (step !== undefined) {
306
+ mocks.getJobScope.mockResolvedValue({
307
+ workspaceId: '00000000-0000-4000-8000-000000000010',
308
+ projectId: '00000000-0000-4000-8000-000000000011',
309
+ });
310
+ }
311
+
312
+ await expectKnownError(
313
+ presentation(runners).handlers.getLeasedAgentSessionContext(input, {
314
+ signal: new AbortController().signal,
315
+ }),
316
+ code,
317
+ );
318
+ });
319
+
320
+ it('maps a missing job scope to job-not-found', async () => {
321
+ mocks.getStepByIdForJobExecution.mockResolvedValue({
322
+ currentAttempt: input.attempt,
323
+ status: 'running',
324
+ type: 'agent',
325
+ config: {},
326
+ });
327
+ mocks.getJobScope.mockResolvedValue(undefined);
328
+ const runners = {getLeaseState: vi.fn().mockResolvedValue({active: true})};
329
+
330
+ await expectKnownError(
331
+ presentation(runners).handlers.getLeasedAgentSessionContext(input, {
332
+ signal: new AbortController().signal,
333
+ }),
334
+ 'job-not-found',
335
+ );
336
+ expect(mocks.getStepAttemptDetail).not.toHaveBeenCalled();
337
+ });
338
+
339
+ it('maps a missing step attempt detail to step-attempt-mismatch', async () => {
340
+ mocks.getStepByIdForJobExecution.mockResolvedValue({
341
+ currentAttempt: input.attempt,
342
+ status: 'running',
343
+ type: 'agent',
344
+ config: {},
345
+ });
346
+ mocks.getJobScope.mockResolvedValue({
347
+ workspaceId: '00000000-0000-4000-8000-000000000010',
348
+ projectId: '00000000-0000-4000-8000-000000000011',
349
+ });
350
+ mocks.getStepAttemptDetail.mockResolvedValue(undefined);
351
+ const runners = {getLeaseState: vi.fn().mockResolvedValue({active: true})};
352
+
353
+ await expectKnownError(
354
+ presentation(runners).handlers.getLeasedAgentSessionContext(input, {
355
+ signal: new AbortController().signal,
356
+ }),
357
+ 'step-attempt-mismatch',
358
+ );
359
+ });
360
+ });
361
+
139
362
  test.each([
140
363
  ['definition-not-found', () => new DefinitionNotFoundError(input.definitionId)],
141
364
  ['project-mismatch', () => new ProjectMismatchError(input.projectId, input.definitionId)],
@@ -1,4 +1,7 @@
1
- import {materializedAgentStepConfigSchema} from '@shipfox/api-agent-dto';
1
+ import {
2
+ agentSessionDescriptorSchema,
3
+ materializedAgentStepConfigSchema,
4
+ } from '@shipfox/api-agent-dto';
2
5
  import type {AgentInterModuleClient} from '@shipfox/api-agent-dto/inter-module';
3
6
  import type {DefinitionsInterModuleClient} from '@shipfox/api-definitions-dto/inter-module';
4
7
  import type {IntegrationsModuleClient} from '@shipfox/api-integration-core-dto/inter-module';
@@ -14,6 +17,7 @@ import {
14
17
  type InterModulePresentation,
15
18
  } from '@shipfox/inter-module';
16
19
  import {DEFAULT_HARNESS, harnessSchema} from '@shipfox/workflow-document';
20
+ import type {Step} from '#core/entities/step.js';
17
21
  import type {WorkflowRunTriggerReference} from '#core/entities/workflow-run.js';
18
22
  import {
19
23
  InvalidJobRunnerLabelsError,
@@ -32,7 +36,13 @@ import {
32
36
  } from '#core/index.js';
33
37
  import {resolveWorkflowRunTriggerReference} from '#core/resolve-trigger-reference.js';
34
38
  import {assertWorkspaceAdmitsNewJobs} from '#core/workspace-admission.js';
35
- import {getJobScope, getStepById, getStepByIdForJobExecution} from '#db/index.js';
39
+ import {
40
+ getJobScope,
41
+ getStepAttemptDetail,
42
+ getStepById,
43
+ getStepByIdForJobExecution,
44
+ listStepAttemptIdsByJobId,
45
+ } from '#db/index.js';
36
46
  import {deliverEventToListener} from '#db/job-listener-events.js';
37
47
 
38
48
  type WorkspaceAdmissionKnownError = InterModuleKnownErrorFor<
@@ -48,6 +58,65 @@ export function createWorkflowsInterModulePresentation(params: {
48
58
  integrations: IntegrationsModuleClient;
49
59
  projects: ProjectsModuleClient;
50
60
  }): InterModulePresentation<typeof workflowsInterModuleContract> {
61
+ /**
62
+ * Shared lease + running-agent-step resolution for the lease-authed
63
+ * inter-module methods (`getLeasedAgentToolContext` and
64
+ * `getLeasedAgentSessionContext`): verifies the runner lease, resolves the
65
+ * step for the job execution, then validates the job scope, attempt, status,
66
+ * and agent type in one canonical order. The two seams previously
67
+ * copy-pasted this chain with diverging check orders (the tool method
68
+ * resolved the job scope before the attempt/status checks, the session
69
+ * method after), so the same underlying state surfaced different errors
70
+ * depending on the method; a single helper keeps error codes and check
71
+ * ordering from drifting.
72
+ *
73
+ * `loadRunningLeasedStep` (routes) deliberately keeps its own copy: it
74
+ * returns the run scope (trigger reference/origin state), skips the
75
+ * agent-type check, and speaks HTTP `ClientError`s instead of contract
76
+ * known errors.
77
+ */
78
+ async function resolveLeasedAgentStep(resolution: {
79
+ method:
80
+ | typeof workflowsInterModuleContract.methods.getLeasedAgentToolContext
81
+ | typeof workflowsInterModuleContract.methods.getLeasedAgentSessionContext;
82
+ input: {
83
+ jobId: string;
84
+ jobExecutionId: string;
85
+ runnerSessionId: string;
86
+ stepId: string;
87
+ attempt: number;
88
+ };
89
+ }): Promise<{
90
+ step: Step;
91
+ scope: NonNullable<Awaited<ReturnType<typeof getJobScope>>>;
92
+ }> {
93
+ const {active: leaseIsActive} = await params.runners.getLeaseState({
94
+ jobId: resolution.input.jobId,
95
+ jobExecutionId: resolution.input.jobExecutionId,
96
+ runnerSessionId: resolution.input.runnerSessionId,
97
+ });
98
+ if (!leaseIsActive)
99
+ throw createInterModuleKnownError(resolution.method, 'lease-not-active', {});
100
+
101
+ const step = await getStepByIdForJobExecution({
102
+ stepId: resolution.input.stepId,
103
+ jobExecutionId: resolution.input.jobExecutionId,
104
+ });
105
+ if (!step) throw createInterModuleKnownError(resolution.method, 'step-not-found', {});
106
+
107
+ const scope = await getJobScope(resolution.input.jobId);
108
+ if (!scope) throw createInterModuleKnownError(resolution.method, 'job-not-found', {});
109
+ if (step.currentAttempt !== resolution.input.attempt) {
110
+ throw createInterModuleKnownError(resolution.method, 'step-attempt-mismatch', {});
111
+ }
112
+ if (step.status !== 'running')
113
+ throw createInterModuleKnownError(resolution.method, 'step-not-running', {});
114
+ if (step.type !== 'agent')
115
+ throw createInterModuleKnownError(resolution.method, 'leased-step-not-agent', {});
116
+
117
+ return {step, scope};
118
+ }
119
+
51
120
  return defineInterModulePresentation(workflowsInterModuleContract, {
52
121
  startRunFromTrigger: async (input) => {
53
122
  try {
@@ -151,30 +220,12 @@ export function createWorkflowsInterModulePresentation(params: {
151
220
  const parsed = harnessSchema.safeParse(step?.config.harness);
152
221
  return {harness: parsed.success ? parsed.data : DEFAULT_HARNESS};
153
222
  },
223
+ listJobStepAttempts: async ({jobId}) => {
224
+ return {stepAttemptIds: await listStepAttemptIdsByJobId(jobId)};
225
+ },
154
226
  getLeasedAgentToolContext: async (input) => {
155
227
  const method = workflowsInterModuleContract.methods.getLeasedAgentToolContext;
156
- const {active: leaseIsActive} = await params.runners.getLeaseState({
157
- jobId: input.jobId,
158
- jobExecutionId: input.jobExecutionId,
159
- runnerSessionId: input.runnerSessionId,
160
- });
161
- if (!leaseIsActive) throw createInterModuleKnownError(method, 'lease-not-active', {});
162
-
163
- const step = await getStepByIdForJobExecution({
164
- stepId: input.stepId,
165
- jobExecutionId: input.jobExecutionId,
166
- });
167
- if (!step) throw createInterModuleKnownError(method, 'step-not-found', {});
168
-
169
- const scope = await getJobScope(input.jobId);
170
- if (!scope) throw createInterModuleKnownError(method, 'job-not-found', {});
171
- if (step.currentAttempt !== input.attempt) {
172
- throw createInterModuleKnownError(method, 'step-attempt-mismatch', {});
173
- }
174
- if (step.status !== 'running')
175
- throw createInterModuleKnownError(method, 'step-not-running', {});
176
- if (step.type !== 'agent')
177
- throw createInterModuleKnownError(method, 'leased-step-not-agent', {});
228
+ const {step, scope} = await resolveLeasedAgentStep({method, input});
178
229
 
179
230
  const config = materializedAgentStepConfigSchema.safeParse(step.config);
180
231
  if (!config.success) {
@@ -182,6 +233,37 @@ export function createWorkflowsInterModulePresentation(params: {
182
233
  }
183
234
  return {workspaceId: scope.workspaceId, integrations: config.data.integrations ?? []};
184
235
  },
236
+ getLeasedAgentSessionContext: async (input) => {
237
+ const method = workflowsInterModuleContract.methods.getLeasedAgentSessionContext;
238
+ const {scope} = await resolveLeasedAgentStep({method, input});
239
+
240
+ const detail = await getStepAttemptDetail({stepId: input.stepId, attempt: input.attempt});
241
+ if (!detail) throw createInterModuleKnownError(method, 'step-attempt-mismatch', {});
242
+
243
+ // The dispatch integration records the resolved descriptor on the step
244
+ // attempt's config; anything else is a recording we cannot trust.
245
+ const recorded = detail.attempt.config?.session;
246
+ if (recorded === undefined || recorded === null) {
247
+ return {
248
+ workspaceId: scope.workspaceId,
249
+ projectId: scope.projectId,
250
+ workflowRunAttemptId: detail.workflowRunAttemptId,
251
+ stepAttemptId: detail.attempt.id,
252
+ session: null,
253
+ };
254
+ }
255
+ const parsed = agentSessionDescriptorSchema.safeParse(recorded);
256
+ if (!parsed.success) {
257
+ throw createInterModuleKnownError(method, 'step-session-config-invalid', {});
258
+ }
259
+ return {
260
+ workspaceId: scope.workspaceId,
261
+ projectId: scope.projectId,
262
+ workflowRunAttemptId: detail.workflowRunAttemptId,
263
+ stepAttemptId: detail.attempt.id,
264
+ session: parsed.data,
265
+ };
266
+ },
185
267
  });
186
268
  }
187
269
 
@@ -54,6 +54,8 @@ const agentTestClient: AgentInterModuleClient = {
54
54
  getValidationCatalog: vi.fn(),
55
55
  resolveAgentConfig: vi.fn(),
56
56
  resolveRuntimeCredentials,
57
+ claimSession: vi.fn(),
58
+ carryOverSessions: vi.fn(),
57
59
  };
58
60
 
59
61
  describe('GET /runs/jobs/current/agent-runtime-config', () => {
@@ -1,4 +1,9 @@
1
- import {DEFAULT_JOB_CHECKOUT, type WorkflowModel} from '@shipfox/api-definitions-dto';
1
+ import {
2
+ DEFAULT_JOB_CHECKOUT,
3
+ type WorkflowJsonTemplateTree,
4
+ type WorkflowJsonValue,
5
+ type WorkflowModel,
6
+ } from '@shipfox/api-definitions-dto';
2
7
  import {
3
8
  createWorkflowExpression,
4
9
  parseWorkflowTemplate,
@@ -41,7 +46,14 @@ interface TestCheckoutStep extends TestWorkflowStepBase {
41
46
  readonly checkout: Checkout;
42
47
  }
43
48
 
44
- type TestWorkflowStep = TestRunStep | TestAgentStep | TestCheckoutStep;
49
+ interface TestToolStep extends TestWorkflowStepBase {
50
+ readonly tool: string;
51
+ readonly connection?: string | undefined;
52
+ readonly with?: WorkflowJsonValue | undefined;
53
+ readonly outputs?: Readonly<Record<string, string>> | undefined;
54
+ }
55
+
56
+ type TestWorkflowStep = TestRunStep | TestAgentStep | TestCheckoutStep | TestToolStep;
45
57
 
46
58
  const DEFAULT_RUNNER_LABELS = ['ubuntu-latest'] as const;
47
59
 
@@ -171,6 +183,18 @@ function normalizeStep(step: TestWorkflowStep, jobId: string, stepIndex: number)
171
183
  };
172
184
  }
173
185
 
186
+ if ('tool' in step) {
187
+ return {
188
+ ...base,
189
+ kind: 'tool',
190
+ tool: splitToolId(step.tool),
191
+ ...(step.connection === undefined ? {} : {connection: step.connection}),
192
+ ...(step.with === undefined ? {} : {with: step.with}),
193
+ ...(step.outputs === undefined ? {} : {outputMappings: outputMappings(step.outputs)}),
194
+ ...optionalToolTemplates(step),
195
+ };
196
+ }
197
+
174
198
  return {
175
199
  ...base,
176
200
  kind: 'checkout',
@@ -274,6 +298,54 @@ function optionalAgentTemplates(step: TestAgentStep) {
274
298
  };
275
299
  }
276
300
 
301
+ function optionalToolTemplates(step: TestToolStep) {
302
+ const withTree = step.with === undefined ? undefined : withTemplateTree(step.with);
303
+ const name = step.name === undefined ? undefined : fieldTemplate('step.name', step.name);
304
+ if (withTree === undefined && name === undefined) return {};
305
+ return {
306
+ templates: {
307
+ ...(withTree === undefined ? {} : {with: withTree}),
308
+ ...(name === undefined ? {} : {name}),
309
+ },
310
+ };
311
+ }
312
+
313
+ function withTemplateTree(value: WorkflowJsonValue): WorkflowJsonTemplateTree | undefined {
314
+ if (Array.isArray(value)) {
315
+ const trees = value.map((child) => withTemplateTree(child));
316
+ return trees.every((tree) => tree === undefined) ? undefined : trees;
317
+ }
318
+ if (typeof value === 'object' && value !== null) {
319
+ const entries = Object.entries(value).flatMap(([key, child]) => {
320
+ const tree = withTemplateTree(child);
321
+ return tree === undefined ? [] : [[key, tree] as const];
322
+ });
323
+ return entries.length === 0 ? undefined : Object.fromEntries(entries);
324
+ }
325
+ if (typeof value !== 'string') return undefined;
326
+ return fieldTemplate('tool.with', value);
327
+ }
328
+
329
+ function outputMappings(outputs: Readonly<Record<string, string>>) {
330
+ return Object.fromEntries(
331
+ Object.entries(outputs).map(([key, source]) => {
332
+ const template = fieldTemplate('tool.outputs', source);
333
+ if (template === undefined || template.length !== 1 || template[0]?.kind !== 'deferred') {
334
+ throw new Error(
335
+ `Expected test tool output mapping to be exactly one expression for ${key}: ${source}`,
336
+ );
337
+ }
338
+ return [key, template[0].expression];
339
+ }),
340
+ );
341
+ }
342
+
343
+ function splitToolId(source: string): {readonly id: string; readonly method?: string} {
344
+ const dotIndex = source.indexOf('.');
345
+ if (dotIndex < 1 || dotIndex === source.length - 1) return {id: source};
346
+ return {id: source.slice(0, dotIndex), method: source.slice(dotIndex + 1)};
347
+ }
348
+
277
349
  function envTemplates(env: WorkflowModel['env'] | undefined): WorkflowEnvTemplates | undefined {
278
350
  if (env === undefined) return undefined;
279
351
 
@@ -46,6 +46,8 @@ export const agentTestClient: AgentInterModuleClient = {
46
46
  credentials: {api_key: 'test-agent-credential'},
47
47
  });
48
48
  },
49
+ claimSession: vi.fn(),
50
+ carryOverSessions: vi.fn(),
49
51
  };
50
52
 
51
53
  export const resolveTestAgentDefaults: AgentDefaultsResolver = (config) => {