@shipfox/api-runners 10.0.0 → 10.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 (118) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +28 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +8 -13
  5. package/dist/config.js.map +1 -1
  6. package/dist/core/admin-runner-instances.d.ts +34 -0
  7. package/dist/core/admin-runner-instances.d.ts.map +1 -0
  8. package/dist/core/admin-runner-instances.js +58 -0
  9. package/dist/core/admin-runner-instances.js.map +1 -0
  10. package/dist/core/errors.d.ts +6 -0
  11. package/dist/core/errors.d.ts.map +1 -1
  12. package/dist/core/errors.js +12 -0
  13. package/dist/core/errors.js.map +1 -1
  14. package/dist/core/index.d.ts +3 -2
  15. package/dist/core/index.d.ts.map +1 -1
  16. package/dist/core/index.js +3 -2
  17. package/dist/core/index.js.map +1 -1
  18. package/dist/core/provisioner-tokens.d.ts +33 -0
  19. package/dist/core/provisioner-tokens.d.ts.map +1 -1
  20. package/dist/core/provisioner-tokens.js +107 -2
  21. package/dist/core/provisioner-tokens.js.map +1 -1
  22. package/dist/db/admin-runner-instances.d.ts +34 -0
  23. package/dist/db/admin-runner-instances.d.ts.map +1 -0
  24. package/dist/db/admin-runner-instances.js +62 -0
  25. package/dist/db/admin-runner-instances.js.map +1 -0
  26. package/dist/db/db.d.ts +290 -0
  27. package/dist/db/db.d.ts.map +1 -1
  28. package/dist/db/db.js +2 -0
  29. package/dist/db/db.js.map +1 -1
  30. package/dist/db/index.d.ts +3 -1
  31. package/dist/db/index.d.ts.map +1 -1
  32. package/dist/db/index.js +2 -1
  33. package/dist/db/index.js.map +1 -1
  34. package/dist/db/job-executions.d.ts.map +1 -1
  35. package/dist/db/job-executions.js +11 -0
  36. package/dist/db/job-executions.js.map +1 -1
  37. package/dist/db/provisioner-tokens.d.ts +38 -0
  38. package/dist/db/provisioner-tokens.d.ts.map +1 -1
  39. package/dist/db/provisioner-tokens.js +157 -3
  40. package/dist/db/provisioner-tokens.js.map +1 -1
  41. package/dist/db/reservations.d.ts.map +1 -1
  42. package/dist/db/reservations.js +60 -8
  43. package/dist/db/reservations.js.map +1 -1
  44. package/dist/db/schema/admin-command-results.d.ts +134 -0
  45. package/dist/db/schema/admin-command-results.d.ts.map +1 -0
  46. package/dist/db/schema/admin-command-results.js +18 -0
  47. package/dist/db/schema/admin-command-results.js.map +1 -0
  48. package/dist/db/schema/runner-instances.d.ts +17 -0
  49. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  50. package/dist/db/schema/runner-instances.js +3 -0
  51. package/dist/db/schema/runner-instances.js.map +1 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +6 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/presentation/dto/admin-provisioner-token.d.ts +16 -0
  56. package/dist/presentation/dto/admin-provisioner-token.d.ts.map +1 -0
  57. package/dist/presentation/dto/admin-provisioner-token.js +22 -0
  58. package/dist/presentation/dto/admin-provisioner-token.js.map +1 -0
  59. package/dist/presentation/dto/admin-runner-instances.d.ts +23 -0
  60. package/dist/presentation/dto/admin-runner-instances.d.ts.map +1 -0
  61. package/dist/presentation/dto/admin-runner-instances.js +19 -0
  62. package/dist/presentation/dto/admin-runner-instances.js.map +1 -0
  63. package/dist/presentation/dto/index.d.ts +2 -0
  64. package/dist/presentation/dto/index.d.ts.map +1 -1
  65. package/dist/presentation/dto/index.js +2 -0
  66. package/dist/presentation/dto/index.js.map +1 -1
  67. package/dist/presentation/routes/admin-provisioner-tokens.d.ts +4 -0
  68. package/dist/presentation/routes/admin-provisioner-tokens.d.ts.map +1 -0
  69. package/dist/presentation/routes/admin-provisioner-tokens.js +167 -0
  70. package/dist/presentation/routes/admin-provisioner-tokens.js.map +1 -0
  71. package/dist/presentation/routes/admin-runner-instances.d.ts +3 -0
  72. package/dist/presentation/routes/admin-runner-instances.d.ts.map +1 -0
  73. package/dist/presentation/routes/admin-runner-instances.js +63 -0
  74. package/dist/presentation/routes/admin-runner-instances.js.map +1 -0
  75. package/dist/presentation/routes/index.d.ts.map +1 -1
  76. package/dist/presentation/routes/index.js +11 -1
  77. package/dist/presentation/routes/index.js.map +1 -1
  78. package/dist/presentation/routes/runner-enrollment.d.ts +1 -0
  79. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  80. package/dist/presentation/routes/runner-enrollment.js +11 -4
  81. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  82. package/dist/tsconfig.test.tsbuildinfo +1 -1
  83. package/drizzle/0002_special_shadowcat.sql +1 -0
  84. package/drizzle/0003_flaky_sally_floyd.sql +11 -0
  85. package/drizzle/meta/0002_snapshot.json +2099 -0
  86. package/drizzle/meta/0003_snapshot.json +2178 -0
  87. package/drizzle/meta/_journal.json +14 -0
  88. package/package.json +10 -9
  89. package/src/config.test.ts +11 -0
  90. package/src/config.ts +19 -8
  91. package/src/core/admin-runner-instances.ts +125 -0
  92. package/src/core/errors.ts +14 -0
  93. package/src/core/index.ts +12 -0
  94. package/src/core/provisioner-tokens.ts +157 -1
  95. package/src/db/admin-runner-instances.ts +121 -0
  96. package/src/db/db.ts +2 -0
  97. package/src/db/index.ts +10 -0
  98. package/src/db/job-executions.test.ts +17 -0
  99. package/src/db/job-executions.ts +22 -0
  100. package/src/db/provisioner-tokens.ts +263 -9
  101. package/src/db/reservations.test.ts +253 -0
  102. package/src/db/reservations.ts +170 -15
  103. package/src/db/schema/admin-command-results.ts +29 -0
  104. package/src/db/schema/runner-instances.ts +1 -0
  105. package/src/index.test.ts +1 -1
  106. package/src/index.ts +6 -1
  107. package/src/presentation/dto/admin-provisioner-token.ts +23 -0
  108. package/src/presentation/dto/admin-runner-instances.ts +19 -0
  109. package/src/presentation/dto/index.ts +2 -0
  110. package/src/presentation/routes/admin-provisioner-tokens.test.ts +227 -0
  111. package/src/presentation/routes/admin-provisioner-tokens.ts +177 -0
  112. package/src/presentation/routes/admin-runner-instances.test.ts +671 -0
  113. package/src/presentation/routes/admin-runner-instances.ts +67 -0
  114. package/src/presentation/routes/index.ts +8 -0
  115. package/src/presentation/routes/runner-enrollment.test.ts +60 -2
  116. package/src/presentation/routes/runner-enrollment.ts +24 -4
  117. package/test/globalSetup.ts +1 -0
  118. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,671 @@
