@shipfox/api-logs 10.2.0 → 12.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +45 -0
  3. package/README.md +6 -6
  4. package/dist/config.js +1 -1
  5. package/dist/config.js.map +1 -1
  6. package/dist/core/append-logs.js +1 -1
  7. package/dist/core/append-logs.js.map +1 -1
  8. package/dist/core/budget.d.ts +1 -1
  9. package/dist/core/budget.js +1 -1
  10. package/dist/core/budget.js.map +1 -1
  11. package/dist/core/errors.d.ts +4 -4
  12. package/dist/core/errors.js +4 -4
  13. package/dist/core/errors.js.map +1 -1
  14. package/dist/db/schema/attempt-streams.d.ts +2 -2
  15. package/dist/db/schema/attempt-streams.js +2 -2
  16. package/dist/db/schema/attempt-streams.js.map +1 -1
  17. package/dist/db/schema/job-accounting.d.ts +1 -1
  18. package/dist/db/schema/job-accounting.js +1 -1
  19. package/dist/db/schema/job-accounting.js.map +1 -1
  20. package/dist/presentation/routes/read-logs.d.ts.map +1 -1
  21. package/dist/presentation/routes/read-logs.js +11 -5
  22. package/dist/presentation/routes/read-logs.js.map +1 -1
  23. package/dist/tsconfig.test.tsbuildinfo +1 -1
  24. package/package.json +10 -10
  25. package/src/config.ts +1 -1
  26. package/src/core/append-logs.test.ts +4 -0
  27. package/src/core/append-logs.ts +1 -1
  28. package/src/core/budget.ts +1 -1
  29. package/src/core/errors.ts +4 -4
  30. package/src/core/finalize-attempt-stream.test.ts +1 -0
  31. package/src/db/schema/attempt-streams.ts +2 -2
  32. package/src/db/schema/job-accounting.ts +1 -1
  33. package/src/presentation/routes/read-logs.test.ts +18 -4
  34. package/src/presentation/routes/read-logs.ts +9 -5
  35. package/test/fixtures/workflows-client.ts +1 -0
  36. package/test/globalSetup.ts +1 -1
  37. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,6 +1,6 @@
1
1
  $ shipfox-swc && shipfox-temporal-bundle dist/temporal/workflows/index.js
2
- Successfully compiled: 59 files with swc (431.26ms)
3
- 2026-07-28T16:59:25.699Z [INFO] asset workflow-bundle-17839eee3b21fbae5b8c.js 3.18 MiB [emitted] [immutable] (name: main)
2
+ Successfully compiled: 59 files with swc (282.86ms)
3
+ 2026-08-04T08:17:13.836Z [INFO] asset workflow-bundle-17839eee3b21fbae5b8c.js 3.18 MiB [emitted] [immutable] (name: main)
4
4
  orphan modules 33.5 KiB [orphan] 21 modules
5
5
  runtime modules 1.13 KiB 5 modules
6
6
  modules by path ../../../node_modules/.pnpm/ 970 KiB 196 modules
@@ -16,6 +16,6 @@ optional modules 30 bytes [optional]
16
16
  __temporal_custom_payload_converter (ignored) 15 bytes [optional] [built] [code generated]
17
17
  __temporal_custom_failure_converter (ignored) 15 bytes [optional] [built] [code generated]
18
18
  ../../shared/node/temporal/dist/workflow-error-interceptor.js 1.21 KiB [built] [code generated]
19
- webpack 5.107.2 compiled successfully in 6248 ms
20
- 2026-07-28T16:59:25.714Z [INFO] Workflow bundle created { size: '3.18MB' }
19
+ webpack 5.107.2 compiled successfully in 3307 ms
20
+ 2026-08-04T08:17:13.842Z [INFO] Workflow bundle created { size: '3.18MB' }
21
21
  /home/runner/work/shipfox/shipfox/libs/api/logs/dist/temporal/workflows/index.js: 3335222 bytes
package/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # @shipfox/api-logs
2
2
 
3
+ ## 12.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - f78740d: Remove Unicode dash punctuation from package prose and source comments.
8
+ - Updated dependencies [ee2ce67]
9
+ - Updated dependencies [e95fdf4]
10
+ - Updated dependencies [f7939c7]
11
+ - Updated dependencies [f78740d]
12
+ - Updated dependencies [dea1ffd]
13
+ - Updated dependencies [adf07e7]
14
+ - Updated dependencies [3f781ee]
15
+ - Updated dependencies [285fff2]
16
+ - Updated dependencies [e44a279]
17
+ - Updated dependencies [f13e8bb]
18
+ - Updated dependencies [54c820e]
19
+ - Updated dependencies [35a42bd]
20
+ - Updated dependencies [d77baaa]
21
+ - Updated dependencies [032d316]
22
+ - Updated dependencies [c2a8e54]
23
+ - Updated dependencies [cb0abfa]
24
+ - Updated dependencies [7f90b0c]
25
+ - @shipfox/workflow-document@3.0.0
26
+ - @shipfox/api-workflows-dto@12.0.0
27
+ - @shipfox/api-logs-dto@12.0.0
28
+ - @shipfox/node-fastify@0.4.1
29
+ - @shipfox/node-module@1.0.5
30
+ - @shipfox/node-postgres@0.5.0
31
+ - @shipfox/api-auth-context@12.0.0
32
+ - @shipfox/node-drizzle@0.3.5
33
+ - @shipfox/node-outbox@0.2.6
34
+
35
+ ## 11.0.0
36
+
37
+ ### Major Changes
38
+
39
+ - 25158c8: Carry workspace lifecycle status in JWT membership claims and enforce suspended or inactive access at the stateless workspace gate while keeping access-token verification stateless.
40
+
41
+ `getAuthenticatedSessionContext()` now reads refresh-session metadata from verified access-token claims without checking active refresh-session state; revoking a refresh session does not invalidate an already-issued access token.
42
+
43
+ ### Patch Changes
44
+
45
+ - Updated dependencies [25158c8]
46
+ - @shipfox/api-auth-context@11.0.0
47
+
3
48
  ## 10.2.0
4
49
 
5
50
  ### Minor Changes
package/README.md CHANGED
@@ -63,9 +63,9 @@ output; control records are flat by `type`:
63
63
 
64
64
  Two layers stop a runner from writing what it should not:
65
65
 
66
- 1. **Lease scope** the lease binds writes to the job's own `(step, attempt)`, so cross-job
66
+ 1. **Lease scope**: the lease binds writes to the job's own `(step, attempt)`, so cross-job
67
67
  injection is structurally impossible.
68
- 2. **Distinct raw/write and stored/read unions** every line is validated against the **raw**
68
+ 2. **Distinct raw/write and stored/read unions**: every line is validated against the **raw**
69
69
  record union. The server-only `capped`/`runner_lost` tombstones are members of the read union
70
70
  only, not the raw write union, so a forged tombstone append is rejected (400). A forged
71
71
  tombstone that is otherwise a valid record is logged as a narrowed audit warning (no payload,
@@ -86,13 +86,13 @@ the live tail and the full history. It is workspace-scoped through the stream ro
86
86
  `workspace_id`; a 404 covers both a missing stream and a cross-workspace step, so existence never
87
87
  leaks.
88
88
 
89
- - **Hot (open, or closed but not yet compacted)** inline NDJSON read from the Postgres chunks,
89
+ - **Hot (open, or closed but not yet compacted)**: inline NDJSON read from the Postgres chunks,
90
90
  walked by chunk `seq` so server control tombstones interleave with normalized runner records
91
91
  exactly as compaction concatenates them, making the inline bytes byte-identical to the
92
92
  decompressed object.
93
93
  Pages are bounded by `LOG_READ_INLINE_MAX_BYTES`; the client follows `has_more`/`next_cursor` to
94
94
  drain the backlog, then tails from the last cursor.
95
- - **Cold (compacted, `object_key` set)** a presigned GET URL (`LOG_READ_URL_TTL_SECONDS`) so the
95
+ - **Cold (compacted, `object_key` set)**: a presigned GET URL (`LOG_READ_URL_TTL_SECONDS`) so the
96
96
  browser fetches the object directly and API egress is bypassed.
97
97
 
98
98
  ## Agent-session capture
@@ -124,10 +124,10 @@ rejected with 400, and the runner drops it with a `gap`); the request body limit
124
124
  One shared, job-wide, generous accrual budget covers a job's logs. When the job's stored bytes
