amalgm 0.1.264 → 0.1.266
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/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/observer/README.md +4 -1
- package/runtime/scripts/amalgm-mcp/observer/index.js +30 -5
- package/runtime/scripts/amalgm-mcp/registration/index.js +280 -24
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +32 -7
- package/runtime/scripts/amalgm-mcp/registration/service.js +33 -5
- package/runtime/scripts/amalgm-mcp/registration/tree.js +71 -3
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +14 -1
- package/runtime/scripts/amalgm-mcp/registry/index.js +7 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +23 -1
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +46 -1
package/package.json
CHANGED
|
@@ -94,7 +94,10 @@ never merges, never decides truth — it emits facts for the layers above.
|
|
|
94
94
|
(catch-up for the window we were dead), watcher death (rebuild the
|
|
95
95
|
doorbell, reconcile the gap once), and whenever a consumer asks — a sync
|
|
96
96
|
session opening, a document opening, an app regaining focus are events
|
|
97
|
-
too, and they belong to the layer above.
|
|
97
|
+
too, and they belong to the layer above. A freshly registered repo is the
|
|
98
|
+
one adapter-defined birth exception: its Card + Checkpoint is already the
|
|
99
|
+
complete first observation, so it arms Watch without scanning every child
|
|
100
|
+
again. Restart still performs the ordinary conservative catch-up scan.
|
|
98
101
|
4. **Echo suppression is content, not bookkeeping.** A writer records its own
|
|
99
102
|
write (`noteWrite`); a scan that finds the recorded hash finds nothing.
|
|
100
103
|
5. **Identity has two axes, never mixed — and KIND decides which axis
|
|
@@ -1229,6 +1229,22 @@ function createObserver(options) {
|
|
|
1229
1229
|
}
|
|
1230
1230
|
scanFilesRoot(root, { allowRootRepository: true });
|
|
1231
1231
|
},
|
|
1232
|
+
/**
|
|
1233
|
+
* Structural registration already knows its nested Git boundaries from
|
|
1234
|
+
* the same state parcel that names them. Refresh only that in-memory
|
|
1235
|
+
* forwarding map after it installs those observer roots; this is not a
|
|
1236
|
+
* filesystem census and it neither reads nor records worktree files.
|
|
1237
|
+
*/
|
|
1238
|
+
refreshRepoTerritories(rootId) {
|
|
1239
|
+
const root = store.listRoots().find((row) => row.rootId === rootId);
|
|
1240
|
+
if (!root || root.kind !== 'repo') {
|
|
1241
|
+
throw new Error(`refreshRepoTerritories requires a live repo root: ${rootId}`);
|
|
1242
|
+
}
|
|
1243
|
+
const state = live.get(root.rootId);
|
|
1244
|
+
if (!state) return;
|
|
1245
|
+
state.territories = new Set(childRepoRoots(root)
|
|
1246
|
+
.map((child) => child.path.slice(root.path.length + 1)));
|
|
1247
|
+
},
|
|
1232
1248
|
scanNow,
|
|
1233
1249
|
|
|
1234
1250
|
// The referee (verify.js): re-hash every enrolled byte, report every
|
|
@@ -1313,7 +1329,7 @@ function createObserver(options) {
|
|
|
1313
1329
|
return statusOf(root);
|
|
1314
1330
|
},
|
|
1315
1331
|
|
|
1316
|
-
start(rootId = null) {
|
|
1332
|
+
start(rootId = null, { census = true } = {}) {
|
|
1317
1333
|
const firstStart = !started;
|
|
1318
1334
|
if (firstStart) started = true;
|
|
1319
1335
|
if (!firstStart && rootId === null) return;
|
|
@@ -1328,10 +1344,19 @@ function createObserver(options) {
|
|
|
1328
1344
|
const state = attach(root, { watch: true });
|
|
1329
1345
|
state.held = false;
|
|
1330
1346
|
}
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1347
|
+
// A normal root needs its first observed census before its structural
|
|
1348
|
+
// registration can become evidence. A freshly registered Git root is
|
|
1349
|
+
// different: its already-committed Card + Checkpoint is that complete
|
|
1350
|
+
// initial observation. Re-walking its worktree here would duplicate
|
|
1351
|
+
// the adapter's state (and can block the runtime for a large repo).
|
|
1352
|
+
// `census: false` is therefore only the repo adapter's birth path;
|
|
1353
|
+
// later recoveries and runtime start retain the conservative census.
|
|
1354
|
+
if (census) {
|
|
1355
|
+
try {
|
|
1356
|
+
for (const root of roots) scanNow(root.rootId);
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
console.warn('[Observer] initial scan failed:', error?.message || error);
|
|
1359
|
+
}
|
|
1335
1360
|
}
|
|
1336
1361
|
},
|
|
1337
1362
|
|
|
@@ -67,7 +67,7 @@ const { capture } = require('../repocard');
|
|
|
67
67
|
const { refusal } = require('./refusal');
|
|
68
68
|
const { createRepoFollowers } = require('./repo-followers');
|
|
69
69
|
const { createRepoStates } = require('./repo-states');
|
|
70
|
-
const { registerTree } = require('./tree');
|
|
70
|
+
const { collectWorktreeIdentityAsync, registerTree, transportIdentity } = require('./tree');
|
|
71
71
|
const { gitDirTruth } = require('../observer/scan');
|
|
72
72
|
|
|
73
73
|
// Events that can change what the recorded edges reach: a new or
|
|
@@ -88,6 +88,10 @@ function createRegistration(options) {
|
|
|
88
88
|
|
|
89
89
|
const opened = openIdentity();
|
|
90
90
|
const captureRepo = opened.captureRepo || ((repoPath) => capture(repoPath, { withBundle: false }));
|
|
91
|
+
const captureRepoAsync = opened.captureRepoAsync ?? null;
|
|
92
|
+
if (captureRepoAsync !== null && typeof captureRepoAsync !== 'function') {
|
|
93
|
+
throw new Error('openIdentity.captureRepoAsync must be a function when supplied');
|
|
94
|
+
}
|
|
91
95
|
const repoStateAt = opened.repoStateAt ?? null;
|
|
92
96
|
if (repoStateAt !== null && typeof repoStateAt !== 'function') {
|
|
93
97
|
throw new Error('openIdentity.repoStateAt must be a function when supplied');
|
|
@@ -111,6 +115,7 @@ function createRegistration(options) {
|
|
|
111
115
|
registry,
|
|
112
116
|
repoStates,
|
|
113
117
|
prepareRepo: opened.prepareRepo ?? null,
|
|
118
|
+
captureRepoAsync,
|
|
114
119
|
recordForRoot: repoRecordForRoot,
|
|
115
120
|
onSettled: reconcileSettledRepo,
|
|
116
121
|
});
|
|
@@ -167,13 +172,14 @@ function createRegistration(options) {
|
|
|
167
172
|
for (const { scopeId } of memory.allBindings()) {
|
|
168
173
|
if (!liveIds.has(scopeId)) memory.dropScope(scopeId);
|
|
169
174
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
175
|
+
// Retirement is decided from durable witness bindings, not from the
|
|
176
|
+
// in-memory lens map. A fresh runtime has no lenses yet, but it may have
|
|
177
|
+
// restarted halfway through retiring an adopted tree; letting that old
|
|
178
|
+
// observer root survive would make its next start look like a second
|
|
179
|
+
// tree. The binding is the evidence that survives the crash, so it is
|
|
180
|
+
// the only honest starting point.
|
|
181
|
+
for (const row of observer.listRoots()) {
|
|
182
|
+
const record = rootRecordOf(row.rootId);
|
|
177
183
|
const nestedRepoScope = row.kind === 'repo' && record?.type === 'repo.git'
|
|
178
184
|
&& observer.listRoots().some((other) => other.rootId !== row.rootId
|
|
179
185
|
&& row.path.startsWith(`${other.path}/`));
|
|
@@ -185,15 +191,18 @@ function createRegistration(options) {
|
|
|
185
191
|
// sweep above collects. The reverse order once minted a duplicate
|
|
186
192
|
// tree: an unbound living root looks like fresh ground.
|
|
187
193
|
const family = observer.listRoots()
|
|
188
|
-
.filter((member) => member.rootId === rootId || member.path.startsWith(`${row.path}/`));
|
|
194
|
+
.filter((member) => member.rootId === row.rootId || member.path.startsWith(`${row.path}/`));
|
|
189
195
|
for (const member of family) {
|
|
190
|
-
if (member.rootId !== rootId) observer.removeRoot(member.rootId);
|
|
196
|
+
if (member.rootId !== row.rootId) observer.removeRoot(member.rootId);
|
|
191
197
|
}
|
|
192
|
-
observer.removeRoot(rootId);
|
|
193
|
-
memory.dropScope(rootId);
|
|
194
|
-
lenses.delete(rootId);
|
|
198
|
+
observer.removeRoot(row.rootId);
|
|
199
|
+
memory.dropScope(row.rootId);
|
|
200
|
+
lenses.delete(row.rootId);
|
|
195
201
|
}
|
|
196
202
|
}
|
|
203
|
+
for (const [rootId] of [...lenses]) {
|
|
204
|
+
if (!observer.listRoots().some((row) => row.rootId === rootId)) lenses.delete(rootId);
|
|
205
|
+
}
|
|
197
206
|
for (const root of treeRoots()) {
|
|
198
207
|
if (lenses.has(root.rootId)) continue;
|
|
199
208
|
const lens = createEvidence({ observer, registry, memory, rootId: root.rootId, classify });
|
|
@@ -438,8 +447,14 @@ function createRegistration(options) {
|
|
|
438
447
|
* same territory), so the only missing fact is the repo's atomic
|
|
439
448
|
* Card+Checkpoint-backed identity birth.
|
|
440
449
|
*/
|
|
441
|
-
function registerRepositoryInsideCoveredGround(ground, {
|
|
450
|
+
function registerRepositoryInsideCoveredGround(ground, {
|
|
451
|
+
identityAt = null,
|
|
452
|
+
suppressJournal = false,
|
|
453
|
+
preparedRepoAt = null,
|
|
454
|
+
watcherAlreadyHeld = false,
|
|
455
|
+
} = {}) {
|
|
442
456
|
let root = observer.listRoots().find((candidate) => candidate.path === ground) || null;
|
|
457
|
+
const rootAlreadyExisted = root !== null;
|
|
443
458
|
if (!root) root = observer.enrollRoot(ground, { scan: false, watch: false });
|
|
444
459
|
if (root.kind !== 'repo') {
|
|
445
460
|
throw new Error(`expected Git repository ground at ${ground}, found ${root.kind}`);
|
|
@@ -462,8 +477,12 @@ function createRegistration(options) {
|
|
|
462
477
|
return { rootPath: ground, record };
|
|
463
478
|
}
|
|
464
479
|
|
|
480
|
+
let watcherHeld = watcherAlreadyHeld;
|
|
465
481
|
try {
|
|
466
|
-
|
|
482
|
+
if (!watcherHeld) {
|
|
483
|
+
requireWatchBirth(root);
|
|
484
|
+
watcherHeld = true;
|
|
485
|
+
}
|
|
467
486
|
assertWitnessed({ observer, registry, memory });
|
|
468
487
|
const register = () => registry.transaction(() => {
|
|
469
488
|
registerTree({
|
|
@@ -475,6 +494,7 @@ function createRegistration(options) {
|
|
|
475
494
|
repoStates,
|
|
476
495
|
repoStateAt,
|
|
477
496
|
identityAt,
|
|
497
|
+
preparedRepoAt,
|
|
478
498
|
});
|
|
479
499
|
assertWatchedThroughCommit(root.path);
|
|
480
500
|
});
|
|
@@ -483,23 +503,225 @@ function createRegistration(options) {
|
|
|
483
503
|
} catch (error) {
|
|
484
504
|
// A root that existed only because the outer watcher discovered its
|
|
485
505
|
// territory must remain coverage after a refused explicit admission;
|
|
486
|
-
// do not remove it (the parent still owns the OS watcher).
|
|
487
|
-
//
|
|
506
|
+
// do not remove it (the parent still owns the OS watcher). Release a
|
|
507
|
+
// held doorbell before returning the refusal; otherwise a failed
|
|
508
|
+
// registration would silently turn a healthy observer territory into
|
|
509
|
+
// a permanently mute one. A root this call itself enrolled has no
|
|
510
|
+
// durable identity and can safely retire in the outer caller.
|
|
511
|
+
if (watcherHeld && rootAlreadyExisted) observer.start(root.rootId);
|
|
488
512
|
throw error;
|
|
489
513
|
}
|
|
490
514
|
|
|
491
|
-
const startWatch = () =>
|
|
492
|
-
observer.start(root.rootId);
|
|
493
|
-
ensureTrees();
|
|
494
|
-
reconcileInitialRepoWorktrees(root.path);
|
|
495
|
-
flushIfDirty();
|
|
496
|
-
requireLiveWatchers(root.path);
|
|
497
|
-
};
|
|
515
|
+
const startWatch = () => startRepositoryFromBaseline(root);
|
|
498
516
|
if (suppressJournal) registry.withoutJournal(startWatch);
|
|
499
517
|
else startWatch();
|
|
500
518
|
return { rootPath: root.path, record: rootRecordOf(root.rootId) };
|
|
501
519
|
}
|
|
502
520
|
|
|
521
|
+
/**
|
|
522
|
+
* A repo's initial Card + Checkpoint is not preliminary data awaiting a
|
|
523
|
+
* generic filesystem census: it *is* the first complete state. Start its
|
|
524
|
+
* follower while the watcher is held, then release the watcher without
|
|
525
|
+
* scanning every worktree child a second time. The next raw ding reaches
|
|
526
|
+
* the follower and exactly one settled state authorizes the ordinary child
|
|
527
|
+
* reconciliation. This keeps Register synchronous without duplicating
|
|
528
|
+
* either its cargo capture or its initial Detect pass.
|
|
529
|
+
*/
|
|
530
|
+
function startRepositoryFromBaseline(root) {
|
|
531
|
+
// Structural registration already knows every nested repo boundary from
|
|
532
|
+
// the same assignment that packed its state. Install those territories
|
|
533
|
+
// directly from that identity graph; discovering them again by walking
|
|
534
|
+
// files would reintroduce the duplicate initial census this adapter
|
|
535
|
+
// exists to avoid.
|
|
536
|
+
const territories = [];
|
|
537
|
+
for (const record of registry.activeByType('repo.git')) {
|
|
538
|
+
const repoPath = addressOf(record.uuid);
|
|
539
|
+
if (repoPath === null || (repoPath !== root.path && !repoPath.startsWith(`${root.path}/`))) continue;
|
|
540
|
+
let territory = observer.listRoots().find((candidate) => candidate.path === repoPath) ?? null;
|
|
541
|
+
if (!territory) territory = observer.enrollRoot(repoPath, { scan: false, watch: false });
|
|
542
|
+
if (territory.kind !== 'repo') {
|
|
543
|
+
throw new Error(`registered repository ${repoPath} is no longer Git ground before Watch began`);
|
|
544
|
+
}
|
|
545
|
+
memory.bind(territory.rootId, territory.rootId, record.uuid);
|
|
546
|
+
if (!lenses.has(territory.rootId)) {
|
|
547
|
+
// A repo baseline is already authoritative state. The lens exists so
|
|
548
|
+
// callers can address the tree, but does not reconcile absent
|
|
549
|
+
// observer child rows as if that state had never been captured.
|
|
550
|
+
lenses.set(territory.rootId, createEvidence({
|
|
551
|
+
observer, registry, memory, rootId: territory.rootId, classify,
|
|
552
|
+
}));
|
|
553
|
+
}
|
|
554
|
+
territories.push(territory);
|
|
555
|
+
}
|
|
556
|
+
territories.sort((left, right) => left.path.length - right.path.length);
|
|
557
|
+
// The outer raw watcher must forward a nested repo's first later ding
|
|
558
|
+
// to that nested follower. Its territory set is normally filled by a
|
|
559
|
+
// census; structural registration already supplied the same boundaries,
|
|
560
|
+
// so refresh the forwarding map without performing that census.
|
|
561
|
+
for (const territory of territories) observer.refreshRepoTerritories(territory.rootId);
|
|
562
|
+
repoFollowers.sync({ startOnBirth: false });
|
|
563
|
+
for (const territory of territories) observer.start(territory.rootId, { census: false });
|
|
564
|
+
requireLiveWatchers(root.path);
|
|
565
|
+
|
|
566
|
+
// Edge discovery is a distinct registration promise. It only reads link
|
|
567
|
+
// surfaces (repo roots answer that with a link census), never converts
|
|
568
|
+
// the just-packed worktree into generic per-file evidence. The newly
|
|
569
|
+
// reached roots then receive their usual identity/lens setup.
|
|
570
|
+
edgesDirty = true;
|
|
571
|
+
tryFollow();
|
|
572
|
+
ensureTrees();
|
|
573
|
+
repoFollowers.sync({ startOnBirth: false });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Plan all repository cargo under one selected root before its identity
|
|
578
|
+
* transaction begins. This work can be slow (a Card contains a Git bundle
|
|
579
|
+
* and its Checkpoint may contain real work), but it creates no registry
|
|
580
|
+
* fact. Its watcher is already held by the caller, so every ding remains
|
|
581
|
+
* available to Start's catch-up once the plan commits.
|
|
582
|
+
*
|
|
583
|
+
* Every nested repo has its own identity map and own state parcel. The
|
|
584
|
+
* outer map names the nested repo boundary; the nested parcel names its
|
|
585
|
+
* children. That is the same travel law used later by Send, merely used
|
|
586
|
+
* here for the first state.
|
|
587
|
+
*/
|
|
588
|
+
async function prepareRepositoryPlans(root, identityAt) {
|
|
589
|
+
if (typeof captureRepoAsync !== 'function') return null;
|
|
590
|
+
const plans = new Map(); // absolute repo path -> { state, assignment }
|
|
591
|
+
|
|
592
|
+
const assertState = (repoPath, state) => {
|
|
593
|
+
if (state?.busy) {
|
|
594
|
+
throw new Error(`cannot register ${repoPath}: Git is unsettled (${state.busy})`);
|
|
595
|
+
}
|
|
596
|
+
if (!state?.stateId || !state.cardId || !state.checkpointId) {
|
|
597
|
+
throw new Error(`cannot register ${repoPath}: Git did not produce a Card + Checkpoint state`);
|
|
598
|
+
}
|
|
599
|
+
return state;
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
const planRepo = async (repoPath, rootRelativePath) => {
|
|
603
|
+
const assignment = await collectWorktreeIdentityAsync(repoPath, {
|
|
604
|
+
shouldEnroll: observer.shouldEnroll,
|
|
605
|
+
identityFor: (repoRelativePath) => identityAt?.(
|
|
606
|
+
rootRelativePath ? `${rootRelativePath}/${repoRelativePath}` : repoRelativePath,
|
|
607
|
+
)?.uuid || null,
|
|
608
|
+
});
|
|
609
|
+
const state = assertState(repoPath, await captureRepoAsync(repoPath, {
|
|
610
|
+
identity: transportIdentity(assignment),
|
|
611
|
+
}));
|
|
612
|
+
plans.set(repoPath, { state, assignment });
|
|
613
|
+
for (const [entryRelativePath, entry] of assignment) {
|
|
614
|
+
if (entry.type !== 'repo.git') continue;
|
|
615
|
+
await planRepo(
|
|
616
|
+
path.join(repoPath, entryRelativePath),
|
|
617
|
+
rootRelativePath ? `${rootRelativePath}/${entryRelativePath}` : entryRelativePath,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
// A direct repo is its own initial state. An ordinary folder can still
|
|
623
|
+
// contain repo boundaries, and those must never fall back to a blocking
|
|
624
|
+
// main-thread capture just because the user selected their parent.
|
|
625
|
+
if (root.kind === 'repo') {
|
|
626
|
+
await planRepo(root.path, '');
|
|
627
|
+
return (repoPath) => plans.get(repoPath) || null;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
let visited = 0;
|
|
631
|
+
const yieldIfNeeded = async () => {
|
|
632
|
+
visited += 1;
|
|
633
|
+
if (visited % 256 !== 0) return;
|
|
634
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
635
|
+
};
|
|
636
|
+
const discover = async (dirPath, relativePath) => {
|
|
637
|
+
let entries;
|
|
638
|
+
try {
|
|
639
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
640
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
641
|
+
} catch (error) {
|
|
642
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') return;
|
|
643
|
+
throw error;
|
|
644
|
+
}
|
|
645
|
+
for (const entry of entries) {
|
|
646
|
+
await yieldIfNeeded();
|
|
647
|
+
const entryPath = path.join(dirPath, entry.name);
|
|
648
|
+
const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
649
|
+
if (!observer.shouldEnroll(entryPath)) continue;
|
|
650
|
+
let stat;
|
|
651
|
+
try {
|
|
652
|
+
stat = fs.lstatSync(entryPath);
|
|
653
|
+
} catch {
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (!stat.isDirectory()) continue;
|
|
657
|
+
if (gitDirTruth(entryPath) === 'present') await planRepo(entryPath, entryRelativePath);
|
|
658
|
+
else await discover(entryPath, entryRelativePath);
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
await discover(root.path, '');
|
|
662
|
+
return (repoPath) => plans.get(repoPath) || null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* The public operation remains synchronous in its result: it returns only
|
|
667
|
+
* after the watcher, complete identity tree, state parcel, and journal all
|
|
668
|
+
* agree. Its preparation is asynchronous solely so large immutable cargo
|
|
669
|
+
* cannot freeze the HTTP runtime that owns the watcher proof.
|
|
670
|
+
*/
|
|
671
|
+
async function addAsync(targetPath, options = {}) {
|
|
672
|
+
const identityAt = options.identityAt ?? null;
|
|
673
|
+
const suppressJournal = options.suppressJournal === true;
|
|
674
|
+
if (identityAt !== null && typeof identityAt !== 'function') {
|
|
675
|
+
throw new Error('addAsync identityAt must be a function when supplied');
|
|
676
|
+
}
|
|
677
|
+
if (typeof captureRepoAsync !== 'function') return add(targetPath, options);
|
|
678
|
+
|
|
679
|
+
let address;
|
|
680
|
+
let ground;
|
|
681
|
+
try {
|
|
682
|
+
const parent = fs.realpathSync(path.dirname(targetPath));
|
|
683
|
+
address = path.join(parent, path.basename(targetPath));
|
|
684
|
+
ground = fs.lstatSync(address).isSymbolicLink() ? address : fs.realpathSync(address);
|
|
685
|
+
} catch (error) {
|
|
686
|
+
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
|
|
687
|
+
throw refusal(`refusing to add ${targetPath}: there is no ground at that address`);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Links intentionally retain link semantics even if their target is a
|
|
691
|
+
// repo. That is the same address law as `add`, not a hidden realpath.
|
|
692
|
+
const rootKind = fs.lstatSync(ground).isDirectory() && gitDirTruth(ground) === 'present'
|
|
693
|
+
? 'repo'
|
|
694
|
+
: null;
|
|
695
|
+
if (rootKind !== 'repo') return add(targetPath, options);
|
|
696
|
+
|
|
697
|
+
let root = observer.listRoots().find((candidate) => candidate.path === ground) || null;
|
|
698
|
+
const rootAlreadyExisted = root !== null;
|
|
699
|
+
if (!root) root = observer.enrollRoot(ground, { scan: false, watch: false });
|
|
700
|
+
if (root.kind !== 'repo') return add(targetPath, options);
|
|
701
|
+
const bound = memory.boundTo(root.rootId);
|
|
702
|
+
if (bound !== undefined) return add(targetPath, options);
|
|
703
|
+
|
|
704
|
+
let watcherHeld = false;
|
|
705
|
+
try {
|
|
706
|
+
requireWatchBirth(root);
|
|
707
|
+
watcherHeld = true;
|
|
708
|
+
const preparedRepoAt = await prepareRepositoryPlans(root, identityAt);
|
|
709
|
+
return registerRepositoryInsideCoveredGround(ground, {
|
|
710
|
+
identityAt,
|
|
711
|
+
suppressJournal,
|
|
712
|
+
preparedRepoAt,
|
|
713
|
+
watcherAlreadyHeld: true,
|
|
714
|
+
});
|
|
715
|
+
} catch (error) {
|
|
716
|
+
// No identity was committed before preparation. A new root is only
|
|
717
|
+
// this failed request's temporary witness; an existing discovered root
|
|
718
|
+
// stays covered, but its held doorbell must be released.
|
|
719
|
+
if (!rootAlreadyExisted) observer.removeRoot(root.rootId);
|
|
720
|
+
else if (watcherHeld) observer.start(root.rootId);
|
|
721
|
+
throw error;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
503
725
|
/** Real ground -> the UUID of the entity that owns it, across trees. */
|
|
504
726
|
function resolveGround(real) {
|
|
505
727
|
const home = treeRootCovering(real);
|
|
@@ -707,6 +929,7 @@ function createRegistration(options) {
|
|
|
707
929
|
// mutation. Keep the whole first Watch/Detect transition inside the
|
|
708
930
|
// existing journal boundary used for the seeded structural bind.
|
|
709
931
|
const startWatch = () => {
|
|
932
|
+
if (root.kind === 'repo') return startRepositoryFromBaseline(root);
|
|
710
933
|
observer.start(root.rootId);
|
|
711
934
|
ensureTrees();
|
|
712
935
|
reconcileInitialRepoWorktrees(root.path);
|
|
@@ -746,6 +969,30 @@ function createRegistration(options) {
|
|
|
746
969
|
return normalized.map(({ targetPath, identityAt }) => add(targetPath, { identityAt }));
|
|
747
970
|
}
|
|
748
971
|
|
|
972
|
+
/** Same declared batch contract as `addMany`, while allowing a large Git
|
|
973
|
+
* cargo plan to run off the runtime event loop. Entries remain ordered and
|
|
974
|
+
* individually durable: this adds no second transaction model. */
|
|
975
|
+
async function addManyAsync(entries, { suppressJournal = false } = {}) {
|
|
976
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
977
|
+
throw new Error('addManyAsync requires one or more target paths');
|
|
978
|
+
}
|
|
979
|
+
const normalized = entries.map((entry) => {
|
|
980
|
+
if (typeof entry === 'string') return { targetPath: entry, identityAt: null };
|
|
981
|
+
if (!entry || typeof entry !== 'object' || typeof entry.targetPath !== 'string') {
|
|
982
|
+
throw new Error('addManyAsync entries must be paths or { targetPath, identityAt }');
|
|
983
|
+
}
|
|
984
|
+
if (entry.identityAt !== undefined && entry.identityAt !== null && typeof entry.identityAt !== 'function') {
|
|
985
|
+
throw new Error('addManyAsync identityAt must be a function when supplied');
|
|
986
|
+
}
|
|
987
|
+
return { targetPath: entry.targetPath, identityAt: entry.identityAt ?? null };
|
|
988
|
+
});
|
|
989
|
+
const results = [];
|
|
990
|
+
for (const { targetPath, identityAt } of normalized) {
|
|
991
|
+
results.push(await addAsync(targetPath, { identityAt, suppressJournal }));
|
|
992
|
+
}
|
|
993
|
+
return results;
|
|
994
|
+
}
|
|
995
|
+
|
|
749
996
|
/** Reconcile one already-watched tree after the platform has materialized
|
|
750
997
|
* a newly declared exact default inside it. This is deliberately not part
|
|
751
998
|
* of ordinary re-registration: the caller must have already named the
|
|
@@ -783,6 +1030,13 @@ function createRegistration(options) {
|
|
|
783
1030
|
return clean;
|
|
784
1031
|
}
|
|
785
1032
|
|
|
1033
|
+
// Reopen every persisted observer before the first reconciliation. An
|
|
1034
|
+
// identity database survives a runtime restart; its Watch proof must too.
|
|
1035
|
+
// `start()` opens physical owners and restores covered roots through the
|
|
1036
|
+
// same coverage law as a new registration, then its census catches up on
|
|
1037
|
+
// dings that occurred while this process was absent.
|
|
1038
|
+
observer.start();
|
|
1039
|
+
|
|
786
1040
|
// Startup is the same act as running — the one convergence pass,
|
|
787
1041
|
// nothing less. The projection must not wait for an event: a
|
|
788
1042
|
// rendering whose deletion was detected before a crash is equal
|
|
@@ -795,7 +1049,9 @@ function createRegistration(options) {
|
|
|
795
1049
|
// observer bindings remain private to this registration composition.
|
|
796
1050
|
registry,
|
|
797
1051
|
add,
|
|
1052
|
+
addAsync,
|
|
798
1053
|
addMany,
|
|
1054
|
+
addManyAsync,
|
|
799
1055
|
reconcileDeclared,
|
|
800
1056
|
|
|
801
1057
|
/**
|
|
@@ -25,12 +25,23 @@
|
|
|
25
25
|
|
|
26
26
|
const { createFollower, createCaptureWorker } = require('../repocard');
|
|
27
27
|
|
|
28
|
-
function createRepoFollowers({
|
|
28
|
+
function createRepoFollowers({
|
|
29
|
+
observer,
|
|
30
|
+
registry,
|
|
31
|
+
repoStates,
|
|
32
|
+
recordForRoot,
|
|
33
|
+
onSettled,
|
|
34
|
+
prepareRepo = null,
|
|
35
|
+
captureRepoAsync = null,
|
|
36
|
+
}) {
|
|
29
37
|
if (!observer || !registry || !repoStates || typeof recordForRoot !== 'function'
|
|
30
38
|
|| typeof onSettled !== 'function') {
|
|
31
39
|
throw new Error('createRepoFollowers requires { observer, registry, repoStates, recordForRoot, onSettled }');
|
|
32
40
|
}
|
|
33
41
|
|
|
42
|
+
// Production cloud-cargo captures are already complete in the service's
|
|
43
|
+
// dedicated worker (bundle -> transport -> immutable content head). The
|
|
44
|
+
// local/test fallback owns its lightweight capture worker here.
|
|
34
45
|
const worker = createCaptureWorker();
|
|
35
46
|
const followers = new Map(); // observer rootId -> { path, uuid, follower }
|
|
36
47
|
let stopped = false;
|
|
@@ -65,7 +76,16 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
|
|
|
65
76
|
* move, be adopted, or cease being a repository; in every case the old
|
|
66
77
|
* follower is closed before a replacement can speak.
|
|
67
78
|
*/
|
|
68
|
-
|
|
79
|
+
/**
|
|
80
|
+
* A freshly registered repo already has a Card + Checkpoint baseline and
|
|
81
|
+
* its watcher is still held. Its follower must exist before that hold is
|
|
82
|
+
* released, but it must not immediately spend a second pair of full Git
|
|
83
|
+
* captures just to rediscover the state it was born with. Any ding heard
|
|
84
|
+
* during the release rings this follower normally. A cold runtime restart
|
|
85
|
+
* keeps the default (`startOnBirth: true`) and independently proves the
|
|
86
|
+
* persisted baseline against disk.
|
|
87
|
+
*/
|
|
88
|
+
function sync({ startOnBirth = true } = {}) {
|
|
69
89
|
if (stopped) return;
|
|
70
90
|
const roots = observer.listRoots().filter((root) => root.kind === 'repo');
|
|
71
91
|
const live = new Set(roots.map((root) => root.rootId));
|
|
@@ -102,13 +122,17 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
|
|
|
102
122
|
// cargo captures (withBundle) need content and read everything;
|
|
103
123
|
// capture() enforces that side of the contract itself.
|
|
104
124
|
const previous = withBundle ? null : repoStates.get(record.uuid);
|
|
105
|
-
const
|
|
125
|
+
const options = {
|
|
106
126
|
withBundle,
|
|
107
127
|
suspects,
|
|
108
128
|
previousUntracked: previous?.checkpoint?.untracked?.map(
|
|
109
129
|
({ path: rel, mode, sha256 }) => ({ path: rel, mode, sha256 }),
|
|
110
130
|
) ?? null,
|
|
111
|
-
}
|
|
131
|
+
};
|
|
132
|
+
if (withBundle && captureRepoAsync) {
|
|
133
|
+
return captureRepoAsync(repoPath, { entityUuid: record.uuid, ...options });
|
|
134
|
+
}
|
|
135
|
+
const captured = await worker.capture(repoPath, options);
|
|
112
136
|
return withBundle && !captured?.busy
|
|
113
137
|
? prepareRepo(captured, { entityUuid: record.uuid })
|
|
114
138
|
: captured;
|
|
@@ -128,9 +152,10 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
|
|
|
128
152
|
},
|
|
129
153
|
});
|
|
130
154
|
followers.set(root.rootId, { path: root.path, uuid: record.uuid, follower });
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
155
|
+
// A restart catches up from disk; a fresh Register has already
|
|
156
|
+
// captured its exact baseline and will receive held doorbells when
|
|
157
|
+
// Watch releases, so it does not duplicate the expensive birth pair.
|
|
158
|
+
if (startOnBirth) void follower.start();
|
|
134
159
|
}
|
|
135
160
|
}
|
|
136
161
|
|
|
@@ -58,7 +58,7 @@ const { classifyFile } = require('./classify');
|
|
|
58
58
|
const { createEntityCloud } = require('./entity-cloud');
|
|
59
59
|
const { createEntityContent } = require('./entity-content');
|
|
60
60
|
const { createRegistration } = require('./index');
|
|
61
|
-
const { capture, apply: applyRepo } = require('../repocard');
|
|
61
|
+
const { capture, apply: applyRepo, createCaptureWorker } = require('../repocard');
|
|
62
62
|
const { transportBytes, localState, unpack } = require('./repo-states');
|
|
63
63
|
const { machineId, userId } = require('../workspace/identity');
|
|
64
64
|
|
|
@@ -99,6 +99,11 @@ function createRegistrationService({
|
|
|
99
99
|
handle.pragma('synchronous = NORMAL');
|
|
100
100
|
}
|
|
101
101
|
const entityContent = createEntityContent({ dir });
|
|
102
|
+
// Git bundles and their encoded immutable transports can be hundreds of
|
|
103
|
+
// megabytes. Build and chunk them off the runtime thread so its local
|
|
104
|
+
// health endpoint and already-live watchers stay responsive throughout a
|
|
105
|
+
// synchronous Register operation.
|
|
106
|
+
const cargoCaptureWorker = createCaptureWorker();
|
|
102
107
|
// createRegistration owns the schema migration. Give entity-cloud a stable
|
|
103
108
|
// lookup facade now, then connect it to that one migrated state store once
|
|
104
109
|
// the boundary exists; never open a second registry just to read Git state.
|
|
@@ -142,6 +147,27 @@ function createRegistrationService({
|
|
|
142
147
|
return localState(captured, artifact.contentHash);
|
|
143
148
|
}
|
|
144
149
|
|
|
150
|
+
async function captureRepoAsync(repoPath, {
|
|
151
|
+
identity = null,
|
|
152
|
+
entityUuid = null,
|
|
153
|
+
suspects = null,
|
|
154
|
+
previousUntracked = null,
|
|
155
|
+
} = {}) {
|
|
156
|
+
const transportIdentity = identity ?? (entityUuid ? worktreeIdentity(entityUuid) : null);
|
|
157
|
+
const captured = await cargoCaptureWorker.capture(repoPath, {
|
|
158
|
+
withBundle: true,
|
|
159
|
+
suspects,
|
|
160
|
+
previousUntracked,
|
|
161
|
+
cargo: { dir, identity: transportIdentity },
|
|
162
|
+
});
|
|
163
|
+
if (captured?.busy) return captured;
|
|
164
|
+
if (!captured?.stateId || !captured?.transportVersion) {
|
|
165
|
+
throw new Error(`repo cargo capture did not produce a state and immutable transport: ${repoPath}`);
|
|
166
|
+
}
|
|
167
|
+
repoTransportHeads.set(captured.stateId, captured.transportVersion);
|
|
168
|
+
return captured;
|
|
169
|
+
}
|
|
170
|
+
|
|
145
171
|
// Entity-cloud needs only logical ancestry to choose a content plan. It
|
|
146
172
|
// never owns a watcher or address; this facade is available after the
|
|
147
173
|
// registration boundary is constructed below.
|
|
@@ -174,6 +200,7 @@ function createRegistrationService({
|
|
|
174
200
|
sentinelPath: path.join(dir, 'identity.epoch'),
|
|
175
201
|
journal: entityCloud.journal,
|
|
176
202
|
captureRepo: (repoPath, options) => prepareRepo(capture(repoPath, { withBundle: true }), options),
|
|
203
|
+
captureRepoAsync,
|
|
177
204
|
prepareRepo,
|
|
178
205
|
repoStateAt: (uuid) => importedRepoStates.get(uuid) || null,
|
|
179
206
|
}),
|
|
@@ -972,9 +999,9 @@ function createRegistrationService({
|
|
|
972
999
|
// signed-out registration is allowed.
|
|
973
1000
|
const cloudActive = entityCloud.replica()?.state === 'active';
|
|
974
1001
|
if (!cloudActive || estimatedRegistrationRecords(targetPath) < ATOMIC_REGISTRATION_THRESHOLD) {
|
|
975
|
-
return boundary.current.
|
|
1002
|
+
return (await boundary.current.addManyAsync([targetPath]))[0];
|
|
976
1003
|
}
|
|
977
|
-
const result =
|
|
1004
|
+
const result = (await boundary.current.addManyAsync([targetPath], { suppressJournal: true }))[0];
|
|
978
1005
|
entityCloud.replaceSnapshot(boundary.current.registry.syncRecords());
|
|
979
1006
|
return result;
|
|
980
1007
|
},
|
|
@@ -988,13 +1015,13 @@ function createRegistrationService({
|
|
|
988
1015
|
&& targetPaths.some((targetPath) => (
|
|
989
1016
|
estimatedRegistrationRecords(targetPath) >= ATOMIC_REGISTRATION_THRESHOLD
|
|
990
1017
|
));
|
|
991
|
-
if (!needsAtomicReplacement) return boundary.current.
|
|
1018
|
+
if (!needsAtomicReplacement) return boundary.current.addManyAsync(targetPaths);
|
|
992
1019
|
// The snapshot must describe whatever actually committed, even when a
|
|
993
1020
|
// later path refuses (each path is durable alone): without journal
|
|
994
1021
|
// rows, a committed-but-unsnapshotted registration would be local
|
|
995
1022
|
// truth with zero cloud delivery intent — a ghost nothing repairs.
|
|
996
1023
|
try {
|
|
997
|
-
return boundary.current.
|
|
1024
|
+
return await boundary.current.addManyAsync(targetPaths, { suppressJournal: true });
|
|
998
1025
|
} finally {
|
|
999
1026
|
entityCloud.replaceSnapshot(boundary.current.registry.syncRecords());
|
|
1000
1027
|
}
|
|
@@ -1049,6 +1076,7 @@ function createRegistrationService({
|
|
|
1049
1076
|
closed = true;
|
|
1050
1077
|
observer.stop(); // doorbells and their timers
|
|
1051
1078
|
boundary.current.stop(); // and the pass they may already have scheduled
|
|
1079
|
+
void cargoCaptureWorker.close();
|
|
1052
1080
|
evidenceDb.close();
|
|
1053
1081
|
identityDb.close();
|
|
1054
1082
|
},
|
|
@@ -23,7 +23,10 @@
|
|
|
23
23
|
* although they have identical behavior today. A structural walk therefore
|
|
24
24
|
* uses file.text as the content-free ordinary-file placeholder. The first
|
|
25
25
|
* Detect pass immediately attests the actual bytes and corrects that
|
|
26
|
-
* representation through the
|
|
26
|
+
* representation through the first later settled repo reconciliation. The
|
|
27
|
+
* initial Card + Checkpoint is authoritative content state; repeating a
|
|
28
|
+
* generic per-file scan merely to refine this local physical label would
|
|
29
|
+
* duplicate that state and can freeze large registration.
|
|
27
30
|
*/
|
|
28
31
|
|
|
29
32
|
const fs = require('fs');
|
|
@@ -91,6 +94,56 @@ function collectWorktreeIdentity(repoPath, { shouldEnroll, identityFor }) {
|
|
|
91
94
|
return assignment;
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The asynchronous twin of the identity walk. It has exactly the same
|
|
99
|
+
* shape rules as `collectWorktreeIdentity`, but returns to Node between
|
|
100
|
+
* bounded batches. A large initial repo registration may take time to
|
|
101
|
+
* inspect, yet its held watcher and the runtime's health endpoint must stay
|
|
102
|
+
* alive while that inspection is in flight. This is planning only: no
|
|
103
|
+
* identity row exists until `registerTree` commits the finished plan.
|
|
104
|
+
*/
|
|
105
|
+
async function collectWorktreeIdentityAsync(repoPath, { shouldEnroll, identityFor, yieldEvery = 256 }) {
|
|
106
|
+
const assignment = new Map(); // repo-relative path -> { uuid, type }
|
|
107
|
+
let visited = 0;
|
|
108
|
+
const yieldIfNeeded = async () => {
|
|
109
|
+
visited += 1;
|
|
110
|
+
if (visited % yieldEvery !== 0) return;
|
|
111
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
112
|
+
};
|
|
113
|
+
const walk = async (dirPath, relativePath) => {
|
|
114
|
+
let entries;
|
|
115
|
+
try {
|
|
116
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
117
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') return;
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
await yieldIfNeeded();
|
|
124
|
+
const entryPath = path.join(dirPath, entry.name);
|
|
125
|
+
const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
126
|
+
if (!shouldEnroll(entryPath)) continue;
|
|
127
|
+
let stat;
|
|
128
|
+
try {
|
|
129
|
+
stat = fs.lstatSync(entryPath);
|
|
130
|
+
} catch {
|
|
131
|
+
continue; // raced away between readdir and look; the create-walk decides
|
|
132
|
+
}
|
|
133
|
+
let type;
|
|
134
|
+
if (stat.isSymbolicLink()) type = 'link';
|
|
135
|
+
else if (stat.isDirectory()) type = isRepository(entryPath) ? 'repo.git' : 'folder';
|
|
136
|
+
else if (stat.isFile()) type = 'file.text';
|
|
137
|
+
else continue;
|
|
138
|
+
const uuid = identityFor?.(entryRelativePath) || crypto.randomUUID();
|
|
139
|
+
assignment.set(entryRelativePath, { uuid, type });
|
|
140
|
+
if (type === 'folder') await walk(entryPath, entryRelativePath);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
await walk(repoPath, '');
|
|
144
|
+
return assignment;
|
|
145
|
+
}
|
|
146
|
+
|
|
94
147
|
function transportIdentity(assignment) {
|
|
95
148
|
return [...assignment].map(([entryPath, { uuid, type }]) => ({ path: entryPath, uuid, type }));
|
|
96
149
|
}
|
|
@@ -100,7 +153,17 @@ function transportIdentity(assignment) {
|
|
|
100
153
|
* directory ground. The caller supplies a root that is already known to the
|
|
101
154
|
* observer but is not yet watched or scanned.
|
|
102
155
|
*/
|
|
103
|
-
function registerTree({
|
|
156
|
+
function registerTree({
|
|
157
|
+
root,
|
|
158
|
+
registry,
|
|
159
|
+
memory,
|
|
160
|
+
shouldEnroll,
|
|
161
|
+
captureRepo,
|
|
162
|
+
repoStates,
|
|
163
|
+
identityAt = null,
|
|
164
|
+
repoStateAt = null,
|
|
165
|
+
preparedRepoAt = null,
|
|
166
|
+
}) {
|
|
104
167
|
if (!root || !registry || !memory || typeof shouldEnroll !== 'function'
|
|
105
168
|
|| typeof captureRepo !== 'function' || !repoStates) {
|
|
106
169
|
throw new Error('registerTree requires { root, registry, memory, shouldEnroll, captureRepo, repoStates }');
|
|
@@ -109,6 +172,9 @@ function registerTree({ root, registry, memory, shouldEnroll, captureRepo, repoS
|
|
|
109
172
|
if (identityAt !== null && typeof identityAt !== 'function') {
|
|
110
173
|
throw new Error('registerTree identityAt must be a function when supplied');
|
|
111
174
|
}
|
|
175
|
+
if (preparedRepoAt !== null && typeof preparedRepoAt !== 'function') {
|
|
176
|
+
throw new Error('registerTree preparedRepoAt must be a function when supplied');
|
|
177
|
+
}
|
|
112
178
|
|
|
113
179
|
function seeded(relativePath, { type, parentUUID, name, root: isRoot }) {
|
|
114
180
|
const identity = identityAt?.(relativePath) || null;
|
|
@@ -151,6 +217,8 @@ function registerTree({ root, registry, memory, shouldEnroll, captureRepo, repoS
|
|
|
151
217
|
const saved = repoStates.get(identity.uuid);
|
|
152
218
|
if (saved?.stateId === identity.payloadVersion) return { state: saved, assignment: null };
|
|
153
219
|
}
|
|
220
|
+
const prepared = preparedRepoAt?.(repoPath) || null;
|
|
221
|
+
if (prepared) return prepared;
|
|
154
222
|
const assignment = collectWorktreeIdentity(repoPath, {
|
|
155
223
|
shouldEnroll,
|
|
156
224
|
identityFor: (repoRelativePath) => identityAt?.(
|
|
@@ -296,4 +364,4 @@ function registerDirectory(dirPath, parentUUID, {
|
|
|
296
364
|
}
|
|
297
365
|
}
|
|
298
366
|
|
|
299
|
-
module.exports = { registerTree };
|
|
367
|
+
module.exports = { collectWorktreeIdentityAsync, registerTree, transportIdentity };
|
|
@@ -622,7 +622,20 @@ function createEvidence(options) {
|
|
|
622
622
|
const parentUuid = ensureDir(path.dirname(territory) === '.' ? '' : path.dirname(territory), root);
|
|
623
623
|
const name = path.basename(territory);
|
|
624
624
|
const at = registry.activeAt(parentUuid, name);
|
|
625
|
-
if (at?.type
|
|
625
|
+
if (at?.type === 'repo.git') continue;
|
|
626
|
+
const territoryRoot = observer.listRoots()
|
|
627
|
+
.find((row) => row.kind === 'repo' && row.path === path.join(groundPath, territory));
|
|
628
|
+
const arrivedRecord = arrivedTree(territoryRoot?.device, territoryRoot?.inode, REPO_TREE);
|
|
629
|
+
if (!arrivedRecord) continue;
|
|
630
|
+
// A Git territory is the repo-shaped form of the same adoption law
|
|
631
|
+
// directories use above. Its initial Card + Checkpoint already
|
|
632
|
+
// travels with the existing repo UUID, so the new observer root must
|
|
633
|
+
// adopt that identity — never mint or ignore a half-repository.
|
|
634
|
+
if (at) {
|
|
635
|
+
nowDivergent.set(territory, null); // two identity claims: decide neither
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
registry.move(arrivedRecord.uuid, { parentUUID: parentUuid, name });
|
|
626
639
|
}
|
|
627
640
|
// A lifted lens is the reverse transition on the same record.
|
|
628
641
|
for (const { record, rel } of walkActive(root.uuid, '', [])) {
|
|
@@ -750,6 +750,13 @@ function createRegistry(options) {
|
|
|
750
750
|
/** Full logical graph for cloud snapshots. Machine bindings and evidence
|
|
751
751
|
* are intentionally absent. */
|
|
752
752
|
syncRecords: () => store.allEntities().map(syncRecord),
|
|
753
|
+
/** Active records of one physical type. This is a query primitive for
|
|
754
|
+
* type adapters; it does not expose storage writes or weaken the single
|
|
755
|
+
* registry boundary. */
|
|
756
|
+
activeByType: (type) => {
|
|
757
|
+
if (!TYPES.has(type)) throw new Error(`unknown entity type ${JSON.stringify(type)}`);
|
|
758
|
+
return store.activeByType(type).map(record);
|
|
759
|
+
},
|
|
753
760
|
/** Every registered tree's top record. */
|
|
754
761
|
roots: () => store.activeRoots().map(record),
|
|
755
762
|
/** Every active managed reference with the identity it targets. The
|
|
@@ -20,9 +20,31 @@ const { Worker, isMainThread, parentPort } = require('worker_threads');
|
|
|
20
20
|
|
|
21
21
|
if (!isMainThread) {
|
|
22
22
|
const { capture } = require('./capture');
|
|
23
|
+
const { createEntityContent } = require('../registration/entity-content');
|
|
24
|
+
const { transportBytes, localState } = require('../registration/repo-states');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A cloud-bound initial repo capture is deliberately completed here, not
|
|
28
|
+
* handed back as a giant Git bundle for the runtime thread to JSON-encode
|
|
29
|
+
* and chunk. The runtime's job is to remain the responsive authority for
|
|
30
|
+
* Watch + Register; this worker owns the expensive, immutable cargo build.
|
|
31
|
+
* It returns only the bundle-free state plus its content head.
|
|
32
|
+
*/
|
|
33
|
+
function captureCargo(repoPath, options) {
|
|
34
|
+
const cargo = options?.cargo;
|
|
35
|
+
if (!cargo || typeof cargo.dir !== 'string') return capture(repoPath, options);
|
|
36
|
+
const snap = capture(repoPath, { ...options, withBundle: true });
|
|
37
|
+
if (snap?.busy) return snap;
|
|
38
|
+
const content = createEntityContent({ dir: cargo.dir });
|
|
39
|
+
const artifact = content.captureBytes({
|
|
40
|
+
bytes: transportBytes(snap, Array.isArray(cargo.identity) ? cargo.identity : null),
|
|
41
|
+
});
|
|
42
|
+
return localState(snap, artifact.contentHash);
|
|
43
|
+
}
|
|
44
|
+
|
|
23
45
|
parentPort.on('message', ({ id, repoPath, options }) => {
|
|
24
46
|
try {
|
|
25
|
-
parentPort.postMessage({ id, snap:
|
|
47
|
+
parentPort.postMessage({ id, snap: captureCargo(repoPath, options) });
|
|
26
48
|
} catch (error) {
|
|
27
49
|
parentPort.postMessage({ id, error: error?.message || String(error) });
|
|
28
50
|
}
|
|
@@ -608,13 +608,17 @@ test('adding a repository registers its allowed tree and saves its initial Repoc
|
|
|
608
608
|
assert.ok(initialState?.stateId, 'registration saved the initial Card + Checkpoint');
|
|
609
609
|
assert.ok(initialState.checkpoint.untracked.some((file) => file.path === 'readme.md'),
|
|
610
610
|
'the persisted baseline contains the actual working checkpoint, not only its hash');
|
|
611
|
+
const observerRoot = service.observer.listRoots().find((root) => root.path === repo);
|
|
612
|
+
assert.deepEqual(service.observer.filesForRoot(observerRoot.rootId), [],
|
|
613
|
+
'repo birth does not perform a second per-file census after its Card + Checkpoint is saved');
|
|
611
614
|
assert.equal(tree.root().version, registered.version,
|
|
612
615
|
'add returned only after the repo entity pointed at its saved baseline');
|
|
613
616
|
const readme = tree.registry.activeAt(registered.uuid, 'readme.md');
|
|
614
617
|
const src = tree.registry.activeAt(registered.uuid, 'src');
|
|
615
618
|
assert.equal(readme.type, 'file.text');
|
|
616
619
|
assert.equal(src.type, 'folder');
|
|
617
|
-
assert.equal(tree.registry.activeAt(src.uuid, 'blob.bin').type, 'file.
|
|
620
|
+
assert.equal(tree.registry.activeAt(src.uuid, 'blob.bin').type, 'file.text',
|
|
621
|
+
'repo birth records structural leaf shape; the Card + Checkpoint owns its actual bytes without a second read');
|
|
618
622
|
assert.equal(tree.registry.activeAt(registered.uuid, 'node_modules'), null,
|
|
619
623
|
'the same enrollment policy excludes dependency trees inside repos');
|
|
620
624
|
const initial = tree.root();
|
|
@@ -657,6 +661,28 @@ test('adding a repository registers its allowed tree and saves its initial Repoc
|
|
|
657
661
|
assert.equal(tree.root().uuid, after.uuid);
|
|
658
662
|
});
|
|
659
663
|
|
|
664
|
+
test('a production repository register builds its immutable Git cargo off the runtime thread', async () => {
|
|
665
|
+
const service = makeService('repo-cargo-worker');
|
|
666
|
+
const repo = makeGround('repo-cargo-worker');
|
|
667
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
668
|
+
fs.writeFileSync(path.join(repo, 'history.md'), '# committed history\n');
|
|
669
|
+
execFileSync('git', ['add', '.'], { cwd: repo });
|
|
670
|
+
execFileSync('git', [
|
|
671
|
+
'-c', 'user.name=Amalgm Test',
|
|
672
|
+
'-c', 'user.email=amalgm-test@example.invalid',
|
|
673
|
+
'commit', '-qm', 'initial history',
|
|
674
|
+
], { cwd: repo });
|
|
675
|
+
|
|
676
|
+
const { record } = await service.register(repo);
|
|
677
|
+
const state = service.registration.repoState(record.uuid);
|
|
678
|
+
assert.equal(record.type, 'repo.git');
|
|
679
|
+
assert.ok(state?.transportVersion, 'the saved state names one immutable transport object');
|
|
680
|
+
assert.equal(state.card.bundle, null,
|
|
681
|
+
'identity.db holds only bundle-free state; the worker left history in immutable content');
|
|
682
|
+
assert.ok(service.entityContent.bytes({ contentHash: state.transportVersion }).length > 0,
|
|
683
|
+
'the worker finished the transport before Register committed the repo head');
|
|
684
|
+
});
|
|
685
|
+
|
|
660
686
|
test('an edge-reached repository gets the same complete registration as a directly added repo', async () => {
|
|
661
687
|
const service = makeService('edge-repo-card');
|
|
662
688
|
const holder = makeGround('edge-repo-holder');
|
|
@@ -959,6 +985,25 @@ test('identity survives a real close-and-reopen over the same directory', async
|
|
|
959
985
|
assert.equal(after, before, 'the tree keeps its permanent UUID across processes');
|
|
960
986
|
});
|
|
961
987
|
|
|
988
|
+
test('a restarted runtime reopens the persisted watcher before it reports the tree healthy', async () => {
|
|
989
|
+
const dir = path.join(tempRoot, 'state-reopen-watch');
|
|
990
|
+
const ground = makeGround('reopen-watch');
|
|
991
|
+
fs.writeFileSync(path.join(ground, 'notes.md'), 'persisted ground\n');
|
|
992
|
+
|
|
993
|
+
const first = createRegistrationService({ dir });
|
|
994
|
+
const registered = await first.register(ground);
|
|
995
|
+
first.close();
|
|
996
|
+
|
|
997
|
+
const reopened = createRegistrationService({ dir });
|
|
998
|
+
services.push(reopened);
|
|
999
|
+
const root = reopened.observer.status().find((entry) => entry.path === ground);
|
|
1000
|
+
assert.ok(root, 'the original observer root survived the restart');
|
|
1001
|
+
assert.equal(root.watching, true, 'the restarted runtime has a live Watch rail');
|
|
1002
|
+
assert.equal(root.degraded, false);
|
|
1003
|
+
assert.equal(reopened.registration.tree(ground).root().uuid, registered.record.uuid,
|
|
1004
|
+
'the watcher reopened over the same permanent identity');
|
|
1005
|
+
});
|
|
1006
|
+
|
|
962
1007
|
// --- the policies are the real ones ---------------------------------------
|
|
963
1008
|
|
|
964
1009
|
test("amalgm's enrollment policy is in force, and refuses loudly", async () => {
|