@shipfox/api-logs 5.0.0 → 7.1.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 (58) hide show
  1. package/.turbo/turbo-build.log +14 -14
  2. package/CHANGELOG.md +46 -0
  3. package/dist/api/object-storage.d.ts.map +1 -1
  4. package/dist/api/object-storage.js +28 -2
  5. package/dist/api/object-storage.js.map +1 -1
  6. package/dist/config.d.ts +1 -0
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +5 -10
  9. package/dist/config.js.map +1 -1
  10. package/dist/core/append-logs.d.ts +2 -6
  11. package/dist/core/append-logs.d.ts.map +1 -1
  12. package/dist/core/append-logs.js +7 -16
  13. package/dist/core/append-logs.js.map +1 -1
  14. package/dist/core/reap-stale-open-streams.d.ts.map +1 -1
  15. package/dist/core/reap-stale-open-streams.js +7 -0
  16. package/dist/core/reap-stale-open-streams.js.map +1 -1
  17. package/dist/core/retention.d.ts.map +1 -1
  18. package/dist/core/retention.js +25 -0
  19. package/dist/core/retention.js.map +1 -1
  20. package/dist/index.d.ts +5 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +63 -59
  23. package/dist/index.js.map +1 -1
  24. package/dist/presentation/routes/append-logs.d.ts +2 -1
  25. package/dist/presentation/routes/append-logs.d.ts.map +1 -1
  26. package/dist/presentation/routes/append-logs.js +61 -61
  27. package/dist/presentation/routes/append-logs.js.map +1 -1
  28. package/dist/presentation/routes/index.d.ts +2 -1
  29. package/dist/presentation/routes/index.d.ts.map +1 -1
  30. package/dist/presentation/routes/index.js +25 -23
  31. package/dist/presentation/routes/index.js.map +1 -1
  32. package/dist/temporal/activities/compact-stream.d.ts.map +1 -1
  33. package/dist/temporal/activities/compact-stream.js +17 -1
  34. package/dist/temporal/activities/compact-stream.js.map +1 -1
  35. package/dist/temporal/activities/compaction-reconcile.d.ts.map +1 -1
  36. package/dist/temporal/activities/compaction-reconcile.js +7 -0
  37. package/dist/temporal/activities/compaction-reconcile.js.map +1 -1
  38. package/dist/temporal/workflows/index.bundle.js +17164 -3306
  39. package/dist/tsconfig.test.tsbuildinfo +1 -1
  40. package/package.json +12 -30
  41. package/src/api/object-storage.ts +21 -2
  42. package/src/config.test.ts +8 -32
  43. package/src/config.ts +10 -15
  44. package/src/core/append-logs.test.ts +13 -19
  45. package/src/core/append-logs.ts +12 -20
  46. package/src/core/reap-stale-open-streams.ts +2 -0
  47. package/src/core/retention.ts +5 -0
  48. package/src/index.ts +63 -51
  49. package/src/presentation/routes/append-logs.test.ts +3 -2
  50. package/src/presentation/routes/append-logs.ts +54 -50
  51. package/src/presentation/routes/index.ts +22 -19
  52. package/src/presentation/routes/read-logs.test.ts +3 -2
  53. package/src/temporal/activities/compact-stream.ts +13 -1
  54. package/src/temporal/activities/compaction-reconcile.ts +2 -0
  55. package/test/env.ts +1 -3
  56. package/test/fixtures/lease-token.ts +2 -1
  57. package/test/fixtures/workflows-client.ts +17 -0
  58. package/tsconfig.build.tsbuildinfo +1 -1
@@ -5,62 +5,66 @@ import {
5
5
  appendLogsResponseSchema,
6
6
  offsetGapResponseSchema,
7
7
  } from '@shipfox/api-logs-dto';
8
+ import type {WorkflowsModuleClient} from '@shipfox/api-workflows-dto/inter-module';
8
9
  import {ClientError, defineRoute} from '@shipfox/node-fastify';
9
10
  import {z} from 'zod';
10
11
  import {appendLogs} from '#core/append-logs.js';
11
12
  import {LeaseStreamMismatchError, MalformedLogChunkError, OffsetGapError} from '#core/errors.js';
12
13
 
