@wardby/cli 0.8.0 → 0.9.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/deploy/local/docker-compose.quickstart-coding.yml +5 -0
- package/dist/claude-plugin/command.d.ts +4 -1
- package/dist/claude-plugin/command.js +7 -4
- package/dist/cli.js +24 -0
- package/dist/coding/transcript/recorder.js +2 -8
- package/dist/core/dispatch.js +3 -0
- package/dist/core/engine-native.d.ts +2 -0
- package/dist/core/engine-native.js +13 -0
- package/dist/core/error-code.js +1 -0
- package/dist/core/listen-address.d.ts +10 -0
- package/dist/core/listen-address.js +14 -0
- package/dist/core/power-idle.d.ts +8 -2
- package/dist/core/power-idle.js +17 -3
- package/dist/core/power.d.ts +5 -0
- package/dist/core/power.js +5 -0
- package/dist/core/retention-days.d.ts +5 -0
- package/dist/core/retention-days.js +14 -0
- package/dist/core/run-steps/config.d.ts +25 -0
- package/dist/core/run-steps/config.js +40 -0
- package/dist/core/run-steps/control-chars.d.ts +16 -0
- package/dist/core/run-steps/control-chars.js +26 -0
- package/dist/core/run-steps/detail.d.ts +32 -0
- package/dist/core/run-steps/detail.js +240 -0
- package/dist/core/run-steps/extract.d.ts +52 -0
- package/dist/core/run-steps/extract.js +467 -0
- package/dist/core/run-steps/ingress.d.ts +51 -0
- package/dist/core/run-steps/ingress.js +160 -0
- package/dist/core/run-steps/last-step.d.ts +15 -0
- package/dist/core/run-steps/last-step.js +35 -0
- package/dist/core/run-steps/native.d.ts +48 -0
- package/dist/core/run-steps/native.js +164 -0
- package/dist/core/run-steps/seen.d.ts +16 -0
- package/dist/core/run-steps/seen.js +35 -0
- package/dist/core/run-steps/summarize.d.ts +57 -0
- package/dist/core/run-steps/summarize.js +304 -0
- package/dist/core/run-steps/sweep.d.ts +21 -0
- package/dist/core/run-steps/sweep.js +41 -0
- package/dist/core/run-steps/types.d.ts +89 -0
- package/dist/core/run-steps/types.js +39 -0
- package/dist/core/run-steps/wire.d.ts +35 -0
- package/dist/core/run-steps/wire.js +47 -0
- package/dist/core/run-steps/writer.d.ts +49 -0
- package/dist/core/run-steps/writer.js +279 -0
- package/dist/core/runner.d.ts +4 -0
- package/dist/core/runner.js +15 -1
- package/dist/generated/prisma/browser.d.ts +8 -0
- package/dist/generated/prisma/client.d.ts +8 -0
- package/dist/generated/prisma/commonInputTypes.d.ts +30 -0
- package/dist/generated/prisma/enums.d.ts +6 -0
- package/dist/generated/prisma/enums.js +5 -0
- package/dist/generated/prisma/internal/class.d.ts +11 -0
- package/dist/generated/prisma/internal/class.js +4 -4
- package/dist/generated/prisma/internal/prismaNamespace.d.ts +103 -1
- package/dist/generated/prisma/internal/prismaNamespace.js +19 -1
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +19 -0
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +19 -1
- package/dist/generated/prisma/models/Agent.d.ts +101 -1
- package/dist/generated/prisma/models/CodingProxySession.d.ts +41 -1
- package/dist/generated/prisma/models/Run.d.ts +503 -1
- package/dist/generated/prisma/models/RunStep.d.ts +1453 -0
- package/dist/generated/prisma/models/RunStep.js +1 -0
- package/dist/generated/prisma/models.d.ts +1 -0
- package/dist/help-index.json +102 -14
- package/dist/mcp/auth/access.d.ts +1 -0
- package/dist/mcp/context.d.ts +3 -0
- package/dist/mcp/index.js +10 -0
- package/dist/mcp/tools/agents.js +37 -7
- package/dist/mcp/tools/power.js +2 -2
- package/dist/mcp/tools/run-access.d.ts +7 -0
- package/dist/mcp/tools/run-access.js +14 -0
- package/dist/mcp/tools/run-steps.d.ts +16 -0
- package/dist/mcp/tools/run-steps.js +85 -0
- package/dist/mcp/tools/runs.js +10 -2
- package/dist/native-worker/gateway-server.d.ts +4 -1
- package/dist/native-worker/gateway-server.js +10 -2
- package/dist/native-worker/gateway.d.ts +3 -0
- package/dist/native-worker/gateway.js +8 -0
- package/dist/native-worker/http-server.d.ts +2 -4
- package/dist/native-worker/http-server.js +2 -8
- package/dist/providers/coding-proxy/memory-ledger.js +1 -0
- package/dist/providers/coding-proxy/prisma-ledger.js +9 -3
- package/dist/providers/coding-proxy/proxy.d.ts +26 -0
- package/dist/providers/coding-proxy/proxy.js +133 -42
- package/dist/providers/coding-proxy/run-step-forwarder.d.ts +42 -0
- package/dist/providers/coding-proxy/run-step-forwarder.js +206 -0
- package/dist/providers/coding-proxy/runtime.d.ts +7 -0
- package/dist/providers/coding-proxy/runtime.js +55 -3
- package/dist/providers/coding-proxy/types.d.ts +5 -0
- package/dist/providers/engine/types.d.ts +26 -0
- package/dist/providers/executor/container.d.ts +14 -0
- package/dist/providers/executor/container.js +8 -1
- package/dist/providers/jobs/kubernetes.d.ts +8 -0
- package/dist/providers/jobs/kubernetes.js +22 -4
- package/dist/quickstart-images.json +1 -1
- package/dist/serve.js +26 -18
- package/dist/viewer/api-schema.d.ts +66 -0
- package/dist/viewer/api-schema.js +31 -0
- package/dist/viewer/openapi.js +2 -1
- package/dist/viewer/run-detail.d.ts +2 -0
- package/dist/viewer/run-detail.js +26 -1
- package/docs/claude-code-plugin.md +57 -13
- package/docs/coding-agent-setup.md +17 -0
- package/docs/coding-worker-isolation.md +16 -0
- package/docs/debugging-agent-runs.md +112 -0
- package/docs/getting-started-gke.md +28 -7
- package/docs/mcp-servers.md +9 -0
- package/docs/native-sandbox.md +6 -0
- package/docs/security-deployment.md +51 -0
- package/docs/viewer-api.md +21 -12
- package/docs/viewer-api.openapi.yml +78 -2
- package/help/admin-viewer.md +1 -0
- package/help/claude-code-run-pane.md +27 -4
- package/help/deploy-gke.md +5 -0
- package/help/errors/coding-mcp-gateway-unavailable.md +46 -0
- package/help/low-power-mode.md +19 -8
- package/help/mcp.md +2 -0
- package/help/resume-runs-locally.md +4 -0
- package/help/run-steps.md +140 -0
- package/package.json +1 -1
- package/prisma/migrations/20261110000000_run_steps/migration.sql +26 -0
- package/prisma/migrations/20261111000000_run_steps_viewer_notify/migration.sql +59 -0
- package/prisma/migrations/20261112000000_run_steps_detail/migration.sql +9 -0
- package/prisma/schema.prisma +52 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ModelPricing } from "../llm/pricing-core.js";
|
|
2
2
|
import { capabilityHash } from "./capability-hash.js";
|
|
3
3
|
import type { McpToolPin } from "../../core/mcp-tools.js";
|
|
4
|
+
import { type StepCaptureOptions, type StreamStepExtractor } from "../../core/run-steps/extract.js";
|
|
5
|
+
import { type RunStepsLevel } from "../../core/run-steps/config.js";
|
|
6
|
+
import type { RunStepSubmitter } from "../../core/run-steps/types.js";
|
|
4
7
|
import type { CredentialResolver, ProxyAuditSink, ProxyLedger, ProxyModelTerms, ProxyProtocol, ProxyRefusalRecord } from "./types.js";
|
|
5
8
|
export declare const PROXY_MAX_BODY_BYTES: number;
|
|
6
9
|
export declare const PROXY_MAX_OUTPUT_TOKENS = 1000000;
|
|
@@ -49,6 +52,8 @@ export interface CreateCodingProxySession {
|
|
|
49
52
|
mcpTools?: McpToolPin[];
|
|
50
53
|
/** SHA-256 (base64url) of the run's MCP token; the control plane mints and delivers the token itself. */
|
|
51
54
|
mcpTokenHash?: string;
|
|
55
|
+
/** The run's level (Run.runStepsLevel), fixed at dispatch; absent counts as summary. */
|
|
56
|
+
runStepsLevel?: RunStepsLevel;
|
|
52
57
|
}
|
|
53
58
|
export interface CreatedCodingProxySession {
|
|
54
59
|
id: string;
|
|
@@ -75,6 +80,19 @@ export interface CodingProxyOptions {
|
|
|
75
80
|
* dependency cache's seal, ModelRequestSeal). Awaited; a failure is ignored, never the request's.
|
|
76
81
|
*/
|
|
77
82
|
onModelRequest?: (runId: string) => Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Where coding runs' steps go (RunStepForwarder, to the control plane's run-steps listener);
|
|
85
|
+
* absent, no step is extracted. Steps are read from frames after they are relayed and handed over
|
|
86
|
+
* unmasked (the control plane redacts them by exact value before writing). Extraction never fails,
|
|
87
|
+
* delays or alters a relay, and nothing of it is logged or written to the proxy's database.
|
|
88
|
+
* Tool-result output (its end) is sent only for a session at detail under a detail ceiling; at off
|
|
89
|
+
* nothing is read.
|
|
90
|
+
*/
|
|
91
|
+
steps?: RunStepSubmitter;
|
|
92
|
+
/** The proxy's own WARDBY_RUN_STEPS: a session captures detail only when this is detail too. Default summary. */
|
|
93
|
+
runStepsCeiling?: RunStepsLevel;
|
|
94
|
+
/** Tests only: replaces the stream step extractor. */
|
|
95
|
+
createStepExtractor?: (protocol: ProxyProtocol, options?: StepCaptureOptions) => StreamStepExtractor;
|
|
78
96
|
}
|
|
79
97
|
export { capabilityHash };
|
|
80
98
|
export declare class CodingProxy {
|
|
@@ -88,6 +106,11 @@ export declare class CodingProxy {
|
|
|
88
106
|
private readonly priceVersion;
|
|
89
107
|
private readonly activeRequests;
|
|
90
108
|
private readonly onModelRequest?;
|
|
109
|
+
private readonly steps?;
|
|
110
|
+
private readonly createStepExtractor?;
|
|
111
|
+
private readonly runStepsCeiling;
|
|
112
|
+
/** Results already handed over, per session: each request resends the history. */
|
|
113
|
+
private readonly seenResults;
|
|
91
114
|
constructor(options: CodingProxyOptions);
|
|
92
115
|
createSession(input: CreateCodingProxySession): Promise<CreatedCodingProxySession>;
|
|
93
116
|
cancelSession(sessionId: string): Promise<void>;
|
|
@@ -106,6 +129,9 @@ export declare class CodingProxy {
|
|
|
106
129
|
private complete;
|
|
107
130
|
private forwardJson;
|
|
108
131
|
private forwardStream;
|
|
132
|
+
/** Queues a step batch for the control plane. Best effort: any failure drops the steps, never the request. */
|
|
133
|
+
private submitSteps;
|
|
134
|
+
private newStepExtractor;
|
|
109
135
|
/** Best effort: the executor reads it to name the failure; it must never break the relay. */
|
|
110
136
|
private recordUpstreamFailure;
|
|
111
137
|
private markUncertain;
|
|
@@ -8,6 +8,10 @@ import { AnthropicSseUsageTracker, actualCostUsd, fingerprintRequest, parseAnthr
|
|
|
8
8
|
import { conversationKey, estimateHold } from "./hold.js";
|
|
9
9
|
import { estimateInterrupted, previousInput } from "./interrupted.js";
|
|
10
10
|
import { createPinnedProxyFetch } from "./secure-fetch.js";
|
|
11
|
+
import { resultsFromRequest, stepsFromJson, streamStepExtractor, } from "../../core/run-steps/extract.js";
|
|
12
|
+
import { RUN_STEPS_LEVELS } from "../../core/run-steps/config.js";
|
|
13
|
+
import { SeenIds } from "../../core/run-steps/seen.js";
|
|
14
|
+
import { CLAUDE_RESULT_TOOL } from "../../core/run-steps/summarize.js";
|
|
11
15
|
export const PROXY_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
12
16
|
export const PROXY_MAX_OUTPUT_TOKENS = 1_000_000;
|
|
13
17
|
export const PROXY_DEFAULT_MAX_OUTPUT_TOKENS = 4_096;
|
|
@@ -32,7 +36,7 @@ const REQUEST_KEY_PATTERN = /^[\x21-\x7e]{1,200}$/;
|
|
|
32
36
|
export const OPTIONAL_ANTHROPIC_BETAS = ["per-turn-control-2026-07-01"];
|
|
33
37
|
const APPROVED_ANTHROPIC_BETAS = new Set([...CLAUDE_CODE_ANTHROPIC_BETAS, ...OPTIONAL_ANTHROPIC_BETAS]);
|
|
34
38
|
const WARDBY_COMMAND_TOOL = "mcp__wardby_tools__run_command";
|
|
35
|
-
const STRUCTURED_OUTPUT_TOOL =
|
|
39
|
+
const STRUCTURED_OUTPUT_TOOL = CLAUDE_RESULT_TOOL;
|
|
36
40
|
const MAX_COMMAND_BYTES = 16 * 1024;
|
|
37
41
|
const MAX_TOOL_TEXT_BYTES = 256 * 1024;
|
|
38
42
|
/**
|
|
@@ -50,6 +54,13 @@ export class CodingProxyError extends Error {
|
|
|
50
54
|
this.code = code;
|
|
51
55
|
}
|
|
52
56
|
}
|
|
57
|
+
/** What this session's replies and requests are read for. */
|
|
58
|
+
function stepCapture(session, ceiling) {
|
|
59
|
+
const level = session.runStepsLevel ?? "summary";
|
|
60
|
+
if (level === "off" || ceiling === "off")
|
|
61
|
+
return "none";
|
|
62
|
+
return level === "detail" && ceiling === "detail" ? "detail" : "summary";
|
|
63
|
+
}
|
|
53
64
|
export { capabilityHash };
|
|
54
65
|
// OpenAI Responses allowlist. Everything a request may carry is listed here;
|
|
55
66
|
// anything else is refused before a credential is resolved. It mirrors what the
|
|
@@ -915,9 +926,17 @@ export class CodingProxy {
|
|
|
915
926
|
priceVersion;
|
|
916
927
|
activeRequests = new Map();
|
|
917
928
|
onModelRequest;
|
|
929
|
+
steps;
|
|
930
|
+
createStepExtractor;
|
|
931
|
+
runStepsCeiling;
|
|
932
|
+
/** Results already handed over, per session: each request resends the history. */
|
|
933
|
+
seenResults = new SeenIds();
|
|
918
934
|
constructor(options) {
|
|
919
935
|
this.ledger = options.ledger;
|
|
920
936
|
this.onModelRequest = options.onModelRequest;
|
|
937
|
+
this.steps = options.steps;
|
|
938
|
+
this.createStepExtractor = options.createStepExtractor;
|
|
939
|
+
this.runStepsCeiling = options.runStepsCeiling ?? "summary";
|
|
921
940
|
this.credentials = options.credentials;
|
|
922
941
|
this.upstreamUrls = {
|
|
923
942
|
"openai-responses": options.upstreamUrl ?? "https://api.openai.com/v1/responses",
|
|
@@ -973,6 +992,9 @@ export class CodingProxy {
|
|
|
973
992
|
if (mcp && (!input.mcpTools || input.mcpTools.length === 0 || !input.mcpTokenHash)) {
|
|
974
993
|
throw new Error("invalid_proxy_mcp_tools");
|
|
975
994
|
}
|
|
995
|
+
if (input.runStepsLevel !== undefined && !RUN_STEPS_LEVELS.includes(input.runStepsLevel)) {
|
|
996
|
+
throw new Error("invalid_proxy_run_steps_level");
|
|
997
|
+
}
|
|
976
998
|
const capability = `rrp_${randomBytes(32).toString("base64url")}`;
|
|
977
999
|
const id = randomUUID();
|
|
978
1000
|
const registryTokenHash = capabilityHash(deriveRegistryToken(capability));
|
|
@@ -988,6 +1010,7 @@ export class CodingProxy {
|
|
|
988
1010
|
registryTokenHash,
|
|
989
1011
|
terms: input.terms,
|
|
990
1012
|
...(mcp ? { mcpTools: input.mcpTools, mcpTokenHash: input.mcpTokenHash } : {}),
|
|
1013
|
+
...(input.runStepsLevel ? { runStepsLevel: input.runStepsLevel } : {}),
|
|
991
1014
|
});
|
|
992
1015
|
this.audit({ type: "session.created", runId: input.runId });
|
|
993
1016
|
return {
|
|
@@ -1001,6 +1024,7 @@ export class CodingProxy {
|
|
|
1001
1024
|
};
|
|
1002
1025
|
}
|
|
1003
1026
|
async cancelSession(sessionId) {
|
|
1027
|
+
this.seenResults.forget(sessionId);
|
|
1004
1028
|
await this.ledger.cancelSession(sessionId);
|
|
1005
1029
|
for (const controller of this.activeRequests.get(sessionId) ?? [])
|
|
1006
1030
|
controller.abort();
|
|
@@ -1100,6 +1124,8 @@ export class CodingProxy {
|
|
|
1100
1124
|
now: this.now(),
|
|
1101
1125
|
});
|
|
1102
1126
|
if (reservation.outcome === "inactive") {
|
|
1127
|
+
// The session has ended (cancelled, expired or closed): another replica may have ended it.
|
|
1128
|
+
this.seenResults.forget(session.id);
|
|
1103
1129
|
this.audit({ type: "request.rejected", runId: session.runId, reason: reservation.reason });
|
|
1104
1130
|
throw new CodingProxyError(403, `session_${reservation.reason}`);
|
|
1105
1131
|
}
|
|
@@ -1129,6 +1155,14 @@ export class CodingProxy {
|
|
|
1129
1155
|
model: request.model,
|
|
1130
1156
|
...holdAudit,
|
|
1131
1157
|
});
|
|
1158
|
+
// Run steps: the results this (validated) request carries for the previous turn, once per call id.
|
|
1159
|
+
const capture = stepCapture(session, this.runStepsCeiling);
|
|
1160
|
+
if (capture !== "none") {
|
|
1161
|
+
this.submitSteps(input.bearer, session.runId, () => ({
|
|
1162
|
+
steps: [],
|
|
1163
|
+
results: this.seenResults.fresh(session.id, resultsFromRequest(session.protocol, parsed.body, { detail: capture === "detail" })),
|
|
1164
|
+
}));
|
|
1165
|
+
}
|
|
1132
1166
|
let key;
|
|
1133
1167
|
try {
|
|
1134
1168
|
key = await this.credentials.resolve(session.credentialRef);
|
|
@@ -1148,6 +1182,7 @@ export class CodingProxy {
|
|
|
1148
1182
|
}
|
|
1149
1183
|
const remainingMs = session.deadlineAt.getTime() - this.now().getTime();
|
|
1150
1184
|
if (remainingMs <= 0) {
|
|
1185
|
+
this.seenResults.forget(session.id);
|
|
1151
1186
|
await this.ledger.release(request.id, 403);
|
|
1152
1187
|
throw new CodingProxyError(403, "session_expired");
|
|
1153
1188
|
}
|
|
@@ -1221,9 +1256,9 @@ export class CodingProxy {
|
|
|
1221
1256
|
}
|
|
1222
1257
|
try {
|
|
1223
1258
|
if (parsed.stream)
|
|
1224
|
-
await this.forwardStream(session, request, upstream, sink, progress);
|
|
1259
|
+
await this.forwardStream(session, request, upstream, sink, progress, input.bearer);
|
|
1225
1260
|
else
|
|
1226
|
-
await this.forwardJson(session, request, upstream, sink);
|
|
1261
|
+
await this.forwardJson(session, request, upstream, sink, input.bearer);
|
|
1227
1262
|
}
|
|
1228
1263
|
catch (error) {
|
|
1229
1264
|
await this.markUncertain(session, request, upstream.status, failureReason(error), responseHeadersOf(upstream), progress);
|
|
@@ -1293,7 +1328,7 @@ export class CodingProxy {
|
|
|
1293
1328
|
throw new CodingProxyError(502, "reservation_invariant_violated");
|
|
1294
1329
|
}
|
|
1295
1330
|
}
|
|
1296
|
-
async forwardJson(session, request, upstream, sink) {
|
|
1331
|
+
async forwardJson(session, request, upstream, sink, capability) {
|
|
1297
1332
|
const declaredLength = Number(upstream.headers.get("content-length"));
|
|
1298
1333
|
if (Number.isFinite(declaredLength) && declaredLength > MAX_UPSTREAM_JSON_BYTES) {
|
|
1299
1334
|
throw new Error("upstream_response_too_large");
|
|
@@ -1313,8 +1348,15 @@ export class CodingProxy {
|
|
|
1313
1348
|
sink.start(upstream.status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
1314
1349
|
await safeWrite(sink, bytes, { value: true });
|
|
1315
1350
|
sink.end();
|
|
1351
|
+
const capture = stepCapture(session, this.runStepsCeiling);
|
|
1352
|
+
if (capture !== "none") {
|
|
1353
|
+
this.submitSteps(capability, session.runId, () => ({
|
|
1354
|
+
steps: stepsFromJson(session.protocol, value, { detail: capture === "detail" }),
|
|
1355
|
+
results: [],
|
|
1356
|
+
}));
|
|
1357
|
+
}
|
|
1316
1358
|
}
|
|
1317
|
-
async forwardStream(session, request, upstream, sink, progress) {
|
|
1359
|
+
async forwardStream(session, request, upstream, sink, progress, capability) {
|
|
1318
1360
|
if (!upstream.body)
|
|
1319
1361
|
throw new Error("missing_upstream_body");
|
|
1320
1362
|
sink.start(upstream.status, { "content-type": "text/event-stream", "cache-control": "no-store" });
|
|
@@ -1325,49 +1367,98 @@ export class CodingProxy {
|
|
|
1325
1367
|
let completed = false;
|
|
1326
1368
|
let failedWithoutUsage = false;
|
|
1327
1369
|
const anthropicUsage = session.protocol === "anthropic-messages" ? (progress.anthropic = new AnthropicSseUsageTracker()) : undefined;
|
|
1370
|
+
// Run steps: each frame is read after it was relayed. An extractor that throws is dropped, never the relay.
|
|
1371
|
+
let steps = this.newStepExtractor(session);
|
|
1372
|
+
const observe = (frame) => {
|
|
1373
|
+
if (!steps)
|
|
1374
|
+
return;
|
|
1375
|
+
try {
|
|
1376
|
+
steps.consume(frame);
|
|
1377
|
+
}
|
|
1378
|
+
catch {
|
|
1379
|
+
steps = undefined;
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1328
1382
|
const body = upstream.body;
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
if (!terminal.failure)
|
|
1348
|
-
throw new Error("terminal_usage_missing");
|
|
1349
|
-
// The upstream failed the response and reported no usage. Pass its failure on so the
|
|
1350
|
-
// client sees the real error instead of a cut connection; the reservation stays held.
|
|
1351
|
-
await this.recordUpstreamFailure(session, terminal.failure);
|
|
1352
|
-
if (!completed) {
|
|
1353
|
-
await this.markUncertain(session, request, upstream.status, `upstream_failed:${terminal.failure}`, responseHeadersOf(upstream), progress);
|
|
1383
|
+
try {
|
|
1384
|
+
for await (const chunk of body) {
|
|
1385
|
+
receivedBytes += chunk.byteLength;
|
|
1386
|
+
if (receivedBytes > MAX_UPSTREAM_JSON_BYTES)
|
|
1387
|
+
throw new Error("upstream_response_too_large");
|
|
1388
|
+
buffer = (buffer + decoder.decode(chunk, { stream: true })).replaceAll("\r\n", "\n");
|
|
1389
|
+
for (;;) {
|
|
1390
|
+
const boundary = buffer.indexOf("\n\n");
|
|
1391
|
+
if (boundary < 0)
|
|
1392
|
+
break;
|
|
1393
|
+
const frame = buffer.slice(0, boundary);
|
|
1394
|
+
buffer = buffer.slice(boundary + 2);
|
|
1395
|
+
const terminal = anthropicUsage ? anthropicUsage.consume(frame) : terminalUsageFromSseFrame(frame);
|
|
1396
|
+
if (!terminal.terminal) {
|
|
1397
|
+
progress.relayedOutputBytes += relayedTextBytes(frame, session.protocol);
|
|
1398
|
+
await safeWrite(sink, Buffer.from(`${frame}\n\n`), connected);
|
|
1399
|
+
observe(frame);
|
|
1400
|
+
continue;
|
|
1354
1401
|
}
|
|
1355
|
-
|
|
1402
|
+
if (!terminal.usage) {
|
|
1403
|
+
if (!terminal.failure)
|
|
1404
|
+
throw new Error("terminal_usage_missing");
|
|
1405
|
+
// The upstream failed the response and reported no usage. Pass its failure on so the
|
|
1406
|
+
// client sees the real error instead of a cut connection; the reservation stays held.
|
|
1407
|
+
await this.recordUpstreamFailure(session, terminal.failure);
|
|
1408
|
+
if (!completed) {
|
|
1409
|
+
await this.markUncertain(session, request, upstream.status, `upstream_failed:${terminal.failure}`, responseHeadersOf(upstream), progress);
|
|
1410
|
+
}
|
|
1411
|
+
failedWithoutUsage = true;
|
|
1412
|
+
await safeWrite(sink, Buffer.from(`${frame}\n\n`), connected);
|
|
1413
|
+
observe(frame);
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
await this.complete(session, request, terminal.usage, upstream.status);
|
|
1417
|
+
completed = true;
|
|
1356
1418
|
await safeWrite(sink, Buffer.from(`${frame}\n\n`), connected);
|
|
1357
|
-
|
|
1419
|
+
observe(frame);
|
|
1358
1420
|
}
|
|
1359
|
-
await this.complete(session, request, terminal.usage, upstream.status);
|
|
1360
|
-
completed = true;
|
|
1361
|
-
await safeWrite(sink, Buffer.from(`${frame}\n\n`), connected);
|
|
1362
1421
|
}
|
|
1422
|
+
buffer = (buffer + decoder.decode()).replaceAll("\r\n", "\n");
|
|
1423
|
+
if (!completed && !failedWithoutUsage)
|
|
1424
|
+
throw new Error("terminal_usage_missing");
|
|
1425
|
+
if (buffer)
|
|
1426
|
+
await safeWrite(sink, Buffer.from(buffer), connected);
|
|
1427
|
+
if (connected.value)
|
|
1428
|
+
sink.end();
|
|
1429
|
+
}
|
|
1430
|
+
finally {
|
|
1431
|
+
// The reply's steps, also when the stream broke (then its open text is marked cut).
|
|
1432
|
+
const extractor = steps;
|
|
1433
|
+
if (extractor)
|
|
1434
|
+
this.submitSteps(capability, session.runId, () => ({ steps: extractor.finish(), results: [] }));
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
/** Queues a step batch for the control plane. Best effort: any failure drops the steps, never the request. */
|
|
1438
|
+
submitSteps(capability, runId, build) {
|
|
1439
|
+
if (!this.steps)
|
|
1440
|
+
return;
|
|
1441
|
+
try {
|
|
1442
|
+
const batch = build();
|
|
1443
|
+
if (batch.steps.length > 0 || batch.results.length > 0)
|
|
1444
|
+
this.steps.submit(capability, runId, batch);
|
|
1445
|
+
}
|
|
1446
|
+
catch {
|
|
1447
|
+
// Dropped: steps are never a reason to fail a model request.
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
newStepExtractor(session) {
|
|
1451
|
+
if (!this.steps)
|
|
1452
|
+
return undefined;
|
|
1453
|
+
const capture = stepCapture(session, this.runStepsCeiling);
|
|
1454
|
+
if (capture === "none")
|
|
1455
|
+
return undefined;
|
|
1456
|
+
try {
|
|
1457
|
+
return (this.createStepExtractor ?? streamStepExtractor)(session.protocol, { detail: capture === "detail" });
|
|
1458
|
+
}
|
|
1459
|
+
catch {
|
|
1460
|
+
return undefined;
|
|
1363
1461
|
}
|
|
1364
|
-
buffer = (buffer + decoder.decode()).replaceAll("\r\n", "\n");
|
|
1365
|
-
if (!completed && !failedWithoutUsage)
|
|
1366
|
-
throw new Error("terminal_usage_missing");
|
|
1367
|
-
if (buffer)
|
|
1368
|
-
await safeWrite(sink, Buffer.from(buffer), connected);
|
|
1369
|
-
if (connected.value)
|
|
1370
|
-
sink.end();
|
|
1371
1462
|
}
|
|
1372
1463
|
/** Best effort: the executor reads it to name the failure; it must never break the relay. */
|
|
1373
1464
|
async recordUpstreamFailure(session, code) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type RunStepBatch, type RunStepSubmitter } from "../../core/run-steps/types.js";
|
|
2
|
+
export declare const MAX_FORWARD_QUEUE = 256;
|
|
3
|
+
/** A queue holds at most this many bytes of (estimated) serialized submissions. */
|
|
4
|
+
export declare const MAX_FORWARD_QUEUE_BYTES: number;
|
|
5
|
+
export interface RunStepForwarderOptions {
|
|
6
|
+
/** WARDBY_RUN_STEPS_URL, without a trailing slash. */
|
|
7
|
+
url: string;
|
|
8
|
+
fetchImpl?: typeof fetch;
|
|
9
|
+
maxQueue?: number;
|
|
10
|
+
maxQueueBytes?: number;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
/** How long flush() and close() wait for the queue to settle before giving up. */
|
|
13
|
+
flushTimeoutMs?: number;
|
|
14
|
+
log?: {
|
|
15
|
+
info(fields: Record<string, unknown>, message: string): void;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export declare class RunStepForwarder implements RunStepSubmitter {
|
|
19
|
+
private readonly options;
|
|
20
|
+
private queue;
|
|
21
|
+
private queueBytes;
|
|
22
|
+
private draining;
|
|
23
|
+
private scheduled;
|
|
24
|
+
private closed;
|
|
25
|
+
private inflight;
|
|
26
|
+
private dropped;
|
|
27
|
+
private readonly loggedReasons;
|
|
28
|
+
constructor(options: RunStepForwarderOptions);
|
|
29
|
+
submit(capability: string, runId: string, batch: RunStepBatch): void;
|
|
30
|
+
/** Resolves once everything submitted so far was posted or dropped, or after a bound. Never rejects. */
|
|
31
|
+
flush(): Promise<void>;
|
|
32
|
+
/** Stops accepting, waits at most the flush bound, then drops what is left and aborts the in-flight POST. */
|
|
33
|
+
close(): Promise<void>;
|
|
34
|
+
private kick;
|
|
35
|
+
private drain;
|
|
36
|
+
/**
|
|
37
|
+
* The capability goes in the Authorization header only. The response body is never read, and a
|
|
38
|
+
* failure is logged by a fixed reason: an error's or a response's text never reaches a log line.
|
|
39
|
+
*/
|
|
40
|
+
private post;
|
|
41
|
+
private drop;
|
|
42
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hands coding runs' steps from the proxy to the control plane's run-steps listener
|
|
3
|
+
* (`POST <WARDBY_RUN_STEPS_URL>/internal/run-steps`, `Authorization: Bearer <the run capability>`).
|
|
4
|
+
* The proxy forwards raw step text over the authenticated in-cluster listener; the control plane
|
|
5
|
+
* redacts by exact value before anything is stored. Because the body is raw, this class never logs,
|
|
6
|
+
* echoes, or puts into an error any part of the body, the step text, or the capability: log lines
|
|
7
|
+
* carry a fixed reason and a count only.
|
|
8
|
+
*
|
|
9
|
+
* A bounded in-memory queue, drained one POST at a time off the relay path: a full queue or a slow
|
|
10
|
+
* or failing control plane drops steps and never delays a model request.
|
|
11
|
+
*/
|
|
12
|
+
import { logger } from "../../core/logger.js";
|
|
13
|
+
import { MAX_RESULTS_PER_POST, MAX_RUN_STEPS_POST_BYTES, MAX_STEPS_PER_POST, RUN_STEPS_INGRESS_PATH, } from "../../core/run-steps/types.js";
|
|
14
|
+
const forwarderLog = logger.child({ module: "coding-proxy" });
|
|
15
|
+
export const MAX_FORWARD_QUEUE = 256;
|
|
16
|
+
/** A queue holds at most this many bytes of (estimated) serialized submissions. */
|
|
17
|
+
export const MAX_FORWARD_QUEUE_BYTES = 8 * 1024 * 1024;
|
|
18
|
+
/** Margin over the listener's 15 s request limit (its write can take lock wait 5 s + transaction 10 s). */
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
20
|
+
const DEFAULT_FLUSH_TIMEOUT_MS = 5_000;
|
|
21
|
+
/** The envelope around the arrays, with slack for the runId. */
|
|
22
|
+
const ENVELOPE_SLACK_BYTES = 1024;
|
|
23
|
+
/** A cheap upper-ish bound on a batch's serialized size, with no stringify on the caller's stack. */
|
|
24
|
+
function estimate(batch) {
|
|
25
|
+
let bytes = ENVELOPE_SLACK_BYTES;
|
|
26
|
+
for (const step of batch.steps) {
|
|
27
|
+
bytes += step.text.length * 3 + step.callId.length * 3 + 256;
|
|
28
|
+
if (step.kind === "tool")
|
|
29
|
+
bytes += (step.input?.length ?? 0) * 3;
|
|
30
|
+
}
|
|
31
|
+
for (const result of batch.results)
|
|
32
|
+
bytes += 128 + (result.output?.length ?? 0) * 3;
|
|
33
|
+
return bytes;
|
|
34
|
+
}
|
|
35
|
+
/** Splits by count and by serialized UTF-8 size, so no body passes MAX_RUN_STEPS_POST_BYTES. */
|
|
36
|
+
function split(batch, runId) {
|
|
37
|
+
const parts = [];
|
|
38
|
+
const base = ENVELOPE_SLACK_BYTES + Buffer.byteLength(runId);
|
|
39
|
+
let steps = [];
|
|
40
|
+
let size = base;
|
|
41
|
+
for (const step of batch.steps) {
|
|
42
|
+
const bytes = Buffer.byteLength(JSON.stringify(step)) + 1;
|
|
43
|
+
if (steps.length > 0 && (steps.length >= MAX_STEPS_PER_POST || size + bytes > MAX_RUN_STEPS_POST_BYTES)) {
|
|
44
|
+
parts.push({ steps, results: [] });
|
|
45
|
+
steps = [];
|
|
46
|
+
size = base;
|
|
47
|
+
}
|
|
48
|
+
steps.push(step);
|
|
49
|
+
size += bytes;
|
|
50
|
+
}
|
|
51
|
+
if (steps.length > 0)
|
|
52
|
+
parts.push({ steps, results: [] });
|
|
53
|
+
let results = [];
|
|
54
|
+
let resultBytes = base;
|
|
55
|
+
for (const result of batch.results) {
|
|
56
|
+
const bytes = Buffer.byteLength(JSON.stringify(result)) + 1;
|
|
57
|
+
if (results.length > 0 &&
|
|
58
|
+
(results.length >= MAX_RESULTS_PER_POST || resultBytes + bytes > MAX_RUN_STEPS_POST_BYTES)) {
|
|
59
|
+
parts.push({ steps: [], results });
|
|
60
|
+
results = [];
|
|
61
|
+
resultBytes = base;
|
|
62
|
+
}
|
|
63
|
+
results.push(result);
|
|
64
|
+
resultBytes += bytes;
|
|
65
|
+
}
|
|
66
|
+
if (results.length > 0)
|
|
67
|
+
parts.push({ steps: [], results });
|
|
68
|
+
return parts;
|
|
69
|
+
}
|
|
70
|
+
export class RunStepForwarder {
|
|
71
|
+
options;
|
|
72
|
+
queue = [];
|
|
73
|
+
queueBytes = 0;
|
|
74
|
+
draining;
|
|
75
|
+
scheduled = false;
|
|
76
|
+
closed = false;
|
|
77
|
+
inflight;
|
|
78
|
+
dropped = 0;
|
|
79
|
+
loggedReasons = new Set();
|
|
80
|
+
constructor(options) {
|
|
81
|
+
this.options = options;
|
|
82
|
+
}
|
|
83
|
+
submit(capability, runId, batch) {
|
|
84
|
+
try {
|
|
85
|
+
if (this.closed)
|
|
86
|
+
return this.drop("closed");
|
|
87
|
+
const size = estimate(batch);
|
|
88
|
+
if (this.queue.length >= (this.options.maxQueue ?? MAX_FORWARD_QUEUE) ||
|
|
89
|
+
this.queueBytes + size > (this.options.maxQueueBytes ?? MAX_FORWARD_QUEUE_BYTES)) {
|
|
90
|
+
return this.drop("queue_full");
|
|
91
|
+
}
|
|
92
|
+
this.queue.push({ capability, runId, batch, estimate: size });
|
|
93
|
+
this.queueBytes += size;
|
|
94
|
+
this.kick();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
this.drop("submit_failed");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Resolves once everything submitted so far was posted or dropped, or after a bound. Never rejects. */
|
|
101
|
+
async flush() {
|
|
102
|
+
let timer;
|
|
103
|
+
const bound = new Promise((resolve) => {
|
|
104
|
+
timer = setTimeout(resolve, this.options.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS);
|
|
105
|
+
timer.unref();
|
|
106
|
+
});
|
|
107
|
+
const settled = (async () => {
|
|
108
|
+
while (this.draining || this.scheduled)
|
|
109
|
+
await (this.draining ?? new Promise((r) => setImmediate(r)));
|
|
110
|
+
})().catch(() => undefined);
|
|
111
|
+
await Promise.race([settled, bound]);
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
/** Stops accepting, waits at most the flush bound, then drops what is left and aborts the in-flight POST. */
|
|
115
|
+
async close() {
|
|
116
|
+
this.closed = true;
|
|
117
|
+
await this.flush();
|
|
118
|
+
const left = this.queue.length;
|
|
119
|
+
this.queue = [];
|
|
120
|
+
this.queueBytes = 0;
|
|
121
|
+
for (let n = 0; n < left; n += 1)
|
|
122
|
+
this.drop("closed");
|
|
123
|
+
this.inflight?.abort();
|
|
124
|
+
}
|
|
125
|
+
kick() {
|
|
126
|
+
if (this.draining || this.scheduled || this.queue.length === 0)
|
|
127
|
+
return;
|
|
128
|
+
this.scheduled = true;
|
|
129
|
+
// Off the caller's stack: the first serialization must not run on the relay path.
|
|
130
|
+
setImmediate(() => {
|
|
131
|
+
this.scheduled = false;
|
|
132
|
+
if (this.draining || this.queue.length === 0)
|
|
133
|
+
return;
|
|
134
|
+
this.draining = this.drain().finally(() => {
|
|
135
|
+
this.draining = undefined;
|
|
136
|
+
this.kick();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async drain() {
|
|
141
|
+
for (let next = this.queue.shift(); next; next = this.queue.shift()) {
|
|
142
|
+
this.queueBytes -= next.estimate;
|
|
143
|
+
if (this.closed) {
|
|
144
|
+
this.drop("closed");
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
let bodies;
|
|
148
|
+
try {
|
|
149
|
+
bodies = split(next.batch, next.runId).map((part) => JSON.stringify({ runId: next.runId, ...part }));
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
this.drop("serialize_failed");
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
for (const body of bodies) {
|
|
156
|
+
if (this.closed)
|
|
157
|
+
this.drop("closed");
|
|
158
|
+
else
|
|
159
|
+
await this.post(next.capability, body);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The capability goes in the Authorization header only. The response body is never read, and a
|
|
165
|
+
* failure is logged by a fixed reason: an error's or a response's text never reaches a log line.
|
|
166
|
+
*/
|
|
167
|
+
async post(capability, body) {
|
|
168
|
+
const controller = new AbortController();
|
|
169
|
+
this.inflight = controller;
|
|
170
|
+
const timer = setTimeout(() => controller.abort(), this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
171
|
+
timer.unref();
|
|
172
|
+
try {
|
|
173
|
+
const response = await (this.options.fetchImpl ?? fetch)(`${this.options.url}${RUN_STEPS_INGRESS_PATH}`, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: { authorization: `Bearer ${capability}`, "content-type": "application/json" },
|
|
176
|
+
body,
|
|
177
|
+
redirect: "error",
|
|
178
|
+
signal: controller.signal,
|
|
179
|
+
});
|
|
180
|
+
await response.body?.cancel().catch(() => undefined);
|
|
181
|
+
if (!response.ok)
|
|
182
|
+
this.drop(`http_${response.status}`);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
this.drop(this.closed ? "closed" : controller.signal.aborted ? "timeout" : "unreachable");
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
if (this.inflight === controller)
|
|
190
|
+
this.inflight = undefined;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
drop(reason) {
|
|
194
|
+
this.dropped += 1;
|
|
195
|
+
const first = !this.loggedReasons.has(reason);
|
|
196
|
+
this.loggedReasons.add(reason);
|
|
197
|
+
if (first || this.dropped % 100 === 0) {
|
|
198
|
+
try {
|
|
199
|
+
(this.options.log ?? forwarderLog).info({ event: "run_steps.forward_dropped", reason, droppedParts: this.dropped }, "coding proxy: run steps not delivered to the control plane");
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// logging must never break the relay
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -20,6 +20,13 @@ export declare function createRegistryUpstream(pinned: typeof globalThis.fetch):
|
|
|
20
20
|
* without credentials, query or fragment stops start-up (the value is not echoed).
|
|
21
21
|
*/
|
|
22
22
|
export declare function loadMcpGatewayUrl(env: NodeJS.ProcessEnv): string | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* WARDBY_RUN_STEPS_URL: the control plane's run-steps listener (WARDBY_RUN_STEPS_LISTEN on the
|
|
25
|
+
* server), which the proxy posts coding runs' steps to. Unset or empty: coding runs record no steps.
|
|
26
|
+
* Anything but an http(s) origin without credentials, path, query or fragment stops start-up (the
|
|
27
|
+
* value is not echoed, so a credential in it never reaches a log).
|
|
28
|
+
*/
|
|
29
|
+
export declare function loadRunStepsUrl(env: NodeJS.ProcessEnv): string | undefined;
|
|
23
30
|
export interface CodingProxyRuntimeOptions {
|
|
24
31
|
db: PrismaClient;
|
|
25
32
|
/** The package registry's own client, so its traffic (many concurrent
|