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.
292 lines (291 loc) • 16.8 kB
JavaScript
/**
* # sdk/remote — the HTTP transport: the 109 client over the 5rb wire (kestrel-djm.5)
*
* The LIGHT transport. `remoteTransport({ baseUrl, fetch })` binds the kestrel-109 {@link KestrelClient}
* (mint / validate / sim / grade over the 5rb wire dialect) and re-exposes it under the ONE typed SDK
* contract. Catalog discovery / validation / grade delegate to the client verbatim; an incremental Session
* is a CLIENT-SIDE REPLAY of the server-computed, catalog-pinned transcript the `POST /sim` stream carries
* (the wire deliberately omits an `advance` — a face is a pure encoder over the controller, never a forked
* progression). Because the server ran the SAME engine the LOCAL transport runs, the replayed pentad chain /
* Blotter / grade / root are BYTE-IDENTICAL to the LOCAL run (the djm.5 headline).
*
* FAIL-CLOSED, PROTOCOL-UNIFORM: a 402/Offer is DATA on {@link Gated} (surfaced, never thrown / redirected);
* an unknown subject is a typed {@link KestrelClientError} (the 109 client's non-2xx throw); an off-contract
* SSE event is the 109 client's `SSE_PROTOCOL_VIOLATION` (fail-closed, never laundered into a face state);
* a replay turn that diverges from the recorded script is a typed djm.4 {@link SessionResponse} refusal.
*
* DEPENDENCY BOUNDARY (djm.3 / djm.5): the ONLY value import is the 109 client (+ its re-exported wire
* types) and this SDK's light core (`facade.ts` / `types.ts`). Every djm.2/djm.4 shape is a TYPE-ONLY import
* (erased). NO local runtime — no djm.4 controller, no simulate/sim driver, no engine tree, no djm.8 loader
* — reaches this graph. Pinned by tests/package.boundary.test.ts.
*
* ── DESIGN FORK LOGGED (hard rules forbid .beads writes) — the HTTP Session is a server-run REPLAY ──
* A catalog Session's SUBJECT is a FIXED, server-pinned script (arm-once/pass-forever). The HTTP transport
* runs it server-side ONCE at `openSession` (`POST /sim` resolves the catalog dataset to its pre-computed
* record) and REPLAYS the record verb-by-verb: `start`/`advance` deliver the recorded Frames and return the
* recorded pentad-chained {@link import("../protocol/session.ts").TurnEntry}s verbatim (never re-sealed — the
* server's entries ARE the truth), verifying each caller turn against the recorded script (a divergent turn
* is a typed `turn-conflict` refusal — fail-closed). This mirrors "HTTP is canonical; the SDK is a lossless
* projection" (djm.5 design). The minimal shape consistent with djm.4's Delivery/SessionResponse.
*/
import { KestrelClient, KestrelClientError } from "../client/index.js";
import { decodeCatalogPage } from "../catalog/catalog-listing.js";
import { SDK_VERSION, createSdk } from "./facade.js";
// Re-export the light SDK core so a remote-only consumer imports EVERYTHING it needs from this ONE light
// entry: `import { createSdk, remoteTransport } from "kestrel.markets/sdk/remote"`.
export { createSdk, SDK_VERSION } from "./facade.js";
const isRecord = (v) => typeof v === "object" && v !== null;
/** The disposition a replayed response earns (the djm.4 receipt label; not asserted by the parity test but
* kept honest). */
function dispositionOf(r) {
switch (r.kind) {
case "authored":
return "armed";
case "pass":
return "pass";
case "stand-down":
return "stood-down";
}
}
/**
* The RECORDED body a caller response must match for a faithful replay. Returns `null` on a match, else the
* typed djm.4 diagnostic for the divergence (fail-closed — the HTTP replay honors only the server-pinned
* script; a divergent turn cannot be re-run against a fixed server transcript).
*/
function matchOrDiagnose(body, r) {
switch (body.kind) {
case "authored":
// An empty authored body is a `pass` (a no-op continuation); a non-empty one is an authored document.
if (body.bytes === "")
return r.kind === "pass" ? null : "turn-conflict";
return r.kind === "authored" && r.document === body.bytes ? null : "turn-conflict";
case "stand-down":
return r.kind === "stand-down" && r.reason === body.reason ? null : "turn-conflict";
case "failure":
// A recorded host failure has no re-authorable content; only a `pass` continues past it.
return r.kind === "pass" ? null : "turn-conflict";
}
}
/**
* A HTTP Session — a client-side REPLAY of the server-computed, catalog-pinned record. Every returned object
* is the server's verbatim djm.2/djm.4 protocol shape (byte-identical to the LOCAL run).
*/
class RemoteSession {
record;
sessionId;
cursor = 0; // index of the next uncommitted recorded turn
started = false;
constructor(record) {
this.record = record;
this.sessionId = record.sessionId;
}
async start() {
if (this.started)
throw new KestrelClientError({ code: "ALREADY_STARTED", message: "start() may only be called once, first" });
this.started = true;
const first = this.deliveryAt(0);
if (first === null)
throw new KestrelClientError({ code: "EMPTY_TRANSCRIPT", message: "the server returned an empty Session transcript" });
return first;
}
advance(response) {
return Promise.resolve(this.commit(response));
}
revise(response) {
return Promise.resolve(this.commit(response));
}
async submit(response) {
const entry = this.record.entries.find((e) => e.ordinal === response.ordinal);
if (entry === undefined)
return { ok: false, diagnostic: "broken-chain" };
if (response.sessionId !== this.sessionId)
return { ok: false, diagnostic: "wrong-session" };
if (response.parentHash !== entry.parentHash)
return { ok: false, diagnostic: "broken-chain" };
if (response.frameRoot !== entry.frameRoot)
return { ok: false, diagnostic: "stale-frame" };
if (!sameBody(entry.body, response.body))
return { ok: false, diagnostic: "turn-conflict" };
const receipt = { entry: entry, disposition: bodyDisposition(entry.body) };
const idx = this.record.entries.indexOf(entry);
const next = this.deliveryAt(idx + 1);
return { ok: true, receipt, next };
}
async resume() {
return { sessionId: this.sessionId, entries: this.record.entries };
}
async finalize() {
return {
blotter: this.record.blotter,
sessionId: this.sessionId,
tipHash: this.record.tipHash,
conformanceRoot: this.record.conformanceRoot,
artifacts: this.record.artifacts,
};
}
deliveryAt(i) {
const entry = this.record.entries[i];
if (entry === undefined)
return null;
return {
sessionId: this.sessionId,
ordinal: entry.ordinal,
parentHash: entry.parentHash,
frameRoot: entry.frameRoot,
frame: this.record.frames[i],
};
}
commit(response) {
const entry = this.record.entries[this.cursor];
if (entry === undefined)
return { ok: false, diagnostic: "not-pending" };
const mismatch = matchOrDiagnose(entry.body, response);
if (mismatch !== null)
return { ok: false, diagnostic: mismatch };
this.cursor += 1;
const receipt = { entry: entry, disposition: dispositionOf(response) };
return { ok: true, receipt, next: this.deliveryAt(this.cursor) };
}
}
/** Structural body equality for the explicit-binding submit path (no re-seal). */
function sameBody(a, b) {
if (a.kind !== b.kind)
return false;
if (a.kind === "authored" && b.kind === "authored")
return a.bytes === b.bytes;
if (a.kind === "stand-down" && b.kind === "stand-down")
return a.reason === b.reason;
if (a.kind === "failure" && b.kind === "failure")
return a.failureClass === b.failureClass;
return false;
}
/** The disposition of a recorded body (the submit path's receipt label). */
function bodyDisposition(body) {
switch (body.kind) {
case "authored":
return body.bytes === "" ? "pass" : "armed";
case "stand-down":
return "stood-down";
case "failure":
return "failure";
}
}
/** Resolve a {@link SessionRef} (+ an optional caller-supplied strategy `document`) to the `POST /sim` inputs.
* A catalog subject rides its `id` DIRECTLY as `dataset.artifact_id` — the BARE platform artifact handle the
* server resolves against its catalog store; a raw dataset carries its own `source` + `dataset`.
*
* kestrel-p6hz: the `catalog:` PREFIX this used to prepend (`dataset: "catalog:" + subject.id`) is a
* CLIENT-INVENTED namespace the deployed server never speaks — `POST /sim` with `dataset.artifact_id =
* "catalog:art_…"` fail-closes `HTTP_404 'dataset catalog:art_… is not present in the catalog store'`, while
* the BARE `dataset.artifact_id = "art_…"` opens cleanly (both verified against live prod). So a discovered
* free catalog row 404'd through the agent face even though the same artifact opens over raw HTTP — the
* equal-projections violation. Sending the bare handle removes the prefix defect; the SimRequest's
* `artifact_id` is a bare Id (the vendored OpenAPI contract), and `catalog()` now surfaces exactly that bare
* handle as each row's `id`, so `openSession({ kind:"catalog", id: row.id })` is self-consistent end-to-end.
*
* The optional `document` is the customer strategy the managed author-no-strategy fence requires (ADR-0012,
* kestrel-88u2) — matching the sim verb's `--plans` semantics: when present it is the sim `source` (for a
* catalog subject, whose `source` is structurally `""` otherwise; for a dataset subject it overrides the
* ref's own source). A bare openSession (no document) keeps `source:""`: the platform's fence answers, now
* legibly (Bug A). */
function simInputs(subject, document) {
return subject.kind === "catalog"
? { source: document ?? "", dataset: subject.id }
: { source: document ?? subject.source, dataset: subject.dataset };
}
/**
* Build the HTTP transport over the managed API. Wrap it with `createSdk(remoteTransport({ baseUrl, fetch }))`.
* Delegates catalog / validate / grade to the 109 client; runs + replays a catalog Session's server-computed
* transcript for the incremental surface. Fail-closed and protocol-uniform throughout.
*/
export function remoteTransport(options) {
const base = options.baseUrl.replace(/\/+$/, "");
const fetchImpl = options.fetch ?? globalThis.fetch;
const client = new KestrelClient({
baseUrl: options.baseUrl,
...(options.fetch !== undefined ? { fetch: options.fetch } : {}),
...(options.capability !== undefined ? { capability: options.capability } : {}),
});
return {
kind: "http",
version: `${SDK_VERSION}+http`,
async catalog() {
const res = await fetchImpl(`${base}/catalog`, { headers: { accept: "application/json" } });
if (!res.ok) {
throw new KestrelClientError({ code: `HTTP_${res.status}`, message: `GET /catalog failed (HTTP ${res.status})`, status: res.status });
}
// FAITHFUL wire decode into the canonical CatalogPage (kestrel-adge) — the byte-identical
// CatalogListing[] (kestrel-dnxq; `artifact_id → id`, `frame_count → frameCount`, drop the
// `content_hash` replay pin, fail-closed on an unaddressable row) PLUS the optional ex-ante `pricing`
// block surfaced VERBATIM when the served `{ entries, pricing }` body carries one (absent ⇒ a bare page).
return decodeCatalogPage(await res.json());
},
validate(document) {
return client.validate(document);
},
async openSession(subject, document) {
const { source, dataset } = simInputs(subject, document);
let record;
const opened = await client.sim({
source,
dataset,
onEvent: (e) => {
const rec = e.data?.["session"];
if (isRecord(rec))
record = rec;
},
});
if (opened.gated)
return { gated: true, payment: opened.payment };
if (record === undefined) {
// A 2xx sim that carried no Session record cannot back an incremental replay — fail closed, never
// fabricate a Session (kestrel-markets-vuiy: the managed transport ran the subject as a BATCH
// simulation but emits no incremental Session transcript to replay; managed incremental openSession
// over a catalog subject is not yet a served surface — tracked cross-repo). Rather than the opaque
// "not an incremental subject" (which contradicted describe's own catalog contract), the refusal now
// POINTS AT THE BATCH PATH the same subject DOES run on, so a wire-first integrator is redirected
// instead of stranded. The self-hosted LOCAL transport still opens the SAME catalog subject
// incrementally today (`createSdk(localTransport())`).
const subjectClause = subject.kind === "catalog"
? `the managed transport does not (yet) serve an incremental Session over catalog subject ${JSON.stringify(subject.id)} — the /sim stream carried no Session transcript to replay. `
: "the /sim stream carried no Session transcript to replay for this subject. ";
throw new KestrelClientError({
code: "NO_SESSION_RECORD",
message: subjectClause +
"Run it as a BATCH simulation instead: the `sim` verb, or the SDK `client.sim({ source, dataset })` — the same drive to a Certified grade, minus the incremental start/advance/finalize surface. (The self-hosted LOCAL transport opens catalog subjects incrementally today.)",
});
}
return { gated: false, value: new RemoteSession(record) };
},
grade(request) {
return client.grade({ blotters: request.blotters });
},
async artifact(ref) {
const res = await fetchImpl(`${base}/artifacts/${encodeURIComponent(ref)}`, { headers: { accept: "application/json" } });
if (!res.ok) {
throw new KestrelClientError({ code: `HTTP_${res.status}`, message: `artifact ${JSON.stringify(ref)} not resolvable (HTTP ${res.status})`, status: res.status });
}
// The server returns the SAME { ref, kind, value } the LOCAL projection yields (src/sdk/local.ts
// artifact()); the transport re-exposes it verbatim so both faces return a byte-identical ArtifactResult.
const body = (await res.json());
return { ref, kind: typeof body.kind === "string" ? body.kind : "artifact", value: body.value };
},
async resumeOperation(ref) {
// A COMPLETED operation's OUTCOME (blotter / report / receipt + artifact refs) is carried on its
// CANONICAL RESUMABLE EVENT STREAM (`GET /operations/{id}/events`) — the SAME stream `sim`/`grade`
// consume — NOT on the plain `GET /operations/{id}` status envelope, which carries NO domain payload for
// a completed op in production (it returns empty, so the old plain-endpoint read handed a wire-first
// integrator an EMPTY payload — kestrel-o3bi: the contract mock had masked this by fabricating a payload
// the real endpoint never serves). Replay the event stream (resumable via the opaque `after` cursor) and
// accumulate the terminal payload, so a resumed Operation returns the SAME { blotter, grade, artifacts }
// the LOCAL projection yields (src/sdk/local.ts resumeOperation) — byte-identical across transports (the
// djm.5 parity headline), now over the stream production actually serves. The stream is read to its
// TERMINAL event, so the resumption cursor is `null` (nothing remains to resume) — matching LOCAL.
const { payload } = await client.resumeOperation({
operationId: ref.operationId,
...(ref.after?.token !== undefined ? { after: ref.after.token } : {}),
});
return { operationId: ref.operationId, cursor: null, payload };
},
};
}