@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,1314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agentic setup/connect flow — BFF wiring.
|
|
3
|
+
*
|
|
4
|
+
* The Setup wizard's CONNECT step onboards a NEW wren project by dispatching
|
|
5
|
+
* warble's `connect_source` component via a dispatched (claude-agent-sdk)
|
|
6
|
+
* subprocess. Design decisions this file encodes (see the ticket for full
|
|
7
|
+
* rationale):
|
|
8
|
+
*
|
|
9
|
+
* - Setup state lives on disk (the scaffolded project + its `.env`), not
|
|
10
|
+
* only in the agent conversation: each setup turn is independently
|
|
11
|
+
* re-verifiable via `parseSetupTerminal`'s on-disk artifact checks below,
|
|
12
|
+
* regardless of which mechanism served the turn. Historically every setup
|
|
13
|
+
* turn was ALSO a fresh dispatched dispatch with no SDK-level continuity
|
|
14
|
+
* across turns (a disk-state-only "Plan B" re-composition — see
|
|
15
|
+
* `server/compose.ts`'s `composeSetupPrompt`'s `resumeFromDisk` option).
|
|
16
|
+
* `SetupStepRunOptions.resumeSessionId`/`SetupStepRunResult.sessionId`
|
|
17
|
+
* below add the alternative "Plan A": resuming the SAME underlying
|
|
18
|
+
* agent-sdk conversation (`DispatchedOptions.resumeSessionId` /
|
|
19
|
+
* `DispatchedResult.sessionId`, `../route/dispatched.js`) when a caller has one to
|
|
20
|
+
* resume from, so a `max_turns_continue` decision (`server/turn.ts`) can
|
|
21
|
+
* give the agent a fresh turn budget without making it re-orient from
|
|
22
|
+
* scratch (re-`ls`, re-`Read`, re-fetch skills). Both remain available:
|
|
23
|
+
* a caller with no session id to resume gets the original disk-state
|
|
24
|
+
* behavior unchanged.
|
|
25
|
+
* - The terminal contract (`SETUP_STATUS: ok|needs_input|error`) lives in
|
|
26
|
+
* the composed prompt, not in warble — `parseSetupTerminal` below parses
|
|
27
|
+
* it back out of the turn's finalText, and independently verifies
|
|
28
|
+
* `<root>/<name>/wren_project.yml` on disk before trusting an `ok`.
|
|
29
|
+
* - `SetupStepRunner` is a backend-abstract seam: `DispatchedSetupRunner`
|
|
30
|
+
* (claude-agent-sdk subprocess, subscription auth) and `InProcessSetupRunner`
|
|
31
|
+
* (in-process vercel loop, api-key/local/gateway auth) both implement it,
|
|
32
|
+
* and a caller picks between them purely on `authChoice.mode` without
|
|
33
|
+
* otherwise caring which one served a given turn.
|
|
34
|
+
*/
|
|
35
|
+
import { createHash } from "node:crypto";
|
|
36
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
37
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
38
|
+
import { spawn } from "node:child_process";
|
|
39
|
+
import { createInterface } from "node:readline";
|
|
40
|
+
import os from "node:os";
|
|
41
|
+
import path from "node:path";
|
|
42
|
+
import { fileURLToPath } from "node:url";
|
|
43
|
+
import { loadBundleWithProvenance } from "../bundle/loader.js";
|
|
44
|
+
import { runWarble } from "../compile/pipeline.js";
|
|
45
|
+
import { resolveWarbleBinary } from "../compile/resolve-binary.js";
|
|
46
|
+
import { createAgentEventEmitter } from "../events/index.js";
|
|
47
|
+
import { createLocalExecutionEnv } from "../exec/index.js";
|
|
48
|
+
import { executeAgent, StepBudgetExhaustedError } from "../loop/index.js";
|
|
49
|
+
// Re-exported: `InProcessSetupRunner.run()` throws this (with an enriched,
|
|
50
|
+
// Mode-A-specific `.message` — see its `catch` block below) whenever the
|
|
51
|
+
// Dispatched agent's step budget is what ended the turn, not the model
|
|
52
|
+
// finishing on its own. Callers that want to distinguish that from any
|
|
53
|
+
// other setup-dispatch failure (e.g. via `instanceof`) can import it from
|
|
54
|
+
// here without also reaching into `harness/loop/index.js`.
|
|
55
|
+
export { StepBudgetExhaustedError };
|
|
56
|
+
import { createDefaultProviderRegistry } from "../providers/index.js";
|
|
57
|
+
import { deriveAdapterSpec } from "../route/adapter-spec.js";
|
|
58
|
+
import { resolveCodexLocalCli } from "../route/codex-local-cli.js";
|
|
59
|
+
import { runDispatchedDefault } from "../route/dispatched.js";
|
|
60
|
+
import { buildUniformTierBinding } from "../route/tier-binding.js";
|
|
61
|
+
import { createNativeToolRegistry, createSetupExecutionTool, SETUP_EXECUTION_TOOL_NAME } from "../tools/index.js";
|
|
62
|
+
import { withResolvedTools } from "../tools/wiring.js";
|
|
63
|
+
import { CodexSetupEventMapper } from "./codex-events.js";
|
|
64
|
+
/** The warble component the setup wizard's connect step dispatches. */
|
|
65
|
+
export const CONNECT_SOURCE_AGENT_ID = "connect_source";
|
|
66
|
+
/** The Warble component the setup wizard's context step dispatches (`profiles/genbi-setup/components/build_context`). */
|
|
67
|
+
export const BUILD_CONTEXT_AGENT_ID = "build_context";
|
|
68
|
+
/**
|
|
69
|
+
* Default agent-loop budget for setup turns, forwarded as `warble-agent-sdk
|
|
70
|
+
* chat --max-turns`. Well above the dispatcher's own default (40) because
|
|
71
|
+
* `build_context` agentically generates an MDL — many read/introspect/write/
|
|
72
|
+
* build/validate tool calls — and routinely blows past 40, failing the turn
|
|
73
|
+
* with `error_max_turns`. Overridable via `DispatchedSetupRunnerOptions.maxTurns`
|
|
74
|
+
* (wired to `WREN_HARNESS_SETUP_MAX_TURNS` in `server/bin.ts`).
|
|
75
|
+
*/
|
|
76
|
+
export const DEFAULT_SETUP_MAX_TURNS = 120;
|
|
77
|
+
/** Single provider-aware selection seam shared by boot wiring and tests. */
|
|
78
|
+
export function selectSetupRunnerForAuth(authChoice, runners) {
|
|
79
|
+
if (authChoice.mode !== "subscription")
|
|
80
|
+
return runners.nonSubscription;
|
|
81
|
+
return authChoice.provider === "codex" ? runners.codexSubscription : runners.claudeSubscription;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Resolves the `--max-turns` budget for dispatching `agentId`, given an
|
|
85
|
+
* optional explicit override (`DispatchedSetupRunnerOptions.maxTurns`, wired from
|
|
86
|
+
* `WREN_HARNESS_SETUP_MAX_TURNS` in `server/bin.ts`). Shared by
|
|
87
|
+
* `DispatchedSetupRunner.run()` and `DispatchedSetupRunner.effectiveMaxTurns()` so the
|
|
88
|
+
* two can never disagree.
|
|
89
|
+
*/
|
|
90
|
+
function resolveEffectiveMaxTurns(configuredMaxTurns, agentId) {
|
|
91
|
+
return configuredMaxTurns ?? (agentId === BUILD_CONTEXT_AGENT_ID ? DEFAULT_SETUP_MAX_TURNS : undefined);
|
|
92
|
+
}
|
|
93
|
+
const SAFE_PROJECT_NAME = /^[a-zA-Z0-9_-]+$/;
|
|
94
|
+
/**
|
|
95
|
+
* Subscription dispatchers have one cwd per turn, while their guarded setup
|
|
96
|
+
* tool accepts a separately bounded working root. Keep the initial scaffold
|
|
97
|
+
* at the workspace root. Immediately before a project-bound dispatch,
|
|
98
|
+
* canonicalize the existing workspace/project dirs and require the latter to
|
|
99
|
+
* be a strict descendant of the former. This is a pre-dispatch guard, not a
|
|
100
|
+
* claim that filesystem paths cannot change after the child process starts.
|
|
101
|
+
*/
|
|
102
|
+
function resolveDispatchedUserProject(runOptions, agentId) {
|
|
103
|
+
const stepKey = runOptions.stepKey ?? (agentId === BUILD_CONTEXT_AGENT_ID ? "context" : "connect");
|
|
104
|
+
if (stepKey === "connect")
|
|
105
|
+
return runOptions.workspaceRoot;
|
|
106
|
+
const projectName = runOptions.projectName;
|
|
107
|
+
if (projectName === undefined || !SAFE_PROJECT_NAME.test(projectName)) {
|
|
108
|
+
throw new Error(`dispatched ${stepKey} requires a validated single-segment projectName`);
|
|
109
|
+
}
|
|
110
|
+
const canonicalDirectory = (directory, label) => {
|
|
111
|
+
if (!existsSync(directory))
|
|
112
|
+
throw new Error(`dispatched ${stepKey} ${label} must exist before dispatch`);
|
|
113
|
+
if (!statSync(directory).isDirectory())
|
|
114
|
+
throw new Error(`dispatched ${stepKey} ${label} must be a directory`);
|
|
115
|
+
return realpathSync(directory);
|
|
116
|
+
};
|
|
117
|
+
const canonicalWorkspace = canonicalDirectory(runOptions.workspaceRoot, "workspace root");
|
|
118
|
+
const projectDir = path.resolve(runOptions.workspaceRoot, projectName);
|
|
119
|
+
const canonicalProject = canonicalDirectory(projectDir, "project directory");
|
|
120
|
+
const relative = path.relative(canonicalWorkspace, canonicalProject);
|
|
121
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
122
|
+
throw new Error(`dispatched ${stepKey} project directory must be a strict descendant of the setup workspace root`);
|
|
123
|
+
}
|
|
124
|
+
return canonicalProject;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Dispatches a setup-wizard turn over dispatched, bypassing `route()` entirely
|
|
128
|
+
* (unlike the Ask turn path) so it can perform its own, setup-specific
|
|
129
|
+
* subscription-mode check with a clear, actionable error message rather than
|
|
130
|
+
* `route()`'s generic in-process/B branch. Uses `DispatchedOptions.irPath` to skip
|
|
131
|
+
* `compileProfile` — there is no bound wren project yet during setup, so
|
|
132
|
+
* there is nothing to compile a profile against; the setup dispatch always
|
|
133
|
+
* runs the same fixed, warble-committed IR regardless of which project is
|
|
134
|
+
* being onboarded.
|
|
135
|
+
*/
|
|
136
|
+
export class DispatchedSetupRunner {
|
|
137
|
+
options;
|
|
138
|
+
constructor(options) {
|
|
139
|
+
this.options = options;
|
|
140
|
+
}
|
|
141
|
+
effectiveMaxTurns(agentId) {
|
|
142
|
+
return resolveEffectiveMaxTurns(this.options.maxTurns, agentId);
|
|
143
|
+
}
|
|
144
|
+
async run(runOptions) {
|
|
145
|
+
const { authChoice } = runOptions;
|
|
146
|
+
const agentId = runOptions.agentId ?? CONNECT_SOURCE_AGENT_ID;
|
|
147
|
+
// Only build_context needs the raised turn budget — generating an MDL is
|
|
148
|
+
// many tool calls. connect/connect_resume stay on the dispatcher's default
|
|
149
|
+
// (40) so a stuck connect (e.g. bad credentials retrying) still fails fast
|
|
150
|
+
// rather than burning 3x the runway first. An explicit
|
|
151
|
+
// DispatchedSetupRunnerOptions.maxTurns override, when set, applies to any turn.
|
|
152
|
+
// Kept in lockstep with `effectiveMaxTurns()` above via the shared
|
|
153
|
+
// `resolveEffectiveMaxTurns` helper.
|
|
154
|
+
const maxTurns = this.effectiveMaxTurns(agentId);
|
|
155
|
+
if (authChoice.mode !== "subscription") {
|
|
156
|
+
throw new Error(`Agentic setup requires subscription auth mode (dispatched) to dispatch "${agentId}" ` +
|
|
157
|
+
`via the claude-agent-sdk dispatcher — got "${authChoice.mode}". Set WREN_HARNESS_MODE=subscription ` +
|
|
158
|
+
"to enable the setup wizard's connect step.");
|
|
159
|
+
}
|
|
160
|
+
const modelsConfig = this.options.getModelsConfig?.();
|
|
161
|
+
const userProject = resolveDispatchedUserProject(runOptions, agentId);
|
|
162
|
+
const result = await runDispatchedDefault({
|
|
163
|
+
authChoice,
|
|
164
|
+
// Unused: `irPath` below makes `runDispatchedDefault` skip compileProfile
|
|
165
|
+
// entirely (see its short-circuit), so this value is never read. It is
|
|
166
|
+
// set to the same setup IR for clarity, not because it is consulted.
|
|
167
|
+
profileSource: this.options.irPath,
|
|
168
|
+
userProject,
|
|
169
|
+
question: runOptions.prompt,
|
|
170
|
+
agentId,
|
|
171
|
+
irPath: this.options.irPath,
|
|
172
|
+
...(this.options.warbleBin !== undefined ? { warbleBin: this.options.warbleBin } : {}),
|
|
173
|
+
...(this.options.agentSdkBin !== undefined ? { agentSdkBin: this.options.agentSdkBin } : {}),
|
|
174
|
+
...(this.options.outDir !== undefined ? { outDir: this.options.outDir } : {}),
|
|
175
|
+
...(this.options.workDir !== undefined ? { workDir: this.options.workDir } : {}),
|
|
176
|
+
...(modelsConfig !== undefined ? { modelsConfig } : {}),
|
|
177
|
+
...(maxTurns !== undefined ? { maxTurns } : {}),
|
|
178
|
+
...(runOptions.onEvent !== undefined ? { onEvent: runOptions.onEvent } : {}),
|
|
179
|
+
...(runOptions.resumeSessionId !== undefined ? { resumeSessionId: runOptions.resumeSessionId } : {}),
|
|
180
|
+
});
|
|
181
|
+
return { finalText: result.finalText, ...(result.sessionId !== undefined ? { sessionId: result.sessionId } : {}) };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const CODEX_BILLING_ENV_KEYS = new Set([
|
|
185
|
+
"OPENAI_API_KEY",
|
|
186
|
+
"CODEX_API_KEY",
|
|
187
|
+
"AZURE_OPENAI_API_KEY",
|
|
188
|
+
"OPENAI_ORGANIZATION",
|
|
189
|
+
"OPENAI_ORG_ID",
|
|
190
|
+
"OPENAI_PROJECT",
|
|
191
|
+
"OPENAI_PROJECT_ID",
|
|
192
|
+
]);
|
|
193
|
+
async function resolveCodexSetupProducedField(irPath, agentId) {
|
|
194
|
+
let parsed;
|
|
195
|
+
try {
|
|
196
|
+
parsed = JSON.parse(await readFile(irPath, "utf8"));
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
throw new Error("Codex setup IR is not valid JSON");
|
|
200
|
+
}
|
|
201
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
202
|
+
throw new Error("Codex setup IR does not contain a valid component contract");
|
|
203
|
+
}
|
|
204
|
+
const components = parsed.components;
|
|
205
|
+
if (!Array.isArray(components)) {
|
|
206
|
+
throw new Error("Codex setup IR does not contain a valid component contract");
|
|
207
|
+
}
|
|
208
|
+
const matches = components.filter((component) => typeof component === "object" && component !== null && component.id === agentId);
|
|
209
|
+
const component = matches[0];
|
|
210
|
+
if (matches.length !== 1 || component === undefined || !Array.isArray(component.llm_calls) || component.llm_calls.length !== 1) {
|
|
211
|
+
throw new Error(`Codex setup component "${agentId}" does not declare exactly one produced field`);
|
|
212
|
+
}
|
|
213
|
+
const produces = component.llm_calls[0]?.produces;
|
|
214
|
+
if (typeof produces !== "string" || !produces.trim()) {
|
|
215
|
+
throw new Error(`Codex setup component "${agentId}" does not declare exactly one produced field`);
|
|
216
|
+
}
|
|
217
|
+
return produces;
|
|
218
|
+
}
|
|
219
|
+
function unwrapCodexSetupEnvelope(rawFinalText, expectedField) {
|
|
220
|
+
let parsed;
|
|
221
|
+
try {
|
|
222
|
+
parsed = JSON.parse(rawFinalText);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
throw new Error("Codex setup final response is not valid JSON");
|
|
226
|
+
}
|
|
227
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
228
|
+
throw new Error("Codex setup final response must be a single-field JSON object");
|
|
229
|
+
}
|
|
230
|
+
const fields = Object.keys(parsed);
|
|
231
|
+
if (fields.length !== 1 || fields[0] !== expectedField) {
|
|
232
|
+
throw new Error(`Codex setup final response must contain only the declared "${expectedField}" field`);
|
|
233
|
+
}
|
|
234
|
+
const produced = parsed[expectedField];
|
|
235
|
+
if (typeof produced !== "string") {
|
|
236
|
+
throw new Error(`Codex setup final response field "${expectedField}" must be a string`);
|
|
237
|
+
}
|
|
238
|
+
return produced;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Setup-only Codex subscription runner. It invokes the target's `dispatch`
|
|
242
|
+
* command directly against the committed setup IR and exposes exactly one
|
|
243
|
+
* guarded MCP tool. Normal Ask routing never calls this class.
|
|
244
|
+
*/
|
|
245
|
+
export class CodexSetupRunner {
|
|
246
|
+
options;
|
|
247
|
+
constructor(options) {
|
|
248
|
+
this.options = options;
|
|
249
|
+
}
|
|
250
|
+
async run(runOptions) {
|
|
251
|
+
if (runOptions.authChoice.mode !== "subscription" || runOptions.authChoice.provider !== "codex") {
|
|
252
|
+
throw new Error("Codex setup runner requires a Codex subscription auth choice");
|
|
253
|
+
}
|
|
254
|
+
const agentId = runOptions.agentId ?? CONNECT_SOURCE_AGENT_ID;
|
|
255
|
+
if (agentId !== CONNECT_SOURCE_AGENT_ID && agentId !== BUILD_CONTEXT_AGENT_ID) {
|
|
256
|
+
throw new Error(`Codex setup only supports "${CONNECT_SOURCE_AGENT_ID}" and "${BUILD_CONTEXT_AGENT_ID}"`);
|
|
257
|
+
}
|
|
258
|
+
const model = this.options.getStrongModel().trim();
|
|
259
|
+
if (!model)
|
|
260
|
+
throw new Error("Codex setup requires a configured strong-tier model");
|
|
261
|
+
const expectedProducedField = await resolveCodexSetupProducedField(this.options.irPath, agentId);
|
|
262
|
+
const cli = this.options.codexLocalCli ?? (await resolveCodexLocalCli(this.options.codexLocalBin));
|
|
263
|
+
const traceDir = this.options.mcpServer ? undefined : await mkdtemp(path.join(os.tmpdir(), "wren-codex-setup-trace-"));
|
|
264
|
+
const tracePath = traceDir ? path.join(traceDir, "setup-execution.jsonl") : undefined;
|
|
265
|
+
if (tracePath)
|
|
266
|
+
await writeFile(tracePath, "", { encoding: "utf8", mode: 0o600 });
|
|
267
|
+
const mcp = this.options.mcpServer ?? defaultCodexSetupMcpInvocation(tracePath);
|
|
268
|
+
const timeoutMs = this.options.timeoutMs ?? 10 * 60 * 1000;
|
|
269
|
+
const turnRoot = resolveDispatchedUserProject(runOptions, agentId);
|
|
270
|
+
const args = [
|
|
271
|
+
...cli.prefixArgs,
|
|
272
|
+
"dispatch",
|
|
273
|
+
this.options.irPath,
|
|
274
|
+
runOptions.prompt,
|
|
275
|
+
"--component",
|
|
276
|
+
agentId,
|
|
277
|
+
"--project",
|
|
278
|
+
turnRoot,
|
|
279
|
+
"--model",
|
|
280
|
+
model,
|
|
281
|
+
"--server",
|
|
282
|
+
"setup",
|
|
283
|
+
"--server-command",
|
|
284
|
+
mcp.command,
|
|
285
|
+
...mcp.prefixArgs.flatMap((arg) => (arg.startsWith("-") ? [`--server-arg=${arg}`] : ["--server-arg", arg])),
|
|
286
|
+
"--server-arg=--workspace-root",
|
|
287
|
+
"--server-arg",
|
|
288
|
+
turnRoot,
|
|
289
|
+
"--source-tool",
|
|
290
|
+
SETUP_EXECUTION_TOOL_NAME,
|
|
291
|
+
"--context-tool",
|
|
292
|
+
SETUP_EXECUTION_TOOL_NAME,
|
|
293
|
+
"--timeout",
|
|
294
|
+
String(timeoutMs),
|
|
295
|
+
...(this.options.codexBin ? ["--codex-bin", this.options.codexBin] : []),
|
|
296
|
+
"--stream-json",
|
|
297
|
+
];
|
|
298
|
+
const emitter = createAgentEventEmitter(runOptions.onEvent);
|
|
299
|
+
emitter.emit({ kind: "run.start", mode: "B", agentId });
|
|
300
|
+
const mapper = new CodexSetupEventMapper(tracePath);
|
|
301
|
+
try {
|
|
302
|
+
const rawFinalText = await spawnCodexSetup(cli.command, args, mapper, emitter.emit, timeoutMs + 5_000);
|
|
303
|
+
const finalText = unwrapCodexSetupEnvelope(rawFinalText, expectedProducedField);
|
|
304
|
+
emitter.emit({ kind: "run.finish", status: "answer" });
|
|
305
|
+
return { finalText };
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
309
|
+
emitter.emit({ kind: "error", message });
|
|
310
|
+
emitter.emit({ kind: "run.finish", status: "error" });
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
if (traceDir)
|
|
315
|
+
await rm(traceDir, { recursive: true, force: true });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function defaultCodexSetupMcpInvocation(tracePath) {
|
|
320
|
+
return {
|
|
321
|
+
command: process.execPath,
|
|
322
|
+
prefixArgs: [fileURLToPath(new URL("../../server/codex-setup-mcp.js", import.meta.url)), "--trace-path", tracePath],
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function sanitizedCodexSetupEnvironment() {
|
|
326
|
+
return Object.fromEntries(Object.entries(process.env).filter(([key, value]) => value !== undefined && !CODEX_BILLING_ENV_KEYS.has(key.toUpperCase())));
|
|
327
|
+
}
|
|
328
|
+
function spawnCodexSetup(command, args, mapper, emit, timeoutMs) {
|
|
329
|
+
return new Promise((resolve, reject) => {
|
|
330
|
+
const child = spawn(command, [...args], {
|
|
331
|
+
env: sanitizedCodexSetupEnvironment(),
|
|
332
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
333
|
+
detached: process.platform !== "win32",
|
|
334
|
+
});
|
|
335
|
+
let stderr = "";
|
|
336
|
+
let settled = false;
|
|
337
|
+
let timedOut = false;
|
|
338
|
+
let killTimer;
|
|
339
|
+
const terminate = (signal) => {
|
|
340
|
+
if (child.pid !== undefined && process.platform !== "win32") {
|
|
341
|
+
try {
|
|
342
|
+
process.kill(-child.pid, signal);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
// Fall through to the direct child.
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
child.kill(signal);
|
|
350
|
+
};
|
|
351
|
+
const timer = setTimeout(() => {
|
|
352
|
+
timedOut = true;
|
|
353
|
+
terminate("SIGTERM");
|
|
354
|
+
killTimer = setTimeout(() => terminate("SIGKILL"), 1_000);
|
|
355
|
+
}, timeoutMs);
|
|
356
|
+
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
357
|
+
lines.on("line", (line) => {
|
|
358
|
+
if (settled || !line.trim())
|
|
359
|
+
return;
|
|
360
|
+
try {
|
|
361
|
+
const event = mapper.nextLine(line);
|
|
362
|
+
if (event)
|
|
363
|
+
emit(event);
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
settled = true;
|
|
367
|
+
clearTimeout(timer);
|
|
368
|
+
terminate("SIGTERM");
|
|
369
|
+
killTimer = setTimeout(() => terminate("SIGKILL"), 1_000);
|
|
370
|
+
reject(error);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
child.stderr.on("data", (chunk) => {
|
|
374
|
+
if (stderr.length < 8_192)
|
|
375
|
+
stderr += chunk.toString("utf8");
|
|
376
|
+
});
|
|
377
|
+
child.on("error", (error) => {
|
|
378
|
+
if (settled)
|
|
379
|
+
return;
|
|
380
|
+
settled = true;
|
|
381
|
+
clearTimeout(timer);
|
|
382
|
+
if (killTimer)
|
|
383
|
+
clearTimeout(killTimer);
|
|
384
|
+
reject(new Error(`warble-codex-local failed to start: ${error.message}`));
|
|
385
|
+
});
|
|
386
|
+
child.on("close", (code, signal) => {
|
|
387
|
+
clearTimeout(timer);
|
|
388
|
+
lines.close();
|
|
389
|
+
// Do not cancel an already-scheduled process-group SIGKILL here. The
|
|
390
|
+
// direct dispatcher can exit on SIGTERM while one of its descendants
|
|
391
|
+
// ignores the signal; the escalation still has to reap that descendant.
|
|
392
|
+
if (settled)
|
|
393
|
+
return;
|
|
394
|
+
settled = true;
|
|
395
|
+
if (timedOut) {
|
|
396
|
+
reject(new Error(`warble-codex-local timed out after ${timeoutMs}ms`));
|
|
397
|
+
}
|
|
398
|
+
else if (code !== 0) {
|
|
399
|
+
reject(new Error(`warble-codex-local exited with ${code ?? signal ?? "unknown"}: ${stderr.trim()}`));
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
try {
|
|
403
|
+
resolve(mapper.result());
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
reject(error);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* This package's bundled setup capability-provider fragment
|
|
414
|
+
* (`providers/setup.provider.yaml`) — the default `--provider` for
|
|
415
|
+
* `InProcessSetupRunner`'s dispatch, mirroring `compileProfile`'s own
|
|
416
|
+
* `DEFAULT_WREN_PROVIDER_PATH` constant (`harness/compile/pipeline.ts`).
|
|
417
|
+
*/
|
|
418
|
+
export const DEFAULT_SETUP_PROVIDER_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "providers", "setup.provider.yaml");
|
|
419
|
+
/**
|
|
420
|
+
* Dispatches a setup-wizard turn over in-process: the in-process vercel loop
|
|
421
|
+
* (`harness/loop/executor.ts`'s `executeAgent`), instead of shelling a
|
|
422
|
+
* claude-agent-sdk subprocess. Mirrors `DispatchedSetupRunner`'s own shape
|
|
423
|
+
* (same `SetupStepRunner` interface, same "bypass the per-user compile
|
|
424
|
+
* pipeline and dispatch the fixed, warble-committed setup IR directly"
|
|
425
|
+
* design) but reuses the in-process building blocks `harness/route/in-process.ts`'s
|
|
426
|
+
* `runInProcessDefault` wires together, minus the two pieces that don't apply to
|
|
427
|
+
* setup:
|
|
428
|
+
*
|
|
429
|
+
* - No `resolveWrenBinary()` preflight — the setup components' only tool
|
|
430
|
+
* (`setup_execution`, see `harness/tools/setup-native.ts`) never shells
|
|
431
|
+
* the `wren` CLI directly; it's a general scoped exec/write pair, not a
|
|
432
|
+
* wren-specific one.
|
|
433
|
+
* - No `deriveEnforcement(agent)` (`harness/guardrails/policy.ts`) — that
|
|
434
|
+
* function maps `read_only`/`scoped_write`/threshold guardrails onto an
|
|
435
|
+
* `EnforcementPolicy` and has no branch for the `setup_execution`
|
|
436
|
+
* guardrail shape (`{name: "setup_execution", locked: true, scope: "."}`)
|
|
437
|
+
* the two setup components declare. Rather than widen that shared module
|
|
438
|
+
* for a guardrail only this path uses, this runner builds its own fixed
|
|
439
|
+
* `ExecutionPolicy` below, matching what `setup_execution` actually grants
|
|
440
|
+
* on the dispatched (claude-agent-sdk) side: unrestricted exec (subject only
|
|
441
|
+
* to the native tool's own destructive/redirection denylist, never
|
|
442
|
+
* `readOnly`-gated) and a write scope of the whole workspace root.
|
|
443
|
+
*
|
|
444
|
+
* Unlike `DispatchedSetupRunner`, which forwards its `--max-turns` budget to a
|
|
445
|
+
* subprocess CLI flag, this runner's turn budget is an in-process
|
|
446
|
+
* `ExecuteAgentContext.maxSteps` (an `isStepCount` stop condition on the
|
|
447
|
+
* step's underlying `ToolLoopAgent`) — see `effectiveMaxTurns()` below for
|
|
448
|
+
* how it's resolved, and `StepBudgetExhaustedError` (`harness/loop/errors.ts`)
|
|
449
|
+
* for how a turn that runs out of steps before finishing is reported.
|
|
450
|
+
*
|
|
451
|
+
* Also calls `executeAgent` directly rather than the higher-level
|
|
452
|
+
* `runAgent()` in-process's Ask-turn path uses: `runAgent` asserts every
|
|
453
|
+
* `agent.capabilities[]` entry against a `CapabilityRegistry`, but
|
|
454
|
+
* `connect_source`/`build_context` declare `source_connect`/`context_build`
|
|
455
|
+
* — capabilities outside the fixed `DEFAULT_CAPABILITIES` set a wren-project
|
|
456
|
+
* Ask turn resolves. `executeAgent` (confirmed via `ExecuteAgentContext`'s
|
|
457
|
+
* own shape) has no `capabilityRegistry` concept at all, so it runs the
|
|
458
|
+
* bundle's dataflow with no capability assertion — exactly what dispatching
|
|
459
|
+
* a setup-only capability needs, without inventing a parallel registry for
|
|
460
|
+
* two names nothing else provides.
|
|
461
|
+
*/
|
|
462
|
+
export class InProcessSetupRunner {
|
|
463
|
+
options;
|
|
464
|
+
constructor(options) {
|
|
465
|
+
this.options = options;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* The `ExecuteAgentContext.maxSteps` budget this runner will actually pass
|
|
469
|
+
* into `executeAgent` for a given `agentId` — the same value `run()`
|
|
470
|
+
* computes internally. This was previously documented as always
|
|
471
|
+
* `undefined` on the theory that "`executeAgent` has no turn-budget
|
|
472
|
+
* concept at all" — that was already false when it was written: every
|
|
473
|
+
* tools-bearing step ran as a `ToolLoopAgent` turn, and `ToolLoopAgent`
|
|
474
|
+
* itself silently defaults to a 20-step budget (`ai`'s
|
|
475
|
+
* `stopWhen: this.settings.stopWhen ?? isStepCount(20)`) whenever nothing
|
|
476
|
+
* overrides it. There was a real, unconfigurable ceiling the whole time;
|
|
477
|
+
* it just had no name and nothing here reported it. Now that `run()` below
|
|
478
|
+
* threads an explicit `maxSteps` through, this reports the real value —
|
|
479
|
+
* mirroring `DispatchedSetupRunner.effectiveMaxTurns` — via the same
|
|
480
|
+
* `resolveEffectiveMaxTurns` helper so the two can never disagree.
|
|
481
|
+
*/
|
|
482
|
+
effectiveMaxTurns(agentId) {
|
|
483
|
+
return resolveEffectiveMaxTurns(this.options.maxTurns, agentId);
|
|
484
|
+
}
|
|
485
|
+
async run(runOptions) {
|
|
486
|
+
const { authChoice } = runOptions;
|
|
487
|
+
const agentId = runOptions.agentId ?? CONNECT_SOURCE_AGENT_ID;
|
|
488
|
+
if (authChoice.mode === "subscription") {
|
|
489
|
+
throw new Error(`in-process setup dispatch requires an api-key/local/gateway auth mode to dispatch "${agentId}" ` +
|
|
490
|
+
`via the in-process vercel loop — got "subscription". Use DispatchedSetupRunner ` +
|
|
491
|
+
"(WREN_HARNESS_MODE=subscription) for a subscription auth choice instead.");
|
|
492
|
+
}
|
|
493
|
+
const warbleBin = await resolveWarbleBinary(this.options.warbleBin);
|
|
494
|
+
const providerPath = this.options.providerPath ?? DEFAULT_SETUP_PROVIDER_PATH;
|
|
495
|
+
const callerSuppliedOutDir = this.options.outDir !== undefined;
|
|
496
|
+
const outDir = this.options.outDir ?? (await mkdtemp(path.join(os.tmpdir(), "wren-harness-setup-in-process-")));
|
|
497
|
+
// `executeAgent` emits the UN-stamped `AgentEventInput` shape (no
|
|
498
|
+
// `runId`/`seq` — it doesn't know either); `SetupStepRunOptions.onEvent`
|
|
499
|
+
// is the caller-facing, fully-stamped `AgentEvent` shape. Wrapping via
|
|
500
|
+
// `createAgentEventEmitter` (exactly how `runAgent` itself bridges the
|
|
501
|
+
// same two shapes in `harness/session/run.ts`) is what stamps every
|
|
502
|
+
// event from this turn with one shared `runId` and a monotonic `seq`
|
|
503
|
+
// before it reaches `runOptions.onEvent`. The emitter itself no-ops when
|
|
504
|
+
// `runOptions.onEvent` is undefined, so `emitter.emit` is always safe to
|
|
505
|
+
// pass unconditionally.
|
|
506
|
+
const emitter = createAgentEventEmitter(runOptions.onEvent);
|
|
507
|
+
emitter.emit({ kind: "run.start", mode: "A", agentId });
|
|
508
|
+
try {
|
|
509
|
+
await runWarble(warbleBin, [
|
|
510
|
+
"dispatch",
|
|
511
|
+
"--target",
|
|
512
|
+
"vercel",
|
|
513
|
+
"--provider",
|
|
514
|
+
providerPath,
|
|
515
|
+
this.options.irPath,
|
|
516
|
+
"--out",
|
|
517
|
+
outDir,
|
|
518
|
+
]);
|
|
519
|
+
const bundlePath = path.join(outDir, "bundle.json");
|
|
520
|
+
const bundle = loadBundleWithProvenance(JSON.parse(await readFile(bundlePath, "utf-8")), {
|
|
521
|
+
warbleBin,
|
|
522
|
+
profileSource: this.options.irPath,
|
|
523
|
+
});
|
|
524
|
+
const agent = bundle.agents.find((candidate) => candidate.id === agentId);
|
|
525
|
+
if (!agent) {
|
|
526
|
+
throw new Error(`compiled setup bundle has no "${agentId}" agent`);
|
|
527
|
+
}
|
|
528
|
+
const adapterSpec = this.options.getStrongAdapterSpec
|
|
529
|
+
? this.options.getStrongAdapterSpec(authChoice)
|
|
530
|
+
: deriveAdapterSpec(authChoice, this.options.model !== undefined ? { model: this.options.model } : {});
|
|
531
|
+
const binding = buildUniformTierBinding(agent, adapterSpec);
|
|
532
|
+
const registry = createDefaultProviderRegistry();
|
|
533
|
+
// Hardcoded, NOT `deriveEnforcement(agent)` — see this class's doc
|
|
534
|
+
// comment above for why. `artifactWriteScope: "."` resolves against
|
|
535
|
+
// `env`'s `rootDir` below, which IS `workspaceRoot` — so "." means
|
|
536
|
+
// "anywhere under workspaceRoot", matching `setup_execution`'s
|
|
537
|
+
// project-root write scope on the dispatched side.
|
|
538
|
+
const policy = { readOnly: false, artifactWriteScope: "." };
|
|
539
|
+
const env = createLocalExecutionEnv({ rootDir: runOptions.workspaceRoot });
|
|
540
|
+
const nativeTools = createNativeToolRegistry();
|
|
541
|
+
nativeTools.register(SETUP_EXECUTION_TOOL_NAME, () => createSetupExecutionTool({ env, policy, workspaceRoot: runOptions.workspaceRoot }));
|
|
542
|
+
// Kept in lockstep with `effectiveMaxTurns()` above via the shared
|
|
543
|
+
// `resolveEffectiveMaxTurns` helper, exactly like `DispatchedSetupRunner.run()`
|
|
544
|
+
// does for its own `--max-turns` flag.
|
|
545
|
+
const maxSteps = this.effectiveMaxTurns(agentId);
|
|
546
|
+
const artifacts = await withResolvedTools(agent, { nativeTools, enforcementPolicy: policy, executionEnv: env }, (tools) => executeAgent(agent, {
|
|
547
|
+
binding,
|
|
548
|
+
registry,
|
|
549
|
+
tools,
|
|
550
|
+
userInput: runOptions.prompt,
|
|
551
|
+
onEvent: emitter.emit,
|
|
552
|
+
...(maxSteps !== undefined ? { maxSteps } : {}),
|
|
553
|
+
}));
|
|
554
|
+
// Every setup component has exactly one step (`connect` / `build`), so
|
|
555
|
+
// its `produces` artifact is the turn's final answer — mirroring
|
|
556
|
+
// `DispatchedSetupRunner`'s `result.finalText`, which is likewise "the last
|
|
557
|
+
// thing the dispatched turn said."
|
|
558
|
+
const producedName = agent.steps[0]?.produces;
|
|
559
|
+
const finalText = producedName !== undefined ? String(artifacts.get(producedName) ?? "") : "";
|
|
560
|
+
// `"answer"` is the closest of `RunFinishEvent.status`'s three literals
|
|
561
|
+
// ("answer" | "refusal" | "error") to "a setup turn completed" — setup
|
|
562
|
+
// has no envelope-gated refusal concept of its own (see this class's
|
|
563
|
+
// doc comment), so a completed dispatch is reported the same way
|
|
564
|
+
// `runAgent` reports a completed Ask turn that produced output.
|
|
565
|
+
emitter.emit({ kind: "answer", text: finalText });
|
|
566
|
+
emitter.emit({ kind: "run.finish", status: "answer" });
|
|
567
|
+
// `sessionId: null`, not omitted: in-process has no SDK subprocess
|
|
568
|
+
// conversation to resume (`executeAgent` re-runs the bundle's dataflow
|
|
569
|
+
// from scratch every call) — `null` is `SetupStepRunResult`'s
|
|
570
|
+
// documented "the dispatcher itself reported no session id at all,"
|
|
571
|
+
// which is exactly this case, distinct from a runner that simply never
|
|
572
|
+
// set the field.
|
|
573
|
+
return { finalText, sessionId: null };
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
// A `StepBudgetExhaustedError` is a distinguishable, honest failure
|
|
577
|
+
// mode, not a generic one — surface that here rather than letting it
|
|
578
|
+
// fall through as a bare "final message did not contain a
|
|
579
|
+
// SETUP_STATUS line" once it reaches `parseSetupTerminal`'s caller
|
|
580
|
+
// (which never even runs `parseSetupTerminal` on a thrown `run()`
|
|
581
|
+
// error — see `executeSetupTurn`, `server/turn.ts` — it reports
|
|
582
|
+
// `error.message` directly). No literal "Continue" checkpoint is
|
|
583
|
+
// offered here (out of scope for this runner: in-process has no SDK
|
|
584
|
+
// session to resume — `sessionId: null` below, always) — just an
|
|
585
|
+
// honest message pointing at where any partial work may have landed.
|
|
586
|
+
if (error instanceof StepBudgetExhaustedError) {
|
|
587
|
+
// Mutate `.message` in place (legal — only the constructor
|
|
588
|
+
// parameters above are `readonly`, not the inherited `Error.message`)
|
|
589
|
+
// rather than wrapping in a fresh `Error`: callers upstream (or
|
|
590
|
+
// future ones) can still `instanceof StepBudgetExhaustedError` this
|
|
591
|
+
// and read `.maxSteps`/`.stepId`/`.stepsCompleted`, while
|
|
592
|
+
// `server/turn.ts`'s generic `failWithError(error.message, ...)`
|
|
593
|
+
// path — which only ever reads `.message` — sees the enriched,
|
|
594
|
+
// Mode-A-specific text instead of the generic one from
|
|
595
|
+
// `harness/loop/errors.ts`.
|
|
596
|
+
error.message =
|
|
597
|
+
`setup step "${agentId}" ran out of steps (${error.maxSteps} max) before finishing — ` +
|
|
598
|
+
`its partial work, if any, may still be on disk under ${runOptions.workspaceRoot}`;
|
|
599
|
+
}
|
|
600
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
601
|
+
// Mirrors `runAgent`'s own catch/emit/rethrow in `harness/session/run.ts`:
|
|
602
|
+
// a thrown failure still gets a `run.finish` bookend (status "error")
|
|
603
|
+
// rather than leaving the event stream hanging with a `run.start` and
|
|
604
|
+
// nothing after it.
|
|
605
|
+
emitter.emit({ kind: "error", message });
|
|
606
|
+
emitter.emit({ kind: "run.finish", status: "error" });
|
|
607
|
+
throw error;
|
|
608
|
+
}
|
|
609
|
+
finally {
|
|
610
|
+
if (!callerSuppliedOutDir) {
|
|
611
|
+
await rm(outDir, { recursive: true, force: true });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Reads a single top-level `field: value` line out of a YAML file using the
|
|
618
|
+
* same lightweight line-scan convention already used elsewhere in this
|
|
619
|
+
* codebase (e.g. `server/compose.ts`'s `countRelationships`/`listModelNames`)
|
|
620
|
+
* rather than pulling in a YAML parser dependency. Matches only an unindented
|
|
621
|
+
* `field:` key (so it can't be fooled by a same-named key nested under
|
|
622
|
+
* another mapping), strips a single layer of surrounding quotes, and returns
|
|
623
|
+
* `undefined` if the file is missing/unreadable or the field isn't present.
|
|
624
|
+
*/
|
|
625
|
+
function readProjectYamlField(yamlPath, field) {
|
|
626
|
+
let content;
|
|
627
|
+
try {
|
|
628
|
+
content = readFileSync(yamlPath, "utf-8");
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
return undefined;
|
|
632
|
+
}
|
|
633
|
+
const pattern = new RegExp(`^${field}:\\s*(.+?)\\s*$`, "m");
|
|
634
|
+
const match = pattern.exec(content);
|
|
635
|
+
if (!match) {
|
|
636
|
+
return undefined;
|
|
637
|
+
}
|
|
638
|
+
const raw = match[1];
|
|
639
|
+
const unquoted = /^"(.*)"$/.exec(raw) ?? /^'(.*)'$/.exec(raw);
|
|
640
|
+
return (unquoted ? unquoted[1] : raw).trim();
|
|
641
|
+
}
|
|
642
|
+
/** Parses the small KEY=value subset of a project .env file used by Wren profiles. */
|
|
643
|
+
function parseDotEnv(text) {
|
|
644
|
+
const values = new Map();
|
|
645
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
646
|
+
const line = rawLine.trim();
|
|
647
|
+
if (!line || line.startsWith("#"))
|
|
648
|
+
continue;
|
|
649
|
+
const assignment = line.startsWith("export ") ? line.slice("export ".length) : line;
|
|
650
|
+
const separator = assignment.indexOf("=");
|
|
651
|
+
if (separator === -1)
|
|
652
|
+
continue;
|
|
653
|
+
const key = assignment.slice(0, separator).trim();
|
|
654
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
655
|
+
continue;
|
|
656
|
+
const rawValue = assignment.slice(separator + 1).trim();
|
|
657
|
+
const quoted = /^(".*"|'.*')$/.test(rawValue) ? rawValue.slice(1, -1) : rawValue;
|
|
658
|
+
values.set(key, quoted);
|
|
659
|
+
}
|
|
660
|
+
return values;
|
|
661
|
+
}
|
|
662
|
+
function normalizeConnectionScheme(scheme) {
|
|
663
|
+
const normalized = scheme.replace(/:$/, "").toLowerCase();
|
|
664
|
+
return normalized === "postgresql" ? "postgres" : normalized;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Closed URL-scheme allowlist for the source types accepted by Setup. Keep
|
|
668
|
+
* this local rather than importing `server/app.ts`: harness must not depend
|
|
669
|
+
* on its BFF consumer. A source without an unambiguous host/database URL
|
|
670
|
+
* shape (for example DuckDB's filesystem `url`) deliberately has no entries
|
|
671
|
+
* and therefore cannot retain lifecycle proof through this URL path.
|
|
672
|
+
*/
|
|
673
|
+
const URL_SCHEMES_BY_SOURCE = new Map([
|
|
674
|
+
["postgres", new Set(["postgres"])],
|
|
675
|
+
["mysql", new Set(["mysql", "mysql+pymysql", "mysql+mysqldb"])],
|
|
676
|
+
["bigquery", new Set(["bigquery"])],
|
|
677
|
+
["snowflake", new Set(["snowflake"])],
|
|
678
|
+
["clickhouse", new Set(["clickhouse", "clickhouse+http", "clickhouse+https"])],
|
|
679
|
+
["mssql", new Set(["mssql"])],
|
|
680
|
+
["trino", new Set(["trino", "trino+https"])],
|
|
681
|
+
["duckdb", new Set()],
|
|
682
|
+
]);
|
|
683
|
+
/**
|
|
684
|
+
* Only these Setup sources have the host/port/database fields represented by
|
|
685
|
+
* the generic field fallback below. Other supported sources use different
|
|
686
|
+
* identity shapes (for example BigQuery project/dataset and Trino
|
|
687
|
+
* catalog/schema), so accepting generic DB_HOST/DB_DATABASE for them would
|
|
688
|
+
* forge an identity rather than derive the effective connection target.
|
|
689
|
+
*/
|
|
690
|
+
const HOST_DATABASE_FIELD_SOURCES = new Set([
|
|
691
|
+
"postgres",
|
|
692
|
+
"mysql",
|
|
693
|
+
"clickhouse",
|
|
694
|
+
"mssql",
|
|
695
|
+
// Added when Setup began offering wren's full connector set. Each of these
|
|
696
|
+
// has host/port/database in its own wren connection model, so the generic
|
|
697
|
+
// fallback derives their identity rather than forging one. Sources whose
|
|
698
|
+
// identity is shaped differently — BigQuery's project/dataset, Trino's
|
|
699
|
+
// catalog/schema, Databricks' serverHostname/httpPath, Athena's S3 staging
|
|
700
|
+
// dir, Spark's bare host/port — are still deliberately absent.
|
|
701
|
+
"oracle",
|
|
702
|
+
"redshift",
|
|
703
|
+
"doris",
|
|
704
|
+
]);
|
|
705
|
+
function connectionTargetFromUrl(value, sourceType) {
|
|
706
|
+
try {
|
|
707
|
+
const url = new URL(value);
|
|
708
|
+
const database = decodeURIComponent(url.pathname).replace(/^\/+|\/+$/g, "");
|
|
709
|
+
const scheme = normalizeConnectionScheme(url.protocol);
|
|
710
|
+
if (!url.protocol || !url.hostname || !database || !URL_SCHEMES_BY_SOURCE.get(sourceType)?.has(scheme))
|
|
711
|
+
return undefined;
|
|
712
|
+
// Intentionally do not return username, password, query, or fragment.
|
|
713
|
+
return {
|
|
714
|
+
scheme,
|
|
715
|
+
host: url.hostname.toLowerCase(),
|
|
716
|
+
port: url.port,
|
|
717
|
+
database,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
return undefined;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
function connectionTargetFromFields(scheme, host, port, database) {
|
|
725
|
+
if (!host || !database)
|
|
726
|
+
return undefined;
|
|
727
|
+
return { scheme: normalizeConnectionScheme(scheme), host: host.toLowerCase(), port: port ?? "", database };
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Resolves a single effective value using Wren's shell-over-.env precedence.
|
|
731
|
+
* Multiple aliases that disagree are ambiguous, so retained proof must not be
|
|
732
|
+
* reused. The returned value is never persisted directly.
|
|
733
|
+
*/
|
|
734
|
+
function effectiveConnectionValue(dotenv, aliases) {
|
|
735
|
+
const values = new Set();
|
|
736
|
+
for (const key of aliases) {
|
|
737
|
+
const value = process.env[key] ?? dotenv.get(key);
|
|
738
|
+
if (value !== undefined && value.trim())
|
|
739
|
+
values.add(value.trim());
|
|
740
|
+
}
|
|
741
|
+
return values.size === 1 ? [...values][0] : undefined;
|
|
742
|
+
}
|
|
743
|
+
function connectionAliases(sourceType) {
|
|
744
|
+
const source = sourceType.trim().toUpperCase().replace(/[^A-Z0-9]+/g, "_");
|
|
745
|
+
const generic = {
|
|
746
|
+
urls: [`${source}_URL`, "DATABASE_URL", "DB_URL"],
|
|
747
|
+
hosts: [`${source}_HOST`, "DB_HOST"],
|
|
748
|
+
ports: [`${source}_PORT`, "DB_PORT"],
|
|
749
|
+
databases: [`${source}_DATABASE`, `${source}_DB`, "DB_DATABASE", "DB_NAME"],
|
|
750
|
+
};
|
|
751
|
+
if (source !== "POSTGRES" && source !== "POSTGRESQL")
|
|
752
|
+
return generic;
|
|
753
|
+
return {
|
|
754
|
+
urls: ["POSTGRES_URL", "POSTGRESQL_URL", "PG_URL", ...generic.urls],
|
|
755
|
+
hosts: ["POSTGRES_HOST", "PGHOST", ...generic.hosts],
|
|
756
|
+
ports: ["POSTGRES_PORT", "PGPORT", ...generic.ports],
|
|
757
|
+
databases: ["POSTGRES_DATABASE", "POSTGRES_DB", "PGDATABASE", "PG_DB", ...generic.databases],
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Returns a digest of the effective, non-secret database target. It accepts a
|
|
762
|
+
* URL or host/database fields, but deliberately retains only scheme, host,
|
|
763
|
+
* port, and database; credentials and raw URLs never enter persisted state.
|
|
764
|
+
* Missing or contradictory target facts fail closed.
|
|
765
|
+
*/
|
|
766
|
+
function effectiveConnectionTargetDigest(projectDir, sourceType) {
|
|
767
|
+
let dotenv;
|
|
768
|
+
try {
|
|
769
|
+
dotenv = parseDotEnv(readFileSync(path.join(projectDir, ".env"), "utf-8"));
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
return undefined;
|
|
773
|
+
}
|
|
774
|
+
const aliases = connectionAliases(sourceType);
|
|
775
|
+
const targets = new Set();
|
|
776
|
+
for (const key of aliases.urls) {
|
|
777
|
+
const value = process.env[key] ?? dotenv.get(key);
|
|
778
|
+
if (value === undefined || !value.trim())
|
|
779
|
+
continue;
|
|
780
|
+
const target = connectionTargetFromUrl(value.trim(), sourceType);
|
|
781
|
+
if (target === undefined)
|
|
782
|
+
return undefined;
|
|
783
|
+
targets.add(JSON.stringify(target));
|
|
784
|
+
}
|
|
785
|
+
const fieldTarget = !HOST_DATABASE_FIELD_SOURCES.has(sourceType)
|
|
786
|
+
? undefined
|
|
787
|
+
: connectionTargetFromFields(sourceType, effectiveConnectionValue(dotenv, aliases.hosts), effectiveConnectionValue(dotenv, aliases.ports), effectiveConnectionValue(dotenv, aliases.databases));
|
|
788
|
+
if (fieldTarget !== undefined)
|
|
789
|
+
targets.add(JSON.stringify(fieldTarget));
|
|
790
|
+
if (targets.size !== 1)
|
|
791
|
+
return undefined;
|
|
792
|
+
return createHash("sha256").update([...targets][0]).digest("hex");
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* A non-secret, host-derived identity for retained context lifecycle proof.
|
|
796
|
+
* It deliberately binds the canonical on-disk project, the BFF-selected
|
|
797
|
+
* source type, wren's project-level profile declaration, and a non-secret
|
|
798
|
+
* digest of the effective connection target. If any required filesystem fact
|
|
799
|
+
* cannot be read or is ambiguous, callers must fail closed and not reuse
|
|
800
|
+
* evidence from an earlier attempt.
|
|
801
|
+
*/
|
|
802
|
+
export function contextLifecycleIdentityFingerprint(root, name, selectedSourceType) {
|
|
803
|
+
try {
|
|
804
|
+
const projectDir = realpathSync(path.join(root, name));
|
|
805
|
+
if (!statSync(projectDir).isDirectory())
|
|
806
|
+
return undefined;
|
|
807
|
+
const projectYml = path.join(projectDir, "wren_project.yml");
|
|
808
|
+
const profile = readProjectYamlField(projectYml, "profile");
|
|
809
|
+
const declaredSourceType = readProjectYamlField(projectYml, "data_source");
|
|
810
|
+
const normalizedSelectedSourceType = selectedSourceType.trim().toLowerCase();
|
|
811
|
+
const normalizedDeclaredSourceType = declaredSourceType?.trim().toLowerCase();
|
|
812
|
+
if (profile === undefined || normalizedDeclaredSourceType === undefined || normalizedDeclaredSourceType !== normalizedSelectedSourceType)
|
|
813
|
+
return undefined;
|
|
814
|
+
const connectionTargetDigest = effectiveConnectionTargetDigest(projectDir, normalizedSelectedSourceType);
|
|
815
|
+
if (connectionTargetDigest === undefined)
|
|
816
|
+
return undefined;
|
|
817
|
+
return createHash("sha256")
|
|
818
|
+
.update(JSON.stringify({ version: 2, projectDir, selectedSourceType: normalizedSelectedSourceType, profile, declaredSourceType: normalizedDeclaredSourceType, connectionTargetDigest }))
|
|
819
|
+
.digest("hex");
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
return undefined;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
function hostContractFailure(code, message, observed, expectedArtifactContract, failureKind) {
|
|
826
|
+
return {
|
|
827
|
+
status: "error",
|
|
828
|
+
message,
|
|
829
|
+
diagnostic: { kind: "host_contract", code, observed, expectedArtifactContract },
|
|
830
|
+
...(failureKind !== undefined ? { failureKind } : {}),
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
const SETUP_STATUS_LINE = /^SETUP_STATUS:\s*(ok|needs_input|error)\b\s*[-:]?\s*(.*)$/i;
|
|
834
|
+
function defaultMessageFor(status) {
|
|
835
|
+
switch (status) {
|
|
836
|
+
case "ok":
|
|
837
|
+
return "setup completed successfully";
|
|
838
|
+
case "needs_input":
|
|
839
|
+
return "waiting for user input";
|
|
840
|
+
case "error":
|
|
841
|
+
return "setup failed";
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
/** Counts the built MDL's models; unreadable/malformed content is deliberately treated as empty rather than trusted. */
|
|
845
|
+
function countMdlModels(mdlPath) {
|
|
846
|
+
try {
|
|
847
|
+
const parsed = JSON.parse(readFileSync(mdlPath, "utf-8"));
|
|
848
|
+
return Array.isArray(parsed.models) ? parsed.models.length : 0;
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
return 0;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Returns the path of the first artifact `connect`'s prompt promises but
|
|
856
|
+
* that is missing from disk, or `undefined` if all three are present. Checked
|
|
857
|
+
* in the order a real `connect` run would produce them, so the message names
|
|
858
|
+
* the earliest failure (e.g. a missing project dir implies the other two are
|
|
859
|
+
* also missing, but naming the dir is the most useful signal).
|
|
860
|
+
*/
|
|
861
|
+
function missingConnectArtifact(context) {
|
|
862
|
+
const projectDir = path.join(context.root, context.name);
|
|
863
|
+
if (!existsSync(projectDir)) {
|
|
864
|
+
return projectDir;
|
|
865
|
+
}
|
|
866
|
+
const projectYml = path.join(projectDir, "wren_project.yml");
|
|
867
|
+
if (!existsSync(projectYml)) {
|
|
868
|
+
return projectYml;
|
|
869
|
+
}
|
|
870
|
+
const envFile = path.join(projectDir, ".env");
|
|
871
|
+
if (!existsSync(envFile)) {
|
|
872
|
+
return envFile;
|
|
873
|
+
}
|
|
874
|
+
return undefined;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Matches in-process's / Codex local's `setup_execution` "exec" call's
|
|
878
|
+
* `ToolStep.detail` — a `summarizeToolOutput`-bounded (200-char)
|
|
879
|
+
* `JSON.stringify` of `{exitCode, stdout, stderr, ...}` (see
|
|
880
|
+
* `harness/tools/setup-native.ts`) — and extracts the exit code. A regex, not
|
|
881
|
+
* `JSON.parse`: once `stdout`/`stderr` push a stringified object past 200
|
|
882
|
+
* characters (routine for a CLI usage/error message), the truncated detail
|
|
883
|
+
* is no longer valid JSON, but in-process serializes `exitCode` first so it
|
|
884
|
+
* always survives.
|
|
885
|
+
*
|
|
886
|
+
* Deliberately Mode-A/Codex-local-only. There used to be a sibling
|
|
887
|
+
* `MODE_B_BASH_EXIT_CODE` regex here matching a literal `"exit code: N"`
|
|
888
|
+
* phrase, on the theory that a dispatched Bash result might carry that text —
|
|
889
|
+
* but no real code path ever writes it: dispatched's `detail` is the command's
|
|
890
|
+
* raw (240-char-truncated) stdout via `summarizeResultContent` in warble's
|
|
891
|
+
* `dispatcher/claude-agent-sdk/src/events.ts`, with no such wrapping. That
|
|
892
|
+
* dead regex made `execSucceeded` return `undefined` for every genuine dispatched
|
|
893
|
+
* discovery/validate/build call, which made the `context` step's discovery
|
|
894
|
+
* gate structurally unsatisfiable in dispatched — see `execSucceeded` for the fix.
|
|
895
|
+
*/
|
|
896
|
+
const SETUP_EXEC_EXIT_CODE = /^\{"exitCode":(-?\d+)/;
|
|
897
|
+
function inProcessExecExitCode(detail) {
|
|
898
|
+
if (detail === undefined)
|
|
899
|
+
return undefined;
|
|
900
|
+
const match = SETUP_EXEC_EXIT_CODE.exec(detail);
|
|
901
|
+
return match ? Number(match[1]) : undefined;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* In-process registers the scoped execution capability under its policy name,
|
|
905
|
+
* while dispatched's streamed SDK events retain the underlying `Bash` tool name.
|
|
906
|
+
* Both names represent the same setup-only command boundary at this layer.
|
|
907
|
+
*/
|
|
908
|
+
const MODE_B_SETUP_EXECUTION_TOOL_NAME = "Bash";
|
|
909
|
+
/** Codex local streams the same allowlisted tool with its MCP server prefix. */
|
|
910
|
+
const CODEX_SETUP_EXECUTION_TOOL_NAME = `setup.${SETUP_EXECUTION_TOOL_NAME}`;
|
|
911
|
+
function isSetupExecutionEntry(entry) {
|
|
912
|
+
return (entry.label === SETUP_EXECUTION_TOOL_NAME ||
|
|
913
|
+
entry.label === MODE_B_SETUP_EXECUTION_TOOL_NAME ||
|
|
914
|
+
entry.label === CODEX_SETUP_EXECUTION_TOOL_NAME);
|
|
915
|
+
}
|
|
916
|
+
/** In-process and Codex local share the same non-throwing `setup_execution` tool — see {@link execSucceeded}. */
|
|
917
|
+
function isInProcessShapedExecEntry(entry) {
|
|
918
|
+
return entry.label === SETUP_EXECUTION_TOOL_NAME || entry.label === CODEX_SETUP_EXECUTION_TOOL_NAME;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Whether a setup-execution worklog entry represents a genuinely successful
|
|
922
|
+
* command run — `true`/`false` when that's known, `undefined` when it isn't
|
|
923
|
+
* recorded either way (e.g. a in-process file-write action, which has no exit
|
|
924
|
+
* code at all).
|
|
925
|
+
*
|
|
926
|
+
* In-process and Codex local share the same non-throwing `setup_execution` tool
|
|
927
|
+
* (`harness/tools/setup-native.ts`): the JS tool call itself only throws on a
|
|
928
|
+
* pre-flight denylist/scope violation (`SetupCommandDeniedError`), never on
|
|
929
|
+
* the shell command's own exit code — a nonexistent CLI subcommand still
|
|
930
|
+
* returns normally as `{exitCode: 2, ...}` structured output. That means
|
|
931
|
+
* `ToolStep.state` is ALWAYS `"done"` for these entries regardless of
|
|
932
|
+
* whether the shell command itself succeeded, so the only trustworthy signal
|
|
933
|
+
* for them is the structured exit code folded into `detail`
|
|
934
|
+
* ({@link inProcessExecExitCode}) — unchanged from before this fix.
|
|
935
|
+
*
|
|
936
|
+
* Dispatched's Bash tool has no such gap: `LiveWorkLog.ingest` (`server/fold.ts`)
|
|
937
|
+
* already sets `ToolStep.state` from the originating `AgentEvent`'s own
|
|
938
|
+
* `status` field ("success" -> "done", "error" -> "error") at fold time, for
|
|
939
|
+
* both a genuine command failure and a blocked/guardrail-denied execution —
|
|
940
|
+
* either way the tool call itself did not succeed, so `state` is sound to
|
|
941
|
+
* trust directly. It is also the ONLY signal available: dispatched's `detail` is
|
|
942
|
+
* the command's raw (240-char-truncated) stdout, never a structured exit
|
|
943
|
+
* code.
|
|
944
|
+
*/
|
|
945
|
+
function execSucceeded(entry) {
|
|
946
|
+
if (isInProcessShapedExecEntry(entry)) {
|
|
947
|
+
const exitCode = inProcessExecExitCode(entry.detail);
|
|
948
|
+
return exitCode === undefined ? undefined : exitCode === 0;
|
|
949
|
+
}
|
|
950
|
+
if (entry.state === "done")
|
|
951
|
+
return true;
|
|
952
|
+
if (entry.state === "error")
|
|
953
|
+
return false;
|
|
954
|
+
return undefined;
|
|
955
|
+
}
|
|
956
|
+
/** Best-effort extraction of the `command` an exec-action worklog entry ran, for a readable error message; `undefined` if the input isn't the expected shape. */
|
|
957
|
+
function execCommandOf(entry) {
|
|
958
|
+
const input = entry.input;
|
|
959
|
+
if (typeof input !== "object" || input === null)
|
|
960
|
+
return undefined;
|
|
961
|
+
const command = input.command;
|
|
962
|
+
return typeof command === "string" ? command : undefined;
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Finds the first setup execution call in `worklog` whose recorded result was
|
|
966
|
+
* a non-zero exit code — i.e. a command that genuinely ran and failed,
|
|
967
|
+
* as opposed to one whose tool invocation merely reported success. This
|
|
968
|
+
* distinction matters because `ToolStep.state` alone cannot make it: every
|
|
969
|
+
* setup execution call reports `state: "done"` as long as the TOOL MECHANISM
|
|
970
|
+
* itself didn't throw (it ran a shell command and captured
|
|
971
|
+
* whatever came back) — regardless of whether the underlying shell command
|
|
972
|
+
* itself succeeded. A nonexistent CLI subcommand (e.g. `wren generate-mdl` —
|
|
973
|
+
* `generate-mdl` is a skill document, not a command; see
|
|
974
|
+
* `composeSetupPrompt`'s `context` branch) still shows `state: "done"`; only
|
|
975
|
+
* the nested `detail` JSON's `exitCode` carries the real signal. See
|
|
976
|
+
* `parseSetupTerminal`'s use of this for why: an `error` status whose own
|
|
977
|
+
* claimed reason blames the connection/data source is only trustworthy if
|
|
978
|
+
* nothing in the trace shows a command failing outright first.
|
|
979
|
+
*
|
|
980
|
+
* KNOWN LIMITATION (disclosed, not silently assumed away): this flags ANY
|
|
981
|
+
* failed exec among the step's setup execution calls, without judging
|
|
982
|
+
* whether that particular command was expected to fail as part of ordinary
|
|
983
|
+
* exploration (e.g. a `grep` with no match, or a conditional shell test) —
|
|
984
|
+
* doing so reliably would require actually understanding the command's
|
|
985
|
+
* intent, which this module has no basis to do. The returned message is
|
|
986
|
+
* worded as advisory ("does not by itself confirm...") rather than as a
|
|
987
|
+
* counter-assertion for exactly this reason.
|
|
988
|
+
*
|
|
989
|
+
* `exitCode` is optional: in-process/Codex local failures carry a real numeric
|
|
990
|
+
* exit code; a dispatched (Bash) failure — including a blocked/guardrail-denied
|
|
991
|
+
* execution — has none (only the fact that it failed), so callers must
|
|
992
|
+
* degrade their wording gracefully rather than assume a number is present.
|
|
993
|
+
*/
|
|
994
|
+
function firstFailedExec(worklog) {
|
|
995
|
+
for (const entry of worklog) {
|
|
996
|
+
if (!isSetupExecutionEntry(entry))
|
|
997
|
+
continue;
|
|
998
|
+
if (execSucceeded(entry) === false) {
|
|
999
|
+
const exitCode = isInProcessShapedExecEntry(entry) ? inProcessExecExitCode(entry.detail) : undefined;
|
|
1000
|
+
return { command: execCommandOf(entry), ...(exitCode !== undefined ? { exitCode } : {}) };
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return undefined;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Whether `command` has one of the schema-introspection shapes currently
|
|
1007
|
+
* documented by the generate-mdl skill. This is deliberately an allowlist:
|
|
1008
|
+
* a Wren SQL query, inline Python using SQLAlchemy or a documented database
|
|
1009
|
+
* driver, or a connector CLI is only counted when its command text ALSO has
|
|
1010
|
+
* recognizable Phase-2 discovery evidence: a schema/catalog query, a table
|
|
1011
|
+
* listing command, or a documented table/column discovery API. In particular,
|
|
1012
|
+
* connectivity probes such as `SELECT 1` and arbitrary data queries do NOT
|
|
1013
|
+
* count; neither do skill fetches or the context build/validate lifecycle
|
|
1014
|
+
* commands.
|
|
1015
|
+
*
|
|
1016
|
+
* KNOWN LIMITATIONS (disclosed, not silently assumed away): a worklog stores
|
|
1017
|
+
* only the shell command, not an executed script's contents or its query
|
|
1018
|
+
* result. Therefore this recognizes an ATTEMPT with recognizable discovery
|
|
1019
|
+
* evidence, not a successful schema read; it cannot recognize opaque script
|
|
1020
|
+
* paths, database-specific CLI metacommands, every future driver/CLI, or
|
|
1021
|
+
* prove that a matched query returned schema rows. Expanding beyond the
|
|
1022
|
+
* evidenced command shapes would turn this from an allowlist into guesswork,
|
|
1023
|
+
* so unmatched commands intentionally do not count.
|
|
1024
|
+
*/
|
|
1025
|
+
const SCHEMA_DISCOVERY_EVIDENCE = /\b(?:information_schema|pg_catalog|sqlite_master|system\.tables|show\s+(?:full\s+)?(?:tables|columns)|describe\s+(?:table|schema)|\.tables|get_table_names|get_columns|get_foreign_keys|get_pk_constraint|get_unique_constraints|list_tables|get_table)\b/i;
|
|
1026
|
+
const SCHEMA_INTROSPECTION_COMMANDS = [
|
|
1027
|
+
/\bwren(?:\s+query)?\s+--sql(?:\s|=)/i,
|
|
1028
|
+
/\b(?:python(?:3(?:\.\d+)?)?|uv\s+run\s+python)\b[\s\S]*\b(?:sqlalchemy|psycopg|asyncpg|google\.cloud\.bigquery|snowflake\.connector|clickhouse_driver)\b/i,
|
|
1029
|
+
/\b(?:python(?:3(?:\.\d+)?)?|uv\s+run\s+python)\b[\s\S]*\bwren\.profile\b[\s\S]*\bwren\.connector\b/i,
|
|
1030
|
+
/\bWREN_PYTHON\b[\s\S]*\bcommand\s+-v\s+wren\b[\s\S]*\bwren\.profile\b[\s\S]*\bwren\.connector\b/i,
|
|
1031
|
+
/\b(?:psql|mysql|sqlite3|duckdb|snowsql|clickhouse-client|trino(?:-cli)?)\b[\s\S]*(?:\s(?:-c|--command|--execute|--query)\s|\b(?:information_schema|show\s+(?:tables|columns)|describe\s+(?:table|schema)|\.tables)\b)/i,
|
|
1032
|
+
/\bbq\s+query\b/i,
|
|
1033
|
+
];
|
|
1034
|
+
/**
|
|
1035
|
+
* The single setup-workflow contract for recognisable schema discovery. A
|
|
1036
|
+
* matching command is evidence only after it completed successfully (see
|
|
1037
|
+
* {@link execSucceeded} for what "successfully" means per mode); command
|
|
1038
|
+
* text alone proves neither execution nor a readable schema. Callers use
|
|
1039
|
+
* this for both terminal acceptance and the no-introspection diagnostic,
|
|
1040
|
+
* rather than reimplementing the allowlist elsewhere.
|
|
1041
|
+
*/
|
|
1042
|
+
export function classifyRecordedSchemaDiscovery(worklog) {
|
|
1043
|
+
let failed;
|
|
1044
|
+
for (const entry of worklog) {
|
|
1045
|
+
const command = isSetupExecutionEntry(entry) ? execCommandOf(entry) : undefined;
|
|
1046
|
+
if (command === undefined ||
|
|
1047
|
+
!SCHEMA_DISCOVERY_EVIDENCE.test(command) ||
|
|
1048
|
+
!SCHEMA_INTROSPECTION_COMMANDS.some((pattern) => pattern.test(command))) {
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
const succeeded = execSucceeded(entry);
|
|
1052
|
+
if (succeeded === true)
|
|
1053
|
+
return { kind: "successful", command };
|
|
1054
|
+
if (succeeded === false && failed === undefined) {
|
|
1055
|
+
const exitCode = isInProcessShapedExecEntry(entry) ? inProcessExecExitCode(entry.detail) : undefined;
|
|
1056
|
+
failed = { kind: "failed", command, ...(exitCode !== undefined ? { exitCode } : {}) };
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
return failed ?? { kind: "none" };
|
|
1060
|
+
}
|
|
1061
|
+
const SHELL_ENV_ASSIGNMENT = String.raw `[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s;&|]+)`;
|
|
1062
|
+
const DIRECT_CONTEXT_LIFECYCLE_COMMAND = new RegExp(String.raw `^\s*(?:(?:${SHELL_ENV_ASSIGNMENT}\s+)*|env\s+(?:${SHELL_ENV_ASSIGNMENT}\s+)+)?wren\s+context\s+(validate|build)(?:\s+--path\s+(?:\.|"\."|'\.'))?\s*$`, "i");
|
|
1063
|
+
/**
|
|
1064
|
+
* The mandatory foundation has a native, host-verifiable sequence. A context
|
|
1065
|
+
* success needs schema discovery, then `wren context validate`, then `wren
|
|
1066
|
+
* context build`, each recorded as a successful setup execution. An artifact
|
|
1067
|
+
* alone or merely naming a command in the final message is not evidence.
|
|
1068
|
+
*/
|
|
1069
|
+
function contextLifecycleStage(command) {
|
|
1070
|
+
// This evidence must describe the actual lifecycle invocation, rather than
|
|
1071
|
+
// merely mention it. `setup_execution` already supplies the project cwd as
|
|
1072
|
+
// a separate structured field; the only shell prefixes we accept here are
|
|
1073
|
+
// environment assignments (including the standard `env KEY=value` form).
|
|
1074
|
+
// The one accepted flag is `--path .`: setup_execution separately binds the
|
|
1075
|
+
// command cwd to the canonical project, so this is the same project-local
|
|
1076
|
+
// lifecycle operation emitted by some Wren guidance. Arbitrary paths, other
|
|
1077
|
+
// flags, shell sequencing, quoted text, and wrappers remain unrecognized.
|
|
1078
|
+
const match = DIRECT_CONTEXT_LIFECYCLE_COMMAND.exec(command);
|
|
1079
|
+
return match?.[1]?.toLowerCase();
|
|
1080
|
+
}
|
|
1081
|
+
function stageAfter(prefix) {
|
|
1082
|
+
if (prefix === "discovery")
|
|
1083
|
+
return "validate";
|
|
1084
|
+
if (prefix === "validate" || prefix === "build")
|
|
1085
|
+
return "build";
|
|
1086
|
+
return "discovery";
|
|
1087
|
+
}
|
|
1088
|
+
function prefixFor(stage) {
|
|
1089
|
+
if (stage === "validate")
|
|
1090
|
+
return "discovery";
|
|
1091
|
+
return stage === "build" ? "validate" : "none";
|
|
1092
|
+
}
|
|
1093
|
+
function scanRecordedContextLifecycle(worklog, prior) {
|
|
1094
|
+
let stage = stageAfter(prior);
|
|
1095
|
+
let completed = prior === "build";
|
|
1096
|
+
let outOfOrderObserved;
|
|
1097
|
+
for (const entry of worklog) {
|
|
1098
|
+
if (!isSetupExecutionEntry(entry) || execSucceeded(entry) !== true)
|
|
1099
|
+
continue;
|
|
1100
|
+
const command = execCommandOf(entry);
|
|
1101
|
+
if (command === undefined)
|
|
1102
|
+
continue;
|
|
1103
|
+
if (stage === "discovery") {
|
|
1104
|
+
if (classifyRecordedSchemaDiscovery([entry]).kind === "successful") {
|
|
1105
|
+
stage = "validate";
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
if (contextLifecycleStage(command) !== undefined) {
|
|
1109
|
+
outOfOrderObserved ??= `successful \`${command}\` ran before successful schema discovery`;
|
|
1110
|
+
}
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
const nativeStage = contextLifecycleStage(command);
|
|
1114
|
+
if (stage === "validate") {
|
|
1115
|
+
if (nativeStage === "validate") {
|
|
1116
|
+
stage = "build";
|
|
1117
|
+
}
|
|
1118
|
+
else if (nativeStage === "build") {
|
|
1119
|
+
outOfOrderObserved ??= `successful \`${command}\` ran before successful \`wren context validate\``;
|
|
1120
|
+
}
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
if (nativeStage === "build")
|
|
1124
|
+
completed = true;
|
|
1125
|
+
}
|
|
1126
|
+
return { stage, completed, outOfOrderObserved };
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Returns the greatest ordered prefix proved by the supplied successful tool
|
|
1130
|
+
* calls. This intentionally retains an earlier valid prefix even if a later
|
|
1131
|
+
* call in the same attempt is out of order, so a corrective attempt can ask
|
|
1132
|
+
* only for the unproven suffix.
|
|
1133
|
+
*/
|
|
1134
|
+
export function recordedContextLifecyclePrefix(worklog, prior = "none") {
|
|
1135
|
+
const scan = scanRecordedContextLifecycle(worklog, prior);
|
|
1136
|
+
return scan.completed ? "build" : prefixFor(scan.stage);
|
|
1137
|
+
}
|
|
1138
|
+
export function classifyRecordedContextLifecycle(worklog, prior = "none") {
|
|
1139
|
+
const { stage, completed, outOfOrderObserved } = scanRecordedContextLifecycle(worklog, prior);
|
|
1140
|
+
if (completed)
|
|
1141
|
+
return { kind: "successful" };
|
|
1142
|
+
if (outOfOrderObserved !== undefined)
|
|
1143
|
+
return { kind: "out_of_order", observed: outOfOrderObserved };
|
|
1144
|
+
if (stage === "discovery")
|
|
1145
|
+
return { kind: "missing_discovery" };
|
|
1146
|
+
if (stage === "validate")
|
|
1147
|
+
return { kind: "validate_missing", observed: "no successful `wren context validate` followed successful schema discovery" };
|
|
1148
|
+
return { kind: "build_missing", observed: "no successful `wren context build` followed successful `wren context validate`" };
|
|
1149
|
+
}
|
|
1150
|
+
/** A narrow terminal-message check: only override a context error that explicitly claims a zero-table/model outcome. */
|
|
1151
|
+
function claimsNoTablesOrModels(message) {
|
|
1152
|
+
return /\b(?:no|zero)\s+(?:tables?|models?)\b|\b0\s+(?:tables?|models?)\b/i.test(message);
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Parses the LAST `SETUP_STATUS: ok|needs_input|error` line out of a setup
|
|
1156
|
+
* turn's finalText (the terminal contract lives in the composed
|
|
1157
|
+
* prompt, not warble — the agent is instructed to emit this line, and an
|
|
1158
|
+
* agent narrating its plan mid-message could emit misleading earlier
|
|
1159
|
+
* "SETUP_STATUS"-like text, so only the last line counts).
|
|
1160
|
+
*
|
|
1161
|
+
* For `ok`, independently verifies an on-disk artifact before trusting the
|
|
1162
|
+
* agent's self-report, rather than blindly advancing the wizard — see
|
|
1163
|
+
* `SetupTerminalContext.stepKey`'s doc comment for exactly which artifact each
|
|
1164
|
+
* step checks (`wren_project.yml` / `.wren-validated` / `target/mdl.json`
|
|
1165
|
+
* with at least one model).
|
|
1166
|
+
*
|
|
1167
|
+
* The `context` step's `ok` check also requires a successfully completed
|
|
1168
|
+
* recognized schema-discovery command whenever a worklog is available. It
|
|
1169
|
+
* does NOT try to infer fabrication from model names or columns. Two semantic
|
|
1170
|
+
* heuristics were considered and rejected as unsafe:
|
|
1171
|
+
* - Flagging a model literally named "example": a real warehouse can
|
|
1172
|
+
* genuinely have a table named "example" (the composed `context` prompt
|
|
1173
|
+
* itself explicitly tolerates a pre-existing "example" placeholder
|
|
1174
|
+
* surviving on disk), so this would false-positive on a legitimate build.
|
|
1175
|
+
* - Flagging models with zero columns: a fabricating agent writes
|
|
1176
|
+
* plausible-looking column lists (that's what makes the fabrication
|
|
1177
|
+
* convincing), so this wouldn't even have caught the real incident —
|
|
1178
|
+
* it's a weak signal with no compensating safety benefit.
|
|
1179
|
+
* No cheap structural signal reliably distinguishes fabricated MDL from a
|
|
1180
|
+
* real one. The completed-discovery gate is therefore the deterministic
|
|
1181
|
+
* provenance boundary here; the prompt still tells the agent to never
|
|
1182
|
+
* fabricate and to report failed discovery honestly as additional guidance.
|
|
1183
|
+
*/
|
|
1184
|
+
export function parseSetupTerminal(finalText, context) {
|
|
1185
|
+
const lines = finalText.split("\n");
|
|
1186
|
+
let matched;
|
|
1187
|
+
for (const line of lines) {
|
|
1188
|
+
const match = SETUP_STATUS_LINE.exec(line.trim());
|
|
1189
|
+
if (match) {
|
|
1190
|
+
const status = match[1].toLowerCase();
|
|
1191
|
+
const message = match[2]?.trim();
|
|
1192
|
+
matched = { status, message: message && message.length > 0 ? message : defaultMessageFor(status) };
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
if (!matched) {
|
|
1196
|
+
return {
|
|
1197
|
+
status: "error",
|
|
1198
|
+
message: "the setup agent's final message did not contain a SETUP_STATUS line",
|
|
1199
|
+
failureKind: "missing_terminal_status",
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
if (matched.status === "ok") {
|
|
1203
|
+
if (context.stepKey === "context") {
|
|
1204
|
+
if (context.worklog !== undefined) {
|
|
1205
|
+
const lifecycle = classifyRecordedContextLifecycle(context.worklog, context.priorContextLifecycle);
|
|
1206
|
+
if (lifecycle.kind === "missing_discovery") {
|
|
1207
|
+
return hostContractFailure("context_schema_discovery_missing", "The agent never completed recognized schema discovery in its recorded worklog, so its model/build result cannot be accepted. This is an agent-workflow failure, not evidence that the connection or data source lacks tables.", "the recorded worklog has no successful recognized schema-discovery command", [
|
|
1208
|
+
"run a recognized schema-discovery command against the connected source and make sure it exits successfully",
|
|
1209
|
+
"build target/mdl.json only from that discovered schema, with at least one model",
|
|
1210
|
+
], "no_successful_schema_discovery");
|
|
1211
|
+
}
|
|
1212
|
+
const discovery = classifyRecordedSchemaDiscovery(context.worklog);
|
|
1213
|
+
if (discovery.kind === "failed") {
|
|
1214
|
+
const exitCodeSuffix = discovery.exitCode !== undefined ? ` with exit code ${discovery.exitCode}` : "";
|
|
1215
|
+
return hostContractFailure("context_schema_discovery_failed", `\`${discovery.command}\` failed${exitCodeSuffix} during schema discovery — a model/build result cannot be accepted until that command/tool failure is resolved; this is not evidence that the connection or data source lacks tables.`, discovery.exitCode !== undefined
|
|
1216
|
+
? `the recorded schema-discovery command exited with code ${discovery.exitCode}`
|
|
1217
|
+
: "the recorded schema-discovery command failed", [
|
|
1218
|
+
"resolve the schema-discovery command failure and run a recognized discovery command successfully",
|
|
1219
|
+
"build target/mdl.json only from that discovered schema, with at least one model",
|
|
1220
|
+
]);
|
|
1221
|
+
}
|
|
1222
|
+
if (lifecycle.kind === "validate_missing") {
|
|
1223
|
+
return hostContractFailure("context_validate_missing", "The agent did not successfully run `wren context validate` after recognized schema discovery, so its model/build result cannot be accepted.", lifecycle.observed, [
|
|
1224
|
+
"run a recognized schema-discovery command successfully",
|
|
1225
|
+
"run `wren context validate` successfully before `wren context build`",
|
|
1226
|
+
"build target/mdl.json only after validation, with at least one model",
|
|
1227
|
+
]);
|
|
1228
|
+
}
|
|
1229
|
+
if (lifecycle.kind === "build_missing") {
|
|
1230
|
+
return hostContractFailure("context_build_missing", "The agent did not successfully run `wren context build` after `wren context validate`, so its claimed context success cannot be accepted.", lifecycle.observed, [
|
|
1231
|
+
"run a recognized schema-discovery command successfully",
|
|
1232
|
+
"run `wren context validate` successfully",
|
|
1233
|
+
"run `wren context build` successfully after validation and produce target/mdl.json with at least one model",
|
|
1234
|
+
]);
|
|
1235
|
+
}
|
|
1236
|
+
if (lifecycle.kind === "out_of_order") {
|
|
1237
|
+
return hostContractFailure("context_lifecycle_out_of_order", "The native context lifecycle ran out of order; Setup requires successful schema discovery followed by `wren context validate` and then `wren context build`.", lifecycle.observed, [
|
|
1238
|
+
"run a recognized schema-discovery command successfully",
|
|
1239
|
+
"run `wren context validate` successfully before `wren context build`",
|
|
1240
|
+
"produce target/mdl.json with at least one schema-derived model",
|
|
1241
|
+
]);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
const mdlPath = path.join(context.root, context.name, "target", "mdl.json");
|
|
1245
|
+
if (!existsSync(mdlPath)) {
|
|
1246
|
+
return hostContractFailure("context_mdl_missing", `the setup agent reported "ok" but ${mdlPath} does not exist — treating this as a failed context build`, "target/mdl.json is missing after the claimed context build", ["target/mdl.json must exist", "target/mdl.json must contain at least one model"]);
|
|
1247
|
+
}
|
|
1248
|
+
const modelCount = countMdlModels(mdlPath);
|
|
1249
|
+
if (modelCount < 1) {
|
|
1250
|
+
return hostContractFailure("context_mdl_empty", `the setup agent reported "ok" but ${mdlPath} has ${modelCount} models — treating this as a failed context build`, `target/mdl.json has ${modelCount} models after the claimed context build`, ["target/mdl.json must contain at least one model"]);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
else {
|
|
1254
|
+
const markerName = context.stepKey === "connect_resume" ? ".wren-validated" : "wren_project.yml";
|
|
1255
|
+
const marker = path.join(context.root, context.name, markerName);
|
|
1256
|
+
if (!existsSync(marker)) {
|
|
1257
|
+
return hostContractFailure(context.stepKey === "connect_resume" ? "connection_marker_missing" : "connect_artifact_missing", `the setup agent reported "ok" but ${marker} does not exist — treating this as a failed setup`, `${markerName} is missing after the claimed setup success`, context.stepKey === "connect_resume"
|
|
1258
|
+
? ["create .wren-validated only after the connection profile validates successfully"]
|
|
1259
|
+
: ["create the project directory", "create wren_project.yml", "create the empty .env template"]);
|
|
1260
|
+
}
|
|
1261
|
+
if (context.stepKey === "connect_resume" && context.expectedSourceType !== undefined) {
|
|
1262
|
+
const projectYml = path.join(context.root, context.name, "wren_project.yml");
|
|
1263
|
+
const pinnedProfile = readProjectYamlField(projectYml, "profile");
|
|
1264
|
+
if (pinnedProfile === undefined || pinnedProfile.length === 0) {
|
|
1265
|
+
return hostContractFailure("connection_profile_missing", `the setup agent reported "ok" but ${projectYml} has no "profile:" pin — the connection profile was never actually pinned to this project (check the profile, not .env)`, "wren_project.yml has no non-empty profile pin", ["wren_project.yml must have a non-empty profile pin", "the pinned profile must match the selected data source"]);
|
|
1266
|
+
}
|
|
1267
|
+
const pinnedDataSource = readProjectYamlField(projectYml, "data_source");
|
|
1268
|
+
if ((pinnedDataSource ?? "").trim().toLowerCase() !== context.expectedSourceType.trim().toLowerCase()) {
|
|
1269
|
+
return hostContractFailure("connection_source_mismatch", `the setup agent reported "ok" but ${projectYml}'s "data_source: ${pinnedDataSource ?? "(missing)"}" does not match the selected data source "${context.expectedSourceType}" — the connection profile is for the wrong data source (check the profile, not .env)`, `the pinned data source does not match the selected ${context.expectedSourceType} source`, [
|
|
1270
|
+
"wren_project.yml must have a non-empty profile pin",
|
|
1271
|
+
`the pinned data_source must match the selected ${context.expectedSourceType} source`,
|
|
1272
|
+
"create .wren-validated only after the matching profile validates successfully",
|
|
1273
|
+
]);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
else if (matched.status === "needs_input" && (context.stepKey === undefined || context.stepKey === "connect")) {
|
|
1279
|
+
const missing = missingConnectArtifact(context);
|
|
1280
|
+
if (missing !== undefined) {
|
|
1281
|
+
return {
|
|
1282
|
+
status: "error",
|
|
1283
|
+
message: `the setup agent reported "needs_input" but ${missing} does not exist — treating this as a failed connect`,
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
else if (matched.status === "error" && context.stepKey === "context" && context.worklog !== undefined) {
|
|
1288
|
+
// The agent's own `SETUP_STATUS: error` line for the `context` step routinely
|
|
1289
|
+
// blames the connection or data source (e.g. "schema introspection found no
|
|
1290
|
+
// tables"), but that is only an honest conclusion if an introspection command
|
|
1291
|
+
// actually ran and completed. If the agent instead ran a nonexistent or
|
|
1292
|
+
// otherwise-failing CLI command (its own tool-use mistake, not a fact about
|
|
1293
|
+
// the data source), the "no tables" framing is a false attribution. Before
|
|
1294
|
+
// trusting the agent's message, check the turn's own worklog for a
|
|
1295
|
+
// `setup_execution` call that exited non-zero, and reframe if one is found.
|
|
1296
|
+
const failedExec = firstFailedExec(context.worklog);
|
|
1297
|
+
if (failedExec !== undefined) {
|
|
1298
|
+
const command = failedExec.command !== undefined ? `\`${failedExec.command}\`` : "a command";
|
|
1299
|
+
const exitCodeSuffix = failedExec.exitCode !== undefined ? ` with exit code ${failedExec.exitCode}` : "";
|
|
1300
|
+
return {
|
|
1301
|
+
status: "error",
|
|
1302
|
+
message: `${command} failed${exitCodeSuffix} during this step — a command in this step's own history failed, so this step's error framing can't be trusted until that's ruled out; this is a command/tool failure, not by itself evidence that the connection or data source lacks tables. The agent's own report was: "${matched.message}"`,
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
if (claimsNoTablesOrModels(matched.message) && classifyRecordedSchemaDiscovery(context.worklog).kind === "none") {
|
|
1306
|
+
return {
|
|
1307
|
+
status: "error",
|
|
1308
|
+
failureKind: "no_successful_schema_discovery",
|
|
1309
|
+
message: "The agent never attempted schema introspection in its recorded worklog — it reported a zero-table/model outcome without a recognized introspection command. This is an agent-workflow failure, not evidence that the connection or data source lacks tables.",
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
return matched;
|
|
1314
|
+
}
|