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
|
@@ -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
|
}
|
|
@@ -80,7 +80,8 @@
|
|
|
80
80
|
* the folder record and every descendant's derived address follows,
|
|
81
81
|
* which is why folders reconcile before files, parents before
|
|
82
82
|
* children;
|
|
83
|
-
* - repo territories as
|
|
83
|
+
* - repo territories as observer truth, with an explicitly registered
|
|
84
|
+
* repository boundary supplying the corresponding repo.git record;
|
|
84
85
|
* - ADOPTION, the channel law completing across trees: unclaimed
|
|
85
86
|
* ground wearing the physical identity of an orphaned top-level tree
|
|
86
87
|
* is that tree's arrival — one lawful parent update on its
|
|
@@ -604,23 +605,24 @@ function createEvidence(options) {
|
|
|
604
605
|
};
|
|
605
606
|
settleClaims(claimsOf(desiredDirs, 'dir'), 'dir');
|
|
606
607
|
|
|
607
|
-
// Territories after folder continuity, before file ground
|
|
608
|
-
// boundary is
|
|
609
|
-
//
|
|
608
|
+
// Territories after folder continuity, before file ground. A Git
|
|
609
|
+
// boundary is NOT ordinary filesystem evidence: every repo.git record
|
|
610
|
+
// needs a Card + Checkpoint state and immutable transport before it can
|
|
611
|
+
// travel. This generic lens has neither, so it may only recognize a
|
|
612
|
+
// boundary that the registration adapter already made whole. An
|
|
613
|
+
// unregistered repository remains a local observer territory until
|
|
614
|
+
// `amalgm register <repo>` admits it through that adapter.
|
|
615
|
+
//
|
|
616
|
+
// This is deliberately not a convenience gate. Letting this layer
|
|
617
|
+
// mint or promote repo.git would create an identity record whose cloud
|
|
618
|
+
// head names no state — the exact invalid half-repository the cloud
|
|
619
|
+
// contract rejects. Folders and files are evidence-shaped; Git is a
|
|
620
|
+
// type-specific state carrier.
|
|
610
621
|
for (const territory of [...territories].sort()) {
|
|
611
622
|
const parentUuid = ensureDir(path.dirname(territory) === '.' ? '' : path.dirname(territory), root);
|
|
612
623
|
const name = path.basename(territory);
|
|
613
624
|
const at = registry.activeAt(parentUuid, name);
|
|
614
|
-
if (
|
|
615
|
-
// Adoption before birth here too: a registered top-level repo
|
|
616
|
-
// TREE that moved in arrives as its own record, never a fresh
|
|
617
|
-
// territory record. The enclosed observer root's identity is
|
|
618
|
-
// the evidence — the same physical dir the orphaned tree owns.
|
|
619
|
-
const enclosed = observer.listRoots().find((row) => row.path === path.join(groundPath, territory));
|
|
620
|
-
const arrivedRecord = enclosed ? arrivedTree(enclosed.device, enclosed.inode, REPO_TREE) : null;
|
|
621
|
-
if (arrivedRecord) registry.move(arrivedRecord.uuid, { parentUUID: parentUuid, name });
|
|
622
|
-
else registry.create({ type: 'repo.git', parentUUID: parentUuid, name });
|
|
623
|
-
} else if (at.type !== 'repo.git') registry.changeType(at.uuid, 'repo.git');
|
|
625
|
+
if (at?.type !== 'repo.git') continue;
|
|
624
626
|
}
|
|
625
627
|
// A lifted lens is the reverse transition on the same record.
|
|
626
628
|
for (const { record, rel } of walkActive(root.uuid, '', [])) {
|
|
@@ -62,13 +62,50 @@ function remoteList(repoPath) {
|
|
|
62
62
|
}).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
/** Is `rel` named by the suspect list — itself, or under a named directory? */
|
|
66
|
+
function makeSuspectTest(suspects) {
|
|
67
|
+
const named = new Set(suspects);
|
|
68
|
+
return (rel) => {
|
|
69
|
+
if (named.has(rel)) return true;
|
|
70
|
+
let prefix = rel;
|
|
71
|
+
for (;;) {
|
|
72
|
+
const cut = prefix.lastIndexOf('/');
|
|
73
|
+
if (cut < 0) return false;
|
|
74
|
+
prefix = prefix.slice(0, cut);
|
|
75
|
+
if (named.has(prefix)) return true;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The warm half of the checkpoint. Git keeps the Card's ledger itself and
|
|
82
|
+
* its index makes the tracked diffs cheap — the one cost with no cache was
|
|
83
|
+
* untracked files, re-read in full on every capture. With `suspects` (the
|
|
84
|
+
* doorbell's named paths since the last settled state) and the previous
|
|
85
|
+
* state's untracked entries, an unsuspected file's (mode, sha256) is
|
|
86
|
+
* REUSED without opening it: silence from the doorbell is the proof the
|
|
87
|
+
* written answer is still true. Everything else stays a fresh read — a
|
|
88
|
+
* suspect, a new path, or any capture without both inputs (suspects null
|
|
89
|
+
* means "everything is suspect": first capture, flush, catch-up, a command
|
|
90
|
+
* ending). Reused entries carry `content: null`; ids derive from
|
|
91
|
+
* (path, mode, sha256) alone, so a scoped capture's ids are byte-identical
|
|
92
|
+
* to a from-scratch one over the same ground.
|
|
93
|
+
*/
|
|
94
|
+
function untrackedFiles(repoPath, { suspects = null, previousUntracked = null } = {}) {
|
|
95
|
+
const reusable = suspects !== null && previousUntracked
|
|
96
|
+
? new Map(previousUntracked.map((file) => [file.path, file]))
|
|
97
|
+
: null;
|
|
98
|
+
const suspect = reusable ? makeSuspectTest(suspects) : null;
|
|
66
99
|
const out = runGit(repoPath, ['ls-files', '--others', '--exclude-standard', '-z']).toString('utf8');
|
|
67
100
|
return out.split('\0').filter(Boolean).sort().flatMap((rel) => {
|
|
68
101
|
// A trailing slash is git marking a repo boundary it will not enter — a
|
|
69
102
|
// nested repository or linked worktree. That is its own territory with
|
|
70
103
|
// its own card; the parent's checkpoint must not contain it.
|
|
71
104
|
if (rel.endsWith('/')) return [];
|
|
105
|
+
if (reusable && !suspect(rel)) {
|
|
106
|
+
const prev = reusable.get(rel);
|
|
107
|
+
if (prev) return [{ path: rel, mode: prev.mode, content: null, sha256: prev.sha256, reused: true }];
|
|
108
|
+
}
|
|
72
109
|
const abs = path.join(repoPath, rel);
|
|
73
110
|
try {
|
|
74
111
|
const stat = fs.lstatSync(abs);
|
|
@@ -123,9 +160,14 @@ function deriveCheckpointId(checkpoint) {
|
|
|
123
160
|
}));
|
|
124
161
|
}
|
|
125
162
|
|
|
126
|
-
function capture(repoPath, { withBundle = true } = {}) {
|
|
163
|
+
function capture(repoPath, { withBundle = true, suspects = null, previousUntracked = null } = {}) {
|
|
127
164
|
const busy = busyReason(repoPath);
|
|
128
165
|
if (busy) return { busy };
|
|
166
|
+
// Untracked reuse is bundle-free (local) captures only: cloud cargo
|
|
167
|
+
// (prepareRepo) consumes untracked CONTENT, and a reused entry carries
|
|
168
|
+
// none. Teaching the cargo path to dedupe by sha256 is the named next
|
|
169
|
+
// step; until then a withBundle capture always reads every byte.
|
|
170
|
+
const scoped = withBundle ? {} : { suspects, previousUntracked };
|
|
129
171
|
|
|
130
172
|
const branch = tryGitText(repoPath, ['symbolic-ref', '-q', '--short', 'HEAD']); // null = detached
|
|
131
173
|
const commit = tryGitText(repoPath, ['rev-parse', '-q', '--verify', 'HEAD']); // null = unborn
|
|
@@ -146,7 +188,7 @@ function capture(repoPath, { withBundle = true } = {}) {
|
|
|
146
188
|
// raw non-UTF-8 bytes, and a string conversion would corrupt it silently.
|
|
147
189
|
stagedPatch: runGit(repoPath, ['diff', '--cached', '--binary', '--full-index', commit ? 'HEAD' : EMPTY_TREE]),
|
|
148
190
|
unstagedPatch: runGit(repoPath, ['diff', '--binary', '--full-index']),
|
|
149
|
-
untracked: untrackedFiles(repoPath),
|
|
191
|
+
untracked: untrackedFiles(repoPath, scoped),
|
|
150
192
|
};
|
|
151
193
|
|
|
152
194
|
const cardId = deriveCardId(card);
|
|
@@ -95,12 +95,24 @@ function createFollower(options) {
|
|
|
95
95
|
last = null, // resume seed: { cardId, checkpointId }
|
|
96
96
|
// May return a promise (e.g. a capture worker): the follower awaits it,
|
|
97
97
|
// and any doorbell that lands mid-pair re-arms a retry (`pendingWake`).
|
|
98
|
-
|
|
98
|
+
// Called with { suspects }: the worktree paths rung since the last
|
|
99
|
+
// settled state, or null when everything is suspect.
|
|
100
|
+
captureFn = (repo, { suspects } = {}) => capture(repo, { withBundle: false, suspects }),
|
|
99
101
|
} = options;
|
|
100
102
|
if (!repoPath) throw new Error('createFollower requires { repoPath }');
|
|
101
103
|
if (typeof emit !== 'function') throw new Error('createFollower requires { emit }');
|
|
102
104
|
|
|
103
105
|
let known = last ? { cardId: last.cardId, checkpointId: last.checkpointId } : null;
|
|
106
|
+
// The warm-checkpoint scope: which worktree paths the doorbell has named
|
|
107
|
+
// since the last settled state. Each entry remembers the GENERATION of
|
|
108
|
+
// the ring that named it, so a path rung again mid-pair keeps its
|
|
109
|
+
// suspicion when the pair's success consumes the older hints. A nameless
|
|
110
|
+
// ring — or any entrance that may follow missed doorbells (birth, flush,
|
|
111
|
+
// a command ending) — makes EVERYTHING suspect until a pair settles.
|
|
112
|
+
const SCOPE_CAP = 512;
|
|
113
|
+
const scope = new Map(); // repo-relative path -> generation
|
|
114
|
+
let scopeGen = 0;
|
|
115
|
+
let scopeAllGen = 0; // >0: everything is suspect (set at birth, flush, command end, nameless ring)
|
|
104
116
|
let owned = 0; // depth of amalgm-owned commands in flight
|
|
105
117
|
let blocked = null; // lock name while the last capture attempt met a held lock
|
|
106
118
|
let blockedPath = null;
|
|
@@ -210,12 +222,15 @@ function createFollower(options) {
|
|
|
210
222
|
|
|
211
223
|
// The torn-read guard: two consecutive reads must agree before a state
|
|
212
224
|
// may travel — a repo mid-mutation cannot produce the same ids twice.
|
|
225
|
+
// Both halves of the pair read the SAME scope: the paths rung so far.
|
|
226
|
+
const genNow = scopeGen;
|
|
227
|
+
const suspects = scopeAllGen > 0 ? null : [...scope.keys()];
|
|
213
228
|
let first;
|
|
214
229
|
let second;
|
|
215
230
|
try {
|
|
216
|
-
first = await captureFn(repoPath);
|
|
231
|
+
first = await captureFn(repoPath, { suspects });
|
|
217
232
|
if (closed || owned > 0) return; // the pair is stale before it finished
|
|
218
|
-
second = first && !first.busy ? await captureFn(repoPath) : null;
|
|
233
|
+
second = first && !first.busy ? await captureFn(repoPath, { suspects }) : null;
|
|
219
234
|
} catch (error) {
|
|
220
235
|
if (closed) return;
|
|
221
236
|
// A read tripping over a mid-flight writer (a file vanishing between
|
|
@@ -234,11 +249,34 @@ function createFollower(options) {
|
|
|
234
249
|
retry(settleMs);
|
|
235
250
|
return;
|
|
236
251
|
}
|
|
237
|
-
if (!speak(second))
|
|
252
|
+
if (!speak(second)) {
|
|
253
|
+
retry(capMs); // consumer failure: patient, loud, never lost
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// The settled pair proved the ground the scope described: hints from
|
|
257
|
+
// BEFORE the pair began are consumed; anything rung mid-pair keeps its
|
|
258
|
+
// newer generation and stays suspect for the pendingWake retry.
|
|
259
|
+
if (scopeAllGen > 0 && scopeAllGen <= genNow) scopeAllGen = 0;
|
|
260
|
+
for (const [rel, gen] of scope) {
|
|
261
|
+
if (gen <= genNow) scope.delete(rel);
|
|
262
|
+
}
|
|
238
263
|
}
|
|
239
264
|
|
|
240
|
-
function ring() {
|
|
265
|
+
function ring(hint) {
|
|
241
266
|
if (closed || owned > 0) return; // the command's end is the boundary
|
|
267
|
+
// Accumulate the warm-checkpoint scope. A .git-area hint never touches
|
|
268
|
+
// untracked ground, so it schedules a capture without widening the
|
|
269
|
+
// scope; a nameless hint (or an overflowing scope) suspects everything.
|
|
270
|
+
scopeGen += 1;
|
|
271
|
+
if (hint === null || hint === undefined) {
|
|
272
|
+
scopeAllGen = scopeGen;
|
|
273
|
+
} else if (hint !== '.git' && !hint.startsWith('.git/')) {
|
|
274
|
+
scope.set(hint, scopeGen);
|
|
275
|
+
if (scope.size > SCOPE_CAP) {
|
|
276
|
+
scope.clear();
|
|
277
|
+
scopeAllGen = scopeGen;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
242
280
|
if (settleTimer) clearTimeout(settleTimer);
|
|
243
281
|
settleTimer = setTimeout(() => {
|
|
244
282
|
settleTimer = null;
|
|
@@ -262,7 +300,13 @@ function createFollower(options) {
|
|
|
262
300
|
|
|
263
301
|
function endCommand() {
|
|
264
302
|
owned = Math.max(0, owned - 1);
|
|
265
|
-
|
|
303
|
+
// Rings were suppressed while the command ran: its writes are nobody's
|
|
304
|
+
// hints, so the capture at the boundary suspects everything.
|
|
305
|
+
if (owned === 0 && !closed) {
|
|
306
|
+
scopeGen += 1;
|
|
307
|
+
scopeAllGen = scopeGen;
|
|
308
|
+
fire();
|
|
309
|
+
}
|
|
266
310
|
}
|
|
267
311
|
|
|
268
312
|
/** Run an amalgm-owned command: no captures while it runs, one at its end. */
|
|
@@ -284,10 +328,21 @@ function createFollower(options) {
|
|
|
284
328
|
|
|
285
329
|
return {
|
|
286
330
|
ring,
|
|
287
|
-
/** Birth: capture once, immediately
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
331
|
+
/** Birth: capture once, immediately, suspecting everything — rings
|
|
332
|
+
* before birth were nobody's. With a seeded `last`, an unchanged
|
|
333
|
+
* repo is silence. */
|
|
334
|
+
start: () => {
|
|
335
|
+
scopeGen += 1;
|
|
336
|
+
scopeAllGen = scopeGen;
|
|
337
|
+
return fire();
|
|
338
|
+
},
|
|
339
|
+
/** Consumer-requested capture — the "scanNow" of this layer. Its whole
|
|
340
|
+
* purpose is catching a LOST doorbell, so it never trusts the scope. */
|
|
341
|
+
flush: () => {
|
|
342
|
+
scopeGen += 1;
|
|
343
|
+
scopeAllGen = scopeGen;
|
|
344
|
+
return fire();
|
|
345
|
+
},
|
|
291
346
|
beginCommand,
|
|
292
347
|
endCommand,
|
|
293
348
|
command,
|
|
@@ -371,3 +371,93 @@ test('add follows in-order mirrored mutations and refuses after a version gap un
|
|
|
371
371
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
372
372
|
}
|
|
373
373
|
});
|
|
374
|
+
|
|
375
|
+
// --- the repo travel law -----------------------------------------------------
|
|
376
|
+
|
|
377
|
+
function activeCloud({ content = { has: () => true, ensureUpload: () => {} }, repoTransportFor = null } = {}) {
|
|
378
|
+
const db = new Database(':memory:');
|
|
379
|
+
let registry = null;
|
|
380
|
+
const cloud = createEntityCloud({
|
|
381
|
+
db,
|
|
382
|
+
userId: 'user-1',
|
|
383
|
+
deviceId: 'device-1',
|
|
384
|
+
content,
|
|
385
|
+
repoTransportFor,
|
|
386
|
+
recordAt: (uuid) => {
|
|
387
|
+
try { return registry.syncRecord(uuid); } catch { return null; }
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
registry = createRegistry({ db, ephemeral: true, emit: () => {}, journal: cloud.journal });
|
|
391
|
+
const root = registry.enrollRoot({ type: 'workspace', name: 'ground' });
|
|
392
|
+
const bootstrap = cloud.bootstrap(registry.syncRecords());
|
|
393
|
+
cloud.install({
|
|
394
|
+
registry,
|
|
395
|
+
resourceId: bootstrap.resourceId,
|
|
396
|
+
authorityEpoch: 1,
|
|
397
|
+
headVersion: 0,
|
|
398
|
+
snapshotBase64: bootstrap.snapshotBase64,
|
|
399
|
+
snapshotChecksum: bootstrap.snapshotChecksum,
|
|
400
|
+
});
|
|
401
|
+
return { db, cloud, registry, root, resourceId: bootstrap.resourceId };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
test('a record enclosed by a repository never journals its own cloud mutation — the repo state parcel is the carrier', () => {
|
|
405
|
+
const transportHead = 'a'.repeat(64);
|
|
406
|
+
const { db, registry, root, resourceId } = activeCloud({
|
|
407
|
+
repoTransportFor: () => transportHead,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const repo = registry.create({ type: 'repo.git', parentUUID: root.uuid, name: 'engine', payloadVersion: 'state-1' });
|
|
411
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 1,
|
|
412
|
+
'the repository record itself travels');
|
|
413
|
+
|
|
414
|
+
const folder = registry.create({ type: 'folder', parentUUID: repo.uuid, name: 'src' });
|
|
415
|
+
registry.create({ type: 'file.text', parentUUID: folder.uuid, name: 'index.js', payloadVersion: 'b'.repeat(64) });
|
|
416
|
+
registry.create({ type: 'link', parentUUID: repo.uuid, name: 'current', payloadVersion: 'src' });
|
|
417
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 1,
|
|
418
|
+
'worktree children mail no postcards: a register or a rebase is one parcel on the repo entity');
|
|
419
|
+
|
|
420
|
+
const catalog = db.prepare('SELECT entity_uuid FROM entity_cloud_catalog WHERE resource_id = ? ORDER BY entity_uuid').all(resourceId)
|
|
421
|
+
.map((row) => row.entity_uuid).sort();
|
|
422
|
+
assert.deepEqual(catalog, [root.uuid, repo.uuid].sort(),
|
|
423
|
+
'the cloud catalog holds the traveling graph only — no rows for repo-enclosed children');
|
|
424
|
+
|
|
425
|
+
// A later Detect correction on the child (type/payload) stays local too.
|
|
426
|
+
const inner = registry.activeAt(folder.uuid, 'index.js');
|
|
427
|
+
registry.attest(inner.uuid, { type: 'file.binary', payloadVersion: 'c'.repeat(64) });
|
|
428
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 1,
|
|
429
|
+
'the census drip is dead: child corrections never reach the outbox');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test('an atomic snapshot replacement excludes repo-enclosed records and never demands their bytes', () => {
|
|
433
|
+
const transportHead = 'd'.repeat(64);
|
|
434
|
+
const childHead = 'e'.repeat(64);
|
|
435
|
+
const { db, cloud, registry, root, resourceId } = activeCloud({
|
|
436
|
+
repoTransportFor: () => transportHead,
|
|
437
|
+
content: {
|
|
438
|
+
ensureUpload: () => {},
|
|
439
|
+
has: ({ contentHash }) => {
|
|
440
|
+
assert.notEqual(contentHash, childHead, 'an enclosed child head must never be demanded as separate cloud cargo');
|
|
441
|
+
return true;
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
registry.withoutJournal(() => {
|
|
447
|
+
const repo = registry.create({ type: 'repo.git', parentUUID: root.uuid, name: 'engine', payloadVersion: 'state-1' });
|
|
448
|
+
registry.create({ type: 'file.text', parentUUID: repo.uuid, name: 'inner.md', payloadVersion: childHead });
|
|
449
|
+
registry.create({ type: 'file.text', parentUUID: root.uuid, name: 'loose.md', payloadVersion: 'f'.repeat(64) });
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
cloud.replaceSnapshot(registry.syncRecords());
|
|
453
|
+
const operation = JSON.parse(db.prepare(`
|
|
454
|
+
SELECT operation_json FROM entity_cloud_mutations
|
|
455
|
+
WHERE resource_id = ? ORDER BY sequence DESC LIMIT 1
|
|
456
|
+
`).get(resourceId).operation_json);
|
|
457
|
+
assert.equal(operation.kind, 'entity.snapshot.replace');
|
|
458
|
+
const types = operation.snapshot.records.map((record) => record.type).sort();
|
|
459
|
+
assert.deepEqual(types, ['file.text', 'repo.git', 'workspace'],
|
|
460
|
+
'the atomic graph carries the workspace, the loose file, and ONE record per repository');
|
|
461
|
+
assert.ok(!operation.snapshot.records.some((record) => record.name === 'inner.md'),
|
|
462
|
+
'repo children stay out of the published graph');
|
|
463
|
+
});
|