@shipfox/api-runners 10.2.0 → 12.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 (28) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +43 -0
  3. package/dist/db/job-executions.d.ts +10 -5
  4. package/dist/db/job-executions.d.ts.map +1 -1
  5. package/dist/db/job-executions.js +59 -23
  6. package/dist/db/job-executions.js.map +1 -1
  7. package/dist/db/rate-limits.d.ts +6 -15
  8. package/dist/db/rate-limits.d.ts.map +1 -1
  9. package/dist/db/rate-limits.js +17 -18
  10. package/dist/db/rate-limits.js.map +1 -1
  11. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  12. package/dist/presentation/routes/rate-limit.js +33 -54
  13. package/dist/presentation/routes/rate-limit.js.map +1 -1
  14. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js +3 -3
  15. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js.map +1 -1
  16. package/dist/tsconfig.test.tsbuildinfo +1 -1
  17. package/package.json +13 -13
  18. package/src/db/job-executions.test.ts +270 -22
  19. package/src/db/job-executions.ts +101 -32
  20. package/src/db/rate-limits.ts +30 -42
  21. package/src/presentation/routes/heartbeat.test.ts +3 -3
  22. package/src/presentation/routes/list-active-runners.test.ts +8 -2
  23. package/src/presentation/routes/manual-registration-tokens.test.ts +8 -2
  24. package/src/presentation/routes/provisioner-tokens.test.ts +8 -2
  25. package/src/presentation/routes/rate-limit.ts +33 -64
  26. package/src/presentation/subscribers/on-workflows-job-execution-timed-out.test.ts +16 -0
  27. package/src/presentation/subscribers/on-workflows-job-execution-timed-out.ts +3 -3
  28. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,28 +1,25 @@
1
+ import {
2
+ type ConsumeRateLimitParams,
3
+ type ConsumeRateLimitResult,
4
+ createRateLimitPersistence,
5
+ } from '@shipfox/node-rate-limit';
1
6
  import {lt, sql} from 'drizzle-orm';
2
7
  import {db} from './db.js';
3
8
  import {runnersRateLimits} from './schema/rate-limits.js';
4
9
 
