amalgm 0.1.245 → 0.1.246
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/lib/cli.js +146 -8
- package/lib/layout.js +89 -4
- package/lib/shared-realtime-tunnel.js +384 -43
- package/lib/supervisor.js +0 -3
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
- package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
- package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
- package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
- package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
- package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
- package/runtime/scripts/amalgm-mcp/index.js +4 -0
- package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
- package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
- package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
- package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
- package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
- package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
- package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
- package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
- package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
- package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
- package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
- package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
- package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
- package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
- package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
- package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
- package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
- package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
- package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
- package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
- package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
- package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
- package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
- package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
|
@@ -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
|
+
});
|