amalgm 0.1.175 → 0.1.176
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/lib/shared-realtime-tunnel.js +28 -12
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/index.js +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +5 -1
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +4 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +13 -6
- package/runtime/scripts/amalgm-mcp/state/migrations.js +0 -4
- package/runtime/scripts/amalgm-mcp/state/mutations.js +13 -1
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +25 -1
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +9 -0
- package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +30 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +4 -5
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +37 -11
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +10 -2
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +14 -6
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +324 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-store.test.js +36 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +81 -48
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -1
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +9 -21
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +187 -122
- package/runtime/scripts/amalgm-mcp/workspace/git.js +71 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +119 -2
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +49 -17
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +582 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +15 -1
- package/runtime/scripts/amalgm-mcp/workspace/store.js +8 -18
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +67 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +3 -4
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +331 -180
- package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +0 -359
- package/runtime/scripts/amalgm-mcp/workspace/ledger.js +0 -96
- package/runtime/scripts/amalgm-mcp/workspace/reconcile.js +0 -452
|
@@ -1,359 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// The workspace rule under test: every writer — human, agent, or remote
|
|
4
|
-
// machine — writes into the same working tree, and no writer's work is
|
|
5
|
-
// ever silently lost. Non-overlapping changes coexist; overlapping
|
|
6
|
-
// changes preserve both and surface a named conflict; commits travel as
|
|
7
|
-
// card-controlled transitions; nothing overwrites another writer.
|
|
8
|
-
|
|
9
|
-
const test = require('node:test');
|
|
10
|
-
const assert = require('node:assert/strict');
|
|
11
|
-
const fs = require('fs');
|
|
12
|
-
const os = require('os');
|
|
13
|
-
const path = require('path');
|
|
14
|
-
const { execFile } = require('child_process');
|
|
15
|
-
const { promisify } = require('util');
|
|
16
|
-
|
|
17
|
-
const execFileAsync = promisify(execFile);
|
|
18
|
-
|
|
19
|
-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-reconcile-test-'));
|
|
20
|
-
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
21
|
-
process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
|
|
22
|
-
process.env.AMALGM_COMPUTER_ID = 'machine-fly';
|
|
23
|
-
process.env.AMALGM_DEVICE_ID = 'device-machine-fly';
|
|
24
|
-
|
|
25
|
-
fs.mkdirSync(process.env.AMALGM_DIR, { recursive: true });
|
|
26
|
-
fs.writeFileSync(
|
|
27
|
-
path.join(process.env.AMALGM_DIR, 'computer.json'),
|
|
28
|
-
JSON.stringify({ computer_id: 'machine-fly', user_id: '11111111-1111-4111-8111-111111111111' }),
|
|
29
|
-
);
|
|
30
|
-
const legacyLedgerDir = path.join(process.env.AMALGM_DIR, 'state', 'workspace-follow');
|
|
31
|
-
const migratedRecord = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
32
|
-
fs.mkdirSync(legacyLedgerDir, { recursive: true });
|
|
33
|
-
fs.writeFileSync(
|
|
34
|
-
path.join(legacyLedgerDir, `${migratedRecord}.json`),
|
|
35
|
-
JSON.stringify({ peerMachine: 'retained-peer', repoKey: 'retained-repo' }),
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
const { closeLocalDb } = require('../state/db');
|
|
39
|
-
const { captureWorktreeDiff, headAndDirty, headOid } = require('../workspace/git');
|
|
40
|
-
const { CHECKPOINT_CONTRACT, revisionOf } = require('../workspace/checkpoint');
|
|
41
|
-
const { listPairStates } = require('../workspace/ledger');
|
|
42
|
-
const { compareCardFreshness, reconciliationStats, reconcilePair } = require('../workspace/reconcile');
|
|
43
|
-
|
|
44
|
-
test.after(() => {
|
|
45
|
-
closeLocalDb();
|
|
46
|
-
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
async function run(cwd, ...args) {
|
|
50
|
-
const { stdout } = await execFileAsync(
|
|
51
|
-
'git',
|
|
52
|
-
['-c', 'user.email=test@amalgm.test', '-c', 'user.name=Amalgm Test', '-c', 'commit.gpgsign=false', ...args],
|
|
53
|
-
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' } },
|
|
54
|
-
);
|
|
55
|
-
return stdout;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const REMOTE = 'https://github.com/amalgm-test/app';
|
|
59
|
-
const TEN_LINES = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join('\n');
|
|
60
|
-
|
|
61
|
-
/** origin bare + an author clone (the peer) + a receiver clone (local). */
|
|
62
|
-
async function makeRig(name) {
|
|
63
|
-
const origin = path.join(tempRoot, `${name}-origin.git`);
|
|
64
|
-
await run(tempRoot, 'init', '--bare', '-b', 'main', origin);
|
|
65
|
-
const author = path.join(tempRoot, `${name}-author`);
|
|
66
|
-
await run(tempRoot, 'clone', '-q', origin, author);
|
|
67
|
-
fs.writeFileSync(path.join(author, 'app.js'), 'v1\n');
|
|
68
|
-
fs.writeFileSync(path.join(author, 'doc.md'), `${TEN_LINES}\n`);
|
|
69
|
-
await run(author, 'add', '.');
|
|
70
|
-
await run(author, 'commit', '-m', 'c1');
|
|
71
|
-
await run(author, 'push', '-q', 'origin', 'main');
|
|
72
|
-
const receiver = path.join(tempRoot, `${name}-receiver`);
|
|
73
|
-
await run(tempRoot, 'clone', '-q', origin, receiver);
|
|
74
|
-
return { origin, author, receiver };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function cardFor(worktree, oid, {
|
|
78
|
-
dirty = false,
|
|
79
|
-
branch = 'main',
|
|
80
|
-
machine = null,
|
|
81
|
-
observedAt = null,
|
|
82
|
-
} = {}) {
|
|
83
|
-
return {
|
|
84
|
-
path: worktree,
|
|
85
|
-
machine,
|
|
86
|
-
kind: 'primary',
|
|
87
|
-
health: 'healthy',
|
|
88
|
-
operation: 'none',
|
|
89
|
-
observedAt,
|
|
90
|
-
head: { branch, oid, detached: false },
|
|
91
|
-
repo: { remote: REMOTE, rootCommit: 'root' },
|
|
92
|
-
dirty: {
|
|
93
|
-
staged: 0, unstaged: dirty ? 1 : 0, untracked: 0, conflicted: 0, submodulesChanged: 0,
|
|
94
|
-
},
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async function checkpointFor(worktree, machine) {
|
|
99
|
-
const captured = await captureWorktreeDiff(worktree);
|
|
100
|
-
const { head } = await headAndDirty(worktree);
|
|
101
|
-
return {
|
|
102
|
-
checkpoint: CHECKPOINT_CONTRACT,
|
|
103
|
-
machine,
|
|
104
|
-
repoKey: REMOTE,
|
|
105
|
-
branch: head.branch,
|
|
106
|
-
baseOid: captured.baseOid,
|
|
107
|
-
revision: captured.diff === '' ? null : revisionOf(captured.baseOid, captured.diff),
|
|
108
|
-
diff: captured.diff,
|
|
109
|
-
capturedAt: new Date().toISOString(),
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async function reconcile(rig, digest, { receiverDirty = true, peerDirty } = {}) {
|
|
114
|
-
const oidLocal = await headOid(rig.receiver);
|
|
115
|
-
const oidPeer = await headOid(rig.author);
|
|
116
|
-
const entry = await checkpointFor(rig.author, 'machine-mac');
|
|
117
|
-
const localState = await headAndDirty(rig.receiver);
|
|
118
|
-
return reconcilePair(
|
|
119
|
-
cardFor(rig.receiver, oidLocal, { dirty: receiverDirty, branch: localState.head.branch }),
|
|
120
|
-
cardFor(rig.author, oidPeer, {
|
|
121
|
-
dirty: peerDirty ?? entry.revision !== null,
|
|
122
|
-
branch: entry.branch,
|
|
123
|
-
}),
|
|
124
|
-
entry,
|
|
125
|
-
digest,
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
test('reconciliation exposes no per-machine enable or direction state', () => {
|
|
130
|
-
const stats = reconciliationStats();
|
|
131
|
-
assert.equal(Object.hasOwn(stats, 'enabled'), false);
|
|
132
|
-
assert.equal(Object.hasOwn(stats, 'direction'), false);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test('card freshness is a total order when timestamps tie', () => {
|
|
136
|
-
const at = '2026-07-23T00:00:00.000Z';
|
|
137
|
-
assert.equal(
|
|
138
|
-
compareCardFreshness({ observedAt: at, machine: 'machine-b' }, { observedAt: at, machine: 'machine-a' }),
|
|
139
|
-
1,
|
|
140
|
-
);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test('the renamed reconciliation ledger preserves existing pair ownership', () => {
|
|
144
|
-
const states = listPairStates();
|
|
145
|
-
assert.equal(states.some((state) => state.peerMachine === 'retained-peer'), true);
|
|
146
|
-
assert.equal(fs.existsSync(legacyLedgerDir), false);
|
|
147
|
-
assert.equal(
|
|
148
|
-
fs.existsSync(path.join(process.env.AMALGM_DIR, 'state', 'workspace-reconciliation', `${migratedRecord}.json`)),
|
|
149
|
-
true,
|
|
150
|
-
);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
test('two writers in different files coexist, and a peer going clean removes only peer content', async () => {
|
|
154
|
-
const rig = await makeRig('coexist');
|
|
155
|
-
|
|
156
|
-
// Local work first — then the peer's edit arrives around it.
|
|
157
|
-
fs.writeFileSync(path.join(rig.receiver, 'local.md'), 'receiver work\n');
|
|
158
|
-
fs.appendFileSync(path.join(rig.author, 'app.js'), 'peer edit\n');
|
|
159
|
-
|
|
160
|
-
const applied = await reconcile(rig, 'pair-coexist');
|
|
161
|
-
assert.equal(applied.state, 'applied');
|
|
162
|
-
assert.equal(applied.reason, 'coexisting-with-local-work');
|
|
163
|
-
assert.equal(fs.readFileSync(path.join(rig.receiver, 'app.js'), 'utf8'), 'v1\npeer edit\n');
|
|
164
|
-
assert.equal(fs.readFileSync(path.join(rig.receiver, 'local.md'), 'utf8'), 'receiver work\n');
|
|
165
|
-
|
|
166
|
-
// The peer reverts: exactly their contribution leaves, local work stays.
|
|
167
|
-
fs.writeFileSync(path.join(rig.author, 'app.js'), 'v1\n');
|
|
168
|
-
const restored = await reconcile(rig, 'pair-coexist');
|
|
169
|
-
assert.equal(restored.state, 'local-work');
|
|
170
|
-
assert.equal(restored.reason, 'peer-clean');
|
|
171
|
-
assert.equal(fs.readFileSync(path.join(rig.receiver, 'app.js'), 'utf8'), 'v1\n');
|
|
172
|
-
assert.equal(fs.readFileSync(path.join(rig.receiver, 'local.md'), 'utf8'), 'receiver work\n');
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test('two writers in the same file, different regions, merge into one combined file', async () => {
|
|
176
|
-
const rig = await makeRig('regions');
|
|
177
|
-
|
|
178
|
-
const docPath = path.join(rig.receiver, 'doc.md');
|
|
179
|
-
fs.writeFileSync(docPath, `${TEN_LINES}\n`.replace('line 1', 'line 1 (local)'));
|
|
180
|
-
fs.writeFileSync(path.join(rig.author, 'doc.md'), `${TEN_LINES}\n`.replace('line 10', 'line 10 (peer)'));
|
|
181
|
-
|
|
182
|
-
const outcome = await reconcile(rig, 'pair-regions');
|
|
183
|
-
assert.equal(outcome.state, 'applied');
|
|
184
|
-
const merged = fs.readFileSync(docPath, 'utf8');
|
|
185
|
-
assert.ok(merged.includes('line 1 (local)'), 'local region survives');
|
|
186
|
-
assert.ok(merged.includes('line 10 (peer)'), 'peer region arrives');
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
test('same region: both versions preserved, a named conflict surfaces, and resolution clears it', async () => {
|
|
190
|
-
const rig = await makeRig('overlap');
|
|
191
|
-
|
|
192
|
-
const appPath = path.join(rig.receiver, 'app.js');
|
|
193
|
-
fs.writeFileSync(appPath, 'local v2\n');
|
|
194
|
-
fs.writeFileSync(path.join(rig.author, 'app.js'), 'peer v2\n');
|
|
195
|
-
|
|
196
|
-
const conflicted = await reconcile(rig, 'pair-overlap');
|
|
197
|
-
assert.equal(conflicted.state, 'conflict');
|
|
198
|
-
assert.equal(conflicted.conflicts[0].path, 'app.js');
|
|
199
|
-
assert.equal(conflicted.conflicts[0].kind, 'content-overlap');
|
|
200
|
-
assert.equal(fs.readFileSync(appPath, 'utf8'), 'local v2\n', 'local version never overwritten');
|
|
201
|
-
|
|
202
|
-
// Unchanged inputs → the standing conflict is reported, not re-fought.
|
|
203
|
-
const standing = await reconcile(rig, 'pair-overlap');
|
|
204
|
-
assert.equal(standing.state, 'conflict');
|
|
205
|
-
assert.equal(fs.readFileSync(appPath, 'utf8'), 'local v2\n');
|
|
206
|
-
|
|
207
|
-
// The human resolves by adopting the peer's version → folds cleanly.
|
|
208
|
-
fs.writeFileSync(appPath, 'peer v2\n');
|
|
209
|
-
const resolved = await reconcile(rig, 'pair-overlap');
|
|
210
|
-
assert.equal(resolved.state, 'applied');
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
test('a peer deletion never removes a file the local writer edited', async () => {
|
|
214
|
-
const rig = await makeRig('delete');
|
|
215
|
-
|
|
216
|
-
fs.writeFileSync(path.join(rig.receiver, 'app.js'), 'precious local edit\n');
|
|
217
|
-
fs.rmSync(path.join(rig.author, 'app.js'));
|
|
218
|
-
|
|
219
|
-
const outcome = await reconcile(rig, 'pair-delete');
|
|
220
|
-
assert.equal(outcome.state, 'conflict');
|
|
221
|
-
assert.equal(outcome.conflicts[0].kind, 'delete-vs-edit');
|
|
222
|
-
assert.equal(fs.readFileSync(path.join(rig.receiver, 'app.js'), 'utf8'), 'precious local edit\n');
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
test('an echoed change — already present locally — folds as a no-op instead of looping', async () => {
|
|
226
|
-
const rig = await makeRig('echo');
|
|
227
|
-
|
|
228
|
-
fs.writeFileSync(path.join(rig.receiver, 'app.js'), 'shared edit\n');
|
|
229
|
-
fs.writeFileSync(path.join(rig.author, 'app.js'), 'shared edit\n');
|
|
230
|
-
|
|
231
|
-
const outcome = await reconcile(rig, 'pair-echo');
|
|
232
|
-
assert.equal(outcome.state, 'applied');
|
|
233
|
-
assert.equal(fs.readFileSync(path.join(rig.receiver, 'app.js'), 'utf8'), 'shared edit\n');
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
test('when the peer commits held work, the fast-forward absorbs it and local work rides along', async () => {
|
|
237
|
-
const rig = await makeRig('absorb');
|
|
238
|
-
|
|
239
|
-
// Coexisting first: peer content held, local work alongside.
|
|
240
|
-
fs.writeFileSync(path.join(rig.receiver, 'local.md'), 'receiver work\n');
|
|
241
|
-
fs.appendFileSync(path.join(rig.author, 'app.js'), 'peer edit\n');
|
|
242
|
-
assert.equal((await reconcile(rig, 'pair-absorb')).state, 'applied');
|
|
243
|
-
|
|
244
|
-
// The peer commits exactly what we hold and pushes.
|
|
245
|
-
await run(rig.author, 'commit', '-am', 'c2');
|
|
246
|
-
await run(rig.author, 'push', '-q', 'origin', 'main');
|
|
247
|
-
const c2 = await headOid(rig.author);
|
|
248
|
-
|
|
249
|
-
// Converge: at most a restore pass, then the forward, then settled.
|
|
250
|
-
let outcome;
|
|
251
|
-
for (let pass = 0; pass < 3; pass += 1) {
|
|
252
|
-
outcome = await reconcile(rig, 'pair-absorb', { peerDirty: false });
|
|
253
|
-
if (outcome.state === 'forwarded') break;
|
|
254
|
-
assert.equal(outcome.state, 'restoring', `unexpected state ${outcome.state}`);
|
|
255
|
-
}
|
|
256
|
-
assert.equal(outcome.state, 'forwarded');
|
|
257
|
-
assert.equal(await headOid(rig.receiver), c2);
|
|
258
|
-
assert.equal(fs.readFileSync(path.join(rig.receiver, 'app.js'), 'utf8'), 'v1\npeer edit\n');
|
|
259
|
-
assert.equal(fs.readFileSync(path.join(rig.receiver, 'local.md'), 'utf8'), 'receiver work\n');
|
|
260
|
-
|
|
261
|
-
const settled = await reconcile(rig, 'pair-absorb', { peerDirty: false });
|
|
262
|
-
assert.equal(settled.state, 'local-work');
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
test('a branch switch follows branch and OID together and never advances the old branch', async () => {
|
|
266
|
-
const rig = await makeRig('branch-switch');
|
|
267
|
-
const mainOid = await headOid(rig.receiver);
|
|
268
|
-
|
|
269
|
-
await run(rig.author, 'switch', '-c', 'feature');
|
|
270
|
-
fs.appendFileSync(path.join(rig.author, 'app.js'), 'feature\n');
|
|
271
|
-
await run(rig.author, 'commit', '-am', 'feature');
|
|
272
|
-
await run(rig.author, 'push', '-q', 'origin', 'feature');
|
|
273
|
-
const featureOid = await headOid(rig.author);
|
|
274
|
-
const entry = await checkpointFor(rig.author, 'machine-mac');
|
|
275
|
-
|
|
276
|
-
// Reproduce the prod race: the stored local card says feature, while
|
|
277
|
-
// the checkout has since moved to main. Fresh state must win.
|
|
278
|
-
const outcome = await reconcilePair(
|
|
279
|
-
cardFor(rig.receiver, mainOid, { branch: 'feature' }),
|
|
280
|
-
cardFor(rig.author, featureOid, { branch: 'feature' }),
|
|
281
|
-
entry,
|
|
282
|
-
'pair-branch-switch',
|
|
283
|
-
);
|
|
284
|
-
|
|
285
|
-
assert.equal(outcome.state, 'switched');
|
|
286
|
-
assert.equal((await headAndDirty(rig.receiver)).head.branch, 'feature');
|
|
287
|
-
assert.equal(await headOid(rig.receiver), featureOid);
|
|
288
|
-
assert.equal((await run(rig.receiver, 'rev-parse', 'refs/heads/main')).trim(), mainOid);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
test('switching branch at the same commit still follows the exact branch', async () => {
|
|
292
|
-
const rig = await makeRig('branch-same-oid');
|
|
293
|
-
const oid = await headOid(rig.author);
|
|
294
|
-
await run(rig.author, 'switch', '-c', 'feature');
|
|
295
|
-
const entry = await checkpointFor(rig.author, 'machine-mac');
|
|
296
|
-
|
|
297
|
-
const outcome = await reconcilePair(
|
|
298
|
-
cardFor(rig.receiver, oid, { branch: 'main' }),
|
|
299
|
-
cardFor(rig.author, oid, { branch: 'feature' }),
|
|
300
|
-
entry,
|
|
301
|
-
'pair-branch-same-oid',
|
|
302
|
-
);
|
|
303
|
-
|
|
304
|
-
assert.equal(outcome.state, 'switched');
|
|
305
|
-
assert.equal((await headAndDirty(rig.receiver)).head.branch, 'feature');
|
|
306
|
-
assert.equal(await headOid(rig.receiver), oid);
|
|
307
|
-
assert.equal((await run(rig.receiver, 'rev-parse', 'refs/heads/main')).trim(), oid);
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
test('an older peer card cannot roll back a newer local branch and commit', async () => {
|
|
311
|
-
const rig = await makeRig('stale-card');
|
|
312
|
-
const oldOid = await headOid(rig.receiver);
|
|
313
|
-
await run(rig.receiver, 'switch', '-c', 'local-feature');
|
|
314
|
-
fs.appendFileSync(path.join(rig.receiver, 'app.js'), 'local commit\n');
|
|
315
|
-
await run(rig.receiver, 'commit', '-am', 'local commit');
|
|
316
|
-
const localOid = await headOid(rig.receiver);
|
|
317
|
-
const entry = await checkpointFor(rig.author, 'machine-peer');
|
|
318
|
-
entry.publishedAt = '2026-07-23T00:00:00.000Z';
|
|
319
|
-
|
|
320
|
-
const outcome = await reconcilePair(
|
|
321
|
-
cardFor(rig.receiver, localOid, {
|
|
322
|
-
branch: 'local-feature',
|
|
323
|
-
machine: 'machine-local',
|
|
324
|
-
observedAt: '2026-07-23T00:00:01.000Z',
|
|
325
|
-
}),
|
|
326
|
-
cardFor(rig.author, oldOid, {
|
|
327
|
-
branch: 'main',
|
|
328
|
-
machine: 'machine-peer',
|
|
329
|
-
observedAt: '2026-07-23T00:00:00.000Z',
|
|
330
|
-
}),
|
|
331
|
-
entry,
|
|
332
|
-
'pair-stale-card',
|
|
333
|
-
);
|
|
334
|
-
|
|
335
|
-
assert.equal(outcome.state, 'local-newer');
|
|
336
|
-
assert.equal((await headAndDirty(rig.receiver)).head.branch, 'local-feature');
|
|
337
|
-
assert.equal(await headOid(rig.receiver), localOid);
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
test('a peer behind us never moves the tree back, and diverged histories never move at all', async () => {
|
|
341
|
-
const rig = await makeRig('history');
|
|
342
|
-
const c1 = await headOid(rig.receiver);
|
|
343
|
-
|
|
344
|
-
// Receiver commits ahead locally; the stale peer is simply behind.
|
|
345
|
-
fs.appendFileSync(path.join(rig.receiver, 'app.js'), 'local commit\n');
|
|
346
|
-
await run(rig.receiver, 'commit', '-am', 'local c2');
|
|
347
|
-
const behind = await reconcile(rig, 'pair-history', { receiverDirty: false });
|
|
348
|
-
assert.equal(behind.state, 'peer-behind');
|
|
349
|
-
|
|
350
|
-
// The peer commits its own line off c1: genuine divergence, no movement.
|
|
351
|
-
fs.appendFileSync(path.join(rig.author, 'app.js'), 'peer commit\n');
|
|
352
|
-
await run(rig.author, 'commit', '-am', 'peer c2');
|
|
353
|
-
await run(rig.author, 'push', '-q', 'origin', 'main');
|
|
354
|
-
const localHead = await headOid(rig.receiver);
|
|
355
|
-
const diverged = await reconcile(rig, 'pair-history', { receiverDirty: false });
|
|
356
|
-
assert.equal(diverged.state, 'diverged');
|
|
357
|
-
assert.equal(await headOid(rig.receiver), localHead, 'divergence never moves the tree');
|
|
358
|
-
assert.equal(c1 === localHead, false);
|
|
359
|
-
});
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The reconciliation ledger: one durable record per (peer machine,
|
|
5
|
-
* repository) pair, remembering which part of this machine's working tree
|
|
6
|
-
* is a peer's contribution.
|
|
7
|
-
*
|
|
8
|
-
* The record's heart is `heldTree` — a git tree oid describing "base
|
|
9
|
-
* commit plus exactly the peer content this machine has folded in". The
|
|
10
|
-
* working tree is never assumed to equal it; local work is expected to
|
|
11
|
-
* coexist around it. `holdsContent` (heldTree differs from the bare base
|
|
12
|
-
* tree) is what lets the checkpoint publisher stamp outgoing diffs with
|
|
13
|
-
* whose work they incorporate.
|
|
14
|
-
*
|
|
15
|
-
* Split out of reconcile.js so the checkpoint layer can read holdings
|
|
16
|
-
* without importing the reconciler.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
const crypto = require('crypto');
|
|
20
|
-
const fs = require('fs');
|
|
21
|
-
const path = require('path');
|
|
22
|
-
const { AMALGM_DIR } = require('../config');
|
|
23
|
-
|
|
24
|
-
const STATE_DIR = path.join(AMALGM_DIR, 'state', 'workspace-reconciliation');
|
|
25
|
-
const LEGACY_STATE_DIR = path.join(AMALGM_DIR, 'state', 'workspace-follow');
|
|
26
|
-
let stateDirReady = false;
|
|
27
|
-
|
|
28
|
-
/** Preserve existing pair ownership while retiring the old service name. */
|
|
29
|
-
function ensureStateDir() {
|
|
30
|
-
if (stateDirReady) return;
|
|
31
|
-
stateDirReady = true;
|
|
32
|
-
if (fs.existsSync(STATE_DIR) || !fs.existsSync(LEGACY_STATE_DIR)) return;
|
|
33
|
-
fs.renameSync(LEGACY_STATE_DIR, STATE_DIR);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function pairDigest(peerMachine, repoKey) {
|
|
37
|
-
return crypto.createHash('sha256').update(`${peerMachine}|${repoKey}`).digest('hex').slice(0, 24);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function pairStateFile(digest) {
|
|
41
|
-
ensureStateDir();
|
|
42
|
-
return path.join(STATE_DIR, `${digest}.json`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function readPairState(digest) {
|
|
46
|
-
try {
|
|
47
|
-
return JSON.parse(fs.readFileSync(pairStateFile(digest), 'utf8'));
|
|
48
|
-
} catch {
|
|
49
|
-
return {};
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function writePairState(digest, patch) {
|
|
54
|
-
ensureStateDir();
|
|
55
|
-
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
56
|
-
const next = { ...readPairState(digest), ...patch, updatedAt: new Date().toISOString() };
|
|
57
|
-
fs.writeFileSync(pairStateFile(digest), `${JSON.stringify(next, null, 2)}\n`);
|
|
58
|
-
return next;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Every pair record on this machine — for stats and incorporates stamps. */
|
|
62
|
-
function listPairStates() {
|
|
63
|
-
ensureStateDir();
|
|
64
|
-
let names;
|
|
65
|
-
try {
|
|
66
|
-
names = fs.readdirSync(STATE_DIR);
|
|
67
|
-
} catch {
|
|
68
|
-
return [];
|
|
69
|
-
}
|
|
70
|
-
const states = [];
|
|
71
|
-
for (const name of names) {
|
|
72
|
-
if (!/^[0-9a-f]{24}\.json$/.test(name)) continue;
|
|
73
|
-
try {
|
|
74
|
-
states.push(JSON.parse(fs.readFileSync(path.join(STATE_DIR, name), 'utf8')));
|
|
75
|
-
} catch { /* an unreadable record is not a pair */ }
|
|
76
|
-
}
|
|
77
|
-
return states;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Boundary: no movement without a durable copy of what the tree held. */
|
|
81
|
-
function saveRollback(digest, diff) {
|
|
82
|
-
ensureStateDir();
|
|
83
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
84
|
-
const file = path.join(STATE_DIR, `rollback-${digest}-${stamp}.patch`);
|
|
85
|
-
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
86
|
-
fs.writeFileSync(file, diff);
|
|
87
|
-
return file;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
module.exports = {
|
|
91
|
-
listPairStates,
|
|
92
|
-
pairDigest,
|
|
93
|
-
readPairState,
|
|
94
|
-
saveRollback,
|
|
95
|
-
writePairState,
|
|
96
|
-
};
|