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.
- package/lib/shared-realtime-tunnel.js +30 -1
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/fs/rest.js +26 -7
- package/runtime/scripts/amalgm-mcp/index.js +3 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +32 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +7 -5
- package/runtime/scripts/amalgm-mcp/state/cloud-resource-id.js +16 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +74 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +122 -4
- package/runtime/scripts/amalgm-mcp/state/migrations.js +62 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +4 -4
- package/runtime/scripts/amalgm-mcp/state/promotions.js +2 -2
- package/runtime/scripts/amalgm-mcp/state/replicas.js +6 -5
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +27 -22
- package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +5 -9
- package/runtime/scripts/amalgm-mcp/tests/workspace-access-store.test.js +120 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +137 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +264 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +155 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +114 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +227 -0
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +182 -0
- package/runtime/scripts/amalgm-mcp/workspace/binding.js +62 -0
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +237 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +63 -12
- package/runtime/scripts/amalgm-mcp/workspace/ledger.js +96 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +290 -0
- package/runtime/scripts/amalgm-mcp/workspace/reconcile.js +347 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +182 -2
- package/runtime/scripts/amalgm-mcp/workspace/store.js +59 -12
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +141 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/access.js +62 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/identity.js +53 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +68 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +174 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/operations.js +93 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +138 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +82 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +391 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-promotion.js +94 -0
- 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
|
+
};
|