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
@@ -1,452 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * Workspace reconciliation: reconcile this machine's checkouts toward
5
- * what peer machines published — cards for where the repo stands (B),
6
- * checkpoints for the uncommitted content (D).
7
- *
8
- * The workspace rule this implements: every writer — human, agent, or
9
- * remote machine — writes into the same working tree, and no writer's
10
- * work is ever silently lost. A peer's edits land here the way a local
11
- * agent's would: non-overlapping changes coexist, overlapping changes
12
- * preserve both versions and surface a named per-file conflict, git
13
- * history moves (commit, branch) travel as card-controlled transitions,
14
- * and ignored files never participate.
15
- *
16
- * Reconciliation is automatic for every eligible checkout. There is no
17
- * per-machine enable flag and no directional relationship between
18
- * machines. Authorization belongs at the user-level workspace membership
19
- * boundary (owner, editor, viewer); this layer acts only on state admitted
20
- * by that boundary. There is one movement verb — advance the peer's
21
- * contribution in this tree from what we hold to what they published
22
- * (merge.js) — and it subsumes apply, re-apply, and restore: a peer going
23
- * clean is just an advance toward their bare base tree, removing exactly
24
- * their content and nothing else. Because folding an already-present
25
- * change is a no-op by content, echoed and replayed patches converge
26
- * instead of looping.
27
- *
28
- * Frozen boundaries: only closed-list operations execute (fetch an exact
29
- * branch from the approved remote, fast-forward, merged file writes via
30
- * the journaled transition layer), local work is never overwritten, and
31
- * every hold is a named, queryable state — never a silent skip. Each
32
- * reconcile pass executes at most one movement per repo and re-observes;
33
- * convergence happens across passes, on freshly verified state.
34
- */
35
-
36
- const fs = require('fs');
37
- const path = require('path');
38
- const { AMALGM_DIR } = require('../config');
39
- const { subscribeStateEvents } = require('../state/events');
40
- const { machineId } = require('./identity');
41
- const { listRepoCards, refreshRepoCard, refreshRepoCards } = require('./cards');
42
- const { readWall } = require('./wall');
43
- const {
44
- captureWorktreeDiff,
45
- hasCommit,
46
- headAndDirty,
47
- isAncestor,
48
- operationState,
49
- } = require('./git');
50
- const {
51
- checkpointFile,
52
- checkpointResourceId,
53
- readCheckpoint,
54
- repoKeyOfCard,
55
- revisionOf,
56
- } = require('./checkpoint');
57
- const { ensureDocBound } = require('./binding');
58
- const { pairDigest, readPairState, saveRollback, writePairState } = require('./ledger');
59
- const merge = require('./merge');
60
- const transition = require('./transition');
61
-
62
- const RECONCILE_QUIET_MS = 25;
63
- const RECONCILE_INTERVAL_MS = 30_000;
64
- const WALL_FILE = path.join(AMALGM_DIR, 'state', 'machine-cards-v4.jsonl');
65
-
66
- const service = {
67
- started: false,
68
- reconcileTimer: null,
69
- intervalTimer: null,
70
- unsubscribe: null,
71
- watchedDocs: new Set([WALL_FILE]),
72
- pairs: new Map(), // digest → last outcome, for stats
73
- counters: { passes: 0, movements: 0, failures: 0 },
74
- lastPass: null,
75
- };
76
-
77
- function fileMtime(file) {
78
- try {
79
- return fs.statSync(file).mtime.toISOString();
80
- } catch {
81
- return null;
82
- }
83
- }
84
-
85
- // --- same base: advance the peer's contribution ------------------------------
86
-
87
- function conflictSummary(conflicts) {
88
- return conflicts.map((c) => `${c.kind}:${c.path}`).join(', ').slice(0, 300);
89
- }
90
-
91
- /**
92
- * Cards are last-writer-wins for committed repo position. positionAt
93
- * changes only when branch/OID changes; machine id breaks the
94
- * vanishingly rare exact timestamp tie so every peer makes one decision.
95
- */
96
- function compareCardFreshness(left, right) {
97
- const leftAt = Date.parse(left?.positionAt || left?.observedAt || left?.updatedAt || '');
98
- const rightAt = Date.parse(right?.positionAt || right?.observedAt || right?.updatedAt || '');
99
- if (!Number.isFinite(leftAt) || !Number.isFinite(rightAt)) return 0;
100
- if (leftAt !== rightAt) return leftAt < rightAt ? -1 : 1;
101
- return String(left?.machine || '').localeCompare(String(right?.machine || ''));
102
- }
103
-
104
- async function advanceSameBase(localCard, digest, entry, captured, localRevision) {
105
- const root = localCard.path;
106
- const pairState = readPairState(digest);
107
- const baseTree = await merge.treeOf(root, captured.baseOid);
108
- const held = (
109
- pairState.baseOid === captured.baseOid
110
- && pairState.heldTree
111
- && await merge.hasTree(root, pairState.heldTree)
112
- ) ? pairState.heldTree : baseTree;
113
- const target = await merge.treeOfPatch(root, captured.baseOid, entry.diff || '');
114
- const ownWork = localRevision !== null && localRevision !== (pairState.settledLocalRevision ?? null);
115
-
116
- if (held === target) {
117
- if (target === baseTree) {
118
- return localRevision === null
119
- ? { state: 'in-sync', reason: null }
120
- : { state: 'local-work', reason: 'peer-clean' };
121
- }
122
- return { state: 'applied', reason: ownWork ? 'with-local-work' : null };
123
- }
124
-
125
- // A conflicted attempt is only repeated when something changed: the
126
- // peer published anew, or the local files moved. Otherwise report the
127
- // standing conflict without touching anything.
128
- if (
129
- pairState.peerRevision === entry.revision
130
- && pairState.heldTree === held
131
- && pairState.conflicts?.length
132
- && pairState.settledLocalRevision === localRevision
133
- ) {
134
- return { state: 'conflict', reason: conflictSummary(pairState.conflicts), conflicts: pairState.conflicts };
135
- }
136
-
137
- saveRollback(digest, captured.diff);
138
- const result = await merge.advance(root, held, target, { label: entry.revision || 'peer-clean' });
139
- const recaptured = await captureWorktreeDiff(root);
140
- writePairState(digest, {
141
- peerMachine: entry.machine,
142
- repoKey: entry.repoKey,
143
- baseOid: captured.baseOid,
144
- heldTree: result.heldTree,
145
- holdsContent: result.heldTree !== baseTree,
146
- peerRevision: entry.revision,
147
- conflicts: result.conflicts,
148
- settledLocalRevision: recaptured.diff ? revisionOf(recaptured.baseOid, recaptured.diff) : null,
149
- });
150
-
151
- const moved = result.applied.length > 0;
152
- if (result.conflicts.length) {
153
- return { state: 'conflict', reason: conflictSummary(result.conflicts), conflicts: result.conflicts, moved };
154
- }
155
- if (target === baseTree) {
156
- return recaptured.diff === ''
157
- ? { state: 'in-sync', reason: 'restored-peer-went-clean', moved }
158
- : { state: 'local-work', reason: 'peer-clean', moved };
159
- }
160
- return { state: 'applied', reason: ownWork ? 'coexisting-with-local-work' : null, moved };
161
- }
162
-
163
- // --- one repo, one pass -----------------------------------------------------
164
-
165
- /**
166
- * Reconcile one local checkout toward one peer's published state.
167
- * Executes at most one movement, returns { state, reason } — every branch
168
- * of the decision has a name that survives into stats.
169
- */
170
- async function reconcilePair(localCard, peerCard, entry, digest) {
171
- if (!entry) return { state: 'waiting', reason: 'no-peer-checkpoint-yet' };
172
- if (!entry.baseOid) return { state: 'waiting', reason: 'peer-unborn' };
173
-
174
- if (entry.payload?.kind === 'pending') {
175
- return { state: 'waiting', reason: 'checkpoint-uploading' };
176
- }
177
- if (entry.payload) {
178
- const payload = require('./objects').readPayload(entry.payload);
179
- if (payload.state !== 'ready') {
180
- return { state: 'waiting', reason: `checkpoint-${payload.state}` };
181
- }
182
- const diff = payload.bytes.toString('utf8');
183
- if (revisionOf(entry.baseOid, diff) !== entry.revision) {
184
- return { state: 'blocked', reason: 'checkpoint-revision-mismatch' };
185
- }
186
- entry = { ...entry, diff };
187
- }
188
-
189
- const captured = await captureWorktreeDiff(localCard.path);
190
- if (captured.diff === null) return { state: 'blocked', reason: `local-capture-${captured.reason}` };
191
- const current = await headAndDirty(localCard.path);
192
- const operation = await operationState(localCard.path);
193
- if (operation !== 'none') return { state: 'waiting', reason: 'local-operation-in-flight' };
194
- if (captured.baseOid !== current.head.oid) return { state: 'waiting', reason: 'local-state-moving' };
195
-
196
- const targetBranch = entry.branch || peerCard.head?.branch;
197
- if (
198
- peerCard.head?.oid !== entry.baseOid
199
- || peerCard.head?.branch !== targetBranch
200
- ) {
201
- return { state: 'waiting', reason: 'peer-card-checkpoint-mismatch' };
202
- }
203
- if (
204
- (entry.baseOid !== current.head.oid || targetBranch !== current.head.branch)
205
- && compareCardFreshness(localCard, peerCard) > 0
206
- ) {
207
- return { state: 'local-newer', reason: null };
208
- }
209
- const localRevision = captured.diff === '' ? null : revisionOf(captured.baseOid, captured.diff);
210
-
211
- if (entry.baseOid === captured.baseOid && targetBranch === current.head.branch) {
212
- // A peer whose dirty state couldn't travel is a hold, not a signal to
213
- // move — what we already folded in stays exactly where it is.
214
- if (entry.skipped) return { state: 'waiting', reason: `peer-skipped-${entry.skipped}` };
215
- return advanceSameBase(localCard, digest, entry, captured, localRevision);
216
- }
217
-
218
- // The peer's base commit differs from ours.
219
- if (
220
- current.head.branch === targetBranch
221
- && await isAncestor(localCard.path, entry.baseOid, captured.baseOid)
222
- ) {
223
- return { state: 'peer-behind', reason: null };
224
- }
225
- if (!(await hasCommit(localCard.path, entry.baseOid))) {
226
- if (peerCard.objects?.kind === 'bundle') {
227
- const imported = await require('./objects').importFromLocator(localCard.path, peerCard.objects);
228
- if (imported.state !== 'imported') {
229
- return { state: 'waiting', reason: `objects-${imported.state}` };
230
- }
231
- } else if (peerCard.objects?.kind === 'pending') {
232
- return { state: 'waiting', reason: 'objects-uploading' };
233
- } else {
234
- const approvedSource = localCard.repo.remote && localCard.repo.remote === peerCard.repo?.remote;
235
- if (!approvedSource || !targetBranch) {
236
- return { state: 'blocked', reason: 'missing-objects-no-approved-source' };
237
- }
238
- await transition.fetchBranch(localCard.path, 'origin', targetBranch);
239
- }
240
- if (!(await hasCommit(localCard.path, entry.baseOid))) {
241
- return { state: 'blocked', reason: 'missing-objects-after-fetch' };
242
- }
243
- }
244
- if (
245
- current.head.branch === targetBranch
246
- && !(await isAncestor(localCard.path, captured.baseOid, entry.baseOid))
247
- ) {
248
- return { state: 'diverged', reason: 'histories-diverged' };
249
- }
250
-
251
- // Fast-forward. Held peer content the peer has since committed is
252
- // absorbed by the checkout (content-equal files simply become clean);
253
- // unrelated local work rides along untouched. If git refuses because
254
- // real local work overlaps the update, first unwind what we ourselves
255
- // hold and retry next pass — our own applied content is always ours to
256
- // remove, other work never is.
257
- try {
258
- await transition.moveToCard(localCard.path, {
259
- expectedBranch: current.head.branch,
260
- expectedOid: current.head.oid,
261
- targetBranch,
262
- targetOid: entry.baseOid,
263
- });
264
- } catch (error) {
265
- const pairState = readPairState(digest);
266
- if (pairState.holdsContent && pairState.baseOid === captured.baseOid && pairState.heldTree) {
267
- const baseTree = await merge.treeOf(localCard.path, captured.baseOid);
268
- saveRollback(digest, captured.diff);
269
- const result = await merge.advance(localCard.path, pairState.heldTree, baseTree, { label: 'unwind-before-forward' });
270
- writePairState(digest, {
271
- heldTree: result.heldTree,
272
- holdsContent: result.heldTree !== baseTree,
273
- conflicts: result.conflicts,
274
- });
275
- return { state: 'restoring', reason: 'unwound-before-forward', moved: result.applied.length > 0 };
276
- }
277
- return { state: 'blocked', reason: 'local-work-overlaps-forward' };
278
- }
279
- writePairState(digest, {
280
- peerMachine: entry.machine,
281
- repoKey: entry.repoKey,
282
- baseOid: entry.baseOid,
283
- heldTree: null,
284
- holdsContent: false,
285
- peerRevision: null,
286
- conflicts: [],
287
- settledLocalRevision: null,
288
- });
289
- return {
290
- state: current.head.branch === targetBranch ? 'forwarded' : 'switched',
291
- reason: null,
292
- moved: true,
293
- };
294
- }
295
-
296
- // --- the sweep --------------------------------------------------------------
297
-
298
- async function runReconcile() {
299
- service.counters.passes += 1;
300
- const startedAt = Date.now();
301
- const self = machineId();
302
- let moved = false;
303
-
304
- const locals = new Map();
305
- for (const card of listRepoCards()) {
306
- if (card.health !== 'healthy' || card.kind !== 'primary') continue;
307
- const key = repoKeyOfCard(card);
308
- if (key && !locals.has(key)) locals.set(key, card);
309
- }
310
-
311
- for (const wallEntry of readWall()) {
312
- if (wallEntry.machine === self) continue;
313
- for (const peerCard of wallEntry.cards || []) {
314
- if (peerCard.kind !== 'primary' || (peerCard.health && peerCard.health !== 'healthy')) continue;
315
- const repoKey = repoKeyOfCard(peerCard);
316
- const localCard = repoKey && locals.get(repoKey);
317
- if (!localCard) continue;
318
- const currentLocalCard = await refreshRepoCard(localCard.replica) || localCard;
319
-
320
- const digest = pairDigest(wallEntry.machine, repoKey);
321
- const record = (outcome) => {
322
- const completedAt = new Date().toISOString();
323
- service.pairs.set(digest, {
324
- peerMachine: wallEntry.machine,
325
- repoKey,
326
- path: currentLocalCard.path,
327
- ...outcome,
328
- timing: {
329
- cardObservedAt: peerCard.observedAt || null,
330
- cardPublishedAt: wallEntry.publishedAt || null,
331
- cardReceivedAt: fileMtime(WALL_FILE),
332
- checkpointCapturedAt: outcome.checkpointCapturedAt || null,
333
- checkpointPublishedAt: outcome.checkpointPublishedAt || null,
334
- checkpointReceivedAt: outcome.checkpointReceivedAt || null,
335
- reconcileStartedAt: outcome.reconcileStartedAt || null,
336
- completedAt,
337
- reconcileDurationMs: outcome.reconcileStartedAt
338
- ? Date.now() - Date.parse(outcome.reconcileStartedAt)
339
- : null,
340
- },
341
- at: completedAt,
342
- });
343
- };
344
- try {
345
- const file = checkpointFile(wallEntry.machine, repoKey);
346
- const binding = await ensureDocBound({
347
- resourceId: checkpointResourceId(wallEntry.machine, repoKey),
348
- filePath: file,
349
- create: false,
350
- });
351
- service.watchedDocs.add(file);
352
- if (!binding.bound) {
353
- record({ state: 'waiting', reason: `checkpoint-${binding.state}` });
354
- continue;
355
- }
356
- const entry = readCheckpoint(wallEntry.machine, repoKey);
357
- const reconcileStartedAt = new Date().toISOString();
358
- const outcome = await reconcilePair(currentLocalCard, peerCard, entry, digest);
359
- if (outcome.moved) {
360
- moved = true;
361
- service.counters.movements += 1;
362
- }
363
- record({
364
- ...outcome,
365
- checkpointCapturedAt: entry?.capturedAt || null,
366
- checkpointPublishedAt: entry?.publishedAt || null,
367
- checkpointReceivedAt: fileMtime(file),
368
- reconcileStartedAt,
369
- });
370
- } catch (error) {
371
- service.counters.failures += 1;
372
- record({ state: 'error', reason: error.message });
373
- console.warn(`[WorkspaceReconcile] ${currentLocalCard.path}:`, error.message);
374
- }
375
- }
376
- }
377
-
378
- service.lastPass = { at: new Date().toISOString(), durationMs: Date.now() - startedAt, moved };
379
- if (moved) {
380
- // A movement changed real repo state: re-card promptly so our own
381
- // published truth (and any follow-up movement) doesn't wait a cycle.
382
- refreshRepoCards().catch(() => {});
383
- scheduleReconcile();
384
- }
385
- }
386
-
387
- let running = null;
388
- let queuedRun = null;
389
-
390
- function reconcileNow() {
391
- if (running) {
392
- queuedRun ??= running.catch(() => {}).then(() => reconcileNow());
393
- return queuedRun;
394
- }
395
- queuedRun = null;
396
- running = runReconcile().finally(() => {
397
- running = null;
398
- });
399
- return running;
400
- }
401
-
402
- function scheduleReconcile() {
403
- if (!service.started || service.reconcileTimer) return;
404
- service.reconcileTimer = setTimeout(() => {
405
- service.reconcileTimer = null;
406
- reconcileNow().catch((error) => console.warn('[WorkspaceReconcile] reconcile failed:', error.message));
407
- }, RECONCILE_QUIET_MS);
408
- service.reconcileTimer.unref?.();
409
- }
410
-
411
- function startWorkspaceReconciler() {
412
- if (service.started) return;
413
- service.started = true;
414
- service.unsubscribe = subscribeStateEvents((event) => {
415
- const docChanged = typeof event.resource === 'string'
416
- && event.resource.startsWith('doc:')
417
- && service.watchedDocs.has(event.id);
418
- if (docChanged || event.resource === 'repo-cards') scheduleReconcile();
419
- });
420
- service.intervalTimer = setInterval(scheduleReconcile, RECONCILE_INTERVAL_MS);
421
- service.intervalTimer.unref?.();
422
- scheduleReconcile();
423
- }
424
-
425
- function stopWorkspaceReconciler() {
426
- if (!service.started) return;
427
- service.started = false;
428
- service.unsubscribe?.();
429
- service.unsubscribe = null;
430
- if (service.reconcileTimer) clearTimeout(service.reconcileTimer);
431
- service.reconcileTimer = null;
432
- if (service.intervalTimer) clearInterval(service.intervalTimer);
433
- service.intervalTimer = null;
434
- }
435
-
436
- function reconciliationStats() {
437
- return {
438
- started: service.started,
439
- ...service.counters,
440
- lastPass: service.lastPass,
441
- pairs: [...service.pairs.values()],
442
- };
443
- }
444
-
445
- module.exports = {
446
- compareCardFreshness,
447
- reconciliationStats,
448
- reconcileNow,
449
- reconcilePair,
450
- startWorkspaceReconciler,
451
- stopWorkspaceReconciler,
452
- };