@celestea/session 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mcd0LUO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # @celestea/session
2
+
3
+ The session log: in-memory and JSONL-backed `SessionLog` implementations, the
4
+ replay/repair path, the monotonic turn-id owner, and the two message
5
+ projections (Studio + engine). Depends only on `@celestea/core`.
6
+
7
+ Ports (1:1 against the legacy engine):
8
+
9
+ | TS module | Legacy source |
10
+ |---|---|
11
+ | `log/derive.ts` | `crates/session/src/log.rs` (`derive_messages_from`, `flush_tool_calls`, `balance_tool_calls`, `project`) |
12
+ | `log/memory.ts` | `crates/session/src/log.rs` (`InMemorySessionLog`) |
13
+ | `log/file.ts` | `crates/session/src/persistent.rs` (naming, replay, torn-tail truncation) |
14
+ | `log/persistent.ts` | `crates/session/src/persistent.rs` (`PersistentSessionLog`) |
15
+ | `turn-id.ts` | `session_log.rs:94-98` + `persistent.rs:382-402` (turn id ownership) |
16
+ | `messages.ts` | `crates/session/src/log.rs` + Studio `src/api.rs:94-135` |
17
+ | `replay.ts` | P0 replay analysis + SSE transcript derivation |
18
+
19
+ ## Public API (via `index.ts` only)
20
+
21
+ - **Logs** — `InMemorySessionLog`, `PersistentSessionLog.open(dir, sessionId,
22
+ opts?)`, `defaultPersistentOptions()`, `PersistentOptions`.
23
+ - **Plugins** — `inMemorySessionLogPlugin()`, `persistentSessionLogPlugin(cfg)`;
24
+ both provide the log under core's `SESSION_LOG_SERVICE` token. Core never
25
+ imports this package: register the plugin into a `Context` at compose time.
26
+ - **Projections** — `projectMessages` (Studio: per-event, keeps thinking rows,
27
+ keeps `parent_id` rows) and `deriveMessages` (engine: model-visible history —
28
+ drops turn markers, thinking and `parent_id` sub-calls, merges consecutive
29
+ tool calls into one assistant message, balances unanswered calls with a
30
+ synthetic cancelled result).
31
+ - **JSONL** — `parseSessionJsonl`, `serializeSessionJsonl`, plus re-exports of
32
+ the serde-exact row codec (`validateSessionEvent`, `parseSessionEvent`,
33
+ `serializeSessionEvent`, `outcomePhase`, `outcomeError`) from core.
34
+ - **Files** — `fileNameFor`, `filePathFor`, `replayFile` (longest valid prefix +
35
+ torn record), `nextTurnId`, `nextTurnNumber`, `auditTurnIds`.
36
+ - **Analysis** — `analyzeReplay`, `deriveSseTranscript` (P0 replay toolchain).
37
+
38
+ ## Extension points
39
+
40
+ ```ts
41
+ import { Context, mountPlugins, SESSION_LOG_SERVICE, type SessionLog } from "@celestea/core";
42
+ import { persistentSessionLogPlugin } from "@celestea/session";
43
+
44
+ const ctx = mountPlugins(Context.root(), [
45
+ persistentSessionLogPlugin({ dir: "~/.celestea/sessions", sessionId: "session-1" }),
46
+ ]);
47
+ const log = ctx.require<SessionLog>(SESSION_LOG_SERVICE);
48
+ log.append({ type: "user_message", text: "hi" });
49
+ log.nextTurnId(); // "turn-0" — owned by the log, monotonic, restored on open
50
+ log.deriveMessages(); // engine Message[] (role / content[] / tool_call_id)
51
+ ```
52
+
53
+ Swap in `inMemorySessionLogPlugin()` for tests; a later mount of the same token
54
+ wins (patch semantics), so composition never needs editing.
55
+
56
+ ## Durability semantics
57
+
58
+ - One record == one JSON line; `append` writes through to the OS immediately
59
+ (`fs.writeSync`), so `flushEachAppend:false` cannot lose a record (documented
60
+ deviation from the engine's `BufWriter`, in the safe direction); `sync()` is the
61
+ fsync/power-loss durability point and `syncEachAppend` fsyncs every record.
62
+ - On open the file is replayed and validated: the **longest valid prefix** is
63
+ kept and everything from the first unparsable record (a torn tail) is
64
+ truncated away; blank lines are harmless padding; a missing final newline is
65
+ repaired before the next append.
66
+ - The turn counter is restored from the max `turn-<n>` id on disk (+1), so ids
67
+ are never reused after a restart. `clear()` truncates the file and resets the
68
+ counter (legacy behaviour); the in-memory log's counter deliberately never
69
+ resets.
70
+ - A failed disk write degrades gracefully: the event stays in the in-memory
71
+ view (`deriveMessages` keeps working) and `writeErrorCount()` counts it.
72
+
73
+ ## derive_messages contract (engine parity)
74
+
75
+ `derive_messages_from` walks the log and: skips `TurnStart`/`TurnEnd`; skips
76
+ `ThinkingDelta`; accumulates `ToolCall` rows (skipping `parent_id` sub-calls)
77
+ and flushes them into ONE assistant message before any other event and at the
78
+ end; projects `ToolResult` as `Error: {err}` (non-empty error) or the
79
+ serde_json text of the value; then `balance_tool_calls` inserts
80
+ `Error: tool call was cancelled before execution (no result recorded)` for every
81
+ unanswered call (engine commit b046564 / W267).
82
+
83
+ **Ported upstream quirks** (parity over silent divergence, reported upstream):
84
+
85
+ 1. `balance_tool_calls` advances its cursor with `i = j + inserted + 1`, so the
86
+ message right after a fully-answered call's results is never balance-checked;
87
+ an unbalanced trailing call in exactly that position stays unbalanced.
88
+ Covered by `log/derive.test.ts` and reproduced against the real engine.
89
+ 2. A whitespace-only JSONL line is an unparsable record (`record.is_empty()`),
90
+ not padding.
91
+
92
+ ## Golden fixtures & the parity test
93
+
94
+ `fixtures/sessions/*/derive-messages-expected.json` is a **frozen golden**: since
95
+ the engine exposes `derive_messages` over no HTTP surface, the expected JSON is
96
+ stored in `fixtures/`. `src/parity.test.ts` asserts, per session and field for
97
+ field: `deriveMessages` vs that golden, `projectMessages` vs the frozen HTTP
98
+ golden, and a byte-identical JSONL round-trip of every event line.
99
+
100
+ ```sh
101
+ pnpm vitest run packages/session/src/parity.test.ts # golden parity suite
102
+ ```
103
+
104
+ ## File layout
105
+
106
+ ```
107
+ src/log/derive.test.ts 184 derive_messages parity suite (legacy tests ported)
108
+ src/replay.ts 160 replay analysis + SSE transcript (P0)
109
+ src/log/derive.ts 147 derive_messages + balance_tool_calls
110
+ src/log/persistent.ts 143 PersistentSessionLog (JSONL, replay, restore)
111
+ src/log/file.ts 106 file naming + replay/truncate helpers
112
+ src/jsonl.test.ts 99 file-level JSONL contract
113
+ src/parity.test.ts 89 golden parity (parity.test.ts)
114
+ src/turn-id.ts 83 turn id math + audit
115
+ src/jsonl.ts 80 file-level parse/serialize + codec re-exports
116
+ src/messages.ts 74 Studio projection + deriveMessages facade
117
+ src/log/persistent.test.ts 174 torn tail / blank lines / counter restore
118
+ src/log/memory.ts 55 InMemorySessionLog
119
+ src/plugin.ts 40 Context registration
120
+ src/index.ts 33 the only public entry point
121
+ ```
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The checkpoint decorator: a `SessionLog` that records its own turn boundary.
3
+ *
4
+ * WHY a decorator and not a hook in the runtime: the `turn_start`/`turn_end`
5
+ * rows are appended by the AGENT LOOP (the log is resolved from the Context per
6
+ * turn), so the ONLY place that sees every boundary — whoever drives the turn —
7
+ * is the log itself. Wrapping it keeps the checkpoint exact without touching
8
+ * the frozen `SessionLog` seam (no new method, no new event, K5/K4).
9
+ *
10
+ * Discipline:
11
+ * - forwarding is transparent (a Proxy binds every other member to the inner
12
+ * log, so `path` / `close()` / `writeErrorCount()` keep working for the host
13
+ * and for the registry's `turnNo` restoration);
14
+ * - the checkpoint is written AFTER the row reached the log, so a crash in
15
+ * between can only lose an OPEN-TURN MARKER, never invent a repair: the boot
16
+ * decision table then reads "no checkpoint" and does nothing (fail-safe);
17
+ * - a checkpoint failure NEVER propagates into a turn (observation only).
18
+ */
19
+ import type { SessionLog } from "@celestea/core";
20
+ import { CheckpointStore } from "./checkpoint.js";
21
+ /** Access key of the wrapped store (symbol: invisible to JSON / spread). */
22
+ export declare const CHECKPOINT_STORE: unique symbol;
23
+ /** Wrap `log` so every turn boundary lands in `store` (§1.2.2 write timings). */
24
+ export declare function checkpointedLog(log: SessionLog, store: CheckpointStore): SessionLog;
25
+ /** The store behind a decorated log (null for a plain / missing log). */
26
+ export declare function checkpointStoreOf(log: SessionLog | null | undefined): CheckpointStore | null;
27
+ /** Graceful-exit mark of a decorated log (`true` = the next boot repairs nothing). */
28
+ export declare function markCleanShutdown(log: SessionLog | null | undefined): boolean;
29
+ /** The log's own degradation counter, when it has one (0 otherwise). */
30
+ export declare function writeErrorCountOf(log: SessionLog | null | undefined): number;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The checkpoint decorator: a `SessionLog` that records its own turn boundary.
3
+ *
4
+ * WHY a decorator and not a hook in the runtime: the `turn_start`/`turn_end`
5
+ * rows are appended by the AGENT LOOP (the log is resolved from the Context per
6
+ * turn), so the ONLY place that sees every boundary — whoever drives the turn —
7
+ * is the log itself. Wrapping it keeps the checkpoint exact without touching
8
+ * the frozen `SessionLog` seam (no new method, no new event, K5/K4).
9
+ *
10
+ * Discipline:
11
+ * - forwarding is transparent (a Proxy binds every other member to the inner
12
+ * log, so `path` / `close()` / `writeErrorCount()` keep working for the host
13
+ * and for the registry's `turnNo` restoration);
14
+ * - the checkpoint is written AFTER the row reached the log, so a crash in
15
+ * between can only lose an OPEN-TURN MARKER, never invent a repair: the boot
16
+ * decision table then reads "no checkpoint" and does nothing (fail-safe);
17
+ * - a checkpoint failure NEVER propagates into a turn (observation only).
18
+ */
19
+ import { CheckpointStore } from "./checkpoint.js";
20
+ /** Access key of the wrapped store (symbol: invisible to JSON / spread). */
21
+ export const CHECKPOINT_STORE = Symbol.for("celestea.session.checkpointStore");
22
+ /** Wrap `log` so every turn boundary lands in `store` (§1.2.2 write timings). */
23
+ export function checkpointedLog(log, store) {
24
+ const handler = {
25
+ get(target, prop) {
26
+ if (prop === CHECKPOINT_STORE)
27
+ return store;
28
+ if (prop === "append")
29
+ return (event) => appendObserved(target, store, event);
30
+ if (prop === "clear") {
31
+ return () => {
32
+ target.clear();
33
+ store.clearOpenTurn(); // an emptied log has no open turn any more
34
+ };
35
+ }
36
+ const value = Reflect.get(target, prop, target);
37
+ return typeof value === "function" ? value.bind(target) : value;
38
+ },
39
+ };
40
+ return new Proxy(log, handler);
41
+ }
42
+ /** The store behind a decorated log (null for a plain / missing log). */
43
+ export function checkpointStoreOf(log) {
44
+ if (log === null || log === undefined)
45
+ return null;
46
+ const store = log[CHECKPOINT_STORE];
47
+ return store instanceof CheckpointStore ? store : null;
48
+ }
49
+ /** Graceful-exit mark of a decorated log (`true` = the next boot repairs nothing). */
50
+ export function markCleanShutdown(log) {
51
+ const store = checkpointStoreOf(log);
52
+ if (store === null)
53
+ return false;
54
+ store.noteLogWriteErrors();
55
+ store.markCleanShutdown();
56
+ return true;
57
+ }
58
+ /** The log's own degradation counter, when it has one (0 otherwise). */
59
+ export function writeErrorCountOf(log) {
60
+ const read = log?.writeErrorCount;
61
+ return typeof read === "function" ? Number(read.call(log)) : 0;
62
+ }
63
+ function appendObserved(log, store, event) {
64
+ log.append(event);
65
+ try {
66
+ if (event.type === "turn_start")
67
+ store.turnStarted(event.id);
68
+ else if (event.type === "turn_end")
69
+ store.turnEnded(event.outcome);
70
+ // E §1.3 P1 ③: a write that the disk refused leaves memory and disk forked —
71
+ // sample it HERE too, so even a degraded non-boundary row is sidecar-visible.
72
+ if (writeErrorCountOf(log) > 0)
73
+ store.noteLogWriteErrors();
74
+ }
75
+ catch (e) {
76
+ process.stderr.write(`[celestea-session] checkpoint not updated: ${String(e)}\n`);
77
+ }
78
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The boot decision table of §1.2.3, as one pure function over an OPEN log.
3
+ *
4
+ * checkpoint | log | action
5
+ * -------------------|----------------------------|-------------------------------
6
+ * missing | dangling turn_start | NOTHING (may be another writer)
7
+ * invalid / foreign | anything | NOTHING + observation
8
+ * clean_shutdown | anything | NOTHING (a clean exit is not a crash)
9
+ * open_turn = null | dangling (legacy/another) | NOTHING (not ours to close)
10
+ * open_turn = turn-N | no turn_start turn-N | NOTHING (log cleared/rotated)
11
+ * open_turn = turn-N | turn_start + turn_end | clear open_turn only (idempotent)
12
+ * open_turn = turn-N | turn_start, no turn_end | APPEND turn_end:interrupted
13
+ *
14
+ * The repair is triggered by a DOUBLE SIGNATURE — the checkpoint says a turn was
15
+ * open AND the log actually holds that `turn_start` without its `turn_end` — so
16
+ * it can never append a row the engine would not have written itself (K4: the
17
+ * log is append-only, and every synthesized row is a legal `TurnOutcome`).
18
+ *
19
+ * Idempotence: the second boot sees the `turn_end` its predecessor appended, so
20
+ * it takes the "clear open_turn only" row and appends nothing (A2).
21
+ */
22
+ import type { SessionEvent, SessionLog } from "@celestea/core";
23
+ import { CheckpointStore } from "./checkpoint.js";
24
+ export type RecoveryAction = "closed_turn" | "cleared_open_turn" | "skipped_no_checkpoint" | "skipped_invalid_checkpoint" | "skipped_clean_shutdown" | "skipped_untracked" | "skipped_no_signature";
25
+ export interface RecoveryOutcome {
26
+ action: RecoveryAction;
27
+ /** The turn the checkpoint named (null when there was none). */
28
+ turn_id: string | null;
29
+ /** True only for [RecoveryAction.closed_turn]: exactly ONE row was appended. */
30
+ appended: boolean;
31
+ dangling_before: string[];
32
+ dangling_after: string[];
33
+ /** Fail-safe observations (ignored checkpoint, failed sidecar write). */
34
+ warnings: string[];
35
+ }
36
+ /** `turn_start` ids that have no matching `turn_end` (crash residue, G1-1). */
37
+ export declare function danglingTurnIds(events: readonly SessionEvent[]): string[];
38
+ /** Decide + repair ONE session. Never throws, never rewrites an existing row. */
39
+ export declare function recoverOpenTurn(log: SessionLog, store: CheckpointStore): RecoveryOutcome;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The boot decision table of §1.2.3, as one pure function over an OPEN log.
3
+ *
4
+ * checkpoint | log | action
5
+ * -------------------|----------------------------|-------------------------------
6
+ * missing | dangling turn_start | NOTHING (may be another writer)
7
+ * invalid / foreign | anything | NOTHING + observation
8
+ * clean_shutdown | anything | NOTHING (a clean exit is not a crash)
9
+ * open_turn = null | dangling (legacy/another) | NOTHING (not ours to close)
10
+ * open_turn = turn-N | no turn_start turn-N | NOTHING (log cleared/rotated)
11
+ * open_turn = turn-N | turn_start + turn_end | clear open_turn only (idempotent)
12
+ * open_turn = turn-N | turn_start, no turn_end | APPEND turn_end:interrupted
13
+ *
14
+ * The repair is triggered by a DOUBLE SIGNATURE — the checkpoint says a turn was
15
+ * open AND the log actually holds that `turn_start` without its `turn_end` — so
16
+ * it can never append a row the engine would not have written itself (K4: the
17
+ * log is append-only, and every synthesized row is a legal `TurnOutcome`).
18
+ *
19
+ * Idempotence: the second boot sees the `turn_end` its predecessor appended, so
20
+ * it takes the "clear open_turn only" row and appends nothing (A2).
21
+ */
22
+ import { CheckpointStore } from "./checkpoint.js";
23
+ /** `turn_start` ids that have no matching `turn_end` (crash residue, G1-1). */
24
+ export function danglingTurnIds(events) {
25
+ const started = [];
26
+ const closed = new Set();
27
+ for (const ev of events) {
28
+ if (ev.type === "turn_start")
29
+ started.push(ev.id);
30
+ else if (ev.type === "turn_end")
31
+ closed.add(ev.id);
32
+ }
33
+ return started.filter((id) => !closed.has(id));
34
+ }
35
+ function outcomeOf(parts) {
36
+ return {
37
+ action: parts.action,
38
+ turn_id: parts.turnId,
39
+ appended: parts.appended,
40
+ dangling_before: parts.before,
41
+ dangling_after: parts.after,
42
+ warnings: parts.warnings,
43
+ };
44
+ }
45
+ /** Decide + repair ONE session. Never throws, never rewrites an existing row. */
46
+ export function recoverOpenTurn(log, store) {
47
+ const read = store.load();
48
+ const before = danglingTurnIds(log.events());
49
+ const skip = (action, turnId = null) => outcomeOf({ action, turnId, appended: false, before, after: before, warnings: store.warnings() });
50
+ if (read.kind === "missing")
51
+ return skip("skipped_no_checkpoint");
52
+ if (read.kind === "invalid")
53
+ return skip("skipped_invalid_checkpoint");
54
+ if (read.value.clean_shutdown)
55
+ return skip("skipped_clean_shutdown");
56
+ const open = read.value.open_turn;
57
+ if (open === null)
58
+ return skip("skipped_untracked");
59
+ const events = log.events();
60
+ if (!events.some((ev) => ev.type === "turn_start" && ev.id === open.id))
61
+ return skip("skipped_no_signature", open.id);
62
+ if (events.some((ev) => ev.type === "turn_end" && ev.id === open.id)) {
63
+ store.clearOpenTurn(); // the previous boot already repaired it: nothing to append
64
+ return outcomeOf({ action: "cleared_open_turn", turnId: open.id, appended: false, before, after: before, warnings: store.warnings() });
65
+ }
66
+ log.append({ type: "turn_end", id: open.id, outcome: "interrupted" });
67
+ store.recordSynthesizedTurnEnd(open.id);
68
+ return outcomeOf({ action: "closed_turn", turnId: open.id, appended: true, before, after: danglingTurnIds(log.events()), warnings: store.warnings() });
69
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `checkpoint.json` — the per-session crash / shutdown sidecar (iteration E §1.2).
3
+ *
4
+ * WHY this file exists at all: `cli-main.jsonl` is the ONLY source of truth for
5
+ * the conversation (K4), and everything derivable from it (turn counter,
6
+ * history, last outcome) is deliberately NOT persisted twice. What the log can
7
+ * NOT express is the difference between
8
+ *
9
+ * - "a turn is open because this process is running it right now" and
10
+ * - "a turn is open because the process died mid-turn"
11
+ *
12
+ * so exactly those two facts are written here: the process identity
13
+ * (`pid`/`boot_id`/`clean_shutdown`) and the open turn (`open_turn`).
14
+ *
15
+ * Failure discipline (fail-safe, §1.2.2 / R1-3):
16
+ * - missing file -> "no checkpoint" (never repair, never invent);
17
+ * - unparsable / unknown `version` / `session` mismatch -> the WHOLE file is
18
+ * ignored and warned about, and the caller degrades to "no checkpoint" — a
19
+ * corrupt sidecar must never read as "the last exit was clean";
20
+ * - a failing write is reported and swallowed: checkpointing is observation,
21
+ * so it can never fail a turn (the session log's own degradation model).
22
+ *
23
+ * Write discipline: `<path>.tmp-<pid>` -> `rename` (atomic), mode `0600`,
24
+ * pretty-printed JSON — the same rules as every other data file
25
+ * (`contracts/data-files/index.json` durability map).
26
+ */
27
+ import { type TurnOutcome } from "@celestea/core";
28
+ /** File name inside a session directory (contract). */
29
+ export declare const CHECKPOINT_FILE_NAME = "checkpoint.json";
30
+ /** The only accepted `version`; anything else is "unknown schema" -> ignored. */
31
+ export declare const CHECKPOINT_VERSION = 1;
32
+ /** The turn a crashed process left open (null = no turn is running). */
33
+ export interface CheckpointOpenTurn {
34
+ id: string;
35
+ started_at: number;
36
+ }
37
+ /** One log repair this engine performed (the honest audit trail, R1-1). */
38
+ export interface CheckpointRepair {
39
+ at: number;
40
+ action: "synthesize_turn_end";
41
+ turn_id: string;
42
+ }
43
+ /**
44
+ * One queued message exactly as the inbox holds it (E §1.2.1: the lanes are the
45
+ * ONE thing the log cannot express — a message that was ACCEPTED and not yet
46
+ * injected). Structurally typed so `packages/session` stays free of an L1
47
+ * sibling import (K1): the runtime's `InjectedMessage` satisfies this shape.
48
+ */
49
+ export interface CheckpointLaneMessage {
50
+ text: string;
51
+ from: string;
52
+ at: number;
53
+ lane: string;
54
+ kind: string;
55
+ id: string;
56
+ source: unknown;
57
+ duplicate?: boolean;
58
+ }
59
+ /**
60
+ * E §1.3 P1 ①: the two injection lanes plus the bounded ledger of already
61
+ * accepted ids. `delivered_ids` is what makes a receipt's idempotency key
62
+ * survive a restart (the cross-process key of capability 2).
63
+ */
64
+ export interface CheckpointLanes {
65
+ next_turn: CheckpointLaneMessage[];
66
+ next_step: CheckpointLaneMessage[];
67
+ }
68
+ export interface Checkpoint {
69
+ version: number;
70
+ /** Self-description `<workspace>/<session>`; a mismatch voids the file. */
71
+ session: string;
72
+ pid: number;
73
+ /** One id per process start (a constant for the life of the process). */
74
+ boot_id: string;
75
+ updated_at: number;
76
+ /** True only after a graceful shutdown; a clean exit is never repaired. */
77
+ clean_shutdown: boolean;
78
+ open_turn: CheckpointOpenTurn | null;
79
+ /** Redundant with the log, kept for operators reading the sidecar directly. */
80
+ last_outcome: string | null;
81
+ degraded: {
82
+ log_write_errors: number;
83
+ };
84
+ lanes: CheckpointLanes;
85
+ /** Bounded (oldest-first) ledger of accepted injection ids (§1.2.1 / W515). */
86
+ delivered_ids: string[];
87
+ repaired: CheckpointRepair[];
88
+ }
89
+ /** Who is writing (`process.pid` + one random id per process start). */
90
+ export interface CheckpointIdentity {
91
+ boot_id: string;
92
+ pid: number;
93
+ }
94
+ /** `b-<8 hex>` — short, greppable, unique per process start. */
95
+ export declare function newBootId(): string;
96
+ /** The identity of THIS process (stable for its whole life). */
97
+ export declare function currentProcessIdentity(): CheckpointIdentity;
98
+ /** Read outcome of the sidecar: three states, never an exception. */
99
+ export type CheckpointRead = {
100
+ kind: "missing";
101
+ } | {
102
+ kind: "invalid";
103
+ error: string;
104
+ } | {
105
+ kind: "ok";
106
+ value: Checkpoint;
107
+ };
108
+ export declare function checkpointPathFor(dir: string): string;
109
+ /** A fresh, empty checkpoint of the current process. */
110
+ export declare function freshCheckpoint(session: string, identity: CheckpointIdentity, now: number): Checkpoint;
111
+ /** Atomic write: tmp file in the same directory -> rename, mode 0600. */
112
+ export declare function writeCheckpointFile(path: string, value: Checkpoint): void;
113
+ /** Read + validate; missing / corrupt / foreign files are never an exception. */
114
+ export declare function readCheckpointFile(path: string, session: string): CheckpointRead;
115
+ /** Shape gate: version, self-description, open_turn and the repair list. */
116
+ export declare function validateCheckpoint(raw: unknown, session: string): CheckpointRead;
117
+ export interface CheckpointStoreOptions {
118
+ /** Session directory the sidecar lives in. */
119
+ dir: string;
120
+ /** Self-description written into the file (`<workspace>/<session>`). */
121
+ session: string;
122
+ identity?: CheckpointIdentity;
123
+ now?: () => number;
124
+ /** Observation channel (stderr by default); never a failure. */
125
+ warn?: (message: string) => void;
126
+ /** Sample the log's own degradation counter at every write (§1.2.2). */
127
+ logWriteErrors?: () => number;
128
+ /**
129
+ * E §1.3 P1 ③: the audit channel of a DEGRADED log (`log_write_errors` just
130
+ * became non-zero). Called AT MOST ONCE per store — the fact is sticky, so
131
+ * repeating it every turn would be log spam, not truth.
132
+ */
133
+ onDegraded?: (info: {
134
+ session: string;
135
+ count: number;
136
+ }) => void;
137
+ }
138
+ /**
139
+ * ONE session's checkpoint: in memory + on disk. Mutations are the write
140
+ * timings of §1.2.2 (turn start, turn end, clean shutdown). Any activity marks
141
+ * the process alive again (`clean_shutdown: false`); `markCleanShutdown` is the
142
+ * only transition to `true`.
143
+ */
144
+ export declare class CheckpointStore {
145
+ readonly path: string;
146
+ readonly session: string;
147
+ private readonly identity;
148
+ private readonly now;
149
+ private readonly warn;
150
+ private readonly sample;
151
+ private loaded;
152
+ private state;
153
+ private readonly observations;
154
+ private degradedReported;
155
+ private readonly onDegraded;
156
+ constructor(opts: CheckpointStoreOptions);
157
+ /** The sidecar as found on disk (cached; never throws). */
158
+ load(): CheckpointRead;
159
+ /** The current state (a fresh, empty checkpoint when missing / ignored). */
160
+ get current(): Checkpoint;
161
+ /** Observations gathered so far (ignored files, failed writes). */
162
+ warnings(): string[];
163
+ /** A turn is open from now on (written RIGHT AFTER `turn_start`). */
164
+ turnStarted(id: string): void;
165
+ /** The turn closed normally: no open turn, outcome recorded (§1.2.2). */
166
+ turnEnded(outcome: TurnOutcome | undefined): void;
167
+ /** Clear a stale open turn WITHOUT touching the log (already-closed case). */
168
+ clearOpenTurn(): void;
169
+ /**
170
+ * The ONE crash repair: the log just got `turn_end: interrupted`, so the
171
+ * record goes into `repaired[]` — the log row itself stays indistinguishable
172
+ * from engine output (§1.2.3 幂等边界 4).
173
+ */
174
+ recordSynthesizedTurnEnd(turnId: string): void;
175
+ /** Graceful exit: `true` forbids any repair on the next boot. */
176
+ markCleanShutdown(): void;
177
+ /** Sample the log's degradation counter (sticky: a fork must stay visible). */
178
+ noteLogWriteErrors(): void;
179
+ /**
180
+ * E §1.3 P1 ①: the two lanes + the delivered-id ledger, written by the SAME
181
+ * atomic path as every other field. This is the third (and last) write timing
182
+ * the sidecar has, so a crash can lose at most the newest queue change.
183
+ */
184
+ lanesChanged(nextTurn: readonly CheckpointLaneMessage[], nextStep: readonly CheckpointLaneMessage[], deliveredIds: readonly string[]): void;
185
+ /** The persisted lanes + delivered ledger of the file on disk (null = unusable). */
186
+ persistedQueues(): {
187
+ lanes: CheckpointLanes;
188
+ delivered_ids: string[];
189
+ } | null;
190
+ private persist;
191
+ /** §5.2③: a silent disk/memory fork is exactly what must never stay silent. */
192
+ private reportDegraded;
193
+ private observe;
194
+ }