brainclaw 1.7.5 → 1.9.0
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/README.md +28 -11
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +139 -13
- package/dist/commands/add-step.js +1 -1
- package/dist/commands/bootstrap.js +2 -26
- package/dist/commands/check-security-mcp.js +50 -33
- package/dist/commands/check-security.js +86 -43
- package/dist/commands/claim.js +22 -21
- package/dist/commands/confirm.js +26 -0
- package/dist/commands/context-diff.js +1 -1
- package/dist/commands/dispatch-watch.js +142 -0
- package/dist/commands/doctor.js +113 -2
- package/dist/commands/estimation-report.js +115 -16
- package/dist/commands/harvest.js +502 -16
- package/dist/commands/init.js +123 -21
- package/dist/commands/loops-handlers.js +4 -0
- package/dist/commands/mcp-read-handlers.js +198 -29
- package/dist/commands/mcp.js +615 -92
- package/dist/commands/memory.js +21 -17
- package/dist/commands/migrate.js +81 -17
- package/dist/commands/prune.js +78 -4
- package/dist/commands/reflect.js +26 -20
- package/dist/commands/register-agent.js +57 -1
- package/dist/commands/repair.js +20 -0
- package/dist/commands/session-end.js +15 -6
- package/dist/commands/session-start.js +18 -1
- package/dist/commands/setup-security.js +39 -18
- package/dist/commands/setup.js +26 -27
- package/dist/commands/stale.js +16 -2
- package/dist/commands/uninstall.js +126 -34
- package/dist/commands/update-step.js +6 -0
- package/dist/commands/worktree.js +60 -0
- package/dist/core/actions.js +12 -3
- package/dist/core/agent-capability.js +11 -13
- package/dist/core/agent-files.js +844 -547
- package/dist/core/agent-integrations.js +0 -3
- package/dist/core/agent-inventory.js +67 -0
- package/dist/core/agent-registry.js +163 -29
- package/dist/core/agentrun-reconciler.js +33 -2
- package/dist/core/agentruns.js +7 -1
- package/dist/core/ai-agent-detection.js +31 -44
- package/dist/core/archival.js +15 -9
- package/dist/core/assignment-reconciler.js +56 -0
- package/dist/core/assignment-sweeper.js +127 -4
- package/dist/core/assignments.js +69 -11
- package/dist/core/bootstrap.js +233 -67
- package/dist/core/brainclaw-version.js +22 -0
- package/dist/core/candidates.js +21 -1
- package/dist/core/claims.js +313 -150
- package/dist/core/config.js +6 -1
- package/dist/core/context-diff.js +148 -20
- package/dist/core/context.js +129 -8
- package/dist/core/coordination.js +22 -3
- package/dist/core/dispatch-status.js +109 -5
- package/dist/core/dispatcher.js +65 -11
- package/dist/core/entity-operations.js +45 -24
- package/dist/core/entity-registry.js +31 -5
- package/dist/core/event-log.js +138 -21
- package/dist/core/events/checkpoint.js +258 -0
- package/dist/core/events/genesis.js +220 -0
- package/dist/core/events/journal.js +507 -0
- package/dist/core/events/materialize.js +126 -0
- package/dist/core/events/registry-post-image.js +110 -0
- package/dist/core/events/verify.js +109 -0
- package/dist/core/execution-adapters.js +23 -0
- package/dist/core/execution.js +25 -0
- package/dist/core/facade-schema.js +48 -0
- package/dist/core/gc-semantic.js +130 -5
- package/dist/core/handoff-snapshot.js +68 -0
- package/dist/core/ids.js +19 -8
- package/dist/core/instruction-templates.js +34 -115
- package/dist/core/io.js +39 -3
- package/dist/core/json-store.js +10 -1
- package/dist/core/lock.js +153 -28
- package/dist/core/loops/bootstrap-acquire.js +25 -1
- package/dist/core/loops/facade-schema.js +2 -0
- package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
- package/dist/core/loops/index.js +1 -0
- package/dist/core/loops/presets/bootstrap.js +7 -0
- package/dist/core/loops/store.js +17 -0
- package/dist/core/loops/verbs.js +24 -1
- package/dist/core/markdown.js +8 -76
- package/dist/core/mcp-command-resolution.js +245 -0
- package/dist/core/memory-compactor.js +5 -3
- package/dist/core/memory-lifecycle.js +282 -0
- package/dist/core/merge-risk.js +150 -0
- package/dist/core/messaging.js +8 -1
- package/dist/core/migration.js +11 -1
- package/dist/core/observer-mode.js +26 -0
- package/dist/core/operations/memory-mutation.js +90 -65
- package/dist/core/operations/plan.js +27 -1
- package/dist/core/protocol-skills.js +210 -0
- package/dist/core/reflection-safety.js +6 -7
- package/dist/core/reputation.js +84 -2
- package/dist/core/runtime-signals.js +71 -9
- package/dist/core/runtime.js +84 -1
- package/dist/core/schema.js +125 -0
- package/dist/core/security-detectors.js +125 -0
- package/dist/core/security-extract.js +189 -0
- package/dist/core/security-guard.js +107 -29
- package/dist/core/security-packages.js +121 -0
- package/dist/core/security-scoring.js +76 -9
- package/dist/core/security.js +34 -2
- package/dist/core/sequence.js +11 -2
- package/dist/core/setup-flow.js +141 -13
- package/dist/core/spawn-check.js +110 -4
- package/dist/core/staleness.js +109 -1
- package/dist/core/state.js +250 -54
- package/dist/core/store-resolution.js +19 -5
- package/dist/core/worktree.js +169 -7
- package/dist/facts.js +8 -8
- package/dist/facts.json +7 -7
- package/docs/PROTOCOL.md +223 -0
- package/docs/cli.md +11 -10
- package/docs/concepts/coordinator-runbook.md +129 -0
- package/docs/concepts/dispatch-lifecycle.md +17 -0
- package/docs/concepts/event-log-store-critique-A.md +333 -0
- package/docs/concepts/event-log-store-critique-B.md +353 -0
- package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
- package/docs/concepts/event-log-store-proposal-A.md +365 -0
- package/docs/concepts/event-log-store-proposal-B.md +404 -0
- package/docs/concepts/event-log-store.md +928 -0
- package/docs/concepts/identity-model-proposal.md +371 -0
- package/docs/concepts/memory.md +5 -4
- package/docs/concepts/observer-protocol.md +361 -0
- package/docs/concepts/parallel-merge-protocol.md +71 -0
- package/docs/concepts/plans-and-claims.md +43 -0
- package/docs/concepts/skills.md +78 -0
- package/docs/concepts/workspace-bootstrapping.md +61 -0
- package/docs/integrations/agents.md +4 -4
- package/docs/integrations/cline.md +10 -11
- package/docs/integrations/codex.md +2 -2
- package/docs/integrations/continue.md +5 -5
- package/docs/integrations/copilot.md +14 -12
- package/docs/integrations/openclaw.md +7 -6
- package/docs/integrations/overview.md +7 -7
- package/docs/integrations/roo.md +3 -3
- package/docs/integrations/windsurf.md +6 -6
- package/docs/mcp-schema-changelog.md +51 -20
- package/docs/quickstart.md +48 -47
- package/docs/security.md +174 -15
- package/docs/storage.md +4 -2
- package/package.json +8 -6
package/dist/core/event-log.js
CHANGED
|
@@ -3,15 +3,18 @@ import path from 'node:path';
|
|
|
3
3
|
import { memoryDir } from './io.js';
|
|
4
4
|
import { nowISO } from './ids.js';
|
|
5
5
|
import { logger } from './logger.js';
|
|
6
|
+
import { isObserverMode } from './observer-mode.js';
|
|
7
|
+
import { appendJournalRecords, resolveJournalMode } from './events/journal.js';
|
|
8
|
+
import { REGISTRY_POST_IMAGE_ITEM_TYPES } from './events/registry-post-image.js';
|
|
6
9
|
const EVENT_LOG_FILE = 'events.jsonl';
|
|
7
10
|
const CURSORS_DIR = '.cursors';
|
|
8
|
-
|
|
9
|
-
export function appendEvent(event, cwd) {
|
|
11
|
+
export function appendEvent(event, cwd, options = {}) {
|
|
10
12
|
try {
|
|
11
13
|
const full = {
|
|
12
14
|
ts: event.ts ?? nowISO(),
|
|
13
15
|
agent: event.agent ?? 'unknown',
|
|
14
16
|
agent_id: event.agent_id,
|
|
17
|
+
session_id: event.session_id ?? (process.env.BRAINCLAW_SESSION_ID?.trim() || undefined),
|
|
15
18
|
user: event.user ?? process.env.USER ?? process.env.USERNAME,
|
|
16
19
|
action: event.action,
|
|
17
20
|
item_type: event.item_type,
|
|
@@ -25,6 +28,56 @@ export function appendEvent(event, cwd) {
|
|
|
25
28
|
catch (err) {
|
|
26
29
|
logger.debug('Failed to write event log entry:', err);
|
|
27
30
|
}
|
|
31
|
+
if (options.journalDualWrite !== false) {
|
|
32
|
+
dualWriteToJournal(event, cwd);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* v2 journal dual-write (pln#543 step 2). Mirrors every v1 emission into
|
|
37
|
+
* the segmented journal when BRAINCLAW_JOURNAL_MODE=dual; a no-op when off.
|
|
38
|
+
* Mapping per spec §2.1.1: the coarse `update/upgrade/rollback : state`
|
|
39
|
+
* store event becomes a `journal_note` kind `store_marker` (the per-entity
|
|
40
|
+
* events it stands for arrive with step 3 dirty-tracking).
|
|
41
|
+
*/
|
|
42
|
+
function dualWriteToJournal(event, cwd) {
|
|
43
|
+
try {
|
|
44
|
+
if (resolveJournalMode(cwd) === 'off')
|
|
45
|
+
return;
|
|
46
|
+
const base = {
|
|
47
|
+
agent: event.agent,
|
|
48
|
+
agent_id: event.agent_id,
|
|
49
|
+
session_id: event.session_id,
|
|
50
|
+
user: event.user,
|
|
51
|
+
summary: event.summary,
|
|
52
|
+
ts: event.ts,
|
|
53
|
+
};
|
|
54
|
+
if (event.item_type === 'state') {
|
|
55
|
+
appendJournalRecords([{
|
|
56
|
+
...base,
|
|
57
|
+
action: 'journal_note',
|
|
58
|
+
item_type: 'journal',
|
|
59
|
+
payload: { kind: 'store_marker', op: event.action, detail: event.summary },
|
|
60
|
+
}], cwd);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// pln#568 — registry/coordination families now journal full entity-state
|
|
64
|
+
// post-images on their persist chokepoint (emitRegistryPostImage). Their
|
|
65
|
+
// legacy envelope-only dual-write here would be redundant noise (a
|
|
66
|
+
// registry-lifecycle record materialize ignores), so suppress it: in the v2
|
|
67
|
+
// journal these families appear ONLY as post-images. events.jsonl (above)
|
|
68
|
+
// still records the v1 lifecycle event for existing consumers.
|
|
69
|
+
if (REGISTRY_POST_IMAGE_ITEM_TYPES.has(event.item_type))
|
|
70
|
+
return;
|
|
71
|
+
appendJournalRecords([{
|
|
72
|
+
...base,
|
|
73
|
+
action: event.action,
|
|
74
|
+
item_type: event.item_type,
|
|
75
|
+
item_id: event.item_id,
|
|
76
|
+
}], cwd);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
logger.debug('journal dual-write skipped:', err);
|
|
80
|
+
}
|
|
28
81
|
}
|
|
29
82
|
// --- Reader ---
|
|
30
83
|
export function readAllEvents(cwd) {
|
|
@@ -43,39 +96,96 @@ export function readAllEvents(cwd) {
|
|
|
43
96
|
}
|
|
44
97
|
return events;
|
|
45
98
|
}
|
|
99
|
+
function normalizeReader(reader) {
|
|
100
|
+
return typeof reader === 'string' ? { agent: reader } : reader;
|
|
101
|
+
}
|
|
46
102
|
function cursorsDir(cwd) {
|
|
47
103
|
return path.join(memoryDir(cwd), CURSORS_DIR);
|
|
48
104
|
}
|
|
49
|
-
|
|
50
|
-
|
|
105
|
+
/** Cursor files are keyed by session_id when present, else by agent name. */
|
|
106
|
+
function cursorKey(reader) {
|
|
107
|
+
return reader.session_id?.trim() || reader.agent;
|
|
51
108
|
}
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
109
|
+
function cursorPath(key, cwd) {
|
|
110
|
+
return path.join(cursorsDir(cwd), `${key}.json`);
|
|
111
|
+
}
|
|
112
|
+
function loadCursor(reader, cwd) {
|
|
113
|
+
const fp = cursorPath(cursorKey(reader), cwd);
|
|
114
|
+
if (fs.existsSync(fp)) {
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(fs.readFileSync(fp, 'utf-8'));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return { offset: 0, last_read: '' };
|
|
120
|
+
}
|
|
58
121
|
}
|
|
59
|
-
|
|
60
|
-
|
|
122
|
+
// name→instance migration: a session-keyed cursor that does not exist yet
|
|
123
|
+
// seeds from the legacy name-keyed cursor, so an upgraded instance does not
|
|
124
|
+
// replay the whole log. Cursors are caches — worst case is a re-read.
|
|
125
|
+
if (reader.session_id?.trim()) {
|
|
126
|
+
const legacy = cursorPath(reader.agent, cwd);
|
|
127
|
+
if (fs.existsSync(legacy)) {
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(fs.readFileSync(legacy, 'utf-8'));
|
|
130
|
+
}
|
|
131
|
+
catch { /* fall through to fresh cursor */ }
|
|
132
|
+
}
|
|
61
133
|
}
|
|
134
|
+
return { offset: 0, last_read: '' };
|
|
62
135
|
}
|
|
63
|
-
function saveCursor(
|
|
136
|
+
function saveCursor(reader, cursor, cwd) {
|
|
64
137
|
const dir = cursorsDir(cwd);
|
|
65
138
|
if (!fs.existsSync(dir)) {
|
|
66
139
|
fs.mkdirSync(dir, { recursive: true });
|
|
67
140
|
}
|
|
68
|
-
fs.writeFileSync(cursorPath(
|
|
141
|
+
fs.writeFileSync(cursorPath(cursorKey(reader), cwd), JSON.stringify(cursor), 'utf-8');
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* True when this reader already has a cursor — session-keyed, or the legacy
|
|
145
|
+
* name-keyed cursor a session reader would migrate from. Absence means first
|
|
146
|
+
* contact: the reader has never consumed this store's event log.
|
|
147
|
+
*/
|
|
148
|
+
export function hasEventCursor(reader, cwd) {
|
|
149
|
+
const effectiveReader = normalizeReader(reader);
|
|
150
|
+
if (fs.existsSync(cursorPath(cursorKey(effectiveReader), cwd)))
|
|
151
|
+
return true;
|
|
152
|
+
if (effectiveReader.session_id?.trim()) {
|
|
153
|
+
return fs.existsSync(cursorPath(effectiveReader.agent, cwd));
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Seed the reader's cursor at the current end of the event log WITHOUT
|
|
159
|
+
* reading it. First-contact path: a fresh agent on a mature store must not
|
|
160
|
+
* replay the whole history (its one chance to triage would be consumed by
|
|
161
|
+
* noise) — the arrival digest informs instead, and the diff becomes
|
|
162
|
+
* incremental from here. Returns the byte offset that was sealed.
|
|
163
|
+
*/
|
|
164
|
+
export function seedCursorToEnd(reader, cwd) {
|
|
165
|
+
const effectiveReader = normalizeReader(reader);
|
|
166
|
+
const logPath = eventLogPath(cwd);
|
|
167
|
+
let size = 0;
|
|
168
|
+
try {
|
|
169
|
+
size = fs.statSync(logPath).size;
|
|
170
|
+
}
|
|
171
|
+
catch { /* missing log → offset 0 */ }
|
|
172
|
+
saveCursor(effectiveReader, { offset: size, last_read: nowISO() }, cwd);
|
|
173
|
+
return size;
|
|
69
174
|
}
|
|
70
175
|
/**
|
|
71
|
-
* Read events unseen by this
|
|
176
|
+
* Read events unseen by this reader since their last read.
|
|
72
177
|
* Updates the cursor after reading.
|
|
178
|
+
*
|
|
179
|
+
* Self-exclusion is by SESSION when both sides carry one (pln#562 step 4):
|
|
180
|
+
* an instance skips only its own events, not those of a same-named sibling.
|
|
181
|
+
* Events or readers without session info fall back to name exclusion.
|
|
73
182
|
*/
|
|
74
|
-
export function readUnseenEvents(
|
|
183
|
+
export function readUnseenEvents(reader, cwd) {
|
|
184
|
+
const effectiveReader = normalizeReader(reader);
|
|
75
185
|
const logPath = eventLogPath(cwd);
|
|
76
186
|
if (!fs.existsSync(logPath))
|
|
77
187
|
return [];
|
|
78
|
-
const cursor = loadCursor(
|
|
188
|
+
const cursor = loadCursor(effectiveReader, cwd);
|
|
79
189
|
const stat = fs.statSync(logPath);
|
|
80
190
|
if (stat.size <= cursor.offset)
|
|
81
191
|
return [];
|
|
@@ -90,8 +200,10 @@ export function readUnseenEvents(agent, cwd) {
|
|
|
90
200
|
for (const line of lines) {
|
|
91
201
|
try {
|
|
92
202
|
const evt = JSON.parse(line);
|
|
93
|
-
|
|
94
|
-
|
|
203
|
+
const isSelf = effectiveReader.session_id && evt.session_id
|
|
204
|
+
? evt.session_id === effectiveReader.session_id
|
|
205
|
+
: evt.agent === effectiveReader.agent;
|
|
206
|
+
if (!isSelf) {
|
|
95
207
|
events.push(evt);
|
|
96
208
|
}
|
|
97
209
|
}
|
|
@@ -99,8 +211,13 @@ export function readUnseenEvents(agent, cwd) {
|
|
|
99
211
|
// skip
|
|
100
212
|
}
|
|
101
213
|
}
|
|
102
|
-
// Update cursor
|
|
103
|
-
|
|
214
|
+
// Update cursor. Observer mode (BRAINCLAW_OBSERVER=1) skips this — the
|
|
215
|
+
// dashboard impersonating an agent must not consume that agent's cursor
|
|
216
|
+
// (the 2026-06-10 leak where the extension drained Juan's claude-code
|
|
217
|
+
// unseen-events queue on every poll).
|
|
218
|
+
if (!isObserverMode()) {
|
|
219
|
+
saveCursor(effectiveReader, { offset: stat.size, last_read: nowISO() }, cwd);
|
|
220
|
+
}
|
|
104
221
|
return events;
|
|
105
222
|
}
|
|
106
223
|
/**
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal-derived checkpoints (pln#543 Phase 3 / pln#566 Inc0, slice 1).
|
|
3
|
+
*
|
|
4
|
+
* A checkpoint is a snapshot of the materialized live-entity set at a covered
|
|
5
|
+
* `head_seq`, plus a manifest that BINDS it to the journal lineage. Per the
|
|
6
|
+
* round-3 review (NF1): checkpoints are derived from the JOURNAL ONLY — never
|
|
7
|
+
* from projection files, never consulting `projection_watermark` — so the
|
|
8
|
+
* journal is the single root of trust and there is no checkpoint↔watermark
|
|
9
|
+
* cycle. The cold-read fast path (a later slice) loads a checkpoint + replays
|
|
10
|
+
* only the sealed tail (head_seq+1..tail); on ANY trust-chain failure it falls
|
|
11
|
+
* back to projection files.
|
|
12
|
+
*
|
|
13
|
+
* This slice is strictly ADDITIVE: it builds/verifies/replays checkpoints but
|
|
14
|
+
* is not yet wired into the read path, so it carries zero read-path risk.
|
|
15
|
+
*
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import { journalDir, readJournalRecords, journalHeadSeq } from './journal.js';
|
|
22
|
+
import { applyRecordsToLive, projectLiveToState } from './materialize.js';
|
|
23
|
+
import { loadConfig } from '../config.js';
|
|
24
|
+
import { writeFileAtomic } from '../io.js';
|
|
25
|
+
import { nowISO } from '../ids.js';
|
|
26
|
+
const CHECKPOINT_SCHEMA_VERSION = 1;
|
|
27
|
+
export const BASELINE_CAPABILITIES = {
|
|
28
|
+
checkpointRead: false,
|
|
29
|
+
readReconcile: false,
|
|
30
|
+
tombstoneDelete: false,
|
|
31
|
+
perEntityPatch: false,
|
|
32
|
+
};
|
|
33
|
+
function checkpointsDir(cwd) {
|
|
34
|
+
return path.join(journalDir(cwd), 'checkpoints');
|
|
35
|
+
}
|
|
36
|
+
function sha256(s) {
|
|
37
|
+
return crypto.createHash('sha256').update(s).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* pln#566 F4 guard: materialize (the reducer behind both checkpoint build and
|
|
41
|
+
* checkpoint+tail replay) only consumes inline `rec.payload`; it does NOT yet
|
|
42
|
+
* dereference `payload_ref`. So if ANY journal record externalized its payload,
|
|
43
|
+
* a checkpoint built/served from the journal would silently DROP that entity.
|
|
44
|
+
* Until payload_ref dereference lands, refuse to build or serve a checkpoint
|
|
45
|
+
* when externalized payloads exist — the read path falls back to projection
|
|
46
|
+
* files, which always carry the full content. Conservative (whole-journal, not
|
|
47
|
+
* just memory-tier) and cheap.
|
|
48
|
+
*/
|
|
49
|
+
function journalHasExternalizedPayload(records) {
|
|
50
|
+
return records.some(r => r.payload_ref != null);
|
|
51
|
+
}
|
|
52
|
+
/** Identity of a record for journal-lineage binding: stable across reads, changes if the head record changes. */
|
|
53
|
+
function recordIdentity(rec) {
|
|
54
|
+
return sha256(`${rec.seq}|${rec.writer}|${rec.ts}|${rec.action}|${rec.item_type}|${rec.item_id ?? ''}`);
|
|
55
|
+
}
|
|
56
|
+
/** The record carrying the maximum seq (the covered head). Undefined for an empty journal. */
|
|
57
|
+
function headRecord(records) {
|
|
58
|
+
let head;
|
|
59
|
+
for (const rec of records) {
|
|
60
|
+
if (!head || rec.seq > head.seq)
|
|
61
|
+
head = rec;
|
|
62
|
+
}
|
|
63
|
+
return head;
|
|
64
|
+
}
|
|
65
|
+
function manifestPath(cwd, headSeq) {
|
|
66
|
+
return path.join(checkpointsDir(cwd), `${String(headSeq).padStart(12, '0')}.manifest.json`);
|
|
67
|
+
}
|
|
68
|
+
function snapshotPath(cwd, headSeq) {
|
|
69
|
+
return path.join(checkpointsDir(cwd), `${String(headSeq).padStart(12, '0')}.snapshot.json`);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Build a journal-derived checkpoint at the current journal head. Materializes
|
|
73
|
+
* the live entity set from the journal (NOT from projections), writes the
|
|
74
|
+
* snapshot blob, then publishes the manifest (the manifest is the commit
|
|
75
|
+
* point — an orphan snapshot without a manifest is ignored).
|
|
76
|
+
*
|
|
77
|
+
* NOTE (F6): this slice materializes from the full journal on demand. The
|
|
78
|
+
* incremental "latest verified checkpoint + sealed tail, built outside the hot
|
|
79
|
+
* lock" optimization is a later slice; on-demand build here is not on the hot
|
|
80
|
+
* write path.
|
|
81
|
+
*/
|
|
82
|
+
export function createCheckpoint(cwd, capabilities = BASELINE_CAPABILITIES) {
|
|
83
|
+
// Cap to the COMMITTED head (meta.next_seq-1, published after fsync). The
|
|
84
|
+
// raw segment read is lock-free, so a concurrent append may have written —
|
|
85
|
+
// but not yet fsync'd/published — records beyond the committed head; excluding
|
|
86
|
+
// them keeps the manifest bound to durable journal state (codex review MED).
|
|
87
|
+
const committedHead = journalHeadSeq(cwd);
|
|
88
|
+
const records = readJournalRecords(cwd).filter(r => r.seq <= committedHead);
|
|
89
|
+
const head = headRecord(records);
|
|
90
|
+
if (!head)
|
|
91
|
+
return { created: false, reason: 'empty journal — nothing to checkpoint' };
|
|
92
|
+
if (journalHasExternalizedPayload(records)) {
|
|
93
|
+
return { created: false, reason: 'journal has externalized payload_ref records; materialize cannot dereference them yet (pln#566 F4)' };
|
|
94
|
+
}
|
|
95
|
+
const live = applyRecordsToLive(records, new Map());
|
|
96
|
+
const entities = [...live.values()];
|
|
97
|
+
const snapshot = JSON.stringify(entities);
|
|
98
|
+
const manifest = {
|
|
99
|
+
schema_version: CHECKPOINT_SCHEMA_VERSION,
|
|
100
|
+
store_id: loadConfig(cwd).project_id ?? 'unknown',
|
|
101
|
+
head_seq: head.seq,
|
|
102
|
+
head_identity: recordIdentity(head),
|
|
103
|
+
snapshot_sha256: sha256(snapshot),
|
|
104
|
+
entity_count: entities.length,
|
|
105
|
+
capability_vector: capabilities,
|
|
106
|
+
created_at: nowISO(),
|
|
107
|
+
};
|
|
108
|
+
const dir = checkpointsDir(cwd);
|
|
109
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
110
|
+
// Snapshot first, manifest last (commit point).
|
|
111
|
+
writeFileAtomic(snapshotPath(cwd, head.seq), snapshot);
|
|
112
|
+
writeFileAtomic(manifestPath(cwd, head.seq), JSON.stringify(manifest));
|
|
113
|
+
return { created: true, manifest };
|
|
114
|
+
}
|
|
115
|
+
/** Default: create a fresh checkpoint once the journal has grown this many
|
|
116
|
+
* records past the last checkpoint head. Bounds the sealed tail so
|
|
117
|
+
* checkpointRead's gap-replay stays cheap, without checkpointing every persist. */
|
|
118
|
+
export const DEFAULT_CHECKPOINT_INTERVAL = 500;
|
|
119
|
+
/**
|
|
120
|
+
* Create a checkpoint ONLY if the journal has grown >= interval records past
|
|
121
|
+
* the last checkpoint head (cheap head-seq check; no full scan unless building).
|
|
122
|
+
* Intended for off-hot-path callers (session-start maintenance). Journal-derived
|
|
123
|
+
* (F6). No-op when the journal is off/empty or hasn't grown enough.
|
|
124
|
+
*/
|
|
125
|
+
export function maybeCreateCheckpoint(cwd, interval = DEFAULT_CHECKPOINT_INTERVAL) {
|
|
126
|
+
const head = journalHeadSeq(cwd);
|
|
127
|
+
if (head === 0)
|
|
128
|
+
return { created: false, gap: 0, reason: 'journal empty/off' };
|
|
129
|
+
const last = loadLatestCheckpointManifest(cwd)?.head_seq ?? 0;
|
|
130
|
+
const gap = head - last;
|
|
131
|
+
if (gap < interval)
|
|
132
|
+
return { created: false, gap, reason: `gap ${gap} < interval ${interval}` };
|
|
133
|
+
const res = createCheckpoint(cwd);
|
|
134
|
+
return { created: res.created, gap, reason: res.reason };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Highest-head_seq manifest on disk, or undefined if none. Selects by NUMERIC
|
|
138
|
+
* head_seq parsed from the filename — lexicographic order on the 12-zero-pad
|
|
139
|
+
* breaks once seq exceeds 12 digits (codex review LOW).
|
|
140
|
+
*/
|
|
141
|
+
export function loadLatestCheckpointManifest(cwd) {
|
|
142
|
+
const dir = checkpointsDir(cwd);
|
|
143
|
+
if (!fs.existsSync(dir))
|
|
144
|
+
return undefined;
|
|
145
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.manifest.json'))
|
|
146
|
+
.sort((a, b) => (parseInt(b, 10) || 0) - (parseInt(a, 10) || 0)); // numeric desc on the seq prefix
|
|
147
|
+
for (const f of files) {
|
|
148
|
+
try {
|
|
149
|
+
return JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'));
|
|
150
|
+
}
|
|
151
|
+
catch { /* skip corrupt manifest, try the next */ }
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
function isMaterializedEntityArray(v) {
|
|
156
|
+
return Array.isArray(v) && v.every(e => !!e && typeof e === 'object'
|
|
157
|
+
&& typeof e.item_type === 'string'
|
|
158
|
+
&& typeof e.item_id === 'string'
|
|
159
|
+
&& !!e.payload && typeof e.payload === 'object');
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Verify a checkpoint against an ALREADY-READ record set — never throws,
|
|
163
|
+
* validates snapshot SHAPE (not just sha256), and binds to the SAME journal
|
|
164
|
+
* view the caller will serve from (no verify/serve TOCTOU). WITHOUT reading
|
|
165
|
+
* projection files (F3). Returns the parsed entities when valid.
|
|
166
|
+
*/
|
|
167
|
+
function verifyCheckpointAgainstRecords(manifest, snapshotRaw, records, cwd) {
|
|
168
|
+
try {
|
|
169
|
+
if (manifest.schema_version !== CHECKPOINT_SCHEMA_VERSION) {
|
|
170
|
+
return { valid: false, reason: `unsupported checkpoint schema_version ${manifest.schema_version}` };
|
|
171
|
+
}
|
|
172
|
+
if (sha256(snapshotRaw) !== manifest.snapshot_sha256) {
|
|
173
|
+
return { valid: false, reason: 'snapshot sha256 mismatch (corrupt/tampered blob)' };
|
|
174
|
+
}
|
|
175
|
+
let parsed;
|
|
176
|
+
try {
|
|
177
|
+
parsed = JSON.parse(snapshotRaw);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return { valid: false, reason: 'snapshot is not valid JSON' };
|
|
181
|
+
}
|
|
182
|
+
if (!isMaterializedEntityArray(parsed))
|
|
183
|
+
return { valid: false, reason: 'snapshot is not a MaterializedEntity[]' };
|
|
184
|
+
const expectedStore = loadConfig(cwd).project_id ?? 'unknown';
|
|
185
|
+
if (manifest.store_id !== expectedStore) {
|
|
186
|
+
return { valid: false, reason: `store_id mismatch (manifest ${manifest.store_id} vs ${expectedStore}) — copied/wrong-branch checkpoint` };
|
|
187
|
+
}
|
|
188
|
+
const head = records.find(r => r.seq === manifest.head_seq);
|
|
189
|
+
if (!head)
|
|
190
|
+
return { valid: false, reason: `head_seq ${manifest.head_seq} not found in journal` };
|
|
191
|
+
if (recordIdentity(head) !== manifest.head_identity) {
|
|
192
|
+
return { valid: false, reason: 'head record identity mismatch — journal lineage diverged from checkpoint' };
|
|
193
|
+
}
|
|
194
|
+
return { valid: true, entities: parsed };
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
return { valid: false, reason: `verification error: ${err instanceof Error ? err.message : String(err)}` };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Public no-throw verification: snapshot integrity + shape, store binding, and
|
|
202
|
+
* journal-lineage binding. Reads the journal once. Returns valid:false on any
|
|
203
|
+
* failure (never throws) so callers fall back.
|
|
204
|
+
*/
|
|
205
|
+
export function verifyCheckpoint(manifest, snapshotRaw, cwd) {
|
|
206
|
+
let records;
|
|
207
|
+
try {
|
|
208
|
+
records = readJournalRecords(cwd);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
return { valid: false, reason: `journal read failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
212
|
+
}
|
|
213
|
+
const r = verifyCheckpointAgainstRecords(manifest, snapshotRaw, records, cwd);
|
|
214
|
+
return { valid: r.valid, reason: r.reason };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Materialize state from the latest VERIFIED checkpoint + the sealed tail
|
|
218
|
+
* (records with seq > head_seq), using the same reducer + projector as
|
|
219
|
+
* full-journal materialization. Reads the journal exactly ONCE and uses that
|
|
220
|
+
* single view for verification, the F4 payload_ref guard, and the tail replay
|
|
221
|
+
* (no TOCTOU). Returns null on any failure (caller falls back to projections).
|
|
222
|
+
*/
|
|
223
|
+
export function materializeStateFromCheckpoint(cwd) {
|
|
224
|
+
const manifest = loadLatestCheckpointManifest(cwd);
|
|
225
|
+
if (!manifest)
|
|
226
|
+
return null;
|
|
227
|
+
let snapshotRaw;
|
|
228
|
+
try {
|
|
229
|
+
snapshotRaw = fs.readFileSync(snapshotPath(cwd, manifest.head_seq), 'utf-8');
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null; // orphan manifest without a readable snapshot
|
|
233
|
+
}
|
|
234
|
+
let records;
|
|
235
|
+
// Same committed-head cap as the build path: only durable records (seq <=
|
|
236
|
+
// meta.next_seq-1) drive verification, the F4 guard, and the tail replay.
|
|
237
|
+
try {
|
|
238
|
+
const committedHead = journalHeadSeq(cwd);
|
|
239
|
+
records = readJournalRecords(cwd).filter(r => r.seq <= committedHead);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
const verdict = verifyCheckpointAgainstRecords(manifest, snapshotRaw, records, cwd);
|
|
245
|
+
if (!verdict.valid || !verdict.entities)
|
|
246
|
+
return null;
|
|
247
|
+
// F4 guard: an externalized payload (which materialize can't deref) means the
|
|
248
|
+
// checkpoint/tail would drop entities — refuse to serve, fall back.
|
|
249
|
+
if (journalHasExternalizedPayload(records))
|
|
250
|
+
return null;
|
|
251
|
+
const live = new Map();
|
|
252
|
+
for (const e of verdict.entities)
|
|
253
|
+
live.set(`${e.item_type}:${e.item_id}`, e);
|
|
254
|
+
// Replay only the sealed tail (head_seq+1..end) from the SAME record view.
|
|
255
|
+
applyRecordsToLive(records.filter(r => r.seq > manifest.head_seq), live);
|
|
256
|
+
return projectLiveToState(live);
|
|
257
|
+
}
|
|
258
|
+
//# sourceMappingURL=checkpoint.js.map
|