amalgm 0.1.178 → 0.1.179
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/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +25 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +1 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +150 -9
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +63 -8
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +3 -21
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +9 -2
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +87 -17
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +1 -0
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +66 -0
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +143 -33
package/package.json
CHANGED
|
@@ -89,3 +89,28 @@ test('an overlapping transition produces deterministic conflict bytes', async ()
|
|
|
89
89
|
assert.match(content, /left/);
|
|
90
90
|
assert.match(content, /right/);
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
test('an echoed transition that is already present reduces to a no-op', async () => {
|
|
94
|
+
const { repo, oid, tree: clean } = await makeRepo('echo');
|
|
95
|
+
const patch = await patchFor(repo, { 'a.txt': 'shared\n' });
|
|
96
|
+
const shared = await merge.treeOfPatch(repo, oid, patch);
|
|
97
|
+
const current = await merge.reduceTree(repo, clean, clean, shared, { label: '20' });
|
|
98
|
+
const echoed = await merge.reduceTree(repo, current.tree, clean, shared, { label: '21' });
|
|
99
|
+
assert.equal(echoed.tree, current.tree);
|
|
100
|
+
assert.deepEqual(echoed.conflicts, []);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a deletion never removes a file another writer edited', async () => {
|
|
104
|
+
const { repo, oid, tree: clean } = await makeRepo('delete-vs-edit');
|
|
105
|
+
const localPatch = await patchFor(repo, { 'a.txt': 'precious local edit\n' });
|
|
106
|
+
const localTree = await merge.treeOfPatch(repo, oid, localPatch);
|
|
107
|
+
fs.rmSync(path.join(repo, 'a.txt'));
|
|
108
|
+
const deletePatch = await git(repo, 'diff', 'HEAD', '--binary', '--full-index');
|
|
109
|
+
await git(repo, 'reset', '--hard', 'HEAD');
|
|
110
|
+
const deletedTree = await merge.treeOfPatch(repo, oid, deletePatch);
|
|
111
|
+
|
|
112
|
+
const current = await merge.reduceTree(repo, clean, clean, localTree, { label: '30' });
|
|
113
|
+
const outcome = await merge.reduceTree(repo, current.tree, clean, deletedTree, { label: '31' });
|
|
114
|
+
assert.equal(outcome.conflicts[0].kind, 'delete-vs-change');
|
|
115
|
+
assert.equal(await treeText(repo, outcome.tree, 'a.txt'), 'precious local edit\n');
|
|
116
|
+
});
|
|
@@ -159,5 +159,5 @@ test('checkpoint captures are local cache; their operations travel on the one wa
|
|
|
159
159
|
assert.equal(first, checkpointFile('m1', 'https://github.com/a/b'));
|
|
160
160
|
assert.notEqual(first, checkpointFile('m2', 'https://github.com/a/b'));
|
|
161
161
|
assert.notEqual(first, checkpointFile('m1', 'https://github.com/a/c'));
|
|
162
|
-
assert.match(first, /repo-checkpoints-
|
|
162
|
+
assert.match(first, /repo-checkpoints-v5/);
|
|
163
163
|
});
|
|
@@ -22,7 +22,7 @@ fs.writeFileSync(
|
|
|
22
22
|
|
|
23
23
|
const { closeLocalDb } = require('../state/db');
|
|
24
24
|
const { revisionOf } = require('../workspace/checkpoint');
|
|
25
|
-
const { materializeRepo, readRepoState, reduceWall } = require('../workspace/reducer');
|
|
25
|
+
const { hydrateRepo, materializeRepo, readRepoState, reduceWall } = require('../workspace/reducer');
|
|
26
26
|
|
|
27
27
|
test.after(() => {
|
|
28
28
|
closeLocalDb();
|
|
@@ -66,7 +66,7 @@ function value(baseOid, diff) {
|
|
|
66
66
|
|
|
67
67
|
function transition(machine, version, baseOid, from, to) {
|
|
68
68
|
return {
|
|
69
|
-
checkpoint: 'repo-checkpoint@
|
|
69
|
+
checkpoint: 'repo-checkpoint@5',
|
|
70
70
|
machine,
|
|
71
71
|
repoKey: 'root:test',
|
|
72
72
|
branch: 'main',
|
|
@@ -96,10 +96,10 @@ function card(repo, oid, version) {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
test('wall reduction chooses one rail winner and orders checkpoint transitions by rail', () => {
|
|
99
|
-
const oldCard =
|
|
100
|
-
oldCard.
|
|
101
|
-
const newCard =
|
|
102
|
-
newCard.
|
|
99
|
+
const oldCard = card('/old', 'a'.repeat(40), 4);
|
|
100
|
+
oldCard.checkpoints = [transition('machine-a', 9, oldCard.head.oid, '', 'a')];
|
|
101
|
+
const newCard = card('/new', 'b'.repeat(40), 7);
|
|
102
|
+
newCard.checkpoints = [transition('machine-b', 8, newCard.head.oid, '', 'b')];
|
|
103
103
|
const plans = reduceWall([
|
|
104
104
|
{ machine: 'machine-a', cards: [oldCard] },
|
|
105
105
|
{ machine: 'machine-b', cards: [newCard] },
|
|
@@ -109,6 +109,64 @@ test('wall reduction chooses one rail winner and orders checkpoint transitions b
|
|
|
109
109
|
assert.deepEqual(plan.transitions.map((item) => item.version), [8]);
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
test('a stale lower rail card cannot roll back a newer branch and commit', () => {
|
|
113
|
+
const newer = card('/newer', 'b'.repeat(40), 12);
|
|
114
|
+
newer.head.branch = 'feature';
|
|
115
|
+
const stale = card('/stale', 'a'.repeat(40), 11);
|
|
116
|
+
stale.head.branch = 'main';
|
|
117
|
+
const plan = reduceWall([
|
|
118
|
+
{ machine: 'machine-stale', cards: [stale] },
|
|
119
|
+
{ machine: 'machine-newer', cards: [newer] },
|
|
120
|
+
]).get('root:test');
|
|
121
|
+
assert.equal(plan.card.head.branch, 'feature');
|
|
122
|
+
assert.equal(plan.card.head.oid, 'b'.repeat(40));
|
|
123
|
+
assert.equal(plan.card.positionVersion, 12);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('an empty project folder hydrates from the winning card bundle without cloning', async () => {
|
|
127
|
+
const sourceRepo = await makeRepo('hydrate-source');
|
|
128
|
+
const source = sourceRepo.repo;
|
|
129
|
+
fs.mkdirSync(path.join(source, '.amalgam', 'context'), { recursive: true });
|
|
130
|
+
fs.writeFileSync(path.join(source, '.amalgam', 'context', 'notes.md'), 'committed note\n');
|
|
131
|
+
await git(source, 'add', '.');
|
|
132
|
+
await git(source, 'commit', '-m', 'track project memory');
|
|
133
|
+
const oid = (await git(source, 'rev-parse', 'HEAD')).trim();
|
|
134
|
+
const bundle = path.join(tempRoot, 'hydrate.bundle');
|
|
135
|
+
await git(source, 'bundle', 'create', bundle, 'main');
|
|
136
|
+
const bytes = fs.readFileSync(bundle);
|
|
137
|
+
const digest = require('crypto').createHash('sha256').update(bytes).digest('hex');
|
|
138
|
+
const chunks = path.join(process.env.AMALGM_DIR, 'state', 'repo-objects', 'chunks');
|
|
139
|
+
fs.mkdirSync(chunks, { recursive: true });
|
|
140
|
+
fs.writeFileSync(path.join(chunks, `${digest}.bin`), bytes);
|
|
141
|
+
|
|
142
|
+
const target = path.join(tempRoot, 'hydrate-target');
|
|
143
|
+
fs.mkdirSync(path.join(target, '.amalgam', 'context'), { recursive: true });
|
|
144
|
+
fs.writeFileSync(path.join(target, '.amalgam', 'context', 'notes.md'), 'local note\n');
|
|
145
|
+
const winning = card('/remote', oid, 1);
|
|
146
|
+
winning.repo.remote = 'https://example.com/org/repo';
|
|
147
|
+
winning.objects = {
|
|
148
|
+
kind: 'bundle',
|
|
149
|
+
contract: 'git-bundle-chunks@1',
|
|
150
|
+
resourceId: 'shr_hydrationProof123456',
|
|
151
|
+
oid,
|
|
152
|
+
ref: `refs/amalgm/exports/${oid}`,
|
|
153
|
+
checksum: digest,
|
|
154
|
+
bytes: bytes.length,
|
|
155
|
+
chunks: [{ sha256: digest, bytes: bytes.length }],
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const result = await hydrateRepo(
|
|
159
|
+
{ path: target },
|
|
160
|
+
{ repoKey: 'https://example.com/org/repo', card: winning, transitions: [] },
|
|
161
|
+
);
|
|
162
|
+
assert.equal(result.state, 'hydrated');
|
|
163
|
+
assert.equal((await git(target, 'rev-parse', 'HEAD')).trim(), oid);
|
|
164
|
+
assert.equal((await git(target, 'symbolic-ref', '--short', 'HEAD')).trim(), 'main');
|
|
165
|
+
assert.equal(fs.readFileSync(path.join(target, 'a.txt'), 'utf8'), 'a0\n');
|
|
166
|
+
assert.equal(fs.readFileSync(path.join(target, '.amalgam', 'context', 'notes.md'), 'utf8'), 'local note\n');
|
|
167
|
+
assert.match(await git(target, 'status', '--porcelain=v1'), /\.amalgam\/context\/notes\.md/);
|
|
168
|
+
});
|
|
169
|
+
|
|
112
170
|
test('branch refs reduce per name; an absent stale list cannot delete another writer branch', () => {
|
|
113
171
|
const first = card('/a', 'a'.repeat(40), 5);
|
|
114
172
|
first.branches = [
|
|
@@ -143,9 +201,7 @@ test('a multi-user storm reduces identically regardless of network arrival order
|
|
|
143
201
|
const entries = Array.from({ length: 12 }, (_, index) => {
|
|
144
202
|
const machine = `machine-${index}`;
|
|
145
203
|
const observed = card(`/machine-${index}`, oid, index + 1);
|
|
146
|
-
observed.
|
|
147
|
-
observed.checkpointVersion = 100 + index;
|
|
148
|
-
observed.checkpointMutationId = `mut_checkpoint_${index}`.padEnd(16, 'x');
|
|
204
|
+
observed.checkpoints = [transition(machine, 100 + index, oid, '', `patch-${index}`)];
|
|
149
205
|
observed.branches = [
|
|
150
206
|
{
|
|
151
207
|
name: `feature-${index % 4}`,
|
|
@@ -216,6 +272,40 @@ test('one materializer converges to the reduced checkpoint and later removes a r
|
|
|
216
272
|
assert.equal(fs.readFileSync(path.join(repo, 'b.txt'), 'utf8'), 'b1\n');
|
|
217
273
|
});
|
|
218
274
|
|
|
275
|
+
test('a writer publishing again cannot erase its earlier uncommitted work', async () => {
|
|
276
|
+
const { repo, oid } = await makeRepo('checkpoint-history');
|
|
277
|
+
const patchA = await patchFor(repo, { 'a.txt': 'a1\n' });
|
|
278
|
+
const patchAB = await patchFor(repo, { 'a.txt': 'a1\n', 'b.txt': 'b1\n' });
|
|
279
|
+
const patchABC = await patchFor(repo, {
|
|
280
|
+
'a.txt': 'a1\n',
|
|
281
|
+
'b.txt': 'b1\n',
|
|
282
|
+
'c.txt': 'c1\n',
|
|
283
|
+
});
|
|
284
|
+
const machineA = card('/machine-a', oid, 1);
|
|
285
|
+
machineA.machine = 'machine-a';
|
|
286
|
+
machineA.checkpoints = [
|
|
287
|
+
transition('machine-a', 2, oid, '', patchA),
|
|
288
|
+
transition('machine-a', 4, oid, patchAB, patchABC),
|
|
289
|
+
];
|
|
290
|
+
const machineB = card('/machine-b', oid, 1);
|
|
291
|
+
machineB.machine = 'machine-b';
|
|
292
|
+
machineB.checkpoints = [
|
|
293
|
+
transition('machine-b', 3, oid, patchA, patchAB),
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
const plan = reduceWall([
|
|
297
|
+
{ machine: 'machine-a', cards: [machineA] },
|
|
298
|
+
{ machine: 'machine-b', cards: [machineB] },
|
|
299
|
+
]).get('root:test');
|
|
300
|
+
assert.deepEqual(plan.transitions.map((item) => item.version), [2, 3, 4]);
|
|
301
|
+
|
|
302
|
+
const outcome = await materializeRepo(card(repo, oid, 1), plan);
|
|
303
|
+
assert.equal(outcome.state, 'in-sync');
|
|
304
|
+
assert.equal(fs.readFileSync(path.join(repo, 'a.txt'), 'utf8'), 'a1\n');
|
|
305
|
+
assert.equal(fs.readFileSync(path.join(repo, 'b.txt'), 'utf8'), 'b1\n');
|
|
306
|
+
assert.equal(fs.readFileSync(path.join(repo, 'c.txt'), 'utf8'), 'c1\n');
|
|
307
|
+
});
|
|
308
|
+
|
|
219
309
|
test('a losing commit is parked, the winning card lands exactly, and losing work becomes uncommitted', async () => {
|
|
220
310
|
const { repo, oid: baseOid } = await makeRepo('simultaneous-commit');
|
|
221
311
|
fs.writeFileSync(path.join(repo, 'local-commit.txt'), 'losing commit work\n');
|
|
@@ -322,3 +412,54 @@ test('the materializer creates, moves, and deletes branch refs without checking
|
|
|
322
412
|
await assert.rejects(() => git(repo, 'rev-parse', 'refs/heads/feature'));
|
|
323
413
|
assert.equal((await git(repo, 'branch', '--show-current')).trim(), 'main');
|
|
324
414
|
});
|
|
415
|
+
|
|
416
|
+
test('a card branch switch follows branch and OID together and leaves the old branch unchanged', async () => {
|
|
417
|
+
const { repo, oid: mainOid } = await makeRepo('card-branch-switch');
|
|
418
|
+
await git(repo, 'checkout', '-b', 'feature');
|
|
419
|
+
fs.writeFileSync(path.join(repo, 'a.txt'), 'feature\n');
|
|
420
|
+
await git(repo, 'add', 'a.txt');
|
|
421
|
+
await git(repo, 'commit', '-m', 'feature');
|
|
422
|
+
const featureOid = (await git(repo, 'rev-parse', 'HEAD')).trim();
|
|
423
|
+
await git(repo, 'checkout', 'main');
|
|
424
|
+
|
|
425
|
+
const local = card(repo, mainOid, 1);
|
|
426
|
+
const winning = card('/peer', featureOid, 2);
|
|
427
|
+
winning.head.branch = 'feature';
|
|
428
|
+
winning.objects = { kind: 'local' };
|
|
429
|
+
const outcome = await materializeRepo(local, {
|
|
430
|
+
repoKey: 'root:test',
|
|
431
|
+
card: winning,
|
|
432
|
+
transitions: [],
|
|
433
|
+
branches: [
|
|
434
|
+
{ name: 'main', oid: mainOid, version: 1, mutationId: 'mut_main_branch01' },
|
|
435
|
+
{ name: 'feature', oid: featureOid, version: 2, mutationId: 'mut_feature_head1' },
|
|
436
|
+
],
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
assert.equal(outcome.state, 'in-sync');
|
|
440
|
+
assert.equal((await git(repo, 'symbolic-ref', '--short', 'HEAD')).trim(), 'feature');
|
|
441
|
+
assert.equal((await git(repo, 'rev-parse', 'HEAD')).trim(), featureOid);
|
|
442
|
+
assert.equal((await git(repo, 'rev-parse', 'refs/heads/main')).trim(), mainOid);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test('a branch-only card change at the same commit still switches the exact branch', async () => {
|
|
446
|
+
const { repo, oid } = await makeRepo('same-oid-branch-switch');
|
|
447
|
+
await git(repo, 'branch', 'feature');
|
|
448
|
+
const local = card(repo, oid, 1);
|
|
449
|
+
const winning = card('/peer', oid, 2);
|
|
450
|
+
winning.head.branch = 'feature';
|
|
451
|
+
winning.objects = { kind: 'local' };
|
|
452
|
+
|
|
453
|
+
const outcome = await materializeRepo(local, {
|
|
454
|
+
repoKey: 'root:test',
|
|
455
|
+
card: winning,
|
|
456
|
+
transitions: [],
|
|
457
|
+
branches: [
|
|
458
|
+
{ name: 'main', oid, version: 1, mutationId: 'mut_same_main001' },
|
|
459
|
+
{ name: 'feature', oid, version: 2, mutationId: 'mut_same_feature1' },
|
|
460
|
+
],
|
|
461
|
+
});
|
|
462
|
+
assert.equal(outcome.state, 'in-sync');
|
|
463
|
+
assert.equal((await git(repo, 'symbolic-ref', '--short', 'HEAD')).trim(), 'feature');
|
|
464
|
+
assert.equal((await git(repo, 'rev-parse', 'HEAD')).trim(), oid);
|
|
465
|
+
});
|
|
@@ -28,11 +28,13 @@ const { registerReplica } = require('../state/replicas');
|
|
|
28
28
|
const { openDoc, getDocText, writeDocText } = require('../state/docs');
|
|
29
29
|
const { upsertWorkspace } = require('../workspace/store');
|
|
30
30
|
const { refreshRepoCards } = require('../workspace/cards');
|
|
31
|
-
const { repoKeyOfCard } = require('../workspace/checkpoint');
|
|
31
|
+
const { checkpointFile, repoKeyOfCard } = require('../workspace/checkpoint');
|
|
32
32
|
const {
|
|
33
33
|
bindProject,
|
|
34
|
+
isCommittedCard,
|
|
34
35
|
projectForRepo,
|
|
35
36
|
projectResourceId,
|
|
37
|
+
publishedCardFor,
|
|
36
38
|
publishWall,
|
|
37
39
|
readWall,
|
|
38
40
|
wallStats,
|
|
@@ -64,16 +66,24 @@ test('a project id is stable for one owner and repo, and distinct for another ow
|
|
|
64
66
|
assert.equal(projectResourceId(null, userA), null);
|
|
65
67
|
});
|
|
66
68
|
|
|
69
|
+
test('no commit means no shared card', () => {
|
|
70
|
+
assert.equal(isCommittedCard({ head: { branch: 'main', oid: null } }), false);
|
|
71
|
+
assert.equal(isCommittedCard({ head: { branch: 'main', oid: 'a'.repeat(40) } }), true);
|
|
72
|
+
});
|
|
73
|
+
|
|
67
74
|
test('a collaborator binds the same repo to the owner project, not a parallel project', () => {
|
|
68
75
|
const shared = projectResourceId('repo-shared', '22222222-2222-4222-8222-222222222222');
|
|
76
|
+
const localPath = path.join(tempRoot, 'projects/shared');
|
|
69
77
|
const bound = bindProject(
|
|
70
78
|
'repo-shared',
|
|
71
79
|
shared,
|
|
72
80
|
'22222222-2222-4222-8222-222222222222',
|
|
81
|
+
localPath,
|
|
73
82
|
);
|
|
74
83
|
assert.equal(bound.resourceId, shared);
|
|
75
84
|
assert.equal(projectForRepo('repo-shared').resourceId, shared);
|
|
76
85
|
assert.equal(projectForRepo('repo-shared').ownerUserId, '22222222-2222-4222-8222-222222222222');
|
|
86
|
+
assert.equal(projectForRepo('repo-shared').path, localPath);
|
|
77
87
|
});
|
|
78
88
|
|
|
79
89
|
test('a repo project carries exactly one line per machine, replaced in place', async () => {
|
|
@@ -104,13 +114,14 @@ test('a repo project carries exactly one line per machine, replaced in place', a
|
|
|
104
114
|
assert.equal(first.published, true);
|
|
105
115
|
const entries = readWall(repoKey);
|
|
106
116
|
assert.equal(entries.length, 1);
|
|
107
|
-
assert.equal(entries[0].wall, 'code-project@
|
|
117
|
+
assert.equal(entries[0].wall, 'code-project@3');
|
|
108
118
|
assert.equal(entries[0].project, repoKey);
|
|
109
119
|
assert.equal(entries[0].machine, 'machine-a');
|
|
110
120
|
assert.equal(entries[0].cards.length, 1);
|
|
111
121
|
assert.equal(entries[0].cards[0].head.branch, 'main');
|
|
112
122
|
assert.equal(entries[0].cards[0].positionMutationId, first.mutationId);
|
|
113
123
|
assert.equal(entries[0].cards[0].positionVersion, null);
|
|
124
|
+
assert.equal(publishedCardFor(refreshed.cards[0]), null);
|
|
114
125
|
|
|
115
126
|
acknowledgeCloudMutation(project.resourceId, first.mutationId, {
|
|
116
127
|
version: 1,
|
|
@@ -121,6 +132,7 @@ test('a repo project carries exactly one line per machine, replaced in place', a
|
|
|
121
132
|
const sealedCard = readWall(repoKey)[0].cards[0];
|
|
122
133
|
assert.equal(sealedCard.positionMutationId, first.mutationId);
|
|
123
134
|
assert.equal(sealedCard.positionVersion, 1);
|
|
135
|
+
assert.equal(publishedCardFor(refreshed.cards[0]).positionVersion, 1);
|
|
124
136
|
|
|
125
137
|
const again = publishWall(repoKey);
|
|
126
138
|
assert.equal(again.published, false);
|
|
@@ -129,18 +141,59 @@ test('a repo project carries exactly one line per machine, replaced in place', a
|
|
|
129
141
|
fs.writeFileSync(path.join(repo, 'new.txt'), 'x\n');
|
|
130
142
|
await refreshRepoCards();
|
|
131
143
|
const republished = publishWall(repoKey);
|
|
132
|
-
assert.equal(republished.published,
|
|
144
|
+
assert.equal(republished.published, false);
|
|
133
145
|
const after = readWall(repoKey);
|
|
134
146
|
assert.equal(after.length, 1);
|
|
135
|
-
assert.equal(after[0].cards[0]
|
|
147
|
+
assert.equal(Object.hasOwn(after[0].cards[0], 'dirty'), false);
|
|
136
148
|
assert.equal(after[0].cards[0].positionMutationId, first.mutationId);
|
|
137
149
|
assert.equal(after[0].cards[0].positionVersion, 1);
|
|
138
150
|
assert.equal(getDocText(project.file).trim().split('\n').length, 1);
|
|
139
151
|
});
|
|
140
152
|
|
|
153
|
+
test('checkpoint transitions append until the next card and carry their official rail numbers', async () => {
|
|
154
|
+
const card = readWall(repoKey)[0].cards[0];
|
|
155
|
+
const file = checkpointFile('machine-a', repoKey);
|
|
156
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
157
|
+
const firstCheckpoint = {
|
|
158
|
+
checkpoint: 'repo-checkpoint@5',
|
|
159
|
+
machine: 'machine-a',
|
|
160
|
+
repoKey,
|
|
161
|
+
branch: card.head.branch,
|
|
162
|
+
baseOid: card.head.oid,
|
|
163
|
+
from: { revision: null, indexDiff: '', worktreeDiff: '' },
|
|
164
|
+
to: { revision: 'revision-a', indexDiff: '', worktreeDiff: 'patch-a' },
|
|
165
|
+
parked: [],
|
|
166
|
+
capturedAt: '2026-07-23T00:00:01.000Z',
|
|
167
|
+
};
|
|
168
|
+
fs.writeFileSync(file, `${JSON.stringify(firstCheckpoint)}\n`);
|
|
169
|
+
const first = publishWall(repoKey);
|
|
170
|
+
assert.equal(first.published, true);
|
|
171
|
+
assert.equal(readWall(repoKey)[0].cards[0].checkpoints.length, 1);
|
|
172
|
+
assert.equal(readWall(repoKey)[0].cards[0].checkpoints[0].mutationId, first.mutationId);
|
|
173
|
+
|
|
174
|
+
acknowledgeCloudMutation(project.resourceId, first.mutationId, {
|
|
175
|
+
version: 2,
|
|
176
|
+
committedAt: '2026-07-23T00:00:02.000Z',
|
|
177
|
+
});
|
|
178
|
+
assert.equal(readWall(repoKey)[0].cards[0].checkpoints[0].version, 2);
|
|
179
|
+
assert.equal(publishWall(repoKey).published, true);
|
|
180
|
+
|
|
181
|
+
fs.writeFileSync(file, `${JSON.stringify({
|
|
182
|
+
...firstCheckpoint,
|
|
183
|
+
from: firstCheckpoint.to,
|
|
184
|
+
to: { revision: 'revision-b', indexDiff: '', worktreeDiff: 'patch-b' },
|
|
185
|
+
capturedAt: '2026-07-23T00:00:03.000Z',
|
|
186
|
+
})}\n`);
|
|
187
|
+
const second = publishWall(repoKey);
|
|
188
|
+
assert.equal(second.published, true);
|
|
189
|
+
const history = readWall(repoKey)[0].cards[0].checkpoints;
|
|
190
|
+
assert.equal(history.length, 2);
|
|
191
|
+
assert.deepEqual(history.map((entry) => entry.mutationId), [first.mutationId, second.mutationId]);
|
|
192
|
+
});
|
|
193
|
+
|
|
141
194
|
test('a peer machine line is preserved byte-for-byte through local publishes', async () => {
|
|
142
195
|
const peerLine = JSON.stringify({
|
|
143
|
-
wall: 'code-project@
|
|
196
|
+
wall: 'code-project@3',
|
|
144
197
|
project: repoKey,
|
|
145
198
|
machine: 'machine-b',
|
|
146
199
|
publishedAt: '2026-07-19T00:00:00.000Z',
|
|
@@ -150,6 +203,8 @@ test('a peer machine line is preserved byte-for-byte through local publishes', a
|
|
|
150
203
|
|
|
151
204
|
const repo = path.join(tempRoot, 'projects/app');
|
|
152
205
|
fs.writeFileSync(path.join(repo, 'another.txt'), 'y\n');
|
|
206
|
+
await run(repo, 'add', '.');
|
|
207
|
+
await run(repo, 'commit', '-m', 'advance card');
|
|
153
208
|
await refreshRepoCards();
|
|
154
209
|
const result = publishWall(repoKey);
|
|
155
210
|
assert.equal(result.published, true);
|
|
@@ -163,7 +218,7 @@ test('a peer machine line is preserved byte-for-byte through local publishes', a
|
|
|
163
218
|
test('a torn line is ignored by readers, never fatal', () => {
|
|
164
219
|
writeDocText(
|
|
165
220
|
project.file,
|
|
166
|
-
`${getDocText(project.file)}{"wall":"code-project@
|
|
221
|
+
`${getDocText(project.file)}{"wall":"code-project@3","project":"${repoKey}","machine":"machine-c","car\n`,
|
|
167
222
|
);
|
|
168
223
|
assert.deepEqual(readWall(repoKey).map((entry) => entry.machine).sort(), ['machine-a', 'machine-b']);
|
|
169
224
|
});
|
|
@@ -171,11 +226,11 @@ test('a torn line is ignored by readers, never fatal', () => {
|
|
|
171
226
|
test('project stats name every project resource, binding, and publish counter', () => {
|
|
172
227
|
const stats = wallStats();
|
|
173
228
|
const row = stats.projects.find((entry) => entry.repoKey === repoKey);
|
|
174
|
-
assert.equal(stats.contract, 'code-project@
|
|
229
|
+
assert.equal(stats.contract, 'code-project@3');
|
|
175
230
|
assert.equal(row.resourceId, project.resourceId);
|
|
176
231
|
assert.equal(row.replica.appliedVersion >= 0, true);
|
|
177
232
|
assert.equal(row.machines, 2);
|
|
178
|
-
assert.ok(stats.publishes >=
|
|
233
|
+
assert.ok(stats.publishes >= 2);
|
|
179
234
|
assert.ok(stats.publishSkips >= 1);
|
|
180
235
|
assert.ok(stats.paths.includes(project.file));
|
|
181
236
|
});
|
|
@@ -25,7 +25,7 @@ const { machineId } = require('./identity');
|
|
|
25
25
|
const { listRepoCards } = require('./cards');
|
|
26
26
|
const { captureCheckpoint } = require('./git');
|
|
27
27
|
|
|
28
|
-
const CHECKPOINT_CONTRACT = 'repo-checkpoint@
|
|
28
|
+
const CHECKPOINT_CONTRACT = 'repo-checkpoint@5';
|
|
29
29
|
const MAX_INLINE_DIFF_BYTES = 512 * 1024;
|
|
30
30
|
const PUBLISH_QUIET_MS = 25;
|
|
31
31
|
const RECONCILE_INTERVAL_MS = 30_000;
|
|
@@ -54,7 +54,7 @@ function checkpointFile(machine, repoKey) {
|
|
|
54
54
|
.update(`${CHECKPOINT_CONTRACT}|${machine}|${repoKey}`)
|
|
55
55
|
.digest('hex')
|
|
56
56
|
.slice(0, 24);
|
|
57
|
-
return path.join(AMALGM_DIR, 'state', 'repo-checkpoints-
|
|
57
|
+
return path.join(AMALGM_DIR, 'state', 'repo-checkpoints-v5', `${digest}.json`);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
function revisionOf(baseOid, indexDiff, worktreeDiff = '') {
|
|
@@ -145,23 +145,6 @@ function readCheckpointValue(baseOid, value) {
|
|
|
145
145
|
return { state: 'ready', indexDiff, worktreeDiff };
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
function ownPublishedCard(card) {
|
|
149
|
-
const own = require('./wall').readWall().find((entry) => (
|
|
150
|
-
entry.machine === machineId()
|
|
151
|
-
&& entry.cards?.some((candidate) => candidate.replica === card.replica)
|
|
152
|
-
));
|
|
153
|
-
const published = own?.cards?.find((candidate) => candidate.replica === card.replica);
|
|
154
|
-
if (
|
|
155
|
-
!published
|
|
156
|
-
|| !Number.isInteger(published.positionVersion)
|
|
157
|
-
|| published.positionVersion < 1
|
|
158
|
-
|| published.head?.branch !== card.head?.branch
|
|
159
|
-
|| published.head?.oid !== card.head?.oid
|
|
160
|
-
|| Boolean(published.head?.detached) !== Boolean(card.head?.detached)
|
|
161
|
-
) return null;
|
|
162
|
-
return published;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
148
|
async function publishPass(replicaId = null) {
|
|
166
149
|
const machine = machineId();
|
|
167
150
|
const run = { published: 0, skips: 0, waiting: 0, failures: 0 };
|
|
@@ -171,7 +154,7 @@ async function publishPass(replicaId = null) {
|
|
|
171
154
|
const repoKey = repoKeyOfCard(card);
|
|
172
155
|
if (!repoKey || !card.head?.oid) continue;
|
|
173
156
|
try {
|
|
174
|
-
const publishedCard =
|
|
157
|
+
const publishedCard = require('./wall').publishedCardFor(card);
|
|
175
158
|
const reduced = require('./reducer').readRepoState(repoKey);
|
|
176
159
|
if (
|
|
177
160
|
!publishedCard
|
|
@@ -226,7 +209,6 @@ async function publishPass(replicaId = null) {
|
|
|
226
209
|
repoKey,
|
|
227
210
|
branch: card.head.branch,
|
|
228
211
|
baseOid: card.head.oid,
|
|
229
|
-
cardVersion: publishedCard.positionVersion,
|
|
230
212
|
from: checkpointValue(
|
|
231
213
|
machine,
|
|
232
214
|
repoKey,
|
|
@@ -328,7 +328,7 @@ function validateLocator(locator) {
|
|
|
328
328
|
) throw new Error('invalid git bundle locator');
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
|
|
331
|
+
function prepareBundleImport(locator) {
|
|
332
332
|
validateLocator(locator);
|
|
333
333
|
ensureDirs();
|
|
334
334
|
let waiting = false;
|
|
@@ -361,7 +361,13 @@ async function importFromLocator(worktreeRoot, locator) {
|
|
|
361
361
|
}
|
|
362
362
|
fs.writeFileSync(bundleFile, bundle, { mode: 0o600 });
|
|
363
363
|
}
|
|
364
|
-
|
|
364
|
+
return { state: 'ready', file: bundleFile };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function importFromLocator(worktreeRoot, locator) {
|
|
368
|
+
const prepared = prepareBundleImport(locator);
|
|
369
|
+
if (prepared.state !== 'ready') return prepared;
|
|
370
|
+
await transition.importBundle(worktreeRoot, prepared.file);
|
|
365
371
|
return { state: 'imported' };
|
|
366
372
|
}
|
|
367
373
|
|
|
@@ -375,6 +381,7 @@ module.exports = {
|
|
|
375
381
|
claimUpload,
|
|
376
382
|
importFromLocator,
|
|
377
383
|
objectSourceFor,
|
|
384
|
+
prepareBundleImport,
|
|
378
385
|
publishPayload,
|
|
379
386
|
readPayload,
|
|
380
387
|
receiveDownload,
|
|
@@ -75,8 +75,9 @@ function validVersion(value) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
* Pure wall reduction. Machine identity never chooses a winner
|
|
79
|
-
*
|
|
78
|
+
* Pure wall reduction. Machine identity never chooses a winner. Checkpoint
|
|
79
|
+
* transitions remain in the wall until the next card folds them into a
|
|
80
|
+
* commit, so reducing current state never forgets an earlier edit.
|
|
80
81
|
*/
|
|
81
82
|
function reduceWall(entries) {
|
|
82
83
|
const repos = new Map();
|
|
@@ -102,17 +103,14 @@ function reduceWall(entries) {
|
|
|
102
103
|
const held = repo.branchMap.get(ref.name);
|
|
103
104
|
if (!held || ref.version > held.version) repo.branchMap.set(ref.name, ref);
|
|
104
105
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
...
|
|
112
|
-
|
|
113
|
-
mutationId: card.checkpointMutationId,
|
|
114
|
-
});
|
|
115
|
-
repo.parked.push(...(card.checkpoint.parked || []));
|
|
106
|
+
for (const checkpoint of card.checkpoints || []) {
|
|
107
|
+
if (
|
|
108
|
+
validVersion(checkpoint.version)
|
|
109
|
+
&& checkpoint.machine === wallEntry.machine
|
|
110
|
+
) {
|
|
111
|
+
repo.transitions.push(checkpoint);
|
|
112
|
+
repo.parked.push(...(checkpoint.parked || []));
|
|
113
|
+
}
|
|
116
114
|
}
|
|
117
115
|
}
|
|
118
116
|
}
|
|
@@ -171,6 +169,36 @@ async function obtainCardObjects(localCard, targetCard) {
|
|
|
171
169
|
: { state: 'missing-objects-after-fetch' };
|
|
172
170
|
}
|
|
173
171
|
|
|
172
|
+
async function hydrateRepo(project, plan) {
|
|
173
|
+
const targetCard = plan.card;
|
|
174
|
+
if (!project?.path) return { state: 'waiting', reason: 'local-project-path-missing' };
|
|
175
|
+
if (!targetCard?.head?.oid) return { state: 'waiting', reason: 'winning-card-unborn' };
|
|
176
|
+
if (targetCard.objects?.kind !== 'bundle') {
|
|
177
|
+
return { state: 'waiting', reason: targetCard.objects?.kind === 'pending'
|
|
178
|
+
? 'objects-uploading'
|
|
179
|
+
: 'winning-card-bundle-missing' };
|
|
180
|
+
}
|
|
181
|
+
const prepared = require('./objects').prepareBundleImport(targetCard.objects);
|
|
182
|
+
if (prepared.state !== 'ready') {
|
|
183
|
+
return { state: 'waiting', reason: `objects-${prepared.state}` };
|
|
184
|
+
}
|
|
185
|
+
await transition.initializeRepo(project.path, targetCard.repo?.remote || null);
|
|
186
|
+
await transition.importBundle(project.path, prepared.file);
|
|
187
|
+
const holding = transition.holdInitialFiles(project.path);
|
|
188
|
+
try {
|
|
189
|
+
await transition.checkoutInitialCard(project.path, {
|
|
190
|
+
branch: targetCard.head.branch,
|
|
191
|
+
oid: targetCard.head.oid,
|
|
192
|
+
});
|
|
193
|
+
} catch (error) {
|
|
194
|
+
transition.restoreInitialFiles(project.path, holding);
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
transition.restoreInitialFiles(project.path, holding);
|
|
198
|
+
await refreshRepoCards();
|
|
199
|
+
return { state: 'hydrated', moved: true };
|
|
200
|
+
}
|
|
201
|
+
|
|
174
202
|
async function moveToWinningCard(localCard, targetCard) {
|
|
175
203
|
const current = await headAndDirty(localCard.path);
|
|
176
204
|
const operation = await operationState(localCard.path);
|
|
@@ -471,7 +499,8 @@ async function materializeRepo(localCardInput, plan) {
|
|
|
471
499
|
async function runReducer() {
|
|
472
500
|
service.counters.passes += 1;
|
|
473
501
|
const startedAt = Date.now();
|
|
474
|
-
const
|
|
502
|
+
const wall = require('./wall');
|
|
503
|
+
const plans = reduceWall(wall.readWall());
|
|
475
504
|
const locals = new Map();
|
|
476
505
|
for (const card of listRepoCards()) {
|
|
477
506
|
if (card.kind !== 'primary' || card.health !== 'healthy') continue;
|
|
@@ -481,10 +510,50 @@ async function runReducer() {
|
|
|
481
510
|
|
|
482
511
|
let moved = false;
|
|
483
512
|
for (const [repoKey, plan] of plans) {
|
|
484
|
-
|
|
485
|
-
if (!
|
|
513
|
+
let local = locals.get(repoKey);
|
|
514
|
+
if (!plan.card) continue;
|
|
486
515
|
try {
|
|
487
|
-
|
|
516
|
+
if (!local?.head?.oid) {
|
|
517
|
+
const hydrated = await hydrateRepo(wall.projectForRepo(repoKey), plan);
|
|
518
|
+
service.repos.set(repoKey, {
|
|
519
|
+
repoKey,
|
|
520
|
+
path: wall.projectForRepo(repoKey)?.path || local?.path || null,
|
|
521
|
+
...hydrated,
|
|
522
|
+
at: new Date().toISOString(),
|
|
523
|
+
});
|
|
524
|
+
if (hydrated.state === 'waiting') service.counters.waiting += 1;
|
|
525
|
+
if (hydrated.moved) {
|
|
526
|
+
moved = true;
|
|
527
|
+
service.counters.materialized += 1;
|
|
528
|
+
}
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
const refreshed = await refreshRepoCard(local.replica) || local;
|
|
532
|
+
if (!wall.publishedCardFor(refreshed)) {
|
|
533
|
+
wall.publishWall(repoKey);
|
|
534
|
+
const outcome = { state: 'waiting', reason: 'local-card-publishing', moved: false };
|
|
535
|
+
service.repos.set(repoKey, {
|
|
536
|
+
repoKey,
|
|
537
|
+
path: refreshed.path,
|
|
538
|
+
...outcome,
|
|
539
|
+
at: new Date().toISOString(),
|
|
540
|
+
});
|
|
541
|
+
service.counters.waiting += 1;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const currentPlan = reduceWall(wall.readWall()).get(repoKey);
|
|
545
|
+
if (!currentPlan?.card) {
|
|
546
|
+
const outcome = { state: 'waiting', reason: 'official-card-settling', moved: false };
|
|
547
|
+
service.repos.set(repoKey, {
|
|
548
|
+
repoKey,
|
|
549
|
+
path: refreshed.path,
|
|
550
|
+
...outcome,
|
|
551
|
+
at: new Date().toISOString(),
|
|
552
|
+
});
|
|
553
|
+
service.counters.waiting += 1;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
const outcome = await materializeRepo(refreshed, currentPlan);
|
|
488
557
|
service.repos.set(repoKey, { repoKey, path: local.path, ...outcome, at: new Date().toISOString() });
|
|
489
558
|
if (outcome.state === 'waiting') service.counters.waiting += 1;
|
|
490
559
|
if (outcome.moved) {
|
|
@@ -572,6 +641,7 @@ function reducerStats() {
|
|
|
572
641
|
}
|
|
573
642
|
|
|
574
643
|
module.exports = {
|
|
644
|
+
hydrateRepo,
|
|
575
645
|
materializeRepo,
|
|
576
646
|
readRepoState,
|
|
577
647
|
reduceNow,
|
|
@@ -23,6 +23,7 @@ const { AMALGM_DIR } = require('../config');
|
|
|
23
23
|
const execFileAsync = promisify(execFile);
|
|
24
24
|
|
|
25
25
|
const JOURNAL_FILE = path.join(AMALGM_DIR, 'state', 'workspace-transitions.jsonl');
|
|
26
|
+
const HYDRATION_DIR = path.join(AMALGM_DIR, 'state', 'repo-hydration');
|
|
26
27
|
|
|
27
28
|
const TRANSITION_ENV = {
|
|
28
29
|
GIT_TERMINAL_PROMPT: '0',
|
|
@@ -101,6 +102,67 @@ function importBundle(worktreeRoot, bundleFile) {
|
|
|
101
102
|
]);
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
async function initializeRepo(worktreeRoot, remote) {
|
|
106
|
+
fs.mkdirSync(worktreeRoot, { recursive: true });
|
|
107
|
+
await run('initialize-repo', worktreeRoot, [
|
|
108
|
+
'-C', worktreeRoot, 'init',
|
|
109
|
+
]);
|
|
110
|
+
if (!remote) return;
|
|
111
|
+
let current = null;
|
|
112
|
+
try {
|
|
113
|
+
current = (await run(
|
|
114
|
+
'observe-origin',
|
|
115
|
+
worktreeRoot,
|
|
116
|
+
['-C', worktreeRoot, 'remote', 'get-url', 'origin'],
|
|
117
|
+
{ journalResult: false },
|
|
118
|
+
)).trim();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (!(error instanceof TransitionError)) throw error;
|
|
121
|
+
}
|
|
122
|
+
if (!current) {
|
|
123
|
+
await run('set-origin', worktreeRoot, [
|
|
124
|
+
'-C', worktreeRoot, 'remote', 'add', 'origin', remote,
|
|
125
|
+
]);
|
|
126
|
+
} else if (current !== remote) {
|
|
127
|
+
throw new TransitionError('initialize-repo', worktreeRoot, 'existing origin does not match the card');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function checkoutInitialCard(worktreeRoot, { branch, oid }) {
|
|
132
|
+
await run('checkout-initial-card', worktreeRoot, branch
|
|
133
|
+
? ['-C', worktreeRoot, 'checkout', '-B', branch, oid]
|
|
134
|
+
: ['-C', worktreeRoot, 'checkout', '--detach', oid], {
|
|
135
|
+
timeoutMs: 60_000,
|
|
136
|
+
});
|
|
137
|
+
const after = await currentHead(worktreeRoot);
|
|
138
|
+
if (after.branch !== (branch || null) || after.oid !== oid) {
|
|
139
|
+
throw new TransitionError(
|
|
140
|
+
'checkout-initial-card',
|
|
141
|
+
worktreeRoot,
|
|
142
|
+
`expected ${branch || '(detached)'}@${oid}, observed ${after.branch || '(detached)'}@${after.oid}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function holdInitialFiles(worktreeRoot) {
|
|
148
|
+
const entries = fs.readdirSync(worktreeRoot).filter((entry) => entry !== '.git');
|
|
149
|
+
if (!entries.length) return null;
|
|
150
|
+
fs.mkdirSync(HYDRATION_DIR, { recursive: true });
|
|
151
|
+
const holding = fs.mkdtempSync(path.join(HYDRATION_DIR, 'hold-'));
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
fs.renameSync(path.join(worktreeRoot, entry), path.join(holding, entry));
|
|
154
|
+
}
|
|
155
|
+
journal({ action: 'hold-initial-files', worktree: worktreeRoot, holding, entries, ok: true });
|
|
156
|
+
return holding;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function restoreInitialFiles(worktreeRoot, holding) {
|
|
160
|
+
if (!holding) return;
|
|
161
|
+
fs.cpSync(holding, worktreeRoot, { recursive: true, force: true, dereference: false });
|
|
162
|
+
fs.rmSync(holding, { recursive: true, force: true });
|
|
163
|
+
journal({ action: 'restore-initial-files', worktree: worktreeRoot, holding, ok: true });
|
|
164
|
+
}
|
|
165
|
+
|
|
104
166
|
/** Fast-forward the current branch to an exact commit. Refuses anything but a ff. */
|
|
105
167
|
function fastForwardTo(worktreeRoot, oid) {
|
|
106
168
|
return run('fast-forward', worktreeRoot, ['-C', worktreeRoot, 'merge', '--ff-only', oid], {
|
|
@@ -285,14 +347,18 @@ module.exports = {
|
|
|
285
347
|
TransitionError,
|
|
286
348
|
applyMergedFiles,
|
|
287
349
|
applyPatch,
|
|
350
|
+
checkoutInitialCard,
|
|
288
351
|
createBundle,
|
|
289
352
|
deleteBranchRef,
|
|
290
353
|
fastForwardTo,
|
|
291
354
|
fetchBranch,
|
|
292
355
|
forceMoveToCard,
|
|
356
|
+
holdInitialFiles,
|
|
293
357
|
importBundle,
|
|
358
|
+
initializeRepo,
|
|
294
359
|
moveToCard,
|
|
295
360
|
parkCommit,
|
|
361
|
+
restoreInitialFiles,
|
|
296
362
|
setBranchRef,
|
|
297
363
|
setIndexTree,
|
|
298
364
|
};
|
|
@@ -19,7 +19,7 @@ const { machineId, userId } = require('./identity');
|
|
|
19
19
|
const { listRepoCards } = require('./cards');
|
|
20
20
|
const { readCheckpoint, repoKeyOfCard } = require('./checkpoint');
|
|
21
21
|
|
|
22
|
-
const WALL_CONTRACT = 'code-project@
|
|
22
|
+
const WALL_CONTRACT = 'code-project@3';
|
|
23
23
|
const PROJECT_DIR = path.join(AMALGM_DIR, 'state', 'code-projects');
|
|
24
24
|
const REGISTRY_FILE = path.join(PROJECT_DIR, 'projects.json');
|
|
25
25
|
const PUBLISH_QUIET_MS = 25;
|
|
@@ -74,22 +74,40 @@ function projectForRepo(repoKey) {
|
|
|
74
74
|
const bound = readRegistry()[repoKey];
|
|
75
75
|
const ownerUserId = bound?.ownerUserId || userId();
|
|
76
76
|
const resourceId = bound?.resourceId || projectResourceId(repoKey, ownerUserId);
|
|
77
|
-
return resourceId ? {
|
|
77
|
+
return resourceId ? {
|
|
78
|
+
repoKey,
|
|
79
|
+
resourceId,
|
|
80
|
+
ownerUserId,
|
|
81
|
+
path: bound?.path || null,
|
|
82
|
+
file: projectFile(resourceId),
|
|
83
|
+
} : null;
|
|
78
84
|
}
|
|
79
85
|
|
|
80
|
-
function bindProject(repoKey, resourceId, ownerUserId = null) {
|
|
86
|
+
function bindProject(repoKey, resourceId, ownerUserId = null, localPath = null) {
|
|
81
87
|
if (!repoKey || typeof repoKey !== 'string') throw new Error('repoKey is required');
|
|
82
88
|
if (!/^shr_[A-Za-z0-9_-]{16,128}$/.test(String(resourceId || ''))) {
|
|
83
89
|
throw new Error('resourceId must be an opaque shared resource id');
|
|
84
90
|
}
|
|
91
|
+
if (localPath !== null && (typeof localPath !== 'string' || !path.isAbsolute(localPath))) {
|
|
92
|
+
throw new Error('path must be an absolute folder path');
|
|
93
|
+
}
|
|
85
94
|
const registry = readRegistry();
|
|
95
|
+
const previous = registry[repoKey];
|
|
86
96
|
registry[repoKey] = {
|
|
87
97
|
resourceId,
|
|
88
98
|
ownerUserId: ownerUserId || null,
|
|
99
|
+
path: localPath || previous?.path || null,
|
|
89
100
|
joinedAt: new Date().toISOString(),
|
|
90
101
|
};
|
|
91
102
|
writeRegistry(registry);
|
|
92
|
-
if (service.started)
|
|
103
|
+
if (service.started) {
|
|
104
|
+
ensureProjectBound(projectForRepo(repoKey))
|
|
105
|
+
.then(() => {
|
|
106
|
+
schedulePublish();
|
|
107
|
+
return require('./reducer').reduceNow();
|
|
108
|
+
})
|
|
109
|
+
.catch(() => {});
|
|
110
|
+
}
|
|
93
111
|
return projectForRepo(repoKey);
|
|
94
112
|
}
|
|
95
113
|
|
|
@@ -130,7 +148,29 @@ function readProjectWall(project) {
|
|
|
130
148
|
for (const line of require('../state/docs').getDocText(project.file).split('\n')) {
|
|
131
149
|
if (!line) continue;
|
|
132
150
|
const entry = parseWallLine(line);
|
|
133
|
-
if (entry?.project
|
|
151
|
+
if (entry?.project !== project.repoKey) continue;
|
|
152
|
+
entries.set(entry.machine, {
|
|
153
|
+
...entry,
|
|
154
|
+
resourceId: project.resourceId,
|
|
155
|
+
cards: entry.cards.map((card) => ({
|
|
156
|
+
...card,
|
|
157
|
+
positionVersion: validVersion(card.positionVersion)
|
|
158
|
+
? card.positionVersion
|
|
159
|
+
: officialVersion(project.resourceId, card.positionMutationId),
|
|
160
|
+
branches: (card.branches || []).map((ref) => ({
|
|
161
|
+
...ref,
|
|
162
|
+
version: validVersion(ref.version)
|
|
163
|
+
? ref.version
|
|
164
|
+
: officialVersion(project.resourceId, ref.mutationId),
|
|
165
|
+
})),
|
|
166
|
+
checkpoints: (card.checkpoints || []).map((checkpoint) => ({
|
|
167
|
+
...checkpoint,
|
|
168
|
+
version: validVersion(checkpoint.version)
|
|
169
|
+
? checkpoint.version
|
|
170
|
+
: officialVersion(project.resourceId, checkpoint.mutationId),
|
|
171
|
+
})),
|
|
172
|
+
})),
|
|
173
|
+
});
|
|
134
174
|
}
|
|
135
175
|
return [...entries.values()];
|
|
136
176
|
}
|
|
@@ -140,6 +180,22 @@ function readWall(repoKey = null) {
|
|
|
140
180
|
return selected.flatMap((project) => readProjectWall(project));
|
|
141
181
|
}
|
|
142
182
|
|
|
183
|
+
function publishedCardFor(card) {
|
|
184
|
+
const repoKey = repoKeyOfCard(card);
|
|
185
|
+
if (!repoKey) return null;
|
|
186
|
+
const own = readWall(repoKey).find((entry) => entry.machine === machineId());
|
|
187
|
+
const published = own?.cards?.find((candidate) => candidate.replica === card.replica);
|
|
188
|
+
if (
|
|
189
|
+
!published
|
|
190
|
+
|| !Number.isInteger(published.positionVersion)
|
|
191
|
+
|| published.positionVersion < 1
|
|
192
|
+
|| published.head?.branch !== card.head?.branch
|
|
193
|
+
|| published.head?.oid !== card.head?.oid
|
|
194
|
+
|| Boolean(published.head?.detached) !== Boolean(card.head?.detached)
|
|
195
|
+
) return null;
|
|
196
|
+
return published;
|
|
197
|
+
}
|
|
198
|
+
|
|
143
199
|
function samePosition(left, right) {
|
|
144
200
|
const sameHead = left?.head?.branch === right?.head?.branch
|
|
145
201
|
&& left?.head?.oid === right?.head?.oid
|
|
@@ -165,6 +221,15 @@ function checkpointIdentity(checkpoint) {
|
|
|
165
221
|
});
|
|
166
222
|
}
|
|
167
223
|
|
|
224
|
+
function validVersion(value) {
|
|
225
|
+
return Number.isInteger(value) && value > 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function knownMutation(resourceId, mutationId) {
|
|
229
|
+
if (!mutationId) return false;
|
|
230
|
+
return Boolean(require('../state/mutations').getMutation(`resource:${resourceId}`, mutationId));
|
|
231
|
+
}
|
|
232
|
+
|
|
168
233
|
function officialVersion(resourceId, mutationId) {
|
|
169
234
|
if (!mutationId) return null;
|
|
170
235
|
const mutation = require('../state/mutations').getMutation(`resource:${resourceId}`, mutationId);
|
|
@@ -180,30 +245,63 @@ function stampBranches(refs, previousBranches, mutationId, resourceId) {
|
|
|
180
245
|
const before = previous.get(name);
|
|
181
246
|
const oid = observed?.oid ?? null;
|
|
182
247
|
const unchanged = before && (before.oid ?? null) === oid;
|
|
183
|
-
const
|
|
184
|
-
|
|
248
|
+
const reusable = unchanged
|
|
249
|
+
&& before.mutationId
|
|
250
|
+
&& (validVersion(before.version) || knownMutation(resourceId, before.mutationId));
|
|
251
|
+
const mutation = reusable ? before.mutationId : mutationId;
|
|
252
|
+
const version = reusable
|
|
253
|
+
? (validVersion(before.version) ? before.version : officialVersion(resourceId, mutation))
|
|
254
|
+
: null;
|
|
185
255
|
return {
|
|
186
256
|
name,
|
|
187
257
|
oid,
|
|
188
258
|
...(observed?.objects ? { objects: observed.objects } : {}),
|
|
189
259
|
mutationId: mutation,
|
|
190
|
-
version
|
|
260
|
+
...(validVersion(version) ? { version } : {}),
|
|
191
261
|
};
|
|
192
262
|
});
|
|
193
263
|
}
|
|
194
264
|
|
|
265
|
+
function sharedCard(card) {
|
|
266
|
+
return {
|
|
267
|
+
card: card.card,
|
|
268
|
+
machine: card.machine,
|
|
269
|
+
replica: card.replica,
|
|
270
|
+
repo: card.repo,
|
|
271
|
+
objects: card.objects,
|
|
272
|
+
kind: card.kind,
|
|
273
|
+
head: {
|
|
274
|
+
branch: card.head?.branch ?? null,
|
|
275
|
+
oid: card.head?.oid ?? null,
|
|
276
|
+
detached: Boolean(card.head?.detached),
|
|
277
|
+
},
|
|
278
|
+
health: card.health,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function isCommittedCard(card) {
|
|
283
|
+
return typeof card?.head?.oid === 'string' && /^[0-9a-f]{40,64}$/.test(card.head.oid);
|
|
284
|
+
}
|
|
285
|
+
|
|
195
286
|
function stampCards(cards, previousCards, mutationId, project) {
|
|
196
287
|
const previousByReplica = new Map(
|
|
197
288
|
(previousCards || []).filter((card) => card?.replica).map((card) => [card.replica, card]),
|
|
198
289
|
);
|
|
199
290
|
return cards.map((card) => {
|
|
200
291
|
const previous = previousByReplica.get(card.replica);
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
292
|
+
const reusablePosition = samePosition(previous, card)
|
|
293
|
+
&& previous?.positionMutationId
|
|
294
|
+
&& (
|
|
295
|
+
validVersion(previous.positionVersion)
|
|
296
|
+
|| knownMutation(project.resourceId, previous.positionMutationId)
|
|
297
|
+
);
|
|
298
|
+
const positionMutationId = reusablePosition ? previous.positionMutationId : mutationId;
|
|
299
|
+
const positionVersion = reusablePosition
|
|
300
|
+
? (
|
|
301
|
+
validVersion(previous.positionVersion)
|
|
302
|
+
? previous.positionVersion
|
|
303
|
+
: officialVersion(project.resourceId, positionMutationId)
|
|
304
|
+
)
|
|
207
305
|
: null;
|
|
208
306
|
const checkpoint = readCheckpoint(machineId(), project.repoKey);
|
|
209
307
|
const currentCheckpoint = checkpoint
|
|
@@ -211,26 +309,33 @@ function stampCards(cards, previousCards, mutationId, project) {
|
|
|
211
309
|
&& checkpoint.branch === card.head?.branch
|
|
212
310
|
? checkpoint
|
|
213
311
|
: null;
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
312
|
+
const sameBase = previous?.head?.oid === card.head?.oid
|
|
313
|
+
&& previous?.head?.branch === card.head?.branch;
|
|
314
|
+
const history = sameBase && Array.isArray(previous?.checkpoints)
|
|
315
|
+
? previous.checkpoints.map((entry) => {
|
|
316
|
+
const version = validVersion(entry.version)
|
|
317
|
+
? entry.version
|
|
318
|
+
: officialVersion(project.resourceId, entry.mutationId);
|
|
319
|
+
return validVersion(version) ? { ...entry, version } : entry;
|
|
320
|
+
})
|
|
321
|
+
: [];
|
|
322
|
+
const last = history.at(-1);
|
|
323
|
+
const checkpoints = currentCheckpoint
|
|
324
|
+
? (checkpointIdentity(last) === checkpointIdentity(currentCheckpoint)
|
|
325
|
+
? history
|
|
326
|
+
: [...history, { ...currentCheckpoint, mutationId }])
|
|
327
|
+
: history;
|
|
224
328
|
return {
|
|
225
|
-
...
|
|
329
|
+
...sharedCard(card),
|
|
226
330
|
positionMutationId,
|
|
227
|
-
positionVersion
|
|
228
|
-
branches: stampBranches(
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
331
|
+
...(validVersion(positionVersion) ? { positionVersion } : {}),
|
|
332
|
+
branches: stampBranches(
|
|
333
|
+
card.refs,
|
|
334
|
+
sameBase ? previous?.branches : [],
|
|
335
|
+
mutationId,
|
|
336
|
+
project.resourceId,
|
|
337
|
+
),
|
|
338
|
+
...(checkpoints.length ? { checkpoints } : {}),
|
|
234
339
|
};
|
|
235
340
|
});
|
|
236
341
|
}
|
|
@@ -316,7 +421,10 @@ function publishWall(repoKey = null) {
|
|
|
316
421
|
const selected = repoKey ? [projectForRepo(repoKey)].filter(Boolean) : projects();
|
|
317
422
|
const results = selected.map((project) => publishProject(
|
|
318
423
|
project,
|
|
319
|
-
allCards.filter((card) =>
|
|
424
|
+
allCards.filter((card) => (
|
|
425
|
+
repoKeyOfCard(card) === project.repoKey
|
|
426
|
+
&& isCommittedCard(card)
|
|
427
|
+
)),
|
|
320
428
|
));
|
|
321
429
|
if (repoKey) return results[0] || { published: false, reason: 'project-missing', repoKey };
|
|
322
430
|
return {
|
|
@@ -475,9 +583,11 @@ function wallStats() {
|
|
|
475
583
|
module.exports = {
|
|
476
584
|
WALL_CONTRACT,
|
|
477
585
|
bindProject,
|
|
586
|
+
isCommittedCard,
|
|
478
587
|
projectFile,
|
|
479
588
|
projectForRepo,
|
|
480
589
|
projectResourceId,
|
|
590
|
+
publishedCardFor,
|
|
481
591
|
publishWall,
|
|
482
592
|
readWall,
|
|
483
593
|
startWallService,
|