amalgm 0.1.161 → 0.1.162

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 (42) hide show
  1. package/lib/shared-realtime-tunnel.js +30 -1
  2. package/package.json +1 -1
  3. package/runtime/scripts/amalgm-mcp/fs/rest.js +26 -7
  4. package/runtime/scripts/amalgm-mcp/index.js +3 -0
  5. package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
  6. package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +32 -0
  7. package/runtime/scripts/amalgm-mcp/state/attachments.js +7 -5
  8. package/runtime/scripts/amalgm-mcp/state/cloud-resource-id.js +16 -0
  9. package/runtime/scripts/amalgm-mcp/state/db.js +74 -0
  10. package/runtime/scripts/amalgm-mcp/state/docs.js +122 -4
  11. package/runtime/scripts/amalgm-mcp/state/migrations.js +62 -0
  12. package/runtime/scripts/amalgm-mcp/state/mutations.js +4 -4
  13. package/runtime/scripts/amalgm-mcp/state/promotions.js +2 -2
  14. package/runtime/scripts/amalgm-mcp/state/replicas.js +6 -5
  15. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +27 -22
  16. package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +5 -9
  17. package/runtime/scripts/amalgm-mcp/tests/workspace-access-store.test.js +120 -0
  18. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +137 -0
  19. package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +264 -0
  20. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +155 -0
  21. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +114 -0
  22. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +227 -0
  23. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +182 -0
  24. package/runtime/scripts/amalgm-mcp/workspace/binding.js +62 -0
  25. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +237 -0
  26. package/runtime/scripts/amalgm-mcp/workspace/git.js +63 -12
  27. package/runtime/scripts/amalgm-mcp/workspace/ledger.js +96 -0
  28. package/runtime/scripts/amalgm-mcp/workspace/merge.js +290 -0
  29. package/runtime/scripts/amalgm-mcp/workspace/reconcile.js +347 -0
  30. package/runtime/scripts/amalgm-mcp/workspace/rest.js +182 -2
  31. package/runtime/scripts/amalgm-mcp/workspace/store.js +59 -12
  32. package/runtime/scripts/amalgm-mcp/workspace/transition.js +141 -0
  33. package/runtime/scripts/amalgm-mcp/workspace/tree/access.js +62 -0
  34. package/runtime/scripts/amalgm-mcp/workspace/tree/identity.js +53 -0
  35. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +68 -0
  36. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +174 -0
  37. package/runtime/scripts/amalgm-mcp/workspace/tree/operations.js +93 -0
  38. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +138 -0
  39. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +82 -0
  40. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +391 -0
  41. package/runtime/scripts/amalgm-mcp/workspace/tree-promotion.js +94 -0
  42. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +666 -0
