57dc91585d
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.
197 lines
6.2 KiB
JavaScript
197 lines
6.2 KiB
JavaScript
/**
|
|
* @file Integration test for the Workflow-tool run feature (issue #167): a Stop
|
|
* hook with a transcript_path triggers on-disk journal ingestion off the
|
|
* response path, and the run then surfaces via GET /api/workflows/runs,
|
|
* GET /api/workflows/runs/:runId, and the session-detail `workflows[]` field.
|
|
* @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 fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const http = require("http");
|
|
|
|
const TEST_DB = path.join(os.tmpdir(), `dashboard-wfapi-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;
|
|
let ROOT;
|
|
const SESSION_ID = "wfapi-sess-1";
|
|
|
|
function fetchJson(urlPath, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(urlPath, BASE);
|
|
const req = http.request(
|
|
{
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
path: url.pathname + url.search,
|
|
method: options.method || "GET",
|
|
headers: { "Content-Type": "application/json", ...options.headers },
|
|
},
|
|
(res) => {
|
|
let body = "";
|
|
res.on("data", (c) => (body += c));
|
|
res.on("end", () => {
|
|
try {
|
|
resolve({ status: res.statusCode, body: body ? JSON.parse(body) : null });
|
|
} catch {
|
|
resolve({ status: res.statusCode, body });
|
|
}
|
|
});
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
if (options.body) req.write(JSON.stringify(options.body));
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
before(async () => {
|
|
ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "wfapi-fixture-"));
|
|
const transcriptPath = path.join(ROOT, `${SESSION_ID}.jsonl`);
|
|
fs.writeFileSync(transcriptPath, "");
|
|
|
|
// On-disk run journal next to the transcript.
|
|
const wfDir = path.join(ROOT, SESSION_ID, "workflows");
|
|
fs.mkdirSync(wfDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(wfDir, "wf_apitest1.json"),
|
|
JSON.stringify({
|
|
runId: "wf_apitest1",
|
|
workflowName: "api-review",
|
|
status: "completed",
|
|
startTime: 1700001000000,
|
|
durationMs: 4000,
|
|
defaultModel: "claude-opus-4-8",
|
|
agentCount: 1,
|
|
totalTokens: 999,
|
|
totalToolCalls: 2,
|
|
phases: [{ title: "Scan" }],
|
|
workflowProgress: [
|
|
{ type: "workflow_phase", index: 1, title: "Scan" },
|
|
{
|
|
type: "workflow_agent",
|
|
index: 1,
|
|
agentId: "x1",
|
|
state: "done",
|
|
phaseTitle: "Scan",
|
|
label: "scan:repo",
|
|
tokens: 999,
|
|
toolCalls: 2,
|
|
},
|
|
],
|
|
})
|
|
);
|
|
|
|
const app = createApp();
|
|
server = await startServer(app, 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
// Drive a Stop hook with the transcript_path — this is what triggers the
|
|
// post-response workflow ingest in the hooks router.
|
|
await fetchJson("/api/hooks/event", {
|
|
method: "POST",
|
|
body: {
|
|
hook_type: "Stop",
|
|
data: {
|
|
session_id: SESSION_ID,
|
|
cwd: "/tmp/proj",
|
|
transcript_path: transcriptPath,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Ingest is fire-and-forget after res.json; poll until it lands.
|
|
for (let i = 0; i < 40; i++) {
|
|
const r = await fetchJson(`/api/workflows/runs?session_id=${SESSION_ID}`);
|
|
if (r.body && r.body.runs && r.body.runs.length > 0) break;
|
|
await sleep(50);
|
|
}
|
|
});
|
|
|
|
after(() => {
|
|
server?.close();
|
|
try {
|
|
db.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
fs.rmSync(TEST_DB, { force: true });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
});
|
|
|
|
describe("GET /api/workflows/runs", () => {
|
|
it("lists the ingested run with hydrated phases/progress arrays", async () => {
|
|
const r = await fetchJson(`/api/workflows/runs?session_id=${SESSION_ID}`);
|
|
assert.equal(r.status, 200);
|
|
assert.ok(Array.isArray(r.body.runs));
|
|
const run = r.body.runs.find((x) => x.run_id === "wf_apitest1");
|
|
assert.ok(run, "run present");
|
|
assert.equal(run.name, "api-review");
|
|
assert.equal(run.status, "completed");
|
|
assert.equal(run.total_tokens, 999);
|
|
assert.ok(Array.isArray(run.phases) && run.phases.length === 1);
|
|
assert.ok(Array.isArray(run.progress) && run.progress.length === 2);
|
|
assert.equal(run.progress.filter((p) => p.type === "workflow_agent").length, 1);
|
|
assert.equal(typeof r.body.total, "number");
|
|
assert.ok(r.body.counts && typeof r.body.counts === "object");
|
|
assert.ok(r.body.counts.completed >= 1, "status counts include completed");
|
|
});
|
|
|
|
it("filters by status", async () => {
|
|
const completed = await fetchJson("/api/workflows/runs?status=completed");
|
|
assert.equal(completed.status, 200);
|
|
assert.ok(completed.body.runs.some((x) => x.run_id === "wf_apitest1"));
|
|
const running = await fetchJson("/api/workflows/runs?status=running");
|
|
assert.equal(running.status, 200);
|
|
assert.ok(
|
|
!running.body.runs.some((x) => x.run_id === "wf_apitest1"),
|
|
"completed run excluded from the running filter"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/workflows/runs/:runId", () => {
|
|
it("returns the run with its linked inner agents", async () => {
|
|
const r = await fetchJson("/api/workflows/runs/wf_apitest1");
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.workflow.run_id, "wf_apitest1");
|
|
assert.ok(Array.isArray(r.body.agents));
|
|
const linked = r.body.agents.find((a) => a.id === `${SESSION_ID}-jsonl-x1`);
|
|
assert.ok(linked, "inner agent linked");
|
|
assert.equal(linked.workflow_run_id, "wf_apitest1");
|
|
assert.equal(linked.workflow_phase, "Scan");
|
|
});
|
|
|
|
it("404s an unknown run id", async () => {
|
|
const r = await fetchJson("/api/workflows/runs/wf_nope");
|
|
assert.equal(r.status, 404);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/sessions/:id includes workflows[]", () => {
|
|
it("surfaces the run on the parent session detail", async () => {
|
|
const r = await fetchJson(`/api/sessions/${SESSION_ID}`);
|
|
assert.equal(r.status, 200);
|
|
assert.ok(Array.isArray(r.body.workflows));
|
|
assert.ok(r.body.workflows.some((w) => w.run_id === "wf_apitest1"));
|
|
});
|
|
});
|