@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.
Files changed (123) hide show
  1. package/deploy/local/docker-compose.quickstart-coding.yml +5 -0
  2. package/dist/claude-plugin/command.d.ts +4 -1
  3. package/dist/claude-plugin/command.js +7 -4
  4. package/dist/cli.js +24 -0
  5. package/dist/coding/transcript/recorder.js +2 -8
  6. package/dist/core/dispatch.js +3 -0
  7. package/dist/core/engine-native.d.ts +2 -0
  8. package/dist/core/engine-native.js +13 -0
  9. package/dist/core/error-code.js +1 -0
  10. package/dist/core/listen-address.d.ts +10 -0
  11. package/dist/core/listen-address.js +14 -0
  12. package/dist/core/power-idle.d.ts +8 -2
  13. package/dist/core/power-idle.js +17 -3
  14. package/dist/core/power.d.ts +5 -0
  15. package/dist/core/power.js +5 -0
  16. package/dist/core/retention-days.d.ts +5 -0
  17. package/dist/core/retention-days.js +14 -0
  18. package/dist/core/run-steps/config.d.ts +25 -0
  19. package/dist/core/run-steps/config.js +40 -0
  20. package/dist/core/run-steps/control-chars.d.ts +16 -0
  21. package/dist/core/run-steps/control-chars.js +26 -0
  22. package/dist/core/run-steps/detail.d.ts +32 -0
  23. package/dist/core/run-steps/detail.js +240 -0
  24. package/dist/core/run-steps/extract.d.ts +52 -0
  25. package/dist/core/run-steps/extract.js +467 -0
  26. package/dist/core/run-steps/ingress.d.ts +51 -0
  27. package/dist/core/run-steps/ingress.js +160 -0
  28. package/dist/core/run-steps/last-step.d.ts +15 -0
  29. package/dist/core/run-steps/last-step.js +35 -0
  30. package/dist/core/run-steps/native.d.ts +48 -0
  31. package/dist/core/run-steps/native.js +164 -0
  32. package/dist/core/run-steps/seen.d.ts +16 -0
  33. package/dist/core/run-steps/seen.js +35 -0
  34. package/dist/core/run-steps/summarize.d.ts +57 -0
  35. package/dist/core/run-steps/summarize.js +304 -0
  36. package/dist/core/run-steps/sweep.d.ts +21 -0
  37. package/dist/core/run-steps/sweep.js +41 -0
  38. package/dist/core/run-steps/types.d.ts +89 -0
  39. package/dist/core/run-steps/types.js +39 -0
  40. package/dist/core/run-steps/wire.d.ts +35 -0
  41. package/dist/core/run-steps/wire.js +47 -0
  42. package/dist/core/run-steps/writer.d.ts +49 -0
  43. package/dist/core/run-steps/writer.js +279 -0
  44. package/dist/core/runner.d.ts +4 -0
  45. package/dist/core/runner.js +15 -1
  46. package/dist/generated/prisma/browser.d.ts +8 -0
  47. package/dist/generated/prisma/client.d.ts +8 -0
  48. package/dist/generated/prisma/commonInputTypes.d.ts +30 -0
  49. package/dist/generated/prisma/enums.d.ts +6 -0
  50. package/dist/generated/prisma/enums.js +5 -0
  51. package/dist/generated/prisma/internal/class.d.ts +11 -0
  52. package/dist/generated/prisma/internal/class.js +4 -4
  53. package/dist/generated/prisma/internal/prismaNamespace.d.ts +103 -1
  54. package/dist/generated/prisma/internal/prismaNamespace.js +19 -1
  55. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +19 -0
  56. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +19 -1
  57. package/dist/generated/prisma/models/Agent.d.ts +101 -1
  58. package/dist/generated/prisma/models/CodingProxySession.d.ts +41 -1
  59. package/dist/generated/prisma/models/Run.d.ts +503 -1
  60. package/dist/generated/prisma/models/RunStep.d.ts +1453 -0
  61. package/dist/generated/prisma/models/RunStep.js +1 -0
  62. package/dist/generated/prisma/models.d.ts +1 -0
  63. package/dist/help-index.json +102 -14
  64. package/dist/mcp/auth/access.d.ts +1 -0
  65. package/dist/mcp/context.d.ts +3 -0
  66. package/dist/mcp/index.js +10 -0
  67. package/dist/mcp/tools/agents.js +37 -7
  68. package/dist/mcp/tools/power.js +2 -2
  69. package/dist/mcp/tools/run-access.d.ts +7 -0
  70. package/dist/mcp/tools/run-access.js +14 -0
  71. package/dist/mcp/tools/run-steps.d.ts +16 -0
  72. package/dist/mcp/tools/run-steps.js +85 -0
  73. package/dist/mcp/tools/runs.js +10 -2
  74. package/dist/native-worker/gateway-server.d.ts +4 -1
  75. package/dist/native-worker/gateway-server.js +10 -2
  76. package/dist/native-worker/gateway.d.ts +3 -0
  77. package/dist/native-worker/gateway.js +8 -0
  78. package/dist/native-worker/http-server.d.ts +2 -4
  79. package/dist/native-worker/http-server.js +2 -8
  80. package/dist/providers/coding-proxy/memory-ledger.js +1 -0
  81. package/dist/providers/coding-proxy/prisma-ledger.js +9 -3
  82. package/dist/providers/coding-proxy/proxy.d.ts +26 -0
  83. package/dist/providers/coding-proxy/proxy.js +133 -42
  84. package/dist/providers/coding-proxy/run-step-forwarder.d.ts +42 -0
  85. package/dist/providers/coding-proxy/run-step-forwarder.js +206 -0
  86. package/dist/providers/coding-proxy/runtime.d.ts +7 -0
  87. package/dist/providers/coding-proxy/runtime.js +55 -3
  88. package/dist/providers/coding-proxy/types.d.ts +5 -0
  89. package/dist/providers/engine/types.d.ts +26 -0
  90. package/dist/providers/executor/container.d.ts +14 -0
  91. package/dist/providers/executor/container.js +8 -1
  92. package/dist/providers/jobs/kubernetes.d.ts +8 -0
  93. package/dist/providers/jobs/kubernetes.js +22 -4
  94. package/dist/quickstart-images.json +1 -1
  95. package/dist/serve.js +26 -18
  96. package/dist/viewer/api-schema.d.ts +66 -0
  97. package/dist/viewer/api-schema.js +31 -0
  98. package/dist/viewer/openapi.js +2 -1
  99. package/dist/viewer/run-detail.d.ts +2 -0
  100. package/dist/viewer/run-detail.js +26 -1
  101. package/docs/claude-code-plugin.md +57 -13
  102. package/docs/coding-agent-setup.md +17 -0
  103. package/docs/coding-worker-isolation.md +16 -0
  104. package/docs/debugging-agent-runs.md +112 -0
  105. package/docs/getting-started-gke.md +28 -7
  106. package/docs/mcp-servers.md +9 -0
  107. package/docs/native-sandbox.md +6 -0
  108. package/docs/security-deployment.md +51 -0
  109. package/docs/viewer-api.md +21 -12
  110. package/docs/viewer-api.openapi.yml +78 -2
  111. package/help/admin-viewer.md +1 -0
  112. package/help/claude-code-run-pane.md +27 -4
  113. package/help/deploy-gke.md +5 -0
  114. package/help/errors/coding-mcp-gateway-unavailable.md +46 -0
  115. package/help/low-power-mode.md +19 -8
  116. package/help/mcp.md +2 -0
  117. package/help/resume-runs-locally.md +4 -0
  118. package/help/run-steps.md +140 -0
  119. package/package.json +1 -1
  120. package/prisma/migrations/20261110000000_run_steps/migration.sql +26 -0
  121. package/prisma/migrations/20261111000000_run_steps_viewer_notify/migration.sql +59 -0
  122. package/prisma/migrations/20261112000000_run_steps_detail/migration.sql +9 -0
  123. 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 = "StructuredOutput";
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
- for await (const chunk of body) {
1330
- receivedBytes += chunk.byteLength;
1331
- if (receivedBytes > MAX_UPSTREAM_JSON_BYTES)
1332
- throw new Error("upstream_response_too_large");
1333
- buffer = (buffer + decoder.decode(chunk, { stream: true })).replaceAll("\r\n", "\n");
1334
- for (;;) {
1335
- const boundary = buffer.indexOf("\n\n");
1336
- if (boundary < 0)
1337
- break;
1338
- const frame = buffer.slice(0, boundary);
1339
- buffer = buffer.slice(boundary + 2);
1340
- const terminal = anthropicUsage ? anthropicUsage.consume(frame) : terminalUsageFromSseFrame(frame);
1341
- if (!terminal.terminal) {
1342
- progress.relayedOutputBytes += relayedTextBytes(frame, session.protocol);
1343
- await safeWrite(sink, Buffer.from(`${frame}\n\n`), connected);
1344
- continue;
1345
- }
1346
- if (!terminal.usage) {
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
- failedWithoutUsage = true;
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
- continue;
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