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.
293 lines (279 loc) • 17.7 kB
text/typescript
/**
* # ledger/hosted — the LOCAL receipt of a HOSTED `sim` run (bead kestrel-pvlc).
*
* The durable {@link ./index.ts run/plan/lineage ledger} is `bun:sqlite` and records only LOCAL
* `run`/`day` sessions. A hosted `sim` (`../cli/commands/sim.ts`) is LIGHT by construction — it must
* run under plain node for every agent who copies the snippet, so it can touch NEITHER `bun:sqlite`
* NOR the network beyond the funnel it already drove. Yet the doctrine that every state change leaves
* a LEGIBLE, RETRIEVABLE receipt applies to hosted sims too: without a local ledger of its own
* attempts, a first-party agent iterating a strategy across sims has no way to re-list its operations,
* grades, or proof URLs (they print exactly once on stdout).
*
* This module is that sibling lineage: an append-only JSONL file that sim appends one row to per
* completed hosted run, and `runs list` reads. It imports NOTHING Bun-only (node `fs`/`path`/`os`
* only), so it loads on the LIGHT sim path.
*
* ## Where it lives — HOME, not CWD (kestrel-fq78 / kestrel-markets-p3af)
* The receipt store is IDENTITY-BOUND: it lives beside the agent's credential + operator secrets in
* `~/.kestrel/hosted-runs.jsonl` ({@link defaultHostedStorePath}, the `KESTREL_HOME`-overridable home
* established by `cli/credentials.ts`, kestrel-jh9w.1), NOT in a CWD-relative `./data/`. Identity
* already follows the agent globally; the run ledger now follows it too, so `runs list`/`runs show`/
* `runs compare` resolve the same authenticated agent's prior runs — and their `art_`/`op_` handles —
* from ANY working directory. The old CWD-local path ({@link LEGACY_CWD_HOSTED_STORE}) survives ONLY
* as a READ-COMPAT fallback ({@link readHostedRunsMerged}): receipts an older CLI wrote into an
* originating folder stay visible there and are never dropped, but new writes always go home. This is
* a merged READ VIEW, never a migrate-on-touch — a query verb never mints or mutates a store.
*
* ## The rules it holds (mirroring the sqlite ledger)
* - **Idempotent by operation id.** Re-listing collapses rows on `operation_id` (the hosted run's
* identity), keeping the LAST write — a re-run of the same Operation is one receipt, never a
* duplicate (ADR-0007 idempotent-upsert, held at read time since append-only can't rewrite in place).
* - **No wall clock.** The row's `issued_at` is the platform's signed-proof timestamp (server-provided,
* part of the grade), never `Date.now()` — the hosted receipt stays reproducible (RUNTIME §0).
* - **Fail-closed on corruption.** A present-but-unparseable store line is a loud error, never a
* silently-dropped receipt — the same discipline `runs list` holds for a corrupt sqlite store.
*/
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
/** The store's basename, so a caller can derive it as a sibling of a ledger `--db` path or the home dir. */
export const HOSTED_STORE_BASENAME = "hosted-runs.jsonl";
/**
* The LEGACY CWD-local hosted-lineage store — a sibling of the default ledger `data/kestrel.db`. Kept
* ONLY as a READ-COMPAT fallback ({@link readHostedRunsMerged}): older CLIs wrote receipts here, so they
* must stay visible in the originating folder. New writes go to {@link defaultHostedStorePath} (home).
*/
export const LEGACY_CWD_HOSTED_STORE = `data/${HOSTED_STORE_BASENAME}`;
/** The env this module reads (only `KESTREL_HOME`, mirroring `cli/credentials.ts` — for test isolation). */
interface HostedStoreEnv {
readonly [k: string]: string | undefined;
}
/**
* The default (WRITE + primary READ) hosted-run store: `$KESTREL_HOME/.kestrel/hosted-runs.jsonl` (else
* `~/.kestrel/hosted-runs.jsonl`). IDENTITY-BOUND — it lives in the SAME owner-scoped home dir the
* credential + operator-secrets store establish (kestrel-jh9w.1), so the run ledger follows the agent
* across working directories instead of being trapped in the folder a run happened to start in
* (kestrel-fq78 / kestrel-markets-p3af). A pure path: no wall clock, no RNG (RUNTIME §0). `KESTREL_HOME`
* overrides the base dir so tests stay hermetic and CI/containers can redirect it.
*/
export function defaultHostedStorePath(env: HostedStoreEnv = process.env): string {
const home = env.KESTREL_HOME ?? homedir();
return join(home, ".kestrel", HOSTED_STORE_BASENAME);
}
/**
* One hosted-run receipt row. `source` is a literal discriminant so a merged `runs list` can label
* each row's lineage; the rest is the print-once story made retrievable — the scenario slug, the
* Operation id (its identity), the grade digest (order/fill/pnl), the shareable proof URL, the durable
* evidence artifact ids, and the platform's signed timestamp.
*/
export interface HostedRunRow {
readonly source: "hosted";
readonly slug: string;
readonly requested_slug: string;
readonly operation_id: string;
readonly grade_artifact_id: string;
readonly bus_artifact_id: string;
readonly manifest_artifact_id: string;
readonly order_count: number;
readonly fill_count: number;
readonly realized_pnl: number;
readonly proof_url: string;
readonly issued_at: string;
/**
* The human LABEL of the plan/strategy that produced this run — the `--plans <file>` path, or the
* label of the stand-down/bundled default when no `--plans` was supplied (kestrel-etyu). For
* DISPLAY/legibility; it is NOT a content identity — an in-place edit of one `--plans` path keeps
* the same label (see {@link strategy_digest} for the identity that moves with the content).
*
* ADDITIVE-OPTIONAL. Receipts written before this field lack it; a missing (or non-string) value
* reads as UNKNOWN and is a NORMAL back-compat state, never a refusal. This is a LEGIBILITY field
* on the DISPLAY path, NOT the runtime trust path, so its read fails SOFT (the required grade
* fields still fail closed). Historical records are never rewritten.
*/
readonly strategy?: string;
/**
* The deterministic CONTENT identity of the plan/strategy — `sha256` of the canonical plan text,
* 64-char lowercase hex (kestrel-etyu). THIS is what makes two runs distinguishable in the
* returning-user workflow ("edit my plan file in place and re-run the same `--plans` path; did my
* tweak help?"): the {@link strategy} label is identical across such an edit, but the digest moves
* with the content, so `runs list` / `runs compare` can tell the two runs apart. Same ADDITIVE-
* OPTIONAL, fail-soft, never-rewritten discipline as {@link strategy}.
*/
readonly strategy_digest?: string;
/**
* The OPAQUE copy-token this run continued from, recorded VERBATIM (never interpreted — repo
* boundary) so a `--copy-token` continuation threads back to its origin instead of being an
* island (kestrel-gu9h). Optional: a run started without a copy-token carries none, and an older
* receipt line that predates this field still parses (it is not required by the row validator).
*/
readonly copy_token?: string;
/**
* The grader's certified explanation of WHY this run placed no orders (kestrel-kglw) — the
* single highest-value teaching moment of a 0-order hosted run. The hosted `sim` face states it
* ONCE on stdout (the `why` line); without it on the receipt, a returning user who `runs show`s
* the SAME run re-reads a bare `order_count=0` with the reason lost — the exact silent-gate class
* the product claims to have closed, one surface downstream. Recorded so the reason is DURABLE and
* re-openable, not print-once. Surfaced VERBATIM (the CLI never recomputes a grader verdict).
*
* ADDITIVE-OPTIONAL, fail-SOFT (a DISPLAY field, like {@link strategy}): a receipt written before
* this field lacks it; a run that TRADED (order_count > 0) never carries it; an empty/blank value
* NORMALIZES to absent rather than refusing the row (the required grade fields still fail closed).
* Historical records are never rewritten.
*/
readonly never_fired_reason?: string;
}
/**
* Append one hosted-run receipt to the JSONL store at `storePath`, creating the parent dir if absent
* (a fresh cwd has no `data/` yet). One row per line, JSON, newline-terminated — an append-only ledger
* a later `runs list` reads. Zero network; a pure local fs write.
*/
export function appendHostedRun(storePath: string, row: HostedRunRow): void {
const dir = dirname(storePath);
if (dir.length > 0 && !existsSync(dir)) mkdirSync(dir, { recursive: true });
appendFileSync(storePath, JSON.stringify(row) + "\n");
}
/**
* Read the hosted-run receipts at `storePath`, recent-first. An ABSENT store is a NORMAL empty state
* ([]), never a fault — a query verb never mints a store. A PRESENT store with an unparseable line is a
* loud error (fail-closed: a corrupt local ledger is never silently read as empty).
*
* Determinism: rows are collapsed idempotently on `operation_id` (LAST write wins, mirroring the
* sqlite ledger's `INSERT OR REPLACE`), then ordered `issued_at` desc, then `operation_id` asc — a
* total, wall-clock-free order (the timestamp is the server's signed proof time).
*/
export function readHostedRuns(storePath: string): HostedRunRow[] {
return collapseHostedRuns(readRawHostedRows(storePath));
}
/**
* Read the hosted receipts as a MERGED VIEW across the identity-bound HOME store (the primary, and the
* only write target) and any number of legacy CWD-local FALLBACK stores (read-compat for receipts an
* older CLI wrote into an originating folder). This is what makes `runs list`/`runs show`/`runs compare`
* resolve the same authenticated agent's runs from ANY cwd (kestrel-fq78 / kestrel-markets-p3af).
*
* Purely a READ view: it NEVER writes or migrates a store (no migrate-on-touch), holding the invariant
* that a query verb never mints/mutates a store. Rows are deduped by `operation_id` — the HOME store
* WINS a collision (it is the durable, identity-bound record; a run present in both places is the same
* Operation, so this only affects which byte-identical copy is kept) — then the same wall-clock-free
* total order as {@link readHostedRuns} is applied. A corrupt store on ANY source faults LOUDLY
* (fail-closed), never a silently-dropped receipt.
*/
export function readHostedRunsMerged(opts: { home: string; fallbacks?: readonly string[] }): HostedRunRow[] {
// Fallbacks first, HOME last: `collapseHostedRuns` keeps the LAST write per Operation id, so a home
// row overrides a same-id CWD-local row. De-dupe the source paths so a fallback that resolves to the
// home path (e.g. cwd == home) is not read twice.
const seen = new Set<string>();
const rows: HostedRunRow[] = [];
for (const path of [...(opts.fallbacks ?? []), opts.home]) {
if (seen.has(path)) continue;
seen.add(path);
rows.push(...readRawHostedRows(path));
}
return collapseHostedRuns(rows);
}
/** Parse + validate one store's receipt lines in FILE ORDER (no collapse). An absent store is a NORMAL
* empty state ([]). A present-but-unparseable line, or a line that is not a hosted-run row, is a LOUD
* fail-closed error — a corrupt local ledger is never silently read as empty. */
function readRawHostedRows(storePath: string): HostedRunRow[] {
if (!existsSync(storePath)) return [];
const text = readFileSync(storePath, "utf8");
const out: HostedRunRow[] = [];
const lines = text.split("\n");
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]!.trim();
if (line.length === 0) continue;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (e) {
throw new Error(
`ledger/hosted: ${JSON.stringify(storePath)} line ${i + 1} is not valid JSON — refusing to read a corrupt hosted store (fail-closed): ${e instanceof Error ? e.message : String(e)}`,
);
}
const row = asHostedRunRow(parsed);
if (row === null)
throw new Error(
`ledger/hosted: ${JSON.stringify(storePath)} line ${i + 1} is not a hosted-run row — refusing to read a corrupt hosted store (fail-closed)`,
);
out.push(row);
}
return out;
}
/** Collapse receipts idempotently on `operation_id` (LAST in array order wins, mirroring the sqlite
* ledger's `INSERT OR REPLACE`), then order `issued_at` desc, then `operation_id` asc — a total,
* wall-clock-free order (the timestamp is the server's signed proof time). */
function collapseHostedRuns(rows: readonly HostedRunRow[]): HostedRunRow[] {
const byOp = new Map<string, HostedRunRow>();
for (const row of rows) byOp.set(row.operation_id, row); // last write wins (idempotent upsert by Operation id)
return [...byOp.values()].sort(
(a, b) => (a.issued_at < b.issued_at ? 1 : a.issued_at > b.issued_at ? -1 : a.operation_id < b.operation_id ? -1 : 1),
);
}
/**
* The ONE definition of a KNOWN plan digest (kestrel-etyu): a 64-char lowercase-hex sha256. Anything
* else — absent, EMPTY STRING, wrong length/case, non-hex, a non-string — is UNKNOWN.
*
* This predicate is shared by the store's normalization ({@link asHostedRunRow}) and every renderer, so
* "unknown" cannot mean two different things in two places. It exists because it once did: an empty-string
* digest passed a bare `typeof === "string"` check and read as KNOWN, so `runs compare` answered
* `same_plan: true` ("A and B ran the SAME plan content") for two runs whose digests `runs list` was
* simultaneously rendering as `—`. Fabricating a MATCH from UNKNOWN is the dangerous direction: it asserts
* "your tweak changed nothing" when the truth is unknown. Unknown is never a match.
*/
export function isPlanDigest(v: unknown): v is string {
return typeof v === "string" && /^[0-9a-f]{64}$/.test(v);
}
/** Narrow an untrusted parsed line to a {@link HostedRunRow}, or `null` (→ the caller's loud refusal).
* The row is REBUILT from the known field set — an unknown extra field on a store line must never leak
* verbatim into the `kestrel.runs/v1` / `kestrel.hosted-run/v1` machine payloads (the store is local
* but still untrusted input; the wire schemas name exactly these fields). */
function asHostedRunRow(v: unknown): HostedRunRow | null {
if (typeof v !== "object" || v === null || Array.isArray(v)) return null;
const r = v as Record<string, unknown>;
if (r["source"] !== "hosted") return null;
const str = (k: string): boolean => typeof r[k] === "string";
const num = (k: string): boolean => typeof r[k] === "number" && Number.isFinite(r[k] as number);
if (!(str("slug") && str("requested_slug") && str("operation_id") && str("grade_artifact_id"))) return null;
if (!(str("bus_artifact_id") && str("manifest_artifact_id") && str("proof_url") && str("issued_at"))) return null;
if (!(num("order_count") && num("fill_count") && num("realized_pnl"))) return null;
// `strategy` + `strategy_digest` are ADDITIVE-OPTIONAL (kestrel-etyu): a receipt written before the
// fields existed lacks them, and an unusable value is NORMALIZED TO ABSENT rather than refused — these
// are display fields, so they fail SOFT (the required grade fields above still fail closed). Never
// rewritten onto old rows.
//
// Normalization is the point: this is the ONE place an untrusted store line becomes a typed row, so
// "unknown" is decided HERE and nowhere else. An empty-string label and a non-64-hex digest are UNKNOWN
// — not "known and blank". Letting `""` through as a known digest is what once made `runs compare`
// report `same_plan: true` for two runs `runs list` was rendering as `—`.
const strategyRaw = r["strategy"];
const strategy = typeof strategyRaw === "string" && strategyRaw !== "" ? strategyRaw : undefined;
const strategyDigest = isPlanDigest(r["strategy_digest"]) ? r["strategy_digest"] : undefined;
// `copy_token` is OPTIONAL: carried through verbatim when present and a non-empty string, absent
// otherwise (an older receipt predating the field, or a run with no continuation). A present-but-
// non-string value is a malformed row → reject (the caller's loud, fail-closed refusal).
const rawCopyToken = r["copy_token"];
if (rawCopyToken !== undefined && typeof rawCopyToken !== "string") return null;
// `never_fired_reason` is ADDITIVE-OPTIONAL and fail-SOFT (a display field, kestrel-kglw): a non-
// string or blank value normalizes to ABSENT (no reason to surface), never a row refusal — the same
// discipline as `strategy`. Only a non-empty, trimmed string is a reason worth re-showing.
const rawNeverFired = r["never_fired_reason"];
const neverFiredReason =
typeof rawNeverFired === "string" && rawNeverFired.trim().length > 0 ? rawNeverFired : undefined;
return {
source: "hosted",
slug: r["slug"] as string,
requested_slug: r["requested_slug"] as string,
operation_id: r["operation_id"] as string,
grade_artifact_id: r["grade_artifact_id"] as string,
bus_artifact_id: r["bus_artifact_id"] as string,
manifest_artifact_id: r["manifest_artifact_id"] as string,
order_count: r["order_count"] as number,
fill_count: r["fill_count"] as number,
realized_pnl: r["realized_pnl"] as number,
proof_url: r["proof_url"] as string,
issued_at: r["issued_at"] as string,
...(strategy !== undefined ? { strategy } : {}),
...(strategyDigest !== undefined ? { strategy_digest: strategyDigest } : {}),
...(typeof rawCopyToken === "string" && rawCopyToken.length > 0 ? { copy_token: rawCopyToken } : {}),
...(neverFiredReason !== undefined ? { never_fired_reason: neverFiredReason } : {}),
};
}