brainclaw 1.8.0 → 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 +12 -11
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +138 -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 +285 -22
- 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 +588 -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 +79 -5
- package/dist/core/dispatcher.js +64 -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/facade-schema.js +38 -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 +114 -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/staleness.js +72 -1
- package/dist/core/state.js +250 -54
- package/dist/core/store-resolution.js +19 -5
- package/dist/core/worktree.js +72 -8
- 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/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 +29 -2
- package/docs/quickstart.md +48 -47
- package/docs/security.md +174 -15
- package/docs/storage.md +4 -2
- package/package.json +8 -6
|
@@ -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
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal genesis migration + rollback (pln#543 step 4, spec §4 phase 1).
|
|
3
|
+
*
|
|
4
|
+
* Genesis seeds the v2 journal from the current v1 projection store: one
|
|
5
|
+
* `journal_note` kind `genesis` followed by one `backfill` record per live
|
|
6
|
+
* memory entity (entity_rev 1), all under a single lock hold. It is the
|
|
7
|
+
* baseline the journal grows from and that materialize/verify check against.
|
|
8
|
+
*
|
|
9
|
+
* Discipline (matches the house upgrade rule, feedback_no_init_force):
|
|
10
|
+
* - MANDATORY backup before writing — projections copied to a timestamped
|
|
11
|
+
* park dir; nothing is ever deleted.
|
|
12
|
+
* - Refuses to clobber an existing genesis unless `force` (which parks the
|
|
13
|
+
* prior journal first — park-don't-delete).
|
|
14
|
+
* - Rollback parks the journal directory; projections are untouched (in
|
|
15
|
+
* dual mode they were always the source of truth), so it is a safe,
|
|
16
|
+
* reversible "stop using the journal" operation.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { memoryDir } from '../io.js';
|
|
21
|
+
import { loadState } from '../state.js';
|
|
22
|
+
import { preparePersistedDocument } from '../migration.js';
|
|
23
|
+
import { nowISO } from '../ids.js';
|
|
24
|
+
import { logger } from '../logger.js';
|
|
25
|
+
import { forceAppendJournalRecords, journalDir, readJournalRecords, resolveJournalMode, } from './journal.js';
|
|
26
|
+
import { REGISTRY_FAMILIES } from './registry-post-image.js';
|
|
27
|
+
import { listClaims } from '../claims.js';
|
|
28
|
+
import { listAssignments } from '../assignments.js';
|
|
29
|
+
import { listAgentRuns } from '../agentruns.js';
|
|
30
|
+
import { listActionRequired } from '../actions.js';
|
|
31
|
+
import { listCandidates } from '../candidates.js';
|
|
32
|
+
import { listSequences } from '../sequence.js';
|
|
33
|
+
import { listSharedJournaledRuntimeNotes } from '../runtime.js';
|
|
34
|
+
const MEMORY_FAMILIES = [
|
|
35
|
+
{ collection: 'active_constraints', itemType: 'constraint' },
|
|
36
|
+
{ collection: 'recent_decisions', itemType: 'decision' },
|
|
37
|
+
{ collection: 'known_traps', itemType: 'trap' },
|
|
38
|
+
{ collection: 'open_handoffs', itemType: 'handoff' },
|
|
39
|
+
{ collection: 'plan_items', itemType: 'plan' },
|
|
40
|
+
];
|
|
41
|
+
/** True once a `journal_note` kind `genesis` exists in the journal. */
|
|
42
|
+
export function hasGenesis(cwd) {
|
|
43
|
+
return readJournalRecords(cwd).some(r => r.action === 'journal_note' && r.payload?.kind === 'genesis');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Copy the live projection store to a timestamped park dir. Returns the path.
|
|
47
|
+
* `ts` is injected (the codebase forbids Date.now()/new Date() in some layers;
|
|
48
|
+
* callers pass nowISO()-derived stamps) — defaults to a lexically-sortable ISO.
|
|
49
|
+
*/
|
|
50
|
+
export function backupStore(cwd, stamp) {
|
|
51
|
+
const base = memoryDir(cwd);
|
|
52
|
+
const memorySrc = path.join(base, 'memory');
|
|
53
|
+
const safeStamp = stamp.replace(/[:.]/g, '-');
|
|
54
|
+
const backupDir = path.join(base, 'migration-backups', `genesis-${safeStamp}`);
|
|
55
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
56
|
+
if (fs.existsSync(memorySrc)) {
|
|
57
|
+
fs.cpSync(memorySrc, path.join(backupDir, 'memory'), { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
return backupDir;
|
|
60
|
+
}
|
|
61
|
+
export function runGenesisMigration(options = {}) {
|
|
62
|
+
const cwd = options.cwd ?? process.cwd();
|
|
63
|
+
const state = loadState(cwd);
|
|
64
|
+
const perFamily = {};
|
|
65
|
+
const backfill = [];
|
|
66
|
+
for (const { collection, itemType } of MEMORY_FAMILIES) {
|
|
67
|
+
const items = state[collection];
|
|
68
|
+
perFamily[itemType] = items.length;
|
|
69
|
+
for (const item of items) {
|
|
70
|
+
backfill.push({
|
|
71
|
+
action: 'backfill',
|
|
72
|
+
item_type: itemType,
|
|
73
|
+
item_id: item.id,
|
|
74
|
+
agent: 'system',
|
|
75
|
+
payload: preparePersistedDocument(itemType, item),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const total = backfill.length;
|
|
80
|
+
if (options.dryRun) {
|
|
81
|
+
return { status: 'dry_run', backfilled: total, per_family: perFamily };
|
|
82
|
+
}
|
|
83
|
+
if (hasGenesis(cwd)) {
|
|
84
|
+
if (!options.force) {
|
|
85
|
+
return { status: 'already_present', backfilled: 0, per_family: perFamily };
|
|
86
|
+
}
|
|
87
|
+
parkJournal(cwd, nowISO());
|
|
88
|
+
}
|
|
89
|
+
// Genesis is the phase-1 (dual) seed (spec §4). Running it with the flag
|
|
90
|
+
// off lays the seed but mutations after will not dual-write — the journal
|
|
91
|
+
// then silently diverges from projections until BRAINCLAW_JOURNAL_MODE is
|
|
92
|
+
// flipped to dual. Warn so the operator flips the flag (or accepts the
|
|
93
|
+
// seed-then-flip sequence deliberately).
|
|
94
|
+
if (resolveJournalMode(cwd) === 'off') {
|
|
95
|
+
logger.warn('runGenesisMigration: BRAINCLAW_JOURNAL_MODE=off — genesis will seed the journal, but subsequent mutations will not dual-write, so the journal will diverge from projections until you set BRAINCLAW_JOURNAL_MODE=dual.');
|
|
96
|
+
}
|
|
97
|
+
const backupPath = backupStore(cwd, nowISO());
|
|
98
|
+
// genesis note first, then the backfill batch — all under one lock hold via
|
|
99
|
+
// a single forced append call (appendLocked stamps them with consecutive seqs).
|
|
100
|
+
const genesisNote = {
|
|
101
|
+
action: 'journal_note',
|
|
102
|
+
item_type: 'journal',
|
|
103
|
+
agent: 'system',
|
|
104
|
+
payload: {
|
|
105
|
+
kind: 'genesis',
|
|
106
|
+
migrated_from: 'v1',
|
|
107
|
+
backfill_count: total,
|
|
108
|
+
per_family: perFamily,
|
|
109
|
+
backup_path: backupPath,
|
|
110
|
+
at: nowISO(),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
const written = forceAppendJournalRecords([genesisNote, ...backfill], cwd);
|
|
114
|
+
// Locate the genesis note by action+kind, not array position: appendLocked
|
|
115
|
+
// can prepend a `seq_repair` or `journal_note kind torn_tail_adjudicated`
|
|
116
|
+
// when meta is stale or the prior segment tail is torn, which would shift
|
|
117
|
+
// written[0] off the genesis note and report the wrong genesis_seq.
|
|
118
|
+
const genesisSeq = written.find(r => r.action === 'journal_note' && r.payload?.kind === 'genesis')?.seq;
|
|
119
|
+
logger.debug(`journal genesis: ${total} entities backfilled at seq ${genesisSeq}, backup ${backupPath}`);
|
|
120
|
+
return { status: 'migrated', genesis_seq: genesisSeq, backfilled: total, backup_path: backupPath, per_family: perFamily };
|
|
121
|
+
}
|
|
122
|
+
// ── Registry genesis supplement (pln#568 slice 3 — cutover signal O2) ──────
|
|
123
|
+
/**
|
|
124
|
+
* The registry / coordination families backfilled by the registry genesis
|
|
125
|
+
* supplement, each mapped to its projection reader. Mirrors verify.ts's
|
|
126
|
+
* VERIFIED_REGISTRY_FAMILIES so a supplemented store passes `doctor
|
|
127
|
+
* --verify-journal` with zero registry drift. Runtime notes are shared-only
|
|
128
|
+
* (private/machine never enter the shared journal, pln#568).
|
|
129
|
+
*/
|
|
130
|
+
const REGISTRY_GENESIS_FAMILIES = [
|
|
131
|
+
{ family: 'claim', list: listClaims },
|
|
132
|
+
// listActionRequired performs the server's sweep-on-read expiration, which can
|
|
133
|
+
// update dependent assignments/runs. Run it before snapshotting those families
|
|
134
|
+
// so the supplement cannot append stale assignment/run post-images after the
|
|
135
|
+
// sweep's fresh journal records. Dry-run disables the sweep to honor the
|
|
136
|
+
// no-write contract.
|
|
137
|
+
{ family: 'action', list: (cwd, options) => listActionRequired(cwd, {}, { expireStale: !options.dryRun }) },
|
|
138
|
+
{ family: 'assignment', list: (cwd) => listAssignments(cwd) },
|
|
139
|
+
{ family: 'agent_run', list: (cwd) => listAgentRuns(cwd) },
|
|
140
|
+
{ family: 'candidate', list: (cwd) => listCandidates('pending', cwd) },
|
|
141
|
+
{ family: 'runtime_note', list: listSharedJournaledRuntimeNotes },
|
|
142
|
+
{ family: 'sequence', list: listSequences },
|
|
143
|
+
];
|
|
144
|
+
/** True once a `journal_note` kind `registry_genesis` exists — the cutover
|
|
145
|
+
* signal (O2) the observer reads to trust the journal as AUTHORITATIVE for the
|
|
146
|
+
* registry families (drop the board_summary MCP seed). */
|
|
147
|
+
export function hasRegistryGenesis(cwd) {
|
|
148
|
+
return readJournalRecords(cwd).some(r => r.action === 'journal_note' && r.payload?.kind === 'registry_genesis');
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Backfill the registry / coordination families into the journal and emit the
|
|
152
|
+
* `registry_genesis` cutover marker (pln#568 slice 3). INCREMENTAL by design:
|
|
153
|
+
* it appends to the existing journal (preserving the memory genesis + all
|
|
154
|
+
* accumulated post-image history) rather than parking/re-seeding — a re-genesis
|
|
155
|
+
* would reset seq to 1 and break live observers' seq cursors. Idempotent: a
|
|
156
|
+
* second run no-ops once the marker is present.
|
|
157
|
+
*
|
|
158
|
+
* The marker is the safe authority signal: an observer must not switch a
|
|
159
|
+
* registry family from the MCP seed to the journal until EVERY pre-existing
|
|
160
|
+
* entity has a post-image, else it undercounts (the trp#559 badge regression).
|
|
161
|
+
* This backfill establishes that guarantee, then the marker announces it.
|
|
162
|
+
*/
|
|
163
|
+
export function runRegistryGenesisSupplement(options = {}) {
|
|
164
|
+
const cwd = options.cwd ?? process.cwd();
|
|
165
|
+
const perFamily = {};
|
|
166
|
+
const backfill = [];
|
|
167
|
+
for (const { family, list } of REGISTRY_GENESIS_FAMILIES) {
|
|
168
|
+
const spec = REGISTRY_FAMILIES[family];
|
|
169
|
+
const items = list(cwd, options);
|
|
170
|
+
perFamily[spec.journalItemType] = items.length;
|
|
171
|
+
for (const item of items) {
|
|
172
|
+
backfill.push({
|
|
173
|
+
action: 'backfill',
|
|
174
|
+
item_type: spec.journalItemType,
|
|
175
|
+
item_id: item.id,
|
|
176
|
+
agent: 'system',
|
|
177
|
+
payload: preparePersistedDocument(spec.docType, item),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const total = backfill.length;
|
|
182
|
+
if (options.dryRun) {
|
|
183
|
+
return { status: 'dry_run', backfilled: total, per_family: perFamily };
|
|
184
|
+
}
|
|
185
|
+
if (hasRegistryGenesis(cwd)) {
|
|
186
|
+
return { status: 'already_present', backfilled: 0, per_family: perFamily };
|
|
187
|
+
}
|
|
188
|
+
const marker = {
|
|
189
|
+
action: 'journal_note',
|
|
190
|
+
item_type: 'journal',
|
|
191
|
+
agent: 'system',
|
|
192
|
+
payload: { kind: 'registry_genesis', backfill_count: total, per_family: perFamily, at: nowISO() },
|
|
193
|
+
};
|
|
194
|
+
forceAppendJournalRecords([...backfill, marker], cwd);
|
|
195
|
+
logger.debug(`registry genesis: ${total} registry entities backfilled, cutover marker emitted`);
|
|
196
|
+
return { status: 'migrated', backfilled: total, per_family: perFamily };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Park the journal directory (events/) to a timestamped archive — the
|
|
200
|
+
* reversible "stop using the journal" operation. Projections are untouched.
|
|
201
|
+
* Returns the park path, or undefined if there was no journal.
|
|
202
|
+
*/
|
|
203
|
+
export function parkJournal(cwd, stamp) {
|
|
204
|
+
const dir = journalDir(cwd);
|
|
205
|
+
if (!fs.existsSync(dir))
|
|
206
|
+
return undefined;
|
|
207
|
+
const safeStamp = stamp.replace(/[:.]/g, '-');
|
|
208
|
+
const parked = path.join(memoryDir(cwd), 'migration-backups', `journal-parked-${safeStamp}`);
|
|
209
|
+
fs.mkdirSync(path.dirname(parked), { recursive: true });
|
|
210
|
+
fs.renameSync(dir, parked);
|
|
211
|
+
return parked;
|
|
212
|
+
}
|
|
213
|
+
export function rollbackJournal(options = {}) {
|
|
214
|
+
const cwd = options.cwd ?? process.cwd();
|
|
215
|
+
const parked = parkJournal(cwd, nowISO());
|
|
216
|
+
return parked
|
|
217
|
+
? { status: 'rolled_back', parked_path: parked }
|
|
218
|
+
: { status: 'nothing_to_roll_back' };
|
|
219
|
+
}
|
|
220
|
+
//# sourceMappingURL=genesis.js.map
|