@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
@@ -1,21 +1,21 @@
1
1
  $ shipfox-swc && shipfox-temporal-bundle dist/temporal/workflows/index.js
2
- Successfully compiled: 59 files with swc (130.15ms)
3
- 2026-07-19T19:41:38.063Z [INFO] asset workflow-bundle-2850a9a125f53b77d607.js 1.46 MiB [emitted] [immutable] (name: main)
4
- runtime modules 670 bytes 3 modules
5
- modules by path ../../../node_modules/.pnpm/ 424 KiB
6
- modules by path ../../../node_modules/.pnpm/@temporalio+common@1.18.1/node_modules/@temporal...(truncated) 155 KiB 34 modules
7
- modules by path ../../../node_modules/.pnpm/@temporalio+workflow@1.18.1/node_modules/@tempor...(truncated) 201 KiB 26 modules
8
- modules by path ../../../node_modules/.pnpm/nexus-rpc@0.0.2/node_modules/nexus-rpc/lib/ 22.3 KiB 13 modules
9
- + 2 modules
10
- modules by path ./dist/temporal/workflows/ 4.17 KiB
11
- ./dist/temporal/workflows/index-autogenerated-entrypoint.cjs 523 bytes [built] [code generated]
2
+ Successfully compiled: 59 files with swc (568.34ms)
3
+ 2026-07-22T13:11:51.110Z [INFO] asset workflow-bundle-17839eee3b21fbae5b8c.js 3.18 MiB [emitted] [immutable] (name: main)
4
+ orphan modules 33.5 KiB [orphan] 21 modules
5
+ runtime modules 1.13 KiB 5 modules
6
+ modules by path ../../../node_modules/.pnpm/ 970 KiB 196 modules
7
+ modules by path ./dist/temporal/workflows/ 4.57 KiB
8
+ ./dist/temporal/workflows/index-autogenerated-entrypoint.cjs 929 bytes [built] [code generated]
12
9
  ./dist/temporal/workflows/index.js 340 bytes [built] [code generated]
13
10
  ./dist/temporal/workflows/close-abandoned-streams.js 753 bytes [built] [code generated]
14
11
  ./dist/temporal/workflows/compact-stream.js 941 bytes [built] [code generated]
15
- + 3 modules
12
+ ./dist/temporal/workflows/compaction-reconcile-cron.js 559 bytes [built] [code generated]
13
+ ./dist/temporal/workflows/reap-stale-open-streams-cron.js 429 bytes [built] [code generated]
14
+ ./dist/temporal/workflows/retention-sweep-cron.js 727 bytes [built] [code generated]
16
15
  optional modules 30 bytes [optional]
17
16
  __temporal_custom_payload_converter (ignored) 15 bytes [optional] [built] [code generated]
18
17
  __temporal_custom_failure_converter (ignored) 15 bytes [optional] [built] [code generated]
19
- webpack 5.107.2 compiled successfully in 980 ms
20
- 2026-07-19T19:41:38.067Z [INFO] Workflow bundle created { size: '1.46MB' }
21
- /home/runner/work/shipfox/shipfox/libs/api/logs/dist/temporal/workflows/index.js: 1530555 bytes
18
+ ../../shared/node/temporal/dist/workflow-error-interceptor.js 1.21 KiB [built] [code generated]
19
+ webpack 5.107.2 compiled successfully in 8256 ms
20
+ 2026-07-22T13:11:51.130Z [INFO] Workflow bundle created { size: '3.18MB' }
21
+ /home/runner/work/shipfox/shipfox/libs/api/logs/dist/temporal/workflows/index.js: 3335222 bytes
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # @shipfox/api-logs
2
2
 
