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.

416 lines (388 loc) 17.8 kB
/** * # cli/backend/oversight-remote — `RemoteOversight`, the SSE client for the oversight stream * (kestrel-telx.5; contract §4). * * The REMOTE half of the {@link ../../protocol/oversight.ts OversightBackend} seam — the CLI (Ink) * and the Web dashboard are the two clients of ONE model, so an event the CLI cannot name is not an * event the Web may quietly render. Its local twin is {@link ../../oversight/local.ts LocalOversight}. * * ## THE ROUTE (§4.1) * ``` * GET /sessions/{sessionId}/oversight?cursor={opaque} Accept: text/event-stream * Last-Event-ID: {same opaque} * ``` * `sessionId` is the deterministic `SessionId` (genesis hash) — the identity a self-hosted kestrel * and the managed backend both derive. * * ## TRANSPORT — REUSED, never re-implemented * Framing, the opaque cursor, `?cursor=`+`Last-Event-ID` agreement, the bounded reconnect budget and * the "off-vocabulary `event:` ⇒ fail closed" gate are the shared leaf's * ({@link ./sse.ts} → {@link ../../client/sse-transport.ts}). This module supplies the oversight * VOCABULARY (`isOversightEventType`), the oversight ERROR taxonomy, and the five remaining * §4.3 rules; it owns no framing code. The leaf is push-based (`onFrame`) and the seam is pull-based * (`AsyncIterable`), so the only glue here is a bounded hand-off queue. * * ## THE SIX FAIL-CLOSED CLIENT RULES (§4.3) — each one loud, none silent * 1. Unknown `event:` name ⇒ `OVERSIGHT_PROTOCOL_VIOLATION`. Enforced by handing the leaf * `isOversightEventType` + {@link oversightProtocolViolation}, so the gate sits on the REAL * transport path (not a private re-check that a caller could bypass). * 2. Non-JSON `data:` on a typed event ⇒ violation. So is a body that does not match the wire * shape — {@link ../../protocol/oversight-wire.ts} decodes field-by-field and throws. * 3. SNAPSHOT-FIRST. A delta before any `oversight.snapshot` ⇒ violation (a Rendering may not * invent a baseline). A re-emitted snapshot RE-BASELINES; one that regresses below the last * seen `seq` is a lying stream ⇒ violation. * 4. MONOTONE seq on every BUS-BACKED event (`busSeqOf`); `seq <= last` ⇒ violation. * 5. TERMINAL. `oversight.finalized` ends the stream cleanly; `oversight.failed` THROWS * (`OVERSIGHT_FAILED`). A stream that simply STOPS without either reaches the leaf's reconnect * budget and dies as a hard 504 — a truncated stream can never read as success. * 6. The cursor is OPAQUE. It is never parsed here — {@link OversightStreamReader} is not even * given it, and no cursor is ever derived from a decoded body; the leaf hands the last `id:` * back verbatim on BOTH `?cursor=` and `Last-Event-ID`. * * ## 402 IS DATA (ADR-0004) * {@link RemoteOversight.openStream} returns {@link Gated}: at the paid boundary the platform's * structured `OfferResponse` is surfaced as a VALUE, never as an exception and never as a browser * redirect (`human_action.url` is never followed). The plain {@link RemoteOversight.stream} is the * `OversightBackend` method and has no data channel for an Offer, so it delegates to `openStream` * and fails LOUDLY when gated — never an empty stream that reads as "no events". * * ## WHAT IS DELIBERATELY NOT HERE (fail closed, never a stub) * `submitAct` / `say` / `respond` have NO platform route: §4.1 adds exactly one route, and the * write-side authz is contract §10's named open question. They REFUSE loudly, exactly as * {@link ./remote.ts RemoteBackend.getPercept} refuses a route that does not exist. A stub that * returned `ok:true` would be a lie about coverage. */ import type { Gated } from "./index.ts"; import { CliError, EXIT } from "../errors.ts"; import { type FetchLike, consumeSseStream, httpErr } from "./sse.ts"; import { type InputResolution, type OversightAct, type OversightBackend, type OversightEvent, type OversightFrame, type OversightIdentity, isOversightEventType, } from "../../protocol/oversight.ts"; import type { SessionId } from "../../protocol/session.ts"; import { OversightWireError, asOfferResponse, busSeqOf, decodeOversightEvent, type WireOfferResponse, } from "./wire.ts"; /** Fail-closed error for anything the oversight contract forbids (§4.3). The mirror of * {@link ./sse.ts unknownEvent}, with the contract's own code. */ export function oversightProtocolViolation(detail: string): CliError { return new CliError({ code: "OVERSIGHT_PROTOCOL_VIOLATION", exit: EXIT.RUNTIME_UNAVAILABLE, message: `oversight protocol violation — ${detail}`, }); } /** Rule 1's factory, in the shape the transport requires. */ function unknownOversightEvent(type: string): CliError { return oversightProtocolViolation( `unknown event ${JSON.stringify(type)} — not in the closed OversightEventType vocabulary`, ); } /** The client's private cancel signal (a consumer abandoned the iterator). NOT an error — it is * swallowed at the one place it can arise, and it is a class of its own so it can never be * confused with a protocol violation. */ class StreamCancelled {} /** * The RULE MACHINE — pure, stateful over ONE connection's event order: rules 2, 3, 4 and 5. * * Deliberately NOT given the cursor (rule 6: an opaque bookmark this layer has no business * reading). Rule 1 is not here either: it belongs on the transport's vocabulary gate, where every * frame must pass it. */ export class OversightStreamReader { private baselined = false; private lastSeq = -1; /** Decode one in-vocabulary frame, applying rules 2–5. Throws (fail closed) on any violation; * returns `done` on the clean terminal. */ accept(type: string, data: string): { readonly event: OversightEvent; readonly done: boolean } { if (!isOversightEventType(type)) throw unknownOversightEvent(type); // defensive; the gate is upstream // Rule 2 — non-JSON `data:` on a typed event is a violation. let body: unknown; try { body = JSON.parse(data); } catch { throw oversightProtocolViolation(`event ${type} carried non-JSON data: (${data.length} bytes)`); } let event: OversightEvent; try { event = decodeOversightEvent(type, body); } catch (e) { // A wire-shape mismatch is the same class of failure as non-JSON: the client cannot name what // it received, so it refuses rather than rendering a partially-decoded frame. if (e instanceof OversightWireError) throw oversightProtocolViolation(e.message); throw e; } // Rule 3 — snapshot-first / re-baseline. if (event.type === "oversight.snapshot") { this.baselined = true; // A re-baseline may only ADVANCE the seq watermark. The contract lets the server re-emit a // snapshot at any point, including a slightly stale one on a resume — that is legal and is not // refused here (refusing it would be an invented rule). What it may NEVER do is RELAX rule 4: // taking the max means a replayed delta still reds instead of sneaking in behind a stale // baseline. this.lastSeq = Math.max(this.lastSeq, event.frame.asofSeq); return { event, done: false }; } if (!this.baselined) { throw oversightProtocolViolation( `${event.type} arrived before any oversight.snapshot — a Rendering may not invent a baseline`, ); } // Rule 4 — monotone seq on the bus-backed members. const seq = busSeqOf(event); if (seq !== null) { if (seq <= this.lastSeq) { throw oversightProtocolViolation( `${event.type} regressed seq ${seq} <= last seen ${this.lastSeq} — a stream that regresses is lying`, ); } this.lastSeq = seq; } // Rule 5 — terminals. if (event.type === "oversight.failed") { throw new CliError({ code: "OVERSIGHT_FAILED", exit: EXIT.RUNTIME_UNAVAILABLE, message: `oversight stream failed: ${event.reason}`, }); } return { event, done: event.type === "oversight.finalized" }; } } /** A one-slot-at-a-time hand-off from the push transport to the pull `AsyncIterable`. */ class HandoffQueue<T> { private readonly buffer: T[] = []; private waiting: { resolve: (v: IteratorResult<T>) => void; reject: (e: unknown) => void } | undefined; private ended = false; private failure?: unknown; cancelled = false; push(v: T): void { if (this.waiting) { const w = this.waiting; this.waiting = undefined; w.resolve({ value: v, done: false }); } else this.buffer.push(v); } close(): void { this.ended = true; if (this.waiting) { const w = this.waiting; this.waiting = undefined; w.resolve({ value: undefined, done: true }); } } fail(e: unknown): void { this.failure = e; this.ended = true; if (this.waiting) { const w = this.waiting; this.waiting = undefined; w.reject(e); } } async *drain(): AsyncGenerator<T> { try { for (;;) { if (this.buffer.length > 0) { yield this.buffer.shift() as T; continue; } if (this.failure !== undefined) throw this.failure; if (this.ended) return; const next = await new Promise<IteratorResult<T>>((resolve, reject) => { this.waiting = { resolve, reject }; }); if (next.done) return; yield next.value; } } finally { // A consumer that walks away (`break`) stops the underlying connection at the next frame. this.cancelled = true; } } } export interface RemoteOversightOptions { readonly baseUrl: string; /** The deterministic `SessionId` (genesis hash) the route is keyed by. */ readonly sessionId: SessionId; readonly fetch?: FetchLike; /** Authorization (and any other) headers for each request. Absent ⇒ unauthenticated. */ readonly auth?: () => Promise<Record<string, string>>; /** * Parse-first resolution of the one input box (§3.8). A REQUIRED dependency, exactly as in * `LocalOversight`: wiring a stub here would be a surface whose stated coverage exceeds its * actual coverage. */ readonly resolveInput: (text: string) => InputResolution; /** Reconnect budget override (tests); defaults to the leaf's `MAX_RECONNECTS`. */ readonly maxReconnects?: number; } export class RemoteOversight implements OversightBackend { readonly kind = "remote" as const; private readonly base: string; private readonly sessionId: SessionId; private readonly fetch: FetchLike; private readonly auth?: () => Promise<Record<string, string>>; private readonly resolver: (text: string) => InputResolution; private readonly maxReconnects?: number; constructor(opts: RemoteOversightOptions) { this.base = opts.baseUrl.replace(/\/+$/, ""); this.sessionId = opts.sessionId; const f = opts.fetch ?? (globalThis.fetch as FetchLike | undefined); if (f === undefined) throw new CliError({ code: "RUNTIME_UNAVAILABLE", exit: EXIT.RUNTIME_UNAVAILABLE, message: "global fetch unavailable — need node ≥18 or bun for the remote oversight stream", }); this.fetch = f; if (opts.auth !== undefined) this.auth = opts.auth; this.resolver = opts.resolveInput; if (opts.maxReconnects !== undefined) this.maxReconnects = opts.maxReconnects; } /** §4.1 — the one oversight route. */ private endpoint(): string { return `${this.base}/sessions/${encodeURIComponent(this.sessionId)}/oversight`; } /** * Open the stream. THE GATED ENTRY POINT: a 402 at the paid boundary comes back as * `{gated:true, payment}` — DATA, never an exception, never a redirect. * * `after` is the OPAQUE cursor handed back verbatim; absent ⇒ `""` (a cold connection, which the * contract answers with a snapshot first). */ async openStream(after?: string): Promise<Gated<AsyncIterable<OversightEvent>>> { const queue = new HandoffQueue<OversightEvent>(); const reader = new OversightStreamReader(); let payment: WireOfferResponse | null = null; let opened: (() => void) | undefined; const firstResponse = new Promise<void>((resolve) => { opened = resolve; }); /** The gating wrapper: it sees the RESPONSE, which is the only place a 402 body exists. The * transport itself stays a transport — it never learns what an Offer is. */ const gatedFetch: FetchLike = async (url, init) => { const res = await this.fetch(url, init); if (res.status === 402) { const body = (await res.json().catch(() => null)) as unknown; payment = asOfferResponse(body); // An unreadable 402 body is NOT a gate we can surface as data — refuse loudly (do not // resolve `firstResponse`, so `openStream` rejects with this error rather than handing back // a stream that would quietly yield nothing). if (payment === null) throw httpErr(402, "oversight stream gated with an unreadable 402 body"); opened?.(); // Hand the transport a body-less 402 so it fails closed; `openStream` turns that into DATA. return new Response(null, { status: 402 }); } opened?.(); return res; }; const run = consumeSseStream({ fetch: gatedFetch, endpoint: this.endpoint(), cursor: after ?? "", headers: this.auth ? await this.auth() : {}, // RULE 1 lives HERE — on the real transport's vocabulary gate. isEventType: isOversightEventType, unknownEventError: unknownOversightEvent, httpError: httpErr, ...(this.maxReconnects !== undefined ? { maxReconnects: this.maxReconnects } : {}), // The `cursor` argument is deliberately NOT forwarded to the reader (rule 6), and no cursor is // ever returned from a decoded body — the opaque `id:` is the only bookmark. onFrame: (type, data) => { if (queue.cancelled) throw new StreamCancelled(); const { event, done } = reader.accept(type, data); queue.push(event); return { done }; }, }); run.then( () => queue.close(), (e: unknown) => (e instanceof StreamCancelled ? queue.close() : queue.fail(e)), ); // Resolve as soon as the FIRST response is known (gated or not), or if the run dies first. await Promise.race([firstResponse, run.then(() => undefined)]); if (payment !== null) return { gated: true, payment }; return { gated: false, value: queue.drain() }; } /** * The `OversightBackend` stream. Has no data channel for an Offer, so a gated session throws * LOUDLY (carrying the offer id) rather than yielding an empty stream that would read as * "nothing happened". Callers that can render an Offer use {@link openStream}. */ stream(after?: string): AsyncIterable<OversightEvent> { const opened = this.openStream(after); return { [Symbol.asyncIterator]: async function* () { const g = await opened; if (g.gated) { throw new CliError({ code: "REMOTE_GATED", exit: EXIT.GENERIC, message: `the oversight stream is gated behind offer ${g.payment.offer.offer_id} (${g.payment.offer.amount.value} ${g.payment.offer.amount.asset})`, hint: "settle the offer, or use openStream() to render it as data", }); } yield* g.value; }, }; } /** * The current view. The contract guarantees SNAPSHOT-FIRST (§4.3 rule 3), so the frame IS the * stream's first event — there is no second route to fetch it from and none is invented. The * iterator is abandoned right after, which stops the connection at the next frame. */ async frame(): Promise<OversightFrame> { for await (const e of this.stream()) { if (e.type === "oversight.snapshot") return e.frame; // Unreachable while rule 3 holds; if it ever is reached, the rule is broken — say so. throw oversightProtocolViolation(`expected oversight.snapshot first, got ${e.type}`); } throw oversightProtocolViolation("the oversight stream ended before delivering a snapshot"); } async identity(): Promise<OversightIdentity> { return (await this.frame()).identity; } /** PURE and TOTAL (§3.8): a resolver that throws becomes a `parse-error`, never chat. */ resolveInput(text: string): InputResolution { try { return this.resolver(text); } catch (e) { return { kind: "parse-error", diagnostics: [`input resolution failed: ${String(e)}`] }; } } submitAct(_act: OversightAct): Promise<never> { return Promise.reject(this.noWriteRoute("submitAct")); } say(_text: string, _to: string | null): Promise<never> { return Promise.reject(this.noWriteRoute("say")); } respond( _r: | { readonly kind: "authored"; readonly document: string } | { readonly kind: "pass" } | { readonly kind: "stand-down"; readonly reason: string }, ): Promise<never> { return Promise.reject(this.noWriteRoute("respond")); } /** The write side of the seam has NO platform route yet (contract §4.1 adds exactly one route; * §10 names the authz question as open). Refuse loudly — never a fabricated route, never a * silent `ok:true`. */ private noWriteRoute(op: string): CliError { return new CliError({ code: "REMOTE_UNSUPPORTED", exit: EXIT.USAGE, message: `${op} has no remote contract route — the oversight contract defines exactly one route today: GET /sessions/{id}/oversight`, hint: "run the session locally (LocalOversight) until the write routes land", }); } }