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
text/typescript
/**
* # 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 = "[31m";
const DIM = "[2m";
const RESET = "[0m";
/**
* 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;
}