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
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
*
|
|
5
|
-
* fact on the rail.
|
|
4
|
+
* The checkpoint publisher.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
23
|
+
const { appendStateEvent, subscribeStateEvents } = require('../state/events');
|
|
24
|
+
const { machineId } = require('./identity');
|
|
28
25
|
const { listRepoCards } = require('./cards');
|
|
29
|
-
const {
|
|
30
|
-
const { ensureDocBound } = require('./binding');
|
|
31
|
-
const { listPairStates } = require('./ledger');
|
|
26
|
+
const { captureCheckpoint } = require('./git');
|
|
32
27
|
|
|
33
|
-
const CHECKPOINT_CONTRACT = 'repo-checkpoint@
|
|
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,
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 (
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
85
|
+
try {
|
|
86
|
+
return parseCheckpoint(fs.readFileSync(checkpointFile(machine, repoKey), 'utf8'));
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
99
90
|
}
|
|
100
91
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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,
|