125
125
  cross the budget, the job is capped and further appends are dropped. The append that crosses the
126
126
  budget is stored in full, then an in-band `capped` tombstone record is injected once; later appends
127
- are accepted-and-dropped. The cap is a **job-level** signal a stream can be byte-complete and
127
+ are accepted-and-dropped. The cap is a **job-level** signal: a stream can be byte-complete and
128
128
  still sit under a capped job if a sibling step exhausted the shared budget.
129
129
 
130
- A stream closes on one of four triggers, all converging on the same guarded, exactly-once close:
130
+ A stream closes on one of four triggers, all converging on the same guarded, exactly once close:
131
131
  the runner declares its end in-band (an `end` record in an append), which closes the stream
132
132
  `declared` the moment those bytes commit; the step-attempt-terminated subscriber closes an attempt
133
133
  the runner never ended in-band, `declared` when the runner drained its spool or `timeout` when it
package/dist/config.js CHANGED
@@ -84,7 +84,7 @@ if (config.LOG_READ_INLINE_MAX_BYTES < 1) {
84
84
  throw new Error(`LOG_READ_INLINE_MAX_BYTES must be at least 1; got ${config.LOG_READ_INLINE_MAX_BYTES}`);
85
85
  }
86
86
  // A whole agent_session line is one record in one body. If the body limit is below the
87
- // line cap, a legitimate large session line could never fit in one request body Fastify
87
+ // line cap, a legitimate large session line could never fit in one request body: Fastify
88
88
  // would 413 it before the per-line validator runs. Fail fast at startup instead.
89
89
  if (config.LOG_APPEND_BODY_LIMIT_BYTES < config.LOG_MAX_SESSION_LINE_BYTES) {
90
90
  throw new Error(`LOG_APPEND_BODY_LIMIT_BYTES (${config.LOG_APPEND_BODY_LIMIT_BYTES}) must be >= LOG_MAX_SESSION_LINE_BYTES (${config.LOG_MAX_SESSION_LINE_BYTES}).`);
@@ -1 +1 @@
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
+ {"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,yFAAyF;AACzF,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"}
@@ -71,7 +71,7 @@ import { parseSessionRecord } from './session/parse-session.js';
71
71
  }
