@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,1074 @@
1
+ /**
2
+ * Orchestrates one turn end-to-end: clarify pre-flight, context
3
+ * composition, invoking `route()`, folding its result/events into the UI's
4
+ * wire types, and persisting everything needed to replay a resolved turn
5
+ * without re-invoking `route()`.
6
+ *
7
+ * `route` and `baseRouteOptions` are injected (`TurnDeps`) so tests can pass
8
+ * a stub `route`/`inProcess` without a real LLM or CLI — mirroring the seam
9
+ * `harness/route/route.ts` already uses internally for `inProcess`/`dispatched`.
10
+ */
11
+ import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
12
+ import path from "node:path";
13
+ import { BUILD_CONTEXT_AGENT_ID, contextLifecycleIdentityFingerprint, DEFAULT_SETUP_MAX_TURNS, DispatchedSessionError, parseSetupTerminal, recordedContextLifecyclePrefix, resolveArtifactsDir, WarbleCommandFailedError, } from "../harness/index.js";
14
+ import { newId } from "./db.js";
15
+ import { assertRuntimeSettingsDispatchable, materializeRuntimeRouteOptions } from "./runtime-binding.js";
16
+ import { classifyClarify } from "./clarify.js";
17
+ import { classifyIntent } from "./route-intent.js";
18
+ import { composeClarifyFollowUp, composeInput } from "./compose.js";
19
+ import { clarifyDecisionStep, extractTrace, foldTrace, gateDecisionStep, gateFailureStep, hostContractRecoveryStep, LiveWorkLog, redactPublicSetupText, sanitizeLiveSetupWorklog, sanitizePublicSetupWorklog, routeDecisionStep, summarizeResult, toAnswerOrRefusalEvent, toArtifactEvent, } from "./fold.js";
20
+ export class SessionNotFoundError extends Error {
21
+ sessionId;
22
+ constructor(sessionId) {
23
+ super(`session not found: ${sessionId}`);
24
+ this.sessionId = sessionId;
25
+ }
26
+ }
27
+ export class TurnNotFoundError extends Error {
28
+ turnId;
29
+ constructor(turnId) {
30
+ super(`turn not found: ${turnId}`);
31
+ this.turnId = turnId;
32
+ }
33
+ }
34
+ export class ArtifactNotFoundError extends Error {
35
+ artifactId;
36
+ constructor(artifactId) {
37
+ super(`artifact not found: ${artifactId}`);
38
+ this.artifactId = artifactId;
39
+ }
40
+ }
41
+ /**
42
+ * Thrown by `effectiveRouteOptions` when no wren project is bound yet
43
+ * (bootstrap-mode, before the setup wizard's bind step). Routes that require
44
+ * a bound project (`server/app.ts`'s 409 guard) check `isProjectBound` first
45
+ * and never actually let this escape in normal operation — it exists mainly
46
+ * so a caller that forgets the guard fails loudly instead of silently
47
+ * compiling against `undefined`.
48
+ */
49
+ /** Shared 409 message for every route that requires a bound project (`server/app.ts`'s guard) — single source of truth so the guard and `ProjectNotBoundError` never drift apart. */
50
+ export const PROJECT_NOT_BOUND_MESSAGE = "no wren project is bound yet — complete the setup wizard's connect/bind steps first";
51
+ export class ProjectNotBoundError extends Error {
52
+ constructor() {
53
+ super(PROJECT_NOT_BOUND_MESSAGE);
54
+ }
55
+ }
56
+ /** Whether a wren project is currently bound (always true for a `TurnDeps` without `getUserProject` wired — the pre-bootstrap-mode case). */
57
+ export function isProjectBound(deps) {
58
+ return resolveUserProject(deps) !== undefined;
59
+ }
60
+ /** Resolves the currently-bound project directory, preferring the mutable `getUserProject` binding over the fixed `baseRouteOptions.userProject`. */
61
+ export function resolveUserProject(deps) {
62
+ return deps.getUserProject ? deps.getUserProject() : deps.baseRouteOptions.userProject;
63
+ }
64
+ /** Resolves the auth choice actually in effect, preferring the mutable `getAuthChoice` binding over the fixed `baseRouteOptions.authChoice` — the auth-choice mirror of `resolveUserProject`. */
65
+ export function resolveAuthChoice(deps) {
66
+ return deps.getAuthChoice ? deps.getAuthChoice() : deps.baseRouteOptions.authChoice;
67
+ }
68
+ /** Resolves which `SetupStepRunner` a setup turn should dispatch through, given the auth choice actually in effect. Falls back to the fixed `deps.setupRunner` when `setupRunnerFor` isn't wired (pre-live-auth-choice behavior). */
69
+ export function resolveSetupRunner(deps) {
70
+ return deps.setupRunnerFor ? deps.setupRunnerFor(resolveAuthChoice(deps)) : deps.setupRunner;
71
+ }
72
+ /**
73
+ * `deps.baseRouteOptions` with `userProject`/`authChoice` re-resolved through
74
+ * their mutable bindings at call time. By default this throws while unbound;
75
+ * the read-only bootstrap Harness descriptor is the one deliberate exception
76
+ * and receives an empty project value that its raw-profile path never reads.
77
+ */
78
+ export function effectiveRouteOptions(deps, options) {
79
+ const userProject = resolveUserProject(deps);
80
+ if (userProject === undefined && !options?.allowUnbound)
81
+ throw new ProjectNotBoundError();
82
+ const authChoice = resolveAuthChoice(deps);
83
+ // Seeded settings only populate the Setup form; they are not an operator
84
+ // decision and must not shadow WREN_HARNESS_MODE/MODEL/API_KEY/ENDPOINT (or
85
+ // their CLI equivalents). Preserve the complete boot route until a
86
+ // validated PUT marks the persisted runtime settings explicit.
87
+ if (!deps.store.hasExplicitRuntimeSettings()) {
88
+ return { ...deps.baseRouteOptions, userProject: userProject ?? "", authChoice };
89
+ }
90
+ // These three fields are mutually exclusive runtime materializations. Drop
91
+ // any boot-time value before applying the live persisted choice so an auth
92
+ // switch cannot carry a stale in-process/Claude/Codex binding into another mode.
93
+ const { tierBinding: _bootTierBinding, modelsConfig: _bootModelsConfig, codexModels: _bootCodexModels, ...stableBaseRouteOptions } = deps.baseRouteOptions;
94
+ const settings = deps.store.getRuntimeSettings();
95
+ assertRuntimeSettingsDispatchable(settings);
96
+ return {
97
+ ...stableBaseRouteOptions,
98
+ userProject: userProject ?? "",
99
+ authChoice,
100
+ ...materializeRuntimeRouteOptions(settings, authChoice),
101
+ };
102
+ }
103
+ /** Evicts `deps`'s memoized compiled-bundle-agent-ids entry (see `bundleAgentInfoCache` below) — called by `bindProject` so a turn after a (re)bind recompiles against the newly-bound project instead of replaying a stale/empty cache entry. */
104
+ export function invalidateBundleAgentIdsCache(deps) {
105
+ bundleAgentInfoCache.delete(deps);
106
+ }
107
+ const RECENT_TURNS_FOR_COMPOSE = 5;
108
+ /**
109
+ * The compiled bundle's agent ids, used by `classifyIntent` to
110
+ * validate/fall back its rule-matched id. The bundle doesn't change between
111
+ * turns, so this resolves `deps.describeBundle` (compile-cache-backed, but
112
+ * still a real async compile+load) AT MOST ONCE per `TurnDeps` instance and
113
+ * reuses that same settled promise for every later turn — keyed off the
114
+ * `TurnDeps` object itself (a `WeakMap`, not a module-level singleton) so
115
+ * each test's own `TurnDeps` gets its own cache instead of leaking into
116
+ * others'. Falls back to `[]` — which makes `classifyIntent` always land on
117
+ * its `"answer_query"` default — when no `describeBundle` dependency is
118
+ * wired, or when it throws; intent routing must never crash a turn.
119
+ *
120
+ * Only a SUCCESSFUL resolution is cached. If `describeBundle` rejects (a
121
+ * transient compile-cache hiccup, momentary disk/binary issue, etc.), the
122
+ * failing entry is evicted from the cache so the very next call retries
123
+ * `describeBundle` from scratch instead of being permanently pinned to the
124
+ * `[]`/`answer_query`-only fallback for the rest of the process's life.
125
+ */
126
+ /**
127
+ * The compiled bundle's `artifact_write`-capable agent ids, cached
128
+ * alongside `agentIds` off the SAME `describeBundle` resolution (one compile
129
+ * per `TurnDeps`, not two) — used to decide whether a dispatched turn's rich
130
+ * answer should ALSO be persisted as an artifact (see `maybeCreateDispatchedArtifact`
131
+ * below). `generate_dashboard`/`explain_change` declare this capability in the
132
+ * genbi-default profile (see `bundle/schema.ts`'s `capabilitySchema`);
133
+ * `answer_query`/`explore_model` don't — this is a signal read straight off
134
+ * the bundle rather than a hardcoded agent-id allowlist, so a future
135
+ * artifact-producing component picks it up automatically.
136
+ */
137
+ const ARTIFACT_WRITE_CAPABILITY = "artifact_write";
138
+ const EMPTY_BUNDLE_AGENT_INFO = { agentIds: [], artifactProducerAgentIds: new Set() };
139
+ const bundleAgentInfoCache = new WeakMap();
140
+ async function getBundleAgentInfo(deps) {
141
+ if (!deps.describeBundle)
142
+ return EMPTY_BUNDLE_AGENT_INFO;
143
+ const describeBundle = deps.describeBundle;
144
+ let pending = bundleAgentInfoCache.get(deps);
145
+ if (!pending) {
146
+ // Wrapped in an async IIFE so a synchronous throw from
147
+ // `effectiveRouteOptions` (unbound project — bootstrap mode) becomes a
148
+ // rejected promise, caught by the try/catch below exactly like a
149
+ // rejected `describeBundle` call, instead of escaping this function.
150
+ pending = (async () => {
151
+ const bundle = await describeBundle(effectiveRouteOptions(deps));
152
+ const artifactProducerAgentIds = new Set(bundle.agents.filter((agent) => agent.capabilities.some((c) => c.capability === ARTIFACT_WRITE_CAPABILITY)).map((agent) => agent.id));
153
+ return { agentIds: bundle.agents.map((agent) => agent.id), artifactProducerAgentIds };
154
+ })();
155
+ bundleAgentInfoCache.set(deps, pending);
156
+ }
157
+ try {
158
+ return await pending;
159
+ }
160
+ catch {
161
+ // Evict so the next turn re-attempts describeBundle rather than being
162
+ // stuck on this rejected promise forever; this turn falls back to empty.
163
+ bundleAgentInfoCache.delete(deps);
164
+ return EMPTY_BUNDLE_AGENT_INFO;
165
+ }
166
+ }
167
+ async function getBundleAgentIds(deps) {
168
+ return (await getBundleAgentInfo(deps)).agentIds;
169
+ }
170
+ /** Whether `agentId`'s compiled bundle agent declares the `artifact_write` capability — see `BundleAgentInfo`. */
171
+ async function isArtifactProducerAgent(deps, agentId) {
172
+ return (await getBundleAgentInfo(deps)).artifactProducerAgentIds.has(agentId);
173
+ }
174
+ /** `generate_dashboard` -> `"dashboard"`; every other artifact-producing agent (e.g. `explain_change`) -> `"report"` — mirrors in-process's `write_artifact` default (`harness/loop/executor.ts`). */
175
+ function artifactKindForAgent(agentId) {
176
+ return agentId === "generate_dashboard" ? "dashboard" : "report";
177
+ }
178
+ /** POST /api/sessions/:id/turns — persists the user event, runs the D1 clarify pre-flight, and either short-circuits with a clarify prompt or prepares a turn for streaming. */
179
+ export async function postTurn(deps, sessionId, question) {
180
+ const session = deps.store.getSession(sessionId);
181
+ if (!session)
182
+ throw new SessionNotFoundError(sessionId);
183
+ const userEvent = { id: newId("evt"), kind: "user", text: question };
184
+ deps.store.insertEvent({ sessionId, kind: "user", payload: userEvent, turnId: null });
185
+ const clarify = classifyClarify(question);
186
+ const turnId = newId("turn");
187
+ if (clarify) {
188
+ const clarifyEvent = { id: newId("evt"), kind: "clarify", prompt: clarify.prompt, chips: [...clarify.chips] };
189
+ // Classify against the ORIGINAL question — this turn never reaches route(), but
190
+ // the id is still persisted for consistency/debuggability of the turn row.
191
+ const { agentId, reason } = classifyIntent(question, await getBundleAgentIds(deps));
192
+ // Persist the deterministic decisions (Route + Clarify) into the
193
+ // turn's work log so GET /api/sessions/:id and a resumed stream show why the
194
+ // turn routed and what it clarified. No verify-gate entry: a clarify turn
195
+ // short-circuits before route() runs, so there is no verdict to report.
196
+ const decisions = [routeDecisionStep(agentId, reason), clarifyDecisionStep(clarify.prompt)];
197
+ deps.store.createTurn({ id: turnId, sessionId, question, composedInput: null, agentId, traceJson: JSON.stringify(decisions) });
198
+ deps.store.insertEvent({ sessionId, kind: "clarify", payload: clarifyEvent, turnId });
199
+ deps.store.markTurnClarify(turnId);
200
+ // Re-clarify: if a clarify is already pending, keep the ORIGINAL question being
201
+ // clarified rather than overwriting it with this (also-vague) follow-up.
202
+ const pendingQuestion = session.status === "awaiting_clarify" && session.pendingQuestion ? session.pendingQuestion : question;
203
+ deps.store.updateSessionStatus(sessionId, "awaiting_clarify", pendingQuestion);
204
+ return { turnId, clarify: clarifyEvent };
205
+ }
206
+ const effectiveQuestion = session.status === "awaiting_clarify" && session.pendingQuestion
207
+ ? composeClarifyFollowUp(session.pendingQuestion, question)
208
+ : question;
209
+ // Classify against the EFFECTIVE question — the one actually sent to route() below
210
+ // (a bare clarify follow-up like "this quarter" carries none of the original intent words).
211
+ const { agentId, reason } = classifyIntent(effectiveQuestion, await getBundleAgentIds(deps));
212
+ const priorTurns = deps.store.listRecentResolvedTurns(sessionId, RECENT_TURNS_FOR_COMPOSE);
213
+ const composedInput = composeInput(priorTurns, effectiveQuestion);
214
+ // Seed the turn's work log with the Route decision so executeTurn can
215
+ // replay it back (reason and all) without re-classifying, and GET /api/sessions/:id
216
+ // shows the routing decision even before the turn has executed.
217
+ const decisions = [routeDecisionStep(agentId, reason)];
218
+ deps.store.createTurn({ id: turnId, sessionId, question, composedInput, agentId, traceJson: JSON.stringify(decisions) });
219
+ deps.store.updateSessionStatus(sessionId, "streaming", null); // clears any pending_question just consumed above
220
+ return { turnId };
221
+ }
222
+ /** GET (SSE) /api/sessions/:id/stream?turn=:turnId — replays a resolved turn, or executes+streams a pending one. */
223
+ export async function streamTurn(deps, sessionId, turnId, emit, signal) {
224
+ const session = deps.store.getSession(sessionId);
225
+ if (!session)
226
+ throw new SessionNotFoundError(sessionId);
227
+ const turn = deps.store.getTurn(turnId);
228
+ if (!turn || turn.sessionId !== sessionId)
229
+ throw new TurnNotFoundError(turnId);
230
+ if (turn.resultKind === "clarify") {
231
+ const stored = deps.store.listEventsForTurn(turnId).find((e) => e.kind === "clarify");
232
+ if (stored)
233
+ await emit({ event: "event", data: stored.payload });
234
+ await emit({ event: "done", data: {} });
235
+ return;
236
+ }
237
+ if (turn.resultKind) {
238
+ await replayResolvedTurn(deps.store, turn, emit);
239
+ return;
240
+ }
241
+ await executeTurn(deps, session, turn, emit, signal);
242
+ }
243
+ async function replayResolvedTurn(store, turn, emit) {
244
+ if (turn.resultKind === "error") {
245
+ await emit({ event: "error", data: { message: turn.errorMessage ?? "unknown error" } });
246
+ return; // spec: on error emit ONLY the error frame, never a trailing done
247
+ }
248
+ const worklog = turn.traceJson ? JSON.parse(turn.traceJson) : [];
249
+ await emit({ event: "worklog", data: worklog });
250
+ const events = store.listEventsForTurn(turn.id).filter((e) => e.kind !== "user" && e.kind !== "clarify");
251
+ for (const stored of events) {
252
+ await emit({ event: "event", data: stored.payload });
253
+ }
254
+ await emit({ event: "done", data: {} });
255
+ }
256
+ async function executeTurn(deps, session, turn, emit, signal) {
257
+ if (turn.setupStepKey !== null) {
258
+ await executeSetupTurn(deps, session, turn, emit);
259
+ return;
260
+ }
261
+ const liveLog = new LiveWorkLog();
262
+ // Serializes best-effort live worklog/artifact frames behind route()'s synchronous onEvent callback,
263
+ // so concurrent SSE writes never interleave out of order (see server/fold.ts's doc comment).
264
+ let chain = Promise.resolve();
265
+ // The turn's pre-execution decision entries (the Route decision,
266
+ // assembled + persisted at postTurn time). Prepended to every emitted work-log
267
+ // snapshot and to the finalized log so the routing decision LEADS the list —
268
+ // it is the first entry in the first content-bearing worklog frame a live
269
+ // client sees, ahead of the agent's own steps. (A dedicated pre-route frame is
270
+ // deliberately avoided: it would leak into the error path, whose contract is to
271
+ // emit ONLY the error frame, and would break resolved-turn replay frame-parity.)
272
+ const preDecisions = turn.traceJson ? JSON.parse(turn.traceJson) : [];
273
+ const onEvent = (event) => {
274
+ if (event.kind === "artifact") {
275
+ // No live verification signal exists yet for a freshly-produced artifact — default to unverified.
276
+ const artifactRow = deps.store.createArtifact({ sessionId: session.id, name: event.name, kind: event.artifactKind, location: event.location, verified: false });
277
+ const artifactEvent = toArtifactEvent(newId("evt"), event.name, event.artifactKind, event.location, artifactRow.id);
278
+ deps.store.insertEvent({ sessionId: session.id, kind: "artifact", payload: artifactEvent, turnId: turn.id });
279
+ chain = chain.then(() => emit({ event: "event", data: artifactEvent }));
280
+ return;
281
+ }
282
+ const snapshot = liveLog.ingest(event);
283
+ if (snapshot) {
284
+ const merged = [...preDecisions, ...snapshot];
285
+ chain = chain.then(() => emit({ event: "worklog", data: merged }));
286
+ }
287
+ };
288
+ let result;
289
+ try {
290
+ const routeOptions = {
291
+ ...effectiveRouteOptions(deps),
292
+ question: turn.composedInput ?? turn.question,
293
+ onEvent,
294
+ ...(signal !== undefined ? { signal } : {}),
295
+ // the turn's own persisted agent id (classified once at postTurn time), so a turn
296
+ // always resolves to the same agent whether executed live or (via a future replay path) re-run.
297
+ ...(turn.agentId !== null ? { agentId: turn.agentId } : {}),
298
+ };
299
+ result = await deps.route(routeOptions);
300
+ }
301
+ catch (err) {
302
+ await chain;
303
+ const message = err instanceof Error ? err.message : String(err);
304
+ // Persist the PARTIAL work log on failure — the Route/Clarify
305
+ // decision(s) seeded at postTurn plus whatever step/tool rows the live log
306
+ // captured before the throw, ended with a failure Verify-gate entry saying
307
+ // why it failed. This is exactly when a user most wants the decision trail,
308
+ // so a failed turn's GET /api/sessions/:id shows it instead of an empty log.
309
+ // The SSE stream stays error-only (no worklog frame emitted here); only the
310
+ // persisted traceJson changes.
311
+ const partialWorklog = [...preDecisions, ...liveLog.snapshot(), gateFailureStep(message)];
312
+ deps.store.resolveTurn(turn.id, { backend: null, resultKind: "error", answerSummary: null, traceJson: JSON.stringify(partialWorklog), errorMessage: message });
313
+ deps.store.updateSessionStatus(session.id, "active", null);
314
+ await emit({ event: "error", data: { message } });
315
+ return; // spec: on error emit ONLY the error frame, never a trailing done
316
+ }
317
+ await chain;
318
+ // Prefer the live worklog snapshot (LLM step rows interleaved
319
+ // with tool calls, each with its own detail) whenever it has content — it's
320
+ // strictly richer than the floor trace's tool-outcomes-only view. Fall back
321
+ // to `foldTrace(trace)` only when the live log is empty (no `onEvent`
322
+ // firings reached it at all, e.g. Dispatched, or a caller that never wires
323
+ // live events) — this exactly preserves prior behavior for those cases.
324
+ const trace = extractTrace(result);
325
+ const liveSnapshot = liveLog.snapshot();
326
+ const agentWorklog = liveSnapshot.length > 0 ? liveSnapshot : trace ? foldTrace(trace) : [];
327
+ // Worklog order: [Route (+ any pre-exec decisions), ...agent steps, Verify gate?].
328
+ // The gate is display-only and derived from the resolved result — a verified
329
+ // answer or a refusal produces a verdict; a non-verified answer omits it. The
330
+ // deterministic gate/result itself (terminalEvent below) is unchanged.
331
+ const gate = gateDecisionStep(result);
332
+ const worklog = [...preDecisions, ...agentWorklog, ...(gate ? [gate] : [])];
333
+ const terminalEvent = toAnswerOrRefusalEvent(newId("evt"), result, agentWorklog);
334
+ // Subscription dispatchers (`agent-sdk` and `codex-local`) do not emit a
335
+ // native `artifact` AgentEvent the way the in-process `write_artifact` tool
336
+ // does above (via `onEvent`) — their structured output only reaches the
337
+ // BFF as a recovered rich envelope inside `terminalEvent` (see
338
+ // `toAnswerOrRefusalEvent`'s doc comment). When that answer came from an
339
+ // artifact-producing component (`generate_dashboard`/`explain_change` —
340
+ // NOT a plain `answer_query` table), persist it as a real artifact too, so
341
+ // it's publishable regardless of which backend ran the turn.
342
+ const artifactEvent = await maybeCreateDispatchedArtifact(deps, session, turn, result, terminalEvent);
343
+ // Persist the turn as fully resolved BEFORE emitting any terminal frames.
344
+ // If a client disconnect makes an emit below throw, the turn is already
345
+ // resolved, so the next stream deterministically REPLAYS these stored frames
346
+ // instead of re-invoking route() (which would duplicate the terminal event).
347
+ deps.store.insertEvent({ sessionId: session.id, kind: terminalEvent.kind, payload: terminalEvent, turnId: turn.id });
348
+ deps.store.resolveTurn(turn.id, {
349
+ backend: result.backend,
350
+ resultKind: terminalEvent.kind,
351
+ answerSummary: summarizeResult(result),
352
+ traceJson: JSON.stringify(worklog),
353
+ errorMessage: null,
354
+ });
355
+ deps.store.updateSessionStatus(session.id, "active", null);
356
+ await emit({ event: "worklog", data: worklog });
357
+ if (artifactEvent)
358
+ await emit({ event: "event", data: artifactEvent });
359
+ await emit({ event: "event", data: terminalEvent });
360
+ await emit({ event: "done", data: {} });
361
+ }
362
+ /**
363
+ * Subscription-dispatch analog of the `onEvent` handler's in-process
364
+ * `artifact` case in `executeTurn` above: subscription dispatchers have no
365
+ * native `artifact` AgentEvent, so this derives one from the turn's own
366
+ * already-resolved rich answer instead.
367
+ * Returns `undefined` (no artifact created/persisted, no frame emitted)
368
+ * unless ALL of:
369
+ * - a subscription dispatcher ran this turn (`result.backend` is
370
+ * `"agent-sdk"` or `"codex-local"`);
371
+ * - the terminal event is a rich answer (a recovered `RenderEnvelope`, not
372
+ * the `form: "text"` fallback);
373
+ * - the turn's routed agent (`turn.agentId`, classified once at `postTurn`
374
+ * time) declares the `artifact_write` capability in the compiled bundle —
375
+ * so a plain `answer_query` rich table never creates one.
376
+ *
377
+ * Idempotent by construction: `executeTurn` itself only ever runs for a turn
378
+ * that isn't resolved yet — once `deps.store.resolveTurn` below marks it
379
+ * resolved, a later `streamTurn` call replays the persisted frames instead
380
+ * of re-invoking `executeTurn` (see `replayResolvedTurn`) — so this can never
381
+ * fire twice for the same turn.
382
+ *
383
+ * Persisted representation: the envelope IS the artifact's content, written
384
+ * verbatim as JSON to a session-scoped file under the same artifacts root
385
+ * the in-process `write_artifact` tool uses (`resolveArtifactsDir`) — self-
386
+ * contained from the envelope, no dependency on the dispatcher's `htmlPath`
387
+ * (never present in the NDJSON stream today). `verified` comes straight from
388
+ * the envelope's own `verified` field, never hardcoded.
389
+ */
390
+ async function maybeCreateDispatchedArtifact(deps, session, turn, result, terminalEvent) {
391
+ if (result.backend !== "agent-sdk" && result.backend !== "codex-local")
392
+ return undefined;
393
+ if (terminalEvent.kind !== "answer" || terminalEvent.answer.form !== "rich")
394
+ return undefined;
395
+ if (turn.agentId === null)
396
+ return undefined;
397
+ if (!(await isArtifactProducerAgent(deps, turn.agentId)))
398
+ return undefined;
399
+ const envelope = terminalEvent.answer.envelope;
400
+ const artifactKind = artifactKindForAgent(turn.agentId);
401
+ const verified = envelope.verified === true;
402
+ const sessionArtifactsDir = path.join(resolveArtifactsDir(effectiveRouteOptions(deps).outDir), session.id);
403
+ mkdirSync(sessionArtifactsDir, { recursive: true });
404
+ const name = `${artifactKind}-${turn.id}.json`;
405
+ const location = path.join(sessionArtifactsDir, name);
406
+ writeFileSync(location, JSON.stringify(envelope, null, 2), "utf8");
407
+ const artifactRow = deps.store.createArtifact({ sessionId: session.id, name, kind: artifactKind, location, verified });
408
+ const artifactEvent = toArtifactEvent(newId("evt"), name, artifactKind, location, artifactRow.id);
409
+ deps.store.insertEvent({ sessionId: session.id, kind: "artifact", payload: artifactEvent, turnId: turn.id });
410
+ return artifactEvent;
411
+ }
412
+ /** Whether a caught setup-turn error is the dispatcher's `error_max_turns` exit (dispatched running out of its configured turn budget), vs. any other genuine failure. */
413
+ function isMaxTurnsError(message) {
414
+ return /error_max_turns/i.test(message);
415
+ }
416
+ /**
417
+ * Progress signal for a `context`-step `error_max_turns` checkpoint:
418
+ * counts model directories already written under `<projectDir>/models` (one
419
+ * subdirectory per model — confirmed against the driftwood fixture project),
420
+ * so the "continue" decision's label can show real progress instead of a bare
421
+ * "ran out of turns". Returns 0 (never throws) if `models/` doesn't exist yet
422
+ * — a legitimate state if the agent hadn't written any model before the budget ran out.
423
+ */
424
+ function countModelDirs(projectDir) {
425
+ try {
426
+ return readdirSync(path.join(projectDir, "models"), { withFileTypes: true }).filter((entry) => entry.isDirectory()).length;
427
+ }
428
+ catch {
429
+ return 0;
430
+ }
431
+ }
432
+ /**
433
+ * Turns a `describeBundle`/`compileProfile` failure into a short,
434
+ * user-facing summary instead of dumping raw `warble` stderr (often many
435
+ * lines of low-level compiler diagnostics) into a `setup_status` message. A
436
+ * `WarbleCommandFailedError` reports its primary diagnostic on the FIRST line
437
+ * of `stderr` (warble puts the actual error there; any remaining lines are
438
+ * supporting detail/context) — falls back to the first line of `err.message`
439
+ * for any other failure shape (e.g. `WarbleBinaryNotFoundError`).
440
+ */
441
+ function summarizeCompileHealthcheckFailure(err) {
442
+ if (err instanceof WarbleCommandFailedError) {
443
+ const firstStderrLine = err.stderr.trim().split("\n")[0];
444
+ if (firstStderrLine)
445
+ return firstStderrLine;
446
+ }
447
+ const message = err instanceof Error ? err.message : String(err);
448
+ return message.split("\n")[0] ?? message;
449
+ }
450
+ function nonEmptySessionId(value) {
451
+ return typeof value === "string" && value.trim().length > 0;
452
+ }
453
+ /** The only thrown setup error that can carry a safe, resumable dispatcher anchor. */
454
+ function sessionIdFromSetupError(error) {
455
+ return error instanceof DispatchedSessionError && nonEmptySessionId(error.sessionId) ? error.sessionId : undefined;
456
+ }
457
+ /**
458
+ * The second dispatch is not a new task: it continues the completed agent
459
+ * conversation with host-owned, secret-free evidence. `HostContractDiagnostic`
460
+ * values are intentionally static artifact/workflow facts, never tool stdout,
461
+ * stderr, or credential-bearing project content.
462
+ */
463
+ function composeHostContractCorrection(diagnostic) {
464
+ return ("Host-side validation rejected your claimed SETUP_STATUS: ok. Correct the existing project rather than merely restating success. " +
465
+ `Observed host-contract failure: ${diagnostic.observed}. ` +
466
+ `Required artifact contract before another SETUP_STATUS: ok: ${diagnostic.expectedArtifactContract.join("; ")}. ` +
467
+ "Use the work already completed in this conversation, make the real correction, and then re-check the contract. " +
468
+ "Do not read, print, or ask for credential values. If the correction cannot be completed, report SETUP_STATUS: error or SETUP_STATUS: needs_input honestly.");
469
+ }
470
+ /** One bounded same-session repair for a structurally incomplete final response. */
471
+ function composeTerminalContractCorrection() {
472
+ return ("Your previous completed response omitted the required SETUP_STATUS terminal line. Continue the existing setup attempt; do not replay, rebuild, clean, or overwrite completed work. " +
473
+ "Inspect the existing project state only as needed to determine the honest outcome. End this response with exactly one line in the form SETUP_STATUS: ok|needs_input|error - short reason. " +
474
+ "Do not read, print, or ask for credential values.");
475
+ }
476
+ function safeHostContractSummary(diagnostic) {
477
+ return `host contract ${diagnostic.code}: ${diagnostic.observed}; required: ${diagnostic.expectedArtifactContract.join("; ")}`;
478
+ }
479
+ function recoveryAnchor(sessionId, authChoice) {
480
+ if (!nonEmptySessionId(sessionId) || authChoice.mode !== "subscription")
481
+ return undefined;
482
+ return { sessionId, provider: authChoice.provider, runner: `subscription:${authChoice.provider}` };
483
+ }
484
+ /** Preserve a compatible input anchor when a resumed dispatcher omits a new id. */
485
+ function turnRecoveryAnchor(turn, sessionId, authChoice) {
486
+ const completed = recoveryAnchor(sessionId, authChoice);
487
+ if (completed !== undefined)
488
+ return completed;
489
+ if (authChoice.mode === "subscription" &&
490
+ nonEmptySessionId(turn.resumeSessionId) &&
491
+ turn.resumeSessionProvider === authChoice.provider &&
492
+ turn.resumeRunner === `subscription:${authChoice.provider}`) {
493
+ return { sessionId: turn.resumeSessionId, provider: authChoice.provider, runner: turn.resumeRunner };
494
+ }
495
+ return undefined;
496
+ }
497
+ /**
498
+ * Dispatches a setup-wizard turn (`turn.setupStepKey !== null`) via
499
+ * `deps.setupRunner` instead of `deps.route(...)`, reusing the exact same SSE
500
+ * worklog-streaming machinery (`LiveWorkLog` + the same `onEvent`-to-`emit`
501
+ * chaining pattern as `executeTurn`) so a setup turn's tool/step frames look
502
+ * identical to an Ask turn's on the wire.
503
+ *
504
+ * Terminal handling (see `harness/setup/runner.ts`): the composed
505
+ * prompt's `SETUP_STATUS: ok|needs_input|error` line is parsed via
506
+ * `parseSetupTerminal` (which also independently verifies an on-disk marker
507
+ * for an `ok`, keyed on `stepKey` — `wren_project.yml` for `connect`,
508
+ * `.wren-validated` for `connect_resume`, and `target/mdl.json` with at least
509
+ * one model for `context`). A parsed `error`
510
+ * (including the agent claiming `ok` without the file existing) follows the
511
+ * same "error frame only, never a trailing done" convention as a thrown
512
+ * `route()` error. `ok`/`needs_input` both persist as `resultKind: "answer"`
513
+ * — `TurnResultKind` has no dedicated setup-specific values, and neither
514
+ * outcome is a `clarify` or `refusal` in the existing sense; the real
515
+ * three-way distinction is carried in the `SetupStatusEvent` payload's
516
+ * `status` field, which the frontend inspects (see the wire contract in the
517
+ * final report). Only a parsed `error` persists as `resultKind: "error"`.
518
+ */
519
+ async function executeSetupTurn(deps, session, turn, emit) {
520
+ // Provider session anchors are server-only. Keep every anchor that becomes
521
+ // known during this turn so a host-contract continuation that rotates its
522
+ // anchor still redacts both the original and replacement from the combined
523
+ // worklog.
524
+ const knownInternalValues = [turn.resumeSessionId, turn.resumeSessionProvider, turn.resumeRunner]
525
+ .filter((value) => typeof value === "string");
526
+ const rememberAnchor = (anchor) => {
527
+ if (anchor === undefined)
528
+ return;
529
+ for (const value of [anchor.sessionId, anchor.provider, anchor.runner]) {
530
+ if (!knownInternalValues.includes(value))
531
+ knownInternalValues.push(value);
532
+ }
533
+ };
534
+ const failWithError = async (message, worklog, resumableAnchor) => {
535
+ const internalValues = [
536
+ ...knownInternalValues,
537
+ resumableAnchor?.sessionId,
538
+ resumableAnchor?.provider,
539
+ resumableAnchor?.runner,
540
+ ].filter((value) => typeof value === "string");
541
+ const publicMessage = redactPublicSetupText(message, internalValues);
542
+ const publicWorklog = sanitizePublicSetupWorklog(worklog, internalValues);
543
+ if (resumableAnchor !== undefined)
544
+ deps.store.setTurnResumeAnchor(turn.id, resumableAnchor);
545
+ deps.store.resolveTurn(turn.id, { backend: null, resultKind: "error", answerSummary: null, traceJson: JSON.stringify(publicWorklog), errorMessage: publicMessage });
546
+ deps.store.updateSessionStatus(session.id, "active", null);
547
+ await emit({ event: "error", data: { message: publicMessage } });
548
+ };
549
+ // Adopt-flow turns (context-build against an already-existing project outside the
550
+ // bootstrap workspace root) carry their own `workspaceRoot` on the turn row —
551
+ // `turn.workspaceRoot ?? deps.workspaceRoot` lets every create-flow turn (which never
552
+ // sets it) keep resolving against the configured bootstrap root exactly as before.
553
+ const workspaceRoot = turn.workspaceRoot ?? deps.workspaceRoot;
554
+ // Resolved through the live auth-choice binding, not the boot-fixed `deps.setupRunner`,
555
+ // so a setup turn dispatches through the runner matching whichever auth mode is
556
+ // actually bound right now (see `resolveSetupRunner`).
557
+ const setupRunner = resolveSetupRunner(deps);
558
+ if (!setupRunner || workspaceRoot === undefined) {
559
+ await failWithError("agentic setup is not configured on this BFF instance (missing setupRunner/workspaceRoot)", []);
560
+ return;
561
+ }
562
+ const form = deps.store.getSetupConnectForm();
563
+ if (!form) {
564
+ await failWithError("no setup connect form is on record — cannot resolve which project this setup turn is onboarding", []);
565
+ return;
566
+ }
567
+ const useRetainedContextLifecycle = turn.setupStepKey === "context" &&
568
+ (turn.contextRecovery === "lifecycle" || turn.contextRecovery === "schema_discovery");
569
+ // A normal context rebuild is a fresh lifecycle even when a caller bypasses
570
+ // the route that normally clears prior proof. Only explicit corrective turns
571
+ // may read or merge against retained evidence.
572
+ if (turn.setupStepKey === "context" && !useRetainedContextLifecycle)
573
+ deps.store.clearSetupContextLifecycleEvidence();
574
+ /**
575
+ * Terminal verification receives only identity-matching host evidence from
576
+ * earlier attempts. The current worklog can extend it monotonically, but an
577
+ * agent's final message can neither manufacture nor reset the prefix.
578
+ */
579
+ const retainContextLifecycle = (worklog, allowBuild = false) => {
580
+ const identityFingerprint = turn.setupStepKey === "context"
581
+ ? contextLifecycleIdentityFingerprint(workspaceRoot, form.projectName, form.sourceType)
582
+ : undefined;
583
+ const prior = !useRetainedContextLifecycle || identityFingerprint === undefined
584
+ ? undefined
585
+ : deps.store.getSetupContextLifecycleEvidence(session.id, identityFingerprint)?.completed;
586
+ if (identityFingerprint !== undefined) {
587
+ const recorded = recordedContextLifecyclePrefix(worklog, prior ?? "none");
588
+ // A build command is not retained by itself: only a successful terminal
589
+ // parse has independently checked target/mdl.json. Discovery/validate
590
+ // remain useful across an interrupted turn, but build includes artifact
591
+ // proof only after that host check passes.
592
+ const completed = recorded === "build" && !allowBuild ? "validate" : recorded;
593
+ if (completed !== "none") {
594
+ deps.store.mergeSetupContextLifecycleEvidence({ sessionId: session.id, identityFingerprint, completed });
595
+ }
596
+ }
597
+ };
598
+ const parseTerminalWithRetainedLifecycle = (finalText, worklog) => {
599
+ const identityFingerprint = turn.setupStepKey === "context"
600
+ ? contextLifecycleIdentityFingerprint(workspaceRoot, form.projectName, form.sourceType)
601
+ : undefined;
602
+ const prior = !useRetainedContextLifecycle || identityFingerprint === undefined
603
+ ? undefined
604
+ : deps.store.getSetupContextLifecycleEvidence(session.id, identityFingerprint)?.completed;
605
+ const terminal = parseSetupTerminal(finalText, {
606
+ root: workspaceRoot,
607
+ name: form.projectName,
608
+ ...(turn.setupStepKey !== null ? { stepKey: turn.setupStepKey } : {}),
609
+ expectedSourceType: form.sourceType,
610
+ worklog,
611
+ ...(prior !== undefined ? { priorContextLifecycle: prior } : {}),
612
+ });
613
+ retainContextLifecycle(worklog, terminal.status === "ok");
614
+ return terminal;
615
+ };
616
+ const liveLog = new LiveWorkLog();
617
+ let chain = Promise.resolve();
618
+ let recoveryEvidence;
619
+ let worklogBeforeRecovery;
620
+ const publicWorklog = (snapshot) => {
621
+ const ordered = recoveryEvidence === undefined || worklogBeforeRecovery === undefined
622
+ ? snapshot
623
+ : [...worklogBeforeRecovery, recoveryEvidence, ...snapshot.slice(worklogBeforeRecovery.length)];
624
+ return sanitizePublicSetupWorklog(ordered, knownInternalValues);
625
+ };
626
+ const initialAuthChoice = resolveAuthChoice(deps);
627
+ const initialResumableAnchor = turnRecoveryAnchor(turn, undefined, initialAuthChoice);
628
+ rememberAnchor(initialResumableAnchor);
629
+ // A subscription attempt can rotate to a replacement server-owned session
630
+ // anchor even when it was supplied a compatible resume anchor, so
631
+ // `publicWorklog`'s by-KNOWN-VALUE redaction above can't be used on a live
632
+ // frame: the anchor it would need to scrub isn't known until the attempt
633
+ // resolves or throws. Withholding every frame until then was the previous
634
+ // behavior — a subscription setup turn showed no progress for minutes,
635
+ // then every worklog entry at once. Stream subscription live
636
+ // frames through `sanitizeLiveSetupWorklog` instead: by-shape redaction
637
+ // plus dropping `input`/`detail` outright, a real accepted weakening
638
+ // versus withholding everything (see that function's doc comment).
639
+ // Non-subscription attempts are unaffected and keep using `publicWorklog`
640
+ // exactly as before.
641
+ const onEvent = (event) => {
642
+ const snapshot = liveLog.ingest(event);
643
+ if (!snapshot)
644
+ return;
645
+ const data = initialAuthChoice.mode === "subscription" ? sanitizeLiveSetupWorklog(snapshot) : publicWorklog(snapshot);
646
+ chain = chain.then(() => emit({ event: "worklog", data }));
647
+ };
648
+ const runAttempt = (prompt, authChoice, resumeSessionId) => setupRunner.run({
649
+ prompt,
650
+ workspaceRoot,
651
+ // The form was validated before persistence. Thread it to dispatched so
652
+ // resumed connect/context turns bind the SDK cwd and mutation scope to
653
+ // the project itself rather than the outer scaffold workspace.
654
+ projectName: form.projectName,
655
+ stepKey: turn.setupStepKey === "connect_resume" || turn.setupStepKey === "context" ? turn.setupStepKey : "connect",
656
+ authChoice,
657
+ // The turn's own persisted agentId (CONNECT_SOURCE_AGENT_ID for connect/connect_resume,
658
+ // BUILD_CONTEXT_AGENT_ID for context — see server/app.ts's setup routes) so the setup
659
+ // runner dispatches the RIGHT warble component instead of always connect_source.
660
+ ...(turn.agentId !== null ? { agentId: turn.agentId } : {}),
661
+ ...(resumeSessionId !== undefined ? { resumeSessionId } : {}),
662
+ onEvent,
663
+ });
664
+ let finalText;
665
+ let completedSessionId;
666
+ try {
667
+ const result = await runAttempt(turn.composedInput ?? turn.question, initialAuthChoice, initialResumableAnchor?.sessionId);
668
+ finalText = result.finalText;
669
+ completedSessionId = result.sessionId;
670
+ rememberAnchor(turnRecoveryAnchor(turn, completedSessionId, initialAuthChoice));
671
+ }
672
+ catch (err) {
673
+ // Hang-bug fix: a rejected queued worklog `emit` must never prevent the
674
+ // terminal frame below from being sent — swallow it here (best-effort
675
+ // live streaming; the resolved turn's persisted `traceJson` is still
676
+ // complete via `liveLog.snapshot()` regardless of whether this promise
677
+ // settled).
678
+ await chain.catch(() => { });
679
+ const message = err instanceof Error ? err.message : String(err);
680
+ // Plan A: a failed turn (e.g. this very `error_max_turns` exit) can still carry a resumable
681
+ // SDK session id — `DispatchedSessionError` mirrors warble's own `DispatchSessionError` for exactly
682
+ // this reason. `undefined` (not a `DispatchedSessionError` at all, e.g. a stub runner in tests) is
683
+ // treated the same as "no session id available" below.
684
+ const failedSessionId = sessionIdFromSetupError(err);
685
+ const failedRecoveryAnchor = recoveryAnchor(failedSessionId, initialAuthChoice);
686
+ rememberAnchor(failedRecoveryAnchor);
687
+ retainContextLifecycle(liveLog.snapshot());
688
+ // An `error_max_turns` exit while building context (the only
689
+ // step long/open-ended enough to plausibly exhaust its turn budget
690
+ // mid-way) is not a hard failure — some models may already be written to
691
+ // disk. Offer a resumable checkpoint instead of a terminal error frame.
692
+ if (turn.setupStepKey === "context" && isMaxTurnsError(message)) {
693
+ const modelCount = countModelDirs(path.join(workspaceRoot, form.projectName));
694
+ const worklog = [...liveLog.snapshot(), gateFailureStep(message)];
695
+ if (turn.contextRecovery === "schema_discovery") {
696
+ await failWithError(`the one permitted schema-discovery retry ran out of turns before finishing — no further automatic or chained retry was started; inspect the recorded tool history and resolve the discovery issue before trying context again`, worklog, turnRecoveryAnchor(turn, failedSessionId, initialAuthChoice));
697
+ return;
698
+ }
699
+ // Same budget the resumed turn will actually be dispatched with — see
700
+ // `SetupStepRunner.effectiveMaxTurns`. Falls back to
701
+ // `DEFAULT_SETUP_MAX_TURNS` only for stub runners in tests that don't
702
+ // implement it.
703
+ const continueMaxTurns = setupRunner.effectiveMaxTurns?.(BUILD_CONTEXT_AGENT_ID) ?? DEFAULT_SETUP_MAX_TURNS;
704
+ const decision = {
705
+ kind: "max_turns_continue",
706
+ options: [
707
+ { id: "continue", label: `Continue (+${continueMaxTurns} turns)` },
708
+ { id: "stop", label: "Stop" },
709
+ ],
710
+ detail: `${modelCount} model${modelCount === 1 ? "" : "s"} written so far before running out of turns`,
711
+ };
712
+ const statusEvent = {
713
+ id: newId("evt"),
714
+ kind: "setup_status",
715
+ status: "needs_decision",
716
+ message: "ran out of turns while building context — choose how to proceed",
717
+ decision,
718
+ };
719
+ deps.store.insertEvent({ sessionId: session.id, kind: "setup_status", payload: statusEvent, turnId: turn.id });
720
+ const publicFailureWorklog = publicWorklog(worklog);
721
+ deps.store.resolveTurn(turn.id, {
722
+ backend: "agent-sdk",
723
+ resultKind: "answer",
724
+ answerSummary: statusEvent.message,
725
+ traceJson: JSON.stringify(publicFailureWorklog),
726
+ errorMessage: null,
727
+ });
728
+ const pendingDecision = {
729
+ kind: "max_turns_continue",
730
+ stepKey: "context",
731
+ ...(failedSessionId !== undefined ? { sessionId: failedSessionId } : {}),
732
+ ...(failedRecoveryAnchor !== undefined
733
+ ? { sessionProvider: failedRecoveryAnchor.provider, sessionRunner: failedRecoveryAnchor.runner }
734
+ : {}),
735
+ ...(turn.workspaceRoot !== null ? { workspaceRoot } : {}),
736
+ };
737
+ deps.store.updateSessionDecision(session.id, "awaiting_decision", JSON.stringify(pendingDecision));
738
+ await emit({ event: "worklog", data: publicFailureWorklog });
739
+ await emit({ event: "event", data: statusEvent });
740
+ await emit({ event: "done", data: {} });
741
+ return;
742
+ }
743
+ await failWithError(message, sanitizePublicSetupWorklog([...liveLog.snapshot(), gateFailureStep(message)]), turnRecoveryAnchor(turn, failedSessionId, initialAuthChoice));
744
+ return;
745
+ }
746
+ await chain.catch(() => { });
747
+ const worklog = liveLog.snapshot();
748
+ let terminal = parseTerminalWithRetainedLifecycle(finalText, worklog);
749
+ terminal = { ...terminal, message: redactPublicSetupText(terminal.message) };
750
+ // A host-contract diagnostic can only be emitted after a completed runner
751
+ // result claimed success. Keep the original turn/SSE open and make exactly
752
+ // one same-session continuation when the backend supplied a real anchor.
753
+ // In-process returns `null` and Codex currently returns no session id, so both
754
+ // intentionally keep their existing single-dispatch behavior.
755
+ let retainedRecoveryAnchor = turn.contextRecovery === "schema_discovery" ? undefined : turnRecoveryAnchor(turn, completedSessionId, initialAuthChoice);
756
+ rememberAnchor(retainedRecoveryAnchor);
757
+ let automaticCorrectionAttempted = false;
758
+ if (terminal.failureKind === "missing_terminal_status" && retainedRecoveryAnchor !== undefined) {
759
+ automaticCorrectionAttempted = true;
760
+ worklogBeforeRecovery = worklog;
761
+ recoveryEvidence = {
762
+ id: "decision-terminal-contract-recovery",
763
+ kind: "decision",
764
+ label: "Terminal contract",
765
+ state: "error",
766
+ detail: "the completed response omitted the required SETUP_STATUS line",
767
+ };
768
+ await emit({ event: "worklog", data: publicWorklog(worklog) });
769
+ // The correction attempt's own live frames still go through `onEvent`
770
+ // above, so they keep getting the shape-based `sanitizeLiveSetupWorklog`
771
+ // treatment for subscription mode — no separate gate needed here, since
772
+ // it may also rotate to a new server-only anchor.
773
+ try {
774
+ const recovered = await runAttempt(composeTerminalContractCorrection(), initialAuthChoice, retainedRecoveryAnchor.sessionId);
775
+ finalText = recovered.finalText;
776
+ retainedRecoveryAnchor = turnRecoveryAnchor(turn, recovered.sessionId, initialAuthChoice) ?? retainedRecoveryAnchor;
777
+ rememberAnchor(retainedRecoveryAnchor);
778
+ await chain.catch(() => { });
779
+ const recoveredWorklog = liveLog.snapshot();
780
+ terminal = parseTerminalWithRetainedLifecycle(finalText, recoveredWorklog);
781
+ terminal = { ...terminal, message: redactPublicSetupText(terminal.message) };
782
+ }
783
+ catch (err) {
784
+ await chain.catch(() => { });
785
+ const errorAnchor = turnRecoveryAnchor(turn, sessionIdFromSetupError(err), initialAuthChoice) ?? retainedRecoveryAnchor;
786
+ await failWithError("the automatic terminal-contract correction did not complete; retry this setup step explicitly", publicWorklog([...liveLog.snapshot()]), errorAnchor);
787
+ return;
788
+ }
789
+ }
790
+ if (!automaticCorrectionAttempted &&
791
+ terminal.diagnostic?.kind === "host_contract" &&
792
+ turn.contextRecovery !== "schema_discovery" &&
793
+ retainedRecoveryAnchor !== undefined) {
794
+ worklogBeforeRecovery = worklog;
795
+ recoveryEvidence = hostContractRecoveryStep(safeHostContractSummary(terminal.diagnostic));
796
+ await emit({ event: "worklog", data: publicWorklog(worklog) });
797
+ // A correction can rotate to a replacement provider anchor even though it
798
+ // resumes a known compatible one; its own live frames still go through
799
+ // `onEvent`'s shape-based `sanitizeLiveSetupWorklog` treatment for
800
+ // subscription mode, same as the initial attempt.
801
+ try {
802
+ const recovered = await runAttempt(composeHostContractCorrection(terminal.diagnostic), initialAuthChoice, retainedRecoveryAnchor.sessionId);
803
+ finalText = recovered.finalText;
804
+ retainedRecoveryAnchor = turnRecoveryAnchor(turn, recovered.sessionId, initialAuthChoice) ?? retainedRecoveryAnchor;
805
+ rememberAnchor(retainedRecoveryAnchor);
806
+ await chain.catch(() => { });
807
+ const recoveredWorklog = liveLog.snapshot();
808
+ terminal = parseTerminalWithRetainedLifecycle(finalText, recoveredWorklog);
809
+ terminal = { ...terminal, message: redactPublicSetupText(terminal.message) };
810
+ }
811
+ catch (err) {
812
+ await chain.catch(() => { });
813
+ const errorAnchor = turnRecoveryAnchor(turn, sessionIdFromSetupError(err), initialAuthChoice) ?? retainedRecoveryAnchor;
814
+ const message = "the automatic host-contract correction did not complete; retry this setup step explicitly after resolving the host contract";
815
+ await failWithError(message, publicWorklog([...liveLog.snapshot()]), errorAnchor);
816
+ return;
817
+ }
818
+ }
819
+ // A completed runner result can introduce a new server-owned anchor after
820
+ // the live frames have begun. Re-sanitize every normal terminal boundary
821
+ // with that now-known identity before it reaches SSE or SQLite.
822
+ terminal = { ...terminal, message: redactPublicSetupText(terminal.message, knownInternalValues) };
823
+ const finalWorklog = publicWorklog(liveLog.snapshot());
824
+ if (terminal.failureKind === "no_successful_schema_discovery" && recoveryEvidence === undefined && turn.contextRecovery !== "schema_discovery") {
825
+ const continueMaxTurns = setupRunner.effectiveMaxTurns?.(BUILD_CONTEXT_AGENT_ID) ?? DEFAULT_SETUP_MAX_TURNS;
826
+ const decision = {
827
+ kind: "schema_discovery_retry",
828
+ options: [
829
+ { id: "retry", label: `Retry schema discovery once (+${continueMaxTurns} turns)` },
830
+ { id: "stop", label: "Stop" },
831
+ ],
832
+ detail: "the context agent did not complete recognized schema discovery, so build/validate output was not accepted",
833
+ };
834
+ const statusEvent = {
835
+ id: newId("evt"),
836
+ kind: "setup_status",
837
+ status: "needs_decision",
838
+ message: terminal.message,
839
+ decision,
840
+ };
841
+ deps.store.insertEvent({ sessionId: session.id, kind: "setup_status", payload: statusEvent, turnId: turn.id });
842
+ deps.store.resolveTurn(turn.id, {
843
+ backend: "agent-sdk",
844
+ resultKind: "answer",
845
+ answerSummary: terminal.message,
846
+ traceJson: JSON.stringify(finalWorklog),
847
+ errorMessage: null,
848
+ });
849
+ const pendingDecision = {
850
+ kind: "schema_discovery_retry",
851
+ stepKey: "context",
852
+ ...(completedSessionId !== undefined ? { sessionId: completedSessionId } : {}),
853
+ ...(retainedRecoveryAnchor !== undefined
854
+ ? { sessionProvider: retainedRecoveryAnchor.provider, sessionRunner: retainedRecoveryAnchor.runner }
855
+ : {}),
856
+ ...(turn.workspaceRoot !== null ? { workspaceRoot } : {}),
857
+ };
858
+ deps.store.updateSessionDecision(session.id, "awaiting_decision", JSON.stringify(pendingDecision));
859
+ await emit({ event: "worklog", data: finalWorklog });
860
+ await emit({ event: "event", data: statusEvent });
861
+ await emit({ event: "done", data: {} });
862
+ return;
863
+ }
864
+ if (terminal.status === "error") {
865
+ const message = recoveryEvidence === undefined
866
+ ? terminal.message
867
+ : terminal.diagnostic?.kind === "host_contract"
868
+ ? `the corrective setup attempt still failed ${safeHostContractSummary(terminal.diagnostic)}`
869
+ : "the corrective setup attempt ended without a host-accepted success; retry this setup step explicitly";
870
+ await failWithError(message, finalWorklog, retainedRecoveryAnchor);
871
+ return;
872
+ }
873
+ if (terminal.status === "ok") {
874
+ if (turn.setupStepKey === "context") {
875
+ // The parser already accepted the native discovery -> validate -> build
876
+ // sequence and a nonempty MDL. The profile compile is the final
877
+ // foundation gate: do it before persisting either the step transition or
878
+ // a successful turn. Adopt can require a temporary binding solely so the
879
+ // canonical describeBundle seam resolves this project; roll that back on
880
+ // a failed healthcheck so retry/reload remains at context -> bind todo.
881
+ const temporarilyBoundForHealthcheck = Boolean(deps.bindProject && !isProjectBound(deps));
882
+ if (temporarilyBoundForHealthcheck) {
883
+ deps.bindProject(path.join(workspaceRoot, form.projectName));
884
+ }
885
+ let compileHealthcheckFailure;
886
+ if (!deps.describeBundle) {
887
+ compileHealthcheckFailure = "the profile compile healthcheck is not configured";
888
+ }
889
+ else {
890
+ try {
891
+ await deps.describeBundle(effectiveRouteOptions(deps));
892
+ }
893
+ catch (err) {
894
+ compileHealthcheckFailure = summarizeCompileHealthcheckFailure(err);
895
+ }
896
+ }
897
+ if (compileHealthcheckFailure !== undefined) {
898
+ if (temporarilyBoundForHealthcheck)
899
+ deps.unbindProject?.();
900
+ await failWithError(`${terminal.message} — but the genbi profile failed to compile against this project: ${compileHealthcheckFailure}`, finalWorklog, retainedRecoveryAnchor);
901
+ return;
902
+ }
903
+ const steps = deps.store.getSetupSteps().map((step) => {
904
+ if (step.key === "adopt")
905
+ return { ...step, state: "done" };
906
+ if (step.key === "context")
907
+ return { ...step, state: "done" };
908
+ if (step.key === "bind")
909
+ return { ...step, state: "current" };
910
+ return step;
911
+ });
912
+ const statusEvent = {
913
+ id: newId("evt"),
914
+ kind: "setup_status",
915
+ status: terminal.status,
916
+ message: terminal.message,
917
+ };
918
+ try {
919
+ deps.store.completeContextSetupSuccess({
920
+ sessionId: session.id,
921
+ turnId: turn.id,
922
+ steps,
923
+ statusEvent,
924
+ backend: "agent-sdk",
925
+ answerSummary: terminal.message,
926
+ traceJson: JSON.stringify(finalWorklog),
927
+ errorMessage: null,
928
+ });
929
+ }
930
+ catch {
931
+ if (temporarilyBoundForHealthcheck)
932
+ deps.unbindProject?.();
933
+ await failWithError("the completed context foundation could not be persisted atomically; retry this setup step", finalWorklog, retainedRecoveryAnchor);
934
+ return;
935
+ }
936
+ invalidateBundleAgentIdsCache(deps);
937
+ await emit({ event: "worklog", data: finalWorklog });
938
+ await emit({ event: "event", data: statusEvent });
939
+ await emit({ event: "done", data: {} });
940
+ return;
941
+ }
942
+ else {
943
+ const steps = deps.store.getSetupSteps().map((step) => {
944
+ // Belt: runtime should already be "done" (PUT /api/config/runtime
945
+ // advances it when its settings are saved), but force it here too so a
946
+ // connect that somehow ran with runtime still "current" can't leave two
947
+ // "current" steps behind.
948
+ if (step.key === "runtime")
949
+ return { ...step, state: "done" };
950
+ if (step.key === "connect")
951
+ return { ...step, state: "done" };
952
+ if (step.key === "context")
953
+ return { ...step, state: "current" };
954
+ return step;
955
+ });
956
+ deps.store.setSetupSteps(steps);
957
+ // The scaffolded project now exists on disk (parseSetupTerminal already verified
958
+ // wren_project.yml) — bind it as THE project so the remaining wizard steps (context,
959
+ // bind, ask) and any Ask turn operate against it instead of staying unbound.
960
+ if (deps.bindProject)
961
+ deps.bindProject(path.join(workspaceRoot, form.projectName));
962
+ }
963
+ }
964
+ const message = terminal.message;
965
+ const statusEvent = {
966
+ id: newId("evt"),
967
+ kind: "setup_status",
968
+ status: terminal.status,
969
+ message,
970
+ };
971
+ deps.store.insertEvent({ sessionId: session.id, kind: "setup_status", payload: statusEvent, turnId: turn.id });
972
+ deps.store.resolveTurn(turn.id, {
973
+ backend: "agent-sdk",
974
+ resultKind: "answer",
975
+ answerSummary: message,
976
+ traceJson: JSON.stringify(finalWorklog),
977
+ errorMessage: null,
978
+ });
979
+ deps.store.updateSessionStatus(session.id, "active", null);
980
+ await emit({ event: "worklog", data: finalWorklog });
981
+ await emit({ event: "event", data: statusEvent });
982
+ await emit({ event: "done", data: {} });
983
+ }
984
+ /** GET /api/sessions/:id — the Ask page's session snapshot: full event log + the latest turn's work log. */
985
+ export function getAskSessionData(store, sessionId) {
986
+ const session = store.getSession(sessionId);
987
+ if (!session)
988
+ throw new SessionNotFoundError(sessionId);
989
+ const events = store.listEventsForSession(sessionId).map((e) => e.payload);
990
+ const latestTurn = store.getLatestTurn(sessionId);
991
+ const workLog = latestTurn?.traceJson ? JSON.parse(latestTurn.traceJson) : [];
992
+ return { id: session.id, title: session.title, updatedAt: session.updatedAt, events, workLog };
993
+ }
994
+ const PUBLISH_LINK_BASE = "https://share.genbi.example";
995
+ /** POST /api/sessions/:id/artifacts/:artifactId/publish */
996
+ export function publishArtifactForSession(store, sessionId, artifactId, scope) {
997
+ const session = store.getSession(sessionId);
998
+ if (!session)
999
+ throw new SessionNotFoundError(sessionId);
1000
+ const artifact = store.getArtifact(artifactId);
1001
+ if (!artifact || artifact.sessionId !== sessionId)
1002
+ throw new ArtifactNotFoundError(artifactId);
1003
+ const link = `${PUBLISH_LINK_BASE}/${artifactId}`;
1004
+ store.publishArtifact(artifactId, link, scope);
1005
+ const publishedEvent = { id: newId("evt"), kind: "published", artifactName: artifact.name, link, scope };
1006
+ store.insertEvent({ sessionId, kind: "published", payload: publishedEvent, turnId: null });
1007
+ store.touchSession(sessionId);
1008
+ return publishedEvent;
1009
+ }
1010
+ /**
1011
+ * POST /api/sessions/:id/artifacts/:artifactId/save — promotes an
1012
+ * auto-created artifact onto the Artifacts page. Idempotent: a repeat call
1013
+ * on an already-saved artifact returns the same `savedAt` (via
1014
+ * `store.saveArtifact`'s own idempotency) and does NOT insert a second
1015
+ * `SavedEvent` into the session's event log — otherwise every no-op re-save
1016
+ * would grow the persisted history with duplicate events.
1017
+ */
1018
+ export function saveArtifactForSession(store, sessionId, artifactId) {
1019
+ const session = store.getSession(sessionId);
1020
+ if (!session)
1021
+ throw new SessionNotFoundError(sessionId);
1022
+ const artifact = store.getArtifact(artifactId);
1023
+ if (!artifact || artifact.sessionId !== sessionId)
1024
+ throw new ArtifactNotFoundError(artifactId);
1025
+ const alreadySaved = artifact.savedAt !== null;
1026
+ const saved = store.saveArtifact(artifactId);
1027
+ const savedEvent = {
1028
+ id: newId("evt"),
1029
+ kind: "saved",
1030
+ artifactId: artifact.id,
1031
+ artifactName: artifact.name,
1032
+ savedAt: saved.savedAt,
1033
+ };
1034
+ if (!alreadySaved) {
1035
+ store.insertEvent({ sessionId, kind: "saved", payload: savedEvent, turnId: null });
1036
+ store.touchSession(sessionId);
1037
+ }
1038
+ return savedEvent;
1039
+ }
1040
+ /**
1041
+ * POST /api/sessions/:id/artifacts/:artifactId/unsave — the
1042
+ * mirror of `saveArtifactForSession`: unpins an artifact from the Artifacts
1043
+ * page. The artifact row and its envelope file are left exactly as they are
1044
+ * — only `saved_at` clears — so the artifact remains re-saveable. Idempotent:
1045
+ * a repeat call on an already-unsaved artifact does NOT append a second
1046
+ * `UnsavedEvent` into the session's event log, for the same reason
1047
+ * `saveArtifactForSession` guards its own repeat calls.
1048
+ *
1049
+ * Appends an `UnsavedEvent` rather than deleting the earlier `SavedEvent` —
1050
+ * the event log is append-only/replayed (see `getAskSessionData`), so
1051
+ * "unsaved" is recorded as a newer fact, never as erasing an older one.
1052
+ */
1053
+ export function unsaveArtifactForSession(store, sessionId, artifactId) {
1054
+ const session = store.getSession(sessionId);
1055
+ if (!session)
1056
+ throw new SessionNotFoundError(sessionId);
1057
+ const artifact = store.getArtifact(artifactId);
1058
+ if (!artifact || artifact.sessionId !== sessionId)
1059
+ throw new ArtifactNotFoundError(artifactId);
1060
+ const wasSaved = artifact.savedAt !== null;
1061
+ store.unsaveArtifact(artifactId);
1062
+ const unsavedEvent = {
1063
+ id: newId("evt"),
1064
+ kind: "unsaved",
1065
+ artifactId: artifact.id,
1066
+ artifactName: artifact.name,
1067
+ unsavedAt: new Date().toISOString(),
1068
+ };
1069
+ if (wasSaved) {
1070
+ store.insertEvent({ sessionId, kind: "unsaved", payload: unsavedEvent, turnId: null });
1071
+ store.touchSession(sessionId);
1072
+ }
1073
+ return unsavedEvent;
1074
+ }