13
- export const appendLogsRoute = defineRoute({
14
- method: 'POST',
15
- path: '/steps/:stepId/logs',
16
- description: 'Append a chunk of logs for a step attempt of the leased job.',
17
- schema: {
18
- params: z.object({stepId: z.string().uuid()}),
19
- querystring: appendLogsQuerySchema,
20
- response: {
21
- 200: appendLogsResponseSchema,
22
- 409: offsetGapResponseSchema,
14
+ export function createAppendLogsRoute(workflows: WorkflowsModuleClient) {
15
+ return defineRoute({
16
+ method: 'POST',
17
+ path: '/steps/:stepId/logs',
18
+ description: 'Append a chunk of logs for a step attempt of the leased job.',
19
+ schema: {
20
+ params: z.object({stepId: z.string().uuid()}),
21
+ querystring: appendLogsQuerySchema,
22
+ response: {
23
+ 200: appendLogsResponseSchema,
24
+ 409: offsetGapResponseSchema,
25
+ },
23
26
  },
24
- },
25
- errorHandler: (error) => {
26
- if (error instanceof OffsetGapError) {
27
- throw new ClientError('Append offset is ahead of the committed length', 'offset-gap', {
28
- status: 409,
29
- details: {committed_length: error.committedLength},
30
- });
31
- }
32
- if (error instanceof MalformedLogChunkError) {
33
- throw new ClientError(error.message, 'malformed-log-chunk', {status: 400});
34
- }
35
- if (error instanceof LeaseStreamMismatchError) {
36
- throw new ClientError(error.message, 'lease-stream-mismatch', {status: 403});
37
- }
38
- throw error;
39
- },
40
- handler: async (request) => {
41
- const leasedJob = requireLeasedJobContext(request);
42
- const {stepId} = request.params;
43
- const {attempt, offset} = request.query;
27
+ errorHandler: (error) => {
28
+ if (error instanceof OffsetGapError) {
29
+ throw new ClientError('Append offset is ahead of the committed length', 'offset-gap', {
30
+ status: 409,
31
+ details: {committed_length: error.committedLength},
32
+ });
33
+ }
34
+ if (error instanceof MalformedLogChunkError) {
35
+ throw new ClientError(error.message, 'malformed-log-chunk', {status: 400});
36
+ }
37
+ if (error instanceof LeaseStreamMismatchError) {
38
+ throw new ClientError(error.message, 'lease-stream-mismatch', {status: 403});
39
+ }
40
+ throw error;
41
+ },
42
+ handler: async (request) => {
43
+ const leasedJob = requireLeasedJobContext(request);
44
+ const {stepId} = request.params;
45
+ const {attempt, offset} = request.query;
44
46
 
45
- // The scoped lease proves membership in the dispatched step attempt; active-step
46
- // completion remains governed by workflow/report state, not the log append route.
47
- if (leasedJob.currentStepId !== stepId || leasedJob.currentStepAttempt !== attempt) {
48
- throw new ClientError('Step not found for leased job execution', 'step-not-found', {
49
- status: 404,
50
- });
51
- }
47
+ if (leasedJob.currentStepId !== stepId || leasedJob.currentStepAttempt !== attempt) {
48
+ throw new ClientError('Step not found for leased job execution', 'step-not-found', {
49
+ status: 404,
50
+ });
51
+ }
52
52
 
53
- const result = await appendLogs({
54
- jobId: leasedJob.jobId,
55
- workspaceId: leasedJob.workspaceId,
56
- projectId: leasedJob.projectId,
57
- workflowRunAttemptId: leasedJob.workflowRunAttemptId,
58
- stepId,
59
- attempt,
60
- offset,
61
- body: (request.body as Buffer | undefined) ?? Buffer.alloc(0),
62
- });
53
+ const result = await appendLogs(
54
+ {
55
+ jobId: leasedJob.jobId,
56
+ workspaceId: leasedJob.workspaceId,
57
+ projectId: leasedJob.projectId,
58
+ workflowRunAttemptId: leasedJob.workflowRunAttemptId,
59
+ stepId,
60
+ attempt,
61
+ offset,
62
+ body: (request.body as Buffer | undefined) ?? Buffer.alloc(0),
63
+ },
64
+ workflows,
65
+ );
63
66
 
64
- return {committed_length: result.committedLength, capped: result.capped};
65
- },
66
- });
67
+ return {committed_length: result.committedLength, capped: result.capped};
68
+ },
69
+ });
70
+ }
@@ -1,27 +1,30 @@
1
1
  import {AUTH_LEASED_JOB, AUTH_USER} from '@shipfox/api-auth-context';
2
+ import type {WorkflowsModuleClient} from '@shipfox/api-workflows-dto/inter-module';
2
3
  import {createRawBodyPlugin, type RouteGroup} from '@shipfox/node-fastify';
3
4
  import {config} from '#config.js';
4
- import {appendLogsRoute} from './append-logs.js';
5
+ import {createAppendLogsRoute} from './append-logs.js';
5
6
  import {readLogsRoute} from './read-logs.js';
6
7
 
7
8
  // Keep the lease-authed append in its own Fastify scope so the raw NDJSON parser does not
8
9
  // disturb the JSON read route (or workflow routes). The body limit also bounds one-append
9
10
  // budget overshoot. The read route is session-authed and workspace-scoped via the row.
10
- export const logsRoutes: RouteGroup[] = [
11
- {
12
- prefix: '/runs/jobs/current',
13
- auth: AUTH_LEASED_JOB,
14
- plugins: [
15
- createRawBodyPlugin({
16
- contentType: 'application/x-ndjson',
17
- bodyLimit: config.LOG_APPEND_BODY_LIMIT_BYTES,
18
- }),
19
- ],
20
- routes: [appendLogsRoute],
21
- },
22
- {
23
- prefix: '/steps',
24
- auth: AUTH_USER,
25
- routes: [readLogsRoute],
26
- },
27
- ];
11
+ export function createLogsRoutes(workflows: WorkflowsModuleClient): RouteGroup[] {
12
+ return [
13
+ {
14
+ prefix: '/runs/jobs/current',
15
+ auth: AUTH_LEASED_JOB,
16
+ plugins: [
17
+ createRawBodyPlugin({
18
+ contentType: 'application/x-ndjson',
19
+ bodyLimit: config.LOG_APPEND_BODY_LIMIT_BYTES,
20
+ }),
21
+ ],
22
+ routes: [createAppendLogsRoute(workflows)],
23
+ },
24
+ {
25
+ prefix: '/steps',
26
+ auth: AUTH_USER,
27
+ routes: [readLogsRoute],
28
+ },
29
+ ];
30
+ }
@@ -35,9 +35,10 @@ import {
35
35
  compactStreamActivity,
36
36
  } from '#temporal/activities/compact-stream.js';
37
37
  import {ndjsonBody, outputLine, recordLine} from '#test/fixtures/ndjson.js';
38
+ import {createTestWorkflowsClient} from '#test/fixtures/workflows-client.js';
38
39
  import {findStream} from '#test/queries.js';
39
40
  import {onStepAttemptTerminated} from '../subscribers/on-step-attempt-terminated.js';
40
- import {logsRoutes} from './index.js';
41
+ import {createLogsRoutes} from './index.js';
41
42
 
42
43
  // AUTH_USER stub: a `Bearer user` request is a member of whatever workspace it names in the
43
44
  // `x-test-workspace` header, so each test grants or withholds access against the arranged
@@ -159,7 +160,7 @@ describe('GET /steps/:stepId/attempts/:attempt/logs', () => {
159
160
  beforeAll(async () => {
160
161
  app = await createApp({
161
162
  auth: [fakeUserAuth, stubLeaseAuth],
162
- routes: logsRoutes,
163
+ routes: createLogsRoutes(createTestWorkflowsClient()),
163
164
  swagger: false,
164
165
  });
165
166
  await app.ready();
@@ -1,3 +1,5 @@
1
+ import {reportError} from '@shipfox/node-error-monitoring';
2
+ import {logger} from '@shipfox/node-opentelemetry';
1
3
  import {Context} from '@temporalio/activity';
2
4
  import {compactedObjectKey, deleteObject, putCompactedObject} from '#api/object-storage.js';
3
5
  import {compactedGzipStream} from '#core/compaction.js';
@@ -79,7 +81,17 @@ async function compactStream(
79
81
  stats.lastSeq !== expected.maxSeq ||
80
82
  stats.uncompressedBytes !== expected.uncompressedBytes
81
83
  ) {
82
- await deleteObject(uploadKey).catch(() => undefined);
84
+ await deleteObject(uploadKey).catch((error) => {
85
+ logger().error(
86
+ {err: error, streamId: stream.id, objectKey: uploadKey},
87
+ 'Failed to delete invalid compacted log object',
88
+ );
89
+ reportError(error, {
90
+ boundary: 'logs.cleanup',
91
+ operation: 'delete-invalid-compaction-object',
92
+ extra: {streamId: stream.id, objectKey: uploadKey},
93
+ });
94
+ });
83
95
  throw new Error(
84
96
  `Compaction integrity check failed for stream ${stream.id}: streamed ${stats.chunkCount} chunks / ${stats.uncompressedBytes} bytes up to seq ${stats.lastSeq}, table holds ${expected.count} / ${expected.uncompressedBytes} bytes up to seq ${expected.maxSeq}`,
85
97
  );
@@ -1,3 +1,4 @@
1
+ import {reportError} from '@shipfox/node-error-monitoring';
1
2
  import {logger} from '@shipfox/node-opentelemetry';
2
3
  import {temporalClient} from '@shipfox/node-temporal';
3
4
  import {config} from '#config.js';
@@ -41,6 +42,7 @@ export async function compactionReconcileActivity(): Promise<{restarted: number;
41
42
  {err: error, streamId: stream.id},
42
43
  'Failed to re-drive stale stream compaction',
43
44
  );
45
+ reportError(error, {boundary: 'logs.maintenance', extra: {streamId: stream.id}});
44
46
  }
45
47
  }
46
48
 
package/test/env.ts CHANGED
@@ -4,9 +4,7 @@ process.env.POSTGRES_USERNAME ??= 'shipfox';
4
4
  process.env.POSTGRES_PASSWORD ??= 'password';
5
5
  process.env.POSTGRES_DATABASE = 'api_test';
6
6
  process.env.POSTGRES_MAX_CONNECTIONS ??= '5';
7
- process.env.AUTH_JWT_SECRET = 'test-secret';
8
- process.env.AUTH_JOB_LEASE_TOKEN_SECRET = 'test-lease-secret';
9
- process.env.AUTH_RUNNER_SESSION_TOKEN_SECRET = 'test-runner-session-secret';
7
+ process.env.AUTH_ROOT_KEY = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
10
8
  process.env.SECRETS_ENCRYPTION_KEK = 'ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=';
11
9
 
12
10
  // Small accrual budget so cap/budget tests use tiny payloads: base 100 bytes,
@@ -1,8 +1,9 @@
1
1
  import {JOB_LEASE_TOKEN_AUDIENCE} from '@shipfox/api-auth-dto';
2
+ import {jobLeaseTokenKey} from '@shipfox/node-auth-root-key';
2
3
  import {signHs256} from '@shipfox/node-jwt';
3
4
 
4
5
  // Matches test/env.ts; the lease-token auth method reads this same value from config.
5
- const SECRET = process.env.AUTH_JOB_LEASE_TOKEN_SECRET ?? 'test-lease-secret';
6
+ const SECRET = jobLeaseTokenKey();
6
7
 
7
8
  export interface MintLeaseTokenParams {
8
9
  jobId: string;
@@ -0,0 +1,17 @@
1
+ import {
2
+ type WorkflowsModuleClient,
3
+ workflowsInterModuleContract,
4
+ } from '@shipfox/api-workflows-dto/inter-module';
5
+ import {defineInterModulePresentation} from '@shipfox/inter-module';
6
+ import {createFakeInterModuleClients} from '@shipfox/node-module/inter-module/testing';
7
+
8
+ export function createTestWorkflowsClient(): WorkflowsModuleClient {
9
+ return createFakeInterModuleClients({
10
+ workflows: defineInterModulePresentation(workflowsInterModuleContract, {
11
+ startRunFromTrigger: vi.fn(),
12
+ deliverEventToJobListener: vi.fn(),
13
+ getStepLogContext: () => ({harness: 'pi' as const}),
14
+ getLeasedAgentToolContext: vi.fn(),
15
+ }),
16
+ }).workflows;
17
+ }