@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,279 @@
1
+ import {Buffer} from 'node:buffer';
2
+ import {createLeaseTokenAuthMethod} from '@shipfox/api-auth';
3
+ import {AUTH_USER} from '@shipfox/api-auth-context';
4
+ import {type AuthMethod, closeApp, createApp, type FastifyInstance} from '@shipfox/node-fastify';
5
+ import {mintLeaseToken} from '#test/fixtures/lease-token.js';
6
+ import {endLine, ndjsonBody, outputLine, recordLine} from '#test/fixtures/ndjson.js';
7
+ import {logsRoutes} from './index.js';
8
+
9
+ const NDJSON = 'application/x-ndjson';
10
+ const JOB_EXECUTION_ID = '00000000-0000-4000-8000-0000000000ee';
11
+
12
+ // logsRoutes also carries the session-authed read group; register a no-op AUTH_USER method
13
+ // so auth-reference validation passes (these tests only exercise the lease append route).
14
+ const stubUserAuth: AuthMethod = {name: AUTH_USER, authenticate: () => Promise.resolve()};
15
+
16
+ function logsUrl(stepId: string, attempt: number, offset: number): string {
17
+ return `/runs/jobs/current/steps/${stepId}/logs?attempt=${attempt}&offset=${offset}`;
18
+ }
19
+
20
+ function mintTestLeaseToken(params: {
21
+ jobId?: string;
22
+ stepId: string;
23
+ attempt?: number;
24
+ }): Promise<string> {
25
+ return mintLeaseToken({
26
+ jobId: params.jobId ?? crypto.randomUUID(),
27
+ jobExecutionId: JOB_EXECUTION_ID,
28
+ currentStepId: params.stepId,
29
+ currentStepAttempt: params.attempt ?? 1,
30
+ });
31
+ }
32
+
33
+ describe('POST /runs/jobs/current/steps/:stepId/logs', () => {
34
+ let app: FastifyInstance;
35
+
36
+ beforeAll(async () => {
37
+ app = await createApp({
38
+ auth: [createLeaseTokenAuthMethod(), stubUserAuth],
39
+ routes: logsRoutes,
40
+ swagger: false,
41
+ });
42
+ await app.ready();
43
+ });
44
+
45
+ afterAll(async () => {
46
+ await closeApp();
47
+ });
48
+
49
+ it('rejects a request without a lease token', async () => {
50
+ const res = await app.inject({
51
+ method: 'POST',
52
+ url: logsUrl(crypto.randomUUID(), 1, 0),
53
+ headers: {'content-type': NDJSON},
54
+ payload: ndjsonBody(outputLine('hi\n')),
55
+ });
56
+
57
+ expect(res.statusCode).toBe(401);
58
+ expect(res.json().code).toBe('unauthorized');
59
+ });
60
+
61
+ it('accepts an in-order append and returns the committed length', async () => {
62
+ const jobId = crypto.randomUUID();
63
+ const stepId = crypto.randomUUID();
64
+ const token = await mintTestLeaseToken({jobId, stepId});
65
+ const body = ndjsonBody(outputLine('installing\n'));
66
+
67
+ const res = await app.inject({
68
+ method: 'POST',
69
+ url: logsUrl(stepId, 1, 0),
70
+ headers: {authorization: `Bearer ${token}`, 'content-type': NDJSON},
71
+ payload: body,
72
+ });
73
+
74
+ expect(res.statusCode).toBe(200);
75
+ expect(res.json()).toEqual({committed_length: body.length, capped: false});
76
+ });
77
+
78
+ it('rejects appends for a step outside the leased execution', async () => {
79
+ const leasedStepId = crypto.randomUUID();
80
+ const requestedStepId = crypto.randomUUID();
81
+ const token = await mintTestLeaseToken({stepId: leasedStepId});
82
+
83
+ const res = await app.inject({
84
+ method: 'POST',
85
+ url: logsUrl(requestedStepId, 1, 0),
86
+ headers: {authorization: `Bearer ${token}`, 'content-type': NDJSON},
87
+ payload: ndjsonBody(outputLine('wrong execution\n')),
88
+ });
89
+
90
+ expect(res.statusCode).toBe(404);
91
+ expect(res.json().code).toBe('step-not-found');
92
+ });
93
+
94
+ it('rejects appends for a different attempt than the leased step scope', async () => {
95
+ const stepId = crypto.randomUUID();
96
+ const token = await mintTestLeaseToken({stepId, attempt: 1});
97
+
98
+ const res = await app.inject({
99
+ method: 'POST',
100
+ url: logsUrl(stepId, 2, 0),
101
+ headers: {authorization: `Bearer ${token}`, 'content-type': NDJSON},
102
+ payload: ndjsonBody(outputLine('wrong attempt\n')),
103
+ });
104
+
105
+ expect(res.statusCode).toBe(404);
106
+ expect(res.json().code).toBe('step-not-found');
107
+ });
108
+
109
+ it('rejects appends with a job-scoped lease token', async () => {
110
+ const stepId = crypto.randomUUID();
111
+ const token = await mintLeaseToken({
112
+ jobId: crypto.randomUUID(),
113
+ jobExecutionId: JOB_EXECUTION_ID,
114
+ });
115
+
116
+ const res = await app.inject({
117
+ method: 'POST',
118
+ url: logsUrl(stepId, 1, 0),
119
+ headers: {authorization: `Bearer ${token}`, 'content-type': NDJSON},
120
+ payload: ndjsonBody(outputLine('job scoped\n')),
121
+ });
122
+
123
+ expect(res.statusCode).toBe(404);
124
+ expect(res.json().code).toBe('step-not-found');
125
+ });
126
+
127
+ it('acks a re-sent append at an earlier offset', async () => {
128
+ const jobId = crypto.randomUUID();
129
+ const stepId = crypto.randomUUID();
130
+ const token = await mintTestLeaseToken({jobId, stepId});
131
+ const body = ndjsonBody(outputLine('once\n'));
132
+ await app.inject({
133
+ method: 'POST',
134
+ url: logsUrl(stepId, 1, 0),
135
+ headers: {authorization: `Bearer ${token}`, 'content-type': NDJSON},
136
+ payload: body,
137
+ });
138
+
139
+ const res = await app.inject({
140
+ method: 'POST',
141
+ url: logsUrl(stepId, 1, 0),
142
+ headers: {authorization: `Bearer ${token}`, 'content-type': NDJSON},
143
+ payload: body,
144
+ });
145
+
146
+ expect(res.statusCode).toBe(200);
147
+ expect(res.json()).toEqual({committed_length: body.length, capped: false});
148
+ });
149
+
150
+ it('rejects an offset gap with 409 and the committed length', async () => {
151
+ const jobId = crypto.randomUUID();
152
+ const stepId = crypto.randomUUID();
153
+ const token = await mintTestLeaseToken({jobId, stepId});
154
+ const body = ndjsonBody(outputLine('first\n'));
155
+ await app.inject({
156
+ method: 'POST',
157
+ url: logsUrl(stepId, 1, 0),
158
+ headers: {authorization: `Bearer ${token}`, 'content-type': NDJSON},
159
+ payload: body,
160
+ });
161
+
162
+ const res = await app.inject({
163
+ method: 'POST',
164
+ url: logsUrl(stepId, 1, body.length + 10),
165
+ headers: {authorization: `Bearer ${token}`, 'content-type': NDJSON},
166
+ payload: ndjsonBody(outputLine('gap\n')),
167
+ });
168
+
169
+ expect(res.statusCode).toBe(409);
170
+ expect(res.json().code).toBe('offset-gap');
171
+ expect(res.json().details).toEqual({committed_length: body.length});
172
+ });
173
+
174
+ it('rejects a forged server-only tombstone with 400', async () => {
175
+ const stepId = crypto.randomUUID();
176
+ const token = await mintTestLeaseToken({stepId});
177
+
178
+ const res = await app.inject({
179
+ method: 'POST',
180
+ url: logsUrl(stepId, 1, 0),
181
+ headers: {authorization: `Bearer ${token}`, 'content-type': NDJSON},
182
+ payload: ndjsonBody(recordLine({type: 'capped'})),
183
+ });
184
+
185
+ expect(res.statusCode).toBe(400);
186
+ expect(res.json().code).toBe('malformed-log-chunk');
187
+ });
188
+
189
+ it('rejects a body that is not newline-terminated with 400', async () => {
190
+ const stepId = crypto.randomUUID();
191
+ const token = await mintTestLeaseToken({stepId});
192
+
193
+ const res = await app.inject({
194
+ method: 'POST',
195
+ url: logsUrl(stepId, 1, 0),
196
+ headers: {authorization: `Bearer ${token}`, 'content-type': NDJSON},
197
+ payload: '{"v":1,"ts":1,"type":"output","stream":"stdout","data":"no newline"}',
198
+ });
199
+
200
+ expect(res.statusCode).toBe(400);
201
+ expect(res.json().code).toBe('malformed-log-chunk');
202
+ });
203
+
204
+ it('rejects a record whose data exceeds the per-record byte cap with 400', async () => {
205
+ const stepId = crypto.randomUUID();
206
+ const token = await mintTestLeaseToken({stepId});
207
+
208
+ const res = await app.inject({
209
+ method: 'POST',
210
+ url: logsUrl(stepId, 1, 0),
211
+ headers: {authorization: `Bearer ${token}`, 'content-type': NDJSON},
212
+ payload: ndjsonBody(outputLine('x'.repeat(16 * 1024 + 1))),
213
+ });
214
+
215
+ expect(res.statusCode).toBe(400);
216
+ expect(res.json().code).toBe('malformed-log-chunk');
217
+ });
218
+
219
+ it('treats an empty body as a no-op', async () => {
220
+ const stepId = crypto.randomUUID();
221
+ const token = await mintTestLeaseToken({stepId});
222
+
223
+ const res = await app.inject({
224
+ method: 'POST',
225
+ url: logsUrl(stepId, 1, 0),
226
+ headers: {authorization: `Bearer ${token}`, 'content-type': NDJSON},
227
+ payload: '',
228
+ });
229
+
230
+ expect(res.statusCode).toBe(200);
231
+ expect(res.json()).toEqual({committed_length: 0, capped: false});
232
+ });
233
+
234
+ it('rejects a non-ndjson content type with 415', async () => {
235
+ const stepId = crypto.randomUUID();
236
+ const token = await mintTestLeaseToken({stepId});
237
+
238
+ const res = await app.inject({
239
+ method: 'POST',
240
+ url: logsUrl(stepId, 1, 0),
241
+ headers: {authorization: `Bearer ${token}`, 'content-type': 'application/json'},
242
+ payload: '{"hello":"world"}',
243
+ });
244
+
245
+ expect(res.statusCode).toBe(415);
246
+ });
247
+
248
+ it('rejects a body over the configured size limit with 413', async () => {
249
+ const stepId = crypto.randomUUID();
250
+ const token = await mintTestLeaseToken({stepId});
251
+ // Test body limit is 64 KiB (LOG_APPEND_BODY_LIMIT_BYTES in test/env.ts).
252
+ const oversize = Buffer.alloc(65536 + 1024, 0x61).toString('utf8');
253
+
254
+ const res = await app.inject({
255
+ method: 'POST',
256
+ url: logsUrl(stepId, 1, 0),
257
+ headers: {authorization: `Bearer ${token}`, 'content-type': NDJSON},
258
+ payload: oversize,
259
+ });
260
+
261
+ expect(res.statusCode).toBe(413);
262
+ });
263
+
264
+ it('reports capped once the budget is crossed', async () => {
265
+ const jobId = crypto.randomUUID();
266
+ const stepId = crypto.randomUUID();
267
+ const token = await mintTestLeaseToken({jobId, stepId});
268
+
269
+ const res = await app.inject({
270
+ method: 'POST',
271
+ url: logsUrl(stepId, 1, 0),
272
+ headers: {authorization: `Bearer ${token}`, 'content-type': NDJSON},
273
+ payload: ndjsonBody(outputLine('x'.repeat(150)), endLine(150)),
274
+ });
275
+
276
+ expect(res.statusCode).toBe(200);
277
+ expect(res.json().capped).toBe(true);
278
+ });
279
+ });
@@ -0,0 +1,66 @@
1
+ import {Buffer} from 'node:buffer';
2
+ import {requireLeasedJobContext} from '@shipfox/api-auth-context';
3
+ import {
4
+ appendLogsQuerySchema,
5
+ appendLogsResponseSchema,
6
+ offsetGapResponseSchema,
7
+ } from '@shipfox/api-logs-dto';
8
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
9
+ import {z} from 'zod';
10
+ import {appendLogs} from '#core/append-logs.js';
11
+ import {LeaseStreamMismatchError, MalformedLogChunkError, OffsetGapError} from '#core/errors.js';
12
+
13
+ export const appendLogsRoute = defineRoute({
14
+ method: 'POST',
15
+ path: '/steps/:stepId/logs',
16
+ description: 'Append a chunk of logs for a step attempt of the leased job.',
17
+ schema: {
18
+ params: z.object({stepId: z.string().uuid()}),
19
+ querystring: appendLogsQuerySchema,
20
+ response: {
21
+ 200: appendLogsResponseSchema,
22
+ 409: offsetGapResponseSchema,
23
+ },
24
+ },
25
+ errorHandler: (error) => {
26
+ if (error instanceof OffsetGapError) {
27
+ throw new ClientError('Append offset is ahead of the committed length', 'offset-gap', {
28
+ status: 409,
29
+ details: {committed_length: error.committedLength},
30
+ });
31
+ }
32
+ if (error instanceof MalformedLogChunkError) {
33
+ throw new ClientError(error.message, 'malformed-log-chunk', {status: 400});
34
+ }
35
+ if (error instanceof LeaseStreamMismatchError) {
36
+ throw new ClientError(error.message, 'lease-stream-mismatch', {status: 403});
37
+ }
38
+ throw error;
39
+ },
40
+ handler: async (request) => {
41
+ const leasedJob = requireLeasedJobContext(request);
42
+ const {stepId} = request.params;
43
+ const {attempt, offset} = request.query;
44
+
45
+ // The scoped lease proves membership in the dispatched step attempt; active-step
46
+ // completion remains governed by workflow/report state, not the log append route.
47
+ if (leasedJob.currentStepId !== stepId || leasedJob.currentStepAttempt !== attempt) {
48
+ throw new ClientError('Step not found for leased job execution', 'step-not-found', {
49
+ status: 404,
50
+ });
51
+ }
52
+
53
+ const result = await appendLogs({
54
+ jobId: leasedJob.jobId,
55
+ workspaceId: leasedJob.workspaceId,
56
+ projectId: leasedJob.projectId,
57
+ workflowRunAttemptId: leasedJob.workflowRunAttemptId,
58
+ stepId,
59
+ attempt,
60
+ offset,
61
+ body: (request.body as Buffer | undefined) ?? Buffer.alloc(0),
62
+ });
63
+
64
+ return {committed_length: result.committedLength, capped: result.capped};
65
+ },
66
+ });
@@ -0,0 +1,27 @@
1
+ import {AUTH_LEASED_JOB, AUTH_USER} from '@shipfox/api-auth-context';
2
+ import {createRawBodyPlugin, type RouteGroup} from '@shipfox/node-fastify';
3
+ import {config} from '#config.js';
4
+ import {appendLogsRoute} from './append-logs.js';
5
+ import {readLogsRoute} from './read-logs.js';
6
+
7
+ // Keep the lease-authed append in its own Fastify scope so the raw NDJSON parser does not
8
+ // disturb the JSON read route (or workflow routes). The body limit also bounds one-append
9
+ // budget overshoot. The read route is session-authed and workspace-scoped via the row.
10
+ export const logsRoutes: RouteGroup[] = [
11
+ {
12
+ prefix: '/runs/jobs/current',
13
+ auth: AUTH_LEASED_JOB,
14
+ plugins: [
15
+ createRawBodyPlugin({
16
+ contentType: 'application/x-ndjson',
17
+ bodyLimit: config.LOG_APPEND_BODY_LIMIT_BYTES,
18
+ }),
19
+ ],
20
+ routes: [appendLogsRoute],
21
+ },
22
+ {
23
+ prefix: '/steps',
24
+ auth: AUTH_USER,
25
+ routes: [readLogsRoute],
26
+ },
27
+ ];