@wrenai/genbi 0.0.1

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 (286) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +141 -0
  3. package/bin/genbi.mjs +31 -0
  4. package/dist/assets/echarts-LTFC-ysd.js +40 -0
  5. package/dist/assets/index-Bo--QMUN.js +441 -0
  6. package/dist/assets/index-Xs4q7pNo.css +1 -0
  7. package/dist/index.html +14 -0
  8. package/dist/wrenai_logo.png +0 -0
  9. package/dist/wrenai_logo_white.png +0 -0
  10. package/dist-server/harness/auth/detect.d.ts +18 -0
  11. package/dist-server/harness/auth/detect.js +37 -0
  12. package/dist-server/harness/auth/index.d.ts +4 -0
  13. package/dist-server/harness/auth/index.js +2 -0
  14. package/dist-server/harness/auth/probe.d.ts +18 -0
  15. package/dist-server/harness/auth/probe.js +52 -0
  16. package/dist-server/harness/auth/types.d.ts +42 -0
  17. package/dist-server/harness/auth/types.js +9 -0
  18. package/dist-server/harness/bundle/loader.d.ts +32 -0
  19. package/dist-server/harness/bundle/loader.js +43 -0
  20. package/dist-server/harness/bundle/schema.d.ts +199 -0
  21. package/dist-server/harness/bundle/schema.js +127 -0
  22. package/dist-server/harness/bundle/version.d.ts +10 -0
  23. package/dist-server/harness/bundle/version.js +43 -0
  24. package/dist-server/harness/capability/gate.d.ts +6 -0
  25. package/dist-server/harness/capability/gate.js +29 -0
  26. package/dist-server/harness/capability/registry.d.ts +6 -0
  27. package/dist-server/harness/capability/registry.js +17 -0
  28. package/dist-server/harness/cli-args.d.ts +123 -0
  29. package/dist-server/harness/cli-args.js +247 -0
  30. package/dist-server/harness/cli.d.ts +2 -0
  31. package/dist-server/harness/cli.js +151 -0
  32. package/dist-server/harness/compile/cache.d.ts +23 -0
  33. package/dist-server/harness/compile/cache.js +109 -0
  34. package/dist-server/harness/compile/compose-profile.d.ts +34 -0
  35. package/dist-server/harness/compile/compose-profile.js +95 -0
  36. package/dist-server/harness/compile/errors.d.ts +22 -0
  37. package/dist-server/harness/compile/errors.js +41 -0
  38. package/dist-server/harness/compile/fingerprint.d.ts +22 -0
  39. package/dist-server/harness/compile/fingerprint.js +84 -0
  40. package/dist-server/harness/compile/index.d.ts +8 -0
  41. package/dist-server/harness/compile/index.js +7 -0
  42. package/dist-server/harness/compile/pipeline.d.ts +66 -0
  43. package/dist-server/harness/compile/pipeline.js +176 -0
  44. package/dist-server/harness/compile/resolve-binary.d.ts +55 -0
  45. package/dist-server/harness/compile/resolve-binary.js +133 -0
  46. package/dist-server/harness/compile/types.d.ts +107 -0
  47. package/dist-server/harness/compile/types.js +1 -0
  48. package/dist-server/harness/compile/warble-identity.d.ts +12 -0
  49. package/dist-server/harness/compile/warble-identity.js +33 -0
  50. package/dist-server/harness/compliance/gate.d.ts +35 -0
  51. package/dist-server/harness/compliance/gate.js +48 -0
  52. package/dist-server/harness/compliance/index.d.ts +2 -0
  53. package/dist-server/harness/compliance/index.js +1 -0
  54. package/dist-server/harness/compliance/types.d.ts +16 -0
  55. package/dist-server/harness/compliance/types.js +1 -0
  56. package/dist-server/harness/events/emitter.d.ts +34 -0
  57. package/dist-server/harness/events/emitter.js +25 -0
  58. package/dist-server/harness/events/index.d.ts +4 -0
  59. package/dist-server/harness/events/index.js +2 -0
  60. package/dist-server/harness/events/sse.d.ts +20 -0
  61. package/dist-server/harness/events/sse.js +48 -0
  62. package/dist-server/harness/events/types.d.ts +131 -0
  63. package/dist-server/harness/events/types.js +1 -0
  64. package/dist-server/harness/exec/errors.d.ts +16 -0
  65. package/dist-server/harness/exec/errors.js +29 -0
  66. package/dist-server/harness/exec/index.d.ts +5 -0
  67. package/dist-server/harness/exec/index.js +2 -0
  68. package/dist-server/harness/exec/local.d.ts +32 -0
  69. package/dist-server/harness/exec/local.js +134 -0
  70. package/dist-server/harness/exec/policy.d.ts +12 -0
  71. package/dist-server/harness/exec/policy.js +1 -0
  72. package/dist-server/harness/exec/types.d.ts +63 -0
  73. package/dist-server/harness/exec/types.js +1 -0
  74. package/dist-server/harness/guardrails/index.d.ts +2 -0
  75. package/dist-server/harness/guardrails/index.js +1 -0
  76. package/dist-server/harness/guardrails/policy.d.ts +45 -0
  77. package/dist-server/harness/guardrails/policy.js +57 -0
  78. package/dist-server/harness/index.d.ts +37 -0
  79. package/dist-server/harness/index.js +19 -0
  80. package/dist-server/harness/loop/errors.d.ts +53 -0
  81. package/dist-server/harness/loop/errors.js +72 -0
  82. package/dist-server/harness/loop/executor.d.ts +123 -0
  83. package/dist-server/harness/loop/executor.js +380 -0
  84. package/dist-server/harness/loop/guard.d.ts +16 -0
  85. package/dist-server/harness/loop/guard.js +1 -0
  86. package/dist-server/harness/loop/index.d.ts +5 -0
  87. package/dist-server/harness/loop/index.js +3 -0
  88. package/dist-server/harness/native-producer/index.d.ts +109 -0
  89. package/dist-server/harness/native-producer/index.js +302 -0
  90. package/dist-server/harness/npm-package-resolve.d.ts +28 -0
  91. package/dist-server/harness/npm-package-resolve.js +47 -0
  92. package/dist-server/harness/providers/adapters/anthropic.d.ts +7 -0
  93. package/dist-server/harness/providers/adapters/anthropic.js +6 -0
  94. package/dist-server/harness/providers/adapters/mock.d.ts +4 -0
  95. package/dist-server/harness/providers/adapters/mock.js +5 -0
  96. package/dist-server/harness/providers/adapters/openai-compatible.d.ts +10 -0
  97. package/dist-server/harness/providers/adapters/openai-compatible.js +10 -0
  98. package/dist-server/harness/providers/binding.d.ts +23 -0
  99. package/dist-server/harness/providers/binding.js +13 -0
  100. package/dist-server/harness/providers/errors.d.ts +6 -0
  101. package/dist-server/harness/providers/errors.js +12 -0
  102. package/dist-server/harness/providers/index.d.ts +18 -0
  103. package/dist-server/harness/providers/index.js +22 -0
  104. package/dist-server/harness/providers/registry.d.ts +16 -0
  105. package/dist-server/harness/providers/registry.js +24 -0
  106. package/dist-server/harness/render/envelope.d.ts +145 -0
  107. package/dist-server/harness/render/envelope.js +428 -0
  108. package/dist-server/harness/render/errors.d.ts +33 -0
  109. package/dist-server/harness/render/errors.js +46 -0
  110. package/dist-server/harness/render/index.d.ts +5 -0
  111. package/dist-server/harness/render/index.js +3 -0
  112. package/dist-server/harness/render/validate.d.ts +13 -0
  113. package/dist-server/harness/render/validate.js +88 -0
  114. package/dist-server/harness/route/adapter-spec.d.ts +40 -0
  115. package/dist-server/harness/route/adapter-spec.js +61 -0
  116. package/dist-server/harness/route/agent-sdk-cli.d.ts +30 -0
  117. package/dist-server/harness/route/agent-sdk-cli.js +86 -0
  118. package/dist-server/harness/route/agent-sdk-manifest.d.ts +31 -0
  119. package/dist-server/harness/route/agent-sdk-manifest.js +56 -0
  120. package/dist-server/harness/route/artifact-content.d.ts +53 -0
  121. package/dist-server/harness/route/artifact-content.js +83 -0
  122. package/dist-server/harness/route/chat-event-mapper.d.ts +95 -0
  123. package/dist-server/harness/route/chat-event-mapper.js +164 -0
  124. package/dist-server/harness/route/codex-ask-events.d.ts +25 -0
  125. package/dist-server/harness/route/codex-ask-events.js +395 -0
  126. package/dist-server/harness/route/codex-ask.d.ts +15 -0
  127. package/dist-server/harness/route/codex-ask.js +242 -0
  128. package/dist-server/harness/route/codex-local-cli.d.ts +20 -0
  129. package/dist-server/harness/route/codex-local-cli.js +78 -0
  130. package/dist-server/harness/route/codex-local-manifest.d.ts +22 -0
  131. package/dist-server/harness/route/codex-local-manifest.js +180 -0
  132. package/dist-server/harness/route/describe.d.ts +54 -0
  133. package/dist-server/harness/route/describe.js +84 -0
  134. package/dist-server/harness/route/dispatched.d.ts +138 -0
  135. package/dist-server/harness/route/dispatched.js +339 -0
  136. package/dist-server/harness/route/in-process.d.ts +43 -0
  137. package/dist-server/harness/route/in-process.js +106 -0
  138. package/dist-server/harness/route/index.d.ts +19 -0
  139. package/dist-server/harness/route/index.js +12 -0
  140. package/dist-server/harness/route/profile-source.d.ts +37 -0
  141. package/dist-server/harness/route/profile-source.js +76 -0
  142. package/dist-server/harness/route/route.d.ts +33 -0
  143. package/dist-server/harness/route/route.js +114 -0
  144. package/dist-server/harness/route/tier-binding.d.ts +36 -0
  145. package/dist-server/harness/route/tier-binding.js +63 -0
  146. package/dist-server/harness/route/types.d.ts +286 -0
  147. package/dist-server/harness/route/types.js +1 -0
  148. package/dist-server/harness/session/errors.d.ts +4 -0
  149. package/dist-server/harness/session/errors.js +7 -0
  150. package/dist-server/harness/session/gate.d.ts +11 -0
  151. package/dist-server/harness/session/gate.js +13 -0
  152. package/dist-server/harness/session/index.d.ts +5 -0
  153. package/dist-server/harness/session/index.js +3 -0
  154. package/dist-server/harness/session/run.d.ts +60 -0
  155. package/dist-server/harness/session/run.js +333 -0
  156. package/dist-server/harness/session/types.d.ts +29 -0
  157. package/dist-server/harness/session/types.js +1 -0
  158. package/dist-server/harness/setup/codex-events.d.ts +12 -0
  159. package/dist-server/harness/setup/codex-events.js +108 -0
  160. package/dist-server/harness/setup/runner.d.ts +497 -0
  161. package/dist-server/harness/setup/runner.js +1314 -0
  162. package/dist-server/harness/tools/errors.d.ts +107 -0
  163. package/dist-server/harness/tools/errors.js +141 -0
  164. package/dist-server/harness/tools/index.d.ts +13 -0
  165. package/dist-server/harness/tools/index.js +8 -0
  166. package/dist-server/harness/tools/mcp.d.ts +26 -0
  167. package/dist-server/harness/tools/mcp.js +15 -0
  168. package/dist-server/harness/tools/native.d.ts +179 -0
  169. package/dist-server/harness/tools/native.js +322 -0
  170. package/dist-server/harness/tools/parse.d.ts +14 -0
  171. package/dist-server/harness/tools/parse.js +18 -0
  172. package/dist-server/harness/tools/resolve-wren-binary.d.ts +12 -0
  173. package/dist-server/harness/tools/resolve-wren-binary.js +21 -0
  174. package/dist-server/harness/tools/resolve.d.ts +51 -0
  175. package/dist-server/harness/tools/resolve.js +79 -0
  176. package/dist-server/harness/tools/setup-native.d.ts +84 -0
  177. package/dist-server/harness/tools/setup-native.js +353 -0
  178. package/dist-server/harness/tools/wiring.d.ts +10 -0
  179. package/dist-server/harness/tools/wiring.js +16 -0
  180. package/dist-server/providers/setup.provider.yaml +31 -0
  181. package/dist-server/providers/wren.provider.yaml +33 -0
  182. package/dist-server/server/adopt.d.ts +87 -0
  183. package/dist-server/server/adopt.js +263 -0
  184. package/dist-server/server/app.d.ts +6 -0
  185. package/dist-server/server/app.js +2521 -0
  186. package/dist-server/server/auth-choice.d.ts +16 -0
  187. package/dist-server/server/auth-choice.js +36 -0
  188. package/dist-server/server/bin.d.ts +2 -0
  189. package/dist-server/server/bin.js +601 -0
  190. package/dist-server/server/bootstrap-project-binding.d.ts +31 -0
  191. package/dist-server/server/bootstrap-project-binding.js +71 -0
  192. package/dist-server/server/clarify.d.ts +10 -0
  193. package/dist-server/server/clarify.js +17 -0
  194. package/dist-server/server/codex-setup-mcp.d.ts +2 -0
  195. package/dist-server/server/codex-setup-mcp.js +147 -0
  196. package/dist-server/server/compose.d.ts +89 -0
  197. package/dist-server/server/compose.js +271 -0
  198. package/dist-server/server/conn-config.d.ts +30 -0
  199. package/dist-server/server/conn-config.js +253 -0
  200. package/dist-server/server/context-files.d.ts +12 -0
  201. package/dist-server/server/context-files.js +117 -0
  202. package/dist-server/server/context-map.d.ts +30 -0
  203. package/dist-server/server/context-map.js +95 -0
  204. package/dist-server/server/context-source.d.ts +68 -0
  205. package/dist-server/server/context-source.js +85 -0
  206. package/dist-server/server/db.d.ts +862 -0
  207. package/dist-server/server/db.js +1839 -0
  208. package/dist-server/server/enrichment-runner.d.ts +184 -0
  209. package/dist-server/server/enrichment-runner.js +213 -0
  210. package/dist-server/server/enrichment-verify.d.ts +73 -0
  211. package/dist-server/server/enrichment-verify.js +290 -0
  212. package/dist-server/server/enrichment.d.ts +232 -0
  213. package/dist-server/server/enrichment.js +199 -0
  214. package/dist-server/server/env-detect.d.ts +8 -0
  215. package/dist-server/server/env-detect.js +17 -0
  216. package/dist-server/server/env-field-hints.d.ts +46 -0
  217. package/dist-server/server/env-field-hints.js +48 -0
  218. package/dist-server/server/fold.d.ts +133 -0
  219. package/dist-server/server/fold.js +407 -0
  220. package/dist-server/server/harness-profile-sources.d.ts +3 -0
  221. package/dist-server/server/harness-profile-sources.js +24 -0
  222. package/dist-server/server/harness.d.ts +19 -0
  223. package/dist-server/server/harness.js +439 -0
  224. package/dist-server/server/impact.d.ts +18 -0
  225. package/dist-server/server/impact.js +85 -0
  226. package/dist-server/server/interactive-terminal.d.ts +175 -0
  227. package/dist-server/server/interactive-terminal.js +336 -0
  228. package/dist-server/server/native-artifacts.d.ts +1004 -0
  229. package/dist-server/server/native-artifacts.js +564 -0
  230. package/dist-server/server/native-dispatch-registry.d.ts +42 -0
  231. package/dist-server/server/native-dispatch-registry.js +45 -0
  232. package/dist-server/server/native-producer-fence.d.ts +14 -0
  233. package/dist-server/server/native-producer-fence.js +48 -0
  234. package/dist-server/server/native-session-resume.d.ts +6 -0
  235. package/dist-server/server/native-session-resume.js +103 -0
  236. package/dist-server/server/native-session-workspace.d.ts +40 -0
  237. package/dist-server/server/native-session-workspace.js +175 -0
  238. package/dist-server/server/native-sessions.d.ts +374 -0
  239. package/dist-server/server/native-sessions.js +1517 -0
  240. package/dist-server/server/native-wren-runtime.d.ts +20 -0
  241. package/dist-server/server/native-wren-runtime.js +259 -0
  242. package/dist-server/server/node-pty-host.d.ts +31 -0
  243. package/dist-server/server/node-pty-host.js +100 -0
  244. package/dist-server/server/route-intent.d.ts +40 -0
  245. package/dist-server/server/route-intent.js +23 -0
  246. package/dist-server/server/runtime-binding.d.ts +52 -0
  247. package/dist-server/server/runtime-binding.js +258 -0
  248. package/dist-server/server/source-catalog.d.ts +84 -0
  249. package/dist-server/server/source-catalog.js +221 -0
  250. package/dist-server/server/source-driver.d.ts +35 -0
  251. package/dist-server/server/source-driver.js +124 -0
  252. package/dist-server/server/spa.d.ts +2 -0
  253. package/dist-server/server/spa.js +51 -0
  254. package/dist-server/server/subscription-model-catalog.d.ts +27 -0
  255. package/dist-server/server/subscription-model-catalog.js +203 -0
  256. package/dist-server/server/turn.d.ts +249 -0
  257. package/dist-server/server/turn.js +1074 -0
  258. package/dist-server/server/wire-types.d.ts +799 -0
  259. package/dist-server/server/wire-types.js +1 -0
  260. package/dist-server/server/wren-home.d.ts +19 -0
  261. package/dist-server/server/wren-home.js +33 -0
  262. package/dist-server/server/wren-profiles.d.ts +43 -0
  263. package/dist-server/server/wren-profiles.js +123 -0
  264. package/package.json +93 -0
  265. package/profiles/genbi-default/context/binding.yml +9 -0
  266. package/profiles/genbi-default/ir.golden.json +506 -0
  267. package/profiles/genbi-default/profile.yml +16 -0
  268. package/profiles/genbi-enrich-context/components/apply_enrichment/component.yml +42 -0
  269. package/profiles/genbi-enrich-context/components/draft_enrichment/component.yml +30 -0
  270. package/profiles/genbi-enrich-context/components/draft_enrichment/steps/draft.md +34 -0
  271. package/profiles/genbi-enrich-context/components/inspect_context/component.yml +32 -0
  272. package/profiles/genbi-enrich-context/components/inspect_context/steps/inspect.md +13 -0
  273. package/profiles/genbi-enrich-context/context/binding.yml +4 -0
  274. package/profiles/genbi-enrich-context/ir.golden.json +302 -0
  275. package/profiles/genbi-enrich-context/profile.yml +15 -0
  276. package/profiles/genbi-monitor/context/binding.yml +7 -0
  277. package/profiles/genbi-monitor/ir.golden.json +188 -0
  278. package/profiles/genbi-monitor/profile.yml +27 -0
  279. package/profiles/genbi-setup/components/build_context/component.yml +44 -0
  280. package/profiles/genbi-setup/components/build_context/steps/build.md +14 -0
  281. package/profiles/genbi-setup/components/connect_source/component.yml +51 -0
  282. package/profiles/genbi-setup/components/connect_source/steps/connect.md +23 -0
  283. package/profiles/genbi-setup/context/binding.yml +8 -0
  284. package/profiles/genbi-setup/ir.golden.json +140 -0
  285. package/profiles/genbi-setup/profile.yml +18 -0
  286. package/profiles/genbi-setup/raw/schema.json +5 -0
