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
@@ -15,6 +15,7 @@ const {
15
15
  classifyDirectory,
16
16
  headAndDirty,
17
17
  headOid,
18
+ localBranches,
18
19
  listWorktrees,
19
20
  operationState,
20
21
  originUrl,
@@ -63,10 +64,11 @@ async function observeWorktree(classified) {
63
64
  const worktreeRoot = classified.toplevel;
64
65
  const gitDir = await fs.promises.realpath(classified.gitDir);
65
66
  const replica = replicaForGitDir(gitDir);
66
- const [{ head, dirty }, operation, remote] = await Promise.all([
67
+ const [{ head, dirty }, operation, remote, branches] = await Promise.all([
67
68
  headAndDirty(worktreeRoot),
68
69
  operationState(worktreeRoot),
69
70
  originUrl(worktreeRoot),
71
+ localBranches(worktreeRoot),
70
72
  ]);
71
73
  let anchor = replica.rootCommit;
72
74
  if (!anchor && head.oid) {
@@ -78,12 +80,19 @@ async function observeWorktree(classified) {
78
80
  const objects = head.oid && repoIdentity
79
81
  ? await require('./objects').objectSourceFor(worktreeRoot, repoIdentity, head.oid)
80
82
  : null;
83
+ const refs = await Promise.all(branches.map(async (branch) => ({
84
+ ...branch,
85
+ objects: repoIdentity
86
+ ? await require('./objects').objectSourceFor(worktreeRoot, repoIdentity, branch.oid)
87
+ : null,
88
+ })));
81
89
  return {
82
90
  card: CARD_CONTRACT,
83
91
  machine: machineId(),
84
92
  replica: replica.replicaId,
85
93
  path: worktreeRoot,
86
94
  gitDir,
95
+ commonGitDir: classified.commonDir,
87
96
  repo: {
88
97
  remote: repoIdentity,
89
98
  rootCommit: anchor,
@@ -91,6 +100,7 @@ async function observeWorktree(classified) {
91
100
  objects,
92
101
  kind: cardKind(classified),
93
102
  head,
103
+ refs,
94
104
  dirty,
95
105
  operation,
96
106
  observedAt: new Date().toISOString(),
@@ -52,24 +52,6 @@ function rowToCard(row) {
52
52
  };
53
53
  }
54
54
 
55
- /**
56
- * positionAt is the card's committed-state clock. Working-tree changes
57
- * update the card without pretending that branch/commit moved.
58
- */
59
- function withPositionAt(card, row) {
60
- if (!row) return { ...card, positionAt: card.observedAt };
61
- const previous = JSON.parse(row.card_json);
62
- const samePosition = previous.head?.branch === card.head?.branch
63
- && previous.head?.oid === card.head?.oid
64
- && Boolean(previous.head?.detached) === Boolean(card.head?.detached);
65
- return {
66
- ...card,
67
- positionAt: samePosition
68
- ? previous.positionAt || previous.observedAt || card.observedAt
69
- : card.observedAt,
70
- };
71
- }
72
-
73
55
  function listRepoCards() {
74
56
  return openLocalDb()
75
57
  .prepare('SELECT * FROM repo_cards ORDER BY path')
@@ -261,7 +243,7 @@ async function runRefresh() {
261
243
 
262
244
  for (const { card: observedCard, root } of built) {
263
245
  const row = readRow.get(observedCard.replica);
264
- const card = withPositionAt(observedCard, row);
246
+ const card = observedCard;
265
247
  const fingerprint = cardFingerprint(card);
266
248
  if (row && row.fingerprint === fingerprint && row.health === 'healthy') {
267
249
  // Which registration covers a checkout can change with no git
@@ -341,7 +323,7 @@ async function refreshRepoCard(replicaId) {
341
323
 
342
324
  const observedCard = await buildRepoCard(row.path);
343
325
  if (observedCard.replica !== replicaId) return refreshRepoCards();
344
- const card = withPositionAt(observedCard, row);
326
+ const card = observedCard;
345
327
  const fingerprint = cardFingerprint(card);
346
328
  if (row.fingerprint === fingerprint && row.health === 'healthy') {
347
329
  service.lastRefresh = {
@@ -442,7 +424,13 @@ function watchGitDir(card) {
442
424
  const ring = () => ringDoorbell(card.replica);
443
425
  // The git dir itself carries HEAD, index, and operation markers; logs/
444
426
  // carries the reflog, which moves on every commit, checkout, and reset.
445
- for (const dir of [card.gitDir, path.join(card.gitDir, 'logs')]) {
427
+ const common = card.commonGitDir || card.gitDir;
428
+ for (const dir of [
429
+ card.gitDir,
430
+ path.join(card.gitDir, 'logs'),
431
+ path.join(common, 'refs', 'heads'),
432
+ path.join(common, 'logs', 'refs', 'heads'),
433
+ ]) {
446
434
  try {
447
435
  const watcher = fs.watch(dir, ring);
448
436
  watcher.on('error', () => watcher.close());
@@ -1,36 +1,31 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Repo checkpoints: each machine's uncommitted work as one recoverable
5
- * fact on the rail.
4
+ * The checkpoint publisher.
6
5
  *
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.
6
+ * One checkpoint operation says only this:
14
7
  *
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. Large dirty states use the same
19
- * chunk lane as unpushed commits instead of changing the model.
8
+ * shared checkpoint I read → working tree I now have
9
+ *
10
+ * Both sides are exact patches against the card commit. The repo reducer
11
+ * applies these transitions in server rail order. A remote edit that was
12
+ * merely materialized is therefore not republished as new work, while a
13
+ * real local edit or revert is one new transition.
14
+ *
15
+ * Checkpoint files below are local capture cache only. Their operations ride
16
+ * inside the machine wall, so the entire repo has one ordered stream.
20
17
  */
21
18
 
22
19
  const crypto = require('crypto');
23
20
  const fs = require('fs');
24
21
  const path = require('path');
25
22
  const { AMALGM_DIR } = require('../config');
26
- const { subscribeStateEvents } = require('../state/events');
27
- const { machineId, userId } = require('./identity');
23
+ const { appendStateEvent, subscribeStateEvents } = require('../state/events');
24
+ const { machineId } = require('./identity');
28
25
  const { listRepoCards } = require('./cards');
29
- const { captureWorktreeDiff } = require('./git');
30
- const { ensureDocBound } = require('./binding');
31
- const { listPairStates } = require('./ledger');
26
+ const { captureCheckpoint } = require('./git');
32
27
 
33
- const CHECKPOINT_CONTRACT = 'repo-checkpoint@3';
28
+ const CHECKPOINT_CONTRACT = 'repo-checkpoint@4';
34
29
  const MAX_INLINE_DIFF_BYTES = 512 * 1024;
35
30
  const PUBLISH_QUIET_MS = 25;
36
31
  const RECONCILE_INTERVAL_MS = 30_000;
@@ -40,7 +35,7 @@ const service = {
40
35
  publishTimer: null,
41
36
  reconcileTimer: null,
42
37
  unsubscribe: null,
43
- counters: { published: 0, skips: 0, unbound: 0, failures: 0 },
38
+ counters: { published: 0, skips: 0, waiting: 0, failures: 0 },
44
39
  lastRun: null,
45
40
  };
46
41
 
@@ -48,141 +43,217 @@ let publishing = null;
48
43
  let publishAllRequested = false;
49
44
  const publishReplicasRequested = new Set();
50
45
 
51
- function sha256(text) {
52
- return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
53
- }
54
-
55
46
  /** The transportable identity of a card's repository, or null. */
56
47
  function repoKeyOfCard(card) {
57
48
  if (card?.repo?.remote) return card.repo.remote;
58
49
  return card?.repo?.rootCommit ? `root:${card.repo.rootCommit}` : null;
59
50
  }
60
51
 
61
- /** One resource per (machine, repo) per account — same id on every machine. */
62
- function checkpointResourceId(machine, repoKey, forUserId = userId()) {
63
- if (!forUserId || !machine || !repoKey) return null;
64
- const digest = crypto.createHash('sha256')
65
- .update(`amalgm-repo-checkpoint@3|${forUserId}|${machine}|${repoKey}`)
66
- .digest('base64url');
67
- return `shr_rc3${digest.slice(0, 40)}`;
68
- }
69
-
70
- /** The local file backing a checkpoint doc — own or an attached peer's. */
71
52
  function checkpointFile(machine, repoKey) {
72
53
  const digest = crypto.createHash('sha256')
73
54
  .update(`${CHECKPOINT_CONTRACT}|${machine}|${repoKey}`)
74
55
  .digest('hex')
75
56
  .slice(0, 24);
76
- return path.join(AMALGM_DIR, 'state', 'repo-checkpoints', `${digest}.json`);
57
+ return path.join(AMALGM_DIR, 'state', 'repo-checkpoints-v4', `${digest}.json`);
77
58
  }
78
59
 
79
- /** The content identity of a dirty state: same base, same patch → same revision. */
80
- function revisionOf(baseOid, diff) {
81
- return crypto.createHash('sha256').update(`${baseOid}\n${diff}`, 'utf8').digest('hex');
60
+ function revisionOf(baseOid, indexDiff, worktreeDiff = '') {
61
+ return indexDiff === '' && worktreeDiff === ''
62
+ ? null
63
+ : crypto.createHash('sha256')
64
+ .update(`${baseOid}\nindex\n${indexDiff}\nworktree\n${worktreeDiff}`, 'utf8')
65
+ .digest('hex');
82
66
  }
83
67
 
84
68
  function parseCheckpoint(text) {
85
69
  try {
86
70
  const entry = JSON.parse(text);
87
- if (entry?.checkpoint !== CHECKPOINT_CONTRACT || !entry.machine || !entry.repoKey) return null;
71
+ if (
72
+ entry?.checkpoint !== CHECKPOINT_CONTRACT
73
+ || !entry.machine
74
+ || !entry.repoKey
75
+ || !entry.from
76
+ || !entry.to
77
+ ) return null;
88
78
  return entry;
89
79
  } catch {
90
80
  return null;
91
81
  }
92
82
  }
93
83
 
94
- /** A machine's published checkpoint for a repo, as this machine sees it. */
95
84
  function readCheckpoint(machine, repoKey) {
96
- const file = checkpointFile(machine, repoKey);
97
- if (!fs.existsSync(file)) return null;
98
- return parseCheckpoint(require('../state/docs').getDocText(file));
85
+ try {
86
+ return parseCheckpoint(fs.readFileSync(checkpointFile(machine, repoKey), 'utf8'));
87
+ } catch {
88
+ return null;
89
+ }
99
90
  }
100
91
 
101
- /**
102
- * Whose work this machine's tree already holds for a repo, from the
103
- * reconciliation ledger. Stamped onto outgoing checkpoints so "this diff
104
- * incorporates peer X's revision R" is an explicit fact on the wire —
105
- * a receiver can tell its own echoed work from new content.
106
- */
107
- function incorporatesFor(repoKey) {
108
- const map = {};
109
- for (const state of listPairStates()) {
110
- if (state.repoKey === repoKey && state.holdsContent && state.peerRevision) {
111
- map[state.peerMachine] = state.peerRevision;
112
- }
113
- }
114
- return Object.keys(map).length ? map : undefined;
92
+ function valueIdentity(value) {
93
+ return JSON.stringify({
94
+ revision: value?.revision ?? null,
95
+ payload: value?.payload ?? null,
96
+ });
115
97
  }
116
98
 
117
- async function buildEnvelope(card, machine, repoKey) {
118
- if (!card.head.oid) return null;
119
- const base = {
120
- checkpoint: CHECKPOINT_CONTRACT,
121
- machine,
122
- repoKey,
123
- branch: card.head.branch,
124
- baseOid: card.head.oid,
125
- capturedAt: new Date().toISOString(),
126
- };
127
- const dirtyTotal = card.dirty.staged + card.dirty.unstaged + card.dirty.untracked + card.dirty.conflicted;
128
- if (dirtyTotal === 0) return { ...base, revision: null, diff: '' };
99
+ function sameTransition(left, right) {
100
+ return left
101
+ && left.baseOid === right.baseOid
102
+ && left.branch === right.branch
103
+ && valueIdentity(left.from) === valueIdentity(right.from)
104
+ && valueIdentity(left.to) === valueIdentity(right.to)
105
+ && JSON.stringify(left.parked || []) === JSON.stringify(right.parked || []);
106
+ }
129
107
 
130
- const captured = await captureWorktreeDiff(card.path);
131
- if (captured.diff === null) return { ...base, revision: null, diff: '', skipped: captured.reason };
132
- // The repo may have moved between the card and this capture; the next
133
- // doorbell pass re-cards and re-captures a coherent pair.
134
- if (captured.baseOid !== card.head.oid) return null;
135
- const incorporates = incorporatesFor(repoKey);
136
- const revision = revisionOf(captured.baseOid, captured.diff);
137
- const payload = captured.bytes > MAX_INLINE_DIFF_BYTES
138
- ? require('./objects').publishPayload(
139
- `${machine}\n${repoKey}\n${revision}`,
140
- Buffer.from(captured.diff),
141
- )
142
- : null;
108
+ function checkpointValue(machine, repoKey, baseOid, indexDiff, worktreeDiff) {
109
+ const revision = revisionOf(baseOid, indexDiff, worktreeDiff);
110
+ const bytes = Buffer.from(JSON.stringify({ indexDiff, worktreeDiff }), 'utf8');
111
+ if (bytes.length <= MAX_INLINE_DIFF_BYTES) return { revision, indexDiff, worktreeDiff };
143
112
  return {
144
- ...base,
145
- baseOid: captured.baseOid,
146
113
  revision,
147
- diff: payload ? '' : captured.diff,
148
- ...(payload ? { payload } : {}),
149
- ...(incorporates ? { incorporates } : {}),
114
+ payload: require('./objects').publishPayload(
115
+ `${CHECKPOINT_CONTRACT}\n${machine}\n${repoKey}\n${baseOid}\n${revision || 'clean'}`,
116
+ bytes,
117
+ repoKey,
118
+ ),
150
119
  };
151
120
  }
152
121
 
122
+ function readCheckpointValue(baseOid, value) {
123
+ let indexDiff = typeof value?.indexDiff === 'string' ? value.indexDiff : '';
124
+ let worktreeDiff = typeof value?.worktreeDiff === 'string' ? value.worktreeDiff : '';
125
+ if (value?.payload) {
126
+ const payload = require('./objects').readPayload(value.payload);
127
+ if (payload.state !== 'ready') {
128
+ return { state: payload.state, indexDiff: null, worktreeDiff: null };
129
+ }
130
+ try {
131
+ const parsed = JSON.parse(payload.bytes.toString('utf8'));
132
+ indexDiff = parsed.indexDiff;
133
+ worktreeDiff = parsed.worktreeDiff;
134
+ } catch {
135
+ return { state: 'payload-invalid', indexDiff: null, worktreeDiff: null };
136
+ }
137
+ }
138
+ if (
139
+ typeof indexDiff !== 'string'
140
+ || typeof worktreeDiff !== 'string'
141
+ || revisionOf(baseOid, indexDiff, worktreeDiff) !== (value?.revision ?? null)
142
+ ) {
143
+ return { state: 'revision-mismatch', indexDiff: null, worktreeDiff: null };
144
+ }
145
+ return { state: 'ready', indexDiff, worktreeDiff };
146
+ }
147
+
148
+ function ownPublishedCard(card) {
149
+ const own = require('./wall').readWall().find((entry) => entry.machine === machineId());
150
+ const published = own?.cards?.find((candidate) => candidate.replica === card.replica);
151
+ if (
152
+ !published
153
+ || !Number.isInteger(published.positionVersion)
154
+ || published.positionVersion < 1
155
+ || published.head?.branch !== card.head?.branch
156
+ || published.head?.oid !== card.head?.oid
157
+ || Boolean(published.head?.detached) !== Boolean(card.head?.detached)
158
+ ) return null;
159
+ return published;
160
+ }
161
+
153
162
  async function publishPass(replicaId = null) {
154
163
  const machine = machineId();
155
- const run = { published: 0, skips: 0, unbound: 0, failures: 0 };
164
+ const run = { published: 0, skips: 0, waiting: 0, failures: 0 };
156
165
  for (const card of listRepoCards()) {
157
166
  if (replicaId && card.replica !== replicaId) continue;
158
167
  if (card.health !== 'healthy' || card.kind !== 'primary' || card.operation !== 'none') continue;
159
168
  const repoKey = repoKeyOfCard(card);
160
- const resourceId = checkpointResourceId(machine, repoKey);
161
- if (!resourceId) continue;
169
+ if (!repoKey || !card.head?.oid) continue;
162
170
  try {
171
+ const publishedCard = ownPublishedCard(card);
172
+ const reduced = require('./reducer').readRepoState(repoKey);
173
+ if (
174
+ !publishedCard
175
+ || !reduced
176
+ || reduced.baseOid !== card.head.oid
177
+ || reduced.branch !== card.head.branch
178
+ || typeof reduced.indexDiff !== 'string'
179
+ || typeof reduced.worktreeDiff !== 'string'
180
+ ) {
181
+ run.waiting += 1;
182
+ continue;
183
+ }
184
+
185
+ const captured = await captureCheckpoint(card.path);
186
+ if (captured.indexDiff === null || captured.baseOid !== card.head.oid) {
187
+ run.waiting += 1;
188
+ continue;
189
+ }
190
+ const sharedRevision = revisionOf(card.head.oid, reduced.indexDiff, reduced.worktreeDiff);
191
+ const localRevision = revisionOf(card.head.oid, captured.indexDiff, captured.worktreeDiff);
163
192
  const file = checkpointFile(machine, repoKey);
164
- const binding = await ensureDocBound({ resourceId, filePath: file, create: true });
165
- if (!binding.bound) {
166
- run.unbound += 1;
193
+ const existing = readCheckpoint(machine, repoKey);
194
+ const parked = await Promise.all((reduced.parked || []).map(async (entry) => ({
195
+ ...entry,
196
+ objects: await require('./objects').bundleSourceFor(card.path, repoKey, entry.oid),
197
+ })));
198
+
199
+ // Exact equality means the working tree merely materialized the shared
200
+ // checkpoint. Keep the writer's last transition because it remains part
201
+ // of the reducer input, but do not invent an echo operation.
202
+ if (sharedRevision === localRevision) {
203
+ const refreshed = existing ? { ...existing, parked } : null;
204
+ if (refreshed && !sameTransition(existing, refreshed)) {
205
+ fs.writeFileSync(file, `${JSON.stringify(refreshed)}\n`);
206
+ appendStateEvent({
207
+ resource: 'repo-checkpoints',
208
+ op: 'update',
209
+ id: card.replica,
210
+ value: refreshed,
211
+ source: 'repo-checkpoint:parked-objects',
212
+ });
213
+ run.published += 1;
214
+ continue;
215
+ }
216
+ run.skips += 1;
167
217
  continue;
168
218
  }
169
- const envelope = await buildEnvelope(card, machine, repoKey);
170
- if (!envelope) continue;
171
- const docs = require('../state/docs');
172
- const current = docs.getDocText(file);
173
- const existing = parseCheckpoint(current);
174
- const unchanged = existing
175
- && existing.baseOid === envelope.baseOid
176
- && existing.revision === envelope.revision
177
- && (existing.skipped || null) === (envelope.skipped || null)
178
- && JSON.stringify(existing.payload ?? null) === JSON.stringify(envelope.payload ?? null)
179
- && JSON.stringify(existing.incorporates ?? null) === JSON.stringify(envelope.incorporates ?? null);
180
- if (unchanged) {
219
+
220
+ const envelope = {
221
+ checkpoint: CHECKPOINT_CONTRACT,
222
+ machine,
223
+ repoKey,
224
+ branch: card.head.branch,
225
+ baseOid: card.head.oid,
226
+ cardVersion: publishedCard.positionVersion,
227
+ from: checkpointValue(
228
+ machine,
229
+ repoKey,
230
+ card.head.oid,
231
+ reduced.indexDiff,
232
+ reduced.worktreeDiff,
233
+ ),
234
+ to: checkpointValue(
235
+ machine,
236
+ repoKey,
237
+ card.head.oid,
238
+ captured.indexDiff,
239
+ captured.worktreeDiff,
240
+ ),
241
+ parked,
242
+ capturedAt: new Date().toISOString(),
243
+ };
244
+ if (sameTransition(existing, envelope)) {
181
245
  run.skips += 1;
182
246
  continue;
183
247
  }
184
- envelope.publishedAt = new Date().toISOString();
185
- docs.writeDocText(file, `${JSON.stringify(envelope)}\n`, { baseHash: sha256(current) });
248
+ fs.mkdirSync(path.dirname(file), { recursive: true });
249
+ fs.writeFileSync(file, `${JSON.stringify(envelope)}\n`);
250
+ appendStateEvent({
251
+ resource: 'repo-checkpoints',
252
+ op: existing ? 'update' : 'insert',
253
+ id: card.replica,
254
+ value: envelope,
255
+ source: 'repo-checkpoint:capture',
256
+ });
186
257
  run.published += 1;
187
258
  } catch (error) {
188
259
  run.failures += 1;
@@ -191,34 +262,28 @@ async function publishPass(replicaId = null) {
191
262
  }
192
263
  service.counters.published += run.published;
193
264
  service.counters.skips += run.skips;
194
- service.counters.unbound += run.unbound;
265
+ service.counters.waiting += run.waiting;
195
266
  service.counters.failures += run.failures;
196
267
  service.lastRun = { ...run, at: new Date().toISOString() };
197
268
  return run;
198
269
  }
199
270
 
200
- /**
201
- * Publish this machine's checkpoint docs. Calls are one serialized queue:
202
- * a worktree can change again while Git is being captured, but two
203
- * whole-checkpoint replacements must never race each other.
204
- */
205
271
  function publishCheckpoints(replicaId = null) {
206
272
  if (replicaId) publishReplicasRequested.add(replicaId);
207
273
  else publishAllRequested = true;
208
274
  if (publishing) return publishing;
209
-
210
275
  publishing = (async () => {
211
- let lastRun = { published: 0, skips: 0, unbound: 0, failures: 0 };
276
+ let lastRun = { published: 0, skips: 0, waiting: 0, failures: 0 };
212
277
  while (publishAllRequested || publishReplicasRequested.size > 0) {
213
278
  if (publishAllRequested) {
214
279
  publishAllRequested = false;
215
280
  publishReplicasRequested.clear();
216
281
  lastRun = await publishPass();
217
- continue;
282
+ } else {
283
+ const replicas = [...publishReplicasRequested];
284
+ publishReplicasRequested.clear();
285
+ for (const requested of replicas) lastRun = await publishPass(requested);
218
286
  }
219
- const replicas = [...publishReplicasRequested];
220
- publishReplicasRequested.clear();
221
- for (const requested of replicas) lastRun = await publishPass(requested);
222
287
  }
223
288
  return lastRun;
224
289
  })().finally(() => {
@@ -240,7 +305,7 @@ function startCheckpointService() {
240
305
  if (service.started) return;
241
306
  service.started = true;
242
307
  service.unsubscribe = subscribeStateEvents((event) => {
243
- if (event.resource === 'repo-cards') schedulePublish();
308
+ if (event.resource === 'repo-cards' || event.resource === 'workspace-reducer') schedulePublish();
244
309
  });
245
310
  service.reconcileTimer = setInterval(schedulePublish, RECONCILE_INTERVAL_MS);
246
311
  service.reconcileTimer.unref?.();
@@ -271,11 +336,11 @@ module.exports = {
271
336
  CHECKPOINT_CONTRACT,
272
337
  MAX_INLINE_DIFF_BYTES,
273
338
  checkpointFile,
274
- checkpointResourceId,
275
339
  checkpointStats,
276
340
  parseCheckpoint,
277
341
  publishCheckpoints,
278
342
  readCheckpoint,
343
+ readCheckpointValue,
279
344
  repoKeyOfCard,
280
345
  revisionOf,
281
346
  startCheckpointService,
@@ -158,6 +158,18 @@ async function listWorktrees(worktreeRoot) {
158
158
  return worktrees.filter((worktree) => !worktree.bare);
159
159
  }
160
160
 
161
+ /** Every local branch ref, sorted by name. */
162
+ async function localBranches(worktreeRoot) {
163
+ const { stdout } = await git(
164
+ ['-C', worktreeRoot, 'for-each-ref', '--format=%(refname:strip=2)%00%(objectname)', 'refs/heads/'],
165
+ worktreeRoot,
166
+ );
167
+ return stdout.trim().split('\n').filter(Boolean).map((line) => {
168
+ const [name, oid] = line.split('\0');
169
+ return { name, oid };
170
+ }).sort((left, right) => left.name.localeCompare(right.name));
171
+ }
172
+
161
173
  /**
162
174
  * HEAD position and working-tree cleanliness in one `git status
163
175
  * --porcelain=v2 -z --branch` call.
@@ -278,6 +290,16 @@ async function isAncestor(worktreeRoot, ancestorOid, descendantOid) {
278
290
  return result.exitCode === 0;
279
291
  }
280
292
 
293
+ /** The shared ancestor of two commits, or null for unrelated histories. */
294
+ async function mergeBase(worktreeRoot, leftOid, rightOid) {
295
+ const result = await git(
296
+ ['-C', worktreeRoot, 'merge-base', leftOid, rightOid],
297
+ worktreeRoot,
298
+ { okCodes: [0, 1, 128] },
299
+ );
300
+ return result.exitCode === 0 ? result.stdout.trim() || null : null;
301
+ }
302
+
281
303
  /** Is this commit already obtainable from an origin remote-tracking ref? */
282
304
  async function originContains(worktreeRoot, oid) {
283
305
  const { stdout } = await git(
@@ -331,15 +353,64 @@ async function captureWorktreeDiff(worktreeRoot, { maxBytes = Infinity } = {}) {
331
353
  return { baseOid, diff: parts.join(''), bytes };
332
354
  }
333
355
 
356
+ /**
357
+ * Git's exact two-layer uncommitted state:
358
+ * HEAD → index, then index → working tree.
359
+ *
360
+ * Untracked, non-ignored files belong to the working-tree layer. Keeping
361
+ * these deltas separate preserves what the user deliberately staged.
362
+ */
363
+ async function captureCheckpoint(worktreeRoot, { maxBytes = Infinity } = {}) {
364
+ const baseOid = await headOid(worktreeRoot);
365
+ if (!baseOid) return { baseOid: null, indexDiff: null, worktreeDiff: null, reason: 'unborn' };
366
+
367
+ const staged = await git(
368
+ ['-C', worktreeRoot, ...PATCH_ARGS, 'diff', '--cached', 'HEAD', '--binary', '--full-index'],
369
+ worktreeRoot,
370
+ );
371
+ const unstaged = await git(
372
+ ['-C', worktreeRoot, ...PATCH_ARGS, 'diff', '--binary', '--full-index'],
373
+ worktreeRoot,
374
+ );
375
+ const worktreeParts = [unstaged.stdout];
376
+ let bytes = Buffer.byteLength(staged.stdout) + Buffer.byteLength(unstaged.stdout);
377
+ const untracked = await git(
378
+ ['-C', worktreeRoot, 'ls-files', '--others', '--exclude-standard', '-z'],
379
+ worktreeRoot,
380
+ );
381
+ for (const file of untracked.stdout.split('\0').filter(Boolean).sort()) {
382
+ if (bytes > maxBytes) break;
383
+ const creation = await git(
384
+ ['-C', worktreeRoot, ...PATCH_ARGS, 'diff', '--no-index', '--binary', '--full-index', '--', '/dev/null', file],
385
+ worktreeRoot,
386
+ { okCodes: [0, 1] },
387
+ );
388
+ worktreeParts.push(creation.stdout);
389
+ bytes += Buffer.byteLength(creation.stdout);
390
+ }
391
+ if (bytes > maxBytes) {
392
+ return { baseOid, indexDiff: null, worktreeDiff: null, reason: 'too-large', bytes };
393
+ }
394
+ return {
395
+ baseOid,
396
+ indexDiff: staged.stdout,
397
+ worktreeDiff: worktreeParts.join(''),
398
+ bytes,
399
+ };
400
+ }
401
+
334
402
  module.exports = {
335
403
  GitError,
404
+ captureCheckpoint,
336
405
  captureWorktreeDiff,
337
406
  classifyDirectory,
338
407
  hasCommit,
339
408
  headAndDirty,
340
409
  headOid,
341
410
  isAncestor,
411
+ localBranches,
342
412
  listWorktrees,
413
+ mergeBase,
343
414
  operationState,
344
415
  originContains,
345
416
  originUrl,