amalgm 0.1.262 → 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/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 +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 +99 -12
- package/runtime/scripts/amalgm-mcp/registration/tree.js +104 -20
- 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 +9 -3
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +10 -6
- package/runtime/scripts/amalgm-mcp/tests/repocard.scope.test.js +182 -0
|
@@ -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 }) => {
|
|
@@ -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
|
}
|
|
@@ -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);
|