@shipfox/api-runners 12.1.0 → 12.3.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 (129) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +35 -0
  3. package/dist/config.d.ts +3 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +24 -2
  6. package/dist/config.js.map +1 -1
  7. package/dist/core/errors.d.ts +4 -0
  8. package/dist/core/errors.d.ts.map +1 -1
  9. package/dist/core/errors.js +6 -0
  10. package/dist/core/errors.js.map +1 -1
  11. package/dist/core/index.d.ts +1 -1
  12. package/dist/core/index.d.ts.map +1 -1
  13. package/dist/core/index.js +1 -1
  14. package/dist/core/index.js.map +1 -1
  15. package/dist/core/rate-limit.d.ts.map +1 -1
  16. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  17. package/dist/core/runner-control-sessions.js +37 -11
  18. package/dist/core/runner-control-sessions.js.map +1 -1
  19. package/dist/core/runner-instances.d.ts +1 -0
  20. package/dist/core/runner-instances.d.ts.map +1 -1
  21. package/dist/core/runner-instances.js.map +1 -1
  22. package/dist/core/runner-labels.d.ts +11 -0
  23. package/dist/core/runner-labels.d.ts.map +1 -0
  24. package/dist/core/runner-labels.js +41 -0
  25. package/dist/core/runner-labels.js.map +1 -0
  26. package/dist/core/runner-sessions.d.ts.map +1 -1
  27. package/dist/core/runner-sessions.js +5 -1
  28. package/dist/core/runner-sessions.js.map +1 -1
  29. package/dist/db/db.d.ts +64 -64
  30. package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
  31. package/dist/db/ephemeral-registration-tokens.js +12 -2
  32. package/dist/db/ephemeral-registration-tokens.js.map +1 -1
  33. package/dist/db/index.d.ts +1 -1
  34. package/dist/db/index.d.ts.map +1 -1
  35. package/dist/db/index.js +1 -1
  36. package/dist/db/index.js.map +1 -1
  37. package/dist/db/reservations.d.ts.map +1 -1
  38. package/dist/db/reservations.js +36 -10
  39. package/dist/db/reservations.js.map +1 -1
  40. package/dist/db/runner-instances.d.ts +4 -0
  41. package/dist/db/runner-instances.d.ts.map +1 -1
  42. package/dist/db/runner-instances.js +16 -5
  43. package/dist/db/runner-instances.js.map +1 -1
  44. package/dist/db/runner-sessions.d.ts.map +1 -1
  45. package/dist/db/runner-sessions.js +33 -15
  46. package/dist/db/runner-sessions.js.map +1 -1
  47. package/dist/db/schema/admin-command-results.d.ts +1 -1
  48. package/dist/db/schema/ephemeral-registration-tokens.d.ts +1 -1
  49. package/dist/db/schema/manual-registration-tokens.d.ts +1 -1
  50. package/dist/db/schema/outbox.d.ts +1 -1
  51. package/dist/db/schema/pending-job-executions.d.ts +3 -3
  52. package/dist/db/schema/provisioner-capability-snapshots.d.ts +3 -3
  53. package/dist/db/schema/provisioner-tokens.d.ts +2 -2
  54. package/dist/db/schema/rate-limits.d.ts +1 -1
  55. package/dist/db/schema/reservations.d.ts +3 -3
  56. package/dist/db/schema/runner-activation-tokens.d.ts +1 -1
  57. package/dist/db/schema/runner-control-sessions.d.ts +2 -2
  58. package/dist/db/schema/runner-instances.d.ts +4 -4
  59. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  60. package/dist/db/schema/runner-instances.js +1 -0
  61. package/dist/db/schema/runner-instances.js.map +1 -1
  62. package/dist/db/schema/runner-sessions.d.ts +4 -4
  63. package/dist/db/schema/running-job-executions.d.ts +5 -5
  64. package/dist/metrics/index.d.ts +2 -1
  65. package/dist/metrics/index.d.ts.map +1 -1
  66. package/dist/metrics/index.js +1 -1
  67. package/dist/metrics/index.js.map +1 -1
  68. package/dist/metrics/instance.d.ts +13 -8
  69. package/dist/metrics/instance.d.ts.map +1 -1
  70. package/dist/metrics/instance.js +8 -0
  71. package/dist/metrics/instance.js.map +1 -1
  72. package/dist/metrics/service.d.ts.map +1 -1
  73. package/dist/metrics/service.js +20 -4
  74. package/dist/metrics/service.js.map +1 -1
  75. package/dist/presentation/dto/admin-provisioner-token.d.ts +1 -1
  76. package/dist/presentation/dto/admin-provisioner-token.d.ts.map +1 -1
  77. package/dist/presentation/dto/admin-runner-instances.d.ts +2 -2
  78. package/dist/presentation/dto/admin-runner-instances.d.ts.map +1 -1
  79. package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
  80. package/dist/presentation/routes/poll-demand.js +12 -6
  81. package/dist/presentation/routes/poll-demand.js.map +1 -1
  82. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  83. package/dist/presentation/routes/register.d.ts.map +1 -1
  84. package/dist/presentation/routes/register.js +9 -1
  85. package/dist/presentation/routes/register.js.map +1 -1
  86. package/dist/presentation/routes/report-runner-instances.d.ts.map +1 -1
  87. package/dist/presentation/routes/report-runner-instances.js +1 -0
  88. package/dist/presentation/routes/report-runner-instances.js.map +1 -1
  89. package/dist/tsconfig.test.tsbuildinfo +1 -1
  90. package/drizzle/0004_huge_skreet.sql +1 -0
  91. package/drizzle/meta/0004_snapshot.json +2194 -0
  92. package/drizzle/meta/_journal.json +7 -0
  93. package/package.json +9 -9
  94. package/src/config.test.ts +81 -0
  95. package/src/config.ts +39 -2
  96. package/src/core/errors.ts +9 -0
  97. package/src/core/index.ts +1 -0
  98. package/src/core/runner-control-sessions.test.ts +404 -0
  99. package/src/core/runner-control-sessions.ts +51 -11
  100. package/src/core/runner-instances.ts +1 -0
  101. package/src/core/runner-labels.test.ts +53 -0
  102. package/src/core/runner-labels.ts +51 -0
  103. package/src/core/runner-sessions.test.ts +34 -1
  104. package/src/core/runner-sessions.ts +8 -1
  105. package/src/db/ephemeral-registration-tokens.ts +15 -1
  106. package/src/db/index.ts +1 -0
  107. package/src/db/reservations.test.ts +537 -0
  108. package/src/db/reservations.ts +116 -17
  109. package/src/db/runner-instances.test.ts +276 -1
  110. package/src/db/runner-instances.ts +39 -3
  111. package/src/db/runner-sessions.ts +39 -15
  112. package/src/db/schema/runner-instances.ts +3 -0
  113. package/src/metrics/index.ts +2 -0
  114. package/src/metrics/instance.ts +18 -0
  115. package/src/metrics/service.test.ts +99 -0
  116. package/src/metrics/service.ts +24 -4
  117. package/src/presentation/routes/late-runner-enrollment.test.ts +230 -0
  118. package/src/presentation/routes/poll-demand.test.ts +116 -3
  119. package/src/presentation/routes/poll-demand.ts +18 -6
  120. package/src/presentation/routes/register.test.ts +116 -2
  121. package/src/presentation/routes/register.ts +7 -0
  122. package/src/presentation/routes/report-runner-instances.test.ts +33 -0
  123. package/src/presentation/routes/report-runner-instances.ts +1 -0
  124. package/src/presentation/routes/runner-enrollment.test.ts +65 -5
  125. package/src/temporal/activities/maintenance-activities.test.ts +24 -31
  126. package/test/env.ts +1 -0
  127. package/test/fixtures/late-runner-enrollment.ts +103 -0
  128. package/test/index.ts +5 -0
  129. package/tsconfig.build.tsbuildinfo +1 -1
