@shipfox/api-runners 12.3.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 (134) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +22 -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/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 +55 -9
  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/reservation-locks.d.ts +6 -0
  35. package/dist/db/reservation-locks.d.ts.map +1 -0
  36. package/dist/db/reservation-locks.js +15 -0
  37. package/dist/db/reservation-locks.js.map +1 -0
  38. package/dist/db/reservations.d.ts +17 -3
  39. package/dist/db/reservations.d.ts.map +1 -1
  40. package/dist/db/reservations.js +246 -51
  41. package/dist/db/reservations.js.map +1 -1
  42. package/dist/db/runner-assignments.d.ts +17 -0
  43. package/dist/db/runner-assignments.d.ts.map +1 -1
  44. package/dist/db/runner-assignments.js +103 -8
  45. package/dist/db/runner-assignments.js.map +1 -1
  46. package/dist/db/runner-instances.d.ts +2 -2
  47. package/dist/db/runner-instances.d.ts.map +1 -1
  48. package/dist/db/runner-instances.js +124 -24
  49. package/dist/db/runner-instances.js.map +1 -1
  50. package/dist/db/runner-states.d.ts +2 -0
  51. package/dist/db/runner-states.d.ts.map +1 -0
  52. package/dist/db/runner-states.js +7 -0
  53. package/dist/db/runner-states.js.map +1 -0
  54. package/dist/db/schema/reservations.d.ts +18 -0
  55. package/dist/db/schema/reservations.d.ts.map +1 -1
  56. package/dist/db/schema/reservations.js +7 -1
  57. package/dist/db/schema/reservations.js.map +1 -1
  58. package/dist/db/schema/runner-instances.d.ts +18 -0
  59. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  60. package/dist/db/schema/runner-instances.js +8 -0
  61. package/dist/db/schema/runner-instances.js.map +1 -1
  62. package/dist/metrics/index.d.ts +2 -2
  63. package/dist/metrics/index.d.ts.map +1 -1
  64. package/dist/metrics/index.js +1 -1
  65. package/dist/metrics/index.js.map +1 -1
  66. package/dist/metrics/instance.d.ts +24 -2
  67. package/dist/metrics/instance.d.ts.map +1 -1
  68. package/dist/metrics/instance.js +23 -0
  69. package/dist/metrics/instance.js.map +1 -1
  70. package/dist/presentation/dto/poll-demand.d.ts.map +1 -1
  71. package/dist/presentation/dto/poll-demand.js +3 -0
  72. package/dist/presentation/dto/poll-demand.js.map +1 -1
  73. package/dist/presentation/dto/runner-instances.d.ts +4 -1
  74. package/dist/presentation/dto/runner-instances.d.ts.map +1 -1
  75. package/dist/presentation/dto/runner-instances.js +4 -1
  76. package/dist/presentation/dto/runner-instances.js.map +1 -1
  77. package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
  78. package/dist/presentation/routes/assign-runner-instances.js +3 -3
  79. package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
  80. package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
  81. package/dist/presentation/routes/poll-demand.js +1 -0
  82. package/dist/presentation/routes/poll-demand.js.map +1 -1
  83. package/dist/presentation/routes/reconcile-runner-instances.d.ts.map +1 -1
  84. package/dist/presentation/routes/reconcile-runner-instances.js +4 -2
  85. package/dist/presentation/routes/reconcile-runner-instances.js.map +1 -1
  86. package/dist/presentation/routes/runner-enrollment.d.ts +4 -1
  87. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  88. package/dist/presentation/routes/runner-enrollment.js +11 -6
  89. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  90. package/dist/tsconfig.test.tsbuildinfo +1 -1
  91. package/drizzle/0005_previous_lenny_balinger.sql +5 -0
  92. package/drizzle/meta/0005_snapshot.json +2220 -0
  93. package/drizzle/meta/_journal.json +7 -0
  94. package/package.json +2 -2
  95. package/src/config.test.ts +22 -0
  96. package/src/config.ts +15 -2
  97. package/src/core/demand.test.ts +192 -3
  98. package/src/core/demand.ts +44 -4
  99. package/src/core/entities/reservation.ts +1 -0
  100. package/src/core/entities/runner-instance.ts +4 -0
  101. package/src/core/runner-activation.ts +48 -2
  102. package/src/core/runner-control-sessions.test.ts +64 -3
  103. package/src/core/runner-control-sessions.ts +62 -9
  104. package/src/core/runner-instances.test.ts +19 -0
  105. package/src/core/runner-instances.ts +2 -0
  106. package/src/core/runner-sessions.test.ts +121 -1
  107. package/src/db/ephemeral-registration-tokens.test.ts +20 -1
  108. package/src/db/ephemeral-registration-tokens.ts +1 -0
  109. package/src/db/reservation-locks.ts +22 -0
  110. package/src/db/reservations.test.ts +733 -22
  111. package/src/db/reservations.ts +408 -144
  112. package/src/db/runner-assignments.test.ts +105 -1
  113. package/src/db/runner-assignments.ts +178 -9
  114. package/src/db/runner-instances.test.ts +265 -2
  115. package/src/db/runner-instances.ts +215 -55
  116. package/src/db/runner-states.ts +7 -0
  117. package/src/db/schema/reservations.ts +5 -1
  118. package/src/db/schema/runner-instances.ts +9 -0
  119. package/src/metrics/index.ts +10 -1
  120. package/src/metrics/instance.ts +59 -2
  121. package/src/metrics/service.test.ts +8 -1
  122. package/src/presentation/dto/poll-demand.ts +3 -0
  123. package/src/presentation/dto/runner-instances.ts +8 -1
  124. package/src/presentation/routes/assign-runner-instances.ts +6 -2
  125. package/src/presentation/routes/late-runner-enrollment.test.ts +219 -2
  126. package/src/presentation/routes/poll-demand.test.ts +56 -0
  127. package/src/presentation/routes/poll-demand.ts +1 -0
  128. package/src/presentation/routes/reconcile-runner-instances.test.ts +34 -2
  129. package/src/presentation/routes/reconcile-runner-instances.ts +7 -1
  130. package/src/presentation/routes/runner-enrollment.test.ts +316 -3
  131. package/src/presentation/routes/runner-enrollment.ts +8 -5
  132. package/test/factories/reservation.ts +2 -0
  133. package/test/factories/runner-instance.ts +6 -0
  134. package/tsconfig.build.tsbuildinfo +1 -1
