Controller, claim store, live-session guard, engine link and seal, turn tracker, cohort force stop and recovery, client library, transcript and mediated terminal, with the fake engine and tests. Fixtures only; no live cutover. Dewey built it. Darkwing (comment 26690) and Filbert (comment 26694) approved round 2. Manifest I1-r2-manifest.sha256 (2b48e333, 27 files). Suites on an export: conversation 152/152, control-board 124, webui 14, seat 19, chat-00/01/01c checks, and all nine scripts/test-*.sh green. Follow-ups for I3 are in DEFERRED. Gate E stays with Jason. Co-Authored-By: Claude Opus 5.5 <[email protected]>
69 lines
2.1 KiB
JavaScript
69 lines
2.1 KiB
JavaScript
// JSONL framing for the engine pipe and the control socket (#1507, CHAT-03 §1).
|
||
//
|
||
// Records are split on LF (0x0A) only, and a trailing CR is stripped (rpc.md
|
||
// lines 30–38). Node's readline is not used, because it also splits on U+2028
|
||
// and U+2029, which JSON strings may carry raw (E2). Splitting happens on
|
||
// bytes, before UTF-8 decoding, so a multibyte character split across two
|
||
// chunks is never damaged.
|
||
|
||
export const MAX_LINE_BYTES = 64 * 1024 * 1024;
|
||
|
||
export class LineSplitter {
|
||
constructor(onLine, { maxBytes = MAX_LINE_BYTES, onOverflow = null } = {}) {
|
||
this.onLine = onLine;
|
||
this.maxBytes = maxBytes;
|
||
this.onOverflow = onOverflow;
|
||
this.parts = [];
|
||
this.size = 0;
|
||
this.overflowed = false;
|
||
}
|
||
|
||
push(chunk) {
|
||
if (this.overflowed) return;
|
||
let start = 0;
|
||
for (;;) {
|
||
const lf = chunk.indexOf(0x0a, start);
|
||
if (lf === -1) break;
|
||
this.parts.push(chunk.subarray(start, lf));
|
||
const line = Buffer.concat(this.parts);
|
||
this.parts = [];
|
||
this.size = 0;
|
||
const end = line.length > 0 && line[line.length - 1] === 0x0d ? line.length - 1 : line.length;
|
||
this.onLine(line.subarray(0, end).toString("utf8"));
|
||
start = lf + 1;
|
||
}
|
||
if (start < chunk.length) {
|
||
const rest = chunk.subarray(start);
|
||
this.size += rest.length;
|
||
if (this.size > this.maxBytes) {
|
||
this.overflowed = true;
|
||
this.parts = [];
|
||
this.onOverflow?.();
|
||
return;
|
||
}
|
||
this.parts.push(Buffer.from(rest));
|
||
}
|
||
}
|
||
|
||
// Bytes left without a terminating LF when the stream ends. They are never
|
||
// parsed as a record: a partial line is a transport gap, not data.
|
||
pending() {
|
||
return this.size;
|
||
}
|
||
}
|
||
|
||
export function encodeLine(value) {
|
||
return JSON.stringify(value) + "\n";
|
||
}
|
||
|
||
// Parses one line. Returns {value} or {error}; never throws.
|
||
export function parseLine(line) {
|
||
try {
|
||
const value = JSON.parse(line);
|
||
if (value === null || typeof value !== "object" || Array.isArray(value)) return { error: "not-an-object" };
|
||
return { value };
|
||
} catch {
|
||
return { error: "unparseable" };
|
||
}
|
||
}
|