harness-dispatch 0.4.0 → 0.5.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/README.md +228 -86
- package/config.default.yaml +416 -397
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +2 -2
- package/dist/auth.js.map +1 -1
- package/dist/billing.d.ts +35 -4
- package/dist/billing.d.ts.map +1 -1
- package/dist/billing.js +48 -17
- package/dist/billing.js.map +1 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +253 -23
- package/dist/bin.js.map +1 -1
- package/dist/breaker-store.d.ts +60 -9
- package/dist/breaker-store.d.ts.map +1 -1
- package/dist/breaker-store.js +246 -50
- package/dist/breaker-store.js.map +1 -1
- package/dist/circuit-breaker.d.ts +35 -0
- package/dist/circuit-breaker.d.ts.map +1 -1
- package/dist/circuit-breaker.js +68 -11
- package/dist/circuit-breaker.js.map +1 -1
- package/dist/config/coercions.d.ts +31 -0
- package/dist/config/coercions.d.ts.map +1 -0
- package/dist/config/coercions.js +169 -0
- package/dist/config/coercions.js.map +1 -0
- package/dist/config/env-interpolation.d.ts +36 -0
- package/dist/config/env-interpolation.d.ts.map +1 -0
- package/dist/config/env-interpolation.js +73 -0
- package/dist/config/env-interpolation.js.map +1 -0
- package/dist/config/protocol.d.ts +51 -0
- package/dist/config/protocol.d.ts.map +1 -0
- package/dist/config/protocol.js +300 -0
- package/dist/config/protocol.js.map +1 -0
- package/dist/config/validation.d.ts +72 -0
- package/dist/config/validation.d.ts.map +1 -0
- package/dist/config/validation.js +189 -0
- package/dist/config/validation.js.map +1 -0
- package/dist/config.d.ts +19 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +229 -511
- package/dist/config.js.map +1 -1
- package/dist/dispatch-log.d.ts.map +1 -1
- package/dist/dispatch-log.js +2 -3
- package/dist/dispatch-log.js.map +1 -1
- package/dist/dispatchers/generic-cli.d.ts +15 -1
- package/dist/dispatchers/generic-cli.d.ts.map +1 -1
- package/dist/dispatchers/generic-cli.js +65 -5
- package/dist/dispatchers/generic-cli.js.map +1 -1
- package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
- package/dist/dispatchers/openai-compatible.js +52 -2
- package/dist/dispatchers/openai-compatible.js.map +1 -1
- package/dist/dispatchers/shared/kill-tree.d.ts +17 -4
- package/dist/dispatchers/shared/kill-tree.d.ts.map +1 -1
- package/dist/dispatchers/shared/kill-tree.js +28 -4
- package/dist/dispatchers/shared/kill-tree.js.map +1 -1
- package/dist/dispatchers/shared/rate-limit-headers.d.ts +17 -0
- package/dist/dispatchers/shared/rate-limit-headers.d.ts.map +1 -1
- package/dist/dispatchers/shared/rate-limit-headers.js +27 -0
- package/dist/dispatchers/shared/rate-limit-headers.js.map +1 -1
- package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
- package/dist/dispatchers/shared/stream-subprocess.js +5 -0
- package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
- package/dist/dispatchers/shared/subprocess.d.ts.map +1 -1
- package/dist/dispatchers/shared/subprocess.js +3 -0
- package/dist/dispatchers/shared/subprocess.js.map +1 -1
- package/dist/dispatchers/shared/which-available.d.ts.map +1 -1
- package/dist/dispatchers/shared/which-available.js +16 -2
- package/dist/dispatchers/shared/which-available.js.map +1 -1
- package/dist/file-lock.d.ts +17 -0
- package/dist/file-lock.d.ts.map +1 -0
- package/dist/file-lock.js +143 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/http/server.d.ts +11 -0
- package/dist/http/server.d.ts.map +1 -1
- package/dist/http/server.js +331 -162
- package/dist/http/server.js.map +1 -1
- package/dist/job-runner.js +23 -5
- package/dist/job-runner.js.map +1 -1
- package/dist/jobs.d.ts +91 -1
- package/dist/jobs.d.ts.map +1 -1
- package/dist/jobs.js +647 -36
- package/dist/jobs.js.map +1 -1
- package/dist/leaderboard.d.ts +24 -1
- package/dist/leaderboard.d.ts.map +1 -1
- package/dist/leaderboard.js +94 -2
- package/dist/leaderboard.js.map +1 -1
- package/dist/mcp/config-hot-reload.d.ts.map +1 -1
- package/dist/mcp/config-hot-reload.js +1 -1
- package/dist/mcp/config-hot-reload.js.map +1 -1
- package/dist/mcp/dispatcher-factory.d.ts +16 -1
- package/dist/mcp/dispatcher-factory.d.ts.map +1 -1
- package/dist/mcp/dispatcher-factory.js +41 -3
- package/dist/mcp/dispatcher-factory.js.map +1 -1
- package/dist/mcp/tools.d.ts +23 -4
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +163 -16
- package/dist/mcp/tools.js.map +1 -1
- package/dist/observability/index.d.ts +16 -3
- package/dist/observability/index.d.ts.map +1 -1
- package/dist/observability/index.js +23 -7
- package/dist/observability/index.js.map +1 -1
- package/dist/observability/spans.d.ts.map +1 -1
- package/dist/observability/spans.js +4 -1
- package/dist/observability/spans.js.map +1 -1
- package/dist/quota.d.ts +88 -22
- package/dist/quota.d.ts.map +1 -1
- package/dist/quota.js +163 -58
- package/dist/quota.js.map +1 -1
- package/dist/route-policy.d.ts.map +1 -1
- package/dist/route-policy.js +13 -3
- package/dist/route-policy.js.map +1 -1
- package/dist/router.d.ts +25 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +94 -40
- package/dist/router.js.map +1 -1
- package/dist/safety.d.ts.map +1 -1
- package/dist/safety.js +11 -2
- package/dist/safety.js.map +1 -1
- package/dist/state-dir.d.ts +17 -0
- package/dist/state-dir.d.ts.map +1 -0
- package/dist/state-dir.js +21 -0
- package/dist/state-dir.js.map +1 -0
- package/dist/status.d.ts +36 -0
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +61 -4
- package/dist/status.js.map +1 -1
- package/dist/types.d.ts +50 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/working-dir.d.ts +13 -0
- package/dist/working-dir.d.ts.map +1 -1
- package/dist/working-dir.js +27 -0
- package/dist/working-dir.js.map +1 -1
- package/dist/workspace-lock.d.ts +40 -0
- package/dist/workspace-lock.d.ts.map +1 -0
- package/dist/workspace-lock.js +258 -0
- package/dist/workspace-lock.js.map +1 -0
- package/dist/workspaces.d.ts +15 -0
- package/dist/workspaces.d.ts.map +1 -1
- package/dist/workspaces.js +104 -12
- package/dist/workspaces.js.map +1 -1
- package/package.json +98 -96
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/http/server.ts"],"names":[],"mappings":"AAQA,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,SAAS,EACf,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/http/server.ts"],"names":[],"mappings":"AAQA,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,SAAS,EACf,MAAM,kBAAkB,CAAC;AAU1B,MAAM,WAAW,gBAAiB,SAAQ,SAAS;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,gBAAiB,SAAQ,eAAe;IACvD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAiDD;;;;;;;;GAQG;AACH,qBAAa,eAAgB,SAAQ,KAAK;CAAG;AA0gB7C,wBAAsB,eAAe,CAAC,IAAI,GAAE,gBAAqB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAgN5F;AAED,eAAO,MAAM,kBAAkB,wBAAkB,CAAC"}
|
package/dist/http/server.js
CHANGED
|
@@ -6,7 +6,8 @@ import { buildMcpServer, buildMcpServerInstance, } from "../mcp/server.js";
|
|
|
6
6
|
import { buildStatus, buildUsage } from "../status.js";
|
|
7
7
|
import { evaluateRoutePolicy } from "../route-policy.js";
|
|
8
8
|
import { isIsolatedWorkspacePolicy } from "../workspaces.js";
|
|
9
|
-
import { resolveWorkingDir, workingDirWarning } from "../working-dir.js";
|
|
9
|
+
import { resolveWorkingDir, validateWorkingDir, workingDirWarning } from "../working-dir.js";
|
|
10
|
+
import { getAsyncJob, startAsyncJobTracked } from "../jobs.js";
|
|
10
11
|
function sendJson(res, statusCode, body) {
|
|
11
12
|
const text = JSON.stringify(body, null, 2);
|
|
12
13
|
res.writeHead(statusCode, {
|
|
@@ -31,6 +32,19 @@ function isLoopbackHost(host) {
|
|
|
31
32
|
// any authorized (or, if --host is opened beyond loopback, network-adjacent)
|
|
32
33
|
// caller exhaust process memory with one oversized POST.
|
|
33
34
|
const MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024;
|
|
35
|
+
/**
|
|
36
|
+
* A request the CALLER can fix — malformed JSON, a missing required field, a
|
|
37
|
+
* working directory that does not exist.
|
|
38
|
+
*
|
|
39
|
+
* Everything except the 413 was returned as 500. PRODUCT.md names CI and cron
|
|
40
|
+
* as consumers of this surface, and retry-on-5xx will happily retry a request
|
|
41
|
+
* that can never succeed. A 4xx says "stop and fix the request", which is the
|
|
42
|
+
* true statement.
|
|
43
|
+
*/
|
|
44
|
+
export class BadRequestError extends Error {
|
|
45
|
+
}
|
|
46
|
+
/** Mirrors MAX_CONTEXT_FILES in mcp/tools.ts — the two surfaces must agree. */
|
|
47
|
+
const MAX_CONTEXT_FILES_HTTP = 64;
|
|
34
48
|
class PayloadTooLargeError extends Error {
|
|
35
49
|
}
|
|
36
50
|
async function readJson(req, maxBytes = MAX_REQUEST_BODY_BYTES) {
|
|
@@ -49,7 +63,14 @@ async function readJson(req, maxBytes = MAX_REQUEST_BODY_BYTES) {
|
|
|
49
63
|
chunks.push(buf);
|
|
50
64
|
}
|
|
51
65
|
const text = Buffer.concat(chunks).toString("utf-8");
|
|
52
|
-
|
|
66
|
+
if (!text.trim())
|
|
67
|
+
return {};
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(text);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
throw new BadRequestError(`request body is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
73
|
+
}
|
|
53
74
|
}
|
|
54
75
|
function contentToText(content) {
|
|
55
76
|
if (typeof content === "string")
|
|
@@ -115,6 +136,37 @@ function parseHints(body) {
|
|
|
115
136
|
raw.workspacePolicy === "git_worktree") {
|
|
116
137
|
hints.workspacePolicy = raw.workspacePolicy;
|
|
117
138
|
}
|
|
139
|
+
// routePolicy was never read here at all. evaluateRoutePolicy implements
|
|
140
|
+
// local_only, approval_required and blocked in full, and on this surface
|
|
141
|
+
// they were wired to nothing: POST {"hints":{"routePolicy":"blocked"}}
|
|
142
|
+
// returned 200 and dispatched. PRODUCT.md names CI and cron as this
|
|
143
|
+
// surface's consumers, and calls a guarantee that reads correctly and does
|
|
144
|
+
// nothing at runtime its own counter-signal.
|
|
145
|
+
if (raw.routePolicy === "local_only" ||
|
|
146
|
+
raw.routePolicy === "approval_required" ||
|
|
147
|
+
raw.routePolicy === "blocked") {
|
|
148
|
+
hints.routePolicy = raw.routePolicy;
|
|
149
|
+
}
|
|
150
|
+
if (typeof raw.timeoutMs === "number" && Number.isFinite(raw.timeoutMs)) {
|
|
151
|
+
hints.timeoutMs = raw.timeoutMs;
|
|
152
|
+
}
|
|
153
|
+
// Unknown keys are REJECTED, matching the MCP surface. The whole point of
|
|
154
|
+
// making hints strict there was that `safety_profile` (the config
|
|
155
|
+
// spelling) silently disabled a safety limit; accepting it here left the
|
|
156
|
+
// identical typo failing open on the other surface.
|
|
157
|
+
const known = new Set([
|
|
158
|
+
"model",
|
|
159
|
+
"taskType",
|
|
160
|
+
"preferLargeContext",
|
|
161
|
+
"safetyProfile",
|
|
162
|
+
"workspacePolicy",
|
|
163
|
+
"routePolicy",
|
|
164
|
+
"timeoutMs",
|
|
165
|
+
]);
|
|
166
|
+
const unknown = Object.keys(raw).filter((k) => !known.has(k));
|
|
167
|
+
if (unknown.length > 0) {
|
|
168
|
+
throw new BadRequestError(`unknown hints key(s): ${unknown.join(", ")}. Valid: ${[...known].join(", ")}.`);
|
|
169
|
+
}
|
|
118
170
|
}
|
|
119
171
|
if (body.workspacePolicy === "shared" ||
|
|
120
172
|
body.workspacePolicy === "shared_locked" ||
|
|
@@ -125,15 +177,33 @@ function parseHints(body) {
|
|
|
125
177
|
return hints;
|
|
126
178
|
}
|
|
127
179
|
function parseChatRequest(raw) {
|
|
128
|
-
if (!raw || typeof raw !== "object") {
|
|
129
|
-
|
|
180
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
181
|
+
// Was a plain Error, so `null`, `"hello"` and `42` all returned 500. The
|
|
182
|
+
// BadRequestError mapping was added for JSON PARSE failures only — same
|
|
183
|
+
// fix, one of two paths, which is the pattern this file keeps repeating.
|
|
184
|
+
throw new BadRequestError("request body must be a JSON object");
|
|
130
185
|
}
|
|
131
186
|
const body = raw;
|
|
132
187
|
const prompt = typeof body.prompt === "string" && body.prompt.trim()
|
|
133
188
|
? body.prompt
|
|
134
189
|
: messagesToPrompt(body.messages);
|
|
135
190
|
if (!prompt.trim())
|
|
136
|
-
throw new
|
|
191
|
+
throw new BadRequestError("messages or prompt is required");
|
|
192
|
+
// MCP validates this; HTTP did not, so `workingDir: "Z:/nope"` surfaced as
|
|
193
|
+
// `spawn node.EXE ENOENT` — verbatim the wrong-cause error working-dir.ts
|
|
194
|
+
// exists to prevent, on the surface CI uses.
|
|
195
|
+
const workingDirError = validateWorkingDir(typeof body.workingDir === "string"
|
|
196
|
+
? body.workingDir
|
|
197
|
+
: undefined);
|
|
198
|
+
if (workingDirError !== undefined)
|
|
199
|
+
throw new BadRequestError(workingDirError);
|
|
200
|
+
// Same cap as the MCP surface, and for the same reason: each file's parent
|
|
201
|
+
// directory becomes an --add-dir grant on CLI routes, so an unbounded list
|
|
202
|
+
// is an unbounded set of directories handed to a coding agent. The cap was
|
|
203
|
+
// added at the MCP boundary only; this surface accepted 500.
|
|
204
|
+
if (Array.isArray(body.files) && body.files.length > MAX_CONTEXT_FILES_HTTP) {
|
|
205
|
+
throw new BadRequestError(`files: ${body.files.length} entries exceeds the maximum of ${MAX_CONTEXT_FILES_HTTP}.`);
|
|
206
|
+
}
|
|
137
207
|
const files = Array.isArray(body.files)
|
|
138
208
|
? body.files.filter((v) => typeof v === "string")
|
|
139
209
|
: [];
|
|
@@ -153,6 +223,235 @@ function parseChatRequest(raw) {
|
|
|
153
223
|
hints: parseHints(body),
|
|
154
224
|
};
|
|
155
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Run fanout arms to completion INDEPENDENTLY of one another.
|
|
228
|
+
*
|
|
229
|
+
* Promise.all rejected the whole batch when one arm threw (workspace lock
|
|
230
|
+
* timeout, worktree setup failure), discarding every other arm's completed —
|
|
231
|
+
* and possibly billed — work behind a single 500. Each arm now settles on its
|
|
232
|
+
* own; a thrown arm becomes a failed row naming its route. One row shape for
|
|
233
|
+
* the streaming and non-streaming branches on purpose: they used to differ
|
|
234
|
+
* (streaming omitted success/error), the one-sibling-guarded pattern again.
|
|
235
|
+
*/
|
|
236
|
+
async function runFanoutArms(router, routes, parsed) {
|
|
237
|
+
const settled = await Promise.allSettled(routes.map((route) => router.routeTo(route, parsed.prompt, parsed.files, parsed.workingDir, {
|
|
238
|
+
...(parsed.hints.safetyProfile !== undefined
|
|
239
|
+
? { safetyProfile: parsed.hints.safetyProfile }
|
|
240
|
+
: {}),
|
|
241
|
+
...(parsed.hints.workspacePolicy !== undefined
|
|
242
|
+
? { workspacePolicy: parsed.hints.workspacePolicy }
|
|
243
|
+
: {}),
|
|
244
|
+
...(parsed.hints.taskType !== undefined ? { taskType: parsed.hints.taskType } : {}),
|
|
245
|
+
})));
|
|
246
|
+
return settled.map((s, i) => {
|
|
247
|
+
if (s.status === "fulfilled") {
|
|
248
|
+
const r = s.value.result;
|
|
249
|
+
return {
|
|
250
|
+
route: r.service,
|
|
251
|
+
success: r.success,
|
|
252
|
+
output: r.output,
|
|
253
|
+
...(r.error !== undefined ? { error: r.error } : {}),
|
|
254
|
+
...(r.workspace !== undefined ? { workspace: r.workspace } : {}),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
route: routes[i],
|
|
259
|
+
success: false,
|
|
260
|
+
output: "",
|
|
261
|
+
error: s.reason instanceof Error ? s.reason.message : String(s.reason),
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* POST /v1/chat/completions — extracted from the request callback, where
|
|
267
|
+
* this logic sat eleven brace-levels deep (the smell checker's worst
|
|
268
|
+
* finding for the whole repo). Behaviour is unchanged; only the nesting
|
|
269
|
+
* moved.
|
|
270
|
+
*/
|
|
271
|
+
async function handleChatCompletions(holder, parsed, res, sse) {
|
|
272
|
+
const state = holder.state;
|
|
273
|
+
if (parsed.mode === "fanout") {
|
|
274
|
+
const fanoutSafetyProfile = parsed.hints.safetyProfile ?? "read_only";
|
|
275
|
+
if (fanoutSafetyProfile !== "read_only" &&
|
|
276
|
+
(parsed.hints.workspacePolicy === undefined ||
|
|
277
|
+
!isIsolatedWorkspacePolicy(parsed.hints.workspacePolicy))) {
|
|
278
|
+
sendJson(res, 400, {
|
|
279
|
+
error: {
|
|
280
|
+
message: "write-capable fanout requires workspacePolicy=copy or workspacePolicy=git_worktree; use read_only fanout or run single-route workspace_edit",
|
|
281
|
+
code: "workspace_isolation_required",
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
parsed.hints.safetyProfile = fanoutSafetyProfile;
|
|
287
|
+
}
|
|
288
|
+
const eligibleRoutes = (requestedRoutes) => {
|
|
289
|
+
const routes = [];
|
|
290
|
+
const skippedRoutes = [];
|
|
291
|
+
for (const route of requestedRoutes) {
|
|
292
|
+
const svc = state.config.services[route];
|
|
293
|
+
if (!svc) {
|
|
294
|
+
// The MCP tool rejects unknown fanout targets by name; this
|
|
295
|
+
// surface silently skipped them, returning 200 with fewer arms
|
|
296
|
+
// and an empty skippedRoutes. Same input, two answers.
|
|
297
|
+
throw new BadRequestError(`Unknown fanout target: ${route}. Valid route ids: ` +
|
|
298
|
+
`${Object.keys(state.config.services).join(", ")}.`);
|
|
299
|
+
}
|
|
300
|
+
const dispatcher = state.dispatchers[route];
|
|
301
|
+
const breaker = state.router.getBreaker(route);
|
|
302
|
+
const policy = evaluateRoutePolicy(route, svc, {
|
|
303
|
+
...(dispatcher !== undefined ? { dispatcher } : {}),
|
|
304
|
+
circuitBroken: Boolean(breaker?.isTripped),
|
|
305
|
+
...(parsed.hints.safetyProfile !== undefined
|
|
306
|
+
? { requestedSafetyProfile: parsed.hints.safetyProfile }
|
|
307
|
+
: {}),
|
|
308
|
+
});
|
|
309
|
+
if (policy.skipped)
|
|
310
|
+
skippedRoutes.push(policy.skipped);
|
|
311
|
+
if (!policy.blocked)
|
|
312
|
+
routes.push(route);
|
|
313
|
+
}
|
|
314
|
+
return { routes, skippedRoutes };
|
|
315
|
+
};
|
|
316
|
+
if (parsed.stream) {
|
|
317
|
+
// Resolve fanout targets BEFORE writing SSE headers.
|
|
318
|
+
//
|
|
319
|
+
// eligibleRoutes throws BadRequestError for an unknown route, but it
|
|
320
|
+
// was called after writeHead — so headersSent was true, the error
|
|
321
|
+
// handler could only res.end(), and the caller got HTTP 200 with a
|
|
322
|
+
// zero-byte body. The non-streaming path returns a proper 400 with
|
|
323
|
+
// the valid ids. Same input, two answers, again.
|
|
324
|
+
// Default to every dispatchable route when `models` is omitted,
|
|
325
|
+
// exactly as the non-streaming branch does. Streaming passed
|
|
326
|
+
// parsed.models straight through, so {"mode":"fanout","stream":true}
|
|
327
|
+
// with no models fanned out to ZERO routes and reported success —
|
|
328
|
+
// content "[]", empty skippedRoutes, HTTP 200.
|
|
329
|
+
const preSelected = parsed.mode === "fanout"
|
|
330
|
+
? eligibleRoutes(parsed.models.length > 0
|
|
331
|
+
? parsed.models
|
|
332
|
+
: Object.keys(state.config.services).filter((route) => route in state.dispatchers))
|
|
333
|
+
: undefined;
|
|
334
|
+
res.writeHead(200, {
|
|
335
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
336
|
+
"cache-control": "no-cache",
|
|
337
|
+
connection: "keep-alive",
|
|
338
|
+
});
|
|
339
|
+
sse.started = true;
|
|
340
|
+
if (parsed.mode === "fanout") {
|
|
341
|
+
const selected = preSelected;
|
|
342
|
+
const rows = await runFanoutArms(state.router, selected.routes, parsed);
|
|
343
|
+
writeSse(res, {
|
|
344
|
+
choices: [
|
|
345
|
+
{
|
|
346
|
+
index: 0,
|
|
347
|
+
delta: {
|
|
348
|
+
content: JSON.stringify(rows),
|
|
349
|
+
},
|
|
350
|
+
finish_reason: null,
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
harness_dispatch: {
|
|
354
|
+
mode: "fanout",
|
|
355
|
+
skippedRoutes: selected.skippedRoutes,
|
|
356
|
+
...(parsed.workingDirWarning !== undefined
|
|
357
|
+
? { warning: parsed.workingDirWarning }
|
|
358
|
+
: {}),
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
for await (const { event, decision } of state.router.stream(parsed.prompt, parsed.files, parsed.workingDir, { hints: parsed.hints, maxFallbacks: 2 })) {
|
|
364
|
+
if (event.type === "stdout") {
|
|
365
|
+
writeSse(res, {
|
|
366
|
+
choices: [
|
|
367
|
+
{
|
|
368
|
+
index: 0,
|
|
369
|
+
delta: { content: event.chunk },
|
|
370
|
+
finish_reason: null,
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
harness_dispatch: decision ? { route: decision.service } : undefined,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
else if (event.type === "completion" && !event.result.success) {
|
|
377
|
+
writeSse(res, {
|
|
378
|
+
error: {
|
|
379
|
+
message: event.result.error ?? "routing failed",
|
|
380
|
+
route: event.result.service,
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
writeSse(res, {
|
|
387
|
+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
388
|
+
});
|
|
389
|
+
res.write("data: [DONE]\n\n");
|
|
390
|
+
res.end();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (parsed.mode === "fanout") {
|
|
394
|
+
const routes = parsed.models.length > 0
|
|
395
|
+
? parsed.models
|
|
396
|
+
: Object.keys(state.config.services).filter((route) => route in state.dispatchers);
|
|
397
|
+
const selected = eligibleRoutes(routes);
|
|
398
|
+
const rows = await runFanoutArms(state.router, selected.routes, parsed);
|
|
399
|
+
sendJson(res, 200, completionEnvelope(JSON.stringify(rows, null, 2), typeof parsed.hints.model === "string" ? parsed.hints.model : "harness-dispatch", {
|
|
400
|
+
harness_dispatch: {
|
|
401
|
+
mode: "fanout",
|
|
402
|
+
skippedRoutes: selected.skippedRoutes,
|
|
403
|
+
...(parsed.workingDirWarning !== undefined
|
|
404
|
+
? { warning: parsed.workingDirWarning }
|
|
405
|
+
: {}),
|
|
406
|
+
},
|
|
407
|
+
}));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
// Backed by a persisted job, not a bare in-process await. This
|
|
411
|
+
// surface's users are curl/CI/cron — exactly the clients that enforce
|
|
412
|
+
// their own request timeouts — and a direct await meant a client that
|
|
413
|
+
// gave up mid-run lost the finished result with no way to retrieve
|
|
414
|
+
// it. The job survives (it runs detached and lands on disk); the
|
|
415
|
+
// jobId is exposed in the response AND in a header so even a caller
|
|
416
|
+
// that only captured headers before timing out can recover the
|
|
417
|
+
// result via `job_status`.
|
|
418
|
+
const { status: jobStatus, completion } = await startAsyncJobTracked({ holder }, {
|
|
419
|
+
prompt: parsed.prompt,
|
|
420
|
+
files: parsed.files,
|
|
421
|
+
workingDir: parsed.workingDir,
|
|
422
|
+
hints: parsed.hints,
|
|
423
|
+
});
|
|
424
|
+
res.setHeader("x-harness-dispatch-job-id", jobStatus.jobId);
|
|
425
|
+
await completion;
|
|
426
|
+
const job = await getAsyncJob(jobStatus.jobId);
|
|
427
|
+
const result = job.result?.result;
|
|
428
|
+
const decision = job.result?.decision ?? undefined;
|
|
429
|
+
if (result === undefined) {
|
|
430
|
+
// Terminal without a result payload: the runner died or was
|
|
431
|
+
// orphaned. The job dir still names what happened.
|
|
432
|
+
sendJson(res, 500, {
|
|
433
|
+
error: `job ${jobStatus.jobId} ended without a result (status: ${job.status.status}); ` +
|
|
434
|
+
`check job_status for details`,
|
|
435
|
+
jobId: jobStatus.jobId,
|
|
436
|
+
});
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
sendJson(res, 200, completionEnvelope(result.output, decision?.model ?? parsed.hints.model ?? "harness-dispatch", {
|
|
440
|
+
harness_dispatch: {
|
|
441
|
+
jobId: jobStatus.jobId,
|
|
442
|
+
route: result.service,
|
|
443
|
+
success: result.success,
|
|
444
|
+
error: result.error,
|
|
445
|
+
workspace: result.workspace,
|
|
446
|
+
routing: decision,
|
|
447
|
+
skippedRoutes: decision?.skippedRoutes ?? result.skippedRoutes,
|
|
448
|
+
...(parsed.workingDirWarning !== undefined
|
|
449
|
+
? { warning: parsed.workingDirWarning }
|
|
450
|
+
: {}),
|
|
451
|
+
},
|
|
452
|
+
}));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
156
455
|
function completionEnvelope(content, model, extra) {
|
|
157
456
|
return {
|
|
158
457
|
id: `chatcmpl-${randomUUID()}`,
|
|
@@ -190,9 +489,16 @@ export async function startHttpServer(opts = {}) {
|
|
|
190
489
|
sendJson(res, 401, { error: "unauthorized" });
|
|
191
490
|
return false;
|
|
192
491
|
};
|
|
492
|
+
// A bare IPv6 host must be bracketed to be legal inside a URL:
|
|
493
|
+
// new URL("/mcp", "http://::1") throws "Invalid URL", which turned EVERY
|
|
494
|
+
// request to a server bound on ::1 into a 500 — the bind succeeded and the
|
|
495
|
+
// loopback check blessed the address, so nothing else ever caught it.
|
|
496
|
+
const urlBase = `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}`;
|
|
193
497
|
const http = createServer(async (req, res) => {
|
|
498
|
+
// Shared with handleChatCompletions, read by the catch below.
|
|
499
|
+
const sse = { started: false };
|
|
194
500
|
try {
|
|
195
|
-
const url = new URL(req.url ?? "/",
|
|
501
|
+
const url = new URL(req.url ?? "/", urlBase);
|
|
196
502
|
if (url.pathname === "/v1/models" && req.method === "GET") {
|
|
197
503
|
if (!requireAuth(req, res))
|
|
198
504
|
return;
|
|
@@ -244,161 +550,7 @@ export async function startHttpServer(opts = {}) {
|
|
|
244
550
|
return;
|
|
245
551
|
await reloader.maybeReload();
|
|
246
552
|
const parsed = parseChatRequest(await readJson(req));
|
|
247
|
-
|
|
248
|
-
if (parsed.mode === "fanout") {
|
|
249
|
-
const fanoutSafetyProfile = parsed.hints.safetyProfile ?? "read_only";
|
|
250
|
-
if (fanoutSafetyProfile !== "read_only" &&
|
|
251
|
-
(parsed.hints.workspacePolicy === undefined ||
|
|
252
|
-
!isIsolatedWorkspacePolicy(parsed.hints.workspacePolicy))) {
|
|
253
|
-
sendJson(res, 400, {
|
|
254
|
-
error: {
|
|
255
|
-
message: "write-capable fanout requires workspacePolicy=copy or workspacePolicy=git_worktree; use read_only fanout or run single-route workspace_edit",
|
|
256
|
-
code: "workspace_isolation_required",
|
|
257
|
-
},
|
|
258
|
-
});
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
parsed.hints.safetyProfile = fanoutSafetyProfile;
|
|
262
|
-
}
|
|
263
|
-
const eligibleRoutes = (requestedRoutes) => {
|
|
264
|
-
const routes = [];
|
|
265
|
-
const skippedRoutes = [];
|
|
266
|
-
for (const route of requestedRoutes) {
|
|
267
|
-
const svc = state.config.services[route];
|
|
268
|
-
if (!svc)
|
|
269
|
-
continue;
|
|
270
|
-
const dispatcher = state.dispatchers[route];
|
|
271
|
-
const breaker = state.router.getBreaker(route);
|
|
272
|
-
const policy = evaluateRoutePolicy(route, svc, {
|
|
273
|
-
...(dispatcher !== undefined ? { dispatcher } : {}),
|
|
274
|
-
circuitBroken: Boolean(breaker?.isTripped),
|
|
275
|
-
...(parsed.hints.safetyProfile !== undefined
|
|
276
|
-
? { requestedSafetyProfile: parsed.hints.safetyProfile }
|
|
277
|
-
: {}),
|
|
278
|
-
});
|
|
279
|
-
if (policy.skipped)
|
|
280
|
-
skippedRoutes.push(policy.skipped);
|
|
281
|
-
if (!policy.blocked)
|
|
282
|
-
routes.push(route);
|
|
283
|
-
}
|
|
284
|
-
return { routes, skippedRoutes };
|
|
285
|
-
};
|
|
286
|
-
if (parsed.stream) {
|
|
287
|
-
res.writeHead(200, {
|
|
288
|
-
"content-type": "text/event-stream; charset=utf-8",
|
|
289
|
-
"cache-control": "no-cache",
|
|
290
|
-
connection: "keep-alive",
|
|
291
|
-
});
|
|
292
|
-
if (parsed.mode === "fanout") {
|
|
293
|
-
const selected = eligibleRoutes(parsed.models);
|
|
294
|
-
const results = await Promise.all(selected.routes.map((route) => state.router.routeTo(route, parsed.prompt, parsed.files, parsed.workingDir, {
|
|
295
|
-
...(parsed.hints.safetyProfile !== undefined
|
|
296
|
-
? { safetyProfile: parsed.hints.safetyProfile }
|
|
297
|
-
: {}),
|
|
298
|
-
...(parsed.hints.workspacePolicy !== undefined
|
|
299
|
-
? { workspacePolicy: parsed.hints.workspacePolicy }
|
|
300
|
-
: {}),
|
|
301
|
-
...(parsed.hints.taskType !== undefined ? { taskType: parsed.hints.taskType } : {}),
|
|
302
|
-
})));
|
|
303
|
-
writeSse(res, {
|
|
304
|
-
choices: [
|
|
305
|
-
{
|
|
306
|
-
index: 0,
|
|
307
|
-
delta: {
|
|
308
|
-
content: JSON.stringify(results.map((r) => ({
|
|
309
|
-
route: r.result.service,
|
|
310
|
-
output: r.result.output,
|
|
311
|
-
workspace: r.result.workspace,
|
|
312
|
-
}))),
|
|
313
|
-
},
|
|
314
|
-
finish_reason: null,
|
|
315
|
-
},
|
|
316
|
-
],
|
|
317
|
-
harness_dispatch: {
|
|
318
|
-
mode: "fanout",
|
|
319
|
-
skippedRoutes: selected.skippedRoutes,
|
|
320
|
-
...(parsed.workingDirWarning !== undefined
|
|
321
|
-
? { warning: parsed.workingDirWarning }
|
|
322
|
-
: {}),
|
|
323
|
-
},
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
else {
|
|
327
|
-
for await (const { event, decision } of state.router.stream(parsed.prompt, parsed.files, parsed.workingDir, { hints: parsed.hints, maxFallbacks: 2 })) {
|
|
328
|
-
if (event.type === "stdout") {
|
|
329
|
-
writeSse(res, {
|
|
330
|
-
choices: [
|
|
331
|
-
{
|
|
332
|
-
index: 0,
|
|
333
|
-
delta: { content: event.chunk },
|
|
334
|
-
finish_reason: null,
|
|
335
|
-
},
|
|
336
|
-
],
|
|
337
|
-
harness_dispatch: decision ? { route: decision.service } : undefined,
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
else if (event.type === "completion" && !event.result.success) {
|
|
341
|
-
writeSse(res, {
|
|
342
|
-
error: {
|
|
343
|
-
message: event.result.error ?? "routing failed",
|
|
344
|
-
route: event.result.service,
|
|
345
|
-
},
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
writeSse(res, {
|
|
351
|
-
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
352
|
-
});
|
|
353
|
-
res.write("data: [DONE]\n\n");
|
|
354
|
-
res.end();
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
if (parsed.mode === "fanout") {
|
|
358
|
-
const routes = parsed.models.length > 0
|
|
359
|
-
? parsed.models
|
|
360
|
-
: Object.keys(state.config.services).filter((route) => route in state.dispatchers);
|
|
361
|
-
const selected = eligibleRoutes(routes);
|
|
362
|
-
const results = await Promise.all(selected.routes.map((route) => state.router.routeTo(route, parsed.prompt, parsed.files, parsed.workingDir, {
|
|
363
|
-
...(parsed.hints.safetyProfile !== undefined
|
|
364
|
-
? { safetyProfile: parsed.hints.safetyProfile }
|
|
365
|
-
: {}),
|
|
366
|
-
...(parsed.hints.workspacePolicy !== undefined
|
|
367
|
-
? { workspacePolicy: parsed.hints.workspacePolicy }
|
|
368
|
-
: {}),
|
|
369
|
-
...(parsed.hints.taskType !== undefined ? { taskType: parsed.hints.taskType } : {}),
|
|
370
|
-
})));
|
|
371
|
-
sendJson(res, 200, completionEnvelope(JSON.stringify(results.map((r) => ({
|
|
372
|
-
route: r.result.service,
|
|
373
|
-
success: r.result.success,
|
|
374
|
-
output: r.result.output,
|
|
375
|
-
error: r.result.error,
|
|
376
|
-
workspace: r.result.workspace,
|
|
377
|
-
})), null, 2), typeof parsed.hints.model === "string" ? parsed.hints.model : "harness-dispatch", {
|
|
378
|
-
harness_dispatch: {
|
|
379
|
-
mode: "fanout",
|
|
380
|
-
skippedRoutes: selected.skippedRoutes,
|
|
381
|
-
...(parsed.workingDirWarning !== undefined
|
|
382
|
-
? { warning: parsed.workingDirWarning }
|
|
383
|
-
: {}),
|
|
384
|
-
},
|
|
385
|
-
}));
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
const { result, decision } = await state.router.route(parsed.prompt, parsed.files, parsed.workingDir, { hints: parsed.hints, maxFallbacks: 2 });
|
|
389
|
-
sendJson(res, 200, completionEnvelope(result.output, decision?.model ?? parsed.hints.model ?? "harness-dispatch", {
|
|
390
|
-
harness_dispatch: {
|
|
391
|
-
route: result.service,
|
|
392
|
-
success: result.success,
|
|
393
|
-
error: result.error,
|
|
394
|
-
workspace: result.workspace,
|
|
395
|
-
routing: decision,
|
|
396
|
-
skippedRoutes: decision?.skippedRoutes ?? result.skippedRoutes,
|
|
397
|
-
...(parsed.workingDirWarning !== undefined
|
|
398
|
-
? { warning: parsed.workingDirWarning }
|
|
399
|
-
: {}),
|
|
400
|
-
},
|
|
401
|
-
}));
|
|
553
|
+
await handleChatCompletions(holder, parsed, res, sse);
|
|
402
554
|
return;
|
|
403
555
|
}
|
|
404
556
|
if (url.pathname === mcpRoute) {
|
|
@@ -439,10 +591,27 @@ export async function startHttpServer(opts = {}) {
|
|
|
439
591
|
sendText(res, 404, "not found");
|
|
440
592
|
}
|
|
441
593
|
catch (err) {
|
|
442
|
-
if (
|
|
594
|
+
if (sse.started) {
|
|
595
|
+
// Mid-stream failure: the client already holds a 200 and possibly
|
|
596
|
+
// partial frames, and a bare end() made a truncated stream
|
|
597
|
+
// indistinguishable from a complete one. Emit an error frame and the
|
|
598
|
+
// stream terminator so the caller can tell.
|
|
599
|
+
try {
|
|
600
|
+
writeSse(res, { error: { message: err instanceof Error ? err.message : String(err) } });
|
|
601
|
+
res.write("data: [DONE]\n\n");
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
// Socket already gone; nothing left to tell it.
|
|
605
|
+
}
|
|
606
|
+
res.end();
|
|
607
|
+
}
|
|
608
|
+
else if (!res.headersSent) {
|
|
443
609
|
if (err instanceof PayloadTooLargeError) {
|
|
444
610
|
sendJson(res, 413, { error: err.message });
|
|
445
611
|
}
|
|
612
|
+
else if (err instanceof BadRequestError) {
|
|
613
|
+
sendJson(res, 400, { error: err.message });
|
|
614
|
+
}
|
|
446
615
|
else {
|
|
447
616
|
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
448
617
|
}
|