@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
@@ -86,6 +86,350 @@ describe('pollDemandAndReserve', () => {
86
86
  });
87
87
  });
88
88
 
89
+ it('binds a runner whose intended reservation has passed its activation grace period', async () => {
90
+ const intendedReservation = await createIntendedReservation({
91
+ workspaceId,
92
+ expiresAt: new Date(Date.now() - 60_000),
93
+ });
94
+ const runner = await createIdleRunner({
95
+ labels: ['linux'],
96
+ intendedReservationId: intendedReservation.id,
97
+ });
98
+ await createPendingJobs(1, ['linux']);
99
+
100
+ const result = await pollDemandAndReserve({
101
+ workspaceId,
102
+ provisionerId,
103
+ maxReservations: 1,
104
+ ttlSeconds: 60,
105
+ templates: [template('linux', ['linux'], 1)],
106
+ });
107
+
108
+ const [storedRunner] = await db()
109
+ .select()
110
+ .from(providerRunners)
111
+ .where(eq(providerRunners.id, runner.id));
112
+ expect(storedRunner).toMatchObject({
113
+ workspaceId,
114
+ reservationId: result.reservations[0]?.reservationId,
115
+ intendedReservationId: null,
116
+ assignedAt: expect.any(Date),
117
+ });
118
+ });
119
+
120
+ it('binds a runner whose intended reservation was deleted after its grace period', async () => {
121
+ const intendedReservation = await createIntendedReservation({
122
+ workspaceId,
123
+ expiresAt: new Date(Date.now() + 60_000),
124
+ });
125
+ await db().delete(reservations).where(eq(reservations.id, intendedReservation.id));
126
+ const runner = await createIdleRunner({
127
+ labels: ['linux'],
128
+ intendedReservationId: intendedReservation.id,
129
+ });
130
+ await createPendingJobs(1, ['linux']);
131
+
132
+ const result = await pollDemandAndReserve({
133
+ workspaceId,
134
+ provisionerId,
135
+ maxReservations: 1,
136
+ ttlSeconds: 60,
137
+ templates: [template('linux', ['linux'], 1)],
138
+ });
139
+
140
+ const [storedRunner] = await db()
141
+ .select()
142
+ .from(providerRunners)
143
+ .where(eq(providerRunners.id, runner.id));
144
+ expect(storedRunner).toMatchObject({
145
+ workspaceId,
146
+ reservationId: result.reservations[0]?.reservationId,
147
+ intendedReservationId: null,
148
+ assignedAt: expect.any(Date),
149
+ });
150
+ });
151
+
152
+ it('binds an assigned runner after its reservation grace period expires', async () => {
153
+ const staleReservation = await createIntendedReservation({
154
+ workspaceId,
155
+ expiresAt: new Date(Date.now() - 60_000),
156
+ });
157
+ const runner = await createIdleRunner({
158
+ labels: ['linux'],
159
+ workspaceId,
160
+ reservationId: staleReservation.id,
161
+ intendedReservationId: staleReservation.id,
162
+ });
163
+ const [activationToken] = await db()
164
+ .insert(runnerActivationTokens)
165
+ .values({
166
+ runnerInstanceId: runner.id,
167
+ hashedToken: crypto.randomUUID(),
168
+ prefix: 'test',
169
+ expiresAt: new Date(Date.now() + 60_000),
170
+ })
171
+ .returning();
172
+ if (!activationToken) throw new Error('Expected activation token');
173
+ await createPendingJobs(1, ['linux']);
174
+
175
+ const result = await pollDemandAndReserve({
176
+ workspaceId,
177
+ provisionerId,
178
+ maxReservations: 1,
179
+ ttlSeconds: 60,
180
+ templates: [template('linux', ['linux'], 1)],
181
+ });
182
+
183
+ const [storedRunner] = await db()
184
+ .select()
185
+ .from(providerRunners)
186
+ .where(eq(providerRunners.id, runner.id));
187
+ const [storedToken] = await db()
188
+ .select()
189
+ .from(runnerActivationTokens)
190
+ .where(eq(runnerActivationTokens.id, activationToken.id));
191
+ expect(storedRunner).toMatchObject({
192
+ workspaceId,
193
+ reservationId: result.reservations[0]?.reservationId,
194
+ intendedReservationId: null,
195
+ assignedAt: expect.any(Date),
196
+ });
197
+ expect(storedToken?.revokedAt).toBeInstanceOf(Date);
198
+ });
199
+
200
+ it('binds an assigned runner whose reservation was deleted', async () => {
201
+ const staleReservation = await createIntendedReservation({
202
+ workspaceId,
203
+ expiresAt: new Date(Date.now() + 60_000),
204
+ });
205
+ await db().delete(reservations).where(eq(reservations.id, staleReservation.id));
206
+ const runner = await createIdleRunner({
207
+ labels: ['linux'],
208
+ workspaceId,
209
+ reservationId: staleReservation.id,
210
+ intendedReservationId: staleReservation.id,
211
+ });
212
+ await createPendingJobs(1, ['linux']);
213
+
214
+ const result = await pollDemandAndReserve({
215
+ workspaceId,
216
+ provisionerId,
217
+ maxReservations: 1,
218
+ ttlSeconds: 60,
219
+ templates: [template('linux', ['linux'], 1)],
220
+ });
221
+
222
+ const [storedRunner] = await db()
223
+ .select()
224
+ .from(providerRunners)
225
+ .where(eq(providerRunners.id, runner.id));
226
+ expect(storedRunner).toMatchObject({
227
+ workspaceId,
228
+ reservationId: result.reservations[0]?.reservationId,
229
+ intendedReservationId: null,
230
+ assignedAt: expect.any(Date),
231
+ });
232
+ });
233
+
234
+ it('does not bind an assigned runner within its activation grace period', async () => {
235
+ const liveReservation = await createIntendedReservation({
236
+ workspaceId,
237
+ expiresAt: new Date(Date.now() + 60_000),
238
+ });
239
+ const runner = await createIdleRunner({
240
+ labels: ['linux'],
241
+ workspaceId,
242
+ reservationId: liveReservation.id,
243
+ });
244
+ await createPendingJobs(2, ['linux']);
245
+
246
+ const result = await pollDemandAndReserve({
247
+ workspaceId,
248
+ provisionerId,
249
+ maxReservations: 2,
250
+ ttlSeconds: 60,
251
+ templates: [template('linux', ['linux'], 2)],
252
+ });
253
+
254
+ const [storedRunner] = await db()
255
+ .select()
256
+ .from(providerRunners)
257
+ .where(eq(providerRunners.id, runner.id));
258
+ expect(result.reservations[0]?.count).toBe(1);
259
+ expect(storedRunner).toMatchObject({
260
+ workspaceId,
261
+ reservationId: liveReservation.id,
262
+ intendedReservationId: null,
263
+ assignedAt: null,
264
+ });
265
+ });
266
+
267
+ it('does not bind a runner whose intended reservation is within its activation grace period', async () => {
268
+ const intendedReservation = await createIntendedReservation({
269
+ workspaceId: crypto.randomUUID(),
270
+ expiresAt: new Date(Date.now() + 60_000),
271
+ });
272
+ const runner = await createIdleRunner({
273
+ labels: ['linux'],
274
+ intendedReservationId: intendedReservation.id,
275
+ });
276
+ await createPendingJobs(1, ['linux']);
277
+
278
+ const result = await pollDemandAndReserve({
279
+ workspaceId,
280
+ provisionerId,
281
+ maxReservations: 1,
282
+ ttlSeconds: 60,
283
+ templates: [template('linux', ['linux'], 1)],
284
+ });
285
+
286
+ const [storedRunner] = await db()
287
+ .select()
288
+ .from(providerRunners)
289
+ .where(eq(providerRunners.id, runner.id));
290
+ expect(result.reservations[0]?.count).toBe(1);
291
+ expect(storedRunner).toMatchObject({
292
+ workspaceId: null,
293
+ reservationId: null,
294
+ intendedReservationId: intendedReservation.id,
295
+ assignedAt: null,
296
+ });
297
+ });
298
+
299
+ it('does not rebind a stale runner from another workspace in a workspace-scoped poll', async () => {
300
+ const previousWorkspaceId = crypto.randomUUID();
301
+ const staleReservation = await createIntendedReservation({
302
+ workspaceId: previousWorkspaceId,
303
+ expiresAt: new Date(Date.now() - 60_000),
304
+ });
305
+ const runner = await createIdleRunner({
306
+ labels: ['linux'],
307
+ workspaceId: previousWorkspaceId,
308
+ reservationId: staleReservation.id,
309
+ intendedReservationId: staleReservation.id,
310
+ });
311
+ await createPendingJobs(1, ['linux']);
312
+
313
+ const result = await pollDemandAndReserve({
314
+ workspaceId,
315
+ provisionerId,
316
+ maxReservations: 1,
317
+ ttlSeconds: 60,
318
+ templates: [template('linux', ['linux'], 1)],
319
+ });
320
+
321
+ const [storedRunner] = await db()
322
+ .select()
323
+ .from(providerRunners)
324
+ .where(eq(providerRunners.id, runner.id));
325
+ expect(result.reservations[0]?.count).toBe(1);
326
+ expect(storedRunner).toMatchObject({
327
+ workspaceId: previousWorkspaceId,
328
+ reservationId: staleReservation.id,
329
+ intendedReservationId: staleReservation.id,
330
+ });
331
+ });
332
+
333
+ it('rebinds an unowned runner whose reservation was deleted', async () => {
334
+ const runner = await createIdleRunner({
335
+ labels: ['linux'],
336
+ reservationId: crypto.randomUUID(),
337
+ });
338
+ await createPendingJobs(1, ['linux']);
339
+
340
+ const result = await pollDemandAndReserve({
341
+ workspaceId,
342
+ provisionerId,
343
+ maxReservations: 1,
344
+ ttlSeconds: 60,
345
+ templates: [template('linux', ['linux'], 1)],
346
+ });
347
+
348
+ const [storedRunner] = await db()
349
+ .select()
350
+ .from(providerRunners)
351
+ .where(eq(providerRunners.id, runner.id));
352
+ expect(storedRunner).toMatchObject({
353
+ workspaceId,
354
+ reservationId: result.reservations[0]?.reservationId,
355
+ intendedReservationId: null,
356
+ assignedAt: expect.any(Date),
357
+ });
358
+ });
359
+
360
+ it('rebinds a stale runner across workspaces for an installation-scoped poll', async () => {
361
+ const previousWorkspaceId = crypto.randomUUID();
362
+ const targetWorkspaceId = crypto.randomUUID();
363
+ const staleReservation = await createIntendedReservation({
364
+ workspaceId: previousWorkspaceId,
365
+ expiresAt: new Date(Date.now() - 60_000),
366
+ });
367
+ const runner = await createIdleRunner({
368
+ labels: ['linux'],
369
+ workspaceId: previousWorkspaceId,
370
+ reservationId: staleReservation.id,
371
+ intendedReservationId: staleReservation.id,
372
+ });
373
+ await pendingJobFactory.create({workspaceId: targetWorkspaceId, requiredLabels: ['linux']});
374
+
375
+ const result = await pollInstallationDemandAndReserve({
376
+ provisionerId,
377
+ maxReservations: 1,
378
+ ttlSeconds: 60,
379
+ templates: [template('linux', ['linux'], 1)],
380
+ capabilityWindowSeconds: 60,
381
+ eligibleWorkspaceIds: new Set([targetWorkspaceId]),
382
+ });
383
+
384
+ const [storedRunner] = await db()
385
+ .select()
386
+ .from(providerRunners)
387
+ .where(eq(providerRunners.id, runner.id));
388
+ expect(result.reservations[0]).toMatchObject({workspaceId: targetWorkspaceId, count: 1});
389
+ expect(storedRunner).toMatchObject({
390
+ workspaceId: targetWorkspaceId,
391
+ reservationId: result.reservations[0]?.reservationId,
392
+ intendedReservationId: null,
393
+ assignedAt: expect.any(Date),
394
+ });
395
+ });
396
+
397
+ it('does not rebind a released runner after its reservation expires', async () => {
398
+ const staleReservation = await createIntendedReservation({
399
+ workspaceId,
400
+ expiresAt: new Date(Date.now() - 60_000),
401
+ });
402
+ const releasedAt = new Date(Date.now() - 30_000);
403
+ const runner = await createIdleRunner({
404
+ labels: ['linux'],
405
+ workspaceId,
406
+ reservationId: staleReservation.id,
407
+ intendedReservationId: staleReservation.id,
408
+ reservationReleasedAt: releasedAt,
409
+ });
410
+ await createPendingJobs(1, ['linux']);
411
+
412
+ const result = await pollDemandAndReserve({
413
+ workspaceId,
414
+ provisionerId,
415
+ maxReservations: 1,
416
+ ttlSeconds: 60,
417
+ templates: [template('linux', ['linux'], 1)],
418
+ });
419
+
420
+ const [storedRunner] = await db()
421
+ .select()
422
+ .from(providerRunners)
423
+ .where(eq(providerRunners.id, runner.id));
424
+ expect(result.reservations[0]?.count).toBe(1);
425
+ expect(storedRunner).toMatchObject({
426
+ workspaceId,
427
+ reservationId: staleReservation.id,
428
+ intendedReservationId: staleReservation.id,
429
+ reservationReleasedAt: releasedAt,
430
+ });
431
+ });
432
+
89
433
  it('does not bind a running runner without an active control session', async () => {
90
434
  const [runner] = await db()
91
435
  .insert(providerRunners)
@@ -461,6 +805,64 @@ describe('pollDemandAndReserve', () => {
461
805
  expect(storedToken?.revokedAt).toBeInstanceOf(Date);
462
806
  });
463
807
 
808
+ it('preserves activation tokens for active runners with an intended reservation', async () => {
809
+ const [reservation] = await db()
810
+ .insert(reservations)
811
+ .values({
812
+ workspaceId,
813
+ provisionerId,
814
+ requiredLabels: ['linux'],
815
+ count: 1,
816
+ expiresAt: new Date(Date.now() + 60_000),
817
+ })
818
+ .returning();
819
+ if (!reservation) throw new Error('Expected reservation');
820
+ const [runner] = await db()
821
+ .insert(providerRunners)
822
+ .values({
823
+ workspaceId,
824
+ provisionerId,
825
+ providerRunnerId: crypto.randomUUID(),
826
+ reservationId: reservation.id,
827
+ intendedReservationId: reservation.id,
828
+ runnerSessionId: crypto.randomUUID(),
829
+ labels: ['linux'],
830
+ state: 'running',
831
+ reportedAt: new Date(),
832
+ })
833
+ .returning();
834
+ if (!runner) throw new Error('Expected runner instance');
835
+ const [activationToken] = await db()
836
+ .insert(runnerActivationTokens)
837
+ .values({
838
+ runnerInstanceId: runner.id,
839
+ hashedToken: crypto.randomUUID(),
840
+ prefix: 'test',
841
+ expiresAt: new Date(Date.now() + 60_000),
842
+ })
843
+ .returning();
844
+ if (!activationToken) throw new Error('Expected activation token');
845
+
846
+ const deleted = await deleteReservationsByIds([reservation.id]);
847
+
848
+ const [storedRunner] = await db()
849
+ .select()
850
+ .from(providerRunners)
851
+ .where(eq(providerRunners.id, runner.id));
852
+ const [storedToken] = await db()
853
+ .select()
854
+ .from(runnerActivationTokens)
855
+ .where(eq(runnerActivationTokens.id, activationToken.id));
856
+ expect(deleted).toBe(1);
857
+ expect(storedRunner).toMatchObject({
858
+ reservationId: reservation.id,
859
+ intendedReservationId: null,
860
+ runnerSessionId: expect.any(String),
861
+ reservationReleasedAt: null,
862
+ });
863
+ expect(storedToken?.revokedAt).toBeNull();
864
+ });
865
+
464
866
  it('unbinds prewarmed runners and revokes activation tokens when deleting expired reservations', async () => {
465
867
  const runner = await createIdleRunner({labels: ['linux']});
466
868
  await createPendingJobs(1, ['linux']);
@@ -509,6 +911,115 @@ describe('pollDemandAndReserve', () => {
509
911
  expect(storedToken?.revokedAt).toBeInstanceOf(Date);
510
912
  });
511
913
 
914
+ it('clears intended reservations and rebinds runners when deleting a reservation by id', async () => {
915
+ const [reservation, survivingReservation] = await db()
916
+ .insert(reservations)
917
+ .values([
918
+ {
919
+ workspaceId,
920
+ provisionerId,
921
+ requiredLabels: ['linux'],
922
+ count: 1,
923
+ expiresAt: new Date(Date.now() + 60_000),
924
+ },
925
+ {
926
+ workspaceId: crypto.randomUUID(),
927
+ provisionerId,
928
+ requiredLabels: ['linux'],
929
+ count: 1,
930
+ expiresAt: new Date(Date.now() + 60_000),
931
+ },
932
+ ])
933
+ .returning();
934
+ if (!reservation || !survivingReservation) throw new Error('Expected reservations');
935
+ const runner = await createIdleRunner({
936
+ labels: ['linux'],
937
+ intendedReservationId: reservation.id,
938
+ });
939
+ const survivingRunner = await createIdleRunner({
940
+ labels: ['linux'],
941
+ intendedReservationId: survivingReservation.id,
942
+ });
943
+
944
+ const deleted = await deleteReservationsByIds([reservation.id]);
945
+
946
+ const [storedRunner] = await db()
947
+ .select()
948
+ .from(providerRunners)
949
+ .where(eq(providerRunners.id, runner.id));
950
+ expect(deleted).toBe(1);
951
+ expect(storedRunner).toMatchObject({
952
+ intendedReservationId: null,
953
+ workspaceId: null,
954
+ reservationId: null,
955
+ assignedAt: null,
956
+ state: 'running',
957
+ reservationReleasedAt: null,
958
+ });
959
+ const [storedSurvivingRunner] = await db()
960
+ .select()
961
+ .from(providerRunners)
962
+ .where(eq(providerRunners.id, survivingRunner.id));
963
+ expect(storedSurvivingRunner?.intendedReservationId).toBe(survivingReservation.id);
964
+
965
+ await createPendingJobs(1, ['linux']);
966
+ const result = await pollDemandAndReserve({
967
+ workspaceId,
968
+ provisionerId,
969
+ maxReservations: 1,
970
+ ttlSeconds: 60,
971
+ templates: [template('linux', ['linux'], 1)],
972
+ });
973
+ const reboundReservationId = result.reservations[0]?.reservationId;
974
+ const [reboundRunner] = await db()
975
+ .select()
976
+ .from(providerRunners)
977
+ .where(eq(providerRunners.id, runner.id));
978
+ expect(reboundReservationId).toEqual(expect.any(String));
979
+ expect(reboundRunner).toMatchObject({
980
+ intendedReservationId: null,
981
+ workspaceId,
982
+ reservationId: reboundReservationId,
983
+ assignedAt: expect.any(Date),
984
+ state: 'running',
985
+ reservationReleasedAt: null,
986
+ });
987
+ });
988
+
989
+ it('clears intended reservations when deleting expired reservations', async () => {
990
+ const [reservation] = await db()
991
+ .insert(reservations)
992
+ .values({
993
+ workspaceId,
994
+ provisionerId,
995
+ requiredLabels: ['linux'],
996
+ count: 1,
997
+ expiresAt: new Date(Date.now() - 60_000),
998
+ })
999
+ .returning();
1000
+ if (!reservation) throw new Error('Expected reservation');
1001
+ const runner = await createIdleRunner({
1002
+ labels: ['linux'],
1003
+ intendedReservationId: reservation.id,
1004
+ });
1005
+
1006
+ const deleted = await deleteExpiredReservations();
1007
+
1008
+ const [storedRunner] = await db()
1009
+ .select()
1010
+ .from(providerRunners)
1011
+ .where(eq(providerRunners.id, runner.id));
1012
+ expect(deleted).toBeGreaterThanOrEqual(1);
1013
+ expect(storedRunner).toMatchObject({
1014
+ intendedReservationId: null,
1015
+ workspaceId: null,
1016
+ reservationId: null,
1017
+ assignedAt: null,
1018
+ state: 'running',
1019
+ reservationReleasedAt: null,
1020
+ });
1021
+ });
1022
+
512
1023
  it('decrements reservation units inside a caller transaction', async () => {
513
1024
  await reservationFactory.create({
514
1025
  workspaceId,
@@ -641,17 +1152,43 @@ describe('pollDemandAndReserve', () => {
641
1152
  }
642
1153
  }
643
1154
 
1155
+ async function createIntendedReservation(params: {
1156
+ workspaceId: string;
1157
+ expiresAt: Date;
1158
+ }): Promise<{id: string}> {
1159
+ const [reservation] = await db()
1160
+ .insert(reservations)
1161
+ .values({
1162
+ workspaceId: params.workspaceId,
1163
+ provisionerId,
1164
+ requiredLabels: ['linux'],
1165
+ count: 1,
1166
+ expiresAt: params.expiresAt,
1167
+ })
1168
+ .returning({id: reservations.id});
1169
+ if (!reservation) throw new Error('Expected reservation');
1170
+ return reservation;
1171
+ }
1172
+
644
1173
  async function createIdleRunner(params: {
645
1174
  labels: string[];
646
1175
  createdAt?: Date;
647
1176
  controlSessionExpiresAt?: Date;
1177
+ workspaceId?: string | null;
1178
+ reservationId?: string | null;
1179
+ intendedReservationId?: string | null;
1180
+ reservationReleasedAt?: Date | null;
648
1181
  }) {
649
1182
  const createdAt = params.createdAt ?? new Date();
650
1183
  const [runner] = await db()
651
1184
  .insert(providerRunners)
652
1185
  .values({
653
1186
  provisionerId,
1187
+ workspaceId: params.workspaceId,
1188
+ reservationId: params.reservationId,
654
1189
  providerRunnerId: crypto.randomUUID(),
1190
+ intendedReservationId: params.intendedReservationId,
1191
+ reservationReleasedAt: params.reservationReleasedAt,
655
1192
  labels: params.labels,
656
1193
  state: 'running',
657
1194
  reportedAt: createdAt,