@shipfox/api-runners 12.2.0 → 12.4.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 (149) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +34 -0
  3. package/dist/config.d.ts +1 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +10 -3
  6. package/dist/config.js.map +1 -1
  7. package/dist/core/demand.d.ts +4 -2
  8. package/dist/core/demand.d.ts.map +1 -1
  9. package/dist/core/demand.js +22 -6
  10. package/dist/core/demand.js.map +1 -1
  11. package/dist/core/entities/reservation.d.ts +1 -0
  12. package/dist/core/entities/reservation.d.ts.map +1 -1
  13. package/dist/core/entities/reservation.js.map +1 -1
  14. package/dist/core/entities/runner-instance.d.ts +3 -0
  15. package/dist/core/entities/runner-instance.d.ts.map +1 -1
  16. package/dist/core/entities/runner-instance.js.map +1 -1
  17. package/dist/core/runner-activation.d.ts +4 -1
  18. package/dist/core/runner-activation.d.ts.map +1 -1
  19. package/dist/core/runner-activation.js +32 -3
  20. package/dist/core/runner-activation.js.map +1 -1
  21. package/dist/core/runner-control-sessions.d.ts +8 -4
  22. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  23. package/dist/core/runner-control-sessions.js +69 -11
  24. package/dist/core/runner-control-sessions.js.map +1 -1
  25. package/dist/core/runner-instances.d.ts +1 -0
  26. package/dist/core/runner-instances.d.ts.map +1 -1
  27. package/dist/core/runner-instances.js +1 -0
  28. package/dist/core/runner-instances.js.map +1 -1
  29. package/dist/db/db.d.ts +68 -0
  30. package/dist/db/db.d.ts.map +1 -1
  31. package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
  32. package/dist/db/ephemeral-registration-tokens.js +1 -1
  33. package/dist/db/ephemeral-registration-tokens.js.map +1 -1
  34. package/dist/db/index.d.ts +1 -1
  35. package/dist/db/index.d.ts.map +1 -1
  36. package/dist/db/index.js +1 -1
  37. package/dist/db/index.js.map +1 -1
  38. package/dist/db/reservation-locks.d.ts +6 -0
  39. package/dist/db/reservation-locks.d.ts.map +1 -0
  40. package/dist/db/reservation-locks.js +15 -0
  41. package/dist/db/reservation-locks.js.map +1 -0
  42. package/dist/db/reservations.d.ts +17 -3
  43. package/dist/db/reservations.d.ts.map +1 -1
  44. package/dist/db/reservations.js +256 -45
  45. package/dist/db/reservations.js.map +1 -1
  46. package/dist/db/runner-assignments.d.ts +17 -0
  47. package/dist/db/runner-assignments.d.ts.map +1 -1
  48. package/dist/db/runner-assignments.js +103 -8
  49. package/dist/db/runner-assignments.js.map +1 -1
  50. package/dist/db/runner-instances.d.ts +5 -2
  51. package/dist/db/runner-instances.d.ts.map +1 -1
  52. package/dist/db/runner-instances.js +134 -25
  53. package/dist/db/runner-instances.js.map +1 -1
  54. package/dist/db/runner-sessions.d.ts.map +1 -1
  55. package/dist/db/runner-sessions.js +22 -15
  56. package/dist/db/runner-sessions.js.map +1 -1
  57. package/dist/db/runner-states.d.ts +2 -0
  58. package/dist/db/runner-states.d.ts.map +1 -0
  59. package/dist/db/runner-states.js +7 -0
  60. package/dist/db/runner-states.js.map +1 -0
  61. package/dist/db/schema/reservations.d.ts +18 -0
  62. package/dist/db/schema/reservations.d.ts.map +1 -1
  63. package/dist/db/schema/reservations.js +7 -1
  64. package/dist/db/schema/reservations.js.map +1 -1
  65. package/dist/db/schema/runner-instances.d.ts +18 -0
  66. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  67. package/dist/db/schema/runner-instances.js +8 -0
  68. package/dist/db/schema/runner-instances.js.map +1 -1
  69. package/dist/metrics/index.d.ts +2 -2
  70. package/dist/metrics/index.d.ts.map +1 -1
  71. package/dist/metrics/index.js +1 -1
  72. package/dist/metrics/index.js.map +1 -1
  73. package/dist/metrics/instance.d.ts +24 -2
  74. package/dist/metrics/instance.d.ts.map +1 -1
  75. package/dist/metrics/instance.js +23 -0
  76. package/dist/metrics/instance.js.map +1 -1
  77. package/dist/metrics/service.d.ts.map +1 -1
  78. package/dist/metrics/service.js +20 -4
  79. package/dist/metrics/service.js.map +1 -1
  80. package/dist/presentation/dto/poll-demand.d.ts.map +1 -1
  81. package/dist/presentation/dto/poll-demand.js +3 -0
  82. package/dist/presentation/dto/poll-demand.js.map +1 -1
  83. package/dist/presentation/dto/runner-instances.d.ts +4 -1
  84. package/dist/presentation/dto/runner-instances.d.ts.map +1 -1
  85. package/dist/presentation/dto/runner-instances.js +4 -1
  86. package/dist/presentation/dto/runner-instances.js.map +1 -1
  87. package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
  88. package/dist/presentation/routes/assign-runner-instances.js +3 -3
  89. package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
  90. package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
  91. package/dist/presentation/routes/poll-demand.js +1 -0
  92. package/dist/presentation/routes/poll-demand.js.map +1 -1
  93. package/dist/presentation/routes/reconcile-runner-instances.d.ts.map +1 -1
  94. package/dist/presentation/routes/reconcile-runner-instances.js +4 -2
  95. package/dist/presentation/routes/reconcile-runner-instances.js.map +1 -1
  96. package/dist/presentation/routes/runner-enrollment.d.ts +4 -1
  97. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  98. package/dist/presentation/routes/runner-enrollment.js +11 -6
  99. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  100. package/dist/tsconfig.test.tsbuildinfo +1 -1
  101. package/drizzle/0005_previous_lenny_balinger.sql +5 -0
  102. package/drizzle/meta/0005_snapshot.json +2220 -0
  103. package/drizzle/meta/_journal.json +7 -0
  104. package/package.json +4 -4
  105. package/src/config.test.ts +24 -2
  106. package/src/config.ts +16 -3
  107. package/src/core/demand.test.ts +192 -3
  108. package/src/core/demand.ts +44 -4
  109. package/src/core/entities/reservation.ts +1 -0
  110. package/src/core/entities/runner-instance.ts +4 -0
  111. package/src/core/runner-activation.ts +48 -2
  112. package/src/core/runner-control-sessions.test.ts +230 -4
  113. package/src/core/runner-control-sessions.ts +84 -10
  114. package/src/core/runner-instances.test.ts +19 -0
  115. package/src/core/runner-instances.ts +2 -0
  116. package/src/core/runner-sessions.test.ts +121 -1
  117. package/src/db/ephemeral-registration-tokens.test.ts +20 -1
  118. package/src/db/ephemeral-registration-tokens.ts +1 -0
  119. package/src/db/index.ts +1 -0
  120. package/src/db/reservation-locks.ts +22 -0
  121. package/src/db/reservations.test.ts +1110 -31
  122. package/src/db/reservations.ts +434 -105
  123. package/src/db/runner-assignments.test.ts +105 -1
  124. package/src/db/runner-assignments.ts +178 -9
  125. package/src/db/runner-instances.test.ts +496 -2
  126. package/src/db/runner-instances.ts +247 -56
  127. package/src/db/runner-sessions.ts +26 -15
  128. package/src/db/runner-states.ts +7 -0
  129. package/src/db/schema/reservations.ts +5 -1
  130. package/src/db/schema/runner-instances.ts +9 -0
  131. package/src/metrics/index.ts +10 -1
  132. package/src/metrics/instance.ts +59 -2
  133. package/src/metrics/service.test.ts +106 -0
  134. package/src/metrics/service.ts +24 -4
  135. package/src/presentation/dto/poll-demand.ts +3 -0
  136. package/src/presentation/dto/runner-instances.ts +8 -1
  137. package/src/presentation/routes/assign-runner-instances.ts +6 -2
  138. package/src/presentation/routes/late-runner-enrollment.test.ts +447 -0
  139. package/src/presentation/routes/poll-demand.test.ts +79 -6
  140. package/src/presentation/routes/poll-demand.ts +1 -0
  141. package/src/presentation/routes/reconcile-runner-instances.test.ts +34 -2
  142. package/src/presentation/routes/reconcile-runner-instances.ts +7 -1
  143. package/src/presentation/routes/runner-enrollment.test.ts +316 -3
  144. package/src/presentation/routes/runner-enrollment.ts +8 -5
  145. package/test/factories/reservation.ts +2 -0
  146. package/test/factories/runner-instance.ts +6 -0
  147. package/test/fixtures/late-runner-enrollment.ts +103 -0
  148. package/test/index.ts +5 -0
  149. package/tsconfig.build.tsbuildinfo +1 -1
