@shipfox/api-runners 12.2.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 (43) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +12 -0
  3. package/dist/config.js +3 -3
  4. package/dist/config.js.map +1 -1
  5. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  6. package/dist/core/runner-control-sessions.js +14 -2
  7. package/dist/core/runner-control-sessions.js.map +1 -1
  8. package/dist/db/index.d.ts +1 -1
  9. package/dist/db/index.d.ts.map +1 -1
  10. package/dist/db/index.js +1 -1
  11. package/dist/db/index.js.map +1 -1
  12. package/dist/db/reservations.d.ts.map +1 -1
  13. package/dist/db/reservations.js +22 -6
  14. package/dist/db/reservations.js.map +1 -1
  15. package/dist/db/runner-instances.d.ts +3 -0
  16. package/dist/db/runner-instances.d.ts.map +1 -1
  17. package/dist/db/runner-instances.js +10 -1
  18. package/dist/db/runner-instances.js.map +1 -1
  19. package/dist/db/runner-sessions.d.ts.map +1 -1
  20. package/dist/db/runner-sessions.js +22 -15
  21. package/dist/db/runner-sessions.js.map +1 -1
  22. package/dist/metrics/service.d.ts.map +1 -1
  23. package/dist/metrics/service.js +20 -4
  24. package/dist/metrics/service.js.map +1 -1
  25. package/dist/tsconfig.test.tsbuildinfo +1 -1
  26. package/package.json +3 -3
  27. package/src/config.test.ts +2 -2
  28. package/src/config.ts +3 -3
  29. package/src/core/runner-control-sessions.test.ts +169 -4
  30. package/src/core/runner-control-sessions.ts +22 -1
  31. package/src/db/index.ts +1 -0
  32. package/src/db/reservations.test.ts +369 -1
  33. package/src/db/reservations.ts +74 -9
  34. package/src/db/runner-instances.test.ts +231 -0
  35. package/src/db/runner-instances.ts +32 -1
  36. package/src/db/runner-sessions.ts +26 -15
  37. package/src/metrics/service.test.ts +99 -0
  38. package/src/metrics/service.ts +24 -4
  39. package/src/presentation/routes/late-runner-enrollment.test.ts +230 -0
  40. package/src/presentation/routes/poll-demand.test.ts +23 -6
  41. package/test/fixtures/late-runner-enrollment.ts +103 -0
  42. package/test/index.ts +5 -0
  43. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-runners",
3
3
  "license": "MIT",
4
- "version": "12.2.0",
4
+ "version": "12.3.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -25,19 +25,19 @@
25
25
  "@shipfox/api-auth-context": "12.2.0",
26
26
  "@shipfox/api-auth-dto": "12.0.0",
27
27
  "@shipfox/api-runners-dto": "12.2.0",
28
- "@shipfox/api-workflows-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
34
  "@shipfox/node-fastify": "0.4.2",
35
+ "@shipfox/node-module": "1.0.6",
35
36
  "@shipfox/node-opentelemetry": "0.6.4",
36
37
  "@shipfox/node-outbox": "0.2.6",
37
38
  "@shipfox/node-postgres": "0.5.0",
38
39
  "@shipfox/node-rate-limit": "0.4.0",
39
40
  "@shipfox/node-temporal": "0.4.5",
40
- "@shipfox/node-module": "1.0.6",
41
41
  "@shipfox/node-tokens": "0.3.2",
42
42
  "@shipfox/runner-labels": "0.2.0"
43
43
  },
@@ -104,12 +104,12 @@ describe('reservation TTL ceiling validation', () => {
104
104
  await expect(import('#config.js')).rejects.toThrow('RESERVATION_TTL_MAX_SECONDS');
105
105
  });
106
106
 
