harness-dispatch 0.8.0 → 0.10.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/CHANGELOG.md +1074 -2
- package/README.md +92 -36
- package/config.default.yaml +19 -3
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js.map +1 -1
- package/dist/billing.d.ts +24 -0
- package/dist/billing.d.ts.map +1 -1
- package/dist/billing.js +24 -0
- package/dist/billing.js.map +1 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +299 -48
- package/dist/bin.js.map +1 -1
- package/dist/circuit-breaker.d.ts +9 -1
- package/dist/circuit-breaker.d.ts.map +1 -1
- package/dist/circuit-breaker.js +15 -1
- package/dist/circuit-breaker.js.map +1 -1
- package/dist/client-register.d.ts +11 -1
- package/dist/client-register.d.ts.map +1 -1
- package/dist/client-register.js +15 -6
- package/dist/client-register.js.map +1 -1
- package/dist/config/coercions.d.ts +5 -0
- package/dist/config/coercions.d.ts.map +1 -1
- package/dist/config/coercions.js +8 -0
- package/dist/config/coercions.js.map +1 -1
- package/dist/config/protocol.js.map +1 -1
- package/dist/config/validation.d.ts +12 -0
- package/dist/config/validation.d.ts.map +1 -1
- package/dist/config/validation.js +133 -7
- package/dist/config/validation.js.map +1 -1
- package/dist/config.d.ts +15 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +199 -152
- package/dist/config.js.map +1 -1
- package/dist/configure-yaml.d.ts +2 -0
- package/dist/configure-yaml.d.ts.map +1 -1
- package/dist/configure-yaml.js +114 -4
- package/dist/configure-yaml.js.map +1 -1
- package/dist/dispatch-log.d.ts.map +1 -1
- package/dist/dispatch-log.js +3 -1
- package/dist/dispatch-log.js.map +1 -1
- package/dist/dispatchers/generic-cli.d.ts.map +1 -1
- package/dist/dispatchers/generic-cli.js +281 -112
- package/dist/dispatchers/generic-cli.js.map +1 -1
- package/dist/dispatchers/openai-compatible.d.ts +0 -5
- package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
- package/dist/dispatchers/openai-compatible.js +150 -83
- package/dist/dispatchers/openai-compatible.js.map +1 -1
- package/dist/dispatchers/shared/harness-login.d.ts +25 -0
- package/dist/dispatchers/shared/harness-login.d.ts.map +1 -0
- package/dist/dispatchers/shared/harness-login.js +73 -0
- package/dist/dispatchers/shared/harness-login.js.map +1 -0
- package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
- package/dist/dispatchers/shared/stream-subprocess.js +71 -58
- package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
- package/dist/file-lock.d.ts +29 -1
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +53 -20
- package/dist/file-lock.js.map +1 -1
- package/dist/harness-presets.d.ts +51 -0
- package/dist/harness-presets.d.ts.map +1 -0
- package/dist/harness-presets.js +154 -0
- package/dist/harness-presets.js.map +1 -0
- package/dist/http/parse.d.ts +8 -1
- package/dist/http/parse.d.ts.map +1 -1
- package/dist/http/parse.js +10 -3
- package/dist/http/parse.js.map +1 -1
- package/dist/http/server.d.ts +2 -0
- package/dist/http/server.d.ts.map +1 -1
- package/dist/http/server.js +188 -16
- package/dist/http/server.js.map +1 -1
- package/dist/job-runner.js +6 -0
- package/dist/job-runner.js.map +1 -1
- package/dist/jobs/context.d.ts +0 -7
- package/dist/jobs/context.d.ts.map +1 -1
- package/dist/jobs/context.js +95 -7
- package/dist/jobs/context.js.map +1 -1
- package/dist/jobs/lifecycle.d.ts +100 -0
- package/dist/jobs/lifecycle.d.ts.map +1 -0
- package/dist/jobs/lifecycle.js +263 -0
- package/dist/jobs/lifecycle.js.map +1 -0
- package/dist/jobs/read.d.ts +17 -0
- package/dist/jobs/read.d.ts.map +1 -0
- package/dist/jobs/read.js +103 -0
- package/dist/jobs/read.js.map +1 -0
- package/dist/jobs/run.d.ts +45 -0
- package/dist/jobs/run.d.ts.map +1 -0
- package/dist/jobs/run.js +351 -0
- package/dist/jobs/run.js.map +1 -0
- package/dist/jobs/start.d.ts +9 -0
- package/dist/jobs/start.d.ts.map +1 -0
- package/dist/jobs/start.js +126 -0
- package/dist/jobs/start.js.map +1 -0
- package/dist/jobs/store.d.ts.map +1 -1
- package/dist/jobs/store.js +13 -3
- package/dist/jobs/store.js.map +1 -1
- package/dist/jobs/supervisor.d.ts +151 -0
- package/dist/jobs/supervisor.d.ts.map +1 -0
- package/dist/jobs/supervisor.js +679 -0
- package/dist/jobs/supervisor.js.map +1 -0
- package/dist/jobs/types.d.ts +8 -1
- package/dist/jobs/types.d.ts.map +1 -1
- package/dist/jobs.d.ts +15 -218
- package/dist/jobs.d.ts.map +1 -1
- package/dist/jobs.js +14 -1317
- package/dist/jobs.js.map +1 -1
- package/dist/leaderboard.d.ts.map +1 -1
- package/dist/leaderboard.js +44 -28
- package/dist/leaderboard.js.map +1 -1
- package/dist/mcp/config-hot-reload.d.ts +14 -3
- package/dist/mcp/config-hot-reload.d.ts.map +1 -1
- package/dist/mcp/config-hot-reload.js +27 -6
- package/dist/mcp/config-hot-reload.js.map +1 -1
- package/dist/mcp/dispatcher-factory.d.ts.map +1 -1
- package/dist/mcp/dispatcher-factory.js +15 -4
- package/dist/mcp/dispatcher-factory.js.map +1 -1
- package/dist/mcp/resources.d.ts.map +1 -1
- package/dist/mcp/resources.js +3 -2
- package/dist/mcp/resources.js.map +1 -1
- package/dist/mcp/tool-schemas.d.ts +1 -1
- package/dist/mcp/tool-schemas.d.ts.map +1 -1
- package/dist/mcp/tool-schemas.js +6 -3
- package/dist/mcp/tool-schemas.js.map +1 -1
- package/dist/mcp/tools.d.ts +20 -6
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +92 -14
- package/dist/mcp/tools.js.map +1 -1
- package/dist/mcp-clients.d.ts +9 -0
- package/dist/mcp-clients.d.ts.map +1 -1
- package/dist/mcp-clients.js +4 -0
- package/dist/mcp-clients.js.map +1 -1
- package/dist/observability/spans.d.ts.map +1 -1
- package/dist/observability/spans.js +10 -2
- package/dist/observability/spans.js.map +1 -1
- package/dist/quota.d.ts.map +1 -1
- package/dist/quota.js +16 -2
- package/dist/quota.js.map +1 -1
- package/dist/redaction.d.ts +90 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +221 -0
- package/dist/redaction.js.map +1 -0
- package/dist/route-policy.d.ts +2 -1
- package/dist/route-policy.d.ts.map +1 -1
- package/dist/route-policy.js +54 -0
- package/dist/route-policy.js.map +1 -1
- package/dist/router.d.ts +12 -23
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +86 -223
- package/dist/router.js.map +1 -1
- package/dist/safety.d.ts.map +1 -1
- package/dist/safety.js +7 -1
- package/dist/safety.js.map +1 -1
- package/dist/state-dir.d.ts +10 -0
- package/dist/state-dir.d.ts.map +1 -1
- package/dist/state-dir.js +27 -1
- package/dist/state-dir.js.map +1 -1
- package/dist/status.d.ts +14 -3
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +36 -3
- package/dist/status.js.map +1 -1
- package/dist/types.d.ts +24 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/workspace-lock.d.ts +20 -0
- package/dist/workspace-lock.d.ts.map +1 -1
- package/dist/workspace-lock.js +63 -0
- package/dist/workspace-lock.js.map +1 -1
- package/dist/workspace-resolve.d.ts +0 -9
- package/dist/workspace-resolve.d.ts.map +1 -1
- package/dist/workspace-resolve.js +115 -183
- package/dist/workspace-resolve.js.map +1 -1
- package/dist/workspaces.d.ts +15 -0
- package/dist/workspaces.d.ts.map +1 -1
- package/dist/workspaces.js +330 -90
- package/dist/workspaces.js.map +1 -1
- package/package.json +5 -4
package/dist/bin.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* harness-dispatch CLI entrypoint.
|
|
4
4
|
*/
|
|
5
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { installOutputRedaction } from "./redaction.js";
|
|
6
7
|
import { promises as fs } from "node:fs";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -12,9 +13,11 @@ import { AUTO_DETECT_COMMANDS, loadConfig, resolveConfigPath } from "./config.js
|
|
|
12
13
|
import { LeaderboardCache } from "./leaderboard.js";
|
|
13
14
|
import { VERSION } from "./version.js";
|
|
14
15
|
import { commandAvailable } from "./dispatchers/shared/which-available.js";
|
|
15
|
-
import {
|
|
16
|
+
import { codexLoginState } from "./dispatchers/shared/harness-login.js";
|
|
17
|
+
import { clientConfigLocations, inspectClientEntries } from "./mcp-clients.js";
|
|
16
18
|
import { buildDispatchers } from "./mcp/dispatcher-factory.js";
|
|
17
19
|
import { startMcpServer } from "./mcp/server.js";
|
|
20
|
+
import { resolveRunnerPath } from "./jobs.js";
|
|
18
21
|
import { initObservability } from "./observability/index.js";
|
|
19
22
|
import { QuotaCache } from "./quota.js";
|
|
20
23
|
import { Router } from "./router.js";
|
|
@@ -22,9 +25,9 @@ import { buildStatus, buildUsage, renderStatusText, renderUsageText } from "./st
|
|
|
22
25
|
import { startHttpServer } from "./http/server.js";
|
|
23
26
|
import { billingIsBlocked, buildRouteBilling } from "./billing.js";
|
|
24
27
|
import { effectiveSafetyProfile } from "./safety.js";
|
|
25
|
-
import { configToYaml } from "./configure-yaml.js";
|
|
26
|
-
import { devLaunchCommand, planClientWrites, removeClientEntry, writeClientEntry, } from "./client-register.js";
|
|
27
|
-
import { stateRoot } from "./state-dir.js";
|
|
28
|
+
import { configToYaml, isUneditedGenerated, stampGenerated } from "./configure-yaml.js";
|
|
29
|
+
import { desiredEntry, devLaunchCommand, launchCommand, planClientWrites, removeClientEntry, writeClientEntry, } from "./client-register.js";
|
|
30
|
+
import { stateRoot, userConfigPath } from "./state-dir.js";
|
|
28
31
|
async function buildRuntime(configPath) {
|
|
29
32
|
const config = await loadConfig(configPath);
|
|
30
33
|
const dispatchers = await buildDispatchers(config);
|
|
@@ -35,8 +38,21 @@ async function buildRuntime(configPath) {
|
|
|
35
38
|
const router = new Router(config, quota, dispatchers, leaderboard);
|
|
36
39
|
return { config, dispatchers, quota, leaderboard, router };
|
|
37
40
|
}
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Did this invocation ask for machine-readable output?
|
|
43
|
+
*
|
|
44
|
+
* Read from raw argv rather than the parsed flags because the two callers are
|
|
45
|
+
* the unknown-command branch and the top-level error handler, both of which
|
|
46
|
+
* run where parsing has either not happened or already failed. `--json=true`
|
|
47
|
+
* counts: an acceptance pass measured that spelling getting JSON on success
|
|
48
|
+
* and plain text on failure, which is the inconsistency the envelope exists to
|
|
49
|
+
* remove.
|
|
50
|
+
*/
|
|
51
|
+
function wantsJsonOutput() {
|
|
52
|
+
return process.argv.slice(2).some((a) => a === "--json" || a.startsWith("--json="));
|
|
53
|
+
}
|
|
54
|
+
function printUsage(stream = process.stdout) {
|
|
55
|
+
stream.write([
|
|
40
56
|
"harness-dispatch",
|
|
41
57
|
"",
|
|
42
58
|
"Usage:",
|
|
@@ -78,30 +94,68 @@ function printUsage() {
|
|
|
78
94
|
"",
|
|
79
95
|
].join("\n"));
|
|
80
96
|
}
|
|
81
|
-
async function cmdConfigure(configPath,
|
|
97
|
+
async function cmdConfigure(configPath, opts) {
|
|
82
98
|
// configure's --config names where it will WRITE, so a path that does not
|
|
83
99
|
// exist yet is the normal first-run case, not a typo.
|
|
84
|
-
const
|
|
100
|
+
const target = configPath ?? userConfigPath();
|
|
101
|
+
// A file configure wrote and nobody edited is regenerated from a fresh
|
|
102
|
+
// detection rather than loaded: loading it would make it authoritative and
|
|
103
|
+
// hide the harness installed since — the reason the user is re-running.
|
|
104
|
+
// Anything else on disk is the user's, loaded so its settings migrate, and
|
|
105
|
+
// guarded below. See stampGenerated for the natural-order story.
|
|
106
|
+
const existing = existsSync(target) ? await fs.readFile(target, "utf-8") : undefined;
|
|
107
|
+
const regenerate = existing !== undefined && isUneditedGenerated(existing);
|
|
108
|
+
const config = await loadConfig(regenerate ? undefined : configPath, { allowMissing: true });
|
|
85
109
|
const routeCount = Object.keys(config.services).length;
|
|
86
110
|
if (opts.print) {
|
|
87
111
|
// Preview goes to a terminal and, routinely, into a bug report — a
|
|
88
112
|
// literal key with no ${VAR} to restore is redacted rather than echoed.
|
|
89
113
|
const preview = configToYaml(config, { redactLiterals: true });
|
|
90
114
|
process.stdout.write(preview);
|
|
91
|
-
const
|
|
115
|
+
const keyRedacted = Object.values(config.services).some((svc) => svc.apiKey !== undefined &&
|
|
92
116
|
svc.apiKey !== "" &&
|
|
93
117
|
config.envRefs?.get(svc.apiKey) === undefined &&
|
|
94
118
|
config.apiKeyRefs?.get(svc.name) === undefined);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
119
|
+
// The note used to speak only for the api_key while the base_url beside it
|
|
120
|
+
// was printed whole, so a preview containing a URL password carried a
|
|
121
|
+
// sentence implying it had been sanitised. It now names what it did.
|
|
122
|
+
const urlRedacted = Object.values(config.services).some((svc) => {
|
|
123
|
+
if (svc.baseUrl === undefined || svc.baseUrl === "")
|
|
124
|
+
return false;
|
|
125
|
+
if (config.envRefs?.get(svc.baseUrl) !== undefined)
|
|
126
|
+
return false;
|
|
127
|
+
try {
|
|
128
|
+
const url = new URL(svc.baseUrl);
|
|
129
|
+
return (url.password !== "" || url.username !== "" || [...url.searchParams.keys()].length > 0);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
if (keyRedacted || urlRedacted) {
|
|
136
|
+
const what = [
|
|
137
|
+
keyRedacted ? "api_key values" : undefined,
|
|
138
|
+
urlRedacted ? "credential-bearing parts of base_url" : undefined,
|
|
139
|
+
]
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.join(" and ");
|
|
142
|
+
process.stderr.write(`note: ${what} are literals in the source config and were redacted in this ` +
|
|
143
|
+
"preview. Move them to environment variables — this output is not a drop-in " +
|
|
144
|
+
"replacement for that file until you do.\n");
|
|
100
145
|
}
|
|
101
146
|
return 0;
|
|
102
147
|
}
|
|
103
148
|
const yamlText = configToYaml(config, { redactLiterals: false });
|
|
104
|
-
|
|
149
|
+
// "Detected" is only true when detection ran. Over an edited file that lists
|
|
150
|
+
// its own routes it does not (the file is authoritative), and an acceptance
|
|
151
|
+
// pass measured `configure --yes --force` printing "Detected 1 harness route"
|
|
152
|
+
// with a second harness on PATH that never appeared in the output.
|
|
153
|
+
const plural = routeCount === 1 ? "" : "s";
|
|
154
|
+
process.stdout.write(config.detectionRan === false
|
|
155
|
+
? `${routeCount} route${plural} from ${path.resolve(configPath ?? target)} — detection did not run, ` +
|
|
156
|
+
"because this file lists its own routes. To merge harnesses installed since, add " +
|
|
157
|
+
"`detect: true` to it; to start over from a fresh detection, delete it first.\n"
|
|
158
|
+
: `Detected ${routeCount} harness route${plural}.\n`);
|
|
105
159
|
for (const [name, svc] of Object.entries(config.services)) {
|
|
106
160
|
process.stdout.write(`- ${name}: harness=${svc.harness ?? name} billing=${buildRouteBilling(svc).kind} safety=${effectiveSafetyProfile(svc)} model=${svc.model ?? svc.leaderboardModel ?? "unknown"}\n`);
|
|
107
161
|
}
|
|
@@ -120,7 +174,10 @@ async function cmdConfigure(configPath, explicitConfigPath, opts) {
|
|
|
120
174
|
process.stdout.write(`- ${warning}\n`);
|
|
121
175
|
}
|
|
122
176
|
}
|
|
123
|
-
|
|
177
|
+
if (regenerate) {
|
|
178
|
+
process.stdout.write(`\n${path.resolve(target)} is unedited configure output — ` +
|
|
179
|
+
`${opts.yes ? "regenerating it from this detection" : "--yes will regenerate it from this detection"}.\n`);
|
|
180
|
+
}
|
|
124
181
|
if (!opts.yes) {
|
|
125
182
|
process.stdout.write(`\nNo files written. Re-run with --yes to write ${target}, or use --print to inspect YAML.\n`);
|
|
126
183
|
process.stdout.write("After writing config, connect agents by adding the harness-dispatch MCP snippet to the agent you use.\n");
|
|
@@ -133,7 +190,7 @@ async function cmdConfigure(configPath, explicitConfigPath, opts) {
|
|
|
133
190
|
// you to pass --config, which is exactly what disabled it. Overwriting a
|
|
134
191
|
// hand-written config is not recoverable, so it now takes an explicit
|
|
135
192
|
// --force rather than an accident of which flag you happened to use.
|
|
136
|
-
if (existsSync(target) && !opts.force) {
|
|
193
|
+
if (existsSync(target) && !opts.force && !regenerate) {
|
|
137
194
|
process.stderr.write(`configure: ${target} already exists and would be overwritten.\n` +
|
|
138
195
|
"Use --print to inspect the generated YAML, --config <other-path> to write\n" +
|
|
139
196
|
"elsewhere, or --force to overwrite it deliberately.\n");
|
|
@@ -151,9 +208,12 @@ async function cmdConfigure(configPath, explicitConfigPath, opts) {
|
|
|
151
208
|
// Applied on create only: `writeFile`'s mode does not change an existing
|
|
152
209
|
// file's permissions, so re-running `configure` will not silently tighten a
|
|
153
210
|
// file the user deliberately made group-readable.
|
|
154
|
-
|
|
211
|
+
// The default target lives in the state directory, which a first run has
|
|
212
|
+
// not created yet. Same mode the rest of the state dir gets.
|
|
213
|
+
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
214
|
+
await fs.writeFile(target, stampGenerated(yamlText), { encoding: "utf-8", mode: 0o600 });
|
|
155
215
|
const absoluteTarget = path.resolve(target);
|
|
156
|
-
process.stdout.write(`Wrote ${
|
|
216
|
+
process.stdout.write(`Wrote ${absoluteTarget}.\n`);
|
|
157
217
|
// The last step of setup used to be "here is some JSON, paste it somewhere".
|
|
158
218
|
// Nobody owned the result, and the paths in it later moved — which is how one
|
|
159
219
|
// machine ended up running a dead entry, a dead hook, and a third client
|
|
@@ -174,7 +234,16 @@ async function cmdConfigure(configPath, explicitConfigPath, opts) {
|
|
|
174
234
|
process.stdout.write("\nMCP snippet (uses an absolute --config path so it resolves correctly no matter what\n" +
|
|
175
235
|
"directory the MCP client launches from — a relative path or none at all silently\n" +
|
|
176
236
|
"falls back to the shipped defaults, ignoring every edit you make to this file):\n");
|
|
177
|
-
|
|
237
|
+
// The SAME entry `connect` writes, built by the same function.
|
|
238
|
+
//
|
|
239
|
+
// This printed a hardcoded `harness-dispatch --config <path>` while connect
|
|
240
|
+
// writes whatever `launchCommand()` resolves to — `npx -y harness-dispatch
|
|
241
|
+
// --config <path>` without a global install. So pasting the snippet this
|
|
242
|
+
// command prints and then running `connect --remove` was answered "has an
|
|
243
|
+
// entry we did not write — left alone unless --force", and exit 1. The tool
|
|
244
|
+
// called its own documented output hand-edited, and blocked both update and
|
|
245
|
+
// removal on the manual-install path it had just recommended.
|
|
246
|
+
printMcpSnippet(desiredEntry(absoluteTarget, launchCommand()));
|
|
178
247
|
process.stdout.write("Or let `harness-dispatch connect` write it for you.\n");
|
|
179
248
|
return 0;
|
|
180
249
|
}
|
|
@@ -194,7 +263,7 @@ function printMcpSnippet(entry) {
|
|
|
194
263
|
* than hanging or guessing.
|
|
195
264
|
*/
|
|
196
265
|
async function cmdConnect(configPath, opts) {
|
|
197
|
-
const target = path.resolve(configPath ??
|
|
266
|
+
const target = path.resolve(configPath ?? userConfigPath());
|
|
198
267
|
if (!existsSync(target) && !opts.remove) {
|
|
199
268
|
process.stderr.write(`connect: no config at ${target}. Run \`harness-dispatch configure --yes\` first —\n` +
|
|
200
269
|
"a client entry pointing at a config that does not exist is the failure this\n" +
|
|
@@ -272,7 +341,8 @@ async function cmdConnect(configPath, opts) {
|
|
|
272
341
|
: await writeClientEntry(plan, { stamp, consented });
|
|
273
342
|
if (outcome.action === "written") {
|
|
274
343
|
wrote = true;
|
|
275
|
-
process.stdout.write(`${opts.remove ? "Removed from" : "Wrote"} ${outcome.client}
|
|
344
|
+
process.stdout.write(`${opts.remove ? "Removed from" : "Wrote"} ${outcome.client} ` +
|
|
345
|
+
`(${outcome.backupPath !== undefined ? `backup: ${outcome.backupPath}` : `created ${outcome.file}`})\n`);
|
|
276
346
|
}
|
|
277
347
|
else if (outcome.action === "unchanged") {
|
|
278
348
|
process.stdout.write(`${outcome.client}: ${opts.remove ? "no entry of ours to remove" : "already correct"}, nothing changed.\n`);
|
|
@@ -299,6 +369,7 @@ function describeState(state, removing) {
|
|
|
299
369
|
if (removing) {
|
|
300
370
|
return {
|
|
301
371
|
absent: "not installed",
|
|
372
|
+
"missing-file": "installed, no config file yet — nothing to remove",
|
|
302
373
|
unreadable: "config does not parse — will be left alone",
|
|
303
374
|
"missing-entry": "no entry of ours to remove",
|
|
304
375
|
matches: "our entry is here — will be removed",
|
|
@@ -307,6 +378,7 @@ function describeState(state, removing) {
|
|
|
307
378
|
}
|
|
308
379
|
return {
|
|
309
380
|
absent: "not installed",
|
|
381
|
+
"missing-file": "installed, no config file yet — one will be created",
|
|
310
382
|
unreadable: "config does not parse — will be left alone",
|
|
311
383
|
"missing-entry": "no harness-dispatch entry yet",
|
|
312
384
|
matches: "already registered correctly",
|
|
@@ -328,7 +400,24 @@ function summariseEntry(entry) {
|
|
|
328
400
|
* differing entry was the WORKING one.
|
|
329
401
|
*/
|
|
330
402
|
async function chooseInteractively(plans, opts) {
|
|
331
|
-
|
|
403
|
+
// What counts as actionable INVERTS under --remove.
|
|
404
|
+
//
|
|
405
|
+
// Registering: `matches` means the entry is already what we would write, so
|
|
406
|
+
// there is nothing to do. Removing: `matches` is exactly the entry being
|
|
407
|
+
// removed, so filtering it out left nothing actionable — and the command
|
|
408
|
+
// printed "our entry is here — will be removed", exited 0, and changed
|
|
409
|
+
// nothing. Reproduced byte-for-byte: same md5 before and after.
|
|
410
|
+
//
|
|
411
|
+
// Only the bare form was affected, because `--clients` bypasses this
|
|
412
|
+
// function entirely. That is the form README documents twice and
|
|
413
|
+
// OPERATIONS.md once, and no test covered this command at all.
|
|
414
|
+
// Stated as what IS actionable rather than what is not: the states are
|
|
415
|
+
// absent / unreadable / missing-entry / matches / differs, and under
|
|
416
|
+
// --remove only the two that actually hold an entry qualify. Written as a
|
|
417
|
+
// negation, `absent` (no config file at all) slipped through.
|
|
418
|
+
const actionable = plans.filter((p) => opts.remove === true
|
|
419
|
+
? p.state === "matches" || p.state === "differs"
|
|
420
|
+
: p.state !== "matches" && p.state !== "unreadable");
|
|
332
421
|
if (actionable.length === 0)
|
|
333
422
|
return [];
|
|
334
423
|
// `--yes` skips the question; it does NOT answer it.
|
|
@@ -341,8 +430,12 @@ async function chooseInteractively(plans, opts) {
|
|
|
341
430
|
if (opts.yes)
|
|
342
431
|
return actionable;
|
|
343
432
|
if (!process.stdin.isTTY) {
|
|
344
|
-
process.stdout.write(
|
|
345
|
-
|
|
433
|
+
process.stdout.write(
|
|
434
|
+
// Names the command, not just the flags: this is reached from
|
|
435
|
+
// `configure --yes` too, where "or --yes" told the user to pass the flag
|
|
436
|
+
// they had already passed.
|
|
437
|
+
"\nNot a terminal, so nothing was written. Run `harness-dispatch connect --clients " +
|
|
438
|
+
`${actionable.map((p) => p.id).join(",")}\` (or \`connect --yes\`) to register.\n`);
|
|
346
439
|
return undefined;
|
|
347
440
|
}
|
|
348
441
|
const { createInterface } = await import("node:readline/promises");
|
|
@@ -435,6 +528,13 @@ async function cmdDoctor(configPath, opts) {
|
|
|
435
528
|
const nodeOk = (nodeMajor ?? 0) > 22 ||
|
|
436
529
|
((nodeMajor ?? 0) === 22 &&
|
|
437
530
|
((nodeMinor ?? 0) > 22 || ((nodeMinor ?? 0) === 22 && (nodePatch ?? 0) >= 2)));
|
|
531
|
+
const configuredCommands = new Set(status.routes
|
|
532
|
+
.map((route) => route.command)
|
|
533
|
+
.filter((command) => typeof command === "string")
|
|
534
|
+
.map((command) => path.basename(command).replace(/\.(cmd|exe)$/i, "")));
|
|
535
|
+
const unconfiguredHarnesses = runtime.config.detectionRan === false
|
|
536
|
+
? Object.values(AUTO_DETECT_COMMANDS).filter((command) => commandAvailable(command) && !configuredCommands.has(command))
|
|
537
|
+
: [];
|
|
438
538
|
const checks = [
|
|
439
539
|
{
|
|
440
540
|
name: "node",
|
|
@@ -446,7 +546,21 @@ async function cmdDoctor(configPath, opts) {
|
|
|
446
546
|
{
|
|
447
547
|
name: "config",
|
|
448
548
|
ok: Object.keys(runtime.config.services).length > 0,
|
|
449
|
-
|
|
549
|
+
// Names the file, because "which config is this looking at" was the
|
|
550
|
+
// question: `configure` run from one directory and `doctor` from
|
|
551
|
+
// another used to load different things and neither said so.
|
|
552
|
+
detail: `${Object.keys(runtime.config.services).length} configured route(s)` +
|
|
553
|
+
(configPath === undefined
|
|
554
|
+
? " (no config file found; shipped defaults with auto-detected harnesses)"
|
|
555
|
+
: runtime.config.detectionRan === false
|
|
556
|
+
? ` from ${path.resolve(configPath)}`
|
|
557
|
+
: // Detection ran. Two different reasons, and saying the wrong one
|
|
558
|
+
// is confusing: a file may define no routes at all, or it may
|
|
559
|
+
// define some AND ask for detection with `detect: true`. The
|
|
560
|
+
// first version reported the former for both.
|
|
561
|
+
(runtime.config.detect === true
|
|
562
|
+
? ` from ${path.resolve(configPath)} plus auto-detected harnesses (detect: true)`
|
|
563
|
+
: ` auto-detected — ${path.resolve(configPath)} defines no routes of its own`)),
|
|
450
564
|
},
|
|
451
565
|
// This one DOES fail, unlike the advisory git check below.
|
|
452
566
|
//
|
|
@@ -464,11 +578,20 @@ async function cmdDoctor(configPath, opts) {
|
|
|
464
578
|
const entries = inspectClientEntries();
|
|
465
579
|
const broken = entries.filter((e) => e.missingPaths.length > 0);
|
|
466
580
|
if (entries.length === 0) {
|
|
581
|
+
// Name the client that IS here, so "not registered" reads as the
|
|
582
|
+
// next step rather than as "nothing to register with".
|
|
583
|
+
const present = clientConfigLocations()
|
|
584
|
+
.filter((c) => c.commands.some((cmd) => commandAvailable(cmd)))
|
|
585
|
+
.map((c) => c.client);
|
|
467
586
|
return {
|
|
468
587
|
name: "mcp-clients",
|
|
469
588
|
ok: true,
|
|
470
|
-
detail:
|
|
471
|
-
"
|
|
589
|
+
detail: present.length > 0
|
|
590
|
+
? `${present.join(", ")} installed but harness-dispatch is not registered with it — ` +
|
|
591
|
+
"run `harness-dispatch connect`"
|
|
592
|
+
: "not registered with any MCP client this tool knows how to read " +
|
|
593
|
+
"(Claude Code, Cursor), and none is installed — run `harness-dispatch connect` " +
|
|
594
|
+
"after installing one",
|
|
472
595
|
};
|
|
473
596
|
}
|
|
474
597
|
return {
|
|
@@ -546,11 +669,22 @@ async function cmdDoctor(configPath, opts) {
|
|
|
546
669
|
// When nothing is ready, say what was looked for. "0 ready route(s)" on
|
|
547
670
|
// its own leaves a new user with no idea whether the tool is broken or
|
|
548
671
|
// simply has nothing to route to, and no hint what to install.
|
|
549
|
-
detail: status.ready.length > 0
|
|
550
|
-
?
|
|
672
|
+
detail: (status.ready.length > 0
|
|
673
|
+
? // Trailing period so this reads as a sentence when the
|
|
674
|
+
// installed-but-unconfigured note is appended after it.
|
|
675
|
+
`${status.ready.length} ready route(s).`
|
|
551
676
|
: `0 ready route(s). Looked for these harness CLIs on PATH: ` +
|
|
552
677
|
`${Object.values(AUTO_DETECT_COMMANDS).join(", ")}. ` +
|
|
553
|
-
`Install one, or add a route to config.yaml (endpoints: need no CLI)
|
|
678
|
+
`Install one, or add a route to config.yaml (endpoints: need no CLI).`) +
|
|
679
|
+
// A config that lists its own routes is authoritative, so a harness
|
|
680
|
+
// installed later is simply absent — and this line said "1 ready
|
|
681
|
+
// route(s)" with a second CLI on PATH and no hint. The hint about PATH
|
|
682
|
+
// above only fired at zero routes.
|
|
683
|
+
(unconfiguredHarnesses.length > 0
|
|
684
|
+
? ` Installed but not in this config: ${unconfiguredHarnesses.join(", ")} — add ` +
|
|
685
|
+
`\`detect: true\` to ${configPath !== undefined ? path.resolve(configPath) : "the config"} ` +
|
|
686
|
+
`to merge them, or a clis: entry for each.`
|
|
687
|
+
: ""),
|
|
554
688
|
},
|
|
555
689
|
{
|
|
556
690
|
// Nothing checked this, so an unwritable state directory surfaced only
|
|
@@ -558,8 +692,27 @@ async function cmdDoctor(configPath, opts) {
|
|
|
558
692
|
// run finished" — a false cause, 90s after the work had actually
|
|
559
693
|
// succeeded.
|
|
560
694
|
name: "state-dir",
|
|
561
|
-
|
|
562
|
-
|
|
695
|
+
// Called ONCE: each call creates and deletes a probe file, and this
|
|
696
|
+
// asked the same question twice to fill two fields.
|
|
697
|
+
...stateDirWritable(),
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
// Whether dispatches will actually be detached.
|
|
701
|
+
//
|
|
702
|
+
// `resolveRunnerPath()` returning undefined is not an error — it is the
|
|
703
|
+
// signal to run the job IN-PROCESS, which is right for an unbuilt
|
|
704
|
+
// checkout and wrong everywhere else: the concurrency cap is enforced by
|
|
705
|
+
// the supervisor pool, so in-process mode silently removes the bound
|
|
706
|
+
// that exists because of a measured OOM. It prints one line on stderr at
|
|
707
|
+
// dispatch time and nothing checked it, so "am I actually capped?" had
|
|
708
|
+
// no answer. An audit noticed; this is that answer.
|
|
709
|
+
name: "job-runner",
|
|
710
|
+
ok: resolveRunnerPath() !== undefined,
|
|
711
|
+
detail: resolveRunnerPath() !== undefined
|
|
712
|
+
? "found; jobs run detached and the concurrency cap applies"
|
|
713
|
+
: "dist/job-runner.js not found — jobs will run IN-PROCESS, which " +
|
|
714
|
+
"removes the max_concurrent_runs cap and does not survive a server " +
|
|
715
|
+
"restart. Run `npm run build`, or reinstall the package.",
|
|
563
716
|
},
|
|
564
717
|
{
|
|
565
718
|
name: "http-auth",
|
|
@@ -570,6 +723,63 @@ async function cmdDoctor(configPath, opts) {
|
|
|
570
723
|
},
|
|
571
724
|
];
|
|
572
725
|
const blocked = status.skippedRoutes.filter((skip) => skip.code === "paid_blocked" || skip.code === "unknown_billing");
|
|
726
|
+
// A ready route is one whose CLI is on PATH. That said nothing about whether
|
|
727
|
+
// the CLI could actually make a request: an installed, never-logged-in Codex
|
|
728
|
+
// passed routes, billing and safety, and the first dispatch then failed with
|
|
729
|
+
// a raw OpenAI 401 that never mentioned `codex login`. The cold-install walk
|
|
730
|
+
// in acceptance/0.8.0.md is where that was seen. The CLI is asked directly
|
|
731
|
+
// (see harness-login.ts for why not the credential file), and only a
|
|
732
|
+
// definite "not logged in" fails the check.
|
|
733
|
+
const codexRoutes = status.routes.filter((route) => status.ready.includes(route.id) && route.harness === "codex" && route.command);
|
|
734
|
+
const loginStates = await Promise.all(codexRoutes.map(async (route) => ({ route, state: await codexLoginState(route.command) })));
|
|
735
|
+
const loggedOut = loginStates.filter((entry) => entry.state === "logged_out");
|
|
736
|
+
// A route that has NEVER succeeded is worth saying out loud.
|
|
737
|
+
//
|
|
738
|
+
// The breaker is about recent failure and forgets after its cooldown, so a
|
|
739
|
+
// route that is simply dead — a host that no longer resolves, a key that was
|
|
740
|
+
// revoked — keeps being selected, failing, and falling back, forever. On the
|
|
741
|
+
// maintainer's own machine a local endpoint sat at 8 calls and 0 successes
|
|
742
|
+
// while being tier-3-preferred for `review`, so every review dispatch paid
|
|
743
|
+
// for one doomed attempt before falling back. Nothing reported it: `usage`
|
|
744
|
+
// showed the counts and nobody reads `usage` when things merely feel slow.
|
|
745
|
+
//
|
|
746
|
+
// Advisory, never a failure: a fresh install has no calls at all, and a
|
|
747
|
+
// route can legitimately fail its first few (a laptop that was asleep). The
|
|
748
|
+
// threshold is about having enough evidence to be worth mentioning, not
|
|
749
|
+
// about being sure.
|
|
750
|
+
const NEVER_SUCCEEDED_MIN_CALLS = 5;
|
|
751
|
+
const deadRoutes = status.routes
|
|
752
|
+
.filter((route) => status.ready.includes(route.id))
|
|
753
|
+
.map((route) => ({
|
|
754
|
+
id: route.id,
|
|
755
|
+
calls: route.quota.localCallCount ?? 0,
|
|
756
|
+
successes: route.quota.localSuccessCount ?? 0,
|
|
757
|
+
}))
|
|
758
|
+
.filter((r) => r.calls >= NEVER_SUCCEEDED_MIN_CALLS && r.successes === 0);
|
|
759
|
+
checks.push({
|
|
760
|
+
name: "route-health",
|
|
761
|
+
ok: true,
|
|
762
|
+
detail: deadRoutes.length === 0
|
|
763
|
+
? "no ready route has failed every call it has been given"
|
|
764
|
+
: deadRoutes
|
|
765
|
+
.map((r) => `${r.id} has never succeeded (${r.calls} calls, 0 successes) — it is still ` +
|
|
766
|
+
`being selected and failing, so every dispatch it wins costs an attempt ` +
|
|
767
|
+
`before falling back. Check the endpoint or credential, or disable it.`)
|
|
768
|
+
.join(" | "),
|
|
769
|
+
});
|
|
770
|
+
checks.push({
|
|
771
|
+
name: "harness-login",
|
|
772
|
+
ok: loggedOut.length === 0,
|
|
773
|
+
detail: loginStates.length === 0
|
|
774
|
+
? "no ready route has a login state this tool knows how to ask for (Codex only, today)"
|
|
775
|
+
: loggedOut.length > 0
|
|
776
|
+
? `${loggedOut.map((entry) => entry.route.id).join(", ")}: codex reports "Not logged in" — ` +
|
|
777
|
+
"run `codex login` (or `codex login --with-api-key`), or every dispatch to it fails " +
|
|
778
|
+
"with 401 Unauthorized from OpenAI"
|
|
779
|
+
: loginStates
|
|
780
|
+
.map((entry) => `${entry.route.id}: ${entry.state === "logged_in" ? "logged in" : "could not determine (codex login status gave no answer)"}`)
|
|
781
|
+
.join("; "),
|
|
782
|
+
});
|
|
573
783
|
checks.push({
|
|
574
784
|
name: "billing-policy",
|
|
575
785
|
ok: true,
|
|
@@ -662,8 +872,10 @@ async function cmdAuth(action) {
|
|
|
662
872
|
return 0;
|
|
663
873
|
}
|
|
664
874
|
default:
|
|
665
|
-
|
|
666
|
-
|
|
875
|
+
// Thrown, not written: the top-level handler is the one place that
|
|
876
|
+
// knows whether --json was asked for. Written here it bypassed the
|
|
877
|
+
// envelope, so `auth --json` reported failure as a bare sentence.
|
|
878
|
+
throw new UsageError("auth: expected show or rotate");
|
|
667
879
|
}
|
|
668
880
|
}
|
|
669
881
|
/**
|
|
@@ -682,9 +894,8 @@ async function cmdAuth(action) {
|
|
|
682
894
|
*/
|
|
683
895
|
async function cmdDispatch(prompt, configPath, opts) {
|
|
684
896
|
if (!prompt) {
|
|
685
|
-
|
|
686
|
-
'
|
|
687
|
-
return 1;
|
|
897
|
+
throw new UsageError('dispatch: missing prompt. Usage: dispatch [--service <id>] [--safety <profile>] ' +
|
|
898
|
+
'[--task-type <type>] [--no-fallback] [--json] "<prompt>"');
|
|
688
899
|
}
|
|
689
900
|
const runtime = await buildRuntime(configPath);
|
|
690
901
|
const hints = { taskType: opts.taskType ?? "execute" };
|
|
@@ -769,6 +980,9 @@ function serveOpts(values) {
|
|
|
769
980
|
return out;
|
|
770
981
|
}
|
|
771
982
|
export async function main(argv) {
|
|
983
|
+
// Terminal output is a sink; see src/redaction.ts. Installed before any
|
|
984
|
+
// config is loaded, which is fine — the registry is consulted per write.
|
|
985
|
+
installOutputRedaction();
|
|
772
986
|
const { values, positionals } = parseArgs({
|
|
773
987
|
args: argv,
|
|
774
988
|
options: {
|
|
@@ -834,7 +1048,10 @@ export async function main(argv) {
|
|
|
834
1048
|
const [command, ...rest] = positionals;
|
|
835
1049
|
// `--config` with no value: parseArgs yields boolean true, which reached
|
|
836
1050
|
// path.join and threw ERR_INVALID_ARG_TYPE as a raw Node stack trace.
|
|
837
|
-
|
|
1051
|
+
// `--config=` (empty) is the same mistake with a string type: it resolved to
|
|
1052
|
+
// "", loadConfig treated it as no path, and doctor reported the auto-detected
|
|
1053
|
+
// routes as loaded "from" the current directory.
|
|
1054
|
+
if (values.config !== undefined && (typeof values.config !== "string" || values.config === "")) {
|
|
838
1055
|
throw new UsageError("--config needs a path, e.g. --config ./config.yaml");
|
|
839
1056
|
}
|
|
840
1057
|
const explicitConfigPath = values.config;
|
|
@@ -860,7 +1077,7 @@ export async function main(argv) {
|
|
|
860
1077
|
}
|
|
861
1078
|
switch (command) {
|
|
862
1079
|
case "configure":
|
|
863
|
-
return cmdConfigure(configPath,
|
|
1080
|
+
return cmdConfigure(configPath, {
|
|
864
1081
|
print: Boolean(values.print),
|
|
865
1082
|
yes: Boolean(values.yes),
|
|
866
1083
|
force: Boolean(values.force),
|
|
@@ -916,9 +1133,16 @@ export async function main(argv) {
|
|
|
916
1133
|
}
|
|
917
1134
|
return main(configPath !== undefined ? ["--config", configPath] : []);
|
|
918
1135
|
default:
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1136
|
+
// Usage goes to STDERR here, not stdout. An unknown command is an
|
|
1137
|
+
// error, and printing the help block on stdout meant
|
|
1138
|
+
// `harness-dispatch frobnicate --json | jq` got the usage text as
|
|
1139
|
+
// its input — the exact pipe the --json envelope keeps parseable.
|
|
1140
|
+
// Suppressed under --json: the caller asked for machine-readable
|
|
1141
|
+
// output, and a help block ahead of the envelope makes stderr
|
|
1142
|
+
// unparseable in the same way stdout was.
|
|
1143
|
+
if (!wantsJsonOutput())
|
|
1144
|
+
printUsage(process.stderr);
|
|
1145
|
+
throw new UsageError(`unknown command: ${command}`);
|
|
922
1146
|
}
|
|
923
1147
|
}
|
|
924
1148
|
/**
|
|
@@ -949,8 +1173,27 @@ function finish(code) {
|
|
|
949
1173
|
}, EXIT_DRAIN_GRACE_MS);
|
|
950
1174
|
bail.unref();
|
|
951
1175
|
}
|
|
952
|
-
|
|
953
|
-
|
|
1176
|
+
// Run main() only when this file is the process entrypoint, not when a test
|
|
1177
|
+
// imports it. `argv[1]` is the path the user invoked, which is NOT this file
|
|
1178
|
+
// when npm installed the command as a symlink (`/usr/local/bin/harness-dispatch`
|
|
1179
|
+
// on Linux and macOS): node does not resolve it, so a name check alone
|
|
1180
|
+
// silently ran nothing there and exited 0 — every documented command was a
|
|
1181
|
+
// no-op for every non-Windows `npm install -g` user through 0.8.0. Windows
|
|
1182
|
+
// never hit it because npm's .cmd shim passes the real dist/bin.js path.
|
|
1183
|
+
const entrypoint = typeof process !== "undefined" && Array.isArray(process.argv) ? (process.argv[1] ?? "") : "";
|
|
1184
|
+
function isThisFile(invoked) {
|
|
1185
|
+
if (!invoked)
|
|
1186
|
+
return false;
|
|
1187
|
+
if (invoked.endsWith("bin.ts") || invoked.endsWith("bin.js"))
|
|
1188
|
+
return true;
|
|
1189
|
+
try {
|
|
1190
|
+
return realpathSync(invoked) === realpathSync(fileURLToPath(import.meta.url));
|
|
1191
|
+
}
|
|
1192
|
+
catch {
|
|
1193
|
+
return false;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
if (isThisFile(entrypoint)) {
|
|
954
1197
|
void main(process.argv.slice(2))
|
|
955
1198
|
.then((code) => {
|
|
956
1199
|
finish(code);
|
|
@@ -963,8 +1206,16 @@ if (entrypoint && (entrypoint.endsWith("bin.ts") || entrypoint.endsWith("bin.js"
|
|
|
963
1206
|
// reliable way to tell "bug" from "bad input" by class here. Only a
|
|
964
1207
|
// non-Error throw (a genuine programming error) keeps its stack.
|
|
965
1208
|
if (err instanceof UsageError || err instanceof Error) {
|
|
966
|
-
|
|
967
|
-
|
|
1209
|
+
// `--json` is a promise about the SHAPE of this command's output, and
|
|
1210
|
+
// it was kept only on the success path: a bad --config made
|
|
1211
|
+
// `doctor --json` print a sentence, so anything parsing the output got
|
|
1212
|
+
// a parse error instead of the reason. The message is the same; only
|
|
1213
|
+
// the envelope follows what was asked for. Errors still go to stderr,
|
|
1214
|
+
// so a caller reading stdout for results is unaffected either way.
|
|
1215
|
+
const wantsJson = wantsJsonOutput();
|
|
1216
|
+
process.stderr.write(wantsJson
|
|
1217
|
+
? `${JSON.stringify({ ok: false, error: err.message }, null, 2)}\n`
|
|
1218
|
+
: `harness-dispatch: ${err.message}\n`);
|
|
968
1219
|
process.exit(1);
|
|
969
1220
|
}
|
|
970
1221
|
throw err;
|