@@ -4,16 +4,18 @@ import {db} from '#db/db.js';
4
4
  import {createRunnerSessionConsumingEphemeralToken} from '#db/ephemeral-registration-tokens.js';
5
5
  import {
6
6
  attachRunnerInstanceProviderId,
7
+ countStaleEnrolledRunnerInstances,
7
8
  listActiveRunnerInstanceCountsByTemplateTx,
8
9
  listActiveRunnerInstances,
9
10
  listProvisionerTerminateIntentRowsTx,
10
11
  listProvisionerTerminateIntents,
11
12
  reapStaleRunnerInstances,
12
13
  reconcileRunnerInstances,
13
- reportRunnerInstances,
14
+ reportRunnerInstances as reportRunnerInstancesDb,
14
15
  } from '#db/runner-instances.js';
15
16
  import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
16
17
  import {reservations} from '#db/schema/reservations.js';
18
+ import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
17
19
  import {providerRunners} from '#db/schema/runner-instances.js';
18
20
  import {runnerSessions} from '#db/schema/runner-sessions.js';
19
21
  import {runningJobExecutions} from '#db/schema/running-job-executions.js';
@@ -25,6 +27,12 @@ import {
25
27
  runnerSessionFactory,
26
28
  } from '#test/index.js';
27
29
 
