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
text/typescript
/**
* # 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",
});
}
}