@shipfox/api-workflows 16.0.0 → 16.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.
Files changed (63) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/.turbo/turbo-type.log +1 -0
  3. package/CHANGELOG.md +20 -0
  4. package/dist/core/agent-tools.js +8 -3
  5. package/dist/core/agent-tools.js.map +1 -1
  6. package/dist/core/entities/step.d.ts +1 -1
  7. package/dist/core/entities/step.d.ts.map +1 -1
  8. package/dist/core/entities/step.js +2 -1
  9. package/dist/core/entities/step.js.map +1 -1
  10. package/dist/core/errors.d.ts +1 -1
  11. package/dist/core/errors.d.ts.map +1 -1
  12. package/dist/core/errors.js.map +1 -1
  13. package/dist/core/step-config/materialize-job-execution-steps.js +2 -0
  14. package/dist/core/step-config/materialize-job-execution-steps.js.map +1 -1
  15. package/dist/core/step-config/resolve-step-config.js +3 -1
  16. package/dist/core/step-config/resolve-step-config.js.map +1 -1
  17. package/dist/db/db.d.ts +8 -8
  18. package/dist/db/index.d.ts +1 -1
  19. package/dist/db/index.d.ts.map +1 -1
  20. package/dist/db/index.js +1 -1
  21. package/dist/db/index.js.map +1 -1
  22. package/dist/db/schema/steps.d.ts +2 -2
  23. package/dist/db/schema/workflow-run-attempts.d.ts +2 -2
  24. package/dist/db/workflow-runs/outbox.d.ts +1 -0
  25. package/dist/db/workflow-runs/outbox.d.ts.map +1 -1
  26. package/dist/db/workflow-runs/outbox.js +2 -1
  27. package/dist/db/workflow-runs/outbox.js.map +1 -1
  28. package/dist/db/workflow-runs/run-create.d.ts.map +1 -1
  29. package/dist/db/workflow-runs/run-create.js +49 -1
  30. package/dist/db/workflow-runs/run-create.js.map +1 -1
  31. package/dist/db/workflow-runs/steps.d.ts +6 -0
  32. package/dist/db/workflow-runs/steps.d.ts.map +1 -1
  33. package/dist/db/workflow-runs/steps.js +14 -0
  34. package/dist/db/workflow-runs/steps.js.map +1 -1
  35. package/dist/db/workflow-runs.d.ts +1 -1
  36. package/dist/db/workflow-runs.d.ts.map +1 -1
  37. package/dist/db/workflow-runs.js +1 -1
  38. package/dist/db/workflow-runs.js.map +1 -1
  39. package/dist/presentation/inter-module.d.ts.map +1 -1
  40. package/dist/presentation/inter-module.js +6 -1
  41. package/dist/presentation/inter-module.js.map +1 -1
  42. package/dist/tsconfig.test.tsbuildinfo +1 -1
  43. package/package.json +4 -4
  44. package/src/core/agent-tools.test.ts +85 -0
  45. package/src/core/agent-tools.ts +11 -3
  46. package/src/core/entities/step.ts +1 -1
  47. package/src/core/errors.ts +2 -0
  48. package/src/core/job-execution.test.ts +1 -0
  49. package/src/core/run-workflow.test.ts +4 -0
  50. package/src/core/step-config/materialize-job-execution-steps.ts +2 -0
  51. package/src/core/step-config/resolve-step-config.ts +4 -1
  52. package/src/db/index.ts +1 -0
  53. package/src/db/workflow-runs/outbox.ts +2 -0
  54. package/src/db/workflow-runs/run-create.test.ts +41 -0
  55. package/src/db/workflow-runs/run-create.ts +81 -3
  56. package/src/db/workflow-runs/steps.ts +19 -0
  57. package/src/db/workflow-runs.ts +1 -0
  58. package/src/presentation/inter-module.test.ts +26 -0
  59. package/src/presentation/inter-module.ts +9 -1
  60. package/src/presentation/routes/agent-runtime-config.test.ts +2 -0
  61. package/test/factories/workflow-model.ts +74 -2
  62. package/test/fixtures/agent-inter-module.ts +2 -0
  63. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-workflows",
