@shipfox/api-runners 10.0.0 → 10.2.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 (118) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +28 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +8 -13
  5. package/dist/config.js.map +1 -1
  6. package/dist/core/admin-runner-instances.d.ts +34 -0
  7. package/dist/core/admin-runner-instances.d.ts.map +1 -0
  8. package/dist/core/admin-runner-instances.js +58 -0
  9. package/dist/core/admin-runner-instances.js.map +1 -0
  10. package/dist/core/errors.d.ts +6 -0
  11. package/dist/core/errors.d.ts.map +1 -1
  12. package/dist/core/errors.js +12 -0
  13. package/dist/core/errors.js.map +1 -1
  14. package/dist/core/index.d.ts +3 -2
  15. package/dist/core/index.d.ts.map +1 -1
  16. package/dist/core/index.js +3 -2
  17. package/dist/core/index.js.map +1 -1
  18. package/dist/core/provisioner-tokens.d.ts +33 -0
  19. package/dist/core/provisioner-tokens.d.ts.map +1 -1
  20. package/dist/core/provisioner-tokens.js +107 -2
  21. package/dist/core/provisioner-tokens.js.map +1 -1
  22. package/dist/db/admin-runner-instances.d.ts +34 -0
  23. package/dist/db/admin-runner-instances.d.ts.map +1 -0
  24. package/dist/db/admin-runner-instances.js +62 -0
  25. package/dist/db/admin-runner-instances.js.map +1 -0
  26. package/dist/db/db.d.ts +290 -0
  27. package/dist/db/db.d.ts.map +1 -1
  28. package/dist/db/db.js +2 -0
  29. package/dist/db/db.js.map +1 -1
  30. package/dist/db/index.d.ts +3 -1
  31. package/dist/db/index.d.ts.map +1 -1
  32. package/dist/db/index.js +2 -1
  33. package/dist/db/index.js.map +1 -1
  34. package/dist/db/job-executions.d.ts.map +1 -1
  35. package/dist/db/job-executions.js +11 -0
  36. package/dist/db/job-executions.js.map +1 -1
  37. package/dist/db/provisioner-tokens.d.ts +38 -0
  38. package/dist/db/provisioner-tokens.d.ts.map +1 -1
  39. package/dist/db/provisioner-tokens.js +157 -3
  40. package/dist/db/provisioner-tokens.js.map +1 -1
  41. package/dist/db/reservations.d.ts.map +1 -1
  42. package/dist/db/reservations.js +60 -8
  43. package/dist/db/reservations.js.map +1 -1
  44. package/dist/db/schema/admin-command-results.d.ts +134 -0
  45. package/dist/db/schema/admin-command-results.d.ts.map +1 -0
  46. package/dist/db/schema/admin-command-results.js +18 -0
  47. package/dist/db/schema/admin-command-results.js.map +1 -0
  48. package/dist/db/schema/runner-instances.d.ts +17 -0
  49. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  50. package/dist/db/schema/runner-instances.js +3 -0
  51. package/dist/db/schema/runner-instances.js.map +1 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +6 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/presentation/dto/admin-provisioner-token.d.ts +16 -0
  56. package/dist/presentation/dto/admin-provisioner-token.d.ts.map +1 -0
  57. package/dist/presentation/dto/admin-provisioner-token.js +22 -0
  58. package/dist/presentation/dto/admin-provisioner-token.js.map +1 -0
  59. package/dist/presentation/dto/admin-runner-instances.d.ts +23 -0
  60. package/dist/presentation/dto/admin-runner-instances.d.ts.map +1 -0
  61. package/dist/presentation/dto/admin-runner-instances.js +19 -0
  62. package/dist/presentation/dto/admin-runner-instances.js.map +1 -0
  63. package/dist/presentation/dto/index.d.ts +2 -0
  64. package/dist/presentation/dto/index.d.ts.map +1 -1
  65. package/dist/presentation/dto/index.js +2 -0
  66. package/dist/presentation/dto/index.js.map +1 -1
  67. package/dist/presentation/routes/admin-provisioner-tokens.d.ts +4 -0
  68. package/dist/presentation/routes/admin-provisioner-tokens.d.ts.map +1 -0
  69. package/dist/presentation/routes/admin-provisioner-tokens.js +167 -0
  70. package/dist/presentation/routes/admin-provisioner-tokens.js.map +1 -0
  71. package/dist/presentation/routes/admin-runner-instances.d.ts +3 -0
  72. package/dist/presentation/routes/admin-runner-instances.d.ts.map +1 -0
  73. package/dist/presentation/routes/admin-runner-instances.js +63 -0
  74. package/dist/presentation/routes/admin-runner-instances.js.map +1 -0
  75. package/dist/presentation/routes/index.d.ts.map +1 -1
  76. package/dist/presentation/routes/index.js +11 -1
  77. package/dist/presentation/routes/index.js.map +1 -1
  78. package/dist/presentation/routes/runner-enrollment.d.ts +1 -0
  79. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  80. package/dist/presentation/routes/runner-enrollment.js +11 -4
  81. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  82. package/dist/tsconfig.test.tsbuildinfo +1 -1
  83. package/drizzle/0002_special_shadowcat.sql +1 -0
  84. package/drizzle/0003_flaky_sally_floyd.sql +11 -0
  85. package/drizzle/meta/0002_snapshot.json +2099 -0
  86. package/drizzle/meta/0003_snapshot.json +2178 -0
  87. package/drizzle/meta/_journal.json +14 -0
  88. package/package.json +10 -9
  89. package/src/config.test.ts +11 -0
  90. package/src/config.ts +19 -8
  91. package/src/core/admin-runner-instances.ts +125 -0
  92. package/src/core/errors.ts +14 -0
  93. package/src/core/index.ts +12 -0
  94. package/src/core/provisioner-tokens.ts +157 -1
  95. package/src/db/admin-runner-instances.ts +121 -0
  96. package/src/db/db.ts +2 -0
  97. package/src/db/index.ts +10 -0
  98. package/src/db/job-executions.test.ts +17 -0
  99. package/src/db/job-executions.ts +22 -0
  100. package/src/db/provisioner-tokens.ts +263 -9
  101. package/src/db/reservations.test.ts +253 -0
  102. package/src/db/reservations.ts +170 -15
  103. package/src/db/schema/admin-command-results.ts +29 -0
  104. package/src/db/schema/runner-instances.ts +1 -0
  105. package/src/index.test.ts +1 -1
  106. package/src/index.ts +6 -1
  107. package/src/presentation/dto/admin-provisioner-token.ts +23 -0
  108. package/src/presentation/dto/admin-runner-instances.ts +19 -0
  109. package/src/presentation/dto/index.ts +2 -0
  110. package/src/presentation/routes/admin-provisioner-tokens.test.ts +227 -0
  111. package/src/presentation/routes/admin-provisioner-tokens.ts +177 -0
  112. package/src/presentation/routes/admin-runner-instances.test.ts +671 -0
  113. package/src/presentation/routes/admin-runner-instances.ts +67 -0
  114. package/src/presentation/routes/index.ts +8 -0
  115. package/src/presentation/routes/runner-enrollment.test.ts +60 -2
  116. package/src/presentation/routes/runner-enrollment.ts +24 -4
  117. package/test/globalSetup.ts +1 -0
  118. package/tsconfig.build.tsbuildinfo +1 -1
