amalgm 0.1.175 → 0.1.176

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/lib/shared-realtime-tunnel.js +28 -12
  2. package/package.json +1 -1
  3. package/runtime/scripts/amalgm-mcp/index.js +1 -1
  4. package/runtime/scripts/amalgm-mcp/server/routes/state.js +5 -1
  5. package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +4 -0
  6. package/runtime/scripts/amalgm-mcp/state/docs.js +13 -6
  7. package/runtime/scripts/amalgm-mcp/state/migrations.js +0 -4
  8. package/runtime/scripts/amalgm-mcp/state/mutations.js +13 -1
  9. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +25 -1
  10. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +9 -0
  11. package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +30 -3
  12. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +4 -5
  13. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +91 -0
  14. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +37 -11
  15. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +10 -2
  16. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +14 -6
  17. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +324 -0
  18. package/runtime/scripts/amalgm-mcp/tests/workspace-store.test.js +36 -1
  19. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +81 -48
  20. package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -1
  21. package/runtime/scripts/amalgm-mcp/workspace/cards.js +9 -21
  22. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +187 -122
  23. package/runtime/scripts/amalgm-mcp/workspace/git.js +71 -0
  24. package/runtime/scripts/amalgm-mcp/workspace/merge.js +119 -2
  25. package/runtime/scripts/amalgm-mcp/workspace/objects.js +49 -17
  26. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +582 -0
  27. package/runtime/scripts/amalgm-mcp/workspace/rest.js +15 -1
  28. package/runtime/scripts/amalgm-mcp/workspace/store.js +8 -18
  29. package/runtime/scripts/amalgm-mcp/workspace/transition.js +67 -0
  30. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +3 -4
  31. package/runtime/scripts/amalgm-mcp/workspace/wall.js +331 -180
  32. package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +0 -359
  33. package/runtime/scripts/amalgm-mcp/workspace/ledger.js +0 -96
  34. package/runtime/scripts/amalgm-mcp/workspace/reconcile.js +0 -452
