claude-code-swarm 0.3.26 → 0.4.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CLAUDE.md +95 -0
- package/LICENSE +21 -0
- package/hooks/hooks.json +9 -0
- package/package.json +14 -1
- package/renovate.json5 +6 -0
- package/scripts/map-hook.mjs +58 -2
- package/scripts/map-sidecar.mjs +178 -1
- package/src/__tests__/cascade-client.test.mjs +217 -0
- package/src/__tests__/cascade-diff-server.test.mjs +375 -0
- package/src/__tests__/cascade-watcher.test.mjs +475 -0
- package/src/__tests__/config.test.mjs +23 -0
- package/src/bootstrap.mjs +3 -0
- package/src/cascade-client.mjs +334 -0
- package/src/cascade-diff-server.mjs +326 -0
- package/src/cascade-events.mjs +285 -0
- package/src/cascade-watcher.mjs +694 -0
- package/src/config.mjs +7 -0
- package/src/map-connection.mjs +18 -1
- package/src/paths.mjs +12 -0
- package/src/sidecar-server.mjs +26 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cascade-client.mjs — git-cascade tracker ownership for claude-code-swarm
|
|
3
|
+
*
|
|
4
|
+
* Owns a persistent `MultiAgentRepoTracker` running in **local mode** as a
|
|
5
|
+
* local state store. cc-swarm emits `x-cascade/*` events itself over the MAP
|
|
6
|
+
* connection (see cascade-events.mjs) — the tracker here is NOT given an
|
|
7
|
+
* `emit` callback. This is the "observed git" design: the tracker mirrors the
|
|
8
|
+
* working branch as a local-mode stream so later phases can observe commits
|
|
9
|
+
* and merges without owning the branch.
|
|
10
|
+
*
|
|
11
|
+
* Every git-cascade call is wrapped in try/catch — cascade failures must never
|
|
12
|
+
* crash the sidecar. When git-cascade can't be imported (not installed) or the
|
|
13
|
+
* repo path is not a git repo, openCascadeTracker() returns null and callers
|
|
14
|
+
* skip cascade work entirely.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execFileSync } from "child_process";
|
|
18
|
+
import { createLogger } from "./log.mjs";
|
|
19
|
+
import { resolvePackage } from "./swarmkit-resolver.mjs";
|
|
20
|
+
|
|
21
|
+
const log = createLogger("cascade-client");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Cached git-cascade module namespace exports (`streams`, `changes`).
|
|
25
|
+
*
|
|
26
|
+
* git-cascade exposes its CRUD helpers as `export * as streams` /
|
|
27
|
+
* `export * as changes`. We resolve them lazily on first use and cache them so
|
|
28
|
+
* the DB-record helpers below don't re-import on every call.
|
|
29
|
+
*/
|
|
30
|
+
let _gitCascadeMod = null;
|
|
31
|
+
async function getGitCascadeMod() {
|
|
32
|
+
if (_gitCascadeMod) return _gitCascadeMod;
|
|
33
|
+
try {
|
|
34
|
+
const mod = await resolvePackage("git-cascade");
|
|
35
|
+
if (mod) _gitCascadeMod = mod;
|
|
36
|
+
return mod;
|
|
37
|
+
} catch (err) {
|
|
38
|
+
log.warn("git-cascade module resolve failed", { error: err.message });
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Table prefix for the git-cascade tracker DB. Keeps cascade tables namespaced
|
|
45
|
+
* within the SQLite file in case the DB is ever shared.
|
|
46
|
+
*/
|
|
47
|
+
const TABLE_PREFIX = "cascade_";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check whether `repoPath` is inside a git working tree.
|
|
51
|
+
* Best-effort — returns false on any error.
|
|
52
|
+
*/
|
|
53
|
+
function isGitRepo(repoPath) {
|
|
54
|
+
try {
|
|
55
|
+
const out = execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
56
|
+
cwd: repoPath,
|
|
57
|
+
encoding: "utf-8",
|
|
58
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
59
|
+
}).trim();
|
|
60
|
+
return out === "true";
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Open a git-cascade MultiAgentRepoTracker in local mode.
|
|
68
|
+
*
|
|
69
|
+
* The tracker acts purely as a local state store — no `emit` callback is
|
|
70
|
+
* passed because cc-swarm emits x-cascade/* events itself over MAP.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} opts
|
|
73
|
+
* @param {string} opts.repoPath Path to the git repository (the swarm cwd)
|
|
74
|
+
* @param {string} opts.dbPath Path to the tracker SQLite DB file
|
|
75
|
+
* @returns {Promise<object|null>} The tracker instance, or null if git-cascade
|
|
76
|
+
* is unavailable or `repoPath` is not a git repo. Never throws.
|
|
77
|
+
*/
|
|
78
|
+
export async function openCascadeTracker({ repoPath, dbPath }) {
|
|
79
|
+
if (!repoPath || !isGitRepo(repoPath)) {
|
|
80
|
+
log.warn("cascade disabled: not a git repository", { repoPath });
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let gitCascade;
|
|
85
|
+
try {
|
|
86
|
+
gitCascade = await resolvePackage("git-cascade");
|
|
87
|
+
} catch (err) {
|
|
88
|
+
log.warn("cascade disabled: git-cascade import failed", { error: err.message });
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (!gitCascade || typeof gitCascade.MultiAgentRepoTracker !== "function") {
|
|
92
|
+
log.warn("cascade disabled: git-cascade not available");
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const tracker = new gitCascade.MultiAgentRepoTracker({
|
|
98
|
+
repoPath,
|
|
99
|
+
dbPath,
|
|
100
|
+
tablePrefix: TABLE_PREFIX,
|
|
101
|
+
// Skip startup recovery — local-mode tracking has no in-flight
|
|
102
|
+
// operations to recover, and recovery touches git state we don't own.
|
|
103
|
+
skipRecovery: true,
|
|
104
|
+
});
|
|
105
|
+
log.info("cascade tracker opened", { repoPath, dbPath });
|
|
106
|
+
return tracker;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
log.warn("cascade disabled: failed to open tracker", { error: err.message });
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the stream id that tracks `branch`, or null when none does.
|
|
115
|
+
*
|
|
116
|
+
* Streams registered via `trackExistingBranch` are local-mode —
|
|
117
|
+
* `getStreamBranchName` returns the existing branch they track. Best-effort:
|
|
118
|
+
* returns null on any error and skips streams whose branch can't be read.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} tracker A MultiAgentRepoTracker (from openCascadeTracker)
|
|
121
|
+
* @param {string} branch Branch name to match
|
|
122
|
+
* @returns {string|null} The matching stream id, or null.
|
|
123
|
+
*/
|
|
124
|
+
export function findStreamByBranch(tracker, branch) {
|
|
125
|
+
if (!tracker || !branch) return null;
|
|
126
|
+
try {
|
|
127
|
+
const existing = tracker.listStreams();
|
|
128
|
+
for (const stream of existing) {
|
|
129
|
+
let trackedBranch;
|
|
130
|
+
try {
|
|
131
|
+
trackedBranch = tracker.getStreamBranchName(stream.id);
|
|
132
|
+
} catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (trackedBranch === branch) return stream.id;
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
log.warn("findStreamByBranch failed", { branch, error: err.message });
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Ensure a stream exists for the given branch (idempotent).
|
|
145
|
+
*
|
|
146
|
+
* If a stream already tracks `branch`, returns its id with `created: false`.
|
|
147
|
+
* Otherwise calls `trackExistingBranch` to register the branch as a
|
|
148
|
+
* local-mode stream and returns the new id with `created: true`.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} tracker A MultiAgentRepoTracker (from openCascadeTracker)
|
|
151
|
+
* @param {object} opts
|
|
152
|
+
* @param {string} opts.branch Branch name to track
|
|
153
|
+
* @param {string} opts.agentId Owning agent id
|
|
154
|
+
* @param {string} [opts.parentStream] Parent stream id, if forked
|
|
155
|
+
* @returns {{ streamId: string, created: boolean } | null} Null on failure.
|
|
156
|
+
*/
|
|
157
|
+
export function ensureStream(tracker, { branch, agentId, parentStream } = {}) {
|
|
158
|
+
if (!tracker || !branch) return null;
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const existingId = findStreamByBranch(tracker, branch);
|
|
162
|
+
if (existingId) {
|
|
163
|
+
return { streamId: existingId, created: false };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const streamId = tracker.trackExistingBranch({
|
|
167
|
+
branch,
|
|
168
|
+
name: branch,
|
|
169
|
+
agentId,
|
|
170
|
+
parentStream,
|
|
171
|
+
});
|
|
172
|
+
return { streamId, created: true };
|
|
173
|
+
} catch (err) {
|
|
174
|
+
log.warn("ensureStream failed", { branch, error: err.message });
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Record an observed commit as a git-cascade change.
|
|
181
|
+
*
|
|
182
|
+
* Calls `changes.createChange` against the tracker's DB. createChange does NOT
|
|
183
|
+
* emit any event — that is intentional: the cascade-watcher emits the
|
|
184
|
+
* `x-cascade/stream.committed` event itself over MAP. This helper exists only
|
|
185
|
+
* to persist the change row and hand back the git-cascade change id so the
|
|
186
|
+
* watcher can stamp `change_id` on the emitted event.
|
|
187
|
+
*
|
|
188
|
+
* @param {object} tracker A MultiAgentRepoTracker (from openCascadeTracker)
|
|
189
|
+
* @param {object} opts
|
|
190
|
+
* @param {string} opts.streamId Stream that received the commit
|
|
191
|
+
* @param {string} opts.commit Commit SHA
|
|
192
|
+
* @param {string} opts.description Change description (commit summary)
|
|
193
|
+
* @returns {Promise<string|null>} The git-cascade change id, or null on failure.
|
|
194
|
+
*/
|
|
195
|
+
export async function recordObservedCommit(tracker, { streamId, commit, description } = {}) {
|
|
196
|
+
if (!tracker || !tracker.db || !streamId || !commit) return null;
|
|
197
|
+
try {
|
|
198
|
+
const mod = await getGitCascadeMod();
|
|
199
|
+
if (!mod?.changes?.createChange) return null;
|
|
200
|
+
return mod.changes.createChange(tracker.db, {
|
|
201
|
+
streamId,
|
|
202
|
+
commit,
|
|
203
|
+
description: description || "",
|
|
204
|
+
});
|
|
205
|
+
} catch (err) {
|
|
206
|
+
log.warn("recordObservedCommit failed", { streamId, commit, error: err.message });
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Record an observed merge as a git-cascade stream merge edge.
|
|
213
|
+
*
|
|
214
|
+
* Calls `streams.recordMerge` against the tracker's DB. Like
|
|
215
|
+
* `recordObservedCommit`, this only persists the merge row — the
|
|
216
|
+
* cascade-watcher emits the `x-cascade/stream.merged` event itself.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} tracker A MultiAgentRepoTracker (from openCascadeTracker)
|
|
219
|
+
* @param {object} opts
|
|
220
|
+
* @param {string} opts.sourceStreamId Stream merged FROM
|
|
221
|
+
* @param {string} opts.sourceCommit Commit SHA in the source stream
|
|
222
|
+
* @param {string} opts.targetStreamId Stream merged INTO
|
|
223
|
+
* @param {string} opts.mergeCommit Resulting merge commit SHA
|
|
224
|
+
* @param {object} [opts.metadata] Free-form metadata
|
|
225
|
+
* @returns {Promise<string|null>} The merge record id, or null on failure.
|
|
226
|
+
*/
|
|
227
|
+
export async function recordObservedMerge(tracker, { sourceStreamId, sourceCommit, targetStreamId, mergeCommit, metadata } = {}) {
|
|
228
|
+
if (!tracker || !tracker.db || !sourceStreamId || !targetStreamId || !mergeCommit) return null;
|
|
229
|
+
try {
|
|
230
|
+
const mod = await getGitCascadeMod();
|
|
231
|
+
if (!mod?.streams?.recordMerge) return null;
|
|
232
|
+
return mod.streams.recordMerge(tracker.db, {
|
|
233
|
+
sourceStreamId,
|
|
234
|
+
sourceCommit: sourceCommit || "",
|
|
235
|
+
targetStreamId,
|
|
236
|
+
mergeCommit,
|
|
237
|
+
metadata: metadata || {},
|
|
238
|
+
});
|
|
239
|
+
} catch (err) {
|
|
240
|
+
log.warn("recordObservedMerge failed", { sourceStreamId, targetStreamId, mergeCommit, error: err.message });
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Record an observed in-progress merge conflict as a git-cascade conflict row.
|
|
247
|
+
*
|
|
248
|
+
* Calls `conflicts.createConflict` against the tracker's DB so the watcher has
|
|
249
|
+
* a stable `conflict_id` (cf-xxx) to stamp on emitted events. The function
|
|
250
|
+
* mirrors the `recordObserved*` style — no event emission (the watcher emits),
|
|
251
|
+
* try/catch, returns null on any failure so cascade can never crash the
|
|
252
|
+
* sidecar.
|
|
253
|
+
*
|
|
254
|
+
* @param {object} tracker A MultiAgentRepoTracker (from openCascadeTracker)
|
|
255
|
+
* @param {object} opts
|
|
256
|
+
* @param {string} opts.streamId Stream that became conflicted
|
|
257
|
+
* @param {string} opts.conflictingCommit Commit being applied (MERGE_HEAD)
|
|
258
|
+
* @param {string} opts.targetCommit Commit being applied onto (HEAD)
|
|
259
|
+
* @param {string[]} opts.conflictedFiles Files reported by git as conflicted
|
|
260
|
+
* @returns {Promise<string|null>} The git-cascade conflict id, or null on failure.
|
|
261
|
+
*/
|
|
262
|
+
export async function recordObservedConflict(tracker, { streamId, conflictingCommit, targetCommit, conflictedFiles } = {}) {
|
|
263
|
+
if (!tracker || !tracker.db || !streamId || !conflictingCommit) return null;
|
|
264
|
+
try {
|
|
265
|
+
const mod = await getGitCascadeMod();
|
|
266
|
+
if (!mod?.conflicts?.createConflict) return null;
|
|
267
|
+
return mod.conflicts.createConflict(tracker.db, {
|
|
268
|
+
streamId,
|
|
269
|
+
conflictingCommit,
|
|
270
|
+
targetCommit: targetCommit || "",
|
|
271
|
+
conflictedFiles: Array.isArray(conflictedFiles) ? conflictedFiles : [],
|
|
272
|
+
});
|
|
273
|
+
} catch (err) {
|
|
274
|
+
log.warn("recordObservedConflict failed", { streamId, conflictingCommit, error: err.message });
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Record the resolution (or abandonment) of an observed conflict.
|
|
281
|
+
*
|
|
282
|
+
* For methods `"manual"` / `"agent"` / `"ours"` / `"theirs"` this calls
|
|
283
|
+
* `conflicts.resolveConflict`. For `"abandoned"` (the merge was aborted) it
|
|
284
|
+
* calls `conflicts.abandonConflict`, since `ConflictResolution.method` does
|
|
285
|
+
* not include `"abandoned"` in git-cascade's type union.
|
|
286
|
+
*
|
|
287
|
+
* Best-effort — returns false on any failure, never throws.
|
|
288
|
+
*
|
|
289
|
+
* @param {object} tracker A MultiAgentRepoTracker (from openCascadeTracker)
|
|
290
|
+
* @param {object} opts
|
|
291
|
+
* @param {string} opts.conflictId Conflict record id (cf-xxx) returned by
|
|
292
|
+
* `recordObservedConflict`
|
|
293
|
+
* @param {string} opts.method "manual" | "agent" | "abandoned" | "ours" | "theirs"
|
|
294
|
+
* @param {string} [opts.resolvedBy] Agent id or "human"
|
|
295
|
+
* @param {string} [opts.summary] Optional free-form resolution summary
|
|
296
|
+
* @returns {Promise<boolean>} True on success, false on any failure.
|
|
297
|
+
*/
|
|
298
|
+
export async function recordObservedConflictResolved(tracker, { conflictId, method, resolvedBy, summary } = {}) {
|
|
299
|
+
if (!tracker || !tracker.db || !conflictId) return false;
|
|
300
|
+
try {
|
|
301
|
+
const mod = await getGitCascadeMod();
|
|
302
|
+
if (!mod?.conflicts) return false;
|
|
303
|
+
if (method === "abandoned") {
|
|
304
|
+
if (typeof mod.conflicts.abandonConflict !== "function") return false;
|
|
305
|
+
mod.conflicts.abandonConflict(tracker.db, conflictId);
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
if (typeof mod.conflicts.resolveConflict !== "function") return false;
|
|
309
|
+
mod.conflicts.resolveConflict(tracker.db, conflictId, {
|
|
310
|
+
method: method || "manual",
|
|
311
|
+
resolvedBy: resolvedBy || "human",
|
|
312
|
+
...(summary ? { details: summary } : {}),
|
|
313
|
+
});
|
|
314
|
+
return true;
|
|
315
|
+
} catch (err) {
|
|
316
|
+
log.warn("recordObservedConflictResolved failed", { conflictId, method, error: err.message });
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Safely close a cascade tracker. No-op when `tracker` is falsy. Never throws.
|
|
323
|
+
*
|
|
324
|
+
* @param {object|null} tracker
|
|
325
|
+
*/
|
|
326
|
+
export function closeCascadeTracker(tracker) {
|
|
327
|
+
if (!tracker) return;
|
|
328
|
+
try {
|
|
329
|
+
tracker.close();
|
|
330
|
+
log.debug("cascade tracker closed");
|
|
331
|
+
} catch (err) {
|
|
332
|
+
log.warn("failed to close cascade tracker", { error: err.message });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cascade-diff-server.mjs — services `cascade/diff.request` notifications.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3 of cascade integration. The OpenHive hub fetches unified diffs from
|
|
5
|
+
* the cc-swarm sidecar on demand so its cascade changelog/diff endpoints work
|
|
6
|
+
* for cc-swarm-tracked streams. Flow:
|
|
7
|
+
*
|
|
8
|
+
* 1. Hub sends `cascade/diff.request` with `request_id`, `stream_id`,
|
|
9
|
+
* `head`, optional `base` / `file_paths` / `files_only`.
|
|
10
|
+
* 2. This handler shells out to plain `git show` / `git diff` in the repo
|
|
11
|
+
* cwd and produces a unified-diff blob (or a name-only list when
|
|
12
|
+
* `files_only: true`).
|
|
13
|
+
* 3. Response is emitted as a `cascade/diff.response` notification —
|
|
14
|
+
* inline when ≤ 512 KB, streamed via N `cascade/diff.chunk`
|
|
15
|
+
* notifications when larger.
|
|
16
|
+
*
|
|
17
|
+
* cc-swarm runs git-cascade in **local mode** — there are no worktrees. The
|
|
18
|
+
* `head` / `base` in the request are commit hashes and self-identifying, so
|
|
19
|
+
* stream → diff resolution is just `git` against the single repo cwd; the
|
|
20
|
+
* `stream_id` is validated against the tracker but never used to pick a path.
|
|
21
|
+
*
|
|
22
|
+
* The 50 MB raw cap (`MAX_DIFF_BYTES`) defends against runaway monorepo
|
|
23
|
+
* diffs. Errors fold into the same `cascade/diff.response` method via the
|
|
24
|
+
* `error` shape — this handler never throws.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { spawn } from "child_process";
|
|
28
|
+
import { createHash, randomBytes } from "crypto";
|
|
29
|
+
import { createLogger } from "./log.mjs";
|
|
30
|
+
|
|
31
|
+
const log = createLogger("cascade-diff");
|
|
32
|
+
|
|
33
|
+
const REQUEST_METHOD = "cascade/diff.request";
|
|
34
|
+
const RESPONSE_METHOD = "cascade/diff.response";
|
|
35
|
+
const CHUNK_METHOD = "cascade/diff.chunk";
|
|
36
|
+
|
|
37
|
+
/** Reply inline when the raw diff is at or below this size. */
|
|
38
|
+
const INLINE_THRESHOLD_BYTES = 512 * 1024;
|
|
39
|
+
/** Per-chunk payload size for streamed diffs (mirrors sync-client STREAM_CHUNK_SIZE). */
|
|
40
|
+
const CHUNK_SIZE_BYTES = 1024 * 1024;
|
|
41
|
+
/** Hard cap on captured git stdout — defends against runaway monorepo diffs. */
|
|
42
|
+
const MAX_DIFF_BYTES = 50 * 1024 * 1024;
|
|
43
|
+
/** Kill the git spawn after this long. */
|
|
44
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Register the `cascade/diff.request` handler on a MAP connection.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} connection MAP AgentConnection — must expose
|
|
50
|
+
* `onNotification(method, handler)` and `sendNotification(method, params)`.
|
|
51
|
+
* @param {object} opts
|
|
52
|
+
* @param {string} opts.repoPath Path to the git repository (the swarm cwd).
|
|
53
|
+
* @param {object} [opts.tracker] git-cascade tracker — used only to validate
|
|
54
|
+
* that `stream_id` is known; diff resolution never needs it.
|
|
55
|
+
* @returns {Function} A dispose function — best-effort, never throws.
|
|
56
|
+
*/
|
|
57
|
+
export function setupCascadeDiffServer(connection, { repoPath, tracker } = {}) {
|
|
58
|
+
if (!connection || typeof connection.onNotification !== "function") {
|
|
59
|
+
log.debug("cascade diff server skipped: no MAP connection");
|
|
60
|
+
return () => {};
|
|
61
|
+
}
|
|
62
|
+
if (typeof connection.sendNotification !== "function") {
|
|
63
|
+
log.debug("cascade diff server skipped: connection cannot sendNotification");
|
|
64
|
+
return () => {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const handler = async (params) => {
|
|
68
|
+
const req = params || null;
|
|
69
|
+
if (!req || typeof req.request_id !== "string" || !req.request_id) return;
|
|
70
|
+
if (!req.stream_id || !req.head) {
|
|
71
|
+
sendError(connection, req.request_id, "bad_request", "missing stream_id or head");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
log.info("cascade diff request", {
|
|
76
|
+
requestId: req.request_id,
|
|
77
|
+
streamId: req.stream_id,
|
|
78
|
+
head: req.head,
|
|
79
|
+
hasBase: Boolean(req.base),
|
|
80
|
+
filesOnly: req.files_only === true,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Best-effort stream validation. The tracker only carries local-mode
|
|
84
|
+
// streams; an unknown stream id still resolves fine via git (head/base
|
|
85
|
+
// are commit hashes), so a missing stream is a warning, not an error.
|
|
86
|
+
if (tracker && typeof tracker.getStreamBranchName === "function") {
|
|
87
|
+
try {
|
|
88
|
+
tracker.getStreamBranchName(req.stream_id);
|
|
89
|
+
} catch {
|
|
90
|
+
log.debug("cascade diff: stream not tracked, resolving by commit hash", {
|
|
91
|
+
streamId: req.stream_id,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let produced;
|
|
97
|
+
try {
|
|
98
|
+
produced = await runGit({
|
|
99
|
+
repoPath,
|
|
100
|
+
head: req.head,
|
|
101
|
+
base: req.base,
|
|
102
|
+
filePaths: Array.isArray(req.file_paths) ? req.file_paths : undefined,
|
|
103
|
+
filesOnly: req.files_only === true,
|
|
104
|
+
});
|
|
105
|
+
} catch (err) {
|
|
106
|
+
log.warn("cascade diff: git failed", { requestId: req.request_id, error: err.message });
|
|
107
|
+
sendError(connection, req.request_id, "internal", `git failed: ${err.message}`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
if (produced.blob.length <= INLINE_THRESHOLD_BYTES) {
|
|
113
|
+
await connection.sendNotification(RESPONSE_METHOD, {
|
|
114
|
+
request_id: req.request_id,
|
|
115
|
+
streaming: false,
|
|
116
|
+
diff: produced.blob.toString("utf-8"),
|
|
117
|
+
files_touched: produced.filesTouched,
|
|
118
|
+
truncated: produced.truncated,
|
|
119
|
+
});
|
|
120
|
+
log.info("cascade diff response sent (inline)", {
|
|
121
|
+
requestId: req.request_id,
|
|
122
|
+
size: produced.blob.length,
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
await streamLargeBlob(connection, req.request_id, produced);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
log.warn("cascade diff: send failed", { requestId: req.request_id, error: err.message });
|
|
129
|
+
sendError(connection, req.request_id, "internal", `send failed: ${err.message}`);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
connection.onNotification(REQUEST_METHOD, handler);
|
|
134
|
+
log.info("cascade diff server registered", { repoPath });
|
|
135
|
+
|
|
136
|
+
return () => {
|
|
137
|
+
try {
|
|
138
|
+
if (typeof connection.offNotification === "function") {
|
|
139
|
+
connection.offNotification(REQUEST_METHOD, handler);
|
|
140
|
+
}
|
|
141
|
+
} catch { /* non-fatal */ }
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Git shell-out ────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolve a diff via plain git in the repo cwd.
|
|
149
|
+
*
|
|
150
|
+
* `head` / `base` are commit hashes — self-identifying, no worktree needed:
|
|
151
|
+
* - Single commit (no base): `git show <head>`.
|
|
152
|
+
* - Range (base present): `git diff <base>..<head>`.
|
|
153
|
+
* `files_only` swaps in `--name-only`. `files_touched` is always computed via
|
|
154
|
+
* a separate `--name-only` invocation so it is correct even for full diffs.
|
|
155
|
+
*/
|
|
156
|
+
async function runGit({ repoPath, head, base, filePaths, filesOnly }) {
|
|
157
|
+
const diffArgs = buildGitArgs({ head, base, filePaths, filesOnly });
|
|
158
|
+
const captured = await spawnCapped(repoPath, diffArgs);
|
|
159
|
+
|
|
160
|
+
// files_touched: always compute via --name-only, independent of filesOnly.
|
|
161
|
+
let filesTouched;
|
|
162
|
+
if (filesOnly) {
|
|
163
|
+
filesTouched = parseNameOnly(captured.data);
|
|
164
|
+
} else {
|
|
165
|
+
const nameArgs = buildGitArgs({ head, base, filePaths, filesOnly: true });
|
|
166
|
+
try {
|
|
167
|
+
const names = await spawnCapped(repoPath, nameArgs);
|
|
168
|
+
filesTouched = parseNameOnly(names.data);
|
|
169
|
+
} catch {
|
|
170
|
+
filesTouched = [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
blob: filesOnly ? Buffer.from("") : captured.data,
|
|
176
|
+
filesTouched,
|
|
177
|
+
truncated: captured.truncated,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Build the git argv for a diff / name-only request. */
|
|
182
|
+
function buildGitArgs({ head, base, filePaths, filesOnly }) {
|
|
183
|
+
const paths = Array.isArray(filePaths) && filePaths.length > 0
|
|
184
|
+
? ["--", ...filePaths]
|
|
185
|
+
: [];
|
|
186
|
+
if (filesOnly) {
|
|
187
|
+
if (base) {
|
|
188
|
+
return ["diff", "--name-only", `${base}..${head}`, ...paths];
|
|
189
|
+
}
|
|
190
|
+
return ["show", "--name-only", "--format=", head, ...paths];
|
|
191
|
+
}
|
|
192
|
+
if (base) {
|
|
193
|
+
return ["diff", "--no-textconv", "-U3", `${base}..${head}`, ...paths];
|
|
194
|
+
}
|
|
195
|
+
return ["show", "--no-textconv", "-U3", "--format=", head, ...paths];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Spawn git, capture stdout up to MAX_DIFF_BYTES. Beyond that, drop the rest
|
|
200
|
+
* and mark `truncated`. Kills after GIT_TIMEOUT_MS. Resolves on overflow /
|
|
201
|
+
* timeout; rejects only on spawn error or non-zero exit.
|
|
202
|
+
*/
|
|
203
|
+
function spawnCapped(cwd, args) {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
let proc;
|
|
206
|
+
try {
|
|
207
|
+
proc = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
208
|
+
} catch (err) {
|
|
209
|
+
reject(err);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const parts = [];
|
|
214
|
+
let total = 0;
|
|
215
|
+
let truncated = false;
|
|
216
|
+
let stderrBuf = "";
|
|
217
|
+
let settled = false;
|
|
218
|
+
|
|
219
|
+
const killTimer = setTimeout(() => {
|
|
220
|
+
truncated = true;
|
|
221
|
+
try { proc.kill("SIGKILL"); } catch { /* nothing to kill */ }
|
|
222
|
+
}, GIT_TIMEOUT_MS);
|
|
223
|
+
|
|
224
|
+
proc.stdout.on("data", (chunk) => {
|
|
225
|
+
if (truncated) return;
|
|
226
|
+
if (total + chunk.length > MAX_DIFF_BYTES) {
|
|
227
|
+
const remaining = MAX_DIFF_BYTES - total;
|
|
228
|
+
if (remaining > 0) parts.push(chunk.subarray(0, remaining));
|
|
229
|
+
total = MAX_DIFF_BYTES;
|
|
230
|
+
truncated = true;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
parts.push(chunk);
|
|
234
|
+
total += chunk.length;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
proc.stderr.on("data", (chunk) => {
|
|
238
|
+
if (stderrBuf.length < 8192) stderrBuf += chunk.toString("utf-8");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
proc.on("error", (err) => {
|
|
242
|
+
if (settled) return;
|
|
243
|
+
settled = true;
|
|
244
|
+
clearTimeout(killTimer);
|
|
245
|
+
reject(err);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
proc.on("close", (code) => {
|
|
249
|
+
if (settled) return;
|
|
250
|
+
settled = true;
|
|
251
|
+
clearTimeout(killTimer);
|
|
252
|
+
if (code !== 0 && !truncated) {
|
|
253
|
+
reject(new Error(`git ${args.join(" ")} exited ${code}: ${stderrBuf.trim().slice(0, 200)}`));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
resolve({ data: Buffer.concat(parts), truncated });
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Parse `--name-only` output into a deduped list of file paths. */
|
|
262
|
+
function parseNameOnly(buf) {
|
|
263
|
+
const seen = new Set();
|
|
264
|
+
for (const line of buf.toString("utf-8").split("\n")) {
|
|
265
|
+
const trimmed = line.trim();
|
|
266
|
+
if (trimmed) seen.add(trimmed);
|
|
267
|
+
}
|
|
268
|
+
return Array.from(seen);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Streaming ────────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Send a large diff as a streaming announcement followed by base64 chunks.
|
|
275
|
+
* The last chunk carries `final: true` + a sha256 over the full payload.
|
|
276
|
+
*/
|
|
277
|
+
async function streamLargeBlob(connection, requestId, produced) {
|
|
278
|
+
const { blob, filesTouched, truncated } = produced;
|
|
279
|
+
// A random nonce keeps two sidecars routing through the same hub from
|
|
280
|
+
// colliding on the hub-side chunk-stream map even on a retried request_id.
|
|
281
|
+
const chunkStreamId = `cdiff-${requestId}-${randomBytes(6).toString("hex")}`;
|
|
282
|
+
|
|
283
|
+
await connection.sendNotification(RESPONSE_METHOD, {
|
|
284
|
+
request_id: requestId,
|
|
285
|
+
streaming: true,
|
|
286
|
+
chunk_stream_id: chunkStreamId,
|
|
287
|
+
total_size: blob.length,
|
|
288
|
+
files_touched: filesTouched,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const sha = createHash("sha256").update(blob).digest("hex");
|
|
292
|
+
const totalChunks = Math.max(1, Math.ceil(blob.length / CHUNK_SIZE_BYTES));
|
|
293
|
+
|
|
294
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
295
|
+
const start = i * CHUNK_SIZE_BYTES;
|
|
296
|
+
const end = Math.min(start + CHUNK_SIZE_BYTES, blob.length);
|
|
297
|
+
const slice = blob.subarray(start, end);
|
|
298
|
+
const isFinal = i === totalChunks - 1;
|
|
299
|
+
await connection.sendNotification(CHUNK_METHOD, {
|
|
300
|
+
chunk_stream_id: chunkStreamId,
|
|
301
|
+
seq: i,
|
|
302
|
+
data: slice.toString("base64"),
|
|
303
|
+
...(isFinal ? { final: true, sha256: sha, truncated } : {}),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
log.info("cascade diff response sent (streamed)", {
|
|
308
|
+
requestId,
|
|
309
|
+
size: blob.length,
|
|
310
|
+
chunks: totalChunks,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Reply with the typed error variant of `cascade/diff.response`. Never throws. */
|
|
315
|
+
function sendError(connection, requestId, code, message) {
|
|
316
|
+
try {
|
|
317
|
+
Promise.resolve(
|
|
318
|
+
connection.sendNotification(RESPONSE_METHOD, {
|
|
319
|
+
request_id: requestId,
|
|
320
|
+
error: { code, message },
|
|
321
|
+
}),
|
|
322
|
+
).catch((err) => log.warn("cascade diff: error reply failed", { requestId, error: err.message }));
|
|
323
|
+
} catch (err) {
|
|
324
|
+
log.warn("cascade diff: error reply threw", { requestId, error: err.message });
|
|
325
|
+
}
|
|
326
|
+
}
|