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.
123 lines (122 loc) • 7.16 kB
JavaScript
/**
* # bus/write — the single-writer, append-only JSONL bus writer (RUNTIME §1)
*
* `BusWriter` is the ONE thing that assigns `seq`. It is a **single writer** by contract:
* one writer owns one file; `seq` is monotonic and gap-free from 0; every `append` emits
* exactly one JSONL line and **flushes it** (each event is durable the instant it is
* written — that is what makes the reader's torn-final-line tolerance the only crash mode
* it must handle, RUNTIME §1).
*
* The envelope is serialized in canonical field order `{seq, ts, stream, type, ...payload}`
* (RUNTIME §1). Serialization is a pure function of the event, so the same event sequence
* always produces byte-identical text — the determinism substrate for replay (RUNTIME §0)
* and for byte-identical synthetic sessions.
*/
// ─────────────────────────────────────────────────────────────────────────────
// Pure serialization (the byte-stable substrate)
// ─────────────────────────────────────────────────────────────────────────────
/** Serialize one event to its single JSONL line (including the trailing newline).
* Canonical field order: `seq, ts, stream, type`, then payload in declaration order. */
export function serializeEvent(event) {
return JSON.stringify(event) + "\n";
}
/** Serialize a whole event sequence to bus text. Pure and total: same events ⇒ same bytes. */
export function serializeBus(events) {
let out = "";
for (const ev of events)
out += serializeEvent(ev);
return out;
}
/** Write a whole (already-`seq`-stamped) event sequence to a file in one shot. Used to
* persist a synthetic session; `BusWriter` is for incremental engine writes. */
export async function writeBusFile(path, events) {
await Bun.write(path, serializeBus(events));
}
// ─────────────────────────────────────────────────────────────────────────────
// Envelope assembly (seq stamping) — the one place seq is minted
// ─────────────────────────────────────────────────────────────────────────────
/**
* Stamp a `seq` onto a seq-less event, producing the CANONICAL-ORDER envelope
* `{seq, ts, stream, type, ...payload}` (RUNTIME §1) — **the one owner of bus field order**.
*
* It re-canonicalizes rather than prepending, and that is the whole point: `serializeEvent` is
* `JSON.stringify`, which is INSERTION-ORDER-dependent, so a caller spreading a payload that itself
* carries `ts`/`stream`/`type` mid-object (`{ stream, type, ...held.thetaBleed }` where `thetaBleed`
* owns a `ts`) would otherwise serialize a NON-canonical record onto the graded bus. Destructuring the
* envelope fields out and re-emitting them first makes the canonical order a property of this function
* instead of a discipline every call site must remember (kestrel-byfl; the pta5 instance).
*
* `type` is re-emitted ONLY when the caller has one: JOURNAL's discriminant is `kind`, not `type`
* ({@link BusWriter.appendJournal}), and an unconditional `type` would inject a `type: undefined` key
* into the in-memory envelope — invisible in the bytes (`JSON.stringify` drops it) but visible to any
* reader that asks `"type" in ev`. Never a silent default.
*
* The cast is the single boundary where the flat union is reassembled; the overloads on
* {@link BusWriter.append} and the {@link NewBusEvent} shape guarantee the fields are right.
*/
export function canonicalEnvelope(seq, ev) {
const { ts, stream, type, ...payload } = ev;
return (type === undefined ? { seq, ts, stream, ...payload } : { seq, ts, stream, type, ...payload });
}
// ─────────────────────────────────────────────────────────────────────────────
// BusWriter
// ─────────────────────────────────────────────────────────────────────────────
/**
* The single writer of a bus file. Construct one per session; `append` stamps the next
* `seq`, writes the JSONL line, and flushes. `close()` ends the underlying sink; any
* `append` after `close()` throws (single-writer, no-reopen).
*
* `append(stream, type, payload, ts)` is overloaded per stream so the payload is checked
* against the stream/type pair. The returned value is the fully-stamped event (with `seq`),
* so a caller can echo it or fold it immediately.
*/
export class BusWriter {
constructor(path) {
this.
}
/** The next `seq` that will be assigned (i.e. the count of events written so far). */
get nextSeq() {
return this.
}
append(stream, type, payload, ts) {
if (this.
throw new Error("BusWriter: append after close (single-writer, no reopen)");
}
const seq = this.
const event = canonicalEnvelope(seq, { ts, stream, type, ...payload });
this.
this.
return event;
}
/**
* Append a JOURNAL record — the author's reasoning, full markdown `body` inline (bus_schema
* v2). JOURNAL has no engine `type`; its discriminant is `kind`, so it takes its own writer
* method rather than an {@link append} overload. Mints the next `seq` exactly like `append`,
* which is what makes the pre-hoc/post-hoc seq contract a fact of the log: write an `author`
* record before you arm and its `seq` is provably below the first armed PLAN's; append a
* `debrief` after CLOSE and its `seq` is provably above the CLOSE's.
*
* JOURNAL is author metadata, never an engine input — appending these records cannot move the
* emitted engine event stream or the determinism hash (the determinism invariant, a57.11).
*/
appendJournal(kind, body, ts) {
if (this.
throw new Error("BusWriter: append after close (single-writer, no reopen)");
}
const seq = this.
const event = { seq, ts, stream: "JOURNAL", kind, body };
this.
this.
return event;
}
/** End the sink and forbid further appends. Idempotent. */
async close() {
if (this.
return;
this.
await this.
}
}