amalgm 0.1.217 → 0.1.218

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.217",
3
+ "version": "0.1.218",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -180,7 +180,7 @@ test('checkpoint captures are local cache; their operations travel on the one wa
180
180
  assert.equal(first, checkpointFile('m1', 'https://github.com/a/b'));
181
181
  assert.notEqual(first, checkpointFile('m2', 'https://github.com/a/b'));
182
182
  assert.notEqual(first, checkpointFile('m1', 'https://github.com/a/c'));
183
- assert.match(first, /repo-checkpoints-v7/);
183
+ assert.match(first, /repo-checkpoints-v8/);
184
184
  });
185
185
 
186
186
  test('a materialized peer card is immediately a valid checkpoint base', () => {
@@ -258,7 +258,7 @@ test('two concurrent compacting publishers converge without losing either latest
258
258
  const Y = require('yjs');
259
259
  const repoKey = 'root:compaction-collision';
260
260
  const line = (machine, value) => JSON.stringify({
261
- wall: 'code-project@3',
261
+ wall: 'code-project@4',
262
262
  project: repoKey,
263
263
  machine,
264
264
  mutationId: `mut_${machine}_${value}`,
@@ -152,6 +152,56 @@ test('wall reduction chooses one rail winner and orders checkpoint transitions b
152
152
  ]);
153
153
  });
154
154
 
155
+ test('a checkpoint base replaces covered history and keeps only newer mutations', async () => {
156
+ const { repo, oid } = await makeRepo('checkpoint-base');
157
+ const patchA = await patchFor(repo, { 'a.txt': 'a1\n' });
158
+ const patchAB = await patchFor(repo, { 'a.txt': 'a1\n', 'b.txt': 'b1\n' });
159
+ const current = card(repo, oid, 1);
160
+ current.checkpointBase = {
161
+ checkpoint: CHECKPOINT_CONTRACT,
162
+ baseOid: oid,
163
+ branch: 'main',
164
+ through: 4,
165
+ value: value(oid, patchA),
166
+ };
167
+ current.checkpoints = [
168
+ transition('machine-a', 2, oid, '', patchA),
169
+ transition('machine-a', 5, oid, patchA, patchAB),
170
+ ];
171
+ const plan = reduceWall([{ machine: 'machine-a', cards: [current] }]).get('root:test');
172
+ assert.equal(plan.checkpointBase.through, 4);
173
+ assert.deepEqual(plan.transitions.map((entry) => entry.version), [5]);
174
+ const reduced = await reduceCheckpoint(current, plan);
175
+ assert.equal(reduced.state, 'ready');
176
+ assert.equal(reduced.checkpointRevision, revisionOf(oid, patchAB, ''));
177
+ });
178
+
179
+ test('different snapshots claiming the same rail position stop instead of choosing a winner', () => {
180
+ const oid = 'a'.repeat(40);
181
+ const first = card('/first', oid, 1);
182
+ first.checkpointBase = {
183
+ checkpoint: CHECKPOINT_CONTRACT,
184
+ baseOid: oid,
185
+ branch: 'main',
186
+ through: 4,
187
+ value: value(oid, 'patch-a'),
188
+ };
189
+ const second = card('/second', oid, 1);
190
+ second.checkpointBase = {
191
+ checkpoint: CHECKPOINT_CONTRACT,
192
+ baseOid: oid,
193
+ branch: 'main',
194
+ through: 4,
195
+ value: value(oid, 'patch-b'),
196
+ };
197
+ const plan = reduceWall([
198
+ { machine: 'machine-a', cards: [first] },
199
+ { machine: 'machine-b', cards: [second] },
200
+ ]).get('root:test');
201
+ assert.equal(plan.checkpointBase, null);
202
+ assert.equal(plan.checkpointBaseConflict, true);
203
+ });
204
+
155
205
  test('a ready parked bundle never regresses to pending', () => {
156
206
  const oid = 'c'.repeat(40);
157
207
  const ready = card('/ready', 'a'.repeat(40), 1);
@@ -35,6 +35,7 @@ const { upsertWorkspace } = require('../workspace/store');
35
35
  const { refreshRepoCards } = require('../workspace/cards');
36
36
  const { checkpointFile, repoKeyOfCard } = require('../workspace/checkpoint');
37
37
  const {
38
+ _test: wallTest,
38
39
  bindProject,
39
40
  compactWallText,
40
41
  hasLocalContribution,
@@ -148,7 +149,7 @@ test('only a value different from both the materialized baseline and the rail is
148
149
  };
149
150
  assert.equal(hasLocalContribution(local, remoteAdvance, baseline), false);
150
151
  assert.equal(hasLocalContribution(local, remoteAdvance, baseline, {
151
- checkpoint: 'repo-checkpoint@7',
152
+ checkpoint: 'repo-checkpoint@8',
152
153
  machine: 'machine-a',
153
154
  branch: 'main',
154
155
  baseOid: oidA,
@@ -218,7 +219,7 @@ test('only a value different from both the materialized baseline and the rail is
218
219
  },
219
220
  ), true);
220
221
  assert.equal(hasLocalContribution(local, plan, baseline, {
221
- checkpoint: 'repo-checkpoint@7',
222
+ checkpoint: 'repo-checkpoint@8',
222
223
  machine: 'machine-a',
223
224
  branch: 'main',
224
225
  baseOid: oidA,
@@ -328,7 +329,7 @@ test('a repo project appends immutable machine records', async () => {
328
329
  assert.equal(first.published, true);
329
330
  const entries = readWall(repoKey);
330
331
  assert.equal(entries.length, 1);
331
- assert.equal(entries[0].wall, 'code-project@3');
332
+ assert.equal(entries[0].wall, 'code-project@4');
332
333
  assert.equal(entries[0].project, repoKey);
333
334
  assert.equal(entries[0].machine, 'machine-a');
334
335
  assert.equal(entries[0].cards.length, 1);
@@ -384,12 +385,12 @@ test('a repo project appends immutable machine records', async () => {
384
385
  assert.equal(getDocText(project.file).trim().split('\n').length, 1);
385
386
  });
386
387
 
387
- test('checkpoint transitions append until the next card and carry their official rail numbers', async () => {
388
+ test('a checkpoint base replaces history already covered by the applied rail', async () => {
388
389
  const card = readWall(repoKey)[0].cards[0];
389
390
  const file = checkpointFile('machine-a', repoKey);
390
391
  fs.mkdirSync(path.dirname(file), { recursive: true });
391
392
  const firstCheckpoint = {
392
- checkpoint: 'repo-checkpoint@7',
393
+ checkpoint: 'repo-checkpoint@8',
393
394
  machine: 'machine-a',
394
395
  repoKey,
395
396
  branch: card.head.branch,
@@ -413,6 +414,20 @@ test('checkpoint transitions append until the next card and carry their official
413
414
  JSON.parse(checkpointLines.at(-1)).cards[0].checkpoints[0].version ?? null,
414
415
  null,
415
416
  );
417
+ const reducerFile = path.join(
418
+ process.env.AMALGM_DIR,
419
+ 'state',
420
+ 'repo-reducer',
421
+ `${crypto.createHash('sha256').update(repoKey).digest('hex').slice(0, 24)}.json`,
422
+ );
423
+ fs.writeFileSync(reducerFile, `${JSON.stringify({
424
+ repoKey,
425
+ baseOid: card.head.oid,
426
+ branch: card.head.branch,
427
+ appliedThrough: 2,
428
+ indexDiff: '',
429
+ worktreeDiff: 'patch-a',
430
+ })}\n`);
416
431
 
417
432
  fs.writeFileSync(file, `${JSON.stringify({
418
433
  ...firstCheckpoint,
@@ -422,12 +437,26 @@ test('checkpoint transitions append until the next card and carry their official
422
437
  })}\n`);
423
438
  const second = publishWall(repoKey);
424
439
  assert.equal(second.published, true);
425
- const history = readWall(repoKey)[0].cards[0].checkpoints;
426
- assert.equal(history.length, 2);
427
- assert.deepEqual(history.map((entry) => entry.mutationId), [first.mutationId, second.mutationId]);
440
+ const latestCard = readWall(repoKey)[0].cards[0];
441
+ assert.equal(latestCard.checkpointBase.through, 2);
442
+ assert.equal(latestCard.checkpoints.length, 1);
443
+ assert.deepEqual(latestCard.checkpoints.map((entry) => entry.mutationId), [second.mutationId]);
428
444
  assert.deepEqual(settleProject(), [3]);
429
445
  });
430
446
 
447
+ test('one failed project publisher cannot block the next repo', () => {
448
+ const selected = [{ repoKey: 'root:first' }, { repoKey: 'root:second' }];
449
+ const calls = [];
450
+ const results = wallTest.publishSelected(selected, [], (selectedProject) => {
451
+ calls.push(selectedProject.repoKey);
452
+ if (selectedProject.repoKey === 'root:first') throw new Error('too large');
453
+ return { published: true, repoKey: selectedProject.repoKey };
454
+ });
455
+ assert.deepEqual(calls, ['root:first', 'root:second']);
456
+ assert.equal(results[0].reason, 'publish-failed');
457
+ assert.equal(results[1].published, true);
458
+ });
459
+
431
460
  test('side branches keep one rail operation while the checked-out card advances', async () => {
432
461
  const repo = path.join(tempRoot, 'projects/app');
433
462
  const before = readWall(repoKey)[0].cards[0];
@@ -475,7 +504,7 @@ test('side branches keep one rail operation while the checked-out card advances'
475
504
  test('peer records and earlier local history survive later publishes', async () => {
476
505
  const beforeLines = getDocText(project.file).trim().split('\n');
477
506
  const peerLine = JSON.stringify({
478
- wall: 'code-project@3',
507
+ wall: 'code-project@4',
479
508
  project: repoKey,
480
509
  machine: 'machine-b',
481
510
  mutationId: 'mut_project_peer',
@@ -532,7 +561,7 @@ test('three concurrent append records stay whole under Yjs ordering', () => {
532
561
 
533
562
  test('wall compaction deletes only records superseded for every machine', () => {
534
563
  const line = (machine, value) => JSON.stringify({
535
- wall: 'code-project@3',
564
+ wall: 'code-project@4',
536
565
  project: repoKey,
537
566
  machine,
538
567
  mutationId: `mut_project_${machine}_${value}`,
@@ -541,7 +570,7 @@ test('wall compaction deletes only records superseded for every machine', () =>
541
570
  const oldA = line('machine-a', 'old-a');
542
571
  const oldB = line('machine-b', 'old-b');
543
572
  const latestA = line('machine-a', 'latest-a');
544
- const torn = '{"wall":"code-project@3","machine":"torn"';
573
+ const torn = '{"wall":"code-project@4","machine":"torn"';
545
574
  const latestB = line('machine-b', 'latest-b');
546
575
  const current = `${oldA}\n${oldB}\n${latestA}\n${torn}\n${latestB}\n`;
547
576
  const compacted = compactWallText(current, repoKey);
@@ -553,13 +582,13 @@ test('wall compaction deletes only records superseded for every machine', () =>
553
582
 
554
583
  test('an unordered legacy record cannot pin wall compaction', () => {
555
584
  const unordered = JSON.stringify({
556
- wall: 'code-project@3',
585
+ wall: 'code-project@4',
557
586
  project: repoKey,
558
587
  machine: '16.612Z',
559
588
  cards: [{ value: 'corrupted-legacy-record' }],
560
589
  });
561
590
  const ordered = (machine) => JSON.stringify({
562
- wall: 'code-project@3',
591
+ wall: 'code-project@4',
563
592
  project: repoKey,
564
593
  machine,
565
594
  mutationId: `mut_project_${machine}`,
@@ -576,7 +605,7 @@ test('an unordered legacy record cannot pin wall compaction', () => {
576
605
 
577
606
  test('retired machine values stay represented without pinning obsolete active history', () => {
578
607
  const line = (machine, value) => JSON.stringify({
579
- wall: 'code-project@3',
608
+ wall: 'code-project@4',
580
609
  project: repoKey,
581
610
  machine,
582
611
  mutationId: `mut_project_${machine}_${value}`,
@@ -601,7 +630,7 @@ test('retired machine values stay represented without pinning obsolete active hi
601
630
  test('a concurrent deletion-only compaction and append commute in Yjs', () => {
602
631
  const Y = require('yjs');
603
632
  const line = (machine, value) => JSON.stringify({
604
- wall: 'code-project@3',
633
+ wall: 'code-project@4',
605
634
  project: repoKey,
606
635
  machine,
607
636
  mutationId: `mut_project_${machine}_${value}`,
@@ -654,7 +683,7 @@ test('a concurrent deletion-only compaction and append commute in Yjs', () => {
654
683
  test('a torn line is ignored by readers, never fatal', () => {
655
684
  writeDocText(
656
685
  project.file,
657
- `${getDocText(project.file)}{"wall":"code-project@3","project":"${repoKey}","machine":"machine-c","car\n`,
686
+ `${getDocText(project.file)}{"wall":"code-project@4","project":"${repoKey}","machine":"machine-c","car\n`,
658
687
  );
659
688
  settleProject();
660
689
  assert.deepEqual(readWall(repoKey).map((entry) => entry.machine).sort(), ['machine-a', 'machine-b']);
@@ -682,7 +711,7 @@ test('publishing compacts an oversized history before appending the new record',
682
711
  test('project stats name every project resource, binding, and publish counter', () => {
683
712
  const stats = wallStats();
684
713
  const row = stats.projects.find((entry) => entry.repoKey === repoKey);
685
- assert.equal(stats.contract, 'code-project@3');
714
+ assert.equal(stats.contract, 'code-project@4');
686
715
  assert.equal(row.resourceId, project.resourceId);
687
716
  assert.equal(row.replica.appliedVersion >= 0, true);
688
717
  assert.equal(row.machines, 2);
@@ -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@7';
28
+ const CHECKPOINT_CONTRACT = 'repo-checkpoint@8';
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-v7', `${digest}.json`);
57
+ return path.join(AMALGM_DIR, 'state', 'repo-checkpoints-v8', `${digest}.json`);
58
58
  }
59
59
 
60
60
  function revisionOf(baseOid, indexDiff, worktreeDiff = '') {
@@ -416,6 +416,7 @@ module.exports = {
416
416
  MAX_INLINE_DIFF_BYTES,
417
417
  checkpointFile,
418
418
  checkpointStats,
419
+ checkpointValue,
419
420
  checkpointValueReady,
420
421
  parseCheckpoint,
421
422
  publishCheckpoints,
@@ -29,6 +29,7 @@ const {
29
29
  } = require('./git');
30
30
  const {
31
31
  CHECKPOINT_CONTRACT,
32
+ checkpointValueReady,
32
33
  readCheckpointValue,
33
34
  repoKeyOfCard,
34
35
  revisionOf,
@@ -108,7 +109,14 @@ function reduceWall(entries) {
108
109
  ) continue;
109
110
  let repo = repos.get(repoKey);
110
111
  if (!repo) {
111
- repo = { repoKey, card: null, transitions: [], parked: [], branchMap: new Map() };
112
+ repo = {
113
+ repoKey,
114
+ card: null,
115
+ checkpointBases: [],
116
+ transitions: [],
117
+ parked: [],
118
+ branchMap: new Map(),
119
+ };
112
120
  repos.set(repoKey, repo);
113
121
  }
114
122
  if (!repo.card || card.positionVersion > repo.card.positionVersion) {
@@ -128,13 +136,39 @@ function reduceWall(entries) {
128
136
  repo.transitions.push(checkpoint);
129
137
  }
130
138
  }
139
+ const checkpointBase = card.checkpointBase;
140
+ if (
141
+ checkpointBase?.checkpoint === CHECKPOINT_CONTRACT
142
+ && Number.isInteger(checkpointBase.through)
143
+ && checkpointBase.through >= 0
144
+ && checkpointBase.baseOid === card.head?.oid
145
+ && checkpointBase.branch === card.head?.branch
146
+ ) {
147
+ repo.checkpointBases.push(checkpointBase);
148
+ }
131
149
  }
132
150
  }
133
151
  for (const repo of repos.values()) {
152
+ const matchingBases = repo.checkpointBases
153
+ .filter((entry) => (
154
+ entry.baseOid === repo.card.head?.oid
155
+ && entry.branch === repo.card.head?.branch
156
+ ))
157
+ .sort((left, right) => right.through - left.through);
158
+ const newestThrough = matchingBases[0]?.through ?? null;
159
+ const newestBases = matchingBases
160
+ .filter((entry) => entry.through === newestThrough)
161
+ .sort((left, right) => (
162
+ Number(checkpointValueReady(right.value)) - Number(checkpointValueReady(left.value))
163
+ ));
164
+ const baseRevisions = new Set(newestBases.map((entry) => entry.value?.revision ?? null));
165
+ repo.checkpointBaseConflict = baseRevisions.size > 1;
166
+ repo.checkpointBase = repo.checkpointBaseConflict ? null : newestBases[0] || null;
134
167
  repo.transitions = repo.transitions
135
168
  .filter((entry) => (
136
169
  entry.baseOid === repo.card.head?.oid
137
170
  && entry.branch === repo.card.head?.branch
171
+ && (!repo.checkpointBase || entry.version > repo.checkpointBase.through)
138
172
  ))
139
173
  .sort((left, right) => left.version - right.version);
140
174
  repo.parked = uniqueParked(repo.transitions.flatMap((checkpoint) => (
@@ -157,6 +191,7 @@ function reduceWall(entries) {
157
191
  }
158
192
  repo.branches = [...repo.branchMap.values()].sort((left, right) => left.name.localeCompare(right.name));
159
193
  delete repo.branchMap;
194
+ delete repo.checkpointBases;
160
195
  }
161
196
  return repos;
162
197
  }
@@ -433,6 +468,21 @@ async function reduceCheckpoint(localCard, plan) {
433
468
  let indexTree = baseTree;
434
469
  let worktreeTree = baseTree;
435
470
  const conflicts = [];
471
+ if (plan.checkpointBaseConflict) {
472
+ return { state: 'waiting', reason: 'checkpoint-base-disagrees' };
473
+ }
474
+ if (plan.checkpointBase) {
475
+ const snapshot = readCheckpointValue(baseOid, plan.checkpointBase.value);
476
+ if (snapshot.state !== 'ready') {
477
+ return { state: 'waiting', reason: `checkpoint-base-${snapshot.state}` };
478
+ }
479
+ indexTree = await merge.treeOfPatch(localCard.path, baseOid, snapshot.indexDiff);
480
+ worktreeTree = await merge.treeOfPatch(
481
+ localCard.path,
482
+ indexTree,
483
+ snapshot.worktreeDiff,
484
+ );
485
+ }
436
486
 
437
487
  for (const entry of plan.parked || []) {
438
488
  const commonOid = await mergeBase(localCard.path, baseOid, entry.oid);
@@ -17,9 +17,15 @@ const { AMALGM_DIR } = require('../config');
17
17
  const { subscribeStateEvents } = require('../state/events');
18
18
  const { machineId, userId } = require('./identity');
19
19
  const { listRepoCards } = require('./cards');
20
- const { readCheckpoint, repoKeyOfCard, sameTransition } = require('./checkpoint');
21
-
22
- const WALL_CONTRACT = 'code-project@3';
20
+ const {
21
+ CHECKPOINT_CONTRACT,
22
+ checkpointValue,
23
+ readCheckpoint,
24
+ repoKeyOfCard,
25
+ sameTransition,
26
+ } = require('./checkpoint');
27
+
28
+ const WALL_CONTRACT = 'code-project@4';
23
29
  const PROJECT_DIR = path.join(AMALGM_DIR, 'state', 'code-projects');
24
30
  const REGISTRY_FILE = path.join(PROJECT_DIR, 'projects.json');
25
31
  const PUBLISH_QUIET_MS = 25;
@@ -309,6 +315,46 @@ function hasObjectUpgrade(card, plan) {
309
315
  });
310
316
  }
311
317
 
318
+ function checkpointBaseFor(card, baseline, project, shared = null) {
319
+ if (
320
+ !baseline
321
+ || baseline.baseOid !== card.head?.oid
322
+ || baseline.branch !== card.head?.branch
323
+ || !Number.isInteger(baseline.appliedThrough)
324
+ || baseline.appliedThrough < 0
325
+ ) return null;
326
+ const value = checkpointValue(
327
+ machineId(),
328
+ project.repoKey,
329
+ card.head.oid,
330
+ baseline.indexDiff || '',
331
+ baseline.worktreeDiff || '',
332
+ );
333
+ const reusableThrough = shared
334
+ && shared.baseOid === card.head.oid
335
+ && shared.branch === card.head.branch
336
+ && shared.value?.revision === value.revision
337
+ && Number.isInteger(shared.through)
338
+ && shared.through >= 0;
339
+ return {
340
+ checkpoint: CHECKPOINT_CONTRACT,
341
+ baseOid: card.head.oid,
342
+ branch: card.head.branch,
343
+ through: reusableThrough ? shared.through : baseline.appliedThrough,
344
+ value,
345
+ };
346
+ }
347
+
348
+ function hasCheckpointBaseUpgrade(card, plan, baseline, project) {
349
+ const shared = plan?.card?.checkpointBase;
350
+ const candidate = checkpointBaseFor(card, baseline, project, shared);
351
+ return Boolean(shared && candidate) && (
352
+ candidate.through === shared.through
353
+ && candidate.value?.revision === shared.value?.revision
354
+ && JSON.stringify(candidate.value) !== JSON.stringify(shared.value)
355
+ );
356
+ }
357
+
312
358
  function validVersion(value) {
313
359
  return Number.isInteger(value) && value > 0;
314
360
  }
@@ -443,6 +489,12 @@ function stampCards(cards, previousCards, sharedPlan, baseline, mutationId, proj
443
489
  && checkpoint.branch === card.head?.branch
444
490
  ? checkpoint
445
491
  : null;
492
+ const checkpointBase = checkpointBaseFor(
493
+ card,
494
+ baseline,
495
+ project,
496
+ sharedPlan?.card?.checkpointBase,
497
+ );
446
498
  const sameBase = previous?.head?.oid === card.head?.oid
447
499
  && previous?.head?.branch === card.head?.branch;
448
500
  const history = sameBase && Array.isArray(previous?.checkpoints)
@@ -453,7 +505,10 @@ function stampCards(cards, previousCards, sharedPlan, baseline, mutationId, proj
453
505
  entry.mutationId,
454
506
  entry.version,
455
507
  ),
456
- }))
508
+ })).filter((entry) => (
509
+ validVersion(entry.version)
510
+ && (!checkpointBase || entry.version > checkpointBase.through)
511
+ ))
457
512
  : [];
458
513
  const last = history.at(-1);
459
514
  const checkpoints = currentCheckpoint
@@ -473,6 +528,7 @@ function stampCards(cards, previousCards, sharedPlan, baseline, mutationId, proj
473
528
  mutationId,
474
529
  project.resourceId,
475
530
  ),
531
+ ...(checkpointBase ? { checkpointBase } : {}),
476
532
  ...(checkpoints.length ? { checkpoints } : {}),
477
533
  };
478
534
  });
@@ -549,7 +605,8 @@ function publishProject(project, rawCards) {
549
605
  const checkpoint = readCheckpoint(machineId(), project.repoKey);
550
606
  const previousCard = previousCards.find((entry) => entry.replica === card.replica);
551
607
  return hasLocalContribution(card, sharedPlan, baseline, checkpoint, previousCard)
552
- || hasObjectUpgrade(card, sharedPlan);
608
+ || hasObjectUpgrade(card, sharedPlan)
609
+ || hasCheckpointBaseUpgrade(card, sharedPlan, baseline, project);
553
610
  });
554
611
  if (!removed && !contributes) {
555
612
  service.counters.publishSkips += 1;
@@ -618,16 +675,33 @@ function publishProject(project, rawCards) {
618
675
  };
619
676
  }
620
677
 
678
+ function publishSelected(selected, allCards, publisher = publishProject) {
679
+ return selected.map((project) => {
680
+ try {
681
+ return publisher(
682
+ project,
683
+ allCards.filter((card) => (
684
+ repoKeyOfCard(card) === project.repoKey
685
+ && isCommittedCard(card)
686
+ )),
687
+ );
688
+ } catch (error) {
689
+ service.counters.publishFailures += 1;
690
+ console.warn(`[CodeProject] ${project.repoKey} publish failed:`, error.message);
691
+ return {
692
+ published: false,
693
+ reason: 'publish-failed',
694
+ repoKey: project.repoKey,
695
+ error: error.message,
696
+ };
697
+ }
698
+ });
699
+ }
700
+
621
701
  function publishWall(repoKey = null) {
622
702
  const allCards = listRepoCards();
623
703
  const selected = repoKey ? [projectForRepo(repoKey)].filter(Boolean) : projects();
624
- const results = selected.map((project) => publishProject(
625
- project,
626
- allCards.filter((card) => (
627
- repoKeyOfCard(card) === project.repoKey
628
- && isCommittedCard(card)
629
- )),
630
- ));
704
+ const results = publishSelected(selected, allCards);
631
705
  if (repoKey) return results[0] || { published: false, reason: 'project-missing', repoKey };
632
706
  return {
633
707
  published: results.some((result) => result.published),
@@ -788,6 +862,7 @@ function wallStats() {
788
862
  }
789
863
 
790
864
  module.exports = {
865
+ _test: { publishSelected },
791
866
  WALL_CONTRACT,
792
867
  bindProject,
793
868
  compactWallText,