107
- it('defaults high enough for the EC2 registration deadline', async () => {
107
+ it('defaults high enough for provider registration deadlines and launch headroom', async () => {
108
108
  vi.resetModules();
109
109
 
110
110
  const {config} = await import('#config.js');
111
111
 
112
- expect(config.RESERVATION_TTL_MAX_SECONDS).toBe(300);
112
+ expect(config.RESERVATION_TTL_MAX_SECONDS).toBe(600);
113
113
  });
114
114
 
115
115
  it('accepts a whole-second ceiling inside the hard maximum', async () => {
package/src/config.ts CHANGED
@@ -62,12 +62,12 @@ export const config = createConfig({
62
62
  default: 250,
63
63
  }),
64
64
  RESERVATION_TTL_SECONDS: num({
65
- desc: 'Default lifetime of a count-based runner reservation, in seconds. Used when a provisioner does not request a provider-specific value. 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.',
66
66
  default: 60,
67
67
  }),
68
68
  RESERVATION_TTL_MAX_SECONDS: num({
69
- desc: `Server-side ceiling for a count-based runner reservation lifetime, in seconds. Set it at least as high as every provider registration deadline so provider-specific values can cover boot and enrollment. Set this between 1 and ${RESERVATION_TTL_HARD_MAX_SECONDS}.`,
70
- default: 300,
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
71
  }),
72
72
  RESERVATION_LONG_POLL_MAX_WAIT_SECONDS: num({
73
73
  desc: 'Maximum time the provisioner demand poll endpoint waits for reservable demand before returning, in seconds.',
@@ -1,6 +1,8 @@
1
+ import type {RunnerToolCapabilitiesDto} from '@shipfox/api-runners-dto';
1
2
  import {afterEach, vi} from '@shipfox/vitest/vi';
2
- import {inArray} from 'drizzle-orm';
3
+ import {eq, inArray} from 'drizzle-orm';
3
4
  import {db} from '#db/db.js';
5
+ import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
4
6
  import {reservations} from '#db/schema/reservations.js';
5
7
  import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
6
8
  import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
@@ -12,6 +14,7 @@ import {
12
14
  import {enrollRunnerControlSession} from './runner-control-sessions.js';
13
15
 
14
16
  const createdReservationIds = new Set<string>();
17
+ const createdProvisionerTokenIds = new Set<string>();
15
18
  const createdRunnerInstanceIds = new Set<string>();
16
19
 
17
20
  afterEach(async () => {
@@ -29,9 +32,14 @@ afterEach(async () => {
29
32
  }
30
33
  if (reservationIds.length > 0)
31
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]));
32
39
 
33
40
  createdRunnerInstanceIds.clear();
34
41
  createdReservationIds.clear();
42
+ createdProvisionerTokenIds.clear();
35
43
  });
36
44
 
37
45
  describe('enrollRunnerControlSession', () => {
@@ -110,6 +118,139 @@ describe('enrollRunnerControlSession', () => {
110
118
  }
111
119
  });
112
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
+
113
254
  it('keeps unexpected promotion failures on the error-log path', async () => {
114
255
  const provisionerId = crypto.randomUUID();
115
256
  const reservation = await createReservation({provisionerId});
@@ -207,18 +348,42 @@ async function createReservation(params: {
207
348
 
208
349
  async function createRunner(params: {
209
350
  provisionerId: string;
210
- intendedReservationId: string;
211
- reservationId?: string;
351
+ intendedReservationId?: string | null;
352
+ reservationId?: string | null;
353
+ workspaceId?: string | null;
212
354
  labels?: string[];
355
+ providerKind?: string | null;
356
+ protocolVersion?: string | null;
357
+ capabilities?: RunnerToolCapabilitiesDto | null;
358
+ assignedAt?: Date | null;
213
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
+
214
374
  const [runner] = await db()
215
375
  .insert(providerRunners)
216
376
  .values({
217
377
  provisionerId: params.provisionerId,
218
- intendedReservationId: params.intendedReservationId,
378
+ intendedReservationId: params.intendedReservationId ?? null,
219
379
  reservationId: params.reservationId ?? null,
380
+ workspaceId: params.workspaceId ?? null,
220
381
  providerRunnerId: crypto.randomUUID(),
221
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,
222
387
  state: 'starting',
223
388
  reportedAt: new Date(),
224
389
  })
@@ -163,7 +163,9 @@ export async function enrollRunnerControlSession(params: {
163
163
  sql`select pg_advisory_xact_lock(hashtext(${`runners_assignment:${params.provisionerId}:${current.intendedReservationId}`}))`,
164
164
  );
165
165
 
166
- const [updated] = await tx
166
+ // Provider reports may populate reservationId before the assignment commits. assignedAt is
167
+ // written by the assignment transaction, so keep the guard on that write boundary.
168
+ const [metadataUpdated] = await tx
167
169
  .update(providerRunners)
168
170
  .set({
169
171
  labels: sanitizeRunnerLabels(params.labels, {
@@ -173,6 +175,25 @@ export async function enrollRunnerControlSession(params: {
173
175
  providerKind: params.providerKind,
174
176
  protocolVersion: params.protocolVersion,
175
177
  capabilities: params.capabilities ?? null,
178
+ })
179
+ .where(
180
+ and(
181
+ eq(providerRunners.id, params.runnerInstanceId),
182
+ eq(providerRunners.provisionerId, params.provisionerId),
183
+ isNull(providerRunners.assignedAt),
184
+ notInArray(providerRunners.state, [...terminalStates]),
185
+ ),
186
+ )
187
+ .returning({id: providerRunners.id});
188
+ if (!metadataUpdated)
189
+ logger().debug(
190
+ {runnerInstanceId: params.runnerInstanceId, provisionerId: params.provisionerId},
191
+ 'Runner enrollment metadata update skipped for assigned or terminal runner',
192
+ );
193
+
194
+ const [updated] = await tx
195
+ .update(providerRunners)
196
+ .set({
176
197
  state: 'running',
177
198
  reportedAt: sql`now()`,
178
199
  updatedAt: sql`now()`,
package/src/db/index.ts CHANGED
@@ -90,6 +90,7 @@ export type {
90
90
  } from './runner-instances.js';
91
91
  export {
92
92
  attachRunnerInstanceProviderId,
93
+ countStaleEnrolledRunnerInstances,
93
94
  isTerminalState,
94
95
  listActiveRunnerInstanceCountsByTemplateTx,
95
96
  listActiveRunnerInstances,