@@ -7,9 +7,11 @@ import {
7
7
  extractBearerToken,
8
8
  } from '@shipfox/node-fastify';
9
9
  import {vi} from '@shipfox/vitest/vi';
10
- import {eq} from 'drizzle-orm';
10
+ import {and, eq} from 'drizzle-orm';
11
11
  import type {FastifyInstance, FastifyRequest} from 'fastify';
12
12
  import {db} from '#db/db.js';
13
+ import {reservations} from '#db/schema/reservations.js';
14
+ import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
13
15
  import {providerRunners} from '#db/schema/runner-instances.js';
14
16
  import {runnerSessions} from '#db/schema/runner-sessions.js';
15
17
  import {runnerReservationPromotionFailureCount} from '#metrics/instance.js';
@@ -160,7 +162,22 @@ describe('late runner enrollment recovery', () => {
160
162
  });
161
163
 
162
164
  expect(demand.statusCode).toBe(200);
163
- const reboundReservationId = demand.json().reservations[0]?.reservation_id;
165
+ expect(demand.json().reservations).toEqual([]);
166
+ const reboundReservations = await db()
167
+ .select()
168
+ .from(reservations)
169
+ .where(
170
+ and(
171
+ eq(reservations.workspaceId, workspaceId),
172
+ eq(reservations.provisionerId, provisionerId),
173
+ eq(reservations.kind, 'bound'),
174
+ ),
175
+ );
176
+ const reboundReservation = reboundReservations.find(
177
+ (reservation) => reservation.id !== arrangement.reservationId,
178
+ );
179
+ const reboundReservationId = reboundReservation?.id;
180
+ if (!reboundReservationId) throw new Error('Expected rebound reservation');
164
181
  expect(reboundReservationId).toEqual(expect.any(String));
165
182
 
166
183
  const [reboundRunner] = await db()