3
+ ## 7.1.0
4
+
5
+ ### Patch Changes
6
+
7
+ - ac42c96: Adds boundary-owned reporting for unexpected API runtime failures while preserving expected client and provider outcomes.
8
+ - Updated dependencies [ac42c96]
9
+ - Updated dependencies [6ce08c0]
10
+ - @shipfox/node-error-monitoring@0.2.0
11
+ - @shipfox/node-fastify@0.3.0
12
+ - @shipfox/node-module@0.5.0
13
+ - @shipfox/node-temporal@0.4.0
14
+ - @shipfox/node-opentelemetry@0.6.0
15
+ - @shipfox/api-auth-context@7.1.0
16
+
17
+ ## 6.0.0
18
+
19
+ ### Major Changes
20
+
21
+ - 23a4dc2: Moves Logs and Integrations to injected Workflows inter-module clients with minimal log and leased agent-tool queries.
22
+
23
+ ### Patch Changes
24
+
25
+ - 112c0fa: Adds the Auth inter-module token-minting contract and removes Auth implementation and configuration coupling from its consumers.
26
+ - f73da5d: Enforces bounded API context imports and routes inter-module consumers through producer contracts.
27
+ - Updated dependencies [0bb82a4]
28
+ - Updated dependencies [23563de]
29
+ - Updated dependencies [54ce48b]
30
+ - Updated dependencies [f4bc2eb]
31
+ - Updated dependencies [c0162b0]
32
+ - Updated dependencies [7ac43a4]
33
+ - Updated dependencies [a01e917]
34
+ - Updated dependencies [8bdc149]
35
+ - Updated dependencies [3810996]
36
+ - Updated dependencies [23a4dc2]
37
+ - Updated dependencies [b00ed29]
38
+ - Updated dependencies [8aa7cd3]
39
+ - Updated dependencies [81f9544]
40
+ - @shipfox/api-agent-dto@6.0.0
41
+ - @shipfox/api-workflows-dto@6.0.0
42
+ - @shipfox/node-module@0.4.0
43
+ - @shipfox/node-temporal@0.3.2
44
+ - @shipfox/node-drizzle@0.3.2
45
+ - @shipfox/node-outbox@0.2.4
46
+ - @shipfox/api-auth-context@6.0.0
47
+ - @shipfox/node-fastify@0.2.4
48
+
3
49
  ## 5.0.0
4
50
 
5
51
  ### Patch Changes
