feat: right-click menus everywhere, workspace/session cost, long-term DB safety
Right-click menus: one portal shell (components/ContextMenu.tsx) for every item with actions - sidebar/Recents/archived session rows (SessionMenu), console tabs, pane headers, the Changes panel's files/tree/commits, the workspace home, sessions outside a workspace (SessionRowMenu: Sessions table, Dashboard, Activity, Workflow runs), prompt images, every Settings list and every Claude Code/Codex config list (FileItemMenu). Buttons stay; a delete keeps its button's confirm; the shell stops portal clicks from reaching the card that opened it. Status bar: Workspace $ and Session $ on the Workspace page, from ?lane= on GET /api/pricing/cost and the focused/replayed session's cost. Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB, hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created (replacing the redundant idx_events_session), watchdog reads sessions.transcript_path, transcript parsed before the hook's write transaction, GET /api/sessions/:id?events=0 for the web page (reloads only on its own session, debounced), streamed export, transactional clear-data and chunked cleanup, subagent import reads tool_use_ids once, runtime logs rotated past 5 MB.
This commit is contained in:
@@ -1813,13 +1813,57 @@ describe("Database Integrity", () => {
|
||||
|
||||
assert.ok(indexes.includes("idx_agents_session"));
|
||||
assert.ok(indexes.includes("idx_agents_status"));
|
||||
assert.ok(indexes.includes("idx_events_session"));
|
||||
// (session_id, created_at) replaces the bare session_id index, a prefix of
|
||||
// idx_events_session_type that only cost disk.
|
||||
assert.ok(indexes.includes("idx_events_session_created"));
|
||||
assert.ok(!indexes.includes("idx_events_session"));
|
||||
assert.ok(indexes.includes("idx_events_type"));
|
||||
assert.ok(indexes.includes("idx_events_created"));
|
||||
assert.ok(indexes.includes("idx_sessions_status"));
|
||||
assert.ok(indexes.includes("idx_sessions_started"));
|
||||
});
|
||||
|
||||
it("keeps the WAL bounded and fsyncs at checkpoints, not every commit", () => {
|
||||
assert.equal(db.pragma("synchronous", { simple: true }), 1); // NORMAL
|
||||
assert.equal(db.pragma("journal_size_limit", { simple: true }), 64 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("exports every event in time order, streamed in pages", async () => {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO sessions (id, name, status, started_at, updated_at) VALUES ('exp-s', 'e', 'completed', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')"
|
||||
).run();
|
||||
const ins = db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, summary, data, created_at) VALUES ('exp-s', 'PostToolUse', ?, '{}', ?)"
|
||||
);
|
||||
db.transaction(() => {
|
||||
// 1203 rows, with ties on created_at, across the 500-row page edges.
|
||||
for (let i = 0; i < 1203; i++) {
|
||||
const ts = new Date(Date.UTC(2026, 0, 1, 0, 0, Math.floor(i / 3))).toISOString();
|
||||
ins.run(`exp-${i}`, ts);
|
||||
}
|
||||
})();
|
||||
const all = db.prepare("SELECT COUNT(*) AS c FROM events").get().c;
|
||||
const res = await fetch("/api/settings/export");
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers["content-disposition"], /attachment/);
|
||||
const { events, sessions, agents, token_usage, model_pricing, exported_at } = res.body;
|
||||
assert.ok(exported_at && sessions && agents && token_usage && model_pricing);
|
||||
assert.equal(events.length, all);
|
||||
assert.equal(new Set(events.map((e) => e.id)).size, all);
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
assert.ok(events[i - 1].created_at >= events[i].created_at);
|
||||
}
|
||||
assert.equal(events.filter((e) => e.session_id === "exp-s").length, 1203);
|
||||
});
|
||||
|
||||
it("upkeep checkpoints without waiting and optimizes", () => {
|
||||
const { maintainDatabase } = require("../db");
|
||||
const out = maintainDatabase();
|
||||
assert.equal(out.checkpointError, undefined);
|
||||
assert.equal(out.optimizeError, undefined);
|
||||
assert.equal(out.checkpoint[0].busy, 0);
|
||||
});
|
||||
|
||||
it("should use WAL journal mode", () => {
|
||||
const mode = db.pragma("journal_mode", { simple: true });
|
||||
assert.equal(mode, "wal");
|
||||
|
||||
@@ -154,6 +154,31 @@ describe("GET /api/pricing/cost daily split", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/pricing/cost?lane=", () => {
|
||||
it("prices only the sessions a workspace owns, including one its run holds unstamped", async () => {
|
||||
const lane = require("../lib/lanes").createLane({
|
||||
title: "w",
|
||||
cwd: fs.mkdtempSync(path.join(ROOT, "lane-")),
|
||||
tool: "claude",
|
||||
});
|
||||
session("owned", "2026-08-01T00:00:00.000Z");
|
||||
session("held", "2026-08-01T00:00:00.000Z");
|
||||
session("other", "2026-08-01T00:00:00.000Z");
|
||||
db.prepare("UPDATE sessions SET lane_id = ? WHERE id = 'owned'").run(lane.id);
|
||||
db.prepare(
|
||||
"INSERT INTO dashboard_runs (id, lane_id, session_id, cwd, status) VALUES ('run-held', ?, 'held', '/x', 'exited')"
|
||||
).run(lane.id);
|
||||
total("owned", 2 * M);
|
||||
total("held", 1 * M);
|
||||
total("other", 5 * M);
|
||||
|
||||
const r = await call("GET", `/api/pricing/cost?tz_offset=0&lane=${lane.id}`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.total_cost, 3);
|
||||
assert.equal((await call("GET", "/api/pricing/cost?lane=x")).status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TranscriptCache tokensByHour", () => {
|
||||
it("splits by message hour, counts a message once, and survives an incremental read", () => {
|
||||
const file = path.join(ROOT, "t.jsonl");
|
||||
|
||||
@@ -375,6 +375,18 @@ describe("test probe + clear-data", () => {
|
||||
assert.equal(stmts.lastWebhookDeliveryForTarget.get(created.body.target.id), undefined);
|
||||
assert.ok(stmts.getWebhookTarget.get(created.body.target.id)); // target survives
|
||||
});
|
||||
|
||||
it("clear-data leaves no workflow run behind its cleared session, and turns foreign keys back on", async () => {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO sessions (id, name, status, started_at, updated_at) VALUES ('wf-s', 'w', 'completed', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')"
|
||||
).run();
|
||||
db.prepare("INSERT INTO workflows (run_id, session_id) VALUES ('wf-run', 'wf-s')").run();
|
||||
const res = await post("/api/settings/clear-data");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM workflows").get().c, 0);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions").get().c, 0);
|
||||
assert.equal(db.pragma("foreign_keys", { simple: true }), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider registry", () => {
|
||||
|
||||
+44
-2
@@ -144,6 +144,13 @@ const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
db.pragma("busy_timeout = 5000");
|
||||
// WAL + NORMAL never corrupts the file; a power cut can lose only the last
|
||||
// commits, which hooks re-send. FULL fsynced every commit — several per hook.
|
||||
db.pragma("synchronous = NORMAL");
|
||||
// A checkpoint truncates the WAL back to this. Unset, one big transaction
|
||||
// (an import, a cleanup) left a WAL of hundreds of MB that never shrank while
|
||||
// any dashboard kept the database open. See also `maintainDatabase`.
|
||||
db.pragma("journal_size_limit = 67108864");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
@@ -298,7 +305,12 @@ db.exec(`
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_session ON agents(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
|
||||
-- (session_id, created_at): a session's newest event and its time order,
|
||||
-- read by the watchdog every 15 s and by every lane payload. Without it each
|
||||
-- read walked the rows' overflow chains (created_at sits after data).
|
||||
CREATE INDEX IF NOT EXISTS idx_events_session_created ON events(session_id, created_at);
|
||||
-- A prefix of idx_events_session_type: never needed, ~1% of the file.
|
||||
DROP INDEX IF EXISTS idx_events_session;
|
||||
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
||||
@@ -2127,4 +2139,34 @@ const stmts = {
|
||||
),
|
||||
};
|
||||
|
||||
module.exports = { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing, topUpDefaultPricing };
|
||||
/**
|
||||
* Periodic upkeep, run hourly by the server: a PASSIVE checkpoint (never waits
|
||||
* on a reader — the other dashboard's), after which the next write truncates
|
||||
* the WAL to `journal_size_limit`; and `PRAGMA optimize`, which refreshes the
|
||||
* planner's statistics only where they went stale. Never VACUUM: it rewrites
|
||||
* the whole file under an exclusive lock.
|
||||
*/
|
||||
function maintainDatabase() {
|
||||
const out = {};
|
||||
try {
|
||||
out.checkpoint = db.pragma("wal_checkpoint(PASSIVE)", { simple: false });
|
||||
} catch (err) {
|
||||
out.checkpointError = err.message;
|
||||
}
|
||||
try {
|
||||
db.pragma("optimize");
|
||||
} catch (err) {
|
||||
out.optimizeError = err.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
db,
|
||||
stmts,
|
||||
DB_PATH,
|
||||
DEFAULT_PRICING,
|
||||
applyIntroPricing,
|
||||
topUpDefaultPricing,
|
||||
maintainDatabase,
|
||||
};
|
||||
|
||||
@@ -347,6 +347,16 @@ function startBackgroundServices() {
|
||||
.then(() => require("./lib/ai-tools").refresh())
|
||||
.catch(() => {});
|
||||
|
||||
// Hourly DB upkeep: a non-blocking checkpoint (the WAL then shrinks to
|
||||
// journal_size_limit) and PRAGMA optimize. Off with DASHBOARD_DB_MAINTAIN_MS=0.
|
||||
{
|
||||
const every = Number(process.env.DASHBOARD_DB_MAINTAIN_MS ?? 60 * 60 * 1000);
|
||||
if (every > 0) {
|
||||
const t = setInterval(() => require("./db").maintainDatabase(), every);
|
||||
if (t.unref) t.unref();
|
||||
}
|
||||
}
|
||||
|
||||
// Rates for models seen in token_usage with no pricing row of their own.
|
||||
require("./lib/pricing-sync").startPricingSync(require("./db").db);
|
||||
// The AI Tools catalog of the newest release, when it is newer than this build.
|
||||
|
||||
+25
-9
@@ -1367,6 +1367,14 @@ router.post("/event", (req, res) => {
|
||||
return res.json({ ok: true, duplicate: true });
|
||||
}
|
||||
|
||||
// Parse the transcript BEFORE the write transaction: `processEvent` takes the
|
||||
// write lock at its first statement and reads the transcript inside it, so a
|
||||
// cold multi-MB parse held the lock and the other dashboard on this DB hit
|
||||
// SQLITE_BUSY. Warmed here, the read inside is a stat-check cache hit.
|
||||
if (tool === "claude" && typeof data.transcript_path === "string" && data.transcript_path) {
|
||||
transcriptCache.extract(data.transcript_path);
|
||||
}
|
||||
|
||||
const result = processEvent(hook_type, data, tool);
|
||||
if (!result) {
|
||||
return res.status(400).json({
|
||||
@@ -1482,6 +1490,12 @@ router.post("/event", (req, res) => {
|
||||
// sessions every 15s to detect errors that hooks missed.
|
||||
const WATCHDOG_INTERVAL_MS = 15_000;
|
||||
const STALE_THRESHOLD_MS = 10_000; // only check sessions idle for >10s
|
||||
// The watchdog's fallback for a session with no transcript_path column value.
|
||||
const lastTranscriptEvent = db.prepare(
|
||||
`SELECT data FROM events WHERE session_id = ?
|
||||
AND event_type IN ('SessionStart','UserPromptSubmit','PreToolUse','Stop','Notification')
|
||||
ORDER BY created_at DESC LIMIT 1`
|
||||
);
|
||||
|
||||
// Idle-working timeout for the no-marker interrupt case (Esc pressed before the
|
||||
// model emits anything → Claude Code writes no interrupt entry and fires no
|
||||
@@ -1541,11 +1555,11 @@ function watchdogCheck() {
|
||||
// lifecycle is owned by server/lib/codex-usage.js.
|
||||
const staleSessions = db
|
||||
.prepare(
|
||||
`SELECT s.id, s.status, s.cwd,
|
||||
(SELECT MAX(e.created_at) FROM events e WHERE e.session_id = s.id) as last_event,
|
||||
(SELECT e.data FROM events e WHERE e.session_id = s.id
|
||||
AND e.event_type IN ('SessionStart','UserPromptSubmit','PreToolUse','Stop','Notification')
|
||||
ORDER BY e.created_at DESC LIMIT 1) as last_data
|
||||
// transcript_path is the session's own column (every hook keeps it);
|
||||
// reading it out of the newest event's `data` walked a multi-KB payload
|
||||
// per session, every 15 s, in every dashboard on this database.
|
||||
`SELECT s.id, s.status, s.cwd, s.transcript_path,
|
||||
(SELECT MAX(e.created_at) FROM events e WHERE e.session_id = s.id) as last_event
|
||||
FROM sessions s
|
||||
WHERE s.status IN ('active', 'error') AND s.updated_at < ?
|
||||
AND (s.source = 'local' OR s.source IS NULL) AND s.tool = 'claude'`
|
||||
@@ -1553,11 +1567,13 @@ function watchdogCheck() {
|
||||
.all(cutoff);
|
||||
|
||||
for (const sess of staleSessions) {
|
||||
// Try to get transcript_path from event data first
|
||||
let tPath = null;
|
||||
if (sess.last_data) {
|
||||
// The session's recorded transcript first, else the newest hook payload
|
||||
// that named one (a row from before the column was filled).
|
||||
let tPath = sess.transcript_path || null;
|
||||
if (!tPath) {
|
||||
const lastData = lastTranscriptEvent.get(sess.id)?.data;
|
||||
try {
|
||||
tPath = JSON.parse(sess.last_data).transcript_path;
|
||||
tPath = lastData ? JSON.parse(lastData).transcript_path : null;
|
||||
} catch {}
|
||||
}
|
||||
// Fall back: derive from Claude's standard path layout
|
||||
|
||||
@@ -463,15 +463,31 @@ router.delete("/:pattern", (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/pricing/cost - Get total cost across all sessions.
|
||||
// GET /api/pricing/cost - Get total cost across all sessions (`?lane=<id>`: the
|
||||
// sessions that workspace owns).
|
||||
// Honors the global data-scope: `?sources=<csv>` narrows the aggregate to those
|
||||
// origin machines (see server/lib/source-filter.js), matching the sessions /
|
||||
// stats / analytics endpoints so the Dashboard "total cost" tracks the selector.
|
||||
router.get("/cost", (req, res) => {
|
||||
const rawOffset = parseInt(req.query.tz_offset, 10);
|
||||
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
|
||||
const { clause, params } = sourceColumnClause(parseSources(req), "s.source");
|
||||
const whereClause = clause ? `WHERE ${clause}` : "";
|
||||
const lane = req.query.lane;
|
||||
if (lane !== undefined && !/^\d+$/.test(String(lane))) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "INVALID_INPUT", message: "lane must be a lane id" } });
|
||||
}
|
||||
const scope = sourceColumnClause(parseSources(req), "s.source");
|
||||
const params = [...scope.params];
|
||||
const where = scope.clause ? [scope.clause] : [];
|
||||
if (lane !== undefined) {
|
||||
// The sessions the workspace owns — the same rule as GET /api/sessions?lane=.
|
||||
where.push(
|
||||
"(s.lane_id = ? OR (s.lane_id IS NULL AND s.id IN (SELECT session_id FROM dashboard_runs WHERE lane_id = ?)))"
|
||||
);
|
||||
params.push(Number(lane), Number(lane));
|
||||
}
|
||||
const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||
|
||||
const dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
|
||||
const rules = stmts.listPricing.all();
|
||||
|
||||
@@ -300,7 +300,9 @@ router.get("/:id", (req, res) => {
|
||||
// Each agent's OWN cost (from its metadata token buckets) so subagent cards on
|
||||
// the session-detail tree show their real cost, not the session total.
|
||||
const agents = attachAgentCosts(stmts.listAgentsBySession.all(req.params.id));
|
||||
const events = stmts.listEventsBySession.all(req.params.id);
|
||||
// `?events=0`: the web page pages its timeline separately, and a long
|
||||
// session's full list is hundreds of MB of payload. The CLI still asks.
|
||||
const events = req.query.events === "0" ? [] : stmts.listEventsBySession.all(req.params.id);
|
||||
// Workflow-tool runs launched within this session (issue #167). Parse the
|
||||
// JSON-blob columns so the client gets arrays, not strings.
|
||||
const workflows = stmts.listWorkflowsBySession.all(req.params.id).map((w) => {
|
||||
|
||||
+93
-44
@@ -141,7 +141,19 @@ router.get("/info", (req, res) => {
|
||||
// POST /api/settings/clear-data — delete all sessions, agents, events, tokens
|
||||
router.post("/clear-data", (_req, res) => {
|
||||
const counts = getTableCounts();
|
||||
// foreign_keys cannot change inside a transaction, so it is turned off
|
||||
// around one: all of it lands, or none of it (a crash midway used to leave
|
||||
// events without sessions).
|
||||
db.pragma("foreign_keys = OFF");
|
||||
try {
|
||||
db.transaction(clearAll)();
|
||||
} finally {
|
||||
db.pragma("foreign_keys = ON");
|
||||
}
|
||||
res.json({ ok: true, cleared: counts });
|
||||
});
|
||||
|
||||
function clearAll() {
|
||||
db.prepare("DELETE FROM token_usage").run();
|
||||
db.prepare("DELETE FROM token_usage_hourly").run();
|
||||
db.prepare("DELETE FROM events").run();
|
||||
@@ -157,9 +169,10 @@ router.post("/clear-data", (_req, res) => {
|
||||
// Webhook delivery log is an audit trail of those fired alerts — wipe it too.
|
||||
// Webhook *targets* survive, like alert rules and pricing.
|
||||
db.prepare("DELETE FROM webhook_deliveries").run();
|
||||
db.pragma("foreign_keys = ON");
|
||||
res.json({ ok: true, cleared: counts });
|
||||
});
|
||||
// Workflow runs belong to the sessions; with foreign keys off they would
|
||||
// not cascade and stay as orphans.
|
||||
db.prepare("DELETE FROM workflows").run();
|
||||
}
|
||||
|
||||
// POST /api/settings/reimport — re-import legacy sessions from ~/.claude/
|
||||
router.post("/reimport", async (_req, res) => {
|
||||
@@ -257,27 +270,53 @@ router.post("/reset-pricing", async (req, res) => {
|
||||
res.json({ ok: true, pricing });
|
||||
});
|
||||
|
||||
// GET /api/settings/export — export all data as JSON
|
||||
router.get("/export", (_req, res) => {
|
||||
const sessions = db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all();
|
||||
const agents = db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all();
|
||||
const events = db.prepare("SELECT * FROM events ORDER BY created_at DESC").all();
|
||||
const tokenUsage = db.prepare("SELECT * FROM token_usage").all();
|
||||
const pricing = stmts.listPricing.all();
|
||||
|
||||
// GET /api/settings/export — export all data as JSON. Same document as ever,
|
||||
// but written as it is read: `events` alone is GBs on a long-lived install,
|
||||
// and one `.all()` + `res.json` held it in memory twice (past V8's string
|
||||
// limit). Events go out in keyset pages, waiting for the socket to drain.
|
||||
const EXPORT_PAGE = 500;
|
||||
router.get("/export", async (req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="agent-monitor-export-${new Date().toISOString().slice(0, 10)}.json"`
|
||||
);
|
||||
res.json({
|
||||
exported_at: new Date().toISOString(),
|
||||
sessions,
|
||||
agents,
|
||||
events,
|
||||
token_usage: tokenUsage,
|
||||
model_pricing: pricing,
|
||||
});
|
||||
let gone = false;
|
||||
req.on("close", () => (gone = true));
|
||||
const write = async (chunk) => {
|
||||
if (!res.write(chunk)) await new Promise((r) => res.once("drain", r));
|
||||
};
|
||||
const field = (name, value) => `,${JSON.stringify(name)}:${JSON.stringify(value)}`;
|
||||
try {
|
||||
await write(`{"exported_at":${JSON.stringify(new Date().toISOString())}`);
|
||||
await write(
|
||||
field("sessions", db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all())
|
||||
);
|
||||
await write(field("agents", db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all()));
|
||||
await write(`,"events":[`);
|
||||
const first = db.prepare(
|
||||
`SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT ${EXPORT_PAGE}`
|
||||
);
|
||||
const next = db.prepare(
|
||||
`SELECT * FROM events WHERE created_at < ? OR (created_at = ? AND id < ?)
|
||||
ORDER BY created_at DESC, id DESC LIMIT ${EXPORT_PAGE}`
|
||||
);
|
||||
let page = first.all();
|
||||
let sep = "";
|
||||
while (page.length && !gone) {
|
||||
await write(sep + page.map((e) => JSON.stringify(e)).join(","));
|
||||
sep = ",";
|
||||
const last = page[page.length - 1];
|
||||
page = next.all(last.created_at, last.created_at, last.id);
|
||||
}
|
||||
await write("]");
|
||||
await write(field("token_usage", db.prepare("SELECT * FROM token_usage").all()));
|
||||
await write(field("model_pricing", stmts.listPricing.all()) + "}");
|
||||
res.end();
|
||||
} catch (err) {
|
||||
console.warn("export failed:", err.message);
|
||||
res.destroy(err);
|
||||
}
|
||||
});
|
||||
|
||||
// The two editable home directories share one shape: GET answers the value and
|
||||
@@ -319,6 +358,7 @@ homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaud
|
||||
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
|
||||
|
||||
// POST /api/settings/cleanup — abandon stale sessions, purge old data
|
||||
const PURGE_CHUNK = 500;
|
||||
router.post("/cleanup", (req, res) => {
|
||||
const { abandon_hours, purge_days } = req.body;
|
||||
const result = { abandoned: 0, purged_sessions: 0, purged_events: 0, purged_agents: 0 };
|
||||
@@ -337,15 +377,19 @@ router.post("/cleanup", (req, res) => {
|
||||
)
|
||||
.all(cutoff, cutoff);
|
||||
|
||||
for (const row of stale) {
|
||||
db.prepare(
|
||||
"UPDATE sessions SET status = 'abandoned', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
|
||||
).run(row.id);
|
||||
// Also complete any lingering agents
|
||||
db.prepare(
|
||||
"UPDATE agents SET status = 'completed', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE session_id = ? AND status IN ('waiting','working')"
|
||||
).run(row.id);
|
||||
}
|
||||
const abandon = db.prepare(
|
||||
"UPDATE sessions SET status = 'abandoned', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
|
||||
);
|
||||
// Also complete any lingering agents
|
||||
const complete = db.prepare(
|
||||
"UPDATE agents SET status = 'completed', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE session_id = ? AND status IN ('waiting','working')"
|
||||
);
|
||||
db.transaction(() => {
|
||||
for (const row of stale) {
|
||||
abandon.run(row.id);
|
||||
complete.run(row.id);
|
||||
}
|
||||
})();
|
||||
result.abandoned = stale.length;
|
||||
}
|
||||
|
||||
@@ -358,23 +402,28 @@ router.post("/cleanup", (req, res) => {
|
||||
)
|
||||
.all(cutoff);
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
// One transaction, in chunks: a single IN (...) list of every id would
|
||||
// pass SQLite's bound-variable limit on a large history.
|
||||
db.transaction(() => {
|
||||
const ids = toDelete.map((r) => r.id);
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
// Cascading deletes handle agents/events, but token_usage FK might not cascade on all setups
|
||||
result.purged_events = db
|
||||
.prepare(`DELETE FROM events WHERE session_id IN (${placeholders})`)
|
||||
.run(...ids).changes;
|
||||
result.purged_agents = db
|
||||
.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`)
|
||||
.run(...ids).changes;
|
||||
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...ids);
|
||||
db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run(
|
||||
...ids
|
||||
);
|
||||
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...ids);
|
||||
result.purged_sessions = toDelete.length;
|
||||
}
|
||||
for (let i = 0; i < ids.length; i += PURGE_CHUNK) {
|
||||
const chunk = ids.slice(i, i + PURGE_CHUNK);
|
||||
const placeholders = chunk.map(() => "?").join(",");
|
||||
// Cascading deletes handle agents/events, but token_usage FK might not cascade on all setups
|
||||
result.purged_events += db
|
||||
.prepare(`DELETE FROM events WHERE session_id IN (${placeholders})`)
|
||||
.run(...chunk).changes;
|
||||
result.purged_agents += db
|
||||
.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`)
|
||||
.run(...chunk).changes;
|
||||
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...chunk);
|
||||
db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run(
|
||||
...chunk
|
||||
);
|
||||
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...chunk);
|
||||
}
|
||||
result.purged_sessions = ids.length;
|
||||
})();
|
||||
}
|
||||
|
||||
res.json({ ok: true, ...result });
|
||||
|
||||
Reference in New Issue
Block a user