amalgm 0.1.261 → 0.1.263
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/lib/cli.js +9 -3
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/lib/prepared.js +27 -0
- package/runtime/scripts/amalgm-mcp/observer/README.md +4 -1
- package/runtime/scripts/amalgm-mcp/observer/coverage.js +52 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +186 -44
- package/runtime/scripts/amalgm-mcp/observer/store.js +41 -27
- package/runtime/scripts/amalgm-mcp/observer/watch.js +6 -2
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +54 -19
- package/runtime/scripts/amalgm-mcp/registration/index.js +58 -12
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +43 -4
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +15 -5
- package/runtime/scripts/amalgm-mcp/registration/service.js +121 -16
- package/runtime/scripts/amalgm-mcp/registration/tree.js +104 -20
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +34 -13
- package/runtime/scripts/amalgm-mcp/registry/store.js +30 -26
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +45 -3
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +65 -10
- package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +90 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +236 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.coverage.test.js +197 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +30 -3
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +10 -6
- package/runtime/scripts/amalgm-mcp/tests/repocard.scope.test.js +182 -0
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* is durable, or its loss is explicit.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
const { preparer } = require('../lib/prepared');
|
|
19
|
+
|
|
18
20
|
const SCHEMA = `
|
|
19
21
|
CREATE TABLE IF NOT EXISTS observer_roots (
|
|
20
22
|
root_id TEXT PRIMARY KEY,
|
|
@@ -141,6 +143,8 @@ function createStore(db) {
|
|
|
141
143
|
db.exec(SCHEMA);
|
|
142
144
|
db.pragma(`user_version = ${GENERATION}`);
|
|
143
145
|
|
|
146
|
+
const prepared = preparer(db);
|
|
147
|
+
|
|
144
148
|
/**
|
|
145
149
|
* One directory's immediate child rows — the scoped scan's slice of
|
|
146
150
|
* memory, served by the (root_id, rel_path) unique index. '0' is the
|
|
@@ -150,33 +154,43 @@ function createStore(db) {
|
|
|
150
154
|
*/
|
|
151
155
|
function immediateRows(table, rootId, relDir) {
|
|
152
156
|
if (relDir === '') {
|
|
153
|
-
return
|
|
157
|
+
return prepared(`SELECT * FROM ${table} WHERE root_id = ? AND instr(rel_path, '/') = 0 ORDER BY rel_path`)
|
|
154
158
|
.all(rootId);
|
|
155
159
|
}
|
|
156
160
|
const prefix = `${relDir}/`;
|
|
157
|
-
return
|
|
161
|
+
return prepared(`SELECT * FROM ${table} WHERE root_id = ? AND rel_path >= ? AND rel_path < ? ORDER BY rel_path`)
|
|
158
162
|
.all(rootId, prefix, `${relDir}0`)
|
|
159
163
|
.filter((row) => !row.rel_path.slice(prefix.length).includes('/'));
|
|
160
164
|
}
|
|
161
165
|
|
|
162
166
|
return {
|
|
167
|
+
/**
|
|
168
|
+
* One scan's whole apply phase commits as one durable transition.
|
|
169
|
+
* Row-level verbs each pay a full journal cycle when run bare, so a
|
|
170
|
+
* census over thousands of channels was thousands of commits; batched,
|
|
171
|
+
* it is one. Nesting is safe — SQLite savepoints — and a crash
|
|
172
|
+
* mid-batch leaves the previous truth whole, exactly like applyDirDiff.
|
|
173
|
+
*/
|
|
174
|
+
transaction(fn) {
|
|
175
|
+
return db.transaction(fn)();
|
|
176
|
+
},
|
|
163
177
|
addRoot({ rootId, path, kind, device = null, inode = null }) {
|
|
164
|
-
|
|
178
|
+
prepared('INSERT INTO observer_roots (root_id, path, kind, device, inode) VALUES (?, ?, ?, ?, ?)')
|
|
165
179
|
.run(rootId, path, kind, device, inode);
|
|
166
180
|
},
|
|
167
181
|
rootByPath(path) {
|
|
168
|
-
const row =
|
|
182
|
+
const row = prepared('SELECT * FROM observer_roots WHERE path = ?').get(path);
|
|
169
183
|
return row ? rowToRoot(row) : null;
|
|
170
184
|
},
|
|
171
185
|
listRoots() {
|
|
172
|
-
return
|
|
186
|
+
return prepared('SELECT * FROM observer_roots ORDER BY path').all().map(rowToRoot);
|
|
173
187
|
},
|
|
174
188
|
setRootKind(rootId, kind) {
|
|
175
|
-
|
|
189
|
+
prepared('UPDATE observer_roots SET kind = ? WHERE root_id = ?').run(kind, rootId);
|
|
176
190
|
},
|
|
177
191
|
/** Identity persists; local presence is separate evidence. */
|
|
178
192
|
setRootPresent(rootId, present) {
|
|
179
|
-
|
|
193
|
+
prepared('UPDATE observer_roots SET present = ? WHERE root_id = ?').run(present ? 1 : 0, rootId);
|
|
180
194
|
},
|
|
181
195
|
/**
|
|
182
196
|
* A root's identity is permanent; its address is not — and a family
|
|
@@ -186,7 +200,7 @@ function createStore(db) {
|
|
|
186
200
|
* Device and inode are continuity itself and never churn on a rename.
|
|
187
201
|
*/
|
|
188
202
|
moveRoots(moves) {
|
|
189
|
-
const move =
|
|
203
|
+
const move = prepared('UPDATE observer_roots SET path = ? WHERE root_id = ?');
|
|
190
204
|
db.transaction(() => {
|
|
191
205
|
for (const { rootId, path } of moves) move.run(path, rootId);
|
|
192
206
|
})();
|
|
@@ -195,18 +209,18 @@ function createStore(db) {
|
|
|
195
209
|
* new identity at a known address (see enrollRoot) — the one write
|
|
196
210
|
* where a root's own device:inode may change. */
|
|
197
211
|
setRootIdentity(rootId, device, inode) {
|
|
198
|
-
|
|
212
|
+
prepared('UPDATE observer_roots SET device = ?, inode = ? WHERE root_id = ?')
|
|
199
213
|
.run(device, inode, rootId);
|
|
200
214
|
},
|
|
201
215
|
removeRoot(rootId) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
216
|
+
prepared('DELETE FROM observer_files WHERE root_id = ?').run(rootId);
|
|
217
|
+
prepared('DELETE FROM observer_dirs WHERE root_id = ?').run(rootId);
|
|
218
|
+
prepared('DELETE FROM observer_links WHERE root_id = ?').run(rootId);
|
|
219
|
+
prepared('DELETE FROM observer_roots WHERE root_id = ?').run(rootId);
|
|
206
220
|
},
|
|
207
221
|
|
|
208
222
|
filesForRoot(rootId) {
|
|
209
|
-
return
|
|
223
|
+
return prepared('SELECT * FROM observer_files WHERE root_id = ? ORDER BY rel_path').all(rootId)
|
|
210
224
|
.map(rowToFile);
|
|
211
225
|
},
|
|
212
226
|
filesInDir(rootId, relDir) {
|
|
@@ -220,11 +234,11 @@ function createStore(db) {
|
|
|
220
234
|
},
|
|
221
235
|
fileByPath(rootId, relPath) {
|
|
222
236
|
return rowToFile(
|
|
223
|
-
|
|
237
|
+
prepared('SELECT * FROM observer_files WHERE root_id = ? AND rel_path = ?').get(rootId, relPath),
|
|
224
238
|
);
|
|
225
239
|
},
|
|
226
240
|
insertFile(file) {
|
|
227
|
-
|
|
241
|
+
prepared(`
|
|
228
242
|
INSERT INTO observer_files (file_id, root_id, rel_path, device, inode, size, mtime_ms, content_hash, is_binary, hashed_at_ms)
|
|
229
243
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
230
244
|
`).run(
|
|
@@ -233,18 +247,18 @@ function createStore(db) {
|
|
|
233
247
|
);
|
|
234
248
|
},
|
|
235
249
|
updateFile(fileId, { relPath, device, inode, size, mtimeMs, contentHash, binary, hashedAtMs }) {
|
|
236
|
-
|
|
250
|
+
prepared(`
|
|
237
251
|
UPDATE observer_files
|
|
238
252
|
SET rel_path = ?, device = ?, inode = ?, size = ?, mtime_ms = ?, content_hash = ?, is_binary = ?, hashed_at_ms = ?
|
|
239
253
|
WHERE file_id = ?
|
|
240
254
|
`).run(relPath, device, inode, size, mtimeMs, contentHash, binary ? 1 : 0, hashedAtMs, fileId);
|
|
241
255
|
},
|
|
242
256
|
removeFile(fileId) {
|
|
243
|
-
|
|
257
|
+
prepared('DELETE FROM observer_files WHERE file_id = ?').run(fileId);
|
|
244
258
|
},
|
|
245
259
|
|
|
246
260
|
dirsForRoot(rootId) {
|
|
247
|
-
return
|
|
261
|
+
return prepared('SELECT * FROM observer_dirs WHERE root_id = ? ORDER BY rel_path').all(rootId)
|
|
248
262
|
.map(rowToDir);
|
|
249
263
|
},
|
|
250
264
|
/**
|
|
@@ -257,9 +271,9 @@ function createStore(db) {
|
|
|
257
271
|
* the next scan re-detects the same batch.
|
|
258
272
|
*/
|
|
259
273
|
applyDirDiff({ deletes, moves, retags, creates }) {
|
|
260
|
-
const remove =
|
|
261
|
-
const insert =
|
|
262
|
-
const retag =
|
|
274
|
+
const remove = prepared('DELETE FROM observer_dirs WHERE dir_id = ?');
|
|
275
|
+
const insert = prepared('INSERT INTO observer_dirs (dir_id, root_id, rel_path, device, inode) VALUES (?, ?, ?, ?, ?)');
|
|
276
|
+
const retag = prepared('UPDATE observer_dirs SET rel_path = ?, device = ?, inode = ? WHERE dir_id = ?');
|
|
263
277
|
db.transaction(() => {
|
|
264
278
|
for (const dirId of deletes) remove.run(dirId);
|
|
265
279
|
for (const dir of moves) remove.run(dir.dirId);
|
|
@@ -269,7 +283,7 @@ function createStore(db) {
|
|
|
269
283
|
})();
|
|
270
284
|
},
|
|
271
285
|
removeDir(dirId) {
|
|
272
|
-
|
|
286
|
+
prepared('DELETE FROM observer_dirs WHERE dir_id = ?').run(dirId);
|
|
273
287
|
},
|
|
274
288
|
|
|
275
289
|
// Link channels: a pointer's whole payload is its target string, read
|
|
@@ -277,19 +291,19 @@ function createStore(db) {
|
|
|
277
291
|
// a rename only ever targets a row-free slot), so link rows commit
|
|
278
292
|
// per-row like files — every commit is a valid last-known truth.
|
|
279
293
|
linksForRoot(rootId) {
|
|
280
|
-
return
|
|
294
|
+
return prepared('SELECT * FROM observer_links WHERE root_id = ? ORDER BY rel_path').all(rootId)
|
|
281
295
|
.map(rowToLink);
|
|
282
296
|
},
|
|
283
297
|
insertLink({ linkId, rootId, relPath, device, inode, target }) {
|
|
284
|
-
|
|
298
|
+
prepared('INSERT INTO observer_links (link_id, root_id, rel_path, device, inode, target) VALUES (?, ?, ?, ?, ?, ?)')
|
|
285
299
|
.run(linkId, rootId, relPath, device, inode, target);
|
|
286
300
|
},
|
|
287
301
|
updateLink(linkId, { relPath, device, inode, target }) {
|
|
288
|
-
|
|
302
|
+
prepared('UPDATE observer_links SET rel_path = ?, device = ?, inode = ?, target = ? WHERE link_id = ?')
|
|
289
303
|
.run(relPath, device, inode, target, linkId);
|
|
290
304
|
},
|
|
291
305
|
removeLink(linkId) {
|
|
292
|
-
|
|
306
|
+
prepared('DELETE FROM observer_links WHERE link_id = ?').run(linkId);
|
|
293
307
|
},
|
|
294
308
|
};
|
|
295
309
|
}
|
|
@@ -23,13 +23,17 @@ function install(dirPath, recursive, onDirty, onDead) {
|
|
|
23
23
|
watcher = fs.watch(dirPath, { recursive, persistent: false }, (_event, filename) => {
|
|
24
24
|
onDirty(filename ? String(filename).split('\\').join('/') : null);
|
|
25
25
|
});
|
|
26
|
-
} catch {
|
|
26
|
+
} catch (error) {
|
|
27
27
|
handle.degraded = true;
|
|
28
|
+
// WHY the birth failed travels with the handle: ENOSPC (the kernel's
|
|
29
|
+
// file-watch budget) earns the caller an actionable message, not a shrug.
|
|
30
|
+
handle.reason = error?.code || 'unknown';
|
|
28
31
|
return handle;
|
|
29
32
|
}
|
|
30
|
-
watcher.on('error', () => {
|
|
33
|
+
watcher.on('error', (error) => {
|
|
31
34
|
if (handle.degraded) return; // dead is dead: one death, one report
|
|
32
35
|
handle.degraded = true;
|
|
36
|
+
handle.reason = error?.code || 'unknown';
|
|
33
37
|
try { watcher.close(); } catch { /* already dead */ }
|
|
34
38
|
if (onDead) onDead();
|
|
35
39
|
});
|
|
@@ -92,6 +92,31 @@ function assertRecord(value) {
|
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The repo travel law: a record with a `repo.git` PROPER ancestor never
|
|
97
|
+
* travels to the cloud on its own. The repository is the sync boundary —
|
|
98
|
+
* its one Card + Checkpoint state carries the worktree's bytes AND its
|
|
99
|
+
* children's permanent UUIDs (the transport identity map), so a register
|
|
100
|
+
* or a rebase is one parcel, never one postcard per file. A nested
|
|
101
|
+
* repository inside another repo's worktree is enclosed too: it becomes
|
|
102
|
+
* cloud-visible only when registered explicitly as its own tree.
|
|
103
|
+
* Missing ancestry keeps the record traveling rather than risking a
|
|
104
|
+
* silently unsynced entity.
|
|
105
|
+
*/
|
|
106
|
+
function enclosedByRepositoryAncestor(record, lookup) {
|
|
107
|
+
const seen = new Set([record.uuid]);
|
|
108
|
+
let parentUUID = record.parentUUID;
|
|
109
|
+
while (parentUUID !== null && parentUUID !== undefined) {
|
|
110
|
+
if (seen.has(parentUUID)) throw new Error(`cloud entity ${record.uuid} has a cyclic parent chain`);
|
|
111
|
+
seen.add(parentUUID);
|
|
112
|
+
const parent = lookup(parentUUID);
|
|
113
|
+
if (!parent) return false;
|
|
114
|
+
if (parent.type === 'repo.git') return true;
|
|
115
|
+
parentUUID = parent.parentUUID;
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
95
120
|
function snapshotFromRecords(records) {
|
|
96
121
|
const normalized = records.map(assertRecord).sort((left, right) => left.uuid.localeCompare(right.uuid));
|
|
97
122
|
if (new Set(normalized.map((record) => record.uuid)).size !== normalized.length) {
|
|
@@ -180,28 +205,14 @@ function createEntityCloud({
|
|
|
180
205
|
return fallback ? cloudRecord(fallback) : null;
|
|
181
206
|
};
|
|
182
207
|
|
|
183
|
-
function enclosedByRepository(record) {
|
|
184
|
-
const seen = new Set([record.uuid]);
|
|
185
|
-
let parentUUID = record.parentUUID;
|
|
186
|
-
while (parentUUID !== null) {
|
|
187
|
-
if (seen.has(parentUUID)) throw new Error(`cloud entity ${record.uuid} has a cyclic parent chain`);
|
|
188
|
-
seen.add(parentUUID);
|
|
189
|
-
const parent = lookup(parentUUID);
|
|
190
|
-
// Missing ancestry retains content rather than risking an ordinary
|
|
191
|
-
// file whose only bytes were silently skipped.
|
|
192
|
-
if (!parent) return false;
|
|
193
|
-
if (parent.type === 'repo.git') return true;
|
|
194
|
-
parentUUID = parent.parentUUID;
|
|
195
|
-
}
|
|
196
|
-
return false;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
208
|
const heads = [];
|
|
200
209
|
const seen = new Set();
|
|
201
210
|
for (const record of normalized) {
|
|
202
211
|
const contentHash = contentHead(record);
|
|
203
212
|
if (!contentHash) continue;
|
|
204
|
-
|
|
213
|
+
// Missing ancestry retains content rather than risking an ordinary
|
|
214
|
+
// file whose only bytes were silently skipped.
|
|
215
|
+
if (['file.text', 'file.binary'].includes(record.type) && enclosedByRepositoryAncestor(record, lookup)) continue;
|
|
205
216
|
if (seen.has(contentHash)) continue;
|
|
206
217
|
seen.add(contentHash);
|
|
207
218
|
heads.push({ record, contentHash });
|
|
@@ -432,6 +443,14 @@ function createEntityCloud({
|
|
|
432
443
|
// Bootstrap roots are included in the first immutable snapshot. No
|
|
433
444
|
// unscoped row is written before the cloud replica exists.
|
|
434
445
|
if (!current || current.state !== 'active') return;
|
|
446
|
+
// The repo travel law: a record enclosed by a repository never mails
|
|
447
|
+
// its own postcard. The repo's one settled state is the parcel — the
|
|
448
|
+
// transport carries the worktree's bytes and its children's UUIDs, so
|
|
449
|
+
// a 14k-file repo register (or a 300-file rebase) is ONE mutation on
|
|
450
|
+
// the repo entity, never thousands of child mutations.
|
|
451
|
+
if (enclosedByRepositoryAncestor(record, (uuid) => (typeof recordAt === 'function' ? recordAt(uuid) : null))) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
435
454
|
// The SQLite mutation is the durable delivery intent. Its post-commit
|
|
436
455
|
// content drain creates the filesystem upload cursor; doing that here
|
|
437
456
|
// would let a rolled-back identity write upload unreachable bytes.
|
|
@@ -470,7 +489,15 @@ function createEntityCloud({
|
|
|
470
489
|
if (Number(unsettled.count) !== 0) {
|
|
471
490
|
throw new Error('entity cloud registration is already uploading another change; wait for it before registering this large tree');
|
|
472
491
|
}
|
|
473
|
-
|
|
492
|
+
// The repo travel law applies to the atomic graph too: repo children
|
|
493
|
+
// stay out of the snapshot, so a workspace of large repositories
|
|
494
|
+
// publishes a graph proportional to its traveling records — the
|
|
495
|
+
// repos' bytes and child identity ride each repo's transport.
|
|
496
|
+
const byUuid = new Map(records.map((candidate) => [candidate.uuid, candidate]));
|
|
497
|
+
const traveling = records.filter((candidate) => (
|
|
498
|
+
!enclosedByRepositoryAncestor(candidate, (uuid) => byUuid.get(uuid) || null)
|
|
499
|
+
));
|
|
500
|
+
const snapshot = cloudSnapshot(traveling);
|
|
474
501
|
const operation = { kind: SNAPSHOT_REPLACE_OPERATION, snapshot };
|
|
475
502
|
const bytes = Buffer.byteLength(stableJson(operation), 'utf8');
|
|
476
503
|
if (bytes > SNAPSHOT_REPLACE_MAX_BYTES) {
|
|
@@ -671,7 +698,14 @@ function createEntityCloud({
|
|
|
671
698
|
}
|
|
672
699
|
if (!bootstrapping) {
|
|
673
700
|
const cloudByUuid = new Map(snapshot.records.map((record) => [record.uuid, record]));
|
|
674
|
-
|
|
701
|
+
const locals = registry.syncRecords();
|
|
702
|
+
const localByUuid = new Map(locals.map((record) => [record.uuid, record]));
|
|
703
|
+
for (const local of locals) {
|
|
704
|
+
// Repo-enclosed local records evolve locally (Detect corrects
|
|
705
|
+
// their types and payloads) without journaling; a snapshot from
|
|
706
|
+
// before the repo travel law may still carry their stale rows.
|
|
707
|
+
// They are not cloud truth, so they cannot veto an install.
|
|
708
|
+
if (enclosedByRepositoryAncestor(local, (uuid) => localByUuid.get(uuid) || null)) continue;
|
|
675
709
|
const remote = cloudByUuid.get(local.uuid);
|
|
676
710
|
if (remote && !sameRecord(cloudRecord(local), remote)) {
|
|
677
711
|
throw new Error('cloud entity graph conflicts with locally bound ground; resolve the local mutation before importing a new cloud head');
|
|
@@ -1196,6 +1230,7 @@ module.exports = {
|
|
|
1196
1230
|
ENTITY_CLOUD_CONTRACT,
|
|
1197
1231
|
ENTITY_CLOUD_SCHEMA_VERSION,
|
|
1198
1232
|
createEntityCloud,
|
|
1233
|
+
enclosedByRepositoryAncestor,
|
|
1199
1234
|
parseSnapshot,
|
|
1200
1235
|
privateEntityResourceId,
|
|
1201
1236
|
snapshotFromRecords,
|
|
@@ -359,6 +359,43 @@ function createRegistration(options) {
|
|
|
359
359
|
* genuinely live. A filesystem watcher that could not open is not a
|
|
360
360
|
* warning; it means the requested Register → Watch operation is incomplete
|
|
361
361
|
* and the caller must hear that. */
|
|
362
|
+
/**
|
|
363
|
+
* Watcher FIRST: prove the doorbell can be born before any identity
|
|
364
|
+
* commits. The birth is held (observer.watchNow) — the watcher exists
|
|
365
|
+
* and is judged, but concludes nothing until start() releases it. One
|
|
366
|
+
* retry absorbs a transient failure; a genuine refusal names its reason,
|
|
367
|
+
* because ENOSPC is a kernel watch budget the caller can actually fix.
|
|
368
|
+
*/
|
|
369
|
+
function requireWatchBirth(root) {
|
|
370
|
+
const unhealthy = (row) => !row.watching || row.degraded
|
|
371
|
+
|| (row.addressRequired && (!row.addressWatching || row.addressDegraded));
|
|
372
|
+
let health = observer.watchNow(root.rootId);
|
|
373
|
+
if (unhealthy(health)) health = observer.watchNow(root.rootId);
|
|
374
|
+
if (!unhealthy(health)) return;
|
|
375
|
+
const reason = health.degradedReason ? ` (${health.degradedReason})` : '';
|
|
376
|
+
const remedy = health.degradedReason === 'ENOSPC'
|
|
377
|
+
? ' — the OS file-watch budget is exhausted; raise fs.inotify.max_user_watches or unregister unused ground, then retry'
|
|
378
|
+
: '';
|
|
379
|
+
throw refusal(`refusing to register ${root.path}: its watcher could not be born${reason}${remedy}; nothing was registered`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The commit's last word, spoken INSIDE the identity transaction: the
|
|
384
|
+
* watchers proven before the walk are still live as the rows land. A
|
|
385
|
+
* watcher that died mid-walk rolls the whole registration — records and
|
|
386
|
+
* cloud journal together — back to nothing. This is an environment
|
|
387
|
+
* fault, never the caller's path.
|
|
388
|
+
*/
|
|
389
|
+
function assertWatchedThroughCommit(rootPath) {
|
|
390
|
+
const dead = observer.status().filter((row) => (row.path === rootPath
|
|
391
|
+
|| row.path.startsWith(`${rootPath}/`))
|
|
392
|
+
&& (!row.watching || row.degraded));
|
|
393
|
+
if (dead.length > 0) {
|
|
394
|
+
throw new Error(`watcher(s) died during registration of ${rootPath}: `
|
|
395
|
+
+ `${dead.map((row) => row.path).join(', ')} — the registration was rolled back`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
362
399
|
function requireLiveWatchers(rootPath) {
|
|
363
400
|
const belongs = (root) => root.path === rootPath || root.path.startsWith(`${rootPath}/`);
|
|
364
401
|
let unavailable = observer.status().filter((root) => belongs(root)
|
|
@@ -525,24 +562,33 @@ function createRegistration(options) {
|
|
|
525
562
|
}
|
|
526
563
|
const root = observer.enrollRoot(ground, { scan: false, watch: false });
|
|
527
564
|
try {
|
|
565
|
+
// WATCHER FIRST. Registration is synchronous, never optimistic: the
|
|
566
|
+
// doorbell is born and proven (held — concluding nothing) before one
|
|
567
|
+
// identity row exists, and the commit's own last word re-checks it.
|
|
568
|
+
// A record and its cloud journal land together inside registerTree's
|
|
569
|
+
// transaction, so the outcome is registered-and-watched, or nothing.
|
|
570
|
+
requireWatchBirth(root);
|
|
528
571
|
assertWitnessed({ observer, registry, memory });
|
|
529
|
-
const register = () =>
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
572
|
+
const register = () => registry.transaction(() => {
|
|
573
|
+
registerTree({
|
|
574
|
+
root,
|
|
575
|
+
registry,
|
|
576
|
+
memory,
|
|
577
|
+
shouldEnroll: observer.shouldEnroll,
|
|
578
|
+
captureRepo,
|
|
579
|
+
repoStates,
|
|
580
|
+
repoStateAt,
|
|
581
|
+
identityAt,
|
|
582
|
+
});
|
|
583
|
+
assertWatchedThroughCommit(root.path);
|
|
538
584
|
});
|
|
539
585
|
if (suppressJournal) registry.withoutJournal(register);
|
|
540
586
|
else register();
|
|
541
587
|
} catch (error) {
|
|
542
588
|
// The observer root is only an address until structural registration
|
|
543
|
-
// commits
|
|
544
|
-
//
|
|
545
|
-
//
|
|
589
|
+
// commits — and its held watchers are only handles. A refused or
|
|
590
|
+
// failed walk leaves NOTHING behind: removeRoot closes the doorbells
|
|
591
|
+
// and the transaction above rolled every row and journal entry back.
|
|
546
592
|
observer.removeRoot(root.rootId);
|
|
547
593
|
throw error;
|
|
548
594
|
}
|
|
@@ -35,6 +35,24 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
|
|
|
35
35
|
const followers = new Map(); // observer rootId -> { path, uuid, follower }
|
|
36
36
|
let stopped = false;
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A scoped capture reuses an unsuspected untracked entry WITHOUT its
|
|
40
|
+
* bytes. The persisted state must stay whole — transport cargo packs
|
|
41
|
+
* untracked content straight from it — so the bytes are carried forward
|
|
42
|
+
* from the previous settled state: a memory copy, never a disk read.
|
|
43
|
+
* The transient `reused` flag never persists.
|
|
44
|
+
*/
|
|
45
|
+
function hydrateReusedUntracked(next, previous) {
|
|
46
|
+
const entries = next?.checkpoint?.untracked;
|
|
47
|
+
if (!entries?.some((file) => file.reused)) return;
|
|
48
|
+
const prior = new Map((previous?.checkpoint?.untracked ?? []).map((file) => [file.path, file]));
|
|
49
|
+
next.checkpoint.untracked = entries.map((file) => {
|
|
50
|
+
if (!file.reused) return file;
|
|
51
|
+
const { reused, ...kept } = file;
|
|
52
|
+
return { ...kept, content: prior.get(file.path)?.content ?? null };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
function close(rootId) {
|
|
39
57
|
const entry = followers.get(rootId);
|
|
40
58
|
if (!entry) return;
|
|
@@ -74,14 +92,32 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
|
|
|
74
92
|
// The service can turn a full capture into immutable cloud cargo
|
|
75
93
|
// before the one stateId attestation commits. Local-only callers
|
|
76
94
|
// retain the lightweight bundle-free capture used historically.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
95
|
+
// The repo UUID rides along so the transport packs the current
|
|
96
|
+
// worktree identity map from the registry.
|
|
97
|
+
captureFn: async (repoPath, { suspects } = {}) => {
|
|
98
|
+
const withBundle = typeof prepareRepo === 'function';
|
|
99
|
+
// The warm checkpoint: the last settled state's untracked entries
|
|
100
|
+
// ((path, mode, sha256) — content is never persisted) let a scoped
|
|
101
|
+
// capture reuse every unsuspected file without opening it. Cloud
|
|
102
|
+
// cargo captures (withBundle) need content and read everything;
|
|
103
|
+
// capture() enforces that side of the contract itself.
|
|
104
|
+
const previous = withBundle ? null : repoStates.get(record.uuid);
|
|
105
|
+
const captured = await worker.capture(repoPath, {
|
|
106
|
+
withBundle,
|
|
107
|
+
suspects,
|
|
108
|
+
previousUntracked: previous?.checkpoint?.untracked?.map(
|
|
109
|
+
({ path: rel, mode, sha256 }) => ({ path: rel, mode, sha256 }),
|
|
110
|
+
) ?? null,
|
|
111
|
+
});
|
|
112
|
+
return withBundle && !captured?.busy
|
|
113
|
+
? prepareRepo(captured, { entityUuid: record.uuid })
|
|
114
|
+
: captured;
|
|
80
115
|
},
|
|
81
116
|
emit: (next) => {
|
|
82
117
|
// ONE settled Card + Checkpoint state is ONE registry transition on
|
|
83
118
|
// the repo entity. Raw filesystem events never write Git state.
|
|
84
119
|
const previous = repoStates.get(record.uuid);
|
|
120
|
+
hydrateReusedUntracked(next, previous);
|
|
85
121
|
registry.transaction(() => {
|
|
86
122
|
repoStates.put(record.uuid, next);
|
|
87
123
|
registry.attest(record.uuid, { type: 'repo.git', payloadVersion: next.stateId });
|
|
@@ -115,7 +151,10 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
|
|
|
115
151
|
sync();
|
|
116
152
|
return;
|
|
117
153
|
}
|
|
118
|
-
|
|
154
|
+
// The ding's hint (the named path, rebased to the repo's ground, or
|
|
155
|
+
// null for "anywhere") rides into the follower's scope: it is what
|
|
156
|
+
// lets the next capture patch one file instead of re-reading the world.
|
|
157
|
+
if (event.type === 'repo.changed') followers.get(event.rootId)?.follower.ring(event.hint ?? null);
|
|
119
158
|
}
|
|
120
159
|
|
|
121
160
|
function stop() {
|
|
@@ -28,13 +28,18 @@ function decodeBytes(encoded) {
|
|
|
28
28
|
/** The durable local state deliberately omits a Git bundle. Bundles are
|
|
29
29
|
* transport cargo: keeping them in identity.db would turn a small registry
|
|
30
30
|
* into a second, unbounded object store. The immutable content store owns
|
|
31
|
-
* the bundle bytes; SQLite retains only the content head that names them.
|
|
32
|
-
|
|
31
|
+
* the bundle bytes; SQLite retains only the content head that names them.
|
|
32
|
+
* The identity map is transport cargo too: it names the repo's enrolled
|
|
33
|
+
* worktree entities so a receiving machine binds the SAME child UUIDs
|
|
34
|
+
* instead of minting competitors — derived from the registry whenever a
|
|
35
|
+
* transport is packed, never persisted beside the local state. */
|
|
36
|
+
function pack(state, { includeBundle = true, transportVersion = state.transportVersion ?? null, identity = null } = {}) {
|
|
33
37
|
return JSON.stringify({
|
|
34
38
|
stateId: state.stateId,
|
|
35
39
|
cardId: state.cardId,
|
|
36
40
|
checkpointId: state.checkpointId,
|
|
37
41
|
transportVersion,
|
|
42
|
+
...(identity ? { identity } : {}),
|
|
38
43
|
card: {
|
|
39
44
|
...state.card,
|
|
40
45
|
bundle: includeBundle && state.card.bundle ? {
|
|
@@ -59,6 +64,7 @@ function unpack(text) {
|
|
|
59
64
|
return {
|
|
60
65
|
...state,
|
|
61
66
|
transportVersion: typeof state.transportVersion === 'string' ? state.transportVersion : null,
|
|
67
|
+
identity: Array.isArray(state.identity) ? state.identity : null,
|
|
62
68
|
card: {
|
|
63
69
|
...state.card,
|
|
64
70
|
bundle: state.card.bundle && {
|
|
@@ -81,15 +87,19 @@ function unpack(text) {
|
|
|
81
87
|
/** A Repocard transport document contains its bundle and checkpoint bytes,
|
|
82
88
|
* but never names itself: its SHA-256 is the immutable cloud content head.
|
|
83
89
|
* The semantic `stateId` remains on the repo entity, so bundle packing can
|
|
84
|
-
* vary without changing repository identity.
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
* vary without changing repository identity. `identity` is the enrolled
|
|
91
|
+
* worktree's path → { uuid, type } entries: repo children never travel as
|
|
92
|
+
* individual cloud records, so their permanent UUIDs ride here — inside
|
|
93
|
+
* the one state parcel — and `amalgm add` binds them from this map. */
|
|
94
|
+
function transportBytes(state, identity = null) {
|
|
95
|
+
return Buffer.from(pack(state, { includeBundle: true, transportVersion: null, identity }), 'utf8');
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
function localState(state, transportVersion) {
|
|
90
99
|
return {
|
|
91
100
|
...state,
|
|
92
101
|
transportVersion,
|
|
102
|
+
identity: null,
|
|
93
103
|
card: { ...state.card, bundle: null },
|
|
94
104
|
};
|
|
95
105
|
}
|