876c6de6d2
Lane Pipeline Pass Rates read `lanes.stages`, which is a snapshot of the
pass in flight: re-entering a node overwrites its record, and moving
backwards deletes every record ahead of the new stage. The report treated
one lane row as one run, so shipping ten features through a lane still
counted as one, and no node could ever exceed 1/1. History survived only
via `lane_features`, which is written when a feature is activated — a
path most users never take. The panel was not wrong, it was structurally
incapable of counting.
Add `lane_stage_events`, an append-only log written by every declaration
and every accepted detection, and aggregate from that. A run is one pass
through the template: the log starts a new one when the lane moves
backwards or leaves the template's last node. Existing snapshots are
seeded as one run each on first boot, so upgrading does not read as
"never run". Templates a lane is configured with are still listed at
zero — a pipeline going unused has to be visible as such.
Count inferred reaches too, in their own column and their own colour.
Detection stays out of the pass rate ("inference is never evidence"), but
reporting a stage the hooks demonstrably saw as "never reached" is what
made the panel look broken to anyone who works without running the
pipeline commands.
Also reject a stage naming no node of the lane's pipeline (400 EBADSTAGE,
listing the ones that exist) instead of storing it with a warning nobody
reads. A real lane had accumulated five such keys, one of them `test` on
a ship-feature lane whose CI node is `gates` — which is why that row read
never-reached while the stage had in fact been declared. Aliases still
resolve, so shorthand is unaffected.
223 lines
8.6 KiB
JavaScript
223 lines
8.6 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 laneFeatures = require("../lib/lane-features");
|
|
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" });
|
|
}
|
|
|
|
// Declare stages the way an agent does. Poking `lanes.stages` directly would
|
|
// test the snapshot the pass-rate report no longer reads: that blob describes
|
|
// the pass in flight, while the report counts the append-only stage log every
|
|
// declaration writes.
|
|
function declare(laneId, stages) {
|
|
for (const [stage, rec] of Object.entries(stages)) {
|
|
lanesLib.setStage(laneId, { stage, evidence: rec.evidence, result: rec.result });
|
|
}
|
|
}
|
|
|
|
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();
|
|
declare(lane.id, {
|
|
plan: { evidence: "plan.md", result: null },
|
|
implement: { evidence: null, result: null },
|
|
tests: { 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 a finished feature's run after the lane starts the next one", async () => {
|
|
// The regression this guards: the report used to read the lane's live
|
|
// `stages` blob, which the next feature overwrites. Ship two features
|
|
// through one lane and the first one's gate had never happened.
|
|
const lane = makeLane();
|
|
declare(lane.id, { gate: { evidence: "GO" } });
|
|
laneFeatures.activateFeature(lane.id, "next-feature");
|
|
declare(lane.id, { plan: { evidence: null } });
|
|
|
|
const res = await get(`/api/lanes/pipeline-stats?lane=${lane.id}`);
|
|
const gate = node(res.body, "default", "gate");
|
|
assert.equal(gate.passed, 1);
|
|
assert.equal(gate.withEvidence, 1);
|
|
assert.equal(node(res.body, "default", "plan").passed, 1);
|
|
// Two passes through the pipeline, counted as two runs rather than one lane.
|
|
assert.equal(res.body.pipelines.find((p) => p.id === "default").runs, 2);
|
|
});
|
|
|
|
it("narrows to one lane and all of its own runs with ?lane=", async () => {
|
|
const mine = makeLane();
|
|
const other = makeLane();
|
|
declare(mine.id, { plan: { evidence: "plan.md" }, done: {} });
|
|
declare(mine.id, { plan: { evidence: "again.md" } });
|
|
declare(other.id, { plan: { evidence: null } });
|
|
|
|
const res = await get(`/api/lanes/pipeline-stats?lane=${mine.id}`);
|
|
assert.equal(res.status, 200);
|
|
const plan = node(res.body, "default", "plan");
|
|
// Both of this lane's passes — 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 }
|
|
);
|
|
});
|
|
});
|