/** * @file GET /api/workflows tool-to-tool transitions (`stats.topFlow`, * `toolFlow.transitions`): each tool event pairs with the NEXT tool event of the * same session, and the query stays linear in the session's event count — the * old correlated `MIN(id)` subquery went quadratic once `idx_events_session` * was dropped and froze the whole server on a large session. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("fs"); const os = require("os"); const path = require("path"); const TEST_DB = path.join(os.tmpdir(), `dashboard-toolflow-test-${Date.now()}-${process.pid}.db`); process.env.DASHBOARD_DB_PATH = TEST_DB; const { createApp, startServer } = require("../index"); const { db } = require("../db"); let server; let BASE; function addSession(id, tools) { db.prepare("INSERT INTO sessions (id, status) VALUES (?, 'completed')").run(id); const ins = db.prepare("INSERT INTO events (session_id, event_type, tool_name) VALUES (?, ?, ?)"); db.transaction(() => { for (const t of tools) ins.run(id, t ? "PreToolUse" : "Stop", t); })(); } before(async () => { // A: Read → (Stop, no tool) → Edit → Read ; B: Read → Edit addSession("flow-a", ["Read", null, "Edit", "Read"]); addSession("flow-b", ["Read", "Edit"]); server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; }); after(() => { server?.close(); try { db.close(); } catch {} for (const s of ["", "-wal", "-shm"]) fs.rmSync(TEST_DB + s, { force: true }); }); describe("GET /api/workflows tool transitions", () => { it("pairs each tool event with the next tool event of its own session", async () => { const body = await (await fetch(`${BASE}/api/workflows`)).json(); const flow = Object.fromEntries( body.toolFlow.transitions.map((t) => [`${t.source}>${t.target}`, t.value]) ); // Read>Edit twice (A skips its Stop, B), Edit>Read once; nothing crosses sessions. assert.deepEqual(flow, { "Read>Edit": 2, "Edit>Read": 1 }); assert.deepEqual(body.stats.topFlow, { source: "Read", target: "Edit", count: 2 }); }); it("?sections= computes only the named parts, and refuses names it does not know", async () => { const body = await (await fetch(`${BASE}/api/workflows?sections=stats,toolFlow,bogus`)).json(); assert.deepEqual(Object.keys(body), ["stats", "toolFlow"]); assert.equal(body.stats.topFlow.source, "Read"); const bad = await fetch(`${BASE}/api/workflows?sections=bogus`); assert.equal(bad.status, 400); assert.equal((await bad.json()).error.code, "INVALID_SECTIONS"); }); it("stays fast on one large session", async () => { const tools = ["Read", "Edit", "Bash", "Grep"]; addSession( "flow-big", Array.from({ length: 20000 }, (_, i) => tools[i % 4]) ); const t0 = Date.now(); const res = await fetch(`${BASE}/api/workflows`); assert.equal(res.status, 200); await res.json(); assert.ok(Date.now() - t0 < 3000, `took ${Date.now() - t0} ms`); }); });