@shipfox/api-logs 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (342) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +179 -0
  5. package/LICENSE +21 -0
  6. package/README.md +151 -0
  7. package/dist/api/object-storage.d.ts +69 -0
  8. package/dist/api/object-storage.d.ts.map +1 -0
  9. package/dist/api/object-storage.js +199 -0
  10. package/dist/api/object-storage.js.map +1 -0
  11. package/dist/config.d.ts +20 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +108 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/core/append-logs.d.ts +28 -0
  16. package/dist/core/append-logs.d.ts.map +1 -0
  17. package/dist/core/append-logs.js +318 -0
  18. package/dist/core/append-logs.js.map +1 -0
  19. package/dist/core/budget.d.ts +12 -0
  20. package/dist/core/budget.d.ts.map +1 -0
  21. package/dist/core/budget.js +9 -0
  22. package/dist/core/budget.js.map +1 -0
  23. package/dist/core/close-stream.d.ts +27 -0
  24. package/dist/core/close-stream.d.ts.map +1 -0
  25. package/dist/core/close-stream.js +59 -0
  26. package/dist/core/close-stream.js.map +1 -0
  27. package/dist/core/compaction.d.ts +28 -0
  28. package/dist/core/compaction.d.ts.map +1 -0
  29. package/dist/core/compaction.js +49 -0
  30. package/dist/core/compaction.js.map +1 -0
  31. package/dist/core/entities/attempt-stream.d.ts +37 -0
  32. package/dist/core/entities/attempt-stream.d.ts.map +1 -0
  33. package/dist/core/entities/attempt-stream.js +15 -0
  34. package/dist/core/entities/attempt-stream.js.map +1 -0
  35. package/dist/core/entities/job-accounting.d.ts +12 -0
  36. package/dist/core/entities/job-accounting.d.ts.map +1 -0
  37. package/dist/core/entities/job-accounting.js +6 -0
  38. package/dist/core/entities/job-accounting.js.map +1 -0
  39. package/dist/core/entities/log-object.d.ts +13 -0
  40. package/dist/core/entities/log-object.d.ts.map +1 -0
  41. package/dist/core/entities/log-object.js +9 -0
  42. package/dist/core/entities/log-object.js.map +1 -0
  43. package/dist/core/errors.d.ts +26 -0
  44. package/dist/core/errors.d.ts.map +1 -0
  45. package/dist/core/errors.js +31 -0
  46. package/dist/core/errors.js.map +1 -0
  47. package/dist/core/finalize-attempt-stream.d.ts +22 -0
  48. package/dist/core/finalize-attempt-stream.d.ts.map +1 -0
  49. package/dist/core/finalize-attempt-stream.js +48 -0
  50. package/dist/core/finalize-attempt-stream.js.map +1 -0
  51. package/dist/core/read-logs.d.ts +33 -0
  52. package/dist/core/read-logs.d.ts.map +1 -0
  53. package/dist/core/read-logs.js +49 -0
  54. package/dist/core/read-logs.js.map +1 -0
  55. package/dist/core/reap-stale-open-streams.d.ts +27 -0
  56. package/dist/core/reap-stale-open-streams.d.ts.map +1 -0
  57. package/dist/core/reap-stale-open-streams.js +50 -0
  58. package/dist/core/reap-stale-open-streams.js.map +1 -0
  59. package/dist/core/retention.d.ts +32 -0
  60. package/dist/core/retention.d.ts.map +1 -0
  61. package/dist/core/retention.js +148 -0
  62. package/dist/core/retention.js.map +1 -0
  63. package/dist/core/session/claude/rows.d.ts +6 -0
  64. package/dist/core/session/claude/rows.d.ts.map +1 -0
  65. package/dist/core/session/claude/rows.js +145 -0
  66. package/dist/core/session/claude/rows.js.map +1 -0
  67. package/dist/core/session/claude-parser.d.ts +4 -0
  68. package/dist/core/session/claude-parser.d.ts.map +1 -0
  69. package/dist/core/session/claude-parser.js +42 -0
  70. package/dist/core/session/claude-parser.js.map +1 -0
  71. package/dist/core/session/entry-schema.d.ts +51 -0
  72. package/dist/core/session/entry-schema.d.ts.map +1 -0
  73. package/dist/core/session/entry-schema.js +61 -0
  74. package/dist/core/session/entry-schema.js.map +1 -0
  75. package/dist/core/session/object.d.ts +12 -0
  76. package/dist/core/session/object.d.ts.map +1 -0
  77. package/dist/core/session/object.js +50 -0
  78. package/dist/core/session/object.js.map +1 -0
  79. package/dist/core/session/parse-session.d.ts +5 -0
  80. package/dist/core/session/parse-session.d.ts.map +1 -0
  81. package/dist/core/session/parse-session.js +18 -0
  82. package/dist/core/session/parse-session.js.map +1 -0
  83. package/dist/core/session/pi/message-rows.d.ts +4 -0
  84. package/dist/core/session/pi/message-rows.d.ts.map +1 -0
  85. package/dist/core/session/pi/message-rows.js +113 -0
  86. package/dist/core/session/pi/message-rows.js.map +1 -0
  87. package/dist/core/session/pi/metadata.d.ts +18 -0
  88. package/dist/core/session/pi/metadata.d.ts.map +1 -0
  89. package/dist/core/session/pi/metadata.js +120 -0
  90. package/dist/core/session/pi/metadata.js.map +1 -0
  91. package/dist/core/session/pi/text.d.ts +12 -0
  92. package/dist/core/session/pi/text.d.ts.map +1 -0
  93. package/dist/core/session/pi/text.js +59 -0
  94. package/dist/core/session/pi/text.js.map +1 -0
  95. package/dist/core/session/pi-parser.d.ts +5 -0
  96. package/dist/core/session/pi-parser.d.ts.map +1 -0
  97. package/dist/core/session/pi-parser.js +73 -0
  98. package/dist/core/session/pi-parser.js.map +1 -0
  99. package/dist/core/session/rows.d.ts +7 -0
  100. package/dist/core/session/rows.d.ts.map +1 -0
  101. package/dist/core/session/rows.js +39 -0
  102. package/dist/core/session/rows.js.map +1 -0
  103. package/dist/core/session/session-record.d.ts +5 -0
  104. package/dist/core/session/session-record.d.ts.map +1 -0
  105. package/dist/core/session/session-record.js +3 -0
  106. package/dist/core/session/session-record.js.map +1 -0
  107. package/dist/db/accounting.d.ts +33 -0
  108. package/dist/db/accounting.d.ts.map +1 -0
  109. package/dist/db/accounting.js +52 -0
  110. package/dist/db/accounting.js.map +1 -0
  111. package/dist/db/chunks.d.ts +62 -0
  112. package/dist/db/chunks.d.ts.map +1 -0
  113. package/dist/db/chunks.js +88 -0
  114. package/dist/db/chunks.js.map +1 -0
  115. package/dist/db/db.d.ts +752 -0
  116. package/dist/db/db.d.ts.map +1 -0
  117. package/dist/db/db.js +24 -0
  118. package/dist/db/db.js.map +1 -0
  119. package/dist/db/index.d.ts +2 -0
  120. package/dist/db/index.d.ts.map +1 -0
  121. package/dist/db/index.js +5 -0
  122. package/dist/db/index.js.map +1 -0
  123. package/dist/db/schema/attempt-streams.d.ts +304 -0
  124. package/dist/db/schema/attempt-streams.d.ts.map +1 -0
  125. package/dist/db/schema/attempt-streams.js +100 -0
  126. package/dist/db/schema/attempt-streams.js.map +1 -0
  127. package/dist/db/schema/chunks.d.ts +166 -0
  128. package/dist/db/schema/chunks.d.ts.map +1 -0
  129. package/dist/db/schema/chunks.js +47 -0
  130. package/dist/db/schema/chunks.js.map +1 -0
  131. package/dist/db/schema/common.d.ts +29 -0
  132. package/dist/db/schema/common.d.ts.map +1 -0
  133. package/dist/db/schema/common.js +11 -0
  134. package/dist/db/schema/common.js.map +1 -0
  135. package/dist/db/schema/job-accounting.d.ts +143 -0
  136. package/dist/db/schema/job-accounting.d.ts.map +1 -0
  137. package/dist/db/schema/job-accounting.js +43 -0
  138. package/dist/db/schema/job-accounting.js.map +1 -0
  139. package/dist/db/schema/outbox.d.ts +195 -0
  140. package/dist/db/schema/outbox.d.ts.map +1 -0
  141. package/dist/db/schema/outbox.js +5 -0
  142. package/dist/db/schema/outbox.js.map +1 -0
  143. package/dist/db/streams.d.ts +141 -0
  144. package/dist/db/streams.d.ts.map +1 -0
  145. package/dist/db/streams.js +197 -0
  146. package/dist/db/streams.js.map +1 -0
  147. package/dist/index.d.ts +4 -0
  148. package/dist/index.d.ts.map +1 -0
  149. package/dist/index.js +81 -0
  150. package/dist/index.js.map +1 -0
  151. package/dist/metrics/index.d.ts +2 -0
  152. package/dist/metrics/index.d.ts.map +1 -0
  153. package/dist/metrics/index.js +3 -0
  154. package/dist/metrics/index.js.map +1 -0
  155. package/dist/metrics/instance.d.ts +14 -0
  156. package/dist/metrics/instance.d.ts.map +1 -0
  157. package/dist/metrics/instance.js +16 -0
  158. package/dist/metrics/instance.js.map +1 -0
  159. package/dist/metrics/service.d.ts +2 -0
  160. package/dist/metrics/service.d.ts.map +1 -0
  161. package/dist/metrics/service.js +20 -0
  162. package/dist/metrics/service.js.map +1 -0
  163. package/dist/presentation/dto/read-logs.d.ts +4 -0
  164. package/dist/presentation/dto/read-logs.d.ts.map +1 -0
  165. package/dist/presentation/dto/read-logs.js +22 -0
  166. package/dist/presentation/dto/read-logs.js.map +1 -0
  167. package/dist/presentation/routes/append-logs.d.ts +2 -0
  168. package/dist/presentation/routes/append-logs.d.ts.map +1 -0
  169. package/dist/presentation/routes/append-logs.js +71 -0
  170. package/dist/presentation/routes/append-logs.js.map +1 -0
  171. package/dist/presentation/routes/index.d.ts +3 -0
  172. package/dist/presentation/routes/index.d.ts.map +1 -0
  173. package/dist/presentation/routes/index.js +32 -0
  174. package/dist/presentation/routes/index.js.map +1 -0
  175. package/dist/presentation/routes/read-logs.d.ts +2 -0
  176. package/dist/presentation/routes/read-logs.d.ts.map +1 -0
  177. package/dist/presentation/routes/read-logs.js +41 -0
  178. package/dist/presentation/routes/read-logs.js.map +1 -0
  179. package/dist/presentation/subscribers/on-job-terminated.d.ts +8 -0
  180. package/dist/presentation/subscribers/on-job-terminated.d.ts.map +1 -0
  181. package/dist/presentation/subscribers/on-job-terminated.js +32 -0
  182. package/dist/presentation/subscribers/on-job-terminated.js.map +1 -0
  183. package/dist/presentation/subscribers/on-log-stream-closed.d.ts +9 -0
  184. package/dist/presentation/subscribers/on-log-stream-closed.d.ts.map +1 -0
  185. package/dist/presentation/subscribers/on-log-stream-closed.js +31 -0
  186. package/dist/presentation/subscribers/on-log-stream-closed.js.map +1 -0
  187. package/dist/presentation/subscribers/on-step-attempt-terminated.d.ts +3 -0
  188. package/dist/presentation/subscribers/on-step-attempt-terminated.d.ts.map +1 -0
  189. package/dist/presentation/subscribers/on-step-attempt-terminated.js +14 -0
  190. package/dist/presentation/subscribers/on-step-attempt-terminated.js.map +1 -0
  191. package/dist/temporal/activities/close-abandoned-streams.d.ts +12 -0
  192. package/dist/temporal/activities/close-abandoned-streams.d.ts.map +1 -0
  193. package/dist/temporal/activities/close-abandoned-streams.js +33 -0
  194. package/dist/temporal/activities/close-abandoned-streams.js.map +1 -0
  195. package/dist/temporal/activities/compact-stream.d.ts +33 -0
  196. package/dist/temporal/activities/compact-stream.d.ts.map +1 -0
  197. package/dist/temporal/activities/compact-stream.js +95 -0
  198. package/dist/temporal/activities/compact-stream.js.map +1 -0
  199. package/dist/temporal/activities/compaction-reconcile.d.ts +16 -0
  200. package/dist/temporal/activities/compaction-reconcile.d.ts.map +1 -0
  201. package/dist/temporal/activities/compaction-reconcile.js +52 -0
  202. package/dist/temporal/activities/compaction-reconcile.js.map +1 -0
  203. package/dist/temporal/activities/index.d.ts +14 -0
  204. package/dist/temporal/activities/index.d.ts.map +1 -0
  205. package/dist/temporal/activities/index.js +16 -0
  206. package/dist/temporal/activities/index.js.map +1 -0
  207. package/dist/temporal/activities/reap-stale-open-streams.d.ts +4 -0
  208. package/dist/temporal/activities/reap-stale-open-streams.d.ts.map +1 -0
  209. package/dist/temporal/activities/reap-stale-open-streams.js +11 -0
  210. package/dist/temporal/activities/reap-stale-open-streams.js.map +1 -0
  211. package/dist/temporal/activities/retention-sweep.d.ts +7 -0
  212. package/dist/temporal/activities/retention-sweep.d.ts.map +1 -0
  213. package/dist/temporal/activities/retention-sweep.js +19 -0
  214. package/dist/temporal/activities/retention-sweep.js.map +1 -0
  215. package/dist/temporal/constants.d.ts +7 -0
  216. package/dist/temporal/constants.d.ts.map +1 -0
  217. package/dist/temporal/constants.js +13 -0
  218. package/dist/temporal/constants.js.map +1 -0
  219. package/dist/temporal/workflows/close-abandoned-streams.d.ts +11 -0
  220. package/dist/temporal/workflows/close-abandoned-streams.d.ts.map +1 -0
  221. package/dist/temporal/workflows/close-abandoned-streams.js +22 -0
  222. package/dist/temporal/workflows/close-abandoned-streams.js.map +1 -0
  223. package/dist/temporal/workflows/compact-stream.d.ts +6 -0
  224. package/dist/temporal/workflows/compact-stream.d.ts.map +1 -0
  225. package/dist/temporal/workflows/compact-stream.js +25 -0
  226. package/dist/temporal/workflows/compact-stream.js.map +1 -0
  227. package/dist/temporal/workflows/compaction-reconcile-cron.d.ts +3 -0
  228. package/dist/temporal/workflows/compaction-reconcile-cron.d.ts.map +1 -0
  229. package/dist/temporal/workflows/compaction-reconcile-cron.js +15 -0
  230. package/dist/temporal/workflows/compaction-reconcile-cron.js.map +1 -0
  231. package/dist/temporal/workflows/index.d.ts +6 -0
  232. package/dist/temporal/workflows/index.d.ts.map +1 -0
  233. package/dist/temporal/workflows/index.js +7 -0
  234. package/dist/temporal/workflows/index.js.map +1 -0
  235. package/dist/temporal/workflows/reap-stale-open-streams-cron.d.ts +2 -0
  236. package/dist/temporal/workflows/reap-stale-open-streams-cron.d.ts.map +1 -0
  237. package/dist/temporal/workflows/reap-stale-open-streams-cron.js +15 -0
  238. package/dist/temporal/workflows/reap-stale-open-streams-cron.js.map +1 -0
  239. package/dist/temporal/workflows/retention-sweep-cron.d.ts +2 -0
  240. package/dist/temporal/workflows/retention-sweep-cron.d.ts.map +1 -0
  241. package/dist/temporal/workflows/retention-sweep-cron.js +23 -0
  242. package/dist/temporal/workflows/retention-sweep-cron.js.map +1 -0
  243. package/dist/tsconfig.test.tsbuildinfo +1 -0
  244. package/drizzle/0000_initial.sql +65 -0
  245. package/drizzle/meta/0000_snapshot.json +529 -0
  246. package/drizzle/meta/_journal.json +13 -0
  247. package/drizzle.config.ts +7 -0
  248. package/package.json +84 -0
  249. package/src/api/object-storage-credentials.test.ts +70 -0
  250. package/src/api/object-storage.test.ts +64 -0
  251. package/src/api/object-storage.ts +226 -0
  252. package/src/config.test.ts +118 -0
  253. package/src/config.ts +130 -0
  254. package/src/core/append-logs.test.ts +563 -0
  255. package/src/core/append-logs.ts +384 -0
  256. package/src/core/budget.test.ts +30 -0
  257. package/src/core/budget.ts +19 -0
  258. package/src/core/close-stream.ts +72 -0
  259. package/src/core/compaction.ts +68 -0
  260. package/src/core/entities/attempt-stream.ts +38 -0
  261. package/src/core/entities/job-accounting.ts +11 -0
  262. package/src/core/entities/log-object.test.ts +25 -0
  263. package/src/core/entities/log-object.ts +18 -0
  264. package/src/core/errors.ts +37 -0
  265. package/src/core/finalize-attempt-stream.test.ts +90 -0
  266. package/src/core/finalize-attempt-stream.ts +59 -0
  267. package/src/core/read-logs.ts +77 -0
  268. package/src/core/reap-stale-open-streams.test.ts +137 -0
  269. package/src/core/reap-stale-open-streams.ts +60 -0
  270. package/src/core/retention.test.ts +388 -0
  271. package/src/core/retention.ts +184 -0
  272. package/src/core/session/claude/rows.ts +223 -0
  273. package/src/core/session/claude-parser.test.ts +199 -0
  274. package/src/core/session/claude-parser.ts +38 -0
  275. package/src/core/session/entry-schema.ts +82 -0
  276. package/src/core/session/object.ts +57 -0
  277. package/src/core/session/parse-session.ts +23 -0
  278. package/src/core/session/pi/message-rows.ts +211 -0
  279. package/src/core/session/pi/metadata.ts +148 -0
  280. package/src/core/session/pi/text.ts +83 -0
  281. package/src/core/session/pi-parser.test.ts +451 -0
  282. package/src/core/session/pi-parser.ts +107 -0
  283. package/src/core/session/rows.ts +38 -0
  284. package/src/core/session/session-record.ts +4 -0
  285. package/src/db/accounting.ts +81 -0
  286. package/src/db/chunks.ts +143 -0
  287. package/src/db/db.ts +22 -0
  288. package/src/db/index.ts +4 -0
  289. package/src/db/schema/attempt-streams.ts +106 -0
  290. package/src/db/schema/chunks.ts +43 -0
  291. package/src/db/schema/common.ts +12 -0
  292. package/src/db/schema/job-accounting.ts +38 -0
  293. package/src/db/schema/outbox.ts +4 -0
  294. package/src/db/streams.test.ts +47 -0
  295. package/src/db/streams.ts +368 -0
  296. package/src/index.ts +77 -0
  297. package/src/metrics/index.ts +1 -0
  298. package/src/metrics/instance.ts +33 -0
  299. package/src/metrics/service.ts +23 -0
  300. package/src/presentation/dto/read-logs.ts +24 -0
  301. package/src/presentation/routes/append-logs.test.ts +279 -0
  302. package/src/presentation/routes/append-logs.ts +66 -0
  303. package/src/presentation/routes/index.ts +27 -0
  304. package/src/presentation/routes/read-logs.test.ts +504 -0
  305. package/src/presentation/routes/read-logs.ts +38 -0
  306. package/src/presentation/subscribers/on-job-terminated.test.ts +64 -0
  307. package/src/presentation/subscribers/on-job-terminated.ts +26 -0
  308. package/src/presentation/subscribers/on-log-stream-closed.test.ts +63 -0
  309. package/src/presentation/subscribers/on-log-stream-closed.ts +26 -0
  310. package/src/presentation/subscribers/on-step-attempt-terminated.ts +16 -0
  311. package/src/temporal/activities/close-abandoned-streams.test.ts +84 -0
  312. package/src/temporal/activities/close-abandoned-streams.ts +30 -0
  313. package/src/temporal/activities/compact-stream.test.ts +221 -0
  314. package/src/temporal/activities/compact-stream.ts +123 -0
  315. package/src/temporal/activities/compaction-reconcile.test.ts +143 -0
  316. package/src/temporal/activities/compaction-reconcile.ts +48 -0
  317. package/src/temporal/activities/index.ts +15 -0
  318. package/src/temporal/activities/reap-stale-open-streams.test.ts +48 -0
  319. package/src/temporal/activities/reap-stale-open-streams.ts +14 -0
  320. package/src/temporal/activities/retention-sweep.test.ts +38 -0
  321. package/src/temporal/activities/retention-sweep.ts +23 -0
  322. package/src/temporal/constants.ts +14 -0
  323. package/src/temporal/workflows/close-abandoned-streams.ts +25 -0
  324. package/src/temporal/workflows/compact-stream.ts +26 -0
  325. package/src/temporal/workflows/compaction-reconcile-cron.ts +14 -0
  326. package/src/temporal/workflows/index.ts +5 -0
  327. package/src/temporal/workflows/reap-stale-open-streams-cron.ts +13 -0
  328. package/src/temporal/workflows/retention-sweep-cron.ts +23 -0
  329. package/test/env.ts +37 -0
  330. package/test/factories/job-accounting.ts +38 -0
  331. package/test/fixtures/closed-stream.ts +53 -0
  332. package/test/fixtures/lease-token.ts +42 -0
  333. package/test/fixtures/ndjson.ts +34 -0
  334. package/test/globalSetup.ts +44 -0
  335. package/test/index.ts +9 -0
  336. package/test/queries.ts +54 -0
  337. package/test/setup.ts +17 -0
  338. package/tsconfig.build.json +9 -0
  339. package/tsconfig.build.tsbuildinfo +1 -0
  340. package/tsconfig.json +3 -0
  341. package/tsconfig.test.json +8 -0
  342. package/vitest.config.ts +13 -0
