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.
Files changed (157) hide show
  1. package/README.md +12 -0
  2. package/lib/cli.js +146 -8
  3. package/lib/layout.js +89 -4
  4. package/lib/shared-realtime-tunnel.js +384 -43
  5. package/lib/supervisor.js +0 -3
  6. package/package.json +2 -2
  7. package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
  8. package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
  9. package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
  10. package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
  11. package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
  12. package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
  13. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
  14. package/runtime/scripts/amalgm-mcp/index.js +4 -0
  15. package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
  16. package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
  17. package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
  18. package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
  19. package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
  20. package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
  21. package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
  22. package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
  23. package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
  24. package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
  25. package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
  26. package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
  27. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
  28. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
  29. package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
  30. package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
  31. package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
  32. package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
  33. package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
  34. package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
  35. package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
  36. package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
  37. package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
  38. package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
  39. package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
  40. package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
  41. package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
  42. package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
  43. package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
  44. package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
  45. package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
  46. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
  47. package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
  48. package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
  49. package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
  50. package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
  51. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
  52. package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
  53. package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
  54. package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
  55. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
  56. package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
  57. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
  58. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
  59. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
  60. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
  61. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
  62. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
  63. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
  64. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
  65. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
  66. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
  67. package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
  68. package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
  69. package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
  70. package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
  71. package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
  72. package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
  73. package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
  74. package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
  75. package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
  76. package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
  77. package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
  78. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
  79. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
  80. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
  81. package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
  82. package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
  83. package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
  84. package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
  85. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
  86. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
  87. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
  88. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
  89. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
  90. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
  91. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
  92. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
  93. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
  94. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
  95. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
  96. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
  97. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
  98. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
  99. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
  100. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
  101. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
  102. package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
  103. package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
  104. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
  105. package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
  106. package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
  107. package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
  108. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
  109. package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
  110. package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
  111. package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
  112. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
  113. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
  114. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
  115. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
  116. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
  117. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
  118. package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
  119. package/runtime/scripts/local-gateway.js +1 -0
  120. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
  121. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
  122. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
  123. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
  124. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
  125. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
  126. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
  127. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
  128. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
  129. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
  130. package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
  131. package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
  132. package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
  133. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
  134. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  135. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  136. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
  137. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
  138. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
  139. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
  140. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
  141. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
  142. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
  143. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
  144. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
  145. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
  146. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
  147. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
  148. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
  149. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
  150. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
  151. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
  152. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
  153. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
  154. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
  155. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
  156. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
  157. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
