feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,716 @@
|
||||
/**
|
||||
* @file Tests for the Remote Data Sources feature: input validation + command
|
||||
* builders in server/lib/remote-sync.js, the /api/remote-sources route CRUD, and
|
||||
* the source-scoped data filter threaded through the sessions/events/agents/
|
||||
* stats/analytics endpoints. The actual SSH/rsync transfer is not exercised
|
||||
* (that needs a live remote); everything up to and around it is.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const http = require("http");
|
||||
|
||||
// Isolate the DB and disable background pollers/probes before loading server.
|
||||
const TEST_DB = path.join(os.tmpdir(), `dashboard-remote-test-${Date.now()}-${process.pid}.db`);
|
||||
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db, stmts } = require("../db");
|
||||
const remoteSync = require("../lib/remote-sync");
|
||||
const sourceFilter = require("../lib/source-filter");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
|
||||
function fetchJson(urlPath, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(urlPath, BASE);
|
||||
const opts = {
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname + url.search,
|
||||
method: options.method || "GET",
|
||||
headers: { "Content-Type": "application/json", ...options.headers },
|
||||
};
|
||||
const req = http.request(opts, (res) => {
|
||||
let body = "";
|
||||
res.on("data", (c) => (body += c));
|
||||
res.on("end", () => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
parsed = body;
|
||||
}
|
||||
resolve({ status: res.statusCode, body: parsed });
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
if (options.body) req.write(JSON.stringify(options.body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
const get = (p) => fetchJson(p);
|
||||
const post = (p, body) => fetchJson(p, { method: "POST", body });
|
||||
const patch = (p, body) => fetchJson(p, { method: "PATCH", body });
|
||||
const del = (p) => fetchJson(p, { method: "DELETE" });
|
||||
|
||||
before(async () => {
|
||||
const app = createApp();
|
||||
server = await startServer(app, 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (server) server.close();
|
||||
if (db) db.close();
|
||||
for (const suffix of ["", "-wal", "-shm"]) {
|
||||
try {
|
||||
fs.unlinkSync(TEST_DB + suffix);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("remote-sync validateSourceInput", () => {
|
||||
it("accepts a valid full config and expands ~ in identity_file", () => {
|
||||
const v = remoteSync.validateSourceInput({
|
||||
label: "Dev Box",
|
||||
host: "son@dev.local",
|
||||
ssh_port: 22,
|
||||
identity_file: "~/.ssh/id_ed25519",
|
||||
remote_home: "~/.claude",
|
||||
});
|
||||
assert.equal(v.label, "Dev Box");
|
||||
assert.equal(v.host, "son@dev.local");
|
||||
assert.equal(v.sshPort, 22);
|
||||
assert.ok(path.isAbsolute(v.identityFile));
|
||||
assert.equal(v.remoteHome, "~/.claude");
|
||||
});
|
||||
|
||||
it("accepts a config-alias host with no user", () => {
|
||||
const v = remoteSync.validateSourceInput({ label: "x", host: "mybox" });
|
||||
assert.equal(v.host, "mybox");
|
||||
});
|
||||
|
||||
const rejects = [
|
||||
[
|
||||
"leading-dash host (ssh option injection)",
|
||||
{ label: "x", host: "-oProxyCommand=evil" },
|
||||
"INVALID_HOST",
|
||||
],
|
||||
["host with space", { label: "x", host: "a b" }, "INVALID_HOST"],
|
||||
["host with ;", { label: "x", host: "a;rm -rf /" }, "INVALID_HOST"],
|
||||
["host with : (breaks scp spec)", { label: "x", host: "a:b" }, "INVALID_HOST"],
|
||||
["missing label", { host: "a" }, "INVALID_LABEL"],
|
||||
["port out of range", { label: "x", host: "a", ssh_port: 99999 }, "INVALID_PORT"],
|
||||
[
|
||||
"remote_home with ..",
|
||||
{ label: "x", host: "a", remote_home: "~/../etc" },
|
||||
"INVALID_REMOTE_HOME",
|
||||
],
|
||||
[
|
||||
"relative remote_home",
|
||||
{ label: "x", host: "a", remote_home: "rel/path" },
|
||||
"INVALID_REMOTE_HOME",
|
||||
],
|
||||
[
|
||||
"identity_file with newline",
|
||||
{ label: "x", host: "a", identity_file: "/a\nb" },
|
||||
"INVALID_IDENTITY_FILE",
|
||||
],
|
||||
];
|
||||
for (const [name, input, code] of rejects) {
|
||||
it(`rejects ${name}`, () => {
|
||||
assert.throws(
|
||||
() => remoteSync.validateSourceInput(input),
|
||||
(err) => err.code === code
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("allows hyphens inside an identity_file path", () => {
|
||||
const v = remoteSync.validateSourceInput({
|
||||
label: "x",
|
||||
host: "a",
|
||||
identity_file: "/home/u/.ssh/id-ed25519",
|
||||
});
|
||||
assert.equal(v.identityFile, "/home/u/.ssh/id-ed25519");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote-sync command builders", () => {
|
||||
it("builds ssh option args with port + identity", async () => {
|
||||
const args = await remoteSync.sshOptionArgs({ ssh_port: 2222, identity_file: "/k" });
|
||||
assert.ok(args.includes("-p"));
|
||||
assert.equal(args[args.indexOf("-p") + 1], "2222");
|
||||
assert.ok(args.includes("-i"));
|
||||
assert.equal(args[args.indexOf("-i") + 1], "/k");
|
||||
assert.ok(args.includes("IdentitiesOnly=yes"));
|
||||
});
|
||||
it("builds scp option args with capital -P for port", async () => {
|
||||
const args = await remoteSync.scpOptionArgs({ ssh_port: 2222, identity_file: "/k" });
|
||||
assert.ok(args.includes("-P"));
|
||||
assert.equal(args[args.indexOf("-P") + 1], "2222");
|
||||
});
|
||||
it("parses ssh -G output for identity agent discovery", () => {
|
||||
const cfg = remoteSync.parseSshGOutput(
|
||||
"hostname example.com\nidentityagent /tmp/agent.sock\nport 22\n"
|
||||
);
|
||||
assert.equal(cfg.identityagent, "/tmp/agent.sock");
|
||||
assert.equal(cfg.port, "22");
|
||||
});
|
||||
it("buildSshChildEnv sets HOME and does not override SSH_AUTH_SOCK", () => {
|
||||
const env = remoteSync.buildSshChildEnv();
|
||||
assert.ok(env.HOME);
|
||||
if (process.env.SSH_AUTH_SOCK) {
|
||||
assert.equal(env.SSH_AUTH_SOCK, process.env.SSH_AUTH_SOCK);
|
||||
}
|
||||
});
|
||||
it("identityAgentArgsFromConfig follows ssh -G only for concrete agent paths", () => {
|
||||
assert.deepEqual(remoteSync.identityAgentArgsFromConfig("none"), []);
|
||||
assert.deepEqual(remoteSync.identityAgentArgsFromConfig("SSH_AUTH_SOCK"), []);
|
||||
assert.deepEqual(remoteSync.identityAgentArgsFromConfig("/tmp/custom-agent.sock"), [
|
||||
"-o",
|
||||
"IdentityAgent=/tmp/custom-agent.sock",
|
||||
]);
|
||||
const home = os.homedir();
|
||||
assert.deepEqual(remoteSync.identityAgentArgsFromConfig("~/Library/agent.sock"), [
|
||||
"-o",
|
||||
`IdentityAgent=${path.join(home, "Library/agent.sock")}`,
|
||||
]);
|
||||
});
|
||||
it("adds PowerShell and WSL probes for ~-rooted remote homes (Windows remotes)", () => {
|
||||
const probes = remoteSync.connectionProbeCommands({ remote_home: "~/.claude" });
|
||||
assert.equal(probes.length, 3);
|
||||
assert.match(probes[0], /sh -c/);
|
||||
assert.match(probes[0], /~\/\.claude\/projects/);
|
||||
assert.match(probes[1], /powershell\.exe/);
|
||||
assert.match(probes[1], /\.claude\\projects/);
|
||||
assert.match(probes[2], /wsl\.exe/);
|
||||
assert.match(probes[2], /~\/\.claude\/projects/);
|
||||
});
|
||||
it("uses only wsl.exe for wsl: remote homes", () => {
|
||||
const probes = remoteSync.connectionProbeCommands({ remote_home: "wsl:~/.claude" });
|
||||
assert.equal(probes.length, 1);
|
||||
assert.match(probes[0], /wsl\.exe/);
|
||||
});
|
||||
|
||||
it("connectionSuccessMessage reflects explicit vs auto-detected WSL", () => {
|
||||
const wslProbe = "wsl.exe -e sh -c 'test -d ~/.claude/projects && echo CCAM_OK'";
|
||||
assert.match(
|
||||
remoteSync.connectionSuccessMessage({ remote_home: "wsl:~/.claude" }, wslProbe),
|
||||
/wsl:~\/\.claude/
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
remoteSync.connectionSuccessMessage({ remote_home: "wsl:~/.claude" }, wslProbe),
|
||||
/auto-detected/i
|
||||
);
|
||||
assert.match(
|
||||
remoteSync.connectionSuccessMessage({ remote_home: null }, wslProbe),
|
||||
/auto-detected/i
|
||||
);
|
||||
assert.match(
|
||||
remoteSync.connectionSuccessMessage({ remote_home: null }, "sh -c 'echo CCAM_OK'"),
|
||||
/Remote Claude Code history found/
|
||||
);
|
||||
});
|
||||
it("accepts wsl: and UNC remote_home values", () => {
|
||||
const wsl = remoteSync.validateSourceInput({
|
||||
label: "WSL",
|
||||
host: "u@win",
|
||||
remote_home: "wsl:/home/hoang/.claude",
|
||||
});
|
||||
assert.equal(wsl.remoteHome, "wsl:/home/hoang/.claude");
|
||||
assert.equal(
|
||||
remoteSync.remoteProjectsPath({ remote_home: wsl.remoteHome }),
|
||||
"wsl:/home/hoang/.claude/projects"
|
||||
);
|
||||
const unc = remoteSync.validateSourceInput({
|
||||
label: "UNC",
|
||||
host: "u@win",
|
||||
remote_home: "//wsl.localhost/Ubuntu/home/hoang/.claude",
|
||||
});
|
||||
assert.equal(unc.remoteHome, "//wsl.localhost/Ubuntu/home/hoang/.claude");
|
||||
assert.equal(
|
||||
remoteSync.remoteProjectsPath({ remote_home: unc.remoteHome }),
|
||||
"//wsl.localhost/Ubuntu/home/hoang/.claude/projects"
|
||||
);
|
||||
});
|
||||
it("builds a wsl tar command for WSL-hosted Claude homes", () => {
|
||||
assert.equal(
|
||||
remoteSync.wslTarRemoteCmd("~/.claude"),
|
||||
"wsl.exe -e sh -c 'tar -cC ~/.claude/projects .'"
|
||||
);
|
||||
});
|
||||
it("adds cmd.exe probe only for Windows drive-letter remote homes", () => {
|
||||
const probes = remoteSync.connectionProbeCommands({
|
||||
remote_home: "C:/Users/hoang/.claude",
|
||||
});
|
||||
assert.equal(probes.length, 1);
|
||||
assert.match(probes[0], /cmd \/c/);
|
||||
assert.match(probes[0], /C:\\Users\\hoang\\.claude\\projects/);
|
||||
});
|
||||
it("uses sh probe for POSIX absolute remote homes", () => {
|
||||
const probes = remoteSync.connectionProbeCommands({ remote_home: "/opt/cc" });
|
||||
assert.deepEqual(probes, [
|
||||
"sh -c 'test -d /opt/cc/projects && echo CCAM_OK || echo CCAM_NO_DIR'",
|
||||
]);
|
||||
});
|
||||
it("accepts Windows-style remote_home with forward slashes", () => {
|
||||
const v = remoteSync.validateSourceInput({
|
||||
label: "Win",
|
||||
host: "u@win",
|
||||
remote_home: "C:/Users/hoang/.claude",
|
||||
});
|
||||
assert.equal(v.remoteHome, "C:/Users/hoang/.claude");
|
||||
assert.equal(
|
||||
remoteSync.remoteProjectsPath({ remote_home: v.remoteHome }),
|
||||
"C:/Users/hoang/.claude/projects"
|
||||
);
|
||||
assert.equal(
|
||||
remoteSync.scpRemoteSpec({ host: "u@win", remote_home: v.remoteHome }),
|
||||
"u@win:C:/Users/hoang/.claude/projects/."
|
||||
);
|
||||
});
|
||||
it("expands tilde in ssh -G IdentityAgent paths and strips quotes", () => {
|
||||
const home = os.homedir();
|
||||
assert.equal(
|
||||
remoteSync.expandSshConfigPath("~/Library/agent.sock"),
|
||||
path.join(home, "Library/agent.sock")
|
||||
);
|
||||
assert.equal(remoteSync.expandSshConfigPath('"/tmp/quoted.sock"'), "/tmp/quoted.sock");
|
||||
assert.equal(remoteSync.expandSshConfigPath("/tmp/a"), "/tmp/a");
|
||||
});
|
||||
it("sshConfigFileArgs points at user config when present", () => {
|
||||
const args = remoteSync.sshConfigFileArgs();
|
||||
const cfg = path.join(os.homedir(), ".ssh", "config");
|
||||
if (fs.existsSync(cfg)) {
|
||||
assert.deepEqual(args, ["-F", cfg]);
|
||||
} else {
|
||||
assert.deepEqual(args, []);
|
||||
}
|
||||
});
|
||||
it("treats blank identity_file as null", () => {
|
||||
const v = remoteSync.validateSourceInput({
|
||||
label: "x",
|
||||
host: "a",
|
||||
identity_file: " ",
|
||||
});
|
||||
assert.equal(v.identityFile, null);
|
||||
});
|
||||
it("detects legacy scp protocol errors for -O retry", () => {
|
||||
assert.equal(
|
||||
remoteSync.isLegacyScpProtocolError("subsystem request failed on channel 0"),
|
||||
true
|
||||
);
|
||||
assert.equal(remoteSync.isLegacyScpProtocolError("Permission denied"), false);
|
||||
});
|
||||
it("strips ANSI escapes from command output", () => {
|
||||
assert.equal(remoteSync.stripAnsi("\u001b[31;1mscp: not found\u001b[0m"), "scp: not found");
|
||||
});
|
||||
it("resolves ssh/scp binaries on Windows when OpenSSH is in System32", () => {
|
||||
const prev = process.platform;
|
||||
const prevWin = process.env.WINDIR;
|
||||
try {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
process.env.WINDIR = process.env.WINDIR || "C:\\Windows";
|
||||
const ssh = remoteSync.resolveSshBinary("ssh");
|
||||
assert.match(ssh, /ssh\.exe$/i);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { value: prev });
|
||||
if (prevWin === undefined) delete process.env.WINDIR;
|
||||
else process.env.WINDIR = prevWin;
|
||||
}
|
||||
});
|
||||
it("defaults the remote projects path to ~/.claude/projects", () => {
|
||||
assert.equal(remoteSync.remoteProjectsPath({}), "~/.claude/projects");
|
||||
assert.equal(remoteSync.remoteProjectsPath({ remote_home: "/opt/cc" }), "/opt/cc/projects");
|
||||
});
|
||||
it("identifies top-level session ids in a mirrored tree (skips subagents)", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-staged-"));
|
||||
const proj = path.join(dir, "-Users-x-proj");
|
||||
fs.mkdirSync(path.join(proj, "sess-1", "subagents"), { recursive: true });
|
||||
fs.writeFileSync(path.join(proj, "sess-1.jsonl"), "{}\n");
|
||||
fs.writeFileSync(path.join(proj, "sess-2.jsonl"), "{}\n");
|
||||
fs.writeFileSync(path.join(proj, "sess-1", "subagents", "agent-abc.jsonl"), "{}\n");
|
||||
const ids = remoteSync.stagedSessionIds(dir).sort();
|
||||
assert.deepEqual(ids, ["sess-1", "sess-2"]);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── source-filter helper ────────────────────────────────────────────────────
|
||||
|
||||
describe("source-filter helper", () => {
|
||||
it("parses the sources csv, deduped; empty/absent → null", () => {
|
||||
assert.deepEqual(sourceFilter.parseSources({ query: { sources: "local, a ,a,," } }), [
|
||||
"local",
|
||||
"a",
|
||||
]);
|
||||
assert.equal(sourceFilter.parseSources({ query: {} }), null);
|
||||
assert.equal(sourceFilter.parseSources({ query: { sources: " ,, " } }), null);
|
||||
});
|
||||
it("builds a column clause and a subquery clause", () => {
|
||||
assert.deepEqual(sourceFilter.sourceColumnClause(["local", "a"]), {
|
||||
clause: "s.source IN (?,?)",
|
||||
params: ["local", "a"],
|
||||
});
|
||||
assert.deepEqual(sourceFilter.sessionIdInSourcesClause(["local"], "e.session_id"), {
|
||||
clause: "e.session_id IN (SELECT id FROM sessions WHERE source IN (?))",
|
||||
params: ["local"],
|
||||
});
|
||||
assert.deepEqual(sourceFilter.sourceColumnClause(null), { clause: "", params: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Route CRUD ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("/api/remote-sources CRUD", () => {
|
||||
let createdId;
|
||||
|
||||
it("starts empty", async () => {
|
||||
const res = await get("/api/remote-sources");
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body.sources, []);
|
||||
});
|
||||
|
||||
it("creates a source", async () => {
|
||||
const res = await post("/api/remote-sources", { label: "Dev", host: "son@dev", ssh_port: 22 });
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(res.body.source.label, "Dev");
|
||||
assert.equal(res.body.source.host, "son@dev");
|
||||
assert.equal(res.body.source.enabled, true);
|
||||
assert.equal(res.body.source.status, "idle");
|
||||
assert.ok(res.body.source.id.startsWith("src_"));
|
||||
createdId = res.body.source.id;
|
||||
});
|
||||
|
||||
it("rejects an invalid host with a 400 + structured error", async () => {
|
||||
const res = await post("/api/remote-sources", { label: "Bad", host: "-oProxyCommand=x" });
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.error.code, "INVALID_HOST");
|
||||
});
|
||||
|
||||
it("patches label + enabled, leaving other fields intact", async () => {
|
||||
const res = await patch(`/api/remote-sources/${createdId}`, {
|
||||
label: "Renamed",
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.source.label, "Renamed");
|
||||
assert.equal(res.body.source.enabled, false);
|
||||
assert.equal(res.body.source.ssh_port, 22); // unchanged
|
||||
});
|
||||
|
||||
it("404s for an unknown id", async () => {
|
||||
const res = await patch("/api/remote-sources/src_nope", { label: "x" });
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
it("delete without purge detaches its sessions back to local", async () => {
|
||||
// Attach a session to the source, then delete without purge.
|
||||
stmts.insertSession.run("rs-detach-1", "s", "active", "/x", "claude-opus-4-8", null);
|
||||
stmts.setSessionSource.run(createdId, "rs-detach-1");
|
||||
const res = await del(`/api/remote-sources/${createdId}`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.purged, 0);
|
||||
assert.equal(stmts.getSession.get("rs-detach-1").source, "local");
|
||||
assert.equal(stmts.getRemoteSource.get(createdId), undefined);
|
||||
});
|
||||
|
||||
it("delete with purge removes the source's sessions", async () => {
|
||||
const c = await post("/api/remote-sources", { label: "P", host: "p@h" });
|
||||
const id = c.body.source.id;
|
||||
stmts.insertSession.run("rs-purge-1", "s", "active", "/x", "claude-opus-4-8", null);
|
||||
stmts.setSessionSource.run(id, "rs-purge-1");
|
||||
const res = await del(`/api/remote-sources/${id}?purge=true`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.purged, 1);
|
||||
assert.equal(stmts.getSession.get("rs-purge-1"), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Source-scoped data endpoints ──────────────────────────────────────────────
|
||||
|
||||
describe("source scoping across data endpoints", () => {
|
||||
before(async () => {
|
||||
// A local session and a remote-tagged session, each with one event.
|
||||
await post("/api/hooks/event", {
|
||||
hook_type: "SessionStart",
|
||||
data: { session_id: "scope-local", cwd: "/local" },
|
||||
});
|
||||
await post("/api/hooks/event", {
|
||||
hook_type: "SessionStart",
|
||||
data: { session_id: "scope-remote", cwd: "/remote" },
|
||||
});
|
||||
stmts.setSessionSource.run("src_scope", "scope-remote");
|
||||
});
|
||||
|
||||
it("facets lists distinct sources (local + tagged)", async () => {
|
||||
const res = await get("/api/sessions/facets");
|
||||
assert.ok(res.body.sources.includes("local"));
|
||||
assert.ok(res.body.sources.includes("src_scope"));
|
||||
});
|
||||
|
||||
it("sessions?sources=local excludes the remote session", async () => {
|
||||
const res = await get("/api/sessions?sources=local&limit=1000");
|
||||
const ids = res.body.sessions.map((s) => s.id);
|
||||
assert.ok(ids.includes("scope-local"));
|
||||
assert.ok(!ids.includes("scope-remote"));
|
||||
});
|
||||
|
||||
it("sessions?sources=src_scope returns only the remote session", async () => {
|
||||
const res = await get("/api/sessions?sources=src_scope&limit=1000");
|
||||
const ids = res.body.sessions.map((s) => s.id);
|
||||
assert.deepEqual(ids, ["scope-remote"]);
|
||||
assert.equal(res.body.sessions[0].source, "src_scope");
|
||||
});
|
||||
|
||||
it("sessions with no sources param returns both", async () => {
|
||||
const res = await get("/api/sessions?limit=1000");
|
||||
const ids = res.body.sessions.map((s) => s.id);
|
||||
assert.ok(ids.includes("scope-local") && ids.includes("scope-remote"));
|
||||
});
|
||||
|
||||
it("stats respects the source scope", async () => {
|
||||
const all = await get("/api/stats");
|
||||
const local = await get("/api/stats?sources=local");
|
||||
const remote = await get("/api/stats?sources=src_scope");
|
||||
assert.ok(all.body.total_sessions >= 2);
|
||||
assert.equal(
|
||||
local.body.total_sessions + remote.body.total_sessions <= all.body.total_sessions,
|
||||
true
|
||||
);
|
||||
// The remote scope sees exactly its one tagged session.
|
||||
assert.equal(remote.body.total_sessions, 1);
|
||||
});
|
||||
|
||||
it("analytics respects the source scope", async () => {
|
||||
const remote = await get("/api/analytics?sources=src_scope");
|
||||
assert.equal(remote.body.overview.total_sessions, 1);
|
||||
});
|
||||
|
||||
it("events?sources=src_scope only returns the remote session's events", async () => {
|
||||
const res = await get("/api/events?sources=src_scope&limit=1000");
|
||||
assert.ok(res.body.events.every((e) => e.session_id === "scope-remote"));
|
||||
});
|
||||
|
||||
it("agents?sources=local excludes the remote session's agents", async () => {
|
||||
const res = await get("/api/agents?sources=local");
|
||||
assert.ok(res.body.agents.every((a) => a.session_id !== "scope-remote"));
|
||||
});
|
||||
|
||||
it("pricing cost respects the source scope (regression: total cost was global)", async () => {
|
||||
// Equal usage on the local + the remote session; the default claude-opus-4-8
|
||||
// pricing rule prices it. Before the fix, /pricing/cost ignored `sources`, so
|
||||
// every scope returned the same global total and the Dashboard cost never
|
||||
// moved when the scope changed.
|
||||
stmts.upsertTokenUsage.run("scope-local", "claude-opus-4-8", 1_000_000, 0, 0, 0);
|
||||
stmts.upsertTokenUsage.run("scope-remote", "claude-opus-4-8", 1_000_000, 0, 0, 0);
|
||||
|
||||
const all = await get("/api/pricing/cost");
|
||||
const local = await get("/api/pricing/cost?sources=local");
|
||||
const remote = await get("/api/pricing/cost?sources=src_scope");
|
||||
|
||||
assert.ok(all.body.total_cost > 0, "some cost recorded across all sources");
|
||||
assert.ok(local.body.total_cost > 0, "local scope has cost");
|
||||
assert.ok(remote.body.total_cost > 0, "remote scope has cost");
|
||||
// The fix: each scope is strictly less than the combined total.
|
||||
assert.ok(local.body.total_cost < all.body.total_cost, "local scope excludes remote cost");
|
||||
assert.ok(remote.body.total_cost < all.body.total_cost, "remote scope excludes local cost");
|
||||
// The two disjoint scopes partition the whole (no other opus-4-8 usage here).
|
||||
assert.ok(
|
||||
Math.abs(local.body.total_cost + remote.body.total_cost - all.body.total_cost) < 1e-6,
|
||||
"local + remote cost sums to the unscoped total"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/remote-sources session_count", () => {
|
||||
it("reports the live number of sessions attributed to each source", async () => {
|
||||
const c = await post("/api/remote-sources", { label: "Counted", host: "c@h" });
|
||||
const id = c.body.source.id;
|
||||
// A freshly-added source has no sessions yet.
|
||||
assert.equal(c.body.source.session_count ?? 0, 0);
|
||||
// Tag two sessions to it, then confirm the list reflects the count.
|
||||
stmts.insertSession.run("rs-count-1", "s", "active", "/x", "claude-opus-4-8", null);
|
||||
stmts.insertSession.run("rs-count-2", "s", "active", "/y", "claude-opus-4-8", null);
|
||||
stmts.setSessionSource.run(id, "rs-count-1");
|
||||
stmts.setSessionSource.run(id, "rs-count-2");
|
||||
const list = await get("/api/remote-sources");
|
||||
const row = list.body.sources.find((s) => s.id === id);
|
||||
assert.equal(row.session_count, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Remote session status reconciliation ─────────────────────────────────────
|
||||
// A remote session gets NO live hooks and is excluded from every local liveness/
|
||||
// staleness sweep, so its active/completed state is driven solely by whether its
|
||||
// mirrored transcript is still advancing. These exercise that reconciliation
|
||||
// directly against a staged tree with controlled mtimes.
|
||||
describe("reconcileRemoteSessionStatus", () => {
|
||||
const dbModule = require("../db");
|
||||
const SRC = { id: "src_recon" };
|
||||
let stageRoot;
|
||||
|
||||
before(() => {
|
||||
stageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-recon-"));
|
||||
});
|
||||
after(() => {
|
||||
try {
|
||||
fs.rmSync(stageRoot, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
function stageSession(id, ageMs, contentLine = "{}") {
|
||||
const proj = path.join(stageRoot, "-Users-x-proj");
|
||||
fs.mkdirSync(proj, { recursive: true });
|
||||
const f = path.join(proj, `${id}.jsonl`);
|
||||
fs.writeFileSync(f, `${contentLine}\n`);
|
||||
const t = new Date(Date.now() - ageMs);
|
||||
fs.utimesSync(f, t, t);
|
||||
return f;
|
||||
}
|
||||
|
||||
it("heals a wrongly-completed remote session whose mirror is still fresh", () => {
|
||||
stmts.insertSession.run(
|
||||
"recon-fresh",
|
||||
"s",
|
||||
"completed",
|
||||
"/home/ubuntu/matroid",
|
||||
"claude-opus-4-8",
|
||||
null
|
||||
);
|
||||
stmts.setSessionSource.run(SRC.id, "recon-fresh");
|
||||
stmts.insertAgent.run(
|
||||
"recon-fresh-main",
|
||||
"recon-fresh",
|
||||
"Main",
|
||||
"main",
|
||||
null,
|
||||
"completed",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
stageSession("recon-fresh", 1_000); // 1s old → still running
|
||||
|
||||
remoteSync.reconcileRemoteSessionStatus(dbModule, SRC, stageRoot);
|
||||
|
||||
assert.equal(stmts.getSession.get("recon-fresh").status, "active");
|
||||
assert.equal(stmts.getSession.get("recon-fresh").ended_at, null, "ended_at cleared on heal");
|
||||
assert.equal(stmts.getAgent.get("recon-fresh-main").status, "waiting");
|
||||
});
|
||||
|
||||
it("completes an active remote session whose mirror has gone stale", () => {
|
||||
stmts.insertSession.run(
|
||||
"recon-stale",
|
||||
"s",
|
||||
"active",
|
||||
"/home/ubuntu/other",
|
||||
"claude-opus-4-8",
|
||||
null
|
||||
);
|
||||
stmts.setSessionSource.run(SRC.id, "recon-stale");
|
||||
stmts.insertAgent.run(
|
||||
"recon-stale-main",
|
||||
"recon-stale",
|
||||
"Main",
|
||||
"main",
|
||||
null,
|
||||
"waiting",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
stageSession("recon-stale", 20 * 60 * 1000); // 20 min idle → ended
|
||||
|
||||
remoteSync.reconcileRemoteSessionStatus(dbModule, SRC, stageRoot);
|
||||
|
||||
assert.equal(stmts.getSession.get("recon-stale").status, "completed");
|
||||
assert.ok(stmts.getSession.get("recon-stale").ended_at, "ended_at stamped");
|
||||
assert.equal(stmts.getAgent.get("recon-stale-main").status, "completed");
|
||||
});
|
||||
|
||||
it("completes an active session when mtime is fresh but transcript content is stale", () => {
|
||||
stmts.insertSession.run(
|
||||
"recon-touch",
|
||||
"s",
|
||||
"active",
|
||||
"/home/ubuntu/touched",
|
||||
"claude-opus-4-8",
|
||||
null
|
||||
);
|
||||
stmts.setSessionSource.run(SRC.id, "recon-touch");
|
||||
stmts.insertAgent.run(
|
||||
"recon-touch-main",
|
||||
"recon-touch",
|
||||
"Main",
|
||||
"main",
|
||||
null,
|
||||
"waiting",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
const stale = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
||||
stageSession("recon-touch", 1_000, `{"timestamp":"${stale}"}`);
|
||||
|
||||
remoteSync.reconcileRemoteSessionStatus(dbModule, SRC, stageRoot);
|
||||
|
||||
assert.equal(stmts.getSession.get("recon-touch").status, "completed");
|
||||
assert.equal(stmts.getAgent.get("recon-touch-main").status, "completed");
|
||||
});
|
||||
|
||||
it("never touches a session owned by a different source", () => {
|
||||
stmts.insertSession.run(
|
||||
"recon-other",
|
||||
"s",
|
||||
"active",
|
||||
"/home/ubuntu/z",
|
||||
"claude-opus-4-8",
|
||||
null
|
||||
);
|
||||
stmts.setSessionSource.run("src_different", "recon-other");
|
||||
stageSession("recon-other", 20 * 60 * 1000); // stale, but not this source's
|
||||
|
||||
remoteSync.reconcileRemoteSessionStatus(dbModule, SRC, stageRoot);
|
||||
|
||||
assert.equal(stmts.getSession.get("recon-other").status, "active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/remote-sources/sync-all", () => {
|
||||
it("syncs only enabled sources and isolates per-source outcomes", async () => {
|
||||
// Disable every existing source so this exercises the wiring without any
|
||||
// real SSH/rsync shell-out (nothing enabled → nothing to pull).
|
||||
const { body } = await get("/api/remote-sources");
|
||||
for (const s of body.sources) {
|
||||
if (s.enabled) await patch(`/api/remote-sources/${s.id}`, { enabled: false });
|
||||
}
|
||||
const res = await post("/api/remote-sources/sync-all");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ok, true);
|
||||
assert.equal(res.body.synced, 0);
|
||||
assert.deepEqual(res.body.results, []);
|
||||
});
|
||||
|
||||
it("does not collide with the /:id/sync route", async () => {
|
||||
// "sync-all" is a single path segment, so it must not be treated as an :id.
|
||||
const res = await post("/api/remote-sources/sync-all");
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(Array.isArray(res.body.results));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user