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,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
|
+
});
|