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,435 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The follower storm: seeded random git life — bulk checkouts, merges that
5
+ * conflict, ignored churn, cleans, tags, detached wanderings — driven
6
+ * through a REAL observer doorbell into a follower, judged per round:
7
+ *
8
+ * - truth ids unchanged -> the round must add zero events
9
+ * - truth ids moved -> exactly one event, right type, exact stateId
10
+ * - truth busy -> zero events until the operation resolves
11
+ *
12
+ * The storm's ops are synchronous ON PURPOSE: emissions exist only in the
13
+ * quiet gaps whose endpoints the referee samples with plain capture(), so
14
+ * an emitted state the referee never saw is a state that never held still.
15
+ * That soundness costs the storm the mid-operation race — which is why the
16
+ * spawned-git tests below exist: git runs in its own process there, the
17
+ * event loop stays free, and capture attempts genuinely land while a bulk
18
+ * operation is rewriting the worktree.
19
+ *
20
+ * Seed via AMALGM_STORM_SEED for reproduction.
21
+ */
22
+
23
+ const test = require('node:test');
24
+ const assert = require('node:assert/strict');
25
+ const { execFileSync, spawn } = require('child_process');
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const path = require('path');
29
+
30
+ const Database = require('better-sqlite3');
31
+ const { createObserver } = require('../observer');
32
+ const { capture, createFollower } = require('../repocard');
33
+
34
+ const SEED = Number(process.env.AMALGM_STORM_SEED || 20260729);
35
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-repofollow-storm-'));
36
+ test.after(() => {
37
+ fs.rmSync(tempRoot, { recursive: true, force: true });
38
+ });
39
+
40
+ function mulberry32(seed) {
41
+ let a = seed >>> 0;
42
+ return () => {
43
+ a |= 0;
44
+ a = (a + 0x6D2B79F5) | 0;
45
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
46
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
47
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
48
+ };
49
+ }
50
+ const int = (rng, n) => Math.floor(rng() * n);
51
+ const pick = (rng, arr) => arr[int(rng, arr.length)];
52
+
53
+ function git(dir, ...args) {
54
+ return execFileSync('git', args, { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] }).toString('utf8').trim();
55
+ }
56
+ function tryGit(dir, ...args) {
57
+ try { return git(dir, ...args); } catch { return null; }
58
+ }
59
+ /** Git in its own process, the event loop free — the production shape. */
60
+ function spawnGit(dir, ...args) {
61
+ return new Promise((resolve, reject) => {
62
+ const child = spawn('git', args, { cwd: dir, stdio: 'ignore' });
63
+ child.on('error', reject);
64
+ child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`git ${args.join(' ')} exited ${code}`))));
65
+ });
66
+ }
67
+
68
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
69
+ async function until(predicate, ms) {
70
+ const deadline = Date.now() + ms;
71
+ while (!predicate() && Date.now() < deadline) await sleep(20);
72
+ return predicate();
73
+ }
74
+
75
+ const CORE = ['a.md', 'b.md', 'c.md'];
76
+ const BULK = 400;
77
+
78
+ /** main + two branches ('big-a', 'big-b') whose bulk/ files all differ. */
79
+ function makeStormRepo(name) {
80
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(tempRoot, `${name}-`)));
81
+ git(dir, 'init', '--quiet', '-b', 'main');
82
+ git(dir, 'config', 'user.email', 'storm@amalgm.test');
83
+ git(dir, 'config', 'user.name', 'Storm');
84
+ git(dir, 'config', 'commit.gpgsign', 'false');
85
+ fs.writeFileSync(path.join(dir, '.gitignore'), 'ignored/\n');
86
+ for (const file of CORE) fs.writeFileSync(path.join(dir, file), `${file} base\n`);
87
+ fs.mkdirSync(path.join(dir, 'bulk'));
88
+ for (let i = 0; i < BULK; i += 1) {
89
+ fs.writeFileSync(path.join(dir, 'bulk', `f${i}.md`), `A ${i}\n`);
90
+ }
91
+ git(dir, 'add', '-A');
92
+ git(dir, 'commit', '--quiet', '-m', 'base');
93
+ git(dir, 'branch', 'big-a');
94
+ git(dir, 'checkout', '--quiet', '-b', 'big-b');
95
+ for (let i = 0; i < BULK; i += 1) {
96
+ fs.writeFileSync(path.join(dir, 'bulk', `f${i}.md`), `B ${i}\n`);
97
+ }
98
+ git(dir, 'add', '-A');
99
+ git(dir, 'commit', '--quiet', '-m', 'bulk flipped');
100
+ git(dir, 'checkout', '--quiet', 'main');
101
+ return dir;
102
+ }
103
+
104
+ /** Real doorbells: observer -> follower, no synthetic rings anywhere. */
105
+ function makeRig(dir, overrides = {}) {
106
+ const rig = { lastRingAt: Date.now(), events: [], observerEvents: [] };
107
+ rig.observer = createObserver({
108
+ db: new Database(':memory:'),
109
+ emit: (event) => {
110
+ rig.observerEvents.push(event);
111
+ if (event.type === 'repo.changed') {
112
+ rig.lastRingAt = Date.now();
113
+ rig.follower.ring();
114
+ }
115
+ },
116
+ settleMs: 25,
117
+ });
118
+ rig.follower = createFollower({
119
+ repoPath: dir,
120
+ emit: (event) => rig.events.push(event),
121
+ settleMs: 120,
122
+ capMs: 1000,
123
+ last: capture(dir, { withBundle: false }),
124
+ ...overrides,
125
+ });
126
+ const root = rig.observer.enrollRoot(dir);
127
+ assert.equal(root.kind, 'repo', 'a git repo must enroll as repo territory');
128
+ rig.observer.start();
129
+ rig.stop = () => {
130
+ rig.observer.stop();
131
+ rig.follower.close();
132
+ };
133
+ return rig;
134
+ }
135
+
136
+ async function waitQuiet(rig, quietMs = 450, deadlineMs = 15_000) {
137
+ const ok = await until(() => Date.now() - rig.lastRingAt > quietMs, deadlineMs);
138
+ assert.ok(ok, `doorbells never went quiet (seed ${SEED})`);
139
+ }
140
+
141
+ test(`follower storm survives the referee (seed ${SEED})`, async () => {
142
+ const dir = makeStormRepo('storm');
143
+ const rig = makeRig(dir);
144
+ const rng = mulberry32(SEED);
145
+
146
+ const ops = {
147
+ editTracked: () => {
148
+ const file = pick(rng, CORE);
149
+ fs.writeFileSync(path.join(dir, file), `${file} edit r${round} ${rng()}\n`);
150
+ },
151
+ ignoredChurn: () => {
152
+ fs.mkdirSync(path.join(dir, 'ignored'), { recursive: true });
153
+ for (let i = 0; i < 30; i += 1) {
154
+ fs.writeFileSync(path.join(dir, 'ignored', `noise-${i}.log`), `${round} ${i}\n`);
155
+ }
156
+ },
157
+ commitAll: () => {
158
+ tryGit(dir, 'add', '-A');
159
+ tryGit(dir, 'commit', '--quiet', '-m', `round ${round}`);
160
+ },
161
+ checkoutSwap: () => {
162
+ const branch = tryGit(dir, 'symbolic-ref', '-q', '--short', 'HEAD');
163
+ tryGit(dir, 'checkout', '--quiet', branch === 'big-a' ? 'big-b' : 'big-a');
164
+ },
165
+ newBranchCommit: () => {
166
+ tryGit(dir, 'checkout', '--quiet', '-b', `br-${round}`);
167
+ fs.writeFileSync(path.join(dir, 'c.md'), `branch work r${round}\n`);
168
+ tryGit(dir, 'add', '-A');
169
+ tryGit(dir, 'commit', '--quiet', '-m', `branch ${round}`);
170
+ },
171
+ tagNow: () => {
172
+ tryGit(dir, 'tag', `t-${round}`);
173
+ },
174
+ untrackedBatch: () => {
175
+ fs.mkdirSync(path.join(dir, 'loose'), { recursive: true });
176
+ for (let i = 0; i < 25; i += 1) {
177
+ fs.writeFileSync(path.join(dir, 'loose', `u${round}-${i}.md`), `${i}\n`);
178
+ }
179
+ },
180
+ cleanBulk: () => {
181
+ tryGit(dir, 'clean', '-fdq'); // bulk deletion, famously lock-free
182
+ },
183
+ stageSome: () => {
184
+ tryGit(dir, 'add', pick(rng, CORE));
185
+ },
186
+ detachToggle: () => {
187
+ const branch = tryGit(dir, 'symbolic-ref', '-q', '--short', 'HEAD');
188
+ if (branch === null) tryGit(dir, 'checkout', '--quiet', 'main');
189
+ else tryGit(dir, 'checkout', '--quiet', '--detach', 'HEAD~1');
190
+ },
191
+ mergeConflict: () => {
192
+ const base = tryGit(dir, 'symbolic-ref', '-q', '--short', 'HEAD');
193
+ if (base === null) return; // detached: skip, another round will merge
194
+ tryGit(dir, 'checkout', '--quiet', '-b', `clash-${round}`);
195
+ fs.writeFileSync(path.join(dir, 'a.md'), `theirs r${round}\n`);
196
+ tryGit(dir, 'add', '-A');
197
+ tryGit(dir, 'commit', '--quiet', '-m', `theirs ${round}`);
198
+ tryGit(dir, 'checkout', '--quiet', base);
199
+ fs.writeFileSync(path.join(dir, 'a.md'), `ours r${round}\n`);
200
+ tryGit(dir, 'add', '-A');
201
+ tryGit(dir, 'commit', '--quiet', '-m', `ours ${round}`);
202
+ tryGit(dir, 'merge', '--quiet', `clash-${round}`); // conflicts on a.md
203
+ },
204
+ resolveBusy: () => {
205
+ fs.writeFileSync(path.join(dir, 'a.md'), `resolved r${round}\n`);
206
+ tryGit(dir, 'add', '-A');
207
+ tryGit(dir, 'commit', '--quiet', '--no-edit');
208
+ },
209
+ };
210
+ const deck = [
211
+ 'editTracked', 'editTracked', 'editTracked',
212
+ 'ignoredChurn', 'ignoredChurn',
213
+ 'commitAll', 'commitAll',
214
+ 'checkoutSwap', 'checkoutSwap',
215
+ 'newBranchCommit', 'tagNow',
216
+ 'untrackedBatch', 'cleanBulk',
217
+ 'stageSome', 'detachToggle', 'mergeConflict',
218
+ ];
219
+
220
+ const ROUNDS = 26;
221
+ let round = 0;
222
+ let silentRounds = 0;
223
+ let busyRounds = 0;
224
+ let cardEvents = 0;
225
+
226
+ for (round = 1; round <= ROUNDS; round += 1) {
227
+ const before = capture(dir, { withBundle: false });
228
+ const name = before.busy ? 'resolveBusy' : pick(rng, deck);
229
+ const countBefore = rig.events.length;
230
+
231
+ ops[name]();
232
+
233
+ await waitQuiet(rig);
234
+ const after = capture(dir, { withBundle: false });
235
+ const label = `round ${round} (${name}, seed ${SEED})`;
236
+
237
+ if (after.busy) {
238
+ busyRounds += 1;
239
+ await sleep(600); // long enough for settle and the pending cap to tempt it
240
+ assert.equal(rig.events.length, countBefore, `${label}: a busy repo must never travel`);
241
+ continue;
242
+ }
243
+
244
+ // Convergence: the follower's beliefs must land exactly on the referee's truth.
245
+ const converged = await until(() => {
246
+ const ids = rig.follower.ids();
247
+ return ids && ids.cardId === after.cardId && ids.checkpointId === after.checkpointId;
248
+ }, 8_000);
249
+ assert.ok(converged, `${label}: follower never converged to disk truth`);
250
+
251
+ const added = rig.events.slice(countBefore);
252
+ if (!before.busy && before.stateId === after.stateId) {
253
+ silentRounds += 1;
254
+ assert.equal(added.length, 0, `${label}: ids never moved yet events appeared`);
255
+ continue;
256
+ }
257
+ assert.equal(added.length, 1, `${label}: one real change must be exactly one event, got ${added.length}`);
258
+ assert.equal(added[0].stateId, after.stateId, `${label}: the event carries a state the referee never saw`);
259
+ const expectCard = before.busy || before.cardId !== after.cardId;
260
+ assert.equal(added[0].type, expectCard ? 'card.changed' : 'checkpoint.changed', `${label}: wrong event type`);
261
+ if (added[0].type === 'card.changed') cardEvents += 1;
262
+ }
263
+
264
+ rig.stop();
265
+ assert.equal(
266
+ rig.observerEvents.filter((event) => event.type.startsWith('file.')).length,
267
+ 0,
268
+ 'repo territory leaked file events',
269
+ );
270
+ console.log(`[follower-storm] seed ${SEED}: ${ROUNDS} rounds, ${rig.events.length} events`
271
+ + ` (${cardEvents} card), ${silentRounds} silent, ${busyRounds} busy — referee satisfied`);
272
+ });
273
+
274
+ /** A repo whose 'main' and 'huge' branches differ in every one of n files. */
275
+ function makeBulkRepo(name, n) {
276
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(tempRoot, `${name}-`)));
277
+ git(dir, 'init', '--quiet', '-b', 'main');
278
+ git(dir, 'config', 'user.email', 'storm@amalgm.test');
279
+ git(dir, 'config', 'user.name', 'Storm');
280
+ git(dir, 'config', 'commit.gpgsign', 'false');
281
+ for (let i = 0; i < n; i += 1) {
282
+ fs.writeFileSync(path.join(dir, `f${i}.md`), `main ${i}\n`);
283
+ }
284
+ git(dir, 'add', '-A');
285
+ git(dir, 'commit', '--quiet', '-m', 'main');
286
+ git(dir, 'checkout', '--quiet', '-b', 'huge');
287
+ for (let i = 0; i < n; i += 1) {
288
+ fs.writeFileSync(path.join(dir, `f${i}.md`), `huge ${i}\n`);
289
+ }
290
+ git(dir, 'add', '-A');
291
+ git(dir, 'commit', '--quiet', '-m', 'huge');
292
+ git(dir, 'checkout', '--quiet', 'main');
293
+ return dir;
294
+ }
295
+
296
+ test('a thousands-of-files checkout travels as exactly one card event', async () => {
297
+ const N = 2_000;
298
+ const dir = makeBulkRepo('bulk', N);
299
+
300
+ const rig = makeRig(dir);
301
+ await waitQuiet(rig); // swallow the start() catch-up doorbell
302
+ assert.equal(rig.events.length, 0, 'an unchanged repo must not announce itself');
303
+
304
+ const checkoutStart = Date.now();
305
+ git(dir, 'checkout', '--quiet', 'huge');
306
+ const checkoutMs = Date.now() - checkoutStart;
307
+
308
+ const spoke = await until(() => rig.events.length > 0, 20_000);
309
+ const detectMs = Date.now() - checkoutStart;
310
+ assert.ok(spoke, `the ${N}-file checkout was never detected`);
311
+ await waitQuiet(rig);
312
+ await sleep(1_200); // room for the cap to tempt a second emission
313
+ rig.stop();
314
+
315
+ assert.equal(rig.events.length, 1, `a checkout must be ONE event, got ${rig.events.length}`);
316
+ assert.equal(rig.events[0].type, 'card.changed');
317
+ assert.equal(rig.events[0].card.head.branch, 'huge');
318
+ assert.equal(rig.events[0].checkpoint.stagedPatch.length, 0, 'a clean checkout carries no checkpoint patch');
319
+ assert.equal(rig.events[0].checkpoint.unstagedPatch.length, 0);
320
+ assert.deepEqual(rig.events[0].checkpoint.untracked, []);
321
+ assert.equal(rig.observerEvents.filter((event) => event.type.startsWith('file.')).length, 0);
322
+
323
+ const truth = capture(dir, { withBundle: false });
324
+ assert.equal(rig.events[0].stateId, truth.stateId, 'the one event must be the exact final state');
325
+ console.log(`[follower-storm] bulk: ${N}-file checkout ${checkoutMs}ms,`
326
+ + ` detected as one card ${detectMs}ms after checkout began`);
327
+ });
328
+
329
+ // --- the hard case: git in its own process, the event loop free ---------------
330
+
331
+ test('a live spawned checkout is raced by real fires and still travels as one card', async () => {
332
+ const N = 2_000;
333
+ const dir = makeBulkRepo('spawned-checkout', N);
334
+ let childRunning = false;
335
+ let midOpCaptures = 0;
336
+ let sawBlockedMidOp = false;
337
+ const rig = makeRig(dir, {
338
+ settleMs: 30,
339
+ capMs: 100, // aggressive on purpose: fire INTO the running checkout
340
+ captureFn: (repo) => {
341
+ if (childRunning) midOpCaptures += 1;
342
+ return capture(repo, { withBundle: false });
343
+ },
344
+ });
345
+ await waitQuiet(rig);
346
+ assert.equal(rig.events.length, 0, 'an unchanged repo must not announce itself');
347
+
348
+ const probe = setInterval(() => {
349
+ if (childRunning && rig.follower.status().blocked) sawBlockedMidOp = true;
350
+ }, 10);
351
+ childRunning = true;
352
+ const checkoutStart = Date.now();
353
+ await spawnGit(dir, 'checkout', '--quiet', 'huge');
354
+ childRunning = false;
355
+ const checkoutMs = Date.now() - checkoutStart;
356
+ clearInterval(probe);
357
+
358
+ const spoke = await until(() => rig.events.length > 0, 20_000);
359
+ const detectMs = Date.now() - checkoutStart;
360
+ assert.ok(spoke, `the spawned ${N}-file checkout was never detected`);
361
+ await waitQuiet(rig);
362
+ await sleep(500); // room for a pending cap to tempt a second emission
363
+ rig.stop();
364
+
365
+ // The witness is an assertion, not a log line: the hard case MUST occur.
366
+ assert.ok(
367
+ sawBlockedMidOp,
368
+ 'no capture attempt met the live lock while the checkout ran — the test proved nothing',
369
+ );
370
+ assert.equal(rig.events.length, 1, `a live checkout must be ONE event, got ${rig.events.length}`);
371
+ assert.equal(rig.events[0].type, 'card.changed');
372
+ assert.equal(rig.events[0].card.head.branch, 'huge');
373
+ const truth = capture(dir, { withBundle: false });
374
+ assert.equal(rig.events[0].stateId, truth.stateId, 'a partial checkout state must never travel');
375
+ assert.equal(rig.observerEvents.filter((event) => event.type.startsWith('file.')).length, 0);
376
+ console.log(`[follower-storm] spawned checkout: ${N} files ${checkoutMs}ms,`
377
+ + ` lock-blocked fires mid-op (asserted), ${midOpCaptures} captures mid-op,`
378
+ + ` one card ${detectMs}ms after start`);
379
+ });
380
+
381
+ test('a spawned lock-free clean travels as one checkpoint under live capture pressure', async () => {
382
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(tempRoot, 'spawned-clean-')));
383
+ git(dir, 'init', '--quiet', '-b', 'main');
384
+ git(dir, 'config', 'user.email', 'storm@amalgm.test');
385
+ git(dir, 'config', 'user.name', 'Storm');
386
+ git(dir, 'config', 'commit.gpgsign', 'false');
387
+ fs.writeFileSync(path.join(dir, 'a.md'), 'committed\n');
388
+ git(dir, 'add', '-A');
389
+ git(dir, 'commit', '--quiet', '-m', 'base');
390
+ const N = 2_500;
391
+ fs.mkdirSync(path.join(dir, 'loose'));
392
+ for (let i = 0; i < N; i += 1) {
393
+ fs.writeFileSync(path.join(dir, 'loose', `u${i}.md`), `${i}\n`);
394
+ }
395
+
396
+ let childRunning = false;
397
+ let midOpCaptures = 0;
398
+ const rig = makeRig(dir, {
399
+ settleMs: 30,
400
+ capMs: 100,
401
+ captureFn: (repo) => {
402
+ if (childRunning) midOpCaptures += 1;
403
+ return capture(repo, { withBundle: false });
404
+ },
405
+ });
406
+ await waitQuiet(rig);
407
+ assert.equal(rig.events.length, 0, 'an unchanged repo must not announce itself');
408
+
409
+ childRunning = true;
410
+ const cleanStart = Date.now();
411
+ await spawnGit(dir, 'clean', '-fdq'); // bulk deletion, famously lock-free: only the torn-read guard stands
412
+ childRunning = false;
413
+ const cleanMs = Date.now() - cleanStart;
414
+
415
+ const spoke = await until(() => rig.events.length > 0, 20_000);
416
+ await waitQuiet(rig);
417
+ await sleep(500);
418
+ rig.stop();
419
+
420
+ assert.ok(spoke, 'the spawned clean was never detected');
421
+ // The witness is an assertion, not a log line: captures MUST have raced
422
+ // the running clean, or the torn-read guard was never actually tried.
423
+ assert.ok(midOpCaptures > 0, 'no capture landed while the clean ran — the test proved nothing');
424
+ // A running clean cannot hold still across a full capture pair, so no
425
+ // intermediate state should travel. If this ever fails, that is the named
426
+ // residual speaking — a descheduled git stalling across both reads — not
427
+ // a regression in the guard.
428
+ assert.equal(rig.events.length, 1, `a live clean must be ONE event, got ${rig.events.length}`);
429
+ assert.equal(rig.events[0].type, 'checkpoint.changed');
430
+ assert.deepEqual(rig.events[0].checkpoint.untracked, []);
431
+ const truth = capture(dir, { withBundle: false });
432
+ assert.equal(rig.events[0].stateId, truth.stateId, 'a half-cleaned state must never travel');
433
+ console.log(`[follower-storm] spawned clean: ${N} untracked removed in ${cleanMs}ms,`
434
+ + ` ${midOpCaptures} captures mid-op — one checkpoint event`);
435
+ });