@shipfox/api-runners 13.0.0 → 14.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-runners",
3
3
  "license": "MIT",
4
- "version": "13.0.0",
4
+ "version": "14.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -24,8 +24,8 @@
24
24
  "@shipfox/api-common-dto": "12.0.0",
25
25
  "@shipfox/api-auth-context": "12.2.0",
26
26
  "@shipfox/api-auth-dto": "12.0.0",
27
- "@shipfox/api-runners-dto": "13.0.0",
28
- "@shipfox/api-workflows-dto": "13.0.0",
27
+ "@shipfox/api-runners-dto": "14.0.0",
28
+ "@shipfox/api-workflows-dto": "14.0.0",
29
29
  "@shipfox/config": "1.2.4",
30
30
  "@shipfox/inter-module": "0.2.3",
31
31
  "@shipfox/node-drizzle": "0.3.5",
package/src/core/index.ts CHANGED
@@ -40,7 +40,9 @@ export {
40
40
  createAdministratorInstallationProvisionerToken,
41
41
  createInstallationProvisionerToken,
42
42
  createWorkspaceProvisionerToken,
43
+ type InstallationRunnersStatus,
43
44
  installationProvisionerTokenStatus,
45
+ installationRunnersStatus,
44
46
  listActiveProvisioners,
45
47
  listAdministratorInstallationProvisionerTokens,
46
48
  listUsableProvisionerTokens,
@@ -6,6 +6,7 @@ import {provisionerTokenFactory} from '#test/index.js';
6
6
  import {
7
7
  createInstallationProvisionerToken,
8
8
  createWorkspaceProvisionerToken,
9
+ installationRunnersStatus,
9
10
  listUsableProvisionerTokens,
10
11
  revokeInstallationProvisionerToken,
11
12
  revokeWorkspaceProvisionerToken,
@@ -79,3 +80,48 @@ describe('provisioner token core', () => {
79
80
  await expect(result).rejects.toThrow(`Provisioner token not found: ${token.id}`);
80
81
  });
81
82
  });
83
+
84
+ describe('installationRunnersStatus', () => {
85
+ beforeEach(async () => {
86
+ await db().delete(provisionerTokens).where(eq(provisionerTokens.scope, 'installation'));
87
+ });
88
+
89
+ it('reports managed when reserved labels are configured, without any token', async () => {
90
+ await expect(installationRunnersStatus(['linux'])).resolves.toBe('managed');
91
+ });
92
+
93
+ it('reports none without reserved labels and without installation tokens', async () => {
94
+ await expect(installationRunnersStatus([])).resolves.toBe('none');
95
+ });
96
+
97
+ it('reports managed when an installation token is active', async () => {
98
+ await provisionerTokenFactory.create({scope: 'installation'});
99
+
100
+ await expect(installationRunnersStatus([])).resolves.toBe('managed');
101
+ });
102
+
103
+ it('reports none when the only installation token has expired', async () => {
104
+ await provisionerTokenFactory.create({
105
+ scope: 'installation',
106
+ expiresAt: new Date(Date.now() - 60_000),
107
+ });
108
+
109
+ await expect(installationRunnersStatus([])).resolves.toBe('none');
110
+ });
111
+
112
+ it('reports none when the only installation token is revoked', async () => {
113
+ const token = await provisionerTokenFactory.create({scope: 'installation'});
114
+ await revokeInstallationProvisionerToken({
115
+ tokenId: token.id,
116
+ revokedByUserId: crypto.randomUUID(),
117
+ });
118
+
119
+ await expect(installationRunnersStatus([])).resolves.toBe('none');
120
+ });
121
+
122
+ it('ignores workspace-scope tokens', async () => {
123
+ await provisionerTokenFactory.create({workspaceId: crypto.randomUUID()});
124
+
125
+ await expect(installationRunnersStatus([])).resolves.toBe('none');
126
+ });
127
+ });
@@ -14,7 +14,7 @@ import {
14
14
  revokeInstallationProvisionerTokenWithAudit,
15
15
  revokeProvisionerToken,
16
16
  } from '#db/provisioner-tokens.js';