@@ -1 +1 @@
1
- {"version":3,"file":"object-storage.d.ts","sourceRoot":"","sources":["../../src/api/object-storage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,QAAQ,EAAC,MAAM,aAAa,CAAC;AAC1C,OAAO,EAML,QAAQ,EACT,MAAM,oBAAoB,CAAC;AAI5B,OAAO,EAAC,KAAK,kBAAkB,EAAe,MAAM,8BAA8B,CAAC;AAYnF,oFAAoF;AACpF,wBAAgB,QAAQ,IAAI,QAAQ,CAcnC;AAED,wBAAgB,aAAa,IAAI,IAAI,CAGpC;AAED,6HAA6H;AAC7H,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO7D;AAID;;;;;GAKG;AACH,wBAAgB,cAAc,IAAI,QAAQ,CAYzC;AAED,wBAAgB,mBAAmB,IAAI,IAAI,CAG1C;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAE5F;AAOD,MAAM,WAAW,wBAAwB;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,QAAQ,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAiCxF;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE7D;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,IAAI,CAAA;CAAC,CAAC,CAYhG;AAED;;;;;GAKG;AACH,wBAAsB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAmCzE"}
1
+ {"version":3,"file":"object-storage.d.ts","sourceRoot":"","sources":["../../src/api/object-storage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,QAAQ,EAAC,MAAM,aAAa,CAAC;AAC1C,OAAO,EAML,QAAQ,EACT,MAAM,oBAAoB,CAAC;AAM5B,OAAO,EAAC,KAAK,kBAAkB,EAAe,MAAM,8BAA8B,CAAC;AAYnF,oFAAoF;AACpF,wBAAgB,QAAQ,IAAI,QAAQ,CAcnC;AAED,wBAAgB,aAAa,IAAI,IAAI,CAGpC;AAED,6HAA6H;AAC7H,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO7D;AAID;;;;;GAKG;AACH,wBAAgB,cAAc,IAAI,QAAQ,CAYzC;AAED,wBAAgB,mBAAmB,IAAI,IAAI,CAG1C;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAE5F;AAOD,MAAM,WAAW,wBAAwB;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,QAAQ,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAkDxF;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE7D;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,IAAI,CAAA;CAAC,CAAC,CAYhG;AAED;;;;;GAKG;AACH,wBAAsB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAmCzE"}
@@ -1,6 +1,8 @@
1
1
  import { DeleteObjectCommand, DeleteObjectsCommand, GetObjectCommand, HeadBucketCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3';
2
2
  import { Upload } from '@aws-sdk/lib-storage';
3
3
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
4
+ import { reportError } from '@shipfox/node-error-monitoring';
5
+ import { logger } from '@shipfox/node-opentelemetry';
4
6
  import { config } from '#config.js';
5
7
  import { logObjectKey } from '#core/entities/log-object.js';
6
8
  const explicitS3Credentials = config.LOG_STORAGE_S3_ACCESS_KEY_ID && config.LOG_STORAGE_S3_SECRET_ACCESS_KEY ? {
@@ -114,12 +116,36 @@ const UPLOAD_QUEUE_SIZE = 2;
114
116
  // Subscribe before the aborted-check so a signal firing between them cannot be missed,
115
117
  // then handle the already-aborted case eagerly (an already-aborted signal never fires 'abort').
116
118
  params.signal.addEventListener('abort', ()=>{
117
- void upload.abort().catch(()=>undefined);
119
+ void upload.abort().catch((error)=>{
120
+ logger().error({
121
+ err: error,
122
+ objectKey: params.key
123
+ }, 'Failed to abort multipart log upload');
124
+ reportError(error, {
125
+ boundary: 'logs.cleanup',
126
+ operation: 'abort-multipart-upload',
127
+ extra: {
128
+ objectKey: params.key
129
+ }
130
+ });
131
+ });
118
132
  }, {
119
133
  once: true
120
134
  });
121
135
  if (params.signal.aborted) {
122
- await upload.abort().catch(()=>undefined);
136
+ await upload.abort().catch((error)=>{
137
+ logger().error({
138
+ err: error,
139
+ objectKey: params.key
140
+ }, 'Failed to abort multipart log upload');
141
+ reportError(error, {
142
+ boundary: 'logs.cleanup',
143
+ operation: 'abort-multipart-upload',
144
+ extra: {
145
+ objectKey: params.key
146
+ }
147
+ });
148
+ });
123
149
  throw new Error('Compaction upload aborted before it started');
124
150
  }
125
151
  }
@@ -1 +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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"}
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 {reportError} from '@shipfox/node-error-monitoring';\nimport {logger} from '@shipfox/node-opentelemetry';\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((error) => {\n logger().error(\n {err: error, objectKey: params.key},\n 'Failed to abort multipart log upload',\n );\n reportError(error, {\n boundary: 'logs.cleanup',\n operation: 'abort-multipart-upload',\n extra: {objectKey: params.key},\n });\n });\n },\n {once: true},\n );\n if (params.signal.aborted) {\n await upload.abort().catch((error) => {\n logger().error({err: error, objectKey: params.key}, 'Failed to abort multipart log upload');\n reportError(error, {\n boundary: 'logs.cleanup',\n operation: 'abort-multipart-upload',\n extra: {objectKey: params.key},\n });\n });\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","reportError","logger","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","error","err","objectKey","boundary","operation","extra","once","aborted","Error","done","deleteObject","presignedGetUrl","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 CHANGED
@@ -17,4 +17,5 @@ export declare const config: Readonly<{
17
17
  LOG_READ_INLINE_MAX_BYTES: number;
18
18
  LOG_RETENTION_DAYS: number;
19
19
  } & import("@shipfox/config").CleanedEnvAccessors>;
20
+ export declare function validateLogStreamReapAfterSeconds(jobLeaseTokenTtlSeconds: number): void;
20
21
  //# sourceMappingURL=config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;kDAqEjB,CAAC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;kDAqEjB,CAAC;AA4CH,wBAAgB,iCAAiC,CAAC,uBAAuB,EAAE,MAAM,GAAG,IAAI,CASvF"}
package/dist/config.js CHANGED
@@ -1,6 +1,4 @@
1
- import { config as authConfig } from '@shipfox/api-auth/config';
2
1
  import { bool, createConfig, num, str, url } from '@shipfox/config';
3
- import { durationToSeconds } from '@shipfox/node-jwt';
4
2
  export const config = createConfig({
5
3
  LOG_STORAGE_S3_ENDPOINT: url({
6
4
  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.',
@@ -47,7 +45,7 @@ export const config = createConfig({
47
45
  default: 900
48
46
  }),
49
47
  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).',
48
+ 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. The application validates that this exceeds the Auth job lease lifetime. Defaults to 7200 seconds (2 hours).',
51
49
  default: 7200
52
50
  }),
53
51
  LOG_APPEND_BODY_LIMIT_BYTES: num({
@@ -96,13 +94,10 @@ if (config.LOG_APPEND_BODY_LIMIT_BYTES < config.LOG_MAX_SESSION_LINE_BYTES) {
96
94
  if (!Number.isInteger(config.LOG_RETENTION_DAYS) || config.LOG_RETENTION_DAYS < 1) {
97
95
  throw new Error(`LOG_RETENTION_DAYS (${config.LOG_RETENTION_DAYS}) must be a whole number of days >= 1.`);
98
96
  }
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.`);
97
+ export function validateLogStreamReapAfterSeconds(jobLeaseTokenTtlSeconds) {
98
+ if (!Number.isFinite(config.LOG_STREAM_REAP_AFTER_SECONDS) || config.LOG_STREAM_REAP_AFTER_SECONDS <= jobLeaseTokenTtlSeconds) {
99
+ throw new Error(`LOG_STREAM_REAP_AFTER_SECONDS (${config.LOG_STREAM_REAP_AFTER_SECONDS}) must be greater than the Auth job lease TTL (${jobLeaseTokenTtlSeconds}s); a smaller value would let the reaper force-close a stream a still-valid lease is appending to and silently truncate live logs.`);
100
+ }
106
101
  }
107
102
 
108
103
  //# sourceMappingURL=config.js.map
@@ -1 +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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"}
1
+ {"version":3,"sources":["../src/config.ts"],"sourcesContent":["import {bool, createConfig, num, str, url} from '@shipfox/config';\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. The application validates that this exceeds the Auth job lease lifetime. 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\nexport function validateLogStreamReapAfterSeconds(jobLeaseTokenTtlSeconds: number): void {\n if (\n !Number.isFinite(config.LOG_STREAM_REAP_AFTER_SECONDS) ||\n config.LOG_STREAM_REAP_AFTER_SECONDS <= jobLeaseTokenTtlSeconds\n ) {\n throw new Error(\n `LOG_STREAM_REAP_AFTER_SECONDS (${config.LOG_STREAM_REAP_AFTER_SECONDS}) must be greater than the Auth job lease TTL (${jobLeaseTokenTtlSeconds}s); 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}\n"],"names":["bool","createConfig","num","str","url","config","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","validateLogStreamReapAfterSeconds","jobLeaseTokenTtlSeconds","isFinite"],"mappings":"AAAA,SAAQA,IAAI,EAAEC,YAAY,EAAEC,GAAG,EAAEC,GAAG,EAAEC,GAAG,QAAO,kBAAkB;AAElE,OAAO,MAAMC,SAASJ,aAAa;IACjCK,yBAAyBF,IAAI;QAC3BG,MAAM;QACNC,SAAS;IACX;IACAC,uBAAuBN,IAAI;QACzBI,MAAM;QACNC,SAAS;IACX;IACAE,uBAAuBP,IAAI;QACzBI,MAAM;QACNC,SAAS;IACX;IACAG,uBAAuBR,IAAI;QACzBI,MAAM;QACNC,SAAS;IACX;IACAI,8BAA8BT,IAAI;QAChCI,MAAM;QACNC,SAASK;IACX;IACAC,kCAAkCX,IAAI;QACpCI,MAAM;QACNC,SAASK;IACX;IACAE,iCAAiCf,KAAK;QACpCO,MAAM;QACNC,SAAS;IACX;IACAQ,uBAAuBd,IAAI;QACzBK,MAAM;QACNC,SAAS;IACX;IACAS,kCAAkCf,IAAI;QACpCK,MAAM;QACNC,SAAS;IACX;IACAU,gCAAgChB,IAAI;QAClCK,MAAM;QACNC,SAAS;IACX;IACAW,wCAAwCjB,IAAI;QAC1CK,MAAM;QACNC,SAAS;IACX;IACAY,+BAA+BlB,IAAI;QACjCK,MAAM;QACNC,SAAS;IACX;IACAa,6BAA6BnB,IAAI;QAC/BK,MAAM;QACNC,SAAS;IACX;IACAc,4BAA4BpB,IAAI;QAC9BK,MAAM;QACNC,SAAS;IACX;IACAe,0BAA0BrB,IAAI;QAC5BK,MAAM;QACNC,SAAS;IACX;IACAgB,2BAA2BtB,IAAI;QAC7BK,MAAM;QACNC,SAAS;IACX;IACAiB,oBAAoBvB,IAAI;QACtBK,MAAM;QACNC,SAAS;IACX;AACF,GAAG;AAEH,MAAMkB,mBAAmBC,QAAQtB,OAAOO,4BAA4B;AACpE,MAAMgB,uBAAuBD,QAAQtB,OAAOS,gCAAgC;AAC5E,IAAIY,qBAAqBE,sBAAsB;IAC7C,MAAM,IAAIC,MACR;AAEJ;AAEA,yFAAyF;AACzF,0FAA0F;AAC1F,MAAMC,0BAA0B;AAChC,IACEzB,OAAOkB,wBAAwB,GAAG,KAClClB,OAAOkB,wBAAwB,GAAGO,yBAClC;IACA,MAAM,IAAID,MACR,CAAC,+CAA+C,EAAEC,wBAAwB,MAAM,EAAEzB,OAAOkB,wBAAwB,EAAE;AAEvH;AACA,IAAIlB,OAAOmB,yBAAyB,GAAG,GAAG;IACxC,MAAM,IAAIK,MACR,CAAC,kDAAkD,EAAExB,OAAOmB,yBAAyB,EAAE;AAE3F;AAEA,uFAAuF;AACvF,0FAA0F;AAC1F,iFAAiF;AACjF,IAAInB,OAAOgB,2BAA2B,GAAGhB,OAAOiB,0BAA0B,EAAE;IAC1E,MAAM,IAAIO,MACR,CAAC,6BAA6B,EAAExB,OAAOgB,2BAA2B,CAAC,yCAAyC,EAAEhB,OAAOiB,0BAA0B,CAAC,EAAE,CAAC;AAEvJ;AAEA,qFAAqF;AACrF,kEAAkE;AAClE,IAAI,CAACS,OAAOC,SAAS,CAAC3B,OAAOoB,kBAAkB,KAAKpB,OAAOoB,kBAAkB,GAAG,GAAG;IACjF,MAAM,IAAII,MACR,CAAC,oBAAoB,EAAExB,OAAOoB,kBAAkB,CAAC,sCAAsC,CAAC;AAE5F;AAEA,OAAO,SAASQ,kCAAkCC,uBAA+B;IAC/E,IACE,CAACH,OAAOI,QAAQ,CAAC9B,OAAOe,6BAA6B,KACrDf,OAAOe,6BAA6B,IAAIc,yBACxC;QACA,MAAM,IAAIL,MACR,CAAC,+BAA+B,EAAExB,OAAOe,6BAA6B,CAAC,+CAA+C,EAAEc,wBAAwB,kIAAkI,CAAC;IAEvR;AACF"}
@@ -1,8 +1,5 @@
1
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;
2
+ import type { WorkflowsModuleClient } from '@shipfox/api-workflows-dto/inter-module';
6
3
  export interface AppendLogsParams {
7
4
  jobId: string;
8
5
  workspaceId: string;
@@ -23,6 +20,5 @@ export interface AppendLogsResult {
23
20
  * job contend on its single accounting row, so the path is multi-instance safe
24
21
  * but not lock-free.
25
22
  */
26
- export declare function appendLogs(params: AppendLogsParams): Promise<AppendLogsResult>;
27
- export {};
23
+ export declare function appendLogs(params: AppendLogsParams, workflows?: WorkflowsModuleClient): Promise<AppendLogsResult>;
28
24
  //# sourceMappingURL=append-logs.d.ts.map
@@ -1 +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"}
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;AAQnC,OAAO,KAAK,EAAC,qBAAqB,EAAC,MAAM,yCAAyC,CAAC;AAwBnF,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;AAoND;;;;;GAKG;AACH,wBAAsB,UAAU,CAC9B,MAAM,EAAE,gBAAgB,EACxB,SAAS,CAAC,EAAE,qBAAqB,GAChC,OAAO,CAAC,gBAAgB,CAAC,CAmG3B"}
@@ -1,7 +1,6 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { DEFAULT_HARNESS, harnessSchema } from '@shipfox/api-agent-dto';
2
+ import { DEFAULT_HARNESS } from '@shipfox/api-agent-dto';
3
3
  import { parseLogRecordLine, parseRawLogRecordLine } from '@shipfox/api-logs-dto';
4
- import { getStepById } from '@shipfox/api-workflows';
5
4
  import { logger } from '@shipfox/node-opentelemetry';
6
5
  import { config } from '#config.js';
7
6
  import { accrueStoredBytes, claimCap, ensureJobAccounting, isJobCapped } from '#db/accounting.js';
@@ -13,14 +12,6 @@ import { allowedBudget } from './budget.js';
13
12
  import { closeStream, controlTombstone } from './close-stream.js';
14
13
  import { MalformedLogChunkError, OffsetGapError } from './errors.js';
15
14
  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
15
  /**
25
16
  * Pure pre-transaction parse. Requires whole, newline-terminated lines so
26
17
  * `committed_length` always lands on a line boundary (one body = one chunk = whole
@@ -87,10 +78,10 @@ export function setStepLookupForTesting(lookup) {
87
78
  return undefined;
88
79
  }
89
80
  }
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;
81
+ async function getSessionHarness(workflows, stepId) {
82
+ return workflows ? (await workflows.getStepLogContext({
83
+ stepId
84
+ })).harness : DEFAULT_HARNESS;
94
85
  }
95
86
  /**
96
87
  * Empty-body heartbeat: report the current committed length without materializing
@@ -214,7 +205,7 @@ function agentSessionRecord(record) {
214
205
  * conditional UPDATEs, not an explicit `SELECT ... FOR UPDATE`. Appends for one
215
206
  * job contend on its single accounting row, so the path is multi-instance safe
216
207
  * but not lock-free.
217
- */ export async function appendLogs(params) {
208
+ */ export async function appendLogs(params, workflows) {
218
209
  let parsed;
219
210
  try {
220
211
  parsed = parseAppendBody(params.body);
@@ -230,7 +221,7 @@ function agentSessionRecord(record) {
230
221
  throw error;
231
222
  }
232
223
  const { declaredTotalBytes } = parsed;
233
- const sessionHarness = parsed.hasAgentSessionRecord ? await getSessionHarness(params.stepId) : DEFAULT_HARNESS;
224
+ const sessionHarness = parsed.hasAgentSessionRecord ? await getSessionHarness(workflows, params.stepId) : DEFAULT_HARNESS;
234
225
  const stored = buildStoredBody(parsed.records, sessionHarness);
235
226
  const commitByteLen = params.body.length;
236
227
  const metrics = {
@@ -1 +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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"}
1
+ {"version":3,"sources":["../../src/core/append-logs.ts"],"sourcesContent":["import {Buffer} from 'node:buffer';\nimport {DEFAULT_HARNESS, type Harness} from '@shipfox/api-agent-dto';\nimport {\n type LogRecord,\n parseLogRecordLine,\n parseRawLogRecordLine,\n type RawLogRecord,\n} from '@shipfox/api-logs-dto';\nimport type {WorkflowsModuleClient} from '@shipfox/api-workflows-dto/inter-module';\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\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(\n workflows: WorkflowsModuleClient | undefined,\n stepId: string,\n): Promise<Harness> {\n return workflows ? (await workflows.getStepLogContext({stepId})).harness : 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(\n params: AppendLogsParams,\n workflows?: WorkflowsModuleClient,\n): 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(workflows, 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","parseLogRecordLine","parseRawLogRecordLine","logger","config","accrueStoredBytes","claimCap","ensureJobAccounting","isJobCapped","insertChunk","db","casExtendCommittedLength","getAttemptStream","getOrCreateAttemptStreamWithStatus","setDeclaredTotalBytes","recordAppendedCount","streamClosedCount","streamOpenedCount","allowedBudget","closeStream","controlTombstone","MalformedLogChunkError","OffsetGapError","parseSessionRecord","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","workflows","stepId","getStepLogContext","harness","readHeartbeat","tx","params","existing","jobId","attempt","committedLength","capped","storeChunk","streamId","workspaceId","storedByteLen","accrued","delta","stored","recordCounts","streamOffset","offset","byteLen","data","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","parsed","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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"}
@@ -1 +1 @@
1
- {"version":3,"file":"reap-stale-open-streams.d.ts","sourceRoot":"","sources":["../../src/core/reap-stale-open-streams.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,0BAA0B;IACzC,wCAAwC;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,0BAA0B;IACzC;;;;OAIG;IACH,gBAAgB,EAAE,MAAM,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,0BAA0B,GACjC,OAAO,CAAC,0BAA0B,CAAC,CAwBrC"}
1
+ {"version":3,"file":"reap-stale-open-streams.d.ts","sourceRoot":"","sources":["../../src/core/reap-stale-open-streams.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,0BAA0B;IACzC,wCAAwC;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,0BAA0B;IACzC;;;;OAIG;IACH,gBAAgB,EAAE,MAAM,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,0BAA0B,GACjC,OAAO,CAAC,0BAA0B,CAAC,CAyBrC"}
@@ -1,3 +1,4 @@
1
+ import { reportError } from '@shipfox/node-error-monitoring';
1
2
  import { logger } from '@shipfox/node-opentelemetry';
2
3
  import { db } from '#db/db.js';
3
4
  import { listStaleOpenStreams } from '#db/streams.js';
@@ -42,6 +43,12 @@ import { closeStream } from './close-stream.js';
42
43
  err: error,
43
44
  streamId: stream.id
44
45
  }, 'Failed to reap stale open log stream');
46
+ reportError(error, {
47
+ boundary: 'logs.maintenance',
48
+ extra: {
49
+ streamId: stream.id
50
+ }
51
+ });
45
52
  }
46
53
  }
47
54
  return result;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/reap-stale-open-streams.ts"],"sourcesContent":["import {logger} from '@shipfox/node-opentelemetry';\nimport {db} from '#db/db.js';\nimport {listStaleOpenStreams} from '#db/streams.js';\nimport {recordAppendedCount, streamClosedCount} from '#metrics/instance.js';\nimport {closeStream} from './close-stream.js';\n\nexport interface ReapStaleOpenStreamsResult {\n /** Streams force-closed by this run. */\n reaped: number;\n /** Streams whose guarded close threw; logged, skipped, retried next run. */\n failed: number;\n}\n\nexport interface ReapStaleOpenStreamsParams {\n /**\n * A stream open longer than this since `created_at` can no longer be appended to: its job\n * holds one lease minted at-or-before the stream's creation, so once the lease TTL has\n * elapsed no append can land and the close is safe. Set above `AUTH_JOB_LEASE_TOKEN_EXPIRES_IN`.\n */\n olderThanSeconds: number;\n batchLimit: number;\n}\n\n/**\n * Force-closes open streams the one-shot job-terminal sweep missed: a stream whose first append\n * landed after that sweep's snapshot would otherwise stay `open` forever, invisible to\n * compaction and retention (both partial on `state = 'closed'`). Reusing the guarded\n * `closeStream` re-drives each into the normal closed -> compact -> retention lifecycle.\n *\n * Each row closes in its own transaction, so a tick/retry overlap is idempotent (the guarded\n * `markStreamClosed` lets only one close win) and one failed row is logged and skipped instead\n * of aborting the batch.\n */\nexport async function reapStaleOpenStreams(\n params: ReapStaleOpenStreamsParams,\n): Promise<ReapStaleOpenStreamsResult> {\n const stale = await listStaleOpenStreams({\n olderThanSeconds: params.olderThanSeconds,\n limit: params.batchLimit,\n });\n\n const result: ReapStaleOpenStreamsResult = {reaped: 0, failed: 0};\n for (const stream of stale) {\n try {\n const closed = await db().transaction((tx) =>\n closeStream(tx, {streamId: stream.id, reason: 'timeout'}),\n );\n if (closed) {\n result.reaped += 1;\n recordAppendedCount.add(1, {kind: 'runner_lost'});\n streamClosedCount.add(1, {reason: 'timeout'});\n }\n } catch (error) {\n result.failed += 1;\n logger().error({err: error, streamId: stream.id}, 'Failed to reap stale open log stream');\n }\n }\n\n return result;\n}\n"],"names":["logger","db","listStaleOpenStreams","recordAppendedCount","streamClosedCount","closeStream","reapStaleOpenStreams","params","stale","olderThanSeconds","limit","batchLimit","result","reaped","failed","stream","closed","transaction","tx","streamId","id","reason","add","kind","error","err"],"mappings":"AAAA,SAAQA,MAAM,QAAO,8BAA8B;AACnD,SAAQC,EAAE,QAAO,YAAY;AAC7B,SAAQC,oBAAoB,QAAO,iBAAiB;AACpD,SAAQC,mBAAmB,EAAEC,iBAAiB,QAAO,uBAAuB;AAC5E,SAAQC,WAAW,QAAO,oBAAoB;AAmB9C;;;;;;;;;CASC,GACD,OAAO,eAAeC,qBACpBC,MAAkC;IAElC,MAAMC,QAAQ,MAAMN,qBAAqB;QACvCO,kBAAkBF,OAAOE,gBAAgB;QACzCC,OAAOH,OAAOI,UAAU;IAC1B;IAEA,MAAMC,SAAqC;QAACC,QAAQ;QAAGC,QAAQ;IAAC;IAChE,KAAK,MAAMC,UAAUP,MAAO;QAC1B,IAAI;YACF,MAAMQ,SAAS,MAAMf,KAAKgB,WAAW,CAAC,CAACC,KACrCb,YAAYa,IAAI;oBAACC,UAAUJ,OAAOK,EAAE;oBAAEC,QAAQ;gBAAS;YAEzD,IAAIL,QAAQ;gBACVJ,OAAOC,MAAM,IAAI;gBACjBV,oBAAoBmB,GAAG,CAAC,GAAG;oBAACC,MAAM;gBAAa;gBAC/CnB,kBAAkBkB,GAAG,CAAC,GAAG;oBAACD,QAAQ;gBAAS;YAC7C;QACF,EAAE,OAAOG,OAAO;YACdZ,OAAOE,MAAM,IAAI;YACjBd,SAASwB,KAAK,CAAC;gBAACC,KAAKD;gBAAOL,UAAUJ,OAAOK,EAAE;YAAA,GAAG;QACpD;IACF;IAEA,OAAOR;AACT"}
1
+ {"version":3,"sources":["../../src/core/reap-stale-open-streams.ts"],"sourcesContent":["import {reportError} from '@shipfox/node-error-monitoring';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport {db} from '#db/db.js';\nimport {listStaleOpenStreams} from '#db/streams.js';\nimport {recordAppendedCount, streamClosedCount} from '#metrics/instance.js';\nimport {closeStream} from './close-stream.js';\n\nexport interface ReapStaleOpenStreamsResult {\n /** Streams force-closed by this run. */\n reaped: number;\n /** Streams whose guarded close threw; logged, skipped, retried next run. */\n failed: number;\n}\n\nexport interface ReapStaleOpenStreamsParams {\n /**\n * A stream open longer than this since `created_at` can no longer be appended to: its job\n * holds one lease minted at-or-before the stream's creation, so once the lease TTL has\n * elapsed no append can land and the close is safe. Set above `AUTH_JOB_LEASE_TOKEN_EXPIRES_IN`.\n */\n olderThanSeconds: number;\n batchLimit: number;\n}\n\n/**\n * Force-closes open streams the one-shot job-terminal sweep missed: a stream whose first append\n * landed after that sweep's snapshot would otherwise stay `open` forever, invisible to\n * compaction and retention (both partial on `state = 'closed'`). Reusing the guarded\n * `closeStream` re-drives each into the normal closed -> compact -> retention lifecycle.\n *\n * Each row closes in its own transaction, so a tick/retry overlap is idempotent (the guarded\n * `markStreamClosed` lets only one close win) and one failed row is logged and skipped instead\n * of aborting the batch.\n */\nexport async function reapStaleOpenStreams(\n params: ReapStaleOpenStreamsParams,\n): Promise<ReapStaleOpenStreamsResult> {\n const stale = await listStaleOpenStreams({\n olderThanSeconds: params.olderThanSeconds,\n limit: params.batchLimit,\n });\n\n const result: ReapStaleOpenStreamsResult = {reaped: 0, failed: 0};\n for (const stream of stale) {\n try {\n const closed = await db().transaction((tx) =>\n closeStream(tx, {streamId: stream.id, reason: 'timeout'}),\n );\n if (closed) {\n result.reaped += 1;\n recordAppendedCount.add(1, {kind: 'runner_lost'});\n streamClosedCount.add(1, {reason: 'timeout'});\n }\n } catch (error) {\n result.failed += 1;\n logger().error({err: error, streamId: stream.id}, 'Failed to reap stale open log stream');\n reportError(error, {boundary: 'logs.maintenance', extra: {streamId: stream.id}});\n }\n }\n\n return result;\n}\n"],"names":["reportError","logger","db","listStaleOpenStreams","recordAppendedCount","streamClosedCount","closeStream","reapStaleOpenStreams","params","stale","olderThanSeconds","limit","batchLimit","result","reaped","failed","stream","closed","transaction","tx","streamId","id","reason","add","kind","error","err","boundary","extra"],"mappings":"AAAA,SAAQA,WAAW,QAAO,iCAAiC;AAC3D,SAAQC,MAAM,QAAO,8BAA8B;AACnD,SAAQC,EAAE,QAAO,YAAY;AAC7B,SAAQC,oBAAoB,QAAO,iBAAiB;AACpD,SAAQC,mBAAmB,EAAEC,iBAAiB,QAAO,uBAAuB;AAC5E,SAAQC,WAAW,QAAO,oBAAoB;AAmB9C;;;;;;;;;CASC,GACD,OAAO,eAAeC,qBACpBC,MAAkC;IAElC,MAAMC,QAAQ,MAAMN,qBAAqB;QACvCO,kBAAkBF,OAAOE,gBAAgB;QACzCC,OAAOH,OAAOI,UAAU;IAC1B;IAEA,MAAMC,SAAqC;QAACC,QAAQ;QAAGC,QAAQ;IAAC;IAChE,KAAK,MAAMC,UAAUP,MAAO;QAC1B,IAAI;YACF,MAAMQ,SAAS,MAAMf,KAAKgB,WAAW,CAAC,CAACC,KACrCb,YAAYa,IAAI;oBAACC,UAAUJ,OAAOK,EAAE;oBAAEC,QAAQ;gBAAS;YAEzD,IAAIL,QAAQ;gBACVJ,OAAOC,MAAM,IAAI;gBACjBV,oBAAoBmB,GAAG,CAAC,GAAG;oBAACC,MAAM;gBAAa;gBAC/CnB,kBAAkBkB,GAAG,CAAC,GAAG;oBAACD,QAAQ;gBAAS;YAC7C;QACF,EAAE,OAAOG,OAAO;YACdZ,OAAOE,MAAM,IAAI;YACjBd,SAASwB,KAAK,CAAC;gBAACC,KAAKD;gBAAOL,UAAUJ,OAAOK,EAAE;YAAA,GAAG;YAClDrB,YAAYyB,OAAO;gBAACE,UAAU;gBAAoBC,OAAO;oBAACR,UAAUJ,OAAOK,EAAE;gBAAA;YAAC;QAChF;IACF;IAEA,OAAOR;AACT"}
@@ -1 +1 @@
1
- {"version":3,"file":"retention.d.ts","sourceRoot":"","sources":["../../src/core/retention.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,oBAAoB;IACnC,kDAAkD;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,mGAAmG;IACnG,KAAK,EAAE,MAAM,CAAC;IACd,uGAAuG;IACvG,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,0FAA0F;IAC1F,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,mFAAmF;IACnF,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,wFAAwF;IACxF,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;CACzB;AA0BD;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,uBAAuB,GAC9B,OAAO,CAAC,oBAAoB,CAAC,CA+G/B"}
1
+ {"version":3,"file":"retention.d.ts","sourceRoot":"","sources":["../../src/core/retention.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,oBAAoB;IACnC,kDAAkD;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,mGAAmG;IACnG,KAAK,EAAE,MAAM,CAAC;IACd,uGAAuG;IACvG,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,0FAA0F;IAC1F,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,mFAAmF;IACnF,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,wFAAwF;IACxF,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;CACzB;AA0BD;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,uBAAuB,GAC9B,OAAO,CAAC,oBAAoB,CAAC,CAmH/B"}