@legionworks/facet 1.9.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 (327) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/LICENSE-MIT +23 -0
  3. package/README.md +158 -0
  4. package/dist/gallery/chunk-4g8rem85.css +656 -0
  5. package/dist/gallery/chunk-c6s9k9ty.js +5970 -0
  6. package/dist/gallery/frame/artifact.css +8424 -0
  7. package/dist/gallery/frame/chunks/abnfDiagram-N423BO3Z-jj74szbm.js +155 -0
  8. package/dist/gallery/frame/chunks/architecture-TIHT7OUA-cvdjn7dt.js +10 -0
  9. package/dist/gallery/frame/chunks/architectureDiagram-T3A2C74G-n4z77eg6.js +8675 -0
  10. package/dist/gallery/frame/chunks/blockDiagram-VBNYF7ZC-7xsqzrd1.js +3529 -0
  11. package/dist/gallery/frame/chunks/c4Diagram-5PPSVZJV-z2zjs7wx.js +2413 -0
  12. package/dist/gallery/frame/chunks/classDiagram-JCYQIIEL-ve8rngqp.js +47 -0
  13. package/dist/gallery/frame/chunks/classDiagram-v2-OCEON4UE-8cfaxb8z.js +47 -0
  14. package/dist/gallery/frame/chunks/cose-bilkent-JH36ORCC-sn92qexx.js +4822 -0
  15. package/dist/gallery/frame/chunks/cynefin-VYW2F7L2-tqjv913d.js +10 -0
  16. package/dist/gallery/frame/chunks/cynefinDiagram-MW4NZA55-c1c0dj12.js +500 -0
  17. package/dist/gallery/frame/chunks/dagre-VZM6K2ZE-7tyg4yt2.js +476 -0
  18. package/dist/gallery/frame/chunks/diagram-7IWD3JNH-y4eng9sc.js +526 -0
  19. package/dist/gallery/frame/chunks/diagram-B4RE2ZJO-rn3p7j5h.js +673 -0
  20. package/dist/gallery/frame/chunks/diagram-LBJQPF4R-w4a2cm9q.js +255 -0
  21. package/dist/gallery/frame/chunks/diagram-Q27KOJAE-aqc2v7wy.js +575 -0
  22. package/dist/gallery/frame/chunks/diagram-UB23O5K3-xncy8nyd.js +355 -0
  23. package/dist/gallery/frame/chunks/ebnfDiagram-BXEA7PRR-acwgxz16.js +175 -0
  24. package/dist/gallery/frame/chunks/erDiagram-JOGREHBK-mzp5nswt.js +1332 -0
  25. package/dist/gallery/frame/chunks/eventmodeling-45OFAUF4-sg07qr0c.js +10 -0
  26. package/dist/gallery/frame/chunks/flowDiagram-UKHOOZJN-2e5ygs44.js +29 -0
  27. package/dist/gallery/frame/chunks/ganttDiagram-PKOTCBZU-p2hhh7hr.js +2620 -0
  28. package/dist/gallery/frame/chunks/gitGraph-TEB2WS4Q-j6kw0naa.js +10 -0
  29. package/dist/gallery/frame/chunks/gitGraphDiagram-DS77QQ5N-4z9z8wjm.js +1347 -0
  30. package/dist/gallery/frame/chunks/info-DKCQHKI2-mpt80jd3.js +10 -0
  31. package/dist/gallery/frame/chunks/infoDiagram-6WML65LV-5rqm3mgg.js +65 -0
  32. package/dist/gallery/frame/chunks/ishikawaDiagram-WSZJBQD7-b51z96jy.js +975 -0
  33. package/dist/gallery/frame/chunks/journeyDiagram-NVQOT4AX-9jbfp0pk.js +1272 -0
  34. package/dist/gallery/frame/chunks/kanban-definition-27J2QSJJ-4x0vx4se.js +1116 -0
  35. package/dist/gallery/frame/chunks/katex-xfgk95tv.js +14034 -0
  36. package/dist/gallery/frame/chunks/markdown-1s5vwkf2.js +9 -0
  37. package/dist/gallery/frame/chunks/markdown-2h6bwsms.js +3239 -0
  38. package/dist/gallery/frame/chunks/markdown-2x113yh8.js +107 -0
  39. package/dist/gallery/frame/chunks/markdown-30b7rwmp.js +1943 -0
  40. package/dist/gallery/frame/chunks/markdown-356bfj4g.js +2031 -0
  41. package/dist/gallery/frame/chunks/markdown-44n9ea69.js +2406 -0
  42. package/dist/gallery/frame/chunks/markdown-56enrf2r.js +39 -0
  43. package/dist/gallery/frame/chunks/markdown-5avymm8t.js +725 -0
  44. package/dist/gallery/frame/chunks/markdown-6r4svars.js +33 -0
  45. package/dist/gallery/frame/chunks/markdown-6zz8efx2.js +24 -0
  46. package/dist/gallery/frame/chunks/markdown-7jp6prnb.js +19 -0
  47. package/dist/gallery/frame/chunks/markdown-809k7q41.js +55 -0
  48. package/dist/gallery/frame/chunks/markdown-82xmzhzd.js +1076 -0
  49. package/dist/gallery/frame/chunks/markdown-92n37gd4.js +7627 -0
  50. package/dist/gallery/frame/chunks/markdown-ag2sssf8.js +36 -0
  51. package/dist/gallery/frame/chunks/markdown-b4s7ntq2.js +19 -0
  52. package/dist/gallery/frame/chunks/markdown-c9k0fvp1.js +100 -0
  53. package/dist/gallery/frame/chunks/markdown-ceefymn8.js +125 -0
  54. package/dist/gallery/frame/chunks/markdown-d1wfbs70.js +243 -0
  55. package/dist/gallery/frame/chunks/markdown-dy5sgjhc.js +11022 -0
  56. package/dist/gallery/frame/chunks/markdown-dz3z5w30.js +30868 -0
  57. package/dist/gallery/frame/chunks/markdown-eg6ngz7y.js +30340 -0
  58. package/dist/gallery/frame/chunks/markdown-ff5wvb43.js +1149 -0
  59. package/dist/gallery/frame/chunks/markdown-ffg06nfz.js +86 -0
  60. package/dist/gallery/frame/chunks/markdown-fvfncadf.js +472 -0
  61. package/dist/gallery/frame/chunks/markdown-gvyszqpv.js +19 -0
  62. package/dist/gallery/frame/chunks/markdown-gx1p0ew0.js +88 -0
  63. package/dist/gallery/frame/chunks/markdown-gxye5frz.js +65 -0
  64. package/dist/gallery/frame/chunks/markdown-h7pzv34e.js +105 -0
  65. package/dist/gallery/frame/chunks/markdown-hab789t9.js +47 -0
  66. package/dist/gallery/frame/chunks/markdown-hjp6sj8t.js +125 -0
  67. package/dist/gallery/frame/chunks/markdown-jka64qgp.js +1993 -0
  68. package/dist/gallery/frame/chunks/markdown-k8xkn8mw.js +56 -0
  69. package/dist/gallery/frame/chunks/markdown-m7frq8x8.js +112 -0
  70. package/dist/gallery/frame/chunks/markdown-p740v4qm.js +36 -0
  71. package/dist/gallery/frame/chunks/markdown-pxfeh231.js +57 -0
  72. package/dist/gallery/frame/chunks/markdown-pzgd8z3s.js +87 -0
  73. package/dist/gallery/frame/chunks/markdown-qx3cngz2.js +1673 -0
  74. package/dist/gallery/frame/chunks/markdown-rawhfvvc.js +85 -0
  75. package/dist/gallery/frame/chunks/markdown-rd8w0ng9.js +49 -0
  76. package/dist/gallery/frame/chunks/markdown-rm2yxfjj.js +2773 -0
  77. package/dist/gallery/frame/chunks/markdown-s1jatrnk.js +993 -0
  78. package/dist/gallery/frame/chunks/markdown-sgfg5vvq.js +59 -0
  79. package/dist/gallery/frame/chunks/markdown-t4ethv9d.js +409 -0
  80. package/dist/gallery/frame/chunks/markdown-tnmabqvz.js +36 -0
  81. package/dist/gallery/frame/chunks/markdown-tvdpppw8.js +5292 -0
  82. package/dist/gallery/frame/chunks/markdown-v0x27twr.js +22 -0
  83. package/dist/gallery/frame/chunks/markdown-v14td68x.js +36 -0
  84. package/dist/gallery/frame/chunks/markdown-w6sgd54z.js +36 -0
  85. package/dist/gallery/frame/chunks/markdown-xw8dsk8b.js +2125 -0
  86. package/dist/gallery/frame/chunks/markdown-y18mve73.js +85 -0
  87. package/dist/gallery/frame/chunks/markdown-yengsms5.js +427 -0
  88. package/dist/gallery/frame/chunks/markdown-z1wc0v0d.js +1826 -0
  89. package/dist/gallery/frame/chunks/markdown-zazx9h4n.js +329 -0
  90. package/dist/gallery/frame/chunks/mermaid-de7p4v0x.js +30 -0
  91. package/dist/gallery/frame/chunks/mindmap-definition-FAOFIHXS-ra44cbem.js +1216 -0
  92. package/dist/gallery/frame/chunks/packet-7NZHBO7P-xppm5913.js +10 -0
  93. package/dist/gallery/frame/chunks/pegDiagram-VL7TDLO6-a2a4wg03.js +163 -0
  94. package/dist/gallery/frame/chunks/pie-RZYD4A2V-hnbxk152.js +10 -0
  95. package/dist/gallery/frame/chunks/pieDiagram-7S7Q4E2Y-f1vxjtmx.js +311 -0
  96. package/dist/gallery/frame/chunks/quadrantDiagram-CIZ2JOQS-ywafa6kh.js +1390 -0
  97. package/dist/gallery/frame/chunks/radar-I7S5WNFK-ewqd27se.js +10 -0
  98. package/dist/gallery/frame/chunks/railroad-3IZDKUUU-5hy4bdx3.js +10 -0
  99. package/dist/gallery/frame/chunks/railroad-abnf-AHOZXSZD-bh5sxz0d.js +10 -0
  100. package/dist/gallery/frame/chunks/railroad-ebnf-EBAXGLYW-6b9c3zeh.js +10 -0
  101. package/dist/gallery/frame/chunks/railroad-peg-LSFZ7HO6-avc8v39s.js +10 -0
  102. package/dist/gallery/frame/chunks/railroadDiagram-AXF67PYL-qc61wax4.js +131 -0
  103. package/dist/gallery/frame/chunks/requirementDiagram-LRYGKXZP-9bs4786c.js +1295 -0
  104. package/dist/gallery/frame/chunks/sankeyDiagram-W5VNT64P-awt1em8b.js +1339 -0
  105. package/dist/gallery/frame/chunks/sequenceDiagram-SI44F4Z6-qaf9mz3t.js +4324 -0
  106. package/dist/gallery/frame/chunks/sizeCapture-X5ZJPWSS-9fq685xd.js +64 -0
  107. package/dist/gallery/frame/chunks/stateDiagram-OKZ733FA-vx7a7hh0.js +456 -0
  108. package/dist/gallery/frame/chunks/stateDiagram-v2-UEYNNEHI-x4m6ws5t.js +46 -0
  109. package/dist/gallery/frame/chunks/swimlanes-SLNWSIFB-e4mxm1gh.js +8287 -0
  110. package/dist/gallery/frame/chunks/swimlanesDiagram-ULZ7WXOC-0h93e2qq.js +42 -0
  111. package/dist/gallery/frame/chunks/timeline-definition-Z64GVDOM-wp0bhbzg.js +1549 -0
  112. package/dist/gallery/frame/chunks/treeView-QDETBFTQ-hcx7n0vt.js +10 -0
  113. package/dist/gallery/frame/chunks/treemap-6X3UGDF4-av02skdw.js +10 -0
  114. package/dist/gallery/frame/chunks/vennDiagram-T6HMQDX7-pmvk1p7n.js +2566 -0
  115. package/dist/gallery/frame/chunks/wardley-OPB4EBWU-njsp6e40.js +10 -0
  116. package/dist/gallery/frame/chunks/wardleyDiagram-T6FBY63Y-eggmnv4z.js +979 -0
  117. package/dist/gallery/frame/chunks/xychartDiagram-ELKLHX3M-q40tnpe5.js +1954 -0
  118. package/dist/gallery/frame/frame.css +183 -0
  119. package/dist/gallery/frame/runtime/chart.js +42744 -0
  120. package/dist/gallery/frame/runtime/html.js +14 -0
  121. package/dist/gallery/frame/runtime/markdown.js +1403 -0
  122. package/dist/gallery/frame/runtime/mermaid.js +32 -0
  123. package/dist/gallery/frame/runtime/svg.js +14 -0
  124. package/dist/gallery/frame/runtime/tsx.js +52 -0
  125. package/dist/gallery/index.html +104 -0
  126. package/docs/reference/cli.md +163 -0
  127. package/docs/reference/export.md +106 -0
  128. package/docs/reference/html.md +215 -0
  129. package/docs/reference/http.md +85 -0
  130. package/docs/reference/mcp.md +78 -0
  131. package/docs/reference/security.md +119 -0
  132. package/docs/reference/storage.md +93 -0
  133. package/docs/reference/tsx.md +101 -0
  134. package/docs/reference/validation.md +212 -0
  135. package/package.json +89 -0
  136. package/scripts/build-gallery.ts +127 -0
  137. package/scripts/launch-netns.sh +67 -0
  138. package/skills/facet/SKILL.md +100 -0
  139. package/src/cli/client.ts +311 -0
  140. package/src/cli/commands/create.ts +49 -0
  141. package/src/cli/commands/doctor.ts +249 -0
  142. package/src/cli/commands/export.ts +179 -0
  143. package/src/cli/commands/instantiate.ts +35 -0
  144. package/src/cli/commands/list.ts +49 -0
  145. package/src/cli/commands/open.ts +75 -0
  146. package/src/cli/commands/pin.ts +39 -0
  147. package/src/cli/commands/promote.ts +49 -0
  148. package/src/cli/commands/publish.ts +122 -0
  149. package/src/cli/commands/read-back.ts +56 -0
  150. package/src/cli/commands/status.ts +114 -0
  151. package/src/cli/commands/watch.ts +146 -0
  152. package/src/cli/main.ts +522 -0
  153. package/src/cli/output.ts +106 -0
  154. package/src/cli/parser.ts +376 -0
  155. package/src/cli/presenter.ts +212 -0
  156. package/src/cli/service-metadata.ts +134 -0
  157. package/src/cli/spawn-service.ts +326 -0
  158. package/src/css.d.ts +4 -0
  159. package/src/gallery-web/app.ts +1581 -0
  160. package/src/gallery-web/export-menu.ts +121 -0
  161. package/src/gallery-web/export.ts +112 -0
  162. package/src/gallery-web/favicon.ts +54 -0
  163. package/src/gallery-web/frame/entries/chart.ts +6 -0
  164. package/src/gallery-web/frame/entries/html.ts +6 -0
  165. package/src/gallery-web/frame/entries/markdown.ts +6 -0
  166. package/src/gallery-web/frame/entries/mermaid.ts +6 -0
  167. package/src/gallery-web/frame/entries/svg.ts +6 -0
  168. package/src/gallery-web/frame/entries/tsx.ts +6 -0
  169. package/src/gallery-web/frame/frame-payload.ts +91 -0
  170. package/src/gallery-web/frame/renderer-validation.ts +13 -0
  171. package/src/gallery-web/frame/renderers/chart.ts +182 -0
  172. package/src/gallery-web/frame/renderers/dompurify-shim.ts +112 -0
  173. package/src/gallery-web/frame/renderers/html.ts +48 -0
  174. package/src/gallery-web/frame/renderers/markdown.ts +81 -0
  175. package/src/gallery-web/frame/renderers/mermaid.ts +120 -0
  176. package/src/gallery-web/frame/renderers/registry.ts +260 -0
  177. package/src/gallery-web/frame/renderers/svg.ts +393 -0
  178. package/src/gallery-web/frame/renderers/tsx.ts +61 -0
  179. package/src/gallery-web/frame/runtime.ts +501 -0
  180. package/src/gallery-web/frame/styles/artifact.css +8424 -0
  181. package/src/gallery-web/frame/styles/frame.css +183 -0
  182. package/src/gallery-web/frame/styles/html-source.css +23 -0
  183. package/src/gallery-web/frame/view-box.ts +33 -0
  184. package/src/gallery-web/frame-html.ts +72 -0
  185. package/src/gallery-web/index.html +104 -0
  186. package/src/gallery-web/session.ts +107 -0
  187. package/src/gallery-web/sse-client.ts +212 -0
  188. package/src/gallery-web/styles/app.css +420 -0
  189. package/src/gallery-web/styles/tokens.css +112 -0
  190. package/src/gallery-web/styles/verdict.css +186 -0
  191. package/src/gallery-web/swap.ts +64 -0
  192. package/src/gallery-web/theme.ts +40 -0
  193. package/src/gallery-web/view-state.ts +93 -0
  194. package/src/harness-adapters/claude-code/README.md +10 -0
  195. package/src/harness-adapters/claude-code/facet.sh +4 -0
  196. package/src/harness-adapters/codex/README.md +10 -0
  197. package/src/harness-adapters/codex/facet.sh +4 -0
  198. package/src/harness-adapters/mcp/README.md +13 -0
  199. package/src/harness-adapters/mcp/cli-bridge.ts +216 -0
  200. package/src/harness-adapters/mcp/main.ts +15 -0
  201. package/src/harness-adapters/mcp/server.ts +164 -0
  202. package/src/harness-adapters/mcp/tool-schemas.ts +53 -0
  203. package/src/harness-adapters/opencode/README.md +10 -0
  204. package/src/harness-adapters/opencode/facet.sh +4 -0
  205. package/src/runtime/compiled-entrypoints.ts +69 -0
  206. package/src/service/dispatcher.ts +717 -0
  207. package/src/service/export.ts +218 -0
  208. package/src/service/http-utils.ts +55 -0
  209. package/src/service/lexical/expectations.ts +210 -0
  210. package/src/service/lifecycle/idle-controller.ts +125 -0
  211. package/src/service/lifecycle/orphan-cleanup.ts +128 -0
  212. package/src/service/lifecycle/process-lock.ts +235 -0
  213. package/src/service/main.ts +136 -0
  214. package/src/service/process.ts +240 -0
  215. package/src/service/router-guards.ts +239 -0
  216. package/src/service/router.ts +627 -0
  217. package/src/service/security/auth.ts +168 -0
  218. package/src/service/security/host-origin.ts +138 -0
  219. package/src/service/security/http-guards.ts +13 -0
  220. package/src/service/security/leases.ts +182 -0
  221. package/src/service/security/token-store.ts +138 -0
  222. package/src/service/server.ts +356 -0
  223. package/src/service/store/database.ts +61 -0
  224. package/src/service/store/evidence-retention.ts +234 -0
  225. package/src/service/store/migrations.ts +167 -0
  226. package/src/service/store/repository-lifecycle.ts +121 -0
  227. package/src/service/store/repository.ts +732 -0
  228. package/src/service/store/schema.ts +218 -0
  229. package/src/service/stored-verdict.ts +97 -0
  230. package/src/service/stream.ts +301 -0
  231. package/src/service/verdict-enrichment.ts +45 -0
  232. package/src/shared/build/frame-bundle-plugins.ts +61 -0
  233. package/src/shared/build-mode.ts +28 -0
  234. package/src/shared/config/evidence-read.ts +98 -0
  235. package/src/shared/config/limits.ts +56 -0
  236. package/src/shared/config/paths.ts +96 -0
  237. package/src/shared/contracts/artifact-types.ts +2 -0
  238. package/src/shared/contracts/artifact.ts +98 -0
  239. package/src/shared/contracts/commands/_shared.ts +59 -0
  240. package/src/shared/contracts/commands/guards.ts +86 -0
  241. package/src/shared/contracts/commands/index.ts +204 -0
  242. package/src/shared/contracts/commands/names.ts +35 -0
  243. package/src/shared/contracts/commands/requests.ts +149 -0
  244. package/src/shared/contracts/commands/results.ts +200 -0
  245. package/src/shared/contracts/envelope.ts +140 -0
  246. package/src/shared/contracts/events.ts +41 -0
  247. package/src/shared/contracts/observed-counts.ts +19 -0
  248. package/src/shared/contracts/renderers.ts +6 -0
  249. package/src/shared/contracts/validation.ts +296 -0
  250. package/src/shared/errors/facet-error.ts +127 -0
  251. package/src/shared/errors/store-error.ts +68 -0
  252. package/src/shared/evidence-image.ts +37 -0
  253. package/src/shared/export.ts +31 -0
  254. package/src/shared/html/policy.ts +108 -0
  255. package/src/shared/html/style-vocabulary.ts +352 -0
  256. package/src/shared/logging/logger.ts +108 -0
  257. package/src/shared/logging/redact.ts +49 -0
  258. package/src/shared/security/frozen-csp.ts +13 -0
  259. package/src/shared/storage-version.ts +2 -0
  260. package/src/shared/tsx/execution.ts +29 -0
  261. package/src/shared/tsx/import-policy.ts +94 -0
  262. package/src/shared/util/dir-permissions.ts +94 -0
  263. package/src/shared/util/mermaid-nodes.ts +230 -0
  264. package/src/shared/util/process.ts +67 -0
  265. package/src/shared/util/time.ts +21 -0
  266. package/src/shared/version.ts +3 -0
  267. package/src/validation/sandbox/limits.ts +41 -0
  268. package/src/validation/sandbox/netns.ts +177 -0
  269. package/src/validation/tier0/chart.ts +177 -0
  270. package/src/validation/tier0/dom-shim.ts +54 -0
  271. package/src/validation/tier0/html.ts +408 -0
  272. package/src/validation/tier0/markdown.ts +285 -0
  273. package/src/validation/tier0/mermaid.ts +98 -0
  274. package/src/validation/tier0/runner.ts +504 -0
  275. package/src/validation/tier0/svg.ts +321 -0
  276. package/src/validation/tier0/tsx/allowlist-resolver.ts +77 -0
  277. package/src/validation/tier0/tsx/ast-policy-capabilities.ts +238 -0
  278. package/src/validation/tier0/tsx/ast-policy-shared.ts +71 -0
  279. package/src/validation/tier0/tsx/ast-policy.ts +181 -0
  280. package/src/validation/tier0/tsx/compiler.ts +156 -0
  281. package/src/validation/tier0/worker-dispatch.ts +211 -0
  282. package/src/validation/tier0/worker-entry.ts +96 -0
  283. package/src/validation/tier0/worker-input.ts +75 -0
  284. package/src/validation/tier1/browser-process.ts +147 -0
  285. package/src/validation/tier1/cdp-pipe.ts +281 -0
  286. package/src/validation/tier1/entries/chart.ts +5 -0
  287. package/src/validation/tier1/entries/html.ts +6 -0
  288. package/src/validation/tier1/entries/markdown.ts +5 -0
  289. package/src/validation/tier1/entries/mermaid.ts +5 -0
  290. package/src/validation/tier1/entries/svg.ts +5 -0
  291. package/src/validation/tier1/entries/tsx.ts +5 -0
  292. package/src/validation/tier1/frame-target.ts +162 -0
  293. package/src/validation/tier1/harness-entry.ts +261 -0
  294. package/src/validation/tier1/harness.ts +215 -0
  295. package/src/validation/tier1/isolated-probe.ts +94 -0
  296. package/src/validation/tier1/launcher.ts +177 -0
  297. package/src/validation/tier1/limits.ts +80 -0
  298. package/src/validation/tier1/nonce.ts +17 -0
  299. package/src/validation/tier1/protocol-probe.ts +519 -0
  300. package/src/validation/tier1/runner.ts +1122 -0
  301. package/src/validation/tier1/verdict.ts +246 -0
  302. package/src/validation/tier1/webp.ts +69 -0
  303. package/templates/README.md +88 -0
  304. package/templates/bar-compare.vl.json +63 -0
  305. package/templates/capacity-report.tsx +102 -0
  306. package/templates/decision-record.md +24 -0
  307. package/templates/deployment-state.mmd +40 -0
  308. package/templates/exemplar.md +91 -0
  309. package/templates/facet-story.tsx +799 -0
  310. package/templates/fleet-dashboard.html +141 -0
  311. package/templates/html-release-ledger.html +91 -0
  312. package/templates/html-status-report.html +114 -0
  313. package/templates/incident-console.tsx +147 -0
  314. package/templates/legion-boundaries.mmd +23 -0
  315. package/templates/legion-flow.mmd +15 -0
  316. package/templates/legion-sequence.mmd +14 -0
  317. package/templates/legion-state.mmd +7 -0
  318. package/templates/metric-card.svg +29 -0
  319. package/templates/observability-map.svg +101 -0
  320. package/templates/pipeline-audit.md +108 -0
  321. package/templates/release-metrics.vl.json +98 -0
  322. package/templates/service-topology.mmd +47 -0
  323. package/templates/status-report.md +32 -0
  324. package/templates/system-map.svg +53 -0
  325. package/templates/timeseries.vl.json +71 -0
  326. package/templates/tsx-interactive-counter.tsx +19 -0
  327. package/templates/tsx-status-report.tsx +31 -0
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Loopback service server.
3
+ *
4
+ * Bind order (security-critical — D3 review):
5
+ * 1. Acquire the process lock FIRST with a minimal metadata record
6
+ * (pid + startTime; port=0). A second cold start cannot race the
7
+ * install-token create, DB migrate, or port bind while we hold
8
+ * the lock.
9
+ * 2. Open the database + run migrations (idempotent under lock).
10
+ * 3. Create the install token atomically (temp-file + rename under
11
+ * lock; a second starter that wins the lock reads what we wrote).
12
+ * 4. Read the operator promote token (read-only, may be absent).
13
+ * 5. Bind Bun.serve on 127.0.0.1:<os-assigned port>.
14
+ * 6. Update the lock metadata with the assigned port + complete
15
+ * record — STILL holding the lock so a parallel starter cannot
16
+ * observe a port-less lock and assume the service is unready.
17
+ *
18
+ * Idle-driven stop: release the lock, close the server, WAL checkpoint.
19
+ */
20
+
21
+ import { dirname } from "node:path";
22
+
23
+ import { FacetError } from "../shared/errors/facet-error";
24
+ import { createLogger, type FacetLogger } from "../shared/logging/logger";
25
+ import { computeFacetPaths } from "../shared/config/paths";
26
+
27
+ import { openDatabase } from "./store/database";
28
+ import { runMigrations } from "./store/migrations";
29
+ import { ArtifactRepository } from "./store/repository";
30
+ import { buildRouter } from "./router";
31
+ import { createInstallTokenStore, createPromoteTokenStore } from "./security/token-store";
32
+ import { createLeaseManager, type GalleryLeaseManager } from "./security/leases";
33
+ import {
34
+ assertHeartbeatBeforeLeaseTtl,
35
+ createRevisionBroadcaster,
36
+ STREAM_HEARTBEAT_INTERVAL_MS,
37
+ } from "./stream";
38
+ import { defaultInsecureReason } from "./verdict-enrichment";
39
+ import {
40
+ InsecureLevelSchema,
41
+ type InsecureLevel,
42
+ type Tier0Runner,
43
+ type Tier1Runner,
44
+ } from "../shared/contracts/validation";
45
+ import {
46
+ acquireLock,
47
+ releaseLock,
48
+ writeLockMetadata,
49
+ readPidStartTimeTicks,
50
+ type LockMetadata,
51
+ } from "./lifecycle/process-lock";
52
+ import { runOrphanCleanup } from "./lifecycle/orphan-cleanup";
53
+ import { createIdleController, type IdleController } from "./lifecycle/idle-controller";
54
+ import { ensureEvidenceRoot } from "./store/evidence-retention";
55
+ import { ensureOwnerOnlyDirectory } from "../shared/util/dir-permissions";
56
+
57
+ const DEFAULT_IDLE_TIMEOUT_MS = 30_000;
58
+ const LEASE_TTL_MS = 5 * 60_000;
59
+ export const BUN_SOCKET_IDLE_TIMEOUT_S = 45;
60
+
61
+ export interface StartServiceOptions {
62
+ readonly insecureLevel?: InsecureLevel;
63
+ readonly insecureReason?: string | null;
64
+ readonly dbPath?: string;
65
+ readonly installTokenPath?: string;
66
+ readonly promoteTokenPath?: string;
67
+ readonly lockPath?: string;
68
+ readonly evidencePath?: string;
69
+ readonly idleTimeoutMs?: number;
70
+ readonly leaseTtlMs?: number;
71
+ readonly heartbeatIntervalMs?: number;
72
+ readonly logger?: FacetLogger;
73
+ readonly host?: string;
74
+ readonly onIdle?: () => void;
75
+ /**
76
+ * Tier 0 runner. Production callers pass the default from
77
+ * `src/validation/tier0/runner.ts`; tests inject a stub so they
78
+ * can exercise the publish path without spawning a worker. The
79
+ * type lives in `dispatcher.ts` so the server does not need to
80
+ * know which module implements it.
81
+ */
82
+ readonly tier0Runner?: Tier0Runner;
83
+ /**
84
+ * Optional Tier 1 verifier. When present, publish records BOTH a
85
+ * Tier 0 and a Tier 1 render_run; read-back of tier 1 returns the
86
+ * Tier 1 verdict. When absent, tier 1 is never recorded and
87
+ * read-back of tier 1 surfaces `revision_not_found`.
88
+ */
89
+ readonly tier1Runner?: Tier1Runner;
90
+ }
91
+
92
+ export interface RunningService {
93
+ readonly port: number;
94
+ readonly pid: number;
95
+ readonly url: string;
96
+ readonly installToken: string;
97
+ readonly promoteToken: string | null;
98
+ stop(): Promise<void>;
99
+ waitUntilIdle(): Promise<void>;
100
+ }
101
+
102
+ export async function startFacetService(
103
+ options: StartServiceOptions = {},
104
+ ): Promise<RunningService> {
105
+ const logger = options.logger ?? createLogger({ component: "service" });
106
+ const insecureLevel = InsecureLevelSchema.parse(options.insecureLevel ?? 0);
107
+ const paths = computeFacetPaths();
108
+ const dbPath = options.dbPath ?? paths.database;
109
+ const installTokenPath =
110
+ options.installTokenPath ?? paths.token.replace(/promote\.token$/, "install.token");
111
+ const promoteTokenPath = options.promoteTokenPath ?? paths.token;
112
+ const lockPath = options.lockPath ?? paths.lock;
113
+ const evidenceRoot = options.evidencePath ?? paths.evidence;
114
+ // When the parent CLI threaded an explicit evidence root, the child's own
115
+ // FACET_HOME-derived root (`paths.evidence`) is the legacy child-derived
116
+ // root from pre-threading installs. Retain it as a READ-ONLY fallback so
117
+ // old evidence stays reachable; new evidence always lands in evidenceRoot.
118
+ const legacyEvidenceRoot =
119
+ options.evidencePath !== undefined && options.evidencePath !== paths.evidence
120
+ ? paths.evidence
121
+ : undefined;
122
+ const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
123
+ const leaseTtlMs = options.leaseTtlMs ?? LEASE_TTL_MS;
124
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? STREAM_HEARTBEAT_INTERVAL_MS;
125
+ assertHeartbeatBeforeLeaseTtl(heartbeatIntervalMs, leaseTtlMs);
126
+ const host = options.host ?? "127.0.0.1";
127
+
128
+ // Pre-lock: orphan cleanup (read-only inspection of disk state).
129
+ runOrphanCleanup({ lockPath, databasePath: dbPath });
130
+
131
+ // Ensure the evidence root exists with mode 0700 before any
132
+ // service writes land there. Idempotent and best-effort — the
133
+ // directory may already exist (e.g. from a prior run) in which case
134
+ // ensureEvidenceRoot rechmods to enforce the secret-bearing layout.
135
+ ensureEvidenceRoot(evidenceRoot);
136
+
137
+ // 1. Lock FIRST. port=0 because the kernel-assigned port is not yet
138
+ // known. A second cold start that races this one will be refused
139
+ // by acquireLock (live foreign pid contention) until we either
140
+ // succeed (lock released on stop) or fail (typed constraint).
141
+ const lockStartTime = Date.now();
142
+ const lockMetadata: LockMetadata = {
143
+ pid: process.pid,
144
+ startTime: lockStartTime,
145
+ startTimeTicks: readPidStartTimeTicks(process.pid),
146
+ port: 0,
147
+ contractVersion: "facet.v1",
148
+ };
149
+ const lockResult = acquireLock(lockPath, lockMetadata);
150
+ if (!lockResult.ok) {
151
+ throw lockResult.error;
152
+ }
153
+
154
+ let boundServer: ReturnType<typeof Bun.serve> | null = null;
155
+ // Capture the hook before initialization so every later startup failure reaps a lazy worker.
156
+ let tier0Close: (() => void) | null = options.tier0Runner?.close ?? null;
157
+ let databaseClose: (() => void) | null = null;
158
+ let leasesClear: (() => void) | null = null;
159
+ const cleanup = (): void => {
160
+ if (boundServer !== null) {
161
+ try {
162
+ boundServer.stop(true);
163
+ } catch {
164
+ // already stopped
165
+ }
166
+ boundServer = null;
167
+ }
168
+ if (tier0Close !== null) {
169
+ try {
170
+ tier0Close();
171
+ } catch {
172
+ // A worker can die before service cleanup reaches it.
173
+ }
174
+ tier0Close = null;
175
+ }
176
+ if (databaseClose !== null) {
177
+ try {
178
+ databaseClose();
179
+ } catch {
180
+ // already closed
181
+ }
182
+ databaseClose = null;
183
+ }
184
+ if (leasesClear !== null) {
185
+ try {
186
+ leasesClear();
187
+ } catch {
188
+ // already cleared
189
+ }
190
+ leasesClear = null;
191
+ }
192
+ releaseLock(lockPath);
193
+ };
194
+
195
+ try {
196
+ // 2. Open the database under lock.
197
+ ensureOwnerOnlyDirectory(dirname(dbPath));
198
+ const db = openDatabase({ databasePath: dbPath });
199
+ databaseClose = () => db.close();
200
+ runMigrations(db);
201
+ const repository = new ArtifactRepository(db, {
202
+ onCommitted: (revision) => {
203
+ logger.info("revision.committed", {
204
+ artifactId: revision.artifactId,
205
+ revisionSha: revision.sha256,
206
+ });
207
+ },
208
+ // Evidence retention runs inside the write path; the root must
209
+ // match the Tier 1 runner's screenshot/console destination so
210
+ // the cleanup unlink lands on the same path the runner wrote.
211
+ evidenceRoot,
212
+ ...(legacyEvidenceRoot !== undefined ? { legacyEvidenceRoot } : {}),
213
+ });
214
+
215
+ // 3. Install token (atomic create under lock). The token store uses
216
+ // write-tmp + rename so a second starter that later wins the lock
217
+ // reads the persisted token; we never cache a token that is not
218
+ // on disk.
219
+ const installTokenStore = createInstallTokenStore({ tokenPath: installTokenPath });
220
+ const installToken = installTokenStore.read();
221
+
222
+ // 4. Promote token (read-only; absence is a typed state).
223
+ const promoteTokenStore = createPromoteTokenStore({ tokenPath: promoteTokenPath });
224
+ const promoteToken = promoteTokenStore.read();
225
+
226
+ // 5. Idle + lease managers.
227
+ let resolveStop: (() => void) | null = null;
228
+ const idle: IdleController = createIdleController({
229
+ idleTimeoutMs,
230
+ onIdle: () => {
231
+ logger.info("service.idle", { idleTimeoutMs });
232
+ options.onIdle?.();
233
+ resolveStop?.();
234
+ },
235
+ });
236
+ const leases: GalleryLeaseManager = createLeaseManager({
237
+ leaseTtlMs,
238
+ onExpire: (entry) => {
239
+ logger.info("lease.expired", { leaseId: entry.leaseId, artifactId: entry.artifactId });
240
+ idle.release(`lease:${entry.leaseId}`);
241
+ },
242
+ });
243
+ leasesClear = () => leases.clear();
244
+
245
+ // 6. Bind Bun.serve on 127.0.0.1:<os-assigned port>. Host guards
246
+ // must reject (not inject) a missing Host header.
247
+ const hostState: { value: string | null } = { value: null };
248
+ // The Tier 0 runner is required because the service is byte-dumb
249
+ // and may not import `src/validation`. The default runner lives
250
+ // there; callers (CLI, tests) construct it externally and inject.
251
+ if (options.tier0Runner === undefined) {
252
+ throw new FacetError(
253
+ "internal",
254
+ "Tier 0 runner is not configured; pass startFacetService({ tier0Runner })",
255
+ { retryable: false },
256
+ );
257
+ }
258
+ const tier0Runner: Tier0Runner = options.tier0Runner;
259
+ const tier1Runner: Tier1Runner | undefined = options.tier1Runner;
260
+ // Write-path → SSE seam: the dispatcher emits the canonical
261
+ // revision:committed event after commit + verdict runs; the
262
+ // broadcaster fans it out to the live gallery streams.
263
+ const broadcaster = createRevisionBroadcaster();
264
+ const router = buildRouter({
265
+ insecureLevel,
266
+ insecureReason: options.insecureReason ?? null,
267
+ ...(tier1Runner !== undefined ? { tier1Runner } : {}),
268
+ repository,
269
+ installToken,
270
+ promoteToken,
271
+ leases,
272
+ idle,
273
+ logger: logger.child("router"),
274
+ expectedHost: () => hostState.value ?? "127.0.0.1:0",
275
+ ownOrigin: () => `http://${hostState.value ?? "127.0.0.1:0"}`,
276
+ startTime: lockStartTime,
277
+ tier0Runner,
278
+ tier1Runner,
279
+ broadcaster,
280
+ heartbeatIntervalMs,
281
+ onPublished: (event) => broadcaster.emit(event),
282
+ });
283
+ let server: ReturnType<typeof Bun.serve>;
284
+ try {
285
+ server = Bun.serve({
286
+ hostname: host,
287
+ port: 0,
288
+ idleTimeout: BUN_SOCKET_IDLE_TIMEOUT_S,
289
+ fetch: (req) => router.fetch(req),
290
+ });
291
+ } catch (error) {
292
+ throw new FacetError("internal", "Failed to bind loopback port", {
293
+ retryable: false,
294
+ cause: error,
295
+ });
296
+ }
297
+ const actualPort = server.port ?? 0;
298
+ if (actualPort === 0) {
299
+ try {
300
+ server.stop(true);
301
+ } catch {
302
+ // ignore
303
+ }
304
+ throw new FacetError("internal", "Failed to bind loopback port", { retryable: false });
305
+ }
306
+ const expectedHost = `${host}:${actualPort}`;
307
+ const ownOrigin = `http://${expectedHost}`;
308
+ hostState.value = expectedHost;
309
+ boundServer = server;
310
+
311
+ // 7. Update lock metadata with the now-known port while still
312
+ // holding the lock. This is the LAST step before serving; a
313
+ // parallel starter that observes this lock sees a live service.
314
+ writeLockMetadata(lockPath, {
315
+ pid: process.pid,
316
+ startTime: lockStartTime,
317
+ port: actualPort,
318
+ contractVersion: "facet.v1",
319
+ });
320
+
321
+ logger.info("service.ready", {
322
+ port: actualPort,
323
+ pid: process.pid,
324
+ ...(insecureLevel > 0
325
+ ? {
326
+ insecureLevel,
327
+ insecureReason: options.insecureReason ?? defaultInsecureReason(insecureLevel),
328
+ }
329
+ : {}),
330
+ });
331
+
332
+ const stopPromise = new Promise<void>((resolve) => {
333
+ resolveStop = () => {
334
+ cleanup();
335
+ logger.info("service.stopped", { port: actualPort });
336
+ resolve();
337
+ };
338
+ });
339
+
340
+ return {
341
+ port: actualPort,
342
+ pid: process.pid,
343
+ url: ownOrigin,
344
+ installToken,
345
+ promoteToken,
346
+ stop: async () => {
347
+ idle.stop();
348
+ await stopPromise;
349
+ },
350
+ waitUntilIdle: () => idle.waitUntilIdle(),
351
+ };
352
+ } catch (error) {
353
+ cleanup();
354
+ throw error;
355
+ }
356
+ }
@@ -0,0 +1,61 @@
1
+ import { chmodSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { Database } from "bun:sqlite";
4
+
5
+ import { FacetStoreError } from "../../shared/errors/store-error";
6
+ import { ensureOwnerOnlyDirectory } from "../../shared/util/dir-permissions";
7
+
8
+ // `FacetStoreError` + `asStoreError` + `StoreErrorCode` live in
9
+ // `shared/errors/store-error.ts` so they can extend `FacetError`
10
+ // without an upward import from `shared/` to `service/`. Re-exported
11
+ // here so existing `import { ... } from "./database"` call sites keep
12
+ // working unchanged.
13
+ export {
14
+ FacetStoreError,
15
+ asStoreError,
16
+ type StoreErrorCode,
17
+ } from "../../shared/errors/store-error";
18
+
19
+ export interface DatabasePaths {
20
+ readonly databasePath?: string;
21
+ readonly dbPath?: string;
22
+ readonly path?: string;
23
+ readonly busyTimeoutMs?: number;
24
+ }
25
+
26
+ export type FacetDatabase = Database;
27
+
28
+ export function openDatabase(paths: DatabasePaths | string): FacetDatabase {
29
+ const config = typeof paths === "string" ? { databasePath: paths } : paths;
30
+ const databasePath = config.databasePath ?? config.dbPath ?? config.path;
31
+ if (!databasePath) throw new FacetStoreError("constraint", "A database path is required");
32
+ if (databasePath !== ":memory:") ensureOwnerOnlyDirectory(dirname(databasePath));
33
+ let db: Database;
34
+ try {
35
+ db = new Database(databasePath, { create: true, strict: true });
36
+ db.exec(
37
+ `PRAGMA journal_mode = WAL; PRAGMA busy_timeout = ${Math.max(1, config.busyTimeoutMs ?? 1_000)}; PRAGMA foreign_keys = ON;`,
38
+ );
39
+ db.query("PRAGMA quick_check").get();
40
+ hardenDatabaseFiles(databasePath);
41
+ } catch (error) {
42
+ throw new FacetStoreError(
43
+ "database_corrupt",
44
+ error instanceof Error ? error.message : String(error),
45
+ { cause: error },
46
+ );
47
+ }
48
+ return db;
49
+ }
50
+
51
+ /** SQLite can recreate WAL sidecars with the process umask; chmod is repeated after writes where paths exist. */
52
+ export function hardenDatabaseFiles(databasePath: string): void {
53
+ if (databasePath === ":memory:") return;
54
+ for (const suffix of ["", "-wal", "-shm"]) {
55
+ try {
56
+ chmodSync(`${databasePath}${suffix}`, 0o600);
57
+ } catch (error) {
58
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Evidence retention policy.
3
+ *
4
+ * One canonical knob (`EVIDENCE_LAST_N_PER_ARTIFACT`) governs how many
5
+ * render_runs + their on-disk evidence files are kept per artifact.
6
+ * The policy runs INSIDE the write path (`recordRenderRun` invokes
7
+ * `enforceEvidenceRetention`) so a fresh publish can never leave the
8
+ * state growing without bound.
9
+ *
10
+ * Retained-evidence carve-out: the `retained` column on `render_runs`
11
+ * marks rows the policy must NOT delete (pinned/template revisions
12
+ * — the call sites set the flag at the pin/template command site).
13
+ * Cleanup walks oldest-first, skipping retained rows; the eviction
14
+ * stops at N non-retained rows.
15
+ *
16
+ * On-disk evidence lives under the XDG-state evidence root (mode
17
+ * 0700). Files referenced by `screenshot_path` and `console_path` are
18
+ * unlinked alongside their row so a non-retained eviction leaves no
19
+ * orphan pixels behind. Failures to unlink are best-effort — the row
20
+ * is the authoritative state; a stale file under the 0700 root is
21
+ * recoverable by the next orphan sweep.
22
+ *
23
+ * The umask-parity mkdir (mkdirSync + stat + chmodSync when the
24
+ * post-mkdir mode disagrees with 0700) is delegated to the shared
25
+ * `ensureOwnerOnlyDirectory` helper in `src/shared/util/dir-permissions.ts`
26
+ * so the service and the validation runner share ONE canonical
27
+ * implementation. A fix in one place lands in both.
28
+ */
29
+
30
+ import { existsSync, rmSync, statSync } from "node:fs";
31
+ import { dirname, join } from "node:path";
32
+
33
+ import type { Database } from "bun:sqlite";
34
+
35
+ import { asStoreError } from "../../shared/errors/store-error";
36
+ import { ensureOwnerOnlyDirectory } from "../../shared/util/dir-permissions";
37
+
38
+ /**
39
+ * Default retention depth per artifact. Tuned to bound the on-disk
40
+ * evidence footprint while preserving enough recent runs to debug a
41
+ * regression: every artifact keeps the last ten runs.
42
+ */
43
+ export const EVIDENCE_LAST_N_PER_ARTIFACT = 10;
44
+
45
+ export interface EnforceRetentionOptions {
46
+ readonly db: Database;
47
+ readonly artifactId: string;
48
+ readonly evidenceRoot: string;
49
+ readonly limit?: number;
50
+ }
51
+
52
+ export function removeUnreferencedEvidence(
53
+ db: Database,
54
+ paths: readonly (string | null | undefined)[],
55
+ ): void {
56
+ const candidates = [...new Set(paths.filter((path): path is string => path != null))];
57
+ if (candidates.length === 0) return;
58
+ let hasCompiledPath = false;
59
+ try {
60
+ const pragma = db.query("PRAGMA table_info(render_runs)");
61
+ if (typeof pragma.all === "function") {
62
+ hasCompiledPath = (pragma.all() as Array<{ name: string }>).some(
63
+ (column) => column.name === "compiled_path",
64
+ );
65
+ }
66
+ } catch {
67
+ hasCompiledPath = false;
68
+ }
69
+ const release = (): void => {
70
+ for (const path of candidates) {
71
+ const referenced = db
72
+ .query(
73
+ hasCompiledPath
74
+ ? "SELECT 1 FROM render_runs WHERE screenshot_path = ? OR console_path = ? OR compiled_path = ? LIMIT 1"
75
+ : "SELECT 1 FROM render_runs WHERE screenshot_path = ? OR console_path = ? LIMIT 1",
76
+ )
77
+ .get(...(hasCompiledPath ? [path, path, path] : [path, path]));
78
+ if (referenced === null || referenced === undefined) unlinkEvidenceFiles(path, null);
79
+ }
80
+ };
81
+ const transaction = (
82
+ db as unknown as { transaction?: (fn: () => void) => { immediate: () => void } }
83
+ ).transaction;
84
+ if (transaction === undefined) release();
85
+ else transaction.call(db, release).immediate();
86
+ }
87
+
88
+ interface RenderRunRow {
89
+ readonly id: string;
90
+ readonly screenshot_path: string | null;
91
+ readonly console_path: string | null;
92
+ readonly compiled_path: string | null;
93
+ readonly retained: number;
94
+ }
95
+
96
+ /**
97
+ * Enforce last-N retention for one artifact. Deletes the oldest
98
+ * non-retained render_runs beyond `limit` AND unlinks their on-disk
99
+ * evidence files. Retained rows are never deleted.
100
+ *
101
+ * Idempotent: a fresh database, or an artifact with fewer than `limit`
102
+ * runs, is a no-op. Safe to call on every write — the query is
103
+ * bounded and `rmSync` is best-effort.
104
+ */
105
+ export function enforceEvidenceRetention(options: EnforceRetentionOptions): void {
106
+ const limit = options.limit ?? EVIDENCE_LAST_N_PER_ARTIFACT;
107
+ try {
108
+ ensureEvidenceRoot(options.evidenceRoot);
109
+ let hasCompiledPath = false;
110
+ try {
111
+ const pragma = options.db.query("PRAGMA table_info(render_runs)");
112
+ if (typeof pragma.all === "function") {
113
+ hasCompiledPath = (pragma.all() as Array<{ name: string }>).some(
114
+ (column) => column.name === "compiled_path",
115
+ );
116
+ }
117
+ } catch {
118
+ hasCompiledPath = false;
119
+ }
120
+ const candidates = options.db
121
+ .query(
122
+ (hasCompiledPath
123
+ ? "SELECT id, screenshot_path, console_path, compiled_path, retained "
124
+ : "SELECT id, screenshot_path, console_path, retained ") +
125
+ "FROM render_runs " +
126
+ "WHERE revision_id IN (SELECT id FROM revisions WHERE artifact_id = ?) " +
127
+ "AND retained = 0 " +
128
+ "ORDER BY finished_at DESC",
129
+ )
130
+ .all(options.artifactId) as RenderRunRow[];
131
+ const evictable = candidates.slice(limit);
132
+ for (const row of evictable) {
133
+ const evict = (): void => {
134
+ options.db.query("DELETE FROM render_runs WHERE id = ?").run(row.id);
135
+ removeUnreferencedEvidence(options.db, [
136
+ row.screenshot_path,
137
+ row.console_path,
138
+ row.compiled_path,
139
+ ]);
140
+ };
141
+ const transaction = (
142
+ options.db as unknown as { transaction?: (fn: () => void) => { immediate: () => void } }
143
+ ).transaction;
144
+ if (transaction === undefined) evict();
145
+ else transaction.call(options.db, evict).immediate();
146
+ }
147
+ } catch (error) {
148
+ throw asStoreError(error);
149
+ }
150
+ }
151
+
152
+ export interface EnsureEvidenceDirectoryOptions {
153
+ readonly evidenceRoot: string;
154
+ readonly artifactId: string;
155
+ readonly revisionSha: string;
156
+ readonly runId: string;
157
+ }
158
+
159
+ export interface EvidenceSubpaths {
160
+ readonly directory: string;
161
+ readonly screenshotPath: string;
162
+ readonly consolePath: string;
163
+ }
164
+
165
+ /**
166
+ * Compute the canonical per-run evidence directory and ensure it
167
+ * exists with mode 0700. Returns the deterministic paths callers
168
+ * write to. The directory shape is:
169
+ *
170
+ * <evidenceRoot>/<artifactId>/<revisionSha>/<runId>/
171
+ * screenshot.png
172
+ * console.txt
173
+ *
174
+ * Deterministic-by-content lets a future orphan sweep reconstruct
175
+ * paths from the DB without a side index.
176
+ */
177
+ export function ensureRunEvidenceDirectory(
178
+ options: EnsureEvidenceDirectoryOptions,
179
+ ): EvidenceSubpaths {
180
+ const directory = join(
181
+ options.evidenceRoot,
182
+ options.artifactId,
183
+ options.revisionSha,
184
+ options.runId,
185
+ );
186
+ ensureOwnerOnlyDirectory(directory);
187
+ return {
188
+ directory,
189
+ screenshotPath: join(directory, "screenshot.png"),
190
+ consolePath: join(directory, "console.txt"),
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Ensure the evidence directory exists with mode 0700. Idempotent —
196
+ * a pre-existing directory is rechmodded so the canonical secret-
197
+ * bearing layout survives every startup.
198
+ */
199
+ export function ensureEvidenceRoot(directory: string): void {
200
+ ensureOwnerOnlyDirectory(directory);
201
+ }
202
+
203
+ function unlinkEvidenceFiles(
204
+ screenshotPath: string | null,
205
+ consolePath: string | null,
206
+ compiledPath: string | null = null,
207
+ ): void {
208
+ for (const path of [screenshotPath, consolePath, compiledPath]) {
209
+ if (path === null) continue;
210
+ unlinkIfExists(path);
211
+ // Walk up parent directories that became empty after the unlink.
212
+ let parent = dirname(path);
213
+ while (parent.length > 1) {
214
+ try {
215
+ const stat = statSync(parent);
216
+ if (!stat.isDirectory()) break;
217
+ const entries = (require("node:fs") as typeof import("node:fs")).readdirSync(parent);
218
+ if (entries.length > 0) break;
219
+ rmSync(parent, { recursive: true, force: true });
220
+ } catch {
221
+ break;
222
+ }
223
+ parent = dirname(parent);
224
+ }
225
+ }
226
+ }
227
+
228
+ function unlinkIfExists(path: string): void {
229
+ try {
230
+ if (existsSync(path)) rmSync(path, { force: true });
231
+ } catch {
232
+ // best-effort — the row is authoritative, a stale file is recoverable
233
+ }
234
+ }