17d56d0e01
Clean break, no compatibility alias. Renames the product name, the CLI binary (`ccam` -> `agenthub`), the env var prefix (`CCAM_` -> `AGENTHUB_`), the Prometheus metric namespace (`ccam_*` -> `agenthub_*`), the tmux session prefix (`ccam-lane-<id>` -> `agenthub-lane-<id>`), the per-repo runtime profile directory (`.ccam/profile` -> `.agenthub/profile`), the lanes state root (`~/.claude/ccam-lanes` -> `~/.claude/agenthub-lanes`), the bundled plugins (`plugins/ccam*` -> `plugins/agenthub*`) and their slash commands (`/ccam-doctor` -> `/agenthub-doctor`, `/ccam:*` -> `/agenthub:*`). Because there is no alias, an existing install has to be updated by hand: rename its `.ccam/profile` directory and any `CCAM_*` variables, and reinstall the plugin under its new name. Two other name families are deliberately left alone here, since they are not just strings: the `Claude-Code-Monitor` repository/marketplace name, and the `agent-dashboard` npm package plus its `~/.claude/agent-dashboard` runtime directory, which holds the live database and needs a migration rather than a rename. Verified: server 1406/1406, client 459/459, `grep -i ccam` clean across tracked files, `openapi.yaml` regenerated (95 paths, unchanged count).
212 lines
8.2 KiB
JavaScript
212 lines
8.2 KiB
JavaScript
/**
|
|
* @file Tests for the two aggregate endpoints the Workflows page reads:
|
|
* GET /api/lanes/pipeline-stats (how often each pipeline node was passed,
|
|
* over lanes AND archived features) and GET /api/workflows/skill-usage
|
|
* (per-skill and per-plugin call counts). Both are pure reporting endpoints,
|
|
* so the risks are miscounting — double-counting a tool call that emits both a
|
|
* Pre and a Post event, or losing an archived feature's history.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const fs = require("node:fs");
|
|
const http = require("node:http");
|
|
|
|
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-usage-api-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
|
|
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
const { createApp } = require("../index");
|
|
const lanesLib = require("../lib/lanes");
|
|
const { db, stmts } = require("../db");
|
|
|
|
let server;
|
|
let PORT;
|
|
|
|
before(async () => {
|
|
const app = createApp();
|
|
server = http.createServer(app);
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
PORT = server.address().port;
|
|
});
|
|
after(async () => {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
fs.rmSync(SUITE_ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
function get(urlPath) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.request(
|
|
{ method: "GET", hostname: "127.0.0.1", port: PORT, path: urlPath },
|
|
(res) => {
|
|
let raw = "";
|
|
res.on("data", (c) => (raw += c));
|
|
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(raw) }));
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
let laneSeq = 0;
|
|
function makeLane() {
|
|
laneSeq += 1;
|
|
const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`);
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed" });
|
|
}
|
|
|
|
function setStages(laneId, stages) {
|
|
db.prepare("UPDATE lanes SET stages = ? WHERE id = ?").run(JSON.stringify(stages), laneId);
|
|
}
|
|
|
|
function node(pipelineStats, pipelineId, nodeId) {
|
|
const p = pipelineStats.pipelines.find((x) => x.id === pipelineId);
|
|
return p && p.nodes.find((n) => n.id === nodeId);
|
|
}
|
|
|
|
describe("GET /api/lanes/pipeline-stats", () => {
|
|
it("counts a pass with evidence apart from a pass without one, and a fail as neither", async () => {
|
|
const lane = makeLane();
|
|
setStages(lane.id, {
|
|
plan: { enteredAt: "2026-01-01T00:00:00Z", evidence: "plan.md", result: null },
|
|
implement: { enteredAt: "2026-01-01T01:00:00Z", evidence: null, result: null },
|
|
tests: { enteredAt: "2026-01-01T02:00:00Z", evidence: null, result: "fail" },
|
|
});
|
|
|
|
const res = await get("/api/lanes/pipeline-stats");
|
|
assert.equal(res.status, 200);
|
|
|
|
const plan = node(res.body, "default", "plan");
|
|
assert.deepEqual(
|
|
{ entered: plan.entered, passed: plan.passed, withEvidence: plan.withEvidence },
|
|
{ entered: 1, passed: 1, withEvidence: 1 }
|
|
);
|
|
|
|
const implement = node(res.body, "default", "implement");
|
|
assert.deepEqual(
|
|
{ passed: implement.passed, withEvidence: implement.withEvidence },
|
|
{ passed: 1, withEvidence: 0 }
|
|
);
|
|
|
|
const tests = node(res.body, "default", "tests");
|
|
assert.deepEqual(
|
|
{ entered: tests.entered, passed: tests.passed, failed: tests.failed },
|
|
{ entered: 1, passed: 0, failed: 1 }
|
|
);
|
|
});
|
|
|
|
it("leaves a node never reached at zero", async () => {
|
|
const res = await get("/api/lanes/pipeline-stats");
|
|
assert.equal(node(res.body, "default", "ship").entered, 0);
|
|
});
|
|
|
|
it("keeps counting an archived feature's stages after the lane moves on", async () => {
|
|
const lane = makeLane();
|
|
db.prepare(
|
|
`INSERT INTO lane_features (lane_id, slug, pipeline, stage, stages, archived_at)
|
|
VALUES (?, 'old', 'default', 'done', ?, '2026-01-02T00:00:00Z')`
|
|
).run(lane.id, JSON.stringify({ gate: { enteredAt: "x", evidence: "GO", result: null } }));
|
|
|
|
const res = await get("/api/lanes/pipeline-stats");
|
|
const gate = node(res.body, "default", "gate");
|
|
assert.equal(gate.passed, 1);
|
|
assert.equal(gate.withEvidence, 1);
|
|
});
|
|
|
|
it("narrows to one lane and its own archived features with ?lane=", async () => {
|
|
const mine = makeLane();
|
|
const other = makeLane();
|
|
setStages(mine.id, { plan: { enteredAt: "x", evidence: "plan.md", result: null } });
|
|
setStages(other.id, { plan: { enteredAt: "x", evidence: null, result: null } });
|
|
db.prepare(
|
|
`INSERT INTO lane_features (lane_id, slug, pipeline, stage, stages, archived_at)
|
|
VALUES (?, 'mine-old', 'default', 'done', ?, '2026-01-02T00:00:00Z')`
|
|
).run(mine.id, JSON.stringify({ plan: { enteredAt: "x", evidence: "old.md", result: null } }));
|
|
|
|
const res = await get(`/api/lanes/pipeline-stats?lane=${mine.id}`);
|
|
assert.equal(res.status, 200);
|
|
const plan = node(res.body, "default", "plan");
|
|
// The lane itself plus its one archived feature — never the other lane's.
|
|
assert.deepEqual(
|
|
{ entered: plan.entered, withEvidence: plan.withEvidence },
|
|
{ entered: 2, withEvidence: 2 }
|
|
);
|
|
});
|
|
|
|
it("always returns every lane for the picker, even when narrowed", async () => {
|
|
const lane = makeLane();
|
|
const res = await get(`/api/lanes/pipeline-stats?lane=${lane.id}`);
|
|
assert.ok(res.body.lanes.length > 1);
|
|
assert.ok(res.body.lanes.every((l) => l.id && typeof l.cwd === "string"));
|
|
});
|
|
|
|
it("still describes the template for a lane that has recorded nothing", async () => {
|
|
const lane = makeLane();
|
|
const res = await get(`/api/lanes/pipeline-stats?lane=${lane.id}`);
|
|
assert.equal(res.status, 200);
|
|
assert.equal(node(res.body, "default", "plan").entered, 0);
|
|
});
|
|
|
|
it("returns no pipelines at all for a lane id that does not exist", async () => {
|
|
const res = await get("/api/lanes/pipeline-stats?lane=999999");
|
|
assert.equal(res.status, 200);
|
|
assert.deepEqual(res.body.pipelines, []);
|
|
});
|
|
|
|
it("rejects a lane filter that is not an id", async () => {
|
|
const res = await get("/api/lanes/pipeline-stats?lane=../etc");
|
|
assert.equal(res.status, 400);
|
|
assert.equal(res.body.error.code, "EBADLANE");
|
|
});
|
|
});
|
|
|
|
describe("GET /api/workflows/skill-usage", () => {
|
|
before(() => {
|
|
stmts.insertSession.run("usage-s1", "usage", "active", "/tmp/usage", null, null);
|
|
const ev = (type, tool, data) =>
|
|
stmts.insertEvent.run("usage-s1", null, type, tool, "", JSON.stringify(data));
|
|
|
|
// Two full calls of the same skill: each emits Pre + Post, so a naive
|
|
// COUNT(*) would report four.
|
|
for (let i = 0; i < 2; i += 1) {
|
|
ev("PreToolUse", "Skill", { tool_input: { skill: "agenthub:ship-feature" } });
|
|
ev("PostToolUse", "Skill", { tool_input: { skill: "agenthub:ship-feature" } });
|
|
}
|
|
ev("PreToolUse", "Skill", { tool_input: { skill: "daily-report" } });
|
|
ev("PreToolUse", "mcp__plugin_agenthub_agenthub__dashboard_health_check", {});
|
|
ev("PostToolUse", "mcp__plugin_agenthub_agenthub__dashboard_health_check", {});
|
|
});
|
|
|
|
it("counts one call per invocation, not one per hook event", async () => {
|
|
const res = await get("/api/workflows/skill-usage");
|
|
assert.equal(res.status, 200);
|
|
const ship = res.body.skills.find((s) => s.name === "agenthub:ship-feature");
|
|
assert.equal(ship.count, 2);
|
|
assert.equal(ship.sessions, 1);
|
|
});
|
|
|
|
it("attributes a prefixed skill to its plugin and leaves a bare one unattributed", async () => {
|
|
const res = await get("/api/workflows/skill-usage");
|
|
assert.equal(
|
|
res.body.skills.find((s) => s.name === "agenthub:ship-feature").plugin,
|
|
"agenthub"
|
|
);
|
|
assert.equal(res.body.skills.find((s) => s.name === "daily-report").plugin, null);
|
|
});
|
|
|
|
it("adds a plugin's own MCP tool calls to its totals", async () => {
|
|
const res = await get("/api/workflows/skill-usage");
|
|
const agenthub = res.body.plugins.find((p) => p.name === "agenthub");
|
|
assert.deepEqual(
|
|
{ skills: agenthub.skills, skillCalls: agenthub.skillCalls, mcpCalls: agenthub.mcpCalls },
|
|
{ skills: 1, skillCalls: 2, mcpCalls: 1 }
|
|
);
|
|
});
|
|
});
|