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
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One reducer per repository, one materializer per checkout.
|
|
5
|
+
*
|
|
6
|
+
* The reducer reads the single rail-ordered wall and derives exactly:
|
|
7
|
+
* - one winning card (highest official card version), and
|
|
8
|
+
* - one checkpoint tree (checkpoint transitions folded in official order).
|
|
9
|
+
*
|
|
10
|
+
* The materializer has no peer branches. It obtains the winning commit,
|
|
11
|
+
* moves to that exact card when safe, advances the one held checkpoint tree
|
|
12
|
+
* to the newly reduced tree, and verifies on the next pass.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { AMALGM_DIR } = require('../config');
|
|
19
|
+
const { appendStateEvent, subscribeStateEvents } = require('../state/events');
|
|
20
|
+
const { listRepoCards, refreshRepoCard, refreshRepoCards } = require('./cards');
|
|
21
|
+
const {
|
|
22
|
+
captureCheckpoint,
|
|
23
|
+
hasCommit,
|
|
24
|
+
headAndDirty,
|
|
25
|
+
isAncestor,
|
|
26
|
+
localBranches,
|
|
27
|
+
mergeBase,
|
|
28
|
+
operationState,
|
|
29
|
+
} = require('./git');
|
|
30
|
+
const {
|
|
31
|
+
readCheckpointValue,
|
|
32
|
+
repoKeyOfCard,
|
|
33
|
+
revisionOf,
|
|
34
|
+
} = require('./checkpoint');
|
|
35
|
+
const merge = require('./merge');
|
|
36
|
+
const transition = require('./transition');
|
|
37
|
+
|
|
38
|
+
const STATE_DIR = path.join(AMALGM_DIR, 'state', 'repo-reducer');
|
|
39
|
+
const REDUCE_QUIET_MS = 25;
|
|
40
|
+
const REDUCE_INTERVAL_MS = 30_000;
|
|
41
|
+
|
|
42
|
+
const service = {
|
|
43
|
+
started: false,
|
|
44
|
+
timer: null,
|
|
45
|
+
interval: null,
|
|
46
|
+
unsubscribe: null,
|
|
47
|
+
counters: { passes: 0, materialized: 0, waiting: 0, failures: 0 },
|
|
48
|
+
repos: new Map(),
|
|
49
|
+
lastPass: null,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function stateFile(repoKey) {
|
|
53
|
+
const digest = crypto.createHash('sha256').update(repoKey).digest('hex').slice(0, 24);
|
|
54
|
+
return path.join(STATE_DIR, `${digest}.json`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readRepoState(repoKey) {
|
|
58
|
+
try {
|
|
59
|
+
const state = JSON.parse(fs.readFileSync(stateFile(repoKey), 'utf8'));
|
|
60
|
+
return state?.repoKey === repoKey ? state : null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeRepoState(repoKey, state) {
|
|
67
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
68
|
+
const next = { repoKey, ...state, updatedAt: new Date().toISOString() };
|
|
69
|
+
fs.writeFileSync(stateFile(repoKey), `${JSON.stringify(next, null, 2)}\n`);
|
|
70
|
+
return next;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validVersion(value) {
|
|
74
|
+
return Number.isInteger(value) && value > 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Pure wall reduction. Machine identity never chooses a winner; it only
|
|
79
|
+
* identifies the latest checkpoint transition each writer currently owns.
|
|
80
|
+
*/
|
|
81
|
+
function reduceWall(entries) {
|
|
82
|
+
const repos = new Map();
|
|
83
|
+
for (const wallEntry of entries) {
|
|
84
|
+
for (const card of wallEntry.cards || []) {
|
|
85
|
+
const repoKey = repoKeyOfCard(card);
|
|
86
|
+
if (
|
|
87
|
+
!repoKey
|
|
88
|
+
|| card.kind !== 'primary'
|
|
89
|
+
|| (card.health && card.health !== 'healthy')
|
|
90
|
+
|| !validVersion(card.positionVersion)
|
|
91
|
+
) continue;
|
|
92
|
+
let repo = repos.get(repoKey);
|
|
93
|
+
if (!repo) {
|
|
94
|
+
repo = { repoKey, card: null, transitions: [], parked: [], branchMap: new Map() };
|
|
95
|
+
repos.set(repoKey, repo);
|
|
96
|
+
}
|
|
97
|
+
if (!repo.card || card.positionVersion > repo.card.positionVersion) {
|
|
98
|
+
repo.card = { ...card, writer: wallEntry.machine };
|
|
99
|
+
}
|
|
100
|
+
for (const ref of card.branches || []) {
|
|
101
|
+
if (!ref?.name || !validVersion(ref.version)) continue;
|
|
102
|
+
const held = repo.branchMap.get(ref.name);
|
|
103
|
+
if (!held || ref.version > held.version) repo.branchMap.set(ref.name, ref);
|
|
104
|
+
}
|
|
105
|
+
if (
|
|
106
|
+
card.checkpoint
|
|
107
|
+
&& validVersion(card.checkpointVersion)
|
|
108
|
+
&& card.checkpoint.machine === wallEntry.machine
|
|
109
|
+
) {
|
|
110
|
+
repo.transitions.push({
|
|
111
|
+
...card.checkpoint,
|
|
112
|
+
version: card.checkpointVersion,
|
|
113
|
+
mutationId: card.checkpointMutationId,
|
|
114
|
+
});
|
|
115
|
+
repo.parked.push(...(card.checkpoint.parked || []));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const repo of repos.values()) {
|
|
120
|
+
repo.transitions = repo.transitions
|
|
121
|
+
.filter((entry) => (
|
|
122
|
+
entry.baseOid === repo.card.head?.oid
|
|
123
|
+
&& entry.branch === repo.card.head?.branch
|
|
124
|
+
))
|
|
125
|
+
.sort((left, right) => left.version - right.version);
|
|
126
|
+
if (repo.card.head?.branch) {
|
|
127
|
+
const held = repo.branchMap.get(repo.card.head.branch);
|
|
128
|
+
if (!held || repo.card.positionVersion > held.version) {
|
|
129
|
+
repo.branchMap.set(repo.card.head.branch, {
|
|
130
|
+
name: repo.card.head.branch,
|
|
131
|
+
oid: repo.card.head.oid,
|
|
132
|
+
objects: repo.card.objects,
|
|
133
|
+
mutationId: repo.card.positionMutationId,
|
|
134
|
+
version: repo.card.positionVersion,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
repo.branches = [...repo.branchMap.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
139
|
+
delete repo.branchMap;
|
|
140
|
+
}
|
|
141
|
+
return repos;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function uniqueParked(entries) {
|
|
145
|
+
const byOid = new Map();
|
|
146
|
+
for (const entry of entries || []) {
|
|
147
|
+
if (typeof entry?.oid === 'string' && /^[0-9a-f]{40,64}$/.test(entry.oid)) {
|
|
148
|
+
byOid.set(entry.oid, entry);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return [...byOid.values()].sort((left, right) => left.oid.localeCompare(right.oid));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function obtainCardObjects(localCard, targetCard) {
|
|
155
|
+
const oid = targetCard.head?.oid;
|
|
156
|
+
if (!oid || await hasCommit(localCard.path, oid)) return { state: 'ready' };
|
|
157
|
+
if (targetCard.objects?.kind === 'bundle') {
|
|
158
|
+
const imported = await require('./objects').importFromLocator(localCard.path, targetCard.objects);
|
|
159
|
+
if (imported.state !== 'imported') return { state: `objects-${imported.state}` };
|
|
160
|
+
} else if (targetCard.objects?.kind === 'pending') {
|
|
161
|
+
return { state: 'objects-uploading' };
|
|
162
|
+
} else {
|
|
163
|
+
const approved = localCard.repo?.remote
|
|
164
|
+
&& localCard.repo.remote === targetCard.repo?.remote
|
|
165
|
+
&& targetCard.head?.branch;
|
|
166
|
+
if (!approved) return { state: 'missing-objects-no-approved-source' };
|
|
167
|
+
await transition.fetchBranch(localCard.path, 'origin', targetCard.head.branch);
|
|
168
|
+
}
|
|
169
|
+
return await hasCommit(localCard.path, oid)
|
|
170
|
+
? { state: 'ready' }
|
|
171
|
+
: { state: 'missing-objects-after-fetch' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function moveToWinningCard(localCard, targetCard) {
|
|
175
|
+
const current = await headAndDirty(localCard.path);
|
|
176
|
+
const operation = await operationState(localCard.path);
|
|
177
|
+
if (operation !== 'none') return { state: 'waiting', reason: 'local-operation-in-flight' };
|
|
178
|
+
if (
|
|
179
|
+
current.head.oid === targetCard.head?.oid
|
|
180
|
+
&& current.head.branch === targetCard.head?.branch
|
|
181
|
+
) return { state: 'ready', moved: false };
|
|
182
|
+
|
|
183
|
+
const objects = await obtainCardObjects(localCard, targetCard);
|
|
184
|
+
if (objects.state !== 'ready') return { state: 'waiting', reason: objects.state };
|
|
185
|
+
|
|
186
|
+
const forward = current.head.oid
|
|
187
|
+
&& targetCard.head?.oid
|
|
188
|
+
&& await isAncestor(localCard.path, current.head.oid, targetCard.head.oid);
|
|
189
|
+
if (!current.head.oid || !targetCard.head?.oid) {
|
|
190
|
+
return { state: 'waiting', reason: 'unborn-card-movement' };
|
|
191
|
+
}
|
|
192
|
+
const commonOid = forward
|
|
193
|
+
? current.head.oid
|
|
194
|
+
: await mergeBase(localCard.path, current.head.oid, targetCard.head.oid);
|
|
195
|
+
if (!commonOid) return { state: 'waiting', reason: 'unrelated-card-histories' };
|
|
196
|
+
|
|
197
|
+
const captured = await captureCheckpoint(localCard.path);
|
|
198
|
+
if (captured.indexDiff === null || captured.baseOid !== current.head.oid) {
|
|
199
|
+
return { state: 'waiting', reason: 'local-state-moving' };
|
|
200
|
+
}
|
|
201
|
+
const repoKey = repoKeyOfCard(localCard);
|
|
202
|
+
const parked = [];
|
|
203
|
+
if (!forward && current.head.oid !== targetCard.head.oid) {
|
|
204
|
+
const parkedRef = await transition.parkCommit(localCard.path, current.head.oid);
|
|
205
|
+
parked.push({
|
|
206
|
+
oid: current.head.oid,
|
|
207
|
+
ref: parkedRef,
|
|
208
|
+
sourceBranch: current.head.branch,
|
|
209
|
+
objects: await require('./objects').bundleSourceFor(
|
|
210
|
+
localCard.path,
|
|
211
|
+
repoKey,
|
|
212
|
+
current.head.oid,
|
|
213
|
+
),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const commonTree = await merge.treeOf(localCard.path, commonOid);
|
|
218
|
+
const winningTree = await merge.treeOf(localCard.path, targetCard.head.oid);
|
|
219
|
+
const oldTree = await merge.treeOf(localCard.path, current.head.oid);
|
|
220
|
+
const committed = forward || current.head.oid === targetCard.head.oid
|
|
221
|
+
? { tree: winningTree, conflicts: [] }
|
|
222
|
+
: await merge.reduceTree(
|
|
223
|
+
localCard.path,
|
|
224
|
+
winningTree,
|
|
225
|
+
commonTree,
|
|
226
|
+
oldTree,
|
|
227
|
+
{ label: `parked-${current.head.oid.slice(0, 12)}` },
|
|
228
|
+
);
|
|
229
|
+
const oldIndexTree = await merge.treeOfPatch(localCard.path, current.head.oid, captured.indexDiff);
|
|
230
|
+
const staged = await merge.reduceTree(
|
|
231
|
+
localCard.path,
|
|
232
|
+
committed.tree,
|
|
233
|
+
oldTree,
|
|
234
|
+
oldIndexTree,
|
|
235
|
+
{ label: `staged-${current.head.oid.slice(0, 12)}` },
|
|
236
|
+
);
|
|
237
|
+
const oldWorktree = await merge.treeOfPatch(localCard.path, oldIndexTree, captured.worktreeDiff);
|
|
238
|
+
const working = await merge.reduceTree(
|
|
239
|
+
localCard.path,
|
|
240
|
+
staged.tree,
|
|
241
|
+
oldIndexTree,
|
|
242
|
+
oldWorktree,
|
|
243
|
+
{ label: `working-${current.head.oid.slice(0, 12)}` },
|
|
244
|
+
);
|
|
245
|
+
const conflicts = [
|
|
246
|
+
...committed.conflicts,
|
|
247
|
+
...staged.conflicts,
|
|
248
|
+
...working.conflicts,
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
const rollbackDir = path.join(STATE_DIR, 'card-moves');
|
|
252
|
+
fs.mkdirSync(rollbackDir, { recursive: true });
|
|
253
|
+
fs.writeFileSync(
|
|
254
|
+
path.join(rollbackDir, `${current.head.oid}-to-${targetCard.head.oid}.json`),
|
|
255
|
+
`${JSON.stringify({
|
|
256
|
+
baseOid: current.head.oid,
|
|
257
|
+
indexDiff: captured.indexDiff,
|
|
258
|
+
worktreeDiff: captured.worktreeDiff,
|
|
259
|
+
})}\n`,
|
|
260
|
+
);
|
|
261
|
+
await transition.forceMoveToCard(localCard.path, {
|
|
262
|
+
expectedBranch: current.head.branch,
|
|
263
|
+
expectedOid: current.head.oid,
|
|
264
|
+
targetBranch: targetCard.head.branch,
|
|
265
|
+
targetOid: targetCard.head.oid,
|
|
266
|
+
});
|
|
267
|
+
const applied = await merge.advance(localCard.path, winningTree, working.tree, {
|
|
268
|
+
label: `card-${targetCard.positionVersion}`,
|
|
269
|
+
});
|
|
270
|
+
await transition.setIndexTree(localCard.path, staged.tree);
|
|
271
|
+
return {
|
|
272
|
+
state: 'ready',
|
|
273
|
+
moved: true,
|
|
274
|
+
parked,
|
|
275
|
+
conflicts: [...conflicts, ...applied.conflicts],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function materializeParked(localCard, entries) {
|
|
280
|
+
const waiting = [];
|
|
281
|
+
for (const entry of uniqueParked(entries)) {
|
|
282
|
+
if (!(await hasCommit(localCard.path, entry.oid))) {
|
|
283
|
+
if (entry.objects?.kind === 'bundle') {
|
|
284
|
+
const imported = await require('./objects').importFromLocator(localCard.path, entry.objects);
|
|
285
|
+
if (imported.state !== 'imported') {
|
|
286
|
+
waiting.push({ ...entry, state: imported.state });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
waiting.push({ ...entry, state: entry.objects?.kind || 'objects-missing' });
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
await transition.parkCommit(localCard.path, entry.oid);
|
|
295
|
+
}
|
|
296
|
+
return waiting;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function materializeBranches(localCard, plan) {
|
|
300
|
+
const current = new Map((await localBranches(localCard.path)).map((ref) => [ref.name, ref.oid]));
|
|
301
|
+
const waiting = [];
|
|
302
|
+
let moved = false;
|
|
303
|
+
for (const ref of plan.branches || []) {
|
|
304
|
+
if (ref.name === plan.card.head?.branch) {
|
|
305
|
+
if (ref.oid !== plan.card.head.oid) {
|
|
306
|
+
waiting.push({ name: ref.name, state: 'checked-out-ref-disagrees-with-card' });
|
|
307
|
+
}
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (ref.oid === null) {
|
|
311
|
+
if (current.has(ref.name)) {
|
|
312
|
+
await transition.deleteBranchRef(localCard.path, ref.name);
|
|
313
|
+
moved = true;
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (!(await hasCommit(localCard.path, ref.oid))) {
|
|
318
|
+
if (ref.objects?.kind === 'bundle') {
|
|
319
|
+
const imported = await require('./objects').importFromLocator(localCard.path, ref.objects);
|
|
320
|
+
if (imported.state !== 'imported') {
|
|
321
|
+
waiting.push({ name: ref.name, state: imported.state });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
} else if (ref.objects?.kind === 'remote' && localCard.repo?.remote) {
|
|
325
|
+
await transition.fetchBranch(localCard.path, 'origin', ref.name);
|
|
326
|
+
} else {
|
|
327
|
+
waiting.push({ name: ref.name, state: ref.objects?.kind || 'objects-missing' });
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (!(await hasCommit(localCard.path, ref.oid))) {
|
|
332
|
+
waiting.push({ name: ref.name, state: 'objects-missing-after-import' });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (current.get(ref.name) !== ref.oid) {
|
|
336
|
+
await transition.setBranchRef(localCard.path, ref.name, ref.oid);
|
|
337
|
+
moved = true;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return { moved, waiting };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function reduceCheckpoint(localCard, plan) {
|
|
344
|
+
const baseOid = plan.card.head.oid;
|
|
345
|
+
const baseTree = await merge.treeOf(localCard.path, baseOid);
|
|
346
|
+
let indexTree = baseTree;
|
|
347
|
+
let worktreeTree = baseTree;
|
|
348
|
+
const conflicts = [];
|
|
349
|
+
|
|
350
|
+
for (const operation of plan.transitions) {
|
|
351
|
+
const from = readCheckpointValue(baseOid, operation.from);
|
|
352
|
+
const to = readCheckpointValue(baseOid, operation.to);
|
|
353
|
+
if (from.state !== 'ready' || to.state !== 'ready') {
|
|
354
|
+
return { state: 'waiting', reason: `checkpoint-${from.state !== 'ready' ? from.state : to.state}` };
|
|
355
|
+
}
|
|
356
|
+
const fromIndexTree = await merge.treeOfPatch(localCard.path, baseOid, from.indexDiff);
|
|
357
|
+
const toIndexTree = await merge.treeOfPatch(localCard.path, baseOid, to.indexDiff);
|
|
358
|
+
const reducedIndex = await merge.reduceTree(localCard.path, indexTree, fromIndexTree, toIndexTree, {
|
|
359
|
+
label: String(operation.version),
|
|
360
|
+
});
|
|
361
|
+
indexTree = reducedIndex.tree;
|
|
362
|
+
conflicts.push(...reducedIndex.conflicts.map((item) => ({ ...item, layer: 'index' })));
|
|
363
|
+
|
|
364
|
+
const fromWorktreeTree = await merge.treeOfPatch(localCard.path, fromIndexTree, from.worktreeDiff);
|
|
365
|
+
const toWorktreeTree = await merge.treeOfPatch(localCard.path, toIndexTree, to.worktreeDiff);
|
|
366
|
+
const reducedWorktree = await merge.reduceTree(
|
|
367
|
+
localCard.path,
|
|
368
|
+
worktreeTree,
|
|
369
|
+
fromWorktreeTree,
|
|
370
|
+
toWorktreeTree,
|
|
371
|
+
{ label: String(operation.version) },
|
|
372
|
+
);
|
|
373
|
+
worktreeTree = reducedWorktree.tree;
|
|
374
|
+
conflicts.push(...reducedWorktree.conflicts.map((item) => ({ ...item, layer: 'worktree' })));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const indexDiff = await merge.diffOfTree(localCard.path, baseOid, indexTree);
|
|
378
|
+
const worktreeDiff = await merge.diffOfTree(localCard.path, indexTree, worktreeTree);
|
|
379
|
+
return {
|
|
380
|
+
state: 'ready',
|
|
381
|
+
indexTree,
|
|
382
|
+
worktreeTree,
|
|
383
|
+
indexDiff,
|
|
384
|
+
worktreeDiff,
|
|
385
|
+
checkpointRevision: revisionOf(baseOid, indexDiff, worktreeDiff),
|
|
386
|
+
conflicts,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function materializeRepo(localCardInput, plan) {
|
|
391
|
+
const refreshed = await refreshRepoCard(localCardInput.replica) || localCardInput;
|
|
392
|
+
const cardResult = await moveToWinningCard(refreshed, plan.card);
|
|
393
|
+
if (cardResult.state !== 'ready') return cardResult;
|
|
394
|
+
|
|
395
|
+
const localCard = cardResult.moved
|
|
396
|
+
? await refreshRepoCard(refreshed.replica) || refreshed
|
|
397
|
+
: refreshed;
|
|
398
|
+
const previous = readRepoState(plan.repoKey);
|
|
399
|
+
const parked = uniqueParked([
|
|
400
|
+
...(previous?.parked || []),
|
|
401
|
+
...(plan.parked || []),
|
|
402
|
+
...(cardResult.parked || []),
|
|
403
|
+
]);
|
|
404
|
+
const parkedWaiting = await materializeParked(localCard, parked);
|
|
405
|
+
const branchResult = await materializeBranches(localCard, plan);
|
|
406
|
+
const reduced = await reduceCheckpoint(localCard, plan);
|
|
407
|
+
if (reduced.state !== 'ready') return reduced;
|
|
408
|
+
|
|
409
|
+
const baseTree = await merge.treeOf(localCard.path, plan.card.head.oid);
|
|
410
|
+
const heldWorktree = previous?.baseOid === plan.card.head.oid
|
|
411
|
+
&& previous.heldTree
|
|
412
|
+
&& await merge.hasTree(localCard.path, previous.heldTree)
|
|
413
|
+
? previous.heldTree
|
|
414
|
+
: baseTree;
|
|
415
|
+
const heldIndex = previous?.baseOid === plan.card.head.oid
|
|
416
|
+
&& previous.heldIndexTree
|
|
417
|
+
&& await merge.hasTree(localCard.path, previous.heldIndexTree)
|
|
418
|
+
? previous.heldIndexTree
|
|
419
|
+
: baseTree;
|
|
420
|
+
const currentIndex = await merge.indexTree(localCard.path);
|
|
421
|
+
const reducedLocalIndex = await merge.reduceTree(
|
|
422
|
+
localCard.path,
|
|
423
|
+
currentIndex,
|
|
424
|
+
heldIndex,
|
|
425
|
+
reduced.indexTree,
|
|
426
|
+
{ label: `index-${reduced.checkpointRevision || 'clean'}` },
|
|
427
|
+
);
|
|
428
|
+
const applied = await merge.advance(localCard.path, heldWorktree, reduced.worktreeTree, {
|
|
429
|
+
label: `checkpoint-${reduced.checkpointRevision || 'clean'}`,
|
|
430
|
+
});
|
|
431
|
+
await transition.setIndexTree(localCard.path, reducedLocalIndex.tree);
|
|
432
|
+
const state = writeRepoState(plan.repoKey, {
|
|
433
|
+
path: localCard.path,
|
|
434
|
+
replica: localCard.replica,
|
|
435
|
+
branch: plan.card.head.branch,
|
|
436
|
+
baseOid: plan.card.head.oid,
|
|
437
|
+
cardVersion: plan.card.positionVersion,
|
|
438
|
+
cardMutationId: plan.card.positionMutationId,
|
|
439
|
+
checkpointRevision: reduced.checkpointRevision,
|
|
440
|
+
indexDiff: reduced.indexDiff,
|
|
441
|
+
worktreeDiff: reduced.worktreeDiff,
|
|
442
|
+
checkpointVersions: plan.transitions.map((entry) => entry.version),
|
|
443
|
+
branches: plan.branches || [],
|
|
444
|
+
heldTree: applied.heldTree,
|
|
445
|
+
heldIndexTree: reduced.indexTree,
|
|
446
|
+
parked,
|
|
447
|
+
parkedWaiting: [...parkedWaiting, ...branchResult.waiting],
|
|
448
|
+
conflicts: [
|
|
449
|
+
...(cardResult.conflicts || []),
|
|
450
|
+
...reduced.conflicts,
|
|
451
|
+
...reducedLocalIndex.conflicts.map((item) => ({ ...item, layer: 'index-materialize' })),
|
|
452
|
+
...applied.conflicts,
|
|
453
|
+
],
|
|
454
|
+
materialized: applied.heldTree === reduced.worktreeTree
|
|
455
|
+
&& reducedLocalIndex.tree === reduced.indexTree,
|
|
456
|
+
});
|
|
457
|
+
appendStateEvent({
|
|
458
|
+
resource: 'workspace-reducer',
|
|
459
|
+
op: previous ? 'update' : 'insert',
|
|
460
|
+
id: localCard.replica,
|
|
461
|
+
value: state,
|
|
462
|
+
source: 'workspace-reducer',
|
|
463
|
+
});
|
|
464
|
+
return {
|
|
465
|
+
state: state.conflicts.length ? 'conflict' : 'in-sync',
|
|
466
|
+
reason: state.conflicts.length ? state.conflicts.map((item) => `${item.kind}:${item.path}`).join(', ') : null,
|
|
467
|
+
moved: cardResult.moved || branchResult.moved || applied.applied.length > 0,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function runReducer() {
|
|
472
|
+
service.counters.passes += 1;
|
|
473
|
+
const startedAt = Date.now();
|
|
474
|
+
const plans = reduceWall(require('./wall').readWall());
|
|
475
|
+
const locals = new Map();
|
|
476
|
+
for (const card of listRepoCards()) {
|
|
477
|
+
if (card.kind !== 'primary' || card.health !== 'healthy') continue;
|
|
478
|
+
const repoKey = repoKeyOfCard(card);
|
|
479
|
+
if (repoKey && !locals.has(repoKey)) locals.set(repoKey, card);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
let moved = false;
|
|
483
|
+
for (const [repoKey, plan] of plans) {
|
|
484
|
+
const local = locals.get(repoKey);
|
|
485
|
+
if (!local || !plan.card) continue;
|
|
486
|
+
try {
|
|
487
|
+
const outcome = await materializeRepo(local, plan);
|
|
488
|
+
service.repos.set(repoKey, { repoKey, path: local.path, ...outcome, at: new Date().toISOString() });
|
|
489
|
+
if (outcome.state === 'waiting') service.counters.waiting += 1;
|
|
490
|
+
if (outcome.moved) {
|
|
491
|
+
moved = true;
|
|
492
|
+
service.counters.materialized += 1;
|
|
493
|
+
}
|
|
494
|
+
} catch (error) {
|
|
495
|
+
service.counters.failures += 1;
|
|
496
|
+
service.repos.set(repoKey, {
|
|
497
|
+
repoKey,
|
|
498
|
+
path: local.path,
|
|
499
|
+
state: 'error',
|
|
500
|
+
reason: error.message,
|
|
501
|
+
at: new Date().toISOString(),
|
|
502
|
+
});
|
|
503
|
+
console.warn(`[WorkspaceReducer] ${local.path}:`, error.message);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
service.lastPass = { at: new Date().toISOString(), durationMs: Date.now() - startedAt, moved };
|
|
507
|
+
if (moved) refreshRepoCards().catch(() => {});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let running = null;
|
|
511
|
+
let queued = false;
|
|
512
|
+
|
|
513
|
+
function reduceNow() {
|
|
514
|
+
if (running) {
|
|
515
|
+
queued = true;
|
|
516
|
+
return running;
|
|
517
|
+
}
|
|
518
|
+
running = (async () => {
|
|
519
|
+
do {
|
|
520
|
+
queued = false;
|
|
521
|
+
await runReducer();
|
|
522
|
+
} while (queued);
|
|
523
|
+
})().finally(() => {
|
|
524
|
+
running = null;
|
|
525
|
+
});
|
|
526
|
+
return running;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function scheduleReduce() {
|
|
530
|
+
if (!service.started || service.timer) return;
|
|
531
|
+
service.timer = setTimeout(() => {
|
|
532
|
+
service.timer = null;
|
|
533
|
+
reduceNow().catch((error) => console.warn('[WorkspaceReducer] pass failed:', error.message));
|
|
534
|
+
}, REDUCE_QUIET_MS);
|
|
535
|
+
service.timer.unref?.();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function startWorkspaceReducer() {
|
|
539
|
+
if (service.started) return;
|
|
540
|
+
service.started = true;
|
|
541
|
+
service.unsubscribe = subscribeStateEvents((event) => {
|
|
542
|
+
const wallPaths = require('./wall').wallStats().paths;
|
|
543
|
+
if (
|
|
544
|
+
event.resource === 'repo-cards'
|
|
545
|
+
|| event.resource === 'repo-checkpoints'
|
|
546
|
+
|| wallPaths.includes(event.id)
|
|
547
|
+
) scheduleReduce();
|
|
548
|
+
});
|
|
549
|
+
service.interval = setInterval(scheduleReduce, REDUCE_INTERVAL_MS);
|
|
550
|
+
service.interval.unref?.();
|
|
551
|
+
scheduleReduce();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function stopWorkspaceReducer() {
|
|
555
|
+
if (!service.started) return;
|
|
556
|
+
service.started = false;
|
|
557
|
+
service.unsubscribe?.();
|
|
558
|
+
service.unsubscribe = null;
|
|
559
|
+
if (service.timer) clearTimeout(service.timer);
|
|
560
|
+
if (service.interval) clearInterval(service.interval);
|
|
561
|
+
service.timer = null;
|
|
562
|
+
service.interval = null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function reducerStats() {
|
|
566
|
+
return {
|
|
567
|
+
started: service.started,
|
|
568
|
+
...service.counters,
|
|
569
|
+
lastPass: service.lastPass,
|
|
570
|
+
repos: [...service.repos.values()],
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
module.exports = {
|
|
575
|
+
materializeRepo,
|
|
576
|
+
readRepoState,
|
|
577
|
+
reduceNow,
|
|
578
|
+
reduceWall,
|
|
579
|
+
reducerStats,
|
|
580
|
+
startWorkspaceReducer,
|
|
581
|
+
stopWorkspaceReducer,
|
|
582
|
+
};
|
|
@@ -681,13 +681,26 @@ function handleWorkspaceSyncStats(sendJson) {
|
|
|
681
681
|
service: repoCardServiceStats(),
|
|
682
682
|
wall: wallStats(),
|
|
683
683
|
checkpoints: require('./checkpoint').checkpointStats(),
|
|
684
|
-
|
|
684
|
+
reducer: require('./reducer').reducerStats(),
|
|
685
685
|
});
|
|
686
686
|
} catch (error) {
|
|
687
687
|
sendJson(500, { error: error instanceof Error ? error.message : 'Failed to read sync stats' });
|
|
688
688
|
}
|
|
689
689
|
}
|
|
690
690
|
|
|
691
|
+
function handleCodeProjectJoin(body, sendJson) {
|
|
692
|
+
try {
|
|
693
|
+
const project = require('./wall').bindProject(
|
|
694
|
+
body?.repoKey,
|
|
695
|
+
body?.resourceId,
|
|
696
|
+
body?.ownerUserId,
|
|
697
|
+
);
|
|
698
|
+
sendJson(200, { project });
|
|
699
|
+
} catch (error) {
|
|
700
|
+
sendJson(400, { error: error instanceof Error ? error.message : 'Failed to join code project' });
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
691
704
|
/**
|
|
692
705
|
* The whole wall: this machine's cards straight from the live store, every
|
|
693
706
|
* peer's cards as last published. One call answers "what code does each of
|
|
@@ -875,6 +888,7 @@ module.exports = {
|
|
|
875
888
|
handleRepoCardsRefresh,
|
|
876
889
|
handleWorkspaceMachines,
|
|
877
890
|
handleWorkspaceSyncStats,
|
|
891
|
+
handleCodeProjectJoin,
|
|
878
892
|
handleTreeList,
|
|
879
893
|
handleTreeEnroll,
|
|
880
894
|
handleTreeMutation,
|
|
@@ -103,12 +103,11 @@ function normalizeWorkspaceRecord(input, options = {}) {
|
|
|
103
103
|
|
|
104
104
|
function workspaceRow(database, identifier) {
|
|
105
105
|
return database.prepare(`
|
|
106
|
-
SELECT
|
|
106
|
+
SELECT *
|
|
107
107
|
FROM workspaces
|
|
108
|
-
|
|
109
|
-
WHERE workspaces.id = ? OR workspaces.path = ? OR workspace_id_aliases.alias_id = ?
|
|
108
|
+
WHERE id = ? OR path = ?
|
|
110
109
|
LIMIT 1
|
|
111
|
-
`).get(identifier, identifier
|
|
110
|
+
`).get(identifier, identifier) || null;
|
|
112
111
|
}
|
|
113
112
|
|
|
114
113
|
function ensureWorkspaceIdentityRows(database, workspace, options = {}) {
|
|
@@ -197,17 +196,14 @@ function upsertWorkspace(input, options = {}) {
|
|
|
197
196
|
|
|
198
197
|
db.transaction(() => {
|
|
199
198
|
workspace = normalizeWorkspaceRecord(input, options);
|
|
200
|
-
const requestedId = cleanString(input.id);
|
|
201
199
|
const existing = db.prepare(`
|
|
202
|
-
SELECT
|
|
200
|
+
SELECT *
|
|
203
201
|
FROM workspaces
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
OR
|
|
207
|
-
OR workspaces.path = ?
|
|
208
|
-
OR workspace_id_aliases.alias_id = ?
|
|
202
|
+
WHERE id = ?
|
|
203
|
+
OR uri = ?
|
|
204
|
+
OR path = ?
|
|
209
205
|
LIMIT 1
|
|
210
|
-
`).get(workspace.id, workspace.uri, workspace.path
|
|
206
|
+
`).get(workspace.id, workspace.uri, workspace.path);
|
|
211
207
|
if (existing) {
|
|
212
208
|
const old = rowToWorkspace(existing);
|
|
213
209
|
workspace = {
|
|
@@ -262,12 +258,6 @@ function upsertWorkspace(input, options = {}) {
|
|
|
262
258
|
JSON.stringify(workspace),
|
|
263
259
|
);
|
|
264
260
|
|
|
265
|
-
if (requestedId && requestedId !== workspace.id) {
|
|
266
|
-
db.prepare(`
|
|
267
|
-
INSERT OR IGNORE INTO workspace_id_aliases(alias_id, workspace_id)
|
|
268
|
-
VALUES (?, ?)
|
|
269
|
-
`).run(requestedId, workspace.id);
|
|
270
|
-
}
|
|
271
261
|
ensureWorkspaceIdentityRows(db, workspace, options);
|
|
272
262
|
|
|
273
263
|
events = publishWorkspaceEvents(db, existing ? 'update' : 'insert', workspace, options.source || 'workspace:upsert');
|