@shipfox/api-logs 2.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.
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +179 -0
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/api/object-storage.d.ts +69 -0
- package/dist/api/object-storage.d.ts.map +1 -0
- package/dist/api/object-storage.js +199 -0
- package/dist/api/object-storage.js.map +1 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +108 -0
- package/dist/config.js.map +1 -0
- package/dist/core/append-logs.d.ts +28 -0
- package/dist/core/append-logs.d.ts.map +1 -0
- package/dist/core/append-logs.js +318 -0
- package/dist/core/append-logs.js.map +1 -0
- package/dist/core/budget.d.ts +12 -0
- package/dist/core/budget.d.ts.map +1 -0
- package/dist/core/budget.js +9 -0
- package/dist/core/budget.js.map +1 -0
- package/dist/core/close-stream.d.ts +27 -0
- package/dist/core/close-stream.d.ts.map +1 -0
- package/dist/core/close-stream.js +59 -0
- package/dist/core/close-stream.js.map +1 -0
- package/dist/core/compaction.d.ts +28 -0
- package/dist/core/compaction.d.ts.map +1 -0
- package/dist/core/compaction.js +49 -0
- package/dist/core/compaction.js.map +1 -0
- package/dist/core/entities/attempt-stream.d.ts +37 -0
- package/dist/core/entities/attempt-stream.d.ts.map +1 -0
- package/dist/core/entities/attempt-stream.js +15 -0
- package/dist/core/entities/attempt-stream.js.map +1 -0
- package/dist/core/entities/job-accounting.d.ts +12 -0
- package/dist/core/entities/job-accounting.d.ts.map +1 -0
- package/dist/core/entities/job-accounting.js +6 -0
- package/dist/core/entities/job-accounting.js.map +1 -0
- package/dist/core/entities/log-object.d.ts +13 -0
- package/dist/core/entities/log-object.d.ts.map +1 -0
- package/dist/core/entities/log-object.js +9 -0
- package/dist/core/entities/log-object.js.map +1 -0
- package/dist/core/errors.d.ts +26 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +31 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/finalize-attempt-stream.d.ts +22 -0
- package/dist/core/finalize-attempt-stream.d.ts.map +1 -0
- package/dist/core/finalize-attempt-stream.js +48 -0
- package/dist/core/finalize-attempt-stream.js.map +1 -0
- package/dist/core/read-logs.d.ts +33 -0
- package/dist/core/read-logs.d.ts.map +1 -0
- package/dist/core/read-logs.js +49 -0
- package/dist/core/read-logs.js.map +1 -0
- package/dist/core/reap-stale-open-streams.d.ts +27 -0
- package/dist/core/reap-stale-open-streams.d.ts.map +1 -0
- package/dist/core/reap-stale-open-streams.js +50 -0
- package/dist/core/reap-stale-open-streams.js.map +1 -0
- package/dist/core/retention.d.ts +32 -0
- package/dist/core/retention.d.ts.map +1 -0
- package/dist/core/retention.js +148 -0
- package/dist/core/retention.js.map +1 -0
- package/dist/core/session/claude/rows.d.ts +6 -0
- package/dist/core/session/claude/rows.d.ts.map +1 -0
- package/dist/core/session/claude/rows.js +145 -0
- package/dist/core/session/claude/rows.js.map +1 -0
- package/dist/core/session/claude-parser.d.ts +4 -0
- package/dist/core/session/claude-parser.d.ts.map +1 -0
- package/dist/core/session/claude-parser.js +42 -0
- package/dist/core/session/claude-parser.js.map +1 -0
- package/dist/core/session/entry-schema.d.ts +51 -0
- package/dist/core/session/entry-schema.d.ts.map +1 -0
- package/dist/core/session/entry-schema.js +61 -0
- package/dist/core/session/entry-schema.js.map +1 -0
- package/dist/core/session/object.d.ts +12 -0
- package/dist/core/session/object.d.ts.map +1 -0
- package/dist/core/session/object.js +50 -0
- package/dist/core/session/object.js.map +1 -0
- package/dist/core/session/parse-session.d.ts +5 -0
- package/dist/core/session/parse-session.d.ts.map +1 -0
- package/dist/core/session/parse-session.js +18 -0
- package/dist/core/session/parse-session.js.map +1 -0
- package/dist/core/session/pi/message-rows.d.ts +4 -0
- package/dist/core/session/pi/message-rows.d.ts.map +1 -0
- package/dist/core/session/pi/message-rows.js +113 -0
- package/dist/core/session/pi/message-rows.js.map +1 -0
- package/dist/core/session/pi/metadata.d.ts +18 -0
- package/dist/core/session/pi/metadata.d.ts.map +1 -0
- package/dist/core/session/pi/metadata.js +120 -0
- package/dist/core/session/pi/metadata.js.map +1 -0
- package/dist/core/session/pi/text.d.ts +12 -0
- package/dist/core/session/pi/text.d.ts.map +1 -0
- package/dist/core/session/pi/text.js +59 -0
- package/dist/core/session/pi/text.js.map +1 -0
- package/dist/core/session/pi-parser.d.ts +5 -0
- package/dist/core/session/pi-parser.d.ts.map +1 -0
- package/dist/core/session/pi-parser.js +73 -0
- package/dist/core/session/pi-parser.js.map +1 -0
- package/dist/core/session/rows.d.ts +7 -0
- package/dist/core/session/rows.d.ts.map +1 -0
- package/dist/core/session/rows.js +39 -0
- package/dist/core/session/rows.js.map +1 -0
- package/dist/core/session/session-record.d.ts +5 -0
- package/dist/core/session/session-record.d.ts.map +1 -0
- package/dist/core/session/session-record.js +3 -0
- package/dist/core/session/session-record.js.map +1 -0
- package/dist/db/accounting.d.ts +33 -0
- package/dist/db/accounting.d.ts.map +1 -0
- package/dist/db/accounting.js +52 -0
- package/dist/db/accounting.js.map +1 -0
- package/dist/db/chunks.d.ts +62 -0
- package/dist/db/chunks.d.ts.map +1 -0
- package/dist/db/chunks.js +88 -0
- package/dist/db/chunks.js.map +1 -0
- package/dist/db/db.d.ts +752 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +24 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/index.d.ts +2 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +5 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/schema/attempt-streams.d.ts +304 -0
- package/dist/db/schema/attempt-streams.d.ts.map +1 -0
- package/dist/db/schema/attempt-streams.js +100 -0
- package/dist/db/schema/attempt-streams.js.map +1 -0
- package/dist/db/schema/chunks.d.ts +166 -0
- package/dist/db/schema/chunks.d.ts.map +1 -0
- package/dist/db/schema/chunks.js +47 -0
- package/dist/db/schema/chunks.js.map +1 -0
- package/dist/db/schema/common.d.ts +29 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +11 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/job-accounting.d.ts +143 -0
- package/dist/db/schema/job-accounting.d.ts.map +1 -0
- package/dist/db/schema/job-accounting.js +43 -0
- package/dist/db/schema/job-accounting.js.map +1 -0
- package/dist/db/schema/outbox.d.ts +195 -0
- package/dist/db/schema/outbox.d.ts.map +1 -0
- package/dist/db/schema/outbox.js +5 -0
- package/dist/db/schema/outbox.js.map +1 -0
- package/dist/db/streams.d.ts +141 -0
- package/dist/db/streams.d.ts.map +1 -0
- package/dist/db/streams.js +197 -0
- package/dist/db/streams.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +81 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/index.d.ts +2 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +3 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/instance.d.ts +14 -0
- package/dist/metrics/instance.d.ts.map +1 -0
- package/dist/metrics/instance.js +16 -0
- package/dist/metrics/instance.js.map +1 -0
- package/dist/metrics/service.d.ts +2 -0
- package/dist/metrics/service.d.ts.map +1 -0
- package/dist/metrics/service.js +20 -0
- package/dist/metrics/service.js.map +1 -0
- package/dist/presentation/dto/read-logs.d.ts +4 -0
- package/dist/presentation/dto/read-logs.d.ts.map +1 -0
- package/dist/presentation/dto/read-logs.js +22 -0
- package/dist/presentation/dto/read-logs.js.map +1 -0
- package/dist/presentation/routes/append-logs.d.ts +2 -0
- package/dist/presentation/routes/append-logs.d.ts.map +1 -0
- package/dist/presentation/routes/append-logs.js +71 -0
- package/dist/presentation/routes/append-logs.js.map +1 -0
- package/dist/presentation/routes/index.d.ts +3 -0
- package/dist/presentation/routes/index.d.ts.map +1 -0
- package/dist/presentation/routes/index.js +32 -0
- package/dist/presentation/routes/index.js.map +1 -0
- package/dist/presentation/routes/read-logs.d.ts +2 -0
- package/dist/presentation/routes/read-logs.d.ts.map +1 -0
- package/dist/presentation/routes/read-logs.js +41 -0
- package/dist/presentation/routes/read-logs.js.map +1 -0
- package/dist/presentation/subscribers/on-job-terminated.d.ts +8 -0
- package/dist/presentation/subscribers/on-job-terminated.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-job-terminated.js +32 -0
- package/dist/presentation/subscribers/on-job-terminated.js.map +1 -0
- package/dist/presentation/subscribers/on-log-stream-closed.d.ts +9 -0
- package/dist/presentation/subscribers/on-log-stream-closed.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-log-stream-closed.js +31 -0
- package/dist/presentation/subscribers/on-log-stream-closed.js.map +1 -0
- package/dist/presentation/subscribers/on-step-attempt-terminated.d.ts +3 -0
- package/dist/presentation/subscribers/on-step-attempt-terminated.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-step-attempt-terminated.js +14 -0
- package/dist/presentation/subscribers/on-step-attempt-terminated.js.map +1 -0
- package/dist/temporal/activities/close-abandoned-streams.d.ts +12 -0
- package/dist/temporal/activities/close-abandoned-streams.d.ts.map +1 -0
- package/dist/temporal/activities/close-abandoned-streams.js +33 -0
- package/dist/temporal/activities/close-abandoned-streams.js.map +1 -0
- package/dist/temporal/activities/compact-stream.d.ts +33 -0
- package/dist/temporal/activities/compact-stream.d.ts.map +1 -0
- package/dist/temporal/activities/compact-stream.js +95 -0
- package/dist/temporal/activities/compact-stream.js.map +1 -0
- package/dist/temporal/activities/compaction-reconcile.d.ts +16 -0
- package/dist/temporal/activities/compaction-reconcile.d.ts.map +1 -0
- package/dist/temporal/activities/compaction-reconcile.js +52 -0
- package/dist/temporal/activities/compaction-reconcile.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +14 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +16 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/reap-stale-open-streams.d.ts +4 -0
- package/dist/temporal/activities/reap-stale-open-streams.d.ts.map +1 -0
- package/dist/temporal/activities/reap-stale-open-streams.js +11 -0
- package/dist/temporal/activities/reap-stale-open-streams.js.map +1 -0
- package/dist/temporal/activities/retention-sweep.d.ts +7 -0
- package/dist/temporal/activities/retention-sweep.d.ts.map +1 -0
- package/dist/temporal/activities/retention-sweep.js +19 -0
- package/dist/temporal/activities/retention-sweep.js.map +1 -0
- package/dist/temporal/constants.d.ts +7 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +13 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/workflows/close-abandoned-streams.d.ts +11 -0
- package/dist/temporal/workflows/close-abandoned-streams.d.ts.map +1 -0
- package/dist/temporal/workflows/close-abandoned-streams.js +22 -0
- package/dist/temporal/workflows/close-abandoned-streams.js.map +1 -0
- package/dist/temporal/workflows/compact-stream.d.ts +6 -0
- package/dist/temporal/workflows/compact-stream.d.ts.map +1 -0
- package/dist/temporal/workflows/compact-stream.js +25 -0
- package/dist/temporal/workflows/compact-stream.js.map +1 -0
- package/dist/temporal/workflows/compaction-reconcile-cron.d.ts +3 -0
- package/dist/temporal/workflows/compaction-reconcile-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/compaction-reconcile-cron.js +15 -0
- package/dist/temporal/workflows/compaction-reconcile-cron.js.map +1 -0
- package/dist/temporal/workflows/index.d.ts +6 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +7 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/temporal/workflows/reap-stale-open-streams-cron.d.ts +2 -0
- package/dist/temporal/workflows/reap-stale-open-streams-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/reap-stale-open-streams-cron.js +15 -0
- package/dist/temporal/workflows/reap-stale-open-streams-cron.js.map +1 -0
- package/dist/temporal/workflows/retention-sweep-cron.d.ts +2 -0
- package/dist/temporal/workflows/retention-sweep-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/retention-sweep-cron.js +23 -0
- package/dist/temporal/workflows/retention-sweep-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +65 -0
- package/drizzle/meta/0000_snapshot.json +529 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +84 -0
- package/src/api/object-storage-credentials.test.ts +70 -0
- package/src/api/object-storage.test.ts +64 -0
- package/src/api/object-storage.ts +226 -0
- package/src/config.test.ts +118 -0
- package/src/config.ts +130 -0
- package/src/core/append-logs.test.ts +563 -0
- package/src/core/append-logs.ts +384 -0
- package/src/core/budget.test.ts +30 -0
- package/src/core/budget.ts +19 -0
- package/src/core/close-stream.ts +72 -0
- package/src/core/compaction.ts +68 -0
- package/src/core/entities/attempt-stream.ts +38 -0
- package/src/core/entities/job-accounting.ts +11 -0
- package/src/core/entities/log-object.test.ts +25 -0
- package/src/core/entities/log-object.ts +18 -0
- package/src/core/errors.ts +37 -0
- package/src/core/finalize-attempt-stream.test.ts +90 -0
- package/src/core/finalize-attempt-stream.ts +59 -0
- package/src/core/read-logs.ts +77 -0
- package/src/core/reap-stale-open-streams.test.ts +137 -0
- package/src/core/reap-stale-open-streams.ts +60 -0
- package/src/core/retention.test.ts +388 -0
- package/src/core/retention.ts +184 -0
- package/src/core/session/claude/rows.ts +223 -0
- package/src/core/session/claude-parser.test.ts +199 -0
- package/src/core/session/claude-parser.ts +38 -0
- package/src/core/session/entry-schema.ts +82 -0
- package/src/core/session/object.ts +57 -0
- package/src/core/session/parse-session.ts +23 -0
- package/src/core/session/pi/message-rows.ts +211 -0
- package/src/core/session/pi/metadata.ts +148 -0
- package/src/core/session/pi/text.ts +83 -0
- package/src/core/session/pi-parser.test.ts +451 -0
- package/src/core/session/pi-parser.ts +107 -0
- package/src/core/session/rows.ts +38 -0
- package/src/core/session/session-record.ts +4 -0
- package/src/db/accounting.ts +81 -0
- package/src/db/chunks.ts +143 -0
- package/src/db/db.ts +22 -0
- package/src/db/index.ts +4 -0
- package/src/db/schema/attempt-streams.ts +106 -0
- package/src/db/schema/chunks.ts +43 -0
- package/src/db/schema/common.ts +12 -0
- package/src/db/schema/job-accounting.ts +38 -0
- package/src/db/schema/outbox.ts +4 -0
- package/src/db/streams.test.ts +47 -0
- package/src/db/streams.ts +368 -0
- package/src/index.ts +77 -0
- package/src/metrics/index.ts +1 -0
- package/src/metrics/instance.ts +33 -0
- package/src/metrics/service.ts +23 -0
- package/src/presentation/dto/read-logs.ts +24 -0
- package/src/presentation/routes/append-logs.test.ts +279 -0
- package/src/presentation/routes/append-logs.ts +66 -0
- package/src/presentation/routes/index.ts +27 -0
- package/src/presentation/routes/read-logs.test.ts +504 -0
- package/src/presentation/routes/read-logs.ts +38 -0
- package/src/presentation/subscribers/on-job-terminated.test.ts +64 -0
- package/src/presentation/subscribers/on-job-terminated.ts +26 -0
- package/src/presentation/subscribers/on-log-stream-closed.test.ts +63 -0
- package/src/presentation/subscribers/on-log-stream-closed.ts +26 -0
- package/src/presentation/subscribers/on-step-attempt-terminated.ts +16 -0
- package/src/temporal/activities/close-abandoned-streams.test.ts +84 -0
- package/src/temporal/activities/close-abandoned-streams.ts +30 -0
- package/src/temporal/activities/compact-stream.test.ts +221 -0
- package/src/temporal/activities/compact-stream.ts +123 -0
- package/src/temporal/activities/compaction-reconcile.test.ts +143 -0
- package/src/temporal/activities/compaction-reconcile.ts +48 -0
- package/src/temporal/activities/index.ts +15 -0
- package/src/temporal/activities/reap-stale-open-streams.test.ts +48 -0
- package/src/temporal/activities/reap-stale-open-streams.ts +14 -0
- package/src/temporal/activities/retention-sweep.test.ts +38 -0
- package/src/temporal/activities/retention-sweep.ts +23 -0
- package/src/temporal/constants.ts +14 -0
- package/src/temporal/workflows/close-abandoned-streams.ts +25 -0
- package/src/temporal/workflows/compact-stream.ts +26 -0
- package/src/temporal/workflows/compaction-reconcile-cron.ts +14 -0
- package/src/temporal/workflows/index.ts +5 -0
- package/src/temporal/workflows/reap-stale-open-streams-cron.ts +13 -0
- package/src/temporal/workflows/retention-sweep-cron.ts +23 -0
- package/test/env.ts +37 -0
- package/test/factories/job-accounting.ts +38 -0
- package/test/fixtures/closed-stream.ts +53 -0
- package/test/fixtures/lease-token.ts +42 -0
- package/test/fixtures/ndjson.ts +34 -0
- package/test/globalSetup.ts +44 -0
- package/test/index.ts +9 -0
- package/test/queries.ts +54 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/api/object-storage.ts"],"sourcesContent":["import type {Readable} from 'node:stream';\nimport {\n DeleteObjectCommand,\n DeleteObjectsCommand,\n GetObjectCommand,\n HeadBucketCommand,\n ListObjectsV2Command,\n S3Client,\n} from '@aws-sdk/client-s3';\nimport {Upload} from '@aws-sdk/lib-storage';\nimport {getSignedUrl} from '@aws-sdk/s3-request-presigner';\nimport {config} from '#config.js';\nimport {type LogObjectKeyParams, logObjectKey} from '#core/entities/log-object.js';\n\nconst explicitS3Credentials =\n config.LOG_STORAGE_S3_ACCESS_KEY_ID && config.LOG_STORAGE_S3_SECRET_ACCESS_KEY\n ? {\n accessKeyId: config.LOG_STORAGE_S3_ACCESS_KEY_ID,\n secretAccessKey: config.LOG_STORAGE_S3_SECRET_ACCESS_KEY,\n }\n : undefined;\n\nlet _client: S3Client | undefined;\n\n/** Lazily-built S3 client targeting the configured object store (Garage in dev). */\nexport function s3Client(): S3Client {\n if (!_client) {\n _client = new S3Client({\n endpoint: config.LOG_STORAGE_S3_ENDPOINT,\n region: config.LOG_STORAGE_S3_REGION,\n forcePathStyle: config.LOG_STORAGE_S3_FORCE_PATH_STYLE,\n ...(explicitS3Credentials ? {credentials: explicitS3Credentials} : {}),\n // Fail fast: a slow or black-holed endpoint must not hang callers behind\n // SDK backoff. One retry, short connect/request timeouts.\n maxAttempts: 2,\n requestHandler: {connectionTimeout: 1_000, requestTimeout: 3_000},\n });\n }\n return _client;\n}\n\nexport function closeS3Client(): void {\n _client?.destroy();\n _client = undefined;\n}\n\n/** Readiness probe: true when the configured bucket answers a HEAD. Used by the app readiness check, not at process boot. */\nexport async function checkBucketReachable(): Promise<boolean> {\n try {\n await s3Client().send(new HeadBucketCommand({Bucket: config.LOG_STORAGE_S3_BUCKET}));\n return true;\n } catch {\n return false;\n }\n}\n\nlet _uploadClient: S3Client | undefined;\n\n/**\n * S3 client for streaming compaction uploads. `s3Client()` is tuned fail-fast for the\n * readiness HEAD, but each multipart part upload can take as long as the link needs, so\n * the per-request timeout is disabled here. Temporal owns the retry budget, so the SDK\n * keeps only a small attempt count for transient connection blips.\n */\nexport function uploadS3Client(): S3Client {\n if (!_uploadClient) {\n _uploadClient = new S3Client({\n endpoint: config.LOG_STORAGE_S3_ENDPOINT,\n region: config.LOG_STORAGE_S3_REGION,\n forcePathStyle: config.LOG_STORAGE_S3_FORCE_PATH_STYLE,\n ...(explicitS3Credentials ? {credentials: explicitS3Credentials} : {}),\n maxAttempts: 3,\n requestHandler: {connectionTimeout: 5_000, requestTimeout: 0},\n });\n }\n return _uploadClient;\n}\n\nexport function closeUploadS3Client(): void {\n _uploadClient?.destroy();\n _uploadClient = undefined;\n}\n\n/**\n * Per-attempt object key for a compacted stream: the stream's stable prefix\n * (`logObjectKey` -> `{prefix}/{workspaceId}/{jobId}/{stepId}/{attempt}`, shared with retention\n * and workspace prefix deletes) plus a unique `uploadToken` leaf. Each compaction attempt\n * uploads to its own key and the winner records it atomically, so a slow or zombie attempt\n * can never overwrite a published object. Losing and crashed attempts leave a leaf under the\n * same prefix, reclaimed by the bucket's incomplete-multipart-abort rule and prefix-scoped\n * retention.\n */\nexport function compactedObjectKey(identity: LogObjectKeyParams, uploadToken: string): string {\n return `${logObjectKey(config.LOG_STORAGE_S3_PREFIX, identity)}/${uploadToken}`;\n}\n\n// lib-storage defaults (5MB parts, queue of 4); we pin a small queue so peak buffer memory\n// stays bounded under concurrent compactions (queueSize x partSize per active upload).\nconst UPLOAD_PART_SIZE = 5 * 1024 * 1024;\nconst UPLOAD_QUEUE_SIZE = 2;\n\nexport interface PutCompactedObjectParams {\n key: string;\n body: Readable;\n metadata: Record<string, string>;\n signal?: AbortSignal;\n onProgress?: () => void;\n}\n\n/**\n * Streams `body` (gzip-compressed NDJSON) to the compacted object via a multipart upload.\n * `Content-Encoding: gzip` so a browser-direct presigned GET auto-decompresses. Aborts the\n * multipart upload when `signal` fires, so a cancelled activity leaves no dangling parts.\n *\n * A hard crash (no graceful abort) can still strand incomplete multipart parts, which on\n * pay-per-storage providers (S3, R2) bill until aborted. The worker never sets bucket\n * policy, so the deployed bucket must carry an `AbortIncompleteMultipartUpload` lifecycle\n * rule. `dev/garage/bootstrap.sh` provisions it for local Garage; self-hosters set the same\n * rule on their bucket.\n */\nexport async function putCompactedObject(params: PutCompactedObjectParams): Promise<void> {\n const upload = new Upload({\n client: uploadS3Client(),\n partSize: UPLOAD_PART_SIZE,\n queueSize: UPLOAD_QUEUE_SIZE,\n params: {\n Bucket: config.LOG_STORAGE_S3_BUCKET,\n Key: params.key,\n Body: params.body,\n ContentType: 'application/x-ndjson',\n ContentEncoding: 'gzip',\n Metadata: params.metadata,\n },\n });\n\n if (params.onProgress) upload.on('httpUploadProgress', params.onProgress);\n if (params.signal) {\n // Subscribe before the aborted-check so a signal firing between them cannot be missed,\n // then handle the already-aborted case eagerly (an already-aborted signal never fires 'abort').\n params.signal.addEventListener(\n 'abort',\n () => {\n void upload.abort().catch(() => undefined);\n },\n {once: true},\n );\n if (params.signal.aborted) {\n await upload.abort().catch(() => undefined);\n throw new Error('Compaction upload aborted before it started');\n }\n }\n\n await upload.done();\n}\n\n/**\n * Deletes a compacted object. S3 `DeleteObject` is idempotent (a missing key is a success),\n * so retention and the publish orphan guard can call it freely without a pre-check.\n */\nexport async function deleteObject(key: string): Promise<void> {\n await s3Client().send(new DeleteObjectCommand({Bucket: config.LOG_STORAGE_S3_BUCKET, Key: key}));\n}\n\n/**\n * Presigns a GET for a compacted log object so the browser can fetch it directly,\n * bypassing API egress. `getSignedUrl` only computes a signature locally (no network\n * call), so the fail-fast `s3Client()` is fine here. `expiresAt` mirrors the URL's\n * `LOG_READ_URL_TTL_SECONDS` lifetime so the caller can hand the client an absolute\n * deadline without re-deriving it.\n */\nexport async function presignedGetUrl(objectKey: string): Promise<{url: string; expiresAt: Date}> {\n const ttlSeconds = config.LOG_READ_URL_TTL_SECONDS;\n // Stamp the deadline from before signing: getSignedUrl bakes its expiry from the clock at\n // sign time, so reading Date.now() after the await could report a deadline slightly past the\n // real one. Computing it first keeps expiresAt a conservative (never-late) bound.\n const expiresAt = new Date(Date.now() + ttlSeconds * 1000);\n const url = await getSignedUrl(\n s3Client(),\n new GetObjectCommand({Bucket: config.LOG_STORAGE_S3_BUCKET, Key: objectKey}),\n {expiresIn: ttlSeconds},\n );\n return {url, expiresAt};\n}\n\n/**\n * Deletes all objects under a per-attempt prefix. Retention deletes by prefix, not only the\n * recorded `object_key`, to reclaim orphan leaves from failed compaction attempts.\n *\n * Pass a trailing slash so attempt `1` never matches attempt `10`.\n */\nexport async function deleteObjectsByPrefix(prefix: string): Promise<void> {\n if (prefix === '') {\n throw new Error(\n 'deleteObjectsByPrefix refuses an empty prefix (it would target the whole bucket)',\n );\n }\n const client = s3Client();\n let continuationToken: string | undefined;\n do {\n const listed = await client.send(\n new ListObjectsV2Command({\n Bucket: config.LOG_STORAGE_S3_BUCKET,\n Prefix: prefix,\n ContinuationToken: continuationToken,\n }),\n );\n const objects = (listed.Contents ?? []).flatMap((object) =>\n object.Key ? [{Key: object.Key}] : [],\n );\n if (objects.length > 0) {\n const deleted = await client.send(\n new DeleteObjectsCommand({\n Bucket: config.LOG_STORAGE_S3_BUCKET,\n Delete: {Objects: objects, Quiet: true},\n }),\n );\n if (deleted.Errors && deleted.Errors.length > 0) {\n const [first] = deleted.Errors;\n throw new Error(\n `Failed to delete ${deleted.Errors.length} object(s) under ${prefix}: ${first?.Key} ${first?.Message}`,\n );\n }\n }\n continuationToken = listed.IsTruncated ? listed.NextContinuationToken : undefined;\n } while (continuationToken);\n}\n"],"names":["DeleteObjectCommand","DeleteObjectsCommand","GetObjectCommand","HeadBucketCommand","ListObjectsV2Command","S3Client","Upload","getSignedUrl","config","logObjectKey","explicitS3Credentials","LOG_STORAGE_S3_ACCESS_KEY_ID","LOG_STORAGE_S3_SECRET_ACCESS_KEY","accessKeyId","secretAccessKey","undefined","_client","s3Client","endpoint","LOG_STORAGE_S3_ENDPOINT","region","LOG_STORAGE_S3_REGION","forcePathStyle","LOG_STORAGE_S3_FORCE_PATH_STYLE","credentials","maxAttempts","requestHandler","connectionTimeout","requestTimeout","closeS3Client","destroy","checkBucketReachable","send","Bucket","LOG_STORAGE_S3_BUCKET","_uploadClient","uploadS3Client","closeUploadS3Client","compactedObjectKey","identity","uploadToken","LOG_STORAGE_S3_PREFIX","UPLOAD_PART_SIZE","UPLOAD_QUEUE_SIZE","putCompactedObject","params","upload","client","partSize","queueSize","Key","key","Body","body","ContentType","ContentEncoding","Metadata","metadata","onProgress","on","signal","addEventListener","abort","catch","once","aborted","Error","done","deleteObject","presignedGetUrl","objectKey","ttlSeconds","LOG_READ_URL_TTL_SECONDS","expiresAt","Date","now","url","expiresIn","deleteObjectsByPrefix","prefix","continuationToken","listed","Prefix","ContinuationToken","objects","Contents","flatMap","object","length","deleted","Delete","Objects","Quiet","Errors","first","Message","IsTruncated","NextContinuationToken"],"mappings":"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"}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const config: Readonly<{
|
|
2
|
+
LOG_STORAGE_S3_ENDPOINT: string;
|
|
3
|
+
LOG_STORAGE_S3_REGION: string;
|
|
4
|
+
LOG_STORAGE_S3_BUCKET: string;
|
|
5
|
+
LOG_STORAGE_S3_PREFIX: string;
|
|
6
|
+
LOG_STORAGE_S3_ACCESS_KEY_ID: string | undefined;
|
|
7
|
+
LOG_STORAGE_S3_SECRET_ACCESS_KEY: string | undefined;
|
|
8
|
+
LOG_STORAGE_S3_FORCE_PATH_STYLE: boolean;
|
|
9
|
+
LOG_BUDGET_BASE_BYTES: number;
|
|
10
|
+
LOG_BUDGET_RATE_BYTES_PER_MINUTE: number;
|
|
11
|
+
LOG_STREAM_CLOSE_GRACE_SECONDS: number;
|
|
12
|
+
LOG_COMPACTION_RECONCILE_STALE_SECONDS: number;
|
|
13
|
+
LOG_STREAM_REAP_AFTER_SECONDS: number;
|
|
14
|
+
LOG_APPEND_BODY_LIMIT_BYTES: number;
|
|
15
|
+
LOG_MAX_SESSION_LINE_BYTES: number;
|
|
16
|
+
LOG_READ_URL_TTL_SECONDS: number;
|
|
17
|
+
LOG_READ_INLINE_MAX_BYTES: number;
|
|
18
|
+
LOG_RETENTION_DAYS: number;
|
|
19
|
+
} & import("@shipfox/config").CleanedEnvAccessors>;
|
|
20
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;kDAqEjB,CAAC"}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { config as authConfig } from '@shipfox/api-auth/config';
|
|
2
|
+
import { bool, createConfig, num, str, url } from '@shipfox/config';
|
|
3
|
+
import { durationToSeconds } from '@shipfox/node-jwt';
|
|
4
|
+
export const config = createConfig({
|
|
5
|
+
LOG_STORAGE_S3_ENDPOINT: url({
|
|
6
|
+
desc: 'Endpoint URL of the S3-compatible object store that holds compacted logs. Defaults to the bundled local-development Garage (http://localhost:3900); set it to your object store endpoint for production.',
|
|
7
|
+
default: 'http://localhost:3900'
|
|
8
|
+
}),
|
|
9
|
+
LOG_STORAGE_S3_REGION: str({
|
|
10
|
+
desc: 'Region passed to the S3 client. Any value works for Garage; set the real region for AWS S3. Defaults to garage for local development.',
|
|
11
|
+
default: 'garage'
|
|
12
|
+
}),
|
|
13
|
+
LOG_STORAGE_S3_BUCKET: str({
|
|
14
|
+
desc: 'Name of the bucket that stores compacted log objects. Defaults to shipfox-logs (created by dev/garage/bootstrap.sh); create the bucket and set this for production.',
|
|
15
|
+
default: 'shipfox-logs'
|
|
16
|
+
}),
|
|
17
|
+
LOG_STORAGE_S3_PREFIX: str({
|
|
18
|
+
desc: 'Key prefix under which compacted log objects are stored in the bucket. Set this to host several modules in one bucket, each under its own prefix. Use a value without a leading or trailing slash. Defaults to logs.',
|
|
19
|
+
default: 'logs'
|
|
20
|
+
}),
|
|
21
|
+
LOG_STORAGE_S3_ACCESS_KEY_ID: str({
|
|
22
|
+
desc: 'Optional access key ID used to authenticate to the object store. Set it together with LOG_STORAGE_S3_SECRET_ACCESS_KEY for an explicit credential pair, or leave both unset to use the standard AWS SDK credential provider chain.',
|
|
23
|
+
default: undefined
|
|
24
|
+
}),
|
|
25
|
+
LOG_STORAGE_S3_SECRET_ACCESS_KEY: str({
|
|
26
|
+
desc: 'Optional secret access key used to authenticate to the object store. Set it together with LOG_STORAGE_S3_ACCESS_KEY_ID for an explicit credential pair, or leave both unset to use the standard AWS SDK credential provider chain.',
|
|
27
|
+
default: undefined
|
|
28
|
+
}),
|
|
29
|
+
LOG_STORAGE_S3_FORCE_PATH_STYLE: bool({
|
|
30
|
+
desc: 'Whether to address the bucket as a path (endpoint/bucket) instead of a subdomain. Set it to true for Garage and MinIO; false works for AWS S3.',
|
|
31
|
+
default: true
|
|
32
|
+
}),
|
|
33
|
+
LOG_BUDGET_BASE_BYTES: num({
|
|
34
|
+
desc: 'Base of the per-job log accrual budget, in stored bytes (normalized NDJSON the server keeps, framing included). A job may always store this much before the time-based rate is added. Sized to hold a few inline agent_session entries (such as base64 images) before the shared per-job cap trips. Defaults to 32 MiB.',
|
|
35
|
+
default: 33_554_432
|
|
36
|
+
}),
|
|
37
|
+
LOG_BUDGET_RATE_BYTES_PER_MINUTE: num({
|
|
38
|
+
desc: 'Stored bytes added to the per-job log budget for each minute since the first log append. Defaults to 1 MiB per minute.',
|
|
39
|
+
default: 1_048_576
|
|
40
|
+
}),
|
|
41
|
+
LOG_STREAM_CLOSE_GRACE_SECONDS: num({
|
|
42
|
+
desc: 'How long to wait after a job reaches a terminal state before force-closing any of its log streams the runner never ended itself (it died, was capped, or its log spool failed). The wait lets a last in-flight chunk land before the stream is marked truncated. Defaults to 120 seconds.',
|
|
43
|
+
default: 120
|
|
44
|
+
}),
|
|
45
|
+
LOG_COMPACTION_RECONCILE_STALE_SECONDS: num({
|
|
46
|
+
desc: 'How long a closed log stream may stay uncompacted before the reconcile cron re-drives it. The event-triggered compaction normally runs within seconds of close; this backstop only re-drives streams whose compaction never started or permanently failed. Defaults to 900 seconds (15 minutes).',
|
|
47
|
+
default: 900
|
|
48
|
+
}),
|
|
49
|
+
LOG_STREAM_REAP_AFTER_SECONDS: num({
|
|
50
|
+
desc: 'How long a log stream may stay open before the reaper cron force-closes it as abandoned. This is the backstop for a stream a runner started after its job already went terminal (the one-shot close sweep had already run); the reaper marks it truncated so it re-enters the compaction and retention lifecycle. Must be greater than AUTH_JOB_LEASE_TOKEN_EXPIRES_IN (the job lease lifetime, default 90 minutes) so a still-valid lease can never be force-closed mid-append, which would silently truncate live logs. Defaults to 7200 seconds (2 hours).',
|
|
51
|
+
default: 7200
|
|
52
|
+
}),
|
|
53
|
+
LOG_APPEND_BODY_LIMIT_BYTES: num({
|
|
54
|
+
desc: 'Maximum size of a single log append request body, in bytes. Output records cap at 16 KiB each, but one agent_session record carries a whole session entry (which can inline base64 images), so the body must hold a full LOG_MAX_SESSION_LINE_BYTES line plus framing. This also bounds how far a job can overshoot its log budget: the one append that crosses the budget is stored in full before the job is capped. Defaults to 8 MiB.',
|
|
55
|
+
default: 8_388_608
|
|
56
|
+
}),
|
|
57
|
+
LOG_MAX_SESSION_LINE_BYTES: num({
|
|
58
|
+
desc: 'Maximum size of one agent_session log line (a verbatim agent session entry), in bytes. A larger line is rejected with 400, and the runner drops it with a gap marker instead of sending it. Sized generously for entries that inline large content such as base64 images. Must be at most LOG_APPEND_BODY_LIMIT_BYTES, since a line that cannot fit in one request body could never be accepted. Defaults to 4 MiB.',
|
|
59
|
+
default: 4_194_304
|
|
60
|
+
}),
|
|
61
|
+
LOG_READ_URL_TTL_SECONDS: num({
|
|
62
|
+
desc: 'Lifetime, in seconds, of a presigned GET URL handed out for a compacted (cold) log read. The browser must finish fetching the object before it expires; raise it for slow links or large logs. Must be between 1 and 604800 (7 days, the longest a presigned URL can live). Defaults to 3600 (one hour).',
|
|
63
|
+
default: 3600
|
|
64
|
+
}),
|
|
65
|
+
LOG_READ_INLINE_MAX_BYTES: num({
|
|
66
|
+
desc: 'Maximum number of stored log bytes returned in a single inline (hot) read response. A long-running step can buffer far more than this in Postgres before compaction, so a read is capped here and the client re-polls (following has_more) to drain the backlog before it tails. Bounds per-request memory. Must be at least 1. Defaults to 1 MiB.',
|
|
67
|
+
default: 1_048_576
|
|
68
|
+
}),
|
|
69
|
+
LOG_RETENTION_DAYS: num({
|
|
70
|
+
desc: 'How many days a closed log stream is kept before the retention cron hard-deletes its stored object and database row. Our own worker enforces this (not bucket lifecycle rules), so behavior is identical across object stores. Must be a whole number of days, 1 or greater. Defaults to 90 days.',
|
|
71
|
+
default: 90
|
|
72
|
+
})
|
|
73
|
+
});
|
|
74
|
+
const hasS3AccessKeyId = Boolean(config.LOG_STORAGE_S3_ACCESS_KEY_ID);
|
|
75
|
+
const hasS3SecretAccessKey = Boolean(config.LOG_STORAGE_S3_SECRET_ACCESS_KEY);
|
|
76
|
+
if (hasS3AccessKeyId !== hasS3SecretAccessKey) {
|
|
77
|
+
throw new Error('LOG_STORAGE_S3_ACCESS_KEY_ID and LOG_STORAGE_S3_SECRET_ACCESS_KEY must be set together or both left unset.');
|
|
78
|
+
}
|
|
79
|
+
// SigV4 caps a presigned URL's lifetime at 7 days, so a larger TTL would fail at signing
|
|
80
|
+
// time on every cold read. Reject out-of-range values at startup rather than per request.
|
|
81
|
+
const MAX_PRESIGN_TTL_SECONDS = 604_800;
|
|
82
|
+
if (config.LOG_READ_URL_TTL_SECONDS < 1 || config.LOG_READ_URL_TTL_SECONDS > MAX_PRESIGN_TTL_SECONDS) {
|
|
83
|
+
throw new Error(`LOG_READ_URL_TTL_SECONDS must be between 1 and ${MAX_PRESIGN_TTL_SECONDS}; got ${config.LOG_READ_URL_TTL_SECONDS}`);
|
|
84
|
+
}
|
|
85
|
+
if (config.LOG_READ_INLINE_MAX_BYTES < 1) {
|
|
86
|
+
throw new Error(`LOG_READ_INLINE_MAX_BYTES must be at least 1; got ${config.LOG_READ_INLINE_MAX_BYTES}`);
|
|
87
|
+
}
|
|
88
|
+
// A whole agent_session line is one record in one body. If the body limit is below the
|
|
89
|
+
// line cap, a legitimate large session line could never fit in one request body — Fastify
|
|
90
|
+
// would 413 it before the per-line validator runs. Fail fast at startup instead.
|
|
91
|
+
if (config.LOG_APPEND_BODY_LIMIT_BYTES < config.LOG_MAX_SESSION_LINE_BYTES) {
|
|
92
|
+
throw new Error(`LOG_APPEND_BODY_LIMIT_BYTES (${config.LOG_APPEND_BODY_LIMIT_BYTES}) must be >= LOG_MAX_SESSION_LINE_BYTES (${config.LOG_MAX_SESSION_LINE_BYTES}).`);
|
|
93
|
+
}
|
|
94
|
+
// `num` accepts zero, negative, and fractional values; reject them before they reach
|
|
95
|
+
// `make_interval(days => ...)` and hard-delete the wrong streams.
|
|
96
|
+
if (!Number.isInteger(config.LOG_RETENTION_DAYS) || config.LOG_RETENTION_DAYS < 1) {
|
|
97
|
+
throw new Error(`LOG_RETENTION_DAYS (${config.LOG_RETENTION_DAYS}) must be a whole number of days >= 1.`);
|
|
98
|
+
}
|
|
99
|
+
// A stream still accepts appends until its job lease expires, so reaping it any sooner would
|
|
100
|
+
// truncate live logs. Validate against the real lease TTL read from auth's config (not a
|
|
101
|
+
// hardcoded floor), so raising AUTH_JOB_LEASE_TOKEN_EXPIRES_IN can never silently outrun the
|
|
102
|
+
// reaper window.
|
|
103
|
+
const jobLeaseTtlSeconds = durationToSeconds(authConfig.AUTH_JOB_LEASE_TOKEN_EXPIRES_IN);
|
|
104
|
+
if (!Number.isFinite(config.LOG_STREAM_REAP_AFTER_SECONDS) || config.LOG_STREAM_REAP_AFTER_SECONDS <= jobLeaseTtlSeconds) {
|
|
105
|
+
throw new Error(`LOG_STREAM_REAP_AFTER_SECONDS (${config.LOG_STREAM_REAP_AFTER_SECONDS}) must be greater than the job lease TTL (${jobLeaseTtlSeconds}s, from AUTH_JOB_LEASE_TOKEN_EXPIRES_IN=${authConfig.AUTH_JOB_LEASE_TOKEN_EXPIRES_IN}); a smaller value would let the reaper force-close a stream a still-valid lease is appending to and silently truncate live logs.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/config.ts"],"sourcesContent":["import {config as authConfig} from '@shipfox/api-auth/config';\nimport {bool, createConfig, num, str, url} from '@shipfox/config';\nimport {durationToSeconds} from '@shipfox/node-jwt';\n\nexport const config = createConfig({\n LOG_STORAGE_S3_ENDPOINT: url({\n desc: 'Endpoint URL of the S3-compatible object store that holds compacted logs. Defaults to the bundled local-development Garage (http://localhost:3900); set it to your object store endpoint for production.',\n default: 'http://localhost:3900',\n }),\n LOG_STORAGE_S3_REGION: str({\n desc: 'Region passed to the S3 client. Any value works for Garage; set the real region for AWS S3. Defaults to garage for local development.',\n default: 'garage',\n }),\n LOG_STORAGE_S3_BUCKET: str({\n desc: 'Name of the bucket that stores compacted log objects. Defaults to shipfox-logs (created by dev/garage/bootstrap.sh); create the bucket and set this for production.',\n default: 'shipfox-logs',\n }),\n LOG_STORAGE_S3_PREFIX: str({\n desc: 'Key prefix under which compacted log objects are stored in the bucket. Set this to host several modules in one bucket, each under its own prefix. Use a value without a leading or trailing slash. Defaults to logs.',\n default: 'logs',\n }),\n LOG_STORAGE_S3_ACCESS_KEY_ID: str({\n desc: 'Optional access key ID used to authenticate to the object store. Set it together with LOG_STORAGE_S3_SECRET_ACCESS_KEY for an explicit credential pair, or leave both unset to use the standard AWS SDK credential provider chain.',\n default: undefined,\n }),\n LOG_STORAGE_S3_SECRET_ACCESS_KEY: str({\n desc: 'Optional secret access key used to authenticate to the object store. Set it together with LOG_STORAGE_S3_ACCESS_KEY_ID for an explicit credential pair, or leave both unset to use the standard AWS SDK credential provider chain.',\n default: undefined,\n }),\n LOG_STORAGE_S3_FORCE_PATH_STYLE: bool({\n desc: 'Whether to address the bucket as a path (endpoint/bucket) instead of a subdomain. Set it to true for Garage and MinIO; false works for AWS S3.',\n default: true,\n }),\n LOG_BUDGET_BASE_BYTES: num({\n desc: 'Base of the per-job log accrual budget, in stored bytes (normalized NDJSON the server keeps, framing included). A job may always store this much before the time-based rate is added. Sized to hold a few inline agent_session entries (such as base64 images) before the shared per-job cap trips. Defaults to 32 MiB.',\n default: 33_554_432,\n }),\n LOG_BUDGET_RATE_BYTES_PER_MINUTE: num({\n desc: 'Stored bytes added to the per-job log budget for each minute since the first log append. Defaults to 1 MiB per minute.',\n default: 1_048_576,\n }),\n LOG_STREAM_CLOSE_GRACE_SECONDS: num({\n desc: 'How long to wait after a job reaches a terminal state before force-closing any of its log streams the runner never ended itself (it died, was capped, or its log spool failed). The wait lets a last in-flight chunk land before the stream is marked truncated. Defaults to 120 seconds.',\n default: 120,\n }),\n LOG_COMPACTION_RECONCILE_STALE_SECONDS: num({\n desc: 'How long a closed log stream may stay uncompacted before the reconcile cron re-drives it. The event-triggered compaction normally runs within seconds of close; this backstop only re-drives streams whose compaction never started or permanently failed. Defaults to 900 seconds (15 minutes).',\n default: 900,\n }),\n LOG_STREAM_REAP_AFTER_SECONDS: num({\n desc: 'How long a log stream may stay open before the reaper cron force-closes it as abandoned. This is the backstop for a stream a runner started after its job already went terminal (the one-shot close sweep had already run); the reaper marks it truncated so it re-enters the compaction and retention lifecycle. Must be greater than AUTH_JOB_LEASE_TOKEN_EXPIRES_IN (the job lease lifetime, default 90 minutes) so a still-valid lease can never be force-closed mid-append, which would silently truncate live logs. Defaults to 7200 seconds (2 hours).',\n default: 7200,\n }),\n LOG_APPEND_BODY_LIMIT_BYTES: num({\n desc: 'Maximum size of a single log append request body, in bytes. Output records cap at 16 KiB each, but one agent_session record carries a whole session entry (which can inline base64 images), so the body must hold a full LOG_MAX_SESSION_LINE_BYTES line plus framing. This also bounds how far a job can overshoot its log budget: the one append that crosses the budget is stored in full before the job is capped. Defaults to 8 MiB.',\n default: 8_388_608,\n }),\n LOG_MAX_SESSION_LINE_BYTES: num({\n desc: 'Maximum size of one agent_session log line (a verbatim agent session entry), in bytes. A larger line is rejected with 400, and the runner drops it with a gap marker instead of sending it. Sized generously for entries that inline large content such as base64 images. Must be at most LOG_APPEND_BODY_LIMIT_BYTES, since a line that cannot fit in one request body could never be accepted. Defaults to 4 MiB.',\n default: 4_194_304,\n }),\n LOG_READ_URL_TTL_SECONDS: num({\n desc: 'Lifetime, in seconds, of a presigned GET URL handed out for a compacted (cold) log read. The browser must finish fetching the object before it expires; raise it for slow links or large logs. Must be between 1 and 604800 (7 days, the longest a presigned URL can live). Defaults to 3600 (one hour).',\n default: 3600,\n }),\n LOG_READ_INLINE_MAX_BYTES: num({\n desc: 'Maximum number of stored log bytes returned in a single inline (hot) read response. A long-running step can buffer far more than this in Postgres before compaction, so a read is capped here and the client re-polls (following has_more) to drain the backlog before it tails. Bounds per-request memory. Must be at least 1. Defaults to 1 MiB.',\n default: 1_048_576,\n }),\n LOG_RETENTION_DAYS: num({\n desc: 'How many days a closed log stream is kept before the retention cron hard-deletes its stored object and database row. Our own worker enforces this (not bucket lifecycle rules), so behavior is identical across object stores. Must be a whole number of days, 1 or greater. Defaults to 90 days.',\n default: 90,\n }),\n});\n\nconst hasS3AccessKeyId = Boolean(config.LOG_STORAGE_S3_ACCESS_KEY_ID);\nconst hasS3SecretAccessKey = Boolean(config.LOG_STORAGE_S3_SECRET_ACCESS_KEY);\nif (hasS3AccessKeyId !== hasS3SecretAccessKey) {\n throw new Error(\n 'LOG_STORAGE_S3_ACCESS_KEY_ID and LOG_STORAGE_S3_SECRET_ACCESS_KEY must be set together or both left unset.',\n );\n}\n\n// SigV4 caps a presigned URL's lifetime at 7 days, so a larger TTL would fail at signing\n// time on every cold read. Reject out-of-range values at startup rather than per request.\nconst MAX_PRESIGN_TTL_SECONDS = 604_800;\nif (\n config.LOG_READ_URL_TTL_SECONDS < 1 ||\n config.LOG_READ_URL_TTL_SECONDS > MAX_PRESIGN_TTL_SECONDS\n) {\n throw new Error(\n `LOG_READ_URL_TTL_SECONDS must be between 1 and ${MAX_PRESIGN_TTL_SECONDS}; got ${config.LOG_READ_URL_TTL_SECONDS}`,\n );\n}\nif (config.LOG_READ_INLINE_MAX_BYTES < 1) {\n throw new Error(\n `LOG_READ_INLINE_MAX_BYTES must be at least 1; got ${config.LOG_READ_INLINE_MAX_BYTES}`,\n );\n}\n\n// A whole agent_session line is one record in one body. If the body limit is below the\n// line cap, a legitimate large session line could never fit in one request body — Fastify\n// would 413 it before the per-line validator runs. Fail fast at startup instead.\nif (config.LOG_APPEND_BODY_LIMIT_BYTES < config.LOG_MAX_SESSION_LINE_BYTES) {\n throw new Error(\n `LOG_APPEND_BODY_LIMIT_BYTES (${config.LOG_APPEND_BODY_LIMIT_BYTES}) must be >= LOG_MAX_SESSION_LINE_BYTES (${config.LOG_MAX_SESSION_LINE_BYTES}).`,\n );\n}\n\n// `num` accepts zero, negative, and fractional values; reject them before they reach\n// `make_interval(days => ...)` and hard-delete the wrong streams.\nif (!Number.isInteger(config.LOG_RETENTION_DAYS) || config.LOG_RETENTION_DAYS < 1) {\n throw new Error(\n `LOG_RETENTION_DAYS (${config.LOG_RETENTION_DAYS}) must be a whole number of days >= 1.`,\n );\n}\n\n// A stream still accepts appends until its job lease expires, so reaping it any sooner would\n// truncate live logs. Validate against the real lease TTL read from auth's config (not a\n// hardcoded floor), so raising AUTH_JOB_LEASE_TOKEN_EXPIRES_IN can never silently outrun the\n// reaper window.\nconst jobLeaseTtlSeconds = durationToSeconds(authConfig.AUTH_JOB_LEASE_TOKEN_EXPIRES_IN);\nif (\n !Number.isFinite(config.LOG_STREAM_REAP_AFTER_SECONDS) ||\n config.LOG_STREAM_REAP_AFTER_SECONDS <= jobLeaseTtlSeconds\n) {\n throw new Error(\n `LOG_STREAM_REAP_AFTER_SECONDS (${config.LOG_STREAM_REAP_AFTER_SECONDS}) must be greater than the job lease TTL (${jobLeaseTtlSeconds}s, from AUTH_JOB_LEASE_TOKEN_EXPIRES_IN=${authConfig.AUTH_JOB_LEASE_TOKEN_EXPIRES_IN}); a smaller value would let the reaper force-close a stream a still-valid lease is appending to and silently truncate live logs.`,\n );\n}\n"],"names":["config","authConfig","bool","createConfig","num","str","url","durationToSeconds","LOG_STORAGE_S3_ENDPOINT","desc","default","LOG_STORAGE_S3_REGION","LOG_STORAGE_S3_BUCKET","LOG_STORAGE_S3_PREFIX","LOG_STORAGE_S3_ACCESS_KEY_ID","undefined","LOG_STORAGE_S3_SECRET_ACCESS_KEY","LOG_STORAGE_S3_FORCE_PATH_STYLE","LOG_BUDGET_BASE_BYTES","LOG_BUDGET_RATE_BYTES_PER_MINUTE","LOG_STREAM_CLOSE_GRACE_SECONDS","LOG_COMPACTION_RECONCILE_STALE_SECONDS","LOG_STREAM_REAP_AFTER_SECONDS","LOG_APPEND_BODY_LIMIT_BYTES","LOG_MAX_SESSION_LINE_BYTES","LOG_READ_URL_TTL_SECONDS","LOG_READ_INLINE_MAX_BYTES","LOG_RETENTION_DAYS","hasS3AccessKeyId","Boolean","hasS3SecretAccessKey","Error","MAX_PRESIGN_TTL_SECONDS","Number","isInteger","jobLeaseTtlSeconds","AUTH_JOB_LEASE_TOKEN_EXPIRES_IN","isFinite"],"mappings":"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"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
type StepLookup = (stepId: string) => Promise<{
|
|
3
|
+
config: Record<string, unknown>;
|
|
4
|
+
} | undefined>;
|
|
5
|
+
export declare function setStepLookupForTesting(lookup: StepLookup): () => void;
|
|
6
|
+
export interface AppendLogsParams {
|
|
7
|
+
jobId: string;
|
|
8
|
+
workspaceId: string;
|
|
9
|
+
projectId: string;
|
|
10
|
+
workflowRunAttemptId: string;
|
|
11
|
+
stepId: string;
|
|
12
|
+
attempt: number;
|
|
13
|
+
offset: number;
|
|
14
|
+
body: Buffer;
|
|
15
|
+
}
|
|
16
|
+
export interface AppendLogsResult {
|
|
17
|
+
committedLength: number;
|
|
18
|
+
capped: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Concurrency is serialized through Postgres row locks taken implicitly by the
|
|
22
|
+
* conditional UPDATEs, not an explicit `SELECT ... FOR UPDATE`. Appends for one
|
|
23
|
+
* job contend on its single accounting row, so the path is multi-instance safe
|
|
24
|
+
* but not lock-free.
|
|
25
|
+
*/
|
|
26
|
+
export declare function appendLogs(params: AppendLogsParams): Promise<AppendLogsResult>;
|
|
27
|
+
export {};
|
|
28
|
+
//# sourceMappingURL=append-logs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"append-logs.d.ts","sourceRoot":"","sources":["../../src/core/append-logs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,MAAM,EAAC,MAAM,aAAa,CAAC;AAgCnC,KAAK,UAAU,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAC,GAAG,SAAS,CAAC,CAAC;AAI7F,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,IAAI,CAMtE;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,gBAAgB;IAC/B,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,OAAO,CAAC;CACjB;AAmND;;;;;GAKG;AACH,wBAAsB,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAmGpF"}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { DEFAULT_HARNESS, harnessSchema } from '@shipfox/api-agent-dto';
|
|
3
|
+
import { parseLogRecordLine, parseRawLogRecordLine } from '@shipfox/api-logs-dto';
|
|
4
|
+
import { getStepById } from '@shipfox/api-workflows';
|
|
5
|
+
import { logger } from '@shipfox/node-opentelemetry';
|
|
6
|
+
import { config } from '#config.js';
|
|
7
|
+
import { accrueStoredBytes, claimCap, ensureJobAccounting, isJobCapped } from '#db/accounting.js';
|
|
8
|
+
import { insertChunk } from '#db/chunks.js';
|
|
9
|
+
import { db } from '#db/db.js';
|
|
10
|
+
import { casExtendCommittedLength, getAttemptStream, getOrCreateAttemptStreamWithStatus, setDeclaredTotalBytes } from '#db/streams.js';
|
|
11
|
+
import { recordAppendedCount, streamClosedCount, streamOpenedCount } from '#metrics/instance.js';
|
|
12
|
+
import { allowedBudget } from './budget.js';
|
|
13
|
+
import { closeStream, controlTombstone } from './close-stream.js';
|
|
14
|
+
import { MalformedLogChunkError, OffsetGapError } from './errors.js';
|
|
15
|
+
import { parseSessionRecord } from './session/parse-session.js';
|
|
16
|
+
let stepLookup = getStepById;
|
|
17
|
+
export function setStepLookupForTesting(lookup) {
|
|
18
|
+
const previous = stepLookup;
|
|
19
|
+
stepLookup = lookup;
|
|
20
|
+
return ()=>{
|
|
21
|
+
stepLookup = previous;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Pure pre-transaction parse. Requires whole, newline-terminated lines so
|
|
26
|
+
* `committed_length` always lands on a line boundary (one body = one chunk = whole
|
|
27
|
+
* lines; no line ever spans two chunks). Runs before any lock is taken, so a
|
|
28
|
+
* malformed or large body never holds a row. The offset CAS uses the raw append byte length; the
|
|
29
|
+
* budget charges the normalized body built from these parsed records.
|
|
30
|
+
*
|
|
31
|
+
* Each line is validated against the raw record union (a forged server-only
|
|
32
|
+
* `capped`/`runner_lost` fails here); the declared total is pulled from an `end`
|
|
33
|
+
* record. A line that is a valid server-only record under the read union surfaces
|
|
34
|
+
* its type via `forgedType` for the narrowed audit warn.
|
|
35
|
+
*/ function parseAppendBody(body) {
|
|
36
|
+
if (body.length === 0) return {
|
|
37
|
+
records: [],
|
|
38
|
+
hasAgentSessionRecord: false
|
|
39
|
+
};
|
|
40
|
+
const text = body.toString('utf8');
|
|
41
|
+
if (!text.endsWith('\n')) {
|
|
42
|
+
throw new MalformedLogChunkError('append body must end with a newline (whole records only)');
|
|
43
|
+
}
|
|
44
|
+
const lines = text.split('\n');
|
|
45
|
+
lines.pop();
|
|
46
|
+
let declaredTotalBytes;
|
|
47
|
+
const records = [];
|
|
48
|
+
let hasAgentSessionRecord = false;
|
|
49
|
+
for (const line of lines){
|
|
50
|
+
let record;
|
|
51
|
+
try {
|
|
52
|
+
record = parseRawLogRecordLine(line);
|
|
53
|
+
} catch {
|
|
54
|
+
throw new MalformedLogChunkError('append body contains an invalid NDJSON record', detectForgedType(line));
|
|
55
|
+
}
|
|
56
|
+
records.push(record);
|
|
57
|
+
if (record.type === 'end') {
|
|
58
|
+
declaredTotalBytes = record.total_bytes;
|
|
59
|
+
}
|
|
60
|
+
// An agent_session line is one whole entry in one record; bound its size here (the DTO
|
|
61
|
+
// schema leaves `data` uncapped because it cannot read this runtime config). An over-cap
|
|
62
|
+
// line is rejected so a single entry can never blow the request body or the spool window.
|
|
63
|
+
if (record.type === 'agent_session' && Buffer.byteLength(line, 'utf8') > config.LOG_MAX_SESSION_LINE_BYTES) {
|
|
64
|
+
throw new MalformedLogChunkError(`agent_session line exceeds ${config.LOG_MAX_SESSION_LINE_BYTES} bytes`);
|
|
65
|
+
}
|
|
66
|
+
if (record.type === 'agent_session') {
|
|
67
|
+
hasAgentSessionRecord = true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return declaredTotalBytes === undefined ? {
|
|
71
|
+
records,
|
|
72
|
+
hasAgentSessionRecord
|
|
73
|
+
} : {
|
|
74
|
+
declaredTotalBytes,
|
|
75
|
+
records,
|
|
76
|
+
hasAgentSessionRecord
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A line that fails the raw write union but is a valid record under the read union
|
|
81
|
+
* can only be a server-only `capped`/`runner_lost` tombstone — i.e. a forgery
|
|
82
|
+
* attempt. Returns its type for the audit warn, or undefined for plain garbage.
|
|
83
|
+
*/ function detectForgedType(line) {
|
|
84
|
+
try {
|
|
85
|
+
return parseLogRecordLine(line).type;
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function getSessionHarness(stepId) {
|
|
91
|
+
const step = await stepLookup(stepId);
|
|
92
|
+
const parsed = harnessSchema.safeParse(step?.config.harness);
|
|
93
|
+
return parsed.success ? parsed.data : DEFAULT_HARNESS;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Empty-body heartbeat: report the current committed length without materializing
|
|
97
|
+
* a stream, so a runner cannot mint unbounded rows with empty appends.
|
|
98
|
+
*/ async function readHeartbeat(tx, params) {
|
|
99
|
+
const existing = await getAttemptStream(tx, {
|
|
100
|
+
jobId: params.jobId,
|
|
101
|
+
stepId: params.stepId,
|
|
102
|
+
attempt: params.attempt
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
committedLength: existing?.committedLength ?? 0,
|
|
106
|
+
capped: await isJobCapped(tx, params.jobId)
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Accrues the stored bytes, persists the chunk, and trips the per-job cap when
|
|
111
|
+
* this append crosses the budget. Runs only after the offset-CAS extended
|
|
112
|
+
* `committed_length`, so the committed length already reflects the accepted bytes.
|
|
113
|
+
*/ async function storeChunk(tx, { params, streamId, body, committedLength, declaredTotalBytes }) {
|
|
114
|
+
await ensureJobAccounting(tx, {
|
|
115
|
+
jobId: params.jobId,
|
|
116
|
+
workspaceId: params.workspaceId
|
|
117
|
+
});
|
|
118
|
+
const storedByteLen = body.length;
|
|
119
|
+
const accrued = await accrueStoredBytes(tx, {
|
|
120
|
+
jobId: params.jobId,
|
|
121
|
+
delta: storedByteLen
|
|
122
|
+
});
|
|
123
|
+
// Already capped: accept-and-drop. committed_length has advanced so the runner
|
|
124
|
+
// drains its spool cleanly instead of retry-looping; nothing is stored.
|
|
125
|
+
if (!accrued) return {
|
|
126
|
+
committedLength,
|
|
127
|
+
capped: true,
|
|
128
|
+
stored: false,
|
|
129
|
+
recordCounts: {}
|
|
130
|
+
};
|
|
131
|
+
await insertChunk(tx, {
|
|
132
|
+
streamId,
|
|
133
|
+
streamOffset: params.offset,
|
|
134
|
+
byteLen: storedByteLen,
|
|
135
|
+
data: body,
|
|
136
|
+
origin: 'runner'
|
|
137
|
+
});
|
|
138
|
+
if (declaredTotalBytes !== undefined) {
|
|
139
|
+
await setDeclaredTotalBytes(tx, {
|
|
140
|
+
streamId,
|
|
141
|
+
declaredTotalBytes
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const allowed = allowedBudget({
|
|
145
|
+
baseBytes: config.LOG_BUDGET_BASE_BYTES,
|
|
146
|
+
ratePerMinuteBytes: config.LOG_BUDGET_RATE_BYTES_PER_MINUTE,
|
|
147
|
+
elapsedMs: Date.now() - accrued.startedAt.getTime()
|
|
148
|
+
});
|
|
149
|
+
if (accrued.used <= allowed) {
|
|
150
|
+
return {
|
|
151
|
+
committedLength,
|
|
152
|
+
capped: false,
|
|
153
|
+
stored: true,
|
|
154
|
+
recordCounts: {}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// Over budget. No hard ceiling: this crossing append is stored in full (overshoot
|
|
158
|
+
// bounded by one body). Claim the cap once and inject an in-band `capped` tombstone
|
|
159
|
+
// for the winner.
|
|
160
|
+
const won = await claimCap(tx, params.jobId);
|
|
161
|
+
if (won) {
|
|
162
|
+
const tombstone = controlTombstone('capped');
|
|
163
|
+
await insertChunk(tx, {
|
|
164
|
+
streamId,
|
|
165
|
+
streamOffset: committedLength,
|
|
166
|
+
byteLen: tombstone.length,
|
|
167
|
+
data: tombstone,
|
|
168
|
+
origin: 'control'
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
committedLength,
|
|
173
|
+
capped: true,
|
|
174
|
+
stored: true,
|
|
175
|
+
recordCounts: won ? {
|
|
176
|
+
capped: 1
|
|
177
|
+
} : {}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function buildStoredBody(records, harness) {
|
|
181
|
+
const storedRecords = [];
|
|
182
|
+
for (const record of records){
|
|
183
|
+
if (record.type !== 'agent_session') {
|
|
184
|
+
storedRecords.push(record);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
for (const row of parseSessionRecord(agentSessionRecord(record), harness)){
|
|
188
|
+
storedRecords.push({
|
|
189
|
+
v: 1,
|
|
190
|
+
ts: row.timestamp,
|
|
191
|
+
type: 'agent_session',
|
|
192
|
+
row
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const body = Buffer.from(storedRecords.map((record)=>`${JSON.stringify(record)}\n`).join(''));
|
|
197
|
+
const recordCounts = {};
|
|
198
|
+
for (const record of storedRecords){
|
|
199
|
+
recordCounts[record.type] = (recordCounts[record.type] ?? 0) + 1;
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
body,
|
|
203
|
+
recordCounts
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function agentSessionRecord(record) {
|
|
207
|
+
return {
|
|
208
|
+
data: record.data,
|
|
209
|
+
ts: record.ts
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Concurrency is serialized through Postgres row locks taken implicitly by the
|
|
214
|
+
* conditional UPDATEs, not an explicit `SELECT ... FOR UPDATE`. Appends for one
|
|
215
|
+
* job contend on its single accounting row, so the path is multi-instance safe
|
|
216
|
+
* but not lock-free.
|
|
217
|
+
*/ export async function appendLogs(params) {
|
|
218
|
+
let parsed;
|
|
219
|
+
try {
|
|
220
|
+
parsed = parseAppendBody(params.body);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
// Narrowed audit: only the detectable forgery case, never the payload or a token.
|
|
223
|
+
if (error instanceof MalformedLogChunkError && error.forgedType !== undefined) {
|
|
224
|
+
logger().warn({
|
|
225
|
+
jobId: params.jobId,
|
|
226
|
+
stepId: params.stepId,
|
|
227
|
+
offendingType: error.forgedType
|
|
228
|
+
}, 'Rejected forged server-only log record on append');
|
|
229
|
+
}
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
const { declaredTotalBytes } = parsed;
|
|
233
|
+
const sessionHarness = parsed.hasAgentSessionRecord ? await getSessionHarness(params.stepId) : DEFAULT_HARNESS;
|
|
234
|
+
const stored = buildStoredBody(parsed.records, sessionHarness);
|
|
235
|
+
const commitByteLen = params.body.length;
|
|
236
|
+
const metrics = {
|
|
237
|
+
recordCounts: {},
|
|
238
|
+
streamClosedReason: undefined,
|
|
239
|
+
streamOpened: false
|
|
240
|
+
};
|
|
241
|
+
const result = await db().transaction(async (tx)=>{
|
|
242
|
+
if (commitByteLen === 0) return readHeartbeat(tx, params);
|
|
243
|
+
const { created, stream } = await getOrCreateAttemptStreamWithStatus(tx, {
|
|
244
|
+
jobId: params.jobId,
|
|
245
|
+
stepId: params.stepId,
|
|
246
|
+
attempt: params.attempt,
|
|
247
|
+
workspaceId: params.workspaceId,
|
|
248
|
+
projectId: params.projectId,
|
|
249
|
+
workflowRunAttemptId: params.workflowRunAttemptId
|
|
250
|
+
});
|
|
251
|
+
metrics.streamOpened = created;
|
|
252
|
+
// Closed stream (the runner's end already landed, or the job-terminated sweep ran):
|
|
253
|
+
// accept-and-drop so a late chunk can never race compaction. committed_length is
|
|
254
|
+
// frozen at close, so this reports the final offset and the runner stops cleanly.
|
|
255
|
+
if (stream.state === 'closed') {
|
|
256
|
+
return {
|
|
257
|
+
committedLength: stream.committedLength,
|
|
258
|
+
capped: await isJobCapped(tx, params.jobId)
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const cas = await casExtendCommittedLength(tx, {
|
|
262
|
+
streamId: stream.id,
|
|
263
|
+
offset: params.offset,
|
|
264
|
+
byteLen: commitByteLen
|
|
265
|
+
});
|
|
266
|
+
if (cas.outcome === 'gap') throw new OffsetGapError(cas.committedLength);
|
|
267
|
+
if (cas.outcome === 'retry') {
|
|
268
|
+
return {
|
|
269
|
+
committedLength: cas.committedLength,
|
|
270
|
+
capped: await isJobCapped(tx, params.jobId)
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const { recordCounts, stored: chunkStored, ...result } = await storeChunk(tx, {
|
|
274
|
+
params,
|
|
275
|
+
streamId: stream.id,
|
|
276
|
+
body: stored.body,
|
|
277
|
+
committedLength: cas.committedLength,
|
|
278
|
+
declaredTotalBytes
|
|
279
|
+
});
|
|
280
|
+
if (chunkStored) {
|
|
281
|
+
addRecordCounts(metrics.recordCounts, stored.recordCounts);
|
|
282
|
+
}
|
|
283
|
+
addRecordCounts(metrics.recordCounts, recordCounts);
|
|
284
|
+
// The runner's end record was committed in this append (offset-CAS guarantees
|
|
285
|
+
// everything before it is already committed), so the stream is whole. Declared-close
|
|
286
|
+
// it in-band so compaction starts at once instead of waiting for the timeout sweep.
|
|
287
|
+
// Only when the chunk was actually stored: an end body dropped because the job was
|
|
288
|
+
// already capped persists nothing, so the stream is not whole and stays open for the
|
|
289
|
+
// timeout sweep to close it as truncated.
|
|
290
|
+
if (declaredTotalBytes !== undefined && chunkStored) {
|
|
291
|
+
const closed = await closeStream(tx, {
|
|
292
|
+
streamId: stream.id,
|
|
293
|
+
reason: 'declared'
|
|
294
|
+
});
|
|
295
|
+
if (closed) metrics.streamClosedReason = 'declared';
|
|
296
|
+
}
|
|
297
|
+
return result;
|
|
298
|
+
});
|
|
299
|
+
if (metrics.streamOpened) streamOpenedCount.add(1);
|
|
300
|
+
for (const [kind, count] of Object.entries(metrics.recordCounts)){
|
|
301
|
+
if (count > 0) recordAppendedCount.add(count, {
|
|
302
|
+
kind: kind
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
if (metrics.streamClosedReason) {
|
|
306
|
+
streamClosedCount.add(1, {
|
|
307
|
+
reason: metrics.streamClosedReason
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
function addRecordCounts(target, source) {
|
|
313
|
+
for (const [kind, count] of Object.entries(source)){
|
|
314
|
+
target[kind] = (target[kind] ?? 0) + (count ?? 0);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
//# sourceMappingURL=append-logs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/append-logs.ts"],"sourcesContent":["import {Buffer} from 'node:buffer';\nimport {DEFAULT_HARNESS, type Harness, harnessSchema} from '@shipfox/api-agent-dto';\nimport {\n type LogRecord,\n parseLogRecordLine,\n parseRawLogRecordLine,\n type RawLogRecord,\n} from '@shipfox/api-logs-dto';\nimport {getStepById} from '@shipfox/api-workflows';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport {config} from '#config.js';\nimport {accrueStoredBytes, claimCap, ensureJobAccounting, isJobCapped} from '#db/accounting.js';\nimport {insertChunk} from '#db/chunks.js';\nimport {db, type Transaction} from '#db/db.js';\nimport {\n casExtendCommittedLength,\n getAttemptStream,\n getOrCreateAttemptStreamWithStatus,\n setDeclaredTotalBytes,\n} from '#db/streams.js';\nimport {\n type LogRecordMetricKind,\n recordAppendedCount,\n streamClosedCount,\n streamOpenedCount,\n} from '#metrics/instance.js';\nimport {allowedBudget} from './budget.js';\nimport {closeStream, controlTombstone} from './close-stream.js';\nimport {MalformedLogChunkError, OffsetGapError} from './errors.js';\nimport {parseSessionRecord} from './session/parse-session.js';\nimport type {AgentSessionRecord} from './session/session-record.js';\n\ntype StepLookup = (stepId: string) => Promise<{config: Record<string, unknown>} | undefined>;\n\nlet stepLookup: StepLookup = getStepById;\n\nexport function setStepLookupForTesting(lookup: StepLookup): () => void {\n const previous = stepLookup;\n stepLookup = lookup;\n return () => {\n stepLookup = previous;\n };\n}\n\nexport interface AppendLogsParams {\n jobId: string;\n workspaceId: string;\n projectId: string;\n workflowRunAttemptId: string;\n stepId: string;\n attempt: number;\n offset: number;\n body: Buffer;\n}\n\nexport interface AppendLogsResult {\n committedLength: number;\n capped: boolean;\n}\n\ninterface ParsedBody {\n declaredTotalBytes?: number;\n records: RawLogRecord[];\n hasAgentSessionRecord: boolean;\n}\n\n/**\n * Pure pre-transaction parse. Requires whole, newline-terminated lines so\n * `committed_length` always lands on a line boundary (one body = one chunk = whole\n * lines; no line ever spans two chunks). Runs before any lock is taken, so a\n * malformed or large body never holds a row. The offset CAS uses the raw append byte length; the\n * budget charges the normalized body built from these parsed records.\n *\n * Each line is validated against the raw record union (a forged server-only\n * `capped`/`runner_lost` fails here); the declared total is pulled from an `end`\n * record. A line that is a valid server-only record under the read union surfaces\n * its type via `forgedType` for the narrowed audit warn.\n */\nfunction parseAppendBody(body: Buffer): ParsedBody {\n if (body.length === 0) return {records: [], hasAgentSessionRecord: false};\n\n const text = body.toString('utf8');\n if (!text.endsWith('\\n')) {\n throw new MalformedLogChunkError('append body must end with a newline (whole records only)');\n }\n const lines = text.split('\\n');\n lines.pop();\n\n let declaredTotalBytes: number | undefined;\n const records: RawLogRecord[] = [];\n let hasAgentSessionRecord = false;\n for (const line of lines) {\n let record: ReturnType<typeof parseRawLogRecordLine>;\n try {\n record = parseRawLogRecordLine(line);\n } catch {\n throw new MalformedLogChunkError(\n 'append body contains an invalid NDJSON record',\n detectForgedType(line),\n );\n }\n records.push(record);\n if (record.type === 'end') {\n declaredTotalBytes = record.total_bytes;\n }\n // An agent_session line is one whole entry in one record; bound its size here (the DTO\n // schema leaves `data` uncapped because it cannot read this runtime config). An over-cap\n // line is rejected so a single entry can never blow the request body or the spool window.\n if (\n record.type === 'agent_session' &&\n Buffer.byteLength(line, 'utf8') > config.LOG_MAX_SESSION_LINE_BYTES\n ) {\n throw new MalformedLogChunkError(\n `agent_session line exceeds ${config.LOG_MAX_SESSION_LINE_BYTES} bytes`,\n );\n }\n if (record.type === 'agent_session') {\n hasAgentSessionRecord = true;\n }\n }\n\n return declaredTotalBytes === undefined\n ? {records, hasAgentSessionRecord}\n : {declaredTotalBytes, records, hasAgentSessionRecord};\n}\n\n/**\n * A line that fails the raw write union but is a valid record under the read union\n * can only be a server-only `capped`/`runner_lost` tombstone — i.e. a forgery\n * attempt. Returns its type for the audit warn, or undefined for plain garbage.\n */\nfunction detectForgedType(line: string): string | undefined {\n try {\n return parseLogRecordLine(line).type;\n } catch {\n return undefined;\n }\n}\n\nasync function getSessionHarness(stepId: string): Promise<Harness> {\n const step = await stepLookup(stepId);\n const parsed = harnessSchema.safeParse(step?.config.harness);\n return parsed.success ? parsed.data : DEFAULT_HARNESS;\n}\n\n/**\n * Empty-body heartbeat: report the current committed length without materializing\n * a stream, so a runner cannot mint unbounded rows with empty appends.\n */\nasync function readHeartbeat(tx: Transaction, params: AppendLogsParams): Promise<AppendLogsResult> {\n const existing = await getAttemptStream(tx, {\n jobId: params.jobId,\n stepId: params.stepId,\n attempt: params.attempt,\n });\n return {\n committedLength: existing?.committedLength ?? 0,\n capped: await isJobCapped(tx, params.jobId),\n };\n}\n\ninterface StoreChunkParams {\n params: AppendLogsParams;\n streamId: string;\n body: Buffer;\n committedLength: number;\n declaredTotalBytes: number | undefined;\n}\n\ninterface StoreChunkResult extends AppendLogsResult {\n /**\n * Whether the runner chunk was persisted. False only when the job was already\n * capped and the body (including any `end` record) was dropped, so the stream is\n * not whole and must not be declared-closed.\n */\n stored: boolean;\n recordCounts: Partial<Record<LogRecord['type'], number>>;\n}\n\n/**\n * Accrues the stored bytes, persists the chunk, and trips the per-job cap when\n * this append crosses the budget. Runs only after the offset-CAS extended\n * `committed_length`, so the committed length already reflects the accepted bytes.\n */\nasync function storeChunk(\n tx: Transaction,\n {params, streamId, body, committedLength, declaredTotalBytes}: StoreChunkParams,\n): Promise<StoreChunkResult> {\n await ensureJobAccounting(tx, {jobId: params.jobId, workspaceId: params.workspaceId});\n const storedByteLen = body.length;\n const accrued = await accrueStoredBytes(tx, {jobId: params.jobId, delta: storedByteLen});\n\n // Already capped: accept-and-drop. committed_length has advanced so the runner\n // drains its spool cleanly instead of retry-looping; nothing is stored.\n if (!accrued) return {committedLength, capped: true, stored: false, recordCounts: {}};\n\n await insertChunk(tx, {\n streamId,\n streamOffset: params.offset,\n byteLen: storedByteLen,\n data: body,\n origin: 'runner',\n });\n if (declaredTotalBytes !== undefined) {\n await setDeclaredTotalBytes(tx, {streamId, declaredTotalBytes});\n }\n\n const allowed = allowedBudget({\n baseBytes: config.LOG_BUDGET_BASE_BYTES,\n ratePerMinuteBytes: config.LOG_BUDGET_RATE_BYTES_PER_MINUTE,\n elapsedMs: Date.now() - accrued.startedAt.getTime(),\n });\n if (accrued.used <= allowed) {\n return {committedLength, capped: false, stored: true, recordCounts: {}};\n }\n\n // Over budget. No hard ceiling: this crossing append is stored in full (overshoot\n // bounded by one body). Claim the cap once and inject an in-band `capped` tombstone\n // for the winner.\n const won = await claimCap(tx, params.jobId);\n if (won) {\n const tombstone = controlTombstone('capped');\n await insertChunk(tx, {\n streamId,\n streamOffset: committedLength,\n byteLen: tombstone.length,\n data: tombstone,\n origin: 'control',\n });\n }\n return {\n committedLength,\n capped: true,\n stored: true,\n recordCounts: won ? {capped: 1} : {},\n };\n}\n\nfunction buildStoredBody(\n records: readonly RawLogRecord[],\n harness: Harness,\n): {body: Buffer; recordCounts: Partial<Record<LogRecord['type'], number>>} {\n const storedRecords: LogRecord[] = [];\n for (const record of records) {\n if (record.type !== 'agent_session') {\n storedRecords.push(record);\n continue;\n }\n\n for (const row of parseSessionRecord(agentSessionRecord(record), harness)) {\n storedRecords.push({v: 1, ts: row.timestamp, type: 'agent_session', row});\n }\n }\n\n const body = Buffer.from(storedRecords.map((record) => `${JSON.stringify(record)}\\n`).join(''));\n const recordCounts: Partial<Record<LogRecord['type'], number>> = {};\n for (const record of storedRecords) {\n recordCounts[record.type] = (recordCounts[record.type] ?? 0) + 1;\n }\n\n return {body, recordCounts};\n}\n\nfunction agentSessionRecord(\n record: Extract<RawLogRecord, {type: 'agent_session'}>,\n): AgentSessionRecord {\n return {data: record.data, ts: record.ts};\n}\n\n/**\n * Concurrency is serialized through Postgres row locks taken implicitly by the\n * conditional UPDATEs, not an explicit `SELECT ... FOR UPDATE`. Appends for one\n * job contend on its single accounting row, so the path is multi-instance safe\n * but not lock-free.\n */\nexport async function appendLogs(params: AppendLogsParams): Promise<AppendLogsResult> {\n let parsed: ParsedBody;\n try {\n parsed = parseAppendBody(params.body);\n } catch (error) {\n // Narrowed audit: only the detectable forgery case, never the payload or a token.\n if (error instanceof MalformedLogChunkError && error.forgedType !== undefined) {\n logger().warn(\n {\n jobId: params.jobId,\n stepId: params.stepId,\n offendingType: error.forgedType,\n },\n 'Rejected forged server-only log record on append',\n );\n }\n throw error;\n }\n const {declaredTotalBytes} = parsed;\n const sessionHarness = parsed.hasAgentSessionRecord\n ? await getSessionHarness(params.stepId)\n : DEFAULT_HARNESS;\n const stored = buildStoredBody(parsed.records, sessionHarness);\n const commitByteLen = params.body.length;\n const metrics = {\n recordCounts: {} as Partial<Record<LogRecordMetricKind, number>>,\n streamClosedReason: undefined as 'declared' | undefined,\n streamOpened: false,\n };\n\n const result = await db().transaction(async (tx) => {\n if (commitByteLen === 0) return readHeartbeat(tx, params);\n\n const {created, stream} = await getOrCreateAttemptStreamWithStatus(tx, {\n jobId: params.jobId,\n stepId: params.stepId,\n attempt: params.attempt,\n workspaceId: params.workspaceId,\n projectId: params.projectId,\n workflowRunAttemptId: params.workflowRunAttemptId,\n });\n metrics.streamOpened = created;\n\n // Closed stream (the runner's end already landed, or the job-terminated sweep ran):\n // accept-and-drop so a late chunk can never race compaction. committed_length is\n // frozen at close, so this reports the final offset and the runner stops cleanly.\n if (stream.state === 'closed') {\n return {committedLength: stream.committedLength, capped: await isJobCapped(tx, params.jobId)};\n }\n\n const cas = await casExtendCommittedLength(tx, {\n streamId: stream.id,\n offset: params.offset,\n byteLen: commitByteLen,\n });\n if (cas.outcome === 'gap') throw new OffsetGapError(cas.committedLength);\n if (cas.outcome === 'retry') {\n return {committedLength: cas.committedLength, capped: await isJobCapped(tx, params.jobId)};\n }\n\n const {\n recordCounts,\n stored: chunkStored,\n ...result\n } = await storeChunk(tx, {\n params,\n streamId: stream.id,\n body: stored.body,\n committedLength: cas.committedLength,\n declaredTotalBytes,\n });\n if (chunkStored) {\n addRecordCounts(metrics.recordCounts, stored.recordCounts);\n }\n addRecordCounts(metrics.recordCounts, recordCounts);\n\n // The runner's end record was committed in this append (offset-CAS guarantees\n // everything before it is already committed), so the stream is whole. Declared-close\n // it in-band so compaction starts at once instead of waiting for the timeout sweep.\n // Only when the chunk was actually stored: an end body dropped because the job was\n // already capped persists nothing, so the stream is not whole and stays open for the\n // timeout sweep to close it as truncated.\n if (declaredTotalBytes !== undefined && chunkStored) {\n const closed = await closeStream(tx, {streamId: stream.id, reason: 'declared'});\n if (closed) metrics.streamClosedReason = 'declared';\n }\n\n return result;\n });\n\n if (metrics.streamOpened) streamOpenedCount.add(1);\n for (const [kind, count] of Object.entries(metrics.recordCounts)) {\n if (count > 0) recordAppendedCount.add(count, {kind: kind as LogRecordMetricKind});\n }\n if (metrics.streamClosedReason) {\n streamClosedCount.add(1, {reason: metrics.streamClosedReason});\n }\n\n return result;\n}\n\nfunction addRecordCounts(\n target: Partial<Record<LogRecordMetricKind, number>>,\n source: Partial<Record<LogRecordMetricKind, number>>,\n): void {\n for (const [kind, count] of Object.entries(source)) {\n target[kind as LogRecordMetricKind] = (target[kind as LogRecordMetricKind] ?? 0) + (count ?? 0);\n }\n}\n"],"names":["Buffer","DEFAULT_HARNESS","harnessSchema","parseLogRecordLine","parseRawLogRecordLine","getStepById","logger","config","accrueStoredBytes","claimCap","ensureJobAccounting","isJobCapped","insertChunk","db","casExtendCommittedLength","getAttemptStream","getOrCreateAttemptStreamWithStatus","setDeclaredTotalBytes","recordAppendedCount","streamClosedCount","streamOpenedCount","allowedBudget","closeStream","controlTombstone","MalformedLogChunkError","OffsetGapError","parseSessionRecord","stepLookup","setStepLookupForTesting","lookup","previous","parseAppendBody","body","length","records","hasAgentSessionRecord","text","toString","endsWith","lines","split","pop","declaredTotalBytes","line","record","detectForgedType","push","type","total_bytes","byteLength","LOG_MAX_SESSION_LINE_BYTES","undefined","getSessionHarness","stepId","step","parsed","safeParse","harness","success","data","readHeartbeat","tx","params","existing","jobId","attempt","committedLength","capped","storeChunk","streamId","workspaceId","storedByteLen","accrued","delta","stored","recordCounts","streamOffset","offset","byteLen","origin","allowed","baseBytes","LOG_BUDGET_BASE_BYTES","ratePerMinuteBytes","LOG_BUDGET_RATE_BYTES_PER_MINUTE","elapsedMs","Date","now","startedAt","getTime","used","won","tombstone","buildStoredBody","storedRecords","row","agentSessionRecord","v","ts","timestamp","from","map","JSON","stringify","join","appendLogs","error","forgedType","warn","offendingType","sessionHarness","commitByteLen","metrics","streamClosedReason","streamOpened","result","transaction","created","stream","projectId","workflowRunAttemptId","state","cas","id","outcome","chunkStored","addRecordCounts","closed","reason","add","kind","count","Object","entries","target","source"],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface AllowedBudgetParams {
|
|
2
|
+
baseBytes: number;
|
|
3
|
+
ratePerMinuteBytes: number;
|
|
4
|
+
/** Time since the budget clock origin (first append) in milliseconds. */
|
|
5
|
+
elapsedMs: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Accrual budget in stored bytes: `base + rate * elapsedMinutes`, floored to an
|
|
9
|
+
* integer. No hard ceiling — job duration limits bound total volume.
|
|
10
|
+
*/
|
|
11
|
+
export declare function allowedBudget({ baseBytes, ratePerMinuteBytes, elapsedMs, }: AllowedBudgetParams): number;
|
|
12
|
+
//# sourceMappingURL=budget.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"budget.d.ts","sourceRoot":"","sources":["../../src/core/budget.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,yEAAyE;IACzE,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,EAC5B,SAAS,EACT,kBAAkB,EAClB,SAAS,GACV,EAAE,mBAAmB,GAAG,MAAM,CAG9B"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accrual budget in stored bytes: `base + rate * elapsedMinutes`, floored to an
|
|
3
|
+
* integer. No hard ceiling — job duration limits bound total volume.
|
|
4
|
+
*/ export function allowedBudget({ baseBytes, ratePerMinuteBytes, elapsedMs }) {
|
|
5
|
+
const elapsed = Math.max(0, elapsedMs);
|
|
6
|
+
return baseBytes + Math.floor(ratePerMinuteBytes * elapsed / 60_000);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
//# sourceMappingURL=budget.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/budget.ts"],"sourcesContent":["export interface AllowedBudgetParams {\n baseBytes: number;\n ratePerMinuteBytes: number;\n /** Time since the budget clock origin (first append) in milliseconds. */\n elapsedMs: number;\n}\n\n/**\n * Accrual budget in stored bytes: `base + rate * elapsedMinutes`, floored to an\n * integer. No hard ceiling — job duration limits bound total volume.\n */\nexport function allowedBudget({\n baseBytes,\n ratePerMinuteBytes,\n elapsedMs,\n}: AllowedBudgetParams): number {\n const elapsed = Math.max(0, elapsedMs);\n return baseBytes + Math.floor((ratePerMinuteBytes * elapsed) / 60_000);\n}\n"],"names":["allowedBudget","baseBytes","ratePerMinuteBytes","elapsedMs","elapsed","Math","max","floor"],"mappings":"AAOA;;;CAGC,GACD,OAAO,SAASA,cAAc,EAC5BC,SAAS,EACTC,kBAAkB,EAClBC,SAAS,EACW;IACpB,MAAMC,UAAUC,KAAKC,GAAG,CAAC,GAAGH;IAC5B,OAAOF,YAAYI,KAAKE,KAAK,CAAC,AAACL,qBAAqBE,UAAW;AACjE"}
|