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,237 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Repo checkpoints: each machine's uncommitted work as one recoverable
5
+ * fact on the rail.
6
+ *
7
+ * The two-value repo sync model: the card carries B — where the repo
8
+ * stands (commit, branch, dirty counts) — and the checkpoint carries D —
9
+ * the exact uncommitted content as one patch against B. A checkpoint
10
+ * lives in a document only this machine ever writes: one derived-id
11
+ * resource per (machine, repository identity), so machines never share a
12
+ * writer and the doc rail's merge question never arises. Ignored files
13
+ * never travel by construction — git does not list them.
14
+ *
15
+ * Checkpoints are captured only from settled repos (no operation in
16
+ * flight) and only for primary checkouts with a transportable repo
17
+ * identity. A clean repo publishes { revision: null } — "nothing dirty"
18
+ * is a fact peers act on, not an absence. Dirty state over the byte
19
+ * budget publishes { revision: null, skipped: 'too-large' } — visible,
20
+ * never silent.
21
+ */
22
+
23
+ const crypto = require('crypto');
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const { AMALGM_DIR } = require('../config');
27
+ const { subscribeStateEvents } = require('../state/events');
28
+ const { machineId, userId } = require('./identity');
29
+ const { listRepoCards } = require('./cards');
30
+ const { captureWorktreeDiff } = require('./git');
31
+ const { ensureDocBound } = require('./binding');
32
+ const { listPairStates } = require('./ledger');
33
+
34
+ const CHECKPOINT_CONTRACT = 'repo-checkpoint@1';
35
+ const MAX_DIFF_BYTES = 1_000_000;
36
+ const PUBLISH_QUIET_MS = 500;
37
+ const RECONCILE_INTERVAL_MS = 30_000;
38
+
39
+ const service = {
40
+ started: false,
41
+ publishTimer: null,
42
+ reconcileTimer: null,
43
+ unsubscribe: null,
44
+ counters: { published: 0, skips: 0, unbound: 0, failures: 0 },
45
+ lastRun: null,
46
+ };
47
+
48
+ function sha256(text) {
49
+ return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
50
+ }
51
+
52
+ /** The transportable identity of a card's repository, or null. */
53
+ function repoKeyOfCard(card) {
54
+ if (card?.repo?.remote) return card.repo.remote;
55
+ return card?.repo?.rootCommit ? `root:${card.repo.rootCommit}` : null;
56
+ }
57
+
58
+ /** One resource per (machine, repo) per account — same id on every machine. */
59
+ function checkpointResourceId(machine, repoKey, forUserId = userId()) {
60
+ if (!forUserId || !machine || !repoKey) return null;
61
+ const digest = crypto.createHash('sha256')
62
+ .update(`amalgm-repo-checkpoint@1|${forUserId}|${machine}|${repoKey}`)
63
+ .digest('base64url');
64
+ return `shr_rc1${digest.slice(0, 40)}`;
65
+ }
66
+
67
+ /** The local file backing a checkpoint doc — own or an attached peer's. */
68
+ function checkpointFile(machine, repoKey) {
69
+ const digest = crypto.createHash('sha256').update(`${machine}|${repoKey}`).digest('hex').slice(0, 24);
70
+ return path.join(AMALGM_DIR, 'state', 'repo-checkpoints', `${digest}.json`);
71
+ }
72
+
73
+ /** The content identity of a dirty state: same base, same patch → same revision. */
74
+ function revisionOf(baseOid, diff) {
75
+ return crypto.createHash('sha256').update(`${baseOid}\n${diff}`, 'utf8').digest('hex');
76
+ }
77
+
78
+ function parseCheckpoint(text) {
79
+ try {
80
+ const entry = JSON.parse(text);
81
+ if (entry?.checkpoint !== CHECKPOINT_CONTRACT || !entry.machine || !entry.repoKey) return null;
82
+ return entry;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /** A machine's published checkpoint for a repo, as this machine sees it. */
89
+ function readCheckpoint(machine, repoKey) {
90
+ const file = checkpointFile(machine, repoKey);
91
+ if (!fs.existsSync(file)) return null;
92
+ return parseCheckpoint(require('../state/docs').getDocText(file));
93
+ }
94
+
95
+ /**
96
+ * Whose work this machine's tree already holds for a repo, from the
97
+ * reconciliation ledger. Stamped onto outgoing checkpoints so "this diff
98
+ * incorporates peer X's revision R" is an explicit fact on the wire —
99
+ * a receiver can tell its own echoed work from new content.
100
+ */
101
+ function incorporatesFor(repoKey) {
102
+ const map = {};
103
+ for (const state of listPairStates()) {
104
+ if (state.repoKey === repoKey && state.holdsContent && state.peerRevision) {
105
+ map[state.peerMachine] = state.peerRevision;
106
+ }
107
+ }
108
+ return Object.keys(map).length ? map : undefined;
109
+ }
110
+
111
+ async function buildEnvelope(card, machine, repoKey) {
112
+ if (!card.head.oid) return null;
113
+ const base = {
114
+ checkpoint: CHECKPOINT_CONTRACT,
115
+ machine,
116
+ repoKey,
117
+ branch: card.head.branch,
118
+ baseOid: card.head.oid,
119
+ capturedAt: new Date().toISOString(),
120
+ };
121
+ const dirtyTotal = card.dirty.staged + card.dirty.unstaged + card.dirty.untracked + card.dirty.conflicted;
122
+ if (dirtyTotal === 0) return { ...base, revision: null, diff: '' };
123
+
124
+ const captured = await captureWorktreeDiff(card.path, { maxBytes: MAX_DIFF_BYTES });
125
+ if (captured.diff === null) return { ...base, revision: null, diff: '', skipped: captured.reason };
126
+ // The repo may have moved between the card and this capture; the next
127
+ // doorbell pass re-cards and re-captures a coherent pair.
128
+ if (captured.baseOid !== card.head.oid) return null;
129
+ const incorporates = incorporatesFor(repoKey);
130
+ return {
131
+ ...base,
132
+ baseOid: captured.baseOid,
133
+ revision: revisionOf(captured.baseOid, captured.diff),
134
+ diff: captured.diff,
135
+ ...(incorporates ? { incorporates } : {}),
136
+ };
137
+ }
138
+
139
+ /** Publish this machine's checkpoint docs — one per eligible checkout. */
140
+ async function publishCheckpoints() {
141
+ const machine = machineId();
142
+ const run = { published: 0, skips: 0, unbound: 0, failures: 0 };
143
+ for (const card of listRepoCards()) {
144
+ if (card.health !== 'healthy' || card.kind !== 'primary' || card.operation !== 'none') continue;
145
+ const repoKey = repoKeyOfCard(card);
146
+ const resourceId = checkpointResourceId(machine, repoKey);
147
+ if (!resourceId) continue;
148
+ try {
149
+ const file = checkpointFile(machine, repoKey);
150
+ const binding = await ensureDocBound({ resourceId, filePath: file, create: true });
151
+ if (!binding.bound) {
152
+ run.unbound += 1;
153
+ continue;
154
+ }
155
+ const envelope = await buildEnvelope(card, machine, repoKey);
156
+ if (!envelope) continue;
157
+ const docs = require('../state/docs');
158
+ const current = docs.getDocText(file);
159
+ const existing = parseCheckpoint(current);
160
+ const unchanged = existing
161
+ && existing.baseOid === envelope.baseOid
162
+ && existing.revision === envelope.revision
163
+ && (existing.skipped || null) === (envelope.skipped || null)
164
+ && JSON.stringify(existing.incorporates ?? null) === JSON.stringify(envelope.incorporates ?? null);
165
+ if (unchanged) {
166
+ run.skips += 1;
167
+ continue;
168
+ }
169
+ docs.writeDocText(file, `${JSON.stringify(envelope)}\n`, { baseHash: sha256(current) });
170
+ run.published += 1;
171
+ } catch (error) {
172
+ run.failures += 1;
173
+ console.warn(`[RepoCheckpoint] publish failed for ${card.path}:`, error.message);
174
+ }
175
+ }
176
+ service.counters.published += run.published;
177
+ service.counters.skips += run.skips;
178
+ service.counters.unbound += run.unbound;
179
+ service.counters.failures += run.failures;
180
+ service.lastRun = { ...run, at: new Date().toISOString() };
181
+ return run;
182
+ }
183
+
184
+ function schedulePublish() {
185
+ if (!service.started || service.publishTimer) return;
186
+ service.publishTimer = setTimeout(() => {
187
+ service.publishTimer = null;
188
+ publishCheckpoints().catch((error) => console.warn('[RepoCheckpoint] publish pass failed:', error.message));
189
+ }, PUBLISH_QUIET_MS);
190
+ service.publishTimer.unref?.();
191
+ }
192
+
193
+ function startCheckpointService() {
194
+ if (service.started) return;
195
+ service.started = true;
196
+ service.unsubscribe = subscribeStateEvents((event) => {
197
+ if (event.resource === 'repo-cards') schedulePublish();
198
+ });
199
+ service.reconcileTimer = setInterval(schedulePublish, RECONCILE_INTERVAL_MS);
200
+ service.reconcileTimer.unref?.();
201
+ schedulePublish();
202
+ }
203
+
204
+ function stopCheckpointService() {
205
+ if (!service.started) return;
206
+ service.started = false;
207
+ service.unsubscribe?.();
208
+ service.unsubscribe = null;
209
+ if (service.publishTimer) clearTimeout(service.publishTimer);
210
+ service.publishTimer = null;
211
+ if (service.reconcileTimer) clearInterval(service.reconcileTimer);
212
+ service.reconcileTimer = null;
213
+ }
214
+
215
+ function checkpointStats() {
216
+ return {
217
+ contract: CHECKPOINT_CONTRACT,
218
+ started: service.started,
219
+ ...service.counters,
220
+ lastRun: service.lastRun,
221
+ };
222
+ }
223
+
224
+ module.exports = {
225
+ CHECKPOINT_CONTRACT,
226
+ MAX_DIFF_BYTES,
227
+ checkpointFile,
228
+ checkpointResourceId,
229
+ checkpointStats,
230
+ parseCheckpoint,
231
+ publishCheckpoints,
232
+ readCheckpoint,
233
+ repoKeyOfCard,
234
+ revisionOf,
235
+ startCheckpointService,
236
+ stopCheckpointService,
237
+ };
@@ -256,29 +256,80 @@ async function rootCommit(worktreeRoot) {
256
256
  return stdout.trim().split('\n').sort()[0];
257
257
  }
258
258
 
259
+ /** Does this checkout's object store already contain the commit? */
260
+ async function hasCommit(worktreeRoot, oid) {
261
+ const result = await git(['-C', worktreeRoot, 'cat-file', '-e', `${oid}^{commit}`], worktreeRoot, {
262
+ okCodes: [0, 1, 128],
263
+ });
264
+ return result.exitCode === 0;
265
+ }
266
+
259
267
  /**
260
- * Does git own this file? A file is git-owned when the worktree around it
261
- * tracks it (`ls-files --error-unmatch`). Untracked files inside a repo
262
- * and files outside any repo have no git owner.
263
- * Returns { toplevel } for a git-owned file, null otherwise.
268
+ * Is `ancestorOid` an ancestor of (or equal to) `descendantOid`, as far
269
+ * as this checkout can tell? A commit this repo has never fetched (exit
270
+ * 128) is simply not a known ancestor.
264
271
  */
265
- async function gitOwnerOf(filePath) {
266
- const classified = await classifyDirectory(path.dirname(filePath));
267
- if (classified.kind !== 'worktree') return null;
272
+ async function isAncestor(worktreeRoot, ancestorOid, descendantOid) {
273
+ const result = await git(
274
+ ['-C', worktreeRoot, 'merge-base', '--is-ancestor', ancestorOid, descendantOid],
275
+ worktreeRoot,
276
+ { okCodes: [0, 1, 128] },
277
+ );
278
+ return result.exitCode === 0;
279
+ }
280
+
281
+ // Diff output is hashed and applied verbatim, so its bytes must be exact
282
+ // and stable: no quoted path mangling, no abbreviated blob ids.
283
+ const PATCH_ARGS = ['-c', 'core.quotepath=false'];
284
+
285
+ /**
286
+ * The complete uncommitted content of a worktree as one applyable patch
287
+ * against HEAD: staged and unstaged edits together (`git diff HEAD`),
288
+ * plus every untracked file as a creation diff. Ignored files are
289
+ * excluded by construction — git never lists them. Binary content rides
290
+ * `--binary` literals.
291
+ *
292
+ * Returns { baseOid, diff, bytes } — diff is '' for a clean tree — or
293
+ * { baseOid, diff: null, reason } when the state cannot be captured as a
294
+ * patch (an unborn HEAD, or dirty content over the byte budget).
295
+ */
296
+ async function captureWorktreeDiff(worktreeRoot, { maxBytes = 1_000_000 } = {}) {
297
+ const baseOid = await headOid(worktreeRoot);
298
+ if (!baseOid) return { baseOid: null, diff: null, reason: 'unborn' };
299
+
268
300
  const tracked = await git(
269
- ['ls-files', '--error-unmatch', '--', path.basename(filePath)],
270
- path.dirname(filePath),
271
- { okCodes: [0, 1] },
301
+ ['-C', worktreeRoot, ...PATCH_ARGS, 'diff', 'HEAD', '--binary', '--full-index'],
302
+ worktreeRoot,
272
303
  );
273
- return tracked.exitCode === 0 ? { toplevel: classified.toplevel } : null;
304
+ const parts = [tracked.stdout];
305
+ let bytes = Buffer.byteLength(tracked.stdout);
306
+
307
+ const untracked = await git(
308
+ ['-C', worktreeRoot, 'ls-files', '--others', '--exclude-standard', '-z'],
309
+ worktreeRoot,
310
+ );
311
+ for (const file of untracked.stdout.split('\0').filter(Boolean).sort()) {
312
+ if (bytes > maxBytes) break;
313
+ const creation = await git(
314
+ ['-C', worktreeRoot, ...PATCH_ARGS, 'diff', '--no-index', '--binary', '--full-index', '--', '/dev/null', file],
315
+ worktreeRoot,
316
+ { okCodes: [0, 1] },
317
+ );
318
+ parts.push(creation.stdout);
319
+ bytes += Buffer.byteLength(creation.stdout);
320
+ }
321
+ if (bytes > maxBytes) return { baseOid, diff: null, reason: 'too-large', bytes };
322
+ return { baseOid, diff: parts.join(''), bytes };
274
323
  }
275
324
 
276
325
  module.exports = {
277
326
  GitError,
327
+ captureWorktreeDiff,
278
328
  classifyDirectory,
279
- gitOwnerOf,
329
+ hasCommit,
280
330
  headAndDirty,
281
331
  headOid,
332
+ isAncestor,
282
333
  listWorktrees,
283
334
  operationState,
284
335
  originUrl,
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The reconciliation ledger: one durable record per (peer machine,
5
+ * repository) pair, remembering which part of this machine's working tree
6
+ * is a peer's contribution.
7
+ *
8
+ * The record's heart is `heldTree` — a git tree oid describing "base
9
+ * commit plus exactly the peer content this machine has folded in". The
10
+ * working tree is never assumed to equal it; local work is expected to
11
+ * coexist around it. `holdsContent` (heldTree differs from the bare base
12
+ * tree) is what lets the checkpoint publisher stamp outgoing diffs with
13
+ * whose work they incorporate.
14
+ *
15
+ * Split out of reconcile.js so the checkpoint layer can read holdings
16
+ * without importing the reconciler.
17
+ */
18
+
19
+ const crypto = require('crypto');
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { AMALGM_DIR } = require('../config');
23
+
24
+ const STATE_DIR = path.join(AMALGM_DIR, 'state', 'workspace-reconciliation');
25
+ const LEGACY_STATE_DIR = path.join(AMALGM_DIR, 'state', 'workspace-follow');
26
+ let stateDirReady = false;
27
+
28
+ /** Preserve existing pair ownership while retiring the old service name. */
29
+ function ensureStateDir() {
30
+ if (stateDirReady) return;
31
+ stateDirReady = true;
32
+ if (fs.existsSync(STATE_DIR) || !fs.existsSync(LEGACY_STATE_DIR)) return;
33
+ fs.renameSync(LEGACY_STATE_DIR, STATE_DIR);
34
+ }
35
+
36
+ function pairDigest(peerMachine, repoKey) {
37
+ return crypto.createHash('sha256').update(`${peerMachine}|${repoKey}`).digest('hex').slice(0, 24);
38
+ }
39
+
40
+ function pairStateFile(digest) {
41
+ ensureStateDir();
42
+ return path.join(STATE_DIR, `${digest}.json`);
43
+ }
44
+
45
+ function readPairState(digest) {
46
+ try {
47
+ return JSON.parse(fs.readFileSync(pairStateFile(digest), 'utf8'));
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ function writePairState(digest, patch) {
54
+ ensureStateDir();
55
+ fs.mkdirSync(STATE_DIR, { recursive: true });
56
+ const next = { ...readPairState(digest), ...patch, updatedAt: new Date().toISOString() };
57
+ fs.writeFileSync(pairStateFile(digest), `${JSON.stringify(next, null, 2)}\n`);
58
+ return next;
59
+ }
60
+
61
+ /** Every pair record on this machine — for stats and incorporates stamps. */
62
+ function listPairStates() {
63
+ ensureStateDir();
64
+ let names;
65
+ try {
66
+ names = fs.readdirSync(STATE_DIR);
67
+ } catch {
68
+ return [];
69
+ }
70
+ const states = [];
71
+ for (const name of names) {
72
+ if (!/^[0-9a-f]{24}\.json$/.test(name)) continue;
73
+ try {
74
+ states.push(JSON.parse(fs.readFileSync(path.join(STATE_DIR, name), 'utf8')));
75
+ } catch { /* an unreadable record is not a pair */ }
76
+ }
77
+ return states;
78
+ }
79
+
80
+ /** Boundary: no movement without a durable copy of what the tree held. */
81
+ function saveRollback(digest, diff) {
82
+ ensureStateDir();
83
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
84
+ const file = path.join(STATE_DIR, `rollback-${digest}-${stamp}.patch`);
85
+ fs.mkdirSync(STATE_DIR, { recursive: true });
86
+ fs.writeFileSync(file, diff);
87
+ return file;
88
+ }
89
+
90
+ module.exports = {
91
+ listPairStates,
92
+ pairDigest,
93
+ readPairState,
94
+ saveRollback,
95
+ writePairState,
96
+ };