@@ -27,6 +27,7 @@ import {
27
27
  } from './job-executions.js';
28
28
  import {runnersOutbox} from './schema/outbox.js';
29
29
  import {pendingJobExecutions} from './schema/pending-job-executions.js';
30
+ import {providerRunners} from './schema/runner-instances.js';
30
31
  import {runnerSessions} from './schema/runner-sessions.js';
31
32
  import {runningJobExecutions} from './schema/running-job-executions.js';
32
33
 
@@ -408,10 +409,20 @@ describe('claimPendingJobExecution', () => {
408
409
  it('does not spend a claim when a capped session polls an empty queue', async () => {
409
410
  const provisionerId = crypto.randomUUID();
410
411
  const providerRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
412
+ const runnerInstanceId = crypto.randomUUID();
411
413
  await db()
412
414
  .update(runnerSessions)
413
415
  .set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, providerRunnerId})
414
416
  .where(eq(runnerSessions.id, runnerSessionId));
417
+ await db().insert(providerRunners).values({
418
+ id: runnerInstanceId,
419
+ workspaceId,
420
+ provisionerId,
421
+ providerRunnerId,
422
+ labels: sessionLabels,
423
+ state: 'running',
424
+ reportedAt: new Date(),
425
+ });
415
426
 
416
427
  const empty = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
417
428
 
@@ -431,6 +442,12 @@ describe('claimPendingJobExecution', () => {
431
442
  .where(eq(runnerSessions.id, runnerSessionId));
432
443
  expect(claimed?.jobId).toBe(created.jobId);
433
444
  expect(afterClaim?.claimsUsed).toBe(1);
445
+
446
+ const [runner] = await db()
447
+ .select({firstClaimedAt: providerRunners.firstClaimedAt})
448
+ .from(providerRunners)
449
+ .where(eq(providerRunners.id, runnerInstanceId));
450
+ expect(runner?.firstClaimedAt).toBeInstanceOf(Date);
434
451
  });
435
452
 
436
453
  it('allows a manual session to claim repeatedly', async () => {
@@ -34,6 +34,7 @@ import type {Tx} from './db.js';
34
34
  import {db} from './db.js';
35
35
  import {runnersOutbox} from './schema/outbox.js';
36
36
  import {pendingJobExecutions} from './schema/pending-job-executions.js';
37
+ import {providerRunners} from './schema/runner-instances.js';
37
38
  import {runnerSessions} from './schema/runner-sessions.js';
38
39
  import {runningJobExecutions} from './schema/running-job-executions.js';
39
40
 
@@ -174,6 +175,7 @@ export async function claimPendingJobExecution(params: {
174
175
  return await db().transaction(async (tx) => {
175
176
  let provisionerId: string | null = null;
176
177
  let providerRunnerId: string | null = null;
178
+ let runnerInstanceId: string | null = null;
177
179
 
178
180
  if (params.maxClaims !== null) {
179
181
  const [session] = await tx
@@ -181,6 +183,7 @@ export async function claimPendingJobExecution(params: {
181
183
  maxClaims: runnerSessions.maxClaims,
182
184
  claimsUsed: runnerSessions.claimsUsed,
183
185
  revokedAt: runnerSessions.revokedAt,
186
+ runnerInstanceId: runnerSessions.runnerInstanceId,
184
187
  provisionerId: runnerSessions.provisionerId,
185
188
  providerRunnerId: runnerSessions.providerRunnerId,
186
189
  })
@@ -200,6 +203,7 @@ export async function claimPendingJobExecution(params: {
200
203
 
201
204
  // Ephemeral sessions are the only capped sessions, and the DB check keeps
202
205
  // their provisioned-runner link present as a pair.
206
+ runnerInstanceId = session.runnerInstanceId;
203
207
  provisionerId = session.provisionerId;
204
208
  providerRunnerId = session.providerRunnerId;
205
209
  }
@@ -250,6 +254,24 @@ export async function claimPendingJobExecution(params: {
250
254
  const claimed = inserted[0];
251
255
  if (!claimed) return null;
252
256
 
257
+ const runnerInstanceCondition = runnerInstanceId
258
+ ? eq(providerRunners.id, runnerInstanceId)
259
+ : provisionerId && providerRunnerId
260
+ ? and(
261
+ eq(providerRunners.provisionerId, provisionerId),
262
+ eq(providerRunners.providerRunnerId, providerRunnerId),
263
+ )
264
+ : null;
265
+ if (runnerInstanceCondition) {
266
+ await tx
267
+ .update(providerRunners)
268
+ .set({
269
+ firstClaimedAt: sql`coalesce(${providerRunners.firstClaimedAt}, ${claimed.claimedAt})`,
270
+ updatedAt: sql`now()`,
271
+ })
272
+ .where(runnerInstanceCondition);
273
+ }
274
+
253
275
  if (params.maxClaims !== null) {
254
276
  await tx
255
277
  .update(runnerSessions)
@@ -1,16 +1,27 @@
1
- import {and, desc, eq, gt, inArray, isNull, or, sql} from 'drizzle-orm';
1
+ import type {
2
+ AdministrationActionEvent,
3
+ AdministrationActionEventMap,
4
+ } from '@shipfox/api-common-dto';
5
+ import {type TimestampIdCursor, timestampIdCursorWhere} from '@shipfox/node-drizzle';
6
+ import {writeOutboxEvent} from '@shipfox/node-outbox';
7
+ import {and, desc, eq, gt, inArray, isNotNull, isNull, lte, or, type SQL, sql} from 'drizzle-orm';
2
8
  import type {
3
9
  ActiveProvisionerToken,
4
10
  ProvisionerScope,
5
11
  ProvisionerToken,
6
12
  } from '#core/entities/provisioner-token.js';
13
+ import {ProvisionerAdminIdempotencyKeyReuseError} from '#core/errors.js';
7
14
  import {db} from './db.js';
15
+ import {runnersAdminCommandResults} from './schema/admin-command-results.js';
16
+ import {runnersOutbox} from './schema/outbox.js';
8
17
  import {provisionerTokens, toProvisionerToken} from './schema/provisioner-tokens.js';
9
18
  import {runnerActivationTokens} from './schema/runner-activation-tokens.js';
10
19
  import {runnerBootstrapTokens, runnerControlSessions} from './schema/runner-control-sessions.js';
11
20
  import {providerRunners} from './schema/runner-instances.js';
12
21
  import {runnerSessions} from './schema/runner-sessions.js';
13
22
 
23
+ type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
24
+
14
25
  export interface CreateProvisionerTokenParams {
15
26
  scope: ProvisionerScope;
16
27
  workspaceId?: string | undefined;
@@ -21,10 +32,7 @@ export interface CreateProvisionerTokenParams {
21
32
  expiresAt?: Date | undefined;
22
33
  }
23
34
 
24
- async function cascadeProvisionerRevocation(
25
- tx: Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0],
26
- provisionerId: string,
27
- ) {
35
+ async function cascadeProvisionerRevocation(tx: Tx, provisionerId: string) {
28
36
  await tx
29
37
  .update(runnerBootstrapTokens)
30
38
  .set({revokedAt: sql`now()`})
@@ -75,10 +83,8 @@ async function cascadeProvisionerRevocation(
75
83
  );
76
84
  }
77
85
 
78
- export async function createProvisionerToken(
79
- params: CreateProvisionerTokenParams,
80
- ): Promise<ProvisionerToken> {
81
- const rows = await db()
86
+ async function insertProvisionerToken(tx: Tx, params: CreateProvisionerTokenParams) {
87
+ const rows = await tx
82
88
  .insert(provisionerTokens)
83
89
  .values({
84
90
  scope: params.scope,
@@ -93,9 +99,135 @@ export async function createProvisionerToken(
93
99
 
94
100
  const row = rows[0];
95
101
  if (!row) throw new Error('Insert returned no rows');
102
+ return row;
103
+ }
104
+
105
+ export async function createProvisionerToken(
106
+ params: CreateProvisionerTokenParams,
107
+ ): Promise<ProvisionerToken> {
108
+ const row = await db().transaction((tx) => insertProvisionerToken(tx, params));
96
109
  return toProvisionerToken(row);
97
110
  }
98
111
 
112
+ interface AuditedProvisionerTokenCommandParams {
113
+ actorId: string;
114
+ command: string;
115
+ correlationId: string;
116
+ idempotencyKeyFingerprint: string;
117
+ requestFingerprint: string;
118
+ event: (tokenId: string) => AdministrationActionEvent;
119
+ }
120
+
121
+ async function lockAdminCommand(
122
+ tx: Tx,
123
+ params: Pick<AuditedProvisionerTokenCommandParams, 'actorId' | 'idempotencyKeyFingerprint'>,
124
+ ): Promise<void> {
125
+ await tx.execute(
126
+ sql`select pg_advisory_xact_lock(hashtext(${`runners_admin_command:${params.actorId}:${params.idempotencyKeyFingerprint}`}))`,
127
+ );
128
+ }
129
+
130
+ async function loadAdminCommandResult(
131
+ tx: Tx,
132
+ params: Pick<
133
+ AuditedProvisionerTokenCommandParams,
134
+ 'actorId' | 'idempotencyKeyFingerprint' | 'requestFingerprint'
135
+ > & {command: string},
136
+ ) {
137
+ const rows = await tx
138
+ .select()
139
+ .from(runnersAdminCommandResults)
140
+ .where(
141
+ and(
142
+ eq(runnersAdminCommandResults.actorId, params.actorId),
143
+ eq(runnersAdminCommandResults.idempotencyKeyFingerprint, params.idempotencyKeyFingerprint),
144
+ ),
145
+ )
146
+ .limit(1);
147
+ const result = rows[0];
148
+ if (!result) return undefined;
149
+ if (
150
+ result.command !== params.command ||
151
+ result.requestFingerprint !== params.requestFingerprint
152
+ ) {
153
+ throw new ProvisionerAdminIdempotencyKeyReuseError();
154
+ }
155
+ return result;
156
+ }
157
+
158
+ async function storeAdminCommandResult(
159
+ tx: Tx,
160
+ params: AuditedProvisionerTokenCommandParams,
161
+ result: {provisionerTokenId: string; correlationId: string},
162
+ ): Promise<void> {
163
+ await tx.insert(runnersAdminCommandResults).values({
164
+ actorId: params.actorId,
165
+ idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
166
+ command: params.command,
167
+ requestFingerprint: params.requestFingerprint,
168
+ result,
169
+ });
170
+ }
171
+
172
+ async function writeAdministrationAction(tx: Tx, event: AdministrationActionEvent): Promise<void> {
173
+ await writeOutboxEvent<AdministrationActionEventMap>(tx, runnersOutbox, {
174
+ type: 'administration.action.performed',
175
+ payload: event,
176
+ });
177
+ }
178
+
179
+ export async function createInstallationProvisionerTokenWithAudit(
180
+ params: AuditedProvisionerTokenCommandParams & {
181
+ name?: string | undefined;
182
+ ttlSeconds?: number | undefined;
183
+ hashedToken: string;
184
+ prefix: string;
185
+ },
186
+ ): Promise<{token: ProvisionerToken; correlationId: string; replayed: boolean}> {
187
+ const command = 'runners.provisioner_token.create';
188
+ return await db().transaction(async (tx) => {
189
+ await lockAdminCommand(tx, params);
190
+ const existing = await loadAdminCommandResult(tx, {
191
+ actorId: params.actorId,
192
+ idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
193
+ requestFingerprint: params.requestFingerprint,
194
+ command,
195
+ });
196
+ if (existing) {
197
+ const rows = await tx
198
+ .select()
199
+ .from(provisionerTokens)
200
+ .where(eq(provisionerTokens.id, existing.result.provisionerTokenId))
201
+ .limit(1);
202
+ const row = rows[0];
203
+ if (!row) throw new Error('Idempotent provisioner token result is missing');
204
+ return {
205
+ token: toProvisionerToken(row),
206
+ correlationId: existing.result.correlationId,
207
+ replayed: true,
208
+ };
209
+ }
210
+
211
+ const expiresAt =
212
+ params.ttlSeconds === undefined ? undefined : new Date(Date.now() + params.ttlSeconds * 1000);
213
+ const row = await insertProvisionerToken(tx, {
214
+ scope: 'installation',
215
+ hashedToken: params.hashedToken,
216
+ prefix: params.prefix,
217
+ createdByUserId: params.actorId,
218
+ name: params.name,
219
+ expiresAt,
220
+ });
221
+ const event = params.event(row.id);
222
+ await writeAdministrationAction(tx, event);
223
+ await storeAdminCommandResult(tx, params, {
224
+ provisionerTokenId: row.id,
225
+ correlationId: event.correlationId,
226
+ });
227
+ return {token: toProvisionerToken(row), correlationId: event.correlationId, replayed: false};
228
+ });
229
+ }
230
+
99
231
  export async function revokeInstallationProvisionerToken(params: {
100
232
  tokenId: string;
101
233
  revokedByUserId: string;
@@ -132,6 +264,128 @@ export async function revokeInstallationProvisionerToken(params: {
132
264
  return existingRow ? toProvisionerToken(existingRow) : undefined;
133
265
  }
134
266
 
267
+ export async function revokeInstallationProvisionerTokenWithAudit(
268
+ params: AuditedProvisionerTokenCommandParams & {tokenId: string},
269
+ ): Promise<{token: ProvisionerToken; correlationId: string; replayed: boolean} | undefined> {
270
+ const command = 'runners.provisioner_token.revoke';
271
+ return await db().transaction(async (tx) => {
272
+ await lockAdminCommand(tx, params);
273
+ const existing = await loadAdminCommandResult(tx, {
274
+ actorId: params.actorId,
275
+ idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
276
+ requestFingerprint: params.requestFingerprint,
277
+ command,
278
+ });
279
+ if (existing) {
280
+ const rows = await tx
281
+ .select()
282
+ .from(provisionerTokens)
283
+ .where(eq(provisionerTokens.id, existing.result.provisionerTokenId))
284
+ .limit(1);
285
+ const row = rows[0];
286
+ if (!row) throw new Error('Idempotent provisioner token result is missing');
287
+ return {
288
+ token: toProvisionerToken(row),
289
+ correlationId: existing.result.correlationId,
290
+ replayed: true,
291
+ };
292
+ }
293
+
294
+ const updatedRows = await tx
295
+ .update(provisionerTokens)
296
+ .set({revokedAt: new Date(), revokedByUserId: params.actorId, updatedAt: new Date()})
297
+ .where(
298
+ and(
299
+ eq(provisionerTokens.id, params.tokenId),
300
+ eq(provisionerTokens.scope, 'installation'),
301
+ isNull(provisionerTokens.revokedAt),
302
+ ),
303
+ )
304
+ .returning();
305
+ const row = updatedRows[0];
306
+ if (row) await cascadeProvisionerRevocation(tx, params.tokenId);
307
+
308
+ const existingRows = row
309
+ ? [row]
310
+ : await tx
311
+ .select()
312
+ .from(provisionerTokens)
313
+ .where(
314
+ and(
315
+ eq(provisionerTokens.id, params.tokenId),
316
+ eq(provisionerTokens.scope, 'installation'),
317
+ ),
318
+ )
319
+ .limit(1);
320
+ const tokenRow = existingRows[0];
321
+ if (!tokenRow) return undefined;
322
+
323
+ const changed = row !== undefined;
324
+ const event = changed ? params.event(tokenRow.id) : undefined;
325
+ if (event) await writeAdministrationAction(tx, event);
326
+ await storeAdminCommandResult(tx, params, {
327
+ provisionerTokenId: tokenRow.id,
328
+ correlationId: event?.correlationId ?? params.correlationId,
329
+ });
330
+ return {
331
+ token: toProvisionerToken(tokenRow),
332
+ correlationId: event?.correlationId ?? params.correlationId,
333
+ replayed: false,
334
+ };
335
+ });
336
+ }
337
+
338
+ export type InstallationProvisionerTokenStatus = 'active' | 'expired' | 'revoked';
339
+
340
+ export interface ListInstallationProvisionerTokensParams {
341
+ limit: number;
342
+ cursor?: TimestampIdCursor | undefined;
343
+ status?: InstallationProvisionerTokenStatus | undefined;
344
+ }
345
+
346
+ export async function listInstallationProvisionerTokens(
347
+ params: ListInstallationProvisionerTokensParams,
348
+ ): Promise<{tokens: ProvisionerToken[]; nextCursor: TimestampIdCursor | null}> {
349
+ const conditions: SQL[] = [eq(provisionerTokens.scope, 'installation')];
350
+ const cursor = timestampIdCursorWhere({
351
+ timestampColumn: provisionerTokens.createdAt,
352
+ idColumn: provisionerTokens.id,
353
+ cursor: params.cursor,
354
+ });
355
+ if (cursor) conditions.push(cursor);
356
+
357
+ if (params.status === 'active') {
358
+ conditions.push(isNull(provisionerTokens.revokedAt));
359
+ const activeExpiry = or(
360
+ isNull(provisionerTokens.expiresAt),
361
+ gt(provisionerTokens.expiresAt, sql`now()`),
362
+ );
363
+ if (activeExpiry) conditions.push(activeExpiry);
364
+ }
365
+ if (params.status === 'expired') {
366
+ conditions.push(
367
+ isNull(provisionerTokens.revokedAt),
368
+ isNotNull(provisionerTokens.expiresAt),
369
+ lte(provisionerTokens.expiresAt, sql`now()`),
370
+ );
371
+ }
372
+ if (params.status === 'revoked') conditions.push(isNotNull(provisionerTokens.revokedAt));
373
+
374
+ const rows = await db()
375
+ .select()
376
+ .from(provisionerTokens)
377
+ .where(and(...conditions))
378
+ .orderBy(desc(provisionerTokens.createdAt), desc(provisionerTokens.id))
379
+ .limit(params.limit + 1);
380
+ const pageRows = rows.length > params.limit ? rows.slice(0, params.limit) : rows;
381
+ const last = pageRows.at(-1);
382
+ return {
383
+ tokens: pageRows.map(toProvisionerToken),
384
+ nextCursor:
385
+ rows.length > params.limit && last ? {createdAt: last.createdAt, id: last.id} : null,
386
+ };
387
+ }
388
+
135
389
  export async function listUsableProvisionerTokensByWorkspaceId(
136
390
  workspaceId: string,
137
391
  ): Promise<ProvisionerToken[]> {
@@ -1,12 +1,16 @@
1
1
  import {and, eq, inArray, or, sql, sum} from 'drizzle-orm';
2
2
  import {db} from '#db/db.js';
3
3
  import {
4
+ deleteExpiredReservations,
4
5
  deleteReservationsByIds,
5
6
  pollDemandAndReserve,
6
7
  pollInstallationDemandAndReserve,
7
8
  releaseReservationUnits,
8
9
  } from '#db/reservations.js';
9
10
  import {reservations} from '#db/schema/reservations.js';
11
+ import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
12
+ import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
13
+ import {providerRunners} from '#db/schema/runner-instances.js';
10
14
  import {pendingJobFactory, reservationFactory} from '#test/index.js';
11
15
 
12
16
  describe('pollDemandAndReserve', () => {
@@ -34,6 +38,131 @@ describe('pollDemandAndReserve', () => {
34
38
  expect(result.stats[0]).toMatchObject({labels: ['linux'], queued: 50, reserved: 20});
35
39
  });
36
40
 
41
+ it('binds the oldest matching enrolled runners to a new reservation', async () => {
42
+ const olderRunner = await createIdleRunner({
43
+ labels: ['linux'],
44
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
45
+ });
46
+ const newerRunner = await createIdleRunner({
47
+ labels: ['linux'],
48
+ createdAt: new Date('2026-01-02T00:00:00.000Z'),
49
+ });
50
+ const unmatchedRunner = await createIdleRunner({
51
+ labels: ['macos'],
52
+ createdAt: new Date('2026-01-03T00:00:00.000Z'),
53
+ });
54
+ await createPendingJobs(2, ['linux']);
55
+
56
+ const result = await pollDemandAndReserve({
57
+ workspaceId,
58
+ provisionerId,
59
+ maxReservations: 2,
60
+ ttlSeconds: 60,
61
+ templates: [template('linux', ['linux'], 2)],
62
+ });
63
+
64
+ const rows = await db()
65
+ .select()
66
+ .from(providerRunners)
67
+ .where(inArray(providerRunners.id, [olderRunner.id, newerRunner.id, unmatchedRunner.id]));
68
+ const rowsById = new Map(rows.map((row) => [row.id, row]));
69
+ const reservationId = result.reservations[0]?.reservationId;
70
+
71
+ expect(reservationId).toEqual(expect.any(String));
72
+ expect(rowsById.get(olderRunner.id)).toMatchObject({
73
+ workspaceId,
74
+ reservationId,
75
+ assignedAt: expect.any(Date),
76
+ });
77
+ expect(rowsById.get(newerRunner.id)).toMatchObject({
78
+ workspaceId,
79
+ reservationId,
80
+ assignedAt: expect.any(Date),
81
+ });
82
+ expect(rowsById.get(unmatchedRunner.id)).toMatchObject({
83
+ workspaceId: null,
84
+ reservationId: null,
85
+ assignedAt: null,
86
+ });
87
+ });
88
+
89
+ it('does not bind a running runner without an active control session', async () => {
90
+ const [runner] = await db()
91
+ .insert(providerRunners)
92
+ .values({
93
+ provisionerId,
94
+ providerRunnerId: 'un-enrolled-runner',
95
+ labels: ['linux'],
96
+ state: 'running',
97
+ reportedAt: new Date(),
98
+ })
99
+ .returning();
100
+ if (!runner) throw new Error('Expected runner instance');
101
+ await createPendingJobs(1, ['linux']);
102
+
103
+ const result = await pollDemandAndReserve({
104
+ workspaceId,
105
+ provisionerId,
106
+ maxReservations: 1,
107
+ ttlSeconds: 60,
108
+ templates: [template('linux', ['linux'], 1)],
109
+ });
110
+
111
+ const [storedRunner] = await db()
112
+ .select()
113
+ .from(providerRunners)
114
+ .where(eq(providerRunners.id, runner.id));
115
+ expect(result.reservations[0]?.count).toBe(1);
116
+ expect(storedRunner).toMatchObject({workspaceId: null, reservationId: null});
117
+ });
118
+
119
+ it('does not bind a runner with an expired control session', async () => {
120
+ const runner = await createIdleRunner({
121
+ labels: ['linux'],
122
+ controlSessionExpiresAt: new Date(Date.now() - 60_000),
123
+ });
124
+ await createPendingJobs(1, ['linux']);
125
+
126
+ const result = await pollDemandAndReserve({
127
+ workspaceId,
128
+ provisionerId,
129
+ maxReservations: 1,
130
+ ttlSeconds: 60,
131
+ templates: [template('linux', ['linux'], 1)],
132
+ });
133
+
134
+ const [storedRunner] = await db()
135
+ .select()
136
+ .from(providerRunners)
137
+ .where(eq(providerRunners.id, runner.id));
138
+ expect(result.reservations[0]?.count).toBe(1);
139
+ expect(storedRunner).toMatchObject({workspaceId: null, reservationId: null});
140
+ });
141
+
142
+ it('does not grant another reservation after idle runners are covered', async () => {
143
+ await createIdleRunner({labels: ['linux']});
144
+ await createPendingJobs(1, ['linux']);
145
+
146
+ const firstResult = await pollDemandAndReserve({
147
+ workspaceId,
148
+ provisionerId,
149
+ maxReservations: 1,
150
+ ttlSeconds: 60,
151
+ templates: [template('linux', ['linux'], 1)],
152
+ });
153
+ const secondResult = await pollDemandAndReserve({
154
+ workspaceId,
155
+ provisionerId,
156
+ maxReservations: 1,
157
+ ttlSeconds: 60,
158
+ templates: [template('linux', ['linux'], 1)],
159
+ });
160
+
161
+ expect(firstResult.reservations).toHaveLength(1);
162
+ expect(secondResult.reservations).toEqual([]);
163
+ expect(secondResult.stats[0]).toMatchObject({queued: 1, reserved: 1});
164
+ });
165
+
37
166
  it('allocates overlapping label sets most-specific-first', async () => {
38
167
  await createPendingJobs(2, ['linux']);
39
168
  await createPendingJobs(1, ['linux', 'gpu']);
@@ -288,6 +417,98 @@ describe('pollDemandAndReserve', () => {
288
417
  expect(remaining[0]?.requiredLabels).toEqual(['macos']);
289
418
  });
290
419
 
420
+ it('unbinds prewarmed runners and revokes activation tokens when deleting a reservation', async () => {
421
+ const runner = await createIdleRunner({labels: ['linux']});
422
+ await createPendingJobs(1, ['linux']);
423
+
424
+ const result = await pollDemandAndReserve({
425
+ workspaceId,
426
+ provisionerId,
427
+ maxReservations: 1,
428
+ ttlSeconds: 60,
429
+ templates: [template('linux', ['linux'], 1)],
430
+ });
431
+ const reservationId = result.reservations[0]?.reservationId;
432
+ if (!reservationId) throw new Error('Expected reservation');
433
+
434
+ const [activationToken] = await db()
435
+ .insert(runnerActivationTokens)
436
+ .values({
437
+ runnerInstanceId: runner.id,
438
+ hashedToken: crypto.randomUUID(),
439
+ prefix: 'test',
440
+ expiresAt: new Date(Date.now() + 60_000),
441
+ })
442
+ .returning();
443
+ if (!activationToken) throw new Error('Expected activation token');
444
+
445
+ const deleted = await deleteReservationsByIds([reservationId]);
446
+
447
+ const [storedRunner] = await db()
448
+ .select()
449
+ .from(providerRunners)
450
+ .where(eq(providerRunners.id, runner.id));
451
+ const [storedToken] = await db()
452
+ .select()
453
+ .from(runnerActivationTokens)
454
+ .where(eq(runnerActivationTokens.id, activationToken.id));
455
+ expect(deleted).toBe(1);
456
+ expect(storedRunner).toMatchObject({
457
+ workspaceId: null,
458
+ reservationId: null,
459
+ assignedAt: null,
460
+ });
461
+ expect(storedToken?.revokedAt).toBeInstanceOf(Date);
462
+ });
463
+
464
+ it('unbinds prewarmed runners and revokes activation tokens when deleting expired reservations', async () => {
465
+ const runner = await createIdleRunner({labels: ['linux']});
466
+ await createPendingJobs(1, ['linux']);
467
+
468
+ const result = await pollDemandAndReserve({
469
+ workspaceId,
470
+ provisionerId,
471
+ maxReservations: 1,
472
+ ttlSeconds: 60,
473
+ templates: [template('linux', ['linux'], 1)],
474
+ });
475
+ const reservationId = result.reservations[0]?.reservationId;
476
+ if (!reservationId) throw new Error('Expected reservation');
477
+
478
+ await db()
479
+ .update(reservations)
480
+ .set({expiresAt: new Date(Date.now() - 60_000)})
481
+ .where(eq(reservations.id, reservationId));
482
+ const [activationToken] = await db()
483
+ .insert(runnerActivationTokens)
484
+ .values({
485
+ runnerInstanceId: runner.id,
486
+ hashedToken: crypto.randomUUID(),
487
+ prefix: 'test',
488
+ expiresAt: new Date(Date.now() + 60_000),
489
+ })
490
+ .returning();
491
+ if (!activationToken) throw new Error('Expected activation token');
492
+
493
+ const deleted = await deleteExpiredReservations();
494
+
495
+ const [storedRunner] = await db()
496
+ .select()
497
+ .from(providerRunners)
498
+ .where(eq(providerRunners.id, runner.id));
499
+ const [storedToken] = await db()
500
+ .select()
501
+ .from(runnerActivationTokens)
502
+ .where(eq(runnerActivationTokens.id, activationToken.id));
503
+ expect(deleted).toBeGreaterThanOrEqual(1);
504
+ expect(storedRunner).toMatchObject({
505
+ workspaceId: null,
506
+ reservationId: null,
507
+ assignedAt: null,
508
+ });
509
+ expect(storedToken?.revokedAt).toBeInstanceOf(Date);
510
+ });
511
+
291
512
  it('decrements reservation units inside a caller transaction', async () => {
292
513
  await reservationFactory.create({
293
514
  workspaceId,
@@ -420,6 +641,38 @@ describe('pollDemandAndReserve', () => {
420
641
  }
421
642
  }
422
643
 
644
+ async function createIdleRunner(params: {
645
+ labels: string[];
646
+ createdAt?: Date;
647
+ controlSessionExpiresAt?: Date;
648
+ }) {
649
+ const createdAt = params.createdAt ?? new Date();
650
+ const [runner] = await db()
651
+ .insert(providerRunners)
652
+ .values({
653
+ provisionerId,
654
+ providerRunnerId: crypto.randomUUID(),
655
+ labels: params.labels,
656
+ state: 'running',
657
+ reportedAt: createdAt,
658
+ createdAt,
659
+ updatedAt: createdAt,
660
+ })
661
+ .returning();
662
+ if (!runner) throw new Error('Expected runner instance');
663
+
664
+ await db()
665
+ .insert(runnerControlSessions)
666
+ .values({
667
+ runnerInstanceId: runner.id,
668
+ provisionerId,
669
+ hashedToken: crypto.randomUUID(),
670
+ prefix: 'test',
671
+ expiresAt: params.controlSessionExpiresAt ?? new Date(Date.now() + 60_000),
672
+ });
673
+ return runner;
674
+ }
675
+
423
676
  async function activeReservedCount(): Promise<number> {
424
677
  const [row] = await db()
425
678
  .select({value: sum(reservations.count)})