faberun 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { claudeHarness } from "./claude/index.mjs";
|
|
3
|
+
import { codexHarness } from "./codex/index.mjs";
|
|
4
|
+
import { agyHarness } from "./agy/index.mjs";
|
|
5
|
+
import { dshHarness } from "./dsh/index.mjs";
|
|
6
|
+
import { zcodeHarness } from "./zcode/index.mjs";
|
|
7
|
+
import { execJsonlHarness } from "./exec-jsonl/index.mjs";
|
|
8
|
+
import { replayHarness } from "./replay/index.mjs";
|
|
9
|
+
|
|
10
|
+
/** Current wire-contract version for runner protocol artifacts. */
|
|
11
|
+
export const PROTOCOL_SCHEMA_VERSION = 3;
|
|
12
|
+
|
|
13
|
+
/** Version of the runner protocol implementation. */
|
|
14
|
+
export const CONTRACT_VERSION = "0.3.0";
|
|
15
|
+
|
|
16
|
+
const HARNESSES = new Map([
|
|
17
|
+
["claude", claudeHarness],
|
|
18
|
+
["codex", codexHarness],
|
|
19
|
+
["agy", agyHarness],
|
|
20
|
+
["dsh", dshHarness],
|
|
21
|
+
["zcode", zcodeHarness],
|
|
22
|
+
["exec-jsonl", execJsonlHarness],
|
|
23
|
+
["replay", replayHarness],
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const CAPABILITY_NAMES = new Set([
|
|
27
|
+
"structuredOutput",
|
|
28
|
+
"promptTransport",
|
|
29
|
+
"sandbox",
|
|
30
|
+
"permissions",
|
|
31
|
+
"continuation",
|
|
32
|
+
"tokenBudget",
|
|
33
|
+
"costBudget",
|
|
34
|
+
"usage",
|
|
35
|
+
"cost",
|
|
36
|
+
"toolPolicy",
|
|
37
|
+
"streamsOutput",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/** @typedef {"structuredOutput"|"promptTransport"|"sandbox"|"permissions"|"continuation"|"tokenBudget"|"costBudget"|"usage"|"cost"|"toolPolicy"|"streamsOutput"} CapabilityName */
|
|
41
|
+
|
|
42
|
+
/** @typedef {{structuredOutput: boolean, promptTransport: "stdin"|"argv", sandbox: boolean, permissions: boolean, continuation: boolean, tokenBudget: boolean, costBudget: boolean, usage: boolean, cost: boolean, toolPolicy: boolean, streamsOutput: boolean, maxArgvPromptBytes?: number}} HarnessCapabilities */
|
|
43
|
+
|
|
44
|
+
/** @typedef {{structuredOutput?: boolean, promptTransport?: "stdin"|"argv", sandbox?: boolean, permissions?: boolean, continuation?: boolean, tokenBudget?: boolean, costBudget?: boolean, usage?: boolean, cost?: boolean, toolPolicy?: boolean, streamsOutput?: boolean}} CapabilityRequirements */
|
|
45
|
+
|
|
46
|
+
/** @typedef {{executable: string, args: string[], promptTransport: "stdin"|"argv", input: string|null, env?: Record<string, string|null>}} HarnessCommand */
|
|
47
|
+
|
|
48
|
+
/** @typedef {HarnessCommand & {harness: string, model: string, capabilities: HarnessCapabilities}} ProviderCommand */
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which runtime field controls command execution, which values execute, and
|
|
52
|
+
* the value used when the contract omits that field. `null` means the harness
|
|
53
|
+
* has no permission mode that can deny command execution.
|
|
54
|
+
*
|
|
55
|
+
* @typedef {{field: "permissionMode"|"sandbox", executingModes: string[], defaultMode: string}|null} PermissionExecutionPolicy
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/** @typedef {{status: "done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled", result: string|null, continuationId: string|null, usage: {inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}, costUsd: number|null, error: {code: string, message: string, resetAt?: string|null}|null, exhaustedUntil?: string|null, judgeCandidates?: number}} ProviderEnvelope */
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One declared runtime. `harness` names a registered adapter (`claude`,
|
|
62
|
+
* `codex`, `agy`, `dsh`, `zcode`, `exec-jsonl`, or `replay`) and `model` names
|
|
63
|
+
* what that harness asks; the two are independent. replay requires
|
|
64
|
+
* `config["replay.recording"]` for commands, and dsh requires
|
|
65
|
+
* `config.provider` for the provider route every attempt runs on.
|
|
66
|
+
*
|
|
67
|
+
* @typedef {{id?: string, harness: string, model: string, reasoning?: string, sandbox?: string, permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, tier?: number|string, vendor?: string}} HarnessRuntime
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Mechanical worker tool policy sent to the provider boundary:
|
|
72
|
+
* `foregroundOnly` rejects background tool invocations,
|
|
73
|
+
* `maxToolOutputBytes` bounds each tool result head-plus-tail, `workspace`
|
|
74
|
+
* names the attempt's working directory, `writeFiles`/`writeRoots` carry the
|
|
75
|
+
* node's declared write scope, and `maxReadLines` bounds a whole-file read.
|
|
76
|
+
* Claude-compatible adapters enforce it through hook settings; an adapter
|
|
77
|
+
* that cannot prove enforcement must never receive it.
|
|
78
|
+
*
|
|
79
|
+
* @typedef {{foregroundOnly: boolean, maxToolOutputBytes: number|null, workspace: string, writeFiles: string[], writeRoots: string[], maxReadLines: number|null}} ToolPolicy
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/** Line count above which a whole-file read is denied by the tool policy hook. */
|
|
83
|
+
export const READ_LINE_LIMIT = 1500;
|
|
84
|
+
|
|
85
|
+
/** @typedef {{schema?: object, schemaPath?: string, continuationId?: string|null, toolPolicy?: ToolPolicy, env?: Record<string, string>}} CommandOptions */
|
|
86
|
+
|
|
87
|
+
/** @typedef {{preferStructured?: boolean, exitCode?: number|null, signal?: string|null, stderr?: string}} NormalizeOptions */
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One provider adapter: capabilities plus executable, version, command, and
|
|
91
|
+
* result-normalization behavior.
|
|
92
|
+
*
|
|
93
|
+
* @typedef {{capabilities: HarnessCapabilities, permissionExecution: PermissionExecutionPolicy, executable: (runtime: HarnessRuntime) => string, versionArgs: (runtime: HarnessRuntime) => string[], parseVersion: (stdout: string, stderr?: string) => string|null, command: (runtime: HarnessRuntime, prompt: string, options: CommandOptions) => HarnessCommand, normalize: (stdout: string, exitCode: number|null, signal: string|null, options?: NormalizeOptions) => ProviderEnvelope}} HarnessAdapter
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Result of a read-only runtime probe.
|
|
98
|
+
*
|
|
99
|
+
* @typedef {{id: string|null, harness: string, executable: string, model: string, version: string|null, capabilities: HarnessCapabilities, requiredCapabilities: CapabilityRequirements, requiredCapabilitySets: CapabilityRequirements[], ok: boolean, detail: string|null, availability?: {available: boolean, exhaustedUntil: string|null, reason: string}, live?: boolean, liveStatus?: string, usage?: {inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}, costUsd?: number|null}} ProbeResult
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/** @typedef {{id?: string}} RuntimeIdentity */
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Every registered harness name, for callers that must account for the whole
|
|
106
|
+
* registry. Declaration order only — callers that display harnesses sort it
|
|
107
|
+
* themselves.
|
|
108
|
+
*
|
|
109
|
+
* @returns {string[]}
|
|
110
|
+
*/
|
|
111
|
+
export function registeredHarnesses() {
|
|
112
|
+
return [...HARNESSES.keys()];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {string} name
|
|
117
|
+
* @returns {HarnessAdapter}
|
|
118
|
+
*/
|
|
119
|
+
export function getHarness(name) {
|
|
120
|
+
const harness = HARNESSES.get(name);
|
|
121
|
+
if (!harness) throw new TypeError(`unknown harness: ${name}`);
|
|
122
|
+
return harness;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {{harness: string}} runtime
|
|
127
|
+
* @returns {HarnessCapabilities}
|
|
128
|
+
*/
|
|
129
|
+
export function harnessCapabilities(runtime) {
|
|
130
|
+
return { ...getHarness(runtime.harness).capabilities };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {{harness: string, permissionMode?: string, sandbox?: string}} runtime
|
|
135
|
+
* @returns {{executes: boolean, field: "permissionMode"|"sandbox"|null, mode: string|null, executingModes: string[]}}
|
|
136
|
+
*/
|
|
137
|
+
export function resolvePermissionExecution(runtime) {
|
|
138
|
+
const policy = getHarness(runtime.harness).permissionExecution;
|
|
139
|
+
if (!policy) return { executes: true, field: null, mode: null, executingModes: [] };
|
|
140
|
+
const mode = /** @type {string} */ (runtime[policy.field] ?? policy.defaultMode);
|
|
141
|
+
return { executes: policy.executingModes.includes(mode), field: policy.field, mode, executingModes: policy.executingModes };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The vendor a harness talks to when no provider configuration says
|
|
146
|
+
* otherwise. `replay` and `exec-jsonl` stand in for whatever the recording or
|
|
147
|
+
* the exec'd binary actually is, so neither gets a default here — a contract
|
|
148
|
+
* using either must declare `vendor` outright.
|
|
149
|
+
*/
|
|
150
|
+
const DEFAULT_HARNESS_VENDORS = Object.freeze({
|
|
151
|
+
claude: "anthropic",
|
|
152
|
+
codex: "openai",
|
|
153
|
+
agy: "google",
|
|
154
|
+
zcode: "zhipu",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Resolve one runtime's vendor identity: an explicit `vendor` wins outright,
|
|
159
|
+
* then a provider-configuration override (the codex `model_provider` trap —
|
|
160
|
+
* a codex runtime configured for deepseek is a deepseek vendor, not openai),
|
|
161
|
+
* then the harness's own default. `null` means the caller must reject the
|
|
162
|
+
* runtime: nothing here named a vendor for it.
|
|
163
|
+
*
|
|
164
|
+
* @param {{harness: string, vendor?: string, config?: Record<string, unknown>}} runtime
|
|
165
|
+
* @returns {string|null}
|
|
166
|
+
*/
|
|
167
|
+
export function resolveVendor(runtime) {
|
|
168
|
+
if (typeof runtime.vendor === "string" && runtime.vendor.length) return runtime.vendor;
|
|
169
|
+
const provider = runtime.config?.model_provider;
|
|
170
|
+
if (typeof provider === "string" && provider.length) return provider;
|
|
171
|
+
return /** @type {Record<string, string>} */ (DEFAULT_HARNESS_VENDORS)[runtime.harness] ?? null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build one provider invocation. Prompt transport is explicit in the result:
|
|
176
|
+
* stdin adapters return `input`, while argv adapters append the prompt. An
|
|
177
|
+
* optional `env` overlay is merged over the runner environment at spawn time;
|
|
178
|
+
* a null value removes the ambient variable. A caller-supplied `options.env`
|
|
179
|
+
* merges over the adapter's own overlay here, once, for every harness.
|
|
180
|
+
*
|
|
181
|
+
* @param {HarnessRuntime} runtime
|
|
182
|
+
* @param {string} prompt
|
|
183
|
+
* @param {CommandOptions} options
|
|
184
|
+
* @returns {ProviderCommand}
|
|
185
|
+
*/
|
|
186
|
+
export function providerCommand(runtime, prompt, options = {}) {
|
|
187
|
+
const harness = getHarness(runtime.harness);
|
|
188
|
+
const command = harness.command(runtime, prompt, options);
|
|
189
|
+
if (options.env) command.env = { ...command.env, ...options.env };
|
|
190
|
+
if (command.promptTransport === "argv") {
|
|
191
|
+
const limit = runtime.maxArgvPromptBytes ?? harness.capabilities.maxArgvPromptBytes;
|
|
192
|
+
if (typeof limit === "number" && Number.isFinite(limit) && Buffer.byteLength(prompt, "utf8") > limit) {
|
|
193
|
+
const error = /** @type {Error & {code: string}} */ (new Error(`prompt exceeds argv limit of ${limit} bytes for ${runtime.harness}`));
|
|
194
|
+
error.code = "prompt_too_large";
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
...command,
|
|
200
|
+
harness: runtime.harness,
|
|
201
|
+
model: runtime.model,
|
|
202
|
+
capabilities: harnessCapabilities(runtime),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {string|{harness: string}} runtimeOrHarness
|
|
208
|
+
* @param {string} stdout
|
|
209
|
+
* @param {number|null} exitCode
|
|
210
|
+
* @param {string|null} signal
|
|
211
|
+
* @param {NormalizeOptions} options
|
|
212
|
+
* @returns {ProviderEnvelope}
|
|
213
|
+
*/
|
|
214
|
+
export function normalizeProviderResult(runtimeOrHarness, stdout, exitCode, signal, options = {}) {
|
|
215
|
+
const runtime = typeof runtimeOrHarness === "string" ? { harness: runtimeOrHarness } : runtimeOrHarness;
|
|
216
|
+
const harness = getHarness(runtime.harness);
|
|
217
|
+
return harness.normalize(stdout, exitCode, signal, options);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Normalize a provider envelope or recorded response into the availability
|
|
222
|
+
* shape used by doctor and runtime assignment.
|
|
223
|
+
*
|
|
224
|
+
* @param {string|{harness: string}} runtimeOrHarness
|
|
225
|
+
* @param {unknown} response
|
|
226
|
+
* @param {number|null} [exitCode]
|
|
227
|
+
* @param {string|null} [signal]
|
|
228
|
+
* @returns {{available: boolean, exhaustedUntil: string|null, reason: string}}
|
|
229
|
+
*/
|
|
230
|
+
export function normalizeProviderAvailability(runtimeOrHarness, response, exitCode = 0, signal = null) {
|
|
231
|
+
let envelope;
|
|
232
|
+
try {
|
|
233
|
+
envelope = response && typeof response === "object" && !Array.isArray(response) && typeof /** @type {Record<string, unknown>} */ (response).status === "string"
|
|
234
|
+
? /** @type {ProviderEnvelope} */ (response)
|
|
235
|
+
: normalizeProviderResult(runtimeOrHarness, String(response ?? ""), exitCode, signal);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
return { available: false, exhaustedUntil: null, reason: error instanceof Error ? error.message : "provider_unavailable" };
|
|
238
|
+
}
|
|
239
|
+
const error = envelope.error;
|
|
240
|
+
const code = typeof error?.code === "string" ? error.code : "";
|
|
241
|
+
const message = typeof error?.message === "string" ? error.message : "";
|
|
242
|
+
const text = `${code} ${message}`;
|
|
243
|
+
if (envelope.status === "done" || envelope.status === "no-op") return { available: true, exhaustedUntil: null, reason: "ready" };
|
|
244
|
+
const classified = classifyAvailabilityText(text);
|
|
245
|
+
// A hard balance stop (DeepSeek's 402 "Insufficient Balance") has no reset
|
|
246
|
+
// instant to report, unlike quota_exhausted, so it must be classified before
|
|
247
|
+
// that branch even though its text never matches the quota pattern.
|
|
248
|
+
if (classified?.reason === "insufficient_balance") return classified;
|
|
249
|
+
if (envelope.status === "exhausted" || classified?.reason === "quota_exhausted") {
|
|
250
|
+
return { available: false, exhaustedUntil: exhaustedUntilOf(envelope), reason: code || "quota_exhausted" };
|
|
251
|
+
}
|
|
252
|
+
if (classified?.reason === "authentication_failed") return classified;
|
|
253
|
+
return { available: false, exhaustedUntil: null, reason: code || "provider_unavailable" };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The absolute instant an exhaustion envelope announces, taken from whichever
|
|
258
|
+
* field carries it: the envelope's own `exhaustedUntil`, the error's `resetAt`,
|
|
259
|
+
* or a reset sentence inside the error message. `null` means the provider named
|
|
260
|
+
* no reset, which is the controller's signal to take the failover edge instead
|
|
261
|
+
* of waiting on the same runtime.
|
|
262
|
+
*
|
|
263
|
+
* @param {unknown} envelope
|
|
264
|
+
* @returns {string|null}
|
|
265
|
+
*/
|
|
266
|
+
export function exhaustedUntilOf(envelope) {
|
|
267
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return null;
|
|
268
|
+
const record = /** @type {Record<string, unknown>} */ (envelope);
|
|
269
|
+
const error = record.error && typeof record.error === "object"
|
|
270
|
+
? /** @type {Record<string, unknown>} */ (record.error)
|
|
271
|
+
: null;
|
|
272
|
+
return resetTimestamp(record.exhaustedUntil ?? error?.resetAt ?? (typeof error?.message === "string" ? error.message : null));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Classify raw provider-produced text (a structured envelope's `error.code
|
|
277
|
+
* error.message`, or a probe's raw stderr on a non-zero exit) into the same
|
|
278
|
+
* insufficient-balance/quota/authentication reasons `normalizeProviderAvailability`
|
|
279
|
+
* recognizes. Shared so a CLI-missing exit and a raw stderr balance/quota
|
|
280
|
+
* message are classified by one set of patterns, never two drifting copies.
|
|
281
|
+
*
|
|
282
|
+
* @param {string} text
|
|
283
|
+
* @returns {{available: false, exhaustedUntil: string|null, reason: string}|null} null when text names none of the known patterns
|
|
284
|
+
*/
|
|
285
|
+
function classifyAvailabilityText(text) {
|
|
286
|
+
if (/insufficient balance/iu.test(text) || /\b402\b/u.test(text)) {
|
|
287
|
+
return { available: false, exhaustedUntil: null, reason: "insufficient_balance" };
|
|
288
|
+
}
|
|
289
|
+
if (/quota|rate.?limit|usage limit|limit exhausted|1310/iu.test(text)) {
|
|
290
|
+
return { available: false, exhaustedUntil: resetTimestamp(text), reason: "quota_exhausted" };
|
|
291
|
+
}
|
|
292
|
+
if (/auth|credential|unauthori[sz]ed|forbidden|invalid.*(?:key|token)|(?:api|access) key|login/iu.test(text)) {
|
|
293
|
+
return { available: false, exhaustedUntil: null, reason: "authentication_failed" };
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** @param {unknown} value @returns {string|null} */
|
|
299
|
+
function resetTimestamp(value) {
|
|
300
|
+
if (typeof value === "number" && Number.isFinite(value)) return new Date(value).toISOString();
|
|
301
|
+
if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString();
|
|
302
|
+
if (typeof value !== "string") return null;
|
|
303
|
+
const match = /reset(?:s| at| on)?\s+(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:?\d{2})?)/iu.exec(value);
|
|
304
|
+
const input = match?.[1] ?? value;
|
|
305
|
+
const normalized = input.includes("T") || /(?:Z|[+-]\d{2}:?\d{2})$/u.test(input) ? input : `${input.replace(" ", "T")}Z`;
|
|
306
|
+
const parsed = Date.parse(normalized);
|
|
307
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Validate a partial capability requirement against an adapter declaration.
|
|
312
|
+
* The runtime JSON remains the authoritative source for requirement shape.
|
|
313
|
+
*
|
|
314
|
+
* @param {CapabilityRequirements|undefined} requirements
|
|
315
|
+
* @param {string} label
|
|
316
|
+
* @returns {CapabilityRequirements}
|
|
317
|
+
*/
|
|
318
|
+
export function validateCapabilityRequirements(requirements, label = "requiredCapabilities") {
|
|
319
|
+
if (requirements === undefined) return {};
|
|
320
|
+
if (!requirements || typeof requirements !== "object" || Array.isArray(requirements)) {
|
|
321
|
+
throw new TypeError(`${label} must be an object`);
|
|
322
|
+
}
|
|
323
|
+
for (const [name, value] of Object.entries(requirements)) {
|
|
324
|
+
if (!isCapabilityName(name)) throw new TypeError(`${label}.${name} is unknown`);
|
|
325
|
+
if (name === "promptTransport") {
|
|
326
|
+
if (value !== "stdin" && value !== "argv") throw new TypeError(`${label}.promptTransport is invalid`);
|
|
327
|
+
} else if (typeof value !== "boolean") {
|
|
328
|
+
throw new TypeError(`${label}.${name} must be boolean`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { ...requirements };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* @param {HarnessCapabilities} capabilities
|
|
336
|
+
* @param {CapabilityRequirements} requirements
|
|
337
|
+
* @returns {string[]}
|
|
338
|
+
*/
|
|
339
|
+
export function missingCapabilities(capabilities, requirements = {}) {
|
|
340
|
+
return Object.keys(requirements).flatMap((name) => {
|
|
341
|
+
if (!isCapabilityName(name)) return [];
|
|
342
|
+
const required = requirements[name];
|
|
343
|
+
return capabilities[name] === required
|
|
344
|
+
? []
|
|
345
|
+
: [`${name}=${String(required)} (harness provides ${name}=${String(capabilities[name])})`];
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** @param {string} name @returns {name is CapabilityName} */
|
|
350
|
+
function isCapabilityName(name) {
|
|
351
|
+
return CAPABILITY_NAMES.has(name);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** @param {HarnessCapabilities} capabilities @param {CapabilityRequirements[]} requirementSets */
|
|
355
|
+
function missingCapabilitySets(capabilities, requirementSets) {
|
|
356
|
+
return requirementSets.flatMap((requirements, index) =>
|
|
357
|
+
missingCapabilities(capabilities, requirements).map((missing) => `requirement ${index + 1}: ${missing}`),
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Probe an executable version without sending a prompt or exposing secrets.
|
|
363
|
+
*
|
|
364
|
+
* @param {HarnessRuntime} runtime
|
|
365
|
+
* @param {{cwd?: string, timeoutSec?: number, requiredCapabilities?: CapabilityRequirements, requiredCapabilitySets?: CapabilityRequirements[]}} options
|
|
366
|
+
* @returns {Promise<ProbeResult>}
|
|
367
|
+
*/
|
|
368
|
+
export function probeRuntime(runtime, options = {}) {
|
|
369
|
+
const harness = getHarness(runtime.harness);
|
|
370
|
+
const executable = harness.executable(runtime);
|
|
371
|
+
const requirementSets = (options.requiredCapabilitySets ?? [options.requiredCapabilities])
|
|
372
|
+
.filter((requirements) => requirements !== undefined)
|
|
373
|
+
.map((requirements, index) => validateCapabilityRequirements(requirements, `requiredCapabilitySets[${index}]`));
|
|
374
|
+
const missingEnvironment = missingEnvironmentVariables(runtime);
|
|
375
|
+
const base = {
|
|
376
|
+
id: runtime.id ?? null,
|
|
377
|
+
harness: runtime.harness,
|
|
378
|
+
executable,
|
|
379
|
+
model: runtime.model,
|
|
380
|
+
version: null,
|
|
381
|
+
capabilities: harnessCapabilities(runtime),
|
|
382
|
+
requiredCapabilities: requirementSets.length === 1 ? requirementSets[0] : {},
|
|
383
|
+
requiredCapabilitySets: requirementSets,
|
|
384
|
+
ok: false,
|
|
385
|
+
detail: null,
|
|
386
|
+
availability: { available: false, exhaustedUntil: null, reason: "provider_unavailable" },
|
|
387
|
+
};
|
|
388
|
+
const missing = missingCapabilitySets(base.capabilities, requirementSets);
|
|
389
|
+
const args = harness.versionArgs(runtime);
|
|
390
|
+
const timeoutSec = options.timeoutSec ?? 120;
|
|
391
|
+
const identity = (/** @type {string|null} */ version) => `${runtime.harness} · ${executable} · ${runtime.model} · ${version ?? "version unavailable"}`;
|
|
392
|
+
const missingEnvironmentDetail = missingEnvironment.length
|
|
393
|
+
? `missing environment variable ${missingEnvironment.join(", ")}`
|
|
394
|
+
: null;
|
|
395
|
+
return new Promise((settle) => {
|
|
396
|
+
let child;
|
|
397
|
+
try {
|
|
398
|
+
child = spawn(executable, args, {
|
|
399
|
+
cwd: options.cwd,
|
|
400
|
+
env: process.env,
|
|
401
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
402
|
+
});
|
|
403
|
+
} catch (error) {
|
|
404
|
+
settle({
|
|
405
|
+
...base,
|
|
406
|
+
availability: { available: false, exhaustedUntil: null, reason: error && typeof error === "object" && "code" in error && error.code === "ENOENT"
|
|
407
|
+
|| /(?:ENOENT|not found|no such file)/iu.test(error instanceof Error ? error.message : String(error)) ? "not_found" : "provider_unavailable" },
|
|
408
|
+
detail: `${identity(null)} · ${[missingEnvironmentDetail, redactSecrets(error instanceof Error ? error.message : String(error))].filter(Boolean).join(" · ")}`,
|
|
409
|
+
});
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
let stdout = "";
|
|
413
|
+
let stderr = "";
|
|
414
|
+
let settled = false;
|
|
415
|
+
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
416
|
+
let timer = null;
|
|
417
|
+
/** @param {ProbeResult} result */
|
|
418
|
+
const finish = (result) => {
|
|
419
|
+
if (settled) return;
|
|
420
|
+
settled = true;
|
|
421
|
+
if (timer) clearTimeout(timer);
|
|
422
|
+
settle(result);
|
|
423
|
+
};
|
|
424
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
425
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
426
|
+
timer = setTimeout(() => {
|
|
427
|
+
child.kill("SIGTERM");
|
|
428
|
+
finish({
|
|
429
|
+
...base,
|
|
430
|
+
availability: { available: false, exhaustedUntil: null, reason: "provider_unavailable" },
|
|
431
|
+
detail: `${identity(null)} · ${[missingEnvironmentDetail, `no response within ${timeoutSec}s`].filter(Boolean).join(" · ")}`,
|
|
432
|
+
});
|
|
433
|
+
}, timeoutSec * 1_000);
|
|
434
|
+
child.once("error", (error) => {
|
|
435
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
436
|
+
finish({
|
|
437
|
+
...base,
|
|
438
|
+
availability: { available: false, exhaustedUntil: null, reason: /(?:ENOENT|not found|no such file)/iu.test(message) ? "not_found" : "provider_unavailable" },
|
|
439
|
+
detail: `${identity(null)} · ${[missingEnvironmentDetail, redactSecrets(message)].filter(Boolean).join(" · ")}`,
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
child.once("close", (exitCode, signal) => {
|
|
443
|
+
const version = harness.parseVersion(redactSecrets(stdout), redactSecrets(stderr));
|
|
444
|
+
const withVersion = { ...base, version };
|
|
445
|
+
if (signal || exitCode !== 0) {
|
|
446
|
+
finish({
|
|
447
|
+
...withVersion,
|
|
448
|
+
availability: classifyAvailabilityText(stderr) ?? { available: false, exhaustedUntil: null, reason: "provider_unavailable" },
|
|
449
|
+
detail: `${identity(version)} · ${[missingEnvironmentDetail, lastLine(stderr) ?? `${executable} exited with code ${exitCode}`].filter(Boolean).join(" · ")}`,
|
|
450
|
+
});
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (!version) {
|
|
454
|
+
finish({
|
|
455
|
+
...withVersion,
|
|
456
|
+
availability: { available: false, exhaustedUntil: null, reason: "provider_unavailable" },
|
|
457
|
+
detail: `${identity(null)} · ${[missingEnvironmentDetail, "unable to determine version"].filter(Boolean).join(" · ")}`,
|
|
458
|
+
});
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const problems = [];
|
|
462
|
+
if (missingEnvironmentDetail) problems.push(missingEnvironmentDetail);
|
|
463
|
+
if (missing.length) problems.push(`missing capabilities: ${missing.join(", ")}`);
|
|
464
|
+
finish({
|
|
465
|
+
...withVersion,
|
|
466
|
+
ok: problems.length === 0,
|
|
467
|
+
availability: problems.length === 0
|
|
468
|
+
? { available: true, exhaustedUntil: null, reason: "ready" }
|
|
469
|
+
: { available: false, exhaustedUntil: null, reason: missingEnvironmentDetail ? "authentication_required" : "provider_unavailable" },
|
|
470
|
+
detail: `${identity(version)}${problems.length ? ` · ${problems.join(" · ")}` : ""}`,
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* @param {HarnessRuntime} runtime
|
|
478
|
+
* @returns {string[]}
|
|
479
|
+
*/
|
|
480
|
+
function missingEnvironmentVariables(runtime) {
|
|
481
|
+
/** @type {string[]} */
|
|
482
|
+
const names = [];
|
|
483
|
+
for (const [key, value] of Object.entries(runtime.config ?? {})) {
|
|
484
|
+
if (!key.endsWith(".env_key")) continue;
|
|
485
|
+
if (typeof value === "string" && value.length > 0 && !process.env[value]) names.push(value);
|
|
486
|
+
}
|
|
487
|
+
return [...new Set(names)];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* @param {string} text
|
|
492
|
+
* @returns {string|null}
|
|
493
|
+
*/
|
|
494
|
+
function lastLine(text) {
|
|
495
|
+
return redactSecrets(text.trim().split(/\r?\n/u).at(-1) || "") || null;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* @param {string} text
|
|
500
|
+
* @returns {string}
|
|
501
|
+
*/
|
|
502
|
+
function redactSecrets(text) {
|
|
503
|
+
let result = text;
|
|
504
|
+
for (const value of Object.values(process.env)) {
|
|
505
|
+
if (typeof value === "string" && value.length >= 4) result = result.split(value).join("[REDACTED]");
|
|
506
|
+
}
|
|
507
|
+
return result;
|
|
508
|
+
}
|