@shipfox/api-runners 9.3.0 → 10.0.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 (66) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +38 -0
  3. package/dist/config.js +1 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/core/runner-activation.d.ts +6 -0
  6. package/dist/core/runner-activation.d.ts.map +1 -1
  7. package/dist/core/runner-activation.js +20 -19
  8. package/dist/core/runner-activation.js.map +1 -1
  9. package/dist/core/runner-control-sessions.d.ts +2 -1
  10. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  11. package/dist/core/runner-control-sessions.js +65 -19
  12. package/dist/core/runner-control-sessions.js.map +1 -1
  13. package/dist/db/db.d.ts +34 -0
  14. package/dist/db/db.d.ts.map +1 -1
  15. package/dist/db/index.d.ts +1 -1
  16. package/dist/db/index.d.ts.map +1 -1
  17. package/dist/db/index.js +1 -1
  18. package/dist/db/index.js.map +1 -1
  19. package/dist/db/job-executions.d.ts +7 -0
  20. package/dist/db/job-executions.d.ts.map +1 -1
  21. package/dist/db/job-executions.js +25 -0
  22. package/dist/db/job-executions.js.map +1 -1
  23. package/dist/db/runner-assignments.d.ts +6 -0
  24. package/dist/db/runner-assignments.d.ts.map +1 -1
  25. package/dist/db/runner-assignments.js +57 -42
  26. package/dist/db/runner-assignments.js.map +1 -1
  27. package/dist/db/runner-instances.d.ts.map +1 -1
  28. package/dist/db/runner-instances.js +64 -19
  29. package/dist/db/runner-instances.js.map +1 -1
  30. package/dist/db/schema/runner-instances.d.ts +17 -0
  31. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  32. package/dist/db/schema/runner-instances.js +3 -0
  33. package/dist/db/schema/runner-instances.js.map +1 -1
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/presentation/inter-module.d.ts.map +1 -1
  39. package/dist/presentation/inter-module.js +7 -2
  40. package/dist/presentation/inter-module.js.map +1 -1
  41. package/dist/presentation/routes/runner-enrollment.d.ts +9 -0
  42. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  43. package/dist/presentation/routes/runner-enrollment.js +36 -23
  44. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  45. package/dist/tsconfig.test.tsbuildinfo +1 -1
  46. package/drizzle/0001_intended_reservation.sql +3 -0
  47. package/drizzle/meta/0001_snapshot.json +2093 -0
  48. package/drizzle/meta/_journal.json +7 -0
  49. package/package.json +10 -10
  50. package/src/config.test.ts +11 -0
  51. package/src/config.ts +1 -1
  52. package/src/core/runner-activation.ts +48 -38
  53. package/src/core/runner-control-sessions.ts +115 -33
  54. package/src/db/index.ts +1 -0
  55. package/src/db/job-executions.ts +28 -0
  56. package/src/db/runner-assignments.test.ts +81 -0
  57. package/src/db/runner-assignments.ts +134 -86
  58. package/src/db/runner-instances.test.ts +182 -0
  59. package/src/db/runner-instances.ts +123 -28
  60. package/src/db/schema/runner-instances.ts +9 -0
  61. package/src/index.ts +1 -0
  62. package/src/presentation/inter-module.ts +4 -0
  63. package/src/presentation/routes/poll-demand.test.ts +2 -2
  64. package/src/presentation/routes/runner-enrollment.test.ts +144 -1
  65. package/src/presentation/routes/runner-enrollment.ts +40 -20
  66. package/tsconfig.build.tsbuildinfo +1 -1
@@ -8,6 +8,13 @@
8
8
  "when": 1777238442655,
9
9
  "tag": "0000_initial",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1785161220762,
16
+ "tag": "0001_intended_reservation",
17
+ "breakpoints": true
11
18
  }
12
19
  ]
13
20
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-runners",
3
3
  "license": "MIT",
4
- "version": "9.3.0",
4
+ "version": "10.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -21,24 +21,24 @@
21
21
  "@temporalio/workflow": "1.18.1",
22
22
  "drizzle-orm": "^0.45.2",
23
23
  "zod": "^4.4.3",
24
- "@shipfox/api-auth-context": "9.3.0",
25
- "@shipfox/api-auth-dto": "9.3.0",
26
- "@shipfox/api-runners-dto": "9.0.2",
27
- "@shipfox/api-workflows-dto": "9.3.0",
24
+ "@shipfox/api-auth-context": "10.0.0",
25
+ "@shipfox/api-runners-dto": "10.0.0",
28
26
  "@shipfox/config": "1.2.4",