package/src/config.ts ADDED
@@ -0,0 +1,130 @@
1
+ import {config as authConfig} from '@shipfox/api-auth/config';
2
+ import {bool, createConfig, num, str, url} from '@shipfox/config';
3
+ import {durationToSeconds} from '@shipfox/node-jwt';
4
+
5
+ export const config = createConfig({
6
+ LOG_STORAGE_S3_ENDPOINT: url({
7
+ 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.',
8
+ default: 'http://localhost:3900',
9
+ }),
10
+ LOG_STORAGE_S3_REGION: str({
11
+ 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.',
12
+ default: 'garage',
13
+ }),
14
+ LOG_STORAGE_S3_BUCKET: str({
15
+ 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.',
16
+ default: 'shipfox-logs',
17
+ }),
18
+ LOG_STORAGE_S3_PREFIX: str({
19
+ 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.',
20
+ default: 'logs',
21
+ }),
22
+ LOG_STORAGE_S3_ACCESS_KEY_ID: str({
23
+ 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.',
24
+ default: undefined,
25
+ }),
26
+ LOG_STORAGE_S3_SECRET_ACCESS_KEY: str({
27
+ 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.',
28
+ default: undefined,
29
+ }),
30
+ LOG_STORAGE_S3_FORCE_PATH_STYLE: bool({
31
+ 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.',
32
+ default: true,
33
+ }),
34
+ LOG_BUDGET_BASE_BYTES: num({
35
+ 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.',
36
+ default: 33_554_432,
37
+ }),
38
+ LOG_BUDGET_RATE_BYTES_PER_MINUTE: num({
39
+ desc: 'Stored bytes added to the per-job log budget for each minute since the first log append. Defaults to 1 MiB per minute.',
40
+ default: 1_048_576,
41
+ }),
42
+ LOG_STREAM_CLOSE_GRACE_SECONDS: num({
43
+ 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.',
44
+ default: 120,
45
+ }),
46
+ LOG_COMPACTION_RECONCILE_STALE_SECONDS: num({
47
+ 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).',
48
+ default: 900,
49
+ }),
50
+ LOG_STREAM_REAP_AFTER_SECONDS: num({
51
+ desc: 'How long a log stream may stay open before the reaper cron force-closes it as abandoned. This is the backstop for a stream a runner started after its job already went terminal (the one-shot close sweep had already run); the reaper marks it truncated so it re-enters the compaction and retention lifecycle. Must be greater than AUTH_JOB_LEASE_TOKEN_EXPIRES_IN (the job lease lifetime, default 90 minutes) so a still-valid lease can never be force-closed mid-append, which would silently truncate live logs. Defaults to 7200 seconds (2 hours).',
52
+ default: 7200,
53
+ }),
54
+ LOG_APPEND_BODY_LIMIT_BYTES: num({
55
+ 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.',
56
+ default: 8_388_608,
57
+ }),
58
+ LOG_MAX_SESSION_LINE_BYTES: num({
59
+ 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.',
60
+ default: 4_194_304,
61
+ }),
62
+ LOG_READ_URL_TTL_SECONDS: num({
63
+ 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).',
64
+ default: 3600,
65
+ }),
66
+ LOG_READ_INLINE_MAX_BYTES: num({
67
+ 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.',
68
+ default: 1_048_576,
69
+ }),
70
+ LOG_RETENTION_DAYS: num({
71
+ 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.',
72
+ default: 90,
73
+ }),
74
+ });
75
+
76
+ const hasS3AccessKeyId = Boolean(config.LOG_STORAGE_S3_ACCESS_KEY_ID);
77
+ const hasS3SecretAccessKey = Boolean(config.LOG_STORAGE_S3_SECRET_ACCESS_KEY);
78
+ if (hasS3AccessKeyId !== hasS3SecretAccessKey) {
79
+ throw new Error(
80
+ 'LOG_STORAGE_S3_ACCESS_KEY_ID and LOG_STORAGE_S3_SECRET_ACCESS_KEY must be set together or both left unset.',
81
+ );
82
+ }
83
+
84
+ // SigV4 caps a presigned URL's lifetime at 7 days, so a larger TTL would fail at signing
85
+ // time on every cold read. Reject out-of-range values at startup rather than per request.
86
+ const MAX_PRESIGN_TTL_SECONDS = 604_800;
87
+ if (
88
+ config.LOG_READ_URL_TTL_SECONDS < 1 ||
89
+ config.LOG_READ_URL_TTL_SECONDS > MAX_PRESIGN_TTL_SECONDS
90
+ ) {
91
+ throw new Error(
92
+ `LOG_READ_URL_TTL_SECONDS must be between 1 and ${MAX_PRESIGN_TTL_SECONDS}; got ${config.LOG_READ_URL_TTL_SECONDS}`,
93
+ );
94
+ }
95
+ if (config.LOG_READ_INLINE_MAX_BYTES < 1) {
96
+ throw new Error(
97
+ `LOG_READ_INLINE_MAX_BYTES must be at least 1; got ${config.LOG_READ_INLINE_MAX_BYTES}`,
98
+ );
99
+ }
100
+
101
+ // A whole agent_session line is one record in one body. If the body limit is below the
102
+ // line cap, a legitimate large session line could never fit in one request body — Fastify
103
+ // would 413 it before the per-line validator runs. Fail fast at startup instead.
104
+ if (config.LOG_APPEND_BODY_LIMIT_BYTES < config.LOG_MAX_SESSION_LINE_BYTES) {
105
+ throw new Error(
106
+ `LOG_APPEND_BODY_LIMIT_BYTES (${config.LOG_APPEND_BODY_LIMIT_BYTES}) must be >= LOG_MAX_SESSION_LINE_BYTES (${config.LOG_MAX_SESSION_LINE_BYTES}).`,
107
+ );
108
+ }
109
+
110
+ // `num` accepts zero, negative, and fractional values; reject them before they reach
111
+ // `make_interval(days => ...)` and hard-delete the wrong streams.
112
+ if (!Number.isInteger(config.LOG_RETENTION_DAYS) || config.LOG_RETENTION_DAYS < 1) {
113
+ throw new Error(
114
+ `LOG_RETENTION_DAYS (${config.LOG_RETENTION_DAYS}) must be a whole number of days >= 1.`,
115
+ );
116
+ }
117
+
118
+ // A stream still accepts appends until its job lease expires, so reaping it any sooner would
119
+ // truncate live logs. Validate against the real lease TTL read from auth's config (not a
120
+ // hardcoded floor), so raising AUTH_JOB_LEASE_TOKEN_EXPIRES_IN can never silently outrun the
121
+ // reaper window.
122
+ const jobLeaseTtlSeconds = durationToSeconds(authConfig.AUTH_JOB_LEASE_TOKEN_EXPIRES_IN);
123
+ if (
124
+ !Number.isFinite(config.LOG_STREAM_REAP_AFTER_SECONDS) ||
125
+ config.LOG_STREAM_REAP_AFTER_SECONDS <= jobLeaseTtlSeconds
126
+ ) {
127
+ throw new Error(
128
+ `LOG_STREAM_REAP_AFTER_SECONDS (${config.LOG_STREAM_REAP_AFTER_SECONDS}) must be greater than the job lease TTL (${jobLeaseTtlSeconds}s, from AUTH_JOB_LEASE_TOKEN_EXPIRES_IN=${authConfig.AUTH_JOB_LEASE_TOKEN_EXPIRES_IN}); a smaller value would let the reaper force-close a stream a still-valid lease is appending to and silently truncate live logs.`,
129
+ );
130
+ }
@@ -0,0 +1,563 @@
1
+ import {type LogRecord, parseLogRecordLine} from '@shipfox/api-logs-dto';
2
+ import {appendLogs, setStepLookupForTesting} from '#core/append-logs.js';
3
+ import {LeaseStreamMismatchError, MalformedLogChunkError, OffsetGapError} from '#core/errors.js';
4
+ import {jobAccountingFactory} from '#test/factories/job-accounting.js';
5
+ import {
6
+ endLine,
7
+ groupStartLine,
8
+ ndjsonBody,
9
+ outputLine,
10
+ outputOfBytes,
11
+ recordLine,
12
+ sessionLine,
13
+ } from '#test/fixtures/ndjson.js';
14
+ import {findAccounting, findStream, listChunks, listStreamClosedEvents} from '#test/queries.js';
15
+
16
+ const mockSteps = new Map<string, {config: Record<string, unknown>}>();
17
+
18
+ interface Ctx {
19
+ jobId: string;
20
+ stepId: string;
21
+ workspaceId: string;
22
+ projectId: string;
23
+ workflowRunAttemptId: string;
24
+ }
25
+
26
+ function newCtx(): Ctx {
27
+ return {
28
+ jobId: crypto.randomUUID(),
29
+ stepId: crypto.randomUUID(),
30
+ workspaceId: crypto.randomUUID(),
31
+ projectId: crypto.randomUUID(),
32
+ workflowRunAttemptId: crypto.randomUUID(),
33
+ };
34
+ }
35
+
36
+ async function allowLargeLogBudget(ctx: Ctx): Promise<void> {
37
+ await jobAccountingFactory.create({
38
+ jobId: ctx.jobId,
39
+ workspaceId: ctx.workspaceId,
40
+ startedAt: new Date(Date.now() - 5 * 60_000),
41
+ });
42
+ }
43
+
44
+ function recordsFromChunks(chunks: Awaited<ReturnType<typeof listChunks>>): LogRecord[] {
45
+ return chunks.flatMap((chunk) =>
46
+ chunk.data.toString('utf8').split('\n').filter(Boolean).map(parseLogRecordLine),
47
+ );
48
+ }
49
+
50
+ describe('appendLogs', () => {
51
+ let restoreStepLookup: () => void;
52
+
53
+ beforeAll(() => {
54
+ restoreStepLookup = setStepLookupForTesting((stepId) => Promise.resolve(mockSteps.get(stepId)));
55
+ });
56
+
57
+ afterAll(() => {
58
+ restoreStepLookup();
59
+ });
60
+
61
+ beforeEach(() => {
62
+ mockSteps.clear();
63
+ });
64
+
65
+ describe('offset-CAS', () => {
66
+ it('extends committed_length and stores one runner chunk on an in-order append', async () => {
67
+ const ctx = newCtx();
68
+ const body = ndjsonBody(outputLine('hello\n'));
69
+
70
+ const result = await appendLogs({...ctx, attempt: 1, offset: 0, body});
71
+
72
+ expect(result).toEqual({committedLength: body.length, capped: false});
73
+ const stream = await findStream({...ctx, attempt: 1});
74
+ expect(stream?.committedLength).toBe(body.length);
75
+ const chunks = await listChunks(stream?.id as string);
76
+ expect(chunks).toHaveLength(1);
77
+ expect(chunks[0]?.origin).toBe('runner');
78
+ });
79
+
80
+ it('acks a re-sent (offset < committed) append without storing a new chunk', async () => {
81
+ const ctx = newCtx();
82
+ const body = ndjsonBody(outputLine('hello\n'));
83
+ await appendLogs({...ctx, attempt: 1, offset: 0, body});
84
+
85
+ const result = await appendLogs({...ctx, attempt: 1, offset: 0, body});
86
+
87
+ expect(result).toEqual({committedLength: body.length, capped: false});
88
+ const stream = await findStream({...ctx, attempt: 1});
89
+ expect(await listChunks(stream?.id as string)).toHaveLength(1);
90
+ });
91
+
92
+ it('rejects a gap (offset > committed) with the committed length', async () => {
93
+ const ctx = newCtx();
94
+ const body = ndjsonBody(outputLine('hello\n'));
95
+ await appendLogs({...ctx, attempt: 1, offset: 0, body});
96
+
97
+ const error = await appendLogs({
98
+ ...ctx,
99
+ attempt: 1,
100
+ offset: body.length + 5,
101
+ body: ndjsonBody(outputLine('more\n')),
102
+ }).catch((e: unknown) => e);
103
+
104
+ expect(error).toBeInstanceOf(OffsetGapError);
105
+ expect((error as OffsetGapError).committedLength).toBe(body.length);
106
+ });
107
+
108
+ it('rejects a straddling append (offset before committed but extending past it)', async () => {
109
+ const ctx = newCtx();
110
+ const first = ndjsonBody(outputLine('hello\n'));
111
+ await appendLogs({...ctx, attempt: 1, offset: 0, body: first});
112
+
113
+ const error = await appendLogs({
114
+ ...ctx,
115
+ attempt: 1,
116
+ offset: first.length - 2,
117
+ body: ndjsonBody(outputLine('more\n')),
118
+ }).catch((e: unknown) => e);
119
+
120
+ expect(error).toBeInstanceOf(OffsetGapError);
121
+ expect((error as OffsetGapError).committedLength).toBe(first.length);
122
+ });
123
+
124
+ it('treats an empty body as a heartbeat that creates no stream', async () => {
125
+ const ctx = newCtx();
126
+
127
+ const result = await appendLogs({...ctx, attempt: 1, offset: 0, body: ndjsonBody()});
128
+
129
+ expect(result).toEqual({committedLength: 0, capped: false});
130
+ expect(await findStream({...ctx, attempt: 1})).toBeNull();
131
+ });
132
+ });
133
+
134
+ describe('agent_session', () => {
135
+ it('stores a normalized session line as one runner chunk and leaves the stream open', async () => {
136
+ const ctx = newCtx();
137
+ await allowLargeLogBudget(ctx);
138
+ const body = ndjsonBody(sessionLine('{"type":"x"}'));
139
+
140
+ const result = await appendLogs({...ctx, attempt: 1, offset: 0, body});
141
+
142
+ expect(result).toEqual({committedLength: body.length, capped: false});
143
+ const stream = await findStream({...ctx, attempt: 1});
144
+ expect(stream?.state).toBe('open');
145
+ const chunks = await listChunks(stream?.id as string);
146
+ expect(chunks).toHaveLength(1);
147
+ expect(chunks[0]?.origin).toBe('runner');
148
+ expect(recordsFromChunks(chunks)).toEqual([
149
+ {
150
+ v: 1,
151
+ ts: 1,
152
+ type: 'agent_session',
153
+ row: {
154
+ kind: 'raw',
155
+ timestamp: 1,
156
+ label: 'Unknown session entry: x',
157
+ raw: '{"type":"x"}',
158
+ },
159
+ },
160
+ ]);
161
+ });
162
+
163
+ it('stores parsed pi session rows in append order', async () => {
164
+ const ctx = newCtx();
165
+ await allowLargeLogBudget(ctx);
166
+ const first = sessionLine(
167
+ JSON.stringify({type: 'session', id: 'session-1', cwd: '/workspace'}),
168
+ );
169
+ const second = sessionLine(
170
+ JSON.stringify({type: 'message', message: {role: 'assistant', content: 'Done.'}}),
171
+ );
172
+ const body = ndjsonBody(first, second);
173
+
174
+ await appendLogs({...ctx, attempt: 1, offset: 0, body});
175
+
176
+ const stream = await findStream({...ctx, attempt: 1});
177
+ const rows = recordsFromChunks(await listChunks(stream?.id as string)).map((record) => {
178
+ expect(record.type).toBe('agent_session');
179
+ return record.type === 'agent_session' ? record.row : null;
180
+ });
181
+ expect(rows).toEqual([
182
+ {
183
+ kind: 'lifecycle',
184
+ timestamp: 1,
185
+ label: 'Session started',
186
+ detail: 'session-1 · /workspace',
187
+ meta: [],
188
+ tone: 'default',
189
+ terminalFailure: false,
190
+ },
191
+ {
192
+ kind: 'message',
193
+ timestamp: 1,
194
+ role: 'assistant',
195
+ label: 'assistant',
196
+ meta: [],
197
+ text: 'Done.',
198
+ terminalFailure: false,
199
+ },
200
+ ]);
201
+ });
202
+
203
+ it('selects the Claude parser from the step harness', async () => {
204
+ const ctx = newCtx();
205
+ await allowLargeLogBudget(ctx);
206
+ mockSteps.set(ctx.stepId, {config: {harness: 'claude'}});
207
+ const body = ndjsonBody(
208
+ sessionLine(
209
+ JSON.stringify({
210
+ type: 'assistant',
211
+ message: {
212
+ role: 'assistant',
213
+ content: [{type: 'tool_use', id: 'tool-1', name: 'Read', input: {path: 'a.ts'}}],
214
+ },
215
+ }),
216
+ ),
217
+ );
218
+
219
+ await appendLogs({...ctx, attempt: 1, offset: 0, body});
220
+
221
+ const stream = await findStream({...ctx, attempt: 1});
222
+ const rows = recordsFromChunks(await listChunks(stream?.id as string)).map((record) => {
223
+ expect(record.type).toBe('agent_session');
224
+ return record.type === 'agent_session' ? record.row : null;
225
+ });
226
+ expect(rows).toEqual([
227
+ {
228
+ kind: 'tool-call',
229
+ timestamp: 1,
230
+ id: 'tool-1',
231
+ name: 'Read',
232
+ input: '{\n "path": "a.ts"\n}',
233
+ },
234
+ ]);
235
+ });
236
+
237
+ it('does not duplicate parsed rows on a retried append', async () => {
238
+ const ctx = newCtx();
239
+ await allowLargeLogBudget(ctx);
240
+ const body = ndjsonBody(sessionLine(JSON.stringify({type: 'session', id: 'session-1'})));
241
+ await appendLogs({...ctx, attempt: 1, offset: 0, body});
242
+
243
+ await appendLogs({...ctx, attempt: 1, offset: 0, body});
244
+
245
+ const stream = await findStream({...ctx, attempt: 1});
246
+ expect(recordsFromChunks(await listChunks(stream?.id as string))).toHaveLength(1);
247
+ });
248
+
249
+ it('does not store normalized session records when a capped append is dropped', async () => {
250
+ const ctx = newCtx();
251
+ const first = outputOfBytes(150);
252
+ await appendLogs({...ctx, attempt: 1, offset: 0, body: first});
253
+ const straggler = ndjsonBody(sessionLine(JSON.stringify({type: 'session', id: 'dropped'})));
254
+
255
+ await appendLogs({...ctx, attempt: 1, offset: first.length, body: straggler});
256
+
257
+ const stream = await findStream({...ctx, attempt: 1});
258
+ expect(recordsFromChunks(await listChunks(stream?.id as string))).not.toContainEqual(
259
+ expect.objectContaining({type: 'agent_session'}),
260
+ );
261
+ });
262
+
263
+ it('rejects a session line over LOG_MAX_SESSION_LINE_BYTES before any stream is created', async () => {
264
+ const ctx = newCtx();
265
+ const body = ndjsonBody(sessionLine('x'.repeat(600)));
266
+
267
+ const error = await appendLogs({...ctx, attempt: 1, offset: 0, body}).catch(
268
+ (e: unknown) => e,
269
+ );
270
+
271
+ expect(error).toBeInstanceOf(MalformedLogChunkError);
272
+ expect(await findStream({...ctx, attempt: 1})).toBeNull();
273
+ });
274
+ });
275
+
276
+ describe('budget accounting', () => {
277
+ it('charges stored bytes, envelope and control records included', async () => {
278
+ const ctx = newCtx();
279
+ const body = ndjsonBody(outputLine('abc'), groupStartLine('g1', 'Build'));
280
+
281
+ await appendLogs({...ctx, attempt: 1, offset: 0, body});
282
+
283
+ const accounting = await findAccounting(ctx.jobId);
284
+ expect(accounting?.storedBytesUsed).toBe(body.length);
285
+ });
286
+
287
+ it('stays under cap when accrual from elapsed time covers the payload', async () => {
288
+ const ctx = newCtx();
289
+ await jobAccountingFactory.create({
290
+ jobId: ctx.jobId,
291
+ workspaceId: ctx.workspaceId,
292
+ startedAt: new Date(Date.now() - 5 * 60_000),
293
+ });
294
+
295
+ const result = await appendLogs({...ctx, attempt: 1, offset: 0, body: outputOfBytes(150)});
296
+
297
+ expect(result.capped).toBe(false);
298
+ expect((await findAccounting(ctx.jobId))?.cappedAt).toBeNull();
299
+ });
300
+ });
301
+
302
+ describe('cap', () => {
303
+ it('caps when the payload crosses the budget, injecting a control tombstone', async () => {
304
+ const ctx = newCtx();
305
+
306
+ const result = await appendLogs({...ctx, attempt: 1, offset: 0, body: outputOfBytes(150)});
307
+
308
+ expect(result.capped).toBe(true);
309
+ expect((await findAccounting(ctx.jobId))?.cappedAt).not.toBeNull();
310
+ const stream = await findStream({...ctx, attempt: 1});
311
+ const chunks = await listChunks(stream?.id as string);
312
+ expect(chunks.map((c) => c.origin)).toEqual(['runner', 'control']);
313
+ });
314
+
315
+ it('drops a post-cap straggler but still advances committed_length', async () => {
316
+ const ctx = newCtx();
317
+ const first = outputOfBytes(150);
318
+ await appendLogs({...ctx, attempt: 1, offset: 0, body: first});
319
+ const straggler = ndjsonBody(outputLine('late\n'));
320
+
321
+ const result = await appendLogs({...ctx, attempt: 1, offset: first.length, body: straggler});
322
+
323
+ expect(result).toEqual({committedLength: first.length + straggler.length, capped: true});
324
+ const stream = await findStream({...ctx, attempt: 1});
325
+ const chunks = await listChunks(stream?.id as string);
326
+ expect(chunks.map((c) => c.origin)).toEqual(['runner', 'control']);
327
+ });
328
+
329
+ it('does not cap when stored bytes land exactly on the budget', async () => {
330
+ const ctx = newCtx();
331
+ const body = outputOfBytes(9);
332
+ // Test env base budget is 100 bytes; pre-fill so this append lands used == allowed.
333
+ await jobAccountingFactory.create({
334
+ jobId: ctx.jobId,
335
+ workspaceId: ctx.workspaceId,
336
+ storedBytesUsed: 100 - body.length,
337
+ startedAt: new Date(),
338
+ });
339
+
340
+ const result = await appendLogs({...ctx, attempt: 1, offset: 0, body});
341
+
342
+ expect(result.capped).toBe(false);
343
+ expect((await findAccounting(ctx.jobId))?.cappedAt).toBeNull();
344
+ });
345
+ });
346
+
347
+ describe('stream lifecycle', () => {
348
+ it('declared-closes the stream and emits one stream-closed event on an end record', async () => {
349
+ const ctx = newCtx();
350
+ const body = ndjsonBody(outputLine('done\n'), endLine(12345));
351
+
352
+ await appendLogs({...ctx, attempt: 1, offset: 0, body});
353
+
354
+ const stream = await findStream({...ctx, attempt: 1});
355
+ expect(stream?.state).toBe('closed');
356
+ expect(stream?.closeReason).toBe('declared');
357
+ expect(stream?.truncated).toBe(false);
358
+ expect(stream?.declaredTotalBytes).toBe(12345);
359
+ expect(stream?.closedAt).not.toBeNull();
360
+ expect(await listStreamClosedEvents(stream?.id as string)).toHaveLength(1);
361
+ });
362
+
363
+ it('is idempotent: a re-sent end body neither re-closes nor emits a second event', async () => {
364
+ const ctx = newCtx();
365
+ const body = ndjsonBody(outputLine('done\n'), endLine(10));
366
+ await appendLogs({...ctx, attempt: 1, offset: 0, body});
367
+ const stream = await findStream({...ctx, attempt: 1});
368
+
369
+ await appendLogs({...ctx, attempt: 1, offset: 0, body});
370
+
371
+ expect(await listStreamClosedEvents(stream?.id as string)).toHaveLength(1);
372
+ });
373
+
374
+ it('drops further output once declared-closed (no new chunk, committed_length frozen)', async () => {
375
+ const ctx = newCtx();
376
+ // End-only body so the single stored chunk stays under the 100-byte test budget
377
+ // (an extra output line would trip the cap and add a tombstone chunk).
378
+ const end = ndjsonBody(endLine(4));
379
+ await appendLogs({...ctx, attempt: 1, offset: 0, body: end});
380
+ const closed = await findStream({...ctx, attempt: 1});
381
+
382
+ const result = await appendLogs({
383
+ ...ctx,
384
+ attempt: 1,
385
+ offset: end.length,
386
+ body: ndjsonBody(outputLine('late\n')),
387
+ });
388
+
389
+ expect(result.committedLength).toBe(end.length);
390
+ const after = await findStream({...ctx, attempt: 1});
391
+ expect(after?.committedLength).toBe(closed?.committedLength);
392
+ expect(await listChunks(after?.id as string)).toHaveLength(1);
393
+ });
394
+
395
+ it('declared-closes when one body both crosses the budget and ends', async () => {
396
+ const ctx = newCtx();
397
+ // 150 payload bytes cross the 100-byte test budget, but the crossing chunk is still
398
+ // stored in full; the same body carries the end, so the stream declared-closes.
399
+ const body = ndjsonBody(outputLine('x'.repeat(150)), endLine(4));
400
+
401
+ const result = await appendLogs({...ctx, attempt: 1, offset: 0, body});
402
+
403
+ expect(result.capped).toBe(true);
404
+ const stream = await findStream({...ctx, attempt: 1});
405
+ expect(stream?.state).toBe('closed');
406
+ expect(stream?.closeReason).toBe('declared');
407
+ expect(stream?.truncated).toBe(false);
408
+ expect((await listChunks(stream?.id as string)).map((c) => c.origin)).toEqual([
409
+ 'runner',
410
+ 'control',
411
+ ]);
412
+ expect(await listStreamClosedEvents(stream?.id as string)).toHaveLength(1);
413
+ });
414
+
415
+ it('does not declared-close when an already-capped job drops the end body', async () => {
416
+ const ctx = newCtx();
417
+ // Cap the job first (150 payload bytes cross the 100-byte budget), then send the end
418
+ // body: it is dropped, so the stream is not whole and must stay open for the sweep.
419
+ const first = outputOfBytes(150);
420
+ await appendLogs({...ctx, attempt: 1, offset: 0, body: first});
421
+ const end = ndjsonBody(endLine(4));
422
+
423
+ const result = await appendLogs({...ctx, attempt: 1, offset: first.length, body: end});
424
+
425
+ expect(result.capped).toBe(true);
426
+ const stream = await findStream({...ctx, attempt: 1});
427
+ expect(stream?.state).toBe('open');
428
+ expect(stream?.closeReason).toBeNull();
429
+ expect(stream?.declaredTotalBytes).toBeNull();
430
+ // The dropped end body persisted nothing: still just the runner chunk + cap tombstone.
431
+ expect(await listChunks(stream?.id as string)).toHaveLength(2);
432
+ expect(await listStreamClosedEvents(stream?.id as string)).toHaveLength(0);
433
+ });
434
+
435
+ it('keeps attempts of the same step on independent streams', async () => {
436
+ const ctx = newCtx();
437
+ const a1 = ndjsonBody(outputLine('one\n'));
438
+ const a2 = ndjsonBody(outputLine('two-two\n'));
439
+
440
+ await appendLogs({...ctx, attempt: 1, offset: 0, body: a1});
441
+ await appendLogs({...ctx, attempt: 2, offset: 0, body: a2});
442
+
443
+ expect((await findStream({...ctx, attempt: 1}))?.committedLength).toBe(a1.length);
444
+ expect((await findStream({...ctx, attempt: 2}))?.committedLength).toBe(a2.length);
445
+ });
446
+ });
447
+
448
+ describe('concurrency and isolation', () => {
449
+ it('serializes two concurrent first appends at offset 0 into one chunk', async () => {
450
+ const ctx = newCtx();
451
+ const body = ndjsonBody(outputLine('race\n'));
452
+
453
+ const results = await Promise.all([
454
+ appendLogs({...ctx, attempt: 1, offset: 0, body}),
455
+ appendLogs({...ctx, attempt: 1, offset: 0, body}),
456
+ ]);
457
+
458
+ expect(results.every((r) => r.committedLength === body.length)).toBe(true);
459
+ const stream = await findStream({...ctx, attempt: 1});
460
+ expect(await listChunks(stream?.id as string)).toHaveLength(1);
461
+ });
462
+
463
+ it('claims the cap once when two steps of one job cross the budget concurrently', async () => {
464
+ const ctx = newCtx();
465
+ const stepA = crypto.randomUUID();
466
+ const stepB = crypto.randomUUID();
467
+
468
+ await Promise.all([
469
+ appendLogs({...ctx, stepId: stepA, attempt: 1, offset: 0, body: outputOfBytes(80)}),
470
+ appendLogs({...ctx, stepId: stepB, attempt: 1, offset: 0, body: outputOfBytes(80)}),
471
+ ]);
472
+
473
+ expect((await findAccounting(ctx.jobId))?.cappedAt).not.toBeNull();
474
+ const streamA = await findStream({jobId: ctx.jobId, stepId: stepA, attempt: 1});
475
+ const streamB = await findStream({jobId: ctx.jobId, stepId: stepB, attempt: 1});
476
+ const controls = [
477
+ ...(await listChunks(streamA?.id as string)),
478
+ ...(await listChunks(streamB?.id as string)),
479
+ ].filter((c) => c.origin === 'control');
480
+ expect(controls).toHaveLength(1);
481
+ });
482
+
483
+ it('rejects a second append whose lease workspace/project/run does not match the stamped row', async () => {
484
+ const ctx = newCtx();
485
+ const body = ndjsonBody(outputLine('first\n'));
486
+ await appendLogs({...ctx, attempt: 1, offset: 0, body});
487
+ const before = await findStream({...ctx, attempt: 1});
488
+
489
+ const error = await appendLogs({
490
+ ...ctx,
491
+ projectId: crypto.randomUUID(),
492
+ attempt: 1,
493
+ offset: body.length,
494
+ body: ndjsonBody(outputLine('more\n')),
495
+ }).catch((e: unknown) => e);
496
+
497
+ expect(error).toBeInstanceOf(LeaseStreamMismatchError);
498
+ // The wrapping transaction rolls back: updated_at and committed_length on
499
+ // the stamped row are untouched, so a stale lease cannot tick the
500
+ // stream's freshness or advance its CAS axis.
501
+ const after = await findStream({...ctx, attempt: 1});
502
+ expect(after?.updatedAt.getTime()).toBe(before?.updatedAt.getTime());
503
+ expect(after?.committedLength).toBe(before?.committedLength);
504
+ });
505
+
506
+ it('isolates streams by job: a different job with the same stepId gets its own stream', async () => {
507
+ const stepId = crypto.randomUUID();
508
+ const jobA = crypto.randomUUID();
509
+ const jobB = crypto.randomUUID();
510
+ const workspaceId = crypto.randomUUID();
511
+ const projectId = crypto.randomUUID();
512
+ const workflowRunAttemptId = crypto.randomUUID();
513
+ const bodyA = ndjsonBody(outputLine('a\n'));
514
+ const bodyB = ndjsonBody(outputLine('bbbb\n'));
515
+
516
+ const common = {
517
+ workspaceId,
518
+ projectId,
519
+ workflowRunAttemptId,
520
+ stepId,
521
+ attempt: 1,
522
+ offset: 0,
523
+ };
524
+ await appendLogs({...common, jobId: jobA, body: bodyA});
525
+ await appendLogs({...common, jobId: jobB, body: bodyB});
526
+
527
+ const streamA = await findStream({jobId: jobA, stepId, attempt: 1});
528
+ const streamB = await findStream({jobId: jobB, stepId, attempt: 1});
529
+ expect(streamA?.id).not.toBe(streamB?.id);
530
+ expect(streamA?.committedLength).toBe(bodyA.length);
531
+ expect(streamB?.committedLength).toBe(bodyB.length);
532
+ });
533
+ });
534
+
535
+ describe('write-path enforcement', () => {
536
+ it.each([
537
+ 'capped',
538
+ 'runner_lost',
539
+ ])('rejects a forged server-only %s tombstone before any row is created', async (type) => {
540
+ const ctx = newCtx();
541
+ const body = ndjsonBody(recordLine({type}));
542
+
543
+ const error = await appendLogs({...ctx, attempt: 1, offset: 0, body}).catch(
544
+ (e: unknown) => e,
545
+ );
546
+
547
+ expect(error).toBeInstanceOf(MalformedLogChunkError);
548
+ expect(await findStream({...ctx, attempt: 1})).toBeNull();
549
+ });
550
+
551
+ it('rejects an invalid NDJSON record', async () => {
552
+ const ctx = newCtx();
553
+ const recordWithoutData = recordLine({type: 'output', stream: 'stdout'});
554
+ const body = ndjsonBody(recordWithoutData);
555
+
556
+ const error = await appendLogs({...ctx, attempt: 1, offset: 0, body}).catch(
557
+ (e: unknown) => e,
558
+ );
559
+
560
+ expect(error).toBeInstanceOf(MalformedLogChunkError);
561
+ });
562
+ });
563
+ });