UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

182 lines (171 loc) 8.88 kB
/** * # cli/errors — fail closed, loud, machine-parseable (Kestrel CLI v1 §6) * * One error type ({@link CliError}) carrying a stable `code`, an `exit` code, and an optional * `hint`. The router body ({@link ./index.ts}) is wrapped in a single try/catch; any throw is * rendered by {@link fail} to **stderr** in the active mode and its exit code returned. stdout * stays a pure payload channel, so an agent's `JSON.parse(stdout)` never sees a half-written * object followed by an error. `code` strings are stable across releases — agents match on * `code`, not prose. */ import type { OutputCtx } from "./context.ts"; // The server's `application/problem+json` diagnostics (RFC 9457), preserved through the throw so a // backend that reads a typed refusal body (e.g. hosted.ts POST /simulate, kestrel-markets-dd1z) can // surface the server's `title`/`detail`/`code`/`remediation` on the SAME frame — additively on `--json`, // folded into the message on the human/text frames. Type-only import (erased at runtime; no coupling to // the SDK client's module graph). Mirrors {@link ../client/index.ts KestrelClientError.problem}. import type { ProblemDetails } from "../client/index.ts"; /** The exit-code map. `0` iff the command truly succeeded; every error path is nonzero. */ export const EXIT = { OK: 0, GENERIC: 1, // unexpected/uncaught USAGE: 2, // bad/unknown flag, missing required, bad --format, parse failure NOT_FOUND: 3, // `runs show <id>` / `lineage <name>` absent RUNTIME_UNAVAILABLE: 4, // bun/chdb needed but unloadable (loadHeavy) PAYMENT_REQUIRED: 5, // a remote verb returned a 402/Offer (surfaced as data) HANDSHAKE: 6, // `day`: the author handshake got no reply (codes NO_AUTHOR / HANDSHAKE_ABANDONED) // `paper`: a typed PaperSessionRefused (kestrel-7o2.12) — an unreachable/degraded IB Gateway, an // unresolved contract, an absent/stale tape, a tripped kill-switch, an unbounded ceiling, or a // non-paper mode. Its OWN exit, never GENERIC/1: exit 1 is "an unexpected/uncaught error", and a // caller must be able to tell the fail-closed path WORKING from a crash. PAPER_REFUSED: 7, SIGINT: 130, } as const; /** A domain error with a stable code, an exit code, and an optional hint line. When the error came off a * non-2xx wire body carrying `application/problem+json` (or problem-shaped JSON), the parsed * {@link ProblemDetails} ride on `problem` — the server's own diagnostics, preserved through the throw so * {@link fail} can surface them (title/detail/code/remediation) instead of collapsing them to a bare * transport line (kestrel-markets-dd1z). The `message` already carries title+detail in prose; `problem` * is the ADDITIVE structured form agents read off the `--json` envelope. */ export class CliError extends Error { override readonly name = "CliError"; readonly code: string; readonly exit: number; readonly hint?: string; readonly problem?: ProblemDetails; constructor(o: { code: string; exit: number; message: string; hint?: string; problem?: ProblemDetails }) { super(o.message); this.code = o.code; this.exit = o.exit; if (o.hint !== undefined) this.hint = o.hint; if (o.problem !== undefined) this.problem = o.problem; } } const RED = ""; const DIM = ""; const RESET = ""; /** * The transport errnos a raw `fetch` throws when it never reached an HTTP status — DNS misses, * refused/reset/timed-out/unreachable sockets. Node/undici surface these as the system errno on * the `TypeError`'s `cause`; Bun uses its own string codes for the same faults. An HTTP *status* * (4xx/5xx) is NOT here — those are already typed by {@link ./backend/hosted.ts httpErr}. */ const CONNECTION_ERRNOS = new Set([ // Node / undici (libuv errno) "ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ECONNRESET", "ECONNABORTED", "ETIMEDOUT", "EHOSTUNREACH", "ENETUNREACH", "ENETDOWN", "EPIPE", "EAI_FAIL", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET", // Bun (SystemError string codes) "ConnectionRefused", "ConnectionClosed", "ConnectionTimeout", "FailedToOpenSocket", "DNSError", ]); /** The `code` string off an error-shaped value, if it carries one. */ function errCode(e: unknown): string | undefined { if (typeof e === "object" && e !== null && "code" in e) { const c = (e as { code?: unknown }).code; if (typeof c === "string") return c; } return undefined; } /** * Type a raw connection-level `fetch` failure (network down / DNS fail / refused socket) as a * fail-closed {@link CliError} — code `NETWORK_UNAVAILABLE`, exit {@link EXIT.RUNTIME_UNAVAILABLE} * (4), with a hint pointing at the network + the API base — instead of letting the untyped * `TypeError` fall through {@link fail} as an unclear GENERIC exit 1 (PR #68 review 6a, kestrel-a3v9). * * Returns `null` for anything that is NOT a transport failure: an already-typed {@link CliError} * (an HTTP status, a USAGE/NOT_FOUND — leave it alone) and any other throw (a real bug stays * GENERIC, never masked as a network blip). The signal is the standard shape a `fetch` transport * fault takes in this runtime — a `TypeError` whose own or `cause`'s `code` is a known connection * errno, or undici's bare `TypeError: fetch failed` — never a broad "any TypeError" catch. */ export function asConnectionError(e: unknown, base?: string): CliError | null { if (e instanceof CliError) return null; const cause = typeof e === "object" && e !== null ? (e as { cause?: unknown }).cause : undefined; const code = errCode(e) ?? errCode(cause); const isFetchTransport = (code !== undefined && CONNECTION_ERRNOS.has(code)) || (e instanceof TypeError && /fetch failed|failed to fetch|unable to connect|failed to connect/i.test(e.message)); if (!isFetchTransport) return null; // Surface the transport root cause, not just the opaque `TypeError: fetch failed` wrapper: the // errno (ENOTFOUND / ECONNREFUSED / …) is what tells network-down from DNS-fail from refused. const outer = e instanceof Error ? e.message : String(e); const inner = cause instanceof Error ? cause.message : undefined; const parts = [outer]; // Append the underlying cause message (which carries the errno) when it adds information the // wrapper does not already state — undici's outer message is a bare "fetch failed". if (inner !== undefined && inner !== outer) parts.push(inner); else if (code !== undefined && !outer.includes(code)) parts.push(code); const detail = parts.join(" — "); const where = base !== undefined && base !== "" ? ` reaching ${base}` : ""; return new CliError({ code: "NETWORK_UNAVAILABLE", exit: EXIT.RUNTIME_UNAVAILABLE, message: `network unavailable${where}: ${detail}`, hint: "check your network connection and the API base (--api / KESTREL_API)", }); } /** * Render an error to **stderr** in the active mode and return its exit code. Never throws, never * writes to stdout. A non-`CliError` throw is treated as GENERIC (exit 1); `KESTREL_DEBUG=1` also * dumps its stack in that case (dev only). */ export function fail(ctx: OutputCtx, err: unknown): number { const isCli = err instanceof CliError; const code = isCli ? err.code : "GENERIC"; const exit = isCli ? err.exit : EXIT.GENERIC; const message = err instanceof Error ? err.message : String(err); const hint = isCli ? err.hint : undefined; // The server's structured problem+json diagnostics, when a backend preserved them on the throw // (kestrel-markets-dd1z). The `message` already folds title+detail into prose for the human/text // frames; on `--json` they ride ADDITIVELY as `error.problem` so an agent reads the server's // `title`/`detail`/`code`/`remediation` as data, not just off the prose message. const problem = isCli ? err.problem : undefined; if (ctx.mode === "json") { const obj: { error: { code: string; message: string; hint?: string; problem?: ProblemDetails } } = { error: { code, message }, }; if (hint !== undefined) obj.error.hint = hint; if (problem !== undefined) obj.error.problem = problem; process.stderr.write(JSON.stringify(obj) + "\n"); } else if (ctx.mode === "text") { let line = `error\tcode=${code}\tmessage=${message}`; if (hint !== undefined) line += `\thint=${hint}`; process.stderr.write(line + "\n"); } else { // human const emsg = ctx.color ? `${RED}error:${RESET} ${message}` : `error: ${message}`; process.stderr.write(emsg + "\n"); if (hint !== undefined) { const hmsg = ctx.color ? `${DIM}hint: ${hint}${RESET}` : `hint: ${hint}`; process.stderr.write(hmsg + "\n"); } } if (!isCli && process.env.KESTREL_DEBUG === "1" && err instanceof Error && err.stack !== undefined) { process.stderr.write(err.stack + "\n"); } return exit; }