@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.
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +23 -0
- package/README.md +158 -0
- package/dist/gallery/chunk-4g8rem85.css +656 -0
- package/dist/gallery/chunk-c6s9k9ty.js +5970 -0
- package/dist/gallery/frame/artifact.css +8424 -0
- package/dist/gallery/frame/chunks/abnfDiagram-N423BO3Z-jj74szbm.js +155 -0
- package/dist/gallery/frame/chunks/architecture-TIHT7OUA-cvdjn7dt.js +10 -0
- package/dist/gallery/frame/chunks/architectureDiagram-T3A2C74G-n4z77eg6.js +8675 -0
- package/dist/gallery/frame/chunks/blockDiagram-VBNYF7ZC-7xsqzrd1.js +3529 -0
- package/dist/gallery/frame/chunks/c4Diagram-5PPSVZJV-z2zjs7wx.js +2413 -0
- package/dist/gallery/frame/chunks/classDiagram-JCYQIIEL-ve8rngqp.js +47 -0
- package/dist/gallery/frame/chunks/classDiagram-v2-OCEON4UE-8cfaxb8z.js +47 -0
- package/dist/gallery/frame/chunks/cose-bilkent-JH36ORCC-sn92qexx.js +4822 -0
- package/dist/gallery/frame/chunks/cynefin-VYW2F7L2-tqjv913d.js +10 -0
- package/dist/gallery/frame/chunks/cynefinDiagram-MW4NZA55-c1c0dj12.js +500 -0
- package/dist/gallery/frame/chunks/dagre-VZM6K2ZE-7tyg4yt2.js +476 -0
- package/dist/gallery/frame/chunks/diagram-7IWD3JNH-y4eng9sc.js +526 -0
- package/dist/gallery/frame/chunks/diagram-B4RE2ZJO-rn3p7j5h.js +673 -0
- package/dist/gallery/frame/chunks/diagram-LBJQPF4R-w4a2cm9q.js +255 -0
- package/dist/gallery/frame/chunks/diagram-Q27KOJAE-aqc2v7wy.js +575 -0
- package/dist/gallery/frame/chunks/diagram-UB23O5K3-xncy8nyd.js +355 -0
- package/dist/gallery/frame/chunks/ebnfDiagram-BXEA7PRR-acwgxz16.js +175 -0
- package/dist/gallery/frame/chunks/erDiagram-JOGREHBK-mzp5nswt.js +1332 -0
- package/dist/gallery/frame/chunks/eventmodeling-45OFAUF4-sg07qr0c.js +10 -0
- package/dist/gallery/frame/chunks/flowDiagram-UKHOOZJN-2e5ygs44.js +29 -0
- package/dist/gallery/frame/chunks/ganttDiagram-PKOTCBZU-p2hhh7hr.js +2620 -0
- package/dist/gallery/frame/chunks/gitGraph-TEB2WS4Q-j6kw0naa.js +10 -0
- package/dist/gallery/frame/chunks/gitGraphDiagram-DS77QQ5N-4z9z8wjm.js +1347 -0
- package/dist/gallery/frame/chunks/info-DKCQHKI2-mpt80jd3.js +10 -0
- package/dist/gallery/frame/chunks/infoDiagram-6WML65LV-5rqm3mgg.js +65 -0
- package/dist/gallery/frame/chunks/ishikawaDiagram-WSZJBQD7-b51z96jy.js +975 -0
- package/dist/gallery/frame/chunks/journeyDiagram-NVQOT4AX-9jbfp0pk.js +1272 -0
- package/dist/gallery/frame/chunks/kanban-definition-27J2QSJJ-4x0vx4se.js +1116 -0
- package/dist/gallery/frame/chunks/katex-xfgk95tv.js +14034 -0
- package/dist/gallery/frame/chunks/markdown-1s5vwkf2.js +9 -0
- package/dist/gallery/frame/chunks/markdown-2h6bwsms.js +3239 -0
- package/dist/gallery/frame/chunks/markdown-2x113yh8.js +107 -0
- package/dist/gallery/frame/chunks/markdown-30b7rwmp.js +1943 -0
- package/dist/gallery/frame/chunks/markdown-356bfj4g.js +2031 -0
- package/dist/gallery/frame/chunks/markdown-44n9ea69.js +2406 -0
- package/dist/gallery/frame/chunks/markdown-56enrf2r.js +39 -0
- package/dist/gallery/frame/chunks/markdown-5avymm8t.js +725 -0
- package/dist/gallery/frame/chunks/markdown-6r4svars.js +33 -0
- package/dist/gallery/frame/chunks/markdown-6zz8efx2.js +24 -0
- package/dist/gallery/frame/chunks/markdown-7jp6prnb.js +19 -0
- package/dist/gallery/frame/chunks/markdown-809k7q41.js +55 -0
- package/dist/gallery/frame/chunks/markdown-82xmzhzd.js +1076 -0
- package/dist/gallery/frame/chunks/markdown-92n37gd4.js +7627 -0
- package/dist/gallery/frame/chunks/markdown-ag2sssf8.js +36 -0
- package/dist/gallery/frame/chunks/markdown-b4s7ntq2.js +19 -0
- package/dist/gallery/frame/chunks/markdown-c9k0fvp1.js +100 -0
- package/dist/gallery/frame/chunks/markdown-ceefymn8.js +125 -0
- package/dist/gallery/frame/chunks/markdown-d1wfbs70.js +243 -0
- package/dist/gallery/frame/chunks/markdown-dy5sgjhc.js +11022 -0
- package/dist/gallery/frame/chunks/markdown-dz3z5w30.js +30868 -0
- package/dist/gallery/frame/chunks/markdown-eg6ngz7y.js +30340 -0
- package/dist/gallery/frame/chunks/markdown-ff5wvb43.js +1149 -0
- package/dist/gallery/frame/chunks/markdown-ffg06nfz.js +86 -0
- package/dist/gallery/frame/chunks/markdown-fvfncadf.js +472 -0
- package/dist/gallery/frame/chunks/markdown-gvyszqpv.js +19 -0
- package/dist/gallery/frame/chunks/markdown-gx1p0ew0.js +88 -0
- package/dist/gallery/frame/chunks/markdown-gxye5frz.js +65 -0
- package/dist/gallery/frame/chunks/markdown-h7pzv34e.js +105 -0
- package/dist/gallery/frame/chunks/markdown-hab789t9.js +47 -0
- package/dist/gallery/frame/chunks/markdown-hjp6sj8t.js +125 -0
- package/dist/gallery/frame/chunks/markdown-jka64qgp.js +1993 -0
- package/dist/gallery/frame/chunks/markdown-k8xkn8mw.js +56 -0
- package/dist/gallery/frame/chunks/markdown-m7frq8x8.js +112 -0
- package/dist/gallery/frame/chunks/markdown-p740v4qm.js +36 -0
- package/dist/gallery/frame/chunks/markdown-pxfeh231.js +57 -0
- package/dist/gallery/frame/chunks/markdown-pzgd8z3s.js +87 -0
- package/dist/gallery/frame/chunks/markdown-qx3cngz2.js +1673 -0
- package/dist/gallery/frame/chunks/markdown-rawhfvvc.js +85 -0
- package/dist/gallery/frame/chunks/markdown-rd8w0ng9.js +49 -0
- package/dist/gallery/frame/chunks/markdown-rm2yxfjj.js +2773 -0
- package/dist/gallery/frame/chunks/markdown-s1jatrnk.js +993 -0
- package/dist/gallery/frame/chunks/markdown-sgfg5vvq.js +59 -0
- package/dist/gallery/frame/chunks/markdown-t4ethv9d.js +409 -0
- package/dist/gallery/frame/chunks/markdown-tnmabqvz.js +36 -0
- package/dist/gallery/frame/chunks/markdown-tvdpppw8.js +5292 -0
- package/dist/gallery/frame/chunks/markdown-v0x27twr.js +22 -0
- package/dist/gallery/frame/chunks/markdown-v14td68x.js +36 -0
- package/dist/gallery/frame/chunks/markdown-w6sgd54z.js +36 -0
- package/dist/gallery/frame/chunks/markdown-xw8dsk8b.js +2125 -0
- package/dist/gallery/frame/chunks/markdown-y18mve73.js +85 -0
- package/dist/gallery/frame/chunks/markdown-yengsms5.js +427 -0
- package/dist/gallery/frame/chunks/markdown-z1wc0v0d.js +1826 -0
- package/dist/gallery/frame/chunks/markdown-zazx9h4n.js +329 -0
- package/dist/gallery/frame/chunks/mermaid-de7p4v0x.js +30 -0
- package/dist/gallery/frame/chunks/mindmap-definition-FAOFIHXS-ra44cbem.js +1216 -0
- package/dist/gallery/frame/chunks/packet-7NZHBO7P-xppm5913.js +10 -0
- package/dist/gallery/frame/chunks/pegDiagram-VL7TDLO6-a2a4wg03.js +163 -0
- package/dist/gallery/frame/chunks/pie-RZYD4A2V-hnbxk152.js +10 -0
- package/dist/gallery/frame/chunks/pieDiagram-7S7Q4E2Y-f1vxjtmx.js +311 -0
- package/dist/gallery/frame/chunks/quadrantDiagram-CIZ2JOQS-ywafa6kh.js +1390 -0
- package/dist/gallery/frame/chunks/radar-I7S5WNFK-ewqd27se.js +10 -0
- package/dist/gallery/frame/chunks/railroad-3IZDKUUU-5hy4bdx3.js +10 -0
- package/dist/gallery/frame/chunks/railroad-abnf-AHOZXSZD-bh5sxz0d.js +10 -0
- package/dist/gallery/frame/chunks/railroad-ebnf-EBAXGLYW-6b9c3zeh.js +10 -0
- package/dist/gallery/frame/chunks/railroad-peg-LSFZ7HO6-avc8v39s.js +10 -0
- package/dist/gallery/frame/chunks/railroadDiagram-AXF67PYL-qc61wax4.js +131 -0
- package/dist/gallery/frame/chunks/requirementDiagram-LRYGKXZP-9bs4786c.js +1295 -0
- package/dist/gallery/frame/chunks/sankeyDiagram-W5VNT64P-awt1em8b.js +1339 -0
- package/dist/gallery/frame/chunks/sequenceDiagram-SI44F4Z6-qaf9mz3t.js +4324 -0
- package/dist/gallery/frame/chunks/sizeCapture-X5ZJPWSS-9fq685xd.js +64 -0
- package/dist/gallery/frame/chunks/stateDiagram-OKZ733FA-vx7a7hh0.js +456 -0
- package/dist/gallery/frame/chunks/stateDiagram-v2-UEYNNEHI-x4m6ws5t.js +46 -0
- package/dist/gallery/frame/chunks/swimlanes-SLNWSIFB-e4mxm1gh.js +8287 -0
- package/dist/gallery/frame/chunks/swimlanesDiagram-ULZ7WXOC-0h93e2qq.js +42 -0
- package/dist/gallery/frame/chunks/timeline-definition-Z64GVDOM-wp0bhbzg.js +1549 -0
- package/dist/gallery/frame/chunks/treeView-QDETBFTQ-hcx7n0vt.js +10 -0
- package/dist/gallery/frame/chunks/treemap-6X3UGDF4-av02skdw.js +10 -0
- package/dist/gallery/frame/chunks/vennDiagram-T6HMQDX7-pmvk1p7n.js +2566 -0
- package/dist/gallery/frame/chunks/wardley-OPB4EBWU-njsp6e40.js +10 -0
- package/dist/gallery/frame/chunks/wardleyDiagram-T6FBY63Y-eggmnv4z.js +979 -0
- package/dist/gallery/frame/chunks/xychartDiagram-ELKLHX3M-q40tnpe5.js +1954 -0
- package/dist/gallery/frame/frame.css +183 -0
- package/dist/gallery/frame/runtime/chart.js +42744 -0
- package/dist/gallery/frame/runtime/html.js +14 -0
- package/dist/gallery/frame/runtime/markdown.js +1403 -0
- package/dist/gallery/frame/runtime/mermaid.js +32 -0
- package/dist/gallery/frame/runtime/svg.js +14 -0
- package/dist/gallery/frame/runtime/tsx.js +52 -0
- package/dist/gallery/index.html +104 -0
- package/docs/reference/cli.md +163 -0
- package/docs/reference/export.md +106 -0
- package/docs/reference/html.md +215 -0
- package/docs/reference/http.md +85 -0
- package/docs/reference/mcp.md +78 -0
- package/docs/reference/security.md +119 -0
- package/docs/reference/storage.md +93 -0
- package/docs/reference/tsx.md +101 -0
- package/docs/reference/validation.md +212 -0
- package/package.json +89 -0
- package/scripts/build-gallery.ts +127 -0
- package/scripts/launch-netns.sh +67 -0
- package/skills/facet/SKILL.md +100 -0
- package/src/cli/client.ts +311 -0
- package/src/cli/commands/create.ts +49 -0
- package/src/cli/commands/doctor.ts +249 -0
- package/src/cli/commands/export.ts +179 -0
- package/src/cli/commands/instantiate.ts +35 -0
- package/src/cli/commands/list.ts +49 -0
- package/src/cli/commands/open.ts +75 -0
- package/src/cli/commands/pin.ts +39 -0
- package/src/cli/commands/promote.ts +49 -0
- package/src/cli/commands/publish.ts +122 -0
- package/src/cli/commands/read-back.ts +56 -0
- package/src/cli/commands/status.ts +114 -0
- package/src/cli/commands/watch.ts +146 -0
- package/src/cli/main.ts +522 -0
- package/src/cli/output.ts +106 -0
- package/src/cli/parser.ts +376 -0
- package/src/cli/presenter.ts +212 -0
- package/src/cli/service-metadata.ts +134 -0
- package/src/cli/spawn-service.ts +326 -0
- package/src/css.d.ts +4 -0
- package/src/gallery-web/app.ts +1581 -0
- package/src/gallery-web/export-menu.ts +121 -0
- package/src/gallery-web/export.ts +112 -0
- package/src/gallery-web/favicon.ts +54 -0
- package/src/gallery-web/frame/entries/chart.ts +6 -0
- package/src/gallery-web/frame/entries/html.ts +6 -0
- package/src/gallery-web/frame/entries/markdown.ts +6 -0
- package/src/gallery-web/frame/entries/mermaid.ts +6 -0
- package/src/gallery-web/frame/entries/svg.ts +6 -0
- package/src/gallery-web/frame/entries/tsx.ts +6 -0
- package/src/gallery-web/frame/frame-payload.ts +91 -0
- package/src/gallery-web/frame/renderer-validation.ts +13 -0
- package/src/gallery-web/frame/renderers/chart.ts +182 -0
- package/src/gallery-web/frame/renderers/dompurify-shim.ts +112 -0
- package/src/gallery-web/frame/renderers/html.ts +48 -0
- package/src/gallery-web/frame/renderers/markdown.ts +81 -0
- package/src/gallery-web/frame/renderers/mermaid.ts +120 -0
- package/src/gallery-web/frame/renderers/registry.ts +260 -0
- package/src/gallery-web/frame/renderers/svg.ts +393 -0
- package/src/gallery-web/frame/renderers/tsx.ts +61 -0
- package/src/gallery-web/frame/runtime.ts +501 -0
- package/src/gallery-web/frame/styles/artifact.css +8424 -0
- package/src/gallery-web/frame/styles/frame.css +183 -0
- package/src/gallery-web/frame/styles/html-source.css +23 -0
- package/src/gallery-web/frame/view-box.ts +33 -0
- package/src/gallery-web/frame-html.ts +72 -0
- package/src/gallery-web/index.html +104 -0
- package/src/gallery-web/session.ts +107 -0
- package/src/gallery-web/sse-client.ts +212 -0
- package/src/gallery-web/styles/app.css +420 -0
- package/src/gallery-web/styles/tokens.css +112 -0
- package/src/gallery-web/styles/verdict.css +186 -0
- package/src/gallery-web/swap.ts +64 -0
- package/src/gallery-web/theme.ts +40 -0
- package/src/gallery-web/view-state.ts +93 -0
- package/src/harness-adapters/claude-code/README.md +10 -0
- package/src/harness-adapters/claude-code/facet.sh +4 -0
- package/src/harness-adapters/codex/README.md +10 -0
- package/src/harness-adapters/codex/facet.sh +4 -0
- package/src/harness-adapters/mcp/README.md +13 -0
- package/src/harness-adapters/mcp/cli-bridge.ts +216 -0
- package/src/harness-adapters/mcp/main.ts +15 -0
- package/src/harness-adapters/mcp/server.ts +164 -0
- package/src/harness-adapters/mcp/tool-schemas.ts +53 -0
- package/src/harness-adapters/opencode/README.md +10 -0
- package/src/harness-adapters/opencode/facet.sh +4 -0
- package/src/runtime/compiled-entrypoints.ts +69 -0
- package/src/service/dispatcher.ts +717 -0
- package/src/service/export.ts +218 -0
- package/src/service/http-utils.ts +55 -0
- package/src/service/lexical/expectations.ts +210 -0
- package/src/service/lifecycle/idle-controller.ts +125 -0
- package/src/service/lifecycle/orphan-cleanup.ts +128 -0
- package/src/service/lifecycle/process-lock.ts +235 -0
- package/src/service/main.ts +136 -0
- package/src/service/process.ts +240 -0
- package/src/service/router-guards.ts +239 -0
- package/src/service/router.ts +627 -0
- package/src/service/security/auth.ts +168 -0
- package/src/service/security/host-origin.ts +138 -0
- package/src/service/security/http-guards.ts +13 -0
- package/src/service/security/leases.ts +182 -0
- package/src/service/security/token-store.ts +138 -0
- package/src/service/server.ts +356 -0
- package/src/service/store/database.ts +61 -0
- package/src/service/store/evidence-retention.ts +234 -0
- package/src/service/store/migrations.ts +167 -0
- package/src/service/store/repository-lifecycle.ts +121 -0
- package/src/service/store/repository.ts +732 -0
- package/src/service/store/schema.ts +218 -0
- package/src/service/stored-verdict.ts +97 -0
- package/src/service/stream.ts +301 -0
- package/src/service/verdict-enrichment.ts +45 -0
- package/src/shared/build/frame-bundle-plugins.ts +61 -0
- package/src/shared/build-mode.ts +28 -0
- package/src/shared/config/evidence-read.ts +98 -0
- package/src/shared/config/limits.ts +56 -0
- package/src/shared/config/paths.ts +96 -0
- package/src/shared/contracts/artifact-types.ts +2 -0
- package/src/shared/contracts/artifact.ts +98 -0
- package/src/shared/contracts/commands/_shared.ts +59 -0
- package/src/shared/contracts/commands/guards.ts +86 -0
- package/src/shared/contracts/commands/index.ts +204 -0
- package/src/shared/contracts/commands/names.ts +35 -0
- package/src/shared/contracts/commands/requests.ts +149 -0
- package/src/shared/contracts/commands/results.ts +200 -0
- package/src/shared/contracts/envelope.ts +140 -0
- package/src/shared/contracts/events.ts +41 -0
- package/src/shared/contracts/observed-counts.ts +19 -0
- package/src/shared/contracts/renderers.ts +6 -0
- package/src/shared/contracts/validation.ts +296 -0
- package/src/shared/errors/facet-error.ts +127 -0
- package/src/shared/errors/store-error.ts +68 -0
- package/src/shared/evidence-image.ts +37 -0
- package/src/shared/export.ts +31 -0
- package/src/shared/html/policy.ts +108 -0
- package/src/shared/html/style-vocabulary.ts +352 -0
- package/src/shared/logging/logger.ts +108 -0
- package/src/shared/logging/redact.ts +49 -0
- package/src/shared/security/frozen-csp.ts +13 -0
- package/src/shared/storage-version.ts +2 -0
- package/src/shared/tsx/execution.ts +29 -0
- package/src/shared/tsx/import-policy.ts +94 -0
- package/src/shared/util/dir-permissions.ts +94 -0
- package/src/shared/util/mermaid-nodes.ts +230 -0
- package/src/shared/util/process.ts +67 -0
- package/src/shared/util/time.ts +21 -0
- package/src/shared/version.ts +3 -0
- package/src/validation/sandbox/limits.ts +41 -0
- package/src/validation/sandbox/netns.ts +177 -0
- package/src/validation/tier0/chart.ts +177 -0
- package/src/validation/tier0/dom-shim.ts +54 -0
- package/src/validation/tier0/html.ts +408 -0
- package/src/validation/tier0/markdown.ts +285 -0
- package/src/validation/tier0/mermaid.ts +98 -0
- package/src/validation/tier0/runner.ts +504 -0
- package/src/validation/tier0/svg.ts +321 -0
- package/src/validation/tier0/tsx/allowlist-resolver.ts +77 -0
- package/src/validation/tier0/tsx/ast-policy-capabilities.ts +238 -0
- package/src/validation/tier0/tsx/ast-policy-shared.ts +71 -0
- package/src/validation/tier0/tsx/ast-policy.ts +181 -0
- package/src/validation/tier0/tsx/compiler.ts +156 -0
- package/src/validation/tier0/worker-dispatch.ts +211 -0
- package/src/validation/tier0/worker-entry.ts +96 -0
- package/src/validation/tier0/worker-input.ts +75 -0
- package/src/validation/tier1/browser-process.ts +147 -0
- package/src/validation/tier1/cdp-pipe.ts +281 -0
- package/src/validation/tier1/entries/chart.ts +5 -0
- package/src/validation/tier1/entries/html.ts +6 -0
- package/src/validation/tier1/entries/markdown.ts +5 -0
- package/src/validation/tier1/entries/mermaid.ts +5 -0
- package/src/validation/tier1/entries/svg.ts +5 -0
- package/src/validation/tier1/entries/tsx.ts +5 -0
- package/src/validation/tier1/frame-target.ts +162 -0
- package/src/validation/tier1/harness-entry.ts +261 -0
- package/src/validation/tier1/harness.ts +215 -0
- package/src/validation/tier1/isolated-probe.ts +94 -0
- package/src/validation/tier1/launcher.ts +177 -0
- package/src/validation/tier1/limits.ts +80 -0
- package/src/validation/tier1/nonce.ts +17 -0
- package/src/validation/tier1/protocol-probe.ts +519 -0
- package/src/validation/tier1/runner.ts +1122 -0
- package/src/validation/tier1/verdict.ts +246 -0
- package/src/validation/tier1/webp.ts +69 -0
- package/templates/README.md +88 -0
- package/templates/bar-compare.vl.json +63 -0
- package/templates/capacity-report.tsx +102 -0
- package/templates/decision-record.md +24 -0
- package/templates/deployment-state.mmd +40 -0
- package/templates/exemplar.md +91 -0
- package/templates/facet-story.tsx +799 -0
- package/templates/fleet-dashboard.html +141 -0
- package/templates/html-release-ledger.html +91 -0
- package/templates/html-status-report.html +114 -0
- package/templates/incident-console.tsx +147 -0
- package/templates/legion-boundaries.mmd +23 -0
- package/templates/legion-flow.mmd +15 -0
- package/templates/legion-sequence.mmd +14 -0
- package/templates/legion-state.mmd +7 -0
- package/templates/metric-card.svg +29 -0
- package/templates/observability-map.svg +101 -0
- package/templates/pipeline-audit.md +108 -0
- package/templates/release-metrics.vl.json +98 -0
- package/templates/service-topology.mmd +47 -0
- package/templates/status-report.md +32 -0
- package/templates/system-map.svg +53 -0
- package/templates/timeseries.vl.json +71 -0
- package/templates/tsx-interactive-counter.tsx +19 -0
- 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
|
+
}
|