@skanl/brambo-projection 0.1.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 +112 -0
- package/dist/atomic-write.d.ts +3 -0
- package/dist/atomic-write.js +90 -0
- package/dist/config-write.d.ts +49 -0
- package/dist/config-write.js +146 -0
- package/dist/document-fault.d.ts +59 -0
- package/dist/document-fault.js +78 -0
- package/dist/engine.d.ts +69 -0
- package/dist/engine.js +235 -0
- package/dist/formats.d.ts +132 -0
- package/dist/formats.js +1301 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +15 -0
- package/dist/ledger.d.ts +193 -0
- package/dist/ledger.js +552 -0
- package/dist/materialise.d.ts +55 -0
- package/dist/materialise.js +620 -0
- package/dist/remediate.d.ts +65 -0
- package/dist/remediate.js +468 -0
- package/dist/targets/claude-mcp.d.ts +6 -0
- package/dist/targets/claude-mcp.js +36 -0
- package/dist/targets/codex-config.d.ts +6 -0
- package/dist/targets/codex-config.js +27 -0
- package/dist/targets/opencode-config.d.ts +6 -0
- package/dist/targets/opencode-config.js +58 -0
- package/dist/targets/skills.d.ts +22 -0
- package/dist/targets/skills.js +165 -0
- package/package.json +57 -0
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { BRAMBO_ERROR_CODES, BramboError, PROJECTION_LEDGER_VERSION, isRecord } from '@skanl/brambo-contracts';
|
|
6
|
+
import { acquireLock } from '@skanl/brambo-lock';
|
|
7
|
+
import { atomicWriteText } from './atomic-write.js';
|
|
8
|
+
import { strictFaultLocation } from './document-fault.js';
|
|
9
|
+
// The durable ownership ledger (AD-6, correction-01 C2): brambo's own record of
|
|
10
|
+
// every entry it placed in someone else's file. It lives beside the registry
|
|
11
|
+
// store in brambo's own directory and follows the same atomic temp+rename
|
|
12
|
+
// discipline, but it owns its state alone — @skanl/brambo-projection depends on
|
|
13
|
+
// @skanl/brambo-contracts and nothing else (AD-2), so rendering a config file never
|
|
14
|
+
// drags the Registry store or the microkernel in behind it.
|
|
15
|
+
//
|
|
16
|
+
// The ledger is the ONLY proof of ownership. That is deliberate: no vendor
|
|
17
|
+
// schema can reject a record that lives outside the vendor's file, and no
|
|
18
|
+
// format has to carry a marker it has nowhere to put.
|
|
19
|
+
//
|
|
20
|
+
// Failure policy, and it only bends one way. A ledger that cannot be READ is
|
|
21
|
+
// treated as "brambo has written nothing" for this run, which makes brambo report
|
|
22
|
+
// its own entries as foreign and touch nothing — recoverable. PERSISTING that
|
|
23
|
+
// under-claim is not recoverable: it would orphan every entry brambo has ever
|
|
24
|
+
// written, in every config, with no way back. So an unreadable ledger is never
|
|
25
|
+
// written over; it is reported and left exactly as it is.
|
|
26
|
+
//
|
|
27
|
+
// Writes MERGE: a run replaces only the records for the target and file it just
|
|
28
|
+
// projected, so it can never drop a claim for a target+file pair it did not
|
|
29
|
+
// touch. Merging alone is NOT enough, because `update` rewrites the whole
|
|
30
|
+
// document from a read taken before it: two interleaved read-modify-writes lose
|
|
31
|
+
// one side's claim permanently, and the entry is then a foreign collision
|
|
32
|
+
// forever. Serialisation therefore keys on the LEDGER FILE and is process-wide
|
|
33
|
+
// — two ProjectionLedger INSTANCES over one path share a queue, which is what
|
|
34
|
+
// two concurrent inits in one process actually are.
|
|
35
|
+
const LEDGER_FILE_NAME = 'projection-ledger.json';
|
|
36
|
+
const RECORD_FIELDS = ['targetId', 'filePath', 'nativeLocation', 'entryId', 'contentHash'];
|
|
37
|
+
/**
|
|
38
|
+
* Hash of the CANONICAL form of the text brambo placed at a native location.
|
|
39
|
+
*
|
|
40
|
+
* EOL is normalised because a file that git, an editor or a formatter rewrites
|
|
41
|
+
* from LF to CRLF has not been edited in any sense a vendor can observe — and
|
|
42
|
+
* `~/.claude.json` is rewritten by Claude Code itself. Treating that as an edit
|
|
43
|
+
* would make brambo disown every entry it has in the file. Format-specific
|
|
44
|
+
* canonicalisation (indentation, key order) happens in the strategies, which
|
|
45
|
+
* are the only code that knows what "the same entry" means per format.
|
|
46
|
+
*/
|
|
47
|
+
export function hashOwnedText(text) {
|
|
48
|
+
return createHash('sha256').update(text.replaceAll('\r\n', '\n'), 'utf8').digest('hex');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Hash of the exact BYTES brambo copied to a path.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately not {@link hashOwnedText}: a materialised file is copied verbatim
|
|
54
|
+
* from a source brambo does not author, so "the same file" means the same bytes.
|
|
55
|
+
* Normalising EOL here would let brambo overwrite a file whose line endings a
|
|
56
|
+
* user deliberately changed, and — far worse, since this is the delete path —
|
|
57
|
+
* let it REMOVE one.
|
|
58
|
+
*/
|
|
59
|
+
export function hashOwnedBytes(bytes) {
|
|
60
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The EOL-normalised form of the same bytes, for the OVERWRITE comparison only.
|
|
64
|
+
*
|
|
65
|
+
* Never for a removal. A removal is decided byte for byte, because a false
|
|
66
|
+
* match there precedes `rm`; an overwrite decided byte for byte instead makes
|
|
67
|
+
* a skills root kept under `core.autocrlf` report every brambo file as edited
|
|
68
|
+
* forever, and the product has no adopt, force or reclaim path out of that.
|
|
69
|
+
*/
|
|
70
|
+
export function canonicalBytesHash(bytes) {
|
|
71
|
+
return hashOwnedText(Buffer.from(bytes).toString('utf8'));
|
|
72
|
+
}
|
|
73
|
+
/** Ownership keys must be one canonical spelling of a path, never two. */
|
|
74
|
+
export function resolveOwnedPath(filePath) {
|
|
75
|
+
return resolve(filePath);
|
|
76
|
+
}
|
|
77
|
+
/** win32 paths differ in drive-letter and directory casing between processes. */
|
|
78
|
+
export function sameOwnedPath(left, right) {
|
|
79
|
+
return process.platform === 'win32'
|
|
80
|
+
? left.toLowerCase() === right.toLowerCase()
|
|
81
|
+
: left === right;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether `path` is strictly inside `root`. Both arguments must already be
|
|
85
|
+
* resolved — this predicate answers about paths, not about strings a caller
|
|
86
|
+
* hopes are paths.
|
|
87
|
+
*
|
|
88
|
+
* It lives beside {@link resolveOwnedPath} because it is the second half of the
|
|
89
|
+
* same rule: a path brambo acts on is CANONICALISED and then proven to be inside
|
|
90
|
+
* the location brambo owns. Every caller that skips either half has been a
|
|
91
|
+
* user-data defect — the removal path took raw ledger strings straight to `rm`,
|
|
92
|
+
* and a relative one resolved against the process working directory.
|
|
93
|
+
*/
|
|
94
|
+
export function isUnderRoot(path, root) {
|
|
95
|
+
const rest = relative(root, path);
|
|
96
|
+
return rest !== '' && !rest.startsWith('..') && !rest.startsWith(sep + '..');
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The right to replace the WHOLE ownership document without merging.
|
|
100
|
+
*
|
|
101
|
+
* A module-scope symbol rather than a convention: the only way to hold it is to
|
|
102
|
+
* import it, so "one caller" stops being a claim a text scan makes and becomes
|
|
103
|
+
* something the runtime enforces. `test/guard.test.ts` pins who imports it.
|
|
104
|
+
*/
|
|
105
|
+
export const LEDGER_REPAIR_AUTHORITY = Symbol('brambo.projection.ledger.repair');
|
|
106
|
+
/**
|
|
107
|
+
* A malformed `ownedPaths` makes the WHOLE record invalid, and that direction is
|
|
108
|
+
* the point: a dropped record claims nothing, so brambo under-claims and removes
|
|
109
|
+
* nothing. Keeping a record whose path list is half-readable would hand the one
|
|
110
|
+
* operation that deletes a user's files an authority brambo cannot vouch for.
|
|
111
|
+
*/
|
|
112
|
+
function isOwnedPathList(value) {
|
|
113
|
+
return (Array.isArray(value) &&
|
|
114
|
+
value.every((item) => isRecord(item) &&
|
|
115
|
+
typeof item['path'] === 'string' &&
|
|
116
|
+
item['path'] !== '' &&
|
|
117
|
+
typeof item['contentHash'] === 'string' &&
|
|
118
|
+
item['contentHash'] !== '' &&
|
|
119
|
+
(item['canonicalHash'] === undefined ||
|
|
120
|
+
(typeof item['canonicalHash'] === 'string' && item['canonicalHash'] !== ''))));
|
|
121
|
+
}
|
|
122
|
+
function isLedgerRecord(value) {
|
|
123
|
+
if (!isRecord(value))
|
|
124
|
+
return false;
|
|
125
|
+
if (!RECORD_FIELDS.every((field) => typeof value[field] === 'string' && value[field] !== '')) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
return value['ownedPaths'] === undefined || isOwnedPathList(value['ownedPaths']);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The hash brambo writes for a claim it can still ADDRESS but can no longer
|
|
132
|
+
* VOUCH for.
|
|
133
|
+
*
|
|
134
|
+
* Deliberately not hex. A real `contentHash` is `sha256` output, so this can
|
|
135
|
+
* never compare equal to one by accident — the never-matching property is
|
|
136
|
+
* structural rather than improbable. `isLedgerRecord` asks only for a non-empty
|
|
137
|
+
* string, and nothing anywhere validates the shape, so this survives a round
|
|
138
|
+
* trip and reads as `edited` forever: brambo knows which bytes the claim covers
|
|
139
|
+
* and admits it does not know what it wrote there.
|
|
140
|
+
*/
|
|
141
|
+
export const UNVOUCHED_CONTENT_HASH = 'unreadable-after-repair';
|
|
142
|
+
function salvageOwnedPaths(value) {
|
|
143
|
+
if (!Array.isArray(value))
|
|
144
|
+
return undefined;
|
|
145
|
+
const salvaged = [];
|
|
146
|
+
for (const item of value) {
|
|
147
|
+
// The PATH is the claim; the hashes are only the vouching. An item whose
|
|
148
|
+
// path is gone names nothing and cannot be salvaged.
|
|
149
|
+
if (!isRecord(item) || typeof item['path'] !== 'string' || item['path'] === '')
|
|
150
|
+
continue;
|
|
151
|
+
const contentHash = item['contentHash'];
|
|
152
|
+
const canonicalHash = item['canonicalHash'];
|
|
153
|
+
salvaged.push({
|
|
154
|
+
path: item['path'],
|
|
155
|
+
contentHash: typeof contentHash === 'string' && contentHash !== '' ? contentHash : UNVOUCHED_CONTENT_HASH,
|
|
156
|
+
...(typeof canonicalHash === 'string' && canonicalHash !== ''
|
|
157
|
+
? { canonicalHash }
|
|
158
|
+
: contentHash === undefined
|
|
159
|
+
? {}
|
|
160
|
+
: { canonicalHash: UNVOUCHED_CONTENT_HASH }),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return salvaged.length === 0 ? undefined : salvaged;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* A record `isLedgerRecord` rejects, reduced to the smallest claim brambo can
|
|
167
|
+
* still act on — or `undefined` when nothing addressable survives.
|
|
168
|
+
*
|
|
169
|
+
* WHY THIS EXISTS. `repair` used to drop every record it could not read, and
|
|
170
|
+
* dropping the RECORD throws away far more than the broken FIELD: measured, a
|
|
171
|
+
* record whose only damage was its `contentHash` still carried all four identity
|
|
172
|
+
* fields, so brambo knew exactly which bytes it covered. Dropping it left `brambo
|
|
173
|
+
* doctor` reporting NOTHING, `brambo remediate adopt` refusing with exit 1, and
|
|
174
|
+
* `brambo remove` + `brambo init` leaving the entry in the user's config
|
|
175
|
+
* permanently — while `repair` printed that those entries "report as foreign
|
|
176
|
+
* collisions until they are adopted".
|
|
177
|
+
*
|
|
178
|
+
* Only `repair` uses this. An ordinary `read()` still drops a malformed record
|
|
179
|
+
* and warns, because salvaging on every read would silently promote damage into
|
|
180
|
+
* a claim nobody asked brambo to make.
|
|
181
|
+
*/
|
|
182
|
+
function salvageRecord(value) {
|
|
183
|
+
if (!isRecord(value))
|
|
184
|
+
return undefined;
|
|
185
|
+
// `entryId` is part of `recordKey`, and the other three say WHERE. Without all
|
|
186
|
+
// four the claim cannot be addressed, updated or removed, so there is nothing
|
|
187
|
+
// to keep.
|
|
188
|
+
const identity = ['targetId', 'filePath', 'nativeLocation', 'entryId'];
|
|
189
|
+
if (!identity.every((field) => typeof value[field] === 'string' && value[field] !== '')) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
const owned = value['ownedPaths'];
|
|
193
|
+
let ownedPaths;
|
|
194
|
+
if (owned !== undefined) {
|
|
195
|
+
ownedPaths = salvageOwnedPaths(owned);
|
|
196
|
+
// For a MATERIALISATION record `ownedPaths` IS the claim: a record without
|
|
197
|
+
// it claims nothing, so keeping one would be a claim that authorises and
|
|
198
|
+
// protects nothing while looking like ownership.
|
|
199
|
+
if (ownedPaths === undefined)
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
const contentHash = value['contentHash'];
|
|
203
|
+
return {
|
|
204
|
+
targetId: value['targetId'],
|
|
205
|
+
filePath: value['filePath'],
|
|
206
|
+
nativeLocation: value['nativeLocation'],
|
|
207
|
+
entryId: value['entryId'],
|
|
208
|
+
contentHash: typeof contentHash === 'string' && contentHash !== '' ? contentHash : UNVOUCHED_CONTENT_HASH,
|
|
209
|
+
...(ownedPaths === undefined ? {} : { ownedPaths }),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function recordKey(record) {
|
|
213
|
+
return `${record.targetId}\u0000${record.filePath}\u0000${record.entryId}`;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Stable on-disk order with a total ordering over the record key, so an
|
|
217
|
+
* unchanged ledger really is a byte-unchanged file. Later records win a
|
|
218
|
+
* duplicate key: a run's own output must override whatever it is replacing.
|
|
219
|
+
*/
|
|
220
|
+
function normalizeRecords(records) {
|
|
221
|
+
const deduped = new Map();
|
|
222
|
+
for (const record of records)
|
|
223
|
+
deduped.set(recordKey(record), record);
|
|
224
|
+
return [...deduped.values()].sort((a, b) => {
|
|
225
|
+
const left = recordKey(a);
|
|
226
|
+
const right = recordKey(b);
|
|
227
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The exact bytes of the ledger document for a record set.
|
|
232
|
+
*
|
|
233
|
+
* Exported so a remediation can PREDICT the document it is about to write and
|
|
234
|
+
* report the byte delta before writing it, using the same serialisation the
|
|
235
|
+
* write itself performs. A second spelling here would let a preview report a
|
|
236
|
+
* size the act does not produce.
|
|
237
|
+
*/
|
|
238
|
+
export function serialiseLedgerDocument(records) {
|
|
239
|
+
return JSON.stringify({ version: PROJECTION_LEDGER_VERSION, records: normalizeRecords(records) }, null, 2);
|
|
240
|
+
}
|
|
241
|
+
function detailOf(error) {
|
|
242
|
+
return error instanceof Error ? error.message : String(error);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Read-modify-write queues, keyed by resolved ledger path. Module-level on
|
|
246
|
+
* purpose: an instance-level queue serialises one ProjectionLedger object, and
|
|
247
|
+
* every caller that constructs its own — `initMachine` and `initProject` run
|
|
248
|
+
* concurrently, for instance — gets its own object over the SAME file.
|
|
249
|
+
*
|
|
250
|
+
* This is the INNER boundary and it covers one process. The outer one is the
|
|
251
|
+
* `<ledger>.lock` file taken in `#locked`, which covers two brambo PROCESSES —
|
|
252
|
+
* that gap used to lose 10 of 24 claims across three measured rounds, silently,
|
|
253
|
+
* with every writer exiting 0. The queue is kept rather than replaced: it is
|
|
254
|
+
* cheaper than a lockfile and it is exactly right for its own case, so the file
|
|
255
|
+
* lock only ever contends between processes.
|
|
256
|
+
*/
|
|
257
|
+
const LEDGER_QUEUES = new Map();
|
|
258
|
+
/**
|
|
259
|
+
* The leaf lock's neutral codes, translated at this package's boundary (AD-7).
|
|
260
|
+
* `@skanl/brambo-lock` may not raise a projection code and this package may not
|
|
261
|
+
* publish a `BRAMBO_LOCK_*` one, so the mapping lives exactly here.
|
|
262
|
+
*/
|
|
263
|
+
function asLedgerFailure(filePath, error) {
|
|
264
|
+
if (!(error instanceof BramboError))
|
|
265
|
+
return error;
|
|
266
|
+
if (error.code === BRAMBO_ERROR_CODES.lockContention) {
|
|
267
|
+
return new BramboError(BRAMBO_ERROR_CODES.projectionLedgerContention, `projection ledger '${filePath}' is held by another brambo process: ${error.message}`, { cause: error });
|
|
268
|
+
}
|
|
269
|
+
if (error.code === BRAMBO_ERROR_CODES.lockUnavailable) {
|
|
270
|
+
return new BramboError(BRAMBO_ERROR_CODES.projectionLedgerUnavailable, `projection ledger '${filePath}' could not be locked for writing: ${error.message}`, { cause: error });
|
|
271
|
+
}
|
|
272
|
+
return error;
|
|
273
|
+
}
|
|
274
|
+
export class ProjectionLedger {
|
|
275
|
+
filePath;
|
|
276
|
+
#queueKey;
|
|
277
|
+
#lockPath;
|
|
278
|
+
#lockTimeoutMs;
|
|
279
|
+
#onStaleLockBreak;
|
|
280
|
+
constructor(options = {}) {
|
|
281
|
+
this.filePath = options.filePath ?? join(options.homeDir ?? homedir(), '.brambo', LEDGER_FILE_NAME);
|
|
282
|
+
const resolved = resolveOwnedPath(this.filePath);
|
|
283
|
+
this.#queueKey = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
284
|
+
// Beside the document, like the registry store's. Derived from the RESOLVED
|
|
285
|
+
// path so two processes spelling the same ledger differently — a relative
|
|
286
|
+
// argv, a different drive-letter case on win32 — still contend for one file.
|
|
287
|
+
this.#lockPath = `${resolved}.lock`;
|
|
288
|
+
this.#lockTimeoutMs = options.lockTimeoutMs;
|
|
289
|
+
this.#onStaleLockBreak = options.onStaleLockBreak;
|
|
290
|
+
}
|
|
291
|
+
/** Never throws: the three states are what callers must distinguish. */
|
|
292
|
+
async read() {
|
|
293
|
+
let raw;
|
|
294
|
+
try {
|
|
295
|
+
raw = await readFile(this.filePath, 'utf8');
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
if (error?.code === 'ENOENT') {
|
|
299
|
+
return { state: 'absent', records: [], salvaged: [], warnings: [] };
|
|
300
|
+
}
|
|
301
|
+
return this.#unreadable(`cannot be read: ${detailOf(error)}`);
|
|
302
|
+
}
|
|
303
|
+
let parsed;
|
|
304
|
+
try {
|
|
305
|
+
parsed = JSON.parse(raw);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// LOCATED, never quoted (`document-fault.ts`). This ledger holds paths and
|
|
309
|
+
// hashes rather than server arguments, so no leak was measured out of it —
|
|
310
|
+
// and it is brought under the rule anyway, because "no credential happens
|
|
311
|
+
// to sit inside V8's snippet window today" is not a property of the code.
|
|
312
|
+
return this.#unreadable(`is not valid JSON: ${strictFaultLocation(raw)}`);
|
|
313
|
+
}
|
|
314
|
+
if (!isRecord(parsed) || parsed['version'] !== PROJECTION_LEDGER_VERSION) {
|
|
315
|
+
return this.#unreadable(`declares version ${JSON.stringify(isRecord(parsed) ? parsed['version'] : undefined)} but this build reads version ${PROJECTION_LEDGER_VERSION}`);
|
|
316
|
+
}
|
|
317
|
+
const records = parsed['records'];
|
|
318
|
+
if (!Array.isArray(records))
|
|
319
|
+
return this.#unreadable('has no records array');
|
|
320
|
+
// One damaged record is not a damaged ledger: keeping the valid claims
|
|
321
|
+
// keeps brambo able to update and remove everything it still recognises.
|
|
322
|
+
const valid = records.filter(isLedgerRecord);
|
|
323
|
+
const dropped = records.length - valid.length;
|
|
324
|
+
// Computed here because this is the only place that holds the RAW records;
|
|
325
|
+
// `repair` is the sole consumer and every other caller sees `records` alone.
|
|
326
|
+
const salvaged = records
|
|
327
|
+
.filter((record) => !isLedgerRecord(record))
|
|
328
|
+
.map(salvageRecord)
|
|
329
|
+
.filter((record) => record !== undefined);
|
|
330
|
+
return {
|
|
331
|
+
state: 'readable',
|
|
332
|
+
records: valid,
|
|
333
|
+
salvaged,
|
|
334
|
+
warnings: dropped === 0
|
|
335
|
+
? []
|
|
336
|
+
: [
|
|
337
|
+
{
|
|
338
|
+
code: BRAMBO_ERROR_CODES.projectionLedgerUnavailable,
|
|
339
|
+
detail: `projection ledger '${this.filePath}' has ${dropped} malformed record(s); those entries are no longer claimed by brambo`,
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Replaces the records this caller TOOK A POSITION ON inside one scope,
|
|
346
|
+
* keeping every other claim — including claims inside the same scope that
|
|
347
|
+
* landed after the caller's snapshot was taken.
|
|
348
|
+
*
|
|
349
|
+
* `examined` is the third parameter and it is required, because omission used
|
|
350
|
+
* to mean deletion and nobody could see it. The caller passes the entry ids it
|
|
351
|
+
* read out of its own ledger snapshot for this scope; `update` adds the ids it
|
|
352
|
+
* is being handed now. An id in neither set belongs to a writer this caller
|
|
353
|
+
* never saw, and survives untouched.
|
|
354
|
+
*
|
|
355
|
+
* WITHOUT IT, A CONCURRENT RUN ERASES CLAIMS IT DELIBERATELY LEFT ALONE.
|
|
356
|
+
* Measured on the binary: ten rounds of two concurrent `brambo init` over one
|
|
357
|
+
* home lost 20 of 40 claims, 20 of 20 processes exited 0, and no stderr line
|
|
358
|
+
* named foreign, skip, collision or ledger; the one-process control lost 0 of
|
|
359
|
+
* 40. A wider run lost 72 of 144 and left 16 vendor entries owned by nobody,
|
|
360
|
+
* after which `brambo doctor` exited 0 reporting `"drift": []` and a later
|
|
361
|
+
* `remove` + `init` could no longer take those entries back out — brambo had
|
|
362
|
+
* permanently lost the ability to undo bytes it wrote.
|
|
363
|
+
*
|
|
364
|
+
* The mechanism is a granularity mismatch, not a lock. Process B reads the
|
|
365
|
+
* ledger before A persists, so B's snapshot holds none of A's claims. B then
|
|
366
|
+
* reads the vendor file, which by now holds A's entries, and classifies them
|
|
367
|
+
* as foreign — CORRECTLY, since nothing B can see claims them — so they never
|
|
368
|
+
* reach `projected.records`. The merge decided per entry; the write then
|
|
369
|
+
* replaced the whole scope. B left A's bytes alone and erased A's claim in the
|
|
370
|
+
* same breath. Keying the drop to what the caller examined is what makes the
|
|
371
|
+
* two agree.
|
|
372
|
+
*
|
|
373
|
+
* Deliberately NOT one write per entry: that shape preserves the same claims,
|
|
374
|
+
* but measured 99 writes per init instead of 6 (p50 61.5 ms each, 69x the
|
|
375
|
+
* ledger phase) and opened a contention cliff — 17 of 48 scopes failing at
|
|
376
|
+
* eight concurrent inits against 0 of 48 today. This keeps one write per scope.
|
|
377
|
+
*
|
|
378
|
+
* Still serialised against concurrent calls on this instance, and still NOT a
|
|
379
|
+
* fix for the orphan window above it: `engine.ts` writes the vendor file
|
|
380
|
+
* before it reaches this method, so a persist that throws — or a crash in
|
|
381
|
+
* between — leaves bytes no record claims. That is a different defect with a
|
|
382
|
+
* different fix, and it is open.
|
|
383
|
+
*/
|
|
384
|
+
async update(scope, records, examined) {
|
|
385
|
+
await this.#queued(async () => {
|
|
386
|
+
const current = await this.read();
|
|
387
|
+
if (current.state === 'unreadable') {
|
|
388
|
+
throw new BramboError(BRAMBO_ERROR_CODES.projectionLedgerUnavailable, `projection ledger '${this.filePath}' became unreadable; refusing to overwrite it and orphan every claim it holds`);
|
|
389
|
+
}
|
|
390
|
+
const surrendered = new Set([...examined, ...records.map((record) => record.entryId)]);
|
|
391
|
+
const kept = current.records.filter((record) => record.targetId !== scope.targetId ||
|
|
392
|
+
!sameOwnedPath(record.filePath, scope.filePath) ||
|
|
393
|
+
!surrendered.has(record.entryId));
|
|
394
|
+
await this.#persist([...kept, ...records]);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Replaces ONE entry's record inside one scope, reading the current document
|
|
399
|
+
* INSIDE the queue.
|
|
400
|
+
*
|
|
401
|
+
* The granularity is the point. A caller that read the ledger, decided, and
|
|
402
|
+
* then handed `update` a whole replacement set for the scope would resurrect
|
|
403
|
+
* every claim another writer legitimately dropped in between — brambo would
|
|
404
|
+
* then claim a path it does not own, which on the materialisation path is a
|
|
405
|
+
* delete authority. Only the named entry moves here; every sibling claim is
|
|
406
|
+
* whatever the document says at the moment of the write.
|
|
407
|
+
*
|
|
408
|
+
* `record === undefined` drops the entry instead of replacing it.
|
|
409
|
+
*/
|
|
410
|
+
async updateEntry(scope, entryId, record) {
|
|
411
|
+
await this.#queued(async () => {
|
|
412
|
+
const current = await this.read();
|
|
413
|
+
if (current.state === 'unreadable') {
|
|
414
|
+
throw new BramboError(BRAMBO_ERROR_CODES.projectionLedgerUnavailable, `projection ledger '${this.filePath}' became unreadable; refusing to overwrite it and orphan every claim it holds`);
|
|
415
|
+
}
|
|
416
|
+
const kept = current.records.filter((candidate) => candidate.entryId !== entryId ||
|
|
417
|
+
candidate.targetId !== scope.targetId ||
|
|
418
|
+
!sameOwnedPath(resolveOwnedPath(candidate.filePath), scope.filePath));
|
|
419
|
+
await this.#persist(record === undefined ? kept : [...kept, record]);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Replaces the WHOLE document with whatever `select` returns for the document
|
|
424
|
+
* as it is INSIDE the queue.
|
|
425
|
+
*
|
|
426
|
+
* This is the one write that does not merge, and it exists for exactly one
|
|
427
|
+
* caller: the user-named `repair` remediation, which is how a ledger holding
|
|
428
|
+
* records brambo cannot read stops being a state with no exit. Nothing else may
|
|
429
|
+
* use it — `update` is the merging write every projection performs, and its
|
|
430
|
+
* refusal to overwrite an unreadable ledger is a load-bearing guarantee that
|
|
431
|
+
* this method deliberately does not have. `test/guard.test.ts` pins the caller
|
|
432
|
+
* list, because a second one would silently reintroduce the orphan-every-claim
|
|
433
|
+
* failure Story 2.8 declared terminal.
|
|
434
|
+
*
|
|
435
|
+
* `select` runs INSIDE the queue and is handed the read the write will be
|
|
436
|
+
* based on. A caller that read the document itself and passed the result would
|
|
437
|
+
* destroy every claim written in between — with no merge to save it, which is
|
|
438
|
+
* exactly what makes this method the dangerous one. It may throw to abort the
|
|
439
|
+
* write, which is how `repair` refuses when the document moved under it.
|
|
440
|
+
*/
|
|
441
|
+
async rewriteAll(authority, select) {
|
|
442
|
+
// A CAPABILITY, not a spelling check. The static caller list in
|
|
443
|
+
// `test/guard.test.ts` catches the honest second caller and was evaded by a
|
|
444
|
+
// reviewer with `ledger['rewrite' + 'All']([])` — a scan cannot see a name
|
|
445
|
+
// assembled at runtime. Holding the sentinel can only come from importing
|
|
446
|
+
// it, which both the symbol scan and the package's import graph do see, so
|
|
447
|
+
// the obfuscated route now fails at run time instead of silently working.
|
|
448
|
+
if (authority !== LEDGER_REPAIR_AUTHORITY) {
|
|
449
|
+
throw new BramboError(BRAMBO_ERROR_CODES.projectionLedgerUnavailable, `projection ledger '${this.filePath}': rewriting the whole document is reserved for the repair remediation`);
|
|
450
|
+
}
|
|
451
|
+
await this.#queued(async () => {
|
|
452
|
+
await this.#persist(select(await this.read()));
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
/** The read-modify-write queue, keyed by ledger path and shared by instances. */
|
|
456
|
+
async #queued(work) {
|
|
457
|
+
const run = (LEDGER_QUEUES.get(this.#queueKey) ?? Promise.resolve()).then(() => this.#locked(work));
|
|
458
|
+
// The chain must survive a rejection, or one failed target would deadlock
|
|
459
|
+
// every later one.
|
|
460
|
+
const settled = run.catch(() => undefined);
|
|
461
|
+
LEDGER_QUEUES.set(this.#queueKey, settled);
|
|
462
|
+
try {
|
|
463
|
+
await run;
|
|
464
|
+
}
|
|
465
|
+
finally {
|
|
466
|
+
// Drop the entry once this is the tail, so a long-lived process does not
|
|
467
|
+
// accumulate one resolved promise per ledger path it ever touched.
|
|
468
|
+
if (LEDGER_QUEUES.get(this.#queueKey) === settled)
|
|
469
|
+
LEDGER_QUEUES.delete(this.#queueKey);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* The OUTER boundary: THIS METHOD's read-modify-write happens while this
|
|
474
|
+
* process holds `<ledger>.lock`, so two calls into `update`, `updateEntry` or
|
|
475
|
+
* `replace` cannot interleave their own read and write. Merging alone never
|
|
476
|
+
* closed that window — the read is what interleaves, and only mutual exclusion
|
|
477
|
+
* over the read AND the write can close it.
|
|
478
|
+
*
|
|
479
|
+
* WHERE THE BOUNDARY ENDS, and it is not where this comment used to imply. A
|
|
480
|
+
* caller that reads the document ITSELF, decides, and then hands `update` a
|
|
481
|
+
* whole replacement set is outside this lock for the part that matters, and
|
|
482
|
+
* `updateEntry`'s own comment names the consequence: it "would resurrect every
|
|
483
|
+
* claim another writer legitimately dropped in between — brambo would then
|
|
484
|
+
* claim a path it does not own, which on the materialisation path is a delete
|
|
485
|
+
* authority."
|
|
486
|
+
*
|
|
487
|
+
* `runProjection` is that caller and its window is still open: `engine.ts`
|
|
488
|
+
* takes `await store.read()` before the target loop and decides every target
|
|
489
|
+
* against that one snapshot. Measured while the window was also AUTHORITY,
|
|
490
|
+
* ten rounds of two concurrent `brambo init` against one home lost 76 of 120
|
|
491
|
+
* claims with zero bytes on stderr, against a one-process control that lost 0
|
|
492
|
+
* of 120.
|
|
493
|
+
*
|
|
494
|
+
* What closed that is NOT this lock — it never could be, because the decision
|
|
495
|
+
* happens outside it. `update` now takes the entry ids the caller EXAMINED and
|
|
496
|
+
* drops only those, so a stale snapshot can no longer speak for entries it
|
|
497
|
+
* never saw; the same harness reports 0 of 120 after. The window remains, and
|
|
498
|
+
* is now merely stale rather than destructive: two runs can still reach
|
|
499
|
+
* different conclusions about the same entry and resolve last-writer-wins.
|
|
500
|
+
*
|
|
501
|
+
* `finally { release }` mirrors `RegistryStore.#persist` exactly: the lock is
|
|
502
|
+
* given back whether the write succeeded or threw, and a release failure is
|
|
503
|
+
* itself coded rather than swallowed.
|
|
504
|
+
*/
|
|
505
|
+
async #locked(work) {
|
|
506
|
+
// The lockfile is created inside this directory; on a fresh machine nothing
|
|
507
|
+
// has created ~/.brambo yet, and an exclusive create into a missing directory
|
|
508
|
+
// is an ENOENT the lock would report as an unavailable medium.
|
|
509
|
+
try {
|
|
510
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
throw new BramboError(BRAMBO_ERROR_CODES.projectionLedgerUnavailable, `projection ledger '${this.filePath}' could not be written: ${detailOf(error)}`, { cause: error });
|
|
514
|
+
}
|
|
515
|
+
const lock = await acquireLock(this.#lockPath, {
|
|
516
|
+
timeoutMs: this.#lockTimeoutMs,
|
|
517
|
+
onStaleBreak: this.#onStaleLockBreak,
|
|
518
|
+
}).catch((error) => {
|
|
519
|
+
throw asLedgerFailure(this.filePath, error);
|
|
520
|
+
});
|
|
521
|
+
try {
|
|
522
|
+
await work();
|
|
523
|
+
}
|
|
524
|
+
finally {
|
|
525
|
+
await lock.release().catch((error) => {
|
|
526
|
+
throw asLedgerFailure(this.filePath, error);
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
async #persist(records) {
|
|
531
|
+
try {
|
|
532
|
+
await atomicWriteText(this.filePath, serialiseLedgerDocument(records));
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
throw new BramboError(BRAMBO_ERROR_CODES.projectionLedgerUnavailable, `projection ledger '${this.filePath}' could not be written: ${detailOf(error)}`, { cause: error });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
#unreadable(reason) {
|
|
539
|
+
return {
|
|
540
|
+
state: 'unreadable',
|
|
541
|
+
records: [],
|
|
542
|
+
// Nothing parsed, so nothing can be addressed, let alone salvaged.
|
|
543
|
+
salvaged: [],
|
|
544
|
+
warnings: [
|
|
545
|
+
{
|
|
546
|
+
code: BRAMBO_ERROR_CODES.projectionLedgerUnavailable,
|
|
547
|
+
detail: `projection ledger '${this.filePath}' ${reason}; treating it as if brambo had written nothing, and leaving the file untouched`,
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ProjectionClaim, ProjectionLedgerRecord, ProjectionMaterialiseTarget, ProjectionResult, RegistryEntriesByKind } from '@skanl/brambo-contracts';
|
|
2
|
+
export interface MaterialiseOutcome {
|
|
3
|
+
readonly result: ProjectionResult;
|
|
4
|
+
readonly records: readonly ProjectionLedgerRecord[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The ledger record that would claim the tree currently at one entry's location
|
|
8
|
+
* — the materialisation half of `adopt`, and the exit from every reported state
|
|
9
|
+
* a skills root can be in: a `foreign-collision` (brambo's own tree left
|
|
10
|
+
* unclaimed by a crash included), an `edited` tree, and a tree that is only
|
|
11
|
+
* PARTLY there.
|
|
12
|
+
*
|
|
13
|
+
* IT LIVES HERE, BESIDE THE REMOVAL RULE, ON PURPOSE. A record this returns is
|
|
14
|
+
* an authority to DELETE on some later run, so every clause of that rule is
|
|
15
|
+
* applied while the record is built rather than trusted afterwards: the paths
|
|
16
|
+
* come from the target's own plan (never from a directory listing, so a file the
|
|
17
|
+
* user put beside brambo's is not swept into the claim and cannot later be
|
|
18
|
+
* removed); each is resolved and containment-checked against the root; a link
|
|
19
|
+
* anywhere between the root and the file disqualifies it; and a path any OTHER
|
|
20
|
+
* record already claims is refused rather than duplicated.
|
|
21
|
+
*
|
|
22
|
+
* A PARTIALLY PRESENT TREE IS CLAIMED AS THE SUBSET THAT IS THERE, and that is
|
|
23
|
+
* the correction that gives that state an exit at all. Claiming the whole
|
|
24
|
+
* planned set would write a record reading `edited` on the very next run — the
|
|
25
|
+
* state adoption exists to leave — so brambo claims exactly the files that exist,
|
|
26
|
+
* the record reads `intact`, and the ordinary run writes the missing ones back.
|
|
27
|
+
* Refusing instead (the first shipped shape) left three separate routes into a
|
|
28
|
+
* tree with no exit but `rm -rf`: `release` on an edited tree, a crash inside
|
|
29
|
+
* `land()` between two file writes, and a user deleting one file from a skill.
|
|
30
|
+
*
|
|
31
|
+
* ponytail: when a record claims MORE paths than the plan wants — a file that
|
|
32
|
+
* left the source — adoption claims the planned subset and lets that path go
|
|
33
|
+
* unclaimed, so the ordinary run stops being authorised to take it back and it
|
|
34
|
+
* stays inside brambo's tree. Under-claiming, which is the safe direction and the
|
|
35
|
+
* one this whole file errs towards; the alternative is claiming the union, which
|
|
36
|
+
* widens what an explicit user action makes deletable. Upgrade path: claim the
|
|
37
|
+
* union once a case exists where the leftover file matters.
|
|
38
|
+
*
|
|
39
|
+
* WHEN THE ENTRY HAS LEFT THE REGISTRY the plan holds nothing for it, and the
|
|
40
|
+
* ledger record is the fallback authority — that is the shape reported as
|
|
41
|
+
* `edited` on the removal path, whose only other exit was `release`, which drops
|
|
42
|
+
* the claim and leaves the tree on disk forever. Adoption there means the next
|
|
43
|
+
* ordinary run REMOVES the tree, which is why the claim carries `removedNext`
|
|
44
|
+
* and the caller has to say so before writing it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function claimMaterialised(target: ProjectionMaterialiseTarget, entries: RegistryEntriesByKind, claimed: readonly ProjectionLedgerRecord[], entryId: string): Promise<ProjectionClaim>;
|
|
47
|
+
/**
|
|
48
|
+
* Runs one materialisation target: plan, classify against the ledger, then —
|
|
49
|
+
* under `apply` — land exactly the difference.
|
|
50
|
+
*
|
|
51
|
+
* Under inspection NOTHING touches the filesystem except reads, and
|
|
52
|
+
* `result.written` reads as "these paths WOULD change", which is the one field
|
|
53
|
+
* whose sentence the mode alters.
|
|
54
|
+
*/
|
|
55
|
+
export declare function materialiseTarget(target: ProjectionMaterialiseTarget, entries: RegistryEntriesByKind, claimed: readonly ProjectionLedgerRecord[], apply: boolean): Promise<MaterialiseOutcome>;
|