@shipfox/api-runners 12.1.0 → 12.2.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 (116) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +23 -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 +23 -9
  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/reservations.d.ts.map +1 -1
  34. package/dist/db/reservations.js +15 -5
  35. package/dist/db/reservations.js.map +1 -1
  36. package/dist/db/runner-instances.d.ts +1 -0
  37. package/dist/db/runner-instances.d.ts.map +1 -1
  38. package/dist/db/runner-instances.js +6 -4
  39. package/dist/db/runner-instances.js.map +1 -1
  40. package/dist/db/runner-sessions.d.ts.map +1 -1
  41. package/dist/db/runner-sessions.js +12 -1
  42. package/dist/db/runner-sessions.js.map +1 -1
  43. package/dist/db/schema/admin-command-results.d.ts +1 -1
  44. package/dist/db/schema/ephemeral-registration-tokens.d.ts +1 -1
  45. package/dist/db/schema/manual-registration-tokens.d.ts +1 -1
  46. package/dist/db/schema/outbox.d.ts +1 -1
  47. package/dist/db/schema/pending-job-executions.d.ts +3 -3
  48. package/dist/db/schema/provisioner-capability-snapshots.d.ts +3 -3
  49. package/dist/db/schema/provisioner-tokens.d.ts +2 -2
  50. package/dist/db/schema/rate-limits.d.ts +1 -1
  51. package/dist/db/schema/reservations.d.ts +3 -3
  52. package/dist/db/schema/runner-activation-tokens.d.ts +1 -1
  53. package/dist/db/schema/runner-control-sessions.d.ts +2 -2
  54. package/dist/db/schema/runner-instances.d.ts +4 -4
  55. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  56. package/dist/db/schema/runner-instances.js +1 -0
  57. package/dist/db/schema/runner-instances.js.map +1 -1
  58. package/dist/db/schema/runner-sessions.d.ts +4 -4
  59. package/dist/db/schema/running-job-executions.d.ts +5 -5
  60. package/dist/metrics/index.d.ts +2 -1
  61. package/dist/metrics/index.d.ts.map +1 -1
  62. package/dist/metrics/index.js +1 -1
  63. package/dist/metrics/index.js.map +1 -1
  64. package/dist/metrics/instance.d.ts +13 -8
  65. package/dist/metrics/instance.d.ts.map +1 -1
  66. package/dist/metrics/instance.js +8 -0
  67. package/dist/metrics/instance.js.map +1 -1
  68. package/dist/presentation/dto/admin-provisioner-token.d.ts +1 -1
  69. package/dist/presentation/dto/admin-provisioner-token.d.ts.map +1 -1
  70. package/dist/presentation/dto/admin-runner-instances.d.ts +2 -2
  71. package/dist/presentation/dto/admin-runner-instances.d.ts.map +1 -1
  72. package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
  73. package/dist/presentation/routes/poll-demand.js +12 -6
  74. package/dist/presentation/routes/poll-demand.js.map +1 -1
  75. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  76. package/dist/presentation/routes/register.d.ts.map +1 -1
  77. package/dist/presentation/routes/register.js +9 -1
  78. package/dist/presentation/routes/register.js.map +1 -1
  79. package/dist/presentation/routes/report-runner-instances.d.ts.map +1 -1
  80. package/dist/presentation/routes/report-runner-instances.js +1 -0
  81. package/dist/presentation/routes/report-runner-instances.js.map +1 -1
  82. package/dist/tsconfig.test.tsbuildinfo +1 -1
  83. package/drizzle/0004_huge_skreet.sql +1 -0
  84. package/drizzle/meta/0004_snapshot.json +2194 -0
  85. package/drizzle/meta/_journal.json +7 -0
  86. package/package.json +9 -9
  87. package/src/config.test.ts +81 -0
  88. package/src/config.ts +39 -2
  89. package/src/core/errors.ts +9 -0
  90. package/src/core/index.ts +1 -0
  91. package/src/core/runner-control-sessions.test.ts +239 -0
  92. package/src/core/runner-control-sessions.ts +29 -10
  93. package/src/core/runner-instances.ts +1 -0
  94. package/src/core/runner-labels.test.ts +53 -0
  95. package/src/core/runner-labels.ts +51 -0
  96. package/src/core/runner-sessions.test.ts +34 -1
  97. package/src/core/runner-sessions.ts +8 -1
  98. package/src/db/ephemeral-registration-tokens.ts +15 -1
  99. package/src/db/reservations.test.ts +169 -0
  100. package/src/db/reservations.ts +42 -8
  101. package/src/db/runner-instances.test.ts +45 -1
  102. package/src/db/runner-instances.ts +7 -2
  103. package/src/db/runner-sessions.ts +14 -1
  104. package/src/db/schema/runner-instances.ts +3 -0
  105. package/src/metrics/index.ts +2 -0
  106. package/src/metrics/instance.ts +18 -0
  107. package/src/presentation/routes/poll-demand.test.ts +99 -3
  108. package/src/presentation/routes/poll-demand.ts +18 -6
  109. package/src/presentation/routes/register.test.ts +116 -2
  110. package/src/presentation/routes/register.ts +7 -0
  111. package/src/presentation/routes/report-runner-instances.test.ts +33 -0
  112. package/src/presentation/routes/report-runner-instances.ts +1 -0
  113. package/src/presentation/routes/runner-enrollment.test.ts +65 -5
  114. package/src/temporal/activities/maintenance-activities.test.ts +24 -31
  115. package/test/env.ts +1 -0
  116. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,51 @@
