claude-code-swarm 0.3.25 → 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 +15 -2
- package/renovate.json5 +6 -0
- package/scripts/map-hook.mjs +88 -7
- package/scripts/map-sidecar.mjs +210 -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/__tests__/loadout-schema-bridge.test.mjs +1 -2
- package/src/__tests__/sidecar-nudge.test.mjs +137 -0
- package/src/bootstrap.mjs +20 -9
- 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/map-events.mjs +8 -1
- package/src/paths.mjs +12 -0
- package/src/sidecar-server.mjs +62 -0
- package/src/skilltree-client.mjs +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cascade-events.mjs — x-cascade/* event builders + emit for claude-code-swarm
|
|
3
|
+
*
|
|
4
|
+
* cc-swarm emits `x-cascade/*` notifications itself over the MAP connection
|
|
5
|
+
* (rather than letting git-cascade's `emit` callback drive them). This module
|
|
6
|
+
* builds the snake_case wire payloads and forwards them as MAP extension
|
|
7
|
+
* notifications.
|
|
8
|
+
*
|
|
9
|
+
* Wire shapes match git-cascade's `events/index.d.ts` (StreamOpenedParams etc.)
|
|
10
|
+
* so an OpenHive hub can consume them with no translation.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createLogger } from "./log.mjs";
|
|
14
|
+
|
|
15
|
+
const log = createLogger("cascade-events");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build the `x-cascade/stream.opened` param shape (snake_case wire format).
|
|
19
|
+
*
|
|
20
|
+
* Mirrors git-cascade's `StreamOpenedParams`. Always sets `is_local_mode: true`
|
|
21
|
+
* — cc-swarm only ever registers existing branches as local-mode streams.
|
|
22
|
+
*
|
|
23
|
+
* `parent_stream` carries the fork edge: when the watcher detects a branch
|
|
24
|
+
* forked from a tracked branch it passes the parent's stream id, and the hub's
|
|
25
|
+
* `cascade-handler` writes it as `parent_stream_id` so the PR-stack walker can
|
|
26
|
+
* traverse the stack. Omitted (left undefined) when there is no parent.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} opts
|
|
29
|
+
* @param {string} opts.streamId git-cascade stream id
|
|
30
|
+
* @param {string} opts.name Human-readable stream name
|
|
31
|
+
* @param {string} opts.agentId Owning agent id
|
|
32
|
+
* @param {string} opts.baseCommit Commit the stream was based from
|
|
33
|
+
* @param {string} [opts.branchName] Branch the stream maps to
|
|
34
|
+
* @param {string} [opts.parentStream] Parent stream id, when forked
|
|
35
|
+
* @param {object} [opts.metadata] Free-form caller metadata
|
|
36
|
+
* @returns {object} StreamOpenedParams-shaped object
|
|
37
|
+
*/
|
|
38
|
+
export function buildStreamOpenedParams({ streamId, name, agentId, baseCommit, branchName, parentStream, metadata } = {}) {
|
|
39
|
+
return {
|
|
40
|
+
stream_id: streamId,
|
|
41
|
+
name,
|
|
42
|
+
agent_id: agentId,
|
|
43
|
+
base_commit: baseCommit,
|
|
44
|
+
branch_name: branchName,
|
|
45
|
+
is_local_mode: true,
|
|
46
|
+
...(parentStream ? { parent_stream: parentStream } : {}),
|
|
47
|
+
metadata: metadata || {},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Emit an `x-cascade/stream.opened` notification over a MAP connection.
|
|
53
|
+
*
|
|
54
|
+
* Fire-and-forget: any failure is caught and logged — this never throws, so a
|
|
55
|
+
* missing/dead connection can't crash the sidecar.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} connection A MAP AgentConnection (must expose callExtension)
|
|
58
|
+
* @param {object} params Payload from buildStreamOpenedParams()
|
|
59
|
+
*/
|
|
60
|
+
export function emitStreamOpened(connection, params) {
|
|
61
|
+
emitCascadeEvent(connection, "x-cascade/stream.opened", params);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the `x-cascade/stream.committed` param shape (snake_case wire format).
|
|
66
|
+
*
|
|
67
|
+
* Mirrors git-cascade's `StreamCommittedParams`. The watcher pulls real git
|
|
68
|
+
* data (summary, files, parent) and a git-cascade change id per commit.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} opts
|
|
71
|
+
* @param {string} opts.streamId Stream that received the commit
|
|
72
|
+
* @param {string} opts.commitHash Commit SHA
|
|
73
|
+
* @param {string} opts.changeId git-cascade Change-Id for this change
|
|
74
|
+
* @param {string} opts.agentId Authoring agent id ("" when unattributed)
|
|
75
|
+
* @param {string} opts.messageSummary First line of the commit message
|
|
76
|
+
* @param {string[]} opts.filesTouched Files modified by the commit
|
|
77
|
+
* @param {string} opts.parentCommit Parent commit SHA
|
|
78
|
+
* @param {object} [opts.metadata] Free-form caller metadata
|
|
79
|
+
* @returns {object} StreamCommittedParams-shaped object
|
|
80
|
+
*/
|
|
81
|
+
export function buildStreamCommittedParams({ streamId, commitHash, changeId, agentId, messageSummary, filesTouched, parentCommit, metadata } = {}) {
|
|
82
|
+
return {
|
|
83
|
+
stream_id: streamId,
|
|
84
|
+
commit_hash: commitHash,
|
|
85
|
+
change_id: changeId || "",
|
|
86
|
+
agent_id: agentId || "",
|
|
87
|
+
message_summary: messageSummary || "",
|
|
88
|
+
files_touched: Array.isArray(filesTouched) ? filesTouched : [],
|
|
89
|
+
parent_commit: parentCommit || "",
|
|
90
|
+
metadata: metadata || {},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Emit an `x-cascade/stream.committed` notification over a MAP connection.
|
|
96
|
+
* Fire-and-forget — never throws.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} connection A MAP AgentConnection (must expose callExtension)
|
|
99
|
+
* @param {object} params Payload from buildStreamCommittedParams()
|
|
100
|
+
*/
|
|
101
|
+
export function emitStreamCommitted(connection, params) {
|
|
102
|
+
emitCascadeEvent(connection, "x-cascade/stream.committed", params);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build the `x-cascade/stream.merged` param shape (snake_case wire format).
|
|
107
|
+
*
|
|
108
|
+
* Mirrors git-cascade's `StreamMergedParams`. `source_stream_id` is best-effort
|
|
109
|
+
* — the watcher resolves the source branch by the 2nd parent commit and it may
|
|
110
|
+
* be empty when the source branch was already deleted.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} opts
|
|
113
|
+
* @param {string} opts.sourceStreamId Stream merged FROM ("" when unresolved)
|
|
114
|
+
* @param {string} opts.targetStreamId Stream merged INTO
|
|
115
|
+
* @param {string} opts.mergeCommit Resulting merge commit SHA
|
|
116
|
+
* @param {string} opts.agentId Agent that performed the merge
|
|
117
|
+
* @param {string} [opts.sourceCommit] Head of the source at merge time
|
|
118
|
+
* @param {string} [opts.strategy] Merge strategy label
|
|
119
|
+
* @param {object} [opts.metadata] Free-form caller metadata
|
|
120
|
+
* @returns {object} StreamMergedParams-shaped object
|
|
121
|
+
*/
|
|
122
|
+
export function buildStreamMergedParams({ sourceStreamId, targetStreamId, mergeCommit, agentId, sourceCommit, strategy, metadata } = {}) {
|
|
123
|
+
return {
|
|
124
|
+
source_stream_id: sourceStreamId || "",
|
|
125
|
+
target_stream_id: targetStreamId,
|
|
126
|
+
merge_commit: mergeCommit,
|
|
127
|
+
agent_id: agentId || "",
|
|
128
|
+
source_commit: sourceCommit || "",
|
|
129
|
+
strategy: strategy || "merge-commit",
|
|
130
|
+
metadata: metadata || {},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Emit an `x-cascade/stream.merged` notification over a MAP connection.
|
|
136
|
+
* Fire-and-forget — never throws.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} connection A MAP AgentConnection (must expose callExtension)
|
|
139
|
+
* @param {object} params Payload from buildStreamMergedParams()
|
|
140
|
+
*/
|
|
141
|
+
export function emitStreamMerged(connection, params) {
|
|
142
|
+
emitCascadeEvent(connection, "x-cascade/stream.merged", params);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build the `x-cascade/stream.pushed` param shape (snake_case wire format).
|
|
147
|
+
*
|
|
148
|
+
* Mirrors git-cascade's `StreamPushedParams`.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} opts
|
|
151
|
+
* @param {string} opts.streamId Stream whose head was pushed
|
|
152
|
+
* @param {string} opts.agentId Agent that did the push
|
|
153
|
+
* @param {string} opts.pushedCommit Commit SHA at the head when pushed
|
|
154
|
+
* @param {string} opts.remote Remote name (e.g. 'origin')
|
|
155
|
+
* @param {string} opts.remoteRef Remote ref pushed to
|
|
156
|
+
* @param {object} [opts.metadata] Free-form caller metadata
|
|
157
|
+
* @returns {object} StreamPushedParams-shaped object
|
|
158
|
+
*/
|
|
159
|
+
export function buildStreamPushedParams({ streamId, agentId, pushedCommit, remote, remoteRef, metadata } = {}) {
|
|
160
|
+
return {
|
|
161
|
+
stream_id: streamId,
|
|
162
|
+
agent_id: agentId || "",
|
|
163
|
+
pushed_commit: pushedCommit,
|
|
164
|
+
remote: remote || "origin",
|
|
165
|
+
remote_ref: remoteRef || "",
|
|
166
|
+
metadata: metadata || {},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Emit an `x-cascade/stream.pushed` notification over a MAP connection.
|
|
172
|
+
* Fire-and-forget — never throws.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} connection A MAP AgentConnection (must expose callExtension)
|
|
175
|
+
* @param {object} params Payload from buildStreamPushedParams()
|
|
176
|
+
*/
|
|
177
|
+
export function emitStreamPushed(connection, params) {
|
|
178
|
+
emitCascadeEvent(connection, "x-cascade/stream.pushed", params);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Build the `x-cascade/stream.conflicted` param shape (snake_case wire format).
|
|
183
|
+
*
|
|
184
|
+
* Mirrors git-cascade's `StreamConflictedParams`. cc-swarm emits this on the
|
|
185
|
+
* transition where a tracked stream enters an in-progress merge state (i.e.
|
|
186
|
+
* the watcher observes `.git/MERGE_HEAD` appear). Rebase conflicts are out of
|
|
187
|
+
* scope for v1.
|
|
188
|
+
*
|
|
189
|
+
* @param {object} opts
|
|
190
|
+
* @param {string} opts.streamId Stream that became conflicted
|
|
191
|
+
* @param {string} [opts.conflictId] Persisted conflict record id (cf-xxx)
|
|
192
|
+
* @param {string[]} opts.conflictedFiles Files reported as conflicted
|
|
193
|
+
* @param {string} [opts.agentId] Agent that triggered the conflicting op
|
|
194
|
+
* @param {string} [opts.conflictingCommit] Commit being applied (e.g. MERGE_HEAD)
|
|
195
|
+
* @param {string} [opts.targetCommit] Commit being applied onto (HEAD)
|
|
196
|
+
* @param {string} [opts.source] Operation flavor: "merge" | "rebase" | ...
|
|
197
|
+
* @param {object} [opts.metadata] Free-form caller metadata
|
|
198
|
+
* @returns {object} StreamConflictedParams-shaped object
|
|
199
|
+
*/
|
|
200
|
+
export function buildStreamConflictedParams({ streamId, conflictId, conflictedFiles, agentId, conflictingCommit, targetCommit, source, metadata } = {}) {
|
|
201
|
+
return {
|
|
202
|
+
stream_id: streamId,
|
|
203
|
+
...(conflictId ? { conflict_id: conflictId } : {}),
|
|
204
|
+
conflicted_files: Array.isArray(conflictedFiles) ? conflictedFiles : [],
|
|
205
|
+
agent_id: agentId || "",
|
|
206
|
+
conflicting_commit: conflictingCommit || "",
|
|
207
|
+
target_commit: targetCommit || "",
|
|
208
|
+
source: source || "merge",
|
|
209
|
+
metadata: metadata || {},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Emit an `x-cascade/stream.conflicted` notification over a MAP connection.
|
|
215
|
+
* Fire-and-forget — never throws.
|
|
216
|
+
*
|
|
217
|
+
* @param {object} connection A MAP AgentConnection (must expose callExtension)
|
|
218
|
+
* @param {object} params Payload from buildStreamConflictedParams()
|
|
219
|
+
*/
|
|
220
|
+
export function emitStreamConflicted(connection, params) {
|
|
221
|
+
emitCascadeEvent(connection, "x-cascade/stream.conflicted", params);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Build the `x-cascade/stream.conflict_resolved` param shape (snake_case wire).
|
|
226
|
+
*
|
|
227
|
+
* Mirrors git-cascade's `StreamConflictResolvedParams`. cc-swarm emits this on
|
|
228
|
+
* the transition where the in-progress merge state goes away: either HEAD
|
|
229
|
+
* advanced to a merge commit (`manual` / `agent` resolution) or HEAD is
|
|
230
|
+
* unchanged (the merge was aborted — `abandoned`).
|
|
231
|
+
*
|
|
232
|
+
* @param {object} opts
|
|
233
|
+
* @param {string} opts.streamId Stream whose conflict was resolved
|
|
234
|
+
* @param {string} opts.conflictId Conflict record id that was resolved
|
|
235
|
+
* @param {string} opts.resolutionMethod "manual" | "agent" | "abandoned" | ...
|
|
236
|
+
* @param {string} [opts.resolvedBy] Agent or human that resolved it
|
|
237
|
+
* @param {string} [opts.resolutionSummary] Optional human-readable summary
|
|
238
|
+
* @param {object} [opts.metadata] Free-form caller metadata
|
|
239
|
+
* @returns {object} StreamConflictResolvedParams-shaped object
|
|
240
|
+
*/
|
|
241
|
+
export function buildStreamConflictResolvedParams({ streamId, conflictId, resolutionMethod, resolvedBy, resolutionSummary, metadata } = {}) {
|
|
242
|
+
return {
|
|
243
|
+
stream_id: streamId,
|
|
244
|
+
conflict_id: conflictId || "",
|
|
245
|
+
resolution_method: resolutionMethod || "manual",
|
|
246
|
+
...(resolvedBy ? { resolved_by: resolvedBy } : {}),
|
|
247
|
+
...(resolutionSummary ? { resolution_summary: resolutionSummary } : {}),
|
|
248
|
+
metadata: metadata || {},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Emit an `x-cascade/stream.conflict_resolved` notification over a MAP connection.
|
|
254
|
+
* Fire-and-forget — never throws.
|
|
255
|
+
*
|
|
256
|
+
* @param {object} connection A MAP AgentConnection (must expose callExtension)
|
|
257
|
+
* @param {object} params Payload from buildStreamConflictResolvedParams()
|
|
258
|
+
*/
|
|
259
|
+
export function emitStreamConflictResolved(connection, params) {
|
|
260
|
+
emitCascadeEvent(connection, "x-cascade/stream.conflict_resolved", params);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Shared fire-and-forget emit for all `x-cascade/*` notifications.
|
|
265
|
+
*
|
|
266
|
+
* Any failure (no connection, callExtension throws, promise rejects) is caught
|
|
267
|
+
* and logged. This never throws — a missing/dead connection or a misbehaving
|
|
268
|
+
* hub can't crash the sidecar.
|
|
269
|
+
*
|
|
270
|
+
* @param {object} connection A MAP AgentConnection (must expose callExtension)
|
|
271
|
+
* @param {string} method Full `x-cascade/*` method name
|
|
272
|
+
* @param {object} params Snake_case wire payload
|
|
273
|
+
*/
|
|
274
|
+
function emitCascadeEvent(connection, method, params) {
|
|
275
|
+
if (!connection || typeof connection.callExtension !== "function") {
|
|
276
|
+
log.debug("skipping cascade emit: no MAP connection", { method });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
Promise.resolve(connection.callExtension(method, params))
|
|
281
|
+
.catch((err) => log.warn("cascade emit failed", { method, error: err.message }));
|
|
282
|
+
} catch (err) {
|
|
283
|
+
log.warn("cascade emit threw", { method, error: err.message });
|
|
284
|
+
}
|
|
285
|
+
}
|