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,675 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { execFileSync, spawn } = require('child_process');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+
10
+ const Database = require('better-sqlite3');
11
+ const { createObserver } = require('../observer');
12
+ const { capture, createFollower, createCaptureWorker } = require('../repocard');
13
+
14
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-repofollow-test-'));
15
+ test.after(() => {
16
+ fs.rmSync(tempRoot, { recursive: true, force: true });
17
+ });
18
+
19
+ function git(dir, ...args) {
20
+ return execFileSync('git', args, { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] }).toString('utf8').trim();
21
+ }
22
+
23
+ function makeRepo(name) {
24
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(tempRoot, `${name}-`)));
25
+ git(dir, 'init', '--quiet');
26
+ git(dir, 'config', 'user.email', 'follow@amalgm.test');
27
+ git(dir, 'config', 'user.name', 'Follow');
28
+ git(dir, 'config', 'commit.gpgsign', 'false');
29
+ fs.writeFileSync(path.join(dir, '.gitignore'), 'ignored/\n');
30
+ fs.writeFileSync(path.join(dir, 'a.md'), 'v1\n');
31
+ git(dir, 'add', '-A');
32
+ git(dir, 'commit', '--quiet', '-m', 'first');
33
+ return dir;
34
+ }
35
+
36
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
37
+
38
+ async function until(predicate, ms = 4_000) {
39
+ const deadline = Date.now() + ms;
40
+ while (!predicate() && Date.now() < deadline) await sleep(15);
41
+ return predicate();
42
+ }
43
+
44
+ /** Fast timings: settle short, cap well above it. */
45
+ const T = { settleMs: 40, capMs: 500 };
46
+ const SETTLED = T.settleMs + 150; // one full quiet cycle plus margin
47
+
48
+ function makeFollower(repoPath, overrides = {}) {
49
+ const events = [];
50
+ const follower = createFollower({ repoPath, emit: (event) => events.push(event), ...T, ...overrides });
51
+ return { follower, events };
52
+ }
53
+
54
+ // --- identity semantics -----------------------------------------------------
55
+
56
+ test('birth is one card event; an unchanged repo re-rung is silence', async () => {
57
+ const dir = makeRepo('birth');
58
+ const { follower, events } = makeFollower(dir);
59
+
60
+ follower.start();
61
+ await until(() => events.length > 0);
62
+ assert.equal(events.length, 1);
63
+ assert.equal(events[0].type, 'card.changed');
64
+ assert.ok(events[0].cardId && events[0].checkpointId && events[0].stateId);
65
+
66
+ follower.ring();
67
+ await sleep(SETTLED);
68
+ assert.equal(events.length, 1, 'an unchanged repo must stay silent');
69
+ follower.close();
70
+ });
71
+
72
+ test('a seeded follower resumes without re-announcing the world', async () => {
73
+ const dir = makeRepo('resume');
74
+ const known = capture(dir, { withBundle: false });
75
+ const { follower, events } = makeFollower(dir, { last: known });
76
+
77
+ follower.start();
78
+ await sleep(SETTLED);
79
+ assert.equal(events.length, 0);
80
+
81
+ fs.writeFileSync(path.join(dir, 'a.md'), 'edited after resume\n');
82
+ follower.ring();
83
+ await until(() => events.length > 0);
84
+ assert.equal(events.length, 1);
85
+ assert.equal(events[0].type, 'checkpoint.changed');
86
+ follower.close();
87
+ });
88
+
89
+ test('an edit is one checkpoint event; a commit is one card event', async () => {
90
+ const dir = makeRepo('classify');
91
+ const { follower, events } = makeFollower(dir, { last: capture(dir, { withBundle: false }) });
92
+ follower.start();
93
+
94
+ fs.writeFileSync(path.join(dir, 'a.md'), 'v2\n');
95
+ follower.ring();
96
+ await until(() => events.length === 1);
97
+ assert.equal(events[0].type, 'checkpoint.changed');
98
+
99
+ git(dir, 'add', '-A');
100
+ git(dir, 'commit', '--quiet', '-m', 'v2');
101
+ follower.ring();
102
+ await until(() => events.length === 2);
103
+ assert.equal(events[1].type, 'card.changed');
104
+ await sleep(SETTLED);
105
+ assert.equal(events.length, 2);
106
+ follower.close();
107
+ });
108
+
109
+ test('ignored churn rings loudly and emits nothing', async () => {
110
+ const dir = makeRepo('ignored');
111
+ const { follower, events } = makeFollower(dir, { last: capture(dir, { withBundle: false }) });
112
+ follower.start();
113
+
114
+ fs.mkdirSync(path.join(dir, 'ignored'));
115
+ for (let i = 0; i < 20; i += 1) {
116
+ fs.writeFileSync(path.join(dir, 'ignored', `noise-${i}.log`), `${i}\n`);
117
+ follower.ring();
118
+ }
119
+ await sleep(SETTLED + T.capMs);
120
+ assert.equal(events.length, 0, 'ignored churn changed no id and must be silence');
121
+ follower.close();
122
+ });
123
+
124
+ // --- the torn-read guard ------------------------------------------------------
125
+
126
+ test('a capture raced by a write is discarded and retried by the follower itself', async () => {
127
+ const dir = makeRepo('raced');
128
+ let captures = 0;
129
+ let sabotage = 1;
130
+ const events = [];
131
+ const follower = createFollower({
132
+ repoPath: dir,
133
+ emit: (event) => events.push(event),
134
+ ...T,
135
+ captureFn: (repo) => {
136
+ captures += 1;
137
+ const snap = capture(repo, { withBundle: false });
138
+ if (sabotage > 0) {
139
+ // A write lands between the two reads: the pair must disagree, the
140
+ // half-written moment must never travel, and — deliberately — NO
141
+ // doorbell rings for this write. The retry must be the follower's own.
142
+ sabotage -= 1;
143
+ fs.writeFileSync(path.join(dir, 'a.md'), 'the final content\n');
144
+ }
145
+ return snap;
146
+ },
147
+ last: capture(dir, { withBundle: false }),
148
+ });
149
+
150
+ fs.writeFileSync(path.join(dir, 'a.md'), 'racing\n');
151
+ follower.ring();
152
+ await until(() => events.length > 0);
153
+ await sleep(SETTLED);
154
+
155
+ assert.equal(events.length, 1, 'the raced capture must not emit a second event');
156
+ assert.equal(events[0].type, 'checkpoint.changed');
157
+ assert.ok(captures >= 4, 'the raced pair must be retried as a fresh pair, not trusted');
158
+ const settled = capture(dir, { withBundle: false });
159
+ assert.equal(events[0].stateId, settled.stateId, 'only the final still state may travel');
160
+ follower.close();
161
+ });
162
+
163
+ test('an emit failure never advances the remembered state — the event is re-offered', async () => {
164
+ const dir = makeRepo('emit-fail');
165
+ const events = [];
166
+ let failures = 1;
167
+ const follower = createFollower({
168
+ repoPath: dir,
169
+ emit: (event) => {
170
+ if (failures > 0) {
171
+ failures -= 1;
172
+ throw new Error('consumer down');
173
+ }
174
+ events.push(event);
175
+ },
176
+ ...T,
177
+ last: capture(dir, { withBundle: false }),
178
+ });
179
+
180
+ fs.writeFileSync(path.join(dir, 'a.md'), 'must not be lost\n');
181
+ follower.ring();
182
+ // No further rings: the re-offer must be the follower's own retry.
183
+ const delivered = await until(() => events.length > 0, 5_000);
184
+ assert.ok(delivered, 'a failed emit silently advanced the remembered state — the event is lost');
185
+ assert.equal(events.length, 1);
186
+ assert.equal(events[0].type, 'checkpoint.changed');
187
+ const truth = capture(dir, { withBundle: false });
188
+ assert.equal(events[0].stateId, truth.stateId);
189
+ assert.deepEqual(follower.ids(), { cardId: truth.cardId, checkpointId: truth.checkpointId });
190
+ follower.close();
191
+ });
192
+
193
+ test('under endless churn the cap still speaks — via a proven-still capture', async () => {
194
+ const dir = makeRepo('cap');
195
+ const events = [];
196
+ // Churn every 60ms: settle (100ms) never fires. The cap does, and the
197
+ // double-read proof passes because ignored churn cannot move the ids.
198
+ const follower = createFollower({
199
+ repoPath: dir,
200
+ emit: (event) => events.push(event),
201
+ settleMs: 100,
202
+ capMs: 300,
203
+ last: capture(dir, { withBundle: false }),
204
+ });
205
+
206
+ fs.writeFileSync(path.join(dir, 'a.md'), 'the real change\n');
207
+ fs.mkdirSync(path.join(dir, 'ignored'));
208
+ let beat = 0;
209
+ const churn = setInterval(() => {
210
+ beat += 1;
211
+ fs.writeFileSync(path.join(dir, 'ignored', 'noise.log'), `${beat}\n`);
212
+ follower.ring();
213
+ }, 60);
214
+
215
+ const spoke = await until(() => events.length > 0, 6_000);
216
+ clearInterval(churn);
217
+ assert.ok(spoke, 'sustained churn starved detection: the cap never landed a capture');
218
+ assert.equal(events[0].type, 'checkpoint.changed');
219
+ follower.close();
220
+ });
221
+
222
+ // --- git's own boundaries -----------------------------------------------------
223
+
224
+ test('a busy merge emits nothing; its resolution is one card event', async () => {
225
+ const dir = makeRepo('busy');
226
+ git(dir, 'checkout', '--quiet', '-b', 'left');
227
+ fs.writeFileSync(path.join(dir, 'a.md'), 'left\n');
228
+ git(dir, 'add', '-A');
229
+ git(dir, 'commit', '--quiet', '-m', 'left');
230
+ git(dir, 'checkout', '--quiet', '-');
231
+ fs.writeFileSync(path.join(dir, 'a.md'), 'right\n');
232
+ git(dir, 'add', '-A');
233
+ git(dir, 'commit', '--quiet', '-m', 'right');
234
+
235
+ const { follower, events } = makeFollower(dir, { last: capture(dir, { withBundle: false }) });
236
+ follower.start();
237
+
238
+ assert.throws(() => git(dir, 'merge', '--quiet', 'left'));
239
+ follower.ring();
240
+ await sleep(SETTLED + T.capMs);
241
+ assert.equal(events.length, 0, 'a mid-merge repo must never travel');
242
+
243
+ git(dir, 'add', '-A');
244
+ git(dir, 'commit', '--quiet', '--no-edit');
245
+ follower.ring();
246
+ await until(() => events.length > 0);
247
+ assert.equal(events.length, 1);
248
+ assert.equal(events[0].type, 'card.changed');
249
+ follower.close();
250
+ });
251
+
252
+ test('ref-less commands travel as checkpoint moves: staging, then hard reset', async () => {
253
+ // The axiom's second half: commands that move no ref (add, reset --hard
254
+ // HEAD; clean has its own test) still end at a new checkpointId.
255
+ const dir = makeRepo('refless');
256
+ fs.writeFileSync(path.join(dir, 'a.md'), 'dirty\n');
257
+ const seed = capture(dir, { withBundle: false });
258
+ const { follower, events } = makeFollower(dir, { last: seed });
259
+ follower.start();
260
+ await sleep(SETTLED);
261
+ assert.equal(events.length, 0, 'the seeded dirty state must be silence');
262
+
263
+ git(dir, 'add', '-A'); // index-only: no worktree file touched, no ref moved
264
+ follower.ring();
265
+ await until(() => events.length === 1);
266
+ assert.equal(events[0].type, 'checkpoint.changed');
267
+ assert.equal(events[0].cardId, seed.cardId, 'staging must not move the card');
268
+
269
+ git(dir, 'reset', '--quiet', '--hard', 'HEAD'); // reverts index+worktree; HEAD stays put
270
+ follower.ring();
271
+ await until(() => events.length === 2);
272
+ assert.equal(events[1].type, 'checkpoint.changed');
273
+ assert.equal(events[1].cardId, seed.cardId, 'a hard reset to HEAD must not move the card');
274
+ assert.notEqual(events[1].checkpointId, events[0].checkpointId);
275
+ await sleep(SETTLED);
276
+ assert.equal(events.length, 2);
277
+ follower.close();
278
+ });
279
+
280
+ /** A living process holding the lock file open — what a real git does. */
281
+ function holdOpen(filePath) {
282
+ const child = spawn(process.execPath, ['-e',
283
+ 'const fs=require("fs");fs.openSync(process.argv[1],"w");process.stdout.write("held\\n");setInterval(()=>{},1000);',
284
+ filePath], { stdio: ['ignore', 'pipe', 'ignore'] });
285
+ return new Promise((resolve, reject) => {
286
+ child.once('error', reject);
287
+ child.stdout.once('data', () => resolve(child));
288
+ });
289
+ }
290
+ async function releaseHolder(child) {
291
+ child.kill('SIGKILL');
292
+ await new Promise((resolve) => child.once('exit', resolve));
293
+ }
294
+
295
+ test('a lock held by a living process blocks capture and reports it; release recovers', async () => {
296
+ const dir = makeRepo('lock-held');
297
+ const lock = path.join(dir, '.git', 'index.lock');
298
+ const { follower, events } = makeFollower(dir, { last: capture(dir, { withBundle: false }) });
299
+
300
+ const holder = await holdOpen(lock);
301
+ fs.writeFileSync(path.join(dir, 'a.md'), 'edited under a held lock\n');
302
+ follower.ring();
303
+ // Well past settle AND cap: a held lock has no timer that ends in a bypass.
304
+ await sleep(SETTLED + T.capMs + 200);
305
+ assert.equal(events.length, 0, 'a held lock means git may be mid-write — nothing may travel');
306
+ assert.equal(follower.status().blocked, 'index.lock', 'the block must be reported, not silent');
307
+
308
+ await releaseHolder(holder);
309
+ fs.rmSync(lock);
310
+ follower.ring(); // in the composed system, the lock's deletion rings
311
+ await until(() => events.length > 0);
312
+ assert.equal(events.length, 1);
313
+ assert.equal(events[0].type, 'checkpoint.changed');
314
+ assert.equal(follower.status().blocked, null, 'recovery must clear the reported block');
315
+ follower.close();
316
+ });
317
+
318
+ test('an unheld lock is litter: edits, cleans, and card changes all still travel', async () => {
319
+ const dir = makeRepo('lock-stale');
320
+ fs.mkdirSync(path.join(dir, 'loose'));
321
+ for (let i = 0; i < 5; i += 1) fs.writeFileSync(path.join(dir, 'loose', `u${i}.md`), `${i}\n`);
322
+ const { follower, events } = makeFollower(dir, { last: capture(dir, { withBundle: false }) });
323
+ // Nobody holds this lock — a crashed git left it. It stops neither
324
+ // editors nor `git clean` nor `update-ref`, so it must not stop us.
325
+ fs.writeFileSync(path.join(dir, '.git', 'index.lock'), 'left by a crash');
326
+
327
+ fs.writeFileSync(path.join(dir, 'a.md'), 'edited behind a stale lock\n');
328
+ follower.ring();
329
+ await until(() => events.length === 1);
330
+ assert.equal(events[0].type, 'checkpoint.changed', 'a direct edit must travel past a stale lock');
331
+ assert.equal(follower.status().blocked, null, 'litter is not a block');
332
+
333
+ git(dir, 'clean', '-fdq'); // succeeds with the stale lock in place
334
+ follower.ring();
335
+ await until(() => events.length === 2);
336
+ assert.equal(events[1].type, 'checkpoint.changed', 'a bulk clean must travel past a stale lock');
337
+ assert.deepEqual(events[1].checkpoint.untracked, []);
338
+
339
+ git(dir, 'update-ref', 'refs/heads/side', 'HEAD'); // a CARD change the lock does not stop
340
+ follower.ring();
341
+ await until(() => events.length === 3);
342
+ assert.equal(events[2].type, 'card.changed', 'a card change must travel past a stale lock');
343
+ assert.ok(fs.existsSync(path.join(dir, '.git', 'index.lock')), 'the litter never moved');
344
+ follower.close();
345
+ });
346
+
347
+ test('flush() recovers the state even when the lock deletion doorbell was lost', async () => {
348
+ const dir = makeRepo('lock-lost-bell');
349
+ const lock = path.join(dir, '.git', 'index.lock');
350
+ const { follower, events } = makeFollower(dir, { last: capture(dir, { withBundle: false }) });
351
+
352
+ const holder = await holdOpen(lock);
353
+ fs.writeFileSync(path.join(dir, 'a.md'), 'changed while blocked\n');
354
+ follower.ring();
355
+ await sleep(SETTLED + T.capMs + 200);
356
+ assert.equal(events.length, 0);
357
+ assert.equal(follower.status().blocked, 'index.lock');
358
+
359
+ // The lock vanishes and its doorbell is LOST — no ring arrives, ever.
360
+ await releaseHolder(holder);
361
+ fs.rmSync(lock);
362
+ assert.equal(follower.status().blocked, null, 'status must read disk, not its own memory');
363
+
364
+ follower.flush(); // the consumer moment: a session opening, an app regaining focus
365
+ await until(() => events.length > 0);
366
+ assert.equal(events.length, 1);
367
+ assert.equal(events[0].type, 'checkpoint.changed');
368
+ const truth = capture(dir, { withBundle: false });
369
+ assert.equal(events[0].stateId, truth.stateId);
370
+ follower.close();
371
+ });
372
+
373
+ test('a consumer that flushes from inside emit does not recurse or double-speak', async () => {
374
+ const dir = makeRepo('reentrant');
375
+ const events = [];
376
+ let follower; // referenced by emit before assignment completes
377
+ follower = createFollower({
378
+ repoPath: dir,
379
+ emit: (event) => {
380
+ events.push(event);
381
+ follower.flush(); // a greedy consumer asking for more, mid-delivery
382
+ },
383
+ ...T,
384
+ last: capture(dir, { withBundle: false }),
385
+ });
386
+
387
+ fs.writeFileSync(path.join(dir, 'a.md'), 'one fact\n');
388
+ follower.ring();
389
+ await until(() => events.length > 0);
390
+ await sleep(SETTLED + T.capMs);
391
+ assert.equal(events.length, 1, 'a re-entrant flush must not re-speak the same state');
392
+ assert.equal(events[0].type, 'checkpoint.changed');
393
+ follower.close();
394
+ });
395
+
396
+ // --- the amalgm-owned boundary --------------------------------------------------
397
+
398
+ test('an owned command is exactly one event at its declared end', async () => {
399
+ const dir = makeRepo('owned');
400
+ const { follower, events } = makeFollower(dir, { last: capture(dir, { withBundle: false }) });
401
+
402
+ follower.command(() => {
403
+ fs.writeFileSync(path.join(dir, 'a.md'), 'step 1\n');
404
+ follower.ring();
405
+ fs.writeFileSync(path.join(dir, 'b.md'), 'step 2\n');
406
+ follower.ring();
407
+ git(dir, 'add', '-A');
408
+ git(dir, 'commit', '--quiet', '-m', 'owned work');
409
+ follower.ring();
410
+ });
411
+
412
+ await until(() => events.length > 0);
413
+ await sleep(SETTLED);
414
+ assert.equal(events.length, 1, 'the command boundary must collapse everything to one event');
415
+ assert.equal(events[0].type, 'card.changed');
416
+ follower.close();
417
+ });
418
+
419
+ test('an owned async command may stall past every timer: still one event, at its end', async () => {
420
+ const dir = makeRepo('owned-async');
421
+ const { follower, events } = makeFollower(dir, { last: capture(dir, { withBundle: false }) });
422
+
423
+ await follower.command(async () => {
424
+ fs.writeFileSync(path.join(dir, 'a.md'), 'half-written\n');
425
+ follower.ring();
426
+ // The stall their plan asked for: longer than settle AND cap. Inside an
427
+ // owned boundary the half-written state must never leak.
428
+ await sleep(SETTLED + T.capMs);
429
+ assert.equal(events.length, 0, 'a stalled owned command leaked an intermediate state');
430
+ fs.writeFileSync(path.join(dir, 'a.md'), 'finished\n');
431
+ follower.ring();
432
+ });
433
+
434
+ await until(() => events.length > 0);
435
+ await sleep(SETTLED);
436
+ assert.equal(events.length, 1);
437
+ assert.equal(events[0].type, 'checkpoint.changed');
438
+ const truth = capture(dir, { withBundle: false });
439
+ assert.equal(events[0].stateId, truth.stateId, 'only the command-end state may travel');
440
+ follower.close();
441
+ });
442
+
443
+ test('nested owned commands capture once, at the outermost end', async () => {
444
+ const dir = makeRepo('owned-nested');
445
+ const { follower, events } = makeFollower(dir, { last: capture(dir, { withBundle: false }) });
446
+
447
+ await follower.command(async () => {
448
+ fs.writeFileSync(path.join(dir, 'a.md'), 'outer step\n');
449
+ follower.command(() => {
450
+ fs.writeFileSync(path.join(dir, 'b.md'), 'inner step\n');
451
+ follower.ring();
452
+ });
453
+ await sleep(SETTLED);
454
+ assert.equal(events.length, 0, 'the inner command end must not fire inside the outer one');
455
+ git(dir, 'add', '-A');
456
+ git(dir, 'commit', '--quiet', '-m', 'nested work');
457
+ });
458
+
459
+ await until(() => events.length > 0);
460
+ await sleep(SETTLED);
461
+ assert.equal(events.length, 1, 'nesting must still collapse to one event');
462
+ assert.equal(events[0].type, 'card.changed');
463
+ follower.close();
464
+ });
465
+
466
+ test('a failing owned command still captures at its boundary', async () => {
467
+ const dir = makeRepo('owned-fail');
468
+ const { follower, events } = makeFollower(dir, { last: capture(dir, { withBundle: false }) });
469
+
470
+ assert.throws(() => follower.command(() => {
471
+ fs.writeFileSync(path.join(dir, 'a.md'), 'work before the crash\n');
472
+ follower.ring();
473
+ throw new Error('command failed');
474
+ }), /command failed/);
475
+
476
+ await until(() => events.length > 0);
477
+ assert.equal(events.length, 1, 'a failed command still ended — its residue must travel');
478
+ assert.equal(events[0].type, 'checkpoint.changed');
479
+ follower.close();
480
+ });
481
+
482
+ test('an owned command that changed nothing is silence', async () => {
483
+ const dir = makeRepo('owned-noop');
484
+ const { follower, events } = makeFollower(dir, { last: capture(dir, { withBundle: false }) });
485
+
486
+ follower.command(() => {
487
+ git(dir, 'status', '--porcelain');
488
+ follower.ring();
489
+ });
490
+ await sleep(SETTLED);
491
+ assert.equal(events.length, 0);
492
+ follower.close();
493
+ });
494
+
495
+ test('a repo containing a nested repo still travels: no crash, no retry loop', async () => {
496
+ const dir = makeRepo('nested');
497
+ const nested = path.join(dir, 'sub');
498
+ fs.mkdirSync(nested);
499
+ git(nested, 'init', '--quiet');
500
+ fs.writeFileSync(path.join(nested, 'inner.md'), 'child territory\n');
501
+
502
+ const { follower, events } = makeFollower(dir, { last: capture(dir, { withBundle: false }) });
503
+ fs.writeFileSync(path.join(dir, 'a.md'), 'edited beside a nested repo\n');
504
+ follower.ring();
505
+ await until(() => events.length === 1);
506
+ assert.equal(events[0].type, 'checkpoint.changed');
507
+ assert.equal(events[0].checkpoint.untracked.some((file) => file.path.startsWith('sub')), false,
508
+ 'the nested territory must not leak into the parent checkpoint');
509
+
510
+ await sleep(T.capMs + 200); // a crash-looping capture would keep re-firing here
511
+ assert.equal(events.length, 1, 'one edit must stay one event');
512
+ follower.close();
513
+ });
514
+
515
+ // --- async capture: pairs that take time open races timers can't see ---------
516
+
517
+ test('a doorbell during an in-flight capture pair is not swallowed', async () => {
518
+ const dir = makeRepo('inflight');
519
+ const calls = [];
520
+ const { follower, events } = makeFollower(dir, {
521
+ last: capture(dir, { withBundle: false }),
522
+ captureFn: () => new Promise((resolve) => calls.push(resolve)),
523
+ });
524
+
525
+ fs.writeFileSync(path.join(dir, 'a.md'), 'first change\n');
526
+ const snapA = capture(dir, { withBundle: false });
527
+ follower.ring();
528
+ await until(() => calls.length === 1);
529
+
530
+ // While the pair is in flight the repo moves again; the new doorbell's
531
+ // timer fires straight into the firing guard and is consumed.
532
+ fs.writeFileSync(path.join(dir, 'a.md'), 'second change\n');
533
+ follower.ring();
534
+ await sleep(SETTLED);
535
+
536
+ // The pair honestly agrees on the FIRST state — it was read before the
537
+ // second change — so it travels. Without pendingWake, the story ends here.
538
+ calls.shift()(snapA);
539
+ await until(() => calls.length === 2, 2_000);
540
+ calls.shift()(snapA);
541
+ await until(() => events.length === 1);
542
+ assert.equal(events[0].stateId, snapA.stateId);
543
+
544
+ // The follower must have re-armed itself for the swallowed doorbell.
545
+ const snapB = capture(dir, { withBundle: false });
546
+ await until(() => calls.length === 3, 2_000);
547
+ calls.shift()(snapB);
548
+ await until(() => calls.length === 4, 2_000);
549
+ calls.shift()(snapB);
550
+ await until(() => events.length === 2);
551
+ assert.equal(events[1].stateId, snapB.stateId);
552
+ follower.close();
553
+ });
554
+
555
+ test('close() during an in-flight capture pair is never spoken over', async () => {
556
+ const dir = makeRepo('close-race');
557
+ const calls = [];
558
+ const { follower, events } = makeFollower(dir, {
559
+ captureFn: () => new Promise((resolve) => calls.push(resolve)),
560
+ });
561
+ follower.start();
562
+ await until(() => calls.length === 1);
563
+
564
+ follower.close();
565
+ calls.shift()(capture(dir, { withBundle: false }));
566
+ await sleep(SETTLED);
567
+ assert.equal(events.length, 0, 'a closed follower must not announce a stale pair');
568
+ assert.equal(calls.length, 0, 'a closed follower must not keep capturing');
569
+ });
570
+
571
+ test('a command that begins mid-pair discards the stale pair and speaks once at its end', async () => {
572
+ const dir = makeRepo('owned-race');
573
+ let live = false; // stalled pair first, real captures after
574
+ const calls = [];
575
+ const captureFn = (repo) => (live ? capture(repo, { withBundle: false }) : new Promise((resolve) => calls.push(resolve)));
576
+ const { follower, events } = makeFollower(dir, { last: capture(dir, { withBundle: false }), captureFn });
577
+
578
+ fs.writeFileSync(path.join(dir, 'a.md'), 'mid\n');
579
+ const stale = capture(dir, { withBundle: false });
580
+ follower.ring();
581
+ await until(() => calls.length === 1);
582
+
583
+ follower.beginCommand(); // amalgm takes the boundary while the pair is in flight
584
+ live = true;
585
+ calls.shift()(stale);
586
+ await sleep(SETTLED);
587
+ assert.equal(events.length, 0, 'a pair resolving inside an owned command must stay silent');
588
+
589
+ fs.writeFileSync(path.join(dir, 'a.md'), 'final\n');
590
+ follower.endCommand();
591
+ await until(() => events.length === 1);
592
+ assert.equal(events[0].stateId, capture(dir, { withBundle: false }).stateId, 'the command end must speak the final truth');
593
+ follower.close();
594
+ });
595
+
596
+ test('the capture worker feeds a follower off the event loop with identical truth', async () => {
597
+ const dir = makeRepo('worker');
598
+ const pool = createCaptureWorker();
599
+ try {
600
+ const sync = capture(dir, { withBundle: false });
601
+ const off = await pool.capture(dir, { withBundle: false });
602
+ assert.equal(off.stateId, sync.stateId, 'worker capture must derive identical ids');
603
+ assert.ok(Buffer.isBuffer(off.checkpoint.stagedPatch), 'patch bytes must re-hydrate to Buffers');
604
+ assert.deepEqual(off.checkpoint.unstagedPatch, sync.checkpoint.unstagedPatch);
605
+
606
+ const { follower, events } = makeFollower(dir, {
607
+ last: sync,
608
+ captureFn: (repo) => pool.capture(repo, { withBundle: false }),
609
+ });
610
+ fs.writeFileSync(path.join(dir, 'a.md'), 'from the worker\n');
611
+ follower.ring();
612
+ await until(() => events.length === 1, 8_000);
613
+ assert.equal(events[0].type, 'checkpoint.changed');
614
+ assert.equal(events[0].stateId, capture(dir, { withBundle: false }).stateId);
615
+ follower.close();
616
+ } finally {
617
+ await pool.close();
618
+ }
619
+ });
620
+
621
+ // --- composed: real observer doorbells driving the follower ------------------------
622
+
623
+ test('composed: a checkout through real doorbells is one card event, zero file events', async () => {
624
+ const dir = makeRepo('composed');
625
+ for (let i = 0; i < 40; i += 1) {
626
+ fs.writeFileSync(path.join(dir, `f${i}.md`), `main ${i}\n`);
627
+ }
628
+ git(dir, 'add', '-A');
629
+ git(dir, 'commit', '--quiet', '-m', 'main files');
630
+ git(dir, 'checkout', '--quiet', '-b', 'alt');
631
+ for (let i = 0; i < 40; i += 1) {
632
+ fs.writeFileSync(path.join(dir, `f${i}.md`), `alt ${i}\n`);
633
+ }
634
+ git(dir, 'add', '-A');
635
+ git(dir, 'commit', '--quiet', '-m', 'alt files');
636
+ git(dir, 'checkout', '--quiet', '-');
637
+
638
+ const observerEvents = [];
639
+ const observer = createObserver({
640
+ db: new Database(':memory:'),
641
+ emit: (event) => observerEvents.push(event),
642
+ settleMs: 25,
643
+ });
644
+ const root = observer.enrollRoot(dir);
645
+ assert.equal(root.kind, 'repo', 'a directory with a .git enrolls as repo territory');
646
+
647
+ const events = [];
648
+ const follower = createFollower({
649
+ repoPath: dir,
650
+ emit: (event) => events.push(event),
651
+ ...T,
652
+ last: capture(dir, { withBundle: false }),
653
+ });
654
+ observer.start(); // catch-up doorbell arrives below via the wire
655
+ const wire = setInterval(() => {
656
+ while (wire.cursor < observerEvents.length) {
657
+ if (observerEvents[wire.cursor].type === 'repo.changed') follower.ring();
658
+ wire.cursor += 1;
659
+ }
660
+ }, 5);
661
+ wire.cursor = 0;
662
+
663
+ git(dir, 'checkout', '--quiet', 'alt');
664
+ const spoke = await until(() => events.length > 0, 8_000);
665
+ await sleep(SETTLED + T.capMs);
666
+ clearInterval(wire);
667
+ observer.stop();
668
+ follower.close();
669
+
670
+ assert.ok(spoke, 'the checkout was never detected');
671
+ assert.equal(events.length, 1, 'a checkout must travel as exactly one event');
672
+ assert.equal(events[0].type, 'card.changed');
673
+ assert.equal(events[0].card.head.branch, 'alt');
674
+ assert.equal(observerEvents.filter((event) => event.type.startsWith('file.')).length, 0);
675
+ });