1
+ import {
2
+ AUTH_LEASED_JOB,
3
+ AUTH_PROVISIONER_TOKEN,
4
+ AUTH_RUNNER_REGISTRATION_TOKEN,
5
+ AUTH_RUNNER_SESSION,
6
+ AUTH_USER,
7
+ buildUserContext,
8
+ setUserContext,
9
+ } from '@shipfox/api-auth-context';
10
+ import {
11
+ type AuthInterModuleClient,
12
+ authInterModuleContract,
13
+ } from '@shipfox/api-auth-dto/inter-module';
14
+ import {createInterModuleKnownError} from '@shipfox/inter-module';
15
+ import {type AuthMethod, ClientError, closeApp, createApp} from '@shipfox/node-fastify';
16
+ import {vi} from '@shipfox/vitest/vi';
17
+ import {eq} from 'drizzle-orm';
18
+ import type {FastifyInstance, FastifyRequest} from 'fastify';
19
+ import {db} from '#db/db.js';
20
+ import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
21
+ import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
22
+ import {providerRunners} from '#db/schema/runner-instances.js';
23
+ import {provisionerTokenFactory, runnersTestAuthClient} from '#test/index.js';
24
+ import {createRunnerRoutes} from './index.js';
25
+
26
+ const USER_ID = '00000000-0000-4000-8000-000000000001';
27
+
28
+ const fakeUserAuth: AuthMethod = {
29
+ name: AUTH_USER,
30
+ authenticate: (request: FastifyRequest) => {
31
+ if (request.headers.authorization !== 'Bearer user') {
32
+ throw new ClientError('Invalid user token', 'unauthorized', {status: 401});
33
+ }
34
+
35
+ setUserContext(
36
+ request,
37
+ buildUserContext({
38
+ userId: USER_ID,
39
+ email: 'admin@example.com',
40
+ name: 'Administrator',
41
+ memberships: [],
42
+ }),
43
+ );
44
+ return Promise.resolve();
45
+ },
46
+ };
47
+
48
+ const passthroughAuth = (name: string): AuthMethod => ({
49
+ name,
50
+ authenticate: () => Promise.resolve(),
51
+ });
52
+
53
+ describe('GET /admin/runners/instances', () => {
54
+ let app: FastifyInstance;
55
+ let auth: AuthInterModuleClient;
56
+
57
+ beforeEach(async () => {
58
+ await closeApp();
59
+ auth = {
60
+ ...runnersTestAuthClient,
61
+ requireAdminRole: vi.fn().mockResolvedValue({role: 'admin-observer'}),
62
+ };
63
+ app = await createApp({
64
+ auth: [
65
+ fakeUserAuth,
66
+ passthroughAuth(AUTH_RUNNER_REGISTRATION_TOKEN),
67
+ passthroughAuth(AUTH_RUNNER_SESSION),
68
+ passthroughAuth(AUTH_LEASED_JOB),
69
+ passthroughAuth(AUTH_PROVISIONER_TOKEN),
70
+ ],
71
+ routes: createRunnerRoutes(auth),
72
+ swagger: false,
73
+ });
74
+ await app.ready();
75
+ });
76
+
77
+ afterEach(async () => {
78
+ await closeApp();
79
+ });
80
+
81
+ test('returns a bounded safe installation inventory with deterministic pagination', async () => {
82
+ const label = `inventory-${crypto.randomUUID()}`;
83
+ const provisioner = await provisionerTokenFactory.create({
84
+ scope: 'installation',
85
+ name: 'Cloud managed runners',
86
+ });
87
+ const now = new Date();
88
+ await db()
89
+ .update(provisionerTokens)
90
+ .set({lastSeenAt: now})
91
+ .where(eq(provisionerTokens.id, provisioner.id));
92
+
93
+ const firstId = crypto.randomUUID();
94
+ const secondId = crypto.randomUUID();
95
+ const assignedWorkspaceId = crypto.randomUUID();
96
+ await db()
97
+ .insert(providerRunners)
98
+ .values([
99
+ {
100
+ id: firstId,
101
+ workspaceId: assignedWorkspaceId,
102
+ provisionerId: provisioner.id,
103
+ providerRunnerId: 'provider-secret-1',
104
+ reservationId: crypto.randomUUID(),
105
+ assignedAt: new Date(now.getTime() - 4_000),
106
+ labels: [label, 'linux'],
107
+ state: 'running',
108
+ providerKind: 'ec2',
109
+ reportedAt: now,
110
+ createdAt: new Date(now.getTime() - 2_000),
111
+ updatedAt: now,
112
+ },
113
+ {
114
+ id: secondId,
115
+ workspaceId: null,
116
+ provisionerId: provisioner.id,
117
+ providerRunnerId: null,
118
+ labels: [label],
119
+ state: 'starting',
120
+ reportedAt: now,
121
+ createdAt: new Date(now.getTime() - 1_000),
122
+ updatedAt: now,
123
+ },
124
+ ]);
125
+ await db()
126
+ .insert(runnerControlSessions)
127
+ .values({
128
+ runnerInstanceId: secondId,
129
+ provisionerId: provisioner.id,
130
+ hashedToken: `hash-${crypto.randomUUID()}`,
131
+ prefix: 'test',
132
+ expiresAt: new Date(now.getTime() + 60_000),
133
+ lastSeenAt: now,
134
+ });
135
+
136
+ const firstPage = await app.inject({
137
+ method: 'GET',
138
+ url: `/admin/runners/instances?label=${label}&limit=1`,
139
+ headers: {authorization: 'Bearer user'},
140
+ });
141
+
142
+ expect(firstPage.statusCode).toBe(200);
143
+ expect(firstPage.json()).toMatchObject({
144
+ runners: [
145
+ {
146
+ id: secondId,
147
+ lifecycle_state: 'unassigned',
148
+ compute_state: 'starting',
149
+ enrollment_state: 'enrolled',
150
+ assignment_presence: 'unassigned',
151
+ assigned_workspace: null,
152
+ labels: [label],
153
+ provisioner: {
154
+ id: provisioner.id,
155
+ scope: 'installation',
156
+ name: 'Cloud managed runners',
157
+ },
158
+ reconciliation_status: 'current',
159
+ },
160
+ ],
161
+ });
162
+ expect(firstPage.json().runners[0]).not.toHaveProperty('provider_runner_id');
163
+ expect(firstPage.json().runners[0]).not.toHaveProperty('provider_kind');
164
+ expect(firstPage.json().runners[0]).not.toHaveProperty('bootstrap_token');
165
+ expect(firstPage.json().runners[0]).not.toHaveProperty('control_session_token');
166
+ expect(firstPage.json().next_cursor).toEqual(expect.any(String));
167
+
168
+ const secondPage = await app.inject({
169
+ method: 'GET',
170
+ url: `/admin/runners/instances?label=${label}&limit=1&cursor=${encodeURIComponent(firstPage.json().next_cursor)}`,
171
+ headers: {authorization: 'Bearer user'},
172
+ });
173
+
174
+ expect(secondPage.statusCode).toBe(200);
175
+ expect(secondPage.json()).toMatchObject({
176
+ runners: [
177
+ {
178
+ id: firstId,
179
+ lifecycle_state: 'assigned',
180
+ compute_state: 'running',
181
+ enrollment_state: 'pending',
182
+ assignment_presence: 'assigned',
183
+ assigned_workspace: {id: assignedWorkspaceId},
184
+ labels: [label, 'linux'],
185
+ closure_reason: null,
186
+ closed_at: null,
187
+ reconciliation_status: 'current',
188
+ },
189
+ ],
190
+ next_cursor: null,
191
+ });
192
+ });
193
+
194
+ test('checks bounded lifecycle, assignment, and label filters', async () => {
195
+ const label = `filter-${crypto.randomUUID()}`;
196
+ const provisioner = await provisionerTokenFactory.create({scope: 'installation'});
197
+ const now = new Date();
198
+ await db()
199
+ .update(provisionerTokens)
200
+ .set({lastSeenAt: now})
201
+ .where(eq(provisionerTokens.id, provisioner.id));
202
+ await db()
203
+ .insert(providerRunners)
204
+ .values([
205
+ {
206
+ provisionerId: provisioner.id,
207
+ workspaceId: crypto.randomUUID(),
208
+ providerRunnerId: 'filter-running',
209
+ labels: [label],
210
+ state: 'running',
211
+ reportedAt: now,
212
+ },
213
+ {
214
+ provisionerId: provisioner.id,
215
+ workspaceId: null,
216
+ providerRunnerId: 'filter-failed',
217
+ labels: [label],
218
+ state: 'failed',
219
+ reason: 'launch-failed',
220
+ failedAt: now,
221
+ reportedAt: now,
222
+ },
223
+ {
224
+ provisionerId: provisioner.id,
225
+ workspaceId: crypto.randomUUID(),
226
+ providerRunnerId: 'filter-other-label',
227
+ labels: ['other'],
228
+ state: 'running',
229
+ reportedAt: now,
230
+ },
231
+ ]);
232
+
233
+ const response = await app.inject({
234
+ method: 'GET',
235
+ url: `/admin/runners/instances?state=running&assignment=assigned&label=${label}`,
236
+ headers: {authorization: 'Bearer user'},
237
+ });
238
+
239
+ expect(response.statusCode).toBe(200);
240
+ expect(response.json().runners).toHaveLength(1);
241
+ expect(response.json().runners[0]).toMatchObject({
242
+ compute_state: 'running',
243
+ assignment_presence: 'assigned',
244
+ labels: [label],
245
+ });
246
+ });
247
+
248
+ test('reports activated and claimed lifecycle states', async () => {
249
+ const label = `lifecycle-${crypto.randomUUID()}`;
250
+ const provisioner = await provisionerTokenFactory.create({scope: 'installation'});
251
+ const workspaceId = crypto.randomUUID();
252
+ const activatedId = crypto.randomUUID();
253
+ const claimedId = crypto.randomUUID();
254
+ const now = new Date();
255
+ await db()
256
+ .insert(providerRunners)
257
+ .values([
258
+ {
259
+ id: activatedId,
260
+ provisionerId: provisioner.id,
261
+ workspaceId,
262
+ runnerSessionId: crypto.randomUUID(),
263
+ providerRunnerId: 'activated-provider',
264
+ labels: [label],
265
+ state: 'running',
266
+ reportedAt: now,
267
+ },
268
+ {
269
+ id: claimedId,
270
+ provisionerId: provisioner.id,
271
+ workspaceId,
272
+ runnerSessionId: crypto.randomUUID(),
273
+ firstClaimedAt: now,
274
+ providerRunnerId: 'claimed-provider',
275
+ labels: [label],
276
+ state: 'running',
277
+ reportedAt: now,
278
+ },
279
+ ]);
280
+
281
+ const response = await app.inject({
282
+ method: 'GET',
283
+ url: `/admin/runners/instances?label=${label}`,
284
+ headers: {authorization: 'Bearer user'},
285
+ });
286
+
287
+ expect(response.statusCode).toBe(200);
288
+ expect(response.json().runners).toEqual(
289
+ expect.arrayContaining([
290
+ expect.objectContaining({
291
+ id: activatedId,
292
+ lifecycle_state: 'activated',
293
+ enrollment_state: 'activated',
294
+ }),
295
+ expect.objectContaining({
296
+ id: claimedId,
297
+ lifecycle_state: 'claimed',
298
+ enrollment_state: 'activated',
299
+ }),
300
+ ]),
301
+ );
302
+ });
303
+
304
+ test('reports stale and unknown reconciliation states', async () => {
305
+ const label = `reconciliation-${crypto.randomUUID()}`;
306
+ const staleProvisioner = await provisionerTokenFactory.create({scope: 'installation'});
307
+ const unknownProvisioner = await provisionerTokenFactory.create({scope: 'installation'});
308
+ const staleAt = new Date(Date.now() - 24 * 60 * 60 * 1000);
309
+ await db()
310
+ .update(provisionerTokens)
311
+ .set({lastSeenAt: staleAt})
312
+ .where(eq(provisionerTokens.id, staleProvisioner.id));
313
+ const staleId = crypto.randomUUID();
314
+ const unknownId = crypto.randomUUID();
315
+ await db()
316
+ .insert(providerRunners)
317
+ .values([
318
+ {
319
+ id: staleId,
320
+ provisionerId: staleProvisioner.id,
321
+ workspaceId: crypto.randomUUID(),
322
+ providerRunnerId: 'stale-provider',
323
+ labels: [label],
324
+ state: 'running',
325
+ reportedAt: new Date(),
326
+ },
327
+ {
328
+ id: unknownId,
329
+ provisionerId: unknownProvisioner.id,
330
+ workspaceId: crypto.randomUUID(),
331
+ providerRunnerId: 'unknown-provider',
332
+ labels: [label],
333
+ state: 'running',
334
+ reportedAt: new Date(),
335
+ },
336
+ ]);
337
+
338
+ const response = await app.inject({
339
+ method: 'GET',
340
+ url: `/admin/runners/instances?label=${label}`,
341
+ headers: {authorization: 'Bearer user'},
342
+ });
343
+
344
+ expect(response.statusCode).toBe(200);
345
+ expect(response.json().runners).toEqual(
346
+ expect.arrayContaining([
347
+ expect.objectContaining({id: staleId, reconciliation_status: 'stale'}),
348
+ expect.objectContaining({id: unknownId, reconciliation_status: 'unknown'}),
349
+ ]),
350
+ );
351
+ });
352
+
353
+ test('matches a stored lowercase label regardless of filter casing', async () => {
354
+ const label = `mixed-case-${crypto.randomUUID()}`;
355
+ const provisioner = await provisionerTokenFactory.create({scope: 'installation'});
356
+ await db()
357
+ .insert(providerRunners)
358
+ .values({
359
+ provisionerId: provisioner.id,
360
+ workspaceId: crypto.randomUUID(),
361
+ providerRunnerId: `provider-${crypto.randomUUID()}`,
362
+ labels: [label],
363
+ state: 'running',
364
+ reportedAt: new Date(),
365
+ });
366
+
367
+ const response = await app.inject({
368
+ method: 'GET',
369
+ url: `/admin/runners/instances?label=${label.toUpperCase()}`,
370
+ headers: {authorization: 'Bearer user'},
371
+ });
372
+
373
+ expect(response.statusCode).toBe(200);
374
+ expect(response.json().runners).toHaveLength(1);
375
+ expect(response.json().runners[0].labels).toEqual([label]);
376
+ });
377
+
378
+ test('returns an empty inventory when only workspace-scoped runners exist', async () => {
379
+ const label = `empty-${crypto.randomUUID()}`;
380
+ const workspaceProvisioner = await provisionerTokenFactory.create({
381
+ scope: 'workspace',
382
+ workspaceId: crypto.randomUUID(),
383
+ });
384
+ await db()
385
+ .insert(providerRunners)
386
+ .values({
387
+ provisionerId: workspaceProvisioner.id,
388
+ workspaceId: workspaceProvisioner.workspaceId,
389
+ providerRunnerId: `workspace-${crypto.randomUUID()}`,
390
+ labels: [label],
391
+ state: 'running',
392
+ reportedAt: new Date(),
393
+ });
394
+
395
+ const response = await app.inject({
396
+ method: 'GET',
397
+ url: `/admin/runners/instances?label=${label}`,
398
+ headers: {authorization: 'Bearer user'},
399
+ });
400
+
401
+ expect(response.statusCode).toBe(200);
402
+ expect(response.json()).toEqual({runners: [], next_cursor: null});
403
+ });
404
+
405
+ test('requires the administrator observer role', async () => {
406
+ vi.mocked(auth.requireAdminRole).mockRejectedValueOnce(
407
+ createInterModuleKnownError(
408
+ authInterModuleContract.methods.requireAdminRole,
409
+ 'admin-role-required',
410
+ {requiredRole: 'admin-observer'},
411
+ ),
412
+ );
413
+
414
+ const response = await app.inject({
415
+ method: 'GET',
416
+ url: '/admin/runners/instances',
417
+ headers: {authorization: 'Bearer user'},
418
+ });
419
+
420
+ expect(response.statusCode).toBe(403);
421
+ expect(response.json()).toMatchObject({
422
+ code: 'forbidden',
423
+ details: {required_role: 'admin-observer'},
424
+ });
425
+ expect(auth.requireAdminRole).toHaveBeenCalledWith({
426
+ userId: USER_ID,
427
+ minimumRole: 'admin-observer',
428
+ });
429
+ });
430
+
431
+ test('rejects oversized parameters and malformed cursors', async () => {
432
+ const oversizedLimit = await app.inject({
433
+ method: 'GET',
434
+ url: '/admin/runners/instances?limit=101',
435
+ headers: {authorization: 'Bearer user'},
436
+ });
437
+ const malformedCursor = await app.inject({
438
+ method: 'GET',
439
+ url: '/admin/runners/instances?cursor=not-a-cursor',
440
+ headers: {authorization: 'Bearer user'},
441
+ });
442
+ const emptyCursor = await app.inject({
443
+ method: 'GET',
444
+ url: '/admin/runners/instances?cursor=',
445
+ headers: {authorization: 'Bearer user'},
446
+ });
447
+
448
+ expect(oversizedLimit.statusCode).toBe(400);
449
+ expect(malformedCursor.statusCode).toBe(400);
450
+ expect(emptyCursor.statusCode).toBe(400);
451
+ });
452
+
453
+ test('returns terminal closure and explicit unknown reconciliation state', async () => {
454
+ const label = `terminal-${crypto.randomUUID()}`;
455
+ const provisioner = await provisionerTokenFactory.create({scope: 'installation'});
456
+ const now = new Date();
457
+ const closedAt = new Date(now.getTime() - 1_000);
458
+ await db()
459
+ .insert(providerRunners)
460
+ .values({
461
+ provisionerId: provisioner.id,
462
+ workspaceId: null,
463
+ providerRunnerId: 'terminal-provider',
464
+ labels: [label],
465
+ state: 'failed',
466
+ reason: 'provider-launch-failed',
467
+ failedAt: closedAt,
468
+ reportedAt: now,
469
+ });
470
+
471
+ const response = await app.inject({
472
+ method: 'GET',
473
+ url: `/admin/runners/instances?label=${label}`,
474
+ headers: {authorization: 'Bearer user'},
475
+ });
476
+
477
+ expect(response.statusCode).toBe(200);
478
+ expect(response.json().runners[0]).toMatchObject({
479
+ closure_reason: 'provider-launch-failed',
480
+ closed_at: closedAt.toISOString(),
481
+ reconciliation_status: 'terminal',
482
+ });
483
+ });
484
+
485
+ test('prioritizes terminal lifecycle over missing claim history', async () => {
486
+ const label = `terminal-lifecycle-${crypto.randomUUID()}`;
487
+ const provisioner = await provisionerTokenFactory.create({scope: 'installation'});
488
+ const now = new Date();
489
+ const closedAt = new Date(now.getTime() - 1_000);
490
+ const runnerInstanceId = crypto.randomUUID();
491
+ await db()
492
+ .insert(providerRunners)
493
+ .values({
494
+ id: runnerInstanceId,
495
+ provisionerId: provisioner.id,
496
+ workspaceId: crypto.randomUUID(),
497
+ runnerSessionId: crypto.randomUUID(),
498
+ providerRunnerId: 'terminal-activated-provider',
499
+ labels: [label],
500
+ state: 'failed',
501
+ reason: 'provider-launch-failed',
502
+ failedAt: closedAt,
503
+ reportedAt: now,
504
+ });
505
+
506
+ const response = await app.inject({
507
+ method: 'GET',
508
+ url: `/admin/runners/instances?label=${label}`,
509
+ headers: {authorization: 'Bearer user'},
510
+ });
511
+
512
+ expect(response.statusCode).toBe(200);
513
+ expect(response.json().runners[0]).toMatchObject({
514
+ id: runnerInstanceId,
515
+ lifecycle_state: 'completed',
516
+ enrollment_state: 'activated',
517
+ reconciliation_status: 'terminal',
518
+ });
519
+ });
520
+
521
+ test('prioritizes terminal lifecycle before activation state', async () => {
522
+ const label = `terminal-unactivated-${crypto.randomUUID()}`;
523
+ const provisioner = await provisionerTokenFactory.create({scope: 'installation'});
524
+ const now = new Date();
525
+ const closedAt = new Date(now.getTime() - 1_000);
526
+ const runnerInstanceId = crypto.randomUUID();
527
+ await db()
528
+ .insert(providerRunners)
529
+ .values({
530
+ id: runnerInstanceId,
531
+ provisionerId: provisioner.id,
532
+ workspaceId: crypto.randomUUID(),
533
+ runnerSessionId: null,
534
+ providerRunnerId: 'terminal-unactivated-provider',
535
+ labels: [label],
536
+ state: 'terminated',
537
+ reason: 'registration-timeout',
538
+ terminatedAt: closedAt,
539
+ reportedAt: now,
540
+ });
541
+
542
+ const response = await app.inject({
543
+ method: 'GET',
544
+ url: `/admin/runners/instances?label=${label}`,
545
+ headers: {authorization: 'Bearer user'},
546
+ });
547
+
548
+ expect(response.statusCode).toBe(200);
549
+ expect(response.json().runners[0]).toMatchObject({
550
+ id: runnerInstanceId,
551
+ lifecycle_state: 'completed',
552
+ enrollment_state: 'pending',
553
+ reconciliation_status: 'terminal',
554
+ });
555
+ });
556
+
557
+ test('does not treat closed control sessions as enrollment authority', async () => {
558
+ const label = `closed-control-${crypto.randomUUID()}`;
559
+ const provisioner = await provisionerTokenFactory.create({scope: 'installation'});
560
+ const now = new Date();
561
+ const runnerInstanceId = crypto.randomUUID();
562
+ await db()
563
+ .insert(providerRunners)
564
+ .values({
565
+ id: runnerInstanceId,
566
+ provisionerId: provisioner.id,
567
+ workspaceId: null,
568
+ providerRunnerId: 'closed-control-provider',
569
+ labels: [label],
570
+ state: 'starting',
571
+ reportedAt: now,
572
+ });
573
+ await db()
574
+ .insert(runnerControlSessions)
575
+ .values({
576
+ runnerInstanceId,
577
+ provisionerId: provisioner.id,
578
+ hashedToken: `closed-${crypto.randomUUID()}`,
579
+ prefix: 'test',
580
+ expiresAt: now,
581
+ lastSeenAt: now,
582
+ closedAt: now,
583
+ closeReason: 'activated',
584
+ });
585
+
586
+ const response = await app.inject({
587
+ method: 'GET',
588
+ url: `/admin/runners/instances?label=${label}`,
589
+ headers: {authorization: 'Bearer user'},
590
+ });
591
+
592
+ expect(response.statusCode).toBe(200);
593
+ expect(response.json().runners[0].enrollment_state).toBe('pending');
594
+ });
595
+
596
+ test('does not treat expired control sessions as enrollment authority', async () => {
597
+ const label = `expired-control-${crypto.randomUUID()}`;
598
+ const provisioner = await provisionerTokenFactory.create({scope: 'installation'});
599
+ const now = new Date();
600
+ const runnerInstanceId = crypto.randomUUID();
601
+ await db()
602
+ .insert(providerRunners)
603
+ .values({
604
+ id: runnerInstanceId,
605
+ provisionerId: provisioner.id,
606
+ workspaceId: null,
607
+ providerRunnerId: 'expired-control-provider',
608
+ labels: [label],
609
+ state: 'starting',
610
+ reportedAt: now,
611
+ });
612
+ await db()
613
+ .insert(runnerControlSessions)
614
+ .values({
615
+ runnerInstanceId,
616
+ provisionerId: provisioner.id,
617
+ hashedToken: `expired-${crypto.randomUUID()}`,
618
+ prefix: 'test',
619
+ expiresAt: new Date(now.getTime() - 1_000),
620
+ lastSeenAt: now,
621
+ });
622
+
623
+ const response = await app.inject({
624
+ method: 'GET',
625
+ url: `/admin/runners/instances?label=${label}`,
626
+ headers: {authorization: 'Bearer user'},
627
+ });
628
+
629
+ expect(response.statusCode).toBe(200);
630
+ expect(response.json().runners[0].enrollment_state).toBe('pending');
631
+ });
632
+
633
+ test('does not include a workspace-scoped installation row accidentally', async () => {
634
+ const label = `scope-${crypto.randomUUID()}`;
635
+ const installation = await provisionerTokenFactory.create({scope: 'installation'});
636
+ const workspace = await provisionerTokenFactory.create({
637
+ scope: 'workspace',
638
+ workspaceId: crypto.randomUUID(),
639
+ });
640
+ await db()
641
+ .insert(providerRunners)
642
+ .values([
643
+ {
644
+ provisionerId: installation.id,
645
+ workspaceId: null,
646
+ providerRunnerId: 'installation-row',
647
+ labels: [label],
648
+ state: 'running',
649
+ reportedAt: new Date(),
650
+ },
651
+ {
652
+ provisionerId: workspace.id,
653
+ workspaceId: workspace.workspaceId,
654
+ providerRunnerId: 'workspace-row',
655
+ labels: [label],
656
+ state: 'running',
657
+ reportedAt: new Date(),
658
+ },
659
+ ]);
660
+
661
+ const response = await app.inject({
662
+ method: 'GET',
663
+ url: `/admin/runners/instances?label=${label}`,
664
+ headers: {authorization: 'Bearer user'},
665
+ });
666
+
667
+ expect(response.statusCode).toBe(200);
668
+ expect(response.json().runners).toHaveLength(1);
669
+ expect(response.json().runners[0].provisioner.id).toBe(installation.id);
670
+ });
671
+ });