@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.
- package/LICENSE +201 -0
- package/README.md +141 -0
- package/bin/genbi.mjs +31 -0
- package/dist/assets/echarts-LTFC-ysd.js +40 -0
- package/dist/assets/index-Bo--QMUN.js +441 -0
- package/dist/assets/index-Xs4q7pNo.css +1 -0
- package/dist/index.html +14 -0
- package/dist/wrenai_logo.png +0 -0
- package/dist/wrenai_logo_white.png +0 -0
- package/dist-server/harness/auth/detect.d.ts +18 -0
- package/dist-server/harness/auth/detect.js +37 -0
- package/dist-server/harness/auth/index.d.ts +4 -0
- package/dist-server/harness/auth/index.js +2 -0
- package/dist-server/harness/auth/probe.d.ts +18 -0
- package/dist-server/harness/auth/probe.js +52 -0
- package/dist-server/harness/auth/types.d.ts +42 -0
- package/dist-server/harness/auth/types.js +9 -0
- package/dist-server/harness/bundle/loader.d.ts +32 -0
- package/dist-server/harness/bundle/loader.js +43 -0
- package/dist-server/harness/bundle/schema.d.ts +199 -0
- package/dist-server/harness/bundle/schema.js +127 -0
- package/dist-server/harness/bundle/version.d.ts +10 -0
- package/dist-server/harness/bundle/version.js +43 -0
- package/dist-server/harness/capability/gate.d.ts +6 -0
- package/dist-server/harness/capability/gate.js +29 -0
- package/dist-server/harness/capability/registry.d.ts +6 -0
- package/dist-server/harness/capability/registry.js +17 -0
- package/dist-server/harness/cli-args.d.ts +123 -0
- package/dist-server/harness/cli-args.js +247 -0
- package/dist-server/harness/cli.d.ts +2 -0
- package/dist-server/harness/cli.js +151 -0
- package/dist-server/harness/compile/cache.d.ts +23 -0
- package/dist-server/harness/compile/cache.js +109 -0
- package/dist-server/harness/compile/compose-profile.d.ts +34 -0
- package/dist-server/harness/compile/compose-profile.js +95 -0
- package/dist-server/harness/compile/errors.d.ts +22 -0
- package/dist-server/harness/compile/errors.js +41 -0
- package/dist-server/harness/compile/fingerprint.d.ts +22 -0
- package/dist-server/harness/compile/fingerprint.js +84 -0
- package/dist-server/harness/compile/index.d.ts +8 -0
- package/dist-server/harness/compile/index.js +7 -0
- package/dist-server/harness/compile/pipeline.d.ts +66 -0
- package/dist-server/harness/compile/pipeline.js +176 -0
- package/dist-server/harness/compile/resolve-binary.d.ts +55 -0
- package/dist-server/harness/compile/resolve-binary.js +133 -0
- package/dist-server/harness/compile/types.d.ts +107 -0
- package/dist-server/harness/compile/types.js +1 -0
- package/dist-server/harness/compile/warble-identity.d.ts +12 -0
- package/dist-server/harness/compile/warble-identity.js +33 -0
- package/dist-server/harness/compliance/gate.d.ts +35 -0
- package/dist-server/harness/compliance/gate.js +48 -0
- package/dist-server/harness/compliance/index.d.ts +2 -0
- package/dist-server/harness/compliance/index.js +1 -0
- package/dist-server/harness/compliance/types.d.ts +16 -0
- package/dist-server/harness/compliance/types.js +1 -0
- package/dist-server/harness/events/emitter.d.ts +34 -0
- package/dist-server/harness/events/emitter.js +25 -0
- package/dist-server/harness/events/index.d.ts +4 -0
- package/dist-server/harness/events/index.js +2 -0
- package/dist-server/harness/events/sse.d.ts +20 -0
- package/dist-server/harness/events/sse.js +48 -0
- package/dist-server/harness/events/types.d.ts +131 -0
- package/dist-server/harness/events/types.js +1 -0
- package/dist-server/harness/exec/errors.d.ts +16 -0
- package/dist-server/harness/exec/errors.js +29 -0
- package/dist-server/harness/exec/index.d.ts +5 -0
- package/dist-server/harness/exec/index.js +2 -0
- package/dist-server/harness/exec/local.d.ts +32 -0
- package/dist-server/harness/exec/local.js +134 -0
- package/dist-server/harness/exec/policy.d.ts +12 -0
- package/dist-server/harness/exec/policy.js +1 -0
- package/dist-server/harness/exec/types.d.ts +63 -0
- package/dist-server/harness/exec/types.js +1 -0
- package/dist-server/harness/guardrails/index.d.ts +2 -0
- package/dist-server/harness/guardrails/index.js +1 -0
- package/dist-server/harness/guardrails/policy.d.ts +45 -0
- package/dist-server/harness/guardrails/policy.js +57 -0
- package/dist-server/harness/index.d.ts +37 -0
- package/dist-server/harness/index.js +19 -0
- package/dist-server/harness/loop/errors.d.ts +53 -0
- package/dist-server/harness/loop/errors.js +72 -0
- package/dist-server/harness/loop/executor.d.ts +123 -0
- package/dist-server/harness/loop/executor.js +380 -0
- package/dist-server/harness/loop/guard.d.ts +16 -0
- package/dist-server/harness/loop/guard.js +1 -0
- package/dist-server/harness/loop/index.d.ts +5 -0
- package/dist-server/harness/loop/index.js +3 -0
- package/dist-server/harness/native-producer/index.d.ts +109 -0
- package/dist-server/harness/native-producer/index.js +302 -0
- package/dist-server/harness/npm-package-resolve.d.ts +28 -0
- package/dist-server/harness/npm-package-resolve.js +47 -0
- package/dist-server/harness/providers/adapters/anthropic.d.ts +7 -0
- package/dist-server/harness/providers/adapters/anthropic.js +6 -0
- package/dist-server/harness/providers/adapters/mock.d.ts +4 -0
- package/dist-server/harness/providers/adapters/mock.js +5 -0
- package/dist-server/harness/providers/adapters/openai-compatible.d.ts +10 -0
- package/dist-server/harness/providers/adapters/openai-compatible.js +10 -0
- package/dist-server/harness/providers/binding.d.ts +23 -0
- package/dist-server/harness/providers/binding.js +13 -0
- package/dist-server/harness/providers/errors.d.ts +6 -0
- package/dist-server/harness/providers/errors.js +12 -0
- package/dist-server/harness/providers/index.d.ts +18 -0
- package/dist-server/harness/providers/index.js +22 -0
- package/dist-server/harness/providers/registry.d.ts +16 -0
- package/dist-server/harness/providers/registry.js +24 -0
- package/dist-server/harness/render/envelope.d.ts +145 -0
- package/dist-server/harness/render/envelope.js +428 -0
- package/dist-server/harness/render/errors.d.ts +33 -0
- package/dist-server/harness/render/errors.js +46 -0
- package/dist-server/harness/render/index.d.ts +5 -0
- package/dist-server/harness/render/index.js +3 -0
- package/dist-server/harness/render/validate.d.ts +13 -0
- package/dist-server/harness/render/validate.js +88 -0
- package/dist-server/harness/route/adapter-spec.d.ts +40 -0
- package/dist-server/harness/route/adapter-spec.js +61 -0
- package/dist-server/harness/route/agent-sdk-cli.d.ts +30 -0
- package/dist-server/harness/route/agent-sdk-cli.js +86 -0
- package/dist-server/harness/route/agent-sdk-manifest.d.ts +31 -0
- package/dist-server/harness/route/agent-sdk-manifest.js +56 -0
- package/dist-server/harness/route/artifact-content.d.ts +53 -0
- package/dist-server/harness/route/artifact-content.js +83 -0
- package/dist-server/harness/route/chat-event-mapper.d.ts +95 -0
- package/dist-server/harness/route/chat-event-mapper.js +164 -0
- package/dist-server/harness/route/codex-ask-events.d.ts +25 -0
- package/dist-server/harness/route/codex-ask-events.js +395 -0
- package/dist-server/harness/route/codex-ask.d.ts +15 -0
- package/dist-server/harness/route/codex-ask.js +242 -0
- package/dist-server/harness/route/codex-local-cli.d.ts +20 -0
- package/dist-server/harness/route/codex-local-cli.js +78 -0
- package/dist-server/harness/route/codex-local-manifest.d.ts +22 -0
- package/dist-server/harness/route/codex-local-manifest.js +180 -0
- package/dist-server/harness/route/describe.d.ts +54 -0
- package/dist-server/harness/route/describe.js +84 -0
- package/dist-server/harness/route/dispatched.d.ts +138 -0
- package/dist-server/harness/route/dispatched.js +339 -0
- package/dist-server/harness/route/in-process.d.ts +43 -0
- package/dist-server/harness/route/in-process.js +106 -0
- package/dist-server/harness/route/index.d.ts +19 -0
- package/dist-server/harness/route/index.js +12 -0
- package/dist-server/harness/route/profile-source.d.ts +37 -0
- package/dist-server/harness/route/profile-source.js +76 -0
- package/dist-server/harness/route/route.d.ts +33 -0
- package/dist-server/harness/route/route.js +114 -0
- package/dist-server/harness/route/tier-binding.d.ts +36 -0
- package/dist-server/harness/route/tier-binding.js +63 -0
- package/dist-server/harness/route/types.d.ts +286 -0
- package/dist-server/harness/route/types.js +1 -0
- package/dist-server/harness/session/errors.d.ts +4 -0
- package/dist-server/harness/session/errors.js +7 -0
- package/dist-server/harness/session/gate.d.ts +11 -0
- package/dist-server/harness/session/gate.js +13 -0
- package/dist-server/harness/session/index.d.ts +5 -0
- package/dist-server/harness/session/index.js +3 -0
- package/dist-server/harness/session/run.d.ts +60 -0
- package/dist-server/harness/session/run.js +333 -0
- package/dist-server/harness/session/types.d.ts +29 -0
- package/dist-server/harness/session/types.js +1 -0
- package/dist-server/harness/setup/codex-events.d.ts +12 -0
- package/dist-server/harness/setup/codex-events.js +108 -0
- package/dist-server/harness/setup/runner.d.ts +497 -0
- package/dist-server/harness/setup/runner.js +1314 -0
- package/dist-server/harness/tools/errors.d.ts +107 -0
- package/dist-server/harness/tools/errors.js +141 -0
- package/dist-server/harness/tools/index.d.ts +13 -0
- package/dist-server/harness/tools/index.js +8 -0
- package/dist-server/harness/tools/mcp.d.ts +26 -0
- package/dist-server/harness/tools/mcp.js +15 -0
- package/dist-server/harness/tools/native.d.ts +179 -0
- package/dist-server/harness/tools/native.js +322 -0
- package/dist-server/harness/tools/parse.d.ts +14 -0
- package/dist-server/harness/tools/parse.js +18 -0
- package/dist-server/harness/tools/resolve-wren-binary.d.ts +12 -0
- package/dist-server/harness/tools/resolve-wren-binary.js +21 -0
- package/dist-server/harness/tools/resolve.d.ts +51 -0
- package/dist-server/harness/tools/resolve.js +79 -0
- package/dist-server/harness/tools/setup-native.d.ts +84 -0
- package/dist-server/harness/tools/setup-native.js +353 -0
- package/dist-server/harness/tools/wiring.d.ts +10 -0
- package/dist-server/harness/tools/wiring.js +16 -0
- package/dist-server/providers/setup.provider.yaml +31 -0
- package/dist-server/providers/wren.provider.yaml +33 -0
- package/dist-server/server/adopt.d.ts +87 -0
- package/dist-server/server/adopt.js +263 -0
- package/dist-server/server/app.d.ts +6 -0
- package/dist-server/server/app.js +2521 -0
- package/dist-server/server/auth-choice.d.ts +16 -0
- package/dist-server/server/auth-choice.js +36 -0
- package/dist-server/server/bin.d.ts +2 -0
- package/dist-server/server/bin.js +601 -0
- package/dist-server/server/bootstrap-project-binding.d.ts +31 -0
- package/dist-server/server/bootstrap-project-binding.js +71 -0
- package/dist-server/server/clarify.d.ts +10 -0
- package/dist-server/server/clarify.js +17 -0
- package/dist-server/server/codex-setup-mcp.d.ts +2 -0
- package/dist-server/server/codex-setup-mcp.js +147 -0
- package/dist-server/server/compose.d.ts +89 -0
- package/dist-server/server/compose.js +271 -0
- package/dist-server/server/conn-config.d.ts +30 -0
- package/dist-server/server/conn-config.js +253 -0
- package/dist-server/server/context-files.d.ts +12 -0
- package/dist-server/server/context-files.js +117 -0
- package/dist-server/server/context-map.d.ts +30 -0
- package/dist-server/server/context-map.js +95 -0
- package/dist-server/server/context-source.d.ts +68 -0
- package/dist-server/server/context-source.js +85 -0
- package/dist-server/server/db.d.ts +862 -0
- package/dist-server/server/db.js +1839 -0
- package/dist-server/server/enrichment-runner.d.ts +184 -0
- package/dist-server/server/enrichment-runner.js +213 -0
- package/dist-server/server/enrichment-verify.d.ts +73 -0
- package/dist-server/server/enrichment-verify.js +290 -0
- package/dist-server/server/enrichment.d.ts +232 -0
- package/dist-server/server/enrichment.js +199 -0
- package/dist-server/server/env-detect.d.ts +8 -0
- package/dist-server/server/env-detect.js +17 -0
- package/dist-server/server/env-field-hints.d.ts +46 -0
- package/dist-server/server/env-field-hints.js +48 -0
- package/dist-server/server/fold.d.ts +133 -0
- package/dist-server/server/fold.js +407 -0
- package/dist-server/server/harness-profile-sources.d.ts +3 -0
- package/dist-server/server/harness-profile-sources.js +24 -0
- package/dist-server/server/harness.d.ts +19 -0
- package/dist-server/server/harness.js +439 -0
- package/dist-server/server/impact.d.ts +18 -0
- package/dist-server/server/impact.js +85 -0
- package/dist-server/server/interactive-terminal.d.ts +175 -0
- package/dist-server/server/interactive-terminal.js +336 -0
- package/dist-server/server/native-artifacts.d.ts +1004 -0
- package/dist-server/server/native-artifacts.js +564 -0
- package/dist-server/server/native-dispatch-registry.d.ts +42 -0
- package/dist-server/server/native-dispatch-registry.js +45 -0
- package/dist-server/server/native-producer-fence.d.ts +14 -0
- package/dist-server/server/native-producer-fence.js +48 -0
- package/dist-server/server/native-session-resume.d.ts +6 -0
- package/dist-server/server/native-session-resume.js +103 -0
- package/dist-server/server/native-session-workspace.d.ts +40 -0
- package/dist-server/server/native-session-workspace.js +175 -0
- package/dist-server/server/native-sessions.d.ts +374 -0
- package/dist-server/server/native-sessions.js +1517 -0
- package/dist-server/server/native-wren-runtime.d.ts +20 -0
- package/dist-server/server/native-wren-runtime.js +259 -0
- package/dist-server/server/node-pty-host.d.ts +31 -0
- package/dist-server/server/node-pty-host.js +100 -0
- package/dist-server/server/route-intent.d.ts +40 -0
- package/dist-server/server/route-intent.js +23 -0
- package/dist-server/server/runtime-binding.d.ts +52 -0
- package/dist-server/server/runtime-binding.js +258 -0
- package/dist-server/server/source-catalog.d.ts +84 -0
- package/dist-server/server/source-catalog.js +221 -0
- package/dist-server/server/source-driver.d.ts +35 -0
- package/dist-server/server/source-driver.js +124 -0
- package/dist-server/server/spa.d.ts +2 -0
- package/dist-server/server/spa.js +51 -0
- package/dist-server/server/subscription-model-catalog.d.ts +27 -0
- package/dist-server/server/subscription-model-catalog.js +203 -0
- package/dist-server/server/turn.d.ts +249 -0
- package/dist-server/server/turn.js +1074 -0
- package/dist-server/server/wire-types.d.ts +799 -0
- package/dist-server/server/wire-types.js +1 -0
- package/dist-server/server/wren-home.d.ts +19 -0
- package/dist-server/server/wren-home.js +33 -0
- package/dist-server/server/wren-profiles.d.ts +43 -0
- package/dist-server/server/wren-profiles.js +123 -0
- package/package.json +93 -0
- package/profiles/genbi-default/context/binding.yml +9 -0
- package/profiles/genbi-default/ir.golden.json +506 -0
- package/profiles/genbi-default/profile.yml +16 -0
- package/profiles/genbi-enrich-context/components/apply_enrichment/component.yml +42 -0
- package/profiles/genbi-enrich-context/components/draft_enrichment/component.yml +30 -0
- package/profiles/genbi-enrich-context/components/draft_enrichment/steps/draft.md +34 -0
- package/profiles/genbi-enrich-context/components/inspect_context/component.yml +32 -0
- package/profiles/genbi-enrich-context/components/inspect_context/steps/inspect.md +13 -0
- package/profiles/genbi-enrich-context/context/binding.yml +4 -0
- package/profiles/genbi-enrich-context/ir.golden.json +302 -0
- package/profiles/genbi-enrich-context/profile.yml +15 -0
- package/profiles/genbi-monitor/context/binding.yml +7 -0
- package/profiles/genbi-monitor/ir.golden.json +188 -0
- package/profiles/genbi-monitor/profile.yml +27 -0
- package/profiles/genbi-setup/components/build_context/component.yml +44 -0
- package/profiles/genbi-setup/components/build_context/steps/build.md +14 -0
- package/profiles/genbi-setup/components/connect_source/component.yml +51 -0
- package/profiles/genbi-setup/components/connect_source/steps/connect.md +23 -0
- package/profiles/genbi-setup/context/binding.yml +8 -0
- package/profiles/genbi-setup/ir.golden.json +140 -0
- package/profiles/genbi-setup/profile.yml +18 -0
- 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
|
+
}
|