@@ -6,6 +6,7 @@ import {
6
6
  createApp,
7
7
  extractBearerToken,
8
8
  } from '@shipfox/node-fastify';
9
+ import {vi} from '@shipfox/vitest/vi';
9
10
  import {eq} from 'drizzle-orm';
10
11
  import type {FastifyInstance, FastifyRequest} from 'fastify';
11
12
  import {db} from '#db/db.js';
@@ -13,6 +14,7 @@ import {reservations} from '#db/schema/reservations.js';
13
14
  import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
14
15
  import {runnerBootstrapTokens, runnerControlSessions} from '#db/schema/runner-control-sessions.js';
15
16
  import {providerRunners} from '#db/schema/runner-instances.js';
17
+ import {runnerReservationCapacityFailureCount} from '#metrics/instance.js';
16
18
  import {
17
19
  createRunnerControlSessionAuthMethod,
18
20
  createRunnerRegistrationTokenAuthMethod,
@@ -159,6 +161,7 @@ describe('runner enrollment control plane', () => {
159
161
  provisionerId,
160
162
  labels: ['linux', 'shipfox-managed'],
161
163
  providerRunnerId: 'container-1',
164
+ launchKind: 'warm',
162
165
  state: 'running',
163
166
  protocolVersion: '1',
164
167
  });
@@ -210,7 +213,7 @@ describe('runner enrollment control plane', () => {
210
213
  expect(instance?.labels).toEqual(['linux']);
211
214
  });
212
215
 
213
- it('promotes an intended reservation during enrollment and returns its activation token', async () => {
216
+ it('repairs an intended reservation after a provider report and returns its activation token', async () => {
214
217
  const workspaceId = crypto.randomUUID();
215
218
  const [reservation] = await db()
216
219
  .insert(reservations)
@@ -240,14 +243,49 @@ describe('runner enrollment control plane', () => {
240
243
  });
241
244
  const controlToken = exchanged.json().control_session_token;
242
245
 
246
+ const providerRunnerId = 'container-intended-assignment';
243
247
  const attached = await app.inject({
244
248
  method: 'POST',
245
249
  url: `/provisioners/runner-instances/${runner.runner_instance_id}/provider-runner`,
246
250
  headers: {authorization: `Bearer ${token}`},
247
- payload: {provider_runner_id: 'container-intended-assignment'},
251
+ payload: {provider_runner_id: providerRunnerId},
248
252
  });
249
253
  expect(attached.json()).toEqual({attached: true});
250
254
 
255
+ const reported = await app.inject({
256
+ method: 'POST',
257
+ url: '/provisioners/runner-instances/report',
258
+ headers: {authorization: `Bearer ${token}`},
259
+ payload: {
260
+ events: [
261
+ {
262
+ provider_runner_id: providerRunnerId,
263
+ reservation_id: reservation.id,
264
+ template_key: 'linux',
265
+ labels: ['linux'],
266
+ state: 'starting',
267
+ reported_at: new Date().toISOString(),
268
+ provider_kind: 'docker',
269
+ },
270
+ ],
271
+ },
272
+ });
273
+ expect(reported.statusCode).toBe(200);
274
+ expect(reported.json()).toEqual({accepted: 1, reservations_released: 0});
275
+
276
+ const [reportedInstance] = await db()
277
+ .select()
278
+ .from(providerRunners)
279
+ .where(eq(providerRunners.id, runner.runner_instance_id));
280
+ expect(reportedInstance).toMatchObject({
281
+ workspaceId: null,
282
+ reservationId: reservation.id,
283
+ intendedReservationId: reservation.id,
284
+ assignedAt: null,
285
+ state: 'starting',
286
+ providerRunnerId,
287
+ });
288
+
251
289
  const enrolled = await app.inject({
252
290
  method: 'POST',
253
291
  url: '/runner-control/enrollment',
@@ -264,13 +302,288 @@ describe('runner enrollment control plane', () => {
264
302
  expect(instance).toMatchObject({
265
303
  workspaceId,
266
304
  reservationId: reservation.id,
305
+ launchKind: 'demand',
267
306
  intendedReservationId: null,
268
307
  state: 'running',
269
- providerRunnerId: 'container-intended-assignment',
308
+ providerRunnerId,
270
309
  assignedAt: expect.any(Date),
271
310
  });
272
311
  });
273
312
 
313
+ it('does not create a duplicate row or bootstrap token after a reservation is consumed', async () => {
314
+ const workspaceId = crypto.randomUUID();
315
+ const [reservation] = await db()
316
+ .insert(reservations)
317
+ .values({
318
+ workspaceId,
319
+ provisionerId,
320
+ requiredLabels: ['linux'],
321
+ count: 1,
322
+ expiresAt: new Date(Date.now() + 60_000),
323
+ })
324
+ .returning();
325
+ if (!reservation) throw new Error('Reservation insert returned no row');
326
+
327
+ const first = await app.inject({
328
+ method: 'POST',
329
+ url: '/provisioners/runner-instances/batch',
330
+ headers: {authorization: `Bearer ${token}`},
331
+ payload: {runner_instances: [{reservation_id: reservation.id}]},
332
+ });
333
+ const firstRunner = first.json().runner_instances[0];
334
+ expect(first.statusCode).toBe(200);
335
+ expect(first.json().reservation_unavailable).toBeUndefined();
336
+
337
+ const second = await app.inject({
338
+ method: 'POST',
339
+ url: '/provisioners/runner-instances/batch',
340
+ headers: {authorization: `Bearer ${token}`},
341
+ payload: {runner_instances: [{reservation_id: reservation.id}]},
342
+ });
343
+
344
+ expect(second.statusCode).toBe(200);
345
+ expect(second.json()).toMatchObject({
346
+ runner_instances: [],
347
+ reservation_unavailable: true,
348
+ });
349
+ const runners = await db()
350
+ .select()
351
+ .from(providerRunners)
352
+ .where(eq(providerRunners.intendedReservationId, reservation.id));
353
+ const bootstrapTokens = await db()
354
+ .select()
355
+ .from(runnerBootstrapTokens)
356
+ .where(eq(runnerBootstrapTokens.runnerInstanceId, firstRunner.runner_instance_id));
357
+ expect(runners).toHaveLength(1);
358
+ expect(bootstrapTokens).toHaveLength(1);
359
+ });
360
+
361
+ it('returns a classified shortfall for expired and foreign reservations', async () => {
362
+ const otherProvisioner = await provisionerTokenFactory.create({scope: 'installation'});
363
+ const runnersBefore = await db()
364
+ .select()
365
+ .from(providerRunners)
366
+ .where(eq(providerRunners.provisionerId, provisionerId));
367
+ const bootstrapTokensBefore = await db()
368
+ .select()
369
+ .from(runnerBootstrapTokens)
370
+ .where(eq(runnerBootstrapTokens.provisionerId, provisionerId));
371
+ const expiredReservation = await db()
372
+ .insert(reservations)
373
+ .values({
374
+ workspaceId: crypto.randomUUID(),
375
+ provisionerId,
376
+ requiredLabels: ['linux'],
377
+ count: 1,
378
+ expiresAt: new Date(Date.now() - 1_000),
379
+ })
380
+ .returning({id: reservations.id});
381
+ const foreignReservation = await db()
382
+ .insert(reservations)
383
+ .values({
384
+ workspaceId: crypto.randomUUID(),
385
+ provisionerId: otherProvisioner.id,
386
+ requiredLabels: ['linux'],
387
+ count: 1,
388
+ expiresAt: new Date(Date.now() + 60_000),
389
+ })
390
+ .returning({id: reservations.id});
391
+ const boundReservation = await db()
392
+ .insert(reservations)
393
+ .values({
394
+ workspaceId: crypto.randomUUID(),
395
+ provisionerId,
396
+ requiredLabels: ['linux'],
397
+ count: 1,
398
+ kind: 'bound',
399
+ expiresAt: new Date(Date.now() + 60_000),
400
+ })
401
+ .returning({id: reservations.id});
402
+ const reservationIds = [
403
+ expiredReservation[0]?.id,
404
+ foreignReservation[0]?.id,
405
+ boundReservation[0]?.id,
406
+ ];
407
+ if (reservationIds.some((reservationId) => !reservationId))
408
+ throw new Error('Reservation insert returned no row');
409
+
410
+ const failureSpy = vi.spyOn(runnerReservationCapacityFailureCount, 'add');
411
+ try {
412
+ for (const reservationId of reservationIds) {
413
+ const response = await app.inject({
414
+ method: 'POST',
415
+ url: '/provisioners/runner-instances/batch',
416
+ headers: {authorization: `Bearer ${token}`},
417
+ payload: {runner_instances: [{reservation_id: reservationId}]},
418
+ });
419
+
420
+ expect(response.statusCode).toBe(200);
421
+ expect(response.json()).toMatchObject({
422
+ runner_instances: [],
423
+ reservation_unavailable: true,
424
+ });
425
+ }
426
+
427
+ expect(failureSpy).toHaveBeenCalledWith(1, {reason: 'reservation-expired'});
428
+ expect(failureSpy).toHaveBeenCalledWith(1, {reason: 'reservation-not-found'});
429
+ expect(failureSpy).toHaveBeenCalledWith(1, {reason: 'reservation-kind-mismatch'});
430
+ } finally {
431
+ failureSpy.mockRestore();
432
+ }
433
+
434
+ const runners = await db()
435
+ .select()
436
+ .from(providerRunners)
437
+ .where(eq(providerRunners.provisionerId, provisionerId));
438
+ const bootstrapTokens = await db()
439
+ .select()
440
+ .from(runnerBootstrapTokens)
441
+ .where(eq(runnerBootstrapTokens.provisionerId, provisionerId));
442
+ expect(runners).toHaveLength(runnersBefore.length);
443
+ expect(bootstrapTokens).toHaveLength(bootstrapTokensBefore.length);
444
+ });
445
+
446
+ it('returns the remaining reservation capacity with request indexes', async () => {
447
+ const workspaceId = crypto.randomUUID();
448
+ const [reservation] = await db()
449
+ .insert(reservations)
450
+ .values({
451
+ workspaceId,
452
+ provisionerId,
453
+ requiredLabels: ['linux'],
454
+ count: 2,
455
+ expiresAt: new Date(Date.now() + 60_000),
456
+ })
457
+ .returning();
458
+ if (!reservation) throw new Error('Reservation insert returned no row');
459
+
460
+ const first = await app.inject({
461
+ method: 'POST',
462
+ url: '/provisioners/runner-instances/batch',
463
+ headers: {authorization: `Bearer ${token}`},
464
+ payload: {runner_instances: [{reservation_id: reservation.id}]},
465
+ });
466
+ expect(first.statusCode).toBe(200);
467
+
468
+ const second = await app.inject({
469
+ method: 'POST',
470
+ url: '/provisioners/runner-instances/batch',
471
+ headers: {authorization: `Bearer ${token}`},
472
+ payload: {
473
+ runner_instances: [
474
+ {template_key: 'linux', reservation_id: reservation.id},
475
+ {template_key: 'linux', reservation_id: reservation.id},
476
+ ],
477
+ },
478
+ });
479
+
480
+ expect(second.statusCode).toBe(200);
481
+ expect(second.json()).toMatchObject({
482
+ runner_instances: [
483
+ {
484
+ request_index: 0,
485
+ runner_instance_id: expect.any(String),
486
+ bootstrap_token: expect.any(String),
487
+ },
488
+ ],
489
+ reservation_unavailable: true,
490
+ });
491
+ });
492
+
493
+ it('preserves request indexes when a mixed reservation batch has a shortfall', async () => {
494
+ const [exhaustedReservation, availableReservation] = await db()
495
+ .insert(reservations)
496
+ .values([
497
+ {
498
+ workspaceId: crypto.randomUUID(),
499
+ provisionerId,
500
+ requiredLabels: ['linux'],
501
+ count: 1,
502
+ expiresAt: new Date(Date.now() + 60_000),
503
+ },
504
+ {
505
+ workspaceId: crypto.randomUUID(),
506
+ provisionerId,
507
+ requiredLabels: ['linux'],
508
+ count: 1,
509
+ expiresAt: new Date(Date.now() + 60_000),
510
+ },
511
+ ])
512
+ .returning();
513
+ if (!exhaustedReservation || !availableReservation)
514
+ throw new Error('Reservation insert returned no rows');
515
+
516
+ const consumed = await app.inject({
517
+ method: 'POST',
518
+ url: '/provisioners/runner-instances/batch',
519
+ headers: {authorization: `Bearer ${token}`},
520
+ payload: {runner_instances: [{reservation_id: exhaustedReservation.id}]},
521
+ });
522
+ expect(consumed.statusCode).toBe(200);
523
+
524
+ const mixed = await app.inject({
525
+ method: 'POST',
526
+ url: '/provisioners/runner-instances/batch',
527
+ headers: {authorization: `Bearer ${token}`},
528
+ payload: {
529
+ runner_instances: [
530
+ {template_key: 'linux', reservation_id: exhaustedReservation.id},
531
+ {template_key: 'linux', reservation_id: availableReservation.id},
532
+ ],
533
+ },
534
+ });
535
+
536
+ expect(mixed.statusCode).toBe(200);
537
+ expect(mixed.json()).toMatchObject({
538
+ runner_instances: [
539
+ {
540
+ request_index: 1,
541
+ runner_instance_id: expect.any(String),
542
+ bootstrap_token: expect.any(String),
543
+ },
544
+ ],
545
+ reservation_unavailable: true,
546
+ });
547
+ });
548
+
549
+ it('rejects assignment for a provider-reported runner before enrollment', async () => {
550
+ const workspaceId = crypto.randomUUID();
551
+ const [reservation] = await db()
552
+ .insert(reservations)
553
+ .values({
554
+ workspaceId,
555
+ provisionerId,
556
+ requiredLabels: ['linux'],
557
+ count: 1,
558
+ expiresAt: new Date(Date.now() + 60_000),
559
+ })
560
+ .returning();
561
+ if (!reservation) throw new Error('Reservation insert returned no row');
562
+ const [runner] = await db()
563
+ .insert(providerRunners)
564
+ .values({
565
+ provisionerId,
566
+ intendedReservationId: reservation.id,
567
+ reservationId: reservation.id,
568
+ providerRunnerId: crypto.randomUUID(),
569
+ labels: ['linux'],
570
+ state: 'starting',
571
+ reportedAt: new Date(),
572
+ })
573
+ .returning({id: providerRunners.id});
574
+ if (!runner) throw new Error('Runner instance insert returned no row');
575
+
576
+ const assigned = await app.inject({
577
+ method: 'POST',
578
+ url: '/provisioners/runner-instances/assignments',
579
+ headers: {authorization: `Bearer ${token}`},
580
+ payload: {reservation_id: reservation.id, runner_instance_ids: [runner.id]},
581
+ });
582
+
583
+ expect(assigned.statusCode).toBe(409);
584
+ expect(assigned.json()).toMatchObject({code: 'runner-instance-not-assignable'});
585
+ });
586
+
274
587
  it('keeps enrollment successful when an intended reservation cannot be promoted', async () => {
275
588
  const workspaceId = crypto.randomUUID();
276
589
  const [reservation] = await db()
@@ -42,7 +42,7 @@ export const createRunnerInstancesRoute = defineRoute({
42
42
  },
43
43
  handler: async (request) => {
44
44
  const {provisionerTokenId} = requireProvisionerContext(request);
45
- const results = await createRunnerInstancesWithBootstrapTokens({
45
+ const result = await createRunnerInstancesWithBootstrapTokens({
46
46
  provisionerId: provisionerTokenId,
47
47
  ...(request.body.provider_kind ? {providerKind: request.body.provider_kind} : {}),
48
48
  runnerInstances: request.body.runner_instances.map((runner) => ({
@@ -52,10 +52,12 @@ export const createRunnerInstancesRoute = defineRoute({
52
52
  ttlSeconds: config.RUNNER_BOOTSTRAP_TOKEN_TTL_SECONDS,
53
53
  });
54
54
  return {
55
- runner_instances: results.map((result) => ({
56
- runner_instance_id: result.runnerInstanceId,
57
- bootstrap_token: result.bootstrapToken,
55
+ runner_instances: result.runnerInstances.map((runnerInstance) => ({
56
+ runner_instance_id: runnerInstance.runnerInstanceId,
57
+ bootstrap_token: runnerInstance.bootstrapToken,
58
+ request_index: runnerInstance.requestIndex,
58
59
  })),
60
+ ...(result.reservationUnavailable ? {reservation_unavailable: true as const} : {}),
59
61
  };
60
62
  },
61
63
  });
@@ -173,7 +175,7 @@ type RunnerAssignmentPollParams = {
173
175
  deadline: number;
174
176
  intervalMs: number;
175
177
  signal: AbortSignal;
176
- getAssignment: () => Promise<unknown | null>;
178
+ getAssignment: () => Promise<{workspaceId: string | null; runnerSessionId: string | null} | null>;
177
179
  issueActivationToken: () => Promise<string | null>;
178
180
  };
179
181
 
@@ -229,6 +231,7 @@ export const runnerAssignmentPollRoute = defineRoute({
229
231
  issueRunnerActivationToken({
230
232
  ...session,
231
233
  ttlSeconds: config.RUNNER_ACTIVATION_TOKEN_TTL_SECONDS,
234
+ surface: 'poll',
232
235
  }),
233
236
  });
234
237
  return {activation_token: activationToken};
@@ -7,6 +7,7 @@ interface ReservationAttrs {
7
7
  provisionerId: string;
8
8
  requiredLabels: string[];
9
9
  count: number;
10
+ kind?: 'bound' | 'launch';
10
11
  expiresAt: Date;
11
12
  }
12
13
 
@@ -19,6 +20,7 @@ export const reservationFactory = Factory.define<ReservationAttrs>(({onCreate})
19
20
  provisionerId: row.provisionerId,
20
21
  requiredLabels: row.requiredLabels,
21
22
  count: row.count,
23
+ kind: row.kind,
22
24
  expiresAt: row.expiresAt,
23
25
  };
24
26
  });
@@ -11,7 +11,9 @@ export const providerRunnerFactory = Factory.define<RunnerInstance>(({onCreate})
11
11
  workspaceId: providerRunner.workspaceId,
12
12
  provisionerId: providerRunner.provisionerId,
13
13
  providerRunnerId: providerRunner.providerRunnerId,
14
+ intendedReservationId: providerRunner.intendedReservationId,
14
15
  reservationId: providerRunner.reservationId,
16
+ launchKind: providerRunner.launchKind,
15
17
  templateKey: providerRunner.templateKey,
16
18
  labels: providerRunner.labels,
17
19
  state: providerRunner.state,
@@ -25,6 +27,8 @@ export const providerRunnerFactory = Factory.define<RunnerInstance>(({onCreate})
25
27
  failedAt: providerRunner.failedAt,
26
28
  terminatedAt: providerRunner.terminatedAt,
27
29
  reservationReleasedAt: providerRunner.reservationReleasedAt,
30
+ createdAt: providerRunner.createdAt,
31
+ updatedAt: providerRunner.updatedAt,
28
32
  })
29
33
  .returning();
30
34
 
@@ -37,7 +41,9 @@ export const providerRunnerFactory = Factory.define<RunnerInstance>(({onCreate})
37
41
  workspaceId: crypto.randomUUID(),
38
42
  provisionerId: crypto.randomUUID(),
39
43
  providerRunnerId: crypto.randomUUID(),
44
+ intendedReservationId: null,
40
45
  reservationId: null,
46
+ launchKind: 'manual',
41
47
  templateKey: 'linux',
42
48
  labels: ['linux'],
43
49
  state: 'running',
@@ -0,0 +1,103 @@
1
+ import {extractDisplayPrefix, generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
2
+ import {db} from '#db/db.js';
3
+ import {deleteReservationsByIds} from '#db/reservations.js';
4
+ import {reservations} from '#db/schema/reservations.js';
5
+ import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
6
+ import {providerRunners} from '#db/schema/runner-instances.js';
7
+
8
+ export interface LateRunnerEnrollmentArrangement {
9
+ controlSessionToken: string;
10
+ provisionerId: string;
11
+ reservationId: string;
12
+ runnerInstanceId: string;
13
+ workspaceId: string;
14
+ }
15
+
16
+ export async function arrangeExpiredRunnerEnrollment(params: {
17
+ provisionerId: string;
18
+ workspaceId: string;
19
+ }): Promise<LateRunnerEnrollmentArrangement> {
20
+ const reservationId = await insertReservation({
21
+ ...params,
22
+ expiresAt: new Date(Date.now() - 1_000),
23
+ });
24
+
25
+ return await createRunnerEnrollmentArrangement({...params, reservationId});
26
+ }
27
+
28
+ export async function arrangeDeletedRunnerEnrollment(params: {
29
+ provisionerId: string;
30
+ workspaceId: string;
31
+ }): Promise<LateRunnerEnrollmentArrangement> {
32
+ const reservationId = await insertReservation({
33
+ ...params,
34
+ expiresAt: new Date(Date.now() + 60_000),
35
+ });
36
+ const deleted = await deleteReservationsByIds([reservationId]);
37
+ if (deleted !== 1) throw new Error('Expected reservation cleanup to delete one row');
38
+
39
+ // The provider can still enroll from a launch payload carrying the deleted reservation id.
40
+ return await createRunnerEnrollmentArrangement({...params, reservationId});
41
+ }
42
+
43
+ async function insertReservation(params: {
44
+ expiresAt: Date;
45
+ provisionerId: string;
46
+ workspaceId: string;
47
+ }): Promise<string> {
48
+ const [reservation] = await db()
49
+ .insert(reservations)
50
+ .values({
51
+ workspaceId: params.workspaceId,
52
+ provisionerId: params.provisionerId,
53
+ requiredLabels: ['linux'],
54
+ count: 1,
55
+ expiresAt: params.expiresAt,
56
+ })
57
+ .returning({id: reservations.id});
58
+ if (!reservation) throw new Error('Expected reservation');
59
+
60
+ return reservation.id;
61
+ }
62
+
63
+ async function createRunnerEnrollmentArrangement(params: {
64
+ provisionerId: string;
65
+ reservationId: string;
66
+ workspaceId: string;
67
+ }): Promise<LateRunnerEnrollmentArrangement> {
68
+ const providerRunnerId = `late-runner-${crypto.randomUUID()}`;
69
+ const controlSessionToken = generateOpaqueToken('runnerControlSession');
70
+ const [runner] = await db()
71
+ .insert(providerRunners)
72
+ .values({
73
+ provisionerId: params.provisionerId,
74
+ providerRunnerId,
75
+ intendedReservationId: params.reservationId,
76
+ templateKey: 'linux',
77
+ labels: ['linux'],
78
+ state: 'starting',
79
+ providerKind: 'docker',
80
+ protocolVersion: '1',
81
+ reportedAt: new Date(),
82
+ })
83
+ .returning({id: providerRunners.id});
84
+ if (!runner) throw new Error('Expected runner instance');
85
+
86
+ await db()
87
+ .insert(runnerControlSessions)
88
+ .values({
89
+ runnerInstanceId: runner.id,
90
+ provisionerId: params.provisionerId,
91
+ hashedToken: hashOpaqueToken(controlSessionToken),
92
+ prefix: extractDisplayPrefix(controlSessionToken),
93
+ expiresAt: new Date(Date.now() + 60_000),
94
+ });
95
+
96
+ return {
97
+ controlSessionToken,
98
+ provisionerId: params.provisionerId,
99
+ reservationId: params.reservationId,
100
+ runnerInstanceId: runner.id,
101
+ workspaceId: params.workspaceId,
102
+ };
103
+ }
package/test/index.ts CHANGED
@@ -23,3 +23,8 @@ export {
23
23
  mintRunnerSessionToken,
24
24
  } from './fixtures/auth.js';
25
25
  export {runnersTestAuthClient} from './fixtures/auth-inter-module.js';
26
+ export {
27
+ arrangeDeletedRunnerEnrollment,
28
+ arrangeExpiredRunnerEnrollment,
29
+ type LateRunnerEnrollmentArrangement,
30
+ } from './fixtures/late-runner-enrollment.js';