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
|
@@ -13,6 +13,14 @@ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-repo-objects-test
|
|
|
13
13
|
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
14
14
|
process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
|
|
15
15
|
process.env.AMALGM_COMPUTER_ID = 'machine-objects-test';
|
|
16
|
+
fs.mkdirSync(process.env.AMALGM_DIR, { recursive: true });
|
|
17
|
+
fs.writeFileSync(
|
|
18
|
+
path.join(process.env.AMALGM_DIR, 'computer.json'),
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
computer_id: 'machine-objects-test',
|
|
21
|
+
user_id: '11111111-1111-4111-8111-111111111111',
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
16
24
|
|
|
17
25
|
const { closeLocalDb } = require('../state/db');
|
|
18
26
|
const { hasCommit, headOid } = require('../workspace/git');
|
|
@@ -69,7 +77,7 @@ test('an unpushed commit travels as card-owned chunks and imports without moving
|
|
|
69
77
|
continue;
|
|
70
78
|
}
|
|
71
79
|
uploaded.set(upload.sha256, upload.dataBase64);
|
|
72
|
-
acknowledgeUpload(upload.sha256);
|
|
80
|
+
acknowledgeUpload(upload.resourceId, upload.sha256);
|
|
73
81
|
const sourceState = await objectSourceFor(source, 'repo:test', unpushedOid);
|
|
74
82
|
if (sourceState.kind === 'bundle') break;
|
|
75
83
|
}
|
|
@@ -86,7 +94,7 @@ test('an unpushed commit travels as card-owned chunks and imports without moving
|
|
|
86
94
|
while (true) {
|
|
87
95
|
const download = claimDownload();
|
|
88
96
|
if (!download) break;
|
|
89
|
-
receiveDownload(download.sha256, uploaded.get(download.sha256));
|
|
97
|
+
receiveDownload(download.resourceId, download.sha256, uploaded.get(download.sha256));
|
|
90
98
|
}
|
|
91
99
|
assert.equal((await importFromLocator(destination, locator)).state, 'imported');
|
|
92
100
|
assert.equal(await hasCommit(destination, unpushedOid), true);
|
|
@@ -95,16 +103,16 @@ test('an unpushed commit travels as card-owned chunks and imports without moving
|
|
|
95
103
|
|
|
96
104
|
test('a large checkpoint uses the same verified chunk lane', () => {
|
|
97
105
|
const content = Buffer.alloc(1_300_000, 0x61);
|
|
98
|
-
assert.equal(publishPayload('large-checkpoint', content).kind, 'pending');
|
|
106
|
+
assert.equal(publishPayload('large-checkpoint', content, 'repo:test').kind, 'pending');
|
|
99
107
|
|
|
100
108
|
const uploaded = new Map();
|
|
101
109
|
while (true) {
|
|
102
110
|
const upload = claimUpload();
|
|
103
111
|
if (!upload) break;
|
|
104
112
|
uploaded.set(upload.sha256, upload.dataBase64);
|
|
105
|
-
acknowledgeUpload(upload.sha256);
|
|
113
|
+
acknowledgeUpload(upload.resourceId, upload.sha256);
|
|
106
114
|
}
|
|
107
|
-
const locator = publishPayload('large-checkpoint', content);
|
|
115
|
+
const locator = publishPayload('large-checkpoint', content, 'repo:test');
|
|
108
116
|
assert.equal(locator.kind, 'chunks');
|
|
109
117
|
assert.ok(locator.chunks.length >= 3);
|
|
110
118
|
|
|
@@ -116,7 +124,7 @@ test('a large checkpoint uses the same verified chunk lane', () => {
|
|
|
116
124
|
while (true) {
|
|
117
125
|
const download = claimDownload();
|
|
118
126
|
if (!download) break;
|
|
119
|
-
receiveDownload(download.sha256, uploaded.get(download.sha256));
|
|
127
|
+
receiveDownload(download.resourceId, download.sha256, uploaded.get(download.sha256));
|
|
120
128
|
}
|
|
121
129
|
const received = readPayload(locator);
|
|
122
130
|
assert.equal(received.state, 'ready');
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { execFile } = require('child_process');
|
|
9
|
+
const { promisify } = require('util');
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-reducer-test-'));
|
|
13
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
14
|
+
process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
|
|
15
|
+
process.env.AMALGM_COMPUTER_ID = 'machine-local';
|
|
16
|
+
process.env.AMALGM_DEVICE_ID = 'device-local';
|
|
17
|
+
fs.mkdirSync(process.env.AMALGM_DIR, { recursive: true });
|
|
18
|
+
fs.writeFileSync(
|
|
19
|
+
path.join(process.env.AMALGM_DIR, 'computer.json'),
|
|
20
|
+
JSON.stringify({ computer_id: 'machine-local', user_id: '11111111-1111-4111-8111-111111111111' }),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const { closeLocalDb } = require('../state/db');
|
|
24
|
+
const { revisionOf } = require('../workspace/checkpoint');
|
|
25
|
+
const { materializeRepo, readRepoState, reduceWall } = require('../workspace/reducer');
|
|
26
|
+
|
|
27
|
+
test.after(() => {
|
|
28
|
+
closeLocalDb();
|
|
29
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
async function git(cwd, ...args) {
|
|
33
|
+
const { stdout } = await execFileAsync(
|
|
34
|
+
'git',
|
|
35
|
+
['-c', 'user.email=test@amalgm.test', '-c', 'user.name=Amalgm Test', '-c', 'commit.gpgsign=false', ...args],
|
|
36
|
+
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' } },
|
|
37
|
+
);
|
|
38
|
+
return stdout;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function makeRepo(name) {
|
|
42
|
+
const repo = path.join(tempRoot, name);
|
|
43
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
44
|
+
await git(repo, 'init', '-b', 'main');
|
|
45
|
+
fs.writeFileSync(path.join(repo, 'a.txt'), 'a0\n');
|
|
46
|
+
fs.writeFileSync(path.join(repo, 'b.txt'), 'b0\n');
|
|
47
|
+
await git(repo, 'add', '.');
|
|
48
|
+
await git(repo, 'commit', '-m', 'base');
|
|
49
|
+
return { repo, oid: (await git(repo, 'rev-parse', 'HEAD')).trim() };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function patchFor(repo, edits) {
|
|
53
|
+
for (const [name, content] of Object.entries(edits)) fs.writeFileSync(path.join(repo, name), content);
|
|
54
|
+
const diff = await git(repo, 'diff', 'HEAD', '--binary', '--full-index');
|
|
55
|
+
await git(repo, 'reset', '--hard', 'HEAD');
|
|
56
|
+
return diff;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function value(baseOid, diff) {
|
|
60
|
+
return {
|
|
61
|
+
revision: revisionOf(baseOid, diff, ''),
|
|
62
|
+
indexDiff: diff,
|
|
63
|
+
worktreeDiff: '',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function transition(machine, version, baseOid, from, to) {
|
|
68
|
+
return {
|
|
69
|
+
checkpoint: 'repo-checkpoint@4',
|
|
70
|
+
machine,
|
|
71
|
+
repoKey: 'root:test',
|
|
72
|
+
branch: 'main',
|
|
73
|
+
baseOid,
|
|
74
|
+
from: value(baseOid, from),
|
|
75
|
+
to: value(baseOid, to),
|
|
76
|
+
version,
|
|
77
|
+
mutationId: `mut_${machine}_${version}`.padEnd(16, 'x'),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function card(repo, oid, version) {
|
|
82
|
+
return {
|
|
83
|
+
card: 'repo-card@2',
|
|
84
|
+
path: repo,
|
|
85
|
+
replica: 'rpl_local',
|
|
86
|
+
machine: 'machine-local',
|
|
87
|
+
kind: 'primary',
|
|
88
|
+
health: 'healthy',
|
|
89
|
+
operation: 'none',
|
|
90
|
+
head: { branch: 'main', oid, detached: false },
|
|
91
|
+
repo: { rootCommit: 'test', remote: null },
|
|
92
|
+
objects: { kind: 'local' },
|
|
93
|
+
positionVersion: version,
|
|
94
|
+
positionMutationId: `mut_card_${version}`.padEnd(16, 'x'),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
test('wall reduction chooses one rail winner and orders checkpoint transitions by rail', () => {
|
|
99
|
+
const oldCard = { ...card('/old', 'a'.repeat(40), 4), checkpointVersion: 9 };
|
|
100
|
+
oldCard.checkpoint = transition('machine-a', 9, oldCard.head.oid, '', 'a');
|
|
101
|
+
const newCard = { ...card('/new', 'b'.repeat(40), 7), checkpointVersion: 8 };
|
|
102
|
+
newCard.checkpoint = transition('machine-b', 8, newCard.head.oid, '', 'b');
|
|
103
|
+
const plans = reduceWall([
|
|
104
|
+
{ machine: 'machine-a', cards: [oldCard] },
|
|
105
|
+
{ machine: 'machine-b', cards: [newCard] },
|
|
106
|
+
]);
|
|
107
|
+
const plan = plans.get('root:test');
|
|
108
|
+
assert.equal(plan.card.positionVersion, 7);
|
|
109
|
+
assert.deepEqual(plan.transitions.map((item) => item.version), [8]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('branch refs reduce per name; an absent stale list cannot delete another writer branch', () => {
|
|
113
|
+
const first = card('/a', 'a'.repeat(40), 5);
|
|
114
|
+
first.branches = [
|
|
115
|
+
{ name: 'main', oid: first.head.oid, version: 5, mutationId: 'mut_main_00000001' },
|
|
116
|
+
{ name: 'feature', oid: 'c'.repeat(40), version: 4, mutationId: 'mut_feature_0001' },
|
|
117
|
+
];
|
|
118
|
+
const stale = card('/b', 'a'.repeat(40), 6);
|
|
119
|
+
stale.branches = [
|
|
120
|
+
{ name: 'main', oid: stale.head.oid, version: 6, mutationId: 'mut_main_00000002' },
|
|
121
|
+
];
|
|
122
|
+
let plan = reduceWall([
|
|
123
|
+
{ machine: 'machine-a', cards: [first] },
|
|
124
|
+
{ machine: 'machine-b', cards: [stale] },
|
|
125
|
+
]).get('root:test');
|
|
126
|
+
assert.equal(plan.branches.find((ref) => ref.name === 'feature').oid, 'c'.repeat(40));
|
|
127
|
+
|
|
128
|
+
stale.branches.push({
|
|
129
|
+
name: 'feature',
|
|
130
|
+
oid: null,
|
|
131
|
+
version: 7,
|
|
132
|
+
mutationId: 'mut_feature_del01',
|
|
133
|
+
});
|
|
134
|
+
plan = reduceWall([
|
|
135
|
+
{ machine: 'machine-a', cards: [first] },
|
|
136
|
+
{ machine: 'machine-b', cards: [stale] },
|
|
137
|
+
]).get('root:test');
|
|
138
|
+
assert.equal(plan.branches.find((ref) => ref.name === 'feature').oid, null);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('a multi-user storm reduces identically regardless of network arrival order', () => {
|
|
142
|
+
const oid = 'a'.repeat(40);
|
|
143
|
+
const entries = Array.from({ length: 12 }, (_, index) => {
|
|
144
|
+
const machine = `machine-${index}`;
|
|
145
|
+
const observed = card(`/machine-${index}`, oid, index + 1);
|
|
146
|
+
observed.checkpoint = transition(machine, 100 + index, oid, '', `patch-${index}`);
|
|
147
|
+
observed.checkpointVersion = 100 + index;
|
|
148
|
+
observed.checkpointMutationId = `mut_checkpoint_${index}`.padEnd(16, 'x');
|
|
149
|
+
observed.branches = [
|
|
150
|
+
{
|
|
151
|
+
name: `feature-${index % 4}`,
|
|
152
|
+
oid: index >= 8 && index % 4 === 0 ? null : String(index % 10).repeat(40),
|
|
153
|
+
version: 200 + index,
|
|
154
|
+
mutationId: `mut_branch_${index}`.padEnd(16, 'x'),
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
return { machine, cards: [observed] };
|
|
158
|
+
});
|
|
159
|
+
const normalized = (wallEntries) => {
|
|
160
|
+
const plan = reduceWall(wallEntries).get('root:test');
|
|
161
|
+
return {
|
|
162
|
+
card: [plan.card.positionVersion, plan.card.writer],
|
|
163
|
+
checkpoints: plan.transitions.map((item) => [item.version, item.machine]),
|
|
164
|
+
branches: plan.branches.map((ref) => [ref.name, ref.oid, ref.version]),
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
const expected = normalized(entries);
|
|
168
|
+
let seed = 0x5eed1234;
|
|
169
|
+
const random = () => {
|
|
170
|
+
seed ^= seed << 13;
|
|
171
|
+
seed ^= seed >>> 17;
|
|
172
|
+
seed ^= seed << 5;
|
|
173
|
+
return (seed >>> 0) / 0x100000000;
|
|
174
|
+
};
|
|
175
|
+
for (let pass = 0; pass < 250; pass += 1) {
|
|
176
|
+
const shuffled = [...entries];
|
|
177
|
+
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
|
178
|
+
const other = Math.floor(random() * (index + 1));
|
|
179
|
+
[shuffled[index], shuffled[other]] = [shuffled[other], shuffled[index]];
|
|
180
|
+
}
|
|
181
|
+
assert.deepEqual(normalized(shuffled), expected);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('one materializer converges to the reduced checkpoint and later removes a reverted edit', async () => {
|
|
186
|
+
const { repo, oid } = await makeRepo('materialize');
|
|
187
|
+
const patchA = await patchFor(repo, { 'a.txt': 'a1\n' });
|
|
188
|
+
const patchB = await patchFor(repo, { 'b.txt': 'b1\n' });
|
|
189
|
+
const localCard = card(repo, oid, 1);
|
|
190
|
+
|
|
191
|
+
const first = {
|
|
192
|
+
repoKey: 'root:test',
|
|
193
|
+
card: localCard,
|
|
194
|
+
transitions: [
|
|
195
|
+
transition('machine-a', 2, oid, '', patchA),
|
|
196
|
+
transition('machine-b', 3, oid, patchA, `${patchA}${patchB}`),
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
const applied = await materializeRepo(localCard, first);
|
|
200
|
+
assert.equal(applied.state, 'in-sync');
|
|
201
|
+
assert.equal(fs.readFileSync(path.join(repo, 'a.txt'), 'utf8'), 'a1\n');
|
|
202
|
+
assert.equal(fs.readFileSync(path.join(repo, 'b.txt'), 'utf8'), 'b1\n');
|
|
203
|
+
|
|
204
|
+
const combined = readRepoState('root:test').indexDiff;
|
|
205
|
+
const second = {
|
|
206
|
+
repoKey: 'root:test',
|
|
207
|
+
card: localCard,
|
|
208
|
+
transitions: [
|
|
209
|
+
transition('machine-b', 3, oid, patchA, combined),
|
|
210
|
+
transition('machine-a', 4, oid, combined, patchB),
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
const reverted = await materializeRepo(localCard, second);
|
|
214
|
+
assert.equal(reverted.state, 'in-sync');
|
|
215
|
+
assert.equal(fs.readFileSync(path.join(repo, 'a.txt'), 'utf8'), 'a0\n');
|
|
216
|
+
assert.equal(fs.readFileSync(path.join(repo, 'b.txt'), 'utf8'), 'b1\n');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('a losing commit is parked, the winning card lands exactly, and losing work becomes uncommitted', async () => {
|
|
220
|
+
const { repo, oid: baseOid } = await makeRepo('simultaneous-commit');
|
|
221
|
+
fs.writeFileSync(path.join(repo, 'local-commit.txt'), 'losing commit work\n');
|
|
222
|
+
await git(repo, 'add', '.');
|
|
223
|
+
await git(repo, 'commit', '-m', 'local losing commit');
|
|
224
|
+
const losingOid = (await git(repo, 'rev-parse', 'HEAD')).trim();
|
|
225
|
+
fs.writeFileSync(path.join(repo, 'local-dirty.txt'), 'also uncommitted\n');
|
|
226
|
+
|
|
227
|
+
await git(repo, 'switch', '--detach', baseOid);
|
|
228
|
+
fs.writeFileSync(path.join(repo, 'winner.txt'), 'winning commit work\n');
|
|
229
|
+
await git(repo, 'add', '.');
|
|
230
|
+
await git(repo, 'commit', '-m', 'remote winning commit');
|
|
231
|
+
const winningOid = (await git(repo, 'rev-parse', 'HEAD')).trim();
|
|
232
|
+
await git(repo, 'switch', '-C', 'main', losingOid);
|
|
233
|
+
|
|
234
|
+
const localCard = card(repo, losingOid, 1);
|
|
235
|
+
const winningCard = {
|
|
236
|
+
...localCard,
|
|
237
|
+
head: { branch: 'main', oid: winningOid, detached: false },
|
|
238
|
+
positionVersion: 2,
|
|
239
|
+
positionMutationId: 'mut_card_winner_0001',
|
|
240
|
+
};
|
|
241
|
+
const outcome = await materializeRepo(localCard, {
|
|
242
|
+
repoKey: 'root:test',
|
|
243
|
+
card: winningCard,
|
|
244
|
+
transitions: [],
|
|
245
|
+
parked: [],
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
assert.equal(outcome.state, 'in-sync');
|
|
249
|
+
assert.equal((await git(repo, 'rev-parse', 'HEAD')).trim(), winningOid);
|
|
250
|
+
assert.equal((await git(repo, 'rev-parse', `refs/amalgm/parked/${losingOid}`)).trim(), losingOid);
|
|
251
|
+
assert.equal(fs.readFileSync(path.join(repo, 'winner.txt'), 'utf8'), 'winning commit work\n');
|
|
252
|
+
assert.equal(fs.readFileSync(path.join(repo, 'local-commit.txt'), 'utf8'), 'losing commit work\n');
|
|
253
|
+
assert.equal(fs.readFileSync(path.join(repo, 'local-dirty.txt'), 'utf8'), 'also uncommitted\n');
|
|
254
|
+
assert.equal((await git(repo, 'status', '--porcelain')).trim().length > 0, true);
|
|
255
|
+
assert.deepEqual(readRepoState('root:test').parked.map((entry) => entry.oid), [losingOid]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('checkpoint then commit folds through the same card movement and unrelated local work stays', async () => {
|
|
259
|
+
const { repo, oid: baseOid } = await makeRepo('checkpoint-commit-fold');
|
|
260
|
+
await git(repo, 'switch', '-c', 'target');
|
|
261
|
+
fs.writeFileSync(path.join(repo, 'shared.txt'), 'already checkpointed\n');
|
|
262
|
+
await git(repo, 'add', '.');
|
|
263
|
+
await git(repo, 'commit', '-m', 'commit checkpointed work');
|
|
264
|
+
const targetOid = (await git(repo, 'rev-parse', 'HEAD')).trim();
|
|
265
|
+
await git(repo, 'switch', '-C', 'main', baseOid);
|
|
266
|
+
|
|
267
|
+
fs.writeFileSync(path.join(repo, 'shared.txt'), 'already checkpointed\n');
|
|
268
|
+
fs.writeFileSync(path.join(repo, 'local-only.txt'), 'keep me dirty\n');
|
|
269
|
+
const localCard = card(repo, baseOid, 1);
|
|
270
|
+
const outcome = await materializeRepo(localCard, {
|
|
271
|
+
repoKey: 'root:test',
|
|
272
|
+
card: {
|
|
273
|
+
...localCard,
|
|
274
|
+
head: { branch: 'main', oid: targetOid, detached: false },
|
|
275
|
+
positionVersion: 2,
|
|
276
|
+
positionMutationId: 'mut_card_fold_00001',
|
|
277
|
+
},
|
|
278
|
+
transitions: [],
|
|
279
|
+
parked: [],
|
|
280
|
+
branches: [{ name: 'main', oid: targetOid, version: 2 }],
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
assert.equal(outcome.state, 'in-sync');
|
|
284
|
+
assert.equal((await git(repo, 'rev-parse', 'HEAD')).trim(), targetOid);
|
|
285
|
+
assert.equal(fs.readFileSync(path.join(repo, 'shared.txt'), 'utf8'), 'already checkpointed\n');
|
|
286
|
+
assert.equal(fs.readFileSync(path.join(repo, 'local-only.txt'), 'utf8'), 'keep me dirty\n');
|
|
287
|
+
assert.equal((await git(repo, 'status', '--porcelain')).trim(), '?? local-only.txt');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('the materializer creates, moves, and deletes branch refs without checking them out', async () => {
|
|
291
|
+
const { repo, oid } = await makeRepo('branches');
|
|
292
|
+
await git(repo, 'switch', '-c', 'feature');
|
|
293
|
+
fs.writeFileSync(path.join(repo, 'feature.txt'), 'feature\n');
|
|
294
|
+
await git(repo, 'add', '.');
|
|
295
|
+
await git(repo, 'commit', '-m', 'feature');
|
|
296
|
+
const featureOid = (await git(repo, 'rev-parse', 'HEAD')).trim();
|
|
297
|
+
await git(repo, 'switch', 'main');
|
|
298
|
+
await git(repo, 'branch', '-D', 'feature');
|
|
299
|
+
|
|
300
|
+
const localCard = card(repo, oid, 1);
|
|
301
|
+
const createdPlan = {
|
|
302
|
+
repoKey: 'root:test',
|
|
303
|
+
card: localCard,
|
|
304
|
+
transitions: [],
|
|
305
|
+
parked: [],
|
|
306
|
+
branches: [
|
|
307
|
+
{ name: 'main', oid, version: 1 },
|
|
308
|
+
{ name: 'feature', oid: featureOid, version: 2 },
|
|
309
|
+
],
|
|
310
|
+
};
|
|
311
|
+
await materializeRepo(localCard, createdPlan);
|
|
312
|
+
assert.equal((await git(repo, 'rev-parse', 'refs/heads/feature')).trim(), featureOid);
|
|
313
|
+
assert.equal((await git(repo, 'branch', '--show-current')).trim(), 'main');
|
|
314
|
+
|
|
315
|
+
await materializeRepo(localCard, {
|
|
316
|
+
...createdPlan,
|
|
317
|
+
branches: [
|
|
318
|
+
{ name: 'main', oid, version: 1 },
|
|
319
|
+
{ name: 'feature', oid: null, version: 3 },
|
|
320
|
+
],
|
|
321
|
+
});
|
|
322
|
+
await assert.rejects(() => git(repo, 'rev-parse', 'refs/heads/feature'));
|
|
323
|
+
assert.equal((await git(repo, 'branch', '--show-current')).trim(), 'main');
|
|
324
|
+
});
|
|
@@ -11,7 +11,8 @@ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
|
11
11
|
process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
|
|
12
12
|
process.env.AMALGM_COMPUTER_ID = 'computer-test';
|
|
13
13
|
|
|
14
|
-
const { closeLocalDb } = require('../state/db');
|
|
14
|
+
const { closeLocalDb, openLocalDb } = require('../state/db');
|
|
15
|
+
const { isUuidV7 } = require('../workspace/tree/identity');
|
|
15
16
|
const {
|
|
16
17
|
listWorkspaces,
|
|
17
18
|
removeWorkspace,
|
|
@@ -82,3 +83,37 @@ test('workspace registry does not auto-discover managed folders', () => {
|
|
|
82
83
|
assert.equal(fs.existsSync(projectPath), true);
|
|
83
84
|
assert.equal(listWorkspaces().some((item) => item.path === projectPath), false);
|
|
84
85
|
});
|
|
86
|
+
|
|
87
|
+
test('legacy workspace ids are replaced once and never retained as aliases', () => {
|
|
88
|
+
const projectPath = path.join(tempRoot, 'legacy-id-project');
|
|
89
|
+
fs.mkdirSync(projectPath, { recursive: true });
|
|
90
|
+
|
|
91
|
+
const workspace = upsertWorkspace({
|
|
92
|
+
id: 'legacy-workspace-id',
|
|
93
|
+
path: projectPath,
|
|
94
|
+
name: 'Legacy id project',
|
|
95
|
+
source: 'computer',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
assert.equal(isUuidV7(workspace.id), true);
|
|
99
|
+
assert.notEqual(workspace.id, 'legacy-workspace-id');
|
|
100
|
+
assert.equal(
|
|
101
|
+
openLocalDb().prepare('SELECT count(*) AS count FROM workspace_id_aliases').get().count,
|
|
102
|
+
0,
|
|
103
|
+
'the historical alias table stays inert',
|
|
104
|
+
);
|
|
105
|
+
assert.throws(
|
|
106
|
+
() => renameWorkspace('legacy-workspace-id', 'Should not resolve'),
|
|
107
|
+
/Workspace not found/,
|
|
108
|
+
);
|
|
109
|
+
assert.equal(
|
|
110
|
+
upsertWorkspace({
|
|
111
|
+
id: 'another-legacy-id',
|
|
112
|
+
path: projectPath,
|
|
113
|
+
name: 'Same workspace',
|
|
114
|
+
source: 'computer',
|
|
115
|
+
}).id,
|
|
116
|
+
workspace.id,
|
|
117
|
+
'stable path lookup returns the one canonical workspace UUID',
|
|
118
|
+
);
|
|
119
|
+
});
|
|
@@ -23,11 +23,23 @@ fs.writeFileSync(
|
|
|
23
23
|
);
|
|
24
24
|
|
|
25
25
|
const { closeLocalDb } = require('../state/db');
|
|
26
|
+
const { acknowledgeCloudMutation } = require('../state/mutations');
|
|
26
27
|
const { registerReplica } = require('../state/replicas');
|
|
27
28
|
const { openDoc, getDocText, writeDocText } = require('../state/docs');
|
|
28
29
|
const { upsertWorkspace } = require('../workspace/store');
|
|
29
30
|
const { refreshRepoCards } = require('../workspace/cards');
|
|
30
|
-
const {
|
|
31
|
+
const { repoKeyOfCard } = require('../workspace/checkpoint');
|
|
32
|
+
const {
|
|
33
|
+
bindProject,
|
|
34
|
+
projectForRepo,
|
|
35
|
+
projectResourceId,
|
|
36
|
+
publishWall,
|
|
37
|
+
readWall,
|
|
38
|
+
wallStats,
|
|
39
|
+
} = require('../workspace/wall');
|
|
40
|
+
|
|
41
|
+
let repoKey;
|
|
42
|
+
let project;
|
|
31
43
|
|
|
32
44
|
test.after(() => {
|
|
33
45
|
closeLocalDb();
|
|
@@ -42,25 +54,29 @@ async function run(cwd, ...args) {
|
|
|
42
54
|
);
|
|
43
55
|
}
|
|
44
56
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
test('the wall resource id is derived from the account: stable per user, distinct across users, valid on the rail', () => {
|
|
57
|
+
test('a project id is stable for one owner and repo, and distinct for another owner or repo', () => {
|
|
48
58
|
const userA = '11111111-1111-4111-8111-111111111111';
|
|
49
59
|
const userB = '22222222-2222-4222-8222-222222222222';
|
|
50
|
-
assert.equal(
|
|
51
|
-
assert.notEqual(
|
|
52
|
-
assert.
|
|
53
|
-
assert.
|
|
60
|
+
assert.equal(projectResourceId('repo-a', userA), projectResourceId('repo-a', userA));
|
|
61
|
+
assert.notEqual(projectResourceId('repo-a', userA), projectResourceId('repo-a', userB));
|
|
62
|
+
assert.notEqual(projectResourceId('repo-a', userA), projectResourceId('repo-b', userA));
|
|
63
|
+
assert.match(projectResourceId('repo-a', userA), /^shr_[A-Za-z0-9_-]{16,128}$/);
|
|
64
|
+
assert.equal(projectResourceId(null, userA), null);
|
|
54
65
|
});
|
|
55
66
|
|
|
56
|
-
test('
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
test('a collaborator binds the same repo to the owner project, not a parallel project', () => {
|
|
68
|
+
const shared = projectResourceId('repo-shared', '22222222-2222-4222-8222-222222222222');
|
|
69
|
+
const bound = bindProject(
|
|
70
|
+
'repo-shared',
|
|
71
|
+
shared,
|
|
72
|
+
'22222222-2222-4222-8222-222222222222',
|
|
73
|
+
);
|
|
74
|
+
assert.equal(bound.resourceId, shared);
|
|
75
|
+
assert.equal(projectForRepo('repo-shared').resourceId, shared);
|
|
76
|
+
assert.equal(projectForRepo('repo-shared').ownerUserId, '22222222-2222-4222-8222-222222222222');
|
|
61
77
|
});
|
|
62
78
|
|
|
63
|
-
test('a
|
|
79
|
+
test('a repo project carries exactly one line per machine, replaced in place', async () => {
|
|
64
80
|
const repo = path.join(tempRoot, 'projects/app');
|
|
65
81
|
fs.mkdirSync(repo, { recursive: true });
|
|
66
82
|
await run(repo, 'init', '-b', 'main');
|
|
@@ -68,81 +84,98 @@ test('a bound wall carries exactly one line per machine, replaced in place', asy
|
|
|
68
84
|
await run(repo, 'add', '.');
|
|
69
85
|
await run(repo, 'commit', '-m', 'initial');
|
|
70
86
|
upsertWorkspace({ path: path.join(tempRoot, 'projects'), name: 'Projects', source: 'computer' });
|
|
71
|
-
await refreshRepoCards();
|
|
87
|
+
const refreshed = await refreshRepoCards();
|
|
88
|
+
repoKey = repoKeyOfCard(refreshed.cards[0]);
|
|
89
|
+
project = projectForRepo(repoKey);
|
|
72
90
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
fs.writeFileSync(wallFile, '');
|
|
77
|
-
const opened = openDoc(wallFile);
|
|
91
|
+
fs.mkdirSync(path.dirname(project.file), { recursive: true });
|
|
92
|
+
fs.writeFileSync(project.file, '');
|
|
93
|
+
const opened = openDoc(project.file);
|
|
78
94
|
registerReplica({
|
|
79
|
-
resourceId:
|
|
95
|
+
resourceId: project.resourceId,
|
|
80
96
|
localResource: opened.resource,
|
|
81
|
-
localPath:
|
|
97
|
+
localPath: project.file,
|
|
82
98
|
contract: 'text-yjs@1',
|
|
83
99
|
schemaVersion: 1,
|
|
84
100
|
authorityEpoch: 1,
|
|
85
101
|
});
|
|
86
102
|
|
|
87
|
-
const first = publishWall();
|
|
103
|
+
const first = publishWall(repoKey);
|
|
88
104
|
assert.equal(first.published, true);
|
|
89
|
-
const entries = readWall();
|
|
105
|
+
const entries = readWall(repoKey);
|
|
90
106
|
assert.equal(entries.length, 1);
|
|
107
|
+
assert.equal(entries[0].wall, 'code-project@1');
|
|
108
|
+
assert.equal(entries[0].project, repoKey);
|
|
91
109
|
assert.equal(entries[0].machine, 'machine-a');
|
|
92
110
|
assert.equal(entries[0].cards.length, 1);
|
|
93
111
|
assert.equal(entries[0].cards[0].head.branch, 'main');
|
|
112
|
+
assert.equal(entries[0].cards[0].positionMutationId, first.mutationId);
|
|
113
|
+
assert.equal(entries[0].cards[0].positionVersion, null);
|
|
94
114
|
|
|
95
|
-
|
|
96
|
-
|
|
115
|
+
acknowledgeCloudMutation(project.resourceId, first.mutationId, {
|
|
116
|
+
version: 1,
|
|
117
|
+
committedAt: '2026-07-23T00:00:00.000Z',
|
|
118
|
+
});
|
|
119
|
+
const sealed = publishWall(repoKey);
|
|
120
|
+
assert.equal(sealed.published, true);
|
|
121
|
+
const sealedCard = readWall(repoKey)[0].cards[0];
|
|
122
|
+
assert.equal(sealedCard.positionMutationId, first.mutationId);
|
|
123
|
+
assert.equal(sealedCard.positionVersion, 1);
|
|
124
|
+
|
|
125
|
+
const again = publishWall(repoKey);
|
|
97
126
|
assert.equal(again.published, false);
|
|
98
127
|
assert.equal(again.reason, 'unchanged');
|
|
99
128
|
|
|
100
|
-
// A real change replaces the line — never appends a second one.
|
|
101
129
|
fs.writeFileSync(path.join(repo, 'new.txt'), 'x\n');
|
|
102
130
|
await refreshRepoCards();
|
|
103
|
-
const republished = publishWall();
|
|
131
|
+
const republished = publishWall(repoKey);
|
|
104
132
|
assert.equal(republished.published, true);
|
|
105
|
-
const after = readWall();
|
|
133
|
+
const after = readWall(repoKey);
|
|
106
134
|
assert.equal(after.length, 1);
|
|
107
135
|
assert.equal(after[0].cards[0].dirty.untracked, 1);
|
|
108
|
-
assert.equal(
|
|
136
|
+
assert.equal(after[0].cards[0].positionMutationId, first.mutationId);
|
|
137
|
+
assert.equal(after[0].cards[0].positionVersion, 1);
|
|
138
|
+
assert.equal(getDocText(project.file).trim().split('\n').length, 1);
|
|
109
139
|
});
|
|
110
140
|
|
|
111
|
-
test('a peer machine
|
|
141
|
+
test('a peer machine line is preserved byte-for-byte through local publishes', async () => {
|
|
112
142
|
const peerLine = JSON.stringify({
|
|
113
|
-
wall: '
|
|
143
|
+
wall: 'code-project@1',
|
|
144
|
+
project: repoKey,
|
|
114
145
|
machine: 'machine-b',
|
|
115
146
|
publishedAt: '2026-07-19T00:00:00.000Z',
|
|
116
147
|
cards: [{ card: 'repo-card@2', machine: 'machine-b', replica: 'rpl_peer', path: '/work/app', head: { branch: 'preview' } }],
|
|
117
148
|
});
|
|
118
|
-
|
|
119
|
-
writeDocText(wallFile, `${getDocText(wallFile)}${peerLine}\n`);
|
|
149
|
+
writeDocText(project.file, `${getDocText(project.file)}${peerLine}\n`);
|
|
120
150
|
|
|
121
151
|
const repo = path.join(tempRoot, 'projects/app');
|
|
122
152
|
fs.writeFileSync(path.join(repo, 'another.txt'), 'y\n');
|
|
123
153
|
await refreshRepoCards();
|
|
124
|
-
const result = publishWall();
|
|
154
|
+
const result = publishWall(repoKey);
|
|
125
155
|
assert.equal(result.published, true);
|
|
126
156
|
|
|
127
|
-
const lines = getDocText(
|
|
157
|
+
const lines = getDocText(project.file).trim().split('\n');
|
|
128
158
|
assert.equal(lines.length, 2);
|
|
129
|
-
assert.ok(lines.includes(peerLine)
|
|
130
|
-
|
|
131
|
-
assert.deepEqual(entries.map((entry) => entry.machine).sort(), ['machine-a', 'machine-b']);
|
|
159
|
+
assert.ok(lines.includes(peerLine));
|
|
160
|
+
assert.deepEqual(readWall(repoKey).map((entry) => entry.machine).sort(), ['machine-a', 'machine-b']);
|
|
132
161
|
});
|
|
133
162
|
|
|
134
|
-
test('a torn line is ignored by readers
|
|
135
|
-
writeDocText(
|
|
136
|
-
|
|
137
|
-
|
|
163
|
+
test('a torn line is ignored by readers, never fatal', () => {
|
|
164
|
+
writeDocText(
|
|
165
|
+
project.file,
|
|
166
|
+
`${getDocText(project.file)}{"wall":"code-project@1","project":"${repoKey}","machine":"machine-c","car\n`,
|
|
167
|
+
);
|
|
168
|
+
assert.deepEqual(readWall(repoKey).map((entry) => entry.machine).sort(), ['machine-a', 'machine-b']);
|
|
138
169
|
});
|
|
139
170
|
|
|
140
|
-
test('
|
|
171
|
+
test('project stats name every project resource, binding, and publish counter', () => {
|
|
141
172
|
const stats = wallStats();
|
|
142
|
-
|
|
143
|
-
assert.equal(stats.contract, '
|
|
144
|
-
assert.equal(
|
|
145
|
-
assert.equal(
|
|
173
|
+
const row = stats.projects.find((entry) => entry.repoKey === repoKey);
|
|
174
|
+
assert.equal(stats.contract, 'code-project@1');
|
|
175
|
+
assert.equal(row.resourceId, project.resourceId);
|
|
176
|
+
assert.equal(row.replica.appliedVersion >= 0, true);
|
|
177
|
+
assert.equal(row.machines, 2);
|
|
146
178
|
assert.ok(stats.publishes >= 3);
|
|
147
179
|
assert.ok(stats.publishSkips >= 1);
|
|
180
|
+
assert.ok(stats.paths.includes(project.file));
|
|
148
181
|
});
|