1
+ import {logger} from '@shipfox/node-opentelemetry';
2
+ import {canonicalizeLabels} from '@shipfox/runner-labels';
3
+ import {runnerReservedLabels} from '#config.js';
4
+ import {RunnerLabelsReservedError} from './errors.js';
5
+
6
+ export type RunnerLabelScope = 'installation' | 'workspace' | 'manual';
7
+
8
+ type SanitizeRunnerLabelsParams = {
9
+ scope: RunnerLabelScope;
10
+ source: string;
11
+ logLevel?: 'debug' | 'warn';
12
+ reservedLabels?: readonly string[];
13
+ };
14
+
15
+ function sanitizeCanonicalRunnerLabels(
16
+ canonicalLabels: readonly string[],
17
+ params: SanitizeRunnerLabelsParams,
18
+ ): string[] {
19
+ if (params.scope === 'installation') return [...canonicalLabels];
20
+
21
+ const reservedLabels = new Set(canonicalizeLabels(params.reservedLabels ?? runnerReservedLabels));
22
+ if (reservedLabels.size === 0) return [...canonicalLabels];
23
+
24
+ const removedLabels = canonicalLabels.filter((label) => reservedLabels.has(label));
25
+ if (removedLabels.length === 0) return [...canonicalLabels];
26
+
27
+ const details = {source: params.source, removedLabels};
28
+ if (params.logLevel === 'debug')
29
+ logger().debug(details, 'Removed reserved runner labels from a non-installation source');
30
+ else logger().warn(details, 'Removed reserved runner labels from a non-installation source');
31
+ return canonicalLabels.filter((label) => !reservedLabels.has(label));
32
+ }
33
+
34
+ export function sanitizeRunnerLabels(
35
+ labels: readonly string[],
36
+ params: SanitizeRunnerLabelsParams,
37
+ ): string[] {
38
+ return sanitizeCanonicalRunnerLabels([...canonicalizeLabels(labels)], params);
39
+ }
40
+
41
+ export function sanitizeRunnerLabelsOrThrow(
42
+ labels: readonly string[],
43
+ params: SanitizeRunnerLabelsParams,
44
+ ): string[] {
45
+ const canonicalLabels = [...canonicalizeLabels(labels)];
46
+ const sanitizedLabels = sanitizeCanonicalRunnerLabels(canonicalLabels, params);
47
+ if (sanitizedLabels.length === 0 && canonicalLabels.length > 0) {
48
+ throw new RunnerLabelsReservedError(canonicalLabels);
49
+ }
50
+ return sanitizedLabels;
51
+ }
@@ -9,12 +9,14 @@ import {
9
9
  getRunnerSessionTokenClaims,
10
10
  manualRegistrationTokenFactory,
11
11
  providerRunnerFactory,
12
+ provisionerTokenFactory,
12
13
  runnersTestAuthClient,
13
14
  } from '#test/index.js';
14
15
  import {
15
16
  EmptyRunnerLabelsError,
16
17
  RegistrationTokenConsumedError,
17
18
  RegistrationTokenWorkspaceMismatchError,
19
+ RunnerLabelsReservedError,
18
20
  } from './errors.js';
19
21
  import {issueRunnerActivationToken} from './runner-activation.js';
