import { createHash } from "node:crypto"; export const RESULT_V1_SCHEMA_VERSION = 1; export const MAX_RESULT_MEMBERS = 32; export const MAX_RESULT_MEMBER_BYTES = 2 * 1024 * 1024; /** Soft cap for HTTP wire payloads; Go fail-closes at 8MB. */ export const MAX_RESULT_WIRE_BYTES = 6 * 1024 * 1024; /** Tail window for execution.log patches; full log remains on shared volume. */ export const EXECUTION_LOG_PATCH_BYTES = 128 * 1024; const RETRYABLE_CODES = new Set(["agent.timeout", "agent.canceled", "agent.transient"]); const ALLOWED_CODES = new Set([ "agent.prompt_empty", "agent.unauthorized", "agent.timeout", "agent.canceled", "agent.transient", "agent.missing_deliverable", "agent.run_failed", ]); export function typedErrorCode(payload) { const raw = String(payload?.fallback_reason || payload?.error || ""); const t = raw.toLowerCase(); if (t.includes("prompt_empty")) return "agent.prompt_empty"; if (t.includes("unauthorized")) return "agent.unauthorized"; if (t.includes("timeout") || t.includes("timed out")) return "agent.timeout"; if (t.includes("cancel")) return "agent.canceled"; if ( t.includes("connection") || t.includes("terminated") || t.includes("econnreset") || t.includes("socket hang") ) { return "agent.transient"; } if (t.includes("missing target")) return "agent.missing_deliverable"; if (payload?.retryable) return "agent.transient"; if (!payload?.ok) return "agent.run_failed"; return ""; } export function membersDigest(members) { const rows = (Array.isArray(members) ? members : []).map((m) => ({ kind: m.kind, path: m.path, digest: m.digest, size: m.size, })); return createHash("sha256").update(JSON.stringify(rows)).digest("hex"); } /** Keep only the trailing bytes of execution.log for the HTTP patch wire. */ export function truncateExecutionLogContent(content, maxBytes = EXECUTION_LOG_PATCH_BYTES) { const buf = Buffer.from(String(content || ""), "utf8"); if (buf.length <= maxBytes) { return buf.toString("utf8"); } const tail = buf.subarray(buf.length - maxBytes); return `[truncated ${buf.length - maxBytes} bytes]\n` + tail.toString("utf8"); } /** * Cap patch bodies so members and patches stay aligned under wire limits. * Oversized / overflow members are dropped from both lists (fail closed on wire size). */ export function capPatches(patches, opts = {}) { const maxMember = opts.maxMemberBytes ?? MAX_RESULT_MEMBER_BYTES; const maxTotal = opts.maxTotalBytes ?? MAX_RESULT_WIRE_BYTES; const maxMembers = opts.maxMembers ?? MAX_RESULT_MEMBERS; const out = []; let total = 0; for (const p of Array.isArray(patches) ? patches : []) { const rel = String(p?.rel_path || ""); if (!rel || rel.includes("..") || rel.startsWith("/")) continue; let content = String(p.content || ""); if (rel === "execution.log" || rel.endsWith("/execution.log")) { content = truncateExecutionLogContent(content); } const buf = Buffer.from(content, "utf8"); if (buf.length > maxMember) continue; if (out.length >= maxMembers) break; if (total + buf.length > maxTotal) continue; total += buf.length; out.push({ rel_path: rel, content }); } return out; } export function estimateWireBytes(payload) { return Buffer.byteLength(JSON.stringify(payload), "utf8"); } export function withResultV1(payload) { const out = { ...payload }; delete out.stderr; const capped = capPatches(Array.isArray(out.patches) ? out.patches : []); out.patches = capped; const members = []; for (const p of capped) { const rel = String(p?.rel_path || ""); const buf = Buffer.from(String(p.content || ""), "utf8"); members.push({ kind: "file", path: rel, digest: createHash("sha256").update(buf).digest("hex"), size: buf.length, }); } const ok = !!out.ok; const errorCode = ok ? "" : typedErrorCode(out); const retryable = !ok && RETRYABLE_CODES.has(errorCode) && !!out.retryable; out.capabilities = { typed_result: true }; out.runner_protocol = "typed-v1"; out.result_v1 = { schema_version: RESULT_V1_SCHEMA_VERSION, run_id: String(out.run_id || "unknown").slice(0, 128), outcome: ok ? "success" : "error", retryable, error_code: ALLOWED_CODES.has(errorCode) ? errorCode : ok ? "" : "agent.run_failed", members, content_digest: membersDigest(members), }; return out; }