@shipfox/api-workflows 8.0.0 → 9.0.2

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 (55) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +94 -0
  3. package/dist/core/agent-tools.d.ts +3 -1
  4. package/dist/core/agent-tools.d.ts.map +1 -1
  5. package/dist/core/agent-tools.js.map +1 -1
  6. package/dist/core/errors.d.ts +0 -4
  7. package/dist/core/errors.d.ts.map +1 -1
  8. package/dist/core/errors.js +0 -6
  9. package/dist/core/errors.js.map +1 -1
  10. package/dist/core/job-execution.d.ts +0 -2
  11. package/dist/core/job-execution.d.ts.map +1 -1
  12. package/dist/core/job-execution.js +3 -7
  13. package/dist/core/job-execution.js.map +1 -1
  14. package/dist/db/workflow-runs/job-executions.d.ts +0 -5
  15. package/dist/db/workflow-runs/job-executions.d.ts.map +1 -1
  16. package/dist/db/workflow-runs/job-executions.js +0 -7
  17. package/dist/db/workflow-runs/job-executions.js.map +1 -1
  18. package/dist/db/workflow-runs.d.ts +1 -1
  19. package/dist/db/workflow-runs.d.ts.map +1 -1
  20. package/dist/db/workflow-runs.js +1 -1
  21. package/dist/db/workflow-runs.js.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/presentation/routes/next-step.d.ts.map +1 -1
  25. package/dist/presentation/routes/next-step.js +11 -8
  26. package/dist/presentation/routes/next-step.js.map +1 -1
  27. package/dist/tsconfig.test.tsbuildinfo +1 -1
  28. package/package.json +34 -28
  29. package/src/core/agent-tool-capability-warning.test.ts +25 -55
  30. package/src/core/agent-tools.ts +7 -5
  31. package/src/core/errors.ts +0 -7
  32. package/src/core/job-execution.ts +3 -10
  33. package/src/core/run-workflow.test.ts +20 -55
  34. package/src/core/step-config/materialize-job-execution-steps.test.ts +1 -2
  35. package/src/core/step-config/materialize-workflow-model.test.ts +11 -23
  36. package/src/db/workflow-runs/job-executions.ts +0 -21
  37. package/src/db/workflow-runs/run-create.test.ts +31 -33
  38. package/src/db/workflow-runs.ts +0 -1
  39. package/src/index.ts +1 -1
  40. package/src/presentation/dto/checkout-token.test.ts +7 -1
  41. package/src/presentation/routes/agent-runtime-config.test.ts +38 -97
  42. package/src/presentation/routes/next-step.test.ts +18 -12
  43. package/src/presentation/routes/next-step.ts +9 -5
  44. package/test/factories/workflow-model.ts +3 -1
  45. package/test/fixtures/active-lease-token.ts +5 -32
  46. package/test/fixtures/agent-inter-module.ts +3 -2
  47. package/test/fixtures/runners-inter-module.ts +40 -48
  48. package/test/globalSetup.ts +0 -38
  49. package/test/setup.ts +2 -0
  50. package/tsconfig.build.tsbuildinfo +1 -1
  51. package/dist/db/runner-lease-table.d.ts +0 -469
  52. package/dist/db/runner-lease-table.d.ts.map +0 -1
  53. package/dist/db/runner-lease-table.js +0 -43
  54. package/dist/db/runner-lease-table.js.map +0 -1
  55. package/src/db/runner-lease-table.ts +0 -39
@@ -1,15 +1,12 @@
1
- import {normalizeWorkflowDocument as normalizeWorkflowDocumentImpl} from '@shipfox/api-definitions';
2
1
  import {WORKFLOWS_WORKFLOW_RUN_ATTEMPT_CREATED} from '@shipfox/api-workflows-dto';
3
2
  import {and, eq, sql} from 'drizzle-orm';
4
3
  import type {AgentDefaultsResolver} from '#core/agent-defaults.js';
5
4
  import {InterpolationUnresolvableError} from '#core/errors.js';
6
5
  import {nextStepForJob, recordStepResult} from '#core/job-execution.js';
7
- import {
8
- agentValidationCatalog,
9
- resolveTestAgentDefaults,
10
- } from '#test/fixtures/agent-inter-module.js';
6
+ import {resolveTestAgentDefaults} from '#test/fixtures/agent-inter-module.js';
11
7
  import {createTestSecretsClient} from '#test/fixtures/secrets-inter-module.js';
