amalgm 0.1.178 → 0.1.180

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.178",
3
+ "version": "0.1.180",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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
+ });
@@ -26,7 +26,12 @@ const { closeLocalDb } = require('../state/db');
26
26
  const { captureCheckpoint, captureWorktreeDiff } = require('../workspace/git');
27
27
  const merge = require('../workspace/merge');
28
28
  const { applyPatch } = require('../workspace/transition');
29
- const { checkpointFile, revisionOf } = require('../workspace/checkpoint');
29
+ const {
30
+ checkpointFile,
31
+ checkpointValueReady,
32
+ readCheckpointValue,
33
+ revisionOf,
34
+ } = require('../workspace/checkpoint');
30
35
 
31
36
  test.after(() => {
32
37
  closeLocalDb();
@@ -159,5 +164,19 @@ test('checkpoint captures are local cache; their operations travel on the one wa
159
164
  assert.equal(first, checkpointFile('m1', 'https://github.com/a/b'));
160
165
  assert.notEqual(first, checkpointFile('m2', 'https://github.com/a/b'));
161
166
  assert.notEqual(first, checkpointFile('m1', 'https://github.com/a/c'));
162
- assert.match(first, /repo-checkpoints-v4/);
167
+ assert.match(first, /repo-checkpoints-v5/);
168
+ });
169
+
170
+ test('an uploading checkpoint payload is not published as a transition', () => {
171
+ const pending = {
172
+ revision: 'a'.repeat(64),
173
+ payload: {
174
+ kind: 'pending',
175
+ contract: 'opaque-chunks@1',
176
+ checksum: 'b'.repeat(64),
177
+ },
178
+ };
179
+ assert.equal(checkpointValueReady(pending), false);
180
+ assert.equal(readCheckpointValue('c'.repeat(40), pending).state, 'payload-pending');
181
+ assert.equal(checkpointValueReady({ revision: null, indexDiff: '', worktreeDiff: '' }), true);
163
182
  });
@@ -22,7 +22,13 @@ 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 {
26
+ hydrateRepo,
27
+ materializeRepo,
28
+ readRepoState,
29
+ reduceCheckpoint,
30
+ reduceWall,
31
+ } = require('../workspace/reducer');
26
32
 
27
33
  test.after(() => {
28
34
  closeLocalDb();
@@ -66,7 +72,7 @@ function value(baseOid, diff) {
66
72
 
67
73
  function transition(machine, version, baseOid, from, to) {
68
74
  return {
69
- checkpoint: 'repo-checkpoint@4',
75
+ checkpoint: 'repo-checkpoint@5',
70
76
  machine,
71
77
  repoKey: 'root:test',
72
78
  branch: 'main',
@@ -96,10 +102,10 @@ function card(repo, oid, version) {
96
102
  }
97
103
 
98
104
  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');
105
+ const oldCard = card('/old', 'a'.repeat(40), 4);
106
+ oldCard.checkpoints = [transition('machine-a', 9, oldCard.head.oid, '', 'a')];
107
+ const newCard = card('/new', 'b'.repeat(40), 7);
108
+ newCard.checkpoints = [transition('machine-b', 8, newCard.head.oid, '', 'b')];
103
109
  const plans = reduceWall([
104
110
  { machine: 'machine-a', cards: [oldCard] },
105
111
  { machine: 'machine-b', cards: [newCard] },
@@ -109,6 +115,82 @@ test('wall reduction chooses one rail winner and orders checkpoint transitions b
109
115
  assert.deepEqual(plan.transitions.map((item) => item.version), [8]);
110
116
  });
111
117
 
118
+ test('a stale lower rail card cannot roll back a newer branch and commit', () => {
119
+ const newer = card('/newer', 'b'.repeat(40), 12);
120
+ newer.head.branch = 'feature';
121
+ const stale = card('/stale', 'a'.repeat(40), 11);
122
+ stale.head.branch = 'main';
123
+ const plan = reduceWall([
124
+ { machine: 'machine-stale', cards: [stale] },
125
+ { machine: 'machine-newer', cards: [newer] },
126
+ ]).get('root:test');
127
+ assert.equal(plan.card.head.branch, 'feature');
128
+ assert.equal(plan.card.head.oid, 'b'.repeat(40));
129
+ assert.equal(plan.card.positionVersion, 12);
130
+ });
131
+
132
+ test('an old pending payload does not block the later complete checkpoint transition', async () => {
133
+ const { repo, oid } = await makeRepo('pending-payload');
134
+ const diff = await patchFor(repo, { 'a.txt': 'a1\n' });
135
+ const pending = transition('machine-a', 2, oid, '', '');
136
+ pending.from = {
137
+ revision: 'a'.repeat(64),
138
+ payload: { kind: 'pending', contract: 'opaque-chunks@1', checksum: 'b'.repeat(64) },
139
+ };
140
+ pending.to = pending.from;
141
+ const complete = transition('machine-a', 3, oid, '', diff);
142
+ const reduced = await reduceCheckpoint(
143
+ { path: repo },
144
+ { card: card(repo, oid, 1), transitions: [pending, complete] },
145
+ );
146
+ assert.equal(reduced.state, 'ready');
147
+ assert.match(reduced.indexDiff, /a1/);
148
+ });
149
+
150
+ test('an empty project folder hydrates from the winning card bundle without cloning', async () => {
151
+ const sourceRepo = await makeRepo('hydrate-source');
152
+ const source = sourceRepo.repo;
153
+ fs.mkdirSync(path.join(source, '.amalgam', 'context'), { recursive: true });
154
+ fs.writeFileSync(path.join(source, '.amalgam', 'context', 'notes.md'), 'committed note\n');
155
+ await git(source, 'add', '.');
156
+ await git(source, 'commit', '-m', 'track project memory');
157
+ const oid = (await git(source, 'rev-parse', 'HEAD')).trim();
158
+ const bundle = path.join(tempRoot, 'hydrate.bundle');
159
+ await git(source, 'bundle', 'create', bundle, 'main');
160
+ const bytes = fs.readFileSync(bundle);
161
+ const digest = require('crypto').createHash('sha256').update(bytes).digest('hex');
162
+ const chunks = path.join(process.env.AMALGM_DIR, 'state', 'repo-objects', 'chunks');
163
+ fs.mkdirSync(chunks, { recursive: true });
164
+ fs.writeFileSync(path.join(chunks, `${digest}.bin`), bytes);
165
+
166
+ const target = path.join(tempRoot, 'hydrate-target');
167
+ fs.mkdirSync(path.join(target, '.amalgam', 'context'), { recursive: true });
168
+ fs.writeFileSync(path.join(target, '.amalgam', 'context', 'notes.md'), 'local note\n');
169
+ const winning = card('/remote', oid, 1);
170
+ winning.repo.remote = 'https://example.com/org/repo';
171
+ winning.objects = {
172
+ kind: 'bundle',
173
+ contract: 'git-bundle-chunks@1',
174
+ resourceId: 'shr_hydrationProof123456',
175
+ oid,
176
+ ref: `refs/amalgm/exports/${oid}`,
177
+ checksum: digest,
178
+ bytes: bytes.length,
179
+ chunks: [{ sha256: digest, bytes: bytes.length }],
180
+ };
181
+
182
+ const result = await hydrateRepo(
183
+ { path: target },
184
+ { repoKey: 'https://example.com/org/repo', card: winning, transitions: [] },
185
+ );
186
+ assert.equal(result.state, 'hydrated');
187
+ assert.equal((await git(target, 'rev-parse', 'HEAD')).trim(), oid);
188
+ assert.equal((await git(target, 'symbolic-ref', '--short', 'HEAD')).trim(), 'main');
189
+ assert.equal(fs.readFileSync(path.join(target, 'a.txt'), 'utf8'), 'a0\n');
190
+ assert.equal(fs.readFileSync(path.join(target, '.amalgam', 'context', 'notes.md'), 'utf8'), 'local note\n');
191
+ assert.match(await git(target, 'status', '--porcelain=v1'), /\.amalgam\/context\/notes\.md/);
192
+ });
193
+
112
194
  test('branch refs reduce per name; an absent stale list cannot delete another writer branch', () => {
113
195
  const first = card('/a', 'a'.repeat(40), 5);
114
196
  first.branches = [
@@ -143,9 +225,7 @@ test('a multi-user storm reduces identically regardless of network arrival order
143
225
  const entries = Array.from({ length: 12 }, (_, index) => {
144
226
  const machine = `machine-${index}`;
145
227
  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');
228
+ observed.checkpoints = [transition(machine, 100 + index, oid, '', `patch-${index}`)];
149
229
  observed.branches = [
150
230
  {
151
231
  name: `feature-${index % 4}`,
@@ -216,6 +296,40 @@ test('one materializer converges to the reduced checkpoint and later removes a r
216
296
  assert.equal(fs.readFileSync(path.join(repo, 'b.txt'), 'utf8'), 'b1\n');
217
297
  });
218
298
 
299
+ test('a writer publishing again cannot erase its earlier uncommitted work', async () => {
300
+ const { repo, oid } = await makeRepo('checkpoint-history');
301
+ const patchA = await patchFor(repo, { 'a.txt': 'a1\n' });
302
+ const patchAB = await patchFor(repo, { 'a.txt': 'a1\n', 'b.txt': 'b1\n' });
303
+ const patchABC = await patchFor(repo, {
304
+ 'a.txt': 'a1\n',
305
+ 'b.txt': 'b1\n',
306
+ 'c.txt': 'c1\n',
307
+ });
308
+ const machineA = card('/machine-a', oid, 1);
309
+ machineA.machine = 'machine-a';
310
+ machineA.checkpoints = [
311
+ transition('machine-a', 2, oid, '', patchA),
312
+ transition('machine-a', 4, oid, patchAB, patchABC),
313
+ ];
314
+ const machineB = card('/machine-b', oid, 1);
315
+ machineB.machine = 'machine-b';
316
+ machineB.checkpoints = [
317
+ transition('machine-b', 3, oid, patchA, patchAB),
318
+ ];
319
+
320
+ const plan = reduceWall([
321
+ { machine: 'machine-a', cards: [machineA] },
322
+ { machine: 'machine-b', cards: [machineB] },
323
+ ]).get('root:test');
324
+ assert.deepEqual(plan.transitions.map((item) => item.version), [2, 3, 4]);
325
+
326
+ const outcome = await materializeRepo(card(repo, oid, 1), plan);
327
+ assert.equal(outcome.state, 'in-sync');
328
+ assert.equal(fs.readFileSync(path.join(repo, 'a.txt'), 'utf8'), 'a1\n');
329
+ assert.equal(fs.readFileSync(path.join(repo, 'b.txt'), 'utf8'), 'b1\n');
330
+ assert.equal(fs.readFileSync(path.join(repo, 'c.txt'), 'utf8'), 'c1\n');
331
+ });
332
+
219
333
  test('a losing commit is parked, the winning card lands exactly, and losing work becomes uncommitted', async () => {
220
334
  const { repo, oid: baseOid } = await makeRepo('simultaneous-commit');
221
335
  fs.writeFileSync(path.join(repo, 'local-commit.txt'), 'losing commit work\n');
@@ -322,3 +436,54 @@ test('the materializer creates, moves, and deletes branch refs without checking
322
436
  await assert.rejects(() => git(repo, 'rev-parse', 'refs/heads/feature'));
323
437
  assert.equal((await git(repo, 'branch', '--show-current')).trim(), 'main');
324
438
  });
439
+
440
+ test('a card branch switch follows branch and OID together and leaves the old branch unchanged', async () => {
441
+ const { repo, oid: mainOid } = await makeRepo('card-branch-switch');
442
+ await git(repo, 'checkout', '-b', 'feature');
443
+ fs.writeFileSync(path.join(repo, 'a.txt'), 'feature\n');
444
+ await git(repo, 'add', 'a.txt');
445
+ await git(repo, 'commit', '-m', 'feature');
446
+ const featureOid = (await git(repo, 'rev-parse', 'HEAD')).trim();
447
+ await git(repo, 'checkout', 'main');
448
+
449
+ const local = card(repo, mainOid, 1);
450
+ const winning = card('/peer', featureOid, 2);
451
+ winning.head.branch = 'feature';
452
+ winning.objects = { kind: 'local' };
453
+ const outcome = await materializeRepo(local, {
454
+ repoKey: 'root:test',
455
+ card: winning,
456
+ transitions: [],
457
+ branches: [
458
+ { name: 'main', oid: mainOid, version: 1, mutationId: 'mut_main_branch01' },
459
+ { name: 'feature', oid: featureOid, version: 2, mutationId: 'mut_feature_head1' },
460
+ ],
461
+ });
462
+
463
+ assert.equal(outcome.state, 'in-sync');
464
+ assert.equal((await git(repo, 'symbolic-ref', '--short', 'HEAD')).trim(), 'feature');
465
+ assert.equal((await git(repo, 'rev-parse', 'HEAD')).trim(), featureOid);
466
+ assert.equal((await git(repo, 'rev-parse', 'refs/heads/main')).trim(), mainOid);
467
+ });
468
+
469
+ test('a branch-only card change at the same commit still switches the exact branch', async () => {
470
+ const { repo, oid } = await makeRepo('same-oid-branch-switch');
471
+ await git(repo, 'branch', 'feature');
472
+ const local = card(repo, oid, 1);
473
+ const winning = card('/peer', oid, 2);
474
+ winning.head.branch = 'feature';
475
+ winning.objects = { kind: 'local' };
476
+
477
+ const outcome = await materializeRepo(local, {
478
+ repoKey: 'root:test',
479
+ card: winning,
480
+ transitions: [],
481
+ branches: [
482
+ { name: 'main', oid, version: 1, mutationId: 'mut_same_main001' },
483
+ { name: 'feature', oid, version: 2, mutationId: 'mut_same_feature1' },
484
+ ],
485
+ });
486
+ assert.equal(outcome.state, 'in-sync');
487
+ assert.equal((await git(repo, 'symbolic-ref', '--short', 'HEAD')).trim(), 'feature');
488
+ assert.equal((await git(repo, 'rev-parse', 'HEAD')).trim(), oid);
489
+ });
@@ -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@1');
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, true);
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].dirty.untracked, 1);
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@1',
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@1","project":"${repoKey}","machine":"machine-c","car\n`,
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@1');
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 >= 3);
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@4';
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-v4', `${digest}.json`);
57
+ return path.join(AMALGM_DIR, 'state', 'repo-checkpoints-v5', `${digest}.json`);
58
58
  }
59
59
 
60
60
  function revisionOf(baseOid, indexDiff, worktreeDiff = '') {
@@ -119,10 +119,17 @@ function checkpointValue(machine, repoKey, baseOid, indexDiff, worktreeDiff) {
119
119
  };
120
120
  }
121
121
 
122
+ function checkpointValueReady(value) {
123
+ return !value?.payload || value.payload.kind === 'chunks';
124
+ }
125
+
122
126
  function readCheckpointValue(baseOid, value) {
123
127
  let indexDiff = typeof value?.indexDiff === 'string' ? value.indexDiff : '';
124
128
  let worktreeDiff = typeof value?.worktreeDiff === 'string' ? value.worktreeDiff : '';
125
129
  if (value?.payload) {
130
+ if (value.payload.kind === 'pending') {
131
+ return { state: 'payload-pending', indexDiff: null, worktreeDiff: null };
132
+ }
126
133
  const payload = require('./objects').readPayload(value.payload);
127
134
  if (payload.state !== 'ready') {
128
135
  return { state: payload.state, indexDiff: null, worktreeDiff: null };
@@ -145,23 +152,6 @@ function readCheckpointValue(baseOid, value) {
145
152
  return { state: 'ready', indexDiff, worktreeDiff };
146
153
  }
147
154
 
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
155
  async function publishPass(replicaId = null) {
166
156
  const machine = machineId();
167
157
  const run = { published: 0, skips: 0, waiting: 0, failures: 0 };
@@ -171,7 +161,7 @@ async function publishPass(replicaId = null) {
171
161
  const repoKey = repoKeyOfCard(card);
172
162
  if (!repoKey || !card.head?.oid) continue;
173
163
  try {
174
- const publishedCard = ownPublishedCard(card);
164
+ const publishedCard = require('./wall').publishedCardFor(card);
175
165
  const reduced = require('./reducer').readRepoState(repoKey);
176
166
  if (
177
167
  !publishedCard
@@ -220,27 +210,32 @@ async function publishPass(replicaId = null) {
220
210
  continue;
221
211
  }
222
212
 
213
+ const from = checkpointValue(
214
+ machine,
215
+ repoKey,
216
+ card.head.oid,
217
+ reduced.indexDiff,
218
+ reduced.worktreeDiff,
219
+ );
220
+ const to = checkpointValue(
221
+ machine,
222
+ repoKey,
223
+ card.head.oid,
224
+ captured.indexDiff,
225
+ captured.worktreeDiff,
226
+ );
227
+ if (!checkpointValueReady(from) || !checkpointValueReady(to)) {
228
+ run.waiting += 1;
229
+ continue;
230
+ }
223
231
  const envelope = {
224
232
  checkpoint: CHECKPOINT_CONTRACT,
225
233
  machine,
226
234
  repoKey,
227
235
  branch: card.head.branch,
228
236
  baseOid: card.head.oid,
229
- cardVersion: publishedCard.positionVersion,
230
- from: checkpointValue(
231
- machine,
232
- repoKey,
233
- card.head.oid,
234
- reduced.indexDiff,
235
- reduced.worktreeDiff,
236
- ),
237
- to: checkpointValue(
238
- machine,
239
- repoKey,
240
- card.head.oid,
241
- captured.indexDiff,
242
- captured.worktreeDiff,
243
- ),
237
+ from,
238
+ to,
244
239
  parked,
245
240
  capturedAt: new Date().toISOString(),
246
241
  };
@@ -340,6 +335,7 @@ module.exports = {
340
335
  MAX_INLINE_DIFF_BYTES,
341
336
  checkpointFile,
342
337
  checkpointStats,
338
+ checkpointValueReady,
343
339
  parseCheckpoint,
344
340
  publishCheckpoints,
345
341
  readCheckpoint,
@@ -328,7 +328,7 @@ function validateLocator(locator) {
328
328
  ) throw new Error('invalid git bundle locator');
329
329
  }
330
330
 
331
- async function importFromLocator(worktreeRoot, locator) {
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
- await transition.importBundle(worktreeRoot, bundleFile);
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; it only
79
- * identifies the latest checkpoint transition each writer currently owns.
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
- if (
106
- card.checkpoint
107
- && validVersion(card.checkpointVersion)
108
- && card.checkpoint.machine === wallEntry.machine
109
- ) {
110
- repo.transitions.push({
111
- ...card.checkpoint,
112
- version: card.checkpointVersion,
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);
@@ -350,6 +378,7 @@ async function reduceCheckpoint(localCard, plan) {
350
378
  for (const operation of plan.transitions) {
351
379
  const from = readCheckpointValue(baseOid, operation.from);
352
380
  const to = readCheckpointValue(baseOid, operation.to);
381
+ if (from.state === 'payload-pending' || to.state === 'payload-pending') continue;
353
382
  if (from.state !== 'ready' || to.state !== 'ready') {
354
383
  return { state: 'waiting', reason: `checkpoint-${from.state !== 'ready' ? from.state : to.state}` };
355
384
  }
@@ -471,7 +500,8 @@ async function materializeRepo(localCardInput, plan) {
471
500
  async function runReducer() {
472
501
  service.counters.passes += 1;
473
502
  const startedAt = Date.now();
474
- const plans = reduceWall(require('./wall').readWall());
503
+ const wall = require('./wall');
504
+ const plans = reduceWall(wall.readWall());
475
505
  const locals = new Map();
476
506
  for (const card of listRepoCards()) {
477
507
  if (card.kind !== 'primary' || card.health !== 'healthy') continue;
@@ -481,10 +511,50 @@ async function runReducer() {
481
511
 
482
512
  let moved = false;
483
513
  for (const [repoKey, plan] of plans) {
484
- const local = locals.get(repoKey);
485
- if (!local || !plan.card) continue;
514
+ let local = locals.get(repoKey);
515
+ if (!plan.card) continue;
486
516
  try {
487
- const outcome = await materializeRepo(local, plan);
517
+ if (!local?.head?.oid) {
518
+ const hydrated = await hydrateRepo(wall.projectForRepo(repoKey), plan);
519
+ service.repos.set(repoKey, {
520
+ repoKey,
521
+ path: wall.projectForRepo(repoKey)?.path || local?.path || null,
522
+ ...hydrated,
523
+ at: new Date().toISOString(),
524
+ });
525
+ if (hydrated.state === 'waiting') service.counters.waiting += 1;
526
+ if (hydrated.moved) {
527
+ moved = true;
528
+ service.counters.materialized += 1;
529
+ }
530
+ continue;
531
+ }
532
+ const refreshed = await refreshRepoCard(local.replica) || local;
533
+ if (!wall.publishedCardFor(refreshed)) {
534
+ wall.publishWall(repoKey);
535
+ const outcome = { state: 'waiting', reason: 'local-card-publishing', moved: false };
536
+ service.repos.set(repoKey, {
537
+ repoKey,
538
+ path: refreshed.path,
539
+ ...outcome,
540
+ at: new Date().toISOString(),
541
+ });
542
+ service.counters.waiting += 1;
543
+ continue;
544
+ }
545
+ const currentPlan = reduceWall(wall.readWall()).get(repoKey);
546
+ if (!currentPlan?.card) {
547
+ const outcome = { state: 'waiting', reason: 'official-card-settling', moved: false };
548
+ service.repos.set(repoKey, {
549
+ repoKey,
550
+ path: refreshed.path,
551
+ ...outcome,
552
+ at: new Date().toISOString(),
553
+ });
554
+ service.counters.waiting += 1;
555
+ continue;
556
+ }
557
+ const outcome = await materializeRepo(refreshed, currentPlan);
488
558
  service.repos.set(repoKey, { repoKey, path: local.path, ...outcome, at: new Date().toISOString() });
489
559
  if (outcome.state === 'waiting') service.counters.waiting += 1;
490
560
  if (outcome.moved) {
@@ -572,8 +642,10 @@ function reducerStats() {
572
642
  }
573
643
 
574
644
  module.exports = {
645
+ hydrateRepo,
575
646
  materializeRepo,
576
647
  readRepoState,
648
+ reduceCheckpoint,
577
649
  reduceNow,
578
650
  reduceWall,
579
651
  reducerStats,
@@ -694,6 +694,7 @@ function handleCodeProjectJoin(body, sendJson) {
694
694
  body?.repoKey,
695
695
  body?.resourceId,
696
696
  body?.ownerUserId,
697
+ body?.path,
697
698
  );
698
699
  sendJson(200, { project });
699
700
  } catch (error) {
@@ -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@1';
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 ? { repoKey, resourceId, ownerUserId, file: projectFile(resourceId) } : null;
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) ensureProjectBound(projectForRepo(repoKey)).catch(() => {});
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 === project.repoKey) entries.set(entry.machine, entry);
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 mutation = unchanged && before.mutationId ? before.mutationId : mutationId;
184
- const carried = unchanged && Number.isInteger(before.version) ? before.version : null;
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: carried ?? officialVersion(resourceId, mutation),
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 positionMutationId = samePosition(previous, card) && previous?.positionMutationId
202
- ? previous.positionMutationId
203
- : mutationId;
204
- const carriedPosition = samePosition(previous, card)
205
- && Number.isInteger(previous?.positionVersion)
206
- ? previous.positionVersion
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 sameCheckpoint = checkpointIdentity(previous?.checkpoint) === checkpointIdentity(currentCheckpoint);
215
- const checkpointMutationId = currentCheckpoint
216
- ? (sameCheckpoint && previous?.checkpointMutationId ? previous.checkpointMutationId : mutationId)
217
- : null;
218
- const checkpointVersion = currentCheckpoint
219
- ? (sameCheckpoint && Number.isInteger(previous?.checkpointVersion)
220
- ? previous.checkpointVersion
221
- : officialVersion(project.resourceId, checkpointMutationId))
222
- : null;
223
- const { refs, ...cardWithoutRawRefs } = card;
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
- ...cardWithoutRawRefs,
329
+ ...sharedCard(card),
226
330
  positionMutationId,
227
- positionVersion: carriedPosition ?? officialVersion(project.resourceId, positionMutationId),
228
- branches: stampBranches(refs, previous?.branches, mutationId, project.resourceId),
229
- ...(currentCheckpoint ? {
230
- checkpoint: currentCheckpoint,
231
- checkpointMutationId,
232
- checkpointVersion,
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) => repoKeyOfCard(card) === project.repoKey),
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,