@@ -0,0 +1,582 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * One reducer per repository, one materializer per checkout.
5
+ *
6
+ * The reducer reads the single rail-ordered wall and derives exactly:
7
+ * - one winning card (highest official card version), and
8
+ * - one checkpoint tree (checkpoint transitions folded in official order).
9
+ *
10
+ * The materializer has no peer branches. It obtains the winning commit,
11
+ * moves to that exact card when safe, advances the one held checkpoint tree
12
+ * to the newly reduced tree, and verifies on the next pass.
13
+ */
14
+
15
+ const crypto = require('crypto');
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { AMALGM_DIR } = require('../config');
19
+ const { appendStateEvent, subscribeStateEvents } = require('../state/events');
20
+ const { listRepoCards, refreshRepoCard, refreshRepoCards } = require('./cards');
21
+ const {
22
+ captureCheckpoint,
23
+ hasCommit,
24
+ headAndDirty,
25
+ isAncestor,
26
+ localBranches,
27
+ mergeBase,
28
+ operationState,
29
+ } = require('./git');
30
+ const {
31
+ readCheckpointValue,
32
+ repoKeyOfCard,
33
+ revisionOf,
34
+ } = require('./checkpoint');
35
+ const merge = require('./merge');
36
+ const transition = require('./transition');
37
+
38
+ const STATE_DIR = path.join(AMALGM_DIR, 'state', 'repo-reducer');
39
+ const REDUCE_QUIET_MS = 25;
40
+ const REDUCE_INTERVAL_MS = 30_000;
41
+
42
+ const service = {
43
+ started: false,
44
+ timer: null,
45
+ interval: null,
46
+ unsubscribe: null,
47
+ counters: { passes: 0, materialized: 0, waiting: 0, failures: 0 },
48
+ repos: new Map(),
49
+ lastPass: null,
50
+ };
51
+
52
+ function stateFile(repoKey) {
53
+ const digest = crypto.createHash('sha256').update(repoKey).digest('hex').slice(0, 24);
54
+ return path.join(STATE_DIR, `${digest}.json`);
55
+ }
56
+
57
+ function readRepoState(repoKey) {
58
+ try {
59
+ const state = JSON.parse(fs.readFileSync(stateFile(repoKey), 'utf8'));
60
+ return state?.repoKey === repoKey ? state : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function writeRepoState(repoKey, state) {
67
+ fs.mkdirSync(STATE_DIR, { recursive: true });
68
+ const next = { repoKey, ...state, updatedAt: new Date().toISOString() };
69
+ fs.writeFileSync(stateFile(repoKey), `${JSON.stringify(next, null, 2)}\n`);
70
+ return next;
71
+ }
72
+
73
+ function validVersion(value) {
74
+ return Number.isInteger(value) && value > 0;
75
+ }
76
+
77
+ /**
78
+ * Pure wall reduction. Machine identity never chooses a winner; it only
79
+ * identifies the latest checkpoint transition each writer currently owns.
80
+ */
81
+ function reduceWall(entries) {
82
+ const repos = new Map();
83
+ for (const wallEntry of entries) {
84
+ for (const card of wallEntry.cards || []) {
85
+ const repoKey = repoKeyOfCard(card);
86
+ if (
87
+ !repoKey
88
+ || card.kind !== 'primary'
89
+ || (card.health && card.health !== 'healthy')
90
+ || !validVersion(card.positionVersion)
91
+ ) continue;
92
+ let repo = repos.get(repoKey);
93
+ if (!repo) {
94
+ repo = { repoKey, card: null, transitions: [], parked: [], branchMap: new Map() };
95
+ repos.set(repoKey, repo);
96
+ }
97
+ if (!repo.card || card.positionVersion > repo.card.positionVersion) {
98
+ repo.card = { ...card, writer: wallEntry.machine };
99
+ }
100
+ for (const ref of card.branches || []) {
101
+ if (!ref?.name || !validVersion(ref.version)) continue;
102
+ const held = repo.branchMap.get(ref.name);
103
+ if (!held || ref.version > held.version) repo.branchMap.set(ref.name, ref);
104
+ }
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 || []));
116
+ }
117
+ }
118
+ }
119
+ for (const repo of repos.values()) {
120
+ repo.transitions = repo.transitions
121
+ .filter((entry) => (
122
+ entry.baseOid === repo.card.head?.oid
123
+ && entry.branch === repo.card.head?.branch
124
+ ))
125
+ .sort((left, right) => left.version - right.version);
126
+ if (repo.card.head?.branch) {
127
+ const held = repo.branchMap.get(repo.card.head.branch);
128
+ if (!held || repo.card.positionVersion > held.version) {
129
+ repo.branchMap.set(repo.card.head.branch, {
130
+ name: repo.card.head.branch,
131
+ oid: repo.card.head.oid,
132
+ objects: repo.card.objects,
133
+ mutationId: repo.card.positionMutationId,
134
+ version: repo.card.positionVersion,
135
+ });
136
+ }
137
+ }
138
+ repo.branches = [...repo.branchMap.values()].sort((left, right) => left.name.localeCompare(right.name));
139
+ delete repo.branchMap;
140
+ }
141
+ return repos;
142
+ }
143
+
144
+ function uniqueParked(entries) {
145
+ const byOid = new Map();
146
+ for (const entry of entries || []) {
147
+ if (typeof entry?.oid === 'string' && /^[0-9a-f]{40,64}$/.test(entry.oid)) {
148
+ byOid.set(entry.oid, entry);
149
+ }
150
+ }
151
+ return [...byOid.values()].sort((left, right) => left.oid.localeCompare(right.oid));
152
+ }
153
+
154
+ async function obtainCardObjects(localCard, targetCard) {
155
+ const oid = targetCard.head?.oid;
156
+ if (!oid || await hasCommit(localCard.path, oid)) return { state: 'ready' };
157
+ if (targetCard.objects?.kind === 'bundle') {
158
+ const imported = await require('./objects').importFromLocator(localCard.path, targetCard.objects);
159
+ if (imported.state !== 'imported') return { state: `objects-${imported.state}` };
160
+ } else if (targetCard.objects?.kind === 'pending') {
161
+ return { state: 'objects-uploading' };
162
+ } else {
163
+ const approved = localCard.repo?.remote
164
+ && localCard.repo.remote === targetCard.repo?.remote
165
+ && targetCard.head?.branch;
166
+ if (!approved) return { state: 'missing-objects-no-approved-source' };
167
+ await transition.fetchBranch(localCard.path, 'origin', targetCard.head.branch);
168
+ }
169
+ return await hasCommit(localCard.path, oid)
170
+ ? { state: 'ready' }
171
+ : { state: 'missing-objects-after-fetch' };
172
+ }
173
+
174
+ async function moveToWinningCard(localCard, targetCard) {
175
+ const current = await headAndDirty(localCard.path);
176
+ const operation = await operationState(localCard.path);
177
+ if (operation !== 'none') return { state: 'waiting', reason: 'local-operation-in-flight' };
178
+ if (
179
+ current.head.oid === targetCard.head?.oid
180
+ && current.head.branch === targetCard.head?.branch
181
+ ) return { state: 'ready', moved: false };
182
+
183
+ const objects = await obtainCardObjects(localCard, targetCard);
184
+ if (objects.state !== 'ready') return { state: 'waiting', reason: objects.state };
185
+
186
+ const forward = current.head.oid
187
+ && targetCard.head?.oid
188
+ && await isAncestor(localCard.path, current.head.oid, targetCard.head.oid);
189
+ if (!current.head.oid || !targetCard.head?.oid) {
190
+ return { state: 'waiting', reason: 'unborn-card-movement' };
191
+ }
192
+ const commonOid = forward
193
+ ? current.head.oid
194
+ : await mergeBase(localCard.path, current.head.oid, targetCard.head.oid);
195
+ if (!commonOid) return { state: 'waiting', reason: 'unrelated-card-histories' };
196
+
197
+ const captured = await captureCheckpoint(localCard.path);
198
+ if (captured.indexDiff === null || captured.baseOid !== current.head.oid) {
199
+ return { state: 'waiting', reason: 'local-state-moving' };
200
+ }
201
+ const repoKey = repoKeyOfCard(localCard);
202
+ const parked = [];
203
+ if (!forward && current.head.oid !== targetCard.head.oid) {
204
+ const parkedRef = await transition.parkCommit(localCard.path, current.head.oid);
205
+ parked.push({
206
+ oid: current.head.oid,
207
+ ref: parkedRef,
208
+ sourceBranch: current.head.branch,
209
+ objects: await require('./objects').bundleSourceFor(
210
+ localCard.path,
211
+ repoKey,
212
+ current.head.oid,
213
+ ),
214
+ });
215
+ }
216
+
217
+ const commonTree = await merge.treeOf(localCard.path, commonOid);
218
+ const winningTree = await merge.treeOf(localCard.path, targetCard.head.oid);
219
+ const oldTree = await merge.treeOf(localCard.path, current.head.oid);
220
+ const committed = forward || current.head.oid === targetCard.head.oid
221
+ ? { tree: winningTree, conflicts: [] }
222
+ : await merge.reduceTree(
223
+ localCard.path,
224
+ winningTree,
225
+ commonTree,
226
+ oldTree,
227
+ { label: `parked-${current.head.oid.slice(0, 12)}` },
228
+ );
229
+ const oldIndexTree = await merge.treeOfPatch(localCard.path, current.head.oid, captured.indexDiff);
230
+ const staged = await merge.reduceTree(
231
+ localCard.path,
232
+ committed.tree,
233
+ oldTree,
234
+ oldIndexTree,
235
+ { label: `staged-${current.head.oid.slice(0, 12)}` },
236
+ );
237
+ const oldWorktree = await merge.treeOfPatch(localCard.path, oldIndexTree, captured.worktreeDiff);
238
+ const working = await merge.reduceTree(
239
+ localCard.path,
240
+ staged.tree,
241
+ oldIndexTree,
242
+ oldWorktree,
243
+ { label: `working-${current.head.oid.slice(0, 12)}` },
244
+ );
245
+ const conflicts = [
246
+ ...committed.conflicts,
247
+ ...staged.conflicts,
248
+ ...working.conflicts,
249
+ ];
250
+
251
+ const rollbackDir = path.join(STATE_DIR, 'card-moves');
252
+ fs.mkdirSync(rollbackDir, { recursive: true });
253
+ fs.writeFileSync(
254
+ path.join(rollbackDir, `${current.head.oid}-to-${targetCard.head.oid}.json`),
255
+ `${JSON.stringify({
256
+ baseOid: current.head.oid,
257
+ indexDiff: captured.indexDiff,
258
+ worktreeDiff: captured.worktreeDiff,
259
+ })}\n`,
260
+ );
261
+ await transition.forceMoveToCard(localCard.path, {
262
+ expectedBranch: current.head.branch,
263
+ expectedOid: current.head.oid,
264
+ targetBranch: targetCard.head.branch,
265
+ targetOid: targetCard.head.oid,
266
+ });
267
+ const applied = await merge.advance(localCard.path, winningTree, working.tree, {
268
+ label: `card-${targetCard.positionVersion}`,
269
+ });
270
+ await transition.setIndexTree(localCard.path, staged.tree);
271
+ return {
272
+ state: 'ready',
273
+ moved: true,
274
+ parked,
275
+ conflicts: [...conflicts, ...applied.conflicts],
276
+ };
277
+ }
278
+
279
+ async function materializeParked(localCard, entries) {
280
+ const waiting = [];
281
+ for (const entry of uniqueParked(entries)) {
282
+ if (!(await hasCommit(localCard.path, entry.oid))) {
283
+ if (entry.objects?.kind === 'bundle') {
284
+ const imported = await require('./objects').importFromLocator(localCard.path, entry.objects);
285
+ if (imported.state !== 'imported') {
286
+ waiting.push({ ...entry, state: imported.state });
287
+ continue;
288
+ }
289
+ } else {
290
+ waiting.push({ ...entry, state: entry.objects?.kind || 'objects-missing' });
291
+ continue;
292
+ }
293
+ }
294
+ await transition.parkCommit(localCard.path, entry.oid);
295
+ }
296
+ return waiting;
297
+ }
298
+
299
+ async function materializeBranches(localCard, plan) {
300
+ const current = new Map((await localBranches(localCard.path)).map((ref) => [ref.name, ref.oid]));
301
+ const waiting = [];
302
+ let moved = false;
303
+ for (const ref of plan.branches || []) {
304
+ if (ref.name === plan.card.head?.branch) {
305
+ if (ref.oid !== plan.card.head.oid) {
306
+ waiting.push({ name: ref.name, state: 'checked-out-ref-disagrees-with-card' });
307
+ }
308
+ continue;
309
+ }
310
+ if (ref.oid === null) {
311
+ if (current.has(ref.name)) {
312
+ await transition.deleteBranchRef(localCard.path, ref.name);
313
+ moved = true;
314
+ }
315
+ continue;
316
+ }
317
+ if (!(await hasCommit(localCard.path, ref.oid))) {
318
+ if (ref.objects?.kind === 'bundle') {
319
+ const imported = await require('./objects').importFromLocator(localCard.path, ref.objects);
320
+ if (imported.state !== 'imported') {
321
+ waiting.push({ name: ref.name, state: imported.state });
322
+ continue;
323
+ }
324
+ } else if (ref.objects?.kind === 'remote' && localCard.repo?.remote) {
325
+ await transition.fetchBranch(localCard.path, 'origin', ref.name);
326
+ } else {
327
+ waiting.push({ name: ref.name, state: ref.objects?.kind || 'objects-missing' });
328
+ continue;
329
+ }
330
+ }
331
+ if (!(await hasCommit(localCard.path, ref.oid))) {
332
+ waiting.push({ name: ref.name, state: 'objects-missing-after-import' });
333
+ continue;
334
+ }
335
+ if (current.get(ref.name) !== ref.oid) {
336
+ await transition.setBranchRef(localCard.path, ref.name, ref.oid);
337
+ moved = true;
338
+ }
339
+ }
340
+ return { moved, waiting };
341
+ }
342
+
343
+ async function reduceCheckpoint(localCard, plan) {
344
+ const baseOid = plan.card.head.oid;
345
+ const baseTree = await merge.treeOf(localCard.path, baseOid);
346
+ let indexTree = baseTree;
347
+ let worktreeTree = baseTree;
348
+ const conflicts = [];
349
+
350
+ for (const operation of plan.transitions) {
351
+ const from = readCheckpointValue(baseOid, operation.from);
352
+ const to = readCheckpointValue(baseOid, operation.to);
353
+ if (from.state !== 'ready' || to.state !== 'ready') {
354
+ return { state: 'waiting', reason: `checkpoint-${from.state !== 'ready' ? from.state : to.state}` };
355
+ }
356
+ const fromIndexTree = await merge.treeOfPatch(localCard.path, baseOid, from.indexDiff);
357
+ const toIndexTree = await merge.treeOfPatch(localCard.path, baseOid, to.indexDiff);
358
+ const reducedIndex = await merge.reduceTree(localCard.path, indexTree, fromIndexTree, toIndexTree, {
359
+ label: String(operation.version),
360
+ });
361
+ indexTree = reducedIndex.tree;
362
+ conflicts.push(...reducedIndex.conflicts.map((item) => ({ ...item, layer: 'index' })));
363
+
364
+ const fromWorktreeTree = await merge.treeOfPatch(localCard.path, fromIndexTree, from.worktreeDiff);
365
+ const toWorktreeTree = await merge.treeOfPatch(localCard.path, toIndexTree, to.worktreeDiff);
366
+ const reducedWorktree = await merge.reduceTree(
367
+ localCard.path,
368
+ worktreeTree,
369
+ fromWorktreeTree,
370
+ toWorktreeTree,
371
+ { label: String(operation.version) },
372
+ );
373
+ worktreeTree = reducedWorktree.tree;
374
+ conflicts.push(...reducedWorktree.conflicts.map((item) => ({ ...item, layer: 'worktree' })));
375
+ }
376
+
377
+ const indexDiff = await merge.diffOfTree(localCard.path, baseOid, indexTree);
378
+ const worktreeDiff = await merge.diffOfTree(localCard.path, indexTree, worktreeTree);
379
+ return {
380
+ state: 'ready',
381
+ indexTree,
382
+ worktreeTree,
383
+ indexDiff,
384
+ worktreeDiff,
385
+ checkpointRevision: revisionOf(baseOid, indexDiff, worktreeDiff),
386
+ conflicts,
387
+ };
388
+ }
389
+
390
+ async function materializeRepo(localCardInput, plan) {
391
+ const refreshed = await refreshRepoCard(localCardInput.replica) || localCardInput;
392
+ const cardResult = await moveToWinningCard(refreshed, plan.card);
393
+ if (cardResult.state !== 'ready') return cardResult;
394
+
395
+ const localCard = cardResult.moved
396
+ ? await refreshRepoCard(refreshed.replica) || refreshed
397
+ : refreshed;
398
+ const previous = readRepoState(plan.repoKey);
399
+ const parked = uniqueParked([
400
+ ...(previous?.parked || []),
401
+ ...(plan.parked || []),
402
+ ...(cardResult.parked || []),
403
+ ]);
404
+ const parkedWaiting = await materializeParked(localCard, parked);
405
+ const branchResult = await materializeBranches(localCard, plan);
406
+ const reduced = await reduceCheckpoint(localCard, plan);
407
+ if (reduced.state !== 'ready') return reduced;
408
+
409
+ const baseTree = await merge.treeOf(localCard.path, plan.card.head.oid);
410
+ const heldWorktree = previous?.baseOid === plan.card.head.oid
411
+ && previous.heldTree
412
+ && await merge.hasTree(localCard.path, previous.heldTree)
413
+ ? previous.heldTree
414
+ : baseTree;
415
+ const heldIndex = previous?.baseOid === plan.card.head.oid
416
+ && previous.heldIndexTree
417
+ && await merge.hasTree(localCard.path, previous.heldIndexTree)
418
+ ? previous.heldIndexTree
419
+ : baseTree;
420
+ const currentIndex = await merge.indexTree(localCard.path);
421
+ const reducedLocalIndex = await merge.reduceTree(
422
+ localCard.path,
423
+ currentIndex,
424
+ heldIndex,
425
+ reduced.indexTree,
426
+ { label: `index-${reduced.checkpointRevision || 'clean'}` },
427
+ );
428
+ const applied = await merge.advance(localCard.path, heldWorktree, reduced.worktreeTree, {
429
+ label: `checkpoint-${reduced.checkpointRevision || 'clean'}`,
430
+ });
431
+ await transition.setIndexTree(localCard.path, reducedLocalIndex.tree);
432
+ const state = writeRepoState(plan.repoKey, {
433
+ path: localCard.path,
434
+ replica: localCard.replica,
435
+ branch: plan.card.head.branch,
436
+ baseOid: plan.card.head.oid,
437
+ cardVersion: plan.card.positionVersion,
438
+ cardMutationId: plan.card.positionMutationId,
439
+ checkpointRevision: reduced.checkpointRevision,
440
+ indexDiff: reduced.indexDiff,
441
+ worktreeDiff: reduced.worktreeDiff,
442
+ checkpointVersions: plan.transitions.map((entry) => entry.version),
443
+ branches: plan.branches || [],
444
+ heldTree: applied.heldTree,
445
+ heldIndexTree: reduced.indexTree,
446
+ parked,
447
+ parkedWaiting: [...parkedWaiting, ...branchResult.waiting],
448
+ conflicts: [
449
+ ...(cardResult.conflicts || []),
450
+ ...reduced.conflicts,
451
+ ...reducedLocalIndex.conflicts.map((item) => ({ ...item, layer: 'index-materialize' })),
452
+ ...applied.conflicts,
453
+ ],
454
+ materialized: applied.heldTree === reduced.worktreeTree
455
+ && reducedLocalIndex.tree === reduced.indexTree,
456
+ });
457
+ appendStateEvent({
458
+ resource: 'workspace-reducer',
459
+ op: previous ? 'update' : 'insert',
460
+ id: localCard.replica,
461
+ value: state,
462
+ source: 'workspace-reducer',
463
+ });
464
+ return {
465
+ state: state.conflicts.length ? 'conflict' : 'in-sync',
466
+ reason: state.conflicts.length ? state.conflicts.map((item) => `${item.kind}:${item.path}`).join(', ') : null,
467
+ moved: cardResult.moved || branchResult.moved || applied.applied.length > 0,
468
+ };
469
+ }
470
+
471
+ async function runReducer() {
472
+ service.counters.passes += 1;
473
+ const startedAt = Date.now();
474
+ const plans = reduceWall(require('./wall').readWall());
475
+ const locals = new Map();
476
+ for (const card of listRepoCards()) {
477
+ if (card.kind !== 'primary' || card.health !== 'healthy') continue;
478
+ const repoKey = repoKeyOfCard(card);
479
+ if (repoKey && !locals.has(repoKey)) locals.set(repoKey, card);
480
+ }
481
+
482
+ let moved = false;
483
+ for (const [repoKey, plan] of plans) {
484
+ const local = locals.get(repoKey);
485
+ if (!local || !plan.card) continue;
486
+ try {
487
+ const outcome = await materializeRepo(local, plan);
488
+ service.repos.set(repoKey, { repoKey, path: local.path, ...outcome, at: new Date().toISOString() });
489
+ if (outcome.state === 'waiting') service.counters.waiting += 1;
490
+ if (outcome.moved) {
491
+ moved = true;
492
+ service.counters.materialized += 1;
493
+ }
494
+ } catch (error) {
495
+ service.counters.failures += 1;
496
+ service.repos.set(repoKey, {
497
+ repoKey,
498
+ path: local.path,
499
+ state: 'error',
500
+ reason: error.message,
501
+ at: new Date().toISOString(),
502
+ });
503
+ console.warn(`[WorkspaceReducer] ${local.path}:`, error.message);
504
+ }
505
+ }
506
+ service.lastPass = { at: new Date().toISOString(), durationMs: Date.now() - startedAt, moved };
507
+ if (moved) refreshRepoCards().catch(() => {});
508
+ }
509
+
510
+ let running = null;
511
+ let queued = false;
512
+
513
+ function reduceNow() {
514
+ if (running) {
515
+ queued = true;
516
+ return running;
517
+ }
518
+ running = (async () => {
519
+ do {
520
+ queued = false;
521
+ await runReducer();
522
+ } while (queued);
523
+ })().finally(() => {
524
+ running = null;
525
+ });
526
+ return running;
527
+ }
528
+
529
+ function scheduleReduce() {
530
+ if (!service.started || service.timer) return;
531
+ service.timer = setTimeout(() => {
532
+ service.timer = null;
533
+ reduceNow().catch((error) => console.warn('[WorkspaceReducer] pass failed:', error.message));
534
+ }, REDUCE_QUIET_MS);
535
+ service.timer.unref?.();
536
+ }
537
+
538
+ function startWorkspaceReducer() {
539
+ if (service.started) return;
540
+ service.started = true;
541
+ service.unsubscribe = subscribeStateEvents((event) => {
542
+ const wallPaths = require('./wall').wallStats().paths;
543
+ if (
544
+ event.resource === 'repo-cards'
545
+ || event.resource === 'repo-checkpoints'
546
+ || wallPaths.includes(event.id)
547
+ ) scheduleReduce();
548
+ });
549
+ service.interval = setInterval(scheduleReduce, REDUCE_INTERVAL_MS);
550
+ service.interval.unref?.();
551
+ scheduleReduce();
552
+ }
553
+
554
+ function stopWorkspaceReducer() {
555
+ if (!service.started) return;
556
+ service.started = false;
557
+ service.unsubscribe?.();
558
+ service.unsubscribe = null;
559
+ if (service.timer) clearTimeout(service.timer);
560
+ if (service.interval) clearInterval(service.interval);
561
+ service.timer = null;
562
+ service.interval = null;
563
+ }
564
+
565
+ function reducerStats() {
566
+ return {
567
+ started: service.started,
568
+ ...service.counters,
569
+ lastPass: service.lastPass,
570
+ repos: [...service.repos.values()],
571
+ };
572
+ }
573
+
574
+ module.exports = {
575
+ materializeRepo,
576
+ readRepoState,
577
+ reduceNow,
578
+ reduceWall,
579
+ reducerStats,
580
+ startWorkspaceReducer,
581
+ stopWorkspaceReducer,
582
+ };
@@ -681,13 +681,26 @@ function handleWorkspaceSyncStats(sendJson) {
681
681
  service: repoCardServiceStats(),
682
682
  wall: wallStats(),
683
683
  checkpoints: require('./checkpoint').checkpointStats(),
684
- reconciliation: require('./reconcile').reconciliationStats(),
684
+ reducer: require('./reducer').reducerStats(),
685
685
  });