30
+ type ReportRunnerInstancesTestParams = Parameters<typeof reportRunnerInstancesDb>[0];
31
+
32
+ function reportRunnerInstances(params: ReportRunnerInstancesTestParams) {
33
+ return reportRunnerInstancesDb(params);
34
+ }
35
+
28
36
  function providerRunnerRowsFor(params: {workspaceId: string; provisionerId: string}) {
29
37
  return db()
30
38
  .select()
@@ -93,6 +101,7 @@ describe('reportRunnerInstances', () => {
93
101
  const reportedAt = new Date();
94
102
 
95
103
  const report = await reportRunnerInstances({
104
+ scope: 'installation',
96
105
  workspaceId: null,
97
106
  provisionerId,
98
107
  events: [event({providerRunnerId: 'installation-runner', reportedAt})],
@@ -118,6 +127,7 @@ describe('reportRunnerInstances', () => {
118
127
  const reportedAt = new Date();
119
128
 
120
129
  const result = await reportRunnerInstances({
130
+ scope: 'workspace',
121
131
  workspaceId,
122
132
  provisionerId,
123
133
  events: [
@@ -140,6 +150,7 @@ describe('reportRunnerInstances', () => {
140
150
  const reportedAt = new Date();
141
151
 
142
152
  const result = await reportRunnerInstances({
153
+ scope: 'workspace',
143
154
  workspaceId,
144
155
  provisionerId,
145
156
  events: [
@@ -160,6 +171,7 @@ describe('reportRunnerInstances', () => {
160
171
  it('accepts delayed events that move the lifecycle forward', async () => {
161
172
  const newest = new Date();
162
173
  await reportRunnerInstances({
174
+ scope: 'workspace',
163
175
  workspaceId,
164
176
  provisionerId,
165
177
  events: [
@@ -168,6 +180,7 @@ describe('reportRunnerInstances', () => {
168
180
  });
169
181
 
170
182
  await reportRunnerInstances({
183
+ scope: 'workspace',
171
184
  workspaceId,
172
185
  provisionerId,
173
186
  events: [
@@ -188,6 +201,7 @@ describe('reportRunnerInstances', () => {
188
201
  it('rejects older out-of-order events in the same lifecycle state', async () => {
189
202
  const newest = new Date();
190
203
  await reportRunnerInstances({
204
+ scope: 'workspace',
191
205
  workspaceId,
192
206
  provisionerId,
193
207
  events: [
@@ -201,6 +215,7 @@ describe('reportRunnerInstances', () => {
201
215
  });
202
216
 
203
217
  await reportRunnerInstances({
218
+ scope: 'workspace',
204
219
  workspaceId,
205
220
  provisionerId,
206
221
  events: [
@@ -218,10 +233,144 @@ describe('reportRunnerInstances', () => {
218
233
  expect(rows[0]?.reason).toBe('fresh');
219
234
  });
220
235
 
236
+ it('preserves a rebound reservation through stale reports and releases it on termination', async () => {
237
+ const staleReservationId = await createReservation(1);
238
+ await db()
239
+ .update(reservations)
240
+ .set({expiresAt: new Date(Date.now() - 60_000)})
241
+ .where(eq(reservations.id, staleReservationId));
242
+ const reboundReservationId = await createReservation(2);
243
+ const initialReportedAt = new Date(Date.now() - 120_000);
244
+ const runningReportedAt = new Date(Date.now() - 60_000);
245
+ await providerRunnerFactory.create({
246
+ workspaceId,
247
+ provisionerId,
248
+ providerRunnerId: 'rebound-runner',
249
+ reservationId: reboundReservationId,
250
+ state: 'running',
251
+ reportedAt: initialReportedAt,
252
+ });
253
+
254
+ const runningReport = await reportRunnerInstances({
255
+ scope: 'workspace',
256
+ workspaceId,
257
+ provisionerId,
258
+ events: [
259
+ event({
260
+ providerRunnerId: 'rebound-runner',
261
+ reservationId: staleReservationId,
262
+ reportedAt: runningReportedAt,
263
+ }),
264
+ ],
265
+ });
266
+ const terminalReport = await reportRunnerInstances({
267
+ scope: 'workspace',
268
+ workspaceId,
269
+ provisionerId,
270
+ events: [
271
+ event({
272
+ providerRunnerId: 'rebound-runner',
273
+ reservationId: staleReservationId,
274
+ state: 'failed',
275
+ reportedAt: new Date(runningReportedAt.getTime() + 1_000),
276
+ }),
277
+ ],
278
+ });
279
+
280
+ const [runner] = await db()
281
+ .select()
282
+ .from(providerRunners)
283
+ .where(eq(providerRunners.providerRunnerId, 'rebound-runner'));
284
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
285
+ expect(runningReport).toEqual({
286
+ accepted: 1,
287
+ reservationsReleased: 0,
288
+ terminateIntentsHonored: [],
289
+ });
290
+ expect(terminalReport).toEqual({
291
+ accepted: 1,
292
+ reservationsReleased: 1,
293
+ terminateIntentsHonored: [],
294
+ });
295
+ expect(runner).toMatchObject({
296
+ reservationId: reboundReservationId,
297
+ reservationReleasedAt: expect.any(Date),
298
+ });
299
+ expect(
300
+ reservationRows.find((reservation) => reservation.id === reboundReservationId)?.count,
301
+ ).toBe(1);
302
+ expect(
303
+ reservationRows.find((reservation) => reservation.id === staleReservationId)?.count,
304
+ ).toBe(1);
305
+ });
306
+
307
+ it('keeps the stored reservation when a report carries a stale reservation id', async () => {
308
+ const staleReservationId = await createReservation(1);
309
+ const boundReservationId = await createReservation(1);
310
+ await db()
311
+ .insert(providerRunners)
312
+ .values({
313
+ workspaceId,
314
+ provisionerId,
315
+ reservationId: boundReservationId,
316
+ providerRunnerId: 'provisioned-runner-1',
317
+ state: 'running',
318
+ reportedAt: new Date('2025-01-01T00:00:00.000Z'),
319
+ });
320
+
321
+ await reportRunnerInstances({
322
+ scope: 'workspace',
323
+ workspaceId,
324
+ provisionerId,
325
+ events: [
326
+ event({
327
+ providerRunnerId: 'provisioned-runner-1',
328
+ reservationId: staleReservationId,
329
+ state: 'running',
330
+ reportedAt: new Date('2025-01-01T00:01:00.000Z'),
331
+ }),
332
+ ],
333
+ });
334
+
335
+ const rows = await providerRunnerRowsFor({workspaceId, provisionerId});
336
+ expect(rows[0]?.reservationId).toBe(boundReservationId);
337
+ });
338
+
339
+ it('adopts a reported reservation id when the runner carries none', async () => {
340
+ const reservationId = await createReservation(1);
341
+ await db()
342
+ .insert(providerRunners)
343
+ .values({
344
+ workspaceId,
345
+ provisionerId,
346
+ providerRunnerId: 'provisioned-runner-1',
347
+ state: 'starting',
348
+ reportedAt: new Date('2025-01-01T00:00:00.000Z'),
349
+ });
350
+
351
+ await reportRunnerInstances({
352
+ scope: 'workspace',
353
+ workspaceId,
354
+ provisionerId,
355
+ events: [
356
+ event({
357
+ providerRunnerId: 'provisioned-runner-1',
358
+ reservationId,
359
+ state: 'running',
360
+ reportedAt: new Date('2025-01-01T00:01:00.000Z'),
361
+ }),
362
+ ],
363
+ });
364
+
365
+ const rows = await providerRunnerRowsFor({workspaceId, provisionerId});
366
+ expect(rows[0]?.reservationId).toBe(reservationId);
367
+ });
368
+
221
369
  it('does not let equal-timestamp lower-priority reports flip terminal state', async () => {
222
370
  const reservationId = await createReservation(1);
223
371
  const reportedAt = new Date('2025-01-01T00:00:00.000Z');
224
372
  await reportRunnerInstances({
373
+ scope: 'workspace',
225
374
  workspaceId,
226
375
  provisionerId,
227
376
  events: [
@@ -235,6 +384,7 @@ describe('reportRunnerInstances', () => {
235
384
  });
236
385
 
237
386
  const terminal = await reportRunnerInstances({
387
+ scope: 'workspace',
238
388
  workspaceId,
239
389
  provisionerId,
240
390
  events: [
@@ -247,6 +397,7 @@ describe('reportRunnerInstances', () => {
247
397
  ],
248
398
  });
249
399
  const revived = await reportRunnerInstances({
400
+ scope: 'workspace',
250
401
  workspaceId,
251
402
  provisionerId,
252
403
  events: [
@@ -270,6 +421,7 @@ describe('reportRunnerInstances', () => {
270
421
 
271
422
  it('clamps future reported times so they do not pin provisioned runner state', async () => {
272
423
  await reportRunnerInstances({
424
+ scope: 'workspace',
273
425
  workspaceId,
274
426
  provisionerId,
275
427
  events: [
@@ -282,6 +434,7 @@ describe('reportRunnerInstances', () => {
282
434
  });
283
435
 
284
436
  await reportRunnerInstances({
437
+ scope: 'workspace',
285
438
  workspaceId,
286
439
  provisionerId,
287
440
  events: [
@@ -305,6 +458,7 @@ describe('reportRunnerInstances', () => {
305
458
  const terminatedAt = new Date('2025-01-01T00:03:00.000Z');
306
459
 
307
460
  const result = await reportRunnerInstances({
461
+ scope: 'workspace',
308
462
  workspaceId,
309
463
  provisionerId,
310
464
  events: [
@@ -346,6 +500,7 @@ describe('reportRunnerInstances', () => {
346
500
  const terminatedAt = new Date('2025-01-01T00:03:00.000Z');
347
501
  const startedAt = new Date('2025-01-01T00:00:00.000Z');
348
502
  await reportRunnerInstances({
503
+ scope: 'workspace',
349
504
  workspaceId,
350
505
  provisionerId,
351
506
  events: [
@@ -358,6 +513,7 @@ describe('reportRunnerInstances', () => {
358
513
  });
359
514
 
360
515
  await reportRunnerInstances({
516
+ scope: 'workspace',
361
517
  workspaceId,
362
518
  provisionerId,
363
519
  events: [
@@ -378,6 +534,7 @@ describe('reportRunnerInstances', () => {
378
534
 
379
535
  it('uses server update time for active provisioned runner windows', async () => {
380
536
  await reportRunnerInstances({
537
+ scope: 'workspace',
381
538
  workspaceId,
382
539
  provisionerId,
383
540
  events: [
@@ -401,6 +558,7 @@ describe('reportRunnerInstances', () => {
401
558
  const reservationId = await createReservation(2);
402
559
 
403
560
  const result = await reportRunnerInstances({
561
+ scope: 'workspace',
404
562
  workspaceId,
405
563
  provisionerId,
406
564
  events: [event({providerRunnerId: 'provisioned-runner-1', reservationId, state: 'failed'})],
@@ -429,6 +587,7 @@ describe('reportRunnerInstances', () => {
429
587
  if (!runner.providerRunnerId) throw new Error('Runner instance provider id missing');
430
588
 
431
589
  const result = await reportRunnerInstances({
590
+ scope: 'workspace',
432
591
  workspaceId,
433
592
  provisionerId,
434
593
  events: [event({providerRunnerId: runner.providerRunnerId, state: 'failed'})],
@@ -479,6 +638,7 @@ describe('reportRunnerInstances', () => {
479
638
  });
480
639
 
481
640
  const result = await reportRunnerInstances({
641
+ scope: 'installation',
482
642
  workspaceId: null,
483
643
  provisionerId: installationProvisioner.id,
484
644
  events: [event({providerRunnerId: 'installation-pre-enrollment-runner', state: 'failed'})],
@@ -521,6 +681,7 @@ describe('reportRunnerInstances', () => {
521
681
  });
522
682
 
523
683
  const result = await reportRunnerInstances({
684
+ scope: 'installation',
524
685
  workspaceId: null,
525
686
  provisionerId: installationProvisioner.id,
526
687
  events: [
@@ -552,6 +713,7 @@ describe('reportRunnerInstances', () => {
552
713
  });
553
714
 
554
715
  const result = await reportRunnerInstances({
716
+ scope: 'workspace',
555
717
  workspaceId,
556
718
  provisionerId,
557
719
  events: [
@@ -581,11 +743,13 @@ describe('reportRunnerInstances', () => {
581
743
  const reservationId = await createReservation(2);
582
744
 
583
745
  await reportRunnerInstances({
746
+ scope: 'workspace',
584
747
  workspaceId,
585
748
  provisionerId,
586
749
  events: [event({providerRunnerId: 'provisioned-runner-1', reservationId, state: 'failed'})],
587
750
  });
588
751
  const result = await reportRunnerInstances({
752
+ scope: 'workspace',
589
753
  workspaceId,
590
754
  provisionerId,
591
755
  events: [event({providerRunnerId: 'provisioned-runner-1', reservationId, state: 'failed'})],
@@ -600,6 +764,7 @@ describe('reportRunnerInstances', () => {
600
764
  const reservationId = await createReservation(2);
601
765
  const failedAt = new Date();
602
766
  await reportRunnerInstances({
767
+ scope: 'workspace',
603
768
  workspaceId,
604
769
  provisionerId,
605
770
  events: [
@@ -613,6 +778,7 @@ describe('reportRunnerInstances', () => {
613
778
  });
614
779
 
615
780
  await reportRunnerInstances({
781
+ scope: 'workspace',
616
782
  workspaceId,
617
783
  provisionerId,
618
784
  events: [
@@ -636,6 +802,7 @@ describe('reportRunnerInstances', () => {
636
802
  const reservationId = await createReservation(2);
637
803
 
638
804
  const result = await reportRunnerInstances({
805
+ scope: 'workspace',
639
806
  workspaceId,
640
807
  provisionerId,
641
808
  events: [
@@ -656,6 +823,7 @@ describe('reportRunnerInstances', () => {
656
823
  const reservationId = await createReservation(3);
657
824
 
658
825
  const result = await reportRunnerInstances({
826
+ scope: 'workspace',
659
827
  workspaceId,
660
828
  provisionerId,
661
829
  events: [
@@ -673,6 +841,7 @@ describe('reportRunnerInstances', () => {
673
841
  const reservationId = await createReservation(1);
674
842
 
675
843
  const result = await reportRunnerInstances({
844
+ scope: 'workspace',
676
845
  workspaceId,
677
846
  provisionerId,
678
847
  events: [event({providerRunnerId: 'provisioned-runner-1', reservationId, state: 'failed'})],
@@ -695,6 +864,7 @@ describe('reportRunnerInstances', () => {
695
864
  if (!reservation) throw new Error('Expected reservation');
696
865
 
697
866
  const result = await reportRunnerInstances({
867
+ scope: 'workspace',
698
868
  workspaceId,
699
869
  provisionerId,
700
870
  events: [
@@ -715,6 +885,7 @@ describe('reportRunnerInstances', () => {
715
885
  const reservationId = await createReservation(1);
716
886
 
717
887
  const result = await reportRunnerInstances({
888
+ scope: 'workspace',
718
889
  workspaceId,
719
890
  provisionerId,
720
891
  events: [
@@ -750,6 +921,7 @@ describe('reportRunnerInstances', () => {
750
921
  });
751
922
 
752
923
  const result = await reportRunnerInstances({
924
+ scope: 'workspace',
753
925
  workspaceId,
754
926
  provisionerId,
755
927
  events: [
@@ -776,6 +948,7 @@ describe('reportRunnerInstances', () => {
776
948
  const reportedAt = new Date('2025-01-01T00:00:00.000Z');
777
949
 
778
950
  const result = await reportRunnerInstances({
951
+ scope: 'workspace',
779
952
  workspaceId,
780
953
  provisionerId,
781
954
  events: [
@@ -819,11 +992,13 @@ describe('reportRunnerInstances', () => {
819
992
  });
820
993
 
821
994
  const first = await reportRunnerInstances({
995
+ scope: 'workspace',
822
996
  workspaceId,
823
997
  provisionerId,
824
998
  events: [event({providerRunnerId: 'provisioned-runner-1', state: 'terminated'})],
825
999
  });
826
1000
  const second = await reportRunnerInstances({
1001
+ scope: 'workspace',
827
1002
  workspaceId,
828
1003
  provisionerId,
829
1004
  events: [event({providerRunnerId: 'provisioned-runner-1', state: 'terminated'})],
@@ -1365,6 +1540,7 @@ describe('reapStaleRunnerInstances', () => {
1365
1540
 
1366
1541
  await lockHolderReady.promise;
1367
1542
  const report = reportRunnerInstances({
1543
+ scope: 'workspace',
1368
1544
  workspaceId,
1369
1545
  provisionerId,
1370
1546
  events: [
@@ -1845,6 +2021,7 @@ describe('reconcileRunnerInstances', () => {
1845
2021
  });
1846
2022
 
1847
2023
  await reportRunnerInstances({
2024
+ scope: 'workspace',
1848
2025
  workspaceId,
1849
2026
  provisionerId,
1850
2027
  events: [
@@ -1877,6 +2054,7 @@ describe('reconcileRunnerInstances', () => {
1877
2054
 
1878
2055
  await lockHolderReady.promise;
1879
2056
  const report = reportRunnerInstances({
2057
+ scope: 'workspace',
1880
2058
  workspaceId,
1881
2059
  provisionerId,
1882
2060
  events: [
@@ -2003,6 +2181,102 @@ describe('reconcileRunnerInstances', () => {
2003
2181
  }
2004
2182
  });
2005
2183
 
2184
+ describe('countStaleEnrolledRunnerInstances', () => {
2185
+ let provisionerId: string;
2186
+
2187
+ beforeEach(() => {
2188
+ provisionerId = crypto.randomUUID();
2189
+ });
2190
+
2191
+ function staleAt(): Date {
2192
+ return new Date(Date.now() - 120_000);
2193
+ }
2194
+
2195
+ it('counts only stale reported runners with a live control session and no assignment', async () => {
2196
+ const baseline = await countStaleEnrolledRunnerInstances({graceSeconds: 60});
2197
+ const stale = await createRunner({updatedAt: staleAt()});
2198
+ await createControlSession(stale);
2199
+
2200
+ const fresh = await createRunner({updatedAt: new Date()});
2201
+ await createControlSession(fresh);
2202
+
2203
+ const recentlyMutated = await createRunner({reportedAt: staleAt(), updatedAt: new Date()});
2204
+ await createControlSession(recentlyMutated);
2205
+
2206
+ const freshReport = await createRunner({reportedAt: new Date(), updatedAt: staleAt()});
2207
+ await createControlSession(freshReport);
2208
+
2209
+ const assigned = await createRunner({workspaceId: crypto.randomUUID(), updatedAt: staleAt()});
2210
+ await createControlSession(assigned);
2211
+
2212
+ const activated = await createRunner({
2213
+ runnerSessionId: crypto.randomUUID(),
2214
+ updatedAt: staleAt(),
2215
+ });
2216
+ await createControlSession(activated);
2217
+
2218
+ await createRunner({updatedAt: staleAt()});
2219
+ const expiredControlSession = await createRunner({updatedAt: staleAt()});
2220
+ await createControlSession(expiredControlSession, {
2221
+ expiresAt: new Date(Date.now() - 1_000),
2222
+ });
2223
+ const closedControlSession = await createRunner({updatedAt: staleAt()});
2224
+ await createControlSession(closedControlSession, {
2225
+ closedAt: new Date(),
2226
+ });
2227
+ const stopped = await createRunner({state: 'stopped', updatedAt: staleAt()});
2228
+ await createControlSession(stopped);
2229
+
2230
+ const count = await countStaleEnrolledRunnerInstances({graceSeconds: 60});
2231
+
2232
+ expect(count - baseline).toBe(2);
2233
+ });
2234
+
2235
+ async function createRunner(params: {
2236
+ state?: 'running' | 'stopped';
2237
+ workspaceId?: string | null;
2238
+ runnerSessionId?: string | null;
2239
+ reportedAt?: Date;
2240
+ updatedAt?: Date;
2241
+ }) {
2242
+ const reportedAt = params.reportedAt ?? params.updatedAt ?? new Date();
2243
+ const [runner] = await db()
2244
+ .insert(providerRunners)
2245
+ .values({
2246
+ workspaceId: params.workspaceId ?? null,
2247
+ provisionerId,
2248
+ providerRunnerId: crypto.randomUUID(),
2249
+ templateKey: 'linux',
2250
+ labels: ['linux'],
2251
+ state: params.state ?? 'running',
2252
+ runnerSessionId: params.runnerSessionId ?? null,
2253
+ providerKind: 'docker',
2254
+ reportedAt,
2255
+ updatedAt: params.updatedAt ?? reportedAt,
2256
+ })
2257
+ .returning({id: providerRunners.id});
2258
+ if (!runner) throw new Error('Expected runner instance');
2259
+ return runner.id;
2260
+ }
2261
+
2262
+ async function createControlSession(
2263
+ runnerInstanceId: string,
2264
+ params: {expiresAt?: Date; closedAt?: Date} = {},
2265
+ ) {
2266
+ await db()
2267
+ .insert(runnerControlSessions)
2268
+ .values({
2269
+ runnerInstanceId,
2270
+ provisionerId,
2271
+ hashedToken: crypto.randomUUID(),
2272
+ prefix: 'test',
2273
+ expiresAt: params.expiresAt ?? new Date(Date.now() + 60_000),
2274
+ closedAt: params.closedAt,
2275
+ closeReason: params.closedAt ? 'test' : null,
2276
+ });
2277
+ }
2278
+ });
2279
+
2006
2280
  describe('runner instance provider attachment', () => {
2007
2281
  it('updates the pre-created runner instance before its first lifecycle report', async () => {
2008
2282
  const provisionerId = crypto.randomUUID();
@@ -2026,6 +2300,7 @@ describe('runner instance provider attachment', () => {
2026
2300
  providerRunnerId,
2027
2301
  });
2028
2302
  await reportRunnerInstances({
2303
+ scope: 'installation',
2029
2304
  workspaceId: null,
2030
2305
  provisionerId,
2031
2306
  events: [
@@ -1,5 +1,4 @@
1
1
  import {logger} from '@shipfox/node-opentelemetry';
2
- import {canonicalizeLabels} from '@shipfox/runner-labels';
3
2
  import {
4
3
  and,
5
4
  asc,
@@ -19,6 +18,7 @@ import {
19
18
  } from 'drizzle-orm';
20
19
  import {alias} from 'drizzle-orm/pg-core';
21
20
  import type {RunnerInstance, RunnerInstanceState} from '#core/entities/runner-instance.js';
21
+ import {sanitizeRunnerLabels} from '#core/runner-labels.js';
22
22
  import type {Tx} from './db.js';
23
23
  import {db} from './db.js';
24
24
  import {
@@ -29,6 +29,7 @@ import {releaseReservationUnits} from './reservations.js';
29
29
  import {ephemeralRegistrationTokens} from './schema/ephemeral-registration-tokens.js';
30
30
  import {provisionerTokens} from './schema/provisioner-tokens.js';
31
31
  import {reservations} from './schema/reservations.js';
32
+ import {runnerControlSessions} from './schema/runner-control-sessions.js';
32
33
  import {providerRunners, toRunnerInstance} from './schema/runner-instances.js';
33
34
  import {runnerSessions} from './schema/runner-sessions.js';
34
35
  import {runningJobExecutions} from './schema/running-job-executions.js';
@@ -74,6 +75,7 @@ export interface RunnerInstanceReportEvent {
74
75
  }
75
76
 
76
77
  export interface ReportRunnerInstancesParams {
78
+ scope: 'installation' | 'workspace';
77
79
  workspaceId: string | null;
78
80
  provisionerId: string;
79
81
  events: RunnerInstanceReportEvent[];
@@ -165,7 +167,11 @@ export async function reportRunnerInstances(params: ReportRunnerInstancesParams)
165
167
  providerRunnerId: event.providerRunnerId,
166
168
  reservationId: event.reservationId,
167
169
  templateKey: event.templateKey,
168
- labels: [...canonicalizeLabels(event.labels)],
170
+ labels: sanitizeRunnerLabels(event.labels, {
171
+ scope: params.scope,
172
+ logLevel: 'debug',
173
+ source: 'runner instance report',
174
+ }),
169
175
  state: event.state,
170
176
  reason: event.reason,
171
177
  runnerSessionId: event.runnerSessionId,
@@ -185,7 +191,7 @@ export async function reportRunnerInstances(params: ReportRunnerInstancesParams)
185
191
  target: [providerRunners.provisionerId, providerRunners.providerRunnerId],
186
192
  targetWhere: isNotNull(providerRunners.providerRunnerId),
187
193
  set: {
188
- reservationId: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN coalesce(excluded.reservation_id, ${providerRunners.reservationId}) ELSE ${providerRunners.reservationId} END`,
194
+ reservationId: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN coalesce(${providerRunners.reservationId}, excluded.reservation_id) ELSE ${providerRunners.reservationId} END`,
189
195
  templateKey: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN coalesce(excluded.template_key, ${providerRunners.templateKey}) ELSE ${providerRunners.templateKey} END`,
190
196
  labels: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN excluded.labels ELSE ${providerRunners.labels} END`,
191
197
  state: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN excluded.state ELSE ${providerRunners.state} END`,
@@ -242,6 +248,36 @@ export async function listActiveRunnerInstanceCountsByTemplateTx(
242
248
  );
243
249
  }
244
250
 
251
+ export async function countStaleEnrolledRunnerInstances(params: {
252
+ graceSeconds: number;
253
+ }): Promise<number> {
254
+ const [row] = await db()
255
+ .select({count: sql<number>`count(*)::int`})
256
+ .from(providerRunners)
257
+ .where(
258
+ and(
259
+ eq(providerRunners.state, 'running'),
260
+ isNull(providerRunners.workspaceId),
261
+ isNull(providerRunners.runnerSessionId),
262
+ lt(providerRunners.reportedAt, staleRunnerInstanceCutoff(params.graceSeconds)),
263
+ exists(
264
+ db()
265
+ .select({id: runnerControlSessions.id})
266
+ .from(runnerControlSessions)
267
+ .where(
268
+ and(
269
+ eq(runnerControlSessions.runnerInstanceId, providerRunners.id),
270
+ isNull(runnerControlSessions.closedAt),
271
+ gt(runnerControlSessions.expiresAt, sql`now()`),
272
+ ),
273
+ ),
274
+ ),
275
+ ),
276
+ );
277
+
278
+ return row?.count ?? 0;
279
+ }
280
+
245
281
  export async function listActiveRunnerInstances(params: {
246
282
  workspaceId: string;
247
283
  windowSeconds: number;