c7dbaf2d9f
Two leftovers from the rebrand, done together because they touch the same docs and manifests. **Data directory.** `agent-dashboard` could not simply be renamed in the source: it names a real directory holding the live SQLite database. Startup now moves it once, via `migrateLegacyDataDir()` in `server/lib/claude-home.js` — a same-filesystem rename, so it is instant even at multiple gigabytes and cannot leave two divergent copies. `getDataDir()` stays PURE and resolves to the legacy path until the move happens; the first version of this put the rename inside the getter, which meant `npm run test:server` relocated the developer's real database as a side effect. The call sits behind `require.main === module` and ahead of the router requires, because those pull in db.js, which opens SQLite at module load. A failed rename is silent and the old directory keeps being used: an unfashionable directory name beats a blank database beside an unreachable one. Same treatment for the port-discovery file (`.agent-dashboard.json` → `.agenthub.json`, old one dropped on write). Not renamed on purpose: the Sagi localStorage keys (renaming silently resets everyone's prefs) and the PWA manifest `id` (changing it orphans an installed app). **MCP server.** 4 tool calls in 46 days across 577,132 events, against 4,556 lines of TypeScript, a 2 MB committed build artifact, and a pre-commit guard that had already blocked commits twice. Deleted: `mcp/`, the maintainer skills and `mcp-reviewer` agent, the build check, `plugins/agenthub/.mcp.json`, `docs/MCP.md`, 12 npm scripts, 5 Makefile targets, and every doc section describing it. Kept, because they are unrelated things sharing the name: the Config Explorer's MCP tab and its API, per-lane Playwright MCP, the agenthub-config plugin's MCP audit, and the historical `mcp__plugin_agenthub_*` events that analytics must keep attributing. Verified: server 1432/1432, client 459/459, tsc clean, prettier clean. Both directions of the migration proven — a sandbox-HOME test run leaves the legacy directory untouched, and a real boot moves it with its contents intact.
214 lines
8.3 KiB
JavaScript
214 lines
8.3 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" } });
|
|
// AgentHub no longer ships an MCP server, but events recorded while it did
|
|
// are still in the database, and analytics must keep attributing them.
|
|
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 }
|
|
);
|
|
});
|
|
});
|