686
686
  } catch (error) {
687
687
  sendJson(500, { error: error instanceof Error ? error.message : 'Failed to read sync stats' });
688
688
  }
689
689
  }
690
690
 
691
+ function handleCodeProjectJoin(body, sendJson) {
692
+ try {
693
+ const project = require('./wall').bindProject(
694
+ body?.repoKey,
695
+ body?.resourceId,
696
+ body?.ownerUserId,
697
+ );
698
+ sendJson(200, { project });
699
+ } catch (error) {
700
+ sendJson(400, { error: error instanceof Error ? error.message : 'Failed to join code project' });
701
+ }
702
+ }
703
+
691
704
  /**
692
705
  * The whole wall: this machine's cards straight from the live store, every
693
706
  * peer's cards as last published. One call answers "what code does each of
@@ -875,6 +888,7 @@ module.exports = {
875
888
  handleRepoCardsRefresh,
876
889
  handleWorkspaceMachines,
877
890
  handleWorkspaceSyncStats,
891
+ handleCodeProjectJoin,
878
892
  handleTreeList,
879
893
  handleTreeEnroll,
880
894
  handleTreeMutation,
@@ -103,12 +103,11 @@ function normalizeWorkspaceRecord(input, options = {}) {
103
103
 
104
104
  function workspaceRow(database, identifier) {
105
105
  return database.prepare(`
106
- SELECT workspaces.*
106
+ SELECT *
107
107
  FROM workspaces
108
- LEFT JOIN workspace_id_aliases ON workspace_id_aliases.workspace_id = workspaces.id
109
- WHERE workspaces.id = ? OR workspaces.path = ? OR workspace_id_aliases.alias_id = ?
108
+ WHERE id = ? OR path = ?
110
109
  LIMIT 1
111
- `).get(identifier, identifier, identifier) || null;
110
+ `).get(identifier, identifier) || null;
112
111
  }
113
112
 
114
113
  function ensureWorkspaceIdentityRows(database, workspace, options = {}) {
@@ -197,17 +196,14 @@ function upsertWorkspace(input, options = {}) {
197
196
 
198
197
  db.transaction(() => {
199
198
  workspace = normalizeWorkspaceRecord(input, options);
200
- const requestedId = cleanString(input.id);
201
199
  const existing = db.prepare(`
202
- SELECT workspaces.*
200
+ SELECT *
203
201
  FROM workspaces
204
- LEFT JOIN workspace_id_aliases ON workspace_id_aliases.workspace_id = workspaces.id
205
- WHERE workspaces.id = ?
206
- OR workspaces.uri = ?
207
- OR workspaces.path = ?
208
- OR workspace_id_aliases.alias_id = ?
202
+ WHERE id = ?
203
+ OR uri = ?
204
+ OR path = ?
209
205
  LIMIT 1
210
- `).get(workspace.id, workspace.uri, workspace.path, requestedId);
206
+ `).get(workspace.id, workspace.uri, workspace.path);
211
207
  if (existing) {
212
208
  const old = rowToWorkspace(existing);
213
209
  workspace = {
@@ -262,12 +258,6 @@ function upsertWorkspace(input, options = {}) {
262
258
  JSON.stringify(workspace),
263
259
  );
264
260
 
265
- if (requestedId && requestedId !== workspace.id) {
266
- db.prepare(`
267
- INSERT OR IGNORE INTO workspace_id_aliases(alias_id, workspace_id)
268
- VALUES (?, ?)
269
- `).run(requestedId, workspace.id);
270
- }
271
261
  ensureWorkspaceIdentityRows(db, workspace, options);
272
262
 
273
263
  events = publishWorkspaceEvents(db, existing ? 'update' : 'insert', workspace, options.source || 'workspace:upsert');