Skip to content

Commit e57b361

Browse files
authored
feat(tasks): move task ledger to sqlite and add audit CLI (#57361)
* feat(tasks): move task ledger to sqlite * feat(tasks): add task run audit command * style(tasks): normalize audit command formatting * fix(tasks): address audit summary and sqlite perms * fix(tasks): avoid duplicate lost audit findings
1 parent 6f09a68 commit e57b361

13 files changed

Lines changed: 1071 additions & 78 deletions

src/cli/program/register.status-health-sessions.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const healthCommand = vi.fn();
77
const sessionsCommand = vi.fn();
88
const sessionsCleanupCommand = vi.fn();
99
const tasksListCommand = vi.fn();
10+
const tasksAuditCommand = vi.fn();
1011
const tasksShowCommand = vi.fn();
1112
const tasksNotifyCommand = vi.fn();
1213
const tasksCancelCommand = vi.fn();
@@ -32,6 +33,7 @@ vi.mock("../../commands/sessions-cleanup.js", () => ({
3233

3334
vi.mock("../../commands/tasks.js", () => ({
3435
tasksListCommand,
36+
tasksAuditCommand,
3537
tasksShowCommand,
3638
tasksNotifyCommand,
3739
tasksCancelCommand,
@@ -67,6 +69,7 @@ describe("registerStatusHealthSessionsCommands", () => {
6769
sessionsCommand.mockResolvedValue(undefined);
6870
sessionsCleanupCommand.mockResolvedValue(undefined);
6971
tasksListCommand.mockResolvedValue(undefined);
72+
tasksAuditCommand.mockResolvedValue(undefined);
7073
tasksShowCommand.mockResolvedValue(undefined);
7174
tasksNotifyCommand.mockResolvedValue(undefined);
7275
tasksCancelCommand.mockResolvedValue(undefined);
@@ -242,6 +245,30 @@ describe("registerStatusHealthSessionsCommands", () => {
242245
);
243246
});
244247

248+
it("runs tasks audit subcommand with filters", async () => {
249+
await runCli([
250+
"tasks",
251+
"--json",
252+
"audit",
253+
"--severity",
254+
"error",
255+
"--code",
256+
"stale_running",
257+
"--limit",
258+
"5",
259+
]);
260+
261+
expect(tasksAuditCommand).toHaveBeenCalledWith(
262+
expect.objectContaining({
263+
json: true,
264+
severity: "error",
265+
code: "stale_running",
266+
limit: 5,
267+
}),
268+
runtime,
269+
);
270+
});
271+
245272
it("runs tasks notify subcommand with lookup and policy forwarding", async () => {
246273
await runCli(["tasks", "notify", "run-123", "state_changes"]);
247274

src/cli/program/register.status-health-sessions.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { sessionsCleanupCommand } from "../../commands/sessions-cleanup.js";
44
import { sessionsCommand } from "../../commands/sessions.js";
55
import { statusCommand } from "../../commands/status.js";
66
import {
7+
tasksAuditCommand,
78
tasksCancelCommand,
89
tasksListCommand,
910
tasksNotifyCommand,
@@ -272,6 +273,38 @@ export function registerStatusHealthSessionsCommands(program: Command) {
272273
});
273274
});
274275

276+
tasksCmd
277+
.command("audit")
278+
.description("Show stale or broken background task runs")
279+
.option("--json", "Output as JSON", false)
280+
.option("--severity <level>", "Filter by severity (warn, error)")
281+
.option(
282+
"--code <name>",
283+
"Filter by finding code (stale_queued, stale_running, lost, delivery_failed, missing_cleanup, inconsistent_timestamps)",
284+
)
285+
.option("--limit <n>", "Limit displayed findings")
286+
.action(async (opts, command) => {
287+
const parentOpts = command.parent?.opts() as { json?: boolean } | undefined;
288+
await runCommandWithRuntime(defaultRuntime, async () => {
289+
await tasksAuditCommand(
290+
{
291+
json: Boolean(opts.json || parentOpts?.json),
292+
severity: opts.severity as "warn" | "error" | undefined,
293+
code: opts.code as
294+
| "stale_queued"
295+
| "stale_running"
296+
| "lost"
297+
| "delivery_failed"
298+
| "missing_cleanup"
299+
| "inconsistent_timestamps"
300+
| undefined,
301+
limit: parsePositiveIntOrUndefined(opts.limit),
302+
},
303+
defaultRuntime,
304+
);
305+
});
306+
});
307+
275308
tasksCmd
276309
.command("show")
277310
.description("Show one background task by task id, run id, or session key")

src/commands/tasks.test.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { createCliRuntimeCapture } from "../cli/test-runtime-capture.js";
33

44
const reconcileInspectableTasksMock = vi.fn();
55
const reconcileTaskLookupTokenMock = vi.fn();
6+
const listTaskAuditFindingsMock = vi.fn();
7+
const summarizeTaskAuditFindingsMock = vi.fn();
68
const updateTaskNotifyPolicyByIdMock = vi.fn();
79
const cancelTaskByIdMock = vi.fn();
810
const getTaskByIdMock = vi.fn();
@@ -13,6 +15,11 @@ vi.mock("../tasks/task-registry.reconcile.js", () => ({
1315
reconcileTaskLookupToken: (...args: unknown[]) => reconcileTaskLookupTokenMock(...args),
1416
}));
1517

18+
vi.mock("../tasks/task-registry.audit.js", () => ({
19+
listTaskAuditFindings: (...args: unknown[]) => listTaskAuditFindingsMock(...args),
20+
summarizeTaskAuditFindings: (...args: unknown[]) => summarizeTaskAuditFindingsMock(...args),
21+
}));
22+
1623
vi.mock("../tasks/task-registry.js", () => ({
1724
updateTaskNotifyPolicyById: (...args: unknown[]) => updateTaskNotifyPolicyByIdMock(...args),
1825
cancelTaskById: (...args: unknown[]) => cancelTaskByIdMock(...args),
@@ -34,6 +41,7 @@ let tasksListCommand: typeof import("./tasks.js").tasksListCommand;
3441
let tasksShowCommand: typeof import("./tasks.js").tasksShowCommand;
3542
let tasksNotifyCommand: typeof import("./tasks.js").tasksNotifyCommand;
3643
let tasksCancelCommand: typeof import("./tasks.js").tasksCancelCommand;
44+
let tasksAuditCommand: typeof import("./tasks.js").tasksAuditCommand;
3745

3846
const taskFixture = {
3947
taskId: "task-12345678",
@@ -59,8 +67,13 @@ const taskFixture = {
5967
} as const;
6068

6169
beforeAll(async () => {
62-
({ tasksListCommand, tasksShowCommand, tasksNotifyCommand, tasksCancelCommand } =
63-
await import("./tasks.js"));
70+
({
71+
tasksListCommand,
72+
tasksShowCommand,
73+
tasksNotifyCommand,
74+
tasksCancelCommand,
75+
tasksAuditCommand,
76+
} = await import("./tasks.js"));
6477
});
6578

6679
describe("tasks commands", () => {
@@ -69,6 +82,20 @@ describe("tasks commands", () => {
6982
resetRuntimeCapture();
7083
reconcileInspectableTasksMock.mockReturnValue([]);
7184
reconcileTaskLookupTokenMock.mockReturnValue(undefined);
85+
listTaskAuditFindingsMock.mockReturnValue([]);
86+
summarizeTaskAuditFindingsMock.mockReturnValue({
87+
total: 0,
88+
warnings: 0,
89+
errors: 0,
90+
byCode: {
91+
stale_queued: 0,
92+
stale_running: 0,
93+
lost: 0,
94+
delivery_failed: 0,
95+
missing_cleanup: 0,
96+
inconsistent_timestamps: 0,
97+
},
98+
});
7299
updateTaskNotifyPolicyByIdMock.mockReturnValue(undefined);
73100
cancelTaskByIdMock.mockResolvedValue({ found: false, cancelled: false, reason: "missing" });
74101
getTaskByIdMock.mockReturnValue(undefined);
@@ -137,4 +164,50 @@ describe("tasks commands", () => {
137164
expect(runtimeLogs[0]).toContain("Cancelled task-12345678 (acp) run run-12345678.");
138165
expect(runtimeErrors).toEqual([]);
139166
});
167+
168+
it("shows task audit findings with filters", async () => {
169+
const findings = [
170+
{
171+
severity: "error",
172+
code: "stale_running",
173+
task: taskFixture,
174+
ageMs: 45 * 60_000,
175+
detail: "running task appears stuck",
176+
},
177+
{
178+
severity: "warn",
179+
code: "delivery_failed",
180+
task: {
181+
...taskFixture,
182+
taskId: "task-87654321",
183+
status: "failed",
184+
},
185+
ageMs: 10 * 60_000,
186+
detail: "terminal update delivery failed",
187+
},
188+
];
189+
listTaskAuditFindingsMock.mockReturnValue(findings);
190+
summarizeTaskAuditFindingsMock.mockReturnValue({
191+
total: 2,
192+
warnings: 1,
193+
errors: 1,
194+
byCode: {
195+
stale_queued: 0,
196+
stale_running: 1,
197+
lost: 0,
198+
delivery_failed: 1,
199+
missing_cleanup: 0,
200+
inconsistent_timestamps: 0,
201+
},
202+
});
203+
204+
await tasksAuditCommand({ severity: "error", code: "stale_running", limit: 1 }, runtime);
205+
206+
expect(summarizeTaskAuditFindingsMock).toHaveBeenCalledWith(findings);
207+
expect(runtimeLogs[0]).toContain("Task audit: 2 findings · 1 errors · 1 warnings");
208+
expect(runtimeLogs[1]).toContain("Showing 1 matching findings.");
209+
expect(runtimeLogs.join("\n")).toContain("stale_running");
210+
expect(runtimeLogs.join("\n")).toContain("running task appears stuck");
211+
expect(runtimeLogs.join("\n")).not.toContain("delivery_failed");
212+
});
140213
});

src/commands/tasks.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { loadConfig } from "../config/config.js";
22
import { info } from "../globals.js";
33
import type { RuntimeEnv } from "../runtime.js";
4+
import {
5+
listTaskAuditFindings,
6+
summarizeTaskAuditFindings,
7+
type TaskAuditCode,
8+
type TaskAuditFinding,
9+
type TaskAuditSeverity,
10+
} from "../tasks/task-registry.audit.js";
411
import { cancelTaskById, getTaskById, updateTaskNotifyPolicyById } from "../tasks/task-registry.js";
512
import {
613
reconcileInspectableTasks,
@@ -89,6 +96,60 @@ function formatTaskListSummary(tasks: TaskRecord[]) {
8996
return `${summary.byStatus.queued} queued · ${summary.byStatus.running} running · ${summary.failures} issues`;
9097
}
9198

99+
function formatAgeMs(ageMs: number | undefined): string {
100+
if (typeof ageMs !== "number" || ageMs < 1000) {
101+
return "fresh";
102+
}
103+
const totalSeconds = Math.floor(ageMs / 1000);
104+
const days = Math.floor(totalSeconds / 86_400);
105+
const hours = Math.floor((totalSeconds % 86_400) / 3600);
106+
const minutes = Math.floor((totalSeconds % 3600) / 60);
107+
if (days > 0) {
108+
return `${days}d${hours}h`;
109+
}
110+
if (hours > 0) {
111+
return `${hours}h${minutes}m`;
112+
}
113+
if (minutes > 0) {
114+
return `${minutes}m`;
115+
}
116+
return `${totalSeconds}s`;
117+
}
118+
119+
function formatAuditRows(findings: TaskAuditFinding[], rich: boolean) {
120+
const header = [
121+
"Severity".padEnd(8),
122+
"Code".padEnd(22),
123+
"Task".padEnd(ID_PAD),
124+
"Status".padEnd(STATUS_PAD),
125+
"Age".padEnd(8),
126+
"Detail",
127+
].join(" ");
128+
const lines = [rich ? theme.heading(header) : header];
129+
for (const finding of findings) {
130+
const severity = finding.severity.padEnd(8);
131+
const status = formatTaskStatusCell(finding.task.status, rich);
132+
const severityCell = !rich
133+
? severity
134+
: finding.severity === "error"
135+
? theme.error(severity)
136+
: theme.warn(severity);
137+
lines.push(
138+
[
139+
severityCell,
140+
finding.code.padEnd(22),
141+
shortToken(finding.task.taskId).padEnd(ID_PAD),
142+
status,
143+
formatAgeMs(finding.ageMs).padEnd(8),
144+
truncate(finding.detail, 88),
145+
]
146+
.join(" ")
147+
.trimEnd(),
148+
);
149+
}
150+
return lines;
151+
}
152+
92153
export async function tasksListCommand(
93154
opts: { json?: boolean; runtime?: string; status?: string },
94155
runtime: RuntimeEnv,
@@ -241,3 +302,77 @@ export async function tasksCancelCommand(opts: { lookup: string }, runtime: Runt
241302
`Cancelled ${updated?.taskId ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`,
242303
);
243304
}
305+
306+
export async function tasksAuditCommand(
307+
opts: {
308+
json?: boolean;
309+
severity?: TaskAuditSeverity;
310+
code?: TaskAuditCode;
311+
limit?: number;
312+
},
313+
runtime: RuntimeEnv,
314+
) {
315+
const severityFilter = opts.severity?.trim() as TaskAuditSeverity | undefined;
316+
const codeFilter = opts.code?.trim() as TaskAuditCode | undefined;
317+
const allFindings = listTaskAuditFindings();
318+
const findings = allFindings.filter((finding) => {
319+
if (severityFilter && finding.severity !== severityFilter) {
320+
return false;
321+
}
322+
if (codeFilter && finding.code !== codeFilter) {
323+
return false;
324+
}
325+
return true;
326+
});
327+
const limit = typeof opts.limit === "number" && opts.limit > 0 ? opts.limit : undefined;
328+
const displayed = limit ? findings.slice(0, limit) : findings;
329+
const summary = summarizeTaskAuditFindings(allFindings);
330+
331+
if (opts.json) {
332+
runtime.log(
333+
JSON.stringify(
334+
{
335+
count: allFindings.length,
336+
filteredCount: findings.length,
337+
displayed: displayed.length,
338+
filters: {
339+
severity: severityFilter ?? null,
340+
code: codeFilter ?? null,
341+
limit: limit ?? null,
342+
},
343+
summary,
344+
findings: displayed,
345+
},
346+
null,
347+
2,
348+
),
349+
);
350+
return;
351+
}
352+
353+
runtime.log(
354+
info(
355+
`Task audit: ${summary.total} findings · ${summary.errors} errors · ${summary.warnings} warnings`,
356+
),
357+
);
358+
if (severityFilter || codeFilter) {
359+
runtime.log(info(`Showing ${findings.length} matching findings.`));
360+
}
361+
if (severityFilter) {
362+
runtime.log(info(`Severity filter: ${severityFilter}`));
363+
}
364+
if (codeFilter) {
365+
runtime.log(info(`Code filter: ${codeFilter}`));
366+
}
367+
if (limit) {
368+
runtime.log(info(`Limit: ${limit}`));
369+
}
370+
if (displayed.length === 0) {
371+
runtime.log("No task audit findings.");
372+
return;
373+
}
374+
const rich = isRich();
375+
for (const line of formatAuditRows(displayed, rich)) {
376+
runtime.log(line);
377+
}
378+
}

src/infra/node-sqlite.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { createRequire } from "node:module";
2+
import { installProcessWarningFilter } from "./warning-filter.js";
3+
4+
const require = createRequire(import.meta.url);
5+
6+
export function requireNodeSqlite(): typeof import("node:sqlite") {
7+
installProcessWarningFilter();
8+
try {
9+
return require("node:sqlite") as typeof import("node:sqlite");
10+
} catch (err) {
11+
const message = err instanceof Error ? err.message : String(err);
12+
throw new Error(
13+
`SQLite support is unavailable in this Node runtime (missing node:sqlite). ${message}`,
14+
{ cause: err },
15+
);
16+
}
17+
}

0 commit comments

Comments
 (0)