20
22
  import {registerRunnerSession} from './runner-sessions.js';
@@ -66,6 +68,15 @@ describe('registerRunnerSession', () => {
66
68
  ).rejects.toBeInstanceOf(EmptyRunnerLabelsError);
67
69
  });
68
70
 
71
+ it('names labels removed by the reserved-label policy', () => {
72
+ const error = new RunnerLabelsReservedError(['shipfox-managed']);
73
+
74
+ expect(error.message).toBe(
75
+ 'All supplied runner labels are reserved for installation-scope provisioners: shipfox-managed',
76
+ );
77
+ expect(error.labels).toEqual(['shipfox-managed']);
78
+ });
79
+
69
80
  it('consumes an ephemeral token and creates a one-claim session', async () => {
70
81
  const token = await ephemeralRegistrationTokenFactory.create({workspaceId});
71
82
 
@@ -223,7 +234,8 @@ describe('activation runner sessions', () => {
223
234
 
224
235
  beforeEach(async () => {
225
236
  workspaceId = crypto.randomUUID();
226
- provisionerId = crypto.randomUUID();
237
+ const provisioner = await provisionerTokenFactory.create({workspaceId});
238
+ provisionerId = provisioner.id;
227
239
  const runner = await providerRunnerFactory.create({workspaceId, provisionerId});
228
240
  runnerInstanceId = runner.id;
229
241
  });
@@ -296,4 +308,25 @@ describe('activation runner sessions', () => {
296
308
  expect(storedToken?.consumedAt).toBeInstanceOf(Date);
297
309
  expect(sessions).toHaveLength(1);
298
310
  });
311
+
312
+ it('strips reserved labels from workspace activation registration', async () => {
313
+ const rawToken = await issueRunnerActivationToken({
314
+ runnerInstanceId,
315
+ provisionerId,
316
+ ttlSeconds: 60,
317
+ });
318
+ const [activationToken] = await db()
319
+ .select()
320
+ .from(runnerActivationTokens)
321
+ .where(eq(runnerActivationTokens.runnerInstanceId, runnerInstanceId));
322
+ if (!rawToken || !activationToken) throw new Error('Activation token was not created');
323
+
324
+ const result = await registerRunnerSession({
325
+ auth: runnersTestAuthClient,
326
+ credential: {kind: 'activation', activationTokenId: activationToken.id, workspaceId},
327
+ labels: ['linux', 'shipfox-managed'],
328
+ });
329
+
330
+ expect(result.session.labels).toEqual(['linux']);
331
+ });
299
332
  });
@@ -8,6 +8,7 @@ import {
8
8
  } from '#db/runner-sessions.js';
9
9
  import type {RunnerSession} from './entities/runner-session.js';
10
10
  import {EmptyRunnerLabelsError} from './errors.js';
11
+ import {sanitizeRunnerLabelsOrThrow} from './runner-labels.js';
11
12
 
12
13
  export interface RegisterRunnerSessionResult {
13
14
  session: RunnerSession;
@@ -38,7 +39,13 @@ export async function registerRunnerSession(params: {
38
39
  labels: string[];
39
40
  toolCapabilities?: RunnerToolCapabilitiesDto | null;
40
41
  }): Promise<RegisterRunnerSessionResult> {
41
- const labels = [...canonicalizeLabels(params.labels)];
42
+ const labels =
43
+ params.credential.kind === 'manual'
44
+ ? sanitizeRunnerLabelsOrThrow(params.labels, {
45
+ scope: 'manual',
46
+ source: 'manual runner registration',
47
+ })
48
+ : [...canonicalizeLabels(params.labels)];
42
49
  if (labels.length === 0) throw new EmptyRunnerLabelsError();
43
50
 
44
51
  const mode = params.credential.kind;
@@ -4,6 +4,7 @@ import type {EphemeralRegistrationToken} from '#core/entities/ephemeral-registra
4
4
  import {
5
5
  ActiveEphemeralRegistrationTokenExistsError,
6
6
  ActiveEphemeralRegistrationTokensExistError,
7
+ EmptyRunnerLabelsError,
7
8
  RegistrationTokenBatchExceedsReservationError,
8
9
  RegistrationTokenConsumedError,
9
10
  RegistrationTokenExpiredError,
@@ -11,11 +12,13 @@ import {
11
12
  ReservationExpiredError,
12
13
  ReservationNotFoundError,
13
14
  } from '#core/errors.js';
15
+ import {sanitizeRunnerLabelsOrThrow} from '#core/runner-labels.js';
14
16
  import {db} from './db.js';
15
17
  import {
16
18
  ephemeralRegistrationTokens,
17
19
  toEphemeralRegistrationToken,
18
20
  } from './schema/ephemeral-registration-tokens.js';
21
+ import {provisionerTokens} from './schema/provisioner-tokens.js';
19
22
  import {reservations} from './schema/reservations.js';
20
23
  import {providerRunners} from './schema/runner-instances.js';
21
24
  import {runnerSessions, toRunnerSession} from './schema/runner-sessions.js';
@@ -307,6 +310,17 @@ export async function createRunnerSessionConsumingEphemeralToken(params: {
307
310
  );
308
311
  }
309
312
 
313
+ const [provisioner] = await tx
314
+ .select({scope: provisionerTokens.scope})
315
+ .from(provisionerTokens)
316
+ .where(eq(provisionerTokens.id, consumed[0].provisionerId))
317
+ .limit(1);
318
+ const labels = sanitizeRunnerLabelsOrThrow(params.labels, {
319
+ scope: provisioner?.scope ?? 'workspace',
320
+ source: 'ephemeral runner registration',
321
+ });
322
+ if (labels.length === 0) throw new EmptyRunnerLabelsError();
323
+
310
324
  const [session] = await tx
311
325
  .insert(runnerSessions)
312
326
  .values({
@@ -316,7 +330,7 @@ export async function createRunnerSessionConsumingEphemeralToken(params: {
316
330
  registrationTokenKind: 'ephemeral',
317
331
  provisionerId: consumed[0].provisionerId,
318
332
  providerRunnerId: consumed[0].providerRunnerId,
319
- labels: params.labels,
333
+ labels,
320
334
  toolCapabilities: params.toolCapabilities ?? null,
321
335
  toolCapabilitiesReportedAt: params.toolCapabilities ? sql`now()` : null,
322
336
  maxClaims: params.maxClaims,
@@ -461,6 +461,64 @@ describe('pollDemandAndReserve', () => {
461
461
  expect(storedToken?.revokedAt).toBeInstanceOf(Date);
462
462
  });
463
463
 
464
+ it('preserves activation tokens for active runners with an intended reservation', async () => {
465
+ const [reservation] = await db()
466
+ .insert(reservations)
467
+ .values({
468
+ workspaceId,
469
+ provisionerId,
470
+ requiredLabels: ['linux'],
471
+ count: 1,
472
+ expiresAt: new Date(Date.now() + 60_000),
473
+ })
474
+ .returning();
475
+ if (!reservation) throw new Error('Expected reservation');
476
+ const [runner] = await db()
477
+ .insert(providerRunners)
478
+ .values({
479
+ workspaceId,
480
+ provisionerId,
481
+ providerRunnerId: crypto.randomUUID(),
482
+ reservationId: reservation.id,
483
+ intendedReservationId: reservation.id,
484
+ runnerSessionId: crypto.randomUUID(),
485
+ labels: ['linux'],
486
+ state: 'running',
487
+ reportedAt: new Date(),
488
+ })
489
+ .returning();
490
+ if (!runner) throw new Error('Expected runner instance');
491
+ const [activationToken] = await db()
492
+ .insert(runnerActivationTokens)
493
+ .values({
494
+ runnerInstanceId: runner.id,
495
+ hashedToken: crypto.randomUUID(),
496
+ prefix: 'test',
497
+ expiresAt: new Date(Date.now() + 60_000),
498
+ })
499
+ .returning();
500
+ if (!activationToken) throw new Error('Expected activation token');
501
+
502
+ const deleted = await deleteReservationsByIds([reservation.id]);
503
+
504
+ const [storedRunner] = await db()
505
+ .select()
506
+ .from(providerRunners)
507
+ .where(eq(providerRunners.id, runner.id));
508
+ const [storedToken] = await db()
509
+ .select()
510
+ .from(runnerActivationTokens)
511
+ .where(eq(runnerActivationTokens.id, activationToken.id));
512
+ expect(deleted).toBe(1);
513
+ expect(storedRunner).toMatchObject({
514
+ reservationId: reservation.id,
515
+ intendedReservationId: null,
516
+ runnerSessionId: expect.any(String),
517
+ reservationReleasedAt: null,
518
+ });
519
+ expect(storedToken?.revokedAt).toBeNull();
520
+ });
521
+
464
522
  it('unbinds prewarmed runners and revokes activation tokens when deleting expired reservations', async () => {
465
523
  const runner = await createIdleRunner({labels: ['linux']});
466
524
  await createPendingJobs(1, ['linux']);
@@ -509,6 +567,115 @@ describe('pollDemandAndReserve', () => {
509
567
  expect(storedToken?.revokedAt).toBeInstanceOf(Date);
510
568
  });
511
569
 
570
+ it('clears intended reservations and rebinds runners when deleting a reservation by id', async () => {
571
+ const [reservation, survivingReservation] = await db()
572
+ .insert(reservations)
573
+ .values([
574
+ {
575
+ workspaceId,
576
+ provisionerId,
577
+ requiredLabels: ['linux'],
578
+ count: 1,
579
+ expiresAt: new Date(Date.now() + 60_000),
580
+ },
581
+ {
582
+ workspaceId: crypto.randomUUID(),
583
+ provisionerId,
584
+ requiredLabels: ['linux'],
585
+ count: 1,
586
+ expiresAt: new Date(Date.now() + 60_000),
587
+ },
588
+ ])
589
+ .returning();
590
+ if (!reservation || !survivingReservation) throw new Error('Expected reservations');
591
+ const runner = await createIdleRunner({
592
+ labels: ['linux'],
593
+ intendedReservationId: reservation.id,
594
+ });
595
+ const survivingRunner = await createIdleRunner({
596
+ labels: ['linux'],
597
+ intendedReservationId: survivingReservation.id,
598
+ });
599
+
600
+ const deleted = await deleteReservationsByIds([reservation.id]);
601
+
602
+ const [storedRunner] = await db()
603
+ .select()
604
+ .from(providerRunners)
605
+ .where(eq(providerRunners.id, runner.id));
606
+ expect(deleted).toBe(1);
607
+ expect(storedRunner).toMatchObject({
608
+ intendedReservationId: null,
609
+ workspaceId: null,
610
+ reservationId: null,
611
+ assignedAt: null,
612
+ state: 'running',
613
+ reservationReleasedAt: null,
614
+ });
615
+ const [storedSurvivingRunner] = await db()
616
+ .select()
617
+ .from(providerRunners)
618
+ .where(eq(providerRunners.id, survivingRunner.id));
619
+ expect(storedSurvivingRunner?.intendedReservationId).toBe(survivingReservation.id);
620
+
621
+ await createPendingJobs(1, ['linux']);
622
+ const result = await pollDemandAndReserve({
623
+ workspaceId,
624
+ provisionerId,
625
+ maxReservations: 1,
626
+ ttlSeconds: 60,
627
+ templates: [template('linux', ['linux'], 1)],
628
+ });
629
+ const reboundReservationId = result.reservations[0]?.reservationId;
630
+ const [reboundRunner] = await db()
631
+ .select()
632
+ .from(providerRunners)
633
+ .where(eq(providerRunners.id, runner.id));
634
+ expect(reboundReservationId).toEqual(expect.any(String));
635
+ expect(reboundRunner).toMatchObject({
636
+ intendedReservationId: null,
637
+ workspaceId,
638
+ reservationId: reboundReservationId,
639
+ assignedAt: expect.any(Date),
640
+ state: 'running',
641
+ reservationReleasedAt: null,
642
+ });
643
+ });
644
+
645
+ it('clears intended reservations when deleting expired reservations', async () => {
646
+ const [reservation] = await db()
647
+ .insert(reservations)
648
+ .values({
649
+ workspaceId,
650
+ provisionerId,
651
+ requiredLabels: ['linux'],
652
+ count: 1,
653
+ expiresAt: new Date(Date.now() - 60_000),
654
+ })
655
+ .returning();
656
+ if (!reservation) throw new Error('Expected reservation');
657
+ const runner = await createIdleRunner({
658
+ labels: ['linux'],
659
+ intendedReservationId: reservation.id,
660
+ });
661
+
662
+ const deleted = await deleteExpiredReservations();
663
+
664
+ const [storedRunner] = await db()
665
+ .select()
666
+ .from(providerRunners)
667
+ .where(eq(providerRunners.id, runner.id));
668
+ expect(deleted).toBeGreaterThanOrEqual(1);
669
+ expect(storedRunner).toMatchObject({
670
+ intendedReservationId: null,
671
+ workspaceId: null,
672
+ reservationId: null,
673
+ assignedAt: null,
674
+ state: 'running',
675
+ reservationReleasedAt: null,
676
+ });
677
+ });
678
+
512
679
  it('decrements reservation units inside a caller transaction', async () => {
513
680
  await reservationFactory.create({
514
681
  workspaceId,
@@ -645,6 +812,7 @@ describe('pollDemandAndReserve', () => {
645
812
  labels: string[];
646
813
  createdAt?: Date;
647
814
  controlSessionExpiresAt?: Date;
815
+ intendedReservationId?: string;
648
816
  }) {
649
817
  const createdAt = params.createdAt ?? new Date();
650
818
  const [runner] = await db()
@@ -652,6 +820,7 @@ describe('pollDemandAndReserve', () => {
652
820
  .values({
653
821
  provisionerId,
654
822
  providerRunnerId: crypto.randomUUID(),
823
+ intendedReservationId: params.intendedReservationId,
655
824
  labels: params.labels,
656
825
  state: 'running',
657
826
  reportedAt: createdAt,
@@ -10,6 +10,7 @@ import {
10
10
  isNotNull,
11
11
  isNull,
12
12
  lt,
13
+ or,
13
14
  sql,
14
15
  } from 'drizzle-orm';
15
16
  import type {Tx} from './db.js';
@@ -432,16 +433,26 @@ async function deleteReservationsWithCleanupTx(
432
433
  ): Promise<number> {
433
434
  if (ids.length === 0) return 0;
434
435
 
435
- const assignedRunners = await tx
436
- .select({id: providerRunners.id, reservationId: providerRunners.reservationId})
436
+ const affectedRunners = await tx
437
+ .select({
438
+ id: providerRunners.id,
439
+ reservationId: providerRunners.reservationId,
440
+ intendedReservationId: providerRunners.intendedReservationId,
441
+ runnerSessionId: providerRunners.runnerSessionId,
442
+ reservationReleasedAt: providerRunners.reservationReleasedAt,
443
+ })
437
444
  .from(providerRunners)
438
445
  .where(
439
- and(
440
- inArray(providerRunners.reservationId, ids),
441
- isNull(providerRunners.runnerSessionId),
442
- isNull(providerRunners.reservationReleasedAt),
446
+ or(
447
+ and(
448
+ inArray(providerRunners.reservationId, ids),
449
+ isNull(providerRunners.runnerSessionId),
450
+ isNull(providerRunners.reservationReleasedAt),
451
+ ),
452
+ inArray(providerRunners.intendedReservationId, ids),
443
453
  ),
444
454
  )
455
+ .orderBy(asc(providerRunners.id))
445
456
  .for('update');
446
457
  const reservationRows = await tx
447
458
  .select({id: reservations.id})
@@ -457,8 +468,20 @@ async function deleteReservationsWithCleanupTx(
457
468
 
458
469
  if (reservationIds.length === 0) return 0;
459
470
 
460
- const assignedRunnerIds = assignedRunners
461
- .filter((runner) => runner.reservationId && reservationIds.includes(runner.reservationId))
471
+ const assignedRunnerIds = affectedRunners
472
+ .filter(
473
+ (runner) =>
474
+ runner.reservationId &&
475
+ reservationIds.includes(runner.reservationId) &&
476
+ !runner.runnerSessionId &&
477
+ !runner.reservationReleasedAt,
478
+ )
479
+ .map((runner) => runner.id);
480
+ const intendedRunnerIds = affectedRunners
481
+ .filter(
482
+ (runner) =>
483
+ runner.intendedReservationId && reservationIds.includes(runner.intendedReservationId),
484
+ )
462
485
  .map((runner) => runner.id);
463
486
  if (assignedRunnerIds.length > 0) {
464
487
  await tx
@@ -487,6 +510,17 @@ async function deleteReservationsWithCleanupTx(
487
510
  ),
488
511
  );
489
512
  }
513
+ if (intendedRunnerIds.length > 0) {
514
+ await tx
515
+ .update(providerRunners)
516
+ .set({intendedReservationId: null, updatedAt: sql`now()`})
517
+ .where(
518
+ and(
519
+ inArray(providerRunners.id, intendedRunnerIds),
520
+ inArray(providerRunners.intendedReservationId, reservationIds),
521
+ ),
522
+ );
523
+ }
490
524
 
491
525
  const deleted = await tx
492
526
  .delete(reservations)