@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 +21 -0
- package/README.md +121 -0
- package/dist/checkpoint-log.d.ts +30 -0
- package/dist/checkpoint-log.js +78 -0
- package/dist/checkpoint-recovery.d.ts +39 -0
- package/dist/checkpoint-recovery.js +69 -0
- package/dist/checkpoint.d.ts +194 -0
- package/dist/checkpoint.js +292 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +40 -0
- package/dist/jsonl.d.ts +33 -0
- package/dist/jsonl.js +44 -0
- package/dist/log/derive.d.ts +12 -0
- package/dist/log/derive.js +12 -0
- package/dist/log/file.d.ts +43 -0
- package/dist/log/file.js +85 -0
- package/dist/log/memory.d.ts +27 -0
- package/dist/log/memory.js +43 -0
- package/dist/log/persistent.d.ts +58 -0
- package/dist/log/persistent.js +118 -0
- package/dist/messages.d.ts +27 -0
- package/dist/messages.js +128 -0
- package/dist/plugin.d.ts +20 -0
- package/dist/plugin.js +24 -0
- package/dist/replay.d.ts +59 -0
- package/dist/replay.js +145 -0
- package/dist/turn-id.d.ts +8 -0
- package/dist/turn-id.js +7 -0
- package/package.json +28 -0
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
|
+
}
|