29
- "@shipfox/inter-module": "0.2.2",
27
+ "@shipfox/api-workflows-dto": "10.0.0",
28
+ "@shipfox/api-auth-dto": "10.0.0",
30
29
  "@shipfox/node-drizzle": "0.3.4",
31
30
  "@shipfox/node-error-monitoring": "0.3.0",
32
31
  "@shipfox/node-auth-root-key": "0.2.3",
33
- "@shipfox/node-fastify": "0.3.4",
34
- "@shipfox/node-module": "1.0.3",
32
+ "@shipfox/node-module": "1.0.4",
35
33
  "@shipfox/node-opentelemetry": "0.6.3",
34
+ "@shipfox/node-fastify": "0.4.0",
35
+ "@shipfox/node-rate-limit": "0.3.2",
36
36
  "@shipfox/node-outbox": "0.2.6",
37
37
  "@shipfox/node-postgres": "0.4.4",
38
- "@shipfox/node-rate-limit": "0.3.2",
39
38
  "@shipfox/node-temporal": "0.4.4",
40
39
  "@shipfox/node-tokens": "0.3.2",
41
- "@shipfox/runner-labels": "0.1.3"
40
+ "@shipfox/runner-labels": "0.1.3",
41
+ "@shipfox/inter-module": "0.2.2"
42
42
  },
43
43
  "imports": {
44
44
  "#*": "./dist/*"
@@ -27,6 +27,17 @@ describe('EPHEMERAL_REGISTRATION_TOKEN_TTL_SECONDS validation', () => {
27
27
  expect(config.EPHEMERAL_REGISTRATION_TOKEN_TTL_SECONDS).toBe(3600);
28
28
  });
29
29
  });
