@shipfox/api-runners 12.3.0 → 12.4.1

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 (153) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +36 -0
  3. package/dist/config.d.ts +1 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +9 -2
  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/errors.d.ts +5 -1
  18. package/dist/core/errors.d.ts.map +1 -1
  19. package/dist/core/errors.js +9 -2
  20. package/dist/core/errors.js.map +1 -1
  21. package/dist/core/runner-activation.d.ts +4 -1
  22. package/dist/core/runner-activation.d.ts.map +1 -1
  23. package/dist/core/runner-activation.js +32 -3
  24. package/dist/core/runner-activation.js.map +1 -1
  25. package/dist/core/runner-control-sessions.d.ts +8 -4
  26. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  27. package/dist/core/runner-control-sessions.js +116 -20
  28. package/dist/core/runner-control-sessions.js.map +1 -1
  29. package/dist/core/runner-instances.d.ts +1 -0
  30. package/dist/core/runner-instances.d.ts.map +1 -1
  31. package/dist/core/runner-instances.js +1 -0
  32. package/dist/core/runner-instances.js.map +1 -1
  33. package/dist/db/db.d.ts +68 -0
  34. package/dist/db/db.d.ts.map +1 -1
  35. package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
  36. package/dist/db/ephemeral-registration-tokens.js +1 -1
  37. package/dist/db/ephemeral-registration-tokens.js.map +1 -1
  38. package/dist/db/job-executions.d.ts.map +1 -1
  39. package/dist/db/job-executions.js +35 -4
  40. package/dist/db/job-executions.js.map +1 -1
  41. package/dist/db/reservation-locks.d.ts +6 -0
  42. package/dist/db/reservation-locks.d.ts.map +1 -0
  43. package/dist/db/reservation-locks.js +15 -0
  44. package/dist/db/reservation-locks.js.map +1 -0
  45. package/dist/db/reservations.d.ts +17 -3
  46. package/dist/db/reservations.d.ts.map +1 -1
  47. package/dist/db/reservations.js +246 -51
  48. package/dist/db/reservations.js.map +1 -1
  49. package/dist/db/runner-assignments.d.ts +25 -1
  50. package/dist/db/runner-assignments.d.ts.map +1 -1
  51. package/dist/db/runner-assignments.js +160 -18
  52. package/dist/db/runner-assignments.js.map +1 -1
  53. package/dist/db/runner-instances.d.ts +11 -2
  54. package/dist/db/runner-instances.d.ts.map +1 -1
  55. package/dist/db/runner-instances.js +167 -24
  56. package/dist/db/runner-instances.js.map +1 -1
  57. package/dist/db/runner-sessions.d.ts.map +1 -1
  58. package/dist/db/runner-sessions.js +14 -1
  59. package/dist/db/runner-sessions.js.map +1 -1
  60. package/dist/db/runner-states.d.ts +2 -0
  61. package/dist/db/runner-states.d.ts.map +1 -0
  62. package/dist/db/runner-states.js +7 -0
  63. package/dist/db/runner-states.js.map +1 -0
  64. package/dist/db/schema/reservations.d.ts +18 -0
  65. package/dist/db/schema/reservations.d.ts.map +1 -1
  66. package/dist/db/schema/reservations.js +7 -1
  67. package/dist/db/schema/reservations.js.map +1 -1
  68. package/dist/db/schema/runner-instances.d.ts +18 -0
  69. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  70. package/dist/db/schema/runner-instances.js +8 -0
  71. package/dist/db/schema/runner-instances.js.map +1 -1
  72. package/dist/metrics/index.d.ts +2 -2
  73. package/dist/metrics/index.d.ts.map +1 -1
  74. package/dist/metrics/index.js +1 -1
  75. package/dist/metrics/index.js.map +1 -1
  76. package/dist/metrics/instance.d.ts +72 -2
  77. package/dist/metrics/instance.d.ts.map +1 -1
  78. package/dist/metrics/instance.js +161 -1
  79. package/dist/metrics/instance.js.map +1 -1
  80. package/dist/metrics/service.d.ts.map +1 -1
  81. package/dist/metrics/service.js +25 -4
  82. package/dist/metrics/service.js.map +1 -1
  83. package/dist/presentation/dto/poll-demand.d.ts.map +1 -1
  84. package/dist/presentation/dto/poll-demand.js +3 -0
  85. package/dist/presentation/dto/poll-demand.js.map +1 -1
  86. package/dist/presentation/dto/runner-instances.d.ts +4 -1
  87. package/dist/presentation/dto/runner-instances.d.ts.map +1 -1
  88. package/dist/presentation/dto/runner-instances.js +4 -1
  89. package/dist/presentation/dto/runner-instances.js.map +1 -1
  90. package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
  91. package/dist/presentation/routes/assign-runner-instances.js +10 -4
  92. package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
  93. package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
  94. package/dist/presentation/routes/poll-demand.js +1 -0
  95. package/dist/presentation/routes/poll-demand.js.map +1 -1
  96. package/dist/presentation/routes/reconcile-runner-instances.d.ts.map +1 -1
  97. package/dist/presentation/routes/reconcile-runner-instances.js +4 -2
  98. package/dist/presentation/routes/reconcile-runner-instances.js.map +1 -1
  99. package/dist/presentation/routes/runner-enrollment.d.ts +4 -1
  100. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  101. package/dist/presentation/routes/runner-enrollment.js +11 -6
  102. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  103. package/dist/tsconfig.test.tsbuildinfo +1 -1
  104. package/drizzle/0005_previous_lenny_balinger.sql +5 -0
  105. package/drizzle/meta/0005_snapshot.json +2220 -0
  106. package/drizzle/meta/_journal.json +7 -0
  107. package/package.json +6 -6
  108. package/src/config.test.ts +22 -0
  109. package/src/config.ts +15 -2
  110. package/src/core/demand.test.ts +192 -3
  111. package/src/core/demand.ts +44 -4
  112. package/src/core/entities/reservation.ts +1 -0
  113. package/src/core/entities/runner-instance.ts +4 -0
  114. package/src/core/errors.ts +30 -1
  115. package/src/core/runner-activation.ts +48 -2
  116. package/src/core/runner-control-sessions.test.ts +135 -3
  117. package/src/core/runner-control-sessions.ts +141 -19
  118. package/src/core/runner-instances.test.ts +19 -0
  119. package/src/core/runner-instances.ts +2 -0
  120. package/src/core/runner-sessions.test.ts +121 -1
  121. package/src/db/ephemeral-registration-tokens.test.ts +20 -1
  122. package/src/db/ephemeral-registration-tokens.ts +1 -0
  123. package/src/db/job-executions.test.ts +98 -12
  124. package/src/db/job-executions.ts +52 -4
  125. package/src/db/reservation-locks.ts +22 -0
  126. package/src/db/reservations.test.ts +733 -22
  127. package/src/db/reservations.ts +408 -144
  128. package/src/db/runner-assignments.test.ts +105 -1
  129. package/src/db/runner-assignments.ts +245 -25
  130. package/src/db/runner-instances.test.ts +357 -2
  131. package/src/db/runner-instances.ts +289 -55
  132. package/src/db/runner-sessions.ts +19 -1
  133. package/src/db/runner-states.ts +7 -0
  134. package/src/db/schema/reservations.ts +5 -1
  135. package/src/db/schema/runner-instances.ts +9 -0
  136. package/src/metrics/index.ts +24 -1
  137. package/src/metrics/instance.test.ts +118 -0
  138. package/src/metrics/instance.ts +247 -3
  139. package/src/metrics/service.test.ts +46 -1
  140. package/src/metrics/service.ts +55 -8
  141. package/src/presentation/dto/poll-demand.ts +3 -0
  142. package/src/presentation/dto/runner-instances.ts +8 -1
  143. package/src/presentation/routes/assign-runner-instances.ts +14 -2
  144. package/src/presentation/routes/late-runner-enrollment.test.ts +219 -2
  145. package/src/presentation/routes/poll-demand.test.ts +56 -0
  146. package/src/presentation/routes/poll-demand.ts +1 -0
  147. package/src/presentation/routes/reconcile-runner-instances.test.ts +34 -2
  148. package/src/presentation/routes/reconcile-runner-instances.ts +7 -1
  149. package/src/presentation/routes/runner-enrollment.test.ts +316 -3
  150. package/src/presentation/routes/runner-enrollment.ts +8 -5
  151. package/test/factories/reservation.ts +2 -0
  152. package/test/factories/runner-instance.ts +6 -0
  153. package/tsconfig.build.tsbuildinfo +1 -1
