amalgm 0.1.245 → 0.1.246
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/README.md +12 -0
- package/lib/cli.js +146 -8
- package/lib/layout.js +89 -4
- package/lib/shared-realtime-tunnel.js +384 -43
- package/lib/supervisor.js +0 -3
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
- package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
- package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
- package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
- package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
- package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
- package/runtime/scripts/amalgm-mcp/index.js +4 -0
- package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
- package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
- package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
- package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
- package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
- package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
- package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
- package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
- package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
- package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
- package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
- package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
- package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
- package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
- package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
- package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
- package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
- package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
- package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
- package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
- package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
- package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
- package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
- package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
|
@@ -32,15 +32,40 @@ const path = require('path');
|
|
|
32
32
|
const ROOT_MANIFEST_NAME = '.amalgm-root.json';
|
|
33
33
|
const USER_MANIFEST_NAME = '.amalgm-user.json';
|
|
34
34
|
const LAYOUT_VERSION = 1;
|
|
35
|
+
const USER_HOME_MANIFEST_NAME = 'home.json';
|
|
36
|
+
const USER_HOME_SCHEMA_VERSION = 1;
|
|
35
37
|
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
+
// Stable per-user product namespaces. Their authority is NOT inferred from
|
|
39
|
+
// their path: user content and exact platform-managed targets are declared
|
|
40
|
+
// separately below (docs/user-cloud-bootstrap.md).
|
|
38
41
|
const PRIMITIVE_DIRS = Object.freeze([
|
|
39
42
|
'agents',
|
|
40
43
|
'apps',
|
|
41
44
|
'automations',
|
|
42
45
|
'toolbox',
|
|
43
46
|
'workspaces',
|
|
47
|
+
'system',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const USER_HOME_ROOTS = Object.freeze([
|
|
51
|
+
{ key: 'agents', relativePath: 'agents' },
|
|
52
|
+
{ key: 'apps', relativePath: 'apps' },
|
|
53
|
+
{ key: 'automations', relativePath: 'automations' },
|
|
54
|
+
{ key: 'toolbox', relativePath: 'toolbox' },
|
|
55
|
+
{ key: 'workspaces', relativePath: 'workspaces' },
|
|
56
|
+
{ key: 'system', relativePath: 'system' },
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// Platform management is an exact-node fact. It deliberately does not
|
|
60
|
+
// cascade from `system/`: human permissions may still inherit normally, while
|
|
61
|
+
// only these particular entities are product-managed.
|
|
62
|
+
const USER_HOME_EXACT_MANAGED = Object.freeze([
|
|
63
|
+
...USER_HOME_ROOTS.map(({ key, relativePath }) => ({
|
|
64
|
+
key: `${key}-root`, relativePath, rootKey: key,
|
|
65
|
+
})),
|
|
66
|
+
{ key: 'home-manifest', relativePath: 'system/home.json', rootKey: 'system' },
|
|
67
|
+
{ key: 'defaults', relativePath: 'system/defaults', rootKey: 'system' },
|
|
68
|
+
{ key: 'policy', relativePath: 'system/policy', rootKey: 'system' },
|
|
44
69
|
]);
|
|
45
70
|
|
|
46
71
|
function readJson(file, fallback = null) {
|
|
@@ -56,6 +81,51 @@ function writeJson(file, data) {
|
|
|
56
81
|
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
57
82
|
}
|
|
58
83
|
|
|
84
|
+
function declaredUserHome(userDir) {
|
|
85
|
+
const root = path.resolve(userDir);
|
|
86
|
+
return {
|
|
87
|
+
schemaVersion: USER_HOME_SCHEMA_VERSION,
|
|
88
|
+
userDir: root,
|
|
89
|
+
roots: USER_HOME_ROOTS.map((entry) => ({
|
|
90
|
+
...entry,
|
|
91
|
+
path: path.join(root, entry.relativePath),
|
|
92
|
+
})),
|
|
93
|
+
exactManaged: USER_HOME_EXACT_MANAGED.map((entry) => ({
|
|
94
|
+
...entry,
|
|
95
|
+
path: path.join(root, entry.relativePath),
|
|
96
|
+
scope: 'exact',
|
|
97
|
+
})),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Materialize the fixed, safe bootstrap surface. This does not register or
|
|
103
|
+
* synchronize anything; runtime bootstrap calls the shared `amalgm register`
|
|
104
|
+
* boundary for that. Keeping those acts separate prevents login from growing
|
|
105
|
+
* a second identity registrar or silently enrolling machine-local state.
|
|
106
|
+
*/
|
|
107
|
+
function ensureUserHome(userDir) {
|
|
108
|
+
const declaration = declaredUserHome(userDir);
|
|
109
|
+
for (const root of declaration.roots) {
|
|
110
|
+
fs.mkdirSync(root.path, { recursive: true, mode: 0o700 });
|
|
111
|
+
}
|
|
112
|
+
for (const managed of declaration.exactManaged) {
|
|
113
|
+
if (path.extname(managed.path)) continue;
|
|
114
|
+
fs.mkdirSync(managed.path, { recursive: true, mode: 0o700 });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const manifest = declaration.exactManaged.find((entry) => entry.key === 'home-manifest');
|
|
118
|
+
if (manifest && !fs.existsSync(manifest.path)) {
|
|
119
|
+
writeJson(manifest.path, {
|
|
120
|
+
schemaVersion: USER_HOME_SCHEMA_VERSION,
|
|
121
|
+
kind: 'amalgm-user-home',
|
|
122
|
+
roots: declaration.roots.map(({ key, relativePath }) => ({ key, path: relativePath })),
|
|
123
|
+
exactManaged: declaration.exactManaged.map(({ key, relativePath, scope }) => ({ key, path: relativePath, scope })),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return declaration;
|
|
127
|
+
}
|
|
128
|
+
|
|
59
129
|
function lstatOrNull(target) {
|
|
60
130
|
try {
|
|
61
131
|
return fs.lstatSync(target);
|
|
@@ -249,17 +319,25 @@ function ensureUserDir(homeDir, { userId, email }) {
|
|
|
249
319
|
const existing = findUserDirByUserId(homeDir, cleanId);
|
|
250
320
|
if (existing) {
|
|
251
321
|
if (cleanEmail && readUserManifest(existing)?.canonicalEmail !== cleanEmail) {
|
|
252
|
-
|
|
322
|
+
const adopted = adoptUserEmail(homeDir, { userId: cleanId, email: cleanEmail }).dir;
|
|
323
|
+
ensureUserHome(adopted);
|
|
324
|
+
return adopted;
|
|
253
325
|
}
|
|
254
326
|
indexUserFolder(homeDir, path.basename(existing), { userId: cleanId, email: cleanEmail });
|
|
327
|
+
ensureUserHome(existing);
|
|
255
328
|
return existing;
|
|
256
329
|
}
|
|
257
330
|
|
|
258
331
|
const legacyDir = path.join(homeDir, 'users', sanitizeFolderSegment(cleanId));
|
|
259
332
|
if (lstatOrNull(legacyDir)?.isDirectory()) {
|
|
260
333
|
writeUserManifest(legacyDir, { userId: cleanId, email: cleanEmail });
|
|
261
|
-
if (cleanEmail)
|
|
334
|
+
if (cleanEmail) {
|
|
335
|
+
const adopted = adoptUserEmail(homeDir, { userId: cleanId, email: cleanEmail }).dir;
|
|
336
|
+
ensureUserHome(adopted);
|
|
337
|
+
return adopted;
|
|
338
|
+
}
|
|
262
339
|
indexUserFolder(homeDir, path.basename(legacyDir), { userId: cleanId, email: '' });
|
|
340
|
+
ensureUserHome(legacyDir);
|
|
263
341
|
return legacyDir;
|
|
264
342
|
}
|
|
265
343
|
|
|
@@ -268,6 +346,7 @@ function ensureUserDir(homeDir, { userId, email }) {
|
|
|
268
346
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
269
347
|
writeUserManifest(dir, { userId: cleanId, email: cleanEmail });
|
|
270
348
|
indexUserFolder(homeDir, folderName, { userId: cleanId, email: cleanEmail });
|
|
349
|
+
ensureUserHome(dir);
|
|
271
350
|
return dir;
|
|
272
351
|
}
|
|
273
352
|
|
|
@@ -323,8 +402,14 @@ module.exports = {
|
|
|
323
402
|
PRIMITIVE_DIRS,
|
|
324
403
|
ROOT_MANIFEST_NAME,
|
|
325
404
|
USER_MANIFEST_NAME,
|
|
405
|
+
USER_HOME_EXACT_MANAGED,
|
|
406
|
+
USER_HOME_MANIFEST_NAME,
|
|
407
|
+
USER_HOME_ROOTS,
|
|
408
|
+
USER_HOME_SCHEMA_VERSION,
|
|
326
409
|
adoptUserEmail,
|
|
410
|
+
declaredUserHome,
|
|
327
411
|
emailFolderName,
|
|
412
|
+
ensureUserHome,
|
|
328
413
|
ensureRootManifest,
|
|
329
414
|
ensureUserDir,
|
|
330
415
|
createCompatLink,
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# Observer — the change-detection layer
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Notice that bytes on disk changed, and say so exactly once. The observer is
|
|
6
|
+
the single place in the runtime where a local change is born. It never syncs,
|
|
7
|
+
never merges, never decides truth — it emits facts for the layers above.
|
|
8
|
+
|
|
9
|
+
## Axioms
|
|
10
|
+
|
|
11
|
+
1. **Registration is coverage, and every path has exactly one owner —
|
|
12
|
+
permanently.** One observer root per enrolled workspace, repo, or
|
|
13
|
+
single ground (the smallest root there is: one channel, relPath `''`,
|
|
14
|
+
a parent-directory doorbell; a file under the file laws, or an
|
|
15
|
+
explicitly enrolled pointer under the link laws). No caps, no
|
|
16
|
+
side-effect watchers, no "watched because open". A root's identity is
|
|
17
|
+
its rootId and its path is only the current address: each root
|
|
18
|
+
carries its own physical identity (a dir root its inode, a
|
|
19
|
+
single-ground root its channel row's), and enrollment rescues moved
|
|
20
|
+
ground by continuity — unique inode match against a path-orphan
|
|
21
|
+
rebinds the address (`root.moved`, the whole family in ONE store
|
|
22
|
+
transaction), every channel intact; a lookalike at the old address
|
|
23
|
+
is new ground. A scan's evidence horizon is the root's ground, and
|
|
24
|
+
the root's own address is the horizon's edge: ground missing there —
|
|
25
|
+
or a directory root's address wearing a different identity — is
|
|
26
|
+
undecidable (moved? deleted? replaced?), so the scan CONCLUDES
|
|
27
|
+
NOTHING about identity. IDENTITY PERSISTS; LOCAL PRESENCE IS
|
|
28
|
+
SEPARATE EVIDENCE: the undecided address flips one durable `present`
|
|
29
|
+
bit and says so — `root.unavailable` on the way down,
|
|
30
|
+
`root.available` on the way back, one symmetric pair at one choke
|
|
31
|
+
point, never a lifecycle verdict and never a grammar of unrelated
|
|
32
|
+
events. Beliefs freeze, the referee convicts the divergence, and
|
|
33
|
+
the next enrollment moment decides: naming the new address rescues,
|
|
34
|
+
re-naming this one adopts the ground, and explicit removal is the
|
|
35
|
+
only deletion. THE PARENT OBSERVES ADDRESS CHANGES; THE ENTITY
|
|
36
|
+
OBSERVES CONTENT CHANGES: every top-level root also carries a
|
|
37
|
+
non-recursive doorbell on its parent directory, and that doorbell
|
|
38
|
+
HEARS EVERY EVENT THERE — a rename may lawfully deliver only the NEW
|
|
39
|
+
name, so a name filter would decide the answer before looking; the
|
|
40
|
+
cheap address check is what decides (an intact, present address
|
|
41
|
+
returns in one lstat). An undecided address gets ONE DECISION, AT
|
|
42
|
+
EVERY ENTRANCE: the parent doorbell and the root's own scans (the
|
|
43
|
+
settle scan racing a slow delivery, the catch-up scan at start) can
|
|
44
|
+
learn of one rename in either order, and the verdict may never
|
|
45
|
+
depend on which arrived first — whichever entrance finds the
|
|
46
|
+
address undecided makes the same directed look before concluding
|
|
47
|
+
anything, so a followed move has no unavailable moment regardless
|
|
48
|
+
of arrival order, and a same-parent rename made while nothing ran
|
|
49
|
+
is followed at start. The rename is followed for EVERY kind when
|
|
50
|
+
WITNESSED CONTINUITY EVIDENCE claims it — the root's
|
|
51
|
+
kind-appropriate identity as of the last completed scan: a dir root
|
|
52
|
+
its own inode, a single ground its channel row's (which absorbs
|
|
53
|
+
atomic-save churn each time a scan witnesses it) — under the
|
|
54
|
+
exactly-one rule: one candidate of the root's own shape in the
|
|
55
|
+
parent, or no conclusion. The path still claims first (file ground
|
|
56
|
+
is only undecided when nothing file-shaped holds the address),
|
|
57
|
+
ambiguity — two entries wearing one inode, a hardlink beside a
|
|
58
|
+
rename — refuses to guess, and so does MISSING evidence: an atomic
|
|
59
|
+
save renamed away inside its own settle window leaves ground no scan
|
|
60
|
+
ever witnessed (new inode, new bytes, nothing to tie it to the
|
|
61
|
+
channel), so the follow refuses and even a later re-add of the new
|
|
62
|
+
address is honestly a NEW identity — the follow rides evidence,
|
|
63
|
+
never clairvoyance. A departure beyond the parent is honest absence,
|
|
64
|
+
LIVE: `root.unavailable` on the same delivery, reattachment the
|
|
65
|
+
explicit act that names the new address. The remaining honest edge: a
|
|
66
|
+
move BEYOND THE PARENT made while nothing was running, or to ground
|
|
67
|
+
nothing watches,
|
|
68
|
+
stays unavailable until an enrollment moment names it (a move INTO
|
|
69
|
+
registered ground is adopted by that tree's evidence, one level up). A `.git` claims its
|
|
70
|
+
subtree, and
|
|
71
|
+
territory is decided by disk, not by callers: a directory with a `.git`
|
|
72
|
+
enrolls as repo territory; a `.git` appearing inside a live files root
|
|
73
|
+
closes the file channels under it and births a repo root
|
|
74
|
+
(`repo.discovered`); a `.git` vanishing returns the ground to files
|
|
75
|
+
(`repo.removed`) and the channels are reborn. A repo's changes travel as
|
|
76
|
+
one card, never as thousands of file events. Enrollment names ground
|
|
77
|
+
by ADDRESS: the parent chain resolves, the last segment is inspected,
|
|
78
|
+
never traversed — an explicitly enrolled symlink enrolls as the
|
|
79
|
+
pointer itself.
|
|
80
|
+
2. **The doorbell is a hint, never a fact — but a hint only ever raises
|
|
81
|
+
scrutiny.** OS watchers are lossy by contract. Truth is always:
|
|
82
|
+
settle → walk → compare → hash. Stats may excuse a file from hashing
|
|
83
|
+
only when all three hold: nothing named it, its stats are identical,
|
|
84
|
+
and its mtime is strictly older than the moment we last read its bytes
|
|
85
|
+
(git's racily-clean rule). A file the doorbell named is always hashed —
|
|
86
|
+
timestamps can be backdated, a delivered event cannot.
|
|
87
|
+
3. **Every scan is the same code, and every scan has an event.** There is no
|
|
88
|
+
clock. `scanNow` runs at exactly four moments: enrollment (birth), start
|
|
89
|
+
(catch-up for the window we were dead), watcher death (rebuild the
|
|
90
|
+
doorbell, reconcile the gap once), and whenever a consumer asks — a sync
|
|
91
|
+
session opening, a document opening, an app regaining focus are events
|
|
92
|
+
too, and they belong to the layer above.
|
|
93
|
+
4. **Echo suppression is content, not bookkeeping.** A writer records its own
|
|
94
|
+
write (`noteWrite`); a scan that finds the recorded hash finds nothing.
|
|
95
|
+
5. **Identity has two axes, never mixed — and KIND decides which axis
|
|
96
|
+
testifies first.** `fileId → path/inode` answers "which file";
|
|
97
|
+
`fileId → contentHash` answers "did it change". For files the path
|
|
98
|
+
claims the channel — an atomic save replaces the inode in place, so
|
|
99
|
+
the address must absorb that churn — and the inode only rescues
|
|
100
|
+
path-orphans (renames), exactly-one match both sides. Directories are
|
|
101
|
+
channels under the same two rules in the OPPOSITE order, minus
|
|
102
|
+
content: nothing replaces a directory's inode in place, so the inode
|
|
103
|
+
claims first (two directories exchanging names cross by inode,
|
|
104
|
+
exactly like the files inside them) and the path claims only
|
|
105
|
+
recreation — a never-seen inode under an old name is the same
|
|
106
|
+
channel, silently retagged. Nothing to hash: a directory's meaning
|
|
107
|
+
(membership) is derived by the layers above, so empty folders exist
|
|
108
|
+
and folder renames — including exchanges — survive by inode. A
|
|
109
|
+
symlink is a channel in the FILES' order for the file reason (a
|
|
110
|
+
retarget is unlink + recreate, so the address absorbs the inode
|
|
111
|
+
churn; `mv` preserves the inode, so the inode rescues renames) whose
|
|
112
|
+
whole payload is the written target string, read every scan and NEVER
|
|
113
|
+
followed — the real target is watched at its real enrolled address,
|
|
114
|
+
and the edge-following enrollment operation (`edges.js`), not any
|
|
115
|
+
walk, is what turns unclaimed targets into ground. Every root kind
|
|
116
|
+
exposes its edges (`linkEdges`): files ground from detected truth,
|
|
117
|
+
repo ground from a fresh worktree census — a repo's changes travel
|
|
118
|
+
as one card, but its edges are enrollment-policy inputs like any
|
|
119
|
+
other root's.
|
|
120
|
+
|
|
121
|
+
## Shape
|
|
122
|
+
|
|
123
|
+
- `watch.js` — recursive OS doorbell (macOS FSEvents, Windows RDCW, Linux
|
|
124
|
+
inotify via Node ≥20), plus `watchDir`: the non-recursive doorbell
|
|
125
|
+
that hears EVERY event in one directory and delivers the raw name —
|
|
126
|
+
filtering is the consumer's business, because a rename may deliver
|
|
127
|
+
only the new name. Two consumers ride it: the single-file root's main
|
|
128
|
+
doorbell (the file's own inode goes deaf on atomic saves; the parent
|
|
129
|
+
directory does not — events naming the file ring content, foreign
|
|
130
|
+
names ring the cheap address check) and every top-level root's
|
|
131
|
+
ADDRESS doorbell (the parent observes address changes; the entity
|
|
132
|
+
observes content changes). Allowed to die; death is an event — the
|
|
133
|
+
owner rebuilds the doorbell and reconciles once.
|
|
134
|
+
- `scan.js` — walk a root, and the pure compare functions: `diffRootState`
|
|
135
|
+
for files, `diffDirState` for directories (same rules, no content),
|
|
136
|
+
`diffLinkState` for symlinks (the file rules, target string as payload);
|
|
137
|
+
`censusLinks` reads a repo worktree's symlink edges fresh (stored
|
|
138
|
+
nowhere — git owns a repo's memory); `gitDirTruth` is the three-state
|
|
139
|
+
territory look every consumer asks.
|
|
140
|
+
- `apply.js` — where a compare result becomes truth: events published
|
|
141
|
+
and store rows landed, deterministic order, one applier per channel
|
|
142
|
+
kind. The content read is injected, because unsettled suspicion
|
|
143
|
+
surviving onto the root's retry is the wiring's law.
|
|
144
|
+
- `continuity.js` — a root's WITNESSED physical identity, the
|
|
145
|
+
moved-root claim, the undecided-address verdict, and the presence
|
|
146
|
+
bit's only two writers. Questions only: the actions a verdict
|
|
147
|
+
triggers (rebind, follow) re-aim watchers, so they live in the wiring.
|
|
148
|
+
- `verify.js` — the referee, deliberately built from none of the
|
|
149
|
+
machinery above (see Verification below).
|
|
150
|
+
- `store.js` — the layer's whole memory: `observer_roots` (with each
|
|
151
|
+
root's own `device, inode` — identity is permanent, the address is
|
|
152
|
+
not, and a family of roots changes address as ONE transaction — and
|
|
153
|
+
its durable `present` bit: presence survives a restart),
|
|
154
|
+
`observer_files`
|
|
155
|
+
(`file_id, rel_path, device, inode, size, mtime, content_hash`),
|
|
156
|
+
`observer_dirs` (`dir_id, rel_path, device, inode`), and
|
|
157
|
+
`observer_links` (`link_id, rel_path, device, inode, target`), on an
|
|
158
|
+
injected database handle, stamped with its schema generation (5).
|
|
159
|
+
- `index.js` — the wiring, and only the wiring: enroll, settle, scan,
|
|
160
|
+
emit. It owns what the pieces above may not — when a look happens
|
|
161
|
+
(the settle timer and its backoff), what a doorbell means, territory
|
|
162
|
+
births and burials, and the rescue actions that re-aim watchers.
|
|
163
|
+
- `edges.js` — the ratified enrollment policy acting on every root's
|
|
164
|
+
edges (`linkEdges` — repo roots via census, single-ground pointer
|
|
165
|
+
roots via their own one edge): adding a workspace authorizes every
|
|
166
|
+
reachable real target, so this operation resolves each pointer to
|
|
167
|
+
real ground and enrolls EXACTLY the target the link names — a repo
|
|
168
|
+
only when the named target IS the repo root; a file as a
|
|
169
|
+
single-ground root, never its parent — to a fixpoint, idempotent by
|
|
170
|
+
real path (shared targets deduplicate, cycles terminate), add-only
|
|
171
|
+
(a retarget enrolls the new ground and never removes the old). Two
|
|
172
|
+
refusals, both reported: ground the enrollment policy excludes
|
|
173
|
+
(`observer.shouldEnroll` — the SAME policy every route asks, about
|
|
174
|
+
the COMPLETE candidate path, so a benign name inside excluded ground
|
|
175
|
+
like `.ssh/config` is still refused; an edge is never a way around
|
|
176
|
+
it) and a target containing enrolled ground. Enrollment FAILURES are
|
|
177
|
+
never silence: they propagate, the boundary above stays dirty, and
|
|
178
|
+
the retry decides (a vanished target is broken ground next pass).
|
|
179
|
+
The registration boundary above (`registration/`) is what makes
|
|
180
|
+
watched ground REGISTERED ground — a tree per top-level root, keyed
|
|
181
|
+
by permanent rootId — and owns derived link resolution across trees
|
|
182
|
+
(answering with the OWNING entity: repo territory resolves to its
|
|
183
|
+
boundary's record).
|
|
184
|
+
|
|
185
|
+
The observer imports nothing from the rest of the runtime. Enrollment policy
|
|
186
|
+
(`shouldEnroll`) is injected; the default is the workspace inclusion rules.
|
|
187
|
+
|
|
188
|
+
## Events
|
|
189
|
+
|
|
190
|
+
- `file.created { rootId, fileId, path, hash, size }`
|
|
191
|
+
- `file.changed { rootId, fileId, path, hash, previousHash, size }`
|
|
192
|
+
- `file.renamed { rootId, fileId, from, to }`
|
|
193
|
+
- `file.deleted { rootId, fileId, path }`
|
|
194
|
+
- `dir.created { rootId, dirId, path }` / `dir.renamed { rootId, dirId,
|
|
195
|
+
from, to }` / `dir.deleted { rootId, dirId, path }` — no `dir.changed`:
|
|
196
|
+
a directory has no bytes of its own, and a recreation under the same
|
|
197
|
+
name is the same channel wearing a new inode (silent).
|
|
198
|
+
- `link.created { rootId, linkId, path, target }` / `link.changed {
|
|
199
|
+
rootId, linkId, path, target, previousTarget }` (a retarget — the
|
|
200
|
+
written address is the payload) / `link.renamed { rootId, linkId,
|
|
201
|
+
from, to }` / `link.deleted { rootId, linkId, path }` — recreation
|
|
202
|
+
with the same address is silence, the same pointer wearing a new
|
|
203
|
+
inode.
|
|
204
|
+
- `repo.discovered { rootId, path }` / `repo.removed { rootId, path }` —
|
|
205
|
+
territory handoff; the consumer attaches or drops a follower.
|
|
206
|
+
- `root.moved { rootId, from, to }` — continuity rescued a moved root:
|
|
207
|
+
same identity, new address, channels untouched; the follow-up scan
|
|
208
|
+
proves silence. Consumers re-aim address-derived state, nothing else.
|
|
209
|
+
- `root.unavailable { rootId, path }` / `root.available { rootId,
|
|
210
|
+
path }` — the symmetric presence pair: identity persists, local
|
|
211
|
+
presence is separate evidence. Unavailable is never deleted (no
|
|
212
|
+
lifecycle is concluded), available is never new (the same channels
|
|
213
|
+
wake), and a followed move emits neither — continuity never broke.
|
|
214
|
+
- `repo.changed { rootId, areas: ['git'|'worktree'] }` — a raw doorbell,
|
|
215
|
+
forwarded instantly with no settling: quieting and identity belong to the
|
|
216
|
+
follower (repocard/follow.js), and a delayed doorbell would blind its
|
|
217
|
+
torn-read guard. On any catch-up scan a repo root rings unconditionally —
|
|
218
|
+
the observer cannot know what happened while nobody watched; the
|
|
219
|
+
follower's ids turn "maybe" into silence.
|
|
220
|
+
|
|
221
|
+
The store is the identity half of detection's memory: `file_id`/`dir_id`
|
|
222
|
+
are the durable channel identities that survive renames, and the root's own
|
|
223
|
+
id is the store's WITNESS — the registry side binds it, validates it before
|
|
224
|
+
every canonical proposal, and convicts an unknown witness loudly, so a
|
|
225
|
+
lost store is never mistaken for a fresh start. Loss is judged by
|
|
226
|
+
completeness too: an established store either has its whole schema or it
|
|
227
|
+
convicts at open — a silently recreated table would end every channel it
|
|
228
|
+
held behind a witness that still checks out. Bytes are the disposable
|
|
229
|
+
half: re-detection is idempotent because equal hashes are silence.
|
|
230
|
+
Committed store state is always some truth disk actually had: the file loop
|
|
231
|
+
satisfies this per row (file renames never cross — the address claims first,
|
|
232
|
+
so a rename only ever targets a row-free slot), and a directory batch, whose
|
|
233
|
+
movers may cross, lands as ONE transaction with doorbells rung only after
|
|
234
|
+
truth has landed — a crash mid-batch changes nothing, and the next scan
|
|
235
|
+
re-detects the same batch.
|
|
236
|
+
|
|
237
|
+
## The referee
|
|
238
|
+
|
|
239
|
+
`verify()` re-reads enrolled ground and reports any divergence between disk
|
|
240
|
+
truth and the observer's beliefs — including unclaimed territory: a `.git`
|
|
241
|
+
on disk with no registered repo root. It is the referee of the INCREMENTAL
|
|
242
|
+
detector, and only that: it shares no part of the path it judges — no stats
|
|
243
|
+
shortcut, no doorbells, no dirty sets, no compare functions, no applier —
|
|
244
|
+
so no belief the scan reached by shortcut is ever confirmed by that same
|
|
245
|
+
shortcut.
|
|
246
|
+
|
|
247
|
+
It does share detection's PRIMITIVES: the same enrollment policy, the same
|
|
248
|
+
walk, the same truth adapter. That is deliberate — one definition of each,
|
|
249
|
+
none left to drift — and it bounds the verdict exactly: `verify()` grades
|
|
250
|
+
the machinery, never the primitives it asks. Grading those is the oracle
|
|
251
|
+
referee's job (`tests/entity-oracle/referee.js`), which enumerates and
|
|
252
|
+
hashes with its own code and none of this layer's.
|
|
253
|
+
|
|
254
|
+
So the two referees answer different questions and run in different places:
|
|
255
|
+
the storms and the oracle exams end with both, while an audit or shadow
|
|
256
|
+
mode in production has only `verify()` — the oracle is test ground and
|
|
257
|
+
production imports none of it. An empty `verify()` means no change went
|
|
258
|
+
undetected in the ground the primitives reported; that the primitives are
|
|
259
|
+
themselves right is the oracle's proof, not this one's.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a compare result becomes truth: events published, store rows
|
|
5
|
+
* landed, deterministic order — one applier per channel kind. The
|
|
6
|
+
* scan compares (scan.js), this file concludes; the wiring (index.js)
|
|
7
|
+
* decides when. `scanContent` is injected because a file's content read
|
|
8
|
+
* is the wiring's law (unsettled suspicion survives onto the root's
|
|
9
|
+
* retry) — the applier only honors its answer. That answer is the WHOLE
|
|
10
|
+
* content truth, fingerprint and kind together, because one read
|
|
11
|
+
* produced both; the applier never splits them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
|
|
16
|
+
const byPath = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
17
|
+
|
|
18
|
+
function createApply({ store, publish, scanContent }) {
|
|
19
|
+
/**
|
|
20
|
+
* Directory channels: same shape as files, minus content — a rename is
|
|
21
|
+
* the whole event, a retag (recreated under its old name) is silence,
|
|
22
|
+
* and there is nothing to hash. Movers may exchange addresses and land
|
|
23
|
+
* on freshly-deleted ones (directories cross by inode), so the compare
|
|
24
|
+
* result is one truth transition in the store too: one transaction, and
|
|
25
|
+
* the doorbells ring only after truth has landed — a crash mid-batch
|
|
26
|
+
* changes nothing, and the next scan re-detects the same batch.
|
|
27
|
+
* Deterministic order.
|
|
28
|
+
*/
|
|
29
|
+
function applyDirDiff(root, diff) {
|
|
30
|
+
const deletes = diff.deletes.sort((a, b) => byPath(a.relPath, b.relPath));
|
|
31
|
+
const renames = diff.renames.sort((a, b) => byPath(a.entry.relPath, b.entry.relPath));
|
|
32
|
+
const creates = diff.creates.sort((a, b) => byPath(a.relPath, b.relPath))
|
|
33
|
+
.map((entry) => ({ dirId: crypto.randomUUID(), ...entry }));
|
|
34
|
+
store.applyDirDiff({
|
|
35
|
+
deletes: deletes.map((row) => row.dirId),
|
|
36
|
+
moves: renames.map(({ row, entry }) => ({ dirId: row.dirId, rootId: root.rootId, ...entry })),
|
|
37
|
+
retags: diff.retags.map(({ row, entry }) => ({ dirId: row.dirId, ...entry })),
|
|
38
|
+
creates: creates.map((entry) => ({ rootId: root.rootId, ...entry })),
|
|
39
|
+
});
|
|
40
|
+
for (const row of deletes) {
|
|
41
|
+
publish({ type: 'dir.deleted', rootId: root.rootId, dirId: row.dirId, path: row.relPath });
|
|
42
|
+
}
|
|
43
|
+
for (const { row, entry } of renames) {
|
|
44
|
+
publish({ type: 'dir.renamed', rootId: root.rootId, dirId: row.dirId, from: row.relPath, to: entry.relPath });
|
|
45
|
+
}
|
|
46
|
+
for (const entry of creates) {
|
|
47
|
+
publish({ type: 'dir.created', rootId: root.rootId, dirId: entry.dirId, path: entry.relPath });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Link channels: the file shape with the target string as the whole
|
|
53
|
+
* payload — a rename is the move, a retarget is the change (with the
|
|
54
|
+
* previous pointer), and a recreation under an old name with an old
|
|
55
|
+
* target is silence. Renames never cross (the address claims first), so
|
|
56
|
+
* rows commit per-row like files. Deterministic order.
|
|
57
|
+
*/
|
|
58
|
+
function applyLinkDiff(root, diff) {
|
|
59
|
+
for (const { row, entry } of diff.renames.sort((a, b) => byPath(a.entry.relPath, b.entry.relPath))) {
|
|
60
|
+
publish({ type: 'link.renamed', rootId: root.rootId, linkId: row.linkId, from: row.relPath, to: entry.relPath });
|
|
61
|
+
if (row.target !== entry.target) {
|
|
62
|
+
publish({
|
|
63
|
+
type: 'link.changed', rootId: root.rootId, linkId: row.linkId,
|
|
64
|
+
path: entry.relPath, target: entry.target, previousTarget: row.target,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
store.updateLink(row.linkId, entry);
|
|
68
|
+
}
|
|
69
|
+
for (const { row, entry } of diff.changes.sort((a, b) => byPath(a.entry.relPath, b.entry.relPath))) {
|
|
70
|
+
publish({
|
|
71
|
+
type: 'link.changed', rootId: root.rootId, linkId: row.linkId,
|
|
72
|
+
path: entry.relPath, target: entry.target, previousTarget: row.target,
|
|
73
|
+
});
|
|
74
|
+
store.updateLink(row.linkId, entry);
|
|
75
|
+
}
|
|
76
|
+
for (const { row, entry } of diff.retags) {
|
|
77
|
+
store.updateLink(row.linkId, entry); // the same pointer wearing a new inode
|
|
78
|
+
}
|
|
79
|
+
for (const entry of diff.creates.sort((a, b) => byPath(a.relPath, b.relPath))) {
|
|
80
|
+
const linkId = crypto.randomUUID();
|
|
81
|
+
store.insertLink({ linkId, rootId: root.rootId, ...entry });
|
|
82
|
+
publish({ type: 'link.created', rootId: root.rootId, linkId, path: entry.relPath, target: entry.target });
|
|
83
|
+
}
|
|
84
|
+
for (const row of diff.deletes.sort((a, b) => byPath(a.relPath, b.relPath))) {
|
|
85
|
+
store.removeLink(row.linkId);
|
|
86
|
+
publish({ type: 'link.deleted', rootId: root.rootId, linkId: row.linkId, path: row.relPath });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Turn one compare result into events + store updates. Deterministic order. */
|
|
91
|
+
function applyDiff(root, diff) {
|
|
92
|
+
|
|
93
|
+
for (const { row, entry } of diff.renames.sort((a, b) => byPath(a.entry.relPath, b.entry.relPath))) {
|
|
94
|
+
publish({ type: 'file.renamed', rootId: root.rootId, fileId: row.fileId, from: row.relPath, to: entry.relPath });
|
|
95
|
+
// The move is a fact on its own: record it before content is examined,
|
|
96
|
+
// so a file that vanishes mid-hash still deletes under its new name.
|
|
97
|
+
store.updateFile(row.fileId, {
|
|
98
|
+
...entry, relPath: entry.relPath,
|
|
99
|
+
contentHash: row.contentHash, binary: row.binary, hashedAtMs: row.hashedAtMs,
|
|
100
|
+
});
|
|
101
|
+
const hashedAtMs = Date.now(); // Stamped BEFORE reading: a write during our read must force a future re-hash.
|
|
102
|
+
const content = scanContent(root, entry.relPath);
|
|
103
|
+
if (content === null || content.contentHash === row.contentHash) {
|
|
104
|
+
if (content !== null) store.updateFile(row.fileId, { ...entry, relPath: entry.relPath, ...content, hashedAtMs });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
publish({
|
|
108
|
+
type: 'file.changed', rootId: root.rootId, fileId: row.fileId,
|
|
109
|
+
path: entry.relPath, hash: content.contentHash, previousHash: row.contentHash, size: entry.size,
|
|
110
|
+
});
|
|
111
|
+
store.updateFile(row.fileId, { ...entry, relPath: entry.relPath, ...content, hashedAtMs });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const { row, entry } of diff.checks.sort((a, b) => byPath(a.entry.relPath, b.entry.relPath))) {
|
|
115
|
+
const hashedAtMs = Date.now();
|
|
116
|
+
const content = scanContent(root, entry.relPath);
|
|
117
|
+
if (content === null) continue;
|
|
118
|
+
if (content.contentHash !== row.contentHash) {
|
|
119
|
+
publish({
|
|
120
|
+
type: 'file.changed', rootId: root.rootId, fileId: row.fileId,
|
|
121
|
+
path: entry.relPath, hash: content.contentHash, previousHash: row.contentHash, size: entry.size,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// Same hash = a touch or our own write echoing back: silence, but the
|
|
125
|
+
// stat fields advance so the next scan skips it.
|
|
126
|
+
store.updateFile(row.fileId, { ...entry, relPath: entry.relPath, ...content, hashedAtMs });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const entry of diff.creates.sort((a, b) => byPath(a.relPath, b.relPath))) {
|
|
130
|
+
const hashedAtMs = Date.now();
|
|
131
|
+
const content = scanContent(root, entry.relPath);
|
|
132
|
+
if (content === null) continue;
|
|
133
|
+
const fileId = crypto.randomUUID();
|
|
134
|
+
store.insertFile({ fileId, rootId: root.rootId, ...entry, ...content, hashedAtMs });
|
|
135
|
+
publish({
|
|
136
|
+
type: 'file.created', rootId: root.rootId, fileId,
|
|
137
|
+
path: entry.relPath, hash: content.contentHash, size: entry.size,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const row of diff.deletes.sort((a, b) => byPath(a.relPath, b.relPath))) {
|
|
142
|
+
store.removeFile(row.fileId);
|
|
143
|
+
publish({ type: 'file.deleted', rootId: root.rootId, fileId: row.fileId, path: row.relPath });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { applyDiff, applyDirDiff, applyLinkDiff };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { createApply };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Root continuity primitives: what a root's WITNESSED physical identity
|
|
5
|
+
* is, whether its recorded address still holds its ground, and the one
|
|
6
|
+
* presence bit. Pure questions plus the two presence transitions — the
|
|
7
|
+
* ACTIONS a verdict triggers (rebind, the undecided-address decision)
|
|
8
|
+
* stay in the wiring, because they re-aim watchers and scans.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
|
|
13
|
+
function createContinuity({ store, publish }) {
|
|
14
|
+
/**
|
|
15
|
+
* A root's WITNESSED physical identity, for continuity rescue.
|
|
16
|
+
* Directory roots carry their own device:inode (nothing replaces a
|
|
17
|
+
* directory's inode in place). A single-ground root's identity lives
|
|
18
|
+
* in its one channel row — current as of the last completed scan: the
|
|
19
|
+
* file laws absorb atomic-save and retarget churn each time a scan
|
|
20
|
+
* witnesses it, and continuity can only ever claim what a scan has
|
|
21
|
+
* witnessed. Ground that changed inodes inside a still-open settle
|
|
22
|
+
* window is unwitnessed — no evidence, no claim.
|
|
23
|
+
*/
|
|
24
|
+
function rootIdentity(root) {
|
|
25
|
+
if (root.kind !== 'file') {
|
|
26
|
+
return root.device == null ? null : { device: root.device, inode: root.inode };
|
|
27
|
+
}
|
|
28
|
+
const row = store.fileByPath(root.rootId, '') ?? store.linksForRoot(root.rootId)[0];
|
|
29
|
+
return row ? { device: row.device, inode: row.inode } : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Orphaned means the recorded address no longer holds this ground. */
|
|
33
|
+
function orphanedAtAddress(root) {
|
|
34
|
+
const identity = rootIdentity(root);
|
|
35
|
+
if (!identity) return false;
|
|
36
|
+
try {
|
|
37
|
+
const stat = fs.lstatSync(root.path);
|
|
38
|
+
return stat.dev !== identity.device || stat.ino !== identity.inode;
|
|
39
|
+
} catch {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Continuity claims a root exactly like it claims the directories
|
|
46
|
+
* inside one: unique device:inode match, exactly one candidate, and
|
|
47
|
+
* only for a path-orphan — a root whose recorded address still holds
|
|
48
|
+
* its ground is never stolen by a lookalike.
|
|
49
|
+
*/
|
|
50
|
+
function findMovedRoot(stat) {
|
|
51
|
+
const wantDir = stat.isDirectory();
|
|
52
|
+
const matches = store.listRoots().filter((root) => {
|
|
53
|
+
if (wantDir !== (root.kind !== 'file')) return false;
|
|
54
|
+
const identity = rootIdentity(root);
|
|
55
|
+
return Boolean(identity) && identity.device === stat.dev && identity.inode === stat.ino;
|
|
56
|
+
});
|
|
57
|
+
if (matches.length !== 1) return null;
|
|
58
|
+
return orphanedAtAddress(matches[0]) ? matches[0] : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A scan's evidence horizon is the root's ground; the root's own
|
|
63
|
+
* address is the horizon's edge. Ground missing there — or a directory
|
|
64
|
+
* root's address wearing a different physical identity — is
|
|
65
|
+
* UNDECIDABLE from here: moved away, deleted, or replaced by a
|
|
66
|
+
* lookalike all present the same address, and concluding "deleted"
|
|
67
|
+
* would end every channel a rescue could have saved. So the scan
|
|
68
|
+
* concludes nothing: beliefs freeze, the referee convicts the
|
|
69
|
+
* divergence, and the next enrollment moment decides — naming the new
|
|
70
|
+
* address rescues, re-naming this one adopts, and explicit removal is
|
|
71
|
+
* the only deletion (an external filesystem change is never permission
|
|
72
|
+
* to end identity). A single-ground root's address lawfully churns
|
|
73
|
+
* inodes (atomic saves, retargets), so only absence — or foreign
|
|
74
|
+
* directory ground — freezes it; presence is the file laws' business.
|
|
75
|
+
*/
|
|
76
|
+
function addressUndecided(root) {
|
|
77
|
+
let stat;
|
|
78
|
+
try {
|
|
79
|
+
stat = fs.lstatSync(root.path);
|
|
80
|
+
} catch {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
if (root.kind === 'file') return !stat.isFile() && !stat.isSymbolicLink();
|
|
84
|
+
if (!stat.isDirectory()) return true;
|
|
85
|
+
return root.device != null && (stat.dev !== root.device || stat.ino !== root.inode);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Identity persists; local presence is separate evidence. These two
|
|
90
|
+
* transitions are the ONLY writers of `present`, and every scan entry
|
|
91
|
+
* funnels through them — so consumers track one bit through one
|
|
92
|
+
* symmetric event pair, never a grammar of unrelated events. Neither
|
|
93
|
+
* ever concludes lifecycle: unavailable is not deleted, and available
|
|
94
|
+
* is not new.
|
|
95
|
+
*/
|
|
96
|
+
function markAbsent(root) {
|
|
97
|
+
if (!root.present) return;
|
|
98
|
+
store.setRootPresent(root.rootId, false);
|
|
99
|
+
publish({ type: 'root.unavailable', rootId: root.rootId, path: root.path });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function markPresent(root) {
|
|
103
|
+
if (root.present) return;
|
|
104
|
+
store.setRootPresent(root.rootId, true);
|
|
105
|
+
publish({ type: 'root.available', rootId: root.rootId, path: root.path });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { addressUndecided, findMovedRoot, markAbsent, markPresent, rootIdentity };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { createContinuity };
|