@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
@@ -29,6 +29,13 @@
29
29
  "when": 1785235910167,
30
30
  "tag": "0003_flaky_sally_floyd",
31
31
  "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "7",
36
+ "when": 1786017247182,
37
+ "tag": "0004_huge_skreet",
38
+ "breakpoints": true
32
39
  }
33
40
  ]
34
41
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-runners",
3
3
  "license": "MIT",
4
- "version": "12.1.0",
4
+ "version": "12.3.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -22,24 +22,24 @@
22
22
  "drizzle-orm": "^0.45.2",
23
23
  "zod": "^4.4.3",
24
24
  "@shipfox/api-common-dto": "12.0.0",
25
- "@shipfox/api-auth-context": "12.0.0",
25
+ "@shipfox/api-auth-context": "12.2.0",
26
26
  "@shipfox/api-auth-dto": "12.0.0",
27
- "@shipfox/api-runners-dto": "12.0.0",
28
- "@shipfox/api-workflows-dto": "12.1.0",
27
+ "@shipfox/api-runners-dto": "12.2.0",
28
+ "@shipfox/api-workflows-dto": "12.3.0",
29
29
  "@shipfox/config": "1.2.4",
30
30
  "@shipfox/inter-module": "0.2.3",
31
31
  "@shipfox/node-drizzle": "0.3.5",
32
32
  "@shipfox/node-error-monitoring": "0.3.0",
33
33
  "@shipfox/node-auth-root-key": "0.2.3",
34
- "@shipfox/node-fastify": "0.4.1",
35
- "@shipfox/node-module": "1.0.5",
36
- "@shipfox/node-opentelemetry": "0.6.3",
34
+ "@shipfox/node-fastify": "0.4.2",
35
+ "@shipfox/node-module": "1.0.6",
36
+ "@shipfox/node-opentelemetry": "0.6.4",
37
37
  "@shipfox/node-outbox": "0.2.6",
38
38
  "@shipfox/node-postgres": "0.5.0",
39
39
  "@shipfox/node-rate-limit": "0.4.0",
40
- "@shipfox/node-temporal": "0.4.4",
40
+ "@shipfox/node-temporal": "0.4.5",
41
41
  "@shipfox/node-tokens": "0.3.2",
42
- "@shipfox/runner-labels": "0.1.3"
42
+ "@shipfox/runner-labels": "0.2.0"
43
43
  },