30
+ describe('runner assignment polling defaults', () => {
31
+ afterEach(() => {
32
+ vi.unstubAllEnvs();
33
+ vi.resetModules();
34
+ });
35
+ it('uses a 250 millisecond interval between assignment reads', async () => {
36
+ vi.resetModules();
37
+ const {config} = await import('#config.js');
38
+ expect(config.RUNNER_ASSIGNMENT_POLL_INTERVAL_MS).toBe(250);
39
+ });
40
+ });
30
41
  describe('REGISTRATION_TOKEN_BATCH_MAX validation', () => {
31
42
  afterEach(() => {
32
43
  vi.unstubAllEnvs();
package/src/config.ts CHANGED
@@ -24,7 +24,7 @@ export const config = createConfig({
24
24
  }),
25
25
  RUNNER_ASSIGNMENT_POLL_INTERVAL_MS: num({
26
26
  desc: 'Delay between durable assignment reads while a runner-control session waits for an assignment, in milliseconds.',
27
- default: 1000,
27
+ default: 250,
28
28
  }),
29
29
  EPHEMERAL_REGISTRATION_TOKEN_TTL_SECONDS: num({
30
30
  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}.`,
@@ -1,5 +1,6 @@
1
1
  import {extractDisplayPrefix, generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
2
2
  import {and, eq, isNull, sql} from 'drizzle-orm';
3
+ import type {Tx} from '#db/db.js';
3
4
  import {db} from '#db/db.js';
4
5
  import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
5
6
  import {providerRunners} from '#db/schema/runner-instances.js';
@@ -9,46 +10,55 @@ export async function issueRunnerActivationToken(params: {
9
10
  provisionerId: string;
10
11
  ttlSeconds: number;
11
12
  }): Promise<string | null> {
12
- return await db().transaction(async (tx) => {
13
- await tx.execute(
14
- sql`select pg_advisory_xact_lock(hashtext(${`runners_activation:${params.runnerInstanceId}`}))`,
15
- );
16
- const [runner] = await tx
17
- .select({
18
- workspaceId: providerRunners.workspaceId,
19
- runnerSessionId: providerRunners.runnerSessionId,
20
- state: providerRunners.state,
21
- })
22
- .from(providerRunners)
23
- .where(
24
- and(
25
- eq(providerRunners.id, params.runnerInstanceId),
26
- eq(providerRunners.provisionerId, params.provisionerId),
27
- ),
28
- )
29
- .limit(1)
30
- .for('update');
31
- if (!runner?.workspaceId || runner.runnerSessionId || runner.state !== 'running') return null;
13
+ return await db().transaction(async (tx) => issueRunnerActivationTokenTx(tx, params));
14
+ }
15
+
16
+ export async function issueRunnerActivationTokenTx(
17
+ tx: Tx,
18
+ params: {
19
+ runnerInstanceId: string;
20
+ provisionerId: string;
21
+ ttlSeconds: number;
22
+ },
23
+ ): Promise<string | null> {
24
+ await tx.execute(
25
+ sql`select pg_advisory_xact_lock(hashtext(${`runners_activation:${params.runnerInstanceId}`}))`,
26
+ );
27
+ const [runner] = await tx
28
+ .select({
29
+ workspaceId: providerRunners.workspaceId,
30
+ runnerSessionId: providerRunners.runnerSessionId,
31
+ state: providerRunners.state,
32
+ })
33
+ .from(providerRunners)
34
+ .where(
35
+ and(
36
+ eq(providerRunners.id, params.runnerInstanceId),
37
+ eq(providerRunners.provisionerId, params.provisionerId),
38
+ ),
39
+ )
40
+ .limit(1)
41
+ .for('update');
42
+ if (!runner?.workspaceId || runner.runnerSessionId || runner.state !== 'running') return null;
32
43
 
33
- await tx
34
- .update(runnerActivationTokens)
35
- .set({revokedAt: sql`now()`})
36
- .where(
37
- and(
38
- eq(runnerActivationTokens.runnerInstanceId, params.runnerInstanceId),
39
- isNull(runnerActivationTokens.consumedAt),
40
- isNull(runnerActivationTokens.revokedAt),
41
- ),
42
- );
43
- const rawToken = generateOpaqueToken('runnerActivationToken');
44
- await tx.insert(runnerActivationTokens).values({
45
- runnerInstanceId: params.runnerInstanceId,
46
- hashedToken: hashOpaqueToken(rawToken),
47
- prefix: extractDisplayPrefix(rawToken),
48
- expiresAt: new Date(Date.now() + params.ttlSeconds * 1000),
49
- });
50
- return rawToken;
44
+ await tx
45
+ .update(runnerActivationTokens)
46
+ .set({revokedAt: sql`now()`})
47
+ .where(
48
+ and(
49
+ eq(runnerActivationTokens.runnerInstanceId, params.runnerInstanceId),
50
+ isNull(runnerActivationTokens.consumedAt),
51
+ isNull(runnerActivationTokens.revokedAt),
52
+ ),
53
+ );
54
+ const rawToken = generateOpaqueToken('runnerActivationToken');
55
+ await tx.insert(runnerActivationTokens).values({
56
+ runnerInstanceId: params.runnerInstanceId,
57
+ hashedToken: hashOpaqueToken(rawToken),
58
+ prefix: extractDisplayPrefix(rawToken),
59
+ expiresAt: new Date(Date.now() + params.ttlSeconds * 1000),
51
60
  });
61
+ return rawToken;
52
62
  }
53
63
 
54
64
  export async function getRunnerAssignment(params: {
@@ -1,12 +1,23 @@
1
1
  import type {RunnerToolCapabilitiesDto} from '@shipfox/api-runners-dto';
2
+ import type {NodePgDatabase} from '@shipfox/node-drizzle';
3
+ import {logger} from '@shipfox/node-opentelemetry';
2
4
  import {extractDisplayPrefix, generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
3
5
  import {canonicalizeLabels} from '@shipfox/runner-labels';
4
6
  import {and, eq, gt, isNull, notInArray, or, sql} from 'drizzle-orm';
5
- import {db} from '#db/db.js';
7
+ import {config} from '#config.js';
8
+ import {
9
+ ReservationExpiredError,
10
+ ReservationNotFoundError,
11
+ RunnerInstanceAlreadyAssignedError,
12
+ RunnerInstanceNotAssignableError,
13
+ } from '#core/errors.js';
14
+ import {db, type schema, type Tx} from '#db/db.js';
15
+ import {assignRunnerInstancesTx} from '#db/runner-assignments.js';
6
16
  import {terminalStates} from '#db/runner-instances.js';
7
17
  import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
8
18
  import {runnerBootstrapTokens, runnerControlSessions} from '#db/schema/runner-control-sessions.js';
9
19
  import {providerRunners} from '#db/schema/runner-instances.js';
20
+ import {issueRunnerActivationTokenTx} from './runner-activation.js';
10
21
 
11
22
  export class RunnerBootstrapTokenInvalidError extends Error {
12
23
  constructor() {
@@ -25,7 +36,10 @@ export class RunnerControlSessionInvalidError extends Error {
25
36
  export async function createRunnerInstancesWithBootstrapTokens(params: {
26
37
  provisionerId: string;
27
38
  providerKind?: string | null;
28
- runnerInstances: Array<{templateKey?: string | null}>;
39
+ runnerInstances: Array<{
40
+ templateKey?: string | null;
41
+ reservationId?: string | null;
42
+ }>;
29
43
  ttlSeconds: number;
30
44
  }): Promise<Array<{runnerInstanceId: string; bootstrapToken: string}>> {
31
45
  const expiresAt = new Date(Date.now() + params.ttlSeconds * 1000);
@@ -37,6 +51,7 @@ export async function createRunnerInstancesWithBootstrapTokens(params: {
37
51
  provisionerId: params.provisionerId,
38
52
  providerKind: params.providerKind ?? null,
39
53
  templateKey: runner.templateKey ?? null,
54
+ intendedReservationId: runner.reservationId ?? null,
40
55
  state: 'starting' as const,
41
56
  labels: [],
42
57
  reportedAt: new Date(),
@@ -123,28 +138,87 @@ export async function enrollRunnerControlSession(params: {
123
138
  capabilities?: RunnerToolCapabilitiesDto | null;
124
139
  providerKind: string;
125
140
  protocolVersion: string;
126
- }): Promise<void> {
127
- const updated = await db()
128
- .update(providerRunners)
129
- .set({
130
- labels: [...canonicalizeLabels(params.labels)],
131
- providerKind: params.providerKind,
132
- protocolVersion: params.protocolVersion,
133
- capabilities: params.capabilities ?? null,
134
- state: 'running',
135
- reportedAt: sql`now()`,
136
- updatedAt: sql`now()`,
137
- })
138
- .where(
139
- and(
140
- eq(providerRunners.id, params.runnerInstanceId),
141
- eq(providerRunners.provisionerId, params.provisionerId),
142
- notInArray(providerRunners.state, [...terminalStates]),
143
- ),
144
- )
145
- .returning({id: providerRunners.id});
146
- if (!updated[0]) throw new RunnerControlSessionInvalidError();
147
- await touchRunnerControlSession(params.runnerInstanceId, params.provisionerId);
141
+ }): Promise<string | null> {
142
+ return await db().transaction(async (tx) => {
143
+ const [current] = await tx
144
+ .select({intendedReservationId: providerRunners.intendedReservationId})
145
+ .from(providerRunners)
146
+ .where(
147
+ and(
148
+ eq(providerRunners.id, params.runnerInstanceId),
149
+ eq(providerRunners.provisionerId, params.provisionerId),
150
+ ),
151
+ );
152
+ if (!current) throw new RunnerControlSessionInvalidError();
153
+ if (current.intendedReservationId)
154
+ await tx.execute(
155
+ sql`select pg_advisory_xact_lock(hashtext(${`runners_assignment:${params.provisionerId}:${current.intendedReservationId}`}))`,
156
+ );
157
+
158
+ const [updated] = await tx
159
+ .update(providerRunners)
160
+ .set({
161
+ labels: [...canonicalizeLabels(params.labels)],
162
+ providerKind: params.providerKind,
163
+ protocolVersion: params.protocolVersion,
164
+ capabilities: params.capabilities ?? null,
165
+ state: 'running',
166
+ reportedAt: sql`now()`,
167
+ updatedAt: sql`now()`,
168
+ })
169
+ .where(
170
+ and(
171
+ eq(providerRunners.id, params.runnerInstanceId),
172
+ eq(providerRunners.provisionerId, params.provisionerId),
173
+ notInArray(providerRunners.state, [...terminalStates]),
174
+ ),
175
+ )
176
+ .returning({
177
+ id: providerRunners.id,
178
+ intendedReservationId: providerRunners.intendedReservationId,
179
+ });
180
+ if (!updated) throw new RunnerControlSessionInvalidError();
181
+
182
+ await updateRunnerControlSessionLastSeen(tx, params.runnerInstanceId, params.provisionerId);
183
+
184
+ const intendedReservationId = updated.intendedReservationId;
185
+ if (!intendedReservationId) return null;
186
+
187
+ try {
188
+ return await tx.transaction(async (promotionTx) => {
189
+ await assignRunnerInstancesTx(promotionTx, {
190
+ provisionerId: params.provisionerId,
191
+ reservationId: intendedReservationId,
192
+ runnerInstanceIds: [params.runnerInstanceId],
193
+ });
194
+ return await issueRunnerActivationTokenTx(promotionTx, {
195
+ runnerInstanceId: params.runnerInstanceId,
196
+ provisionerId: params.provisionerId,
197
+ ttlSeconds: config.RUNNER_ACTIVATION_TOKEN_TTL_SECONDS,
198
+ });
199
+ });
200
+ } catch (error) {
201
+ const expectedFailure =
202
+ error instanceof ReservationExpiredError ||
203
+ error instanceof ReservationNotFoundError ||
204
+ error instanceof RunnerInstanceAlreadyAssignedError ||
205
+ error instanceof RunnerInstanceNotAssignableError;
206
+ const details = {
207
+ err: error,
208
+ runnerInstanceId: params.runnerInstanceId,
209
+ provisionerId: params.provisionerId,
210
+ reservationId: intendedReservationId,
211
+ };
212
+ if (expectedFailure)
213
+ logger().debug(details, 'Runner reservation could not be promoted during enrollment');
214
+ else
215
+ logger().error(
216
+ details,
217
+ 'Unexpected failure promoting runner reservation during enrollment',
218
+ );
219
+ return null;
220
+ }
221
+ });
148
222
  }
149
223
 
150
224
  export async function attachRunnerControlProviderId(params: {
@@ -168,7 +242,24 @@ export async function attachRunnerControlProviderId(params: {
168
242
  }
169
243
 
170
244
  export async function touchRunnerControlSession(runnerInstanceId: string, provisionerId: string) {
245
+ await updateRunnerControlSessionLastSeen(db(), runnerInstanceId, provisionerId);
171
246
  await db()
247
+ .update(providerRunners)
248
+ .set({reportedAt: sql`now()`, updatedAt: sql`now()`})
249
+ .where(
250
+ and(
251
+ eq(providerRunners.id, runnerInstanceId),
252
+ eq(providerRunners.provisionerId, provisionerId),
253
+ ),
254
+ );
255
+ }
256
+
257
+ async function updateRunnerControlSessionLastSeen(
258
+ tx: Tx | NodePgDatabase<typeof schema>,
259
+ runnerInstanceId: string,
260
+ provisionerId: string,
261
+ ) {
262
+ await tx
172
263
  .update(runnerControlSessions)
173
264
  .set({lastSeenAt: sql`now()`})
174
265
  .where(
@@ -178,13 +269,4 @@ export async function touchRunnerControlSession(runnerInstanceId: string, provis
178
269
  isNull(runnerControlSessions.closedAt),
179
270
  ),
180
271
  );
181
- await db()
182
- .update(providerRunners)
183
- .set({reportedAt: sql`now()`, updatedAt: sql`now()`})
184
- .where(
185
- and(
186
- eq(providerRunners.id, runnerInstanceId),
187
- eq(providerRunners.provisionerId, provisionerId),
188
- ),
189
- );
190
272
  }
package/src/db/index.ts CHANGED
@@ -22,6 +22,7 @@ export {
22
22
  claimPendingJobExecution,
23
23
  enqueueJobExecution,
24
24
  expireStuckJobExecutions,
25
+ getWorkspaceJobCounts,
25
26
  isJobLeaseActive,
26
27
  listActiveRunningJobExecutions,
27
28
  listRunningJobExecutionsByRunnerInstanceTx,
@@ -47,6 +47,34 @@ export interface EnqueueJobExecutionParams {
47
47
  requiredLabels: string[];
48
48
  }
49
49
 
50
+ export async function getWorkspaceJobCounts(params: {
51
+ workspaceIds: string[];
52
+ }): Promise<Array<{workspaceId: string; queued: number; running: number}>> {
53
+ const [pendingRows, runningRows] = await Promise.all([
54
+ db()
55
+ .select({workspaceId: pendingJobExecutions.workspaceId, count: count()})
56
+ .from(pendingJobExecutions)
57
+ .where(inArray(pendingJobExecutions.workspaceId, params.workspaceIds))
58
+ .groupBy(pendingJobExecutions.workspaceId),
59
+ db()
60
+ .select({workspaceId: runningJobExecutions.workspaceId, count: count()})
61
+ .from(runningJobExecutions)
62
+ .where(inArray(runningJobExecutions.workspaceId, params.workspaceIds))
63
+ .groupBy(runningJobExecutions.workspaceId),
64
+ ]);
65
+
66
+ const queuedByWorkspace = new Map(pendingRows.map((row) => [row.workspaceId, Number(row.count)]));
67
+ const runningByWorkspace = new Map(
68
+ runningRows.map((row) => [row.workspaceId, Number(row.count)]),
69
+ );
70
+
71
+ return params.workspaceIds.map((workspaceId) => ({
72
+ workspaceId,
73
+ queued: queuedByWorkspace.get(workspaceId) ?? 0,
74
+ running: runningByWorkspace.get(workspaceId) ?? 0,
75
+ }));
76
+ }
77
+
50
78
  // Idempotent while the job execution is still pending: a duplicate jobExecutionId already in
51
79
  // `runners_pending_jobs` is a no-op. Temporal retries the enqueue activity
52
80
  // at-least-once, so a unique-violation throw on a retry-after-lost-result
@@ -61,6 +61,25 @@ describe('assignRunnerInstances', () => {
61
61
  expect(results).toEqual([[runner.id], [runner.id]]);
62
62
  });
63
63
 
64
+ it('is idempotent after an assigned reservation is deleted', async () => {
65
+ const reservation = await createReservation();
66
+ const runner = await createEnrolledRunner();
67
+ await assignRunnerInstances({
68
+ provisionerId,
69
+ reservationId: reservation.id,
70
+ runnerInstanceIds: [runner.id],
71
+ });
72
+ await db().delete(reservations).where(eq(reservations.id, reservation.id));
73
+
74
+ const assigned = await assignRunnerInstances({
75
+ provisionerId,
76
+ reservationId: reservation.id,
77
+ runnerInstanceIds: [runner.id],
78
+ });
79
+
80
+ expect(assigned).toEqual([runner.id]);
81
+ });
82
+
64
83
  it('rejects expired reservations', async () => {
65
84
  const reservation = await createReservation({expiresAt: new Date(Date.now() - 1_000)});
66
85
  const runner = await createEnrolledRunner();
@@ -120,6 +139,68 @@ describe('assignRunnerInstances', () => {
120
139
  await expect(assignment).rejects.toThrow(RunnerInstanceNotAssignableError);
121
140
  });
122
141
 
142
+ it('does not charge capacity for an already released reservation intent', async () => {
143
+ const reservation = await createReservation();
144
+ await db().insert(providerRunners).values({
145
+ provisionerId,
146
+ intendedReservationId: reservation.id,
147
+ reservationReleasedAt: new Date(),
148
+ providerRunnerId: crypto.randomUUID(),
149
+ state: 'failed',
150
+ reportedAt: new Date(),
151
+ });
152
+ const runner = await createEnrolledRunner();
153
+
154
+ await expect(
155
+ assignRunnerInstances({
156
+ provisionerId,
157
+ reservationId: reservation.id,
158
+ runnerInstanceIds: [runner.id],
159
+ }),
160
+ ).resolves.toEqual([runner.id]);
161
+ });
162
+
163
+ it('does not charge capacity for an already released reservation assignment', async () => {
164
+ const reservation = await createReservation();
165
+ await db().insert(providerRunners).values({
166
+ provisionerId,
167
+ reservationId: reservation.id,
168
+ reservationReleasedAt: new Date(),
169
+ providerRunnerId: crypto.randomUUID(),
170
+ state: 'failed',
171
+ reportedAt: new Date(),
172
+ });
173
+ const runner = await createEnrolledRunner();
174
+
175
+ await expect(
176
+ assignRunnerInstances({
177
+ provisionerId,
178
+ reservationId: reservation.id,
179
+ runnerInstanceIds: [runner.id],
180
+ }),
181
+ ).resolves.toEqual([runner.id]);
182
+ });
183
+
184
+ it('does not charge capacity for a terminal reservation intent before release bookkeeping', async () => {
185
+ const reservation = await createReservation();
186
+ await db().insert(providerRunners).values({
187
+ provisionerId,
188
+ intendedReservationId: reservation.id,
189
+ providerRunnerId: crypto.randomUUID(),
190
+ state: 'failed',
191
+ reportedAt: new Date(),
192
+ });
193
+ const runner = await createEnrolledRunner();
194
+
195
+ await expect(
196
+ assignRunnerInstances({
197
+ provisionerId,
198
+ reservationId: reservation.id,
199
+ runnerInstanceIds: [runner.id],
200
+ }),
201
+ ).resolves.toEqual([runner.id]);
202
+ });
203
+
123
204
  async function createReservation(
124
205
  overrides: Partial<{expiresAt: Date; requiredLabels: string[]}> = {},
125
206
  ) {