@shipfox/api-workflows 16.1.0 → 17.1.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": "16.1.0",
4
+ "version": "17.1.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": "15.0.0",
27
- "@shipfox/api-auth-dto": "15.0.0",
28
- "@shipfox/api-agent-dto": "16.1.0",
26
+ "@shipfox/api-auth-context": "17.0.0",
27
+ "@shipfox/api-auth-dto": "17.0.0",
28
+ "@shipfox/api-agent-dto": "17.1.0",
29
29
  "@shipfox/annotations-dto": "12.3.0",
30
- "@shipfox/api-definitions-dto": "16.1.0",
30
+ "@shipfox/api-definitions-dto": "17.0.0",
31
31
  "@shipfox/api-projects-dto": "15.0.0",
32
32
  "@shipfox/api-integration-core-dto": "16.0.0",
33
33
  "@shipfox/api-runners-dto": "16.0.0",
34
34
  "@shipfox/api-secrets-dto": "12.0.0",
35
- "@shipfox/api-workflows-dto": "16.1.0",
35
+ "@shipfox/api-workflows-dto": "17.1.0",
36
36
  "@shipfox/api-workspaces-dto": "15.0.0",
37
37
  "@shipfox/config": "1.2.4",
38
38
  "@shipfox/inter-module": "0.2.3",
39
- "@shipfox/expression": "2.4.0",
39
+ "@shipfox/expression": "2.4.1",
40
40
  "@shipfox/node-drizzle": "0.3.5",
41
41
  "@shipfox/node-error-monitoring": "0.3.0",
42
42
  "@shipfox/node-fastify": "0.4.3",
43
43
  "@shipfox/node-module": "1.0.7",
44
44
  "@shipfox/node-opentelemetry": "0.6.5",
45
45
  "@shipfox/node-outbox": "0.2.6",
46
- "@shipfox/node-postgres": "0.5.0",
46
+ "@shipfox/node-postgres": "0.5.1",
47
47
  "@shipfox/node-temporal": "0.4.6",
48
48
  "@shipfox/runner-labels": "0.2.0",
49
- "@shipfox/workflow-document": "3.3.0"
49
+ "@shipfox/workflow-document": "3.3.1"
50
50
  },
51
51
  "imports": {
52
52
  "#*": "./dist/*"
@@ -21,6 +21,7 @@ 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(),
26
27
  listStepAttemptIdsByJobId: vi.fn(),
@@ -49,6 +50,7 @@ describe('Workflows inter-module presentation', () => {
49
50
  beforeEach(() => {
50
51
  mocks.getJobScope.mockReset();
51
52
  mocks.listStepAttemptIdsByJobId.mockReset();
53
+ mocks.getStepAttemptDetail.mockReset();
52
54
  mocks.getStepById.mockReset();
53
55
  mocks.getStepByIdForJobExecution.mockReset();
54
56
  mocks.deliverEventToListener.mockReset();
@@ -162,6 +164,201 @@ describe('Workflows inter-module presentation', () => {
162
164
  });
163
165
  });
164
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
+
165
362
  test.each([
166
363
  ['definition-not-found', () => new DefinitionNotFoundError(input.definitionId)],
167
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,
@@ -34,6 +38,7 @@ import {resolveWorkflowRunTriggerReference} from '#core/resolve-trigger-referenc
34
38
  import {assertWorkspaceAdmitsNewJobs} from '#core/workspace-admission.js';
35
39
  import {
36
40
  getJobScope,
41
+ getStepAttemptDetail,
37
42
  getStepById,
38
43
  getStepByIdForJobExecution,
39
44
  listStepAttemptIdsByJobId,
@@ -53,6 +58,65 @@ export function createWorkflowsInterModulePresentation(params: {
53
58
  integrations: IntegrationsModuleClient;
54
59
  projects: ProjectsModuleClient;
55
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
+
56
120
  return defineInterModulePresentation(workflowsInterModuleContract, {
57
121
  startRunFromTrigger: async (input) => {
58
122
  try {
@@ -161,28 +225,7 @@ export function createWorkflowsInterModulePresentation(params: {
161
225
  },
162
226
  getLeasedAgentToolContext: async (input) => {
163
227
  const method = workflowsInterModuleContract.methods.getLeasedAgentToolContext;
164
- const {active: leaseIsActive} = await params.runners.getLeaseState({
165
- jobId: input.jobId,
166
- jobExecutionId: input.jobExecutionId,
167
- runnerSessionId: input.runnerSessionId,
168
- });
169
- if (!leaseIsActive) throw createInterModuleKnownError(method, 'lease-not-active', {});
170
-
171
- const step = await getStepByIdForJobExecution({
172
- stepId: input.stepId,
173
- jobExecutionId: input.jobExecutionId,
174
- });
175
- if (!step) throw createInterModuleKnownError(method, 'step-not-found', {});
176
-
177
- const scope = await getJobScope(input.jobId);
178
- if (!scope) throw createInterModuleKnownError(method, 'job-not-found', {});
179
- if (step.currentAttempt !== input.attempt) {
180
- throw createInterModuleKnownError(method, 'step-attempt-mismatch', {});
181
- }
182
- if (step.status !== 'running')
183
- throw createInterModuleKnownError(method, 'step-not-running', {});
184
- if (step.type !== 'agent')
185
- throw createInterModuleKnownError(method, 'leased-step-not-agent', {});
228
+ const {step, scope} = await resolveLeasedAgentStep({method, input});
186
229
 
187
230
  const config = materializedAgentStepConfigSchema.safeParse(step.config);
188
231
  if (!config.success) {
@@ -190,6 +233,37 @@ export function createWorkflowsInterModulePresentation(params: {
190
233
  }
191
234
  return {workspaceId: scope.workspaceId, integrations: config.data.integrations ?? []};
192
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
+ },
193
267
  });
194
268
  }
195
269