@@ -216,6 +233,206 @@ describe('late runner enrollment recovery', () => {
216
233
  });
217
234
  });
218
235
 
236
+ it('preserves a rebound assignment across stale provider reports and releases it once', async () => {
237
+ const providerRunnerId = `stale-assignment-${crypto.randomUUID()}`;
238
+ const firstReportAt = new Date(Date.now() - 2_000);
239
+ const [staleReservation] = await db()
240
+ .insert(reservations)
241
+ .values({
242
+ workspaceId,
243
+ provisionerId,
244
+ requiredLabels: ['linux'],
245
+ count: 1,
246
+ expiresAt: new Date(Date.now() + 60_000),
247
+ })
248
+ .returning({id: reservations.id});
249
+ if (!staleReservation) throw new Error('Expected stale reservation');
250
+
251
+ const initialReport = await app.inject({
252
+ method: 'POST',
253
+ url: '/provisioners/runner-instances/report',
254
+ headers: {authorization: `Bearer ${workspaceToken}`},
255
+ payload: {
256
+ events: [
257
+ {
258
+ provider_runner_id: providerRunnerId,
259
+ reservation_id: staleReservation.id,
260
+ template_key: 'linux',
261
+ labels: ['linux'],
262
+ state: 'running',
263
+ reported_at: firstReportAt.toISOString(),
264
+ provider_kind: 'docker',
265
+ },
266
+ ],
267
+ },
268
+ });
269
+
270
+ expect(initialReport.statusCode).toBe(200);
271
+ expect(initialReport.json()).toEqual({accepted: 1, reservations_released: 0});
272
+
273
+ const [reportedRunner] = await db()
274
+ .select()
275
+ .from(providerRunners)
276
+ .where(
277
+ and(
278
+ eq(providerRunners.provisionerId, provisionerId),
279
+ eq(providerRunners.providerRunnerId, providerRunnerId),
280
+ ),
281
+ );
282
+ if (!reportedRunner) throw new Error('Expected reported runner');
283
+ expect(reportedRunner).toMatchObject({
284
+ workspaceId,
285
+ reservationId: staleReservation.id,
286
+ state: 'running',
287
+ });
288
+
289
+ await db()
290
+ .insert(runnerControlSessions)
291
+ .values({
292
+ runnerInstanceId: reportedRunner.id,
293
+ provisionerId,
294
+ hashedToken: crypto.randomUUID(),
295
+ prefix: 'test',
296
+ expiresAt: new Date(Date.now() + 60_000),
297
+ });
298
+ await db()
299
+ .update(reservations)
300
+ .set({expiresAt: new Date(Date.now() - 1_000)})
301
+ .where(eq(reservations.id, staleReservation.id));
302
+ await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
303
+
304
+ const demand = await app.inject({
305
+ method: 'POST',
306
+ url: '/provisioners/demand/poll',
307
+ headers: {authorization: `Bearer ${workspaceToken}`},
308
+ payload: {
309
+ wait_seconds: 0,
310
+ max_reservations: 1,
311
+ reservation_ttl_seconds: 60,
312
+ templates: [
313
+ {
314
+ template_key: 'linux',
315
+ labels: ['linux'],
316
+ available_slots: 1,
317
+ starting: 0,
318
+ running: 1,
319
+ },
320
+ ],
321
+ },
322
+ });
323
+
324
+ expect(demand.statusCode).toBe(200);
325
+ expect(demand.json().reservations).toEqual([]);
326
+ const reboundReservations = await db()
327
+ .select()
328
+ .from(reservations)
329
+ .where(
330
+ and(
331
+ eq(reservations.workspaceId, workspaceId),
332
+ eq(reservations.provisionerId, provisionerId),
333
+ eq(reservations.kind, 'bound'),
334
+ ),
335
+ );
336
+ const reboundReservation = reboundReservations.find(
337
+ (reservation) => reservation.id !== staleReservation.id,
338
+ );
339
+ const reboundReservationId = reboundReservation?.id;
340
+ if (!reboundReservationId) throw new Error('Expected rebound reservation');
341
+ expect(reboundReservationId).toEqual(expect.any(String));
342
+ expect(reboundReservationId).not.toBe(staleReservation.id);
343
+
344
+ const staleReport = await app.inject({
345
+ method: 'POST',
346
+ url: '/provisioners/runner-instances/report',
347
+ headers: {authorization: `Bearer ${workspaceToken}`},
348
+ payload: {
349
+ events: [
350
+ {
351
+ provider_runner_id: providerRunnerId,
352
+ reservation_id: staleReservation.id,
353
+ template_key: 'linux',
354
+ labels: ['linux'],
355
+ state: 'running',
356
+ reported_at: new Date(firstReportAt.getTime() + 1_000).toISOString(),
357
+ provider_kind: 'docker',
358
+ },
359
+ ],
360
+ },
361
+ });
362
+
363
+ expect(staleReport.statusCode).toBe(200);
364
+ expect(staleReport.json()).toEqual({accepted: 1, reservations_released: 0});
365
+
366
+ const [reboundRunner] = await db()
367
+ .select()
368
+ .from(providerRunners)
369
+ .where(eq(providerRunners.id, reportedRunner.id));
370
+ expect(reboundRunner).toMatchObject({
371
+ workspaceId,
372
+ reservationId: reboundReservationId,
373
+ state: 'running',
374
+ intendedReservationId: null,
375
+ });
376
+
377
+ const terminalReport = await app.inject({
378
+ method: 'POST',
379
+ url: '/provisioners/runner-instances/report',
380
+ headers: {authorization: `Bearer ${workspaceToken}`},
381
+ payload: {
382
+ events: [
383
+ {
384
+ provider_runner_id: providerRunnerId,
385
+ reservation_id: staleReservation.id,
386
+ template_key: 'linux',
387
+ labels: ['linux'],
388
+ state: 'terminated',
389
+ reported_at: new Date(firstReportAt.getTime() + 2_000).toISOString(),
390
+ provider_kind: 'docker',
391
+ },
392
+ ],
393
+ },
394
+ });
395
+
396
+ expect(terminalReport.statusCode).toBe(200);
397
+ expect(terminalReport.json()).toEqual({accepted: 1, reservations_released: 1});
398
+
399
+ const [staleReservationAfterTerminal] = await db()
400
+ .select()
401
+ .from(reservations)
402
+ .where(eq(reservations.id, staleReservation.id));
403
+ const [reboundReservationAfterTerminal] = await db()
404
+ .select()
405
+ .from(reservations)
406
+ .where(eq(reservations.id, reboundReservationId));
407
+ expect(staleReservationAfterTerminal).toMatchObject({
408
+ id: staleReservation.id,
409
+ count: 1,
410
+ });
411
+ expect(reboundReservationAfterTerminal).toBeUndefined();
412
+
413
+ const repeatedTerminalReport = await app.inject({
414
+ method: 'POST',
415
+ url: '/provisioners/runner-instances/report',
416
+ headers: {authorization: `Bearer ${workspaceToken}`},
417
+ payload: {
418
+ events: [
419
+ {
420
+ provider_runner_id: providerRunnerId,
421
+ reservation_id: staleReservation.id,
422
+ template_key: 'linux',
423
+ labels: ['linux'],
424
+ state: 'terminated',
425
+ reported_at: new Date(firstReportAt.getTime() + 3_000).toISOString(),
426
+ provider_kind: 'docker',
427
+ },
428
+ ],
429
+ },
430
+ });
431
+
432
+ expect(repeatedTerminalReport.statusCode).toBe(200);
433
+ expect(repeatedTerminalReport.json()).toEqual({accepted: 1, reservations_released: 0});
434
+ });
435
+
219
436
  async function enrollRunner(controlSessionToken: string): Promise<void> {
220
437
  const enrolled = await app.inject({
221
438
  method: 'POST',
@@ -20,6 +20,7 @@ import {db} from '#db/db.js';
20
20
  import {provisionerCapabilitySnapshots} from '#db/schema/provisioner-capability-snapshots.js';
21
21
  import {runningJobExecutions} from '#db/schema/running-job-executions.js';
22
22
  import {
23
+ providerRunnerActivationOutcomeCount,
23
24
  providerRunnerCountDivergenceCount,
24
25
  providerRunnerTerminateIntentIssuedCount,
25
26
  } from '#metrics/instance.js';
@@ -99,6 +100,7 @@ describe('POST /provisioners/demand/poll', () => {
99
100
  expect(res.json()).toMatchObject({
100
101
  stats: [{labels: ['linux'], queued: 1, reserved: 1}],
101
102
  reservations: [{labels: ['linux'], count: 1}],
103
+ newly_reserved_count: 1,
102
104
  terminate_provider_runner_ids: [],
103
105
  });
104
106
  expect(res.json().reservations[0].reservation_id).toEqual(expect.any(String));
@@ -367,6 +369,60 @@ describe('POST /provisioners/demand/poll', () => {
367
369
  expect(intentCalls).toHaveLength(1);
368
370
  });
369
371
 
372
+ it('counts activation-timeout reaps once across terminate-intent retries', async () => {
373
+ const intentSpy = vi.spyOn(providerRunnerTerminateIntentIssuedCount, 'add');
374
+ const outcomeSpy = vi.spyOn(providerRunnerActivationOutcomeCount, 'add');
375
+ await providerRunnerFactory.create({
376
+ workspaceId,
377
+ provisionerId: provisionerTokenId,
378
+ providerRunnerId: 'stale-demand-runner',
379
+ launchKind: 'demand',
380
+ createdAt: new Date(Date.now() - 301_000),
381
+ templateKey: 'linux',
382
+ state: 'running',
383
+ });
384
+ const intentCallsBefore = intentSpy.mock.calls.length;
385
+ const outcomeCallsBefore = outcomeSpy.mock.calls.length;
386
+
387
+ const poll = () =>
388
+ app.inject({
389
+ method: 'POST',
390
+ url: '/provisioners/demand/poll',
391
+ headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
392
+ payload: body({max_reservations: 0}),
393
+ });
394
+ const firstResponse = await poll();
395
+ const retryResponse = await poll();
396
+
397
+ expect(firstResponse.statusCode).toBe(200);
398
+ expect(retryResponse.statusCode).toBe(200);
399
+ expect(firstResponse.json()).toMatchObject({
400
+ reservations: [],
401
+ terminate_provider_runner_ids: ['stale-demand-runner'],
402
+ });
403
+ expect(retryResponse.json()).toMatchObject({
404
+ reservations: [],
405
+ terminate_provider_runner_ids: ['stale-demand-runner'],
406
+ });
407
+ const intentCalls = intentSpy.mock.calls
408
+ .slice(intentCallsBefore)
409
+ .filter(
410
+ ([value, attributes]) =>
411
+ value === 1 &&
412
+ JSON.stringify(attributes) ===
413
+ JSON.stringify({surface: 'poll-demand', reason: 'activation-timeout'}),
414
+ );
415
+ expect(intentCalls).toHaveLength(2);
416
+ expect(
417
+ outcomeSpy.mock.calls
418
+ .slice(outcomeCallsBefore)
419
+ .filter(
420
+ ([value, attributes]) =>
421
+ value === 1 && JSON.stringify(attributes) === JSON.stringify({outcome: 'reaped'}),
422
+ ),
423
+ ).toHaveLength(1);
424
+ });
425
+
370
426
  it('returns 400 for max reservations above the request bound', async () => {
371
427
  const res = await app.inject({
372
428
  method: 'POST',
@@ -98,6 +98,7 @@ export function createPollDemandRoute(options: CreateRunnersModuleOptions = {})
98
98
  provisionerId: provisionerContext.provisionerTokenId,
99
99
  maxReservations: request.body.max_reservations,
100
100
  ttlSeconds,
101
+ activationGraceSeconds: config.RESERVATION_TTL_SECONDS,
101
102
  templates,
102
103
  capabilityWindowSeconds: config.PROVISIONER_ACTIVE_WINDOW_SECONDS,
103
104
  eligibleWorkspaceIds,
@@ -6,6 +6,10 @@ import {
6
6
  AUTH_USER,
7
7
  setProvisionerContext,
8
8
  } from '@shipfox/api-auth-context';
9
+ import {
10
+ RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER,
11
+ RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER_VALUE,
12
+ } from '@shipfox/api-runners-dto';
9
13
  import {
10
14
  type AuthMethod,
11
15
  ClientError,
@@ -86,7 +90,11 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
86
90
  const jobId = crypto.randomUUID();
87
91
  const workflowRunId = crypto.randomUUID();
88
92
  const workflowRunAttemptId = crypto.randomUUID();
89
- await createRunnerInstance({providerRunnerId: 'provisioned-runner-1'});
93
+ const intendedReservationId = crypto.randomUUID();
94
+ await createRunnerInstance({
95
+ providerRunnerId: 'provisioned-runner-1',
96
+ intendedReservationId,
97
+ });
90
98
  await insertRunningJob({
91
99
  jobId,
92
100
  workflowRunId,
@@ -98,7 +106,11 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
98
106
  const res = await app.inject({
99
107
  method: 'POST',
100
108
  url: '/provisioners/runner-instances/reconcile',
101
- headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
109
+ headers: {
110
+ authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`,
111
+ [RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER]:
112
+ RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER_VALUE,
113
+ },
102
114
  payload: {observed_provider_runner_ids: ['provisioned-runner-1']},
103
115
  });
104
116
 
@@ -108,6 +120,7 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
108
120
  {
109
121
  provider_runner_id: 'provisioned-runner-1',
110
122
  state: 'running',
123
+ intended_reservation_id: intendedReservationId,
111
124
  reservation_id: null,
112
125
  runner_session_id: null,
113
126
  bound_job: {
@@ -123,6 +136,23 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
123
136
  });
124
137
  });
125
138
 
139
+ it('omits the additive reservation field for legacy provisioners', async () => {
140
+ await createRunnerInstance({
141
+ providerRunnerId: 'provisioned-runner-1',
142
+ intendedReservationId: crypto.randomUUID(),
143
+ });
144
+
145
+ const res = await app.inject({
146
+ method: 'POST',
147
+ url: '/provisioners/runner-instances/reconcile',
148
+ headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
149
+ payload: {observed_provider_runner_ids: ['provisioned-runner-1']},
150
+ });
151
+
152
+ expect(res.statusCode).toBe(200);
153
+ expect(Object.hasOwn(res.json().runners[0], 'intended_reservation_id')).toBe(false);
154
+ });
155
+
126
156
  it('returns terminate for an observed terminal runner', async () => {
127
157
  await createRunnerInstance({
128
158
  providerRunnerId: 'provisioned-runner-1',
@@ -340,6 +370,7 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
340
370
  async function createRunnerInstance(params: {
341
371
  providerRunnerId: string;
342
372
  state?: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'terminated';
373
+ intendedReservationId?: string | null;
343
374
  reservationId?: string | null;
344
375
  reportedAt?: Date;
345
376
  }) {
@@ -347,6 +378,7 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
347
378
  workspaceId,
348
379
  provisionerId: provisionerTokenId,
349
380
  providerRunnerId: params.providerRunnerId,
381
+ intendedReservationId: params.intendedReservationId ?? null,
350
382
  reservationId: params.reservationId ?? null,
351
383
  state: params.state ?? 'running',
352
384
  reportedAt: params.reportedAt ?? new Date(),
@@ -1,5 +1,7 @@
1
1
  import {requireProvisionerContext} from '@shipfox/api-auth-context';
2
2
  import {
3
+ RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER,
4
+ RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER_VALUE,
3
5
  reconcileRunnerInstancesBodySchema,
4
6
  reconcileRunnerInstancesResponseSchema,
5
7
  } from '@shipfox/api-runners-dto';
@@ -25,6 +27,10 @@ export const reconcileRunnerInstancesRoute = defineRoute({
25
27
  observedRunnerInstanceIds: request.body.observed_provider_runner_ids,
26
28
  });
27
29
 
28
- return toReconcileRunnerInstancesResponseDto(result);
30
+ return toReconcileRunnerInstancesResponseDto(result, {
31
+ includeIntendedReservationId:
32
+ request.headers[RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER] ===
33
+ RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER_VALUE,
34
+ });
29
35
  },
30
36
  });