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
|
@@ -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');
|
|
@@ -86,6 +87,17 @@ function createRegistrationService({
|
|
|
86
87
|
|
|
87
88
|
const evidenceDb = new Database(path.join(dir, 'observer.db'));
|
|
88
89
|
const identityDb = new Database(path.join(dir, 'identity.db'));
|
|
90
|
+
// WAL, decided here because this root OWNS the handles: SQLite's default
|
|
91
|
+
// journal mode pays a create-fsync-delete journal cycle on every commit,
|
|
92
|
+
// which made a large registration ~65% commit ceremony. WAL appends to
|
|
93
|
+
// one log instead, and synchronous=NORMAL keeps every commit durable
|
|
94
|
+
// against process crash (an OS crash can lose the tail, never corrupt —
|
|
95
|
+
// detection re-derives evidence, and identity commits are re-walked by
|
|
96
|
+
// the registration that was still running).
|
|
97
|
+
for (const handle of [evidenceDb, identityDb]) {
|
|
98
|
+
handle.pragma('journal_mode = WAL');
|
|
99
|
+
handle.pragma('synchronous = NORMAL');
|
|
100
|
+
}
|
|
89
101
|
const entityContent = createEntityContent({ dir });
|
|
90
102
|
// createRegistration owns the schema migration. Give entity-cloud a stable
|
|
91
103
|
// lookup facade now, then connect it to that one migrated state store once
|
|
@@ -95,13 +107,37 @@ function createRegistrationService({
|
|
|
95
107
|
const repoTransportHeads = new Map(); // semantic stateId -> immutable content hash
|
|
96
108
|
const importedRepoStates = new Map(); // cloud UUID -> verified bundle-free state
|
|
97
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
|
+
|
|
98
131
|
/** Git transport is immutable content, not identity-database cargo. The
|
|
99
132
|
* semantic repo stateId remains the entity head; this prepares the exact
|
|
100
133
|
* Card + Checkpoint + bundle payload and leaves only its content hash in
|
|
101
|
-
* SQLite so a large clone cannot bloat the device registry.
|
|
102
|
-
|
|
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 } = {}) {
|
|
103
138
|
if (captured?.busy) return captured;
|
|
104
|
-
const
|
|
139
|
+
const transportIdentity = identity ?? (entityUuid ? worktreeIdentity(entityUuid) : null);
|
|
140
|
+
const artifact = entityContent.captureBytes({ bytes: transportBytes(captured, transportIdentity) });
|
|
105
141
|
repoTransportHeads.set(captured.stateId, artifact.contentHash);
|
|
106
142
|
return localState(captured, artifact.contentHash);
|
|
107
143
|
}
|
|
@@ -137,7 +173,7 @@ function createRegistrationService({
|
|
|
137
173
|
db: identityDb,
|
|
138
174
|
sentinelPath: path.join(dir, 'identity.epoch'),
|
|
139
175
|
journal: entityCloud.journal,
|
|
140
|
-
captureRepo: (repoPath) => prepareRepo(capture(repoPath, { withBundle: true })),
|
|
176
|
+
captureRepo: (repoPath, options) => prepareRepo(capture(repoPath, { withBundle: true }), options),
|
|
141
177
|
prepareRepo,
|
|
142
178
|
repoStateAt: (uuid) => importedRepoStates.get(uuid) || null,
|
|
143
179
|
}),
|
|
@@ -147,10 +183,12 @@ function createRegistrationService({
|
|
|
147
183
|
|
|
148
184
|
let closed = false;
|
|
149
185
|
|
|
150
|
-
/** Count only the
|
|
186
|
+
/** Count only the records that will actually TRAVEL to the cloud. This is
|
|
151
187
|
* not a second registration walker: it is a bounded routing decision that
|
|
152
188
|
* lets a large tree use one atomic cloud graph replacement instead of
|
|
153
|
-
* 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. */
|
|
154
192
|
function estimatedRegistrationRecords(targetPath, limit = ATOMIC_REGISTRATION_THRESHOLD) {
|
|
155
193
|
let total = 0;
|
|
156
194
|
const visit = (candidate) => {
|
|
@@ -159,6 +197,7 @@ function createRegistrationService({
|
|
|
159
197
|
let stat;
|
|
160
198
|
try { stat = fs.lstatSync(candidate); } catch { return; }
|
|
161
199
|
if (!stat.isDirectory() || stat.isSymbolicLink()) return;
|
|
200
|
+
if (gitDirTruth(candidate) === 'present') return;
|
|
162
201
|
let entries;
|
|
163
202
|
try { entries = fs.readdirSync(candidate, { withFileTypes: true }); } catch { return; }
|
|
164
203
|
for (const entry of entries) {
|
|
@@ -471,15 +510,50 @@ function createRegistrationService({
|
|
|
471
510
|
group.push(record);
|
|
472
511
|
children.set(record.parentUUID, group);
|
|
473
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();
|
|
474
516
|
function repoState(record) {
|
|
517
|
+
const cached = repoStatesByUuid.get(record.uuid);
|
|
518
|
+
if (cached) return cached;
|
|
475
519
|
if (!record.transportVersion) throw new Error(`cloud repository ${record.uuid} has no Repocard transport head`);
|
|
476
520
|
const state = unpack(entityContent.bytes({ contentHash: record.transportVersion }));
|
|
477
521
|
if (state.stateId !== record.payloadVersion) {
|
|
478
522
|
throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
|
|
479
523
|
}
|
|
524
|
+
repoStatesByUuid.set(record.uuid, state);
|
|
480
525
|
return state;
|
|
481
526
|
}
|
|
482
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
|
+
|
|
483
557
|
for (const { contentHash } of entityCloud.contentHeads(active)) {
|
|
484
558
|
if (!entityContent.complete({ contentHash })) return false;
|
|
485
559
|
}
|
|
@@ -524,9 +598,21 @@ function createRegistrationService({
|
|
|
524
598
|
function identityAt(relativePath) {
|
|
525
599
|
if (!relativePath) return root;
|
|
526
600
|
let cursor = root;
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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;
|
|
530
616
|
}
|
|
531
617
|
return records.get(cursor.uuid) || null;
|
|
532
618
|
}
|
|
@@ -610,12 +696,18 @@ function createRegistrationService({
|
|
|
610
696
|
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
611
697
|
}
|
|
612
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();
|
|
613
702
|
function repoState(record) {
|
|
703
|
+
const cached = repoStatesByUuid.get(record.uuid);
|
|
704
|
+
if (cached) return cached;
|
|
614
705
|
if (!record.transportVersion) throw new Error(`cloud repository ${record.uuid} has no Repocard transport head`);
|
|
615
706
|
const state = unpack(entityContent.bytes({ contentHash: record.transportVersion }));
|
|
616
707
|
if (state.stateId !== record.payloadVersion) {
|
|
617
708
|
throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
|
|
618
709
|
}
|
|
710
|
+
repoStatesByUuid.set(record.uuid, state);
|
|
619
711
|
return state;
|
|
620
712
|
}
|
|
621
713
|
|
|
@@ -873,7 +965,13 @@ function createRegistrationService({
|
|
|
873
965
|
/** `amalgm register <path>` — register, then begin Watch + Detect. */
|
|
874
966
|
register: async (targetPath) => {
|
|
875
967
|
entityCloud.assertRegistrationReady();
|
|
876
|
-
|
|
968
|
+
// No active cloud replica means no cloud is listening — the journal
|
|
969
|
+
// itself already no-ops there, so a large tree takes the same plain
|
|
970
|
+
// local registration a small one does instead of demanding an atomic
|
|
971
|
+
// replacement that has nowhere to land. Size never changes whether a
|
|
972
|
+
// signed-out registration is allowed.
|
|
973
|
+
const cloudActive = entityCloud.replica()?.state === 'active';
|
|
974
|
+
if (!cloudActive || estimatedRegistrationRecords(targetPath) < ATOMIC_REGISTRATION_THRESHOLD) {
|
|
877
975
|
return boundary.current.add(targetPath);
|
|
878
976
|
}
|
|
879
977
|
const result = boundary.current.registry.withoutJournal(() => boundary.current.add(targetPath));
|
|
@@ -886,13 +984,20 @@ function createRegistrationService({
|
|
|
886
984
|
* materialize. Small selections retain their ordinary per-entity intent. */
|
|
887
985
|
registerMany: async (targetPaths) => {
|
|
888
986
|
entityCloud.assertRegistrationReady();
|
|
889
|
-
const needsAtomicReplacement =
|
|
890
|
-
|
|
891
|
-
|
|
987
|
+
const needsAtomicReplacement = entityCloud.replica()?.state === 'active'
|
|
988
|
+
&& targetPaths.some((targetPath) => (
|
|
989
|
+
estimatedRegistrationRecords(targetPath) >= ATOMIC_REGISTRATION_THRESHOLD
|
|
990
|
+
));
|
|
892
991
|
if (!needsAtomicReplacement) return boundary.current.addMany(targetPaths);
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
+
}
|
|
896
1001
|
},
|
|
897
1002
|
/** `amalgm add <cloud-root-uuid> [parent-directory]` — cloud to local. */
|
|
898
1003
|
add: async ({ uuid, targetPath }) => {
|
|
@@ -44,6 +44,57 @@ function isRepository(dirPath) {
|
|
|
44
44
|
return gitDirTruth(dirPath) === 'present';
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Assign every enrolled worktree entity its permanent UUID BEFORE the
|
|
49
|
+
* repo's transport is packed, so the transport's identity map and the
|
|
50
|
+
* records this walk then creates name the exact same UUIDs. Repo children
|
|
51
|
+
* never travel as individual cloud records (the repo travel law); this
|
|
52
|
+
* map, riding inside the one state parcel, is how a receiving machine
|
|
53
|
+
* binds the same child identities instead of minting competitors.
|
|
54
|
+
*
|
|
55
|
+
* The walk mirrors registerDirectory's classification exactly — same
|
|
56
|
+
* readdir order, same enrollment gate, same lstat shape — and stops at a
|
|
57
|
+
* nested repository boundary: that repo's own capture owns its own map.
|
|
58
|
+
*/
|
|
59
|
+
function collectWorktreeIdentity(repoPath, { shouldEnroll, identityFor }) {
|
|
60
|
+
const assignment = new Map(); // repo-relative path -> { uuid, type }
|
|
61
|
+
const walk = (dirPath, relativePath) => {
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
65
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') return;
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
const entryPath = path.join(dirPath, entry.name);
|
|
72
|
+
const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
73
|
+
if (!shouldEnroll(entryPath)) continue;
|
|
74
|
+
let stat;
|
|
75
|
+
try {
|
|
76
|
+
stat = fs.lstatSync(entryPath);
|
|
77
|
+
} catch {
|
|
78
|
+
continue; // raced away between readdir and look; the create-walk decides
|
|
79
|
+
}
|
|
80
|
+
let type;
|
|
81
|
+
if (stat.isSymbolicLink()) type = 'link';
|
|
82
|
+
else if (stat.isDirectory()) type = isRepository(entryPath) ? 'repo.git' : 'folder';
|
|
83
|
+
else if (stat.isFile()) type = 'file.text';
|
|
84
|
+
else continue;
|
|
85
|
+
const uuid = identityFor?.(entryRelativePath) || crypto.randomUUID();
|
|
86
|
+
assignment.set(entryRelativePath, { uuid, type });
|
|
87
|
+
if (type === 'folder') walk(entryPath, entryRelativePath);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
walk(repoPath, '');
|
|
91
|
+
return assignment;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function transportIdentity(assignment) {
|
|
95
|
+
return [...assignment].map(([entryPath, { uuid, type }]) => ({ path: entryPath, uuid, type }));
|
|
96
|
+
}
|
|
97
|
+
|
|
47
98
|
/**
|
|
48
99
|
* Create the root record, its witness, and every entity below ordinary
|
|
49
100
|
* directory ground. The caller supplies a root that is already known to the
|
|
@@ -90,47 +141,58 @@ function registerTree({ root, registry, memory, shouldEnroll, captureRepo, repoS
|
|
|
90
141
|
/** An imported repository already has a verified Repocard artifact in the
|
|
91
142
|
* local state table before it is bound to Watch. Reuse that exact state;
|
|
92
143
|
* recapturing would waste a second Git bundle and could race the import.
|
|
93
|
-
* Ordinary local registration has no seeded UUID and captures fresh truth
|
|
94
|
-
|
|
144
|
+
* Ordinary local registration has no seeded UUID and captures fresh truth:
|
|
145
|
+
* the worktree's UUIDs are assigned FIRST so the packed transport's
|
|
146
|
+
* identity map and the created child records agree exactly. */
|
|
147
|
+
function repoStateFor(identity, repoPath, relativePath) {
|
|
95
148
|
if (identity.uuid) {
|
|
96
149
|
const imported = repoStateAt?.(identity.uuid);
|
|
97
|
-
if (imported?.stateId === identity.payloadVersion) return imported;
|
|
150
|
+
if (imported?.stateId === identity.payloadVersion) return { state: imported, assignment: null };
|
|
98
151
|
const saved = repoStates.get(identity.uuid);
|
|
99
|
-
if (saved?.stateId === identity.payloadVersion) return saved;
|
|
152
|
+
if (saved?.stateId === identity.payloadVersion) return { state: saved, assignment: null };
|
|
100
153
|
}
|
|
101
|
-
|
|
154
|
+
const assignment = collectWorktreeIdentity(repoPath, {
|
|
155
|
+
shouldEnroll,
|
|
156
|
+
identityFor: (repoRelativePath) => identityAt?.(
|
|
157
|
+
relativePath ? `${relativePath}/${repoRelativePath}` : repoRelativePath,
|
|
158
|
+
)?.uuid || null,
|
|
159
|
+
});
|
|
160
|
+
return {
|
|
161
|
+
state: captureInitialRepo(repoPath, captureRepo, transportIdentity(assignment)),
|
|
162
|
+
assignment,
|
|
163
|
+
};
|
|
102
164
|
}
|
|
103
165
|
|
|
104
166
|
return registry.transaction(() => {
|
|
105
167
|
const rootIdentity = seeded('', {
|
|
106
168
|
type: rootType(root.path, root.kind), parentUUID: null, name: path.basename(root.path), root: true,
|
|
107
169
|
});
|
|
108
|
-
const
|
|
170
|
+
const initial = root.kind === 'repo' ? repoStateFor(rootIdentity, root.path, '') : null;
|
|
109
171
|
// A repo's immutable transport head is already held by the injected
|
|
110
172
|
// cloud-cargo lookup before its entity journal runs. Allocate the UUID,
|
|
111
173
|
// create the FK parent, then persist its bundle-free state in this same
|
|
112
174
|
// transaction. A rollback removes both durable facts together.
|
|
113
|
-
const rootUuid = rootIdentity.uuid || (
|
|
175
|
+
const rootUuid = rootIdentity.uuid || (initial ? crypto.randomUUID() : undefined);
|
|
114
176
|
const record = registry.enrollRoot({
|
|
115
177
|
type: rootIdentity.type,
|
|
116
178
|
name: path.basename(root.path),
|
|
117
|
-
payloadVersion:
|
|
179
|
+
payloadVersion: initial?.state.stateId ?? rootIdentity.payloadVersion ?? null,
|
|
118
180
|
uuid: rootUuid,
|
|
119
181
|
});
|
|
120
182
|
memory.bind(root.rootId, root.rootId, record.uuid);
|
|
121
|
-
if (
|
|
183
|
+
if (initial) repoStates.put(record.uuid, initial.state);
|
|
122
184
|
|
|
123
185
|
if (root.kind === 'files' || root.kind === 'repo') {
|
|
124
186
|
registerDirectory(root.path, record.uuid, {
|
|
125
187
|
registry, shouldEnroll, captureRepo, repoStates, identityAt, repoStateAt, repoStateFor, seeded, relativePath: '',
|
|
126
|
-
});
|
|
188
|
+
}, initial?.assignment ? { base: '', byPath: initial.assignment } : null);
|
|
127
189
|
}
|
|
128
190
|
return record;
|
|
129
191
|
});
|
|
130
192
|
}
|
|
131
193
|
|
|
132
|
-
function captureInitialRepo(repoPath, captureRepo) {
|
|
133
|
-
const state = captureRepo(repoPath);
|
|
194
|
+
function captureInitialRepo(repoPath, captureRepo, identityEntries = null) {
|
|
195
|
+
const state = captureRepo(repoPath, { identity: identityEntries });
|
|
134
196
|
if (state?.busy) {
|
|
135
197
|
throw new Error(`cannot register ${repoPath}: Git is unsettled (${state.busy})`);
|
|
136
198
|
}
|
|
@@ -142,7 +204,7 @@ function captureInitialRepo(repoPath, captureRepo) {
|
|
|
142
204
|
|
|
143
205
|
function registerDirectory(dirPath, parentUUID, {
|
|
144
206
|
registry, shouldEnroll, captureRepo, repoStates, identityAt, repoStateAt, repoStateFor, seeded, relativePath,
|
|
145
|
-
}) {
|
|
207
|
+
}, assigned = null) {
|
|
146
208
|
let entries;
|
|
147
209
|
try {
|
|
148
210
|
entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
@@ -155,6 +217,20 @@ function registerDirectory(dirPath, parentUUID, {
|
|
|
155
217
|
throw error;
|
|
156
218
|
}
|
|
157
219
|
|
|
220
|
+
// The UUID a pre-capture assignment reserved for this exact ground. The
|
|
221
|
+
// transport's identity map was packed from these, so the created record
|
|
222
|
+
// must wear the same UUID — unless the ground changed shape between the
|
|
223
|
+
// assignment walk and this one, in which case the walk's truth wins and
|
|
224
|
+
// the map entry is honestly stale.
|
|
225
|
+
const assignedFor = (entryRelativePath, type) => {
|
|
226
|
+
if (!assigned) return undefined;
|
|
227
|
+
const repoRelativePath = assigned.base
|
|
228
|
+
? entryRelativePath.slice(assigned.base.length + 1)
|
|
229
|
+
: entryRelativePath;
|
|
230
|
+
const seat = assigned.byPath.get(repoRelativePath);
|
|
231
|
+
return seat && seat.type === type ? seat.uuid : undefined;
|
|
232
|
+
};
|
|
233
|
+
|
|
158
234
|
for (const entry of entries) {
|
|
159
235
|
const entryPath = path.join(dirPath, entry.name);
|
|
160
236
|
const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
@@ -173,7 +249,7 @@ function registerDirectory(dirPath, parentUUID, {
|
|
|
173
249
|
// Preserve it through the structural bind; normal local registration
|
|
174
250
|
// still leaves this null until Watch observes the filesystem.
|
|
175
251
|
payloadVersion: identity.payloadVersion ?? null,
|
|
176
|
-
uuid: identity.uuid,
|
|
252
|
+
uuid: identity.uuid ?? assignedFor(entryRelativePath, 'link'),
|
|
177
253
|
});
|
|
178
254
|
continue;
|
|
179
255
|
}
|
|
@@ -182,19 +258,27 @@ function registerDirectory(dirPath, parentUUID, {
|
|
|
182
258
|
const identity = seeded(entryRelativePath, {
|
|
183
259
|
type, parentUUID, name: entry.name, root: false,
|
|
184
260
|
});
|
|
185
|
-
const
|
|
186
|
-
const uuid = identity.uuid
|
|
261
|
+
const initial = type === 'repo.git' ? repoStateFor(identity, entryPath, entryRelativePath) : null;
|
|
262
|
+
const uuid = identity.uuid
|
|
263
|
+
?? assignedFor(entryRelativePath, type)
|
|
264
|
+
?? (initial ? crypto.randomUUID() : undefined);
|
|
187
265
|
const record = registry.create({
|
|
188
266
|
type: identity.type,
|
|
189
267
|
parentUUID,
|
|
190
268
|
name: entry.name,
|
|
191
|
-
payloadVersion: state
|
|
269
|
+
payloadVersion: initial?.state.stateId ?? identity.payloadVersion ?? null,
|
|
192
270
|
uuid,
|
|
193
271
|
});
|
|
194
|
-
if (
|
|
272
|
+
if (initial) repoStates.put(record.uuid, initial.state);
|
|
273
|
+
// A repository starts its own assignment scope: its transport's map
|
|
274
|
+
// covers exactly its worktree, so its children draw from it — never
|
|
275
|
+
// from an enclosing repo's map.
|
|
276
|
+
const childAssigned = type === 'repo.git'
|
|
277
|
+
? (initial?.assignment ? { base: entryRelativePath, byPath: initial.assignment } : null)
|
|
278
|
+
: assigned;
|
|
195
279
|
registerDirectory(entryPath, record.uuid, {
|
|
196
280
|
registry, shouldEnroll, captureRepo, repoStates, identityAt, repoStateAt, repoStateFor, seeded, relativePath: entryRelativePath,
|
|
197
|
-
});
|
|
281
|
+
}, childAssigned);
|
|
198
282
|
continue;
|
|
199
283
|
}
|
|
200
284
|
if (stat.isFile()) {
|
|
@@ -206,7 +290,7 @@ function registerDirectory(dirPath, parentUUID, {
|
|
|
206
290
|
parentUUID,
|
|
207
291
|
name: entry.name,
|
|
208
292
|
payloadVersion: identity.payloadVersion ?? null,
|
|
209
|
-
uuid: identity.uuid,
|
|
293
|
+
uuid: identity.uuid ?? assignedFor(entryRelativePath, 'file.text'),
|
|
210
294
|
});
|
|
211
295
|
}
|
|
212
296
|
}
|
|
@@ -103,6 +103,8 @@
|
|
|
103
103
|
const fs = require('fs');
|
|
104
104
|
const path = require('path');
|
|
105
105
|
|
|
106
|
+
const { preparer } = require('../lib/prepared');
|
|
107
|
+
|
|
106
108
|
const FILE_TYPES = new Set(['file.text', 'file.binary']);
|
|
107
109
|
const LEAF_TYPES = new Set(['file.text', 'file.binary', 'link']);
|
|
108
110
|
// Adoption candidates by ground shape: a dir channel can be an arrived
|
|
@@ -132,6 +134,7 @@ const MEMORY_TABLES = ['registry_meta', 'channel_bindings', 'contested_ground'];
|
|
|
132
134
|
* Retiring a tree drops its whole scope — bindings and markers together.
|
|
133
135
|
*/
|
|
134
136
|
function createEvidenceMemory(db) {
|
|
137
|
+
const prepared = preparer(db);
|
|
135
138
|
const present = db.prepare(
|
|
136
139
|
`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${MEMORY_TABLES.map(() => '?').join(', ')})`,
|
|
137
140
|
).all(...MEMORY_TABLES).map((row) => row.name);
|
|
@@ -145,30 +148,30 @@ function createEvidenceMemory(db) {
|
|
|
145
148
|
}
|
|
146
149
|
return {
|
|
147
150
|
epoch: () => meta.epoch_uuid,
|
|
148
|
-
bindings: (scopeId) =>
|
|
151
|
+
bindings: (scopeId) => prepared('SELECT channel_id, entity_uuid FROM channel_bindings WHERE scope_id = ?')
|
|
149
152
|
.all(scopeId).map((row) => ({ channelId: row.channel_id, uuid: row.entity_uuid })),
|
|
150
|
-
bind: (scopeId, channelId, uuid) =>
|
|
153
|
+
bind: (scopeId, channelId, uuid) => prepared(
|
|
151
154
|
'INSERT INTO channel_bindings (channel_id, scope_id, entity_uuid) VALUES (?, ?, ?)'
|
|
152
155
|
+ ' ON CONFLICT(channel_id) DO UPDATE SET scope_id = excluded.scope_id, entity_uuid = excluded.entity_uuid',
|
|
153
156
|
).run(channelId, scopeId, uuid),
|
|
154
|
-
unbind: (channelId) =>
|
|
157
|
+
unbind: (channelId) => prepared('DELETE FROM channel_bindings WHERE channel_id = ?').run(channelId),
|
|
155
158
|
/** Across scopes — channel ids are globally unique. */
|
|
156
|
-
boundTo: (channelId) =>
|
|
159
|
+
boundTo: (channelId) => prepared('SELECT entity_uuid FROM channel_bindings WHERE channel_id = ?')
|
|
157
160
|
.get(channelId)?.entity_uuid,
|
|
158
|
-
allBindings: () =>
|
|
161
|
+
allBindings: () => prepared('SELECT channel_id, scope_id, entity_uuid FROM channel_bindings').all()
|
|
159
162
|
.map((row) => ({ channelId: row.channel_id, scopeId: row.scope_id, uuid: row.entity_uuid })),
|
|
160
163
|
/** A retired tree's whole evidence namespace, gone as one act. */
|
|
161
164
|
dropScope: (scopeId) => {
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
prepared('DELETE FROM channel_bindings WHERE scope_id = ?').run(scopeId);
|
|
166
|
+
prepared('DELETE FROM contested_ground WHERE scope_id = ?').run(scopeId);
|
|
164
167
|
},
|
|
165
|
-
contests: (scopeId) =>
|
|
168
|
+
contests: (scopeId) => prepared('SELECT rel_path, claimant_uuid, kind FROM contested_ground WHERE scope_id = ?')
|
|
166
169
|
.all(scopeId).map((row) => ({ relPath: row.rel_path, uuid: row.claimant_uuid, kind: row.kind })),
|
|
167
|
-
contest: (scopeId, relPath, uuid, kind) =>
|
|
170
|
+
contest: (scopeId, relPath, uuid, kind) => prepared(
|
|
168
171
|
'INSERT INTO contested_ground (scope_id, rel_path, claimant_uuid, kind) VALUES (?, ?, ?, ?)'
|
|
169
172
|
+ ' ON CONFLICT(scope_id, rel_path) DO UPDATE SET claimant_uuid = excluded.claimant_uuid, kind = excluded.kind',
|
|
170
173
|
).run(scopeId, relPath, uuid, kind),
|
|
171
|
-
clearContest: (scopeId, relPath) =>
|
|
174
|
+
clearContest: (scopeId, relPath) => prepared('DELETE FROM contested_ground WHERE scope_id = ? AND rel_path = ?')
|
|
172
175
|
.run(scopeId, relPath),
|
|
173
176
|
};
|
|
174
177
|
}
|
|
@@ -359,6 +362,27 @@ function createEvidence(options) {
|
|
|
359
362
|
function reconcile() {
|
|
360
363
|
dirty = false; // an event landing mid-reconcile re-dirties honestly
|
|
361
364
|
try {
|
|
365
|
+
// One reconciliation is ONE durable truth transition: a first census
|
|
366
|
+
// over thousands of channels lands as one commit, not one per record.
|
|
367
|
+
// The registry's own transaction defers every bell to the commit and
|
|
368
|
+
// discards them all on rollback, so a failed pass publishes nothing.
|
|
369
|
+
registry.transaction(pass);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
// The transaction rolled every durable write back; the in-memory
|
|
372
|
+
// caches may still hold entries from the rolled-back half. Durable
|
|
373
|
+
// truth first, cache second — so the caches reload from the tables
|
|
374
|
+
// before the retry, never believing a write that no longer exists.
|
|
375
|
+
channelToUuid.clear();
|
|
376
|
+
for (const row of memory.bindings(rootId)) channelToUuid.set(row.channelId, row.uuid);
|
|
377
|
+
contested.clear();
|
|
378
|
+
for (const row of memory.contests(rootId)) contested.set(row.relPath, { uuid: row.uuid, kind: row.kind });
|
|
379
|
+
dirty = true;
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function pass() {
|
|
385
|
+
{
|
|
362
386
|
const rootRow = observer.listRoots().find((row) => row.rootId === rootId);
|
|
363
387
|
if (!rootRow) return; // not enrolled yet — nothing is claimed
|
|
364
388
|
groundPath = rootRow.path; // the address is derived, never the lens's identity
|
|
@@ -771,9 +795,6 @@ function createEvidence(options) {
|
|
|
771
795
|
: null);
|
|
772
796
|
}
|
|
773
797
|
for (const [relPath, hash] of nowDivergent) divergent.set(relPath, hash);
|
|
774
|
-
} catch (error) {
|
|
775
|
-
dirty = true;
|
|
776
|
-
throw error;
|
|
777
798
|
}
|
|
778
799
|
}
|
|
779
800
|
|