@@ -0,0 +1,1839 @@
1
+ /**
2
+ * Single-file SQLite storage for the BFF, via Node's
3
+ * built-in `node:sqlite` (experimental as of Node 23; no flag required). One
4
+ * `Store` per process; pass `":memory:"` in tests, a real file path in
5
+ * `server/bin.ts`.
6
+ *
7
+ * The schema has two small additive columns beyond the original design, each
8
+ * added for a concrete reason (see inline comments on `events.turn_id` and
9
+ * `turns.error_message` below); every other table and column is exactly as
10
+ * originally specified.
11
+ *
12
+ * `node:sqlite`'s `SQLInputValue` has no native boolean — every boolean
13
+ * column (`artifacts.verified`, `eval_runs.gate_pass`) is stored as `0`/`1`
14
+ * and converted back on read (`intToBool`/`boolToInt`).
15
+ */
16
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
17
+ import { DatabaseSync } from "node:sqlite";
18
+ import { hashEnrichmentOperation, normalizeEnrichmentConfidence } from "./enrichment.js";
19
+ import { resolveNativeRuntimeBinding } from "./native-dispatch-registry.js";
20
+ export function newId(prefix) {
21
+ return `${prefix}-${randomUUID()}`;
22
+ }
23
+ /** Retained answers are intentionally bounded without a global cleanup job. */
24
+ export const MAX_NATIVE_STRUCTURED_ANSWERS_PER_SESSION = 32;
25
+ const SCHEMA_SQL = `
26
+ CREATE TABLE IF NOT EXISTS sessions (
27
+ id TEXT PRIMARY KEY, title TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
28
+ status TEXT NOT NULL, -- 'active' | 'awaiting_clarify' | 'streaming' | 'awaiting_decision'
29
+ pending_question TEXT,
30
+ pending_decision TEXT -- JSON-serialized PendingDecisionPayload, set with status = 'awaiting_decision'
31
+ );
32
+ CREATE TABLE IF NOT EXISTS events (
33
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, seq INTEGER NOT NULL, kind TEXT NOT NULL,
34
+ payload_json TEXT NOT NULL, created_at TEXT NOT NULL,
35
+ -- additive: scopes an event to the turn whose execution produced it, so a
36
+ -- resolved-turn SSE replay can select exactly its own answer/refusal/
37
+ -- artifact event without also replaying the turn's own leading 'user'
38
+ -- event or a sibling turn's events. NULL for 'published' events, which
39
+ -- aren't tied to any single turn's execution.
40
+ turn_id TEXT
41
+ );
42
+ CREATE TABLE IF NOT EXISTS turns (
43
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, question TEXT NOT NULL, composed_input TEXT,
44
+ backend TEXT, result_kind TEXT, answer_summary TEXT, trace_json TEXT, created_at TEXT NOT NULL,
45
+ -- additive: the terminal error message when result_kind = 'error', so a
46
+ -- resumed SSE stream for an already-errored turn can replay the exact
47
+ -- 'error' frame without re-invoking route().
48
+ error_message TEXT,
49
+ -- additive: the intent router's chosen agent id
50
+ -- (classifyIntent, computed once at postTurn time), persisted so
51
+ -- executeTurn passes it to route() and a later SSE replay of an already-
52
+ -- resolved turn never has to re-classify (or re-invoke route() at all).
53
+ -- NULL for turns created before this column existed.
54
+ agent_id TEXT
55
+ -- additive: Plan A session resume anchor (resume_session_id), added via
56
+ -- migrateSchema() below rather than here so an existing on-disk DB still
57
+ -- opens cleanly — see addColumnIfMissing("turns", "resume_session_id", ...).
58
+ );
59
+ CREATE TABLE IF NOT EXISTS artifacts (
60
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, name TEXT NOT NULL, kind TEXT NOT NULL,
61
+ location TEXT NOT NULL, verified INTEGER, created_at TEXT NOT NULL,
62
+ -- NULL until the user explicitly saves this artifact from the Ask
63
+ -- thread; listArtifacts() filters on it so auto-created artifacts don't
64
+ -- appear on the Artifacts page until saved. Also added via migrateSchema()
65
+ -- below for an existing on-disk DB file predating this column.
66
+ saved_at TEXT,
67
+ native_session_id TEXT, project_identity TEXT, binding_generation INTEGER, project_revision TEXT,
68
+ native_vendor TEXT, native_agent TEXT, content_digest TEXT, idempotency_key TEXT,
69
+ source_answer_id TEXT
70
+ );
71
+ CREATE TABLE IF NOT EXISTS publications (
72
+ artifact_id TEXT PRIMARY KEY, link TEXT NOT NULL, scope TEXT NOT NULL, created_at TEXT NOT NULL
73
+ );
74
+ CREATE TABLE IF NOT EXISTS eval_runs (
75
+ id TEXT PRIMARY KEY, when_ts TEXT NOT NULL, score REAL NOT NULL, gate_threshold REAL NOT NULL,
76
+ gate_pass INTEGER NOT NULL, regressions INTEGER NOT NULL, cost TEXT NOT NULL, p50 TEXT NOT NULL,
77
+ component_scores_json TEXT NOT NULL
78
+ );
79
+ CREATE TABLE IF NOT EXISTS config (
80
+ key TEXT PRIMARY KEY, value_json TEXT NOT NULL
81
+ );
82
+ -- Operational enrichment state is deliberately host-owned and outside the
83
+ -- project tree. These rows contain only safe ids, hashes, summaries and
84
+ -- digests; raw source material and runner/session anchors are never stored.
85
+ CREATE TABLE IF NOT EXISTS enrichment_runs (
86
+ id TEXT PRIMARY KEY, mode TEXT NOT NULL, project_path TEXT NOT NULL,
87
+ project_identity TEXT NOT NULL DEFAULT '', project_revision TEXT NOT NULL, proposal_id TEXT NOT NULL, proposal_hash TEXT NOT NULL,
88
+ status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
89
+ validation_digest TEXT, build_digest TEXT, error_message TEXT, binding_generation INTEGER NOT NULL DEFAULT 0,
90
+ version INTEGER NOT NULL DEFAULT 1
91
+ );
92
+ CREATE TABLE IF NOT EXISTS enrichment_operations (
93
+ run_id TEXT NOT NULL, operation_id TEXT NOT NULL, sink TEXT NOT NULL, risk TEXT NOT NULL,
94
+ summary TEXT NOT NULL, draft TEXT NOT NULL DEFAULT '', change_kind TEXT NOT NULL DEFAULT 'knowledge_append', confidence TEXT NOT NULL, decision TEXT, completed INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT 'awaiting_decision', attempt INTEGER NOT NULL DEFAULT 0, lease_token TEXT, lease_expires_at TEXT, idempotency_key TEXT NOT NULL DEFAULT '',
95
+ PRIMARY KEY (run_id, operation_id)
96
+ );
97
+ CREATE TABLE IF NOT EXISTS enrichment_approvals (
98
+ run_id TEXT NOT NULL, operation_id TEXT NOT NULL, project_revision TEXT NOT NULL,
99
+ proposal_hash TEXT NOT NULL, risk TEXT NOT NULL, attested_at TEXT NOT NULL,
100
+ project_path TEXT NOT NULL DEFAULT '', project_identity TEXT NOT NULL DEFAULT '', binding_generation INTEGER NOT NULL DEFAULT 0,
101
+ operation_hash TEXT NOT NULL DEFAULT '', sink TEXT NOT NULL DEFAULT '', change_kind TEXT NOT NULL DEFAULT '',
102
+ evidence_ref TEXT NOT NULL DEFAULT '', nonce TEXT NOT NULL DEFAULT '', expires_at TEXT NOT NULL DEFAULT '',
103
+ PRIMARY KEY (run_id, operation_id)
104
+ );
105
+ CREATE TABLE IF NOT EXISTS enrichment_events (
106
+ id TEXT PRIMARY KEY, run_id TEXT NOT NULL, kind TEXT NOT NULL, message TEXT NOT NULL, created_at TEXT NOT NULL
107
+ );
108
+ -- Native Sessions are a control-plane namespace. They intentionally do not
109
+ -- share Ask session identity and never store terminal bytes or input.
110
+ CREATE TABLE IF NOT EXISTS native_sessions (
111
+ id TEXT PRIMARY KEY, purpose TEXT NOT NULL, vendor TEXT NOT NULL, agent TEXT NOT NULL,
112
+ scope_kind TEXT NOT NULL, scope_id TEXT NOT NULL,
113
+ project_identity TEXT, binding_generation INTEGER, project_revision TEXT,
114
+ dispatch_profile TEXT, dispatch_target TEXT, runtime_generation INTEGER,
115
+ status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
116
+ started_at TEXT, ended_at TEXT, exit_code INTEGER, failure TEXT
117
+ );
118
+ CREATE INDEX IF NOT EXISTS native_sessions_updated_at ON native_sessions(updated_at DESC);
119
+ -- Provider conversation identity is an opaque, sealed server-side value. It
120
+ -- deliberately has no foreign-key cascade: historical session rows remain a
121
+ -- truthful record even after their one-shot resume authority is consumed.
122
+ CREATE TABLE IF NOT EXISTS native_session_resume_handles (
123
+ session_id TEXT PRIMARY KEY, provider TEXT NOT NULL, sealed_handle TEXT NOT NULL,
124
+ consumed_at TEXT
125
+ );
126
+ CREATE TABLE IF NOT EXISTS native_session_resume_actions (
127
+ session_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, scope_fingerprint TEXT NOT NULL,
128
+ resumed_session_id TEXT NOT NULL, created_at TEXT NOT NULL,
129
+ PRIMARY KEY (session_id, idempotency_key)
130
+ );
131
+ -- Setup recovery stores an intentionally closed, redacted projection. It has
132
+ -- no terminal bytes, prompt, credential, capability, path, or tool payload.
133
+ CREATE TABLE IF NOT EXISTS native_setup_recoveries (
134
+ session_id TEXT PRIMARY KEY,
135
+ phase TEXT NOT NULL, state TEXT NOT NULL, code TEXT NOT NULL,
136
+ sequence INTEGER NOT NULL, decision TEXT,
137
+ completion_validated INTEGER NOT NULL DEFAULT 0,
138
+ version INTEGER NOT NULL,
139
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
140
+ );
141
+ -- A browser-only recovery action secret is never stored raw. A per-row salt
142
+ -- and verifier survive BFF restart so an interrupted session can be retried.
143
+ CREATE TABLE IF NOT EXISTS native_setup_recovery_actions (
144
+ session_id TEXT PRIMARY KEY,
145
+ salt TEXT NOT NULL, verifier TEXT NOT NULL, claimed_at TEXT
146
+ );
147
+ -- A native answer is retained only as its typed render envelope. Native
148
+ -- sessions still never store terminal bytes, prompts, or transcripts.
149
+ CREATE TABLE IF NOT EXISTS native_structured_answers (
150
+ id TEXT PRIMARY KEY,
151
+ native_session_id TEXT NOT NULL REFERENCES native_sessions(id) ON DELETE CASCADE,
152
+ idempotency_key TEXT NOT NULL,
153
+ envelope_json TEXT NOT NULL,
154
+ digest TEXT NOT NULL,
155
+ created_at TEXT NOT NULL,
156
+ UNIQUE(native_session_id, idempotency_key)
157
+ );
158
+ CREATE INDEX IF NOT EXISTS native_structured_answers_session_created_at
159
+ ON native_structured_answers(native_session_id, created_at DESC);
160
+ `;
161
+ function boolToInt(value) {
162
+ return value ? 1 : 0;
163
+ }
164
+ function intToBool(value) {
165
+ return Number(value) === 1;
166
+ }
167
+ function str(row, key) {
168
+ const value = row[key];
169
+ if (typeof value !== "string") {
170
+ throw new TypeError(`expected column "${key}" to be a string, got ${typeof value}`);
171
+ }
172
+ return value;
173
+ }
174
+ function strOrNull(row, key) {
175
+ const value = row[key];
176
+ return typeof value === "string" ? value : null;
177
+ }
178
+ function num(row, key) {
179
+ const value = row[key];
180
+ if (typeof value !== "number" && typeof value !== "bigint") {
181
+ throw new TypeError(`expected column "${key}" to be numeric, got ${typeof value}`);
182
+ }
183
+ return Number(value);
184
+ }
185
+ function recoveryCapabilityVerifier(salt, capability) {
186
+ return createHash("sha256").update(`${salt}:${capability}`, "utf8").digest("hex");
187
+ }
188
+ function sameRecoveryCapability(expected, actual) {
189
+ const expectedBytes = Buffer.from(expected, "hex");
190
+ const actualBytes = Buffer.from(actual, "hex");
191
+ return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes);
192
+ }
193
+ /**
194
+ * The setup wizard's initial config, seeded on first init and restored by
195
+ * `resetSetup()`. Kept as module consts so "seed" and "reset" can never drift.
196
+ */
197
+ const SEED_RUNTIME_SETTINGS = {
198
+ authMode: "subscription",
199
+ subscriptionProvider: "claude",
200
+ tierModels: [
201
+ // Tier identities come from the compiled runtime contract. Models are
202
+ // deliberately blank: a provider catalog is a suggestion, never a
203
+ // product-owned selection on the user's behalf.
204
+ { tier: "cheap", model: "" },
205
+ { tier: "strong", model: "" },
206
+ ],
207
+ hybrid: false,
208
+ deployment: "personal",
209
+ };
210
+ const SEED_SETUP_STEPS = [
211
+ { key: "runtime", title: "Runtime & auth", state: "current" },
212
+ { key: "connect", title: "Connect a warehouse", state: "todo" },
213
+ { key: "context", title: "Build context", state: "todo" },
214
+ { key: "bind", title: "Compile & bind", state: "todo" },
215
+ { key: "ask", title: "Ask questions", state: "todo" },
216
+ ];
217
+ /**
218
+ * Invariant guard: a later step in the sequence must never be `"done"` while an
219
+ * earlier one is still `"todo"` — that combination reads to the wizard sidebar as "you're on
220
+ * step 5 but haven't started step 2", which is never a state any single setup-step transition
221
+ * should intentionally produce. Every call site that flips a step to `"done"` is expected to
222
+ * also advance its own predecessors, but this coerces any earlier `"todo"` forward as a
223
+ * last-line-of-defense so a missed call site degrades to a merely-optimistic "done" rather than
224
+ * a visibly broken sidebar. Only touches `"todo"` — a `"current"` step earlier in the array is
225
+ * left alone (that combination shouldn't arise, and silently completing an in-flight step out
226
+ * from under the caller would be a worse failure mode than leaving it visible).
227
+ */
228
+ function coerceOrphanedTodoSteps(steps) {
229
+ let sawDoneAfter = false;
230
+ const result = new Array(steps.length);
231
+ for (let i = steps.length - 1; i >= 0; i--) {
232
+ const step = steps[i];
233
+ if (step.state === "done") {
234
+ sawDoneAfter = true;
235
+ result[i] = step;
236
+ }
237
+ else if (sawDoneAfter && step.state === "todo") {
238
+ result[i] = { ...step, state: "done" };
239
+ }
240
+ else {
241
+ result[i] = step;
242
+ }
243
+ }
244
+ return result;
245
+ }
246
+ export class Store {
247
+ db;
248
+ onContextSuccessWrite;
249
+ onEnrichmentTransitionWrite;
250
+ onEnrichmentCreationWrite;
251
+ now;
252
+ constructor(path, options = {}) {
253
+ this.db = new DatabaseSync(path);
254
+ this.onContextSuccessWrite = options.onContextSuccessWrite;
255
+ this.onEnrichmentTransitionWrite = options.onEnrichmentTransitionWrite;
256
+ this.onEnrichmentCreationWrite = options.onEnrichmentCreationWrite;
257
+ this.now = options.now ?? (() => new Date());
258
+ this.db.exec("PRAGMA foreign_keys = ON");
259
+ this.db.exec(SCHEMA_SQL);
260
+ this.migrateSchema();
261
+ this.seedIfEmpty();
262
+ this.reconcileOrphanedSetupTurns();
263
+ this.reconcileOrphanedEnrichmentRuns();
264
+ this.reconcileOrphanedNativeSessions();
265
+ }
266
+ /**
267
+ * A setup turn is executed only by the owning BFF process. If that process
268
+ * exits, a NULL result cannot still represent running work after restart.
269
+ * Convert only setup turns (never ordinary Ask turns) to a bounded, public
270
+ * recovery error. The old trace is deliberately discarded: it may predate
271
+ * the setup-boundary redaction rules and must not be replayed to the UI.
272
+ */
273
+ reconcileOrphanedSetupTurns() {
274
+ this.db.exec("BEGIN IMMEDIATE");
275
+ try {
276
+ const orphanSessions = this.db
277
+ .prepare(`SELECT DISTINCT session_id FROM turns WHERE result_kind IS NULL AND setup_step_key IS NOT NULL`)
278
+ .all()
279
+ .map((row) => str(row, "session_id"));
280
+ if (orphanSessions.length === 0) {
281
+ this.db.exec("COMMIT");
282
+ return;
283
+ }
284
+ const message = "a previous setup execution was interrupted by a BFF restart; Continue & repair to inspect the preserved project and resume safely";
285
+ this.db
286
+ .prepare(`UPDATE turns SET backend = NULL, result_kind = 'error', answer_summary = NULL, trace_json = '[]', error_message = ? WHERE result_kind IS NULL AND setup_step_key IS NOT NULL`)
287
+ .run(message);
288
+ // A stale streaming status otherwise leaves reload hydration presenting
289
+ // an indefinitely-running turn even though the row is now recoverable.
290
+ const clearStreaming = this.db.prepare(`UPDATE sessions SET status = 'active', pending_question = NULL, updated_at = ? WHERE id = ? AND status = 'streaming'`);
291
+ for (const sessionId of orphanSessions)
292
+ clearStreaming.run(this.now().toISOString(), sessionId);
293
+ this.db.exec("COMMIT");
294
+ }
295
+ catch (error) {
296
+ this.db.exec("ROLLBACK");
297
+ throw error;
298
+ }
299
+ }
300
+ /**
301
+ * An enrichment draft is executed only by the owning BFF process. If that
302
+ * process exits mid-draft, a 'drafting' row cannot still represent running
303
+ * work after restart -- convert it to a terminal, classifiable state
304
+ * (mirrors `reconcileOrphanedSetupTurns` above) so the UI never shows a
305
+ * run stuck at "in progress" forever after a crash or redeploy.
306
+ */
307
+ reconcileOrphanedEnrichmentRuns() {
308
+ this.db.exec("BEGIN IMMEDIATE");
309
+ try {
310
+ const orphanRunIds = this.db
311
+ .prepare(`SELECT id FROM enrichment_runs WHERE status = 'drafting'`)
312
+ .all()
313
+ .map((row) => str(row, "id"));
314
+ if (orphanRunIds.length === 0) {
315
+ this.db.exec("COMMIT");
316
+ return;
317
+ }
318
+ const now = this.now().toISOString();
319
+ const message = "a previous enrichment draft was interrupted by a BFF restart; start a new enrichment run";
320
+ this.db
321
+ .prepare(`UPDATE enrichment_runs SET error_message = ?, updated_at = ?, version = version + 1, status = 'failed' WHERE status = 'drafting'`)
322
+ .run(message, now);
323
+ const insertEvent = this.db.prepare(`INSERT INTO enrichment_events (id, run_id, kind, message, created_at) VALUES (?, ?, 'failed', ?, ?)`);
324
+ for (const runId of orphanRunIds)
325
+ insertEvent.run(newId("enrichment-event"), runId, "Enrichment draft failed.", now);
326
+ this.db.exec("COMMIT");
327
+ }
328
+ catch (error) {
329
+ this.db.exec("ROLLBACK");
330
+ throw error;
331
+ }
332
+ }
333
+ /** A PTY belongs to this BFF process, so it cannot survive a restart. */
334
+ reconcileOrphanedNativeSessions() {
335
+ const now = this.now().toISOString();
336
+ this.db.prepare(`UPDATE native_sessions SET status = 'interrupted', failure = ?, ended_at = ?, updated_at = ?
337
+ WHERE status IN ('creating', 'running', 'detached')`).run("native session interrupted by BFF restart", now, now);
338
+ }
339
+ /**
340
+ * Additive, idempotent migrations for columns added after a DB file may
341
+ * already exist on disk. `CREATE TABLE IF NOT EXISTS` above only defines
342
+ * the schema for a BRAND NEW file — an existing sqlite file's `turns`
343
+ * table predates `agent_id` and needs it added explicitly. Guarded by a
344
+ * `PRAGMA table_info` check so re-running this on an already-migrated (or
345
+ * brand-new, already-current) file is a no-op rather than an error.
346
+ */
347
+ migrateSchema() {
348
+ this.addColumnIfMissing("turns", "agent_id", "TEXT");
349
+ this.addColumnIfMissing("turns", "setup_step_key", "TEXT");
350
+ this.addColumnIfMissing("turns", "resume_session_id", "TEXT");
351
+ this.addColumnIfMissing("turns", "resume_session_provider", "TEXT");
352
+ this.addColumnIfMissing("turns", "resume_runner", "TEXT");
353
+ this.addColumnIfMissing("turns", "workspace_root", "TEXT");
354
+ this.addColumnIfMissing("turns", "context_recovery", "TEXT");
355
+ this.addColumnIfMissing("sessions", "pending_decision", "TEXT");
356
+ this.addColumnIfMissing("artifacts", "saved_at", "TEXT");
357
+ this.addColumnIfMissing("artifacts", "native_session_id", "TEXT");
358
+ this.addColumnIfMissing("artifacts", "project_identity", "TEXT");
359
+ this.addColumnIfMissing("artifacts", "binding_generation", "INTEGER");
360
+ this.addColumnIfMissing("artifacts", "project_revision", "TEXT");
361
+ this.addColumnIfMissing("artifacts", "native_vendor", "TEXT");
362
+ this.addColumnIfMissing("artifacts", "native_agent", "TEXT");
363
+ this.addColumnIfMissing("artifacts", "content_digest", "TEXT");
364
+ this.addColumnIfMissing("artifacts", "idempotency_key", "TEXT");
365
+ this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS artifacts_native_idempotency_unique ON artifacts(native_session_id, idempotency_key) WHERE native_session_id IS NOT NULL AND idempotency_key IS NOT NULL");
366
+ this.addColumnIfMissing("enrichment_operations", "draft", "TEXT NOT NULL DEFAULT ''");
367
+ this.addColumnIfMissing("enrichment_operations", "change_kind", "TEXT NOT NULL DEFAULT 'knowledge_append'");
368
+ this.addColumnIfMissing("enrichment_operations", "state", "TEXT NOT NULL DEFAULT 'awaiting_decision'");
369
+ this.addColumnIfMissing("enrichment_operations", "attempt", "INTEGER NOT NULL DEFAULT 0");
370
+ this.addColumnIfMissing("enrichment_operations", "lease_token", "TEXT");
371
+ this.addColumnIfMissing("enrichment_operations", "lease_expires_at", "TEXT");
372
+ this.addColumnIfMissing("enrichment_operations", "idempotency_key", "TEXT NOT NULL DEFAULT ''");
373
+ this.db.prepare(`UPDATE enrichment_operations SET idempotency_key = run_id || ':' || operation_id WHERE idempotency_key = ''`).run();
374
+ this.addColumnIfMissing("enrichment_runs", "binding_generation", "INTEGER NOT NULL DEFAULT 0");
375
+ this.addColumnIfMissing("enrichment_runs", "project_identity", "TEXT NOT NULL DEFAULT ''");
376
+ this.addColumnIfMissing("enrichment_runs", "version", "INTEGER NOT NULL DEFAULT 1");
377
+ this.addColumnIfMissing("enrichment_approvals", "project_path", "TEXT NOT NULL DEFAULT ''");
378
+ this.addColumnIfMissing("enrichment_approvals", "project_identity", "TEXT NOT NULL DEFAULT ''");
379
+ this.addColumnIfMissing("enrichment_approvals", "binding_generation", "INTEGER NOT NULL DEFAULT 0");
380
+ this.addColumnIfMissing("enrichment_approvals", "operation_hash", "TEXT NOT NULL DEFAULT ''");
381
+ this.addColumnIfMissing("enrichment_approvals", "sink", "TEXT NOT NULL DEFAULT ''");
382
+ this.addColumnIfMissing("enrichment_approvals", "change_kind", "TEXT NOT NULL DEFAULT ''");
383
+ this.addColumnIfMissing("enrichment_approvals", "evidence_ref", "TEXT NOT NULL DEFAULT ''");
384
+ this.addColumnIfMissing("enrichment_approvals", "nonce", "TEXT NOT NULL DEFAULT ''");
385
+ this.addColumnIfMissing("enrichment_approvals", "expires_at", "TEXT NOT NULL DEFAULT ''");
386
+ this.addColumnIfMissing("native_sessions", "dispatch_profile", "TEXT");
387
+ this.addColumnIfMissing("native_sessions", "dispatch_target", "TEXT");
388
+ this.addColumnIfMissing("native_sessions", "runtime_generation", "INTEGER");
389
+ this.addColumnIfMissing("artifacts", "source_answer_id", "TEXT");
390
+ this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS enrichment_approvals_nonce_unique ON enrichment_approvals(nonce) WHERE nonce <> ''");
391
+ }
392
+ addColumnIfMissing(table, column, type) {
393
+ const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
394
+ const exists = columns.some((row) => str(row, "name") === column);
395
+ if (!exists) {
396
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
397
+ }
398
+ }
399
+ close() {
400
+ this.db.close();
401
+ }
402
+ // ---------------------------------------------------------------------
403
+ // sessions
404
+ // ---------------------------------------------------------------------
405
+ createSession(title) {
406
+ const now = this.now().toISOString();
407
+ const row = { id: newId("session"), title, createdAt: now, updatedAt: now, status: "active", pendingQuestion: null, pendingDecision: null };
408
+ this.db
409
+ .prepare(`INSERT INTO sessions (id, title, created_at, updated_at, status, pending_question, pending_decision) VALUES (?, ?, ?, ?, ?, ?, ?)`)
410
+ .run(row.id, row.title, row.createdAt, row.updatedAt, row.status, row.pendingQuestion, row.pendingDecision);
411
+ return row;
412
+ }
413
+ // ---------------------------------------------------------------------
414
+ // native Sessions control plane (never Ask sessions / never transcript)
415
+ // ---------------------------------------------------------------------
416
+ createNativeSession(params) {
417
+ const now = this.now().toISOString();
418
+ this.db.prepare(`INSERT INTO native_sessions (id, purpose, vendor, agent, scope_kind, scope_id, project_identity, binding_generation, project_revision, dispatch_profile, dispatch_target, runtime_generation, status, created_at, updated_at, started_at, ended_at, exit_code, failure)
419
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'creating', ?, ?, NULL, NULL, NULL, NULL)`).run(params.id, params.purpose, params.vendor, params.agent, params.scopeKind, params.scopeId, params.projectIdentity ?? null, params.bindingGeneration ?? null, params.projectRevision ?? null, params.dispatchProfile ?? null, params.dispatchTarget ?? null, params.runtimeGeneration ?? null, now, now);
420
+ return this.getNativeSession(params.id);
421
+ }
422
+ /** Stores a sealed provider handle only; callers must never pass plaintext. */
423
+ saveNativeSessionResumeHandle(sessionId, provider, sealedHandle) {
424
+ this.db.prepare(`INSERT INTO native_session_resume_handles (session_id, provider, sealed_handle, consumed_at)
425
+ VALUES (?, ?, ?, NULL)
426
+ ON CONFLICT(session_id) DO UPDATE SET provider = excluded.provider, sealed_handle = excluded.sealed_handle, consumed_at = NULL`).run(sessionId, provider, sealedHandle);
427
+ }
428
+ getNativeSessionResumeHandle(sessionId) {
429
+ const row = this.db.prepare(`SELECT * FROM native_session_resume_handles WHERE session_id = ?`).get(sessionId);
430
+ return row ? {
431
+ sessionId: str(row, "session_id"), provider: str(row, "provider"),
432
+ sealedHandle: str(row, "sealed_handle"), consumedAt: strOrNull(row, "consumed_at"),
433
+ } : undefined;
434
+ }
435
+ /** Browser-safe availability bit; the opaque sealed value never leaves Store. */
436
+ hasAvailableNativeSessionResume(sessionId, provider) {
437
+ return this.db.prepare(`SELECT 1 FROM native_session_resume_handles WHERE session_id = ? AND provider = ? AND consumed_at IS NULL`).get(sessionId, provider) !== undefined;
438
+ }
439
+ getNativeSessionResumeAction(sourceSessionId, idempotencyKey) {
440
+ const row = this.db.prepare(`SELECT session_id, idempotency_key, scope_fingerprint, resumed_session_id
441
+ FROM native_session_resume_actions WHERE session_id = ? AND idempotency_key = ?`).get(sourceSessionId, idempotencyKey);
442
+ return row ? {
443
+ sourceSessionId: str(row, "session_id"),
444
+ idempotencyKey: str(row, "idempotency_key"),
445
+ scopeFingerprint: str(row, "scope_fingerprint"),
446
+ resumedSessionId: str(row, "resumed_session_id"),
447
+ } : undefined;
448
+ }
449
+ invalidateNativeSessionResume(sessionId) {
450
+ const now = this.now().toISOString();
451
+ this.db.prepare(`UPDATE native_session_resume_handles SET consumed_at = COALESCE(consumed_at, ?) WHERE session_id = ?`).run(now, sessionId);
452
+ }
453
+ /**
454
+ * Atomically consumes a source conversation authority and creates its child
455
+ * process row. A duplicate action returns its original child, while another
456
+ * click can never fork the same provider conversation.
457
+ */
458
+ reserveNativeSessionResume(params) {
459
+ this.db.exec("BEGIN IMMEDIATE");
460
+ try {
461
+ const existing = this.db.prepare(`SELECT scope_fingerprint, resumed_session_id FROM native_session_resume_actions WHERE session_id = ? AND idempotency_key = ?`).get(params.sourceSessionId, params.idempotencyKey);
462
+ if (existing) {
463
+ if (str(existing, "scope_fingerprint") !== params.scopeFingerprint)
464
+ throw new Error("native session resume action is stale");
465
+ const row = this.getNativeSession(str(existing, "resumed_session_id"));
466
+ this.db.exec("COMMIT");
467
+ return row ? { row, created: false } : undefined;
468
+ }
469
+ const handle = this.getNativeSessionResumeHandle(params.sourceSessionId);
470
+ if (!handle || handle.provider !== params.child.vendor || handle.consumedAt !== null) {
471
+ this.db.exec("ROLLBACK");
472
+ return undefined;
473
+ }
474
+ const source = this.getNativeSession(params.sourceSessionId);
475
+ if (!source || source.vendor !== params.child.vendor) {
476
+ this.db.exec("ROLLBACK");
477
+ return undefined;
478
+ }
479
+ const now = this.now().toISOString();
480
+ const child = params.child;
481
+ this.db.prepare(`INSERT INTO native_sessions (id, purpose, vendor, agent, scope_kind, scope_id, project_identity, binding_generation, project_revision, dispatch_profile, dispatch_target, runtime_generation, status, created_at, updated_at, started_at, ended_at, exit_code, failure)
482
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'creating', ?, ?, NULL, NULL, NULL, NULL)`).run(child.id, child.purpose, child.vendor, child.agent, child.scopeKind, child.scopeId, child.projectIdentity ?? null, child.bindingGeneration ?? null, child.projectRevision ?? null, child.dispatchProfile ?? null, child.dispatchTarget ?? null, child.runtimeGeneration ?? null, now, now);
483
+ this.db.prepare(`INSERT INTO native_session_resume_actions (session_id, idempotency_key, scope_fingerprint, resumed_session_id, created_at) VALUES (?, ?, ?, ?, ?)`).run(params.sourceSessionId, params.idempotencyKey, params.scopeFingerprint, child.id, now);
484
+ this.db.prepare(`UPDATE native_session_resume_handles SET consumed_at = ? WHERE session_id = ? AND consumed_at IS NULL`).run(now, params.sourceSessionId);
485
+ this.saveNativeSessionResumeHandle(child.id, child.vendor, params.sealedHandle);
486
+ const row = this.getNativeSession(child.id);
487
+ this.db.exec("COMMIT");
488
+ return { row, created: true };
489
+ }
490
+ catch (error) {
491
+ this.db.exec("ROLLBACK");
492
+ throw error;
493
+ }
494
+ }
495
+ getNativeSession(id) {
496
+ const row = this.db.prepare(`SELECT * FROM native_sessions WHERE id = ?`).get(id);
497
+ return row ? rowToNativeSession(row) : undefined;
498
+ }
499
+ createNativeStructuredAnswer(params) {
500
+ const existing = this.getNativeStructuredAnswerByIdempotency(params.nativeSessionId, params.idempotencyKey);
501
+ if (existing)
502
+ return { row: existing, created: false };
503
+ const createdAt = this.now().toISOString();
504
+ this.db.exec("BEGIN IMMEDIATE");
505
+ try {
506
+ this.db.prepare(`INSERT INTO native_structured_answers (id, native_session_id, idempotency_key, envelope_json, digest, created_at)
507
+ VALUES (?, ?, ?, ?, ?, ?)`).run(params.id, params.nativeSessionId, params.idempotencyKey, params.envelopeJson, params.digest, createdAt);
508
+ this.db.prepare(`DELETE FROM native_structured_answers
509
+ WHERE id IN (
510
+ SELECT id FROM native_structured_answers
511
+ WHERE native_session_id = ?
512
+ ORDER BY created_at DESC, rowid DESC
513
+ LIMIT -1 OFFSET ?
514
+ )`).run(params.nativeSessionId, MAX_NATIVE_STRUCTURED_ANSWERS_PER_SESSION);
515
+ const row = this.getNativeStructuredAnswer(params.id);
516
+ this.db.exec("COMMIT");
517
+ return { row, created: true };
518
+ }
519
+ catch (error) {
520
+ this.db.exec("ROLLBACK");
521
+ const winner = this.getNativeStructuredAnswerByIdempotency(params.nativeSessionId, params.idempotencyKey);
522
+ if (winner)
523
+ return { row: winner, created: false };
524
+ throw error;
525
+ }
526
+ }
527
+ getNativeStructuredAnswer(id) {
528
+ const row = this.db.prepare(`SELECT * FROM native_structured_answers WHERE id = ?`).get(id);
529
+ return row ? rowToNativeStructuredAnswer(row) : undefined;
530
+ }
531
+ getNativeStructuredAnswerByIdempotency(nativeSessionId, idempotencyKey) {
532
+ const row = this.db.prepare(`SELECT * FROM native_structured_answers WHERE native_session_id = ? AND idempotency_key = ?`).get(nativeSessionId, idempotencyKey);
533
+ return row ? rowToNativeStructuredAnswer(row) : undefined;
534
+ }
535
+ getLatestNativeStructuredAnswer(nativeSessionId) {
536
+ const row = this.db.prepare(`SELECT * FROM native_structured_answers
537
+ WHERE native_session_id = ?
538
+ ORDER BY created_at DESC, rowid DESC
539
+ LIMIT 1`).get(nativeSessionId);
540
+ return row ? rowToNativeStructuredAnswer(row) : undefined;
541
+ }
542
+ listNativeSessions() {
543
+ return this.db.prepare(`SELECT * FROM native_sessions ORDER BY updated_at DESC, rowid DESC`).all().map(rowToNativeSession);
544
+ }
545
+ transitionNativeSession(id, status, patch = {}) {
546
+ const existing = this.getNativeSession(id);
547
+ if (!existing)
548
+ return undefined;
549
+ const now = this.now().toISOString();
550
+ this.db.prepare(`UPDATE native_sessions SET status = ?, updated_at = ?, started_at = COALESCE(started_at, ?), ended_at = ?, exit_code = ?, failure = ? WHERE id = ?`).run(status, now, patch.started ? now : null, patch.ended ? now : existing.endedAt, patch.exitCode === undefined ? existing.exitCode : patch.exitCode, patch.failure === undefined ? existing.failure : patch.failure, id);
551
+ return this.getNativeSession(id);
552
+ }
553
+ getNativeSetupRecovery(sessionId) {
554
+ const row = this.db.prepare(`SELECT * FROM native_setup_recoveries WHERE session_id = ?`).get(sessionId);
555
+ return row ? rowToNativeSetupRecovery(row) : undefined;
556
+ }
557
+ /**
558
+ * Accepts one already-validated closed v1 report when its producer sequence
559
+ * moves forward. An identical producer replay at the current sequence is an
560
+ * idempotent acknowledgement; any other replay or stale report fails closed.
561
+ */
562
+ recordNativeSetupRecovery(input) {
563
+ const now = this.now().toISOString();
564
+ this.db.exec("BEGIN IMMEDIATE");
565
+ try {
566
+ const existing = this.getNativeSetupRecovery(input.sessionId);
567
+ if (existing && input.sequence <= existing.sequence) {
568
+ const sameProducerReport = input.sequence === existing.sequence
569
+ && input.phase === existing.phase
570
+ && input.state === existing.state
571
+ && input.code === existing.code
572
+ && input.decision === existing.decision;
573
+ this.db.exec("ROLLBACK");
574
+ return sameProducerReport ? existing : undefined;
575
+ }
576
+ if (existing) {
577
+ this.db.prepare(`UPDATE native_setup_recoveries
578
+ SET phase = ?, state = ?, code = ?, sequence = ?, decision = ?, completion_validated = ?, version = version + 1, updated_at = ?
579
+ WHERE session_id = ? AND version = ?`).run(input.phase, input.state, input.code, input.sequence, input.decision, boolToInt(input.completionValidated), now, input.sessionId, existing.version);
580
+ }
581
+ else {
582
+ this.db.prepare(`INSERT INTO native_setup_recoveries (session_id, phase, state, code, sequence, decision, completion_validated, version, created_at, updated_at)
583
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`).run(input.sessionId, input.phase, input.state, input.code, input.sequence, input.decision, boolToInt(input.completionValidated), now, now);
584
+ }
585
+ const result = this.getNativeSetupRecovery(input.sessionId);
586
+ this.db.exec("COMMIT");
587
+ return result;
588
+ }
589
+ catch (error) {
590
+ this.db.exec("ROLLBACK");
591
+ throw error;
592
+ }
593
+ }
594
+ /** Creates a restart-safe, browser-held action secret without persisting it raw. */
595
+ issueNativeSetupRecoveryAction(sessionId, capability) {
596
+ const salt = randomBytes(32).toString("hex");
597
+ const verifier = recoveryCapabilityVerifier(salt, capability);
598
+ this.db.prepare(`INSERT INTO native_setup_recovery_actions (session_id, salt, verifier, claimed_at) VALUES (?, ?, ?, NULL)
599
+ ON CONFLICT(session_id) DO UPDATE SET salt = excluded.salt, verifier = excluded.verifier, claimed_at = NULL`).run(sessionId, salt, verifier);
600
+ }
601
+ /**
602
+ * Claims one recovery action atomically after checking its salted verifier
603
+ * and the browser's exact recovery version. A missing report has version 0
604
+ * so a BFF-restart interruption can still be retried honestly.
605
+ */
606
+ claimNativeSetupRecoveryAction(sessionId, capability, expectedVersion) {
607
+ const action = this.db.prepare(`SELECT salt, verifier FROM native_setup_recovery_actions WHERE session_id = ? AND claimed_at IS NULL`).get(sessionId);
608
+ if (!action)
609
+ return false;
610
+ const salt = str(action, "salt");
611
+ const verifier = str(action, "verifier");
612
+ if (!sameRecoveryCapability(verifier, recoveryCapabilityVerifier(salt, capability)))
613
+ return false;
614
+ const changed = this.db.prepare(`UPDATE native_setup_recovery_actions SET claimed_at = ?
615
+ WHERE session_id = ? AND verifier = ? AND claimed_at IS NULL
616
+ AND COALESCE((SELECT version FROM native_setup_recoveries WHERE session_id = native_setup_recovery_actions.session_id), 0) = ?`).run(this.now().toISOString(), sessionId, verifier, expectedVersion).changes;
617
+ return changed === 1;
618
+ }
619
+ /** Releases a failed launch claim so the same browser-held secret can retry. */
620
+ releaseNativeSetupRecoveryAction(sessionId) {
621
+ this.db.prepare(`UPDATE native_setup_recovery_actions SET claimed_at = NULL WHERE session_id = ?`).run(sessionId);
622
+ }
623
+ /** Atomically make a native row stopped and consume any durable recovery verifier. */
624
+ stopNativeSessionAndRevokeRecoveryAction(id, patch = {}) {
625
+ this.db.exec("BEGIN IMMEDIATE");
626
+ try {
627
+ const existing = this.getNativeSession(id);
628
+ if (!existing) {
629
+ this.db.exec("ROLLBACK");
630
+ return undefined;
631
+ }
632
+ const now = this.now().toISOString();
633
+ this.db.prepare(`UPDATE native_sessions SET status = 'stopped', updated_at = ?, ended_at = ?, failure = ? WHERE id = ?`).run(now, existing.endedAt ?? now, patch.failure === undefined ? existing.failure : patch.failure, id);
634
+ this.db.prepare(`DELETE FROM native_setup_recovery_actions WHERE session_id = ?`).run(id);
635
+ const result = this.getNativeSession(id);
636
+ this.db.exec("COMMIT");
637
+ return result;
638
+ }
639
+ catch (error) {
640
+ this.db.exec("ROLLBACK");
641
+ throw error;
642
+ }
643
+ }
644
+ listSessions() {
645
+ const rows = this.db.prepare(`SELECT * FROM sessions ORDER BY updated_at DESC`).all();
646
+ return rows.map(rowToSession);
647
+ }
648
+ getSession(id) {
649
+ const row = this.db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id);
650
+ return row ? rowToSession(row) : undefined;
651
+ }
652
+ updateSessionStatus(id, status, pendingQuestion) {
653
+ this.db
654
+ .prepare(`UPDATE sessions SET status = ?, pending_question = ?, updated_at = ? WHERE id = ?`)
655
+ .run(status, pendingQuestion, new Date().toISOString(), id);
656
+ }
657
+ /** Mirrors `updateSessionStatus`, but for the `pendingDecision` column (`status: "awaiting_decision"` checkpoints) — kept as a separate method/column so a pending clarify and a pending decision can never silently clobber each other. */
658
+ updateSessionDecision(id, status, pendingDecision) {
659
+ this.db
660
+ .prepare(`UPDATE sessions SET status = ?, pending_decision = ?, updated_at = ? WHERE id = ?`)
661
+ .run(status, pendingDecision, new Date().toISOString(), id);
662
+ }
663
+ touchSession(id) {
664
+ this.db.prepare(`UPDATE sessions SET updated_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
665
+ }
666
+ /**
667
+ * Deletes a session row along with everything scoped to it — turns, events, artifacts, and any
668
+ * publications for those artifacts — so no dangling rows survive the session itself. No FK
669
+ * cascade is configured on this schema (see SCHEMA_SQL above), so each child table needs its
670
+ * own explicit DELETE. Used by `resetSetup()` to remove an orphaned tracked setup session
671
+ * rather than merely forgetting its id.
672
+ */
673
+ deleteSession(id) {
674
+ const artifactRows = this.db.prepare(`SELECT id FROM artifacts WHERE session_id = ?`).all(id);
675
+ for (const row of artifactRows) {
676
+ this.db.prepare(`DELETE FROM publications WHERE artifact_id = ?`).run(str(row, "id"));
677
+ }
678
+ this.db.prepare(`DELETE FROM artifacts WHERE session_id = ?`).run(id);
679
+ this.db.prepare(`DELETE FROM events WHERE session_id = ?`).run(id);
680
+ this.db.prepare(`DELETE FROM turns WHERE session_id = ?`).run(id);
681
+ this.db.prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
682
+ }
683
+ // ---------------------------------------------------------------------
684
+ // events
685
+ // ---------------------------------------------------------------------
686
+ nextSeq(sessionId) {
687
+ const row = this.db.prepare(`SELECT COALESCE(MAX(seq), -1) AS maxSeq FROM events WHERE session_id = ?`).get(sessionId);
688
+ return row ? num(row, "maxSeq") + 1 : 0;
689
+ }
690
+ insertEvent(params) {
691
+ const seq = this.nextSeq(params.sessionId);
692
+ const row = {
693
+ id: newId("event"),
694
+ sessionId: params.sessionId,
695
+ seq,
696
+ kind: params.kind,
697
+ payload: params.payload,
698
+ createdAt: new Date().toISOString(),
699
+ turnId: params.turnId,
700
+ };
701
+ this.db
702
+ .prepare(`INSERT INTO events (id, session_id, seq, kind, payload_json, created_at, turn_id) VALUES (?, ?, ?, ?, ?, ?, ?)`)
703
+ .run(row.id, row.sessionId, row.seq, row.kind, JSON.stringify(row.payload), row.createdAt, row.turnId);
704
+ return row;
705
+ }
706
+ listEventsForSession(sessionId) {
707
+ const rows = this.db.prepare(`SELECT * FROM events WHERE session_id = ? ORDER BY seq ASC`).all(sessionId);
708
+ return rows.map(rowToEvent);
709
+ }
710
+ listEventsForTurn(turnId) {
711
+ const rows = this.db.prepare(`SELECT * FROM events WHERE turn_id = ? ORDER BY seq ASC`).all(turnId);
712
+ return rows.map(rowToEvent);
713
+ }
714
+ // ---------------------------------------------------------------------
715
+ // turns
716
+ // ---------------------------------------------------------------------
717
+ createTurn(params) {
718
+ const row = {
719
+ id: params.id,
720
+ sessionId: params.sessionId,
721
+ question: params.question,
722
+ composedInput: params.composedInput,
723
+ backend: null,
724
+ resultKind: null,
725
+ answerSummary: null,
726
+ traceJson: params.traceJson ?? null,
727
+ errorMessage: null,
728
+ createdAt: new Date().toISOString(),
729
+ agentId: params.agentId ?? null,
730
+ setupStepKey: params.setupStepKey ?? null,
731
+ resumeSessionId: params.resumeSessionId ?? null,
732
+ resumeSessionProvider: params.resumeSessionProvider ?? null,
733
+ resumeRunner: params.resumeRunner ?? null,
734
+ contextRecovery: params.contextRecovery ?? null,
735
+ workspaceRoot: params.workspaceRoot ?? null,
736
+ };
737
+ this.db
738
+ .prepare(`INSERT INTO turns (id, session_id, question, composed_input, backend, result_kind, answer_summary, trace_json, created_at, error_message, agent_id, setup_step_key, resume_session_id, resume_session_provider, resume_runner, context_recovery, workspace_root)
739
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
740
+ .run(row.id, row.sessionId, row.question, row.composedInput, row.backend, row.resultKind, row.answerSummary, row.traceJson, row.createdAt, row.errorMessage, row.agentId, row.setupStepKey, row.resumeSessionId, row.resumeSessionProvider, row.resumeRunner, row.contextRecovery, row.workspaceRoot);
741
+ return row;
742
+ }
743
+ /**
744
+ * Atomically coalesces duplicate setup dispatches for one session/step.
745
+ * A second tab can attach to the same pending turn, but cannot create a
746
+ * competing mutation of the project.
747
+ */
748
+ createOrGetActiveSetupTurn(params) {
749
+ this.db.exec("BEGIN IMMEDIATE");
750
+ try {
751
+ const active = this.db
752
+ .prepare(`SELECT * FROM turns
753
+ WHERE session_id = ? AND setup_step_key = ? AND result_kind IS NULL
754
+ AND rowid = (SELECT rowid FROM turns WHERE session_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1)`)
755
+ .get(params.sessionId, params.setupStepKey, params.sessionId);
756
+ const result = active
757
+ ? { turn: rowToTurn(active), created: false }
758
+ : { turn: this.createTurn(params), created: true };
759
+ this.db.exec("COMMIT");
760
+ return result;
761
+ }
762
+ catch (error) {
763
+ this.db.exec("ROLLBACK");
764
+ throw error;
765
+ }
766
+ }
767
+ getTurn(id) {
768
+ const row = this.db.prepare(`SELECT * FROM turns WHERE id = ?`).get(id);
769
+ return row ? rowToTurn(row) : undefined;
770
+ }
771
+ resolveTurn(id, patch) {
772
+ this.db
773
+ .prepare(`UPDATE turns SET backend = ?, result_kind = ?, answer_summary = ?, trace_json = ?, error_message = ? WHERE id = ?`)
774
+ .run(patch.backend, patch.resultKind, patch.answerSummary, patch.traceJson, patch.errorMessage, id);
775
+ }
776
+ /**
777
+ * Retains the dispatcher session anchor after a completed setup turn's
778
+ * host-contract recovery ultimately fails. A later explicit retry can use
779
+ * this durable anchor; it is never sent over the REST/SSE wire.
780
+ */
781
+ setTurnResumeAnchor(id, anchor) {
782
+ this.db
783
+ .prepare(`UPDATE turns SET resume_session_id = ?, resume_session_provider = ?, resume_runner = ? WHERE id = ?`)
784
+ .run(anchor.sessionId, anchor.provider, anchor.runner, id);
785
+ }
786
+ markTurnClarify(id) {
787
+ this.db.prepare(`UPDATE turns SET result_kind = 'clarify' WHERE id = ?`).run(id);
788
+ }
789
+ /** Most recent turn (any result kind) for a session — drives the Ask page's current work log. */
790
+ getLatestTurn(sessionId) {
791
+ const row = this.db.prepare(`SELECT * FROM turns WHERE session_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`).get(sessionId);
792
+ return row ? rowToTurn(row) : undefined;
793
+ }
794
+ /** Resolved turns newest-first, used to recover an earlier safe setup pause after a later retry fails. */
795
+ listTurnsForSession(sessionId) {
796
+ return this.db
797
+ .prepare(`SELECT * FROM turns WHERE session_id = ? ORDER BY created_at DESC, rowid DESC`)
798
+ .all(sessionId)
799
+ .map(rowToTurn);
800
+ }
801
+ /** D3 context composition source: last `limit` resolved (answer/refusal) turns, chronological ascending. */
802
+ listRecentResolvedTurns(sessionId, limit) {
803
+ const rows = this.db
804
+ .prepare(
805
+ // rowid (monotonic insertion order) is the tiebreak: created_at is a
806
+ // millisecond ISO string, so turns created in the same ms would
807
+ // otherwise order non-deterministically and pick an arbitrary window.
808
+ `SELECT question, answer_summary FROM turns
809
+ WHERE session_id = ? AND result_kind IN ('answer', 'refusal')
810
+ ORDER BY created_at DESC, rowid DESC LIMIT ?`)
811
+ .all(sessionId, limit);
812
+ return rows
813
+ .map((row) => ({ question: str(row, "question"), answerSummary: strOrNull(row, "answer_summary") ?? undefined }))
814
+ .reverse();
815
+ }
816
+ // ---------------------------------------------------------------------
817
+ // artifacts / publications
818
+ // ---------------------------------------------------------------------
819
+ createArtifact(params) {
820
+ const row = {
821
+ id: newId("artifact"),
822
+ sessionId: params.sessionId,
823
+ name: params.name,
824
+ kind: params.kind,
825
+ location: params.location,
826
+ verified: params.verified,
827
+ createdAt: new Date().toISOString(),
828
+ savedAt: null,
829
+ nativeSessionId: null,
830
+ projectIdentity: null,
831
+ bindingGeneration: null,
832
+ projectRevision: null,
833
+ nativeVendor: null,
834
+ nativeAgent: null,
835
+ contentDigest: null,
836
+ idempotencyKey: null,
837
+ sourceAnswerId: null,
838
+ };
839
+ this.db
840
+ .prepare(`INSERT INTO artifacts (id, session_id, name, kind, location, verified, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`)
841
+ .run(row.id, row.sessionId, row.name, row.kind, row.location, boolToInt(row.verified), row.createdAt);
842
+ return row;
843
+ }
844
+ /**
845
+ * Inserts a host-persisted native artifact as already Saved. The unique
846
+ * native-session/idempotency pair is the retry authority; callers receive
847
+ * the original row when another request won the race.
848
+ */
849
+ createNativeArtifact(params) {
850
+ const existing = this.getNativeArtifactByIdempotency(params.nativeSessionId, params.idempotencyKey);
851
+ if (existing)
852
+ return { row: existing, created: false };
853
+ const createdAt = new Date().toISOString();
854
+ const row = {
855
+ id: params.id, sessionId: params.sessionId, name: params.name, kind: "dashboard", location: params.location,
856
+ verified: true, createdAt, savedAt: createdAt, nativeSessionId: params.nativeSessionId,
857
+ projectIdentity: params.projectIdentity, bindingGeneration: params.bindingGeneration,
858
+ projectRevision: params.projectRevision, nativeVendor: params.vendor, nativeAgent: params.agent,
859
+ contentDigest: params.digest, idempotencyKey: params.idempotencyKey,
860
+ sourceAnswerId: params.sourceAnswerId ?? null,
861
+ };
862
+ try {
863
+ this.db.prepare(`INSERT INTO artifacts (
864
+ id, session_id, name, kind, location, verified, created_at, saved_at,
865
+ native_session_id, project_identity, binding_generation, project_revision,
866
+ native_vendor, native_agent, content_digest, idempotency_key, source_answer_id
867
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
868
+ .run(row.id, row.sessionId, row.name, row.kind, row.location, 1, row.createdAt, row.savedAt, row.nativeSessionId, row.projectIdentity, row.bindingGeneration, row.projectRevision, row.nativeVendor, row.nativeAgent, row.contentDigest, row.idempotencyKey, row.sourceAnswerId);
869
+ return { row, created: true };
870
+ }
871
+ catch (error) {
872
+ const winner = this.getNativeArtifactByIdempotency(params.nativeSessionId, params.idempotencyKey);
873
+ if (winner)
874
+ return { row: winner, created: false };
875
+ throw error;
876
+ }
877
+ }
878
+ getNativeArtifactByIdempotency(nativeSessionId, idempotencyKey) {
879
+ const row = this.db.prepare(`SELECT * FROM artifacts WHERE native_session_id = ? AND idempotency_key = ?`).get(nativeSessionId, idempotencyKey);
880
+ return row ? rowToArtifact(row) : undefined;
881
+ }
882
+ getArtifact(id) {
883
+ const row = this.db.prepare(`SELECT * FROM artifacts WHERE id = ?`).get(id);
884
+ return row ? rowToArtifact(row) : undefined;
885
+ }
886
+ /** Only artifacts the user explicitly saved — see `saveArtifact`. Auto-created (in-process/B) rows stay off the Artifacts page until saved. `getArtifact` stays unfiltered for direct-id lookups (content route, save route). */
887
+ listArtifacts() {
888
+ const rows = this.db.prepare(`SELECT * FROM artifacts WHERE saved_at IS NOT NULL ORDER BY created_at DESC`).all();
889
+ return rows.map(rowToArtifact);
890
+ }
891
+ /**
892
+ * Marks an artifact saved (visible via `listArtifacts()`). Idempotent: a
893
+ * repeat call on an already-saved artifact is a no-op that returns the
894
+ * existing row unchanged — it never overwrites the original `saved_at`.
895
+ * Returns `undefined` if the artifact doesn't exist.
896
+ */
897
+ saveArtifact(artifactId) {
898
+ const existing = this.getArtifact(artifactId);
899
+ if (!existing)
900
+ return undefined;
901
+ if (existing.savedAt !== null)
902
+ return existing;
903
+ const savedAt = new Date().toISOString();
904
+ this.db.prepare(`UPDATE artifacts SET saved_at = ? WHERE id = ?`).run(savedAt, artifactId);
905
+ return { ...existing, savedAt };
906
+ }
907
+ /**
908
+ * The mirror of `saveArtifact`: clears `saved_at` back to
909
+ * `null`, removing the artifact from `listArtifacts()` while leaving its
910
+ * row and envelope file untouched (Unpin is fully reversible — see
911
+ * `unsaveArtifactForSession`). Idempotent: a repeat call on an
912
+ * already-unsaved artifact is a no-op that returns the existing row
913
+ * unchanged. Returns `undefined` if the artifact doesn't exist.
914
+ */
915
+ unsaveArtifact(artifactId) {
916
+ const existing = this.getArtifact(artifactId);
917
+ if (!existing)
918
+ return undefined;
919
+ if (existing.savedAt === null)
920
+ return existing;
921
+ this.db.prepare(`UPDATE artifacts SET saved_at = NULL WHERE id = ?`).run(artifactId);
922
+ return { ...existing, savedAt: null };
923
+ }
924
+ publishArtifact(artifactId, link, scope) {
925
+ const row = { artifactId, link, scope, createdAt: new Date().toISOString() };
926
+ this.db
927
+ .prepare(`INSERT INTO publications (artifact_id, link, scope, created_at) VALUES (?, ?, ?, ?)
928
+ ON CONFLICT(artifact_id) DO UPDATE SET link = excluded.link, scope = excluded.scope, created_at = excluded.created_at`)
929
+ .run(row.artifactId, row.link, row.scope, row.createdAt);
930
+ return row;
931
+ }
932
+ getPublication(artifactId) {
933
+ const row = this.db.prepare(`SELECT * FROM publications WHERE artifact_id = ?`).get(artifactId);
934
+ return row
935
+ ? { artifactId: str(row, "artifact_id"), link: str(row, "link"), scope: str(row, "scope"), createdAt: str(row, "created_at") }
936
+ : undefined;
937
+ }
938
+ // ---------------------------------------------------------------------
939
+ // eval runs
940
+ // ---------------------------------------------------------------------
941
+ listEvalRuns() {
942
+ const rows = this.db.prepare(`SELECT * FROM eval_runs ORDER BY when_ts DESC`).all();
943
+ return rows.map(rowToEvalRun);
944
+ }
945
+ getEvalRun(id) {
946
+ const row = this.db.prepare(`SELECT * FROM eval_runs WHERE id = ?`).get(id);
947
+ if (!row)
948
+ return undefined;
949
+ const componentScores = JSON.parse(str(row, "component_scores_json"));
950
+ return { ...rowToEvalRun(row), componentScores };
951
+ }
952
+ // ---------------------------------------------------------------------
953
+ // config (generic JSON blob store)
954
+ // ---------------------------------------------------------------------
955
+ getConfigJson(key) {
956
+ const row = this.db.prepare(`SELECT value_json FROM config WHERE key = ?`).get(key);
957
+ return row ? JSON.parse(str(row, "value_json")) : undefined;
958
+ }
959
+ setConfigJson(key, value) {
960
+ this.db
961
+ .prepare(`INSERT INTO config (key, value_json) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json`)
962
+ .run(key, JSON.stringify(value));
963
+ }
964
+ /** Removes a config key entirely (so its getter falls back to `undefined`). Used by `resetSetup`. */
965
+ deleteConfig(key) {
966
+ this.db.prepare(`DELETE FROM config WHERE key = ?`).run(key);
967
+ }
968
+ // ---------------------------------------------------------------------
969
+ // typed config accessors
970
+ // ---------------------------------------------------------------------
971
+ getRuntimeSettings() {
972
+ const settings = this.getConfigJson("runtime.settings");
973
+ if (!settings)
974
+ throw new Error("runtime.settings missing — seedIfEmpty should have populated it");
975
+ // Backward-compatible read for databases created before the provider was
976
+ // persisted explicitly. Claude was the only subscription backend then.
977
+ const legacyDriver = settings.tierModels.find((binding) => binding.tier === "orchestrator")?.model;
978
+ return {
979
+ ...settings,
980
+ subscriptionProvider: settings.subscriptionProvider ?? "claude",
981
+ // Databases written before tier rows became bundle-owned may contain an
982
+ // `orchestrator` pseudo-tier. Read it as the dispatcher driver while
983
+ // ensuring it never reappears as a user-facing compiled-bundle row.
984
+ tierModels: settings.tierModels.filter((binding) => binding.tier !== "orchestrator"),
985
+ ...(settings.subscriptionDriverModel !== undefined || legacyDriver === undefined
986
+ ? {}
987
+ : { subscriptionDriverModel: legacyDriver }),
988
+ };
989
+ }
990
+ /**
991
+ * Persists a user-validated runtime choice. The separate marker matters:
992
+ * seeded display defaults are not authority to replace environment/CLI boot
993
+ * routing. Only a successful PUT (or an explicit programmatic equivalent)
994
+ * may supersede those boot flags.
995
+ */
996
+ setRuntimeSettings(settings, explicit = true) {
997
+ this.setConfigJson("runtime.settings", settings);
998
+ if (explicit)
999
+ this.setConfigJson("runtime.settings.explicit", true);
1000
+ else
1001
+ this.deleteConfig("runtime.settings.explicit");
1002
+ this.setConfigJson("runtime.settings.generation", this.getRuntimeGeneration() + 1);
1003
+ }
1004
+ getRuntimeGeneration() {
1005
+ return this.getConfigJson("runtime.settings.generation") ?? 0;
1006
+ }
1007
+ getNativeRuntimeBinding() {
1008
+ return resolveNativeRuntimeBinding(this.getRuntimeSettings(), this.hasExplicitRuntimeSettings(), this.getRuntimeGeneration());
1009
+ }
1010
+ /** Atomically persist a new runtime fence and stop every incompatible live row. */
1011
+ setRuntimeSettingsAndRevokeIncompatibleNativeSessions(settings, explicit = true) {
1012
+ this.db.exec("BEGIN IMMEDIATE");
1013
+ try {
1014
+ const generation = this.getRuntimeGeneration() + 1;
1015
+ const target = resolveNativeRuntimeBinding(settings, explicit, generation).target ?? null;
1016
+ const ids = this.db.prepare(`SELECT id FROM native_sessions WHERE status IN ('creating', 'running', 'detached')
1017
+ AND (? IS NULL OR dispatch_target IS NULL OR dispatch_target <> ?)`).all(target, target).map((row) => str(row, "id"));
1018
+ const now = this.now().toISOString();
1019
+ if (ids.length) {
1020
+ const placeholders = ids.map(() => "?").join(",");
1021
+ this.db.prepare(`UPDATE native_sessions SET status = 'stopped', updated_at = ?, ended_at = ?, failure = 'native runtime binding changed' WHERE id IN (${placeholders})`).run(now, now, ...ids);
1022
+ this.db.prepare(`DELETE FROM native_setup_recovery_actions WHERE session_id IN (${placeholders})`).run(...ids);
1023
+ }
1024
+ this.setConfigJson("runtime.settings", settings);
1025
+ if (explicit)
1026
+ this.setConfigJson("runtime.settings.explicit", true);
1027
+ else
1028
+ this.deleteConfig("runtime.settings.explicit");
1029
+ this.setConfigJson("runtime.settings.generation", generation);
1030
+ this.db.exec("COMMIT");
1031
+ return ids;
1032
+ }
1033
+ catch (error) {
1034
+ this.db.exec("ROLLBACK");
1035
+ throw error;
1036
+ }
1037
+ }
1038
+ /** Whether runtime.settings came from an explicit validated save, not seed data. */
1039
+ hasExplicitRuntimeSettings() {
1040
+ const marker = this.getConfigJson("runtime.settings.explicit");
1041
+ if (marker !== undefined)
1042
+ return marker;
1043
+ // Backward-compatible inference for databases written before the marker:
1044
+ // saving runtime settings also completed the wizard's runtime step.
1045
+ return this.getSetupSteps().find((step) => step.key === "runtime")?.state === "done";
1046
+ }
1047
+ getSetupSteps() {
1048
+ const steps = this.getConfigJson("setup.steps");
1049
+ if (!steps)
1050
+ throw new Error("setup.steps missing — seedIfEmpty should have populated it");
1051
+ return steps;
1052
+ }
1053
+ setSetupSteps(steps) {
1054
+ this.setConfigJson("setup.steps", coerceOrphanedTodoSteps(steps));
1055
+ }
1056
+ /**
1057
+ * Commits the context foundation's accepted lifecycle as one SQLite unit.
1058
+ * The caller has already completed parser/artifact/compile gates; this
1059
+ * method makes its visible success state reload-safe by never exposing a
1060
+ * partial step/event/turn/session transition.
1061
+ */
1062
+ completeContextSetupSuccess(params) {
1063
+ this.db.exec("BEGIN");
1064
+ try {
1065
+ this.setSetupSteps(params.steps);
1066
+ this.onContextSuccessWrite?.("after_steps");
1067
+ this.insertEvent({ sessionId: params.sessionId, kind: "setup_status", payload: params.statusEvent, turnId: params.turnId });
1068
+ this.onContextSuccessWrite?.("after_event");
1069
+ this.resolveTurn(params.turnId, {
1070
+ backend: params.backend,
1071
+ resultKind: "answer",
1072
+ answerSummary: params.answerSummary,
1073
+ traceJson: params.traceJson,
1074
+ errorMessage: params.errorMessage,
1075
+ });
1076
+ this.onContextSuccessWrite?.("after_turn");
1077
+ this.updateSessionStatus(params.sessionId, "active", null);
1078
+ this.db.exec("COMMIT");
1079
+ }
1080
+ catch (err) {
1081
+ this.db.exec("ROLLBACK");
1082
+ throw err;
1083
+ }
1084
+ }
1085
+ /**
1086
+ * Which of the wizard's two entry paths the user picked (`SetupMode`,
1087
+ * `POST /api/setup/mode`) — `undefined` before they've picked either,
1088
+ * which the frontend reads as "show the create-vs-adopt choice screen"
1089
+ * (see `GET /api/setup/mode`). Not seeded (no `SEED_*` constant): a fresh
1090
+ * DB simply has no value here until the user chooses.
1091
+ */
1092
+ getSetupMode() {
1093
+ return this.getConfigJson("setup.mode");
1094
+ }
1095
+ setSetupMode(mode) {
1096
+ this.setConfigJson("setup.mode", mode);
1097
+ }
1098
+ getVerifyGatePassed() {
1099
+ return this.getConfigJson("setup.verifyGatePassed") ?? false;
1100
+ }
1101
+ setVerifyGatePassed(value) {
1102
+ this.setConfigJson("setup.verifyGatePassed", value);
1103
+ }
1104
+ /**
1105
+ * The single dedicated session the setup wizard's connect/resume turns run
1106
+ * in (get-or-create — see `POST /api/setup/connect`). Kept separate from
1107
+ * the Ask page's session list so setup dispatches never appear there.
1108
+ *
1109
+ * Single-bootstrap-per-process assumption: this is a process-wide singleton
1110
+ * (one row in config), not scoped per browser tab/user — fine for the
1111
+ * one-BFF-per-project bootstrap flow, but would need to become session- or
1112
+ * request-scoped if this BFF ever serves multiple concurrent setup wizards.
1113
+ */
1114
+ getSetupSessionId() {
1115
+ return this.getConfigJson("setup.sessionId");
1116
+ }
1117
+ setSetupSessionId(id) {
1118
+ this.setConfigJson("setup.sessionId", id);
1119
+ }
1120
+ /**
1121
+ * The connect form's structured values (project name + source type),
1122
+ * persisted at `POST /api/setup/connect` time so the `connect_resume` turn
1123
+ * doesn't require the frontend to resend them, and so `executeSetupTurn`
1124
+ * (`server/turn.ts`) has the `{root, name}` context `parseSetupTerminal`
1125
+ * needs without having to parse it back out of `composedInput` prose.
1126
+ */
1127
+ getSetupConnectForm() {
1128
+ return this.getConfigJson("setup.connectForm");
1129
+ }
1130
+ setSetupConnectForm(form) {
1131
+ this.setConfigJson("setup.connectForm", form);
1132
+ }
1133
+ getSetupContextLifecycleEvidence(sessionId, identityFingerprint) {
1134
+ const value = this.getConfigJson("setup.contextLifecycleEvidence");
1135
+ if (typeof value !== "object" ||
1136
+ value === null ||
1137
+ Array.isArray(value) ||
1138
+ value.sessionId !== sessionId ||
1139
+ value.identityFingerprint !== identityFingerprint ||
1140
+ !["none", "discovery", "validate", "build"].includes(value.completed)) {
1141
+ // A malformed, stale-session, or stale-identity checkpoint must never
1142
+ // influence a new project. Remove it rather than guessing how to merge.
1143
+ if (value !== undefined)
1144
+ this.deleteConfig("setup.contextLifecycleEvidence");
1145
+ return undefined;
1146
+ }
1147
+ return value;
1148
+ }
1149
+ mergeSetupContextLifecycleEvidence(input) {
1150
+ const current = this.getSetupContextLifecycleEvidence(input.sessionId, input.identityFingerprint);
1151
+ const rank = (value) => ["none", "discovery", "validate", "build"].indexOf(value);
1152
+ const merged = {
1153
+ sessionId: input.sessionId,
1154
+ identityFingerprint: input.identityFingerprint,
1155
+ completed: current && rank(current.completed) > rank(input.completed) ? current.completed : input.completed,
1156
+ };
1157
+ this.setConfigJson("setup.contextLifecycleEvidence", merged);
1158
+ return merged;
1159
+ }
1160
+ clearSetupContextLifecycleEvidence() {
1161
+ this.deleteConfig("setup.contextLifecycleEvidence");
1162
+ }
1163
+ getContextModels() {
1164
+ return this.getConfigJson("context.models") ?? [];
1165
+ }
1166
+ getContextRelationships() {
1167
+ return this.getConfigJson("context.relationships") ?? [];
1168
+ }
1169
+ getContextMeasures() {
1170
+ return this.getConfigJson("context.measures") ?? [];
1171
+ }
1172
+ getContextKnowledge() {
1173
+ return this.getConfigJson("context.knowledge") ?? { instructionsPresent: false, verifiedPairCount: 0 };
1174
+ }
1175
+ getContextFiles() {
1176
+ return this.getConfigJson("context.files") ?? [];
1177
+ }
1178
+ getBlastRadius(entityKey) {
1179
+ const map = this.getConfigJson("context.blastRadius") ?? {};
1180
+ return map[entityKey];
1181
+ }
1182
+ getVerifiedPairs() {
1183
+ return this.getConfigJson("context.verifiedPairs") ?? [];
1184
+ }
1185
+ // ---------------------------------------------------------------------
1186
+ // Optional post-bind enrichment. The durable operation ledger lives here,
1187
+ // never in a project artifact or a provider session.
1188
+ // ---------------------------------------------------------------------
1189
+ /**
1190
+ * Persists a run BEFORE any model turn is dispatched, in a running
1191
+ * ('drafting') state, with the bound revision and generation locked for
1192
+ * the lifetime of the draft. This is the enrichment analogue of
1193
+ * `postTurn` creating a turn row (`resultKind: null`) ahead of `route()`:
1194
+ * the row -- and therefore `GET /api/context/enrichment`'s visibility of
1195
+ * it -- exists for the entire ~minutes-long dispatch, not only after it
1196
+ * resolves. Every exit path from that dispatch must resolve this row to a
1197
+ * terminal status via `finalizeEnrichmentDraft` or `failEnrichmentRun`
1198
+ * (or, after a process restart, `reconcileOrphanedEnrichmentRuns`).
1199
+ */
1200
+ createDraftingEnrichmentRun(params) {
1201
+ const now = this.now().toISOString();
1202
+ this.db.exec("BEGIN");
1203
+ try {
1204
+ this.db.prepare(`INSERT INTO enrichment_runs (id, mode, project_path, project_identity, project_revision, proposal_id, proposal_hash, status, created_at, updated_at, validation_digest, build_digest, error_message, binding_generation, version) VALUES (?, ?, ?, ?, ?, '', '', 'drafting', ?, ?, NULL, NULL, NULL, ?, 1)`).run(params.id, params.mode, params.binding.path, params.binding.identity, params.binding.revision, now, now, params.binding.generation);
1205
+ this.onEnrichmentCreationWrite?.("after_run");
1206
+ this.db.prepare(`INSERT INTO enrichment_events (id, run_id, kind, message, created_at) VALUES (?, ?, 'started', ?, ?)`).run(newId("enrichment-event"), params.id, "Enrichment draft started.", now);
1207
+ this.onEnrichmentCreationWrite?.("after_event");
1208
+ this.db.exec("COMMIT");
1209
+ return this.getEnrichmentRun(params.id);
1210
+ }
1211
+ catch (error) {
1212
+ this.db.exec("ROLLBACK");
1213
+ throw error;
1214
+ }
1215
+ }
1216
+ /**
1217
+ * Transitions a 'drafting' run to 'awaiting_decision' once the draft
1218
+ * callback has resolved AND been independently canonicalized by the host.
1219
+ * Fenced on `status = 'drafting'` plus the CAS version, so a run that a
1220
+ * restart already reconciled to 'failed' (or that was somehow already
1221
+ * finalized) cannot be resurrected by a late completion.
1222
+ */
1223
+ finalizeEnrichmentDraft(params) {
1224
+ this.db.exec("BEGIN");
1225
+ try {
1226
+ const now = this.now().toISOString();
1227
+ const changedRun = this.db.prepare(`UPDATE enrichment_runs SET status = 'awaiting_decision', proposal_id = ?, proposal_hash = ?, validation_digest = ?, build_digest = ?, updated_at = ?, version = version + 1 WHERE id = ? AND version = ? AND status = 'drafting'`).run(params.proposalId, params.proposalHash, params.validationDigest ?? null, params.buildDigest ?? null, now, params.runId, params.expectedVersion).changes;
1228
+ if (Number(changedRun) !== 1) {
1229
+ this.db.exec("ROLLBACK");
1230
+ return false;
1231
+ }
1232
+ this.onEnrichmentCreationWrite?.("after_run");
1233
+ for (const operation of params.operations)
1234
+ this.db.prepare(`INSERT INTO enrichment_operations (run_id, operation_id, sink, risk, summary, draft, change_kind, confidence, decision, completed, state, idempotency_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, 'awaiting_decision', ?)`).run(params.runId, operation.id, operation.sink, operation.risk, operation.summary, operation.draft, operation.changeKind, operation.confidence, `${params.runId}:${operation.id}`);
1235
+ this.onEnrichmentCreationWrite?.("after_operations");
1236
+ this.db.prepare(`INSERT INTO enrichment_events (id, run_id, kind, message, created_at) VALUES (?, ?, 'drafted', ?, ?)`).run(newId("enrichment-event"), params.runId, "A typed enrichment proposal is ready for review.", now);
1237
+ this.onEnrichmentCreationWrite?.("after_event");
1238
+ this.db.exec("COMMIT");
1239
+ return true;
1240
+ }
1241
+ catch (error) {
1242
+ this.db.exec("ROLLBACK");
1243
+ throw error;
1244
+ }
1245
+ }
1246
+ /**
1247
+ * Resolves a 'drafting' run to the terminal 'failed' status -- the
1248
+ * enrichment analogue of `resolveTurn(..., { resultKind: "error" })` in
1249
+ * every catch clause of `executeTurn`. Called on a thrown draft/contract
1250
+ * error, a stale post-draft binding, or (via
1251
+ * `reconcileOrphanedEnrichmentRuns`) a BFF restart. Fenced on
1252
+ * `status = 'drafting'` so it can never clobber a run that already
1253
+ * finalized.
1254
+ */
1255
+ failEnrichmentRun(params) {
1256
+ this.db.exec("BEGIN");
1257
+ try {
1258
+ const now = this.now().toISOString();
1259
+ const changedRun = this.db.prepare(`UPDATE enrichment_runs SET status = 'failed', error_message = ?, updated_at = ?, version = version + 1 WHERE id = ? AND version = ? AND status = 'drafting'`).run(params.message, now, params.runId, params.expectedVersion).changes;
1260
+ if (Number(changedRun) !== 1) {
1261
+ this.db.exec("ROLLBACK");
1262
+ return false;
1263
+ }
1264
+ this.onEnrichmentTransitionWrite?.("after_run");
1265
+ this.db.prepare(`INSERT INTO enrichment_events (id, run_id, kind, message, created_at) VALUES (?, ?, 'failed', ?, ?)`).run(newId("enrichment-event"), params.runId, "Enrichment draft failed.", now);
1266
+ this.onEnrichmentTransitionWrite?.("after_event");
1267
+ this.db.exec("COMMIT");
1268
+ return true;
1269
+ }
1270
+ catch (error) {
1271
+ this.db.exec("ROLLBACK");
1272
+ throw error;
1273
+ }
1274
+ }
1275
+ createEnrichmentRun(params) {
1276
+ const now = new Date().toISOString();
1277
+ this.db.exec("BEGIN");
1278
+ try {
1279
+ this.db.prepare(`INSERT INTO enrichment_runs (id, mode, project_path, project_identity, project_revision, proposal_id, proposal_hash, status, created_at, updated_at, validation_digest, build_digest, error_message, binding_generation, version) VALUES (?, ?, ?, ?, ?, ?, ?, 'awaiting_decision', ?, ?, ?, ?, NULL, ?, 1)`).run(params.id, params.mode, params.binding.path, params.binding.identity, params.binding.revision, params.proposalId, params.proposalHash, now, now, params.validationDigest ?? null, params.buildDigest ?? null, params.binding.generation);
1280
+ this.onEnrichmentCreationWrite?.("after_run");
1281
+ for (const operation of params.operations)
1282
+ this.db.prepare(`INSERT INTO enrichment_operations (run_id, operation_id, sink, risk, summary, draft, change_kind, confidence, decision, completed, state, idempotency_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, 'awaiting_decision', ?)`).run(params.id, operation.id, operation.sink, operation.risk, operation.summary, operation.draft, operation.changeKind, operation.confidence, `${params.id}:${operation.id}`);
1283
+ this.onEnrichmentCreationWrite?.("after_operations");
1284
+ this.db.prepare(`INSERT INTO enrichment_events (id, run_id, kind, message, created_at) VALUES (?, ?, 'drafted', ?, ?)`).run(newId("enrichment-event"), params.id, "A typed enrichment proposal is ready for review.", now);
1285
+ this.onEnrichmentCreationWrite?.("after_event");
1286
+ this.db.exec("COMMIT");
1287
+ return this.getEnrichmentRun(params.id);
1288
+ }
1289
+ catch (error) {
1290
+ this.db.exec("ROLLBACK");
1291
+ throw error;
1292
+ }
1293
+ }
1294
+ /**
1295
+ * Every bind, including the same canonical directory, creates a new
1296
+ * generation. `bindProject` (the foundation path) never has a built
1297
+ * project's revision to offer — binding must succeed for an unbuilt
1298
+ * project — so it activates identity alone; the stored placeholder
1299
+ * revision is never read as a trusted value. Every consumer re-resolves
1300
+ * the revision fresh from disk before treating a binding as current (see
1301
+ * `currentEnrichmentBinding` in app.ts), so a placeholder here cannot
1302
+ * leak into a fencing decision.
1303
+ */
1304
+ activateEnrichmentBinding(binding) {
1305
+ const current = this.getEnrichmentBinding();
1306
+ const revision = "revision" in binding ? binding.revision : "";
1307
+ const next = { ...binding, revision, generation: (current?.generation ?? 0) + 1 };
1308
+ this.setConfigJson("enrichment.binding", next);
1309
+ return next;
1310
+ }
1311
+ /**
1312
+ * Advances the project binding and stops every live project-scoped native
1313
+ * session in one transaction. The caller must immediately discard the
1314
+ * corresponding in-memory PTY and MCP capabilities using the returned ids.
1315
+ * Bootstrap Setup sessions deliberately remain outside this project fence.
1316
+ */
1317
+ activateEnrichmentBindingAndRevokeBoundNativeSessions(binding) {
1318
+ this.db.exec("BEGIN IMMEDIATE");
1319
+ try {
1320
+ const current = this.getEnrichmentBinding();
1321
+ const revision = "revision" in binding ? binding.revision : "";
1322
+ const next = { ...binding, revision, generation: (current?.generation ?? 0) + 1 };
1323
+ const ids = this.db.prepare(`SELECT id FROM native_sessions
1324
+ WHERE status IN ('creating', 'running', 'detached') AND project_identity IS NOT NULL`).all().map((row) => str(row, "id"));
1325
+ const now = this.now().toISOString();
1326
+ if (ids.length) {
1327
+ const placeholders = ids.map(() => "?").join(",");
1328
+ this.db.prepare(`UPDATE native_sessions SET status = 'stopped', updated_at = ?, ended_at = ?, failure = 'native project binding changed' WHERE id IN (${placeholders})`).run(now, now, ...ids);
1329
+ this.db.prepare(`DELETE FROM native_setup_recovery_actions WHERE session_id IN (${placeholders})`).run(...ids);
1330
+ }
1331
+ this.setConfigJson("enrichment.binding", next);
1332
+ this.db.exec("COMMIT");
1333
+ return { binding: next, revokedNativeSessionIds: ids };
1334
+ }
1335
+ catch (error) {
1336
+ this.db.exec("ROLLBACK");
1337
+ throw error;
1338
+ }
1339
+ }
1340
+ getEnrichmentBinding() { return this.getConfigJson("enrichment.binding"); }
1341
+ getEnrichmentRun(id) { const row = this.db.prepare(`SELECT * FROM enrichment_runs WHERE id = ?`).get(id); return row ? rowToEnrichmentRun(row) : undefined; }
1342
+ getLatestEnrichmentRun() { const row = this.db.prepare(`SELECT * FROM enrichment_runs ORDER BY created_at DESC, rowid DESC LIMIT 1`).get(); return row ? rowToEnrichmentRun(row) : undefined; }
1343
+ listEnrichmentOperations(runId) { return this.db.prepare(`SELECT * FROM enrichment_operations WHERE run_id = ? ORDER BY rowid ASC`).all(runId).map(rowToEnrichmentOperation); }
1344
+ getEnrichmentOperation(runId, operationId) { const row = this.db.prepare(`SELECT * FROM enrichment_operations WHERE run_id = ? AND operation_id = ?`).get(runId, operationId); return row ? rowToEnrichmentOperation(row) : undefined; }
1345
+ appendEnrichmentEvent(runId, kind, message) { this.db.prepare(`INSERT INTO enrichment_events (id, run_id, kind, message, created_at) VALUES (?, ?, ?, ?, ?)`).run(newId("enrichment-event"), runId, kind, message, new Date().toISOString()); }
1346
+ listEnrichmentEvents(runId) { return this.db.prepare(`SELECT * FROM enrichment_events WHERE run_id = ? ORDER BY created_at ASC, rowid ASC`).all(runId).map(rowToEnrichmentEvent); }
1347
+ /**
1348
+ * The authority for non-lease enrichment mutations. It checks both the
1349
+ * caller's snapshot and the DB version before making *any* write; operation,
1350
+ * attestation, run metadata and event then commit or roll back together.
1351
+ */
1352
+ transitionEnrichmentMetadata(params) {
1353
+ this.db.exec("BEGIN");
1354
+ try {
1355
+ const run = this.getEnrichmentRun(params.runId);
1356
+ if (!run
1357
+ || run.version !== params.expectedVersion
1358
+ || run.projectPath !== params.binding.path
1359
+ || run.projectIdentity !== params.binding.identity
1360
+ || run.projectRevision !== params.binding.revision
1361
+ || run.bindingGeneration !== params.binding.generation) {
1362
+ this.db.exec("ROLLBACK");
1363
+ return false;
1364
+ }
1365
+ if (params.operation) {
1366
+ const operation = params.operation;
1367
+ const decisionClause = operation.expectedDecision === undefined
1368
+ ? ""
1369
+ : operation.expectedDecision === null ? " AND decision IS NULL" : " AND decision = ?";
1370
+ const values = [operation.decision ?? null, operation.nextState, params.runId, operation.id, operation.expectedState];
1371
+ if (operation.expectedDecision !== undefined && operation.expectedDecision !== null)
1372
+ values.push(operation.expectedDecision);
1373
+ const changed = this.db.prepare(`UPDATE enrichment_operations SET decision = COALESCE(?, decision), state = ? WHERE run_id = ? AND operation_id = ? AND state = ?${decisionClause}`).run(...values).changes;
1374
+ if (Number(changed) !== 1) {
1375
+ this.db.exec("ROLLBACK");
1376
+ return false;
1377
+ }
1378
+ this.onEnrichmentTransitionWrite?.("after_operation");
1379
+ }
1380
+ if (params.attestation !== undefined) {
1381
+ const operation = params.operation ? this.getEnrichmentOperation(params.runId, params.operation.id) : undefined;
1382
+ const attestation = params.attestation;
1383
+ if (!operation
1384
+ || attestation.binding.path !== run.projectPath
1385
+ || attestation.binding.identity !== run.projectIdentity
1386
+ || attestation.binding.generation !== run.bindingGeneration
1387
+ || attestation.binding.revision !== run.projectRevision
1388
+ || attestation.proposalHash !== run.proposalHash
1389
+ || attestation.operationHash !== hashEnrichmentOperation(operation)
1390
+ || !Number.isFinite(Date.parse(attestation.expiresAt))
1391
+ || Date.parse(attestation.expiresAt) <= this.now().getTime()) {
1392
+ this.db.exec("ROLLBACK");
1393
+ return false;
1394
+ }
1395
+ try {
1396
+ this.db.prepare(`INSERT INTO enrichment_approvals (run_id, operation_id, project_revision, proposal_hash, risk, attested_at, project_path, project_identity, binding_generation, operation_hash, sink, change_kind, evidence_ref, nonce, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(params.runId, operation.id, run.projectRevision, run.proposalHash, operation.risk, this.now().toISOString(), run.projectPath, run.projectIdentity, run.bindingGeneration, attestation.operationHash, operation.sink, operation.changeKind, attestation.evidenceRef, attestation.nonce, attestation.expiresAt);
1397
+ }
1398
+ catch {
1399
+ this.db.exec("ROLLBACK");
1400
+ return false;
1401
+ }
1402
+ }
1403
+ const now = this.now().toISOString();
1404
+ const changedRun = this.db.prepare(`UPDATE enrichment_runs SET status = COALESCE(?, status), error_message = ?, updated_at = ?, version = version + 1 WHERE id = ? AND version = ?`).run(params.status ?? null, params.errorMessage === undefined ? run.errorMessage : params.errorMessage, now, params.runId, params.expectedVersion).changes;
1405
+ if (Number(changedRun) !== 1) {
1406
+ this.db.exec("ROLLBACK");
1407
+ return false;
1408
+ }
1409
+ this.onEnrichmentTransitionWrite?.("after_run");
1410
+ for (const event of [params.event, ...(params.additionalEvents ?? [])].filter((value) => value !== undefined)) {
1411
+ this.db.prepare(`INSERT INTO enrichment_events (id, run_id, kind, message, created_at) VALUES (?, ?, ?, ?, ?)`).run(newId("enrichment-event"), params.runId, event.kind, event.message, now);
1412
+ this.onEnrichmentTransitionWrite?.("after_event");
1413
+ }
1414
+ this.db.exec("COMMIT");
1415
+ return true;
1416
+ }
1417
+ catch (error) {
1418
+ this.db.exec("ROLLBACK");
1419
+ throw error;
1420
+ }
1421
+ }
1422
+ transitionEnrichmentEdit(params) {
1423
+ this.db.exec("BEGIN");
1424
+ try {
1425
+ const run = this.getEnrichmentRun(params.runId);
1426
+ const current = this.getEnrichmentOperation(params.runId, params.operationId);
1427
+ if (!run || !current || run.version !== params.expectedVersion
1428
+ || run.projectPath !== params.binding.path || run.projectIdentity !== params.binding.identity
1429
+ || run.projectRevision !== params.binding.revision || run.bindingGeneration !== params.binding.generation
1430
+ || current.state !== "awaiting_decision" || current.decision !== "edit") {
1431
+ this.db.exec("ROLLBACK");
1432
+ return false;
1433
+ }
1434
+ const changedOperation = this.db.prepare(`UPDATE enrichment_operations SET operation_id = ?, sink = ?, risk = ?, summary = ?, draft = ?, change_kind = ?, confidence = ?, decision = NULL, state = 'awaiting_decision', attempt = 0, lease_token = NULL, lease_expires_at = NULL, idempotency_key = ? WHERE run_id = ? AND operation_id = ? AND state = 'awaiting_decision' AND decision = 'edit'`)
1435
+ .run(params.operation.id, params.operation.sink, params.operation.risk, params.operation.summary, params.operation.draft, params.operation.changeKind, params.operation.confidence, `${params.runId}:${params.operation.id}`, params.runId, params.operationId).changes;
1436
+ if (Number(changedOperation) !== 1) {
1437
+ this.db.exec("ROLLBACK");
1438
+ return false;
1439
+ }
1440
+ this.onEnrichmentTransitionWrite?.("after_operation");
1441
+ const now = this.now().toISOString();
1442
+ const changedRun = this.db.prepare(`UPDATE enrichment_runs SET proposal_id = ?, proposal_hash = ?, status = 'awaiting_decision', error_message = NULL, updated_at = ?, version = version + 1 WHERE id = ? AND version = ?`)
1443
+ .run(params.proposalId, params.proposalHash, now, params.runId, params.expectedVersion).changes;
1444
+ if (Number(changedRun) !== 1) {
1445
+ this.db.exec("ROLLBACK");
1446
+ return false;
1447
+ }
1448
+ this.onEnrichmentTransitionWrite?.("after_run");
1449
+ this.db.prepare(`INSERT INTO enrichment_events (id, run_id, kind, message, created_at) VALUES (?, ?, ?, ?, ?)`).run(newId("enrichment-event"), params.runId, params.event.kind, params.event.message, now);
1450
+ this.onEnrichmentTransitionWrite?.("after_event");
1451
+ this.db.exec("COMMIT");
1452
+ return true;
1453
+ }
1454
+ catch (error) {
1455
+ this.db.exec("ROLLBACK");
1456
+ throw error;
1457
+ }
1458
+ }
1459
+ transitionEnrichmentExecution(params) {
1460
+ this.db.exec("BEGIN");
1461
+ try {
1462
+ const run = this.getEnrichmentRun(params.runId);
1463
+ const operation = this.getEnrichmentOperation(params.runId, params.operationId);
1464
+ if (!run || !operation || run.version !== params.expectedVersion
1465
+ || run.projectPath !== params.binding.path || run.projectIdentity !== params.binding.identity
1466
+ || run.projectRevision !== params.binding.revision || run.bindingGeneration !== params.binding.generation
1467
+ || !params.expectedStates.includes(operation.state)
1468
+ || (params.expectedAttempt !== undefined && operation.attempt !== params.expectedAttempt)
1469
+ || (params.expectedLeaseToken !== undefined && operation.leaseToken !== params.expectedLeaseToken)) {
1470
+ this.db.exec("ROLLBACK");
1471
+ return false;
1472
+ }
1473
+ const changedOperation = this.db.prepare(`UPDATE enrichment_operations SET state = ?, attempt = ?, lease_token = ?, lease_expires_at = ?, completed = ? WHERE run_id = ? AND operation_id = ? AND state = ? AND attempt = ? AND (lease_token IS ? OR lease_token = ?)`)
1474
+ .run(params.nextState, params.nextAttempt ?? operation.attempt, params.leaseToken === undefined ? operation.leaseToken : params.leaseToken, params.leaseExpiresAt === undefined ? operation.leaseExpiresAt : params.leaseExpiresAt, boolToInt(params.completed ?? operation.completed), params.runId, params.operationId, operation.state, operation.attempt, operation.leaseToken, operation.leaseToken).changes;
1475
+ if (Number(changedOperation) !== 1) {
1476
+ this.db.exec("ROLLBACK");
1477
+ return false;
1478
+ }
1479
+ this.onEnrichmentTransitionWrite?.("after_operation");
1480
+ const now = this.now().toISOString();
1481
+ const changedRun = this.db.prepare(`UPDATE enrichment_runs SET status = ?, validation_digest = ?, build_digest = ?, error_message = ?, updated_at = ?, version = version + 1 WHERE id = ? AND version = ?`)
1482
+ .run(params.status, params.validationDigest === undefined ? run.validationDigest : params.validationDigest, params.buildDigest === undefined ? run.buildDigest : params.buildDigest, params.errorMessage === undefined ? run.errorMessage : params.errorMessage, now, params.runId, params.expectedVersion).changes;
1483
+ if (Number(changedRun) !== 1) {
1484
+ this.db.exec("ROLLBACK");
1485
+ return false;
1486
+ }
1487
+ this.onEnrichmentTransitionWrite?.("after_run");
1488
+ this.db.prepare(`INSERT INTO enrichment_events (id, run_id, kind, message, created_at) VALUES (?, ?, ?, ?, ?)`).run(newId("enrichment-event"), params.runId, params.event.kind, params.event.message, now);
1489
+ this.onEnrichmentTransitionWrite?.("after_event");
1490
+ this.db.exec("COMMIT");
1491
+ return true;
1492
+ }
1493
+ catch (error) {
1494
+ this.db.exec("ROLLBACK");
1495
+ throw error;
1496
+ }
1497
+ }
1498
+ hasExactEnrichmentAttestation(runId, operationId) {
1499
+ const run = this.getEnrichmentRun(runId);
1500
+ const operation = this.getEnrichmentOperation(runId, operationId);
1501
+ if (!run || !operation)
1502
+ return false;
1503
+ return this.db.prepare(`SELECT 1 FROM enrichment_approvals WHERE run_id = ? AND operation_id = ? AND project_path = ? AND project_identity = ? AND binding_generation = ? AND project_revision = ? AND proposal_hash = ? AND operation_hash = ? AND risk = ? AND sink = ? AND change_kind = ? AND expires_at > ? AND evidence_ref <> '' AND nonce <> ''`).get(runId, operationId, run.projectPath, run.projectIdentity, run.bindingGeneration, run.projectRevision, run.proposalHash, hashEnrichmentOperation(operation), operation.risk, operation.sink, operation.changeKind, this.now().toISOString()) !== undefined;
1504
+ }
1505
+ // ---------------------------------------------------------------------
1506
+ // seed data (first init only — a fresh :memory: or file DB has no sessions row)
1507
+ // ---------------------------------------------------------------------
1508
+ seedIfEmpty() {
1509
+ const row = this.db.prepare(`SELECT COUNT(*) AS n FROM eval_runs`).get();
1510
+ if (row && num(row, "n") > 0)
1511
+ return; // already seeded
1512
+ this.seedEvalRuns();
1513
+ this.setRuntimeSettings(SEED_RUNTIME_SETTINGS, false);
1514
+ this.setSetupSteps(SEED_SETUP_STEPS);
1515
+ this.setVerifyGatePassed(false);
1516
+ this.seedContext();
1517
+ }
1518
+ /**
1519
+ * Restores the setup wizard to its first-run state (for the "Reset setup"
1520
+ * action): steps back to step 1, runtime settings back to defaults, verify
1521
+ * gate off, and the connect form + setup session cleared. Non-destructive —
1522
+ * this only touches wizard STATE; it never deletes scaffolded project files
1523
+ * on disk (the caller unbinds the bootstrap project separately). Leaves the
1524
+ * seeded eval/context fixtures alone (they aren't part of the wizard).
1525
+ *
1526
+ * Clearing `setup.sessionId` used to leave the tracked
1527
+ * setup session's ROW behind. `GET /api/sessions` filters the list by id
1528
+ * against `getSetupSessionId()`, so once the id was forgotten that row
1529
+ * matched nothing and leaked back into the Ask sidebar as an orphaned
1530
+ * "Setup: <name>" session. Delete the row (and its turns/events/artifacts)
1531
+ * BEFORE forgetting the id, so no orphan is ever left behind.
1532
+ */
1533
+ resetSetup() {
1534
+ const staleSetupSessionId = this.getSetupSessionId();
1535
+ if (staleSetupSessionId !== undefined)
1536
+ this.deleteSession(staleSetupSessionId);
1537
+ this.setSetupSteps(SEED_SETUP_STEPS);
1538
+ const revokedNativeSessionIds = this.setRuntimeSettingsAndRevokeIncompatibleNativeSessions(SEED_RUNTIME_SETTINGS, false);
1539
+ this.setVerifyGatePassed(false);
1540
+ this.deleteConfig("setup.connectForm");
1541
+ this.deleteConfig("setup.sessionId");
1542
+ this.deleteConfig("setup.mode");
1543
+ this.clearSetupContextLifecycleEvidence();
1544
+ return revokedNativeSessionIds;
1545
+ }
1546
+ seedEvalRuns() {
1547
+ const runs = [
1548
+ {
1549
+ id: "eval-1",
1550
+ when: "2026-07-10T09:00:00Z",
1551
+ score: 0.87,
1552
+ gateThreshold: 0.8,
1553
+ gatePass: true,
1554
+ regressions: 0,
1555
+ cost: "$1.20",
1556
+ p50: "820ms",
1557
+ componentScores: [
1558
+ { component: "sql_generation", score: 0.91, delta: 0.02 },
1559
+ { component: "render", score: 0.85, delta: -0.01 },
1560
+ ],
1561
+ },
1562
+ {
1563
+ id: "eval-2",
1564
+ when: "2026-07-13T09:00:00Z",
1565
+ score: 0.79,
1566
+ gateThreshold: 0.8,
1567
+ gatePass: false,
1568
+ regressions: 2,
1569
+ cost: "$1.35",
1570
+ p50: "910ms",
1571
+ componentScores: [
1572
+ { component: "sql_generation", score: 0.8, delta: -0.11 },
1573
+ { component: "render", score: 0.78, delta: -0.07 },
1574
+ ],
1575
+ },
1576
+ {
1577
+ id: "eval-3",
1578
+ when: "2026-07-16T09:00:00Z",
1579
+ score: 0.91,
1580
+ gateThreshold: 0.8,
1581
+ gatePass: true,
1582
+ regressions: 0,
1583
+ cost: "$1.10",
1584
+ p50: "790ms",
1585
+ componentScores: [
1586
+ { component: "sql_generation", score: 0.94, delta: 0.14 },
1587
+ { component: "render", score: 0.88, delta: 0.1 },
1588
+ ],
1589
+ },
1590
+ ];
1591
+ const stmt = this.db.prepare(`INSERT INTO eval_runs (id, when_ts, score, gate_threshold, gate_pass, regressions, cost, p50, component_scores_json)
1592
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
1593
+ for (const run of runs) {
1594
+ stmt.run(run.id, run.when, run.score, run.gateThreshold, boolToInt(run.gatePass), run.regressions, run.cost, run.p50, JSON.stringify(run.componentScores));
1595
+ }
1596
+ }
1597
+ /**
1598
+ * Fixture data: two `BlastRadius` seeds (`customers`, `products`) and
1599
+ * three verified Q-SQL pairs whose `refs` deliberately intersect one seed's
1600
+ * downstream set and miss the other's, so `server/impact.ts`'s live join
1601
+ * (see that file's doc comment) is actually exercised both ways — not
1602
+ * vacuously "always non-empty".
1603
+ */
1604
+ seedContext() {
1605
+ const models = [
1606
+ { key: "customers", name: "Customers", position: { x: 0, y: 0 }, columns: [{ name: "id", type: "bigint", key: "pk" }, { name: "name", type: "text" }] },
1607
+ {
1608
+ key: "orders",
1609
+ name: "Orders",
1610
+ position: { x: 240, y: 0 },
1611
+ columns: [
1612
+ { name: "id", type: "bigint", key: "pk" },
1613
+ { name: "customer_id", type: "bigint", key: "fk" },
1614
+ { name: "amount", type: "numeric" },
1615
+ ],
1616
+ },
1617
+ { key: "products", name: "Products", position: { x: 480, y: 0 }, columns: [{ name: "id", type: "bigint", key: "pk" }, { name: "name", type: "text" }] },
1618
+ ];
1619
+ const relationships = [
1620
+ { key: "orders-customers", name: "Orders → Customers", fromModel: "orders", toModel: "customers", type: "many-to-one" },
1621
+ { key: "orders-products", name: "Orders → Products", fromModel: "orders", toModel: "products", type: "many-to-many" },
1622
+ ];
1623
+ const measures = [
1624
+ { key: "total_revenue", name: "Total Revenue", baseModel: "orders", expression: "SUM(orders.amount)", additivity: "additive" },
1625
+ { key: "avg_order_value", name: "Average Order Value", baseModel: "orders", expression: "AVG(orders.amount)", additivity: "non-additive" },
1626
+ ];
1627
+ const knowledge = { instructionsPresent: true, verifiedPairCount: 3 };
1628
+ const files = [
1629
+ {
1630
+ key: "models",
1631
+ title: "Models",
1632
+ children: [
1633
+ { key: "file-customers", title: "customers.yaml", kind: "model", path: "models/customers.yaml", entityKey: "customers", content: "model: customers\ncolumns: [id, name]" },
1634
+ { key: "file-orders", title: "orders.yaml", kind: "model", path: "models/orders.yaml", entityKey: "orders", content: "model: orders\ncolumns: [id, customer_id, amount]" },
1635
+ { key: "file-products", title: "products.yaml", kind: "model", path: "models/products.yaml", entityKey: "products", content: "model: products\ncolumns: [id, name]" },
1636
+ ],
1637
+ },
1638
+ {
1639
+ key: "relationships",
1640
+ title: "Relationships",
1641
+ children: [
1642
+ {
1643
+ key: "file-orders-customers",
1644
+ title: "orders_customers.yaml",
1645
+ kind: "relationship",
1646
+ path: "relationships/orders_customers.yaml",
1647
+ entityKey: "orders-customers",
1648
+ content: "relationship: orders_customers\nfromModel: orders\ntoModel: customers\ntype: many-to-one\njoin:\n type: inner\n keys:\n - from: orders.customer_id\n to: customers.id\n",
1649
+ },
1650
+ {
1651
+ key: "file-orders-products",
1652
+ title: "orders_products.yaml",
1653
+ kind: "relationship",
1654
+ path: "relationships/orders_products.yaml",
1655
+ entityKey: "orders-products",
1656
+ content: "relationship: orders_products\nfromModel: orders\ntoModel: products\ntype: many-to-many\njoin:\n type: inner\n via: order_items\n keys:\n - from: orders.id\n to: order_items.order_id\n - from: order_items.product_id\n to: products.id\n",
1657
+ },
1658
+ ],
1659
+ },
1660
+ {
1661
+ key: "cubes",
1662
+ title: "Cubes",
1663
+ children: [
1664
+ {
1665
+ key: "file-total-revenue",
1666
+ title: "total_revenue.yaml",
1667
+ kind: "cube",
1668
+ path: "cubes/total_revenue.yaml",
1669
+ entityKey: "total_revenue",
1670
+ content: "cube: total_revenue\nbaseModel: orders\nexpression: SUM(orders.amount)\nadditivity: additive\n",
1671
+ },
1672
+ {
1673
+ key: "file-avg-order-value",
1674
+ title: "avg_order_value.yaml",
1675
+ kind: "cube",
1676
+ path: "cubes/avg_order_value.yaml",
1677
+ entityKey: "avg_order_value",
1678
+ content: "cube: avg_order_value\nbaseModel: orders\nexpression: AVG(orders.amount)\nadditivity: non-additive\n",
1679
+ },
1680
+ ],
1681
+ },
1682
+ { key: "knowledge", title: "Knowledge", kind: "knowledge", path: "knowledge/instructions.md", content: "Always filter cancelled orders unless asked otherwise." },
1683
+ ];
1684
+ const blastRadius = {
1685
+ customers: {
1686
+ seed: { key: "customers", name: "Customers", kind: "model" },
1687
+ downstream: [
1688
+ { key: "orders", name: "Orders", kind: "model" },
1689
+ { key: "total_revenue", name: "Total Revenue", kind: "measure" },
1690
+ { key: "orders-customers", name: "Orders → Customers", kind: "relationship" },
1691
+ ],
1692
+ severity: "structural",
1693
+ },
1694
+ products: {
1695
+ seed: { key: "products", name: "Products", kind: "model" },
1696
+ downstream: [{ key: "orders-products", name: "Orders → Products", kind: "relationship" }],
1697
+ severity: "compatibility",
1698
+ },
1699
+ };
1700
+ const verifiedPairs = [
1701
+ { question: "What is total revenue by customer this quarter?", refs: ["customers", "orders", "total_revenue"] },
1702
+ { question: "Which products had no orders last month?", refs: ["products", "orders-products"] },
1703
+ { question: "Show average order value trend.", refs: ["orders", "avg_order_value"] },
1704
+ ];
1705
+ this.setConfigJson("context.models", models);
1706
+ this.setConfigJson("context.relationships", relationships);
1707
+ this.setConfigJson("context.measures", measures);
1708
+ this.setConfigJson("context.knowledge", knowledge);
1709
+ this.setConfigJson("context.files", files);
1710
+ this.setConfigJson("context.blastRadius", blastRadius);
1711
+ this.setConfigJson("context.verifiedPairs", verifiedPairs);
1712
+ }
1713
+ }
1714
+ function rowToSession(row) {
1715
+ return {
1716
+ id: str(row, "id"),
1717
+ title: str(row, "title"),
1718
+ createdAt: str(row, "created_at"),
1719
+ updatedAt: str(row, "updated_at"),
1720
+ status: str(row, "status"),
1721
+ pendingQuestion: strOrNull(row, "pending_question"),
1722
+ pendingDecision: strOrNull(row, "pending_decision"),
1723
+ };
1724
+ }
1725
+ function rowToNativeSession(row) {
1726
+ return {
1727
+ id: str(row, "id"), purpose: str(row, "purpose"),
1728
+ vendor: str(row, "vendor"), agent: str(row, "agent"),
1729
+ scopeKind: str(row, "scope_kind"), scopeId: str(row, "scope_id"),
1730
+ projectIdentity: strOrNull(row, "project_identity"),
1731
+ bindingGeneration: row["binding_generation"] === null ? null : num(row, "binding_generation"),
1732
+ projectRevision: strOrNull(row, "project_revision"),
1733
+ dispatchProfile: strOrNull(row, "dispatch_profile"),
1734
+ dispatchTarget: strOrNull(row, "dispatch_target"),
1735
+ runtimeGeneration: row["runtime_generation"] === null ? null : num(row, "runtime_generation"),
1736
+ status: str(row, "status"),
1737
+ createdAt: str(row, "created_at"), updatedAt: str(row, "updated_at"),
1738
+ startedAt: strOrNull(row, "started_at"), endedAt: strOrNull(row, "ended_at"),
1739
+ exitCode: row["exit_code"] === null ? null : num(row, "exit_code"), failure: strOrNull(row, "failure"),
1740
+ };
1741
+ }
1742
+ function rowToNativeSetupRecovery(row) {
1743
+ return {
1744
+ sessionId: str(row, "session_id"),
1745
+ phase: str(row, "phase"),
1746
+ state: str(row, "state"),
1747
+ code: str(row, "code"),
1748
+ sequence: num(row, "sequence"),
1749
+ decision: strOrNull(row, "decision"),
1750
+ completionValidated: intToBool(row["completion_validated"]),
1751
+ version: num(row, "version"),
1752
+ createdAt: str(row, "created_at"),
1753
+ updatedAt: str(row, "updated_at"),
1754
+ };
1755
+ }
1756
+ function rowToNativeStructuredAnswer(row) {
1757
+ return {
1758
+ id: str(row, "id"),
1759
+ nativeSessionId: str(row, "native_session_id"),
1760
+ idempotencyKey: str(row, "idempotency_key"),
1761
+ envelopeJson: str(row, "envelope_json"),
1762
+ digest: str(row, "digest"),
1763
+ createdAt: str(row, "created_at"),
1764
+ };
1765
+ }
1766
+ function rowToEvent(row) {
1767
+ return {
1768
+ id: str(row, "id"),
1769
+ sessionId: str(row, "session_id"),
1770
+ seq: num(row, "seq"),
1771
+ kind: str(row, "kind"),
1772
+ payload: JSON.parse(str(row, "payload_json")),
1773
+ createdAt: str(row, "created_at"),
1774
+ turnId: strOrNull(row, "turn_id"),
1775
+ };
1776
+ }
1777
+ function rowToTurn(row) {
1778
+ return {
1779
+ id: str(row, "id"),
1780
+ sessionId: str(row, "session_id"),
1781
+ question: str(row, "question"),
1782
+ composedInput: strOrNull(row, "composed_input"),
1783
+ backend: strOrNull(row, "backend"),
1784
+ resultKind: strOrNull(row, "result_kind"),
1785
+ answerSummary: strOrNull(row, "answer_summary"),
1786
+ traceJson: strOrNull(row, "trace_json"),
1787
+ errorMessage: strOrNull(row, "error_message"),
1788
+ createdAt: str(row, "created_at"),
1789
+ agentId: strOrNull(row, "agent_id"),
1790
+ setupStepKey: strOrNull(row, "setup_step_key"),
1791
+ resumeSessionId: strOrNull(row, "resume_session_id"),
1792
+ resumeSessionProvider: strOrNull(row, "resume_session_provider"),
1793
+ resumeRunner: strOrNull(row, "resume_runner"),
1794
+ contextRecovery: strOrNull(row, "context_recovery"),
1795
+ workspaceRoot: strOrNull(row, "workspace_root"),
1796
+ };
1797
+ }
1798
+ function rowToArtifact(row) {
1799
+ return {
1800
+ id: str(row, "id"),
1801
+ sessionId: str(row, "session_id"),
1802
+ name: str(row, "name"),
1803
+ kind: str(row, "kind"),
1804
+ location: str(row, "location"),
1805
+ verified: intToBool(row["verified"]),
1806
+ createdAt: str(row, "created_at"),
1807
+ savedAt: strOrNull(row, "saved_at"),
1808
+ nativeSessionId: strOrNull(row, "native_session_id"),
1809
+ projectIdentity: strOrNull(row, "project_identity"),
1810
+ bindingGeneration: row["binding_generation"] === null || row["binding_generation"] === undefined ? null : num(row, "binding_generation"),
1811
+ projectRevision: strOrNull(row, "project_revision"),
1812
+ nativeVendor: strOrNull(row, "native_vendor"),
1813
+ nativeAgent: strOrNull(row, "native_agent"),
1814
+ contentDigest: strOrNull(row, "content_digest"),
1815
+ idempotencyKey: strOrNull(row, "idempotency_key"),
1816
+ sourceAnswerId: strOrNull(row, "source_answer_id"),
1817
+ };
1818
+ }
1819
+ function rowToEvalRun(row) {
1820
+ return {
1821
+ id: str(row, "id"),
1822
+ when: str(row, "when_ts"),
1823
+ score: num(row, "score"),
1824
+ gateThreshold: num(row, "gate_threshold"),
1825
+ gatePass: intToBool(row["gate_pass"]),
1826
+ regressions: num(row, "regressions"),
1827
+ cost: str(row, "cost"),
1828
+ p50: str(row, "p50"),
1829
+ };
1830
+ }
1831
+ function rowToEnrichmentRun(row) {
1832
+ return { id: str(row, "id"), mode: str(row, "mode"), projectPath: str(row, "project_path"), projectIdentity: str(row, "project_identity"), projectRevision: str(row, "project_revision"), proposalId: str(row, "proposal_id"), proposalHash: str(row, "proposal_hash"), status: str(row, "status"), createdAt: str(row, "created_at"), updatedAt: str(row, "updated_at"), validationDigest: strOrNull(row, "validation_digest"), buildDigest: strOrNull(row, "build_digest"), errorMessage: strOrNull(row, "error_message"), bindingGeneration: num(row, "binding_generation"), version: num(row, "version") };
1833
+ }
1834
+ function rowToEnrichmentOperation(row) {
1835
+ return { id: str(row, "operation_id"), sink: str(row, "sink"), risk: str(row, "risk"), summary: str(row, "summary"), draft: str(row, "draft"), changeKind: str(row, "change_kind"), confidence: normalizeEnrichmentConfidence(row["confidence"]), decision: strOrNull(row, "decision"), completed: str(row, "state") === "applied", state: str(row, "state"), attempt: num(row, "attempt"), leaseToken: strOrNull(row, "lease_token"), leaseExpiresAt: strOrNull(row, "lease_expires_at"), idempotencyKey: str(row, "idempotency_key") };
1836
+ }
1837
+ function rowToEnrichmentEvent(row) {
1838
+ return { id: str(row, "id"), runId: str(row, "run_id"), kind: str(row, "kind"), message: str(row, "message"), createdAt: str(row, "created_at") };
1839
+ }