@@ -0,0 +1,290 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The coexistence engine.
5
+ *
6
+ * The workspace rule: every writer — human, agent, or remote machine —
7
+ * writes into the same working tree, and no writer's work is ever
8
+ * silently lost. This module is the mechanism. A peer's contribution to
9
+ * a local working tree is represented as a git tree oid ("base commit
10
+ * plus exactly the peer content folded in so far"), and the only verb is
11
+ * advance: move that contribution from the tree we hold to the tree the
12
+ * peer just published, file by file, around whatever else lives in the
13
+ * working tree.
14
+ *
15
+ * Per file, in order:
16
+ * - working file already matches the peer's version → fold without
17
+ * writing (this is what makes echoes and replays no-ops);
18
+ * - working file untouched since the held version → take the peer's
19
+ * version verbatim (create, update, or delete);
20
+ * - both sides changed text → content-level three-way merge; a clean
21
+ * merge writes, an overlapping one holds the file aside untouched
22
+ * with a named conflict kind. Both versions survive: local in the
23
+ * tree, the peer's in their checkpoint.
24
+ *
25
+ * Everything here computes against the object database with scratch
26
+ * index files; the only worktree mutation is the final write set, which
27
+ * goes through transition.applyMergedFiles so it lands in the journal.
28
+ */
29
+
30
+ const fs = require('fs');
31
+ const os = require('os');
32
+ const path = require('path');
33
+ const { execFile } = require('child_process');
34
+ const { promisify } = require('util');
35
+ const { GitError } = require('./git');
36
+ const transition = require('./transition');
37
+
38
+ const execFileAsync = promisify(execFile);
39
+
40
+ const MERGE_ENV = {
41
+ GIT_OPTIONAL_LOCKS: '0',
42
+ GIT_TERMINAL_PROMPT: '0',
43
+ LC_ALL: 'C',
44
+ };
45
+
46
+ async function run(args, cwd, { env = {}, okCodes = [0], binary = false } = {}) {
47
+ try {
48
+ const { stdout } = await execFileAsync('git', args, {
49
+ cwd,
50
+ env: { ...process.env, ...MERGE_ENV, ...env },
51
+ timeout: 60_000,
52
+ maxBuffer: 64 * 1024 * 1024,
53
+ encoding: binary ? 'buffer' : 'utf8',
54
+ });
55
+ return { stdout, exitCode: 0 };
56
+ } catch (error) {
57
+ const exitCode = typeof error.code === 'number' ? error.code : 1;
58
+ if (okCodes.includes(exitCode)) {
59
+ return { stdout: error.stdout ?? (binary ? Buffer.alloc(0) : ''), exitCode };
60
+ }
61
+ throw new GitError(args, cwd, exitCode, String(error.stderr ?? error.message));
62
+ }
63
+ }
64
+
65
+ async function withScratch(fn) {
66
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'amalgm-merge-'));
67
+ try {
68
+ return await fn(dir);
69
+ } finally {
70
+ fs.rmSync(dir, { recursive: true, force: true });
71
+ }
72
+ }
73
+
74
+ /** The tree oid of a commit. */
75
+ async function treeOf(worktreeRoot, commitOid) {
76
+ const { stdout } = await run(['rev-parse', `${commitOid}^{tree}`], worktreeRoot);
77
+ return stdout.trim();
78
+ }
79
+
80
+ /** Does this checkout's object store still contain the tree? */
81
+ async function hasTree(worktreeRoot, treeOid) {
82
+ const result = await run(['cat-file', '-e', `${treeOid}^{tree}`], worktreeRoot, { okCodes: [0, 1, 128] });
83
+ return result.exitCode === 0;
84
+ }
85
+
86
+ /**
87
+ * The tree a captured worktree patch describes: base commit's tree with
88
+ * the patch applied. Computed entirely in a scratch index — the worktree
89
+ * is never touched, and the resulting tree lands in the object database
90
+ * where it is content-addressed and inspectable.
91
+ */
92
+ async function treeOfPatch(worktreeRoot, baseOid, diff) {
93
+ if (!diff) return treeOf(worktreeRoot, baseOid);
94
+ return withScratch(async (dir) => {
95
+ const env = { GIT_INDEX_FILE: path.join(dir, 'index') };
96
+ const patchFile = path.join(dir, 'patch');
97
+ await fs.promises.writeFile(patchFile, diff);
98
+ await run(['read-tree', baseOid], worktreeRoot, { env });
99
+ await run(['apply', '--cached', '--whitespace=nowarn', patchFile], worktreeRoot, { env });
100
+ const { stdout } = await run(['write-tree'], worktreeRoot, { env });
101
+ return stdout.trim();
102
+ });
103
+ }
104
+
105
+ /** Paths that differ between two trees, no rename detection — exact adds/deletes. */
106
+ async function changedPaths(worktreeRoot, treeA, treeB) {
107
+ const { stdout } = await run(
108
+ ['diff-tree', '-r', '-z', '--no-renames', '--name-status', treeA, treeB],
109
+ worktreeRoot,
110
+ );
111
+ const tokens = stdout.split('\0');
112
+ const paths = [];
113
+ for (let i = 0; i + 1 < tokens.length && tokens[i]; i += 2) {
114
+ paths.push(tokens[i + 1]);
115
+ }
116
+ return paths;
117
+ }
118
+
119
+ /** The blob entry at a path in a tree: { mode, type, oid } or null. */
120
+ async function entryAt(worktreeRoot, treeOid, relPath) {
121
+ const { stdout } = await run(['ls-tree', '-z', treeOid, '--', relPath], worktreeRoot);
122
+ const line = stdout.split('\0')[0];
123
+ const match = line && line.match(/^(\d+) (\w+) ([0-9a-f]+)\t/);
124
+ return match ? { mode: match[1], type: match[2], oid: match[3] } : null;
125
+ }
126
+
127
+ async function readBlob(worktreeRoot, oid) {
128
+ const { stdout } = await run(['cat-file', 'blob', oid], worktreeRoot, { binary: true });
129
+ return stdout;
130
+ }
131
+
132
+ /** The working file as bytes: { kind: 'file'|'symlink', content, exec } or null. */
133
+ function workFileState(worktreeRoot, relPath) {
134
+ const abs = path.join(worktreeRoot, relPath);
135
+ let stat;
136
+ try {
137
+ stat = fs.lstatSync(abs);
138
+ } catch {
139
+ return null;
140
+ }
141
+ if (stat.isSymbolicLink()) {
142
+ return { kind: 'symlink', content: Buffer.from(fs.readlinkSync(abs)), exec: false };
143
+ }
144
+ if (!stat.isFile()) return null;
145
+ return { kind: 'file', content: fs.readFileSync(abs), exec: (stat.mode & 0o100) !== 0 };
146
+ }
147
+
148
+ /** Does the working file hold exactly this tree entry's content? */
149
+ function workMatches(work, entry, blobBytes) {
150
+ if (!entry) return work === null;
151
+ if (work === null) return false;
152
+ if (entry.mode === '120000') return work.kind === 'symlink' && work.content.equals(blobBytes);
153
+ return work.kind === 'file' && work.content.equals(blobBytes);
154
+ }
155
+
156
+ function isText(buffer) {
157
+ return buffer !== null && !buffer.includes(0);
158
+ }
159
+
160
+ /** Content-level three-way merge; { ok, content } — never writes markers to disk. */
161
+ async function mergeFileContents(ours, base, theirs) {
162
+ return withScratch(async (dir) => {
163
+ const oursFile = path.join(dir, 'ours');
164
+ const baseFile = path.join(dir, 'base');
165
+ const theirsFile = path.join(dir, 'theirs');
166
+ await fs.promises.writeFile(oursFile, ours);
167
+ await fs.promises.writeFile(baseFile, base);
168
+ await fs.promises.writeFile(theirsFile, theirs);
169
+ const conflictCodes = Array.from({ length: 127 }, (_, i) => i + 1);
170
+ const result = await run(
171
+ ['merge-file', '-p', '-L', 'local', '-L', 'base', '-L', 'peer', oursFile, baseFile, theirsFile],
172
+ dir,
173
+ { okCodes: conflictCodes, binary: true },
174
+ );
175
+ return { ok: result.exitCode === 0, content: result.stdout };
176
+ });
177
+ }
178
+
179
+ /** heldTree with the given entries replaced/removed — via a scratch index. */
180
+ async function updateHeldTree(worktreeRoot, heldTree, applied) {
181
+ return withScratch(async (dir) => {
182
+ const env = { GIT_INDEX_FILE: path.join(dir, 'index') };
183
+ await run(['read-tree', heldTree], worktreeRoot, { env });
184
+ for (const { path: relPath, entry } of applied) {
185
+ if (entry) {
186
+ await run(
187
+ ['update-index', '--add', '--cacheinfo', `${entry.mode},${entry.oid},${relPath}`],
188
+ worktreeRoot,
189
+ { env },
190
+ );
191
+ } else {
192
+ await run(['update-index', '--force-remove', '--', relPath], worktreeRoot, { env });
193
+ }
194
+ }
195
+ const { stdout } = await run(['write-tree'], worktreeRoot, { env });
196
+ return stdout.trim();
197
+ });
198
+ }
199
+
200
+ function unsafePath(relPath) {
201
+ return path.isAbsolute(relPath) || relPath.split('/').includes('..');
202
+ }
203
+
204
+ /**
205
+ * Advance the peer's contribution in this working tree from heldTree to
206
+ * targetTree. Returns { applied, conflicts, heldTree, wrote } — applied
207
+ * paths are folded into the returned heldTree; conflicted paths are left
208
+ * exactly as the working tree had them, each with a named kind.
209
+ */
210
+ async function advance(worktreeRoot, heldTree, targetTree, { label } = {}) {
211
+ const applied = [];
212
+ const conflicts = [];
213
+ const writes = [];
214
+
215
+ for (const relPath of await changedPaths(worktreeRoot, heldTree, targetTree)) {
216
+ if (unsafePath(relPath)) {
217
+ conflicts.push({ path: relPath, kind: 'unsafe-path' });
218
+ continue;
219
+ }
220
+ const held = await entryAt(worktreeRoot, heldTree, relPath);
221
+ const target = await entryAt(worktreeRoot, targetTree, relPath);
222
+ if ((held && held.type !== 'blob') || (target && target.type !== 'blob')) {
223
+ conflicts.push({ path: relPath, kind: 'unsupported-entry' });
224
+ continue;
225
+ }
226
+ const heldBytes = held ? await readBlob(worktreeRoot, held.oid) : null;
227
+ const targetBytes = target ? await readBlob(worktreeRoot, target.oid) : null;
228
+ const work = workFileState(worktreeRoot, relPath);
229
+
230
+ if (workMatches(work, target, targetBytes)) {
231
+ // Already there — a replayed or echoed change folds without a write.
232
+ applied.push({ path: relPath, entry: target });
233
+ continue;
234
+ }
235
+ if (workMatches(work, held, heldBytes)) {
236
+ // Untouched locally since what we hold — take the peer's version.
237
+ writes.push(target
238
+ ? { path: relPath, content: targetBytes, mode: target.mode }
239
+ : { path: relPath, content: null });
240
+ applied.push({ path: relPath, entry: target });
241
+ continue;
242
+ }
243
+
244
+ // The working file differs from both what we hold and what the peer
245
+ // published: two writers touched it. Merge or preserve — never pick.
246
+ if (!target) {
247
+ conflicts.push({ path: relPath, kind: 'delete-vs-edit' });
248
+ continue;
249
+ }
250
+ if (!work) {
251
+ conflicts.push({ path: relPath, kind: 'edit-vs-delete' });
252
+ continue;
253
+ }
254
+ if (!held) {
255
+ conflicts.push({ path: relPath, kind: 'add-add' });
256
+ continue;
257
+ }
258
+ const mergeable = work.kind === 'file'
259
+ && target.mode !== '120000'
260
+ && held.mode !== '120000'
261
+ && isText(work.content) && isText(heldBytes) && isText(targetBytes);
262
+ if (!mergeable) {
263
+ conflicts.push({ path: relPath, kind: 'binary-overlap' });
264
+ continue;
265
+ }
266
+ const merged = await mergeFileContents(work.content, heldBytes, targetBytes);
267
+ if (!merged.ok) {
268
+ conflicts.push({ path: relPath, kind: 'content-overlap' });
269
+ continue;
270
+ }
271
+ writes.push({ path: relPath, content: merged.content, mode: work.exec ? '100755' : '100644' });
272
+ applied.push({ path: relPath, entry: target });
273
+ }
274
+
275
+ if (writes.length) await transition.applyMergedFiles(worktreeRoot, writes, label);
276
+ const newHeldTree = applied.length ? await updateHeldTree(worktreeRoot, heldTree, applied) : heldTree;
277
+ return {
278
+ applied: applied.map((a) => a.path),
279
+ conflicts,
280
+ heldTree: newHeldTree,
281
+ wrote: writes.length,
282
+ };
283
+ }
284
+
285
+ module.exports = {
286
+ advance,
287
+ hasTree,
288
+ treeOf,
289
+ treeOfPatch,
290
+ };
@@ -0,0 +1,347 @@
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 path = require('path');
37
+ const { AMALGM_DIR } = require('../config');
38
+ const { subscribeStateEvents } = require('../state/events');
39
+ const { machineId } = require('./identity');
40
+ const { listRepoCards, refreshRepoCards } = require('./cards');
41
+ const { readWall } = require('./wall');
42
+ const { captureWorktreeDiff, hasCommit, isAncestor } = require('./git');
43
+ const {
44
+ MAX_DIFF_BYTES,
45
+ checkpointFile,
46
+ checkpointResourceId,
47
+ readCheckpoint,
48
+ repoKeyOfCard,
49
+ revisionOf,
50
+ } = require('./checkpoint');
51
+ const { ensureDocBound } = require('./binding');
52
+ const { pairDigest, readPairState, saveRollback, writePairState } = require('./ledger');
53
+ const merge = require('./merge');
54
+ const transition = require('./transition');
55
+
56
+ const RECONCILE_QUIET_MS = 750;
57
+ const RECONCILE_INTERVAL_MS = 30_000;
58
+
59
+ const service = {
60
+ started: false,
61
+ reconcileTimer: null,
62
+ intervalTimer: null,
63
+ unsubscribe: null,
64
+ watchedDocs: new Set([path.join(AMALGM_DIR, 'state', 'machine-cards.jsonl')]),
65
+ pairs: new Map(), // digest → last outcome, for stats
66
+ counters: { passes: 0, movements: 0, failures: 0 },
67
+ lastPass: null,
68
+ };
69
+
70
+ // --- same base: advance the peer's contribution ------------------------------
71
+
72
+ function conflictSummary(conflicts) {
73
+ return conflicts.map((c) => `${c.kind}:${c.path}`).join(', ').slice(0, 300);
74
+ }
75
+
76
+ async function advanceSameBase(localCard, digest, entry, captured, localRevision) {
77
+ const root = localCard.path;
78
+ const pairState = readPairState(digest);
79
+ const baseTree = await merge.treeOf(root, captured.baseOid);
80
+ const held = (
81
+ pairState.baseOid === captured.baseOid
82
+ && pairState.heldTree
83
+ && await merge.hasTree(root, pairState.heldTree)
84
+ ) ? pairState.heldTree : baseTree;
85
+ const target = await merge.treeOfPatch(root, captured.baseOid, entry.diff || '');
86
+ const ownWork = localRevision !== null && localRevision !== (pairState.settledLocalRevision ?? null);
87
+
88
+ if (held === target) {
89
+ if (target === baseTree) {
90
+ return localRevision === null
91
+ ? { state: 'in-sync', reason: null }
92
+ : { state: 'local-work', reason: 'peer-clean' };
93
+ }
94
+ return { state: 'applied', reason: ownWork ? 'with-local-work' : null };
95
+ }
96
+
97
+ // A conflicted attempt is only repeated when something changed: the
98
+ // peer published anew, or the local files moved. Otherwise report the
99
+ // standing conflict without touching anything.
100
+ if (
101
+ pairState.peerRevision === entry.revision
102
+ && pairState.heldTree === held
103
+ && pairState.conflicts?.length
104
+ && pairState.settledLocalRevision === localRevision
105
+ ) {
106
+ return { state: 'conflict', reason: conflictSummary(pairState.conflicts), conflicts: pairState.conflicts };
107
+ }
108
+
109
+ saveRollback(digest, captured.diff);
110
+ const result = await merge.advance(root, held, target, { label: entry.revision || 'peer-clean' });
111
+ const recaptured = await captureWorktreeDiff(root, { maxBytes: MAX_DIFF_BYTES * 2 });
112
+ writePairState(digest, {
113
+ peerMachine: entry.machine,
114
+ repoKey: entry.repoKey,
115
+ baseOid: captured.baseOid,
116
+ heldTree: result.heldTree,
117
+ holdsContent: result.heldTree !== baseTree,
118
+ peerRevision: entry.revision,
119
+ conflicts: result.conflicts,
120
+ settledLocalRevision: recaptured.diff ? revisionOf(recaptured.baseOid, recaptured.diff) : null,
121
+ });
122
+
123
+ const moved = result.applied.length > 0;
124
+ if (result.conflicts.length) {
125
+ return { state: 'conflict', reason: conflictSummary(result.conflicts), conflicts: result.conflicts, moved };
126
+ }
127
+ if (target === baseTree) {
128
+ return recaptured.diff === ''
129
+ ? { state: 'in-sync', reason: 'restored-peer-went-clean', moved }
130
+ : { state: 'local-work', reason: 'peer-clean', moved };
131
+ }
132
+ return { state: 'applied', reason: ownWork ? 'coexisting-with-local-work' : null, moved };
133
+ }
134
+
135
+ // --- one repo, one pass -----------------------------------------------------
136
+
137
+ /**
138
+ * Reconcile one local checkout toward one peer's published state.
139
+ * Executes at most one movement, returns { state, reason } — every branch
140
+ * of the decision has a name that survives into stats.
141
+ */
142
+ async function reconcilePair(localCard, peerCard, entry, digest) {
143
+ if (localCard.operation !== 'none') return { state: 'waiting', reason: 'local-operation-in-flight' };
144
+ if (!entry) return { state: 'waiting', reason: 'no-peer-checkpoint-yet' };
145
+ if (!entry.baseOid) return { state: 'waiting', reason: 'peer-unborn' };
146
+
147
+ const captured = await captureWorktreeDiff(localCard.path, { maxBytes: MAX_DIFF_BYTES });
148
+ if (captured.diff === null) return { state: 'blocked', reason: `local-capture-${captured.reason}` };
149
+ const localRevision = captured.diff === '' ? null : revisionOf(captured.baseOid, captured.diff);
150
+
151
+ if (entry.baseOid === captured.baseOid) {
152
+ // A peer whose dirty state couldn't travel is a hold, not a signal to
153
+ // move — what we already folded in stays exactly where it is.
154
+ if (entry.skipped) return { state: 'waiting', reason: `peer-skipped-${entry.skipped}` };
155
+ return advanceSameBase(localCard, digest, entry, captured, localRevision);
156
+ }
157
+
158
+ // The peer's base commit differs from ours.
159
+ if (await isAncestor(localCard.path, entry.baseOid, captured.baseOid)) {
160
+ return { state: 'peer-behind', reason: null };
161
+ }
162
+ if (!(await hasCommit(localCard.path, entry.baseOid))) {
163
+ const branch = entry.branch || peerCard.head?.branch;
164
+ const approvedSource = localCard.repo.remote && localCard.repo.remote === peerCard.repo?.remote;
165
+ if (!approvedSource || !branch) return { state: 'blocked', reason: 'missing-objects-no-approved-source' };
166
+ await transition.fetchBranch(localCard.path, 'origin', branch);
167
+ if (!(await hasCommit(localCard.path, entry.baseOid))) {
168
+ return { state: 'blocked', reason: 'missing-objects-after-fetch' };
169
+ }
170
+ }
171
+ if (localCard.head.branch !== (entry.branch || peerCard.head?.branch)) {
172
+ return { state: 'blocked', reason: 'branch-mismatch' };
173
+ }
174
+ if (!(await isAncestor(localCard.path, captured.baseOid, entry.baseOid))) {
175
+ return { state: 'diverged', reason: 'histories-diverged' };
176
+ }
177
+
178
+ // Fast-forward. Held peer content the peer has since committed is
179
+ // absorbed by the checkout (content-equal files simply become clean);
180
+ // unrelated local work rides along untouched. If git refuses because
181
+ // real local work overlaps the update, first unwind what we ourselves
182
+ // hold and retry next pass — our own applied content is always ours to
183
+ // remove, other work never is.
184
+ try {
185
+ await transition.fastForwardTo(localCard.path, entry.baseOid);
186
+ } catch (error) {
187
+ const pairState = readPairState(digest);
188
+ if (pairState.holdsContent && pairState.baseOid === captured.baseOid && pairState.heldTree) {
189
+ const baseTree = await merge.treeOf(localCard.path, captured.baseOid);
190
+ saveRollback(digest, captured.diff);
191
+ const result = await merge.advance(localCard.path, pairState.heldTree, baseTree, { label: 'unwind-before-forward' });
192
+ writePairState(digest, {
193
+ heldTree: result.heldTree,
194
+ holdsContent: result.heldTree !== baseTree,
195
+ conflicts: result.conflicts,
196
+ });
197
+ return { state: 'restoring', reason: 'unwound-before-forward', moved: result.applied.length > 0 };
198
+ }
199
+ return { state: 'blocked', reason: 'local-work-overlaps-forward' };
200
+ }
201
+ writePairState(digest, {
202
+ peerMachine: entry.machine,
203
+ repoKey: entry.repoKey,
204
+ baseOid: entry.baseOid,
205
+ heldTree: null,
206
+ holdsContent: false,
207
+ peerRevision: null,
208
+ conflicts: [],
209
+ settledLocalRevision: null,
210
+ });
211
+ return { state: 'forwarded', reason: null, moved: true };
212
+ }
213
+
214
+ // --- the sweep --------------------------------------------------------------
215
+
216
+ async function runReconcile() {
217
+ service.counters.passes += 1;
218
+ const startedAt = Date.now();
219
+ const self = machineId();
220
+ let moved = false;
221
+
222
+ const locals = new Map();
223
+ for (const card of listRepoCards()) {
224
+ if (card.health !== 'healthy' || card.kind !== 'primary') continue;
225
+ const key = repoKeyOfCard(card);
226
+ if (key && !locals.has(key)) locals.set(key, card);
227
+ }
228
+
229
+ for (const wallEntry of readWall()) {
230
+ if (wallEntry.machine === self) continue;
231
+ for (const peerCard of wallEntry.cards || []) {
232
+ if (peerCard.kind !== 'primary' || (peerCard.health && peerCard.health !== 'healthy')) continue;
233
+ const repoKey = repoKeyOfCard(peerCard);
234
+ const localCard = repoKey && locals.get(repoKey);
235
+ if (!localCard) continue;
236
+
237
+ const digest = pairDigest(wallEntry.machine, repoKey);
238
+ const record = (outcome) => {
239
+ service.pairs.set(digest, {
240
+ peerMachine: wallEntry.machine,
241
+ repoKey,
242
+ path: localCard.path,
243
+ ...outcome,
244
+ at: new Date().toISOString(),
245
+ });
246
+ };
247
+ try {
248
+ const file = checkpointFile(wallEntry.machine, repoKey);
249
+ const binding = await ensureDocBound({
250
+ resourceId: checkpointResourceId(wallEntry.machine, repoKey),
251
+ filePath: file,
252
+ create: false,
253
+ });
254
+ service.watchedDocs.add(file);
255
+ if (!binding.bound) {
256
+ record({ state: 'waiting', reason: `checkpoint-${binding.state}` });
257
+ continue;
258
+ }
259
+ const entry = readCheckpoint(wallEntry.machine, repoKey);
260
+ const outcome = await reconcilePair(localCard, peerCard, entry, digest);
261
+ if (outcome.moved) {
262
+ moved = true;
263
+ service.counters.movements += 1;
264
+ }
265
+ record(outcome);
266
+ } catch (error) {
267
+ service.counters.failures += 1;
268
+ record({ state: 'error', reason: error.message });
269
+ console.warn(`[WorkspaceReconcile] ${localCard.path}:`, error.message);
270
+ }
271
+ }
272
+ }
273
+
274
+ service.lastPass = { at: new Date().toISOString(), durationMs: Date.now() - startedAt, moved };
275
+ if (moved) {
276
+ // A movement changed real repo state: re-card promptly so our own
277
+ // published truth (and any follow-up movement) doesn't wait a cycle.
278
+ refreshRepoCards().catch(() => {});
279
+ scheduleReconcile();
280
+ }
281
+ }
282
+
283
+ let running = null;
284
+ let queuedRun = null;
285
+
286
+ function reconcileNow() {
287
+ if (running) {
288
+ queuedRun ??= running.catch(() => {}).then(() => reconcileNow());
289
+ return queuedRun;
290
+ }
291
+ queuedRun = null;
292
+ running = runReconcile().finally(() => {
293
+ running = null;
294
+ });
295
+ return running;
296
+ }
297
+
298
+ function scheduleReconcile() {
299
+ if (!service.started || service.reconcileTimer) return;
300
+ service.reconcileTimer = setTimeout(() => {
301
+ service.reconcileTimer = null;
302
+ reconcileNow().catch((error) => console.warn('[WorkspaceReconcile] reconcile failed:', error.message));
303
+ }, RECONCILE_QUIET_MS);
304
+ service.reconcileTimer.unref?.();
305
+ }
306
+
307
+ function startWorkspaceReconciler() {
308
+ if (service.started) return;
309
+ service.started = true;
310
+ service.unsubscribe = subscribeStateEvents((event) => {
311
+ const docChanged = typeof event.resource === 'string'
312
+ && event.resource.startsWith('doc:')
313
+ && service.watchedDocs.has(event.id);
314
+ if (docChanged || event.resource === 'repo-cards') scheduleReconcile();
315
+ });
316
+ service.intervalTimer = setInterval(scheduleReconcile, RECONCILE_INTERVAL_MS);
317
+ service.intervalTimer.unref?.();
318
+ scheduleReconcile();
319
+ }
320
+
321
+ function stopWorkspaceReconciler() {
322
+ if (!service.started) return;
323
+ service.started = false;
324
+ service.unsubscribe?.();
325
+ service.unsubscribe = null;
326
+ if (service.reconcileTimer) clearTimeout(service.reconcileTimer);
327
+ service.reconcileTimer = null;
328
+ if (service.intervalTimer) clearInterval(service.intervalTimer);
329
+ service.intervalTimer = null;
330
+ }
331
+
332
+ function reconciliationStats() {
333
+ return {
334
+ started: service.started,
335
+ ...service.counters,
336
+ lastPass: service.lastPass,
337
+ pairs: [...service.pairs.values()],
338
+ };
339
+ }
340
+
341
+ module.exports = {
342
+ reconciliationStats,
343
+ reconcileNow,
344
+ reconcilePair,
345
+ startWorkspaceReconciler,
346
+ stopWorkspaceReconciler,
347
+ };