17
- import {config} from '../config.js';
17
+ import {config, runnerReservedLabels} from '../config.js';
18
18
  import type {ActiveProvisionerToken, ProvisionerToken} from './entities/provisioner-token.js';
19
19
  import {
20
20
  ProvisionerAdminIdempotencyReplayUnavailableError,
@@ -167,6 +167,22 @@ export function installationProvisionerTokenStatus(
167
167
  return 'active';
168
168
  }
169
169
 
170
+ export type InstallationRunnersStatus = 'managed' | 'none';
171
+
172
+ /**
173
+ * Whether the installation itself provides runner capacity. Both signals are
174
+ * declarations of intent, not heartbeats: a quiet hosted fleet must not flip
175
+ * the value. Reserved labels dominate even when every installation token has
176
+ * expired or been revoked.
177
+ */
178
+ export async function installationRunnersStatus(
179
+ reservedLabels: readonly string[] = runnerReservedLabels,
180
+ ): Promise<InstallationRunnersStatus> {
181
+ if (reservedLabels.length > 0) return 'managed';
182
+ const {tokens} = await listInstallationProvisionerTokens({limit: 1, status: 'active'});
183
+ return tokens.length > 0 ? 'managed' : 'none';
184
+ }
185
+
170
186
  export async function listAdministratorInstallationProvisionerTokens(params: {
171
187
  limit: number;
172
188
  cursor?: TimestampIdCursor | undefined;
@@ -482,6 +482,79 @@ describe('claimPendingJobExecution', () => {
482
482
  expect(runner?.firstClaimedAt).toBeInstanceOf(Date);
483
483
  });
484
484
 
485
+ it('releases a provisioned runner reservation on its first claim only', async () => {
486
+ const provisionerId = crypto.randomUUID();
487
+ const providerRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
488
+ const [reservation] = await db()
489
+ .insert(reservations)
490
+ .values({
491
+ workspaceId,
492
+ provisionerId,
493
+ requiredLabels: sessionLabels,
494
+ count: 2,
495
+ expiresAt: new Date(Date.now() + 60_000),
496
+ })
497
+ .returning({id: reservations.id});
498
+ if (!reservation) throw new Error('Expected reservation');
499
+
500
+ const runner = await providerRunnerFactory.create({
501
+ workspaceId,
502
+ provisionerId,
503
+ providerRunnerId,
504
+ reservationId: reservation.id,
505
+ runnerSessionId,
506
+ state: 'running',
507
+ labels: sessionLabels,
508
+ });
509
+ await db()
510
+ .update(runnerSessions)
511
+ .set({
512
+ registrationTokenKind: 'ephemeral',
513
+ maxClaims: 2,
514
+ provisionerId,
515
+ providerRunnerId,
516
+ })
517
+ .where(eq(runnerSessions.id, runnerSessionId));
518
+
519
+ const firstPending = await pendingJobFactory.create({workspaceId});
520
+ const firstClaim = await claimPendingJobExecution({
521
+ workspaceId,
522
+ runnerSessionId,
523
+ maxClaims: 2,
524
+ });
525
+ const [afterFirstClaim] = await db()
526
+ .select({count: reservations.count})
527
+ .from(reservations)
528
+ .where(eq(reservations.id, reservation.id));
529
+ const [runnerAfterFirstClaim] = await db()
530
+ .select({reservationReleasedAt: providerRunners.reservationReleasedAt})
531
+ .from(providerRunners)
532
+ .where(eq(providerRunners.id, runner.id));
533
+ const firstReleaseAt = runnerAfterFirstClaim?.reservationReleasedAt;
534
+
535
+ const secondPending = await pendingJobFactory.create({workspaceId});
536
+ const secondClaim = await claimPendingJobExecution({
537
+ workspaceId,
538
+ runnerSessionId,
539
+ maxClaims: 2,
540
+ });
541
+ const [afterSecondClaim] = await db()
542
+ .select({count: reservations.count})
543
+ .from(reservations)
544
+ .where(eq(reservations.id, reservation.id));
545
+ const [runnerAfterSecondClaim] = await db()
546
+ .select({reservationReleasedAt: providerRunners.reservationReleasedAt})
547
+ .from(providerRunners)
548
+ .where(eq(providerRunners.id, runner.id));
549
+
550
+ expect(firstClaim?.jobExecutionId).toBe(firstPending.jobExecutionId);
551
+ expect(afterFirstClaim?.count).toBe(1);
552
+ expect(firstReleaseAt).toBeInstanceOf(Date);
553
+ expect(secondClaim?.jobExecutionId).toBe(secondPending.jobExecutionId);
554
+ expect(afterSecondClaim?.count).toBe(1);
555
+ expect(runnerAfterSecondClaim?.reservationReleasedAt).toEqual(firstReleaseAt);
556
+ });
557
+
485
558
  it('allows a manual session to claim repeatedly', async () => {
486
559
  const first = await pendingJobFactory.create({workspaceId});
487
560
  const second = await pendingJobFactory.create({workspaceId});
@@ -1037,7 +1110,7 @@ describe('reconcileTerminalJobExecution', () => {
1037
1110
  expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
1038
1111
  });
1039
1112
 
1040
- it('releases an already-terminal runner reservation after cancelling its last lease', async () => {
1113
+ it('does not double-release a reservation when a claimed runner becomes terminal', async () => {
1041
1114
  const provisionerId = crypto.randomUUID();
1042
1115
  const providerRunnerId = crypto.randomUUID();
1043
1116
  const [reservation] = await db()
@@ -1046,7 +1119,7 @@ describe('reconcileTerminalJobExecution', () => {
1046
1119
  workspaceId,
1047
1120
  provisionerId,
1048
1121
  requiredLabels: sessionLabels,
1049
- count: 1,
1122
+ count: 2,
1050
1123
  expiresAt: new Date(Date.now() + 60_000),
1051
1124
  })
1052
1125
  .returning({id: reservations.id});
@@ -1057,12 +1130,29 @@ describe('reconcileTerminalJobExecution', () => {
1057
1130
  providerRunnerId,
1058
1131
  reservationId: reservation.id,
1059
1132
  runnerSessionId,
1060
- state: 'terminated',
1061
- terminatedAt: new Date(),
1133
+ state: 'running',
1062
1134
  });
1135
+ await db()
1136
+ .update(runnerSessions)
1137
+ .set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, providerRunnerId})
1138
+ .where(eq(runnerSessions.id, runnerSessionId));
1063
1139
  const pending = await pendingJobFactory.create({workspaceId});
1064
- const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
1140
+ const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
1065
1141
  expect(claimed?.jobExecutionId).toBe(pending.jobExecutionId);
1142
+ const [afterClaim] = await db()
1143
+ .select({count: reservations.count})
1144
+ .from(reservations)
1145
+ .where(eq(reservations.id, reservation.id));
1146
+ const [runnerAfterClaim] = await db()
1147
+ .select({reservationReleasedAt: providerRunners.reservationReleasedAt})
1148
+ .from(providerRunners)
1149
+ .where(eq(providerRunners.providerRunnerId, providerRunnerId));
1150
+ expect(afterClaim?.count).toBe(1);
1151
+ expect(runnerAfterClaim?.reservationReleasedAt).toBeInstanceOf(Date);
1152
+ await db()
1153
+ .update(providerRunners)
1154
+ .set({state: 'terminated', terminatedAt: new Date()})
1155
+ .where(eq(providerRunners.providerRunnerId, providerRunnerId));
1066
1156
  await db()
1067
1157
  .update(runningJobExecutions)
1068
1158
  .set({provisionerId, providerRunnerId})
@@ -1070,9 +1160,11 @@ describe('reconcileTerminalJobExecution', () => {
1070
1160
 
1071
1161
  await reconcileTerminalJobExecution({jobExecutionId: pending.jobExecutionId});
1072
1162
 
1073
- expect(
1074
- await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
1075
- ).toHaveLength(0);
1163
+ const [afterTerminal] = await db()
1164
+ .select({count: reservations.count})
1165
+ .from(reservations)
1166
+ .where(eq(reservations.id, reservation.id));
1167
+ expect(afterTerminal?.count).toBe(1);
1076
1168
  const [runner] = await db()
1077
1169
  .select({reservationReleasedAt: providerRunners.reservationReleasedAt})
1078
1170
  .from(providerRunners)
@@ -38,9 +38,14 @@ import {
38
38
  } from '#metrics/instance.js';
39
39
  import type {Tx} from './db.js';
40
40
  import {db} from './db.js';
41
- import {releaseTerminalRunnerInstanceReservationsByIds} from './reservations.js';
41
+ import {lockRunnerReservationAdvisoryKeysTx} from './reservation-locks.js';
42
+ import {
43
+ releaseReservationUnits,
44
+ releaseTerminalRunnerInstanceReservationsByIds,
45
+ } from './reservations.js';
42
46
  import {runnersOutbox} from './schema/outbox.js';
43
47
  import {pendingJobExecutions} from './schema/pending-job-executions.js';
48
+ import {reservations} from './schema/reservations.js';
44
49
  import {providerRunners} from './schema/runner-instances.js';
45
50
  import {runnerSessions} from './schema/runner-sessions.js';
46
51
  import {runningJobExecutions} from './schema/running-job-executions.js';
@@ -262,6 +267,32 @@ export async function claimPendingJobExecution(params: {
262
267
  providerRunnerId = session.providerRunnerId;
263
268
  }
264
269
 
270
+ const runnerInstanceCondition = runnerInstanceId
271
+ ? eq(providerRunners.id, runnerInstanceId)
272
+ : provisionerId && providerRunnerId
273
+ ? and(
274
+ eq(providerRunners.provisionerId, provisionerId),
275
+ eq(providerRunners.providerRunnerId, providerRunnerId),
276
+ )
277
+ : null;
278
+ if (runnerInstanceCondition && provisionerId) {
279
+ const [runner] = await tx
280
+ .select({
281
+ reservationId: providerRunners.reservationId,
282
+ intendedReservationId: providerRunners.intendedReservationId,
283
+ })
284
+ .from(providerRunners)
285
+ .where(runnerInstanceCondition)
286
+ .limit(1);
287
+ await lockRunnerReservationAdvisoryKeysTx(tx, {
288
+ provisionerId,
289
+ reservationIds: [runner?.reservationId, runner?.intendedReservationId].filter(
290
+ (reservationId): reservationId is string =>
291
+ reservationId !== null && reservationId !== undefined,
292
+ ),
293
+ });
294
+ }
295
+
265
296
  // `id` is a uuidv7 (time-ordered), so it is a deterministic FIFO tiebreaker
266
297
  // for rows sharing a created_at within a batch. Lock only the FIFO candidate before
267
298
  // attempting its execution advisory lock; putting pg_try_advisory_xact_lock in this
@@ -328,14 +359,6 @@ export async function claimPendingJobExecution(params: {
328
359
  launchKind: params.maxClaims === null ? 'manual' : 'unknown',
329
360
  };
330
361
 
331
- const runnerInstanceCondition = runnerInstanceId
332
- ? eq(providerRunners.id, runnerInstanceId)
333
- : provisionerId && providerRunnerId
334
- ? and(
335
- eq(providerRunners.provisionerId, provisionerId),
336
- eq(providerRunners.providerRunnerId, providerRunnerId),
337
- )
338
- : null;
339
362
  if (runnerInstanceCondition) {
340
363
  const [claimedRunner] = await tx
341
364
  .update(providerRunners)
@@ -350,6 +373,8 @@ export async function claimPendingJobExecution(params: {
350
373
  provider: providerRunners.providerKind,
351
374
  launchKind: providerRunners.launchKind,
352
375
  runnerInstanceId: providerRunners.id,
376
+ reservationId: providerRunners.reservationId,
377
+ intendedReservationId: providerRunners.intendedReservationId,
353
378
  sessionCreatedAtEpochMs: sql<number | null>`(
354
379
  select extract(epoch from ${runnerSessions.createdAt})::double precision * 1000
355
380
  from ${runnerSessions}
@@ -360,6 +385,47 @@ export async function claimPendingJobExecution(params: {
360
385
  queueTimeObservation.provider = claimedRunner.provider;
361
386
  queueTimeObservation.launchKind = claimedRunner.launchKind;
362
387
  }
388
+ if (claimedRunner?.isFirstClaim) {
389
+ const [releasedRunner] = await tx
390
+ .update(providerRunners)
391
+ .set({
392
+ intendedReservationId: null,
393
+ reservationReleasedAt: sql`now()`,
394
+ updatedAt: sql`now()`,
395
+ })
396
+ .where(
397
+ and(
398
+ eq(providerRunners.id, claimedRunner.runnerInstanceId),
399
+ or(
400
+ isNotNull(providerRunners.reservationId),
401
+ isNotNull(providerRunners.intendedReservationId),
402
+ ),
403
+ isNull(providerRunners.reservationReleasedAt),
404
+ ),
405
+ )
406
+ .returning({
407
+ provisionerId: providerRunners.provisionerId,
408
+ });
409
+ const reservationId = claimedRunner.intendedReservationId ?? claimedRunner.reservationId;
410
+ if (releasedRunner?.provisionerId && reservationId) {
411
+ const [reservation] = await tx
412
+ .select({workspaceId: reservations.workspaceId})
413
+ .from(reservations)
414
+ .where(
415
+ and(
416
+ eq(reservations.id, reservationId),
417
+ eq(reservations.provisionerId, releasedRunner.provisionerId),
418
+ ),
419
+ )
420
+ .limit(1);
421
+ if (reservation)
422
+ await releaseReservationUnits(tx, {
423
+ workspaceId: reservation.workspaceId,
424
+ provisionerId: releasedRunner.provisionerId,
425
+ releases: [{reservationId, count: 1}],
426
+ });
427
+ }
428
+ }
363
429
  if (
364
430
  claimedRunner?.isFirstClaim &&
365
431
  claimedRunner.firstClaimedAt !== null &&
@@ -2,24 +2,41 @@ import {requireWorkspaceAccess} from '@shipfox/api-auth-context';
2
2
  import {listActiveProvisionersResponseSchema} from '@shipfox/api-runners-dto';
3
3
  import {defineRoute} from '@shipfox/node-fastify';
4
4
  import {z} from 'zod';
5
- import {listActiveProvisioners} from '#core/index.js';
5
+ import {installationRunnersStatus, listActiveProvisioners} from '#core/index.js';
6
6
  import {toActiveProvisionerDto} from '#presentation/dto/index.js';
7
7
 
8
- export const listActiveProvisionersRoute = defineRoute({
9
- method: 'GET',
10
- path: '/',
11
- description: 'List active provisioners for a workspace',
12
- schema: {
13
- params: z.object({workspaceId: z.string().uuid()}),
14
- response: {
15
- 200: listActiveProvisionersResponseSchema,
8
+ type InstallationRunnersStatusReader = () => ReturnType<typeof installationRunnersStatus>;
9
+
10
+ export function createListActiveProvisionersRoute(
11
+ readInstallationRunnersStatus: InstallationRunnersStatusReader = installationRunnersStatus,
12
+ ) {
13
+ return defineRoute({
14
+ method: 'GET',
15
+ path: '/',
16
+ description: 'List active provisioners for a workspace',
17
+ schema: {
18
+ params: z.object({workspaceId: z.string().uuid()}),
19
+ response: {
20
+ 200: listActiveProvisionersResponseSchema,
21
+ },
22
+ },
23
+ handler: async (request) => {
24
+ const {workspaceId} = request.params;
25
+ requireWorkspaceAccess({request, workspaceId});
26
+
27
+ const [provisioners, installationRunners] = await Promise.all([
28
+ listActiveProvisioners(workspaceId),
29
+ readInstallationRunnersStatus().catch((error: unknown) => {
30
+ request.log.warn({err: error}, 'Could not resolve installation runner status');
31
+ return 'none' as const;
32
+ }),
33
+ ]);
34
+ return {
35
+ provisioners: provisioners.map(toActiveProvisionerDto),
36
+ installation_runners: installationRunners,
37
+ };
16
38
  },
17
- },
18
- handler: async (request) => {
19
- const {workspaceId} = request.params;
20
- requireWorkspaceAccess({request, workspaceId});
39
+ });
40
+ }
21
41
 
22
- const provisioners = await listActiveProvisioners(workspaceId);
23
- return {provisioners: provisioners.map(toActiveProvisionerDto)};
24
- },
25
- });
42
+ export const listActiveProvisionersRoute = createListActiveProvisionersRoute();
@@ -15,6 +15,7 @@ import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
15
15
  import {createProvisionerTokenAuthMethod} from '#presentation/auth/index.js';
16
16
  import {provisionerTokenFactory} from '#test/index.js';
17
17
  import {provisionerRoutes} from './index.js';
18
+ import {createListActiveProvisionersRoute} from './list-active-provisioners.js';
18
19
 
19
20
  const userId = crypto.randomUUID();
20
21
  let authenticatedMemberships: ReadonlyArray<UserContextMembership> = [];
@@ -141,8 +142,51 @@ describe('provisioner token routes', () => {
141
142
  last_seen_at: expect.any(String),
142
143
  }),
143
144
  ],
145
+ // The test environment configures RUNNER_RESERVED_LABELS, so the
146
+ // installation always reports managed runner capacity.
147
+ installation_runners: 'managed',
144
148
  });
145
149
  });
150
+
151
+ it('reports managed installation runners from the reserved labels signal alone', async () => {
152
+ const res = await app.inject({
153
+ method: 'GET',
154
+ url: `/workspaces/${workspaceId}/provisioners/active`,
155
+ headers: {authorization: 'Bearer user'},
156
+ });
157
+
158
+ expect(res.statusCode).toBe(200);
159
+ expect(res.json().installation_runners).toBe('managed');
160
+ });
161
+
162
+ it('keeps active provisioners available when installation status is unavailable', async () => {
163
+ await closeApp();
164
+ app = await createApp({
165
+ auth: [fakeUserAuth, createProvisionerTokenAuthMethod()],
166
+ routes: [
167
+ {
168
+ prefix: '/workspaces/:workspaceId/provisioners/active',
169
+ auth: AUTH_USER,
170
+ routes: [
171
+ createListActiveProvisionersRoute(() =>
172
+ Promise.reject(new Error('status lookup unavailable')),
173
+ ),
174
+ ],
175
+ },
176
+ ],
177
+ swagger: false,
178
+ });
179
+ await app.ready();
180
+
181
+ const res = await app.inject({
182
+ method: 'GET',
183
+ url: `/workspaces/${workspaceId}/provisioners/active`,
184
+ headers: {authorization: 'Bearer user'},
185
+ });
186
+
187
+ expect(res.statusCode).toBe(200);
188
+ expect(res.json()).toEqual({provisioners: [], installation_runners: 'none'});
189
+ });
146
190
  });
147
191
 
148
192
  describe('POST /workspaces/:workspaceId/provisioners/tokens', () => {