3
3
  "license": "MIT",
4
- "version": "16.0.0",
4
+ "version": "16.1.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -25,14 +25,14 @@
25
25
  "zod": "^4.4.3",
26
26
  "@shipfox/api-auth-context": "15.0.0",
27
27
  "@shipfox/api-auth-dto": "15.0.0",
28
- "@shipfox/api-agent-dto": "16.0.0",
28
+ "@shipfox/api-agent-dto": "16.1.0",
29
29
  "@shipfox/annotations-dto": "12.3.0",
30
- "@shipfox/api-definitions-dto": "16.0.0",
30
+ "@shipfox/api-definitions-dto": "16.1.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.0.0",
35
+ "@shipfox/api-workflows-dto": "16.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",
@@ -0,0 +1,85 @@
1
+ import {workflowModel} from '#test/index.js';
2
+ import {loadAgentToolMaterializationContext} from './agent-tools.js';
3
+ import {AgentIntegrationMaterializationError} from './errors.js';
4
+
5
+ describe('loadAgentToolMaterializationContext', () => {
6
+ const workspaceId = crypto.randomUUID();
7
+ const projectId = crypto.randomUUID();
8
+
9
+ test('returns undefined for a model with only run steps', async () => {
10
+ const model = workflowModel({
11
+ name: 'Plain',
12
+ runner: 'ubuntu-latest',
13
+ });
14
+
15
+ await expect(
16
+ loadAgentToolMaterializationContext({model, workspaceId, projectId}),
17
+ ).resolves.toBeUndefined();
18
+ });
19
+
20
+ test('returns undefined for a model with only checkout steps', async () => {
21
+ const model = workflowModel({
22
+ name: 'Checkout',
23
+ runner: 'ubuntu-latest',
24
+ jobs: {
25
+ build: {
26
+ steps: [
27
+ {
28
+ checkout: {
29
+ repository: 'acme/platform',
30
+ fetchDepth: 1,
31
+ permissions: {contents: 'read'},
32
+ persistCredentials: true,
33
+ },
34
+ },
35
+ ],
36
+ },
37
+ },
38
+ });
39
+
40
+ await expect(
41
+ loadAgentToolMaterializationContext({model, workspaceId, projectId}),
42
+ ).resolves.toBeUndefined();
43
+ });
44
+
45
+ test('returns undefined for agent steps without integrations', async () => {
46
+ const model = workflowModel({
47
+ name: 'Agent',
48
+ runner: 'ubuntu-latest',
49
+ jobs: {
50
+ build: {
51
+ steps: [{prompt: 'Do the thing'}],
52
+ },
53
+ },
54
+ });
55
+
56
+ await expect(
57
+ loadAgentToolMaterializationContext({model, workspaceId, projectId}),
58
+ ).resolves.toBeUndefined();
59
+ });
60
+
61
+ test('does not short-circuit for a tool-step-only model', async () => {
62
+ const model = workflowModel({
63
+ name: 'Tools',
64
+ runner: 'ubuntu-latest',
65
+ jobs: {
66
+ build: {
67
+ steps: [
68
+ {
69
+ tool: 'get_issue',
70
+ connection: 'linear-main',
71
+ with: {id: 'ENG-1'},
72
+ },
73
+ ],
74
+ },
75
+ },
76
+ });
77
+
78
+ // A tool step is an integration tool reference, so the loader must not
79
+ // return early: with project access missing it reports the materialization
80
+ // setup failure instead of silently skipping tool-step runs.
81
+ await expect(
82
+ loadAgentToolMaterializationContext({model, workspaceId, projectId}),
83
+ ).rejects.toThrow(AgentIntegrationMaterializationError);
84
+ });
85
+ });
@@ -61,7 +61,7 @@ export async function loadAgentToolMaterializationContext(params: {
61
61
  readonly projects?: ProjectsModuleClient | undefined;
62
62
  readonly jobs?: readonly WorkflowModelJob[] | undefined;
63
63
  }): Promise<AgentToolMaterializationContext | undefined> {
64
- if (!modelHasAgentStepIntegrations(params.model, params.jobs)) return undefined;
64
+ if (!hasIntegrationToolReferences(params.model, params.jobs)) return undefined;
65
65
 
66
66
  if (params.projects === undefined) {
67
67
  throw new AgentIntegrationMaterializationError('Project access is not configured');
@@ -410,12 +410,20 @@ function isPermissionScope(
410
410
  );
411
411
  }
412
412
 
413
- function modelHasAgentStepIntegrations(
413
+ /**
414
+ * Model-level twin of `hasIntegrationToolReferences` in
415
+ * `libs/api/definitions/src/core/has-integration-tool-references.ts`, which
416
+ * runs the same criterion over the authored `WorkflowDocument` at sync time.
417
+ * Keep both in sync.
418
+ */
419
+ function hasIntegrationToolReferences(
414
420
  model: WorkflowModel | null,
415
421
  jobs: readonly WorkflowModelJob[] | undefined,
416
422
  ): boolean {
417
423
  if (model === null) return false;
418
424
  return (jobs ?? model.jobs).some((job) =>
419
- job.steps.some((step) => step.kind === 'agent' && step.integrations !== undefined),
425
+ job.steps.some(
426
+ (step) => step.kind === 'tool' || (step.kind === 'agent' && step.integrations !== undefined),
427
+ ),
420
428
  );
421
429
  }
@@ -13,7 +13,7 @@ import type {InterpolationUnresolvableField} from '../errors.js';
13
13
 
14
14
  export type StepStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'skipped';
15
15
 
16
- export const STEP_TYPES = ['setup', 'run', 'agent', 'checkout'] as const;
16
+ export const STEP_TYPES = ['setup', 'run', 'agent', 'checkout', 'tool'] as const;
17
17
 
18
18
  export type StepType = (typeof STEP_TYPES)[number];
19
19
 
@@ -84,6 +84,8 @@ export type InterpolationUnresolvableField =
84
84
  | 'step.name'
85
85
  | 'step.working_directory'
86
86
  | 'step.feedback'
87
+ | 'tool.with'
88
+ | 'tool.outputs'
87
89
  | 'checkout.project'
88
90
  | 'checkout.connection'
89
91
  | 'checkout.repository'
@@ -1195,6 +1195,7 @@ describe('step attempts', () => {
1195
1195
  attempt: 1,
1196
1196
  status: 'failed',
1197
1197
  logOutcome: 'abandoned',
1198
+ stepAttemptId: expect.any(String),
1198
1199
  },
1199
1200
  ]);
1200
1201
  });