44
44
  "imports": {
45
45
  "#*": "./dist/*"
@@ -1,6 +1,42 @@
1
1
  import {RUNNER_ASSIGNMENT_POLL_DEFAULT_WAIT_SECONDS} from '@shipfox/api-runners-dto';
2
2
  import {vi} from '@shipfox/vitest/vi';
3
3
 
4
+ describe('RUNNER_RESERVED_LABELS', () => {
5
+ afterEach(() => {
6
+ vi.unstubAllEnvs();
7
+ vi.resetModules();
8
+ });
9
+
10
+ it('defaults to no reserved labels', async () => {
11
+ vi.stubEnv('RUNNER_RESERVED_LABELS', undefined);
12
+ vi.resetModules();
13
+
14
+ const {config, runnerReservedLabels} = await import('#config.js');
15
+
16
+ expect(config.RUNNER_RESERVED_LABELS).toBe('');
17
+ expect(runnerReservedLabels).toEqual([]);
18
+ });
19
+
20
+ it('parses comma-separated labels for case-insensitive matching', async () => {
21
+ vi.stubEnv('RUNNER_RESERVED_LABELS', ' ShipFox-Managed, Linux,shipfox-managed ');
22
+ vi.resetModules();
23
+
24
+ const {config, runnerReservedLabels} = await import('#config.js');
25
+
26
+ expect(config.RUNNER_RESERVED_LABELS).toBe(' ShipFox-Managed, Linux,shipfox-managed ');
27
+ expect(runnerReservedLabels).toEqual(['linux', 'shipfox-managed']);
28
+ });
29
+
30
+ it('rejects invalid runner labels', async () => {
31
+ vi.stubEnv('RUNNER_RESERVED_LABELS', 'linux,has space');
32
+ vi.resetModules();
33
+
34
+ await expect(import('#config.js')).rejects.toThrow(
35
+ 'RUNNER_RESERVED_LABELS contains invalid runner label(s): has space',
36
+ );
37
+ });
38
+ });
39
+
4
40
  describe('EPHEMERAL_REGISTRATION_TOKEN_TTL_SECONDS validation', () => {
5
41
  afterEach(() => {
6
42
  vi.unstubAllEnvs();
@@ -49,6 +85,51 @@ describe('runner assignment polling defaults', () => {
49
85
  await expect(import('#config.js')).rejects.toThrow('RUNNER_ASSIGNMENT_POLL_MAX_WAIT_SECONDS');
50
86
  });
51
87
  });
88
+
89
+ describe('reservation TTL ceiling validation', () => {
90
+ afterEach(() => {
91
+ vi.unstubAllEnvs();
92
+ vi.resetModules();
93
+ });
94
+
95
+ it.each([
96
+ '0',
97
+ '-5',
98
+ '1.5',
99
+ '3601',
100
+ ])('fails startup when RESERVATION_TTL_MAX_SECONDS is %s', async (value) => {
101
+ vi.stubEnv('RESERVATION_TTL_MAX_SECONDS', value);
102
+ vi.resetModules();
103
+
104
+ await expect(import('#config.js')).rejects.toThrow('RESERVATION_TTL_MAX_SECONDS');
105
+ });
106
+
107
+ it('defaults high enough for provider registration deadlines and launch headroom', async () => {
108
+ vi.resetModules();
109
+
110
+ const {config} = await import('#config.js');
111
+
112
+ expect(config.RESERVATION_TTL_MAX_SECONDS).toBe(600);
113
+ });
114
+
115
+ it('accepts a whole-second ceiling inside the hard maximum', async () => {
116
+ vi.stubEnv('RESERVATION_TTL_MAX_SECONDS', '600');
117
+ vi.resetModules();
118
+
119
+ const {config} = await import('#config.js');
120
+
121
+ expect(config.RESERVATION_TTL_MAX_SECONDS).toBe(600);
122
+ });
123
+
124
+ it('fails startup when the default reservation TTL is above the ceiling', async () => {
125
+ vi.stubEnv('RESERVATION_TTL_SECONDS', '600');
126
+ vi.stubEnv('RESERVATION_TTL_MAX_SECONDS', '300');
127
+ vi.resetModules();
128
+
129
+ await expect(import('#config.js')).rejects.toThrow('RESERVATION_TTL_SECONDS');
130
+ });
131
+ });
132
+
52
133
  describe('REGISTRATION_TOKEN_BATCH_MAX validation', () => {
53
134
  afterEach(() => {
54
135
  vi.unstubAllEnvs();
package/src/config.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import {RUNNER_ASSIGNMENT_POLL_DEFAULT_WAIT_SECONDS} from '@shipfox/api-runners-dto';
2
- import {bool, createConfig, num} from '@shipfox/config';
2
+ import {bool, createConfig, num, str} from '@shipfox/config';
3
+ import {findInvalidLabels, parseLabelList} from '@shipfox/runner-labels';
3
4
  import {STUCK_JOB_THRESHOLD_SECONDS} from '#core/maintenance-policy.js';
4
5
 
5
6
  const EPHEMERAL_REGISTRATION_TOKEN_TTL_HARD_MAX_SECONDS = 3600;
6
7
  const REGISTRATION_TOKEN_BATCH_HARD_MAX = 1000;
7
8
  const RUNNER_CONTROL_PLANE_TOKEN_TTL_HARD_MAX_SECONDS = 3600;
9
+ const RESERVATION_TTL_HARD_MAX_SECONDS = 3600;
8
10
 
9
11
  export const config = createConfig({
10
12
  RUNNER_BOOTSTRAP_TOKEN_TTL_SECONDS: num({
@@ -27,6 +29,10 @@ export const config = createConfig({
27
29
  desc: 'Delay between durable assignment reads while a runner-control session waits for an assignment, in milliseconds.',
28
30
  default: 250,
29
31
  }),
32
+ RUNNER_RESERVED_LABELS: str({
33
+ desc: 'Comma-separated labels that only installation-scope provisioners may advertise. Any other provisioner or runner has them removed.',
34
+ default: '',
35
+ }),
30
36
  EPHEMERAL_REGISTRATION_TOKEN_TTL_SECONDS: num({
31
37
  desc: `Lifetime of a provisioner-minted runner registration token, in seconds. The token can be exchanged once by a runner before this time passes. Set this between 1 and ${EPHEMERAL_REGISTRATION_TOKEN_TTL_HARD_MAX_SECONDS}.`,
32
38
  default: 300,
@@ -56,9 +62,13 @@ export const config = createConfig({
56
62
  default: 250,
57
63
  }),
58
64
  RESERVATION_TTL_SECONDS: num({
59
- desc: 'Lifetime of a count-based runner reservation, in seconds. Expired reservations stop counting against queued demand.',
65
+ desc: 'Default lifetime and activation grace period of a count-based runner reservation, in seconds. A runner without a session can be rebound only after this deadline. Used when a provisioner does not request a provider-specific value. Expired reservations stop counting against queued demand.',
60
66
  default: 60,
61
67
  }),
68
+ RESERVATION_TTL_MAX_SECONDS: num({
69
+ desc: `Server-side ceiling for a count-based runner reservation lifetime and activation grace period, in seconds. Set it at least as high as every provider registration deadline plus its launch headroom so provider-specific values can cover the launch gap, boot, and enrollment. Set this between 1 and ${RESERVATION_TTL_HARD_MAX_SECONDS}.`,
70
+ default: 600,
71
+ }),
62
72
  RESERVATION_LONG_POLL_MAX_WAIT_SECONDS: num({
63
73
  desc: 'Maximum time the provisioner demand poll endpoint waits for reservable demand before returning, in seconds.',
64
74
  default: 30,
@@ -133,6 +143,17 @@ export const config = createConfig({
133
143
  }),
134
144
  });
135
145
 
146
+ const parsedRunnerReservedLabels = parseLabelList(config.RUNNER_RESERVED_LABELS);
147
+ const invalidRunnerReservedLabels = findInvalidLabels(parsedRunnerReservedLabels);
148
+
149
+ if (invalidRunnerReservedLabels.length > 0) {
150
+ throw new Error(
151
+ `RUNNER_RESERVED_LABELS contains invalid runner label(s): ${invalidRunnerReservedLabels.join(', ')}`,
152
+ );
153
+ }
154
+
155
+ export const runnerReservedLabels = parsedRunnerReservedLabels;
156
+
136
157
  if (
137
158
  !Number.isInteger(config.EPHEMERAL_REGISTRATION_TOKEN_TTL_SECONDS) ||
138
159
  config.EPHEMERAL_REGISTRATION_TOKEN_TTL_SECONDS < 1 ||
@@ -209,6 +230,22 @@ if (!Number.isInteger(config.RESERVATION_TTL_SECONDS) || config.RESERVATION_TTL_
209
230
  );
210
231
  }
211
232
 
233
+ if (
234
+ !Number.isInteger(config.RESERVATION_TTL_MAX_SECONDS) ||
235
+ config.RESERVATION_TTL_MAX_SECONDS < 1 ||
236
+ config.RESERVATION_TTL_MAX_SECONDS > RESERVATION_TTL_HARD_MAX_SECONDS
237
+ ) {
238
+ throw new Error(
239
+ `RESERVATION_TTL_MAX_SECONDS (${config.RESERVATION_TTL_MAX_SECONDS}) must be a whole number of seconds between 1 and ${RESERVATION_TTL_HARD_MAX_SECONDS}.`,
240
+ );
241
+ }
242
+
243
+ if (config.RESERVATION_TTL_SECONDS > config.RESERVATION_TTL_MAX_SECONDS) {
244
+ throw new Error(
245
+ `RESERVATION_TTL_SECONDS (${config.RESERVATION_TTL_SECONDS}) must not be greater than RESERVATION_TTL_MAX_SECONDS (${config.RESERVATION_TTL_MAX_SECONDS}).`,
246
+ );
247
+ }
248
+
212
249
  if (
213
250
  !Number.isInteger(config.RESERVATION_LONG_POLL_MAX_WAIT_SECONDS) ||
214
251
  config.RESERVATION_LONG_POLL_MAX_WAIT_SECONDS < 0
@@ -40,6 +40,15 @@ export class EmptyRunnerLabelsError extends Error {
40
40
  }
41
41
  }
42
42
 
43
+ export class RunnerLabelsReservedError extends Error {
44
+ constructor(public readonly labels: string[]) {
45
+ super(
46
+ `All supplied runner labels are reserved for installation-scope provisioners: ${labels.join(', ')}`,
47
+ );
48
+ this.name = 'RunnerLabelsReservedError';
49
+ }
50
+ }
51
+
43
52
  export class EmptyRequiredLabelsError extends Error {
44
53
  constructor() {
45
54
  super('Required labels cannot be empty');
package/src/core/index.ts CHANGED
@@ -25,6 +25,7 @@ export {
25
25
  ProvisionerTokenNotFoundError,
26
26
  RegistrationTokenConsumedError,
27
27
  RegistrationTokenExpiredError,
28
+ RunnerLabelsReservedError,
28
29
  RunnerSessionExhaustedError,
29
30
  RunningJobExecutionNotFoundError,
30
31
  } from './errors.js';
@@ -0,0 +1,404 @@
1
+ import type {RunnerToolCapabilitiesDto} from '@shipfox/api-runners-dto';
2
+ import {afterEach, vi} from '@shipfox/vitest/vi';
3
+ import {eq, inArray} from 'drizzle-orm';
4
+ import {db} from '#db/db.js';
5
+ import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
6
+ import {reservations} from '#db/schema/reservations.js';
7
+ import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
8
+ import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
9
+ import {providerRunners} from '#db/schema/runner-instances.js';
10
+ import {
11
+ type RunnerReservationPromotionFailureReason,
12
+ runnerReservationPromotionFailureCount,
13
+ } from '#metrics/instance.js';
14
+ import {enrollRunnerControlSession} from './runner-control-sessions.js';
15
+
16
+ const createdReservationIds = new Set<string>();
17
+ const createdProvisionerTokenIds = new Set<string>();
18
+ const createdRunnerInstanceIds = new Set<string>();
19
+
20
+ afterEach(async () => {
21
+ const runnerInstanceIds = [...createdRunnerInstanceIds];
22
+ const reservationIds = [...createdReservationIds];
23
+
24
+ if (runnerInstanceIds.length > 0) {
25
+ await db()
26
+ .delete(runnerActivationTokens)
27
+ .where(inArray(runnerActivationTokens.runnerInstanceId, runnerInstanceIds));
28
+ await db()
29
+ .delete(runnerControlSessions)
30
+ .where(inArray(runnerControlSessions.runnerInstanceId, runnerInstanceIds));
31
+ await db().delete(providerRunners).where(inArray(providerRunners.id, runnerInstanceIds));
32
+ }
33
+ if (reservationIds.length > 0)
34
+ await db().delete(reservations).where(inArray(reservations.id, reservationIds));
35
+ if (createdProvisionerTokenIds.size > 0)
36
+ await db()
37
+ .delete(provisionerTokens)
38
+ .where(inArray(provisionerTokens.id, [...createdProvisionerTokenIds]));
39
+
40
+ createdRunnerInstanceIds.clear();
41
+ createdReservationIds.clear();
42
+ createdProvisionerTokenIds.clear();
43
+ });
44
+
45
+ describe('enrollRunnerControlSession', () => {
46
+ it('counts expired reservation promotion failures', async () => {
47
+ const provisionerId = crypto.randomUUID();
48
+ const reservation = await createReservation({
49
+ provisionerId,
50
+ expiresAt: new Date(Date.now() - 1_000),
51
+ });
52
+
53
+ await expectPromotionFailure({
54
+ provisionerId,
55
+ intendedReservationId: reservation.id,
56
+ reason: 'reservation-expired',
57
+ });
58
+ });
59
+
60
+ it('counts missing reservation promotion failures', async () => {
61
+ await expectPromotionFailure({
62
+ provisionerId: crypto.randomUUID(),
63
+ intendedReservationId: crypto.randomUUID(),
64
+ reason: 'reservation-not-found',
65
+ });
66
+ });
67
+
68
+ it('counts already-assigned reservation promotion failures', async () => {
69
+ const provisionerId = crypto.randomUUID();
70
+ const intendedReservation = await createReservation({provisionerId});
71
+ const assignedReservation = await createReservation({provisionerId});
72
+
73
+ await expectPromotionFailure({
74
+ provisionerId,
75
+ intendedReservationId: intendedReservation.id,
76
+ reservationId: assignedReservation.id,
77
+ reason: 'already-assigned',
78
+ });
79
+ });
80
+
81
+ it('counts not-assignable reservation promotion failures', async () => {
82
+ const provisionerId = crypto.randomUUID();
83
+ const reservation = await createReservation({
84
+ provisionerId,
85
+ requiredLabels: ['gpu'],
86
+ });
87
+
88
+ await expectPromotionFailure({
89
+ provisionerId,
90
+ intendedReservationId: reservation.id,
91
+ labels: ['linux'],
92
+ reason: 'not-assignable',
93
+ });
94
+ });
95
+
96
+ it('does not count successful reservation promotion', async () => {
97
+ const provisionerId = crypto.randomUUID();
98
+ const reservation = await createReservation({provisionerId});
99
+ const runnerInstanceId = await createRunner({
100
+ provisionerId,
101
+ intendedReservationId: reservation.id,
102
+ });
103
+ const addSpy = vi.spyOn(runnerReservationPromotionFailureCount, 'add');
104
+
105
+ try {
106
+ const activationToken = await enrollRunnerControlSession({
107
+ runnerInstanceId,
108
+ provisionerId,
109
+ labels: ['linux'],
110
+ providerKind: 'docker',
111
+ protocolVersion: '1',
112
+ });
113
+
114
+ expect(activationToken).toEqual(expect.any(String));
115
+ expect(addSpy).not.toHaveBeenCalled();
116
+ } finally {
117
+ addSpy.mockRestore();
118
+ }
119
+ });
120
+
121
+ it('preserves committed assignment metadata during a later enrollment', async () => {
122
+ const provisionerId = crypto.randomUUID();
123
+ const reservation = await createReservation({provisionerId});
124
+ const committedCapabilities: RunnerToolCapabilitiesDto = {
125
+ harnesses: {pi: {tools: ['read']}},
126
+ };
127
+ const runnerInstanceId = await createRunner({
128
+ provisionerId,
129
+ intendedReservationId: reservation.id,
130
+ reservationId: reservation.id,
131
+ workspaceId: reservation.workspaceId,
132
+ assignedAt: new Date(),
133
+ labels: ['linux'],
134
+ providerKind: 'ec2',
135
+ protocolVersion: '1',
136
+ capabilities: committedCapabilities,
137
+ });
138
+
139
+ const activationToken = await enrollRunnerControlSession({
140
+ runnerInstanceId,
141
+ provisionerId,
142
+ labels: ['gpu'],
143
+ capabilities: {harnesses: {claude: {tools: ['bash']}}},
144
+ providerKind: 'docker',
145
+ protocolVersion: '2',
146
+ });
147
+ const [runner] = await db()
148
+ .select({
149
+ reservationId: providerRunners.reservationId,
150
+ labels: providerRunners.labels,
151
+ providerKind: providerRunners.providerKind,
152
+ protocolVersion: providerRunners.protocolVersion,
153
+ capabilities: providerRunners.capabilities,
154
+ state: providerRunners.state,
155
+ })
156
+ .from(providerRunners)
157
+ .where(eq(providerRunners.id, runnerInstanceId));
158
+
159
+ expect(activationToken).toEqual(expect.any(String));
160
+ expect(runner).toEqual({
161
+ reservationId: reservation.id,
162
+ labels: ['linux'],
163
+ providerKind: 'ec2',
164
+ protocolVersion: '1',
165
+ capabilities: committedCapabilities,
166
+ state: 'running',
167
+ });
168
+ });
169
+
170
+ it('updates metadata when a provider report precedes assignment commit', async () => {
171
+ const provisionerId = crypto.randomUUID();
172
+ const reservation = await createReservation({provisionerId});
173
+ const capabilities: RunnerToolCapabilitiesDto = {
174
+ harnesses: {pi: {tools: ['read']}},
175
+ };
176
+ const runnerInstanceId = await createRunner({
177
+ provisionerId,
178
+ intendedReservationId: reservation.id,
179
+ reservationId: reservation.id,
180
+ assignedAt: null,
181
+ });
182
+
183
+ const activationToken = await enrollRunnerControlSession({
184
+ runnerInstanceId,
185
+ provisionerId,
186
+ labels: ['linux'],
187
+ capabilities,
188
+ providerKind: 'ec2',
189
+ protocolVersion: '1',
190
+ });
191
+ const [runner] = await db()
192
+ .select({
193
+ reservationId: providerRunners.reservationId,
194
+ assignedAt: providerRunners.assignedAt,
195
+ labels: providerRunners.labels,
196
+ providerKind: providerRunners.providerKind,
197
+ protocolVersion: providerRunners.protocolVersion,
198
+ capabilities: providerRunners.capabilities,
199
+ state: providerRunners.state,
200
+ })
201
+ .from(providerRunners)
202
+ .where(eq(providerRunners.id, runnerInstanceId));
203
+
204
+ expect(activationToken).toBeNull();
205
+ expect(runner).toEqual({
206
+ reservationId: reservation.id,
207
+ assignedAt: null,
208
+ labels: ['linux'],
209
+ providerKind: 'ec2',
210
+ protocolVersion: '1',
211
+ capabilities,
212
+ state: 'running',
213
+ });
214
+ });
215
+
216
+ it('updates metadata during enrollment for an unassigned runner', async () => {
217
+ const provisionerId = crypto.randomUUID();
218
+ const capabilities: RunnerToolCapabilitiesDto = {
219
+ harnesses: {pi: {tools: ['read']}},
220
+ };
221
+ const runnerInstanceId = await createRunner({provisionerId});
222
+
223
+ const activationToken = await enrollRunnerControlSession({
224
+ runnerInstanceId,
225
+ provisionerId,
226
+ labels: ['linux'],
227
+ capabilities,
228
+ providerKind: 'docker',
229
+ protocolVersion: '1',
230
+ });
231
+ const [runner] = await db()
232
+ .select({
233
+ reservationId: providerRunners.reservationId,
234
+ labels: providerRunners.labels,
235
+ providerKind: providerRunners.providerKind,
236
+ protocolVersion: providerRunners.protocolVersion,
237
+ capabilities: providerRunners.capabilities,
238
+ state: providerRunners.state,
239
+ })
240
+ .from(providerRunners)
241
+ .where(eq(providerRunners.id, runnerInstanceId));
242
+
243
+ expect(activationToken).toBeNull();
244
+ expect(runner).toEqual({
245
+ reservationId: null,
246
+ labels: ['linux'],
247
+ providerKind: 'docker',
248
+ protocolVersion: '1',
249
+ capabilities,
250
+ state: 'running',
251
+ });
252
+ });
253
+
254
+ it('keeps unexpected promotion failures on the error-log path', async () => {
255
+ const provisionerId = crypto.randomUUID();
256
+ const reservation = await createReservation({provisionerId});
257
+ const runnerInstanceId = await createRunner({
258
+ provisionerId,
259
+ intendedReservationId: reservation.id,
260
+ });
261
+ const unexpectedError = new Error('database unavailable');
262
+ const debugLog = vi.fn();
263
+ const errorLog = vi.fn();
264
+
265
+ vi.resetModules();
266
+ vi.doMock('@shipfox/node-opentelemetry', async (importOriginal) => ({
267
+ ...(await importOriginal<typeof import('@shipfox/node-opentelemetry')>()),
268
+ logger: () => ({debug: debugLog, error: errorLog}),
269
+ }));
270
+ vi.doMock('#db/runner-assignments.js', async (importOriginal) => ({
271
+ ...(await importOriginal<typeof import('#db/runner-assignments.js')>()),
272
+ assignRunnerInstancesTx: vi.fn().mockRejectedValue(unexpectedError),
273
+ }));
274
+
275
+ const {closePostgresClient, createPostgresClient} = await import('@shipfox/node-postgres');
276
+ createPostgresClient();
277
+
278
+ try {
279
+ const {enrollRunnerControlSession: enroll} = await import('./runner-control-sessions.js');
280
+ const result = await enroll({
281
+ runnerInstanceId,
282
+ provisionerId,
283
+ labels: ['linux'],
284
+ providerKind: 'docker',
285
+ protocolVersion: '1',
286
+ });
287
+
288
+ expect(result).toBeNull();
289
+ expect(debugLog).not.toHaveBeenCalled();
290
+ expect(errorLog).toHaveBeenCalledWith(
291
+ expect.objectContaining({err: unexpectedError}),
292
+ 'Unexpected failure promoting runner reservation during enrollment',
293
+ );
294
+ } finally {
295
+ await closePostgresClient();
296
+ vi.doUnmock('@shipfox/node-opentelemetry');
297
+ vi.doUnmock('#db/runner-assignments.js');
298
+ vi.resetModules();
299
+ }
300
+ });
301
+ });
302
+
303
+ async function expectPromotionFailure(params: {
304
+ provisionerId: string;
305
+ intendedReservationId: string;
306
+ reservationId?: string;
307
+ labels?: string[];
308
+ reason: RunnerReservationPromotionFailureReason;
309
+ }): Promise<void> {
310
+ const runnerInstanceId = await createRunner(params);
311
+ const addSpy = vi.spyOn(runnerReservationPromotionFailureCount, 'add');
312
+
313
+ try {
314
+ const result = await enrollRunnerControlSession({
315
+ runnerInstanceId,
316
+ provisionerId: params.provisionerId,
317
+ labels: params.labels ?? ['linux'],
318
+ providerKind: 'docker',
319
+ protocolVersion: '1',
320
+ });
321
+
322
+ expect(result).toBeNull();
323
+ expect(addSpy).toHaveBeenCalledWith(1, {reason: params.reason});
324
+ } finally {
325
+ addSpy.mockRestore();
326
+ }
327
+ }
328
+
329
+ async function createReservation(params: {
330
+ provisionerId: string;
331
+ requiredLabels?: string[];
332
+ expiresAt?: Date;
333
+ }) {
334
+ const [reservation] = await db()
335
+ .insert(reservations)
336
+ .values({
337
+ workspaceId: crypto.randomUUID(),
338
+ provisionerId: params.provisionerId,
339
+ requiredLabels: params.requiredLabels ?? ['linux'],
340
+ count: 1,
341
+ expiresAt: params.expiresAt ?? new Date(Date.now() + 60_000),
342
+ })
343
+ .returning();
344
+ if (!reservation) throw new Error('Expected reservation');
345
+ createdReservationIds.add(reservation.id);
346
+ return reservation;
347
+ }
348
+
349
+ async function createRunner(params: {
350
+ provisionerId: string;
351
+ intendedReservationId?: string | null;
352
+ reservationId?: string | null;
353
+ workspaceId?: string | null;
354
+ labels?: string[];
355
+ providerKind?: string | null;
356
+ protocolVersion?: string | null;
357
+ capabilities?: RunnerToolCapabilitiesDto | null;
358
+ assignedAt?: Date | null;
359
+ }): Promise<string> {
360
+ const defaultAssignedAt = params.reservationId ? new Date() : null;
361
+
362
+ if (!createdProvisionerTokenIds.has(params.provisionerId)) {
363
+ await db().insert(provisionerTokens).values({
364
+ id: params.provisionerId,
365
+ scope: 'installation',
366
+ workspaceId: null,
367
+ hashedToken: crypto.randomUUID(),
368
+ prefix: 'test',
369
+ createdByUserId: crypto.randomUUID(),
370
+ });
371
+ createdProvisionerTokenIds.add(params.provisionerId);
372
+ }
373
+
374
+ const [runner] = await db()
375
+ .insert(providerRunners)
376
+ .values({
377
+ provisionerId: params.provisionerId,
378
+ intendedReservationId: params.intendedReservationId ?? null,
379
+ reservationId: params.reservationId ?? null,
380
+ workspaceId: params.workspaceId ?? null,
381
+ providerRunnerId: crypto.randomUUID(),
382
+ labels: params.labels ?? [],
383
+ providerKind: params.providerKind ?? null,
384
+ protocolVersion: params.protocolVersion ?? null,
385
+ capabilities: params.capabilities ?? null,
386
+ assignedAt: params.assignedAt === undefined ? defaultAssignedAt : params.assignedAt,
387
+ state: 'starting',
388
+ reportedAt: new Date(),
389
+ })
390
+ .returning({id: providerRunners.id});
391
+ if (!runner) throw new Error('Expected runner instance');
392
+ createdRunnerInstanceIds.add(runner.id);
393
+
394
+ await db()
395
+ .insert(runnerControlSessions)
396
+ .values({
397
+ runnerInstanceId: runner.id,
398
+ provisionerId: params.provisionerId,
399
+ hashedToken: crypto.randomUUID(),
400
+ prefix: 'test',
401
+ expiresAt: new Date(Date.now() + 60_000),
402
+ });
403
+ return runner.id;
404
+ }