72
72
  /**
73
73
  * A line that fails the raw write union but is a valid record under the read union
74
- * can only be a server-only `capped`/`runner_lost` tombstone i.e. a forgery
74
+ * can only be a server-only `capped`/`runner_lost` tombstone: i.e. a forgery
75
75
  * attempt. Returns its type for the audit warn, or undefined for plain garbage.
76
76
  */ function detectForgedType(line) {
77
77
  try {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/append-logs.ts"],"sourcesContent":["import {Buffer} from 'node:buffer';\nimport {\n type LogRecord,\n parseLogRecordLine,\n parseRawLogRecordLine,\n type RawLogRecord,\n type SessionViewLifecycleRow,\n type SessionViewRow,\n} from '@shipfox/api-logs-dto';\nimport type {WorkflowsModuleClient} from '@shipfox/api-workflows-dto/inter-module';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport {DEFAULT_HARNESS, type Harness} from '@shipfox/workflow-document';\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 setClaudeParseContext,\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 {flushPendingToolRows} from './session/claude/rows.js';\nimport {\n type ClaudeParseContext,\n claudeInitSessionId,\n createClaudeParseContext,\n} from './session/claude-parser.js';\nimport type {SessionParseContext} from './session/parse-session.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\ninterface StoredBody {\n body: Buffer;\n recordCounts: Partial<Record<LogRecord['type'], number>>;\n claudeParseContext: ClaudeParseContext | undefined;\n claudePendingResult: SessionViewLifecycleRow | null | undefined;\n claudePendingToolRows: readonly SessionViewRow[] | undefined;\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 initialClaudeParseContext?: ClaudeParseContext,\n initialClaudePendingResult?: SessionViewLifecycleRow | null,\n isStreamFinal = false,\n): StoredBody {\n const storedRecords: LogRecord[] = [];\n const parseContext: SessionParseContext | undefined =\n harness === 'claude'\n ? {\n claude: initialClaudeParseContext ?? createClaudeParseContext(),\n isFinalResult: true,\n }\n : undefined;\n const latestClaudeInitIndicesBySessionId =\n harness === 'claude' ? latestClaudeInitIndices(records) : undefined;\n let pendingResult = initialClaudePendingResult ?? null;\n\n if (parseContext?.claude !== undefined && pendingResult !== null) {\n const firstInit = firstClaudeInit(records);\n if (isStreamFinal || firstInit.hasInit) {\n storedRecords.push(\n storedAgentSessionRow(\n finalizePendingResult(\n pendingResult,\n firstInit.sessionId !== undefined &&\n firstInit.sessionId === parseContext.claude.sessionId,\n parseContext.claude.turn,\n ),\n ),\n );\n pendingResult = null;\n }\n }\n\n for (const [index, record] of records.entries()) {\n if (record.type !== 'agent_session') {\n storedRecords.push(record);\n continue;\n }\n\n if (parseContext?.claude !== undefined && latestClaudeInitIndicesBySessionId !== undefined) {\n parseContext.isFinalResult = !hasFutureClaudeInit(\n latestClaudeInitIndicesBySessionId,\n index,\n parseContext.claude.sessionId,\n );\n }\n\n for (const row of parseSessionRecord(agentSessionRecord(record), harness, parseContext)) {\n if (parseContext?.claude !== undefined && !isStreamFinal && isClaudeFinalResultRow(row)) {\n if (pendingResult !== null) {\n storedRecords.push(storedAgentSessionRow(pendingResult));\n }\n pendingResult = row;\n continue;\n }\n storedRecords.push(storedAgentSessionRow(row));\n }\n }\n\n if (isStreamFinal && parseContext?.claude !== undefined) {\n for (const row of flushPendingToolRows(parseContext.claude)) {\n storedRecords.push(storedAgentSessionRow(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 {\n body,\n recordCounts,\n claudeParseContext: parseContext?.claude,\n claudePendingResult: parseContext?.claude === undefined ? undefined : pendingResult,\n claudePendingToolRows: parseContext?.claude?.pendingToolRows,\n };\n}\n\nfunction storedAgentSessionRow(row: SessionViewRow): LogRecord {\n return {v: 1, ts: row.timestamp, type: 'agent_session', row};\n}\n\nfunction isClaudeFinalResultRow(row: SessionViewRow): row is SessionViewLifecycleRow {\n return row.kind === 'lifecycle' && row.label === 'Session completed';\n}\n\nfunction finalizePendingResult(\n row: SessionViewLifecycleRow,\n sameSession: boolean,\n turn: number,\n): SessionViewLifecycleRow {\n if (!sameSession) return row;\n\n return {\n ...row,\n label: `Turn ${turn} completed`,\n meta: [{label: 'turn', value: String(turn)}, ...row.meta],\n };\n}\n\nfunction firstClaudeInit(records: readonly RawLogRecord[]): {\n hasInit: boolean;\n sessionId: string | undefined;\n} {\n for (const record of records) {\n if (record.type !== 'agent_session') continue;\n const sessionRecord = agentSessionRecord(record);\n const sessionId = claudeInitSessionId(sessionRecord);\n if (sessionId !== undefined) return {hasInit: true, sessionId};\n }\n\n return {hasInit: false, sessionId: undefined};\n}\n\nfunction latestClaudeInitIndices(records: readonly RawLogRecord[]): Map<string, number> {\n const latestIndices = new Map<string, number>();\n\n for (const [index, record] of records.entries()) {\n if (record.type !== 'agent_session') continue;\n\n const sessionId = claudeInitSessionId(agentSessionRecord(record));\n if (sessionId !== undefined) latestIndices.set(sessionId, index);\n }\n\n return latestIndices;\n}\n\nfunction hasFutureClaudeInit(\n latestClaudeInitIndicesBySessionId: ReadonlyMap<string, number>,\n currentIndex: number,\n sessionId: string | null,\n): boolean {\n if (sessionId === null) return false;\n\n return (latestClaudeInitIndicesBySessionId.get(sessionId) ?? -1) > currentIndex;\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 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 parseHarness =\n sessionHarness === 'claude' ||\n stream.claudePendingResult !== null ||\n stream.claudePendingToolRows.length > 0\n ? 'claude'\n : sessionHarness;\n const stored = buildStoredBody(\n parsed.records,\n parseHarness,\n parseHarness === 'claude'\n ? createClaudeParseContext(stream.claudePendingToolRows, {\n hasInit: stream.claudeHasInit,\n sessionId: stream.claudeSessionId,\n turn: stream.claudeTurn,\n })\n : undefined,\n parseHarness === 'claude' ? stream.claudePendingResult : undefined,\n declaredTotalBytes !== undefined,\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 if (stored.claudeParseContext !== undefined) {\n await setClaudeParseContext(tx, {\n streamId: stream.id,\n hasInit: stored.claudeParseContext.hasInit,\n sessionId: stored.claudeParseContext.sessionId,\n turn: stored.claudeParseContext.turn,\n pendingResult: stored.claudePendingResult ?? null,\n pendingToolRows: stored.claudePendingToolRows ?? [],\n });\n }\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","parseLogRecordLine","parseRawLogRecordLine","logger","DEFAULT_HARNESS","config","accrueStoredBytes","claimCap","ensureJobAccounting","isJobCapped","insertChunk","db","casExtendCommittedLength","getAttemptStream","getOrCreateAttemptStreamWithStatus","setClaudeParseContext","setDeclaredTotalBytes","recordAppendedCount","streamClosedCount","streamOpenedCount","allowedBudget","closeStream","controlTombstone","MalformedLogChunkError","OffsetGapError","flushPendingToolRows","claudeInitSessionId","createClaudeParseContext","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","initialClaudeParseContext","initialClaudePendingResult","isStreamFinal","storedRecords","parseContext","claude","isFinalResult","latestClaudeInitIndicesBySessionId","latestClaudeInitIndices","pendingResult","firstInit","firstClaudeInit","hasInit","storedAgentSessionRow","finalizePendingResult","sessionId","turn","index","entries","hasFutureClaudeInit","row","agentSessionRecord","isClaudeFinalResultRow","from","map","JSON","stringify","join","claudeParseContext","claudePendingResult","claudePendingToolRows","pendingToolRows","v","ts","timestamp","kind","label","sameSession","meta","value","String","sessionRecord","latestIndices","Map","set","currentIndex","get","appendLogs","parsed","error","forgedType","warn","offendingType","sessionHarness","commitByteLen","metrics","streamClosedReason","streamOpened","result","transaction","created","stream","projectId","workflowRunAttemptId","state","cas","id","outcome","parseHarness","claudeHasInit","claudeSessionId","claudeTurn","chunkStored","addRecordCounts","closed","reason","add","count","Object","target","source"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/core/append-logs.ts"],"sourcesContent":["import {Buffer} from 'node:buffer';\nimport {\n type LogRecord,\n parseLogRecordLine,\n parseRawLogRecordLine,\n type RawLogRecord,\n type SessionViewLifecycleRow,\n type SessionViewRow,\n} from '@shipfox/api-logs-dto';\nimport type {WorkflowsModuleClient} from '@shipfox/api-workflows-dto/inter-module';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport {DEFAULT_HARNESS, type Harness} from '@shipfox/workflow-document';\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 setClaudeParseContext,\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 {flushPendingToolRows} from './session/claude/rows.js';\nimport {\n type ClaudeParseContext,\n claudeInitSessionId,\n createClaudeParseContext,\n} from './session/claude-parser.js';\nimport type {SessionParseContext} from './session/parse-session.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\ninterface StoredBody {\n body: Buffer;\n recordCounts: Partial<Record<LogRecord['type'], number>>;\n claudeParseContext: ClaudeParseContext | undefined;\n claudePendingResult: SessionViewLifecycleRow | null | undefined;\n claudePendingToolRows: readonly SessionViewRow[] | undefined;\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 initialClaudeParseContext?: ClaudeParseContext,\n initialClaudePendingResult?: SessionViewLifecycleRow | null,\n isStreamFinal = false,\n): StoredBody {\n const storedRecords: LogRecord[] = [];\n const parseContext: SessionParseContext | undefined =\n harness === 'claude'\n ? {\n claude: initialClaudeParseContext ?? createClaudeParseContext(),\n isFinalResult: true,\n }\n : undefined;\n const latestClaudeInitIndicesBySessionId =\n harness === 'claude' ? latestClaudeInitIndices(records) : undefined;\n let pendingResult = initialClaudePendingResult ?? null;\n\n if (parseContext?.claude !== undefined && pendingResult !== null) {\n const firstInit = firstClaudeInit(records);\n if (isStreamFinal || firstInit.hasInit) {\n storedRecords.push(\n storedAgentSessionRow(\n finalizePendingResult(\n pendingResult,\n firstInit.sessionId !== undefined &&\n firstInit.sessionId === parseContext.claude.sessionId,\n parseContext.claude.turn,\n ),\n ),\n );\n pendingResult = null;\n }\n }\n\n for (const [index, record] of records.entries()) {\n if (record.type !== 'agent_session') {\n storedRecords.push(record);\n continue;\n }\n\n if (parseContext?.claude !== undefined && latestClaudeInitIndicesBySessionId !== undefined) {\n parseContext.isFinalResult = !hasFutureClaudeInit(\n latestClaudeInitIndicesBySessionId,\n index,\n parseContext.claude.sessionId,\n );\n }\n\n for (const row of parseSessionRecord(agentSessionRecord(record), harness, parseContext)) {\n if (parseContext?.claude !== undefined && !isStreamFinal && isClaudeFinalResultRow(row)) {\n if (pendingResult !== null) {\n storedRecords.push(storedAgentSessionRow(pendingResult));\n }\n pendingResult = row;\n continue;\n }\n storedRecords.push(storedAgentSessionRow(row));\n }\n }\n\n if (isStreamFinal && parseContext?.claude !== undefined) {\n for (const row of flushPendingToolRows(parseContext.claude)) {\n storedRecords.push(storedAgentSessionRow(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 {\n body,\n recordCounts,\n claudeParseContext: parseContext?.claude,\n claudePendingResult: parseContext?.claude === undefined ? undefined : pendingResult,\n claudePendingToolRows: parseContext?.claude?.pendingToolRows,\n };\n}\n\nfunction storedAgentSessionRow(row: SessionViewRow): LogRecord {\n return {v: 1, ts: row.timestamp, type: 'agent_session', row};\n}\n\nfunction isClaudeFinalResultRow(row: SessionViewRow): row is SessionViewLifecycleRow {\n return row.kind === 'lifecycle' && row.label === 'Session completed';\n}\n\nfunction finalizePendingResult(\n row: SessionViewLifecycleRow,\n sameSession: boolean,\n turn: number,\n): SessionViewLifecycleRow {\n if (!sameSession) return row;\n\n return {\n ...row,\n label: `Turn ${turn} completed`,\n meta: [{label: 'turn', value: String(turn)}, ...row.meta],\n };\n}\n\nfunction firstClaudeInit(records: readonly RawLogRecord[]): {\n hasInit: boolean;\n sessionId: string | undefined;\n} {\n for (const record of records) {\n if (record.type !== 'agent_session') continue;\n const sessionRecord = agentSessionRecord(record);\n const sessionId = claudeInitSessionId(sessionRecord);\n if (sessionId !== undefined) return {hasInit: true, sessionId};\n }\n\n return {hasInit: false, sessionId: undefined};\n}\n\nfunction latestClaudeInitIndices(records: readonly RawLogRecord[]): Map<string, number> {\n const latestIndices = new Map<string, number>();\n\n for (const [index, record] of records.entries()) {\n if (record.type !== 'agent_session') continue;\n\n const sessionId = claudeInitSessionId(agentSessionRecord(record));\n if (sessionId !== undefined) latestIndices.set(sessionId, index);\n }\n\n return latestIndices;\n}\n\nfunction hasFutureClaudeInit(\n latestClaudeInitIndicesBySessionId: ReadonlyMap<string, number>,\n currentIndex: number,\n sessionId: string | null,\n): boolean {\n if (sessionId === null) return false;\n\n return (latestClaudeInitIndicesBySessionId.get(sessionId) ?? -1) > currentIndex;\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 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 parseHarness =\n sessionHarness === 'claude' ||\n stream.claudePendingResult !== null ||\n stream.claudePendingToolRows.length > 0\n ? 'claude'\n : sessionHarness;\n const stored = buildStoredBody(\n parsed.records,\n parseHarness,\n parseHarness === 'claude'\n ? createClaudeParseContext(stream.claudePendingToolRows, {\n hasInit: stream.claudeHasInit,\n sessionId: stream.claudeSessionId,\n turn: stream.claudeTurn,\n })\n : undefined,\n parseHarness === 'claude' ? stream.claudePendingResult : undefined,\n declaredTotalBytes !== undefined,\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 if (stored.claudeParseContext !== undefined) {\n await setClaudeParseContext(tx, {\n streamId: stream.id,\n hasInit: stored.claudeParseContext.hasInit,\n sessionId: stored.claudeParseContext.sessionId,\n turn: stored.claudeParseContext.turn,\n pendingResult: stored.claudePendingResult ?? null,\n pendingToolRows: stored.claudePendingToolRows ?? [],\n });\n }\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","parseLogRecordLine","parseRawLogRecordLine","logger","DEFAULT_HARNESS","config","accrueStoredBytes","claimCap","ensureJobAccounting","isJobCapped","insertChunk","db","casExtendCommittedLength","getAttemptStream","getOrCreateAttemptStreamWithStatus","setClaudeParseContext","setDeclaredTotalBytes","recordAppendedCount","streamClosedCount","streamOpenedCount","allowedBudget","closeStream","controlTombstone","MalformedLogChunkError","OffsetGapError","flushPendingToolRows","claudeInitSessionId","createClaudeParseContext","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","initialClaudeParseContext","initialClaudePendingResult","isStreamFinal","storedRecords","parseContext","claude","isFinalResult","latestClaudeInitIndicesBySessionId","latestClaudeInitIndices","pendingResult","firstInit","firstClaudeInit","hasInit","storedAgentSessionRow","finalizePendingResult","sessionId","turn","index","entries","hasFutureClaudeInit","row","agentSessionRecord","isClaudeFinalResultRow","from","map","JSON","stringify","join","claudeParseContext","claudePendingResult","claudePendingToolRows","pendingToolRows","v","ts","timestamp","kind","label","sameSession","meta","value","String","sessionRecord","latestIndices","Map","set","currentIndex","get","appendLogs","parsed","error","forgedType","warn","offendingType","sessionHarness","commitByteLen","metrics","streamClosedReason","streamOpened","result","transaction","created","stream","projectId","workflowRunAttemptId","state","cas","id","outcome","parseHarness","claudeHasInit","claudeSessionId","claudeTurn","chunkStored","addRecordCounts","closed","reason","add","count","Object","target","source"],"mappings":"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"}
@@ -6,7 +6,7 @@ export interface AllowedBudgetParams {
6
6
  }
7
7
  /**
8
8
  * Accrual budget in stored bytes: `base + rate * elapsedMinutes`, floored to an
9
- * integer. No hard ceiling job duration limits bound total volume.
9
+ * integer. No hard ceiling: job duration limits bound total volume.
10
10
  */
11
11
  export declare function allowedBudget({ baseBytes, ratePerMinuteBytes, elapsedMs, }: AllowedBudgetParams): number;
12
12
  //# sourceMappingURL=budget.d.ts.map
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Accrual budget in stored bytes: `base + rate * elapsedMinutes`, floored to an
3
- * integer. No hard ceiling job duration limits bound total volume.
3
+ * integer. No hard ceiling: job duration limits bound total volume.
4
4
  */ export function allowedBudget({ baseBytes, ratePerMinuteBytes, elapsedMs }) {
5
5
  const elapsed = Math.max(0, elapsedMs);
6
6
  return baseBytes + Math.floor(ratePerMinuteBytes * elapsed / 60_000);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/budget.ts"],"sourcesContent":["export interface AllowedBudgetParams {\n baseBytes: number;\n ratePerMinuteBytes: number;\n /** Time since the budget clock origin (first append) in milliseconds. */\n elapsedMs: number;\n}\n\n/**\n * Accrual budget in stored bytes: `base + rate * elapsedMinutes`, floored to an\n * integer. No hard ceiling job duration limits bound total volume.\n */\nexport function allowedBudget({\n baseBytes,\n ratePerMinuteBytes,\n elapsedMs,\n}: AllowedBudgetParams): number {\n const elapsed = Math.max(0, elapsedMs);\n return baseBytes + Math.floor((ratePerMinuteBytes * elapsed) / 60_000);\n}\n"],"names":["allowedBudget","baseBytes","ratePerMinuteBytes","elapsedMs","elapsed","Math","max","floor"],"mappings":"AAOA;;;CAGC,GACD,OAAO,SAASA,cAAc,EAC5BC,SAAS,EACTC,kBAAkB,EAClBC,SAAS,EACW;IACpB,MAAMC,UAAUC,KAAKC,GAAG,CAAC,GAAGH;IAC5B,OAAOF,YAAYI,KAAKE,KAAK,CAAC,AAACL,qBAAqBE,UAAW;AACjE"}
1
+ {"version":3,"sources":["../../src/core/budget.ts"],"sourcesContent":["export interface AllowedBudgetParams {\n baseBytes: number;\n ratePerMinuteBytes: number;\n /** Time since the budget clock origin (first append) in milliseconds. */\n elapsedMs: number;\n}\n\n/**\n * Accrual budget in stored bytes: `base + rate * elapsedMinutes`, floored to an\n * integer. No hard ceiling: job duration limits bound total volume.\n */\nexport function allowedBudget({\n baseBytes,\n ratePerMinuteBytes,\n elapsedMs,\n}: AllowedBudgetParams): number {\n const elapsed = Math.max(0, elapsedMs);\n return baseBytes + Math.floor((ratePerMinuteBytes * elapsed) / 60_000);\n}\n"],"names":["allowedBudget","baseBytes","ratePerMinuteBytes","elapsedMs","elapsed","Math","max","floor"],"mappings":"AAOA;;;CAGC,GACD,OAAO,SAASA,cAAc,EAC5BC,SAAS,EACTC,kBAAkB,EAClBC,SAAS,EACW;IACpB,MAAMC,UAAUC,KAAKC,GAAG,CAAC,GAAGH;IAC5B,OAAOF,YAAYI,KAAKE,KAAK,CAAC,AAACL,qBAAqBE,UAAW;AACjE"}
@@ -5,10 +5,10 @@ export declare class OffsetGapError extends Error {
5
5
  }
6
6
  /**
7
7
  * The append body is not whole, newline-terminated records of the raw log
8
- * record contract. `forgedType` is set only for the detectable forgery case a
8
+ * record contract. `forgedType` is set only for the detectable forgery case: a
9
9
  * line that is a valid server-only record (`capped`/`runner_lost`) under the read
10
- * union but is not valid on the raw write path so the append path can emit a narrowed audit warn
11
- * without logging the payload.
10
+ * union but is not valid on the raw write path. The append path can emit a
11
+ * narrowed audit warning without logging the payload.
12
12
  */
13
13
  export declare class MalformedLogChunkError extends Error {
14
14
  readonly forgedType?: string | undefined;
@@ -18,7 +18,7 @@ export declare class MalformedLogChunkError extends Error {
18
18
  * The lease's `(workspaceId, projectId, workflowRunAttemptId)` does not match the values
19
19
  * stamped on the existing stream row. Since these are functionally determined
20
20
  * by `jobId` via workflows FKs, a mismatch implies a forged token or a
21
- * cross-job lease confusion never a legitimate request.
21
+ * cross-job lease confusion. It is never a legitimate request.
22
22
  */
23
23
  export declare class LeaseStreamMismatchError extends Error {
24
24
  constructor();
@@ -6,10 +6,10 @@
6
6
  }
7
7
  /**
8
8
  * The append body is not whole, newline-terminated records of the raw log
9
- * record contract. `forgedType` is set only for the detectable forgery case a
9
+ * record contract. `forgedType` is set only for the detectable forgery case: a
10
10
  * line that is a valid server-only record (`capped`/`runner_lost`) under the read
11
- * union but is not valid on the raw write path so the append path can emit a narrowed audit warn
12
- * without logging the payload.
11
+ * union but is not valid on the raw write path. The append path can emit a
12
+ * narrowed audit warning without logging the payload.
13
13
  */ export class MalformedLogChunkError extends Error {
14
14
  constructor(message, forgedType){
15
15
  super(message), this.forgedType = forgedType;
@@ -20,7 +20,7 @@
20
20
  * The lease's `(workspaceId, projectId, workflowRunAttemptId)` does not match the values
21
21
  * stamped on the existing stream row. Since these are functionally determined
22
22
  * by `jobId` via workflows FKs, a mismatch implies a forged token or a
23
- * cross-job lease confusion never a legitimate request.
23
+ * cross-job lease confusion. It is never a legitimate request.
24
24
  */ export class LeaseStreamMismatchError extends Error {
25
25
  constructor(){
26
26
  super('Lease identity does not match the existing stream row');
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/errors.ts"],"sourcesContent":["/** The append offset is ahead of the committed length: the runner must rewind its spool cursor. */\nexport class OffsetGapError extends Error {\n constructor(public readonly committedLength: number) {\n super(`Append offset is ahead of the committed length (${committedLength})`);\n this.name = 'OffsetGapError';\n }\n}\n\n/**\n * The append body is not whole, newline-terminated records of the raw log\n * record contract. `forgedType` is set only for the detectable forgery case a\n * line that is a valid server-only record (`capped`/`runner_lost`) under the read\n * union but is not valid on the raw write path so the append path can emit a narrowed audit warn\n * without logging the payload.\n */\nexport class MalformedLogChunkError extends Error {\n constructor(\n message: string,\n public readonly forgedType?: string,\n ) {\n super(message);\n this.name = 'MalformedLogChunkError';\n }\n}\n\n/**\n * The lease's `(workspaceId, projectId, workflowRunAttemptId)` does not match the values\n * stamped on the existing stream row. Since these are functionally determined\n * by `jobId` via workflows FKs, a mismatch implies a forged token or a\n * cross-job lease confusion never a legitimate request.\n */\nexport class LeaseStreamMismatchError extends Error {\n constructor() {\n super('Lease identity does not match the existing stream row');\n this.name = 'LeaseStreamMismatchError';\n }\n}\n"],"names":["OffsetGapError","Error","committedLength","name","MalformedLogChunkError","message","forgedType","LeaseStreamMismatchError"],"mappings":"AAAA,iGAAiG,GACjG,OAAO,MAAMA,uBAAuBC;IAClC,YAAY,AAAgBC,eAAuB,CAAE;QACnD,KAAK,CAAC,CAAC,gDAAgD,EAAEA,gBAAgB,CAAC,CAAC,QADjDA,kBAAAA;QAE1B,IAAI,CAACC,IAAI,GAAG;IACd;AACF;AAEA;;;;;;CAMC,GACD,OAAO,MAAMC,+BAA+BH;IAC1C,YACEI,OAAe,EACf,AAAgBC,UAAmB,CACnC;QACA,KAAK,CAACD,eAFUC,aAAAA;QAGhB,IAAI,CAACH,IAAI,GAAG;IACd;AACF;AAEA;;;;;CAKC,GACD,OAAO,MAAMI,iCAAiCN;IAC5C,aAAc;QACZ,KAAK,CAAC;QACN,IAAI,CAACE,IAAI,GAAG;IACd;AACF"}
1
+ {"version":3,"sources":["../../src/core/errors.ts"],"sourcesContent":["/** The append offset is ahead of the committed length: the runner must rewind its spool cursor. */\nexport class OffsetGapError extends Error {\n constructor(public readonly committedLength: number) {\n super(`Append offset is ahead of the committed length (${committedLength})`);\n this.name = 'OffsetGapError';\n }\n}\n\n/**\n * The append body is not whole, newline-terminated records of the raw log\n * record contract. `forgedType` is set only for the detectable forgery case: a\n * line that is a valid server-only record (`capped`/`runner_lost`) under the read\n * union but is not valid on the raw write path. The append path can emit a\n * narrowed audit warning without logging the payload.\n */\nexport class MalformedLogChunkError extends Error {\n constructor(\n message: string,\n public readonly forgedType?: string,\n ) {\n super(message);\n this.name = 'MalformedLogChunkError';\n }\n}\n\n/**\n * The lease's `(workspaceId, projectId, workflowRunAttemptId)` does not match the values\n * stamped on the existing stream row. Since these are functionally determined\n * by `jobId` via workflows FKs, a mismatch implies a forged token or a\n * cross-job lease confusion. It is never a legitimate request.\n */\nexport class LeaseStreamMismatchError extends Error {\n constructor() {\n super('Lease identity does not match the existing stream row');\n this.name = 'LeaseStreamMismatchError';\n }\n}\n"],"names":["OffsetGapError","Error","committedLength","name","MalformedLogChunkError","message","forgedType","LeaseStreamMismatchError"],"mappings":"AAAA,iGAAiG,GACjG,OAAO,MAAMA,uBAAuBC;IAClC,YAAY,AAAgBC,eAAuB,CAAE;QACnD,KAAK,CAAC,CAAC,gDAAgD,EAAEA,gBAAgB,CAAC,CAAC,QADjDA,kBAAAA;QAE1B,IAAI,CAACC,IAAI,GAAG;IACd;AACF;AAEA;;;;;;CAMC,GACD,OAAO,MAAMC,+BAA+BH;IAC1C,YACEI,OAAe,EACf,AAAgBC,UAAmB,CACnC;QACA,KAAK,CAACD,eAFUC,aAAAA;QAGhB,IAAI,CAACH,IAAI,GAAG;IACd;AACF;AAEA;;;;;CAKC,GACD,OAAO,MAAMI,iCAAiCN;IAC5C,aAAc;QACZ,KAAK,CAAC;QACN,IAAI,CAACE,IAAI,GAAG;IACd;AACF"}
@@ -1,7 +1,7 @@
1
1
  import type { AttemptStream } from '#core/entities/attempt-stream.js';
2
2
  /**
3
3
  * One stream per (job, step, attempt). Identity is scoped by `job_id` (from the
4
- * lease), so a lease can only ever reach its own job's streams cross-job log
4
+ * lease), so a lease can only ever reach its own job's streams; cross-job log
5
5
  * injection is structurally impossible. `workspace_id`, `project_id`, and `workflow_run_attempt_id`
6
6
  * are stamped from the lease at create time; they are functionally determined by
7
7
  * `job_id` via workflows FKs, so they are denormalized here for self-contained
@@ -16,7 +16,7 @@ import type { AttemptStream } from '#core/entities/attempt-stream.js';
16
16
  * Per-row `committed_length` and `declared_total_bytes` are bounded by the
17
17
  * per-job budget, so `mode: 'number'` is safe on the hot path. Any cross-row
18
18
  * aggregate (workspace or system-wide totals) MUST read as bigint at the
19
- * query site the global sum is unbounded and would silently lose precision
19
+ * query site: the global sum is unbounded and would silently lose precision
20
20
  * past 2^53 as a JS number.
21
21
  */
22
22
  export declare const attemptStreams: import("drizzle-orm/pg-core").PgTableWithColumns<{
@@ -4,7 +4,7 @@ import { bigint, boolean, index, integer, jsonb, text, timestamp, uniqueIndex, u
4
4
  import { pgTable } from './common.js';
5
5
  /**
6
6
  * One stream per (job, step, attempt). Identity is scoped by `job_id` (from the
7
- * lease), so a lease can only ever reach its own job's streams cross-job log
7
+ * lease), so a lease can only ever reach its own job's streams; cross-job log
8
8
  * injection is structurally impossible. `workspace_id`, `project_id`, and `workflow_run_attempt_id`
9
9
  * are stamped from the lease at create time; they are functionally determined by
10
10
  * `job_id` via workflows FKs, so they are denormalized here for self-contained
@@ -19,7 +19,7 @@ import { pgTable } from './common.js';
19
19
  * Per-row `committed_length` and `declared_total_bytes` are bounded by the
20
20
  * per-job budget, so `mode: 'number'` is safe on the hot path. Any cross-row
21
21
  * aggregate (workspace or system-wide totals) MUST read as bigint at the
22
- * query site the global sum is unbounded and would silently lose precision
22
+ * query site: the global sum is unbounded and would silently lose precision
23
23
  * past 2^53 as a JS number.
24
24
  */ export const attemptStreams = pgTable('attempt_streams', {
25
25
  id: uuidv7PrimaryKey(),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/db/schema/attempt-streams.ts"],"sourcesContent":["import type {SessionViewLifecycleRow, SessionViewRow} from '@shipfox/api-logs-dto';\nimport {uuidv7PrimaryKey} from '@shipfox/node-drizzle';\nimport {sql} from 'drizzle-orm';\nimport {\n bigint,\n boolean,\n index,\n integer,\n jsonb,\n text,\n timestamp,\n uniqueIndex,\n uuid,\n} from 'drizzle-orm/pg-core';\nimport type {AttemptStream} from '#core/entities/attempt-stream.js';\nimport {pgTable} from './common.js';\n\n/**\n * One stream per (job, step, attempt). Identity is scoped by `job_id` (from the\n * lease), so a lease can only ever reach its own job's streams cross-job log\n * injection is structurally impossible. `workspace_id`, `project_id`, and `workflow_run_attempt_id`\n * are stamped from the lease at create time; they are functionally determined by\n * `job_id` via workflows FKs, so they are denormalized here for self-contained\n * authorization (per-project read filtering, audit) without joining back to\n * workflows. `committed_length` is the offset-CAS axis (raw NDJSON spool bytes the\n * server has durably accepted from the runner).\n *\n * `truncated` is an out-of-band terminal flag set when the timeout sweep\n * force-closes a stream the runner never ended; the `AttemptStream` entity is the\n * canonical reference for what it means.\n *\n * Per-row `committed_length` and `declared_total_bytes` are bounded by the\n * per-job budget, so `mode: 'number'` is safe on the hot path. Any cross-row\n * aggregate (workspace or system-wide totals) MUST read as bigint at the\n * query site the global sum is unbounded and would silently lose precision\n * past 2^53 as a JS number.\n */\nexport const attemptStreams = pgTable(\n 'attempt_streams',\n {\n id: uuidv7PrimaryKey(),\n jobId: uuid('job_id').notNull(),\n stepId: uuid('step_id').notNull(),\n attempt: integer('attempt').notNull(),\n workspaceId: uuid('workspace_id').notNull(),\n projectId: uuid('project_id').notNull(),\n workflowRunAttemptId: uuid('workflow_run_attempt_id').notNull(),\n committedLength: bigint('committed_length', {mode: 'number'}).notNull().default(0),\n state: text('state', {enum: ['open', 'closed']})\n .notNull()\n .default('open'),\n closeReason: text('close_reason', {enum: ['declared', 'timeout']}),\n declaredTotalBytes: bigint('declared_total_bytes', {mode: 'number'}),\n claudeHasInit: boolean('claude_has_init').notNull().default(false),\n claudeSessionId: text('claude_session_id'),\n claudeTurn: integer('claude_turn').notNull().default(0),\n claudePendingResult: jsonb('claude_pending_result').$type<SessionViewLifecycleRow>(),\n claudePendingToolRows: jsonb('claude_pending_tool_rows').$type<SessionViewRow[]>(),\n truncated: boolean('truncated').notNull().default(false),\n objectKey: text('object_key'),\n createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),\n updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),\n closedAt: timestamp('closed_at', {withTimezone: true}),\n },\n (table) => [\n uniqueIndex('logs_attempt_streams_identity_unique').on(\n table.jobId,\n table.stepId,\n table.attempt,\n ),\n // The session read path looks up a stream by (step, attempt) without the job id, so the\n // identity unique (which leads with job_id) can't serve it. This index keeps that lookup\n // off a sequential scan as the table grows.\n index('logs_attempt_streams_step_attempt_idx').on(table.stepId, table.attempt),\n // Timeout-close sweeps an open job's streams by job_id; the partial index keeps\n // it off the closed tail.\n index('logs_attempt_streams_open_by_job_idx').on(table.jobId).where(sql`\"state\" = 'open'`),\n // Open-age reaping needs an index that stays off the closed tail; created_at is\n // immutable, so appends do not churn it.\n index('logs_attempt_streams_open_age_idx').on(table.createdAt).where(sql`\"state\" = 'open'`),\n // Retention scans closed streams by close age; partial so it never carries the\n // open (in-flight) set.\n index('logs_attempt_streams_retention_idx').on(table.closedAt).where(sql`\"state\" = 'closed'`),\n // Reconcile re-drives closed streams that never got an object key.\n index('logs_attempt_streams_uncompacted_idx')\n .on(table.closedAt)\n .where(sql`\"state\" = 'closed' and \"object_key\" is null`),\n ],\n);\n\nexport type AttemptStreamDb = typeof attemptStreams.$inferSelect;\nexport type AttemptStreamInsertDb = typeof attemptStreams.$inferInsert;\n\nexport function toAttemptStream(row: AttemptStreamDb): AttemptStream {\n return {\n id: row.id,\n jobId: row.jobId,\n stepId: row.stepId,\n attempt: row.attempt,\n workspaceId: row.workspaceId,\n projectId: row.projectId,\n workflowRunAttemptId: row.workflowRunAttemptId,\n committedLength: row.committedLength,\n state: row.state,\n closeReason: row.closeReason,\n declaredTotalBytes: row.declaredTotalBytes,\n claudeHasInit: row.claudeHasInit,\n claudeSessionId: row.claudeSessionId,\n claudeTurn: row.claudeTurn,\n claudePendingResult: row.claudePendingResult ?? null,\n claudePendingToolRows: row.claudePendingToolRows ?? [],\n truncated: row.truncated,\n objectKey: row.objectKey,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n closedAt: row.closedAt,\n };\n}\n"],"names":["uuidv7PrimaryKey","sql","bigint","boolean","index","integer","jsonb","text","timestamp","uniqueIndex","uuid","pgTable","attemptStreams","id","jobId","notNull","stepId","attempt","workspaceId","projectId","workflowRunAttemptId","committedLength","mode","default","state","enum","closeReason","declaredTotalBytes","claudeHasInit","claudeSessionId","claudeTurn","claudePendingResult","$type","claudePendingToolRows","truncated","objectKey","createdAt","withTimezone","defaultNow","updatedAt","closedAt","table","on","where","toAttemptStream","row"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../src/db/schema/attempt-streams.ts"],"sourcesContent":["import type {SessionViewLifecycleRow, SessionViewRow} from '@shipfox/api-logs-dto';\nimport {uuidv7PrimaryKey} from '@shipfox/node-drizzle';\nimport {sql} from 'drizzle-orm';\nimport {\n bigint,\n boolean,\n index,\n integer,\n jsonb,\n text,\n timestamp,\n uniqueIndex,\n uuid,\n} from 'drizzle-orm/pg-core';\nimport type {AttemptStream} from '#core/entities/attempt-stream.js';\nimport {pgTable} from './common.js';\n\n/**\n * One stream per (job, step, attempt). Identity is scoped by `job_id` (from the\n * lease), so a lease can only ever reach its own job's streams; cross-job log\n * injection is structurally impossible. `workspace_id`, `project_id`, and `workflow_run_attempt_id`\n * are stamped from the lease at create time; they are functionally determined by\n * `job_id` via workflows FKs, so they are denormalized here for self-contained\n * authorization (per-project read filtering, audit) without joining back to\n * workflows. `committed_length` is the offset-CAS axis (raw NDJSON spool bytes the\n * server has durably accepted from the runner).\n *\n * `truncated` is an out-of-band terminal flag set when the timeout sweep\n * force-closes a stream the runner never ended; the `AttemptStream` entity is the\n * canonical reference for what it means.\n *\n * Per-row `committed_length` and `declared_total_bytes` are bounded by the\n * per-job budget, so `mode: 'number'` is safe on the hot path. Any cross-row\n * aggregate (workspace or system-wide totals) MUST read as bigint at the\n * query site: the global sum is unbounded and would silently lose precision\n * past 2^53 as a JS number.\n */\nexport const attemptStreams = pgTable(\n 'attempt_streams',\n {\n id: uuidv7PrimaryKey(),\n jobId: uuid('job_id').notNull(),\n stepId: uuid('step_id').notNull(),\n attempt: integer('attempt').notNull(),\n workspaceId: uuid('workspace_id').notNull(),\n projectId: uuid('project_id').notNull(),\n workflowRunAttemptId: uuid('workflow_run_attempt_id').notNull(),\n committedLength: bigint('committed_length', {mode: 'number'}).notNull().default(0),\n state: text('state', {enum: ['open', 'closed']})\n .notNull()\n .default('open'),\n closeReason: text('close_reason', {enum: ['declared', 'timeout']}),\n declaredTotalBytes: bigint('declared_total_bytes', {mode: 'number'}),\n claudeHasInit: boolean('claude_has_init').notNull().default(false),\n claudeSessionId: text('claude_session_id'),\n claudeTurn: integer('claude_turn').notNull().default(0),\n claudePendingResult: jsonb('claude_pending_result').$type<SessionViewLifecycleRow>(),\n claudePendingToolRows: jsonb('claude_pending_tool_rows').$type<SessionViewRow[]>(),\n truncated: boolean('truncated').notNull().default(false),\n objectKey: text('object_key'),\n createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),\n updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),\n closedAt: timestamp('closed_at', {withTimezone: true}),\n },\n (table) => [\n uniqueIndex('logs_attempt_streams_identity_unique').on(\n table.jobId,\n table.stepId,\n table.attempt,\n ),\n // The session read path looks up a stream by (step, attempt) without the job id, so the\n // identity unique (which leads with job_id) can't serve it. This index keeps that lookup\n // off a sequential scan as the table grows.\n index('logs_attempt_streams_step_attempt_idx').on(table.stepId, table.attempt),\n // Timeout-close sweeps an open job's streams by job_id; the partial index keeps\n // it off the closed tail.\n index('logs_attempt_streams_open_by_job_idx').on(table.jobId).where(sql`\"state\" = 'open'`),\n // Open-age reaping needs an index that stays off the closed tail; created_at is\n // immutable, so appends do not churn it.\n index('logs_attempt_streams_open_age_idx').on(table.createdAt).where(sql`\"state\" = 'open'`),\n // Retention scans closed streams by close age; partial so it never carries the\n // open (in-flight) set.\n index('logs_attempt_streams_retention_idx').on(table.closedAt).where(sql`\"state\" = 'closed'`),\n // Reconcile re-drives closed streams that never got an object key.\n index('logs_attempt_streams_uncompacted_idx')\n .on(table.closedAt)\n .where(sql`\"state\" = 'closed' and \"object_key\" is null`),\n ],\n);\n\nexport type AttemptStreamDb = typeof attemptStreams.$inferSelect;\nexport type AttemptStreamInsertDb = typeof attemptStreams.$inferInsert;\n\nexport function toAttemptStream(row: AttemptStreamDb): AttemptStream {\n return {\n id: row.id,\n jobId: row.jobId,\n stepId: row.stepId,\n attempt: row.attempt,\n workspaceId: row.workspaceId,\n projectId: row.projectId,\n workflowRunAttemptId: row.workflowRunAttemptId,\n committedLength: row.committedLength,\n state: row.state,\n closeReason: row.closeReason,\n declaredTotalBytes: row.declaredTotalBytes,\n claudeHasInit: row.claudeHasInit,\n claudeSessionId: row.claudeSessionId,\n claudeTurn: row.claudeTurn,\n claudePendingResult: row.claudePendingResult ?? null,\n claudePendingToolRows: row.claudePendingToolRows ?? [],\n truncated: row.truncated,\n objectKey: row.objectKey,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n closedAt: row.closedAt,\n };\n}\n"],"names":["uuidv7PrimaryKey","sql","bigint","boolean","index","integer","jsonb","text","timestamp","uniqueIndex","uuid","pgTable","attemptStreams","id","jobId","notNull","stepId","attempt","workspaceId","projectId","workflowRunAttemptId","committedLength","mode","default","state","enum","closeReason","declaredTotalBytes","claudeHasInit","claudeSessionId","claudeTurn","claudePendingResult","$type","claudePendingToolRows","truncated","objectKey","createdAt","withTimezone","defaultNow","updatedAt","closedAt","table","on","where","toAttemptStream","row"],"mappings":"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"}
@@ -8,7 +8,7 @@ import type { JobAccounting } from '#core/entities/job-accounting.js';
8
8
  *
9
9
  * Per-row `stored_bytes_used` is bounded by the per-job budget, so `mode:
10
10
  * 'number'` is safe on the hot path. Any cross-row aggregate (workspace or
11
- * system-wide totals) MUST read as bigint at the query site the global sum
11
+ * system-wide totals) MUST read as bigint at the query site: the global sum
12
12
  * is unbounded and would silently lose precision past 2^53 as a JS number.
13
13
  */
14
14
  export declare const jobAccounting: import("drizzle-orm/pg-core").PgTableWithColumns<{
@@ -9,7 +9,7 @@ import { pgTable } from './common.js';
9
9
  *
10
10
  * Per-row `stored_bytes_used` is bounded by the per-job budget, so `mode:
11
11
  * 'number'` is safe on the hot path. Any cross-row aggregate (workspace or
12
- * system-wide totals) MUST read as bigint at the query site the global sum
12
+ * system-wide totals) MUST read as bigint at the query site: the global sum
13
13
  * is unbounded and would silently lose precision past 2^53 as a JS number.
14
14
  */ export const jobAccounting = pgTable('job_accounting', {
15
15
  jobId: uuid('job_id').primaryKey(),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/db/schema/job-accounting.ts"],"sourcesContent":["import {bigint, timestamp, uuid} from 'drizzle-orm/pg-core';\nimport type {JobAccounting} from '#core/entities/job-accounting.js';\nimport {pgTable} from './common.js';\n\n/**\n * Accrual-budget state per job run, keyed by the job id carried in the lease.\n * `stored_bytes_used` counts the normalized NDJSON bytes the server has stored for the job\n * (envelope and control records included), so the budget bounds exactly what lands in Postgres.\n * `started_at` is the budget clock origin (first append).\n * `capped_at`, once set, makes every later append a no-op drop.\n *\n * Per-row `stored_bytes_used` is bounded by the per-job budget, so `mode:\n * 'number'` is safe on the hot path. Any cross-row aggregate (workspace or\n * system-wide totals) MUST read as bigint at the query site the global sum\n * is unbounded and would silently lose precision past 2^53 as a JS number.\n */\nexport const jobAccounting = pgTable('job_accounting', {\n jobId: uuid('job_id').primaryKey(),\n workspaceId: uuid('workspace_id').notNull(),\n storedBytesUsed: bigint('stored_bytes_used', {mode: 'number'}).notNull().default(0),\n startedAt: timestamp('started_at', {withTimezone: true}).notNull().defaultNow(),\n cappedAt: timestamp('capped_at', {withTimezone: true}),\n createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),\n updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),\n});\n\nexport type JobAccountingDb = typeof jobAccounting.$inferSelect;\nexport type JobAccountingInsertDb = typeof jobAccounting.$inferInsert;\n\nexport function toJobAccounting(row: JobAccountingDb): JobAccounting {\n return {\n jobId: row.jobId,\n workspaceId: row.workspaceId,\n storedBytesUsed: row.storedBytesUsed,\n startedAt: row.startedAt,\n cappedAt: row.cappedAt,\n };\n}\n"],"names":["bigint","timestamp","uuid","pgTable","jobAccounting","jobId","primaryKey","workspaceId","notNull","storedBytesUsed","mode","default","startedAt","withTimezone","defaultNow","cappedAt","createdAt","updatedAt","toJobAccounting","row"],"mappings":"AAAA,SAAQA,MAAM,EAAEC,SAAS,EAAEC,IAAI,QAAO,sBAAsB;AAE5D,SAAQC,OAAO,QAAO,cAAc;AAEpC;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,gBAAgBD,QAAQ,kBAAkB;IACrDE,OAAOH,KAAK,UAAUI,UAAU;IAChCC,aAAaL,KAAK,gBAAgBM,OAAO;IACzCC,iBAAiBT,OAAO,qBAAqB;QAACU,MAAM;IAAQ,GAAGF,OAAO,GAAGG,OAAO,CAAC;IACjFC,WAAWX,UAAU,cAAc;QAACY,cAAc;IAAI,GAAGL,OAAO,GAAGM,UAAU;IAC7EC,UAAUd,UAAU,aAAa;QAACY,cAAc;IAAI;IACpDG,WAAWf,UAAU,cAAc;QAACY,cAAc;IAAI,GAAGL,OAAO,GAAGM,UAAU;IAC7EG,WAAWhB,UAAU,cAAc;QAACY,cAAc;IAAI,GAAGL,OAAO,GAAGM,UAAU;AAC/E,GAAG;AAKH,OAAO,SAASI,gBAAgBC,GAAoB;IAClD,OAAO;QACLd,OAAOc,IAAId,KAAK;QAChBE,aAAaY,IAAIZ,WAAW;QAC5BE,iBAAiBU,IAAIV,eAAe;QACpCG,WAAWO,IAAIP,SAAS;QACxBG,UAAUI,IAAIJ,QAAQ;IACxB;AACF"}
1
+ {"version":3,"sources":["../../../src/db/schema/job-accounting.ts"],"sourcesContent":["import {bigint, timestamp, uuid} from 'drizzle-orm/pg-core';\nimport type {JobAccounting} from '#core/entities/job-accounting.js';\nimport {pgTable} from './common.js';\n\n/**\n * Accrual-budget state per job run, keyed by the job id carried in the lease.\n * `stored_bytes_used` counts the normalized NDJSON bytes the server has stored for the job\n * (envelope and control records included), so the budget bounds exactly what lands in Postgres.\n * `started_at` is the budget clock origin (first append).\n * `capped_at`, once set, makes every later append a no-op drop.\n *\n * Per-row `stored_bytes_used` is bounded by the per-job budget, so `mode:\n * 'number'` is safe on the hot path. Any cross-row aggregate (workspace or\n * system-wide totals) MUST read as bigint at the query site: the global sum\n * is unbounded and would silently lose precision past 2^53 as a JS number.\n */\nexport const jobAccounting = pgTable('job_accounting', {\n jobId: uuid('job_id').primaryKey(),\n workspaceId: uuid('workspace_id').notNull(),\n storedBytesUsed: bigint('stored_bytes_used', {mode: 'number'}).notNull().default(0),\n startedAt: timestamp('started_at', {withTimezone: true}).notNull().defaultNow(),\n cappedAt: timestamp('capped_at', {withTimezone: true}),\n createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),\n updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),\n});\n\nexport type JobAccountingDb = typeof jobAccounting.$inferSelect;\nexport type JobAccountingInsertDb = typeof jobAccounting.$inferInsert;\n\nexport function toJobAccounting(row: JobAccountingDb): JobAccounting {\n return {\n jobId: row.jobId,\n workspaceId: row.workspaceId,\n storedBytesUsed: row.storedBytesUsed,\n startedAt: row.startedAt,\n cappedAt: row.cappedAt,\n };\n}\n"],"names":["bigint","timestamp","uuid","pgTable","jobAccounting","jobId","primaryKey","workspaceId","notNull","storedBytesUsed","mode","default","startedAt","withTimezone","defaultNow","cappedAt","createdAt","updatedAt","toJobAccounting","row"],"mappings":"AAAA,SAAQA,MAAM,EAAEC,SAAS,EAAEC,IAAI,QAAO,sBAAsB;AAE5D,SAAQC,OAAO,QAAO,cAAc;AAEpC;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,gBAAgBD,QAAQ,kBAAkB;IACrDE,OAAOH,KAAK,UAAUI,UAAU;IAChCC,aAAaL,KAAK,gBAAgBM,OAAO;IACzCC,iBAAiBT,OAAO,qBAAqB;QAACU,MAAM;IAAQ,GAAGF,OAAO,GAAGG,OAAO,CAAC;IACjFC,WAAWX,UAAU,cAAc;QAACY,cAAc;IAAI,GAAGL,OAAO,GAAGM,UAAU;IAC7EC,UAAUd,UAAU,aAAa;QAACY,cAAc;IAAI;IACpDG,WAAWf,UAAU,cAAc;QAACY,cAAc;IAAI,GAAGL,OAAO,GAAGM,UAAU;IAC7EG,WAAWhB,UAAU,cAAc;QAACY,cAAc;IAAI,GAAGL,OAAO,GAAGM,UAAU;AAC/E,GAAG;AAKH,OAAO,SAASI,gBAAgBC,GAAoB;IAClD,OAAO;QACLd,OAAOc,IAAId,KAAK;QAChBE,aAAaY,IAAIZ,WAAW;QAC5BE,iBAAiBU,IAAIV,eAAe;QACpCG,WAAWO,IAAIP,SAAS;QACxBG,UAAUI,IAAIJ,QAAQ;IACxB;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"read-logs.d.ts","sourceRoot":"","sources":["../../../src/presentation/routes/read-logs.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,aAAa,iDA6BxB,CAAC"}
1
+ {"version":3,"file":"read-logs.d.ts","sourceRoot":"","sources":["../../../src/presentation/routes/read-logs.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,aAAa,iDAiCxB,CAAC"}
@@ -1,4 +1,4 @@
1
- import { requireUserContext } from '@shipfox/api-auth-context';
1
+ import { requireWorkspaceResourceAccess } from '@shipfox/api-auth-context';
2
2
  import { readLogsQuerySchema, readLogsResponseSchema } from '@shipfox/api-logs-dto';
3
3
  import { ClientError, defineRoute } from '@shipfox/node-fastify';
4
4
  import { z } from 'zod';
@@ -20,20 +20,26 @@ export const readLogsRoute = defineRoute({
20
20
  }
21
21
  },
22
22
  handler: async (request)=>{
23
- const user = requireUserContext(request);
24
23
  const { stepId, attempt } = request.params;
25
24
  const { cursor } = request.query;
26
25
  const stream = await getStreamByStepAttempt({
27
26
  stepId,
28
27
  attempt
29
28
  });
30
- // 404 covers both "no such stream" and "not your workspace" so the endpoint never
31
- // leaks the existence of another workspace's step.
32
- if (!stream || !user.canAccess(stream.workspaceId)) {
29
+ // Missing streams and memberships remain 404 so the endpoint never leaks another
30
+ // workspace's step; lifecycle claims propagate their stable access errors.
31
+ if (!stream) {
33
32
  throw new ClientError('Logs not found', 'not-found', {
34
33
  status: 404
35
34
  });
36
35
  }
36
+ requireWorkspaceResourceAccess({
37
+ request,
38
+ workspaceId: stream.workspaceId,
39
+ notFoundError: new ClientError('Logs not found', 'not-found', {
40
+ status: 404
41
+ })
42
+ });
37
43
  return toReadLogsDto(await buildLogReadResult(stream, cursor));
38
44
  }
39
45
  });