harness-dispatch 0.4.0 → 0.6.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 +152 -0
- package/README.md +232 -87
- 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 +147 -91
- 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/route-fields.d.ts +72 -0
- package/dist/config/route-fields.d.ts.map +1 -0
- package/dist/config/route-fields.js +128 -0
- package/dist/config/route-fields.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 +214 -635
- package/dist/config.js.map +1 -1
- package/dist/configure-yaml.d.ts +23 -0
- package/dist/configure-yaml.d.ts.map +1 -0
- package/dist/configure-yaml.js +193 -0
- package/dist/configure-yaml.js.map +1 -0
- 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/base.d.ts +3 -0
- package/dist/dispatchers/base.d.ts.map +1 -1
- package/dist/dispatchers/base.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 +69 -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 +70 -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 +11 -0
- package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
- package/dist/dispatchers/shared/stream-subprocess.js +62 -35
- 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/parse.d.ts +64 -0
- package/dist/http/parse.d.ts.map +1 -0
- package/dist/http/parse.js +230 -0
- package/dist/http/parse.js.map +1 -0
- package/dist/http/server.d.ts +2 -0
- package/dist/http/server.d.ts.map +1 -1
- package/dist/http/server.js +249 -286
- package/dist/http/server.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/job-runner.js +23 -5
- package/dist/job-runner.js.map +1 -1
- package/dist/jobs/context.d.ts +17 -0
- package/dist/jobs/context.d.ts.map +1 -0
- package/dist/jobs/context.js +84 -0
- package/dist/jobs/context.js.map +1 -0
- package/dist/jobs/store.d.ts +80 -0
- package/dist/jobs/store.d.ts.map +1 -0
- package/dist/jobs/store.js +279 -0
- package/dist/jobs/store.js.map +1 -0
- package/dist/jobs/types.d.ts +135 -0
- package/dist/jobs/types.d.ts.map +1 -0
- package/dist/jobs/types.js +11 -0
- package/dist/jobs/types.js.map +1 -0
- package/dist/jobs.d.ts +165 -85
- package/dist/jobs.d.ts.map +1 -1
- package/dist/jobs.js +824 -208
- 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/tool-schemas.d.ts +194 -0
- package/dist/mcp/tool-schemas.d.ts.map +1 -0
- package/dist/mcp/tool-schemas.js +311 -0
- package/dist/mcp/tool-schemas.js.map +1 -0
- package/dist/mcp/tools.d.ts +26 -51
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +122 -151
- 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 +106 -22
- package/dist/quota.d.ts.map +1 -1
- package/dist/quota.js +198 -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 +29 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +102 -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 +49 -0
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +67 -4
- package/dist/status.js.map +1 -1
- package/dist/types.d.ts +74 -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/workspace-resolve.d.ts +98 -0
- package/dist/workspace-resolve.d.ts.map +1 -0
- package/dist/workspace-resolve.js +382 -0
- package/dist/workspace-resolve.js.map +1 -0
- package/dist/workspaces.d.ts +16 -0
- package/dist/workspaces.d.ts.map +1 -1
- package/dist/workspaces.js +166 -22
- package/dist/workspaces.js.map +1 -1
- package/package.json +99 -96
- package/dist/dashboard/live.d.ts +0 -49
- package/dist/dashboard/live.d.ts.map +0 -1
- package/dist/dashboard/live.js +0 -149
- package/dist/dashboard/live.js.map +0 -1
package/dist/http/server.js
CHANGED
|
@@ -6,7 +6,12 @@ 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 {
|
|
9
|
+
import { getAsyncJob, startAsyncJobTracked } from "../jobs.js";
|
|
10
|
+
import { BadRequestError, completionEnvelope, parseChatRequest, PayloadTooLargeError, readJson, } from "./parse.js";
|
|
11
|
+
// Re-exported: BadRequestError is part of this module's public surface
|
|
12
|
+
// (tests and the CLI import it from here) and moving its definition should
|
|
13
|
+
// not move where callers get it from.
|
|
14
|
+
export { BadRequestError };
|
|
10
15
|
function sendJson(res, statusCode, body) {
|
|
11
16
|
const text = JSON.stringify(body, null, 2);
|
|
12
17
|
res.writeHead(statusCode, {
|
|
@@ -27,148 +32,236 @@ function isLoopbackHost(host) {
|
|
|
27
32
|
// included — it means "bind all interfaces," the opposite of loopback.
|
|
28
33
|
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
29
34
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Run fanout arms to completion INDEPENDENTLY of one another.
|
|
37
|
+
*
|
|
38
|
+
* Promise.all rejected the whole batch when one arm threw (workspace lock
|
|
39
|
+
* timeout, worktree setup failure), discarding every other arm's completed —
|
|
40
|
+
* and possibly billed — work behind a single 500. Each arm now settles on its
|
|
41
|
+
* own; a thrown arm becomes a failed row naming its route. One row shape for
|
|
42
|
+
* the streaming and non-streaming branches on purpose: they used to differ
|
|
43
|
+
* (streaming omitted success/error), the one-sibling-guarded pattern again.
|
|
44
|
+
*/
|
|
45
|
+
async function runFanoutArms(router, routes, parsed) {
|
|
46
|
+
const settled = await Promise.allSettled(routes.map((route) => router.routeTo(route, parsed.prompt, parsed.files, parsed.workingDir, {
|
|
47
|
+
...(parsed.hints.safetyProfile !== undefined
|
|
48
|
+
? { safetyProfile: parsed.hints.safetyProfile }
|
|
49
|
+
: {}),
|
|
50
|
+
...(parsed.hints.workspacePolicy !== undefined
|
|
51
|
+
? { workspacePolicy: parsed.hints.workspacePolicy }
|
|
52
|
+
: {}),
|
|
53
|
+
...(parsed.hints.taskType !== undefined ? { taskType: parsed.hints.taskType } : {}),
|
|
54
|
+
})));
|
|
55
|
+
return settled.map((s, i) => {
|
|
56
|
+
if (s.status === "fulfilled") {
|
|
57
|
+
const r = s.value.result;
|
|
58
|
+
return {
|
|
59
|
+
route: r.service,
|
|
60
|
+
success: r.success,
|
|
61
|
+
output: r.output,
|
|
62
|
+
...(r.error !== undefined ? { error: r.error } : {}),
|
|
63
|
+
...(r.workspace !== undefined ? { workspace: r.workspace } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
route: routes[i],
|
|
68
|
+
success: false,
|
|
69
|
+
output: "",
|
|
70
|
+
error: s.reason instanceof Error ? s.reason.message : String(s.reason),
|
|
71
|
+
};
|
|
72
|
+
});
|
|
35
73
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
74
|
+
/**
|
|
75
|
+
* POST /v1/chat/completions — extracted from the request callback, where
|
|
76
|
+
* this logic sat eleven brace-levels deep (the smell checker's worst
|
|
77
|
+
* finding for the whole repo). Behaviour is unchanged; only the nesting
|
|
78
|
+
* moved.
|
|
79
|
+
*/
|
|
80
|
+
async function handleChatCompletions(holder, parsed, res, sse) {
|
|
81
|
+
const state = holder.state;
|
|
82
|
+
if (parsed.mode === "fanout") {
|
|
83
|
+
const fanoutSafetyProfile = parsed.hints.safetyProfile ?? "read_only";
|
|
84
|
+
if (fanoutSafetyProfile !== "read_only" &&
|
|
85
|
+
(parsed.hints.workspacePolicy === undefined ||
|
|
86
|
+
!isIsolatedWorkspacePolicy(parsed.hints.workspacePolicy))) {
|
|
87
|
+
sendJson(res, 400, {
|
|
88
|
+
error: {
|
|
89
|
+
message: "write-capable fanout requires workspacePolicy=copy or workspacePolicy=git_worktree; use read_only fanout or run single-route workspace_edit",
|
|
90
|
+
code: "workspace_isolation_required",
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
48
94
|
}
|
|
49
|
-
|
|
95
|
+
parsed.hints.safetyProfile = fanoutSafetyProfile;
|
|
50
96
|
}
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (part && typeof part === "object" && "text" in part) {
|
|
63
|
-
const text = part.text;
|
|
64
|
-
return typeof text === "string" ? text : "";
|
|
97
|
+
const eligibleRoutes = (requestedRoutes) => {
|
|
98
|
+
const routes = [];
|
|
99
|
+
const skippedRoutes = [];
|
|
100
|
+
for (const route of requestedRoutes) {
|
|
101
|
+
const svc = state.config.services[route];
|
|
102
|
+
if (!svc) {
|
|
103
|
+
// The MCP tool rejects unknown fanout targets by name; this
|
|
104
|
+
// surface silently skipped them, returning 200 with fewer arms
|
|
105
|
+
// and an empty skippedRoutes. Same input, two answers.
|
|
106
|
+
throw new BadRequestError(`Unknown fanout target: ${route}. Valid route ids: ` +
|
|
107
|
+
`${Object.keys(state.config.services).join(", ")}.`);
|
|
65
108
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const text = contentToText(message.content);
|
|
80
|
-
return text ? `${role}: ${text}` : "";
|
|
81
|
-
})
|
|
82
|
-
.filter(Boolean)
|
|
83
|
-
.join("\n\n");
|
|
84
|
-
}
|
|
85
|
-
function parseHints(body) {
|
|
86
|
-
const hints = {};
|
|
87
|
-
if (typeof body.model === "string" && body.model !== "")
|
|
88
|
-
hints.model = body.model;
|
|
89
|
-
if (body.safetyProfile === "read_only" ||
|
|
90
|
-
body.safetyProfile === "workspace_edit" ||
|
|
91
|
-
body.safetyProfile === "full_auto") {
|
|
92
|
-
hints.safetyProfile = body.safetyProfile;
|
|
93
|
-
}
|
|
94
|
-
if (body.hints && typeof body.hints === "object") {
|
|
95
|
-
const raw = body.hints;
|
|
96
|
-
if (typeof raw.model === "string")
|
|
97
|
-
hints.model = raw.model;
|
|
98
|
-
if (raw.taskType === "execute" ||
|
|
99
|
-
raw.taskType === "plan" ||
|
|
100
|
-
raw.taskType === "review" ||
|
|
101
|
-
raw.taskType === "local") {
|
|
102
|
-
hints.taskType = raw.taskType;
|
|
103
|
-
}
|
|
104
|
-
if (typeof raw.preferLargeContext === "boolean") {
|
|
105
|
-
hints.preferLargeContext = raw.preferLargeContext;
|
|
109
|
+
const dispatcher = state.dispatchers[route];
|
|
110
|
+
const breaker = state.router.getBreaker(route);
|
|
111
|
+
const policy = evaluateRoutePolicy(route, svc, {
|
|
112
|
+
...(dispatcher !== undefined ? { dispatcher } : {}),
|
|
113
|
+
circuitBroken: Boolean(breaker?.isTripped),
|
|
114
|
+
...(parsed.hints.safetyProfile !== undefined
|
|
115
|
+
? { requestedSafetyProfile: parsed.hints.safetyProfile }
|
|
116
|
+
: {}),
|
|
117
|
+
});
|
|
118
|
+
if (policy.skipped)
|
|
119
|
+
skippedRoutes.push(policy.skipped);
|
|
120
|
+
if (!policy.blocked)
|
|
121
|
+
routes.push(route);
|
|
106
122
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
123
|
+
return { routes, skippedRoutes };
|
|
124
|
+
};
|
|
125
|
+
if (parsed.stream) {
|
|
126
|
+
// Resolve fanout targets BEFORE writing SSE headers.
|
|
127
|
+
//
|
|
128
|
+
// eligibleRoutes throws BadRequestError for an unknown route, but it
|
|
129
|
+
// was called after writeHead — so headersSent was true, the error
|
|
130
|
+
// handler could only res.end(), and the caller got HTTP 200 with a
|
|
131
|
+
// zero-byte body. The non-streaming path returns a proper 400 with
|
|
132
|
+
// the valid ids. Same input, two answers, again.
|
|
133
|
+
// Default to every dispatchable route when `models` is omitted,
|
|
134
|
+
// exactly as the non-streaming branch does. Streaming passed
|
|
135
|
+
// parsed.models straight through, so {"mode":"fanout","stream":true}
|
|
136
|
+
// with no models fanned out to ZERO routes and reported success —
|
|
137
|
+
// content "[]", empty skippedRoutes, HTTP 200.
|
|
138
|
+
const preSelected = parsed.mode === "fanout"
|
|
139
|
+
? eligibleRoutes(parsed.models.length > 0
|
|
140
|
+
? parsed.models
|
|
141
|
+
: Object.keys(state.config.services).filter((route) => route in state.dispatchers))
|
|
142
|
+
: undefined;
|
|
143
|
+
res.writeHead(200, {
|
|
144
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
145
|
+
"cache-control": "no-cache",
|
|
146
|
+
connection: "keep-alive",
|
|
147
|
+
});
|
|
148
|
+
sse.started = true;
|
|
149
|
+
if (parsed.mode === "fanout") {
|
|
150
|
+
const selected = preSelected;
|
|
151
|
+
const rows = await runFanoutArms(state.router, selected.routes, parsed);
|
|
152
|
+
writeSse(res, sseContent(JSON.stringify(rows), {
|
|
153
|
+
harness_dispatch: {
|
|
154
|
+
mode: "fanout",
|
|
155
|
+
skippedRoutes: selected.skippedRoutes,
|
|
156
|
+
...(parsed.workingDirWarning !== undefined
|
|
157
|
+
? { warning: parsed.workingDirWarning }
|
|
158
|
+
: {}),
|
|
159
|
+
},
|
|
160
|
+
}));
|
|
111
161
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
162
|
+
else {
|
|
163
|
+
for await (const { event, decision } of state.router.stream(parsed.prompt, parsed.files, parsed.workingDir, { hints: parsed.hints, maxFallbacks: 2 })) {
|
|
164
|
+
if (event.type === "stdout") {
|
|
165
|
+
writeSse(res, sseContent(event.chunk, {
|
|
166
|
+
harness_dispatch: decision ? { route: decision.service } : undefined,
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
else if (event.type === "completion" && !event.result.success) {
|
|
170
|
+
writeSse(res, {
|
|
171
|
+
error: {
|
|
172
|
+
message: event.result.error ?? "routing failed",
|
|
173
|
+
route: event.result.service,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
117
178
|
}
|
|
179
|
+
writeSse(res, sseStop());
|
|
180
|
+
res.write("data: [DONE]\n\n");
|
|
181
|
+
res.end();
|
|
182
|
+
return;
|
|
118
183
|
}
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
184
|
+
if (parsed.mode === "fanout") {
|
|
185
|
+
const routes = parsed.models.length > 0
|
|
186
|
+
? parsed.models
|
|
187
|
+
: Object.keys(state.config.services).filter((route) => route in state.dispatchers);
|
|
188
|
+
const selected = eligibleRoutes(routes);
|
|
189
|
+
const rows = await runFanoutArms(state.router, selected.routes, parsed);
|
|
190
|
+
sendJson(res, 200, completionEnvelope(JSON.stringify(rows, null, 2), typeof parsed.hints.model === "string" ? parsed.hints.model : "harness-dispatch", {
|
|
191
|
+
harness_dispatch: {
|
|
192
|
+
mode: "fanout",
|
|
193
|
+
skippedRoutes: selected.skippedRoutes,
|
|
194
|
+
...(parsed.workingDirWarning !== undefined
|
|
195
|
+
? { warning: parsed.workingDirWarning }
|
|
196
|
+
: {}),
|
|
197
|
+
},
|
|
198
|
+
}));
|
|
199
|
+
return;
|
|
124
200
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
201
|
+
// Backed by a persisted job, not a bare in-process await. This
|
|
202
|
+
// surface's users are curl/CI/cron — exactly the clients that enforce
|
|
203
|
+
// their own request timeouts — and a direct await meant a client that
|
|
204
|
+
// gave up mid-run lost the finished result with no way to retrieve
|
|
205
|
+
// it. The job survives (it runs detached and lands on disk); the
|
|
206
|
+
// jobId is exposed in the response AND in a header so even a caller
|
|
207
|
+
// that only captured headers before timing out can recover the
|
|
208
|
+
// result via `job_status`.
|
|
209
|
+
const { status: jobStatus, completion } = await startAsyncJobTracked({ holder }, {
|
|
210
|
+
prompt: parsed.prompt,
|
|
211
|
+
files: parsed.files,
|
|
212
|
+
workingDir: parsed.workingDir,
|
|
213
|
+
hints: parsed.hints,
|
|
214
|
+
});
|
|
215
|
+
res.setHeader("x-harness-dispatch-job-id", jobStatus.jobId);
|
|
216
|
+
await completion;
|
|
217
|
+
const job = await getAsyncJob(jobStatus.jobId);
|
|
218
|
+
const result = job.result?.result;
|
|
219
|
+
const decision = job.result?.decision ?? undefined;
|
|
220
|
+
if (result === undefined) {
|
|
221
|
+
// Terminal without a result payload: the runner died or was
|
|
222
|
+
// orphaned. The job dir still names what happened.
|
|
223
|
+
sendJson(res, 500, {
|
|
224
|
+
error: `job ${jobStatus.jobId} ended without a result (status: ${job.status.status}); ` +
|
|
225
|
+
`check job_status for details`,
|
|
226
|
+
jobId: jobStatus.jobId,
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
130
229
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
return
|
|
146
|
-
prompt,
|
|
147
|
-
files,
|
|
148
|
-
workingDir: resolvedWorkingDir.workingDir,
|
|
149
|
-
...(warning !== undefined ? { workingDirWarning: warning } : {}),
|
|
150
|
-
stream: body.stream === true,
|
|
151
|
-
mode: body.mode === "fanout" ? "fanout" : "single",
|
|
152
|
-
models,
|
|
153
|
-
hints: parseHints(body),
|
|
154
|
-
};
|
|
230
|
+
sendJson(res, 200, completionEnvelope(result.output, decision?.model ?? parsed.hints.model ?? "harness-dispatch", {
|
|
231
|
+
harness_dispatch: {
|
|
232
|
+
jobId: jobStatus.jobId,
|
|
233
|
+
route: result.service,
|
|
234
|
+
success: result.success,
|
|
235
|
+
error: result.error,
|
|
236
|
+
workspace: result.workspace,
|
|
237
|
+
routing: decision,
|
|
238
|
+
skippedRoutes: decision?.skippedRoutes ?? result.skippedRoutes,
|
|
239
|
+
...(parsed.workingDirWarning !== undefined
|
|
240
|
+
? { warning: parsed.workingDirWarning }
|
|
241
|
+
: {}),
|
|
242
|
+
},
|
|
243
|
+
}));
|
|
244
|
+
return;
|
|
155
245
|
}
|
|
156
|
-
|
|
246
|
+
/**
|
|
247
|
+
* One SSE content frame.
|
|
248
|
+
*
|
|
249
|
+
* The `choices[0].delta` envelope is what an OpenAI-compatible client parses,
|
|
250
|
+
* and it was written out as a literal at three call sites — which is both how
|
|
251
|
+
* the deepest nesting in this file appeared (a data shape indented under a
|
|
252
|
+
* loop inside a branch) and how the streaming and non-streaming fanout
|
|
253
|
+
* replies drifted apart in the first place. Built in one place now.
|
|
254
|
+
*/
|
|
255
|
+
function sseContent(content, extra) {
|
|
157
256
|
return {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
created: Math.floor(Date.now() / 1000),
|
|
161
|
-
model,
|
|
162
|
-
choices: [
|
|
163
|
-
{
|
|
164
|
-
index: 0,
|
|
165
|
-
message: { role: "assistant", content },
|
|
166
|
-
finish_reason: "stop",
|
|
167
|
-
},
|
|
168
|
-
],
|
|
169
|
-
...extra,
|
|
257
|
+
choices: [{ index: 0, delta: { content }, finish_reason: null }],
|
|
258
|
+
...(extra ?? {}),
|
|
170
259
|
};
|
|
171
260
|
}
|
|
261
|
+
/** The terminal frame every stream ends with, before `[DONE]`. */
|
|
262
|
+
function sseStop() {
|
|
263
|
+
return { choices: [{ index: 0, delta: {}, finish_reason: "stop" }] };
|
|
264
|
+
}
|
|
172
265
|
function writeSse(res, payload) {
|
|
173
266
|
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
174
267
|
}
|
|
@@ -190,9 +283,16 @@ export async function startHttpServer(opts = {}) {
|
|
|
190
283
|
sendJson(res, 401, { error: "unauthorized" });
|
|
191
284
|
return false;
|
|
192
285
|
};
|
|
286
|
+
// A bare IPv6 host must be bracketed to be legal inside a URL:
|
|
287
|
+
// new URL("/mcp", "http://::1") throws "Invalid URL", which turned EVERY
|
|
288
|
+
// request to a server bound on ::1 into a 500 — the bind succeeded and the
|
|
289
|
+
// loopback check blessed the address, so nothing else ever caught it.
|
|
290
|
+
const urlBase = `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}`;
|
|
193
291
|
const http = createServer(async (req, res) => {
|
|
292
|
+
// Shared with handleChatCompletions, read by the catch below.
|
|
293
|
+
const sse = { started: false };
|
|
194
294
|
try {
|
|
195
|
-
const url = new URL(req.url ?? "/",
|
|
295
|
+
const url = new URL(req.url ?? "/", urlBase);
|
|
196
296
|
if (url.pathname === "/v1/models" && req.method === "GET") {
|
|
197
297
|
if (!requireAuth(req, res))
|
|
198
298
|
return;
|
|
@@ -244,161 +344,7 @@ export async function startHttpServer(opts = {}) {
|
|
|
244
344
|
return;
|
|
245
345
|
await reloader.maybeReload();
|
|
246
346
|
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
|
-
}));
|
|
347
|
+
await handleChatCompletions(holder, parsed, res, sse);
|
|
402
348
|
return;
|
|
403
349
|
}
|
|
404
350
|
if (url.pathname === mcpRoute) {
|
|
@@ -439,10 +385,27 @@ export async function startHttpServer(opts = {}) {
|
|
|
439
385
|
sendText(res, 404, "not found");
|
|
440
386
|
}
|
|
441
387
|
catch (err) {
|
|
442
|
-
if (
|
|
388
|
+
if (sse.started) {
|
|
389
|
+
// Mid-stream failure: the client already holds a 200 and possibly
|
|
390
|
+
// partial frames, and a bare end() made a truncated stream
|
|
391
|
+
// indistinguishable from a complete one. Emit an error frame and the
|
|
392
|
+
// stream terminator so the caller can tell.
|
|
393
|
+
try {
|
|
394
|
+
writeSse(res, { error: { message: err instanceof Error ? err.message : String(err) } });
|
|
395
|
+
res.write("data: [DONE]\n\n");
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
// Socket already gone; nothing left to tell it.
|
|
399
|
+
}
|
|
400
|
+
res.end();
|
|
401
|
+
}
|
|
402
|
+
else if (!res.headersSent) {
|
|
443
403
|
if (err instanceof PayloadTooLargeError) {
|
|
444
404
|
sendJson(res, 413, { error: err.message });
|
|
445
405
|
}
|
|
406
|
+
else if (err instanceof BadRequestError) {
|
|
407
|
+
sendJson(res, 400, { error: err.message });
|
|
408
|
+
}
|
|
446
409
|
else {
|
|
447
410
|
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
448
411
|
}
|