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