amalgm 0.1.161 → 0.1.162
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/shared-realtime-tunnel.js +30 -1
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/fs/rest.js +26 -7
- package/runtime/scripts/amalgm-mcp/index.js +3 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +32 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +7 -5
- package/runtime/scripts/amalgm-mcp/state/cloud-resource-id.js +16 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +74 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +122 -4
- package/runtime/scripts/amalgm-mcp/state/migrations.js +62 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +4 -4
- package/runtime/scripts/amalgm-mcp/state/promotions.js +2 -2
- package/runtime/scripts/amalgm-mcp/state/replicas.js +6 -5
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +27 -22
- package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +5 -9
- package/runtime/scripts/amalgm-mcp/tests/workspace-access-store.test.js +120 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +137 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +264 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +155 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +114 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +227 -0
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +182 -0
- package/runtime/scripts/amalgm-mcp/workspace/binding.js +62 -0
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +237 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +63 -12
- package/runtime/scripts/amalgm-mcp/workspace/ledger.js +96 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +290 -0
- package/runtime/scripts/amalgm-mcp/workspace/reconcile.js +347 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +182 -2
- package/runtime/scripts/amalgm-mcp/workspace/store.js +59 -12
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +141 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/access.js +62 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/identity.js +53 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +68 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +174 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/operations.js +93 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +138 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +82 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +391 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-promotion.js +94 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +666 -0
|
@@ -0,0 +1,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
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
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
|
|
266
|
-
const
|
|
267
|
-
|
|
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
|
-
['
|
|
270
|
-
|
|
271
|
-
{ okCodes: [0, 1] },
|
|
301
|
+
['-C', worktreeRoot, ...PATCH_ARGS, 'diff', 'HEAD', '--binary', '--full-index'],
|
|
302
|
+
worktreeRoot,
|
|
272
303
|
);
|
|
273
|
-
|
|
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
|
-
|
|
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
|
+
};
|