@@ -0,0 +1,657 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The observer's CORE exams: enrollment births channels, and the compare
5
+ * function decides what a change is — content (an edit is one event
6
+ * carrying both hashes), identity (a rename keeps the channel, a path
7
+ * claims over an inode), echo suppression, doorbell routing, and
8
+ * recovery from a dead watcher. The other seams have their own suites:
9
+ * links, roots, territory — all over the shared rig.
10
+ */
11
+
12
+ const test = require('node:test');
13
+ const assert = require('node:assert/strict');
14
+ const { EventEmitter } = require('events');
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const Database = require('better-sqlite3');
19
+ const { createObserver } = require('../observer');
20
+ const { diffRootState } = require('../observer/scan');
21
+ const {
22
+ cleanup, eventsOf, makeDir, makeObserver, nudgeUntil,
23
+ } = require('./observer.rig');
24
+
25
+ test.after(cleanup);
26
+
27
+ // --- enrollment -----------------------------------------------------------
28
+
29
+ test('enrollment births one channel per enrollable file, with content hashes', () => {
30
+ const dir = makeDir('enroll');
31
+ fs.writeFileSync(path.join(dir, 'notes.md'), 'hello');
32
+ fs.mkdirSync(path.join(dir, 'sub'));
33
+ fs.writeFileSync(path.join(dir, 'sub', 'plan.md'), 'world');
34
+
35
+ const { observer, events } = makeObserver();
36
+ const root = observer.enrollRoot(dir);
37
+
38
+ const created = eventsOf(events, 'file.created');
39
+ assert.deepEqual(created.map((event) => event.path).sort(), ['notes.md', 'sub/plan.md']);
40
+ assert.ok(created.every((event) => /^[0-9a-f]{64}$/.test(event.hash)));
41
+ assert.ok(created.every((event) => event.rootId === root.rootId));
42
+ assert.deepEqual(eventsOf(events, 'dir.created').map((event) => event.path), ['sub'],
43
+ 'a directory is a channel too: enrollment births it beside the files');
44
+ assert.equal(events.length, 3);
45
+ });
46
+
47
+ test('two directories exchange names between scans: both channels follow their inodes', () => {
48
+ const dir = makeDir('dir-swap');
49
+ fs.mkdirSync(path.join(dir, 'north'));
50
+ fs.mkdirSync(path.join(dir, 'south'));
51
+ const { observer, events } = makeObserver();
52
+ const root = observer.enrollRoot(dir);
53
+ const before = new Map(observer.dirsForRoot(root.rootId).map((row) => [row.relPath, row.dirId]));
54
+
55
+ // The swap crosses between two scans; the walk sees only the endpoints.
56
+ fs.renameSync(path.join(dir, 'north'), path.join(dir, 'limbo'));
57
+ fs.renameSync(path.join(dir, 'south'), path.join(dir, 'north'));
58
+ fs.renameSync(path.join(dir, 'limbo'), path.join(dir, 'south'));
59
+ const mark = events.length;
60
+ observer.scanNow(root.rootId);
61
+
62
+ const renamed = eventsOf(events.slice(mark), 'dir.renamed');
63
+ assert.deepEqual(renamed.map((event) => [event.from, event.to, event.dirId]).sort(),
64
+ [['north', 'south', before.get('north')], ['south', 'north', before.get('south')]].sort(),
65
+ 'physical continuity claims first: a known inode at a new address IS the move');
66
+ assert.equal(events.length - mark, renamed.length, 'nothing else spoke');
67
+ assert.deepEqual(observer.verify(root.rootId), []);
68
+
69
+ // The other rule is untouched: a never-seen inode at a known address is
70
+ // recreation — the same channel wearing a new inode, silently.
71
+ fs.rmSync(path.join(dir, 'north'), { recursive: true });
72
+ fs.mkdirSync(path.join(dir, 'north'));
73
+ const quiet = events.length;
74
+ observer.scanNow(root.rootId);
75
+ assert.equal(events.length, quiet, 'recreation under an old name is silence');
76
+ assert.equal(observer.dirsForRoot(root.rootId).find((row) => row.relPath === 'north').dirId,
77
+ before.get('south'), 'the recreated directory wears the same channel');
78
+ });
79
+
80
+ test('a torn dir batch changes nothing: the swap lands as one transaction or not at all', () => {
81
+ const dir = makeDir('dir-swap-torn');
82
+ fs.mkdirSync(path.join(dir, 'north'));
83
+ fs.mkdirSync(path.join(dir, 'south'));
84
+ const db = new Database(':memory:');
85
+ const { observer, events } = makeObserver({ db });
86
+ const root = observer.enrollRoot(dir);
87
+ const before = new Map(observer.dirsForRoot(root.rootId).map((row) => [row.relPath, row.dirId]));
88
+
89
+ fs.renameSync(path.join(dir, 'north'), path.join(dir, 'limbo'));
90
+ fs.renameSync(path.join(dir, 'south'), path.join(dir, 'north'));
91
+ fs.renameSync(path.join(dir, 'limbo'), path.join(dir, 'south'));
92
+
93
+ // The crash strikes after the first mover has landed, injected through
94
+ // the database's own law so the rollback is proven where it must hold.
95
+ db.exec(`CREATE TRIGGER torn BEFORE INSERT ON observer_dirs
96
+ WHEN NEW.rel_path = 'south' BEGIN SELECT RAISE(ABORT, 'injected mid-batch failure'); END`);
97
+ const mark = events.length;
98
+ assert.throws(() => observer.scanNow(root.rootId), /injected mid-batch failure/);
99
+ assert.equal(events.length, mark, 'no doorbell rang for truth that never landed');
100
+ assert.deepEqual(
101
+ new Map(observer.dirsForRoot(root.rootId).map((row) => [row.relPath, row.dirId])), before,
102
+ 'a torn batch changes nothing: the previous truth is whole');
103
+
104
+ // The retry is an ordinary scan: the same batch, both original channels.
105
+ db.exec('DROP TRIGGER torn');
106
+ observer.scanNow(root.rootId);
107
+ const renamed = eventsOf(events.slice(mark), 'dir.renamed');
108
+ assert.deepEqual(renamed.map((event) => [event.from, event.to, event.dirId]).sort(),
109
+ [['north', 'south', before.get('north')], ['south', 'north', before.get('south')]].sort(),
110
+ 'identity survives the crash: the retry crosses with the original channels');
111
+ assert.deepEqual(observer.verify(root.rootId), []);
112
+ });
113
+
114
+ test('an observer store with some of its tables is loss, never a fresh start', () => {
115
+ const dir = makeDir('partial-store');
116
+ fs.mkdirSync(path.join(dir, 'alpha'));
117
+ fs.writeFileSync(path.join(dir, 'alpha', 'note.txt'), 'hello');
118
+ const db = new Database(':memory:');
119
+ const { observer } = makeObserver({ db });
120
+ observer.enrollRoot(dir);
121
+
122
+ // A complete schema reopens freely: the same database carries a second
123
+ // observer (a restart) without ceremony.
124
+ assert.doesNotThrow(() => createObserver({ db, emit: () => {} }),
125
+ 'an established store with its whole schema is an ordinary reopen');
126
+
127
+ // One table gone while the others survive: CREATE IF NOT EXISTS would
128
+ // silently regrow it empty — every channel it held ended behind a root
129
+ // id that still checks out as the witness. The store convicts at open
130
+ // instead: an established store either has its complete schema or it
131
+ // is loss.
132
+ db.exec('DROP TABLE observer_dirs');
133
+ assert.throws(() => createObserver({ db, emit: () => {} }), /identity evidence lost/,
134
+ 'a partial store is loss, never a fresh start');
135
+ });
136
+
137
+ test('an unstamped pre-links store is an older schema generation, refused distinctly from loss', () => {
138
+ const db = new Database(':memory:');
139
+ db.exec(`CREATE TABLE observer_roots (root_id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, kind TEXT NOT NULL);
140
+ CREATE TABLE observer_files (file_id TEXT PRIMARY KEY);
141
+ CREATE TABLE observer_dirs (dir_id TEXT PRIMARY KEY);`);
142
+ assert.throws(() => createObserver({ db, emit: () => {} }), /schema generation 0/,
143
+ 'a legitimate older schema is never confused with partial loss — migration is an explicit act');
144
+ });
145
+
146
+ test('enrollment skips excluded directories and secret files', () => {
147
+ const dir = makeDir('exclude');
148
+ fs.writeFileSync(path.join(dir, 'keep.md'), 'keep');
149
+ fs.writeFileSync(path.join(dir, '.env'), 'SECRET=1');
150
+ fs.writeFileSync(path.join(dir, 'id_rsa'), 'private');
151
+ fs.mkdirSync(path.join(dir, 'node_modules'));
152
+ fs.writeFileSync(path.join(dir, 'node_modules', 'dep.js'), 'x');
153
+
154
+ const { observer, events } = makeObserver();
155
+ observer.enrollRoot(dir);
156
+
157
+ assert.deepEqual(eventsOf(events, 'file.created').map((event) => event.path), ['keep.md']);
158
+ });
159
+
160
+ test('re-enrolling the same root (any spelling) is idempotent', () => {
161
+ const dir = makeDir('idempotent');
162
+ fs.writeFileSync(path.join(dir, 'a.md'), 'a');
163
+
164
+ const { observer, events } = makeObserver();
165
+ const first = observer.enrollRoot(dir);
166
+ const second = observer.enrollRoot(`${dir}${path.sep}`);
167
+ assert.equal(first.rootId, second.rootId);
168
+ assert.equal(eventsOf(events, 'file.created').length, 1);
169
+ });
170
+
171
+ // --- the compare function: content ----------------------------------------
172
+
173
+ test('an external edit is exactly one file.changed carrying old and new hash', () => {
174
+ const dir = makeDir('edit');
175
+ const file = path.join(dir, 'notes.md');
176
+ fs.writeFileSync(file, 'v1');
177
+
178
+ const { observer, events } = makeObserver();
179
+ observer.enrollRoot(dir);
180
+ const born = eventsOf(events, 'file.created')[0];
181
+ events.length = 0;
182
+
183
+ fs.writeFileSync(file, 'v2 with more bytes');
184
+ observer.scanNow();
185
+
186
+ assert.equal(events.length, 1);
187
+ const [changed] = events;
188
+ assert.equal(changed.type, 'file.changed');
189
+ assert.equal(changed.fileId, born.fileId);
190
+ assert.equal(changed.previousHash, born.hash);
191
+ assert.notEqual(changed.hash, born.hash);
192
+ });
193
+
194
+ test('a burst of writes between scans collapses to one event with final content', () => {
195
+ const dir = makeDir('burst');
196
+ const file = path.join(dir, 'notes.md');
197
+ fs.writeFileSync(file, 'v1');
198
+
199
+ const { observer, events } = makeObserver();
200
+ observer.enrollRoot(dir);
201
+ events.length = 0;
202
+
203
+ fs.writeFileSync(file, 'v2');
204
+ fs.writeFileSync(file, 'v3');
205
+ fs.writeFileSync(file, 'v4-final');
206
+ observer.scanNow();
207
+
208
+ assert.equal(eventsOf(events, 'file.changed').length, 1);
209
+ });
210
+
211
+ test('a touch that rewrites identical bytes is silence', () => {
212
+ const dir = makeDir('touch');
213
+ const file = path.join(dir, 'notes.md');
214
+ fs.writeFileSync(file, 'same');
215
+
216
+ const { observer, events } = makeObserver();
217
+ observer.enrollRoot(dir);
218
+ events.length = 0;
219
+
220
+ const future = new Date(Date.now() + 5_000);
221
+ fs.utimesSync(file, future, future); // mtime moves, bytes do not
222
+ observer.scanNow();
223
+ assert.equal(events.length, 0);
224
+
225
+ observer.scanNow(); // and the stat advance persisted: still silence
226
+ assert.equal(events.length, 0);
227
+ });
228
+
229
+ test('an atomic save (write temp, rename over) keeps the channel id', () => {
230
+ const dir = makeDir('atomic');
231
+ const file = path.join(dir, 'notes.md');
232
+ fs.writeFileSync(file, 'v1');
233
+
234
+ const { observer, events } = makeObserver();
235
+ observer.enrollRoot(dir);
236
+ const born = eventsOf(events, 'file.created')[0];
237
+ events.length = 0;
238
+
239
+ // New inode lands at the same path — editors do this on every save.
240
+ const temp = path.join(dir, 'notes.md.tmp-manual');
241
+ fs.writeFileSync(temp, 'v2');
242
+ fs.renameSync(temp, file);
243
+ observer.scanNow();
244
+
245
+ const changed = eventsOf(events, 'file.changed');
246
+ assert.equal(changed.length, 1);
247
+ assert.equal(changed[0].fileId, born.fileId);
248
+ assert.equal(eventsOf(events, 'file.created').length, 0);
249
+ assert.equal(eventsOf(events, 'file.deleted').length, 0);
250
+ });
251
+
252
+ // --- the compare function: identity ----------------------------------------
253
+
254
+ test('a rename keeps the channel id: one file.renamed, no delete, no create', () => {
255
+ const dir = makeDir('rename');
256
+ fs.writeFileSync(path.join(dir, 'old.md'), 'content');
257
+
258
+ const { observer, events } = makeObserver();
259
+ observer.enrollRoot(dir);
260
+ const born = eventsOf(events, 'file.created')[0];
261
+ events.length = 0;
262
+
263
+ fs.renameSync(path.join(dir, 'old.md'), path.join(dir, 'new.md'));
264
+ observer.scanNow();
265
+
266
+ assert.deepEqual(events.map((event) => event.type), ['file.renamed']);
267
+ assert.equal(events[0].fileId, born.fileId);
268
+ assert.equal(events[0].from, 'old.md');
269
+ assert.equal(events[0].to, 'new.md');
270
+ });
271
+
272
+ test('a move across directories with a simultaneous edit is rename + changed on one channel', () => {
273
+ const dir = makeDir('move-edit');
274
+ fs.mkdirSync(path.join(dir, 'a'));
275
+ fs.mkdirSync(path.join(dir, 'b'));
276
+ fs.writeFileSync(path.join(dir, 'a', 'doc.md'), 'v1');
277
+
278
+ const { observer, events } = makeObserver();
279
+ observer.enrollRoot(dir);
280
+ const born = eventsOf(events, 'file.created')[0];
281
+ events.length = 0;
282
+
283
+ fs.renameSync(path.join(dir, 'a', 'doc.md'), path.join(dir, 'b', 'doc.md'));
284
+ fs.appendFileSync(path.join(dir, 'b', 'doc.md'), ' plus v2');
285
+ observer.scanNow();
286
+
287
+ assert.deepEqual(events.map((event) => event.type), ['file.renamed', 'file.changed']);
288
+ assert.ok(events.every((event) => event.fileId === born.fileId));
289
+ });
290
+
291
+ test('deletion removes the channel: one file.deleted', () => {
292
+ const dir = makeDir('delete');
293
+ fs.writeFileSync(path.join(dir, 'gone.md'), 'bye');
294
+
295
+ const { observer, events } = makeObserver();
296
+ observer.enrollRoot(dir);
297
+ const born = eventsOf(events, 'file.created')[0];
298
+ events.length = 0;
299
+
300
+ fs.rmSync(path.join(dir, 'gone.md'));
301
+ observer.scanNow();
302
+
303
+ assert.deepEqual(events.map((event) => event.type), ['file.deleted']);
304
+ assert.equal(events[0].fileId, born.fileId);
305
+ });
306
+
307
+ test('path claims the channel over inode: swap-in at an old path is a change, not a rename', () => {
308
+ const dir = makeDir('path-wins');
309
+ fs.writeFileSync(path.join(dir, 'a.md'), 'original a');
310
+
311
+ const { observer, events } = makeObserver();
312
+ observer.enrollRoot(dir);
313
+ const born = eventsOf(events, 'file.created')[0];
314
+ events.length = 0;
315
+
316
+ // a.md renamed away AND an unrelated file lands at a.md in the same window.
317
+ fs.renameSync(path.join(dir, 'a.md'), path.join(dir, 'archived.md'));
318
+ fs.writeFileSync(path.join(dir, 'a.md'), 'brand new occupant');
319
+ observer.scanNow();
320
+
321
+ // The path a.md is still occupied, so it keeps its channel (rule 1); the
322
+ // inode rescue never runs because the row was never orphaned. archived.md
323
+ // is a new channel. Deterministic, and no bytes are ever lost.
324
+ const renames = eventsOf(events, 'file.renamed');
325
+ assert.equal(renames.length, 0);
326
+ const changed = eventsOf(events, 'file.changed');
327
+ assert.equal(changed.length, 1);
328
+ assert.equal(changed[0].fileId, born.fileId);
329
+ assert.equal(changed[0].path, 'a.md');
330
+ const created = eventsOf(events, 'file.created');
331
+ assert.equal(created.length, 1);
332
+ assert.equal(created[0].path, 'archived.md');
333
+ });
334
+
335
+ test('diffRootState never guesses on ambiguous inode matches', () => {
336
+ const row = (fileId, relPath, inode) => ({
337
+ fileId, relPath, device: 1, inode, size: 1, mtimeMs: 1, contentHash: 'h',
338
+ });
339
+ const entry = (relPath, inode) => ({ relPath, device: 1, inode, size: 2, mtimeMs: 2 });
340
+
341
+ // Two orphan rows share an inode; one orphan entry matches it: no rescue.
342
+ const ambiguous = diffRootState(
343
+ [row('f1', 'a.md', 7), row('f2', 'b.md', 7)],
344
+ [entry('c.md', 7)],
345
+ );
346
+ assert.equal(ambiguous.renames.length, 0);
347
+ assert.equal(ambiguous.creates.length, 1);
348
+ assert.equal(ambiguous.deletes.length, 2);
349
+
350
+ // Exactly one on each side: rescued.
351
+ const clean = diffRootState([row('f1', 'a.md', 7)], [entry('c.md', 7)]);
352
+ assert.equal(clean.renames.length, 1);
353
+ assert.equal(clean.creates.length, 0);
354
+ assert.equal(clean.deletes.length, 0);
355
+ });
356
+
357
+ test('stats alone never prove unchanged: a same-tick write is always re-checked', () => {
358
+ // The racy case: the file's mtime equals the instant we last read its
359
+ // bytes, so a same-size write may hide behind identical stats. The stat
360
+ // shortcut must refuse; only mtime strictly before the read is proof.
361
+ const row = {
362
+ fileId: 'f', relPath: 'a.md', device: 1, inode: 5,
363
+ size: 2, mtimeMs: 1000, contentHash: 'h', hashedAtMs: 1000,
364
+ };
365
+ const entry = { relPath: 'a.md', device: 1, inode: 5, size: 2, mtimeMs: 1000 };
366
+
367
+ const racy = diffRootState([row], [entry]);
368
+ assert.equal(racy.checks.length, 1);
369
+ assert.equal(racy.unchanged, 0);
370
+
371
+ const proven = diffRootState([{ ...row, hashedAtMs: 1001 }], [entry]);
372
+ assert.equal(proven.checks.length, 0);
373
+ assert.equal(proven.unchanged, 1);
374
+
375
+ // A row with no recorded read instant can prove nothing.
376
+ const unknown = diffRootState([{ ...row, hashedAtMs: null }], [entry]);
377
+ assert.equal(unknown.checks.length, 1);
378
+ });
379
+
380
+ test('a doorbell-named path overrules the stat shortcut', () => {
381
+ const row = {
382
+ fileId: 'f', relPath: 'a.md', device: 1, inode: 5,
383
+ size: 4, mtimeMs: 1000, contentHash: 'h', hashedAtMs: 2000,
384
+ };
385
+ const entry = { relPath: 'a.md', device: 1, inode: 5, size: 4, mtimeMs: 1000 };
386
+
387
+ // Quiet scan: stats identical, mtime provably before our read — skipped.
388
+ assert.equal(diffRootState([row], [entry]).unchanged, 1);
389
+
390
+ // The watcher named this exact path: hashed, no matter what stats claim.
391
+ const hinted = diffRootState([row], [entry], new Set(['a.md']));
392
+ assert.equal(hinted.checks.length, 1);
393
+ assert.equal(hinted.unchanged, 0);
394
+ });
395
+
396
+ test('rapid same-size writes between scans are never missed', () => {
397
+ const dir = makeDir('racy-e2e');
398
+ const file = path.join(dir, 'notes.md');
399
+ fs.writeFileSync(file, 'v0');
400
+
401
+ const { observer, events } = makeObserver();
402
+ observer.enrollRoot(dir);
403
+ events.length = 0;
404
+
405
+ // Same byte count every write, scans issued as fast as possible — the
406
+ // window where stats cannot distinguish writes. Every version must land.
407
+ for (let i = 1; i <= 20; i += 1) {
408
+ fs.writeFileSync(file, `v${String(i).padStart(2, '0')}`);
409
+ observer.scanNow();
410
+ const changed = eventsOf(events, 'file.changed');
411
+ assert.equal(changed.length, i, `write ${i} was missed by detection`);
412
+ }
413
+ });
414
+
415
+ // --- echo suppression -------------------------------------------------------
416
+
417
+ test('noteWrite makes our own write invisible to the next scan', () => {
418
+ const dir = makeDir('echo');
419
+ const file = path.join(dir, 'notes.md');
420
+ fs.writeFileSync(file, 'v1');
421
+
422
+ const { observer, events } = makeObserver();
423
+ observer.enrollRoot(dir);
424
+ events.length = 0;
425
+
426
+ fs.writeFileSync(file, 'v2 written by the runtime itself');
427
+ observer.noteWrite(file);
428
+ observer.scanNow();
429
+
430
+ assert.equal(events.length, 0);
431
+ });
432
+
433
+ test('noteWrite on a brand-new file registers the channel without an event', () => {
434
+ const dir = makeDir('echo-create');
435
+ const { observer, events } = makeObserver();
436
+ observer.enrollRoot(dir);
437
+ events.length = 0;
438
+
439
+ const file = path.join(dir, 'made-by-us.md');
440
+ fs.writeFileSync(file, 'runtime artifact');
441
+ observer.noteWrite(file);
442
+ observer.scanNow();
443
+
444
+ assert.equal(events.length, 0);
445
+ const [root] = observer.listRoots();
446
+ assert.equal(observer.filesForRoot(root.rootId).length, 1);
447
+ });
448
+
449
+ // --- doorbells --------------------------------------------------------------
450
+
451
+ test('the doorbell detects an external edit without scanNow being called', async () => {
452
+ const dir = makeDir('doorbell');
453
+ const file = path.join(dir, 'notes.md');
454
+ fs.writeFileSync(file, 'v1');
455
+
456
+ const { observer, events } = makeObserver({ settleMs: 50 });
457
+ observer.enrollRoot(dir);
458
+ observer.start();
459
+ events.length = 0;
460
+
461
+ await nudgeUntil(
462
+ () => fs.writeFileSync(file, 'v2 from outside'),
463
+ () => eventsOf(events, 'file.changed').length > 0,
464
+ );
465
+ observer.stop();
466
+
467
+ assert.equal(eventsOf(events, 'file.changed').length, 1);
468
+ });
469
+
470
+ test('a same-size edit with a backdated mtime is still detected via the doorbell', async () => {
471
+ const dir = makeDir('backdate');
472
+ const file = path.join(dir, 'notes.md');
473
+ fs.writeFileSync(file, 'aaaa');
474
+
475
+ const { observer, events } = makeObserver({ settleMs: 50 });
476
+ observer.enrollRoot(dir);
477
+ observer.start();
478
+ events.length = 0;
479
+
480
+ // Same size, same inode, and the mtime set BACK to its old value: every
481
+ // stat looks untouched, so only the watcher's word says anything happened.
482
+ const before = fs.statSync(file);
483
+ await nudgeUntil(
484
+ () => {
485
+ fs.writeFileSync(file, 'bbbb');
486
+ fs.utimesSync(file, before.atime, before.mtime);
487
+ },
488
+ () => eventsOf(events, 'file.changed').length > 0,
489
+ );
490
+ observer.stop();
491
+
492
+ assert.equal(eventsOf(events, 'file.changed').length, 1);
493
+ });
494
+
495
+ test('a repo root forwards raw doorbells instantly with classified areas', async () => {
496
+ const dir = makeDir('repo');
497
+ fs.mkdirSync(path.join(dir, '.git'));
498
+ fs.writeFileSync(path.join(dir, '.git', 'HEAD'), 'ref: refs/heads/main');
499
+ fs.writeFileSync(path.join(dir, 'main.ts'), 'v1');
500
+
501
+ const { observer, events } = makeObserver({ settleMs: 50 });
502
+ observer.enrollRoot(dir, { kind: 'repo' });
503
+ observer.start();
504
+ // Birth is one discovery; start()'s catch-up rings the repo unconditionally
505
+ // (the observer cannot know what happened while nobody watched — the
506
+ // follower's ids turn "maybe" into silence).
507
+ assert.equal(eventsOf(events, 'repo.discovered').length, 1);
508
+ assert.equal(eventsOf(events, 'repo.changed').length, 1);
509
+ events.length = 0;
510
+
511
+ await nudgeUntil(
512
+ () => {
513
+ fs.writeFileSync(path.join(dir, 'main.ts'), 'v2');
514
+ fs.writeFileSync(path.join(dir, '.git', 'HEAD'), 'ref: refs/heads/other');
515
+ },
516
+ () => new Set(eventsOf(events, 'repo.changed').flatMap((event) => event.areas)).size === 2,
517
+ );
518
+ observer.stop();
519
+
520
+ const areas = new Set(eventsOf(events, 'repo.changed').flatMap((event) => event.areas));
521
+ assert.deepEqual([...areas].sort(), ['git', 'worktree']);
522
+ assert.equal(events.filter((event) => event.type.startsWith('file.')).length, 0);
523
+ });
524
+
525
+ test('a repo born inside a live workspace is detected and claims its ground', async () => {
526
+ const dir = makeDir('live-birth');
527
+ fs.writeFileSync(path.join(dir, 'notes.md'), 'files territory');
528
+
529
+ const { observer, events } = makeObserver({ settleMs: 25 });
530
+ observer.enrollRoot(dir);
531
+ observer.start();
532
+ events.length = 0;
533
+
534
+ // The audited blind spot: a repo appears inside a watched workspace.
535
+ fs.mkdirSync(path.join(dir, 'proj'));
536
+ fs.writeFileSync(path.join(dir, 'proj', 'app.ts'), 'code');
537
+ fs.mkdirSync(path.join(dir, 'proj', '.git'));
538
+ await nudgeUntil(
539
+ () => fs.writeFileSync(path.join(dir, 'proj', '.git', 'HEAD'), 'ref: refs/heads/main'),
540
+ () => eventsOf(events, 'repo.discovered').length > 0,
541
+ );
542
+ const discovered = eventsOf(events, 'repo.discovered');
543
+ assert.equal(discovered.length, 1, 'the new repo was never discovered');
544
+ assert.equal(discovered[0].path, path.join(dir, 'proj'));
545
+ events.length = 0;
546
+
547
+ // Churn inside the new territory rings the repo doorbell, never files.
548
+ await nudgeUntil(
549
+ () => fs.writeFileSync(path.join(dir, 'proj', 'app.ts'), 'checkout churn'),
550
+ () => eventsOf(events, 'repo.changed').length > 0,
551
+ );
552
+ observer.stop();
553
+
554
+ assert.ok(eventsOf(events, 'repo.changed').length >= 1);
555
+ assert.ok(eventsOf(events, 'repo.changed').every((event) => event.rootId === discovered[0].rootId));
556
+ assert.equal(events.filter((event) => event.type.startsWith('file.')).length, 0);
557
+ });
558
+
559
+ // --- recovery ---------------------------------------------------------------
560
+
561
+ test('a dead watcher degrades honestly and the rescan still finds the change', () => {
562
+ const dir = makeDir('degraded');
563
+ fs.writeFileSync(path.join(dir, 'notes.md'), 'v1');
564
+
565
+ const { observer, events } = makeObserver();
566
+ observer.enrollRoot(dir);
567
+ observer.start();
568
+ events.length = 0;
569
+
570
+ // Simulate total watcher loss: stop() closes watchers; an explicit scan
571
+ // (any consumer-driven event) still finds the truth.
572
+ observer.stop();
573
+ fs.writeFileSync(path.join(dir, 'notes.md'), 'v2 missed by any doorbell');
574
+ observer.scanNow();
575
+
576
+ assert.equal(eventsOf(events, 'file.changed').length, 1);
577
+ });
578
+
579
+ test('a fresh observer over the same store resumes channels instead of re-birthing them', () => {
580
+ const dir = makeDir('resume');
581
+ fs.writeFileSync(path.join(dir, 'notes.md'), 'v1');
582
+
583
+ const db = new Database(':memory:');
584
+ const firstEvents = [];
585
+ const first = createObserver({ db, emit: (event) => firstEvents.push(event) });
586
+ first.enrollRoot(dir);
587
+ const born = firstEvents.find((event) => event.type === 'file.created');
588
+
589
+ // New process, same db: the edit made "while we were dead" is one change
590
+ // on the same channel — not a delete + create, not a re-birth.
591
+ fs.writeFileSync(path.join(dir, 'notes.md'), 'v2 while offline');
592
+ const secondEvents = [];
593
+ const second = createObserver({ db, emit: (event) => secondEvents.push(event) });
594
+ second.enrollRoot(dir);
595
+ second.scanNow();
596
+
597
+ assert.deepEqual(secondEvents.map((event) => event.type), ['file.changed']);
598
+ assert.equal(secondEvents[0].fileId, born.fileId);
599
+ });
600
+
601
+ test('start() is the catch-up event: offline edits surface without any explicit scan', () => {
602
+ const dir = makeDir('catch-up');
603
+ const file = path.join(dir, 'notes.md');
604
+ fs.writeFileSync(file, 'v1');
605
+
606
+ const db = new Database(':memory:');
607
+ const firstEvents = [];
608
+ const first = createObserver({ db, emit: (event) => firstEvents.push(event) });
609
+ first.enrollRoot(dir);
610
+ const born = firstEvents.find((event) => event.type === 'file.created');
611
+
612
+ fs.writeFileSync(file, 'v2 while nobody was running');
613
+ const events = [];
614
+ const second = createObserver({ db, emit: (event) => events.push(event) });
615
+ second.start(); // no scanNow call: starting IS the event
616
+ second.stop();
617
+
618
+ assert.deepEqual(events.map((event) => event.type), ['file.changed']);
619
+ assert.equal(events[0].fileId, born.fileId);
620
+ });
621
+
622
+ test('watcher death rebuilds the doorbell and reconciles the gap once', () => {
623
+ const realWatch = fs.watch;
624
+ const fakes = [];
625
+ fs.watch = () => {
626
+ const fake = new EventEmitter();
627
+ fake.close = () => {};
628
+ fakes.push(fake);
629
+ return fake;
630
+ };
631
+ try {
632
+ const dir = makeDir('revive');
633
+ const file = path.join(dir, 'notes.md');
634
+ fs.writeFileSync(file, 'v1');
635
+
636
+ const { observer, events } = makeObserver();
637
+ observer.enrollRoot(dir);
638
+ observer.start();
639
+ assert.equal(fakes.length, 2, 'two doorbells per top-level root: content (recursive) and address (parent)');
640
+ events.length = 0;
641
+
642
+ // The change lands while the doorbell is broken. The death itself is
643
+ // the event: rebuild, then one reconciling scan finds the change.
644
+ fs.writeFileSync(file, 'v2 during the dead window');
645
+ fakes[0].emit('error', new Error('watcher lost'));
646
+
647
+ assert.deepEqual(events.map((event) => event.type), ['file.changed']);
648
+ assert.equal(fakes.length, 3); // a fresh content doorbell was installed
649
+ assert.equal(observer.status()[0].watching, true);
650
+
651
+ fakes[0].emit('error', new Error('echo of the same death'));
652
+ assert.equal(fakes.length, 3); // dead is dead: no rebuild storm
653
+ observer.stop();
654
+ } finally {
655
+ fs.watch = realWatch;
656
+ }
657
+ });