@@ -62,6 +62,8 @@ describe('runWorkflow', () => {
62
62
  getValidationCatalog: vi.fn().mockResolvedValue(agentValidationCatalog),
63
63
  resolveAgentConfig: mockResolveAgentConfig,
64
64
  resolveRuntimeCredentials: vi.fn(),
65
+ claimSession: vi.fn(),
66
+ carryOverSessions: vi.fn(),
65
67
  };
66
68
 
67
69
  beforeEach(() => {
@@ -256,6 +258,8 @@ describe('runDevWorkflow', () => {
256
258
  getValidationCatalog: vi.fn().mockResolvedValue(agentValidationCatalog),
257
259
  resolveAgentConfig: mockResolveAgentConfig,
258
260
  resolveRuntimeCredentials: vi.fn(),
261
+ claimSession: vi.fn(),
262
+ carryOverSessions: vi.fn(),
259
263
  };
260
264
 
261
265
  beforeEach(() => {
@@ -168,6 +168,8 @@ function stepDisplayName(step: WorkflowModelStep): string {
168
168
  : `${step.model} · ${firstLine(step.prompt)}`;
169
169
  case 'checkout':
170
170
  return 'Checkout';
171
+ case 'tool':
172
+ return step.tool.method === undefined ? step.tool.id : `${step.tool.id}.${step.tool.method}`;
171
173
  default:
172
174
  return assertNever(step);
173
175
  }
@@ -154,7 +154,10 @@ function resolveWorkingDirectoryConfig(
154
154
  params: Omit<BuildStepConfigParams, 'context'> & {readonly context: WorkflowEvaluationContext},
155
155
  ): WorkingDirectoryConfig {
156
156
  const value = params.step.workingDirectory;
157
- const template = params.step.templates?.workingDirectory;
157
+ // Tool steps have no working tree, so they never carry a working-directory
158
+ // template; the union narrows the remaining kinds to those that can.
159
+ const template =
160
+ params.step.kind === 'tool' ? undefined : params.step.templates?.workingDirectory;
158
161
  if (value === undefined) {
159
162
  return {
160
163
  config: {},
package/src/db/index.ts CHANGED
@@ -82,6 +82,7 @@ export {
82
82
  getWorkflowRunById,
83
83
  getWorkflowRunDetail,
84
84
  listRunAttempts,
85
+ listStepAttemptIdsByJobId,
85
86
  listWorkflowRunJobSummaries,
86
87
  listWorkflowRuns,
87
88
  listWorkflowRunsByProject,
@@ -99,6 +99,7 @@ export async function writeJobStepsSettledOutbox(
99
99
  export async function writeStepAttemptTerminatedOutbox(
100
100
  tx: Tx,
101
101
  params: {
102
+ stepAttemptId: string;
102
103
  stepId: string;
103
104
  attempt: number;
104
105
  status: 'succeeded' | 'failed' | 'cancelled';
@@ -119,6 +120,7 @@ export async function writeStepAttemptTerminatedOutbox(
119
120
  attempt: params.attempt,
120
121
  status: params.status,
121
122
  logOutcome: params.logOutcome,
123
+ stepAttemptId: params.stepAttemptId,
122
124
  },
123
125
  });
124
126
  }
@@ -1191,6 +1191,47 @@ describe('workflow run queries', () => {
1191
1191
  }),
1192
1192
  expected: {field: 'step.working_directory', source: 'vars.REQUIRED'},
1193
1193
  },
1194
+ {
1195
+ field: 'tool.with',
1196
+ model: () =>
1197
+ workflowModel({
1198
+ name: 'Missing tool with var',
1199
+ runner: 'ubuntu-latest',
1200
+ jobs: {
1201
+ build: {
1202
+ steps: [
1203
+ {
1204
+ tool: 'get_issue',
1205
+ connection: 'linear-main',
1206
+ with: {id: template('vars.REQUIRED')},
1207
+ },
1208
+ ],
1209
+ },
1210
+ },
1211
+ }),
1212
+ expected: {field: 'tool.with', source: 'vars.REQUIRED'},
1213
+ },
1214
+ {
1215
+ field: 'tool.outputs',
1216
+ model: () =>
1217
+ workflowModel({
1218
+ name: 'Missing tool output var',
1219
+ runner: 'ubuntu-latest',
1220
+ jobs: {
1221
+ build: {
1222
+ steps: [
1223
+ {
1224
+ tool: 'get_issue',
1225
+ connection: 'linear-main',
1226
+ with: {id: 'ENG-1'},
1227
+ outputs: {sev: template('vars.REQUIRED')},
1228
+ },
1229
+ ],
1230
+ },
1231
+ },
1232
+ }),
1233
+ expected: {field: 'tool.outputs', envKey: 'sev', source: 'vars.REQUIRED'},
1234
+ },
1194
1235
  {
1195
1236
  field: 'checkout.repository',
1196
1237
  model: () =>
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  createWorkflowModelSnapshot,
3
3
  WORKFLOW_MODEL_CHECKOUT_TARGET_FIELDS,
4
+ type WorkflowJsonTemplateTree,
4
5
  type WorkflowModel,
5
6
  } from '@shipfox/api-definitions-dto';
6
7
  import type {IntegrationsModuleClient} from '@shipfox/api-integration-core-dto/inter-module';
@@ -455,9 +456,13 @@ function referencedVariables(
455
456
 
456
457
  for (const step of job.steps) {
457
458
  collectFieldVariableReferences(step.templates?.name, references, {field: 'step.name'});
458
- collectFieldVariableReferences(step.templates?.workingDirectory, references, {
459
- field: 'step.working_directory',
460
- });
459
+ collectFieldVariableReferences(
460
+ step.kind === 'tool' ? undefined : step.templates?.workingDirectory,
461
+ references,
462
+ {
463
+ field: 'step.working_directory',
464
+ },
465
+ );
461
466
  if (step.kind === 'run') {
462
467
  collectFieldVariableReferences(step.templates?.command, references, {field: 'run'});
463
468
  collectTemplateVariableReferences(step.templates?.env, references);
@@ -467,6 +472,16 @@ function referencedVariables(
467
472
  collectFieldVariableReferences(step.templates?.provider, references, {
468
473
  field: 'agent.provider',
469
474
  });
475
+ } else if (step.kind === 'tool') {
476
+ collectTemplateTreeVariableReferences(step.templates?.with, references, {
477
+ field: 'tool.with',
478
+ });
479
+ for (const [key, expression] of Object.entries(step.outputMappings ?? {})) {
480
+ collectExpressionVariableReferences(expression, references, {
481
+ field: 'tool.outputs',
482
+ envKey: key,
483
+ });
484
+ }
470
485
  } else if (step.kind === 'checkout') {
471
486
  for (const [key, field] of WORKFLOW_MODEL_CHECKOUT_TARGET_FIELDS) {
472
487
  collectFieldVariableReferences(step.checkout.templates?.[key], references, {field});
@@ -534,6 +549,69 @@ function collectFieldVariableReferences(
534
549
  }
535
550
  }
536
551
 
552
+ /**
553
+ * Collect `vars.*` references from a tool step's `with` template tree: a
554
+ * `WorkflowJsonTemplateTree` mirrors the authored `with` payload with every
555
+ * interpolated string leaf replaced by its parsed template, so walk it like
556
+ * the authored structure and collect from each leaf template.
557
+ */
558
+ function collectTemplateTreeVariableReferences(
559
+ tree: WorkflowJsonTemplateTree | undefined,
560
+ references: ReferencedVariable[],
561
+ source: {
562
+ readonly field: InterpolationUnresolvableError['field'];
563
+ readonly envKey?: string | undefined;
564
+ },
565
+ ): void {
566
+ if (tree === undefined) return;
567
+
568
+ if (Array.isArray(tree)) {
569
+ // A field template is itself an array of segments; a `with` list is an
570
+ // array of child trees. Segments carry a `kind`, so distinguish the two.
571
+ if (tree.every((element) => isFieldTemplateSegment(element))) {
572
+ collectFieldVariableReferences(tree, references, source);
573
+ return;
574
+ }
575
+ for (const child of tree) collectTemplateTreeVariableReferences(child, references, source);
576
+ return;
577
+ }
578
+
579
+ if (typeof tree === 'object') {
580
+ for (const child of Object.values(tree)) {
581
+ collectTemplateTreeVariableReferences(child, references, source);
582
+ }
583
+ }
584
+ }
585
+
586
+ function isFieldTemplateSegment(value: unknown): value is ResolvedFieldSegment {
587
+ return (
588
+ typeof value === 'object' &&
589
+ value !== null &&
590
+ ((value as {kind?: unknown}).kind === 'literal' ||
591
+ (value as {kind?: unknown}).kind === 'deferred')
592
+ );
593
+ }
594
+
595
+ function collectExpressionVariableReferences(
596
+ expression: WorkflowExpression,
597
+ references: ReferencedVariable[],
598
+ source: {
599
+ readonly field: InterpolationUnresolvableError['field'];
600
+ readonly envKey?: string | undefined;
601
+ },
602
+ ): void {
603
+ const keyAccess = analyzeContextKeyAccess(expression);
604
+ for (const reference of keyAccess.references) {
605
+ if (reference.root !== 'vars') continue;
606
+ references.push({
607
+ key: reference.key,
608
+ field: source.field,
609
+ source: expression.source,
610
+ envKey: source.envKey,
611
+ });
612
+ }
613
+ }
614
+
537
615
  function logTemplateDiagnostics(params: {
538
616
  readonly workflowRunId: string;
539
617
  readonly diagnostics: readonly (WorkflowStepTemplateDiagnostic & {
@@ -197,6 +197,7 @@ export async function bulkUpdateStepStatuses(
197
197
  ),
198
198
  )
199
199
  .returning({
200
+ id: stepAttempts.id,
200
201
  stepId: stepAttempts.stepId,
201
202
  attempt: stepAttempts.attempt,
202
203
  logOutcome: stepAttempts.logOutcome,
@@ -205,6 +206,7 @@ export async function bulkUpdateStepStatuses(
205
206
  if (finalizedAttempts.length > 0) {
206
207
  for (const attempt of finalizedAttempts) {
207
208
  await writeStepAttemptTerminatedOutbox(tx, {
209
+ stepAttemptId: attempt.id,
208
210
  stepId: attempt.stepId,
209
211
  attempt: attempt.attempt,
210
212
  status: params.status,
@@ -459,6 +461,7 @@ export async function finishStepAttempt(params: FinishStepAttemptParams, tx: Tx)
459
461
  ),
460
462
  )
461
463
  .returning({
464
+ id: stepAttempts.id,
462
465
  stepId: stepAttempts.stepId,
463
466
  attempt: stepAttempts.attempt,
464
467
  logOutcome: stepAttempts.logOutcome,
@@ -468,6 +471,7 @@ export async function finishStepAttempt(params: FinishStepAttemptParams, tx: Tx)
468
471
  if (!row) return;
469
472
 
470
473
  await writeStepAttemptTerminatedOutbox(tx, {
474
+ stepAttemptId: row.id,
471
475
  stepId: row.stepId,
472
476
  attempt: row.attempt,
473
477
  status: params.status,
@@ -531,6 +535,21 @@ export async function getStepAttempts(jobId: string): Promise<StepAttempt[]> {
531
535
  return rows.map((row) => toStepAttempt(row.stepAttempt));
532
536
  }
533
537
 
538
+ /**
539
+ * Lists only the step attempt ids of a job, without materializing the full
540
+ * attempt entities. The session release sweep only needs the ids to release
541
+ * claims, so this keeps the per-job-terminated query off a full-row projection.
542
+ */
543
+ export async function listStepAttemptIdsByJobId(jobId: string): Promise<string[]> {
544
+ const rows = await db()
545
+ .select({id: stepAttempts.id})
546
+ .from(stepAttempts)
547
+ .innerJoin(steps, eq(stepAttempts.stepId, steps.id))
548
+ .innerJoin(jobExecutions, eq(steps.jobExecutionId, jobExecutions.id))
549
+ .where(eq(jobExecutions.jobId, jobId));
550
+ return rows.map((row) => row.id);
551
+ }
552
+
534
553
  export async function getStepAttemptsByJobIds(jobIds: string[]): Promise<StepAttempt[]> {
535
554
  if (jobIds.length === 0) return [];
536
555
  const rows = await db()
@@ -117,6 +117,7 @@ export {
117
117
  getStepsByJobExecutionIdForUpdate,
118
118
  getStepsByJobId,
119
119
  insertRunningStepAttempt,
120
+ listStepAttemptIdsByJobId,
120
121
  markStepRunning,
121
122
  markStepSkipped,
122
123
  rewindStepsToPending,
@@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({
23
23
  getJobScope: vi.fn(),
24
24
  getStepById: vi.fn(),
25
25
  getStepByIdForJobExecution: vi.fn(),
26
+ listStepAttemptIdsByJobId: vi.fn(),
26
27
  }));
27
28
 
28
29
  vi.mock('#db/index.js', () => mocks);
@@ -47,12 +48,37 @@ const input = {
47
48
  describe('Workflows inter-module presentation', () => {
48
49
  beforeEach(() => {
49
50
  mocks.getJobScope.mockReset();
51
+ mocks.listStepAttemptIdsByJobId.mockReset();
50
52
  mocks.getStepById.mockReset();
51
53
  mocks.getStepByIdForJobExecution.mockReset();
52
54
  mocks.deliverEventToListener.mockReset();
53
55
  mocks.deliverEventToListener.mockResolvedValue({buffered: true, skipped: false});
54
56
  });
55
57
 
58
+ it('returns the step attempt ids of a job for the session release sweep', async () => {
59
+ const stepAttemptId = '00000000-0000-4000-8000-000000000010';
60
+ mocks.listStepAttemptIdsByJobId.mockResolvedValue([stepAttemptId]);
61
+ const presentation = createWorkflowsInterModulePresentation({
62
+ agent: {} as never,
63
+ definitions: {} as never,
64
+ integrations: {} as never,
65
+ projects: {} as never,
66
+ runners: {} as never,
67
+ secrets: {} as never,
68
+ workspaces: {getWorkspaceOperatingState: vi.fn()} as never,
69
+ });
70
+
71
+ const result = await presentation.handlers.listJobStepAttempts(
72
+ {jobId: '00000000-0000-4000-8000-000000000006'},
73
+ {signal: new AbortController().signal},
74
+ );
75
+
76
+ expect(mocks.listStepAttemptIdsByJobId).toHaveBeenCalledWith(
77
+ '00000000-0000-4000-8000-000000000006',
78
+ );
79
+ expect(result).toEqual({stepAttemptIds: [stepAttemptId]});
80
+ });
81
+
56
82
  it('returns only the resolved harness for Logs', async () => {
57
83
  mocks.getStepById.mockResolvedValue({config: {harness: 'claude'}});
58
84
  const presentation = createWorkflowsInterModulePresentation({
@@ -32,7 +32,12 @@ import {
32
32
  } from '#core/index.js';
33
33
  import {resolveWorkflowRunTriggerReference} from '#core/resolve-trigger-reference.js';
34
34
  import {assertWorkspaceAdmitsNewJobs} from '#core/workspace-admission.js';
35
- import {getJobScope, getStepById, getStepByIdForJobExecution} from '#db/index.js';
35
+ import {
36
+ getJobScope,
37
+ getStepById,
38
+ getStepByIdForJobExecution,
39
+ listStepAttemptIdsByJobId,
40
+ } from '#db/index.js';
36
41
  import {deliverEventToListener} from '#db/job-listener-events.js';
37
42
 
38
43
  type WorkspaceAdmissionKnownError = InterModuleKnownErrorFor<
@@ -151,6 +156,9 @@ export function createWorkflowsInterModulePresentation(params: {
151
156
  const parsed = harnessSchema.safeParse(step?.config.harness);
152
157
  return {harness: parsed.success ? parsed.data : DEFAULT_HARNESS};
153
158
  },
159
+ listJobStepAttempts: async ({jobId}) => {
160
+ return {stepAttemptIds: await listStepAttemptIdsByJobId(jobId)};
161
+ },
154
162
  getLeasedAgentToolContext: async (input) => {
155
163
  const method = workflowsInterModuleContract.methods.getLeasedAgentToolContext;
156
164
  const {active: leaseIsActive} = await params.runners.getLeaseState({
@@ -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) => {