12
8
  import {buildModel, expression, shellRef, template} from '#test/helpers/workflow-runs.js';
9
+ import {workflowModel} from '#test/index.js';
13
10
  import {db} from '../db.js';
14
11
  import {workflowsOutbox} from '../schema/outbox.js';
15
12
  import {workflowRuns} from '../schema/workflow-runs.js';
@@ -26,12 +23,6 @@ import {
26
23
  updateJobExecutionStatus,
27
24
  } from '../workflow-runs.js';
28
25
 
29
- function normalizeWorkflowDocument(
30
- document: Parameters<typeof normalizeWorkflowDocumentImpl>[0],
31
- ): ReturnType<typeof normalizeWorkflowDocumentImpl> {
32
- return normalizeWorkflowDocumentImpl(document, {agentValidationCatalog});
33
- }
34
-
35
26
  describe('workflow run queries', () => {
36
27
  let workspaceId: string;
37
28
  let projectId: string;
@@ -339,7 +330,7 @@ describe('workflow run queries', () => {
339
330
  const displayNameSource = ['Review batch $', '{{ execution.index }}'].join('');
340
331
  const stepNameSource = ['Review $', '{{ execution.index }}'].join('');
341
332
  const promptSource = ['Review $', '{{ execution.events[0].data.body }}'].join('');
342
- const model = normalizeWorkflowDocument({
333
+ const model = workflowModel({
343
334
  name: 'Listening workflow',
344
335
  runner: 'ubuntu-latest',
345
336
  jobs: {
@@ -348,10 +339,10 @@ describe('workflow run queries', () => {
348
339
  listening: {
349
340
  on: [{source: 'github', event: 'pull_request_review'}],
350
341
  until: [{source: 'github', event: 'pull_request'}],
351
- timeout: '30d',
352
- max_executions: 3,
353
- batch: {debounce: '5s', max_size: 10, max_wait: '1h'},
354
- on_resolve: 'cancel',
342
+ timeoutMs: 30 * 24 * 60 * 60 * 1000,
343
+ maxExecutions: 3,
344
+ batch: {debounceMs: 5000, maxSize: 10, maxWaitMs: 60 * 60 * 1000},
345
+ onResolve: 'cancel',
355
346
  },
356
347
  steps: [{name: stepNameSource, prompt: promptSource}],
357
348
  },
@@ -409,7 +400,7 @@ describe('workflow run queries', () => {
409
400
  test('persists the listening workflow model on the run attempt', async () => {
410
401
  const displayNameSource = ['Review batch $', '{{ execution.index }}'].join('');
411
402
  const promptSource = ['Review $', '{{ execution.events[0].data.body }}'].join('');
412
- const model = normalizeWorkflowDocument({
403
+ const model = workflowModel({
413
404
  name: 'Listening workflow',
414
405
  runner: 'ubuntu-latest',
415
406
  jobs: {
@@ -418,10 +409,10 @@ describe('workflow run queries', () => {
418
409
  listening: {
419
410
  on: [{source: 'github', event: 'pull_request_review'}],
420
411
  until: [{source: 'github', event: 'pull_request'}],
421
- timeout: '30d',
422
- max_executions: 3,
423
- batch: {debounce: '5s', max_size: 10, max_wait: '1h'},
424
- on_resolve: 'cancel',
412
+ timeoutMs: 30 * 24 * 60 * 60 * 1000,
413
+ maxExecutions: 3,
414
+ batch: {debounceMs: 5000, maxSize: 10, maxWaitMs: 60 * 60 * 1000},
415
+ onResolve: 'cancel',
425
416
  },
426
417
  steps: [{prompt: promptSource}],
427
418
  },
@@ -450,7 +441,7 @@ describe('workflow run queries', () => {
450
441
  });
451
442
 
452
443
  test('does not load variables referenced only by listening job executions at run creation', async () => {
453
- const model = normalizeWorkflowDocument({
444
+ const model = workflowModel({
454
445
  name: 'Listening vars workflow',
455
446
  runner: 'ubuntu-latest',
456
447
  jobs: {
@@ -458,6 +449,7 @@ describe('workflow run queries', () => {
458
449
  listening: {
459
450
  on: [{source: 'github', event: 'pull_request_review'}],
460
451
  until: [{source: 'github', event: 'pull_request'}],
452
+ onResolve: 'finish',
461
453
  },
462
454
  steps: [
463
455
  {
@@ -492,7 +484,7 @@ describe('workflow run queries', () => {
492
484
  {
493
485
  field: 'run',
494
486
  model: () =>
495
- normalizeWorkflowDocument({
487
+ workflowModel({
496
488
  name: 'Missing run var',
497
489
  runner: 'ubuntu-latest',
498
490
  jobs: {build: {steps: [{run: `echo ${template('vars.REQUIRED')}`}]}},
@@ -502,7 +494,7 @@ describe('workflow run queries', () => {
502
494
  {
503
495
  field: 'env',
504
496
  model: () =>
505
- normalizeWorkflowDocument({
497
+ workflowModel({
506
498
  name: 'Missing env var',
507
499
  runner: 'ubuntu-latest',
508
500
  jobs: {
@@ -516,7 +508,7 @@ describe('workflow run queries', () => {
516
508
  {
517
509
  field: 'agent.prompt',
518
510
  model: () =>
519
- normalizeWorkflowDocument({
511
+ workflowModel({
520
512
  name: 'Missing prompt var',
521
513
  runner: 'ubuntu-latest',
522
514
  jobs: {fix: {steps: [{prompt: template('vars.REQUIRED')}]}},
@@ -526,7 +518,7 @@ describe('workflow run queries', () => {
526
518
  {
527
519
  field: 'agent.model',
528
520
  model: () =>
529
- normalizeWorkflowDocument({
521
+ workflowModel({
530
522
  name: 'Missing model var',
531
523
  runner: 'ubuntu-latest',
532
524
  jobs: {fix: {steps: [{prompt: 'Fix it', model: template('vars.REQUIRED')}]}},
@@ -536,7 +528,7 @@ describe('workflow run queries', () => {
536
528
  {
537
529
  field: 'agent.provider',
538
530
  model: () =>
539
- normalizeWorkflowDocument({
531
+ workflowModel({
540
532
  name: 'Missing provider var',
541
533
  runner: 'ubuntu-latest',
542
534
  jobs: {fix: {steps: [{prompt: 'Fix it', provider: template('vars.REQUIRED')}]}},
@@ -546,17 +538,23 @@ describe('workflow run queries', () => {
546
538
  {
547
539
  field: 'job.runner',
548
540
  model: () =>
549
- normalizeWorkflowDocument({
541
+ workflowModel({
550
542
  name: 'Missing runner var',
551
543
  runner: 'ubuntu-latest',
552
- jobs: {build: {runner: template('vars.REQUIRED'), steps: [{run: 'echo ok'}]}},
544
+ jobs: {
545
+ build: {
546
+ runner: [],
547
+ runnerTemplates: [template('vars.REQUIRED')],
548
+ steps: [{run: 'echo ok'}],
549
+ },
550
+ },
553
551
  }),
554
552
  expected: {field: 'job.runner', source: 'vars.REQUIRED'},
555
553
  },
556
554
  {
557
555
  field: 'step.name',
558
556
  model: () =>
559
- normalizeWorkflowDocument({
557
+ workflowModel({
560
558
  name: 'Missing step name var',
561
559
  runner: 'ubuntu-latest',
562
560
  jobs: {build: {steps: [{name: template('vars.REQUIRED'), run: 'echo ok'}]}},
@@ -623,7 +621,7 @@ describe('workflow run queries', () => {
623
621
  });
624
622
 
625
623
  test('persists resolved step config and authored step config separately', async () => {
626
- const model = normalizeWorkflowDocument({
624
+ const model = workflowModel({
627
625
  name: 'Interpolated workflow',
628
626
  runner: 'ubuntu-latest',
629
627
  env: {RUN_ID: template('run.id'), REF: template('event.ref')},
@@ -674,7 +672,7 @@ describe('workflow run queries', () => {
674
672
  });
675
673
 
676
674
  test('resolves webhook trigger payload body and headers into step config', async () => {
677
- const model = normalizeWorkflowDocument({
675
+ const model = workflowModel({
678
676
  name: 'Webhook workflow',
679
677
  runner: 'ubuntu-latest',
680
678
  env: {
@@ -720,7 +718,7 @@ describe('workflow run queries', () => {
720
718
  });
721
719
 
722
720
  test('fails for missing available untrusted interpolation paths', async () => {
723
- const model = normalizeWorkflowDocument({
721
+ const model = workflowModel({
724
722
  name: 'Diagnostic workflow',
725
723
  runner: 'ubuntu-latest',
726
724
  env: {REF: template('event.ref')},
@@ -9,7 +9,6 @@ export {
9
9
  getJobExecutionsByJobId,
10
10
  getJobExecutionsByWorkflowRunAttemptId,
11
11
  getLatestJobExecutionByJobId,
12
- lockActiveJobExecutionLeaseForUpdate,
13
12
  recordJobExecutionQueuedAt,
14
13
  recordJobExecutionStartedAt,
15
14
  resolveJobExecutionAfterLeaseExpiry,
package/src/index.ts CHANGED
@@ -95,7 +95,7 @@ export function createWorkflowsModule({
95
95
  }): ShipfoxModule {
96
96
  return {
97
97
  name: 'workflows',
98
- database: {db, migrationsPath},
98
+ database: {db, migrationsPath, databaseNamespace: 'workflows'},
99
99
  routes: createWorkflowRoutes({
100
100
  agent,
101
101
  annotations,
@@ -1,6 +1,12 @@
1
- import type {CheckoutSpec} from '@shipfox/api-integration-core';
2
1
  import {toCheckoutTokenDto} from './checkout-token.js';
3
2
 
3
+ type CheckoutSpec = {
4
+ repositoryUrl: string;
5
+ ref: string;
6
+ credentials?: {username: string; token: string; expiresAt: Date};
7
+ gitAuthor?: {name: string; email: string};
8
+ };
9
+
4
10
  describe('toCheckoutTokenDto', () => {
5
11
  it('maps credentials to basic auth with an ISO expiry', () => {
6
12
  const spec: CheckoutSpec = {
@@ -1,19 +1,11 @@
1
1
  import {
2
- createAgentModule,
3
- createCustomModelProviderConfig,
4
- testAndSaveModelProviderConfig,
5
- } from '@shipfox/api-agent';
6
- import {resolveRuntimeCredentials} from '@shipfox/api-agent/core/resolve-runtime-credentials';
7
- import {agentInterModuleContract} from '@shipfox/api-agent-dto/inter-module';
2
+ type AgentInterModuleClient,
3
+ agentInterModuleContract,
4
+ } from '@shipfox/api-agent-dto/inter-module';
8
5
  import type {WorkflowModel} from '@shipfox/api-definitions-dto';
9
- import {secretsInterModuleContract} from '@shipfox/api-secrets-dto/inter-module';
10
6
  import {createInterModuleKnownError} from '@shipfox/inter-module';
11
7
  import {closeApp, createApp, type FastifyInstance} from '@shipfox/node-fastify';
12
8
  import {createCapturingLogger} from '@shipfox/node-log/test';
13
- import {
14
- createInMemoryInterModuleTransport,
15
- registerInterModulePresentations,
16
- } from '@shipfox/node-module/inter-module';
17
9
  import {eq} from 'drizzle-orm';
18
10
  import type {StepStatus} from '#core/entities/step.js';
19
11
  import {db} from '#db/db.js';
@@ -32,22 +24,32 @@ import {createTestSecretsClient} from '#test/fixtures/secrets-inter-module.js';
32
24
  import {createLeaseTokenRouteGroup} from './index.js';
33
25
 
34
26
  const {captureExceptionMock} = vi.hoisted(() => ({captureExceptionMock: vi.fn()}));
35
- vi.mock('@shipfox/api-agent/core/resolve-runtime-credentials', async (importOriginal) => {
36
- const actual =
37
- await importOriginal<typeof import('@shipfox/api-agent/core/resolve-runtime-credentials')>();
38
- return {...actual, resolveRuntimeCredentials: vi.fn(actual.resolveRuntimeCredentials)};
39
- });
40
27
  vi.mock('@shipfox/node-error-monitoring', () => ({captureException: captureExceptionMock}));
41
28
 
42
29
  const URL = '/runs/jobs/current/agent-runtime-config';
43
30
  const secrets = createTestSecretsClient();
44
- const agentTransport = createInMemoryInterModuleTransport();
45
- const agentTestClient = agentTransport.createClient(agentInterModuleContract);
46
- registerInterModulePresentations({
47
- transport: agentTransport,
48
- modules: [createAgentModule({secrets})],
49
- });
50
- agentTransport.seal();
31
+ const runtimeConfigs = new Map<
32
+ string,
33
+ Awaited<ReturnType<AgentInterModuleClient['resolveRuntimeCredentials']>>
34
+ >();
35
+ const resolveRuntimeCredentials = vi.fn<AgentInterModuleClient['resolveRuntimeCredentials']>(
36
+ ({workspaceId}) => {
37
+ const runtimeConfig = runtimeConfigs.get(workspaceId);
38
+ if (runtimeConfig) return Promise.resolve(runtimeConfig);
39
+ return Promise.reject(
40
+ createInterModuleKnownError(
41
+ agentInterModuleContract.methods.resolveRuntimeCredentials,
42
+ 'model-provider-not-configured',
43
+ {},
44
+ ),
45
+ );
46
+ },
47
+ );
48
+ const agentTestClient: AgentInterModuleClient = {
49
+ getValidationCatalog: vi.fn(),
50
+ resolveAgentConfig: vi.fn(),
51
+ resolveRuntimeCredentials,
52
+ };
51
53
 
52
54
  describe('GET /runs/jobs/current/agent-runtime-config', () => {
53
55
  let app: FastifyInstance;
@@ -76,7 +78,8 @@ describe('GET /runs/jobs/current/agent-runtime-config', () => {
76
78
  beforeEach(() => {
77
79
  clearLogLines();
78
80
  captureExceptionMock.mockReset();
79
- vi.mocked(resolveRuntimeCredentials).mockClear();
81
+ runtimeConfigs.clear();
82
+ resolveRuntimeCredentials.mockClear();
80
83
  });
81
84
 
82
85
  afterAll(async () => {
@@ -160,67 +163,6 @@ describe('GET /runs/jobs/current/agent-runtime-config', () => {
160
163
  expect(res.json().credentials.api_key).toBe('sk-gated-workspace-secret');
161
164
  });
162
165
 
163
- test('returns custom provider descriptors for a running leased custom agent step', async () => {
164
- const workspaceId = crypto.randomUUID();
165
- await createCustomModelProviderConfig(
166
- {
167
- workspaceId,
168
- body: {
169
- slug: 'local-vllm',
170
- display_name: 'Local vLLM',
171
- api: 'openai-responses',
172
- base_url: 'http://127.0.0.1:11434/v1',
173
- api_key: 'sk-local-secret',
174
- headers: [{name: 'authorization', value: 'Bearer local', secret: true}],
175
- models: [{id: 'llama-3.1', label: 'Llama 3.1'}],
176
- },
177
- },
178
- {probe: async () => undefined, secrets},
179
- );
180
- const {job, step} = await createRunningAgentStep({
181
- workspaceId,
182
- resolveAgentDefaults: () => ({
183
- harness: 'pi',
184
- provider: 'local-vllm',
185
- model: 'llama-3.1',
186
- thinking: 'high',
187
- }),
188
- steps: [
189
- {
190
- provider: 'local-vllm',
191
- model: 'llama-3.1',
192
- thinking: 'high',
193
- prompt: 'Fix the tests.',
194
- },
195
- ],
196
- });
197
- const token = await mintActiveLeaseToken({jobId: job.id});
198
-
199
- const res = await app.inject({
200
- method: 'GET',
201
- url: runtimeConfigUrl(step.id, step.currentAttempt),
202
- headers: {authorization: `Bearer ${token}`},
203
- });
204
-
205
- expect(res.statusCode).toBe(200);
206
- expect(res.json()).toMatchObject({
207
- harness: 'pi',
208
- provider_id: 'local-vllm',
209
- model: 'llama-3.1',
210
- credentials: {
211
- api_key: 'sk-local-secret',
212
- 'header:authorization': 'Bearer local',
213
- },
214
- custom_provider: {
215
- api: 'openai-responses',
216
- base_url: 'http://127.0.0.1:11434/v1',
217
- secret_header_names: ['authorization'],
218
- models: [{id: 'llama-3.1', label: 'Llama 3.1'}],
219
- requires_api_key: true,
220
- },
221
- });
222
- });
223
-
224
166
  test('returns 404 when the lease token is no longer the active job lease', async () => {
225
167
  const {run, job, step} = await createRunningAgentStep();
226
168
  await insertRunningJobLease({
@@ -348,10 +290,10 @@ describe('GET /runs/jobs/current/agent-runtime-config', () => {
348
290
 
349
291
  test('returns 409 and reports when workspace credentials cannot be decrypted', async () => {
350
292
  const {job, step} = await createRunningAgentStep();
351
- vi.mocked(resolveRuntimeCredentials).mockRejectedValueOnce(
293
+ resolveRuntimeCredentials.mockRejectedValueOnce(
352
294
  createInterModuleKnownError(
353
- secretsInterModuleContract.methods.getSecretsByNamespace,
354
- 'secret-decryption-failed',
295
+ agentInterModuleContract.methods.resolveRuntimeCredentials,
296
+ 'model-provider-credentials-invalid',
355
297
  {},
356
298
  ),
357
299
  );
@@ -397,15 +339,14 @@ function runtimeConfigUrl(stepId: string, attempt: number): string {
397
339
  return `${URL}?${search.toString()}`;
398
340
  }
399
341
 
400
- async function saveWorkspaceCredential(workspaceId: string, apiKey: string) {
401
- return await testAndSaveModelProviderConfig(
402
- {
403
- workspaceId,
404
- providerId: 'anthropic',
405
- credentials: {api_key: apiKey},
406
- },
407
- {probe: async () => undefined, secrets},
408
- );
342
+ function saveWorkspaceCredential(workspaceId: string, apiKey: string): void {
343
+ runtimeConfigs.set(workspaceId, {
344
+ harness: 'pi',
345
+ provider_id: 'anthropic',
346
+ model: 'claude-opus-4-8',
347
+ thinking: 'xhigh',
348
+ credentials: {api_key: apiKey},
349
+ });
409
350
  }
410
351
 
411
352
  type TestStepInput =
@@ -1,6 +1,6 @@
1
1
  import type {AnnotationsInterModuleClient} from '@shipfox/annotations-dto/inter-module';
2
2
  import {closeApp, createApp, type FastifyInstance} from '@shipfox/node-fastify';
3
- import {eq, sql} from 'drizzle-orm';
3
+ import {eq} from 'drizzle-orm';
4
4
  import {JobNotFoundError} from '#core/errors.js';
5
5
  import {recordStepResult as recordJobExecutionStepResult} from '#core/job-execution.js';
6
6
  import {db} from '#db/db.js';
@@ -21,7 +21,10 @@ import {
21
21
  mintLeaseToken,
22
22
  } from '#test/fixtures/lease-token.js';
23
23
  import {projectsTestClient} from '#test/fixtures/projects-inter-module.js';
24
- import {runnersTestClient} from '#test/fixtures/runners-inter-module.js';
24
+ import {
25
+ runnersTestClient,
26
+ setRunnerToolCapabilities as setTestRunnerToolCapabilities,
27
+ } from '#test/fixtures/runners-inter-module.js';
25
28
  import {createTestSecretsClient} from '#test/fixtures/secrets-inter-module.js';
26
29
  import {createLeaseTokenRouteGroup} from './index.js';
27
30
 
@@ -39,16 +42,11 @@ async function recordStepResult(
39
42
  return recordJobExecutionStepResult({...rest, jobExecutionId: step.jobExecutionId});
40
43
  }
41
44
 
42
- async function setRunnerToolCapabilities(
45
+ function setRunnerToolCapabilities(
43
46
  runnerSessionId: string,
44
- capabilities: Record<string, unknown>,
45
- ): Promise<void> {
46
- await db().execute(sql`
47
- UPDATE runners_runner_sessions
48
- SET tool_capabilities = ${JSON.stringify(capabilities)}::jsonb,
49
- tool_capabilities_reported_at = now()
50
- WHERE id = ${runnerSessionId}
51
- `);
47
+ capabilities: {harnesses: {pi?: {tools: string[]}; claude?: {tools: string[]}}},
48
+ ): void {
49
+ setTestRunnerToolCapabilities(runnerSessionId, {capabilities, reportFresh: true});
52
50
  }
53
51
 
54
52
  const annotationWrites = vi.fn<AnnotationsInterModuleClient['replaceOrRemoveAnnotation']>();
@@ -116,6 +114,9 @@ describe('POST /runs/jobs/current/steps/next', () => {
116
114
  test('returns the lowest-position pending step and marks it running', async () => {
117
115
  const {jobId, steps} = await arrangeJobWithSteps(3);
118
116
  const token = await mintActiveLeaseToken({jobId});
117
+ const lease = getLeaseTokenClaims(token);
118
+ if (!lease) throw new Error('Expected minted lease token to verify');
119
+ const getLeaseState = vi.spyOn(runnersTestClient, 'getLeaseState');
119
120
 
120
121
  const res = await app.inject({
121
122
  method: 'POST',
@@ -124,6 +125,11 @@ describe('POST /runs/jobs/current/steps/next', () => {
124
125
  });
125
126
 
126
127
  expect(res.statusCode).toBe(200);
128
+ expect(getLeaseState).toHaveBeenCalledWith({
129
+ jobId: lease.jobId,
130
+ jobExecutionId: lease.jobExecutionId,
131
+ runnerSessionId: lease.runnerSessionId,
132
+ });
127
133
  const body = res.json();
128
134
  expect(body.kind).toBe('step');
129
135
  expect(body.step.id).toBe(steps[0]?.id);
@@ -182,7 +188,7 @@ describe('POST /runs/jobs/current/steps/next', () => {
182
188
  const token = await mintActiveLeaseToken({jobId});
183
189
  const lease = getLeaseTokenClaims(token);
184
190
  if (!lease) throw new Error('Expected minted lease token to verify');
185
- await setRunnerToolCapabilities(lease.runnerSessionId, {harnesses: {pi: {tools: ['read']}}});
191
+ setRunnerToolCapabilities(lease.runnerSessionId, {harnesses: {pi: {tools: ['read']}}});
186
192
 
187
193
  const first = await app.inject({
188
194
  method: 'POST',
@@ -6,7 +6,7 @@ import type {RunnersInterModuleClient} from '@shipfox/api-runners-dto/inter-modu
6
6
  import {nextStepResponseSchema} from '@shipfox/api-workflows-dto';
7
7
  import {ClientError, defineRoute} from '@shipfox/node-fastify';
8
8
  import {warnAgentToolCapabilityMismatchOnDispatch} from '#core/agent-tool-capability-warning.js';
9
- import {JobLeaseNotActiveError, JobNotFoundError} from '#core/errors.js';
9
+ import {JobNotFoundError} from '#core/errors.js';
10
10
  import {nextStepForLeasedJobExecution} from '#core/job-execution.js';
11
11
  import {toStepDto} from '#presentation/dto/step.js';
12
12
 
@@ -30,17 +30,21 @@ export function createNextStepRoute(params: {
30
30
  if (error instanceof JobNotFoundError) {
31
31
  throw new ClientError(error.message, 'job-not-found', {status: 404});
32
32
  }
33
- if (error instanceof JobLeaseNotActiveError) {
34
- throw new ClientError('Job lease is no longer active', 'lease-not-active', {status: 404});
35
- }
36
33
  throw error;
37
34
  },
38
35
  handler: async (request) => {
39
36
  const leasedJob = requireLeasedJobContext(request);
40
- const next = await nextStepForLeasedJobExecution({
37
+ const {active: leaseIsActive} = await params.runners.getLeaseState({
41
38
  jobId: leasedJob.jobId,
42
39
  jobExecutionId: leasedJob.jobExecutionId,
43
40
  runnerSessionId: leasedJob.runnerSessionId,
41
+ });
42
+ if (!leaseIsActive) {
43
+ throw new ClientError('Job lease is no longer active', 'lease-not-active', {status: 404});
44
+ }
45
+
46
+ const next = await nextStepForLeasedJobExecution({
47
+ jobExecutionId: leasedJob.jobExecutionId,
44
48
  agent: params.agent,
45
49
  });
46
50
 
@@ -49,6 +49,7 @@ interface TestWorkflowJob {
49
49
  readonly success?: string | undefined;
50
50
  readonly outputs?: Readonly<Record<string, string>> | undefined;
51
51
  readonly env?: WorkflowModel['env'] | undefined;
52
+ readonly listening?: WorkflowModel['jobs'][number]['listening'] | undefined;
52
53
  readonly steps: readonly TestWorkflowStep[];
53
54
  }
54
55
 
@@ -70,7 +71,7 @@ export function workflowModel(input: TestWorkflowModelInput = {}): WorkflowModel
70
71
  return {
71
72
  id: jobId,
72
73
  key,
73
- mode: 'one_shot' as const,
74
+ mode: job.listening === undefined ? ('one_shot' as const) : ('listening' as const),
74
75
  runner: normalizeStringArray(job.runner ?? input.runner ?? DEFAULT_RUNNER_LABELS),
75
76
  ...(job.runnerTemplates === undefined
76
77
  ? {}
@@ -90,6 +91,7 @@ export function workflowModel(input: TestWorkflowModelInput = {}): WorkflowModel
90
91
  {kind: 'literal' as const, value: job.name},
91
92
  ],
92
93
  }),
94
+ ...(job.listening === undefined ? {} : {listening: job.listening}),
93
95
  ...optionalScopedEnv(job.env),
94
96
  dependencies: normalizeStringArray(job.needs).map(stableId),
95
97
  steps: job.steps.map((step, stepIndex) => normalizeStep(step, jobId, stepIndex)),
@@ -1,11 +1,10 @@
1
- import {db} from '#db/db.js';
2
- import {runnerSessions, runningJobExecutions} from '#db/runner-lease-table.js';
3
1
  import {
4
2
  getFirstJobExecutionByJobId,
5
3
  getJobById,
6
4
  getWorkflowRunByAttemptId,
7
5
  } from '#db/workflow-runs.js';
8
6
  import {mintLeaseToken} from './lease-token.js';
7
+ import {registerActiveRunnerLease} from './runners-inter-module.js';
9
8
 
10
9
  export interface MintActiveLeaseTokenParams {
11
10
  jobId: string;
@@ -26,13 +25,9 @@ export async function mintActiveLeaseToken(params: MintActiveLeaseTokenParams):
26
25
  if (!run) throw new Error('Expected workflow run to exist');
27
26
  const runnerSessionId = crypto.randomUUID();
28
27
 
29
- await insertRunningJobLease({
30
- workspaceId: run.workspaceId,
31
- workflowRunId: run.id,
32
- workflowRunAttemptId: job.workflowRunAttemptId,
28
+ registerActiveRunnerLease({
33
29
  jobId: params.jobId,
34
30
  jobExecutionId: jobExecution.id,
35
- projectId: run.projectId,
36
31
  runnerSessionId,
37
32
  });
38
33
 
@@ -57,29 +52,7 @@ export interface InsertRunningJobLeaseParams {
57
52
  runnerSessionId: string;
58
53
  }
59
54
 
60
- export async function insertRunningJobLease(params: InsertRunningJobLeaseParams): Promise<void> {
61
- await db()
62
- .insert(runnerSessions)
63
- .values({
64
- id: params.runnerSessionId,
65
- workspaceId: params.workspaceId,
66
- scope: 'workspace',
67
- registrationTokenId: crypto.randomUUID(),
68
- registrationTokenKind: 'manual',
69
- labels: ['linux'],
70
- });
71
-
72
- await db()
73
- .insert(runningJobExecutions)
74
- .values({
75
- workspaceId: params.workspaceId,
76
- workflowRunId: params.workflowRunId,
77
- workflowRunAttemptId: params.workflowRunAttemptId,
78
- jobId: params.jobId,
79
- jobExecutionId: params.jobExecutionId,
80
- projectId: params.projectId,
81
- runnerSessionId: params.runnerSessionId,
82
- requiredLabels: ['linux'],
83
- runnerLabels: ['linux'],
84
- });
55
+ export function insertRunningJobLease(params: InsertRunningJobLeaseParams): Promise<void> {
56
+ registerActiveRunnerLease(params);
57
+ return Promise.resolve();
85
58
  }
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  DEFAULT_HARNESS_TOOL_DEPLOYMENT_CONFIG,
3
- getModelProviderEntry,
4
3
  listEnabledHarnessTools,
5
4
  listHarnessDescriptors,
5
+ MODEL_PROVIDER_CATALOG_SEED,
6
6
  MODEL_PROVIDER_IDS,
7
7
  } from '@shipfox/api-agent-dto';
8
8
  import type {
@@ -15,7 +15,8 @@ export const agentValidationCatalog: AgentValidationCatalog = {
15
15
  version: 1,
16
16
  providers: MODEL_PROVIDER_IDS.map((id) => ({
17
17
  id,
18
- support_status: getModelProviderEntry(id)?.support_status ?? 'unsupported',
18
+ support_status:
19
+ MODEL_PROVIDER_CATALOG_SEED.find((entry) => entry.id === id)?.support_status ?? 'unsupported',
19
20
  })),
20
21
  harnesses: listHarnessDescriptors().map((harness) => ({
21
22
  id: harness.id,