5
- export interface ConsumeRunnersRateLimitParams {
6
- action: string;
7
- scope: string;
8
- identifierHmac: string;
9
- windowStart: Date;
10
- expiresAt: Date;
11
- timeoutMs: number;
12
- }
13
-
14
- export interface ConsumeRunnersRateLimitResult {
15
- count: number;
16
- expiresAt: Date;
17
- }
18
-
19
- export async function consumeRunnersRateLimit(
20
- params: ConsumeRunnersRateLimitParams,
21
- ): Promise<ConsumeRunnersRateLimitResult> {
22
- return await db().transaction(async (tx) => {
23
- await tx.execute(sql`select set_config('statement_timeout', ${`${params.timeoutMs}ms`}, true)`);
24
-
25
- const rows = await tx
10
+ type RunnersRateLimitTransaction = Parameters<
11
+ Parameters<ReturnType<typeof db>['transaction']>[0]
12
+ >[0];
13
+
14
+ const persistence = createRateLimitPersistence<RunnersRateLimitTransaction>({
15
+ transaction: (callback) => db().transaction(callback),
16
+ setStatementTimeout: async (transaction, timeoutMs) => {
17
+ await transaction.execute(
18
+ sql`select set_config('statement_timeout', ${`${timeoutMs}ms`}, true)`,
19
+ );
20
+ },
21
+ consume: async (transaction, params) => {
22
+ const rows = await transaction
26
23
  .insert(runnersRateLimits)
27
24
  .values({
28
25
  action: params.action,
@@ -46,24 +43,15 @@ export async function consumeRunnersRateLimit(
46
43
  })
47
44
  .returning({count: runnersRateLimits.count, expiresAt: runnersRateLimits.expiresAt});
48
45
 
49
- const row = rows[0];
50
- if (!row) throw new Error('Rate limit upsert returned no rows');
51
- return row;
52
- });
53
- }
54
-
55
- let nextPruneAt = 0;
56
-
57
- export async function pruneExpiredRunnersRateLimits(
58
- params: {now?: Date | undefined; minIntervalMs?: number | undefined} = {},
59
- ): Promise<number | undefined> {
60
- const now = params.now ?? new Date();
61
- const minIntervalMs = params.minIntervalMs ?? 60_000;
62
- if (minIntervalMs > 0 && now.getTime() < nextPruneAt) return undefined;
63
-
64
- nextPruneAt = now.getTime() + minIntervalMs;
65
-
66
- const result = await db().delete(runnersRateLimits).where(lt(runnersRateLimits.expiresAt, now));
67
-
68
- return result.rowCount ?? 0;
69
- }
46
+ return rows[0];
47
+ },
48
+ prune: async (now) => {
49
+ const result = await db().delete(runnersRateLimits).where(lt(runnersRateLimits.expiresAt, now));
50
+ return result.rowCount ?? 0;
51
+ },
52
+ });
53
+
54
+ export type ConsumeRunnersRateLimitParams = ConsumeRateLimitParams<string, string>;
55
+ export type ConsumeRunnersRateLimitResult = ConsumeRateLimitResult;
56
+ export const consumeRunnersRateLimit = persistence.consume;
57
+ export const pruneExpiredRunnersRateLimits = persistence.prune;
@@ -6,7 +6,7 @@ import {eq} from 'drizzle-orm';
6
6
  import type {FastifyInstance} from 'fastify';
7
7
  import {claimJobExecution} from '#core/job-executions.js';
8
8
  import {db} from '#db/db.js';
9
- import {requestJobExecutionCancellation} from '#db/job-executions.js';
9
+ import {reconcileTerminalJobExecution} from '#db/job-executions.js';
10
10
  import {runnerSessions} from '#db/schema/runner-sessions.js';
11
11
  import {runningJobExecutions} from '#db/schema/running-job-executions.js';
12
12
  import {createRunnerRegistrationTokenAuthMethod} from '#presentation/auth/index.js';
@@ -223,10 +223,10 @@ describe('POST /runners/jobs/:jobId/heartbeat', () => {
223
223
  expect(session?.toolCapabilitiesReportedAt).toEqual(new Date('2026-01-01T00:00:00.000Z'));
224
224
  });
225
225
 
226
- it('returns 200 + cancel:true after requestJobExecutionCancellation', async () => {
226
+ it('returns 200 + cancel:true after reconcileTerminalJobExecution', async () => {
227
227
  const {jobId, jobExecutionId, workflowRunId, workflowRunAttemptId, leaseToken} =
228
228
  await claimAvailableJob();
229
- await requestJobExecutionCancellation({jobExecutionId});
229
+ await reconcileTerminalJobExecution({jobExecutionId});
230
230
 
231
231
  const res = await app.inject({
232
232
  method: 'POST',
@@ -50,7 +50,7 @@ describe('GET /workspaces/:workspaceId/runners/active', () => {
50
50
  beforeEach(async () => {
51
51
  await closeApp();
52
52
  workspaceId = crypto.randomUUID();
53
- authenticatedMemberships = [{workspaceId, role: 'admin'}];
53
+ authenticatedMemberships = [{workspaceId, role: 'admin', workspaceStatus: 'active'}];
54
54
  app = await createApp({
55
55
  auth: [
56
56
  fakeUserAuth,
@@ -353,7 +353,13 @@ describe('GET /workspaces/:workspaceId/runners/active', () => {
353
353
  });
354
354
 
355
355
  it('returns 403 when the user is not a workspace member', async () => {
356
- authenticatedMemberships = [{workspaceId: crypto.randomUUID(), role: 'admin'}];
356
+ authenticatedMemberships = [
357
+ {
358
+ workspaceId: crypto.randomUUID(),
359
+ role: 'admin',
360
+ workspaceStatus: 'active',
361
+ },
362
+ ];
357
363
 
358
364
  const res = await app.inject({
359
365
  method: 'GET',
@@ -58,7 +58,7 @@ describe('manual registration token routes', () => {
58
58
  beforeEach(async () => {
59
59
  await closeApp();
60
60
  workspaceId = crypto.randomUUID();
61
- authenticatedMemberships = [{workspaceId, role: 'admin'}];
61
+ authenticatedMemberships = [{workspaceId, role: 'admin', workspaceStatus: 'active'}];
62
62
  app = await createApp({
63
63
  auth: [
64
64
  fakeUserAuth,
@@ -109,7 +109,13 @@ describe('manual registration token routes', () => {
109
109
  });
110
110
 
111
111
  it('returns 403 when the user is not a workspace member', async () => {
112
- authenticatedMemberships = [{workspaceId: crypto.randomUUID(), role: 'admin'}];
112
+ authenticatedMemberships = [
113
+ {
114
+ workspaceId: crypto.randomUUID(),
115
+ role: 'admin',
116
+ workspaceStatus: 'active',
117
+ },
118
+ ];
113
119
 
114
120
  const res = await app.inject({
115
121
  method: 'GET',
@@ -45,7 +45,7 @@ describe('provisioner token routes', () => {
45
45
  beforeEach(async () => {
46
46
  await closeApp();
47
47
  workspaceId = crypto.randomUUID();
48
- authenticatedMemberships = [{workspaceId, role: 'admin'}];
48
+ authenticatedMemberships = [{workspaceId, role: 'admin', workspaceStatus: 'active'}];
49
49
  app = await createApp({
50
50
  auth: [fakeUserAuth, createProvisionerTokenAuthMethod()],
51
51
  routes: provisionerRoutes,
@@ -74,7 +74,13 @@ describe('provisioner token routes', () => {
74
74
  });
75
75
 
76
76
  it('returns 403 when the user is not a workspace member', async () => {
77
- authenticatedMemberships = [{workspaceId: crypto.randomUUID(), role: 'admin'}];
77
+ authenticatedMemberships = [
78
+ {
79
+ workspaceId: crypto.randomUUID(),
80
+ role: 'admin',
81
+ workspaceStatus: 'active',
82
+ },
83
+ ];
78
84
 
79
85
  const res = await app.inject({
80
86
  method: 'GET',
@@ -1,11 +1,8 @@
1
1
  import {requireProvisionerContext} from '@shipfox/api-auth-context';
2
2
  import {ClientError, type FastifyReply, type FastifyRequest} from '@shipfox/node-fastify';
3
+ import {enforceRateLimit as enforceSharedRateLimit} from '@shipfox/node-rate-limit';
3
4
  import {config} from '#config.js';
4
- import {
5
- checkRunnersRateLimit,
6
- RunnersRateLimitExceededError,
7
- RunnersRateLimitUnavailableError,
8
- } from '#core/rate-limit.js';
5
+ import {checkRunnersRateLimit} from '#core/rate-limit.js';
9
6
  import {getRunnerContext} from '#presentation/auth/index.js';
10
7
 
11
8
  function routeName(request: FastifyRequest): string {
@@ -21,65 +18,37 @@ async function enforceRateLimit(params: {
21
18
  limit: number;
22
19
  windowSeconds: number;
23
20
  }): Promise<void> {
24
- try {
25
- await checkRunnersRateLimit({
26
- action: params.action,
27
- scope: params.scope,
28
- identifier: params.identifier,
29
- limit: params.limit,
30
- windowSeconds: params.windowSeconds,
31
- });
32
- } catch (error) {
33
- if (error instanceof RunnersRateLimitExceededError) {
34
- params.request.log.warn(
35
- {
36
- action: error.action,
37
- scope: error.scope,
38
- route: routeName(params.request),
39
- retryAfterSeconds: error.retryAfterSeconds,
40
- identifierHmacPrefix: error.identifierHmacPrefix,
41
- },
42
- 'Runners rate limit blocked request',
43
- );
44
- params.reply.header('Retry-After', String(error.retryAfterSeconds));
45
- throw new ClientError('Rate limit exceeded', 'rate-limited', {
46
- status: 429,
47
- details: {retry_after_seconds: error.retryAfterSeconds},
48
- data: {
49
- action: error.action,
50
- scope: error.scope,
51
- route: routeName(params.request),
52
- identifierHmacPrefix: error.identifierHmacPrefix,
53
- },
54
- cause: error,
55
- });
56
- }
57
-
58
- if (error instanceof RunnersRateLimitUnavailableError) {
59
- params.request.log.error(
60
- {
61
- action: error.action,
62
- scope: error.scope,
63
- route: routeName(params.request),
64
- identifierHmacPrefix: error.identifierHmacPrefix,
65
- err: error,
66
- },
67
- 'Runners rate limiter unavailable',
68
- );
69
- throw new ClientError('Runners rate limiter unavailable', 'runners-rate-limit-unavailable', {
70
- status: 503,
71
- data: {
72
- action: error.action,
73
- scope: error.scope,
74
- route: routeName(params.request),
75
- identifierHmacPrefix: error.identifierHmacPrefix,
76
- },
77
- cause: error,
78
- });
79
- }
80
-
81
- throw error;
82
- }
21
+ await enforceSharedRateLimit({
22
+ request: params.request,
23
+ reply: params.reply,
24
+ check: () =>
25
+ checkRunnersRateLimit({
26
+ action: params.action,
27
+ scope: params.scope,
28
+ identifier: params.identifier,
29
+ limit: params.limit,
30
+ windowSeconds: params.windowSeconds,
31
+ }),
32
+ route: routeName(params.request),
33
+ unavailableCode: 'runners-rate-limit-unavailable',
34
+ unavailableMessage: 'Runners rate limiter unavailable',
35
+ setRetryAfter: (reply, retryAfterSeconds) => {
36
+ reply.header('Retry-After', String(retryAfterSeconds));
37
+ },
38
+ logWarn: (request, context) => {
39
+ request.log.warn(context, 'Runners rate limit blocked request');
40
+ },
41
+ logError: (request, context) => {
42
+ request.log.error(context, 'Runners rate limiter unavailable');
43
+ },
44
+ createClientError: (presentation, cause) =>
45
+ new ClientError(presentation.message, presentation.code, {
46
+ status: presentation.status,
47
+ ...(presentation.details ? {details: presentation.details} : {}),
48
+ data: presentation.data,
49
+ cause,
50
+ }),
51
+ });
83
52
  }
84
53
 
85
54
  export function createProvisionerMintRateLimitPreHandler() {
@@ -2,6 +2,7 @@ import type {WorkflowsJobExecutionTimedOutEventDto} from '@shipfox/api-workflows
2
2
  import {eq} from 'drizzle-orm';
3
3
  import {db} from '#db/db.js';
4
4
  import {claimPendingJobExecution} from '#db/job-executions.js';
5
+ import {pendingJobExecutions} from '#db/schema/pending-job-executions.js';
5
6
  import {runningJobExecutions} from '#db/schema/running-job-executions.js';
6
7
  import {pendingJobFactory, runnerSessionFactory} from '#test/index.js';
7
8
  import {onWorkflowsJobExecutionTimedOut} from './on-workflows-job-execution-timed-out.js';
@@ -24,6 +25,21 @@ describe('onWorkflowsJobExecutionTimedOut', () => {
24
25
  runnerSessionId = runnerSession.id;
25
26
  });
26
27
 
28
+ it('deletes an unclaimed pending execution', async () => {
29
+ const pending = await pendingJobFactory.create({workspaceId});
30
+
31
+ await onWorkflowsJobExecutionTimedOut(
32
+ buildPayload(pending.jobId, pending.jobExecutionId, pending.workflowRunAttemptId),
33
+ );
34
+
35
+ expect(
36
+ await db()
37
+ .select()
38
+ .from(pendingJobExecutions)
39
+ .where(eq(pendingJobExecutions.jobExecutionId, pending.jobExecutionId)),
40
+ ).toHaveLength(0);
41
+ });
42
+
27
43
  it('sets cancellation_requested_at on the matching running_jobs row', async () => {
28
44
  await pendingJobFactory.create({workspaceId});
29
45
  const claimed = await claimPendingJobExecution({
@@ -1,6 +1,6 @@
1
1
  import type {WorkflowsJobExecutionTimedOutEventDto} from '@shipfox/api-workflows-dto';
2
2
  import {logger} from '@shipfox/node-opentelemetry';
3
- import {requestJobExecutionCancellation} from '#db/job-executions.js';
3
+ import {reconcileTerminalJobExecution} from '#db/job-executions.js';
4
4
 
5
5
  export async function onWorkflowsJobExecutionTimedOut(
6
6
  payload: WorkflowsJobExecutionTimedOutEventDto,
@@ -11,7 +11,7 @@ export async function onWorkflowsJobExecutionTimedOut(
11
11
  jobExecutionId: payload.jobExecutionId,
12
12
  workflowRunAttemptId: payload.workflowRunAttemptId,
13
13
  },
14
- 'Requesting runner cancellation for timed-out job execution',
14
+ 'Reconciling runner state for timed-out job execution',
15
15
  );
16
- await requestJobExecutionCancellation({jobExecutionId: payload.jobExecutionId});
16
+ await reconcileTerminalJobExecution({jobExecutionId: payload.jobExecutionId});
17
17
  }