@@ -7,6 +7,7 @@ import {
7
7
  countStaleEnrolledRunnerInstances,
8
8
  listActiveRunnerInstanceCountsByTemplateTx,
9
9
  listActiveRunnerInstances,
10
+ listProviderRunnerByPhaseMetrics,
10
11
  listProvisionerTerminateIntentRowsTx,
11
12
  listProvisionerTerminateIntents,
12
13
  reapStaleRunnerInstances,
@@ -146,6 +147,28 @@ describe('reportRunnerInstances', () => {
146
147
  expect(rows[0]?.state).toBe('running');
147
148
  });
148
149
 
150
+ it('preserves launch kind when a provider report updates the runner row', async () => {
151
+ await providerRunnerFactory.create({
152
+ workspaceId,
153
+ provisionerId,
154
+ providerRunnerId: 'demand-runner',
155
+ launchKind: 'demand',
156
+ });
157
+
158
+ await reportRunnerInstances({
159
+ scope: 'workspace',
160
+ workspaceId,
161
+ provisionerId,
162
+ events: [event({providerRunnerId: 'demand-runner', state: 'running'})],
163
+ });
164
+
165
+ const [row] = await db()
166
+ .select({launchKind: providerRunners.launchKind})
167
+ .from(providerRunners)
168
+ .where(eq(providerRunners.providerRunnerId, 'demand-runner'));
169
+ expect(row?.launchKind).toBe('demand');
170
+ });
171
+
149
172
  it('uses state progression to dedupe equal-timestamp provisioned runner reports', async () => {
150
173
  const reportedAt = new Date();
151
174
 
@@ -366,6 +389,58 @@ describe('reportRunnerInstances', () => {
366
389
  expect(rows[0]?.reservationId).toBe(reservationId);
367
390
  });
368
391
 
392
+ it('does not adopt a bound reservation id from an unclaimed report', async () => {
393
+ const reservationId = await createReservation(1, {kind: 'bound'});
394
+ await db()
395
+ .insert(providerRunners)
396
+ .values({
397
+ workspaceId,
398
+ provisionerId,
399
+ providerRunnerId: 'bound-reservation-runner',
400
+ state: 'starting',
401
+ reportedAt: new Date('2025-01-01T00:00:00.000Z'),
402
+ });
403
+
404
+ await reportRunnerInstances({
405
+ scope: 'workspace',
406
+ workspaceId,
407
+ provisionerId,
408
+ events: [
409
+ event({
410
+ providerRunnerId: 'bound-reservation-runner',
411
+ reservationId,
412
+ state: 'running',
413
+ reportedAt: new Date('2025-01-01T00:01:00.000Z'),
414
+ }),
415
+ ],
416
+ });
417
+
418
+ const rows = await providerRunnerRowsFor({workspaceId, provisionerId});
419
+ expect(rows[0]?.reservationId).toBeNull();
420
+ });
421
+
422
+ it('does not let reports exceed reservation capacity when creating projection rows', async () => {
423
+ const reservationId = await createReservation(1);
424
+
425
+ await reportRunnerInstances({
426
+ scope: 'workspace',
427
+ workspaceId,
428
+ provisionerId,
429
+ events: [
430
+ event({providerRunnerId: 'provisioned-runner-1', reservationId}),
431
+ event({providerRunnerId: 'provisioned-runner-2', reservationId}),
432
+ ],
433
+ });
434
+
435
+ const rows = await providerRunnerRowsFor({workspaceId, provisionerId});
436
+ expect(rows.find((row) => row.providerRunnerId === 'provisioned-runner-1')?.reservationId).toBe(
437
+ reservationId,
438
+ );
439
+ expect(
440
+ rows.find((row) => row.providerRunnerId === 'provisioned-runner-2')?.reservationId,
441
+ ).toBeNull();
442
+ });
443
+
369
444
  it('does not let equal-timestamp lower-priority reports flip terminal state', async () => {
370
445
  const reservationId = await createReservation(1);
371
446
  const reportedAt = new Date('2025-01-01T00:00:00.000Z');
@@ -554,8 +629,15 @@ describe('reportRunnerInstances', () => {
554
629
  expect(active).toEqual([]);
555
630
  });
556
631
 
557
- it('releases one reservation unit for a terminal unclaimed provisioned runner', async () => {
632
+ it('releases one reservation unit for a terminal runner with an intended reservation', async () => {
558
633
  const reservationId = await createReservation(2);
634
+ await providerRunnerFactory.create({
635
+ workspaceId,
636
+ provisionerId,
637
+ providerRunnerId: 'provisioned-runner-1',
638
+ intendedReservationId: reservationId,
639
+ state: 'running',
640
+ });
559
641
 
560
642
  const result = await reportRunnerInstances({
561
643
  scope: 'workspace',
@@ -571,6 +653,30 @@ describe('reportRunnerInstances', () => {
571
653
  expect(providerRunnerRows[0]?.reservationReleasedAt).toBeInstanceOf(Date);
572
654
  });
573
655
 
656
+ it('does not let an unrecognized terminal report consume reservation capacity', async () => {
657
+ const reservationId = await createReservation(1);
658
+
659
+ const result = await reportRunnerInstances({
660
+ scope: 'workspace',
661
+ workspaceId,
662
+ provisionerId,
663
+ events: [event({providerRunnerId: 'unrecognized-runner', reservationId, state: 'failed'})],
664
+ });
665
+
666
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
667
+ const providerRunnerRows = await providerRunnerRowsFor({workspaceId, provisionerId});
668
+ expect(result).toEqual({accepted: 1, reservationsReleased: 0, terminateIntentsHonored: []});
669
+ expect(reservationRows[0]?.count).toBe(1);
670
+ expect(providerRunnerRows).toHaveLength(1);
671
+ expect(providerRunnerRows[0]).toMatchObject({
672
+ providerRunnerId: 'unrecognized-runner',
673
+ reservationId: null,
674
+ intendedReservationId: null,
675
+ reservationReleasedAt: null,
676
+ state: 'failed',
677
+ });
678
+ });
679
+
574
680
  it('releases an intended reservation for a runner that dies before enrollment', async () => {
575
681
  const reservationId = await createReservation(2);
576
682
  const [runner] = await db()
@@ -741,6 +847,13 @@ describe('reportRunnerInstances', () => {
741
847
 
742
848
  it('releases a reservation only once across repeated terminal reports', async () => {
743
849
  const reservationId = await createReservation(2);
850
+ await providerRunnerFactory.create({
851
+ workspaceId,
852
+ provisionerId,
853
+ providerRunnerId: 'provisioned-runner-1',
854
+ intendedReservationId: reservationId,
855
+ state: 'running',
856
+ });
744
857
 
745
858
  await reportRunnerInstances({
746
859
  scope: 'workspace',
@@ -762,6 +875,13 @@ describe('reportRunnerInstances', () => {
762
875
 
763
876
  it('does not let a newer running report revive a terminal provisioned runner', async () => {
764
877
  const reservationId = await createReservation(2);
878
+ await providerRunnerFactory.create({
879
+ workspaceId,
880
+ provisionerId,
881
+ providerRunnerId: 'provisioned-runner-1',
882
+ intendedReservationId: reservationId,
883
+ state: 'running',
884
+ });
765
885
  const failedAt = new Date();
766
886
  await reportRunnerInstances({
767
887
  scope: 'workspace',
@@ -800,6 +920,13 @@ describe('reportRunnerInstances', () => {
800
920
 
801
921
  it('tracks provider cleanup as terminated', async () => {
802
922
  const reservationId = await createReservation(2);
923
+ await providerRunnerFactory.create({
924
+ workspaceId,
925
+ provisionerId,
926
+ providerRunnerId: 'provisioned-runner-1',
927
+ intendedReservationId: reservationId,
928
+ state: 'running',
929
+ });
803
930
 
804
931
  const result = await reportRunnerInstances({
805
932
  scope: 'workspace',
@@ -821,6 +948,20 @@ describe('reportRunnerInstances', () => {
821
948
 
822
949
  it('releases multiple units from the same reservation in one batch', async () => {
823
950
  const reservationId = await createReservation(3);
951
+ await providerRunnerFactory.create({
952
+ workspaceId,
953
+ provisionerId,
954
+ providerRunnerId: 'provisioned-runner-1',
955
+ intendedReservationId: reservationId,
956
+ state: 'running',
957
+ });
958
+ await providerRunnerFactory.create({
959
+ workspaceId,
960
+ provisionerId,
961
+ providerRunnerId: 'provisioned-runner-2',
962
+ intendedReservationId: reservationId,
963
+ state: 'running',
964
+ });
824
965
 
825
966
  const result = await reportRunnerInstances({
826
967
  scope: 'workspace',
@@ -839,6 +980,13 @@ describe('reportRunnerInstances', () => {
839
980
 
840
981
  it('deletes a one-unit reservation instead of violating the positive count check', async () => {
841
982
  const reservationId = await createReservation(1);
983
+ await providerRunnerFactory.create({
984
+ workspaceId,
985
+ provisionerId,
986
+ providerRunnerId: 'provisioned-runner-1',
987
+ intendedReservationId: reservationId,
988
+ state: 'running',
989
+ });
842
990
 
843
991
  const result = await reportRunnerInstances({
844
992
  scope: 'workspace',
@@ -862,6 +1010,13 @@ describe('reportRunnerInstances', () => {
862
1010
  });
863
1011
  const [reservation] = await reservationRowsFor({workspaceId, provisionerId});
864
1012
  if (!reservation) throw new Error('Expected reservation');
1013
+ await providerRunnerFactory.create({
1014
+ workspaceId,
1015
+ provisionerId,
1016
+ providerRunnerId: 'provisioned-runner-1',
1017
+ intendedReservationId: reservation.id,
1018
+ state: 'running',
1019
+ });
865
1020
 
866
1021
  const result = await reportRunnerInstances({
867
1022
  scope: 'workspace',
@@ -1012,13 +1167,14 @@ describe('reportRunnerInstances', () => {
1012
1167
 
1013
1168
  async function createReservation(
1014
1169
  count: number,
1015
- overrides?: {workspaceId?: string; provisionerId?: string},
1170
+ overrides?: {kind?: 'bound' | 'launch'; workspaceId?: string; provisionerId?: string},
1016
1171
  ): Promise<string> {
1017
1172
  await reservationFactory.create({
1018
1173
  workspaceId: overrides?.workspaceId ?? workspaceId,
1019
1174
  provisionerId: overrides?.provisionerId ?? provisionerId,
1020
1175
  requiredLabels: ['linux'],
1021
1176
  count,
1177
+ kind: overrides?.kind ?? 'launch',
1022
1178
  expiresAt: new Date(Date.now() + 60_000),
1023
1179
  });
1024
1180
  const [reservation] = await db()
@@ -1157,6 +1313,106 @@ describe('listProvisionerTerminateIntents', () => {
1157
1313
  expect(result).toEqual([{providerRunnerId: 'provisioned-runner-1', reason: 'job-cancelled'}]);
1158
1314
  });
1159
1315
 
1316
+ it('returns an activation-timeout intent for a stale demand-backed runner', async () => {
1317
+ await createRunnerInstance({
1318
+ providerRunnerId: 'stale-demand-runner',
1319
+ launchKind: 'demand',
1320
+ createdAt: new Date(Date.now() - 301_000),
1321
+ });
1322
+
1323
+ const result = await db().transaction((tx) =>
1324
+ listProvisionerTerminateIntentRowsTx(tx, {workspaceId, provisionerId, limit: 1000}),
1325
+ );
1326
+ const [runner] = await providerRunnerRowsFor({workspaceId, provisionerId});
1327
+
1328
+ expect(result).toEqual([
1329
+ {providerRunnerId: 'stale-demand-runner', reason: 'activation-timeout'},
1330
+ ]);
1331
+ expect(runner?.reservationReleasedAt).toBeInstanceOf(Date);
1332
+ });
1333
+
1334
+ it('does not reclaim warm, manual, active, or already-activated runners', async () => {
1335
+ const [liveReservation] = await db()
1336
+ .insert(reservations)
1337
+ .values({
1338
+ workspaceId,
1339
+ provisionerId,
1340
+ requiredLabels: ['linux'],
1341
+ count: 1,
1342
+ expiresAt: new Date(Date.now() + 60_000),
1343
+ })
1344
+ .returning({id: reservations.id});
1345
+ if (!liveReservation) throw new Error('Expected live reservation');
1346
+
1347
+ const staleCreatedAt = new Date(Date.now() - 301_000);
1348
+ await createRunnerInstance({
1349
+ providerRunnerId: 'stale-warm-runner',
1350
+ launchKind: 'warm',
1351
+ createdAt: staleCreatedAt,
1352
+ });
1353
+ await createRunnerInstance({
1354
+ providerRunnerId: 'stale-manual-runner',
1355
+ launchKind: 'manual',
1356
+ createdAt: staleCreatedAt,
1357
+ });
1358
+ await createRunnerInstance({
1359
+ providerRunnerId: 'live-demand-runner',
1360
+ launchKind: 'demand',
1361
+ reservationId: liveReservation.id,
1362
+ createdAt: staleCreatedAt,
1363
+ });
1364
+ await db()
1365
+ .insert(providerRunners)
1366
+ .values({
1367
+ workspaceId,
1368
+ provisionerId,
1369
+ intendedReservationId: liveReservation.id,
1370
+ providerRunnerId: 'live-intended-demand-runner',
1371
+ launchKind: 'demand',
1372
+ state: 'running',
1373
+ labels: ['linux'],
1374
+ reportedAt: staleCreatedAt,
1375
+ createdAt: staleCreatedAt,
1376
+ updatedAt: staleCreatedAt,
1377
+ });
1378
+ await createRunnerInstance({
1379
+ providerRunnerId: 'activated-demand-runner',
1380
+ launchKind: 'demand',
1381
+ runnerSessionId: crypto.randomUUID(),
1382
+ createdAt: staleCreatedAt,
1383
+ });
1384
+
1385
+ const result = await listProvisionerTerminateIntents({workspaceId, provisionerId, limit: 1000});
1386
+
1387
+ expect(result).toEqual([]);
1388
+ });
1389
+
1390
+ it('marks activation-timeout retries after releasing the runner reservation', async () => {
1391
+ await createRunnerInstance({
1392
+ providerRunnerId: 'retryable-demand-runner',
1393
+ launchKind: 'demand',
1394
+ createdAt: new Date(Date.now() - 301_000),
1395
+ });
1396
+
1397
+ const first = await db().transaction((tx) =>
1398
+ listProvisionerTerminateIntentRowsTx(tx, {workspaceId, provisionerId, limit: 1000}),
1399
+ );
1400
+ const second = await db().transaction((tx) =>
1401
+ listProvisionerTerminateIntentRowsTx(tx, {workspaceId, provisionerId, limit: 1000}),
1402
+ );
1403
+
1404
+ expect(first).toEqual([
1405
+ {providerRunnerId: 'retryable-demand-runner', reason: 'activation-timeout'},
1406
+ ]);
1407
+ expect(second).toEqual([
1408
+ {
1409
+ providerRunnerId: 'retryable-demand-runner',
1410
+ reason: 'activation-timeout',
1411
+ activationTimeoutRetry: true,
1412
+ },
1413
+ ]);
1414
+ });
1415
+
1160
1416
  it('excludes active provisioned runners whose latest bound job is healthy', async () => {
1161
1417
  await createRunnerInstance({providerRunnerId: 'provisioned-runner-1'});
1162
1418
  await insertRunningJobRow({
@@ -1272,12 +1528,20 @@ describe('listProvisionerTerminateIntents', () => {
1272
1528
  providerRunnerId: string;
1273
1529
  provisionerId?: string;
1274
1530
  state?: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'terminated';
1531
+ launchKind?: 'demand' | 'warm' | 'manual';
1532
+ createdAt?: Date;
1533
+ reservationId?: string | null;
1534
+ runnerSessionId?: string | null;
1275
1535
  }) {
1276
1536
  return await providerRunnerFactory.create({
1277
1537
  workspaceId,
1278
1538
  provisionerId: params.provisionerId ?? provisionerId,
1279
1539
  providerRunnerId: params.providerRunnerId,
1280
1540
  state: params.state ?? 'running',
1541
+ launchKind: params.launchKind ?? 'manual',
1542
+ createdAt: params.createdAt ?? new Date(),
1543
+ reservationId: params.reservationId ?? null,
1544
+ runnerSessionId: params.runnerSessionId ?? null,
1281
1545
  });
1282
1546
  }
1283
1547
  });
@@ -2277,6 +2541,97 @@ describe('countStaleEnrolledRunnerInstances', () => {
2277
2541
  }
2278
2542
  });
2279
2543
 
2544
+ describe('listProviderRunnerByPhaseMetrics', () => {
2545
+ it('groups runners by the lifecycle phase that is currently blocking activation', async () => {
2546
+ const provisionerId = crypto.randomUUID();
2547
+ const provider = `metrics-test-${crypto.randomUUID()}`;
2548
+ const reservationId = crypto.randomUUID();
2549
+ const workspaceId = crypto.randomUUID();
2550
+ const old = new Date(Date.now() - 120_000);
2551
+
2552
+ const createRunner = async (params: {
2553
+ state: 'starting' | 'running';
2554
+ launchKind: 'demand' | 'warm' | 'manual';
2555
+ intendedReservationId?: string | null;
2556
+ workspaceId?: string | null;
2557
+ reservationId?: string | null;
2558
+ assignedAt?: Date | null;
2559
+ createdAt?: Date;
2560
+ }) => {
2561
+ const [runner] = await db()
2562
+ .insert(providerRunners)
2563
+ .values({
2564
+ provisionerId,
2565
+ providerRunnerId: crypto.randomUUID(),
2566
+ providerKind: provider,
2567
+ launchKind: params.launchKind,
2568
+ intendedReservationId: params.intendedReservationId ?? null,
2569
+ workspaceId: params.workspaceId ?? null,
2570
+ reservationId: params.reservationId ?? null,
2571
+ assignedAt: params.assignedAt ?? null,
2572
+ state: params.state,
2573
+ labels: ['linux'],
2574
+ reportedAt: new Date(),
2575
+ createdAt: params.createdAt ?? old,
2576
+ })
2577
+ .returning({id: providerRunners.id});
2578
+ if (!runner) throw new Error('Expected runner instance');
2579
+ return runner.id;
2580
+ };
2581
+
2582
+ await createRunner({state: 'starting', launchKind: 'demand'});
2583
+ const enrollmentRunnerId = await createRunner({state: 'starting', launchKind: 'warm'});
2584
+ const assignmentRunnerId = await createRunner({
2585
+ state: 'running',
2586
+ launchKind: 'demand',
2587
+ intendedReservationId: reservationId,
2588
+ });
2589
+ const activationRunnerId = await createRunner({
2590
+ state: 'running',
2591
+ launchKind: 'manual',
2592
+ workspaceId,
2593
+ reservationId,
2594
+ assignedAt: old,
2595
+ });
2596
+ const idleRunnerId = await createRunner({state: 'running', launchKind: 'warm'});
2597
+ const demandIdleRunnerId = await createRunner({state: 'running', launchKind: 'demand'});
2598
+
2599
+ await db()
2600
+ .insert(runnerControlSessions)
2601
+ .values(
2602
+ [
2603
+ enrollmentRunnerId,
2604
+ assignmentRunnerId,
2605
+ activationRunnerId,
2606
+ idleRunnerId,
2607
+ demandIdleRunnerId,
2608
+ ].map((runnerInstanceId) => ({
2609
+ runnerInstanceId,
2610
+ provisionerId,
2611
+ hashedToken: crypto.randomUUID(),
2612
+ prefix: 'metrics-test',
2613
+ expiresAt: new Date(Date.now() + 60_000),
2614
+ createdAt: old,
2615
+ })),
2616
+ );
2617
+
2618
+ const metrics = await listProviderRunnerByPhaseMetrics();
2619
+ const ownMetrics = metrics.filter((metric) => metric.provider === provider);
2620
+
2621
+ expect(ownMetrics).toEqual(
2622
+ expect.arrayContaining([
2623
+ expect.objectContaining({phase: 'control_session', launchKind: 'demand', count: 1}),
2624
+ expect.objectContaining({phase: 'enrollment', launchKind: 'warm', count: 1}),
2625
+ expect.objectContaining({phase: 'assignment', launchKind: 'demand', count: 1}),
2626
+ expect.objectContaining({phase: 'activation', launchKind: 'manual', count: 1}),
2627
+ expect.objectContaining({phase: 'idle', launchKind: 'warm', count: 1}),
2628
+ expect.objectContaining({phase: 'idle', launchKind: 'demand', count: 1}),
2629
+ ]),
2630
+ );
2631
+ expect(ownMetrics).toHaveLength(6);
2632
+ });
2633
+ });
2634
+
2280
2635
  describe('runner instance provider attachment', () => {
2281
2636
  it('updates the pre-created runner instance before its first lifecycle report', async () => {
2282
2637
  const provisionerId = crypto.randomUUID();