amalgm 0.1.262 → 0.1.264
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/coverage.js +52 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +171 -37
- 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 +168 -18
- 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 +99 -12
- package/runtime/scripts/amalgm-mcp/registration/tree.js +104 -20
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +16 -14
- 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 +243 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.coverage.test.js +197 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +48 -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,8 +15,10 @@
|
|
|
15
15
|
* (its own future head and rail), never an identity boundary. Every
|
|
16
16
|
* top-level enrolled ground — a workspace, a repository, a single file,
|
|
17
17
|
* or an explicitly added link — is such a tree, with one evidence LENS
|
|
18
|
-
* over its observer root
|
|
19
|
-
* tree
|
|
18
|
+
* over its observer root. A Git territory noticed inside another watched
|
|
19
|
+
* tree is observer-only until an explicit registration admits it; that
|
|
20
|
+
* selected repo is then a tree of its own even if the outer watcher covers
|
|
21
|
+
* its local address. A
|
|
20
22
|
* TREE'S IDENTITY IS ITS UUID, PERMANENT; its root path is only the
|
|
21
23
|
* current local address — lenses are keyed by the observer's rootId
|
|
22
24
|
* (the witness), the observer rescues moved ground by physical
|
|
@@ -66,6 +68,7 @@ const { refusal } = require('./refusal');
|
|
|
66
68
|
const { createRepoFollowers } = require('./repo-followers');
|
|
67
69
|
const { createRepoStates } = require('./repo-states');
|
|
68
70
|
const { registerTree } = require('./tree');
|
|
71
|
+
const { gitDirTruth } = require('../observer/scan');
|
|
69
72
|
|
|
70
73
|
// Events that can change what the recorded edges reach: a new or
|
|
71
74
|
// retargeted pointer, repo ground moving under its card (a worktree
|
|
@@ -112,12 +115,27 @@ function createRegistration(options) {
|
|
|
112
115
|
onSettled: reconcileSettledRepo,
|
|
113
116
|
});
|
|
114
117
|
|
|
115
|
-
/**
|
|
116
|
-
*
|
|
118
|
+
/**
|
|
119
|
+
* A semantic tree is defined by its parentless identity record, never by
|
|
120
|
+
* directory containment. `amalgm add` may materialize a cloud repo inside
|
|
121
|
+
* the local `workspaces/` directory, and `amalgm register` may select a
|
|
122
|
+
* repo already sitting there: both remain their own cloud root. The outer
|
|
123
|
+
* workspace watcher supplies coverage, but it does not own the repo's
|
|
124
|
+
* Card + Checkpoint or identity tree.
|
|
125
|
+
*
|
|
126
|
+
* An unbound observer root is a tree candidate only when no other observer
|
|
127
|
+
* root physically encloses it. That preserves the admission rule for a
|
|
128
|
+
* newly discovered nested repo: Watch can notice it, but only explicit
|
|
129
|
+
* registration can give it a cloud identity and state.
|
|
130
|
+
*/
|
|
117
131
|
function treeRoots() {
|
|
118
132
|
const roots = observer.listRoots();
|
|
119
|
-
return roots.filter((root) =>
|
|
120
|
-
|
|
133
|
+
return roots.filter((root) => {
|
|
134
|
+
const record = rootRecordOf(root.rootId);
|
|
135
|
+
if (record) return record.parentUUID === null;
|
|
136
|
+
return !roots.some((other) => other.rootId !== root.rootId
|
|
137
|
+
&& root.path.startsWith(`${other.path}/`));
|
|
138
|
+
});
|
|
121
139
|
}
|
|
122
140
|
|
|
123
141
|
function treeRootCovering(absPath) {
|
|
@@ -359,6 +377,43 @@ function createRegistration(options) {
|
|
|
359
377
|
* genuinely live. A filesystem watcher that could not open is not a
|
|
360
378
|
* warning; it means the requested Register → Watch operation is incomplete
|
|
361
379
|
* and the caller must hear that. */
|
|
380
|
+
/**
|
|
381
|
+
* Watcher FIRST: prove the doorbell can be born before any identity
|
|
382
|
+
* commits. The birth is held (observer.watchNow) — the watcher exists
|
|
383
|
+
* and is judged, but concludes nothing until start() releases it. One
|
|
384
|
+
* retry absorbs a transient failure; a genuine refusal names its reason,
|
|
385
|
+
* because ENOSPC is a kernel watch budget the caller can actually fix.
|
|
386
|
+
*/
|
|
387
|
+
function requireWatchBirth(root) {
|
|
388
|
+
const unhealthy = (row) => !row.watching || row.degraded
|
|
389
|
+
|| (row.addressRequired && (!row.addressWatching || row.addressDegraded));
|
|
390
|
+
let health = observer.watchNow(root.rootId);
|
|
391
|
+
if (unhealthy(health)) health = observer.watchNow(root.rootId);
|
|
392
|
+
if (!unhealthy(health)) return;
|
|
393
|
+
const reason = health.degradedReason ? ` (${health.degradedReason})` : '';
|
|
394
|
+
const remedy = health.degradedReason === 'ENOSPC'
|
|
395
|
+
? ' — the OS file-watch budget is exhausted; raise fs.inotify.max_user_watches or unregister unused ground, then retry'
|
|
396
|
+
: '';
|
|
397
|
+
throw refusal(`refusing to register ${root.path}: its watcher could not be born${reason}${remedy}; nothing was registered`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The commit's last word, spoken INSIDE the identity transaction: the
|
|
402
|
+
* watchers proven before the walk are still live as the rows land. A
|
|
403
|
+
* watcher that died mid-walk rolls the whole registration — records and
|
|
404
|
+
* cloud journal together — back to nothing. This is an environment
|
|
405
|
+
* fault, never the caller's path.
|
|
406
|
+
*/
|
|
407
|
+
function assertWatchedThroughCommit(rootPath) {
|
|
408
|
+
const dead = observer.status().filter((row) => (row.path === rootPath
|
|
409
|
+
|| row.path.startsWith(`${rootPath}/`))
|
|
410
|
+
&& (!row.watching || row.degraded));
|
|
411
|
+
if (dead.length > 0) {
|
|
412
|
+
throw new Error(`watcher(s) died during registration of ${rootPath}: `
|
|
413
|
+
+ `${dead.map((row) => row.path).join(', ')} — the registration was rolled back`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
362
417
|
function requireLiveWatchers(rootPath) {
|
|
363
418
|
const belongs = (root) => root.path === rootPath || root.path.startsWith(`${rootPath}/`);
|
|
364
419
|
let unavailable = observer.status().filter((root) => belongs(root)
|
|
@@ -376,6 +431,75 @@ function createRegistration(options) {
|
|
|
376
431
|
}
|
|
377
432
|
}
|
|
378
433
|
|
|
434
|
+
/**
|
|
435
|
+
* A repository selected inside already-watched ground is still a new
|
|
436
|
+
* registered TREE, not a child record of the outer workspace. The outer
|
|
437
|
+
* watcher already knows its territory (or this enrollment establishes that
|
|
438
|
+
* same territory), so the only missing fact is the repo's atomic
|
|
439
|
+
* Card+Checkpoint-backed identity birth.
|
|
440
|
+
*/
|
|
441
|
+
function registerRepositoryInsideCoveredGround(ground, { identityAt = null, suppressJournal = false } = {}) {
|
|
442
|
+
let root = observer.listRoots().find((candidate) => candidate.path === ground) || null;
|
|
443
|
+
if (!root) root = observer.enrollRoot(ground, { scan: false, watch: false });
|
|
444
|
+
if (root.kind !== 'repo') {
|
|
445
|
+
throw new Error(`expected Git repository ground at ${ground}, found ${root.kind}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const bound = memory.boundTo(root.rootId);
|
|
449
|
+
if (bound !== undefined) {
|
|
450
|
+
const record = registry.record(bound);
|
|
451
|
+
if (record?.type !== 'repo.git') {
|
|
452
|
+
throw new Error(`repository watcher ${ground} is bound to non-repository entity ${bound}`);
|
|
453
|
+
}
|
|
454
|
+
if (record.parentUUID !== null) {
|
|
455
|
+
// This is the explicit nested-boundary case created as part of an
|
|
456
|
+
// already-registered enclosing repo. It already has a complete
|
|
457
|
+
// Card+Checkpoint state and cannot be duplicated at one address.
|
|
458
|
+
requireLiveWatchers(ground);
|
|
459
|
+
return { rootPath: ground, record };
|
|
460
|
+
}
|
|
461
|
+
requireLiveWatchers(ground);
|
|
462
|
+
return { rootPath: ground, record };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
requireWatchBirth(root);
|
|
467
|
+
assertWitnessed({ observer, registry, memory });
|
|
468
|
+
const register = () => registry.transaction(() => {
|
|
469
|
+
registerTree({
|
|
470
|
+
root,
|
|
471
|
+
registry,
|
|
472
|
+
memory,
|
|
473
|
+
shouldEnroll: observer.shouldEnroll,
|
|
474
|
+
captureRepo,
|
|
475
|
+
repoStates,
|
|
476
|
+
repoStateAt,
|
|
477
|
+
identityAt,
|
|
478
|
+
});
|
|
479
|
+
assertWatchedThroughCommit(root.path);
|
|
480
|
+
});
|
|
481
|
+
if (suppressJournal) registry.withoutJournal(register);
|
|
482
|
+
else register();
|
|
483
|
+
} catch (error) {
|
|
484
|
+
// A root that existed only because the outer watcher discovered its
|
|
485
|
+
// territory must remain coverage after a refused explicit admission;
|
|
486
|
+
// do not remove it (the parent still owns the OS watcher). A root this
|
|
487
|
+
// call itself enrolled has no durable identity and can safely retire.
|
|
488
|
+
throw error;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const startWatch = () => {
|
|
492
|
+
observer.start(root.rootId);
|
|
493
|
+
ensureTrees();
|
|
494
|
+
reconcileInitialRepoWorktrees(root.path);
|
|
495
|
+
flushIfDirty();
|
|
496
|
+
requireLiveWatchers(root.path);
|
|
497
|
+
};
|
|
498
|
+
if (suppressJournal) registry.withoutJournal(startWatch);
|
|
499
|
+
else startWatch();
|
|
500
|
+
return { rootPath: root.path, record: rootRecordOf(root.rootId) };
|
|
501
|
+
}
|
|
502
|
+
|
|
379
503
|
/** Real ground -> the UUID of the entity that owns it, across trees. */
|
|
380
504
|
function resolveGround(real) {
|
|
381
505
|
const home = treeRootCovering(real);
|
|
@@ -505,6 +629,23 @@ function createRegistration(options) {
|
|
|
505
629
|
}
|
|
506
630
|
const covering = treeRootCovering(ground);
|
|
507
631
|
if (covering) {
|
|
632
|
+
// A repository has a different state adapter from the workspace that
|
|
633
|
+
// happens to contain its local address. Its observer territory may
|
|
634
|
+
// already exist, but it is not registered until this explicit command
|
|
635
|
+
// creates the parentless repo tree with its initial Card + Checkpoint.
|
|
636
|
+
// The same branch is used by cloud `add`: materialized repos commonly
|
|
637
|
+
// land under the default `workspaces/` directory while retaining their
|
|
638
|
+
// cloud root UUID and parentless graph position.
|
|
639
|
+
const isRepository = fs.lstatSync(ground).isDirectory() && gitDirTruth(ground) === 'present';
|
|
640
|
+
if (isRepository) {
|
|
641
|
+
const exact = observer.listRoots().find((root) => root.path === ground);
|
|
642
|
+
const known = exact && memory.boundTo(exact.rootId) !== undefined
|
|
643
|
+
? registry.record(memory.boundTo(exact.rootId))
|
|
644
|
+
: null;
|
|
645
|
+
if (!known || known.type !== 'repo.git' || known.parentUUID === null) {
|
|
646
|
+
return registerRepositoryInsideCoveredGround(ground, { identityAt, suppressJournal });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
508
649
|
// This address is already registered ground. Its prior Register →
|
|
509
650
|
// Watch transition remains the proof of its identity; repeating a
|
|
510
651
|
// harmless "ensure the safe home exists" request must not rescan every
|
|
@@ -525,24 +666,33 @@ function createRegistration(options) {
|
|
|
525
666
|
}
|
|
526
667
|
const root = observer.enrollRoot(ground, { scan: false, watch: false });
|
|
527
668
|
try {
|
|
669
|
+
// WATCHER FIRST. Registration is synchronous, never optimistic: the
|
|
670
|
+
// doorbell is born and proven (held — concluding nothing) before one
|
|
671
|
+
// identity row exists, and the commit's own last word re-checks it.
|
|
672
|
+
// A record and its cloud journal land together inside registerTree's
|
|
673
|
+
// transaction, so the outcome is registered-and-watched, or nothing.
|
|
674
|
+
requireWatchBirth(root);
|
|
528
675
|
assertWitnessed({ observer, registry, memory });
|
|
529
|
-
const register = () =>
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
676
|
+
const register = () => registry.transaction(() => {
|
|
677
|
+
registerTree({
|
|
678
|
+
root,
|
|
679
|
+
registry,
|
|
680
|
+
memory,
|
|
681
|
+
shouldEnroll: observer.shouldEnroll,
|
|
682
|
+
captureRepo,
|
|
683
|
+
repoStates,
|
|
684
|
+
repoStateAt,
|
|
685
|
+
identityAt,
|
|
686
|
+
});
|
|
687
|
+
assertWatchedThroughCommit(root.path);
|
|
538
688
|
});
|
|
539
689
|
if (suppressJournal) registry.withoutJournal(register);
|
|
540
690
|
else register();
|
|
541
691
|
} catch (error) {
|
|
542
692
|
// The observer root is only an address until structural registration
|
|
543
|
-
// commits
|
|
544
|
-
//
|
|
545
|
-
//
|
|
693
|
+
// commits — and its held watchers are only handles. A refused or
|
|
694
|
+
// failed walk leaves NOTHING behind: removeRoot closes the doorbells
|
|
695
|
+
// and the transaction above rolled every row and journal entry back.
|
|
546
696
|
observer.removeRoot(root.rootId);
|
|
547
697
|
throw error;
|
|
548
698
|
}
|
|
@@ -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
|
}
|
|
@@ -52,6 +52,7 @@ const {
|
|
|
52
52
|
migrateLegacyToolboxCatalog,
|
|
53
53
|
} = require('../toolbox/artifacts');
|
|
54
54
|
const { createObserver } = require('../observer');
|
|
55
|
+
const { gitDirTruth } = require('../observer/scan');
|
|
55
56
|
const { shouldEnroll } = require('../workspace/tree/inclusion');
|
|
56
57
|
const { classifyFile } = require('./classify');
|
|
57
58
|
const { createEntityCloud } = require('./entity-cloud');
|
|
@@ -106,13 +107,37 @@ function createRegistrationService({
|
|
|
106
107
|
const repoTransportHeads = new Map(); // semantic stateId -> immutable content hash
|
|
107
108
|
const importedRepoStates = new Map(); // cloud UUID -> verified bundle-free state
|
|
108
109
|
|
|
110
|
+
/** The identity map a settled repo state travels with: every enrolled
|
|
111
|
+
* record beneath the repo boundary (stopping at nested repo boundaries,
|
|
112
|
+
* which pack their own maps). Repo children never journal their own
|
|
113
|
+
* cloud mutations, so this map — inside the one transport parcel — is
|
|
114
|
+
* the only way their permanent UUIDs reach another machine. */
|
|
115
|
+
function worktreeIdentity(repoUuid) {
|
|
116
|
+
const registry = boundary.current?.registry;
|
|
117
|
+
if (!registry) return null;
|
|
118
|
+
const entries = [];
|
|
119
|
+
const visit = (parentUuid, base) => {
|
|
120
|
+
for (const child of registry.children(parentUuid)) {
|
|
121
|
+
if (child.status !== 'active') continue;
|
|
122
|
+
const childPath = base ? `${base}/${child.name}` : child.name;
|
|
123
|
+
entries.push({ path: childPath, uuid: child.uuid, type: child.type });
|
|
124
|
+
if (child.type === 'folder') visit(child.uuid, childPath);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
visit(repoUuid, '');
|
|
128
|
+
return entries;
|
|
129
|
+
}
|
|
130
|
+
|
|
109
131
|
/** Git transport is immutable content, not identity-database cargo. The
|
|
110
132
|
* semantic repo stateId remains the entity head; this prepares the exact
|
|
111
133
|
* Card + Checkpoint + bundle payload and leaves only its content hash in
|
|
112
|
-
* SQLite so a large clone cannot bloat the device registry.
|
|
113
|
-
|
|
134
|
+
* SQLite so a large clone cannot bloat the device registry. Registration
|
|
135
|
+
* supplies the freshly assigned identity entries; a settled follower
|
|
136
|
+
* names its repo UUID instead and the map is read from the registry. */
|
|
137
|
+
function prepareRepo(captured, { identity = null, entityUuid = null } = {}) {
|
|
114
138
|
if (captured?.busy) return captured;
|
|
115
|
-
const
|
|
139
|
+
const transportIdentity = identity ?? (entityUuid ? worktreeIdentity(entityUuid) : null);
|
|
140
|
+
const artifact = entityContent.captureBytes({ bytes: transportBytes(captured, transportIdentity) });
|
|
116
141
|
repoTransportHeads.set(captured.stateId, artifact.contentHash);
|
|
117
142
|
return localState(captured, artifact.contentHash);
|
|
118
143
|
}
|
|
@@ -148,7 +173,7 @@ function createRegistrationService({
|
|
|
148
173
|
db: identityDb,
|
|
149
174
|
sentinelPath: path.join(dir, 'identity.epoch'),
|
|
150
175
|
journal: entityCloud.journal,
|
|
151
|
-
captureRepo: (repoPath) => prepareRepo(capture(repoPath, { withBundle: true })),
|
|
176
|
+
captureRepo: (repoPath, options) => prepareRepo(capture(repoPath, { withBundle: true }), options),
|
|
152
177
|
prepareRepo,
|
|
153
178
|
repoStateAt: (uuid) => importedRepoStates.get(uuid) || null,
|
|
154
179
|
}),
|
|
@@ -158,10 +183,12 @@ function createRegistrationService({
|
|
|
158
183
|
|
|
159
184
|
let closed = false;
|
|
160
185
|
|
|
161
|
-
/** Count only the
|
|
186
|
+
/** Count only the records that will actually TRAVEL to the cloud. This is
|
|
162
187
|
* not a second registration walker: it is a bounded routing decision that
|
|
163
188
|
* lets a large tree use one atomic cloud graph replacement instead of
|
|
164
|
-
* thousands of individually compacted mutations.
|
|
189
|
+
* thousands of individually compacted mutations. A repository counts as
|
|
190
|
+
* ONE — its children ride the transport parcel, never the cloud graph —
|
|
191
|
+
* so registering even a 40,000-file repo is a single ordinary mutation. */
|
|
165
192
|
function estimatedRegistrationRecords(targetPath, limit = ATOMIC_REGISTRATION_THRESHOLD) {
|
|
166
193
|
let total = 0;
|
|
167
194
|
const visit = (candidate) => {
|
|
@@ -170,6 +197,7 @@ function createRegistrationService({
|
|
|
170
197
|
let stat;
|
|
171
198
|
try { stat = fs.lstatSync(candidate); } catch { return; }
|
|
172
199
|
if (!stat.isDirectory() || stat.isSymbolicLink()) return;
|
|
200
|
+
if (gitDirTruth(candidate) === 'present') return;
|
|
173
201
|
let entries;
|
|
174
202
|
try { entries = fs.readdirSync(candidate, { withFileTypes: true }); } catch { return; }
|
|
175
203
|
for (const entry of entries) {
|
|
@@ -482,15 +510,50 @@ function createRegistrationService({
|
|
|
482
510
|
group.push(record);
|
|
483
511
|
children.set(record.parentUUID, group);
|
|
484
512
|
}
|
|
513
|
+
// Unpacking a transport decodes the whole bundle from base64; one unpack
|
|
514
|
+
// per repository, not one per caller.
|
|
515
|
+
const repoStatesByUuid = new Map();
|
|
485
516
|
function repoState(record) {
|
|
517
|
+
const cached = repoStatesByUuid.get(record.uuid);
|
|
518
|
+
if (cached) return cached;
|
|
486
519
|
if (!record.transportVersion) throw new Error(`cloud repository ${record.uuid} has no Repocard transport head`);
|
|
487
520
|
const state = unpack(entityContent.bytes({ contentHash: record.transportVersion }));
|
|
488
521
|
if (state.stateId !== record.payloadVersion) {
|
|
489
522
|
throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
|
|
490
523
|
}
|
|
524
|
+
repoStatesByUuid.set(record.uuid, state);
|
|
491
525
|
return state;
|
|
492
526
|
}
|
|
493
527
|
|
|
528
|
+
/** The identity a repo's transport carries for one worktree address:
|
|
529
|
+
* the same permanent UUID the registering machine minted, synthesized
|
|
530
|
+
* into the record shape the structural walk verifies. Records under a
|
|
531
|
+
* repository no longer live in the cloud catalog — the map inside the
|
|
532
|
+
* one state parcel is their identity carrier. */
|
|
533
|
+
function repoChildIdentity(repoRecord, repoRelativePath) {
|
|
534
|
+
const state = repoState(repoRecord);
|
|
535
|
+
if (!Array.isArray(state.identity)) return null;
|
|
536
|
+
const byPath = state.identityByPath
|
|
537
|
+
|| (state.identityByPath = new Map(state.identity.map((entry) => [entry.path, entry])));
|
|
538
|
+
const entry = byPath.get(repoRelativePath);
|
|
539
|
+
if (!entry) return null;
|
|
540
|
+
// A nested repository does not travel (the repo travel law), so the
|
|
541
|
+
// transport cannot promise this ground materializes as a repo here.
|
|
542
|
+
// The receiving walk decides what the ground actually is and mints
|
|
543
|
+
// fresh local identity for it.
|
|
544
|
+
if (entry.type === 'repo.git') return null;
|
|
545
|
+
const slash = repoRelativePath.lastIndexOf('/');
|
|
546
|
+
const parentEntry = slash === -1 ? null : byPath.get(repoRelativePath.slice(0, slash));
|
|
547
|
+
return {
|
|
548
|
+
uuid: entry.uuid,
|
|
549
|
+
type: entry.type,
|
|
550
|
+
parentUUID: parentEntry ? parentEntry.uuid : repoRecord.uuid,
|
|
551
|
+
name: repoRelativePath.slice(slash + 1),
|
|
552
|
+
status: 'active',
|
|
553
|
+
payloadVersion: null,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
494
557
|
for (const { contentHash } of entityCloud.contentHeads(active)) {
|
|
495
558
|
if (!entityContent.complete({ contentHash })) return false;
|
|
496
559
|
}
|
|
@@ -535,9 +598,21 @@ function createRegistrationService({
|
|
|
535
598
|
function identityAt(relativePath) {
|
|
536
599
|
if (!relativePath) return root;
|
|
537
600
|
let cursor = root;
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
601
|
+
const parts = relativePath.split('/');
|
|
602
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
603
|
+
const next = (children.get(cursor.uuid) || []).find((record) => record.name === parts[index]) || null;
|
|
604
|
+
if (next) {
|
|
605
|
+
cursor = next;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
// Inside a repository boundary the catalog goes quiet: the rest of
|
|
609
|
+
// the address is answered by that repo's transport identity map.
|
|
610
|
+
// (A snapshot from before the repo travel law still carries child
|
|
611
|
+
// records; those keep answering above, exactly as they used to.)
|
|
612
|
+
if (cursor.type === 'repo.git') {
|
|
613
|
+
return repoChildIdentity(records.get(cursor.uuid) || cursor, parts.slice(index).join('/'));
|
|
614
|
+
}
|
|
615
|
+
return null;
|
|
541
616
|
}
|
|
542
617
|
return records.get(cursor.uuid) || null;
|
|
543
618
|
}
|
|
@@ -621,12 +696,18 @@ function createRegistrationService({
|
|
|
621
696
|
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
622
697
|
}
|
|
623
698
|
|
|
699
|
+
// One unpack per repository: the live projection consults the same
|
|
700
|
+
// state for its drift check, its registry preload, and its apply.
|
|
701
|
+
const repoStatesByUuid = new Map();
|
|
624
702
|
function repoState(record) {
|
|
703
|
+
const cached = repoStatesByUuid.get(record.uuid);
|
|
704
|
+
if (cached) return cached;
|
|
625
705
|
if (!record.transportVersion) throw new Error(`cloud repository ${record.uuid} has no Repocard transport head`);
|
|
626
706
|
const state = unpack(entityContent.bytes({ contentHash: record.transportVersion }));
|
|
627
707
|
if (state.stateId !== record.payloadVersion) {
|
|
628
708
|
throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
|
|
629
709
|
}
|
|
710
|
+
repoStatesByUuid.set(record.uuid, state);
|
|
630
711
|
return state;
|
|
631
712
|
}
|
|
632
713
|
|
|
@@ -908,9 +989,15 @@ function createRegistrationService({
|
|
|
908
989
|
estimatedRegistrationRecords(targetPath) >= ATOMIC_REGISTRATION_THRESHOLD
|
|
909
990
|
));
|
|
910
991
|
if (!needsAtomicReplacement) return boundary.current.addMany(targetPaths);
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
992
|
+
// The snapshot must describe whatever actually committed, even when a
|
|
993
|
+
// later path refuses (each path is durable alone): without journal
|
|
994
|
+
// rows, a committed-but-unsnapshotted registration would be local
|
|
995
|
+
// truth with zero cloud delivery intent — a ghost nothing repairs.
|
|
996
|
+
try {
|
|
997
|
+
return boundary.current.registry.withoutJournal(() => boundary.current.addMany(targetPaths));
|
|
998
|
+
} finally {
|
|
999
|
+
entityCloud.replaceSnapshot(boundary.current.registry.syncRecords());
|
|
1000
|
+
}
|
|
914
1001
|
},
|
|
915
1002
|
/** `amalgm add <cloud-root-uuid> [parent-directory]` — cloud to local. */
|
|
916
1003
|
add: async ({ uuid, targetPath }) => {
|