feat(conversation): CHAT-02 read-only Pi history reader and two board routes (#1507)
packages/conversation is a library with no server: safe-fs, the Pi session parser, CHAT-01 pages, pinned snapshots, cursors and follow. The control board adds GET /api/conversations and /api/conversation behind the Host and Origin guard. Both are read-only, their queries are validated, and each refusal code maps to a status. Dewey authored it (packet 0cf177b1, revision 2). Filbert reviewed the code: R1 revise (branch ids moving on append, the assumed-link bridge merging branches, one unreadable seat directory turning the catalogue into a 500), then R2 approve (3b14d66c). Darkwing reviewed the routes: R1 approve (07b10ad1), R2 approve (b9d92003). The package lands with the routes, because serve.mjs imports the reader at load. On an index export: the eight suites 24/90/43/17/14/15/63/18, conversation and control-board 153/153, webui 9/9. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -9,6 +9,14 @@
|
||||
// seat's tmux pane through tools/tmux/agent-send.sh (#1505);
|
||||
// answers {delivered, exitCode, stdout, stderr, ...}
|
||||
// GET /healthz {"ok":true}
|
||||
// GET /api/conversations
|
||||
// read-only Pi conversation catalogue (#1507, CHAT-02)
|
||||
// GET /api/conversation?id=<conversation>[&branch=<branch>][&cursor=<cursor>]
|
||||
// one CHAT-01 history page: the first page of a branch (the
|
||||
// default one without branch), or the next page (or a
|
||||
// follow) for a cursor. A cursor call repeats the page's
|
||||
// branch; without it the answer is 400. Refusals carry
|
||||
// {error, refusal: {code, reconcile}}.
|
||||
//
|
||||
// POST requires Content-Type: application/json. A plain form post from another
|
||||
// site in the browser cannot set that header without a CORS preflight, and this
|
||||
@@ -24,6 +32,13 @@
|
||||
//
|
||||
// Every /api/board request rescans, so the page is never staler than its
|
||||
// refresh timer. The scan rewrites the derived board files as a side effect.
|
||||
//
|
||||
// The conversation routes read only: packages/conversation lists the repository
|
||||
// specs' Pi session roots (never fleet or connector ones), opens files
|
||||
// O_NOFOLLOW and writes nothing. Registrations are hints there too. The
|
||||
// conversation id is opaque and no path comes from the request. Cursors live
|
||||
// in this server's memory, bound to the one actor this unauthenticated
|
||||
// loopback route has, local-operator.
|
||||
|
||||
import { createServer as createHttpServer } from "node:http";
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -31,7 +46,8 @@ import { join, resolve } from "node:path";
|
||||
import { isIP } from "node:net";
|
||||
import { hostname } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { scan, markSeen, seenKey, ConfigError } from "./scan.mjs";
|
||||
import { scan, markSeen, seenKey, ConfigError, loadRegistrations } from "./scan.mjs";
|
||||
import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs";
|
||||
|
||||
const MAX_BODY = 4096;
|
||||
|
||||
@@ -149,13 +165,70 @@ export function foreignRequest(req) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// HTTP status for each reader refusal. The body always carries the code. The
|
||||
// tests hold every code the reader can raise to an entry here.
|
||||
export const REFUSAL_STATUS = {
|
||||
"unknown-conversation": 404,
|
||||
"unknown-branch": 404,
|
||||
unavailable: 404,
|
||||
"cursor-unknown": 409,
|
||||
"cursor-expired": 409,
|
||||
"cursor-foreign": 409,
|
||||
"source-replaced": 409,
|
||||
"incomplete-header": 409,
|
||||
"unsafe-path": 403,
|
||||
"foreign-project": 403,
|
||||
unreadable: 403,
|
||||
"unsupported-harness": 422,
|
||||
"not-a-pi-session": 422,
|
||||
"too-large": 422,
|
||||
"unknown-actor": 403,
|
||||
"unsupported-purpose": 422,
|
||||
};
|
||||
const QUERY_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function sendConversationJson(res, status, body) {
|
||||
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
||||
res.end(JSON.stringify(body) + "\n");
|
||||
}
|
||||
|
||||
// GET /api/conversation: id is required; branch and cursor are optional; any
|
||||
// other, repeated or malformed parameter is a 400.
|
||||
function conversationQuery(url) {
|
||||
const out = {};
|
||||
for (const key of new Set(url.searchParams.keys())) {
|
||||
const values = url.searchParams.getAll(key);
|
||||
if (!["id", "branch", "cursor"].includes(key)) return { error: `unknown parameter: ${key}` };
|
||||
if (values.length !== 1 || !QUERY_VALUE.test(values[0])) return { error: `invalid ${key}` };
|
||||
out[key] = values[0];
|
||||
}
|
||||
if (!out.id) return { error: "id is required" };
|
||||
if (out.cursor && !out.branch) return { error: "a cursor call repeats the page's branch" };
|
||||
return out;
|
||||
}
|
||||
|
||||
export function conversationResponse(reader, url) {
|
||||
if (url.pathname === "/api/conversations") {
|
||||
if ([...url.searchParams.keys()].length) return { status: 400, body: { error: "no parameters are accepted" } };
|
||||
return { status: 200, body: reader.catalogue() };
|
||||
}
|
||||
const query = conversationQuery(url);
|
||||
if (query.error) return { status: 400, body: { error: query.error } };
|
||||
const out = query.cursor
|
||||
? reader.next({ cursor: query.cursor, conversation: query.id, branch: query.branch })
|
||||
: reader.open({ conversation: query.id, branch: query.branch ?? null });
|
||||
if (out.ok) return { status: 200, body: out };
|
||||
return { status: REFUSAL_STATUS[out.refusal.code] ?? 422, body: { error: out.refusal.message, refusal: { code: out.refusal.code, reconcile: out.refusal.reconcile } } };
|
||||
}
|
||||
|
||||
export function loadPage(path = join(import.meta.dirname, "page.html")) {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
// specs: agent specs to scan on each request. boardDir: where scan writes.
|
||||
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) {
|
||||
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, conversationReader = null }) {
|
||||
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot });
|
||||
const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) });
|
||||
return createHttpServer((req, res) => {
|
||||
const refused = foreignRequest(req);
|
||||
if (refused) {
|
||||
@@ -212,6 +285,16 @@ export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, d
|
||||
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
return res.end(JSON.stringify(index) + "\n");
|
||||
}
|
||||
if (url.pathname === "/api/conversations" || url.pathname === "/api/conversation") {
|
||||
let out;
|
||||
try {
|
||||
out = conversationResponse(reader, url);
|
||||
} catch (err) {
|
||||
process.stderr.write(`conversation read failed: ${err.message}\n`);
|
||||
out = { status: 500, body: { error: "conversation read failed" } };
|
||||
}
|
||||
return sendConversationJson(res, out.status, out.body);
|
||||
}
|
||||
if (url.pathname === "/favicon.ico") {
|
||||
res.writeHead(204);
|
||||
return res.end();
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
existsSync,
|
||||
chmodSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
lstatSync,
|
||||
readdirSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve, basename } from "node:path";
|
||||
@@ -16,8 +19,9 @@ import { spawnSync, spawn } from "node:child_process";
|
||||
import { createServer as createNetServer } from "node:net";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { ConfigError, markSeen } from "../src/scan.mjs";
|
||||
import { isLoopbackHost, startServer, DEFAULT_AGENT_SEND, REPLY_LIMIT, REPLY_TRAILER } from "../src/serve.mjs";
|
||||
import { isLoopbackHost, startServer, DEFAULT_AGENT_SEND, REPLY_LIMIT, REPLY_TRAILER, REFUSAL_STATUS } from "../src/serve.mjs";
|
||||
import { writeRegistration, makeRegistration } from "../../seat/src/seat.mjs";
|
||||
import { UNSUPPORTED_HARNESS } from "../../conversation/src/reader.mjs";
|
||||
|
||||
const pkgRoot = resolve(import.meta.dirname, "..");
|
||||
const cli = join(pkgRoot, "src", "cli.mjs");
|
||||
@@ -1025,3 +1029,146 @@ test("Host/Origin guard: loopback names on this port are accepted, with or witho
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. Read-only conversation routes (#1507, CHAT-02 D3): GET /api/conversations
|
||||
// and GET /api/conversation, served from packages/conversation. Same Host and
|
||||
// Origin guard as every route (F16), application/json with nosniff and
|
||||
// no-store, refusals mapped to 4xx with their code, and nothing written.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function conversationFixture() {
|
||||
const root = makeRoot();
|
||||
const proj = join(root, "proj");
|
||||
const sessionsDir = join(proj, ".pi", "state", "agent1", "sessions");
|
||||
const header = { type: "session", version: 3, id: "c0ffee00-0000-4000-8000-000000000001", timestamp: "2026-09-26T12:00:00.000Z", cwd: proj };
|
||||
const lines = [header];
|
||||
for (let i = 0; i < 120; i++) {
|
||||
lines.push({ type: "message", id: `e${i}`, parentId: i ? `e${i - 1}` : null, timestamp: new Date(Date.parse("2026-09-26T12:00:01Z") + i * 1000).toISOString(), message: { role: i % 2 ? "assistant" : "user", content: [{ type: "text", text: `m${i}` }] } });
|
||||
}
|
||||
writeSessionFile(sessionsDir, "s.jsonl", lines.map((l) => JSON.stringify(l)));
|
||||
const outside = join(root, "outside.jsonl");
|
||||
writeFile(outside, JSON.stringify({ ...header, cwd: "/elsewhere" }) + "\n");
|
||||
chmodSync(outside, 0o000);
|
||||
symlinkSync(outside, join(sessionsDir, "link.jsonl"));
|
||||
const seatsDir = join(root, "seats");
|
||||
writeRegistration(seatsDir, makeRegistration({
|
||||
resolved: { seat: "rocko", project: "proj", sessionsDir: join(proj, ".pi", "state", "rocko", "sessions"), seatDir: join(root, "rocko"), launchScript: join(root, "rocko", "launch.sh"), layout: "repo", defaultWorkspace: null },
|
||||
harness: "claude-code",
|
||||
}));
|
||||
return { root, proj, sessionsDir, seatsDir, boardDir: join(root, "board"), specs: [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }] };
|
||||
}
|
||||
|
||||
// Size, sha256, mtime, (dev, ino) and listing of every entry under dir.
|
||||
function treePrint(dir) {
|
||||
const out = {};
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const path = join(dir, name);
|
||||
const st = lstatSync(path, { bigint: true });
|
||||
out[name] = [String(st.dev), String(st.ino), String(st.size), String(st.mtimeNs), st.isFile() ? createHashHex(readFileSync(path)) : null];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function createHashHex(buf) {
|
||||
return spawnSync("sha256sum", { input: buf, encoding: "utf8" }).stdout.split(" ")[0];
|
||||
}
|
||||
|
||||
test("conversation routes (F16): a foreign Host, a wrong port and a cross-origin Origin get 403 before the reader runs, with no CORS headers", async () => {
|
||||
const f = conversationFixture();
|
||||
const calls = [];
|
||||
const spy = { catalogue: () => calls.push("catalogue"), open: () => calls.push("open"), next: () => calls.push("next") };
|
||||
const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "<html></html>", conversationReader: spy });
|
||||
const port = server.address().port;
|
||||
const own = `127.0.0.1:${port}`;
|
||||
const cases = [
|
||||
["foreign Host", { host: `rebind.example:${port}` }, "non-local Host refused"],
|
||||
["loopback Host, wrong port", { host: `127.0.0.1:${port + 1}` }, "non-local Host refused"],
|
||||
["cross-origin Origin", { host: own, origin: "http://rebind.example" }, "cross-origin request refused"],
|
||||
["opaque Origin", { host: own, origin: "null" }, "cross-origin request refused"],
|
||||
];
|
||||
try {
|
||||
for (const path of ["/api/conversations", "/api/conversation?id=pi-00000000000000000000000000000000", "/api/conversation?id=x&cursor=c-1"]) {
|
||||
for (const [label, headers, error] of cases) {
|
||||
const r = await rawRequest(port, { path, headers });
|
||||
assert.equal(r.status, 403, `${path}, ${label}`);
|
||||
assert.deepEqual(JSON.parse(r.text), { error }, `${path}, ${label}`);
|
||||
for (const h of Object.keys(r.headers)) assert.ok(!h.startsWith("access-control-"), `${path}, ${label}: ${h}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(calls, [], "the reader never ran for a refused request");
|
||||
assert.equal((await rawRequest(port, { path: "/api/conversations", headers: { host: own, origin: `http://${own}` } })).status, 200, "same-origin passes");
|
||||
assert.deepEqual(calls, ["catalogue"]);
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
test("every refusal code the reader can raise has an HTTP status", () => {
|
||||
const src = ["reader.mjs", "pi.mjs", "safe-fs.mjs"].map((f) => readFileSync(join(pkgRoot, "..", "conversation", "src", f), "utf8")).join("\n");
|
||||
const codes = new Set([...src.matchAll(/new Refusal\(\s*"([a-z-]+)"/g)].map((m) => m[1]));
|
||||
codes.add(UNSUPPORTED_HARNESS); // raised by value, as a root's unsupportedReason
|
||||
assert.ok(codes.size >= 15, [...codes].join(" "));
|
||||
assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []);
|
||||
assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries");
|
||||
});
|
||||
|
||||
test("conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written", async () => {
|
||||
const f = conversationFixture();
|
||||
const before = treePrint(f.sessionsDir);
|
||||
const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "<html></html>" });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const get = async (path) => {
|
||||
const res = await fetch(base + path);
|
||||
assert.equal(res.headers.get("content-type"), "application/json", path);
|
||||
assert.equal(res.headers.get("x-content-type-options"), "nosniff", path);
|
||||
assert.equal(res.headers.get("cache-control"), "no-store", path);
|
||||
assert.equal(res.headers.get("access-control-allow-origin"), null, path);
|
||||
return { status: res.status, body: await res.json() };
|
||||
};
|
||||
try {
|
||||
const cat = await get("/api/conversations");
|
||||
assert.equal(cat.status, 200);
|
||||
const rows = cat.body.conversations;
|
||||
const pi = rows.find((c) => c.availability === "available");
|
||||
const link = rows.find((c) => c.availability === "denied");
|
||||
const claude = rows.find((c) => c.availability === "unsupported");
|
||||
assert.deepEqual([pi.seat, pi.title, pi.readOnly], ["agent1", "m0", true]);
|
||||
assert.deepEqual([link.refusal, claude.seat, claude.unsupportedReason], ["unsafe-path", "rocko", "unsupported-harness"]);
|
||||
|
||||
const first = await get(`/api/conversation?id=${pi.conversation}`);
|
||||
assert.equal(first.status, 200);
|
||||
assert.deepEqual([first.body.page.kind, first.body.page.entries.length, first.body.page.hasMore, first.body.page.branch], ["page", 100, true, "main"]);
|
||||
const q = (cursor) => `/api/conversation?id=${pi.conversation}&branch=${first.body.page.branch}&cursor=${cursor}`;
|
||||
const second = await get(q(first.body.page.nextCursor));
|
||||
assert.equal(second.status, 200);
|
||||
assert.deepEqual([second.body.page.entries.length, second.body.page.hasMore], [20, false]);
|
||||
assert.equal(second.body.page.entries.at(-1).content[0].text, "m119");
|
||||
const follow = await get(q(second.body.follow.id));
|
||||
assert.deepEqual([follow.status, follow.body.page.entries.length], [200, 0]);
|
||||
const branch = await get(`/api/conversation?id=${pi.conversation}&branch=main`);
|
||||
assert.equal(branch.status, 200);
|
||||
|
||||
const refusals = [
|
||||
[`/api/conversation?id=pi-${"0".repeat(32)}`, 404, "unknown-conversation", true],
|
||||
[`/api/conversation?id=${pi.conversation}&branch=b.e5`, 404, "unknown-branch", true],
|
||||
[q("c-unknown"), 409, "cursor-unknown", true],
|
||||
[`/api/conversation?id=${pi.conversation}&branch=b.e1&cursor=${first.body.page.nextCursor}`, 409, "cursor-foreign", true],
|
||||
[`/api/conversation?id=${link.conversation}`, 403, "unsafe-path", false],
|
||||
[`/api/conversation?id=${claude.conversation}`, 422, "unsupported-harness", false],
|
||||
];
|
||||
for (const [path, status, code, reconcile] of refusals) {
|
||||
const r = await get(path);
|
||||
assert.equal(r.status, status, path);
|
||||
assert.deepEqual(r.body.refusal, { code, reconcile }, path);
|
||||
assert.equal(typeof r.body.error, "string");
|
||||
}
|
||||
for (const path of ["/api/conversation", "/api/conversation?id=a&id=b", "/api/conversation?id=a&path=/etc/passwd", "/api/conversation?id=a&path=x", `/api/conversation?id=${pi.conversation}&cursor=${second.body.follow.id}`, "/api/conversation?id=../x", `/api/conversation?id=${"a".repeat(129)}`, "/api/conversations?x=1"]) {
|
||||
const r = await get(path);
|
||||
assert.equal(r.status, 400, path);
|
||||
}
|
||||
assert.deepEqual(treePrint(f.sessionsDir), before, "the sessions directory is unchanged");
|
||||
assert.equal(existsSync(join(f.proj, ".pi", "state", "rocko")), false, "the Claude seat's directory was never created");
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# conversation
|
||||
|
||||
Read-only Pi conversation histories for the Console: a catalogue of approved
|
||||
session files, full branch history in CHAT-01 pages, and cursors. Opening a
|
||||
conversation never resumes, forks, launches or controls anything, and nothing
|
||||
here writes a file.
|
||||
|
||||
Issue #1507 (CHAT-02, row 5). Brief: `agents/dewey/work/chat-02/BRIEF.md` R4.
|
||||
Plain ESM, no dependencies, Node 24 or newer. A library with no server: the
|
||||
control board serves it on two GET routes (D3).
|
||||
|
||||
## API
|
||||
|
||||
```js
|
||||
import { rootsFromSpecs, createReader } from "@mosaic/conversation";
|
||||
|
||||
const reader = createReader({ roots: () => rootsFromSpecs(specs, registrations) });
|
||||
reader.catalogue(); // { ok, conversations, refusedRoots, generatedAt }
|
||||
reader.open({ conversation, branch? }); // first page
|
||||
reader.next({ cursor, conversation, branch }); // next page, or a follow
|
||||
```
|
||||
|
||||
`open` and `next` return `{ ok: true, page, cursor, follow, view }` or
|
||||
`{ ok: false, refusal: { code, reconcile, message } }`.
|
||||
|
||||
- `page` is a CHAT-01 `page`, and `cursor` and `follow` are CHAT-01 `cursor`
|
||||
records. `page.nextCursor` is `cursor.id` when more parts remain in the
|
||||
snapshot.
|
||||
- On the last page, `follow` replaces `cursor`. Calling `next` with it takes
|
||||
a fresh snapshot and returns only what was appended since (see Follow).
|
||||
- `view` holds what the Console needs beyond CHAT-01: `defaultBranch`,
|
||||
`branches`, `incomplete` (a truncated trailing line),
|
||||
`forkedFromEarlierSession` and `unreadableLines`.
|
||||
- `actor` defaults to `local-operator` and `purpose` to `history`. Any other
|
||||
value is refused.
|
||||
|
||||
## Sources
|
||||
|
||||
Roots come from the board's repository specs only:
|
||||
`<projectRoot>/.pi/state/<seat>/sessions`, with the project named after the
|
||||
root directory. Fleet and connector specs are not roots, and there is no
|
||||
global scan. A conversation id is `pi-` plus a hash of project root, seat and
|
||||
file name. It is resolved by listing the roots again, so no path comes from
|
||||
the caller.
|
||||
|
||||
A seat registration is seat-written, so it is a hint, not authority:
|
||||
|
||||
- It counts for a root only when seat, layout `repo`, project and
|
||||
`sessionsDir` all match (`samePath`). Anything else is ignored.
|
||||
- It supplies `engineStartedAt` for conversations created at or after its
|
||||
`startedAt`.
|
||||
- A non-Pi `harness` makes the root unsupported. The root appears as one
|
||||
catalogue row with `unsupportedReason: "unsupported-harness"` (D2), and its
|
||||
directory is never read.
|
||||
- A seat on another harness has no Pi sessions directory, so the board has no
|
||||
spec for it. Its registration adds that placeholder row when it names the
|
||||
standard directory under a project root that is already approved. This is
|
||||
Rocko's case today: `claude-code`, no `sessions`.
|
||||
|
||||
Deviation from brief §2.1: the brief speaks of a registration `sessionFile`.
|
||||
Registrations have no such field, only `sessionsDir`, so the rule above
|
||||
applies to the directory. A registration never adds a root or names a file.
|
||||
|
||||
## Opening a file
|
||||
|
||||
`src/safe-fs.mjs`:
|
||||
|
||||
- Every component from the project root down to the sessions directory must
|
||||
be a real directory (lstat).
|
||||
- A session file must be a regular `*.jsonl` directly in the root.
|
||||
- It is opened `O_RDONLY | O_NOFOLLOW | O_NONBLOCK`, and the descriptor's
|
||||
(dev, ino) must equal the lstat taken before the open.
|
||||
- The components are checked again after the open. Node has no `openat`, so
|
||||
a directory swapped between the checks and the open is detected afterwards,
|
||||
not prevented.
|
||||
|
||||
The project root itself may be a symlink (the compatibility path to this
|
||||
checkout). The header `cwd` must be the project or inside it. Real paths are
|
||||
compared, and a `cwd` that no longer exists is compared as written. That
|
||||
comparison calls `realpath` on the recorded `cwd`, which resolves the path but
|
||||
opens nothing.
|
||||
|
||||
`SessionManager.open` is never used.
|
||||
|
||||
## Parser
|
||||
|
||||
`src/pi.mjs`, pinned against `@earendil-works/pi-coding-agent` 0.85.1
|
||||
(`docs/session-format.md`, `dist/core/session-manager.js`):
|
||||
|
||||
- Line 1 must be the session header.
|
||||
- Entries form an `id`/`parentId` tree. Where an id repeats, the later entry
|
||||
wins, as in Pi's index.
|
||||
- The default leaf is the last valid entry in file order, as Pi loads it.
|
||||
Each leaf ends one branch; other branches are read-only and opened by
|
||||
`branch`.
|
||||
- Branch names do not change while the file grows. The first root's line is
|
||||
`main`, even before the file has entries. Each later root (Pi's
|
||||
`resetLeaf`, or an entry whose parent is missing) and each later child at a
|
||||
fork starts a branch named `b.<entry id>`; the earliest child in file order
|
||||
continues its parent's branch. An inner entry is not a branch.
|
||||
- A malformed line becomes a notice at its file position on every branch,
|
||||
after the leaf too, and reading continues. The same placement on every
|
||||
branch keeps a branch's earlier parts unchanged while the file grows.
|
||||
- A missing parent stops the history with a notice. When unreadable lines sit
|
||||
just before the entry, the notice names them as the likely place of the
|
||||
parent. The history is never joined across the gap: the entries before it
|
||||
may belong to another branch, and they read as their own branch. A loop
|
||||
stops with a notice.
|
||||
- `parentSession` gives a "forked from an earlier session" notice and is
|
||||
never opened.
|
||||
- Redacted reasoning (`redacted: true`) is `unavailable` with empty text.
|
||||
`thinkingSignature` is never read.
|
||||
- A header `cwd` must be absolute and inside the project; a relative one is
|
||||
refused as `foreign-project`.
|
||||
- A compaction is a marker in place, and the full history stays on the path.
|
||||
`retainedTail` entries are already on the path, so they are not rendered
|
||||
twice.
|
||||
- Model and thinking-level changes, labels, session names and extension state
|
||||
are not shown. `custom_message` shows only with `display: true`. An unknown
|
||||
entry type or role becomes a notice.
|
||||
- Ids that do not fit the CHAT-01 id pattern are hashed (`h-` plus 40 hex).
|
||||
OpenAI tool call ids such as `call_x|fc_y` are the real case. Entry ids are
|
||||
namespaced (`n.` for native, `x.` for notices), so no native id can collide
|
||||
with a notice.
|
||||
- `get_entries` order is not used.
|
||||
|
||||
## Pages
|
||||
|
||||
`src/parts.mjs` applies the CHAT-01 limits:
|
||||
|
||||
- at most 100 parts and 8 MiB of serialized UTF-8 per page;
|
||||
- at most 64 blocks per part;
|
||||
- at most 262144 characters per string.
|
||||
|
||||
Strings split into fragments. A fragment is also cut at 1 MiB of JSON, and
|
||||
blocks group into parts of at most 4 MiB, so one part always fits a page.
|
||||
Content is never clipped, and a surrogate pair is never cut.
|
||||
|
||||
## Snapshots, epochs, cursors
|
||||
|
||||
- A snapshot is the file up to its last newline when the descriptor was read.
|
||||
A trailing partial line is left out and sets `view.incomplete`.
|
||||
`snapshotDigest` is the SHA-256 of those bytes.
|
||||
- `sourceEpoch` is a hash of (dev, ino) and the digest at open. Follows carry
|
||||
it forward while the prefix verifies, so growth keeps the epoch. A new open
|
||||
after growth hashes the longer prefix and gets a different id for the same
|
||||
epoch, so an epoch id is comparable only within one cursor chain.
|
||||
- Every `next` re-reads the pinned prefix and refuses `source-replaced` (with
|
||||
reconcile) when dev or ino changed, the file is shorter, or the prefix
|
||||
digest differs (an in-place rewrite with the same inode).
|
||||
- Cursors live in memory, with an LRU cap (1000) and a 10-minute TTL. They are
|
||||
bound to actor, purpose, conversation, branch, snapshot, epoch and expiry.
|
||||
- Refusals: `cursor-unknown` (never issued or evicted), `cursor-expired`,
|
||||
`cursor-foreign` (any binding differs) and `source-replaced`. All carry
|
||||
`reconcile: true`. A foreign attempt does not consume the cursor, so the old
|
||||
view keeps working.
|
||||
- `local-operator` is the only actor on this unauthenticated loopback route.
|
||||
That is not multi-actor safety. Authenticated actors come with CHAT-04R.
|
||||
|
||||
### Follow
|
||||
|
||||
A follow cursor verifies the old prefix, then takes a fresh snapshot and
|
||||
reads the same branch in it. `page.branch` is always the cursor's branch.
|
||||
|
||||
- The parts before the cursor must be unchanged (compared as a digest of
|
||||
entry ids). If they are, the page holds only the parts appended to this
|
||||
branch, which is empty when the conversation continued on another branch.
|
||||
`view.defaultBranch` shows where Pi's default leaf is now.
|
||||
- If the branch is gone or its earlier parts changed (a duplicate id that
|
||||
replaces an entry, or a missing parent that turns up later), it refuses
|
||||
`source-replaced`.
|
||||
|
||||
## Refusal codes
|
||||
|
||||
| Code | Meaning | Reconcile |
|
||||
|---|---|---|
|
||||
| `unknown-conversation` | not in the approved roots now | yes |
|
||||
| `unknown-branch` | not a branch of this conversation | yes |
|
||||
| `unavailable` | the session root no longer exists | no |
|
||||
| `unsupported-harness` | non-Pi seat (D2) | no |
|
||||
| `unsafe-path` | symlink, bad name, not a regular file, swapped | no |
|
||||
| `foreign-project` | header `cwd` outside the project | no |
|
||||
| `unreadable` | permission denied on a file, a root or a directory above it inside the approved roots | no |
|
||||
| `not-a-pi-session`, `incomplete-header` | first line is not a complete Pi header | incomplete: yes |
|
||||
| `too-large` | over 256 MiB | no |
|
||||
| `cursor-unknown`, `cursor-expired`, `cursor-foreign`, `source-replaced` | see above | yes |
|
||||
| `unknown-actor`, `unsupported-purpose` | not `local-operator` / `history` | no |
|
||||
|
||||
## Costs
|
||||
|
||||
Each page reads and hashes the whole pinned prefix. A parse cache and a
|
||||
branch-entries cache (two snapshots each) avoid re-parsing. On this checkout's
|
||||
18 roots (largest file 18.6 MB), reading every page of every conversation took
|
||||
at most 680 ms per conversation.
|
||||
|
||||
## Tests
|
||||
|
||||
`node --test packages/conversation/tests/` covers fixtures F1–F15 and F17 of
|
||||
the brief (F16 is in the control-board suite, which serves the routes).
|
||||
|
||||
- Every reader call runs inside a fingerprint of the fixture tree: size,
|
||||
SHA-256, mtime, (dev, ino), mode and every directory listing, before and
|
||||
after.
|
||||
- Files that no read may touch are mode 000, so an attempted open would throw
|
||||
`EACCES` rather than refuse.
|
||||
- Every page and cursor is validated against
|
||||
`docs/plans/chat-01/contracts.schema.json` with Python `jsonschema`.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@mosaic/conversation",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Read-only Pi conversation histories for the Console: approved roots, safe opens, branch pages and cursors. No server; the control board serves it.",
|
||||
"license": "UNLICENSED",
|
||||
"type": "module",
|
||||
"engines": { "node": ">=24" },
|
||||
"exports": { ".": "./src/reader.mjs" },
|
||||
"scripts": { "test": "node --test tests/" }
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// CHAT-01 history limits (#1507, CHAT-02): at most 100 parts per page, 8 MiB
|
||||
// of serialized UTF-8 per page, 64 blocks per part and 262144 characters per
|
||||
// string. Oversize content splits into fragments and continuation parts; it is
|
||||
// never clipped. Byte limits are measured on the serialized JSON, not on
|
||||
// characters.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const LIMITS = Object.freeze({ parts: 100, pageBytes: 8 * 1024 * 1024, blocks: 64, chars: 262144 });
|
||||
|
||||
// Internal budgets that keep any single part well inside one page, so a page
|
||||
// always holds at least one part.
|
||||
export const FRAGMENT_BYTES = 1024 * 1024;
|
||||
export const PART_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
export const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
// A native value used as a CHAT-01 id. Values that do not fit the id pattern
|
||||
// (OpenAI tool calls such as "call_x|fc_y") map to a stable hash.
|
||||
export function safeId(value) {
|
||||
if (typeof value === "string" && ID.test(value)) return value;
|
||||
return "h-" + createHash("sha256").update(String(value)).digest("hex").slice(0, 40);
|
||||
}
|
||||
|
||||
// Bytes one code point adds to a JSON string literal.
|
||||
function jsonCost(cp) {
|
||||
if (cp === 0x22 || cp === 0x5c) return 2;
|
||||
if (cp < 0x20) return cp === 8 || cp === 9 || cp === 10 || cp === 12 || cp === 13 ? 2 : 6;
|
||||
if (cp < 0x80) return 1;
|
||||
if (cp < 0x800) return 2;
|
||||
if (cp >= 0xd800 && cp <= 0xdfff) return 6; // lone surrogate, escaped by JSON.stringify
|
||||
if (cp <= 0xffff) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
// Splits a string into fragments of at most LIMITS.chars code points and
|
||||
// FRAGMENT_BYTES of JSON. Never cuts a surrogate pair. "" is one fragment.
|
||||
export function fragments(str) {
|
||||
const out = [];
|
||||
let start = 0, chars = 0, bytes = 0;
|
||||
for (let i = 0; i < str.length;) {
|
||||
const cp = str.codePointAt(i);
|
||||
const width = cp > 0xffff ? 2 : 1;
|
||||
const cost = jsonCost(cp);
|
||||
if (chars > 0 && (chars + 1 > LIMITS.chars || bytes + cost > FRAGMENT_BYTES)) {
|
||||
out.push(str.slice(start, i));
|
||||
start = i;
|
||||
chars = 0;
|
||||
bytes = 0;
|
||||
}
|
||||
chars += 1;
|
||||
bytes += cost;
|
||||
i += width;
|
||||
}
|
||||
out.push(str.slice(start));
|
||||
return out;
|
||||
}
|
||||
|
||||
const bytesOf = (value) => Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||
|
||||
// One native unit (a message, a compaction, a notice) becomes one or more
|
||||
// CHAT-01 entries. `unit.blocks` holds logical blocks: { fields, key, value },
|
||||
// where `value` is the string that may split and `key` names its field. A
|
||||
// block with key null (an attachment) has no string and is one fragment.
|
||||
export function unitParts(unit, base) {
|
||||
const items = [];
|
||||
unit.blocks.forEach((b, ordinal) => {
|
||||
if (b.key === null) {
|
||||
items.push({ ...b.fields, block: ordinal, fragment: 0, lastFragment: true });
|
||||
return;
|
||||
}
|
||||
const pieces = fragments(b.value);
|
||||
pieces.forEach((piece, k) => {
|
||||
items.push({ ...b.fields, [b.key]: piece, block: ordinal, fragment: k, lastFragment: k === pieces.length - 1 });
|
||||
});
|
||||
});
|
||||
const groups = [];
|
||||
let current = [], currentBytes = 0;
|
||||
for (const item of items) {
|
||||
const size = bytesOf(item) + 1;
|
||||
if (current.length && (current.length === LIMITS.blocks || currentBytes + size > PART_BYTES)) {
|
||||
groups.push(current);
|
||||
current = [];
|
||||
currentBytes = 0;
|
||||
}
|
||||
current.push(item);
|
||||
currentBytes += size;
|
||||
}
|
||||
groups.push(current);
|
||||
return groups.map((content, part) => ({
|
||||
version: 2,
|
||||
kind: "entry",
|
||||
id: safeId(`${unit.id}:${part}`),
|
||||
conversation: base.conversation,
|
||||
branch: base.branch,
|
||||
parent: unit.parent,
|
||||
execution: base.execution,
|
||||
nativeEntry: unit.nativeEntry,
|
||||
role: unit.role,
|
||||
request: null,
|
||||
content,
|
||||
part,
|
||||
lastPart: part === groups.length - 1,
|
||||
createdAt: unit.createdAt,
|
||||
message: unit.message,
|
||||
}));
|
||||
}
|
||||
|
||||
// Takes entries from `start` while the page stays within LIMITS. `shell` is
|
||||
// the page record with an empty `entries` array.
|
||||
export function takePage(entries, sizes, start, shell) {
|
||||
let bytes = bytesOf(shell);
|
||||
let end = start;
|
||||
while (end < entries.length && end - start < LIMITS.parts) {
|
||||
const add = sizes[end] + (end > start ? 1 : 0);
|
||||
if (bytes + add > LIMITS.pageBytes) break;
|
||||
bytes += add;
|
||||
end += 1;
|
||||
}
|
||||
if (end === start && start < entries.length) throw new Error("internal: a single part exceeds the page byte limit");
|
||||
return end;
|
||||
}
|
||||
|
||||
export { bytesOf };
|
||||
@@ -0,0 +1,287 @@
|
||||
// Pi session parser for read-only histories (#1507, CHAT-02). Pinned against
|
||||
// @earendil-works/pi-coding-agent 0.85.1 docs/session-format.md and
|
||||
// dist/core/session-manager.js:
|
||||
//
|
||||
// - line 1 is the session header; every other line is an entry with id,
|
||||
// parentId and timestamp, forming a tree;
|
||||
// - on load Pi takes the leaf to be the last entry in file order and skips
|
||||
// malformed lines (_buildIndex, parseSessionEntries). This parser uses the
|
||||
// same default leaf and shows a malformed line as an unavailable notice;
|
||||
// - resetLeaf starts a new root entry, so a file can hold several roots;
|
||||
// - the full branch path is shown, including entries before a compaction;
|
||||
// the compaction itself is a marker in place. retainedTail copies entries
|
||||
// that are already on the path, so it is not rendered again.
|
||||
//
|
||||
// get_entries order is not a branch transcript and is not used, and
|
||||
// parentSession is never followed or opened.
|
||||
|
||||
import { Refusal } from "./safe-fs.mjs";
|
||||
import { safeId } from "./parts.mjs";
|
||||
|
||||
const toTime = (v) => {
|
||||
const ms = typeof v === "number" ? v : typeof v === "string" ? Date.parse(v) : NaN;
|
||||
if (!Number.isFinite(ms)) return null;
|
||||
const iso = new Date(ms).toISOString();
|
||||
return /^\d{4}-/.test(iso) ? iso : null;
|
||||
};
|
||||
|
||||
// Branch names. The first root's line of history is "main"; every later root
|
||||
// (Pi's resetLeaf) and every later child at a fork starts a branch named after
|
||||
// its first entry. Appending never changes which child came first, so a name
|
||||
// holds for as long as the file only grows.
|
||||
export const MAIN = "main";
|
||||
const branchName = (r) => safeId(`b.${r.entry.id}`);
|
||||
|
||||
// `text` holds complete lines only (it ends with "\n" or is empty).
|
||||
export function parseSnapshot(text) {
|
||||
const lines = text.split("\n");
|
||||
lines.pop();
|
||||
let header;
|
||||
try {
|
||||
header = JSON.parse(lines[0] ?? "");
|
||||
} catch {
|
||||
header = null;
|
||||
}
|
||||
if (!header || typeof header !== "object" || header.type !== "session") throw new Refusal("not-a-pi-session", "the first line is not a Pi session header");
|
||||
const entries = [], malformed = [], byId = new Map(), children = new Map();
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) continue;
|
||||
let e;
|
||||
try {
|
||||
e = JSON.parse(line);
|
||||
} catch {
|
||||
e = null;
|
||||
}
|
||||
if (!e || typeof e !== "object" || Array.isArray(e) || typeof e.id !== "string" || typeof e.type !== "string" || e.type === "session") {
|
||||
malformed.push({ line: i + 1, after: entries.length - 1 });
|
||||
continue;
|
||||
}
|
||||
const record = { entry: e, line: i + 1, index: entries.length };
|
||||
entries.push(record);
|
||||
byId.set(e.id, record); // later wins, as in Pi's index
|
||||
}
|
||||
for (const r of byId.values()) {
|
||||
const p = typeof r.entry.parentId === "string" ? r.entry.parentId : null;
|
||||
if (!children.has(p)) children.set(p, []);
|
||||
children.get(p).push(r);
|
||||
}
|
||||
for (const list of children.values()) list.sort((a, b) => a.index - b.index);
|
||||
const parentOf = (r) => (typeof r.entry.parentId === "string" ? byId.get(r.entry.parentId) : undefined) ?? null;
|
||||
const firstRoot = [...byId.values()].filter((r) => !parentOf(r)).sort((a, b) => a.index - b.index)[0] ?? null;
|
||||
// Walk up while each entry is its parent's earliest child. The walk ends at
|
||||
// a root or a later child, whose name the branch takes. A walk from a leaf
|
||||
// always ends there; the loop guard only keeps a hostile file finite.
|
||||
const branchOf = (leaf) => {
|
||||
const seen = new Set();
|
||||
for (let r = leaf; !seen.has(r); ) {
|
||||
seen.add(r);
|
||||
const p = parentOf(r);
|
||||
if (!p) return r === firstRoot ? MAIN : branchName(r);
|
||||
if (children.get(p.entry.id)[0] !== r) return branchName(r);
|
||||
r = p;
|
||||
}
|
||||
return branchName(leaf);
|
||||
};
|
||||
const leaves = [...byId.values()].filter((r) => !children.has(r.entry.id)).sort((a, b) => a.index - b.index);
|
||||
const defaultLeaf = entries.length ? byId.get(entries[entries.length - 1].entry.id) : null;
|
||||
const defaultBranch = defaultLeaf ? branchOf(defaultLeaf) : MAIN;
|
||||
// Branch name to leaf. The default branch ends at Pi's default leaf, and a
|
||||
// file with no entries yet has an empty "main".
|
||||
const branches = new Map();
|
||||
for (const leaf of leaves) {
|
||||
const name = branchOf(leaf);
|
||||
if (!branches.has(name)) branches.set(name, leaf);
|
||||
}
|
||||
if (defaultLeaf) branches.set(defaultBranch, defaultLeaf);
|
||||
if (!branches.size) branches.set(MAIN, null);
|
||||
return { header, entries, malformed, byId, branches, defaultLeaf, defaultBranch };
|
||||
}
|
||||
|
||||
// Root-to-leaf items for one branch: { record } for native entries and
|
||||
// { notice, lines? } for markers placed where they apply. A null leaf is a
|
||||
// branch with no entries yet.
|
||||
export function branchPath(parsed, leaf) {
|
||||
const path = [];
|
||||
const seen = new Set();
|
||||
const named = new Set();
|
||||
let r = leaf;
|
||||
while (r) {
|
||||
if (seen.has(r.entry.id)) {
|
||||
path.push({ notice: "loop" });
|
||||
break;
|
||||
}
|
||||
seen.add(r.entry.id);
|
||||
path.push({ record: r });
|
||||
const parentId = r.entry.parentId;
|
||||
if (parentId === null || parentId === undefined) break;
|
||||
const parent = typeof parentId === "string" ? parsed.byId.get(parentId) : null;
|
||||
if (parent) {
|
||||
r = parent;
|
||||
continue;
|
||||
}
|
||||
// The parent is missing. Unreadable lines just before this entry may have
|
||||
// held it; the notice names them. The history does not continue past the
|
||||
// gap: the entries before it may belong to another branch.
|
||||
const lost = parsed.malformed.filter((m) => m.after === r.index - 1).map((m) => m.line);
|
||||
lost.forEach((line) => named.add(line));
|
||||
path.push({ notice: "missing-parent", lines: lost });
|
||||
break;
|
||||
}
|
||||
path.reverse();
|
||||
// Every other unreadable line is shown at its file position on every
|
||||
// branch, after the leaf too: it may belong to any branch. Placing it the
|
||||
// same way on every branch keeps a branch's earlier parts unchanged while
|
||||
// the file grows.
|
||||
const out = [];
|
||||
const pending = parsed.malformed.filter((m) => !named.has(m.line));
|
||||
let mi = 0;
|
||||
const flushBefore = (line) => {
|
||||
while (mi < pending.length && pending[mi].line < line) out.push({ notice: "malformed", lines: [pending[mi++].line] });
|
||||
};
|
||||
for (const item of path) {
|
||||
const line = item.record?.line ?? item.lines?.[0];
|
||||
if (line) flushBefore(line);
|
||||
out.push(item);
|
||||
}
|
||||
flushBefore(Infinity);
|
||||
return out;
|
||||
}
|
||||
|
||||
const NOTICE_TEXT = {
|
||||
loop: "History before this point is unavailable: the entry chain loops.",
|
||||
"missing-parent": "History before this point is unavailable: an earlier entry is missing from the file.",
|
||||
"parent-session": "This session was forked from an earlier session. The earlier session is not opened here.",
|
||||
};
|
||||
|
||||
function text(value) {
|
||||
return { fields: { type: "text" }, key: "text", value: String(value) };
|
||||
}
|
||||
|
||||
function contentText(content) {
|
||||
if (typeof content === "string") return [text(content)];
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content.flatMap((b, i) => blockFor(b, i));
|
||||
}
|
||||
|
||||
function blockFor(b, i, owner = "") {
|
||||
if (!b || typeof b !== "object") return [];
|
||||
if (b.type === "text") return [text(b.text ?? "")];
|
||||
if (b.type === "image") return [{ fields: { type: "attachment", attachment: safeId(`${owner}image.${i}`) }, key: null, value: null }];
|
||||
return [text(`[unsupported content block: ${safeId(String(b.type))}]`)];
|
||||
}
|
||||
|
||||
// Unit ids are namespaced so no native id can collide with a notice: "n."
|
||||
// for native entries, "x." for notices. Entry ids derive from them (parts.mjs).
|
||||
//
|
||||
// Converts one native entry to zero or more units. Entries that Pi keeps out
|
||||
// of the transcript (model and thinking changes, labels, names, extension
|
||||
// state) produce none.
|
||||
function unitsFor(record, ctx) {
|
||||
const e = record.entry;
|
||||
const nativeEntry = safeId(e.id);
|
||||
const createdAt = toTime(e.timestamp) ?? ctx.lastTime;
|
||||
ctx.lastTime = createdAt;
|
||||
const base = { id: `n.${e.id}`, nativeEntry, parent: typeof e.parentId === "string" ? safeId(e.parentId) : null, createdAt, message: nativeEntry };
|
||||
const notice = (value, suffix) => ({ ...base, id: `x.${suffix}.${e.id}`, message: safeId(`x.${suffix}.${e.id}`), role: "notice", blocks: [text(value)] });
|
||||
switch (e.type) {
|
||||
case "message":
|
||||
return messageUnits(e.message, base, notice);
|
||||
case "compaction":
|
||||
return [{ ...base, role: "compaction", blocks: [{ fields: { type: "compaction", nativeEntry }, key: "summary", value: String(e.summary ?? "") }] }];
|
||||
case "branch_summary":
|
||||
return [{ ...base, role: "notice", blocks: [text(`Branch summary\n\n${e.summary ?? ""}`)] }];
|
||||
case "custom_message":
|
||||
return e.display ? [{ ...base, role: "notice", blocks: contentText(e.content) }] : [];
|
||||
case "model_change":
|
||||
case "thinking_level_change":
|
||||
case "label":
|
||||
case "session_info":
|
||||
case "custom":
|
||||
return [];
|
||||
default:
|
||||
return [{ ...base, role: "notice", blocks: [text(`An entry of type ${safeId(e.type)} is not shown.`)] }];
|
||||
}
|
||||
}
|
||||
|
||||
function messageUnits(m, base, notice) {
|
||||
if (!m || typeof m !== "object") return [{ ...base, role: "notice", blocks: [text("This entry has no readable message.")] }];
|
||||
const owner = `${base.nativeEntry}.`;
|
||||
switch (m.role) {
|
||||
case "user":
|
||||
return [{ ...base, role: "user", blocks: typeof m.content === "string" ? [text(m.content)] : (Array.isArray(m.content) ? m.content : []).flatMap((b, i) => blockFor(b, i, owner)) }];
|
||||
case "assistant": {
|
||||
const blocks = (Array.isArray(m.content) ? m.content : []).flatMap((b, i) => {
|
||||
if (b?.type === "thinking") {
|
||||
// Redacted reasoning is stored as a placeholder; it is unavailable, and
|
||||
// thinkingSignature is never read.
|
||||
const t = b.redacted === true ? "" : typeof b.thinking === "string" ? b.thinking : "";
|
||||
return [{ fields: { type: "thinking", visibility: t ? "permitted-visible" : "unavailable" }, key: "text", value: t }];
|
||||
}
|
||||
if (b?.type === "toolCall") {
|
||||
return [{ fields: { type: "tool-call", call: safeId(b.id), name: safeId(b.name) }, key: "argumentsText", value: JSON.stringify(b.arguments ?? {}) }];
|
||||
}
|
||||
return blockFor(b, i, owner);
|
||||
});
|
||||
const units = [{ ...base, role: "assistant", blocks }];
|
||||
if (m.stopReason === "error" || m.stopReason === "aborted") {
|
||||
units.push(notice(m.errorMessage ? `The turn ended (${m.stopReason}): ${m.errorMessage}` : `The turn ended (${m.stopReason}).`, "end"));
|
||||
}
|
||||
return units;
|
||||
}
|
||||
case "toolResult": {
|
||||
const content = Array.isArray(m.content) ? m.content : [];
|
||||
const joined = content.filter((b) => b?.type === "text").map((b) => String(b.text ?? "")).join("\n");
|
||||
const blocks = [{ fields: { type: "tool-result", call: safeId(m.toolCallId), isError: m.isError === true }, key: "text", value: joined }];
|
||||
content.forEach((b, i) => {
|
||||
if (b?.type === "image") blocks.push(...blockFor(b, i, owner));
|
||||
});
|
||||
return [{ ...base, role: "tool", blocks }];
|
||||
}
|
||||
case "bashExecution": {
|
||||
const call = safeId(`bash:${base.nativeEntry}`);
|
||||
return [{
|
||||
...base,
|
||||
role: "tool",
|
||||
blocks: [
|
||||
{ fields: { type: "tool-call", call, name: "bash" }, key: "argumentsText", value: JSON.stringify({ command: String(m.command ?? "") }) },
|
||||
{ fields: { type: "tool-result", call, isError: m.cancelled === true || (m.exitCode !== 0 && m.exitCode !== undefined) }, key: "text", value: String(m.output ?? "") },
|
||||
],
|
||||
}];
|
||||
}
|
||||
case "custom":
|
||||
return m.display ? [{ ...base, role: "notice", blocks: contentText(m.content) }] : [];
|
||||
case "branchSummary":
|
||||
return [{ ...base, role: "notice", blocks: [text(`Branch summary\n\n${m.summary ?? ""}`)] }];
|
||||
case "compactionSummary":
|
||||
return [{ ...base, role: "compaction", blocks: [{ fields: { type: "compaction", nativeEntry: base.nativeEntry }, key: "summary", value: String(m.summary ?? "") }] }];
|
||||
default:
|
||||
return [{ ...base, role: "notice", blocks: [text(`A message with role ${safeId(String(m.role))} is not shown.`)] }];
|
||||
}
|
||||
}
|
||||
|
||||
// All units for one branch, in order.
|
||||
export function branchUnits(parsed, path) {
|
||||
const ctx = { lastTime: toTime(parsed.header.timestamp) ?? new Date(0).toISOString() };
|
||||
const units = [];
|
||||
if (parsed.header.parentSession !== undefined && parsed.header.parentSession !== null) {
|
||||
units.push({ id: "x.parent-session", nativeEntry: "x.parent-session", parent: null, createdAt: ctx.lastTime, message: "x.parent-session", role: "notice", blocks: [text(NOTICE_TEXT["parent-session"])] });
|
||||
}
|
||||
for (const item of path) {
|
||||
if (item.record) {
|
||||
units.push(...unitsFor(item.record, ctx));
|
||||
continue;
|
||||
}
|
||||
const lines = item.lines ?? [];
|
||||
const id = item.notice === "malformed" ? `x.line-${lines[0]}` : `x.${item.notice}`;
|
||||
const value = item.notice === "malformed"
|
||||
? `Line ${lines[0]} could not be read. It may belong to this branch or another one.`
|
||||
: item.notice === "missing-parent" && lines.length
|
||||
? `${NOTICE_TEXT["missing-parent"]} ${lines.length === 1 ? `Line ${lines[0]} could not be read and may have held it.` : `Lines ${lines.join(", ")} could not be read and may have held it.`}`
|
||||
: NOTICE_TEXT[item.notice];
|
||||
units.push({ id, nativeEntry: id, parent: null, createdAt: ctx.lastTime, message: id, role: "notice", blocks: [text(value)] });
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
export { toTime };
|
||||
@@ -0,0 +1,401 @@
|
||||
// Read-only Pi conversation histories for the Console (#1507, CHAT-02).
|
||||
//
|
||||
// A library with no server: the control board owns the two GET routes (D3).
|
||||
// Only approved roots are read, <projectRoot>/.pi/state/<seat>/sessions from
|
||||
// the board's repository specs. There is no global scan, and no path comes
|
||||
// from the caller: a conversation is an opaque id that is resolved by listing
|
||||
// the roots again. Opening a conversation never resumes, forks, launches or
|
||||
// controls anything, and nothing here writes a file.
|
||||
//
|
||||
// A seat registration is seat-written, so it is a hint, not authority. It can
|
||||
// narrow a root (a non-Pi harness makes the root unsupported, D2) and supply
|
||||
// the engine launch time. It never adds a root or names a file.
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { join, resolve, basename, sep, isAbsolute } from "node:path";
|
||||
import { Refusal, listSessionFiles, openSessionFile, readRange, closeSync } from "./safe-fs.mjs";
|
||||
import { parseSnapshot, branchPath, branchUnits, toTime } from "./pi.mjs";
|
||||
import { unitParts, takePage, bytesOf, safeId } from "./parts.mjs";
|
||||
import { samePath } from "../../seat/src/seat.mjs";
|
||||
|
||||
export const ACTOR = "local-operator";
|
||||
export const HISTORY = "pi";
|
||||
export const UNSUPPORTED_HARNESS = "unsupported-harness";
|
||||
const NO_STREAM = "no-stream";
|
||||
const HEAD_BYTES = 1024 * 1024;
|
||||
const TAIL_BYTES = 2 * 1024 * 1024;
|
||||
const TITLE_CHARS = 200;
|
||||
export const MAX_FILE_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
||||
|
||||
// Approved roots from the board's specs. Only repository specs qualify:
|
||||
// sessionsDir must be exactly <projectRoot>/.pi/state/<agent>/sessions and the
|
||||
// project must be the root's directory name. Fleet and connector specs do not.
|
||||
export function rootsFromSpecs(specs, registrations = []) {
|
||||
const roots = [];
|
||||
for (const spec of specs) {
|
||||
if (!spec || spec.connector || typeof spec.sessionsDir !== "string" || typeof spec.agent !== "string") continue;
|
||||
const dir = resolve(spec.sessionsDir);
|
||||
const projectRoot = resolve(dir, "..", "..", "..", "..");
|
||||
if (join(projectRoot, ".pi", "state", spec.agent, "sessions") !== dir || basename(projectRoot) !== spec.project) continue;
|
||||
const reg = registrations.find((r) => r && r.seat === spec.agent && r.layout === "repo" && samePath(r.sessionsDir, dir) && (r.project === null || r.project === spec.project)) ?? null;
|
||||
const harness = reg?.harness ?? null;
|
||||
roots.push({
|
||||
seat: spec.agent,
|
||||
project: spec.project,
|
||||
projectRoot,
|
||||
dir,
|
||||
harness: harness ?? HISTORY,
|
||||
unsupportedReason: harness !== null && harness !== HISTORY ? UNSUPPORTED_HARNESS : null,
|
||||
engineStartedAt: toTime(reg?.startedAt) ?? null,
|
||||
});
|
||||
}
|
||||
// A seat on another harness has no Pi sessions directory, so no spec. Its
|
||||
// registration adds a placeholder root, never read, when it names the
|
||||
// standard directory under a project root already approved above (D2).
|
||||
const projects = new Map(roots.map((r) => [r.projectRoot, r.project]));
|
||||
for (const reg of registrations) {
|
||||
if (!reg || reg.layout !== "repo" || typeof reg.harness !== "string" || reg.harness === HISTORY) continue;
|
||||
if (roots.some((r) => r.seat === reg.seat)) continue;
|
||||
for (const [projectRoot, project] of projects) {
|
||||
const dir = join(projectRoot, ".pi", "state", reg.seat, "sessions");
|
||||
if ((reg.project !== null && reg.project !== project) || !samePath(reg.sessionsDir, dir)) continue;
|
||||
roots.push({ seat: reg.seat, project, projectRoot, dir, harness: reg.harness, unsupportedReason: UNSUPPORTED_HARNESS, engineStartedAt: toTime(reg.startedAt) ?? null });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function conversationId(root, name) {
|
||||
return "pi-" + sha256(`${root.projectRoot}\0${root.seat}\0${name}`).slice(0, 32);
|
||||
}
|
||||
|
||||
function unsupportedId(root) {
|
||||
return "unsupported-" + sha256(`${root.projectRoot}\0${root.seat}`).slice(0, 32);
|
||||
}
|
||||
|
||||
// The header's cwd must be an absolute path to the project or inside it. Pi
|
||||
// writes absolute paths; a relative one would resolve against the board's own
|
||||
// directory, so it is refused. Real paths are compared first (the checkout is
|
||||
// reachable through a compatibility symlink); a cwd that no longer exists is
|
||||
// compared as written.
|
||||
export function cwdInProject(cwd, projectRoot) {
|
||||
if (typeof cwd !== "string" || !isAbsolute(cwd)) return false;
|
||||
const inside = (a, b) => a === b || a.startsWith(b.endsWith(sep) ? b : b + sep);
|
||||
if (inside(resolve(cwd), resolve(projectRoot))) return true;
|
||||
try {
|
||||
return inside(realpathSync(cwd), realpathSync(projectRoot));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const firstChars = (s, n) => {
|
||||
const cps = Array.from(String(s).replace(/\s+/g, " ").trim());
|
||||
return cps.length > n ? cps.slice(0, n).join("") + "…" : cps.join("");
|
||||
};
|
||||
|
||||
function userText(m) {
|
||||
if (!m || m.role !== "user") return null;
|
||||
if (typeof m.content === "string") return m.content;
|
||||
if (Array.isArray(m.content)) {
|
||||
const t = m.content.find((b) => b?.type === "text" && typeof b.text === "string" && b.text.trim());
|
||||
return t ? t.text : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const parseLine = (line) => {
|
||||
try {
|
||||
const v = JSON.parse(line);
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// A catalogue row. This is a CHAT-02 summary, not a CHAT-01 catalogueItem:
|
||||
// that record needs an execution-bound currentView, which D1 moved to CHAT-03.
|
||||
function baseRow(root, conversation) {
|
||||
return {
|
||||
conversation,
|
||||
seat: root.seat,
|
||||
project: root.project,
|
||||
harness: root.harness,
|
||||
history: root.unsupportedReason ? null : HISTORY,
|
||||
title: null,
|
||||
readOnly: true,
|
||||
controlMode: "unavailable",
|
||||
conversationCreatedAt: null,
|
||||
engineStartedAt: null,
|
||||
lastActivityAt: null,
|
||||
availability: "available",
|
||||
unsupportedReason: root.unsupportedReason,
|
||||
refusal: null,
|
||||
};
|
||||
}
|
||||
|
||||
// One catalogue row from a head and a tail window; the whole file is not read.
|
||||
function summarize(root, name) {
|
||||
const row = baseRow(root, conversationId(root, name));
|
||||
let file;
|
||||
try {
|
||||
file = openSessionFile(root, name);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Refusal)) throw err;
|
||||
return { ...row, availability: "denied", refusal: err.code };
|
||||
}
|
||||
try {
|
||||
const head = readRange(file.fd, 0, Math.min(file.size, HEAD_BYTES)).toString("utf8");
|
||||
const headLines = head.split("\n");
|
||||
if (headLines.length < 2) return { ...row, availability: "unavailable", refusal: "incomplete-header" };
|
||||
const header = parseLine(headLines[0]);
|
||||
if (!header || header.type !== "session") return { ...row, availability: "unavailable", refusal: "not-a-pi-session" };
|
||||
if (!cwdInProject(header.cwd, root.projectRoot)) return { ...row, availability: "denied", refusal: "foreign-project" };
|
||||
row.conversationCreatedAt = toTime(header.timestamp);
|
||||
if (row.conversationCreatedAt && root.engineStartedAt && row.conversationCreatedAt >= root.engineStartedAt) row.engineStartedAt = root.engineStartedAt;
|
||||
let name_ = null, firstUser = null;
|
||||
for (const line of headLines.slice(1, -1)) {
|
||||
const e = parseLine(line);
|
||||
if (e?.type === "session_info" && typeof e.name === "string" && e.name.trim()) name_ = e.name;
|
||||
if (firstUser === null && e?.type === "message") firstUser = userText(e.message);
|
||||
}
|
||||
const tailStart = Math.max(0, file.size - TAIL_BYTES);
|
||||
const tail = readRange(file.fd, tailStart, file.size - tailStart).toString("utf8");
|
||||
const complete = tail.slice(0, tail.lastIndexOf("\n") + 1).split("\n").slice(tailStart > 0 ? 1 : 0, -1);
|
||||
let lastName = null;
|
||||
for (let i = complete.length - 1; i >= 0; i--) {
|
||||
const e = parseLine(complete[i]);
|
||||
if (!e || e.type === "session" || typeof e.id !== "string") continue;
|
||||
if (row.lastActivityAt === null) row.lastActivityAt = toTime(e.timestamp);
|
||||
if (lastName === null && e.type === "session_info" && typeof e.name === "string" && e.name.trim()) lastName = e.name;
|
||||
if (row.lastActivityAt !== null && lastName !== null) break;
|
||||
}
|
||||
const title = lastName ?? name_ ?? firstUser;
|
||||
row.title = title ? firstChars(title, TITLE_CHARS) : null;
|
||||
return row;
|
||||
} finally {
|
||||
closeSync(file.fd);
|
||||
}
|
||||
}
|
||||
|
||||
// Complete lines only: the snapshot ends at the last "\n" present when the
|
||||
// descriptor was read, so growth during the read is cut there.
|
||||
function readSnapshot(root, name, pinnedLength = null) {
|
||||
const file = openSessionFile(root, name);
|
||||
try {
|
||||
if (file.size > MAX_FILE_BYTES) throw new Refusal("too-large", "session file is larger than the reader accepts");
|
||||
if (pinnedLength !== null) {
|
||||
if (file.size < pinnedLength) return { file, shorter: true };
|
||||
const buf = readRange(file.fd, 0, pinnedLength);
|
||||
return { file, buf, length: pinnedLength };
|
||||
}
|
||||
const buf = readRange(file.fd, 0, file.size);
|
||||
const length = buf.lastIndexOf(0x0a) + 1;
|
||||
return { file, buf: buf.subarray(0, length), length, incomplete: buf.length > length };
|
||||
} finally {
|
||||
closeSync(file.fd);
|
||||
}
|
||||
}
|
||||
|
||||
export function createReader({ roots, now = () => Date.now(), ttlMs = 10 * 60 * 1000, maxCursors = 1000, cacheSize = 2 } = {}) {
|
||||
const listRoots = typeof roots === "function" ? roots : () => roots ?? [];
|
||||
const cursors = new Map();
|
||||
const parsedCache = new Map();
|
||||
const partsCache = new Map();
|
||||
|
||||
const remember = (cache, key, make) => {
|
||||
if (cache.has(key)) {
|
||||
const v = cache.get(key);
|
||||
cache.delete(key);
|
||||
cache.set(key, v);
|
||||
return v;
|
||||
}
|
||||
const v = make();
|
||||
cache.set(key, v);
|
||||
while (cache.size > cacheSize) cache.delete(cache.keys().next().value);
|
||||
return v;
|
||||
};
|
||||
|
||||
function resolveConversation(conversation) {
|
||||
if (typeof conversation !== "string") return null;
|
||||
for (const root of listRoots()) {
|
||||
if (root.unsupportedReason) {
|
||||
if (unsupportedId(root) === conversation) return { root, name: null };
|
||||
continue;
|
||||
}
|
||||
let listing;
|
||||
try {
|
||||
listing = listSessionFiles(root);
|
||||
} catch (err) {
|
||||
if (err instanceof Refusal) continue;
|
||||
throw err;
|
||||
}
|
||||
for (const name of [...listing.files, ...listing.refused.map((r) => r.name)]) {
|
||||
if (conversationId(root, name) === conversation) return { root, name };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function catalogue() {
|
||||
const conversations = [], refusedRoots = [];
|
||||
for (const root of listRoots()) {
|
||||
if (root.unsupportedReason) {
|
||||
conversations.push({ ...baseRow(root, unsupportedId(root)), engineStartedAt: root.engineStartedAt, availability: "unsupported" });
|
||||
continue;
|
||||
}
|
||||
let listing;
|
||||
try {
|
||||
listing = listSessionFiles(root);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Refusal)) throw err;
|
||||
refusedRoots.push({ seat: root.seat, project: root.project, refusal: err.code });
|
||||
continue;
|
||||
}
|
||||
for (const name of listing.files) conversations.push(summarize(root, name));
|
||||
for (const r of listing.refused) conversations.push({ ...baseRow(root, conversationId(root, r.name)), availability: "denied", refusal: r.code });
|
||||
}
|
||||
conversations.sort((a, b) => (b.lastActivityAt ?? "").localeCompare(a.lastActivityAt ?? "") || a.conversation.localeCompare(b.conversation));
|
||||
return { ok: true, conversations, refusedRoots, generatedAt: new Date(now()).toISOString() };
|
||||
}
|
||||
|
||||
// Parsed snapshot plus one branch's entries, cached by content. A null
|
||||
// branch is the default one; an unknown branch gives null.
|
||||
function build(snap, root, branchName, conversation) {
|
||||
const key = `${snap.file.dev}:${snap.file.ino}:${snap.length}:${snap.digest}`;
|
||||
const parsed = remember(parsedCache, key, () => parseSnapshot(snap.buf.toString("utf8")));
|
||||
if (!cwdInProject(parsed.header.cwd, root.projectRoot)) throw new Refusal("foreign-project", "the session belongs to another project");
|
||||
const branch = branchName ?? parsed.defaultBranch;
|
||||
if (!parsed.branches.has(branch)) return null;
|
||||
const leaf = parsed.branches.get(branch);
|
||||
const { entries, sizes } = remember(partsCache, `${key}:${conversation}:${branch}`, () => {
|
||||
const base = { conversation, branch, execution: safeId(parsed.header.id) };
|
||||
const entries = branchUnits(parsed, branchPath(parsed, leaf)).flatMap((u) => unitParts(u, base));
|
||||
return { entries, sizes: entries.map(bytesOf) };
|
||||
});
|
||||
return { parsed, leaf, branch, entries, sizes };
|
||||
}
|
||||
|
||||
const idsDigest = (entries, end) => sha256(entries.slice(0, end).map((e) => e.id).join("\n"));
|
||||
|
||||
function issue(state) {
|
||||
while (cursors.size >= maxCursors) cursors.delete(cursors.keys().next().value);
|
||||
const id = "c-" + randomBytes(16).toString("hex");
|
||||
const expiresAt = new Date(now() + ttlMs).toISOString();
|
||||
const record = {
|
||||
version: 2, kind: "cursor", id, conversation: state.conversation, branch: state.branch, snapshotDigest: state.digest,
|
||||
sourceEpoch: state.epoch, lastEntry: state.lastEntry, expiresAt, actor: state.actor, purpose: state.purpose,
|
||||
};
|
||||
cursors.set(id, { record, state });
|
||||
return record;
|
||||
}
|
||||
|
||||
// One page from `offset`, with a next cursor when more parts remain in this
|
||||
// snapshot and a follow cursor when the page reaches its end.
|
||||
function pageFrom({ root, name, snap, built, conversation, offset, epoch, actor, purpose, incomplete }) {
|
||||
const { entries, sizes } = built;
|
||||
const shell = {
|
||||
version: 2, kind: "page", conversation, branch: built.branch, snapshotDigest: snap.digest, sourceEpoch: epoch,
|
||||
entries: [], nextCursor: "c-" + "0".repeat(32), hasMore: true, readOnly: true, streamEpoch: NO_STREAM, throughSequence: 0,
|
||||
};
|
||||
const end = takePage(entries, sizes, offset, shell);
|
||||
const hasMore = end < entries.length;
|
||||
const state = {
|
||||
root, name, dev: snap.file.dev, ino: snap.file.ino, length: snap.length, digest: snap.digest, epoch,
|
||||
conversation, branch: built.branch,
|
||||
offset: end, prefix: idsDigest(entries, end), lastEntry: end > 0 ? entries[end - 1].id : null, actor, purpose, incomplete,
|
||||
};
|
||||
const cursor = hasMore ? issue({ ...state, follow: false }) : null;
|
||||
const follow = hasMore ? null : issue({ ...state, follow: true });
|
||||
const page = { ...shell, entries: entries.slice(offset, end), nextCursor: cursor ? cursor.id : null, hasMore };
|
||||
const parsed = built.parsed;
|
||||
const view = {
|
||||
conversation,
|
||||
branch: built.branch,
|
||||
defaultBranch: parsed.defaultBranch,
|
||||
branches: [...parsed.branches]
|
||||
.sort(([, a], [, b]) => (a?.index ?? -1) - (b?.index ?? -1))
|
||||
.map(([branch, leaf]) => ({ branch, isDefault: branch === parsed.defaultBranch, lastActivityAt: leaf ? toTime(leaf.entry.timestamp) : null })),
|
||||
incomplete,
|
||||
forkedFromEarlierSession: parsed.header.parentSession !== undefined && parsed.header.parentSession !== null,
|
||||
unreadableLines: parsed.malformed.length,
|
||||
};
|
||||
return { ok: true, page, cursor, follow, view };
|
||||
}
|
||||
|
||||
function refuse(err) {
|
||||
if (err instanceof Refusal) return { ok: false, refusal: { code: err.code, reconcile: err.reconcile, message: err.message } };
|
||||
throw err;
|
||||
}
|
||||
|
||||
function checkCaller(actor, purpose) {
|
||||
if (actor !== ACTOR) throw new Refusal("unknown-actor", "only the local operator reads histories on this route");
|
||||
if (purpose !== "history") throw new Refusal("unsupported-purpose", "only history pages are served");
|
||||
}
|
||||
|
||||
function snapshot(root, name, pinnedLength = null) {
|
||||
const snap = readSnapshot(root, name, pinnedLength);
|
||||
if (snap.shorter) return snap;
|
||||
if (snap.length === 0) throw new Refusal("incomplete-header", "the session header is not complete yet", { reconcile: true });
|
||||
return { ...snap, digest: sha256(snap.buf) };
|
||||
}
|
||||
|
||||
function open({ conversation, branch = null, actor = ACTOR, purpose = "history" } = {}) {
|
||||
try {
|
||||
checkCaller(actor, purpose);
|
||||
const found = resolveConversation(conversation);
|
||||
if (!found) throw new Refusal("unknown-conversation", "no such conversation in the approved roots", { reconcile: true });
|
||||
if (found.root.unsupportedReason) throw new Refusal(found.root.unsupportedReason, "this harness has no history reader yet");
|
||||
const snap = snapshot(found.root, found.name);
|
||||
const built = build(snap, found.root, branch ?? null, conversation);
|
||||
if (!built) throw new Refusal("unknown-branch", "no such branch in this conversation", { reconcile: true });
|
||||
const epoch = "e-" + sha256(`${snap.file.dev}:${snap.file.ino}:${snap.digest}`).slice(0, 40);
|
||||
return pageFrom({ root: found.root, name: found.name, snap, built, conversation, offset: 0, epoch, actor, purpose, incomplete: snap.incomplete });
|
||||
} catch (err) {
|
||||
return refuse(err);
|
||||
}
|
||||
}
|
||||
|
||||
function next({ cursor, conversation, branch, actor = ACTOR, purpose = "history" } = {}) {
|
||||
try {
|
||||
const held = typeof cursor === "string" ? cursors.get(cursor) : undefined;
|
||||
if (!held) throw new Refusal("cursor-unknown", "the cursor is unknown", { reconcile: true });
|
||||
const { record, state } = held;
|
||||
if (Date.parse(record.expiresAt) <= now()) {
|
||||
cursors.delete(cursor);
|
||||
throw new Refusal("cursor-expired", "the cursor has expired", { reconcile: true });
|
||||
}
|
||||
if (actor !== record.actor || purpose !== record.purpose || conversation !== record.conversation || branch !== record.branch) {
|
||||
throw new Refusal("cursor-foreign", "the cursor belongs to another view", { reconcile: true });
|
||||
}
|
||||
// The source must still be the snapshot's file with the same prefix.
|
||||
const pinned = snapshot(state.root, state.name, state.length);
|
||||
if (pinned.shorter) throw new Refusal("source-replaced", "the session file is shorter than the snapshot", { reconcile: true });
|
||||
if (pinned.file.dev !== state.dev || pinned.file.ino !== state.ino) throw new Refusal("source-replaced", "the session file was replaced", { reconcile: true });
|
||||
if (pinned.digest !== state.digest) throw new Refusal("source-replaced", "the session file was rewritten", { reconcile: true });
|
||||
const args = { root: state.root, name: state.name, conversation, epoch: state.epoch, actor, purpose };
|
||||
if (!state.follow) {
|
||||
const built = build(pinned, state.root, state.branch, conversation);
|
||||
return pageFrom({ ...args, snap: pinned, built, offset: state.offset, incomplete: state.incomplete });
|
||||
}
|
||||
// Follow: a fresh snapshot that extends the verified prefix. The view
|
||||
// stays on its branch; view.defaultBranch shows where Pi's default is.
|
||||
const fresh = snapshot(state.root, state.name);
|
||||
if (fresh.file.dev !== state.dev || fresh.file.ino !== state.ino || fresh.length < state.length) throw new Refusal("source-replaced", "the session file was replaced", { reconcile: true });
|
||||
if (sha256(fresh.buf.subarray(0, state.length)) !== state.digest) throw new Refusal("source-replaced", "the session file was rewritten", { reconcile: true });
|
||||
const built = build(fresh, state.root, state.branch, conversation);
|
||||
if (!built || built.entries.length < state.offset || idsDigest(built.entries, state.offset) !== state.prefix) {
|
||||
throw new Refusal("source-replaced", "the history before this point changed", { reconcile: true });
|
||||
}
|
||||
return pageFrom({ ...args, snap: fresh, built, offset: state.offset, incomplete: fresh.incomplete });
|
||||
} catch (err) {
|
||||
return refuse(err);
|
||||
}
|
||||
}
|
||||
|
||||
return { catalogue, open, next, cursorCount: () => cursors.size };
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Read-only file access for approved Pi session roots (#1507, CHAT-02).
|
||||
//
|
||||
// A root is <projectRoot>/.pi/state/<seat>/sessions. The project root comes
|
||||
// from the board's own configuration and is trusted as given (it may itself
|
||||
// be a symlink, like the compatibility path to this checkout). Every
|
||||
// component below it must be a real directory, never a symlink, and a
|
||||
// session file must be a regular file directly inside the root.
|
||||
//
|
||||
// A file is opened O_RDONLY | O_NOFOLLOW | O_NONBLOCK, and the descriptor's
|
||||
// (dev, ino) must match the lstat taken before the open. Node has no openat,
|
||||
// so a directory component swapped between the checks and the open is caught
|
||||
// by re-checking the components after the open, not prevented outright. The
|
||||
// seat that owns a root can write there anyway; the checks keep anything
|
||||
// outside the root from being read through it.
|
||||
//
|
||||
// Nothing here writes, renames, creates or migrates a file.
|
||||
|
||||
import { lstatSync, openSync, fstatSync, readSync, closeSync, readdirSync, constants } from "node:fs";
|
||||
import { join, relative, isAbsolute, sep, basename } from "node:path";
|
||||
|
||||
export class Refusal extends Error {
|
||||
constructor(code, message, { reconcile = false } = {}) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.reconcile = reconcile;
|
||||
}
|
||||
}
|
||||
|
||||
const SESSION_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]*\.jsonl$/;
|
||||
|
||||
// Permission errors inside a root are one conversation's problem, not the
|
||||
// catalogue's: they become a refusal instead of a thrown error.
|
||||
function denied(err, what) {
|
||||
if (err.code === "EACCES" || err.code === "EPERM") return new Refusal("unreadable", `${what} is not readable`);
|
||||
return err;
|
||||
}
|
||||
|
||||
// A directory above the file without search permission is refused the same
|
||||
// way, so one bad root does not fail the catalogue.
|
||||
function lstatOrNull(path) {
|
||||
try {
|
||||
return lstatSync(path, { bigint: true });
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT" || err.code === "ENOTDIR") return null;
|
||||
throw denied(err, "a session path component");
|
||||
}
|
||||
}
|
||||
|
||||
// Every component from the project root down to the sessions directory must
|
||||
// be a real directory.
|
||||
export function checkRoot(root) {
|
||||
const rel = relative(root.projectRoot, root.dir);
|
||||
if (!rel || rel.startsWith("..") || isAbsolute(rel)) throw new Refusal("unsafe-path", "session root is outside its project");
|
||||
let path = root.projectRoot;
|
||||
for (const part of rel.split(sep)) {
|
||||
path = join(path, part);
|
||||
const st = lstatOrNull(path);
|
||||
if (!st) throw new Refusal("unavailable", "session root does not exist");
|
||||
if (st.isSymbolicLink()) throw new Refusal("unsafe-path", "session root contains a symlink");
|
||||
if (!st.isDirectory()) throw new Refusal("unsafe-path", "session root is not a directory");
|
||||
}
|
||||
}
|
||||
|
||||
// Session files directly inside a root, by name. Symlinks and anything that
|
||||
// is not a regular *.jsonl file are reported, never followed.
|
||||
export function listSessionFiles(root) {
|
||||
checkRoot(root);
|
||||
const files = [], refused = [];
|
||||
let dirents;
|
||||
try {
|
||||
dirents = readdirSync(root.dir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
throw denied(err, "session root");
|
||||
}
|
||||
for (const dirent of dirents) {
|
||||
if (!dirent.name.endsWith(".jsonl")) continue;
|
||||
if (!SESSION_NAME.test(dirent.name)) refused.push({ name: dirent.name, code: "unsafe-path" });
|
||||
else if (dirent.isSymbolicLink()) refused.push({ name: dirent.name, code: "unsafe-path" });
|
||||
else if (dirent.isFile()) files.push(dirent.name);
|
||||
}
|
||||
return { files: files.sort(), refused };
|
||||
}
|
||||
|
||||
// Opens one session file read-only. The caller must close the returned fd.
|
||||
export function openSessionFile(root, name) {
|
||||
if (typeof name !== "string" || name !== basename(name) || !SESSION_NAME.test(name)) throw new Refusal("unsafe-path", "not a session file name");
|
||||
checkRoot(root);
|
||||
const path = join(root.dir, name);
|
||||
const before = lstatOrNull(path);
|
||||
if (!before) throw new Refusal("unknown-conversation", "session file no longer exists", { reconcile: true });
|
||||
if (before.isSymbolicLink()) throw new Refusal("unsafe-path", "session file is a symlink");
|
||||
if (!before.isFile()) throw new Refusal("unsafe-path", "session file is not a regular file");
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
} catch (err) {
|
||||
if (err.code === "ELOOP") throw new Refusal("unsafe-path", "session file became a symlink");
|
||||
if (err.code === "ENOENT") throw new Refusal("unknown-conversation", "session file no longer exists", { reconcile: true });
|
||||
throw denied(err, "session file");
|
||||
}
|
||||
try {
|
||||
const st = fstatSync(fd, { bigint: true });
|
||||
if (!st.isFile() || st.dev !== before.dev || st.ino !== before.ino) throw new Refusal("unsafe-path", "session file changed while it was opened");
|
||||
checkRoot(root);
|
||||
return { fd, dev: st.dev.toString(), ino: st.ino.toString(), size: Number(st.size) };
|
||||
} catch (err) {
|
||||
closeSync(fd);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function readRange(fd, start, length) {
|
||||
const buf = Buffer.alloc(length);
|
||||
let done = 0;
|
||||
while (done < length) {
|
||||
const n = readSync(fd, buf, done, length - done, start + done);
|
||||
if (n === 0) break;
|
||||
done += n;
|
||||
}
|
||||
return done === length ? buf : buf.subarray(0, done);
|
||||
}
|
||||
|
||||
export { closeSync };
|
||||
@@ -0,0 +1,773 @@
|
||||
// packages/conversation (#1507, CHAT-02): fixtures F1–F15 and F17 from the
|
||||
// brief (agents/dewey/work/chat-02/BRIEF.md §2.1). F16 is in the control-board
|
||||
// suite, which serves these routes. Every reader call runs inside noWrites(),
|
||||
// which fingerprints the fixture tree before and after (F17). Every page and
|
||||
// cursor is checked against the CHAT-01 schema at the end.
|
||||
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, readdirSync, rmSync, chmodSync, symlinkSync, lstatSync, renameSync, openSync, writeSync, closeSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve, relative } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { rootsFromSpecs, createReader, conversationId, cwdInProject, UNSUPPORTED_HARNESS } from "../src/reader.mjs";
|
||||
import { fragments, safeId, LIMITS } from "../src/parts.mjs";
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, "..", "..", "..");
|
||||
const schemaPath = join(repoRoot, "docs", "plans", "chat-01", "contracts.schema.json");
|
||||
const tmp = mkdtempSync(join(tmpdir(), "conversation-"));
|
||||
const records = [];
|
||||
after(() => {
|
||||
// chmod 000 fixtures would block removal
|
||||
spawnSync("chmod", ["-R", "u+rwx", tmp]);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
let fixtureCount = 0;
|
||||
const time = (i) => new Date(Date.UTC(2026, 8, 26, 12, 0, 0) + i * 1000).toISOString();
|
||||
|
||||
// A project with one seat's sessions directory.
|
||||
function project(seat = "pi-seat") {
|
||||
const base = join(tmp, `f${++fixtureCount}`);
|
||||
const proj = join(base, "proj");
|
||||
const dir = join(proj, ".pi", "state", seat, "sessions");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return { base, proj, dir, seat, spec: { agent: seat, project: "proj", sessionsDir: dir } };
|
||||
}
|
||||
|
||||
const header = (proj, extra = {}) => ({ type: "session", version: 3, id: "0f5e1c2a-1111-4222-8333-944455556666", timestamp: time(0), cwd: proj, ...extra });
|
||||
const msg = (id, parentId, role, content, extra = {}, i = 1) => ({ type: "message", id, parentId, timestamp: time(i), message: { role, content, ...extra } });
|
||||
const user = (id, parentId, text, i) => msg(id, parentId, "user", [{ type: "text", text }], {}, i);
|
||||
const assistant = (id, parentId, text, i) => msg(id, parentId, "assistant", [{ type: "text", text }], { stopReason: "stop" }, i);
|
||||
const lines = (...values) => values.map((v) => (typeof v === "string" ? v : JSON.stringify(v)) + "\n").join("");
|
||||
|
||||
function writeSession(dir, name, head, entries, trailing = "") {
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, lines(head, ...entries) + trailing);
|
||||
return path;
|
||||
}
|
||||
|
||||
// Fingerprint of every file and directory under `root`, symlinks not followed.
|
||||
function fingerprint(root) {
|
||||
const out = {};
|
||||
const walk = (path) => {
|
||||
const st = lstatSync(path, { bigint: true });
|
||||
const rec = { dev: String(st.dev), ino: String(st.ino), mtime: String(st.mtimeNs), size: String(st.size), mode: String(st.mode) };
|
||||
if (st.isDirectory()) {
|
||||
rec.list = st.mode & 0o400n ? readdirSync(path).sort() : null;
|
||||
for (const n of rec.list ?? []) walk(join(path, n));
|
||||
} else if (st.isFile() && st.mode & 0o400n) {
|
||||
rec.sha256 = createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
}
|
||||
out[path] = rec;
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
// F17: a reader operation leaves every file and listing in `root` unchanged.
|
||||
function noWrites(root, fn) {
|
||||
const before = fingerprint(root);
|
||||
const result = fn();
|
||||
assert.deepEqual(fingerprint(root), before, "a reader operation changed the fixture tree");
|
||||
for (const key of ["page", "cursor", "follow"]) if (result?.[key]) records.push(result[key]);
|
||||
return result;
|
||||
}
|
||||
|
||||
function readerFor(fx, opts = {}) {
|
||||
return createReader({ roots: rootsFromSpecs([fx.spec], opts.registrations ?? []), ...opts });
|
||||
}
|
||||
|
||||
function openOne(fx, reader, name = "s.jsonl", args = {}) {
|
||||
return noWrites(fx.base, () => reader.open({ conversation: conversationId(rootsFromSpecs([fx.spec])[0], name), ...args }));
|
||||
}
|
||||
|
||||
// Every page from an open, following next cursors.
|
||||
function readAll(fx, reader, first) {
|
||||
const pages = [first];
|
||||
let r = first;
|
||||
while (r.ok && r.page.hasMore) {
|
||||
const page = r.page;
|
||||
r = noWrites(fx.base, () => reader.next({ cursor: page.nextCursor, conversation: page.conversation, branch: page.branch }));
|
||||
assert.equal(r.ok, true, JSON.stringify(r.refusal));
|
||||
pages.push(r);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
const texts = (page) => page.entries.map((e) => e.content.map((b) => b.text ?? b.summary ?? b.argumentsText ?? "").join(""));
|
||||
const roles = (page) => page.entries.map((e) => e.role);
|
||||
|
||||
test("a plain conversation: catalogue row, one page, CHAT-01 records", () => {
|
||||
const fx = project();
|
||||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||||
user("a1", null, "hello", 1),
|
||||
{ type: "session_info", id: "a2", parentId: "a1", timestamp: time(2), name: "Greeting" },
|
||||
assistant("a3", "a2", "hi there", 3),
|
||||
{ type: "model_change", id: "a4", parentId: "a3", timestamp: time(4), provider: "x", modelId: "y" },
|
||||
]);
|
||||
const reader = readerFor(fx);
|
||||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||||
assert.equal(cat.conversations.length, 1);
|
||||
const row = cat.conversations[0];
|
||||
assert.equal(row.title, "Greeting");
|
||||
assert.equal(row.availability, "available");
|
||||
assert.equal(row.readOnly, true);
|
||||
assert.equal(row.conversationCreatedAt, time(0));
|
||||
assert.equal(row.lastActivityAt, time(4));
|
||||
assert.equal(row.engineStartedAt, null);
|
||||
const r = openOne(fx, reader);
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(roles(r.page), ["user", "assistant"]);
|
||||
assert.deepEqual(texts(r.page), ["hello", "hi there"]);
|
||||
assert.equal(r.page.hasMore, false);
|
||||
assert.equal(r.page.nextCursor, null);
|
||||
assert.equal(r.cursor, null);
|
||||
assert.ok(r.follow, "the last page carries a follow cursor");
|
||||
assert.equal(r.page.branch, "main");
|
||||
assert.equal(r.view.defaultBranch, "main");
|
||||
assert.equal(r.page.entries[1].parent, "a2");
|
||||
assert.equal(r.page.readOnly, true);
|
||||
});
|
||||
|
||||
test("native entries map to blocks: tools, thinking, bash, notices, ids that do not fit", () => {
|
||||
const fx = project();
|
||||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||||
user("b1", null, "run it", 1),
|
||||
msg("b2", "b1", "assistant", [
|
||||
{ type: "thinking", thinking: "plan" },
|
||||
{ type: "thinking", thinking: "" },
|
||||
{ type: "toolCall", id: "call_x|fc_y", name: "bash", arguments: { command: "ls" } },
|
||||
{ type: "thinking", thinking: "[Reasoning redacted]", redacted: true, thinkingSignature: "opaque" },
|
||||
], { stopReason: "toolUse" }, 2),
|
||||
msg("b3", "b2", "toolResult", [{ type: "text", text: "a" }, { type: "image", data: "AAAA", mimeType: "image/png" }, { type: "text", text: "b" }], { toolCallId: "call_x|fc_y", toolName: "bash", isError: false }, 3),
|
||||
{ type: "message", id: "b4", parentId: "b3", timestamp: time(4), message: { role: "bashExecution", command: "pwd", output: "/p", exitCode: 1, cancelled: false } },
|
||||
{ type: "custom_message", id: "b5", parentId: "b4", timestamp: time(5), customType: "x", content: "shown", display: true },
|
||||
{ type: "custom_message", id: "b6", parentId: "b5", timestamp: time(6), customType: "x", content: "hidden", display: false },
|
||||
msg("b7", "b6", "assistant", [{ type: "text", text: "partial" }], { stopReason: "error", errorMessage: "overloaded" }, 7),
|
||||
{ type: "future_kind", id: "b8", parentId: "b7", timestamp: time(8) },
|
||||
]);
|
||||
const r = openOne(fx, readerFor(fx));
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(roles(r.page), ["user", "assistant", "tool", "tool", "notice", "assistant", "notice", "notice"]);
|
||||
const [, a, tool, bash, shown, , end, unknown] = r.page.entries;
|
||||
assert.deepEqual(a.content.map((b) => [b.type, b.visibility ?? null]), [["thinking", "permitted-visible"], ["thinking", "unavailable"], ["tool-call", null], ["thinking", "unavailable"]]);
|
||||
assert.equal(a.content[3].text, "", "redacted reasoning shows no placeholder");
|
||||
assert.equal(a.content[2].call, safeId("call_x|fc_y"));
|
||||
assert.match(a.content[2].call, /^h-[0-9a-f]{40}$/);
|
||||
assert.equal(a.content[2].argumentsText, '{"command":"ls"}');
|
||||
assert.deepEqual(tool.content.map((b) => b.type), ["tool-result", "attachment"]);
|
||||
assert.equal(tool.content[0].text, "a\nb");
|
||||
assert.equal(tool.content[0].call, a.content[2].call);
|
||||
assert.deepEqual(bash.content.map((b) => b.type), ["tool-call", "tool-result"]);
|
||||
assert.equal(bash.content[1].isError, true);
|
||||
assert.equal(shown.content[0].text, "shown");
|
||||
assert.match(end.content[0].text, /overloaded/);
|
||||
assert.match(unknown.content[0].text, /future_kind/);
|
||||
assert.equal(new Set(r.page.entries.map((e) => e.id)).size, r.page.entries.length, "entry ids are unique");
|
||||
});
|
||||
|
||||
test("F1: a malformed line is an unavailable part at its position, and reading continues", () => {
|
||||
const fx = project();
|
||||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||||
user("c1", null, "one", 1),
|
||||
assistant("c2", "c1", "two", 2),
|
||||
'{"type":"message","id":"c3", broken',
|
||||
user("c4", "c2", "four", 4),
|
||||
"[1,2,3]",
|
||||
assistant("x.line-6", "c4", "five", 5), // a native id shaped like a notice id
|
||||
]);
|
||||
const r = openOne(fx, readerFor(fx));
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(roles(r.page), ["user", "assistant", "notice", "user", "notice", "assistant"]);
|
||||
assert.equal(new Set(r.page.entries.map((e) => e.id)).size, 6, "native ids never collide with notice ids");
|
||||
assert.match(texts(r.page)[2], /^Line 4 could not be read/);
|
||||
assert.match(texts(r.page)[4], /^Line 6 could not be read/);
|
||||
assert.equal(r.view.unreadableLines, 2);
|
||||
const cat = noWrites(fx.base, () => readerFor(fx).catalogue());
|
||||
assert.equal(cat.conversations[0].availability, "available");
|
||||
});
|
||||
|
||||
test("F1: a missing parent stops the history with a notice that names the unreadable lines", () => {
|
||||
const fx = project();
|
||||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||||
user("d1", null, "one", 1),
|
||||
"garbage",
|
||||
assistant("d3", "d2", "three", 3),
|
||||
]);
|
||||
const reader = readerFor(fx);
|
||||
const r = openOne(fx, reader);
|
||||
assert.deepEqual([roles(r.page), r.page.branch], [["notice", "assistant"], "b.d3"]);
|
||||
assert.match(texts(r.page)[0], /missing from the file\. Line 3 could not be read and may have held it\.$/);
|
||||
// The history before the gap is its own branch, and the line shows there too.
|
||||
const main = openOne(fx, reader, "s.jsonl", { branch: "main" });
|
||||
assert.deepEqual([roles(main.page), texts(main.page)[0]], [["user", "notice"], "one"]);
|
||||
assert.match(texts(main.page)[1], /^Line 3 could not be read\. It may belong to this branch or another one\.$/);
|
||||
const fx2 = project();
|
||||
writeSession(fx2.dir, "s.jsonl", header(fx2.proj), [user("e1", null, "one", 1), assistant("e3", "e2", "three", 3)]);
|
||||
const r2 = openOne(fx2, readerFor(fx2));
|
||||
assert.deepEqual(roles(r2.page), ["notice", "assistant"], "no unreadable line: history stops with a notice");
|
||||
assert.equal(texts(r2.page)[0], "History before this point is unavailable: an earlier entry is missing from the file.");
|
||||
});
|
||||
|
||||
test("F1: an unreadable fork is never merged into another branch's history", () => {
|
||||
// Line 4 held X, a fork from a; y is X's child.
|
||||
const fx = project();
|
||||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||||
user("a", null, "root", 1),
|
||||
assistant("b", "a", "ON THE OTHER BRANCH", 2),
|
||||
'{"type":"message","id":"X","parentId":"a",',
|
||||
user("y", "X", "leaf", 4),
|
||||
]);
|
||||
const reader = readerFor(fx);
|
||||
const r = openOne(fx, reader);
|
||||
assert.deepEqual(r.view.branches.map((b) => [b.branch, b.isDefault]), [["main", false], ["b.y", true]]);
|
||||
assert.deepEqual(roles(r.page), ["notice", "user"]);
|
||||
assert.match(texts(r.page)[0], /Line 4 could not be read and may have held it/);
|
||||
assert.ok(!texts(r.page).some((t) => /ON THE OTHER BRANCH|^root$/.test(t)), "no text from the other branch");
|
||||
});
|
||||
|
||||
test("F1: a follow stays on its branch when the next entry's parent is unreadable", () => {
|
||||
const fx = project();
|
||||
const path = writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("t1", null, "one", 1), "garbage"]);
|
||||
const reader = readerFor(fx);
|
||||
const r = openOne(fx, reader);
|
||||
assert.deepEqual([roles(r.page), r.page.branch], [["user", "notice"], "main"]);
|
||||
// t3's parent was on the unreadable line, so t3 starts its own branch. The
|
||||
// view keeps its branch and parts, and reports the new default.
|
||||
appendFileSync(path, lines(assistant("t3", "t2", "three", 3)));
|
||||
const f = noWrites(fx.base, () => reader.next({ cursor: r.follow.id, conversation: r.page.conversation, branch: r.page.branch }));
|
||||
assert.deepEqual([f.ok, f.page.entries.length, f.page.branch, f.view.defaultBranch], [true, 0, "main", "b.t3"]);
|
||||
const again = openOne(fx, reader);
|
||||
assert.deepEqual([roles(again.page), again.page.branch], [["notice", "assistant"], "b.t3"]);
|
||||
assert.match(texts(again.page)[0], /Line 3 could not be read and may have held it/);
|
||||
});
|
||||
|
||||
test("F1: a file whose entries are all unreadable shows a notice per line", () => {
|
||||
const fx = project();
|
||||
writeSession(fx.dir, "s.jsonl", header(fx.proj), ["garbage", "[1]", '{"type":"message"}']);
|
||||
const r = openOne(fx, readerFor(fx));
|
||||
assert.deepEqual([r.ok, r.page.branch, r.view.unreadableLines], [true, "main", 3]);
|
||||
assert.deepEqual(texts(r.page).map((t) => t.match(/^Line (\d+)/)[1]), ["2", "3", "4"]);
|
||||
});
|
||||
|
||||
test("F2: a truncated trailing line marks the view incomplete, not an error", () => {
|
||||
const fx = project();
|
||||
const path = writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("f1", null, "one", 1)], '{"type":"message","id":"f2","parentId":"f1"');
|
||||
const reader = readerFor(fx);
|
||||
const r = openOne(fx, reader);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.view.incomplete, true);
|
||||
assert.deepEqual(texts(r.page), ["one"]);
|
||||
assert.equal(r.view.unreadableLines, 0);
|
||||
appendFileSync(path, ',"timestamp":"' + time(2) + '","message":{"role":"assistant","content":[{"type":"text","text":"two"}]}}\n');
|
||||
const f = noWrites(fx.base, () => reader.next({ cursor: r.follow.id, conversation: r.page.conversation, branch: r.page.branch }));
|
||||
assert.equal(f.ok, true);
|
||||
assert.deepEqual(texts(f.page), ["two"]);
|
||||
assert.equal(f.view.incomplete, false);
|
||||
assert.equal(f.page.sourceEpoch, r.page.sourceEpoch);
|
||||
});
|
||||
|
||||
// 150 one-part entries: two pages.
|
||||
function longSession(fx) {
|
||||
const entries = [];
|
||||
for (let i = 0; i < 150; i++) entries.push(i % 2 ? assistant(`g${i}`, i ? `g${i - 1}` : null, `m${i}`, i + 1) : user(`g${i}`, i ? `g${i - 1}` : null, `m${i}`, i + 1));
|
||||
return writeSession(fx.dir, "s.jsonl", header(fx.proj), entries);
|
||||
}
|
||||
|
||||
test("pagination: 100 parts, then the rest; parts concatenate to the whole branch", () => {
|
||||
const fx = project();
|
||||
longSession(fx);
|
||||
const reader = readerFor(fx);
|
||||
const pages = readAll(fx, reader, openOne(fx, reader));
|
||||
assert.deepEqual(pages.map((p) => p.page.entries.length), [100, 50]);
|
||||
assert.deepEqual(pages.flatMap((p) => texts(p.page)), Array.from({ length: 150 }, (_, i) => `m${i}`));
|
||||
assert.equal(pages[0].cursor.lastEntry, pages[0].page.entries[99].id);
|
||||
assert.equal(pages[0].follow, null);
|
||||
assert.ok(pages[1].follow);
|
||||
});
|
||||
|
||||
test("F3: a replaced file (new inode) refuses old cursors with reconcile", () => {
|
||||
const fx = project();
|
||||
const path = longSession(fx);
|
||||
const reader = readerFor(fx);
|
||||
const r = openOne(fx, reader);
|
||||
const content = readFileSync(path);
|
||||
writeFileSync(path + ".new", content);
|
||||
renameSync(path + ".new", path);
|
||||
for (const cursor of [r.cursor.id]) {
|
||||
const x = noWrites(fx.base, () => reader.next({ cursor, conversation: r.page.conversation, branch: r.page.branch }));
|
||||
assert.deepEqual([x.ok, x.refusal.code, x.refusal.reconcile], [false, "source-replaced", true]);
|
||||
}
|
||||
const short = project();
|
||||
writeSession(short.dir, "s.jsonl", header(short.proj), [user("h1", null, "one", 1)]);
|
||||
const reader2 = readerFor(short);
|
||||
const r2 = openOne(short, reader2);
|
||||
const p2 = join(short.dir, "s.jsonl");
|
||||
writeFileSync(p2 + ".new", readFileSync(p2));
|
||||
renameSync(p2 + ".new", p2);
|
||||
const f = noWrites(short.base, () => reader2.next({ cursor: r2.follow.id, conversation: r2.page.conversation, branch: r2.page.branch }));
|
||||
assert.deepEqual([f.ok, f.refusal.code, f.refusal.reconcile], [false, "source-replaced", true], "follow cursor too");
|
||||
const reopened = openOne(short, reader2);
|
||||
assert.notEqual(reopened.page.sourceEpoch, r2.page.sourceEpoch, "a reopen is a new epoch");
|
||||
});
|
||||
|
||||
test("F4: a same-inode rewrite of the prefix refuses old cursors with reconcile", () => {
|
||||
const fx = project();
|
||||
const path = longSession(fx);
|
||||
const reader = readerFor(fx);
|
||||
const r = openOne(fx, reader);
|
||||
const ino = lstatSync(path).ino;
|
||||
const text = readFileSync(path, "utf8").replace('"m3"', '"M3"');
|
||||
const fd = openSync(path, "r+");
|
||||
writeSync(fd, text, 0);
|
||||
closeSync(fd);
|
||||
assert.equal(lstatSync(path).ino, ino, "same inode");
|
||||
const x = noWrites(fx.base, () => reader.next({ cursor: r.cursor.id, conversation: r.page.conversation, branch: r.page.branch }));
|
||||
assert.deepEqual([x.ok, x.refusal.code, x.refusal.reconcile], [false, "source-replaced", true]);
|
||||
// Shorter than the snapshot.
|
||||
const r2 = openOne(fx, reader);
|
||||
writeFileSync(path, lines(header(fx.proj)));
|
||||
const y = noWrites(fx.base, () => reader.next({ cursor: r2.cursor.id, conversation: r2.page.conversation, branch: r2.page.branch }));
|
||||
assert.deepEqual([y.ok, y.refusal.code, y.refusal.reconcile], [false, "source-replaced", true]);
|
||||
});
|
||||
|
||||
test("F5: growth between pages keeps the epoch and the page stops at the pinned length", () => {
|
||||
const fx = project();
|
||||
const path = longSession(fx);
|
||||
const reader = readerFor(fx);
|
||||
const r = openOne(fx, reader);
|
||||
appendFileSync(path, lines(user("g150", "g149", "later", 200)) + '{"type":"mess');
|
||||
const r2 = noWrites(fx.base, () => reader.next({ cursor: r.cursor.id, conversation: r.page.conversation, branch: r.page.branch }));
|
||||
assert.equal(r2.ok, true);
|
||||
assert.equal(r2.page.entries.length, 50);
|
||||
assert.equal(r2.page.snapshotDigest, r.page.snapshotDigest);
|
||||
assert.equal(r2.page.sourceEpoch, r.page.sourceEpoch);
|
||||
assert.equal(r2.view.incomplete, false, "the pinned snapshot ended on a complete line");
|
||||
assert.ok(!texts(r2.page).includes("later"));
|
||||
// Following picks up the growth, up to the last complete line.
|
||||
const f = noWrites(fx.base, () => reader.next({ cursor: r2.follow.id, conversation: r2.page.conversation, branch: r2.page.branch }));
|
||||
assert.equal(f.ok, true);
|
||||
assert.deepEqual(texts(f.page), ["later"]);
|
||||
assert.equal(f.page.sourceEpoch, r.page.sourceEpoch);
|
||||
assert.notEqual(f.page.snapshotDigest, r.page.snapshotDigest);
|
||||
assert.equal(f.page.branch, "main");
|
||||
assert.equal(f.view.incomplete, true);
|
||||
// A read of a file that grows by a partial line cuts at the last newline.
|
||||
const again = openOne(fx, reader);
|
||||
assert.equal(again.view.incomplete, true);
|
||||
const all = readAll(fx, reader, again).flatMap((p) => texts(p.page));
|
||||
assert.equal(all.length, 151);
|
||||
});
|
||||
|
||||
test("F6: unknown, foreign and expired cursors refuse and leave the cursor usable", () => {
|
||||
const fx = project();
|
||||
longSession(fx);
|
||||
let clock = Date.parse("2026-09-26T12:00:00Z");
|
||||
const reader = readerFor(fx, { now: () => clock, ttlMs: 60_000 });
|
||||
const r = openOne(fx, reader);
|
||||
const good = { cursor: r.cursor.id, conversation: r.page.conversation, branch: r.page.branch };
|
||||
const cases = [
|
||||
[{ ...good, cursor: "c-unknown" }, "cursor-unknown"],
|
||||
[{ ...good, actor: "someone-else" }, "cursor-foreign"],
|
||||
[{ ...good, purpose: "drafts" }, "cursor-foreign"],
|
||||
[{ ...good, conversation: "pi-" + "0".repeat(32) }, "cursor-foreign"],
|
||||
[{ ...good, branch: "g1" }, "cursor-foreign"],
|
||||
];
|
||||
for (const [args, code] of cases) {
|
||||
const x = noWrites(fx.base, () => reader.next(args));
|
||||
assert.deepEqual([x.ok, x.refusal.code, x.refusal.reconcile], [false, code, true], code);
|
||||
}
|
||||
const ok = noWrites(fx.base, () => reader.next(good));
|
||||
assert.equal(ok.ok, true, "the old view's cursor still works after the refusals");
|
||||
clock += 60_000;
|
||||
const expired = noWrites(fx.base, () => reader.next(good));
|
||||
assert.deepEqual([expired.ok, expired.refusal.code, expired.refusal.reconcile], [false, "cursor-expired", true]);
|
||||
const gone = noWrites(fx.base, () => reader.next(good));
|
||||
assert.equal(gone.refusal.code, "cursor-unknown");
|
||||
// Evicted cursors are unknown.
|
||||
const small = readerFor(fx, { maxCursors: 2 });
|
||||
const first = openOne(fx, small);
|
||||
openOne(fx, small);
|
||||
openOne(fx, small);
|
||||
const evicted = noWrites(fx.base, () => small.next({ cursor: first.cursor.id, conversation: first.page.conversation, branch: first.page.branch }));
|
||||
assert.equal(evicted.refusal.code, "cursor-unknown");
|
||||
assert.ok(small.cursorCount() <= 2);
|
||||
// open refuses other actors and purposes outright.
|
||||
assert.equal(openOne(fx, reader, "s.jsonl", { actor: "x" }).refusal.code, "unknown-actor");
|
||||
assert.equal(openOne(fx, reader, "s.jsonl", { purpose: "drafts" }).refusal.code, "unsupported-purpose");
|
||||
});
|
||||
|
||||
// A file outside every root that no read may touch: unreadable, so an
|
||||
// attempted open would throw EACCES rather than refuse.
|
||||
function forbidden(base, name = "outside.jsonl") {
|
||||
const dir = join(base, "outside");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, lines(header("/elsewhere"), user("z1", null, "secret", 1)));
|
||||
chmodSync(path, 0o000);
|
||||
return path;
|
||||
}
|
||||
|
||||
test("F7: a symlinked file and a symlinked directory component are refused, never opened", () => {
|
||||
const fx = project();
|
||||
const outside = forbidden(fx.base);
|
||||
symlinkSync(outside, join(fx.dir, "link.jsonl"));
|
||||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("i1", null, "ok", 1)]);
|
||||
const reader = readerFor(fx);
|
||||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||||
const link = cat.conversations.find((c) => c.availability === "denied");
|
||||
assert.deepEqual([link.refusal, link.title], ["unsafe-path", null]);
|
||||
const r = openOne(fx, reader, "link.jsonl");
|
||||
assert.deepEqual([r.ok, r.refusal.code], [false, "unsafe-path"]);
|
||||
assert.equal(cat.conversations.filter((c) => c.availability === "available").length, 1);
|
||||
|
||||
// .pi/state/<seat> is a symlink to a directory holding a sessions dir.
|
||||
const fy = project("other");
|
||||
const realSeat = join(fy.base, "real-seat");
|
||||
mkdirSync(join(realSeat, "sessions"), { recursive: true });
|
||||
writeFileSync(join(realSeat, "sessions", "s.jsonl"), lines(header(fy.proj), user("j1", null, "hidden", 1)));
|
||||
chmodSync(join(realSeat, "sessions", "s.jsonl"), 0o000);
|
||||
mkdirSync(join(fy.proj, ".pi", "state"), { recursive: true });
|
||||
symlinkSync(realSeat, join(fy.proj, ".pi", "state", "linked"));
|
||||
const spec = { agent: "linked", project: "proj", sessionsDir: join(fy.proj, ".pi", "state", "linked", "sessions") };
|
||||
const reader2 = createReader({ roots: rootsFromSpecs([spec]) });
|
||||
const cat2 = noWrites(fy.base, () => reader2.catalogue());
|
||||
assert.deepEqual(cat2.conversations, []);
|
||||
assert.deepEqual(cat2.refusedRoots, [{ seat: "linked", project: "proj", refusal: "unsafe-path" }]);
|
||||
const root = rootsFromSpecs([spec])[0];
|
||||
const r2 = noWrites(fy.base, () => reader2.open({ conversation: conversationId(root, "s.jsonl") }));
|
||||
assert.deepEqual([r2.ok, r2.refusal.code], [false, "unknown-conversation"]);
|
||||
});
|
||||
|
||||
test("F8: a file swapped for a symlink after the catalogue is refused", () => {
|
||||
const fx = project();
|
||||
const outside = forbidden(fx.base);
|
||||
const path = writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("k1", null, "ok", 1)]);
|
||||
const reader = readerFor(fx);
|
||||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||||
const r = openOne(fx, reader);
|
||||
assert.equal(r.ok, true);
|
||||
rmSync(path);
|
||||
symlinkSync(outside, path);
|
||||
const x = noWrites(fx.base, () => reader.open({ conversation: cat.conversations[0].conversation }));
|
||||
assert.deepEqual([x.ok, x.refusal.code], [false, "unsafe-path"]);
|
||||
const f = noWrites(fx.base, () => reader.next({ cursor: r.follow.id, conversation: r.page.conversation, branch: r.page.branch }));
|
||||
assert.deepEqual([f.ok, f.refusal.code], [false, "unsafe-path"]);
|
||||
});
|
||||
|
||||
test("F9: registrations never add or redirect a root", () => {
|
||||
const fx = project("seat-a");
|
||||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("l1", null, "ok", 1)]);
|
||||
const outside = forbidden(fx.base);
|
||||
const other = project("seat-a");
|
||||
writeSession(other.dir, "o.jsonl", header(other.proj), [user("l2", null, "other project", 1)]);
|
||||
const linkDir = join(fx.base, "link-to-other");
|
||||
symlinkSync(other.dir, linkDir);
|
||||
const reg = (extra) => ({ version: 1, seat: "seat-a", project: "proj", layout: "repo", harness: "claude-code", startedAt: time(0), ...extra });
|
||||
const registrations = [
|
||||
reg({ sessionsDir: join(fx.base, "outside") }), // outside every root
|
||||
reg({ sessionsDir: linkDir }), // a symlink to another project's root
|
||||
reg({ sessionsDir: other.dir }), // another project's directory
|
||||
reg({ sessionsDir: fx.dir, project: "someone-else" }), // this directory, another project
|
||||
reg({ seat: "seat-b", sessionsDir: join(fx.base, "outside") }), // a non-Pi placeholder outside the project
|
||||
];
|
||||
const roots = rootsFromSpecs([fx.spec], registrations);
|
||||
assert.deepEqual(roots.map((r) => [r.seat, r.dir, r.harness, r.unsupportedReason]), [["seat-a", fx.dir, "pi", null]]);
|
||||
const reader = createReader({ roots });
|
||||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||||
assert.deepEqual(cat.conversations.map((c) => [c.title, c.availability]), [["ok", "available"]]);
|
||||
assert.ok(outside);
|
||||
// A registration that matches the root narrows it and supplies launch time.
|
||||
const matched = rootsFromSpecs([fx.spec], [reg({ harness: "pi", sessionsDir: fx.dir, startedAt: time(-5) })]);
|
||||
assert.equal(matched[0].engineStartedAt, time(-5));
|
||||
const cat2 = noWrites(fx.base, () => createReader({ roots: matched }).catalogue());
|
||||
assert.equal(cat2.conversations[0].engineStartedAt, time(-5));
|
||||
// Only repository specs are roots.
|
||||
assert.deepEqual(rootsFromSpecs([
|
||||
{ agent: "x", project: "fleet", sessionsDir: join(fx.base, "fleet", "x", ".pi", "agent", "sessions") },
|
||||
{ agent: "seat-a", project: "wrong", sessionsDir: fx.dir },
|
||||
{ agent: "seat-b", project: "proj", sessionsDir: fx.dir },
|
||||
{ ...fx.spec, connector: "discord" },
|
||||
]), []);
|
||||
});
|
||||
|
||||
test("F10: a header cwd naming another project is refused", () => {
|
||||
const fx = project();
|
||||
writeSession(fx.dir, "s.jsonl", header(join(fx.base, "another-project")), [user("m1", null, "x", 1)]);
|
||||
writeSession(fx.dir, "sub.jsonl", header(join(fx.proj, "packages", "x")), [user("m2", null, "sub", 1)]);
|
||||
writeSession(fx.dir, "prefix.jsonl", header(fx.proj + "-evil"), [user("m3", null, "x", 1)]);
|
||||
// Relative to the reader's own directory this names the project; it is still refused.
|
||||
writeSession(fx.dir, "relative.jsonl", header(relative(process.cwd(), join(fx.proj, "packages")) || "."), [user("m5", null, "x", 1)]);
|
||||
const linkRoot = join(fx.base, "compat");
|
||||
symlinkSync(fx.proj, linkRoot);
|
||||
writeSession(fx.dir, "link.jsonl", header(linkRoot), [user("m4", null, "via compat link", 1)]);
|
||||
const reader = readerFor(fx);
|
||||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||||
const by = Object.fromEntries(cat.conversations.map((c) => [c.conversation, c]));
|
||||
const root = rootsFromSpecs([fx.spec])[0];
|
||||
for (const name of ["s.jsonl", "prefix.jsonl", "relative.jsonl"]) {
|
||||
assert.deepEqual([by[conversationId(root, name)].availability, by[conversationId(root, name)].refusal], ["denied", "foreign-project"], name);
|
||||
assert.deepEqual(openOne(fx, reader, name).refusal.code, "foreign-project", name);
|
||||
}
|
||||
assert.equal(openOne(fx, reader, "sub.jsonl").ok, true);
|
||||
assert.equal(openOne(fx, reader, "link.jsonl").ok, true);
|
||||
assert.equal(cwdInProject(undefined, fx.proj), false);
|
||||
});
|
||||
|
||||
test("F11: parentSession renders with a marker and the parent is never opened", () => {
|
||||
const fx = project();
|
||||
const parent = forbidden(fx.base, "parent.jsonl");
|
||||
writeSession(fx.dir, "s.jsonl", header(fx.proj, { parentSession: parent }), [user("n1", null, "forked", 1)]);
|
||||
const r = openOne(fx, readerFor(fx));
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(roles(r.page), ["notice", "user"]);
|
||||
assert.match(texts(r.page)[0], /forked from an earlier session/);
|
||||
assert.equal(r.view.forkedFromEarlierSession, true);
|
||||
});
|
||||
|
||||
test("F12: two leaves: the default leaf is shown and the other branch reads alone", () => {
|
||||
const fx = project();
|
||||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||||
user("o1", null, "root", 1),
|
||||
assistant("o2", "o1", "first answer", 2),
|
||||
user("o3", "o2", "on branch one", 3),
|
||||
assistant("o4", "o1", "second answer", 4),
|
||||
]);
|
||||
const reader = readerFor(fx);
|
||||
const r = openOne(fx, reader);
|
||||
// o1's earliest child continues "main"; the later child o4 starts "b.o4".
|
||||
assert.deepEqual(texts(r.page), ["root", "second answer"]);
|
||||
assert.equal(r.page.branch, "b.o4");
|
||||
assert.deepEqual(r.view.branches.map((b) => [b.branch, b.isDefault]), [["main", false], ["b.o4", true]]);
|
||||
const other = openOne(fx, reader, "s.jsonl", { branch: "main" });
|
||||
assert.deepEqual(texts(other.page), ["root", "first answer", "on branch one"]);
|
||||
assert.equal(other.page.branch, "main");
|
||||
assert.ok(other.page.entries.every((e) => e.branch === "main"));
|
||||
for (const inner of ["o2", "b.o2", "o3", "b.o3", "o4"]) assert.equal(openOne(fx, reader, "s.jsonl", { branch: inner }).refusal.code, "unknown-branch", `${inner} is not a branch name`);
|
||||
// Following a non-default branch after the default moves on adds nothing.
|
||||
const path = join(fx.dir, "s.jsonl");
|
||||
appendFileSync(path, lines(user("o5", "o4", "more on default", 5)));
|
||||
const f = noWrites(fx.base, () => reader.next({ cursor: other.follow.id, conversation: other.page.conversation, branch: "main" }));
|
||||
assert.deepEqual([f.ok, f.page.entries.length, f.page.branch, f.view.defaultBranch], [true, 0, "main", "b.o4"]);
|
||||
// Following the default branch continues on it under the same name.
|
||||
const g = noWrites(fx.base, () => reader.next({ cursor: r.follow.id, conversation: r.page.conversation, branch: "b.o4" }));
|
||||
assert.deepEqual([texts(g.page), g.page.branch, g.view.defaultBranch], [["more on default"], "b.o4", "b.o4"]);
|
||||
assert.ok([...r.page.entries, ...g.page.entries].every((e) => e.branch === "b.o4"), "one branch value across follows");
|
||||
// The pre-growth name still opens, now with the growth.
|
||||
assert.deepEqual(texts(openOne(fx, reader, "s.jsonl", { branch: "b.o4" }).page), ["root", "second answer", "more on default"]);
|
||||
// A new default elsewhere: the old default view stays put.
|
||||
appendFileSync(path, lines(assistant("o6", "o3", "back on branch one", 6)));
|
||||
const h = noWrites(fx.base, () => reader.next({ cursor: g.follow.id, conversation: g.page.conversation, branch: "b.o4" }));
|
||||
assert.deepEqual([h.page.entries.length, h.page.branch, h.view.defaultBranch], [0, "b.o4", "main"]);
|
||||
// And the "main" view picks its growth up.
|
||||
const k = noWrites(fx.base, () => reader.next({ cursor: f.follow.id, conversation: f.page.conversation, branch: "main" }));
|
||||
assert.deepEqual([texts(k.page), k.page.branch], [["back on branch one"], "main"]);
|
||||
});
|
||||
|
||||
test("F12: a follow refuses when an appended duplicate id changes the branch's earlier parts", () => {
|
||||
// Where an id repeats the later entry wins, so an append can move an entry.
|
||||
for (const [extra, why] of [
|
||||
[[{ ...assistant("r2", "x1", "moved", 4) }], "r2 moves to the other root: main is shorter"],
|
||||
[[{ ...assistant("r2", "x1", "moved", 4) }, assistant("r5", "r1", "in its place", 5)], "same length, another entry in r2's place"],
|
||||
]) {
|
||||
const fx = project();
|
||||
const path = writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("r1", null, "one", 1), user("x1", null, "other root", 2), assistant("r2", "r1", "two", 3)]);
|
||||
const reader = readerFor(fx);
|
||||
const r = openOne(fx, reader, "s.jsonl", { branch: "main" });
|
||||
assert.deepEqual(texts(r.page), ["one", "two"]);
|
||||
appendFileSync(path, lines(...extra));
|
||||
const f = noWrites(fx.base, () => reader.next({ cursor: r.follow.id, conversation: r.page.conversation, branch: "main" }));
|
||||
assert.deepEqual([f.ok, f.refusal?.code, f.refusal?.reconcile], [false, "source-replaced", true], why);
|
||||
}
|
||||
});
|
||||
|
||||
test("F12: a second root (Pi's resetLeaf) starts its own branch", () => {
|
||||
const fx = project();
|
||||
const path = writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||||
user("r1", null, "first root", 1),
|
||||
assistant("r2", "r1", "first reply", 2),
|
||||
user("r3", null, "second root", 3),
|
||||
]);
|
||||
const reader = readerFor(fx);
|
||||
const r = openOne(fx, reader);
|
||||
assert.deepEqual([texts(r.page), r.page.branch], [["second root"], "b.r3"]);
|
||||
assert.deepEqual(r.view.branches.map((b) => [b.branch, b.isDefault]), [["main", false], ["b.r3", true]]);
|
||||
assert.deepEqual(texts(openOne(fx, reader, "s.jsonl", { branch: "main" }).page), ["first root", "first reply"]);
|
||||
appendFileSync(path, lines(assistant("r4", "r3", "second reply", 4)));
|
||||
const f = noWrites(fx.base, () => reader.next({ cursor: r.follow.id, conversation: r.page.conversation, branch: "b.r3" }));
|
||||
assert.deepEqual([texts(f.page), f.page.branch], [["second reply"], "b.r3"]);
|
||||
});
|
||||
|
||||
test("F13: compaction is a marker in place, then the retained content", () => {
|
||||
const fx = project();
|
||||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||||
user("p1", null, "early", 1),
|
||||
assistant("p2", "p1", "early answer", 2),
|
||||
{ type: "compaction", id: "p3", parentId: "p2", timestamp: time(3), summary: "they talked", firstKeptEntryId: "p2", tokensBefore: 1000 },
|
||||
user("p4", "p3", "after", 4),
|
||||
]);
|
||||
const r = openOne(fx, readerFor(fx));
|
||||
assert.deepEqual(roles(r.page), ["user", "assistant", "compaction", "user"]);
|
||||
const marker = r.page.entries[2].content[0];
|
||||
assert.deepEqual([marker.type, marker.summary, marker.nativeEntry], ["compaction", "they talked", "p3"]);
|
||||
});
|
||||
|
||||
test("F14: long strings split into fragments and parts, reassemble exactly, and pages respect the byte cap", () => {
|
||||
const fx = project();
|
||||
const long = "x".repeat(LIMITS.chars + 1);
|
||||
const emoji = "😀".repeat(LIMITS.chars + 3); // 4 bytes each: splits on bytes before characters
|
||||
const blocks = Array.from({ length: 70 }, (_, i) => ({ type: "text", text: `block ${i}` }));
|
||||
const wide = "€".repeat(200_000); // 3 bytes each, ~600 KB per part
|
||||
const entries = [
|
||||
user("q1", null, long, 1),
|
||||
assistant("q2", "q1", emoji, 2),
|
||||
msg("q3", "q2", "assistant", blocks, { stopReason: "stop" }, 3),
|
||||
];
|
||||
for (let i = 4; i < 44; i++) entries.push(user(`q${i}`, `q${i - 1}`, wide, i));
|
||||
writeSession(fx.dir, "s.jsonl", header(fx.proj), entries);
|
||||
const reader = readerFor(fx);
|
||||
const pages = readAll(fx, reader, openOne(fx, reader));
|
||||
for (const p of pages) {
|
||||
assert.ok(p.page.entries.length <= LIMITS.parts);
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(p.page), "utf8") <= LIMITS.pageBytes, "page within 8 MiB");
|
||||
for (const e of p.page.entries) {
|
||||
assert.ok(e.content.length <= LIMITS.blocks);
|
||||
for (const b of e.content) assert.ok([...(b.text ?? "")].length <= LIMITS.chars);
|
||||
}
|
||||
}
|
||||
assert.ok(pages.length >= 3, "the multibyte parts fill pages on bytes before 100 parts");
|
||||
assert.ok(pages[1].page.entries.length < LIMITS.parts);
|
||||
// Reassemble per native entry: parts in order, fragments in order.
|
||||
const all = pages.flatMap((p) => p.page.entries);
|
||||
const rebuilt = new Map();
|
||||
for (const e of all) {
|
||||
const blocksOf = rebuilt.get(e.nativeEntry) ?? [];
|
||||
for (const b of e.content) blocksOf[b.block] = (blocksOf[b.block] ?? "") + b.text;
|
||||
rebuilt.set(e.nativeEntry, blocksOf);
|
||||
}
|
||||
assert.equal(rebuilt.get("q1")[0], long);
|
||||
assert.equal(rebuilt.get("q2")[0], emoji);
|
||||
assert.deepEqual(rebuilt.get("q3"), blocks.map((b) => b.text));
|
||||
assert.equal(rebuilt.get("q20")[0], wide);
|
||||
const q1 = all.filter((e) => e.nativeEntry === "q1");
|
||||
assert.deepEqual(q1.flatMap((e) => e.content.map((b) => [b.fragment, b.lastFragment])), [[0, false], [1, true]]);
|
||||
const q3 = all.filter((e) => e.nativeEntry === "q3");
|
||||
assert.deepEqual(q3.map((e) => [e.part, e.lastPart, e.content.length]), [[0, false, 64], [1, true, 6]]);
|
||||
assert.equal(new Set(all.map((e) => e.id)).size, all.length);
|
||||
});
|
||||
|
||||
test("fragments never cut a surrogate pair and keep an empty string", () => {
|
||||
assert.deepEqual(fragments(""), [""]);
|
||||
const s = "a" + "😀".repeat(LIMITS.chars);
|
||||
const parts = fragments(s);
|
||||
assert.equal(parts.join(""), s);
|
||||
for (const p of parts) {
|
||||
assert.ok(!/^[\udc00-\udfff]/.test(p) && !/[\ud800-\udbff]$/.test(p));
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(p)) <= 1024 * 1024 + 2);
|
||||
}
|
||||
});
|
||||
|
||||
test("F15: a Claude seat is an unsupported-harness placeholder whose directory is never read", () => {
|
||||
const fx = project("pi-seat");
|
||||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("r1", null, "ok", 1)]);
|
||||
// Rocko's case: a claude-code registration and no Pi sessions directory.
|
||||
const claudeDir = join(fx.proj, ".pi", "state", "claude-seat", "sessions");
|
||||
const regs = [{ version: 1, seat: "claude-seat", project: "proj", layout: "repo", harness: "claude-code", sessionsDir: claudeDir, startedAt: time(-1) }];
|
||||
// And a spec'd seat with sessions whose registration says claude-code.
|
||||
const both = join(fx.proj, ".pi", "state", "mixed", "sessions");
|
||||
mkdirSync(both, { recursive: true });
|
||||
writeFileSync(join(both, "s.jsonl"), lines(header(fx.proj), user("r2", null, "never read", 1)));
|
||||
chmodSync(join(both, "s.jsonl"), 0o000);
|
||||
regs.push({ version: 1, seat: "mixed", project: "proj", layout: "repo", harness: "claude-code", sessionsDir: both, startedAt: time(-1) });
|
||||
const roots = rootsFromSpecs([fx.spec, { agent: "mixed", project: "proj", sessionsDir: both }], regs);
|
||||
const reader = createReader({ roots });
|
||||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||||
const unsupported = cat.conversations.filter((c) => c.availability === "unsupported");
|
||||
assert.deepEqual(unsupported.map((c) => [c.seat, c.harness, c.unsupportedReason, c.history]).sort(), [["claude-seat", "claude-code", UNSUPPORTED_HARNESS, null], ["mixed", "claude-code", UNSUPPORTED_HARNESS, null]]);
|
||||
for (const c of unsupported) {
|
||||
const r = noWrites(fx.base, () => reader.open({ conversation: c.conversation }));
|
||||
assert.deepEqual([r.ok, r.refusal.code, r.refusal.reconcile], [false, UNSUPPORTED_HARNESS, false]);
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown conversations, empty files and non-Pi files refuse", () => {
|
||||
const fx = project();
|
||||
writeFileSync(join(fx.dir, "empty.jsonl"), "");
|
||||
writeFileSync(join(fx.dir, "notpi.jsonl"), lines({ hello: 1 }));
|
||||
writeFileSync(join(fx.dir, "header-only.jsonl"), lines(header(fx.proj)));
|
||||
const reader = readerFor(fx);
|
||||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||||
assert.deepEqual(cat.conversations.map((c) => [c.availability, c.refusal]).sort(), [["available", null], ["unavailable", "incomplete-header"], ["unavailable", "not-a-pi-session"]]);
|
||||
assert.equal(openOne(fx, reader, "empty.jsonl").refusal.code, "incomplete-header");
|
||||
assert.equal(openOne(fx, reader, "notpi.jsonl").refusal.code, "not-a-pi-session");
|
||||
const empty = openOne(fx, reader, "header-only.jsonl");
|
||||
assert.deepEqual([empty.ok, empty.page.entries.length, empty.page.branch, empty.view.branches], [true, 0, "main", [{ branch: "main", isDefault: true, lastActivityAt: null }]]);
|
||||
appendFileSync(join(fx.dir, "header-only.jsonl"), lines(user("s1", null, "first", 1)));
|
||||
const f = noWrites(fx.base, () => reader.next({ cursor: empty.follow.id, conversation: empty.page.conversation, branch: "main" }));
|
||||
assert.deepEqual([texts(f.page), f.page.branch], [["first"], "main"]);
|
||||
const u = noWrites(fx.base, () => reader.open({ conversation: "pi-" + "f".repeat(32) }));
|
||||
assert.deepEqual([u.refusal.code, u.refusal.reconcile], ["unknown-conversation", true]);
|
||||
});
|
||||
|
||||
test("an unreadable file or root inside the roots is refused per row, not a failed catalogue", () => {
|
||||
const fx = project();
|
||||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("u1", null, "ok", 1)]);
|
||||
writeSession(fx.dir, "locked.jsonl", header(fx.proj), [user("u2", null, "locked", 1)]);
|
||||
chmodSync(join(fx.dir, "locked.jsonl"), 0o000);
|
||||
const reader = readerFor(fx);
|
||||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||||
assert.deepEqual(cat.conversations.map((c) => [c.availability, c.refusal]).sort(), [["available", null], ["denied", "unreadable"]]);
|
||||
assert.equal(openOne(fx, reader, "locked.jsonl").refusal.code, "unreadable");
|
||||
chmodSync(fx.dir, 0o300);
|
||||
try {
|
||||
const cat2 = noWrites(fx.base, () => reader.catalogue());
|
||||
assert.deepEqual([cat2.conversations, cat2.refusedRoots.map((r) => r.refusal)], [[], ["unreadable"]]);
|
||||
} finally {
|
||||
chmodSync(fx.dir, 0o700);
|
||||
}
|
||||
});
|
||||
|
||||
test("a seat directory without search permission refuses that root, not the catalogue", () => {
|
||||
const fx = project("good");
|
||||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("p1", null, "readable", 1)]);
|
||||
const badDir = join(fx.proj, ".pi", "state", "bad", "sessions");
|
||||
mkdirSync(badDir, { recursive: true });
|
||||
writeSession(badDir, "s.jsonl", header(fx.proj), [user("q1", null, "hidden", 1)]);
|
||||
const bad = { agent: "bad", project: "proj", sessionsDir: badDir };
|
||||
const goodId = conversationId(rootsFromSpecs([fx.spec])[0], "s.jsonl");
|
||||
chmodSync(join(fx.proj, ".pi", "state", "bad"), 0o000);
|
||||
try {
|
||||
for (const specs of [[bad, fx.spec], [fx.spec, bad]]) {
|
||||
const reader = createReader({ roots: rootsFromSpecs(specs) });
|
||||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||||
assert.deepEqual([cat.conversations.map((c) => [c.seat, c.availability]), cat.refusedRoots], [[["good", "available"]], [{ seat: "bad", project: "proj", refusal: "unreadable" }]]);
|
||||
const r = noWrites(fx.base, () => reader.open({ conversation: goodId }));
|
||||
assert.deepEqual(texts(r.page), ["readable"]);
|
||||
const u = noWrites(fx.base, () => reader.open({ conversation: "pi-" + "e".repeat(32) }));
|
||||
assert.equal(u.refusal.code, "unknown-conversation");
|
||||
}
|
||||
} finally {
|
||||
chmodSync(join(fx.proj, ".pi", "state", "bad"), 0o700);
|
||||
}
|
||||
});
|
||||
|
||||
test("every page and cursor is a valid CHAT-01 record", () => {
|
||||
assert.ok(records.length > 50);
|
||||
const script = `
|
||||
import json, sys
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
s = json.load(open(sys.argv[1]))
|
||||
bad = []
|
||||
for i, r in enumerate(json.load(sys.stdin)):
|
||||
v = Draft202012Validator({"$defs": s["$defs"], "$ref": "#/$defs/" + r["kind"]}, format_checker=FormatChecker())
|
||||
for e in v.iter_errors(r):
|
||||
bad.append(f"{i} {r['kind']}: {e.message[:200]}")
|
||||
break
|
||||
print(json.dumps(bad))
|
||||
`;
|
||||
const run = spawnSync("python3", ["-c", script, schemaPath], { input: JSON.stringify(records), encoding: "utf8", maxBuffer: 1 << 30 });
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.deepEqual(JSON.parse(run.stdout), []);
|
||||
});
|
||||
Reference in New Issue
Block a user