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
package/scripts/map-sidecar.mjs
CHANGED
|
@@ -20,13 +20,17 @@
|
|
|
20
20
|
|
|
21
21
|
import fs from "fs";
|
|
22
22
|
import path from "path";
|
|
23
|
-
import { SOCKET_PATH, PID_PATH, INBOX_SOCKET_PATH, sessionPaths, pluginDir } from "../src/paths.mjs";
|
|
23
|
+
import { SOCKET_PATH, PID_PATH, INBOX_SOCKET_PATH, CASCADE_DB_PATH, sessionPaths, pluginDir, ensureCascadeDir } from "../src/paths.mjs";
|
|
24
24
|
import { connectToMAP } from "../src/map-connection.mjs";
|
|
25
25
|
import { createMeshPeer, createMeshInbox } from "../src/mesh-connection.mjs";
|
|
26
26
|
import { createSocketServer, createCommandHandler } from "../src/sidecar-server.mjs";
|
|
27
27
|
import { startOpenTasksEventBridge } from "../src/opentasks-bridge.mjs";
|
|
28
28
|
import { createContentProvider } from "../src/content-provider.mjs";
|
|
29
29
|
import { startMemoryWatcher } from "../src/memory-watcher.mjs";
|
|
30
|
+
import { openCascadeTracker, ensureStream, closeCascadeTracker } from "../src/cascade-client.mjs";
|
|
31
|
+
import { buildStreamOpenedParams, emitStreamOpened } from "../src/cascade-events.mjs";
|
|
32
|
+
import { startCascadeWatcher } from "../src/cascade-watcher.mjs";
|
|
33
|
+
import { setupCascadeDiffServer } from "../src/cascade-diff-server.mjs";
|
|
30
34
|
import { readConfig } from "../src/config.mjs";
|
|
31
35
|
import { createLogger, init as initLog } from "../src/log.mjs";
|
|
32
36
|
import { configureNodePath, resolvePackage } from "../src/swarmkit-resolver.mjs";
|
|
@@ -98,6 +102,16 @@ initLog({ ..._logConfig, sessionId: SESSION_ID || undefined });
|
|
|
98
102
|
const MESH_ENABLED = hasFlag("mesh-enabled");
|
|
99
103
|
const MESH_PEER_ID = getArg("mesh-peer-id", "");
|
|
100
104
|
|
|
105
|
+
// Cascade gate — whether git-cascade integration is enabled. Used to declare
|
|
106
|
+
// the `cascade.canServeDiff` MAP capability conditionally: the diff server
|
|
107
|
+
// (src/cascade-diff-server.mjs) is wired in setupCascade() only when this is
|
|
108
|
+
// true, so declaring the capability without it would invite timed-out
|
|
109
|
+
// cascade/diff.request notifications from the hub.
|
|
110
|
+
let CASCADE_ENABLED = false;
|
|
111
|
+
try {
|
|
112
|
+
CASCADE_ENABLED = Boolean(readConfig().cascade?.enabled);
|
|
113
|
+
} catch { /* config unreadable — leave cascade off */ }
|
|
114
|
+
|
|
101
115
|
// Parse inbox config (passed as JSON blob from sidecar-client)
|
|
102
116
|
let INBOX_CONFIG = null;
|
|
103
117
|
const inboxConfigJson = getArg("inbox-config", "");
|
|
@@ -125,6 +139,9 @@ let inactivityTimer = null;
|
|
|
125
139
|
let reconnectInterval = null;
|
|
126
140
|
let transportMode = "websocket"; // "mesh" or "websocket"
|
|
127
141
|
let opentasksBridge = null; // Daemon watch → MAP event bridge (Option A)
|
|
142
|
+
let cascadeTracker = null; // git-cascade local-mode tracker (Phase 1+)
|
|
143
|
+
let cascadeWatcher = null; // observed-git ref watcher (Phase 2)
|
|
144
|
+
let cascadeDiffServerDispose = null; // cascade/diff.request handler cleanup (Phase 3)
|
|
128
145
|
const registeredAgents = new Map();
|
|
129
146
|
|
|
130
147
|
// ── Inactivity Timer ────────────────────────────────────────────────────────
|
|
@@ -152,6 +169,27 @@ async function shutdown() {
|
|
|
152
169
|
opentasksBridge = null;
|
|
153
170
|
}
|
|
154
171
|
|
|
172
|
+
// Stop the cascade ref watcher before the tracker DB closes — the watcher
|
|
173
|
+
// calls into the tracker. Safe no-op when cascade was never enabled.
|
|
174
|
+
if (cascadeWatcher) {
|
|
175
|
+
try { cascadeWatcher.stop(); } catch { /* ignore */ }
|
|
176
|
+
cascadeWatcher = null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Dispose the cascade diff server (unregisters the cascade/diff.request
|
|
180
|
+
// handler). Safe no-op when cascade was never enabled.
|
|
181
|
+
if (cascadeDiffServerDispose) {
|
|
182
|
+
try { cascadeDiffServerDispose(); } catch { /* ignore */ }
|
|
183
|
+
cascadeDiffServerDispose = null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Close the git-cascade tracker (local state store) before the socket
|
|
187
|
+
// and connection drop. Safe no-op when cascade was never enabled.
|
|
188
|
+
if (cascadeTracker) {
|
|
189
|
+
closeCascadeTracker(cascadeTracker);
|
|
190
|
+
cascadeTracker = null;
|
|
191
|
+
}
|
|
192
|
+
|
|
155
193
|
// Stop agent-inbox first (it borrows the connection/peer, doesn't own it)
|
|
156
194
|
if (inboxInstance) {
|
|
157
195
|
try { await inboxInstance.stop(); } catch { /* ignore */ }
|
|
@@ -234,6 +272,7 @@ function startSlowReconnectLoop() {
|
|
|
234
272
|
credential: AUTH_CREDENTIAL || undefined,
|
|
235
273
|
projectContext: PROJECT_CONTEXT,
|
|
236
274
|
inboxEnabled: !!INBOX_CONFIG || MESH_ENABLED,
|
|
275
|
+
cascadeEnabled: CASCADE_ENABLED,
|
|
237
276
|
onMessage: () => resetInactivityTimer(),
|
|
238
277
|
});
|
|
239
278
|
|
|
@@ -245,6 +284,28 @@ function startSlowReconnectLoop() {
|
|
|
245
284
|
if (commandHandler) commandHandler.setConnection(newConn);
|
|
246
285
|
attachReconnectionListener(newConn);
|
|
247
286
|
|
|
287
|
+
// Point the cascade watcher at the fresh connection and re-assert
|
|
288
|
+
// open streams — idempotent on the hub, covers the reconnect gap.
|
|
289
|
+
if (cascadeWatcher) {
|
|
290
|
+
try {
|
|
291
|
+
cascadeWatcher.setConnection(newConn);
|
|
292
|
+
cascadeWatcher.reassertStreams();
|
|
293
|
+
} catch { /* ignore — cascade must never crash the sidecar */ }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Re-register the cascade diff server on the fresh connection — the
|
|
297
|
+
// previous handler was bound to the dead one. No-op when cascade is
|
|
298
|
+
// disabled (dispose handle is null).
|
|
299
|
+
if (cascadeDiffServerDispose) {
|
|
300
|
+
try {
|
|
301
|
+
cascadeDiffServerDispose();
|
|
302
|
+
cascadeDiffServerDispose = setupCascadeDiffServer(newConn, {
|
|
303
|
+
repoPath: process.cwd(),
|
|
304
|
+
tracker: cascadeTracker,
|
|
305
|
+
});
|
|
306
|
+
} catch { /* ignore — cascade must never crash the sidecar */ }
|
|
307
|
+
}
|
|
308
|
+
|
|
248
309
|
// Re-register active agents so the MAP server knows about them
|
|
249
310
|
await reRegisterAgents(newConn);
|
|
250
311
|
|
|
@@ -425,6 +486,34 @@ function registerContentHandler(conn) {
|
|
|
425
486
|
});
|
|
426
487
|
}
|
|
427
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Register the x-dispatch/nudge notification handler on a connection.
|
|
491
|
+
* When the hub sends a nudge (a dispatch thread received a new turn),
|
|
492
|
+
* the sidecar stores the nudge so the UserPromptSubmit hook can inject
|
|
493
|
+
* a hint about pending messages.
|
|
494
|
+
*/
|
|
495
|
+
function registerNudgeHandler(conn) {
|
|
496
|
+
if (!conn || typeof conn.onNotification !== "function") return;
|
|
497
|
+
|
|
498
|
+
conn.onNotification("x-dispatch/nudge", (params) => {
|
|
499
|
+
const dispatchId = params?.dispatch_id;
|
|
500
|
+
const conversationId = params?.conversation_id;
|
|
501
|
+
if (!dispatchId) return;
|
|
502
|
+
|
|
503
|
+
log.info("dispatch nudge received", { dispatchId, conversationId });
|
|
504
|
+
resetInactivityTimer();
|
|
505
|
+
|
|
506
|
+
// Store the nudge via the command handler's nudge command.
|
|
507
|
+
// Use a fake client since we don't need the response.
|
|
508
|
+
if (commandHandler) {
|
|
509
|
+
commandHandler(
|
|
510
|
+
{ action: "nudge", dispatch_id: dispatchId, conversation_id: conversationId },
|
|
511
|
+
{ write: () => {}, writable: true },
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
428
517
|
async function startWebSocketTransport() {
|
|
429
518
|
connection = await connectToMAP({
|
|
430
519
|
server: MAP_SERVER,
|
|
@@ -433,6 +522,7 @@ async function startWebSocketTransport() {
|
|
|
433
522
|
credential: AUTH_CREDENTIAL || undefined,
|
|
434
523
|
projectContext: PROJECT_CONTEXT,
|
|
435
524
|
inboxEnabled: !!INBOX_CONFIG || MESH_ENABLED,
|
|
525
|
+
cascadeEnabled: CASCADE_ENABLED,
|
|
436
526
|
onMessage: () => {
|
|
437
527
|
resetInactivityTimer();
|
|
438
528
|
},
|
|
@@ -511,6 +601,116 @@ async function startLegacyAgentInbox(mapConnection) {
|
|
|
511
601
|
}
|
|
512
602
|
}
|
|
513
603
|
|
|
604
|
+
// ── Cascade (git-cascade local-mode tracking) ───────────────────────────────
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Determine the current branch and its HEAD commit for `repoPath`.
|
|
608
|
+
* Returns null for either field on any git error.
|
|
609
|
+
*/
|
|
610
|
+
function getRepoHead(repoPath) {
|
|
611
|
+
let branch = null;
|
|
612
|
+
let commit = null;
|
|
613
|
+
try {
|
|
614
|
+
branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
615
|
+
cwd: repoPath, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
|
|
616
|
+
}).trim();
|
|
617
|
+
} catch { /* not a git repo / detached */ }
|
|
618
|
+
try {
|
|
619
|
+
commit = execSync("git rev-parse HEAD", {
|
|
620
|
+
cwd: repoPath, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
|
|
621
|
+
}).trim();
|
|
622
|
+
} catch { /* no commits yet */ }
|
|
623
|
+
return { branch, commit };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Cascade setup: open a git-cascade tracker in local mode, register the
|
|
628
|
+
* working branch as a local-mode stream, emit one `x-cascade/stream.opened`
|
|
629
|
+
* event, and start the Phase 2 observed-git ref watcher.
|
|
630
|
+
*
|
|
631
|
+
* Stores the tracker on `cascadeTracker` and the watcher on `cascadeWatcher`.
|
|
632
|
+
* Fully resilient — any failure is logged and swallowed so cascade can never
|
|
633
|
+
* crash the sidecar.
|
|
634
|
+
*/
|
|
635
|
+
async function setupCascade() {
|
|
636
|
+
try {
|
|
637
|
+
const cfg = readConfig();
|
|
638
|
+
if (!cfg.cascade?.enabled) return;
|
|
639
|
+
|
|
640
|
+
const repoPath = process.cwd();
|
|
641
|
+
const { branch, commit } = getRepoHead(repoPath);
|
|
642
|
+
if (!branch || branch === "HEAD") {
|
|
643
|
+
log.warn("cascade: no current branch, skipping stream registration");
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
ensureCascadeDir();
|
|
648
|
+
cascadeTracker = await openCascadeTracker({ repoPath, dbPath: CASCADE_DB_PATH });
|
|
649
|
+
if (!cascadeTracker) return; // openCascadeTracker already logged the reason
|
|
650
|
+
|
|
651
|
+
// First-emit settle window. AgentConnection.connect() resolves once the
|
|
652
|
+
// SDK handshake completes, but the hub-side session-context attach (which
|
|
653
|
+
// stamps the swarmId on inbound messages) can land a beat later. The live
|
|
654
|
+
// e2e (src/__tests__/cascade/live-cc-swarm-cascade-e2e.test.ts) surfaced
|
|
655
|
+
// that a callExtension fired immediately after connect can race the
|
|
656
|
+
// inbound register and be silently dropped. Give the hub a small window
|
|
657
|
+
// to settle before the first cascade emit. The watcher's
|
|
658
|
+
// reassertStreams() (run on watcher start and on every reconnect) is the
|
|
659
|
+
// belt-and-suspenders recovery; this settle is the cheaper first line of
|
|
660
|
+
// defense. Replace with a proper readiness signal if the MAP SDK ever
|
|
661
|
+
// exposes one.
|
|
662
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
663
|
+
|
|
664
|
+
const teamName = MAP_SCOPE.replace("swarm:", "");
|
|
665
|
+
const agentId = `${teamName}-sidecar`;
|
|
666
|
+
|
|
667
|
+
const result = ensureStream(cascadeTracker, { branch, agentId });
|
|
668
|
+
if (!result) return;
|
|
669
|
+
|
|
670
|
+
if (result.created) {
|
|
671
|
+
log.info("cascade: registered working branch as stream", { branch, streamId: result.streamId });
|
|
672
|
+
const params = buildStreamOpenedParams({
|
|
673
|
+
streamId: result.streamId,
|
|
674
|
+
name: branch,
|
|
675
|
+
agentId,
|
|
676
|
+
baseCommit: commit || "",
|
|
677
|
+
branchName: branch,
|
|
678
|
+
metadata: { trigger: "sidecar-boot" },
|
|
679
|
+
});
|
|
680
|
+
emitStreamOpened(connection, params);
|
|
681
|
+
} else {
|
|
682
|
+
log.debug("cascade: working branch already tracked", { branch, streamId: result.streamId });
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Phase 2: start the observed-git ref watcher. It detects commits/merges/
|
|
686
|
+
// pushes from git ref state and emits x-cascade/* events with real git
|
|
687
|
+
// data. Attribution (agent_id, task_ref) comes from the PostToolUse(Bash)
|
|
688
|
+
// hook via the command handler's cascade-attribution side-channel.
|
|
689
|
+
cascadeWatcher = startCascadeWatcher({
|
|
690
|
+
tracker: cascadeTracker,
|
|
691
|
+
connection,
|
|
692
|
+
repoPath,
|
|
693
|
+
getAttribution: commandHandler?.getCascadeAttribution,
|
|
694
|
+
agentId,
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// Phase 3: wire the cascade diff server. It registers a
|
|
698
|
+
// cascade/diff.request handler so the hub can fetch unified diffs for
|
|
699
|
+
// cc-swarm-tracked streams on demand. Resilient — try/catch keeps a
|
|
700
|
+
// cascade failure from ever crashing the sidecar.
|
|
701
|
+
try {
|
|
702
|
+
cascadeDiffServerDispose = setupCascadeDiffServer(connection, {
|
|
703
|
+
repoPath,
|
|
704
|
+
tracker: cascadeTracker,
|
|
705
|
+
});
|
|
706
|
+
} catch (err) {
|
|
707
|
+
log.warn("cascade diff server setup failed", { error: err.message });
|
|
708
|
+
}
|
|
709
|
+
} catch (err) {
|
|
710
|
+
log.warn("cascade setup failed", { error: err.message });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
514
714
|
// ── Main ────────────────────────────────────────────────────────────────────
|
|
515
715
|
|
|
516
716
|
async function main() {
|
|
@@ -557,6 +757,10 @@ async function main() {
|
|
|
557
757
|
return commandHandler(command, client);
|
|
558
758
|
});
|
|
559
759
|
|
|
760
|
+
// Register dispatch nudge handler — must come after commandHandler is created
|
|
761
|
+
// so the notification can store nudge state via the command handler.
|
|
762
|
+
registerNudgeHandler(connection);
|
|
763
|
+
|
|
560
764
|
// Start memory file watcher if minimem is enabled
|
|
561
765
|
const sidecarConfig = readConfig();
|
|
562
766
|
if (sidecarConfig.minimem?.enabled) {
|
|
@@ -581,6 +785,11 @@ async function main() {
|
|
|
581
785
|
}
|
|
582
786
|
}
|
|
583
787
|
|
|
788
|
+
// Cascade Phase 1: register the working branch as a local-mode
|
|
789
|
+
// git-cascade stream and emit x-cascade/stream.opened. No-op unless
|
|
790
|
+
// cascade.enabled. Resilient — never crashes the sidecar.
|
|
791
|
+
await setupCascade();
|
|
792
|
+
|
|
584
793
|
// Start inactivity timer
|
|
585
794
|
resetInactivityTimer();
|
|
586
795
|
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cascade-client.test.mjs — git-cascade tracker ownership (Phase 1)
|
|
3
|
+
*
|
|
4
|
+
* Exercises openCascadeTracker / ensureStream / closeCascadeTracker against a
|
|
5
|
+
* real temp git repo with a couple of branches. git-cascade is resolved at
|
|
6
|
+
* runtime via swarmkit-resolver, so these tests require git-cascade to be
|
|
7
|
+
* importable from the workspace.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import { execSync } from "child_process";
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import path from "path";
|
|
14
|
+
import {
|
|
15
|
+
openCascadeTracker,
|
|
16
|
+
ensureStream,
|
|
17
|
+
closeCascadeTracker,
|
|
18
|
+
findStreamByBranch,
|
|
19
|
+
recordObservedCommit,
|
|
20
|
+
recordObservedMerge,
|
|
21
|
+
} from "../cascade-client.mjs";
|
|
22
|
+
import { makeTmpDir, cleanupTmpDir } from "./helpers.mjs";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a real git repo with an initial commit and a couple of branches.
|
|
26
|
+
* Leaves `main` (or the default branch) checked out.
|
|
27
|
+
*/
|
|
28
|
+
function makeGitRepo(dir) {
|
|
29
|
+
execSync("git init", { cwd: dir, stdio: "pipe" });
|
|
30
|
+
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "pipe" });
|
|
31
|
+
execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
|
|
32
|
+
execSync("git config commit.gpgsign false", { cwd: dir, stdio: "pipe" });
|
|
33
|
+
fs.writeFileSync(path.join(dir, "README.md"), "# test\n");
|
|
34
|
+
execSync("git add .", { cwd: dir, stdio: "pipe" });
|
|
35
|
+
execSync('git commit -m "initial"', { cwd: dir, stdio: "pipe" });
|
|
36
|
+
// Create a couple of extra branches.
|
|
37
|
+
execSync("git branch feature-a", { cwd: dir, stdio: "pipe" });
|
|
38
|
+
execSync("git branch feature-b", { cwd: dir, stdio: "pipe" });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Current branch name of a repo. */
|
|
42
|
+
function currentBranch(dir) {
|
|
43
|
+
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
44
|
+
cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
|
|
45
|
+
}).trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** HEAD commit SHA of a repo (optionally a specific rev). */
|
|
49
|
+
function headSha(dir, rev = "HEAD") {
|
|
50
|
+
return execSync(`git rev-parse ${rev}`, {
|
|
51
|
+
cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
|
|
52
|
+
}).trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("cascade-client", () => {
|
|
56
|
+
let repoDir;
|
|
57
|
+
let dbPath;
|
|
58
|
+
let tracker;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
repoDir = makeTmpDir("cascade-test-");
|
|
62
|
+
dbPath = path.join(repoDir, "tracker.db");
|
|
63
|
+
makeGitRepo(repoDir);
|
|
64
|
+
tracker = null;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
if (tracker) {
|
|
69
|
+
closeCascadeTracker(tracker);
|
|
70
|
+
tracker = null;
|
|
71
|
+
}
|
|
72
|
+
cleanupTmpDir(repoDir);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("openCascadeTracker returns a tracker for a git repo", async () => {
|
|
76
|
+
tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
|
|
77
|
+
expect(tracker).not.toBeNull();
|
|
78
|
+
expect(typeof tracker.trackExistingBranch).toBe("function");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("openCascadeTracker returns null for a non-git directory", async () => {
|
|
82
|
+
const nonGit = makeTmpDir("cascade-nongit-");
|
|
83
|
+
try {
|
|
84
|
+
const t = await openCascadeTracker({
|
|
85
|
+
repoPath: nonGit,
|
|
86
|
+
dbPath: path.join(nonGit, "tracker.db"),
|
|
87
|
+
});
|
|
88
|
+
expect(t).toBeNull();
|
|
89
|
+
} finally {
|
|
90
|
+
cleanupTmpDir(nonGit);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("ensureStream creates a stream for the working branch", async () => {
|
|
95
|
+
tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
|
|
96
|
+
expect(tracker).not.toBeNull();
|
|
97
|
+
|
|
98
|
+
const branch = currentBranch(repoDir);
|
|
99
|
+
const result = ensureStream(tracker, { branch, agentId: "team-sidecar" });
|
|
100
|
+
|
|
101
|
+
expect(result).not.toBeNull();
|
|
102
|
+
expect(result.created).toBe(true);
|
|
103
|
+
expect(typeof result.streamId).toBe("string");
|
|
104
|
+
expect(result.streamId.length).toBeGreaterThan(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("ensureStream is idempotent on a second call for the same branch", async () => {
|
|
108
|
+
tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
|
|
109
|
+
expect(tracker).not.toBeNull();
|
|
110
|
+
|
|
111
|
+
const branch = currentBranch(repoDir);
|
|
112
|
+
const first = ensureStream(tracker, { branch, agentId: "team-sidecar" });
|
|
113
|
+
const second = ensureStream(tracker, { branch, agentId: "team-sidecar" });
|
|
114
|
+
|
|
115
|
+
expect(first).not.toBeNull();
|
|
116
|
+
expect(second).not.toBeNull();
|
|
117
|
+
expect(first.created).toBe(true);
|
|
118
|
+
expect(second.created).toBe(false);
|
|
119
|
+
expect(second.streamId).toBe(first.streamId);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("ensureStream tracks distinct branches as separate streams", async () => {
|
|
123
|
+
tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
|
|
124
|
+
expect(tracker).not.toBeNull();
|
|
125
|
+
|
|
126
|
+
const a = ensureStream(tracker, { branch: "feature-a", agentId: "team-sidecar" });
|
|
127
|
+
const b = ensureStream(tracker, { branch: "feature-b", agentId: "team-sidecar" });
|
|
128
|
+
|
|
129
|
+
expect(a).not.toBeNull();
|
|
130
|
+
expect(b).not.toBeNull();
|
|
131
|
+
expect(a.created).toBe(true);
|
|
132
|
+
expect(b.created).toBe(true);
|
|
133
|
+
expect(a.streamId).not.toBe(b.streamId);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("ensureStream returns null when tracker is missing", () => {
|
|
137
|
+
expect(ensureStream(null, { branch: "main", agentId: "x" })).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("closeCascadeTracker is a safe no-op on null", () => {
|
|
141
|
+
expect(() => closeCascadeTracker(null)).not.toThrow();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("closeCascadeTracker closes an open tracker", async () => {
|
|
145
|
+
const t = await openCascadeTracker({ repoPath: repoDir, dbPath });
|
|
146
|
+
expect(t).not.toBeNull();
|
|
147
|
+
expect(() => closeCascadeTracker(t)).not.toThrow();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("findStreamByBranch resolves a tracked branch and returns null otherwise", async () => {
|
|
151
|
+
tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
|
|
152
|
+
expect(tracker).not.toBeNull();
|
|
153
|
+
|
|
154
|
+
const branch = currentBranch(repoDir);
|
|
155
|
+
const created = ensureStream(tracker, { branch, agentId: "team-sidecar" });
|
|
156
|
+
expect(created).not.toBeNull();
|
|
157
|
+
|
|
158
|
+
expect(findStreamByBranch(tracker, branch)).toBe(created.streamId);
|
|
159
|
+
expect(findStreamByBranch(tracker, "no-such-branch")).toBeNull();
|
|
160
|
+
expect(findStreamByBranch(null, branch)).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("recordObservedCommit persists a change and returns its id", async () => {
|
|
164
|
+
tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
|
|
165
|
+
expect(tracker).not.toBeNull();
|
|
166
|
+
|
|
167
|
+
const branch = currentBranch(repoDir);
|
|
168
|
+
const stream = ensureStream(tracker, { branch, agentId: "team-sidecar" });
|
|
169
|
+
expect(stream).not.toBeNull();
|
|
170
|
+
|
|
171
|
+
const commit = headSha(repoDir);
|
|
172
|
+
const changeId = await recordObservedCommit(tracker, {
|
|
173
|
+
streamId: stream.streamId,
|
|
174
|
+
commit,
|
|
175
|
+
description: "initial",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(typeof changeId).toBe("string");
|
|
179
|
+
expect(changeId.length).toBeGreaterThan(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("recordObservedCommit returns null when tracker is missing", async () => {
|
|
183
|
+
const result = await recordObservedCommit(null, {
|
|
184
|
+
streamId: "s1", commit: "abc", description: "x",
|
|
185
|
+
});
|
|
186
|
+
expect(result).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("recordObservedMerge persists a merge edge and returns its id", async () => {
|
|
190
|
+
tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
|
|
191
|
+
expect(tracker).not.toBeNull();
|
|
192
|
+
|
|
193
|
+
const target = ensureStream(tracker, { branch: currentBranch(repoDir), agentId: "team-sidecar" });
|
|
194
|
+
const source = ensureStream(tracker, { branch: "feature-a", agentId: "team-sidecar" });
|
|
195
|
+
expect(target).not.toBeNull();
|
|
196
|
+
expect(source).not.toBeNull();
|
|
197
|
+
|
|
198
|
+
const mergeCommit = headSha(repoDir);
|
|
199
|
+
const mergeId = await recordObservedMerge(tracker, {
|
|
200
|
+
sourceStreamId: source.streamId,
|
|
201
|
+
sourceCommit: headSha(repoDir, "feature-a"),
|
|
202
|
+
targetStreamId: target.streamId,
|
|
203
|
+
mergeCommit,
|
|
204
|
+
metadata: { trigger: "test" },
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(typeof mergeId).toBe("string");
|
|
208
|
+
expect(mergeId.length).toBeGreaterThan(0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("recordObservedMerge returns null when required fields are missing", async () => {
|
|
212
|
+
tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
|
|
213
|
+
expect(tracker).not.toBeNull();
|
|
214
|
+
const result = await recordObservedMerge(tracker, { sourceStreamId: "s1" });
|
|
215
|
+
expect(result).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
});
|