@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
@@ -0,0 +1,384 @@
1
+ import {Buffer} from 'node:buffer';
2
+ import {DEFAULT_HARNESS, type Harness, harnessSchema} from '@shipfox/api-agent-dto';
3
+ import {
4
+ type LogRecord,
5
+ parseLogRecordLine,
6
+ parseRawLogRecordLine,
7
+ type RawLogRecord,
8
+ } from '@shipfox/api-logs-dto';
9
+ import {getStepById} from '@shipfox/api-workflows';
10
+ import {logger} from '@shipfox/node-opentelemetry';
11
+ import {config} from '#config.js';
12
+ import {accrueStoredBytes, claimCap, ensureJobAccounting, isJobCapped} from '#db/accounting.js';
13
+ import {insertChunk} from '#db/chunks.js';
14
+ import {db, type Transaction} from '#db/db.js';
15
+ import {
16
+ casExtendCommittedLength,
17
+ getAttemptStream,
18
+ getOrCreateAttemptStreamWithStatus,
19
+ setDeclaredTotalBytes,
20
+ } from '#db/streams.js';
21
+ import {
22
+ type LogRecordMetricKind,
23
+ recordAppendedCount,
24
+ streamClosedCount,
25
+ streamOpenedCount,
26
+ } from '#metrics/instance.js';
27
+ import {allowedBudget} from './budget.js';
28
+ import {closeStream, controlTombstone} from './close-stream.js';
29
+ import {MalformedLogChunkError, OffsetGapError} from './errors.js';
30
+ import {parseSessionRecord} from './session/parse-session.js';
31
+ import type {AgentSessionRecord} from './session/session-record.js';
32
+
33
+ type StepLookup = (stepId: string) => Promise<{config: Record<string, unknown>} | undefined>;
34
+
35
+ let stepLookup: StepLookup = getStepById;
36
+
37
+ export function setStepLookupForTesting(lookup: StepLookup): () => void {
38
+ const previous = stepLookup;
39
+ stepLookup = lookup;
40
+ return () => {
41
+ stepLookup = previous;
42
+ };
43
+ }
44
+
45
+ export interface AppendLogsParams {
46
+ jobId: string;
47
+ workspaceId: string;
48
+ projectId: string;
49
+ workflowRunAttemptId: string;
50
+ stepId: string;
51
+ attempt: number;
52
+ offset: number;
53
+ body: Buffer;
54
+ }
55
+
56
+ export interface AppendLogsResult {
57
+ committedLength: number;
58
+ capped: boolean;
59
+ }
60
+
61
+ interface ParsedBody {
62
+ declaredTotalBytes?: number;
63
+ records: RawLogRecord[];
64
+ hasAgentSessionRecord: boolean;
65
+ }
66
+
67
+ /**
68
+ * Pure pre-transaction parse. Requires whole, newline-terminated lines so
69
+ * `committed_length` always lands on a line boundary (one body = one chunk = whole
70
+ * lines; no line ever spans two chunks). Runs before any lock is taken, so a
71
+ * malformed or large body never holds a row. The offset CAS uses the raw append byte length; the
72
+ * budget charges the normalized body built from these parsed records.
73
+ *
74
+ * Each line is validated against the raw record union (a forged server-only
75
+ * `capped`/`runner_lost` fails here); the declared total is pulled from an `end`
76
+ * record. A line that is a valid server-only record under the read union surfaces
77
+ * its type via `forgedType` for the narrowed audit warn.
78
+ */
79
+ function parseAppendBody(body: Buffer): ParsedBody {
80
+ if (body.length === 0) return {records: [], hasAgentSessionRecord: false};
81
+
82
+ const text = body.toString('utf8');
83
+ if (!text.endsWith('\n')) {
84
+ throw new MalformedLogChunkError('append body must end with a newline (whole records only)');
85
+ }
86
+ const lines = text.split('\n');
87
+ lines.pop();
88
+
89
+ let declaredTotalBytes: number | undefined;
90
+ const records: RawLogRecord[] = [];
91
+ let hasAgentSessionRecord = false;
92
+ for (const line of lines) {
93
+ let record: ReturnType<typeof parseRawLogRecordLine>;
94
+ try {
95
+ record = parseRawLogRecordLine(line);
96
+ } catch {
97
+ throw new MalformedLogChunkError(
98
+ 'append body contains an invalid NDJSON record',
99
+ detectForgedType(line),
100
+ );
101
+ }
102
+ records.push(record);
103
+ if (record.type === 'end') {
104
+ declaredTotalBytes = record.total_bytes;
105
+ }
106
+ // An agent_session line is one whole entry in one record; bound its size here (the DTO
107
+ // schema leaves `data` uncapped because it cannot read this runtime config). An over-cap
108
+ // line is rejected so a single entry can never blow the request body or the spool window.
109
+ if (
110
+ record.type === 'agent_session' &&
111
+ Buffer.byteLength(line, 'utf8') > config.LOG_MAX_SESSION_LINE_BYTES
112
+ ) {
113
+ throw new MalformedLogChunkError(
114
+ `agent_session line exceeds ${config.LOG_MAX_SESSION_LINE_BYTES} bytes`,
115
+ );
116
+ }
117
+ if (record.type === 'agent_session') {
118
+ hasAgentSessionRecord = true;
119
+ }
120
+ }
121
+
122
+ return declaredTotalBytes === undefined
123
+ ? {records, hasAgentSessionRecord}
124
+ : {declaredTotalBytes, records, hasAgentSessionRecord};
125
+ }
126
+
127
+ /**
128
+ * A line that fails the raw write union but is a valid record under the read union
129
+ * can only be a server-only `capped`/`runner_lost` tombstone — i.e. a forgery
130
+ * attempt. Returns its type for the audit warn, or undefined for plain garbage.
131
+ */
132
+ function detectForgedType(line: string): string | undefined {
133
+ try {
134
+ return parseLogRecordLine(line).type;
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
140
+ async function getSessionHarness(stepId: string): Promise<Harness> {
141
+ const step = await stepLookup(stepId);
142
+ const parsed = harnessSchema.safeParse(step?.config.harness);
143
+ return parsed.success ? parsed.data : DEFAULT_HARNESS;
144
+ }
145
+
146
+ /**
147
+ * Empty-body heartbeat: report the current committed length without materializing
148
+ * a stream, so a runner cannot mint unbounded rows with empty appends.
149
+ */
150
+ async function readHeartbeat(tx: Transaction, params: AppendLogsParams): Promise<AppendLogsResult> {
151
+ const existing = await getAttemptStream(tx, {
152
+ jobId: params.jobId,
153
+ stepId: params.stepId,
154
+ attempt: params.attempt,
155
+ });
156
+ return {
157
+ committedLength: existing?.committedLength ?? 0,
158
+ capped: await isJobCapped(tx, params.jobId),
159
+ };
160
+ }
161
+
162
+ interface StoreChunkParams {
163
+ params: AppendLogsParams;
164
+ streamId: string;
165
+ body: Buffer;
166
+ committedLength: number;
167
+ declaredTotalBytes: number | undefined;
168
+ }
169
+
170
+ interface StoreChunkResult extends AppendLogsResult {
171
+ /**
172
+ * Whether the runner chunk was persisted. False only when the job was already
173
+ * capped and the body (including any `end` record) was dropped, so the stream is
174
+ * not whole and must not be declared-closed.
175
+ */
176
+ stored: boolean;
177
+ recordCounts: Partial<Record<LogRecord['type'], number>>;
178
+ }
179
+
180
+ /**
181
+ * Accrues the stored bytes, persists the chunk, and trips the per-job cap when
182
+ * this append crosses the budget. Runs only after the offset-CAS extended
183
+ * `committed_length`, so the committed length already reflects the accepted bytes.
184
+ */
185
+ async function storeChunk(
186
+ tx: Transaction,
187
+ {params, streamId, body, committedLength, declaredTotalBytes}: StoreChunkParams,
188
+ ): Promise<StoreChunkResult> {
189
+ await ensureJobAccounting(tx, {jobId: params.jobId, workspaceId: params.workspaceId});
190
+ const storedByteLen = body.length;
191
+ const accrued = await accrueStoredBytes(tx, {jobId: params.jobId, delta: storedByteLen});
192
+
193
+ // Already capped: accept-and-drop. committed_length has advanced so the runner
194
+ // drains its spool cleanly instead of retry-looping; nothing is stored.
195
+ if (!accrued) return {committedLength, capped: true, stored: false, recordCounts: {}};
196
+
197
+ await insertChunk(tx, {
198
+ streamId,
199
+ streamOffset: params.offset,
200
+ byteLen: storedByteLen,
201
+ data: body,
202
+ origin: 'runner',
203
+ });
204
+ if (declaredTotalBytes !== undefined) {
205
+ await setDeclaredTotalBytes(tx, {streamId, declaredTotalBytes});
206
+ }
207
+
208
+ const allowed = allowedBudget({
209
+ baseBytes: config.LOG_BUDGET_BASE_BYTES,
210
+ ratePerMinuteBytes: config.LOG_BUDGET_RATE_BYTES_PER_MINUTE,
211
+ elapsedMs: Date.now() - accrued.startedAt.getTime(),
212
+ });
213
+ if (accrued.used <= allowed) {
214
+ return {committedLength, capped: false, stored: true, recordCounts: {}};
215
+ }
216
+
217
+ // Over budget. No hard ceiling: this crossing append is stored in full (overshoot
218
+ // bounded by one body). Claim the cap once and inject an in-band `capped` tombstone
219
+ // for the winner.
220
+ const won = await claimCap(tx, params.jobId);
221
+ if (won) {
222
+ const tombstone = controlTombstone('capped');
223
+ await insertChunk(tx, {
224
+ streamId,
225
+ streamOffset: committedLength,
226
+ byteLen: tombstone.length,
227
+ data: tombstone,
228
+ origin: 'control',
229
+ });
230
+ }
231
+ return {
232
+ committedLength,
233
+ capped: true,
234
+ stored: true,
235
+ recordCounts: won ? {capped: 1} : {},
236
+ };
237
+ }
238
+
239
+ function buildStoredBody(
240
+ records: readonly RawLogRecord[],
241
+ harness: Harness,
242
+ ): {body: Buffer; recordCounts: Partial<Record<LogRecord['type'], number>>} {
243
+ const storedRecords: LogRecord[] = [];
244
+ for (const record of records) {
245
+ if (record.type !== 'agent_session') {
246
+ storedRecords.push(record);
247
+ continue;
248
+ }
249
+
250
+ for (const row of parseSessionRecord(agentSessionRecord(record), harness)) {
251
+ storedRecords.push({v: 1, ts: row.timestamp, type: 'agent_session', row});
252
+ }
253
+ }
254
+
255
+ const body = Buffer.from(storedRecords.map((record) => `${JSON.stringify(record)}\n`).join(''));
256
+ const recordCounts: Partial<Record<LogRecord['type'], number>> = {};
257
+ for (const record of storedRecords) {
258
+ recordCounts[record.type] = (recordCounts[record.type] ?? 0) + 1;
259
+ }
260
+
261
+ return {body, recordCounts};
262
+ }
263
+
264
+ function agentSessionRecord(
265
+ record: Extract<RawLogRecord, {type: 'agent_session'}>,
266
+ ): AgentSessionRecord {
267
+ return {data: record.data, ts: record.ts};
268
+ }
269
+
270
+ /**
271
+ * Concurrency is serialized through Postgres row locks taken implicitly by the
272
+ * conditional UPDATEs, not an explicit `SELECT ... FOR UPDATE`. Appends for one
273
+ * job contend on its single accounting row, so the path is multi-instance safe
274
+ * but not lock-free.
275
+ */
276
+ export async function appendLogs(params: AppendLogsParams): Promise<AppendLogsResult> {
277
+ let parsed: ParsedBody;
278
+ try {
279
+ parsed = parseAppendBody(params.body);
280
+ } catch (error) {
281
+ // Narrowed audit: only the detectable forgery case, never the payload or a token.
282
+ if (error instanceof MalformedLogChunkError && error.forgedType !== undefined) {
283
+ logger().warn(
284
+ {
285
+ jobId: params.jobId,
286
+ stepId: params.stepId,
287
+ offendingType: error.forgedType,
288
+ },
289
+ 'Rejected forged server-only log record on append',
290
+ );
291
+ }
292
+ throw error;
293
+ }
294
+ const {declaredTotalBytes} = parsed;
295
+ const sessionHarness = parsed.hasAgentSessionRecord
296
+ ? await getSessionHarness(params.stepId)
297
+ : DEFAULT_HARNESS;
298
+ const stored = buildStoredBody(parsed.records, sessionHarness);
299
+ const commitByteLen = params.body.length;
300
+ const metrics = {
301
+ recordCounts: {} as Partial<Record<LogRecordMetricKind, number>>,
302
+ streamClosedReason: undefined as 'declared' | undefined,
303
+ streamOpened: false,
304
+ };
305
+
306
+ const result = await db().transaction(async (tx) => {
307
+ if (commitByteLen === 0) return readHeartbeat(tx, params);
308
+
309
+ const {created, stream} = await getOrCreateAttemptStreamWithStatus(tx, {
310
+ jobId: params.jobId,
311
+ stepId: params.stepId,
312
+ attempt: params.attempt,
313
+ workspaceId: params.workspaceId,
314
+ projectId: params.projectId,
315
+ workflowRunAttemptId: params.workflowRunAttemptId,
316
+ });
317
+ metrics.streamOpened = created;
318
+
319
+ // Closed stream (the runner's end already landed, or the job-terminated sweep ran):
320
+ // accept-and-drop so a late chunk can never race compaction. committed_length is
321
+ // frozen at close, so this reports the final offset and the runner stops cleanly.
322
+ if (stream.state === 'closed') {
323
+ return {committedLength: stream.committedLength, capped: await isJobCapped(tx, params.jobId)};
324
+ }
325
+
326
+ const cas = await casExtendCommittedLength(tx, {
327
+ streamId: stream.id,
328
+ offset: params.offset,
329
+ byteLen: commitByteLen,
330
+ });
331
+ if (cas.outcome === 'gap') throw new OffsetGapError(cas.committedLength);
332
+ if (cas.outcome === 'retry') {
333
+ return {committedLength: cas.committedLength, capped: await isJobCapped(tx, params.jobId)};
334
+ }
335
+
336
+ const {
337
+ recordCounts,
338
+ stored: chunkStored,
339
+ ...result
340
+ } = await storeChunk(tx, {
341
+ params,
342
+ streamId: stream.id,
343
+ body: stored.body,
344
+ committedLength: cas.committedLength,
345
+ declaredTotalBytes,
346
+ });
347
+ if (chunkStored) {
348
+ addRecordCounts(metrics.recordCounts, stored.recordCounts);
349
+ }
350
+ addRecordCounts(metrics.recordCounts, recordCounts);
351
+
352
+ // The runner's end record was committed in this append (offset-CAS guarantees
353
+ // everything before it is already committed), so the stream is whole. Declared-close
354
+ // it in-band so compaction starts at once instead of waiting for the timeout sweep.
355
+ // Only when the chunk was actually stored: an end body dropped because the job was
356
+ // already capped persists nothing, so the stream is not whole and stays open for the
357
+ // timeout sweep to close it as truncated.
358
+ if (declaredTotalBytes !== undefined && chunkStored) {
359
+ const closed = await closeStream(tx, {streamId: stream.id, reason: 'declared'});
360
+ if (closed) metrics.streamClosedReason = 'declared';
361
+ }
362
+
363
+ return result;
364
+ });
365
+
366
+ if (metrics.streamOpened) streamOpenedCount.add(1);
367
+ for (const [kind, count] of Object.entries(metrics.recordCounts)) {
368
+ if (count > 0) recordAppendedCount.add(count, {kind: kind as LogRecordMetricKind});
369
+ }
370
+ if (metrics.streamClosedReason) {
371
+ streamClosedCount.add(1, {reason: metrics.streamClosedReason});
372
+ }
373
+
374
+ return result;
375
+ }
376
+
377
+ function addRecordCounts(
378
+ target: Partial<Record<LogRecordMetricKind, number>>,
379
+ source: Partial<Record<LogRecordMetricKind, number>>,
380
+ ): void {
381
+ for (const [kind, count] of Object.entries(source)) {
382
+ target[kind as LogRecordMetricKind] = (target[kind as LogRecordMetricKind] ?? 0) + (count ?? 0);
383
+ }
384
+ }
@@ -0,0 +1,30 @@
1
+ import {allowedBudget} from './budget.js';
2
+
3
+ describe('allowedBudget', () => {
4
+ const baseBytes = 5_242_880;
5
+ const ratePerMinuteBytes = 1_048_576;
6
+
7
+ it('returns the base at the clock origin', () => {
8
+ const allowed = allowedBudget({baseBytes, ratePerMinuteBytes, elapsedMs: 0});
9
+
10
+ expect(allowed).toBe(baseBytes);
11
+ });
12
+
13
+ it('adds the full rate after two minutes', () => {
14
+ const allowed = allowedBudget({baseBytes, ratePerMinuteBytes, elapsedMs: 2 * 60_000});
15
+
16
+ expect(allowed).toBe(baseBytes + 2 * ratePerMinuteBytes);
17
+ });
18
+
19
+ it('floors a partial minute', () => {
20
+ const allowed = allowedBudget({baseBytes, ratePerMinuteBytes, elapsedMs: 90_000});
21
+
22
+ expect(allowed).toBe(baseBytes + Math.floor(1.5 * ratePerMinuteBytes));
23
+ });
24
+
25
+ it('treats negative elapsed time as the origin', () => {
26
+ const allowed = allowedBudget({baseBytes, ratePerMinuteBytes, elapsedMs: -10_000});
27
+
28
+ expect(allowed).toBe(baseBytes);
29
+ });
30
+ });
@@ -0,0 +1,19 @@
1
+ export interface AllowedBudgetParams {
2
+ baseBytes: number;
3
+ ratePerMinuteBytes: number;
4
+ /** Time since the budget clock origin (first append) in milliseconds. */
5
+ elapsedMs: number;
6
+ }
7
+
8
+ /**
9
+ * Accrual budget in stored bytes: `base + rate * elapsedMinutes`, floored to an
10
+ * integer. No hard ceiling — job duration limits bound total volume.
11
+ */
12
+ export function allowedBudget({
13
+ baseBytes,
14
+ ratePerMinuteBytes,
15
+ elapsedMs,
16
+ }: AllowedBudgetParams): number {
17
+ const elapsed = Math.max(0, elapsedMs);
18
+ return baseBytes + Math.floor((ratePerMinuteBytes * elapsed) / 60_000);
19
+ }
@@ -0,0 +1,72 @@
1
+ import {Buffer} from 'node:buffer';
2
+ import {LOG_STREAM_CLOSED, type LogRecord, type LogsEventMap} from '@shipfox/api-logs-dto';
3
+ import {writeOutboxEvent} from '@shipfox/node-outbox';
4
+ import type {AttemptStream, StreamCloseReason} from '#core/entities/attempt-stream.js';
5
+ import {insertChunk} from '#db/chunks.js';
6
+ import type {Transaction} from '#db/db.js';
7
+ import {logsOutbox} from '#db/schema/outbox.js';
8
+ import {markStreamClosed} from '#db/streams.js';
9
+
10
+ export type TombstoneKind = 'capped' | 'runner_lost';
11
+
12
+ /**
13
+ * Frames a server tombstone as one newline-terminated record. Typed against the
14
+ * shared contract so an envelope change breaks this at compile time. Stored as a
15
+ * `control`-origin chunk; like every server record it does NOT advance
16
+ * `committed_length`, so the CAS axis stays equal to runner spool bytes.
17
+ */
18
+ export function controlTombstone(kind: TombstoneKind): Buffer {
19
+ const record: LogRecord = {v: 1, ts: Date.now(), type: kind};
20
+ return Buffer.from(`${JSON.stringify(record)}\n`, 'utf8');
21
+ }
22
+
23
+ export interface CloseStreamParams {
24
+ streamId: string;
25
+ reason: StreamCloseReason;
26
+ }
27
+
28
+ /**
29
+ * The guarded UPDATE (`WHERE state='open'`) in `markStreamClosed` is the lock and
30
+ * the idempotency gate: a stream already closed by the other path (append-time
31
+ * declared close vs the job-terminated timeout sweep) returns null, so no duplicate
32
+ * `LOG_STREAM_CLOSED` is written and no second tombstone lands. A timeout close sets
33
+ * `truncated` and injects a `runner_lost` tombstone in-band (a `capped` tombstone,
34
+ * if any, was injected earlier at the append that tripped the cap).
35
+ *
36
+ * The event drives compaction; it is written in the same transaction as the flip.
37
+ */
38
+ export async function closeStream(
39
+ tx: Transaction,
40
+ params: CloseStreamParams,
41
+ ): Promise<AttemptStream | null> {
42
+ const closed = await markStreamClosed(tx, {
43
+ streamId: params.streamId,
44
+ reason: params.reason,
45
+ markTruncated: params.reason === 'timeout',
46
+ });
47
+ if (!closed) return null;
48
+
49
+ if (params.reason === 'timeout') {
50
+ const tombstone = controlTombstone('runner_lost');
51
+ await insertChunk(tx, {
52
+ streamId: closed.id,
53
+ streamOffset: closed.committedLength,
54
+ byteLen: tombstone.length,
55
+ data: tombstone,
56
+ origin: 'control',
57
+ });
58
+ }
59
+
60
+ await writeOutboxEvent<LogsEventMap>(tx, logsOutbox, {
61
+ type: LOG_STREAM_CLOSED,
62
+ payload: {
63
+ workspaceId: closed.workspaceId,
64
+ jobId: closed.jobId,
65
+ stepId: closed.stepId,
66
+ attempt: closed.attempt,
67
+ streamId: closed.id,
68
+ },
69
+ });
70
+
71
+ return closed;
72
+ }
@@ -0,0 +1,68 @@
1
+ import type {Buffer} from 'node:buffer';
2
+ import {Readable} from 'node:stream';
3
+ import {createGzip} from 'node:zlib';
4
+ import {readChunksKeyset} from '#db/chunks.js';
5
+
6
+ // Chunks are capped at ~256KB each by the runner flush, so a small fixed page count gives a
7
+ // predictable memory bound (page x ~256KB) without a byte-budgeted query.
8
+ const CHUNK_PAGE_SIZE = 64;
9
+
10
+ export interface CompactionStreamStats {
11
+ chunkCount: number;
12
+ lastSeq: number;
13
+ uncompressedBytes: number;
14
+ }
15
+
16
+ export interface CompactedGzipStream {
17
+ body: Readable;
18
+ stats: CompactionStreamStats;
19
+ }
20
+
21
+ export interface CompactedGzipStreamParams {
22
+ streamId: string;
23
+ /**
24
+ * Invoked once per fetched chunk page (after the read, before the page's bytes flow on).
25
+ * Lets the caller heartbeat off read progress, so the pre-upload phase and a slow first
26
+ * part still emit liveness instead of waiting on the upload's per-part progress event.
27
+ */
28
+ onPage?: () => void;
29
+ }
30
+
31
+ /**
32
+ * Builds a gzip stream of a closed stream's chunk bytes in `seq` order. Keyset-paginates
33
+ * so memory stays flat at any stream size, and fills `stats` as bytes flow so the caller
34
+ * can check the streamed totals against the table before deleting the source rows. A
35
+ * source error is forwarded to the gzip stream, so the upload consuming it rejects rather
36
+ * than silently truncating.
37
+ */
38
+ export function compactedGzipStream(params: CompactedGzipStreamParams): CompactedGzipStream {
39
+ const stats: CompactionStreamStats = {chunkCount: 0, lastSeq: 0, uncompressedBytes: 0};
40
+
41
+ async function* chunks(): AsyncGenerator<Buffer> {
42
+ let afterSeq = 0;
43
+ while (true) {
44
+ const page = await readChunksKeyset({
45
+ streamId: params.streamId,
46
+ afterSeq,
47
+ limit: CHUNK_PAGE_SIZE,
48
+ });
49
+ if (page.length === 0) break;
50
+ params.onPage?.();
51
+ for (const row of page) {
52
+ stats.chunkCount += 1;
53
+ stats.lastSeq = row.seq;
54
+ stats.uncompressedBytes += row.data.length;
55
+ yield row.data;
56
+ }
57
+ afterSeq = page.at(-1)?.seq ?? afterSeq;
58
+ if (page.length < CHUNK_PAGE_SIZE) break;
59
+ }
60
+ }
61
+
62
+ const source = Readable.from(chunks());
63
+ const gzip = createGzip();
64
+ source.once('error', (error) => gzip.destroy(error));
65
+ source.pipe(gzip);
66
+
67
+ return {body: gzip, stats};
68
+ }
@@ -0,0 +1,38 @@
1
+ /** Lifecycle state of an attempt's log stream. */
2
+ export type StreamState = 'open' | 'closed';
3
+
4
+ /** Why a stream was closed, once it is. */
5
+ export type StreamCloseReason = 'declared' | 'timeout';
6
+
7
+ /**
8
+ * A single `(job, step, attempt)` log stream. Identity is scoped to the lease's
9
+ * job. `workspaceId`, `projectId`, and `workflowRunAttemptId` are stamped from the lease on
10
+ * stream creation and asserted on subsequent appends, so reads can authorize
11
+ * per-project without joining back to workflows. `committedLength` is the
12
+ * offset-CAS axis: raw NDJSON spool bytes the server has durably accepted from
13
+ * the runner.
14
+ *
15
+ * `truncated` is an out-of-band terminal flag set when the timeout sweep
16
+ * force-closes a stream the runner never ended. Budget exhaustion ("capped") is a
17
+ * job-level signal recorded in `job_accounting` and surfaced in-band by a `capped`
18
+ * tombstone record, not on the stream row.
19
+ */
20
+ export interface AttemptStream {
21
+ id: string;
22
+ jobId: string;
23
+ stepId: string;
24
+ attempt: number;
25
+ workspaceId: string;
26
+ projectId: string;
27
+ workflowRunAttemptId: string;
28
+ committedLength: number;
29
+ state: StreamState;
30
+ closeReason: StreamCloseReason | null;
31
+ declaredTotalBytes: number | null;
32
+ truncated: boolean;
33
+ objectKey: string | null;
34
+ createdAt: Date;
35
+ updatedAt: Date;
36
+ /** When the stream was closed (either close path); null while open. Retention anchor. */
37
+ closedAt: Date | null;
38
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Per-job accrual-budget state: how many stored bytes a job has used, the budget
3
+ * clock origin, and whether it has been capped.
4
+ */
5
+ export interface JobAccounting {
6
+ jobId: string;
7
+ workspaceId: string;
8
+ storedBytesUsed: number;
9
+ startedAt: Date;
10
+ cappedAt: Date | null;
11
+ }
@@ -0,0 +1,25 @@
1
+ import {logObjectKey} from './log-object.js';
2
+
3
+ describe('logObjectKey', () => {
4
+ it('builds the {prefix}/{workspace}/{job}/{step}/{attempt} key', () => {
5
+ const key = logObjectKey('logs', {
6
+ workspaceId: 'ws',
7
+ jobId: 'job',
8
+ stepId: 'step',
9
+ attempt: 2,
10
+ });
11
+
12
+ expect(key).toBe('logs/ws/job/step/2');
13
+ });
14
+
15
+ it('uses the given prefix so one bucket can host several modules', () => {
16
+ const key = logObjectKey('shipfox-logs', {
17
+ workspaceId: 'ws',
18
+ jobId: 'job',
19
+ stepId: 'step',
20
+ attempt: 2,
21
+ });
22
+
23
+ expect(key).toBe('shipfox-logs/ws/job/step/2');
24
+ });
25
+ });
@@ -0,0 +1,18 @@
1
+ export interface LogObjectKeyParams {
2
+ workspaceId: string;
3
+ jobId: string;
4
+ stepId: string;
5
+ attempt: number;
6
+ }
7
+
8
+ /**
9
+ * Object layout `{prefix}/{workspace}/{job}/{step}/{attempt}` so retention and workspace
10
+ * deletion are prefix operations. `prefix` is the configurable bucket prefix
11
+ * (`LOG_STORAGE_S3_PREFIX`, default `logs`), so one bucket can host several modules.
12
+ */
13
+ export function logObjectKey(
14
+ prefix: string,
15
+ {workspaceId, jobId, stepId, attempt}: LogObjectKeyParams,
16
+ ): string {
17
+ return `${prefix}/${workspaceId}/${jobId}/${stepId}/${attempt}`;
18
+ }