feat(server,web): page the cost center's error table, abbreviate the source, drop command-tool noise (#103)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-29 16:16:15 +08:00
committed by GitHub
parent 6b3574f5c9
commit e0a47254af
17 changed files with 598 additions and 41 deletions
+1
View File
@@ -141,6 +141,7 @@ On Session creation, `modelId` and `provider` are both-or-neither: send the comp
| Method | Path | Description |
| --- | --- | --- |
| GET | /usage | Usage statistics; query parameters `from`, `to`, `groupBy`, `agentId`, `provider`, `modelId` |
| GET | /usage/errors | One page of the error detail table (newest first): `offset`, `limit`, plus the same `from` / `to` / `agentId` filter → `{items, total}` |
| GET | /agents/:agentId/traces | Date → Session drill-down structure of Trace files |
| GET | /agents/:agentId/traces/:sessionId/:index | Read Trace events (`offset` / `limit` pagination) |
| GET | /agents/:agentId/traces/:sessionId/:index/analysis | Trace performance analysis |
+1
View File
@@ -141,6 +141,7 @@ Schedule 写操作仅限 Owner。新建 Session 模式的任务,`modelId` 与
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | /usage | 用量统计,查询参数 `from`、`to`、`groupBy`、`agentId`、`provider`、`modelId` |
| GET | /usage/errors | 异常明细表分页(按时间倒序):`offset`、`limit`,以及与看板一致的 `from` / `to` / `agentId` 过滤 → `{items, total}` |
| GET | /agents/:agentId/traces | Trace 文件的日期 → Session 下钻结构 |
| GET | /agents/:agentId/traces/:sessionId/:index | 读取 Trace 事件(`offset` / `limit` 分页) |
| GET | /agents/:agentId/traces/:sessionId/:index/analysis | Trace 性能分析结果 |
+13 -1
View File
@@ -1085,10 +1085,22 @@ export interface UsageErrors {
unexpected: number;
/** The most frequent source · code (null when there are no errors). */
topCode: UsageErrorCount | null;
/** Most recent N items (reverse chronological). */
/** Most recent N items (reverse chronological) — the first page; older ones come from `GET /usage/errors`. */
recent: UsageErrorItem[];
}
/**
* GET /api/projects/:projectId/usage/errors — one page of the error detail table, newest
* first. The dashboard response above already carries the first page; this exists so
* "show me earlier ones" does not have to refetch the whole aggregate. It takes the same
* date/agent filter as the dashboard, so a page never widens what the summary counted.
*/
export interface UsageErrorsPage {
items: UsageErrorItem[];
/** Filtered row count, so the caller knows when it has reached the end. */
total: number;
}
export interface UsageResponse {
summary: {
today: UsageBucket;
+3 -3
View File
@@ -194,15 +194,15 @@ export class ErrorsRepo {
}
/** The most recent `limit` entries (reverse chronological order). */
recent(projectId: string, f: ErrorFilter = {}, limit = 20): ErrorItem[] {
recent(projectId: string, f: ErrorFilter = {}, limit = 20, offset = 0): ErrorItem[] {
const { where, params } = this.conds(projectId, f);
const rows = this.db
.prepare(
`SELECT ts, source, code, kind, message
FROM error_records WHERE ${where}
ORDER BY id DESC LIMIT :limit`,
ORDER BY id DESC LIMIT :limit OFFSET :offset`,
)
.all({ ...params, limit });
.all({ ...params, limit, offset });
return rows.map((r) => ({
ts: r.ts as string,
source: r.source as string,
+30 -2
View File
@@ -1,12 +1,14 @@
/**
* Usage statistics routes:
* GET /api/projects/:p/usage?from&to&groupBy&agentId&provider&modelId
* (model filter is paired: provider and modelId are given together).
* (model filter is paired: provider and modelId are given together);
* GET /api/projects/:p/usage/errors?offset&limit&from&to&agentId — one page of the error
* detail table, for paging back past the first page the dashboard already returns.
*/
import { Hono } from "hono";
import type { UsageGroupBy } from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import { badRequest, optionalDateParam, requireValidId } from "../validate.js";
import { badRequest, optionalDateParam, paginationQuery, requireValidId } from "../validate.js";
import type { AppDeps } from "../../app.js";
const GROUP_BYS: readonly UsageGroupBy[] = ["date", "agent", "model", "session"];
@@ -44,5 +46,31 @@ export function usageRoutes(deps: AppDeps): Hono<AppEnv> {
);
});
// One page of the error detail table, newest first. The dashboard response above already
// carries the first page; this serves "show me earlier ones" without refetching the whole
// aggregate. Takes the date/agent filter only — the model filter never applied to errors
// (HTTP and process errors have no Model dimension), so accepting it here would imply a
// narrowing the summary above does not do.
app.get("/errors", (c) => {
const projectId = requireValidId(c, "projectId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
const { offset, limit } = paginationQuery(c);
const from = optionalDateParam(c.req.query("from"), "from");
const to = optionalDateParam(c.req.query("to"), "to");
const agentId = c.req.query("agentId");
return c.json(
deps.usageService.queryErrors(projectId, {
offset,
limit,
// Same admin-only rule as the dashboard: a regular member seeing another tenant's
// unattributed errors would be a cross-tenant leak.
includeGlobalErrors: c.var.user.isAdmin,
...(from !== undefined ? { from } : {}),
...(to !== undefined ? { to } : {}),
...(agentId !== undefined && agentId !== "" ? { agentId } : {}),
}),
);
});
return app;
}
@@ -34,7 +34,10 @@
*
* Environment (source = `environment`): reads `tool_call_output`'s stop_reason ∈
* {failed, timeout} → expected (the error is fed back to the model, and the Agent
* adjusts on its own; `aborted` is denial/interruption, not recorded).
* adjusts on its own; `aborted` is denial/interruption, not recorded). Exactly one shape is
* dropped: a command tool's ordinary non-zero exit, which is how a shell command reports
* information rather than a fault — see isOrdinaryCommandExit; the same tools' environment
* faults (killed by a signal, spawn failure, tool timeout) still record.
* `tool_call_output` only has tool_call_id, no tool name, so `tool_call_id → tool name`
* is cached (tool_call always arrives before its output), and the tool name is written
* into code (`tool_failed:exec_command`) — so the stats dashboard's "most common error
@@ -104,6 +107,50 @@ function isToolFailure(s: unknown): s is ToolFailure {
return s === "failed" || s === "timeout";
}
/**
* The command tools. Both funnel their exit status through core's `resultForExit`, which maps
* *any* non-zero exit to `failed` — and a non-zero exit is how shell commands return
* information, not a fault: `grep` exits 1 when nothing matches, `test -f` when the file is
* absent, `diff` when files differ. Recording those made the cost center's error table mostly a
* log of ordinary Agent work, burying the real errors and eating the row cap that exists to
* protect them. (Only the row cap: the recorder's dedup key carries the error code, so this
* noise could ever have crowded out only *itself*.) The Agent sees the failure in the tool
* output and adjusts — nothing about it needs a human.
*
* Both belong here: `input_command` is how a backgrounded `exec_command` is polled for its
* eventual exit, so covering only one would record the *same* command's non-zero exit when it
* finishes in the background and drop it when it finishes in the foreground.
*/
const COMMAND_TOOLS: ReadonlySet<string> = new Set(["exec_command", "input_command"]);
/**
* `resultForExit`'s terminal marker for an ordinary non-zero exit. Anchored to the end because
* the marker is a `note`, which Environment appends after the (possibly truncated) output — a
* command's own stdout may contain anything, including this text.
*/
const ORDINARY_EXIT_NOTE = /\[exit code: [^\]\n]*\]\s*$/;
/**
* Whether a command-tool failure is just an ordinary non-zero exit — the one failure shape the
* error table is better off without (see COMMAND_TOOLS).
*
* The discrimination is by note rather than by tool name, because `failed` from these tools is
* not only "the command exited non-zero": it also covers termination by signal (an OOM kill, a
* segfault), a spawn failure (nonexistent workdir, EMFILE, an unresolvable shell), a missing
* command session manager, and a tool timeout. Those are config/environment faults — the
* recorder's own "needs a human" category — and no amount of Agent self-correction resolves
* them, so they must keep landing in the table. `resultForExit` writes the exit code as the
* output's last note, which tells the two apart precisely.
*
* An unknown name (tool_call evicted from the cache, or never seen) still qualifies: only these
* two tools ever emit that marker, so dropping it beats recording a nameless
* `tool_failed:unknown` for exactly the noise this exists to remove.
*/
function isOrdinaryCommandExit(name: string | undefined, output: string): boolean {
if (name !== undefined && !COMMAND_TOOLS.has(name)) return false;
return ORDINARY_EXIT_NOTE.test(output);
}
/** A user-interrupt abort message (core's `aborted by user` / `aborted during …`): not a failure reason. */
function isUserAbortReason(reason: string): boolean {
return /^aborted\b/i.test(reason);
@@ -285,9 +332,11 @@ export class StreamErrorWatcher {
const name = this.toolNames.get(key);
this.toolNames.delete(key); // This call has settled: dequeue it, the cache only keeps in-flight calls
if (!isToolFailure(p.stop_reason)) return; // completed / aborted (denial, user interrupt) are not errors
const output = p.output ?? "";
if (isOrdinaryCommandExit(name, output)) return; // a shell's exit status is information, not a fault
this.errors.record({
source: "environment",
err: toolFailureText(p.output ?? ""),
err: toolFailureText(output),
ctx: this.ctxFor(origin),
// The tool name goes into code: so the stats dashboard's "most common error code" and table can show which tool failed.
code: `tool_${p.stop_reason}:${name ?? "unknown"}`,
+46 -1
View File
@@ -19,6 +19,7 @@
import type {
UsageBucket,
UsageErrors,
UsageErrorsPage,
UsageGroupBy,
UsageGroupRow,
UsageResponse,
@@ -32,7 +33,12 @@ import type {
} from "../db/repos/usage.js";
import { formatLocalDate, localDateMinusDays } from "../internal/dates.js";
/** Number of most-recent entries kept in the error detail table. */
/**
* Number of most-recent entries kept in the error detail table. Also the page size the whole
* feature runs on, but nothing needs to hard-code it: `errors.recent` is exactly this many rows
* whenever a second page exists, so the client derives its page size from the response instead
* of holding a constant that could drift out of step with this one.
*/
const ERROR_RECENT_N = 20;
/** The three pricing buckets (usd_per_mtok convention), returned by the pricing lookup callback. */
@@ -61,6 +67,17 @@ export interface UsageQuery {
includeGlobalErrors?: boolean;
}
/** One page of the error detail table (see {@link UsageService.queryErrors}). */
export interface UsageErrorsQuery {
offset: number;
limit: number;
from?: string;
to?: string;
agentId?: string;
/** Admin only: include errors with no Project attribution (see the ErrorsRepo file header). */
includeGlobalErrors?: boolean;
}
/** Cost formula: sum of the three buckets, in USD per million Tokens. */
function costOf(sums: UsageModelSums, rates: PricingRates): number {
return (
@@ -164,6 +181,34 @@ export class UsageService {
};
}
/**
* One page of the error detail table, newest first. The dashboard's own response already
* carries the first page (`errors.recent`); this serves the "show me earlier ones" paging,
* where refetching the whole aggregate to move one page would be wasteful. `total` is the
* filtered row count, so the caller knows when it has reached the end.
*
* Takes the same filter the dashboard applies — date + agent, and admin-only visibility of
* unattributed errors — so a page never widens what the summary above it counted.
*
* Paging is by offset into a newest-first table that grows at its head, so errors recorded
* between two page requests shift every row down and a page can re-show rows already seen.
* Accepted deliberately: this is a diagnostic table read at a moment in time, not a feed to
* be walked exhaustively, and keying off the newest row's id would cost a cursor the caller
* has no other use for.
*/
queryErrors(projectId: string, q: UsageErrorsQuery): UsageErrorsPage {
const f: ErrorFilter = {
...(q.agentId !== undefined ? { agentId: q.agentId } : {}),
...(q.from !== undefined ? { from: q.from } : {}),
...(q.to !== undefined ? { to: q.to } : {}),
...(q.includeGlobalErrors === true ? { includeGlobal: true } : {}),
};
return {
items: this.errors.recent(projectId, f, q.limit, q.offset),
total: this.errors.summary(projectId, f).total,
};
}
/** Error statistics: summary info (total / unexpected / most common error code) + the last N entries, all filtered by the selected range. */
private foldErrors(projectId: string, f: ErrorFilter): UsageErrors {
const { total, unexpected } = this.errors.summary(projectId, f);
+3
View File
@@ -45,6 +45,9 @@ describe("authz", () => {
expect((await outsider.get(`/api/projects/${projectId}/agents`)).status).toBe(404);
expect((await outsider.get(`/api/projects/${projectId}/members`)).status).toBe(404);
expect((await outsider.get(`/api/projects/${projectId}/usage`)).status).toBe(404);
// The error table's paging route is checked separately from /usage: it is the one place a
// reader can walk past the dashboard's first page, so it must refuse the same way.
expect((await outsider.get(`/api/projects/${projectId}/usage/errors`)).status).toBe(404);
});
it("member can read models (masked) but not write; owner can write", async () => {
+167 -16
View File
@@ -24,7 +24,7 @@ import {
withOrigin,
} from "@prismshadow/penguin-core";
import type { OmniMessage } from "@prismshadow/penguin-core";
import type { ProjectCreateResponse, UsageResponse } from "../src/api/types.js";
import type { ProjectCreateResponse, UsageErrorsPage, UsageResponse } from "../src/api/types.js";
import { openDatabase } from "../src/db/database.js";
import { ErrorsRepo } from "../src/db/repos/errors.js";
import type { ErrorRecordInsert } from "../src/db/repos/errors.js";
@@ -526,11 +526,102 @@ describe("stream-error-watcher (LLM / Environment errors)", () => {
const call = (name: string, id: string) => toolCall({ name, arguments: "{}", toolCallId: id });
it("tool failed / timeout → environment + expected, code carries the tool name", () => {
it("a command tool's ordinary non-zero exit is not recorded: an exit status is information", () => {
// Both command tools end in resultForExit, which maps ANY non-zero exit to `failed`, so
// grep finding nothing (exit 1), `test -f` on a missing file, or a diff that differs would
// all land in the cost center and bury the real errors. input_command is covered alongside
// exec_command because it is how a backgrounded command is polled for its eventual exit —
// dropping one but not the other would depend on where the command happened to finish.
// Every other tool still records, whatever its output says.
const got = feed([
call("exec_command", "tc-1"),
toolCallOutput({
output: "ls: /nope: No such file or directory\n[tool error] exit code 2",
output: "grep: no match\n[exit code: 1]",
toolCallId: "tc-1",
stopReason: "failed",
}),
call("input_command", "tc-2"),
toolCallOutput({
output: "make: *** [build] Error 2\n[exit code: 2]",
toolCallId: "tc-2",
stopReason: "failed",
}),
call("write_file", "tc-3"),
toolCallOutput({
output: "EACCES\n[exit code: 1]",
toolCallId: "tc-3",
stopReason: "failed",
}),
]);
expect(got.map((r) => r.code)).toEqual(["tool_failed:write_file"]);
});
it("a command tool killed by a signal, or that never spawned, is still recorded", () => {
// `failed` from these tools is not only "exited non-zero": an OOM kill or a segfault
// (resultForExit's signal branch) and a spawn failure (nonexistent workdir, EMFILE, an
// unresolvable shell) are config/environment faults — the recorder's "needs a human"
// category — that no amount of Agent self-correction gets around. Only the exit marker
// separates them, which is why the rule reads the note rather than the tool name.
const got = feed([
call("exec_command", "tc-1"),
toolCallOutput({
output: "cc1plus: out of memory\n[terminated by signal SIGKILL]",
toolCallId: "tc-1",
stopReason: "failed",
}),
call("input_command", "tc-2"),
toolCallOutput({
output: "[spawn error: ENOENT: no such file or directory, posix_spawn '/bin/nope']",
toolCallId: "tc-2",
stopReason: "failed",
}),
]);
expect(got.map((r) => r.code)).toEqual([
"tool_failed:exec_command",
"tool_failed:input_command",
]);
});
it("a command tool's timeout and a missing session manager are recorded (neither is an exit)", () => {
// Environment finalizes a tool timeout as stop_reason `failed` plus its own note — it never
// emits stop_reason "timeout" for these tools — so a hung command surfaces as tool_failed
// with no exit marker to drop it. A missing command session manager is a server
// misconfiguration and produces no exit marker either.
const got = feed([
call("exec_command", "tc-1"),
toolCallOutput({
output: "still building…\n[tool timeout: exceeded 60000ms]",
toolCallId: "tc-1",
stopReason: "failed",
}),
call("input_command", "tc-2"),
toolCallOutput({
output: "[input_command unavailable: no command session manager configured]",
toolCallId: "tc-2",
stopReason: "failed",
}),
]);
expect(got.map((r) => r.code)).toEqual([
"tool_failed:exec_command",
"tool_failed:input_command",
]);
});
it("an exit marker with no cached tool name is dropped, not filed under tool_failed:unknown", () => {
// Only the command tools ever write that marker, so a cache miss (the tool_call evicted, or
// never seen) is still that noise — recording it nameless would defeat the exclusion.
const got = feed([
toolCallOutput({ output: "[exit code: 1]", toolCallId: "tc-1", stopReason: "failed" }),
toolCallOutput({ output: "boom", toolCallId: "tc-2", stopReason: "failed" }),
]);
expect(got.map((r) => [r.code, r.message])).toEqual([["tool_failed:unknown", "boom"]]);
});
it("tool failed / timeout → environment + expected, code carries the tool name", () => {
const got = feed([
call("write_file", "tc-1"),
toolCallOutput({
output: "EACCES: permission denied\n[tool error] write failed",
toolCallId: "tc-1",
stopReason: "failed",
}),
@@ -545,19 +636,19 @@ describe("stream-error-watcher (LLM / Environment errors)", () => {
expect(got[0]).toMatchObject({
source: "environment",
kind: "expected", // error fed back to the model; the Agent adjusts on its own — no human needed
code: "tool_failed:exec_command",
code: "tool_failed:write_file",
project_id: "p1",
agent_id: "a1",
session_id: "s1",
});
expect(got[0]!.message).toContain("[tool error] exit code 2"); // the actual error text
expect(got[0]!.message).toContain("[tool error] write failed"); // the actual error text
expect(got[1]).toMatchObject({ code: "tool_timeout:read_file", kind: "expected" });
});
it("tool aborted (denial / user interrupt) and completed are not recorded", () => {
expect(
feed([
call("exec_command", "tc-1"),
call("write_file", "tc-1"),
toolCallOutput({
output: "Tool call denied by user.",
toolCallId: "tc-1",
@@ -571,20 +662,20 @@ describe("stream-error-watcher (LLM / Environment errors)", () => {
it("parallel tools: each tool_call_id maps to its own name despite out-of-order outputs", () => {
const got = feed([
call("exec_command", "tc-1"),
call("write_file", "tc-1"),
call("read_file", "tc-2"),
call("write_file", "tc-3"),
toolCallOutput({ output: "boom-2", toolCallId: "tc-2", stopReason: "failed" }),
toolCallOutput({ output: "ok", toolCallId: "tc-3", stopReason: "completed" }),
toolCallOutput({ output: "boom-1", toolCallId: "tc-1", stopReason: "failed" }),
]);
expect(got.map((r) => r.code)).toEqual(["tool_failed:read_file", "tool_failed:exec_command"]);
expect(got.map((r) => r.code)).toEqual(["tool_failed:read_file", "tool_failed:write_file"]);
expect(got.map((r) => r.message)).toEqual(["boom-2", "boom-1"]);
});
it("a child session's tool failure: no name mix-up with the parent's equal tool_call_id", () => {
const got = feed([
call("exec_command", "tc-1"), // parent session
call("read_file", "tc-1"), // parent session
withOrigin(call("write_file", "tc-1"), "session-child"), // sub-session happens to share the same id
withOrigin(
toolCallOutput({ output: "child boom", toolCallId: "tc-1", stopReason: "failed" }),
@@ -598,12 +689,12 @@ describe("stream-error-watcher (LLM / Environment errors)", () => {
message: "child boom",
session_id: "s1", // this test didn't feed the sub-session's session_meta → attribution falls back to the parent ctx (see the "attribution" test cases below)
});
expect(got[1]).toMatchObject({ code: "tool_failed:exec_command", message: "parent boom" });
expect(got[1]).toMatchObject({ code: "tool_failed:read_file", message: "parent boom" });
});
it("overlong tool output: message takes the tail (the reason is at the end)", () => {
const got = feed([
call("exec_command", "tc-1"),
call("write_file", "tc-1"),
toolCallOutput({
output: `${"x".repeat(2000)}\n[tool error] boom`,
toolCallId: "tc-1",
@@ -620,7 +711,7 @@ describe("stream-error-watcher (LLM / Environment errors)", () => {
expect(
feed([
assistantText("normal output"),
call("exec_command", "tc-1"),
call("write_file", "tc-1"),
partialToolCallOutput({ eventType: "stop", toolCallId: "tc-1", stopReason: "failed" }),
]),
).toHaveLength(0);
@@ -649,7 +740,7 @@ describe("stream-error-watcher (LLM / Environment errors)", () => {
it("a child session's tool failure attributes to it (code still carries the tool name)", () => {
const got = feed([
childMeta("session-child", "/data/agents/agent-child/agent_state"),
withOrigin(call("exec_command", "tc-1"), "session-child"),
withOrigin(call("write_file", "tc-1"), "session-child"),
withOrigin(
toolCallOutput({ output: "child boom", toolCallId: "tc-1", stopReason: "failed" }),
"session-child",
@@ -658,7 +749,7 @@ describe("stream-error-watcher (LLM / Environment errors)", () => {
expect(got).toHaveLength(1);
expect(got[0]).toMatchObject({
source: "environment",
code: "tool_failed:exec_command",
code: "tool_failed:write_file",
message: "child boom",
agent_id: "agent-child",
session_id: "session-child",
@@ -678,7 +769,7 @@ describe("stream-error-watcher (LLM / Environment errors)", () => {
toolCallOutput({ output: "child tool boom", toolCallId: "tc-9", stopReason: "failed" }),
"session-child",
),
call("exec_command", "tc-9"), // parent session happens to share the same id
call("read_file", "tc-9"), // parent session happens to share the same id
toolCallOutput({ output: "parent tool boom", toolCallId: "tc-9", stopReason: "failed" }),
requestEnd("failed"), // the parent session's LLM failure only wraps up now
abortEvent("llm request error: 500 upstream"),
@@ -687,7 +778,7 @@ describe("stream-error-watcher (LLM / Environment errors)", () => {
expect(got.map((r) => [r.code, r.agent_id, r.session_id])).toEqual([
["llm_timeout", "agent-child", "session-child"],
["tool_failed:write_file", "agent-child", "session-child"],
["tool_failed:exec_command", "a1", "s1"],
["tool_failed:read_file", "a1", "s1"],
["llm_failed", "a1", "s1"],
]);
});
@@ -822,6 +913,66 @@ describe("HTTP onError persistence (integration)", () => {
expect(adminBody.errors.recent[0]).toMatchObject({ code: "invalid_credentials" });
});
it("the paged error route pages inside the caller's own tenant, at every offset", async () => {
// The point of the route is that paging back is not a way around the dashboard's isolation:
// the rows a member can reach at offset N are the same set the summary counted, never
// another tenant's and never the unattributed ones. Seeded directly so the interleaving is
// exact — going through HTTP would collapse repeats into the recorder's dedup window.
const repo = new ErrorsRepo(t.deps.db);
const seed = (owner: string | null, code: string) =>
repo.insert({
ts: "2026-07-27T00:00:00.000Z",
date: "2026-07-27",
projectId: owner,
agentId: "a1",
sessionId: "s1",
source: "http",
kind: "expected",
code,
status: 404,
message: code,
});
const foreign = (await (
await api.post("/api/projects", { projectId: "err_user-tenant_2", name: "Other tenant" })
).json()) as ProjectCreateResponse;
// Interleaved, so a mistaken filter would show up inside the first page rather than past it.
seed(projectId, "mine_0");
seed(foreign.project.projectId, "theirs_0");
seed(null, "unattributed_0");
seed(projectId, "mine_1");
seed(projectId, "mine_2");
const pageOf = async (offset: number, limit: number) =>
(await (
await api.get(`/api/projects/${projectId}/usage/errors?offset=${offset}&limit=${limit}`)
).json()) as UsageErrorsPage;
const first = await pageOf(0, 2);
expect(first.total).toBe(3); // the filtered count, not the table's
expect(first.items.map((e) => e.code)).toEqual(["mine_2", "mine_1"]); // newest first
const second = await pageOf(2, 2);
expect(second.total).toBe(3);
expect(second.items.map((e) => e.code)).toEqual(["mine_0"]);
expect((await pageOf(3, 2)).items).toEqual([]); // past the end is empty, not an error
// The admin is the only one who reaches the unattributed rows — the same rule the dashboard
// applies, so paging cannot be used to slip past it.
const adminApi = apiClient(t.app, (await loginAdmin(t.app)).cookie);
const adminPage = (await (
await adminApi.get(`/api/projects/default_project/usage/errors?offset=0&limit=20`)
).json()) as UsageErrorsPage;
expect(adminPage.items.map((e) => e.code)).toEqual(["unattributed_0"]);
});
it("the paged error route rejects a page it cannot serve rather than guessing", async () => {
expect((await api.get(`/api/projects/${projectId}/usage/errors?offset=-1`)).status).toBe(400);
expect((await api.get(`/api/projects/${projectId}/usage/errors?limit=0`)).status).toBe(400);
expect((await api.get(`/api/projects/${projectId}/usage/errors?limit=1001`)).status).toBe(400);
expect((await api.get(`/api/projects/${projectId}/usage/errors?from=2026-13-01`)).status).toBe(
400,
);
});
it("Project deletion cascade-cleans that Project's error records", async () => {
await api.get(`/api/projects/${projectId}/agents/agent-nope/sessions`);
expect(errorRows().filter((r) => r.project_id === projectId)).toHaveLength(1);
@@ -97,6 +97,7 @@ describe("id-validation", () => {
expect((await attacker.get(`/api/projects/..%2Fetc/agents`)).status).toBe(404);
expect((await attacker.get(`/api/projects/..%2Fetc/models`)).status).toBe(404);
expect((await attacker.get(`/api/projects/..%2Fetc/usage`)).status).toBe(404);
expect((await attacker.get(`/api/projects/..%2Fetc/usage/errors`)).status).toBe(404);
expect((await attacker.get(`/api/projects/..%2Fetc/members`)).status).toBe(404);
expect((await attacker.delete(`/api/projects/..%2F${victimProject}`)).status).toBe(404);
});
+3 -3
View File
@@ -51,7 +51,7 @@ const ROW: SessionRow = {
};
/** A simple, scriptable fake Session: run yields one tool_call and requests approval for it. */
function approvalFakeSession(sessionId: string, toolName = "exec_command"): RuntimeSession {
function approvalFakeSession(sessionId: string, toolName = "write_file"): RuntimeSession {
return {
sessionId,
toolPermission: (name) => (name === "read_tool" ? "r" : "rw"),
@@ -197,7 +197,7 @@ describe("session-manager", () => {
skipReconnectWait: () => false,
async *run(): AsyncGenerator<OmniMessage> {
yield requestBegin();
yield toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc-1" });
yield toolCall({ name: "write_file", arguments: "{}", toolCallId: "tc-1" });
yield toolCallOutput({
output: "ls: /nope\n[tool error] exit code 2",
toolCallId: "tc-1",
@@ -213,7 +213,7 @@ describe("session-manager", () => {
await waitFor(() => manager.statusOf("session-1") === "idle" && captured.length >= 2);
expect(captured.map((a) => [a.source, a.code, a.kind])).toEqual([
["environment", "tool_failed:exec_command", "expected"], // error fed back to the model; the Agent adjusts on its own
["environment", "tool_failed:write_file", "expected"], // error fed back to the model; the Agent adjusts on its own
["llm", "llm_failed", "unexpected"], // not retryable, requires human intervention
]);
expect(captured[0]!.ctx).toEqual({ projectId: "p1", agentId: "a1", sessionId: "session-1" });
+100
View File
@@ -277,3 +277,103 @@ describe("usage-service (cost computed on the fly)", () => {
expect(byAgent.success.map((s) => s.modelId)).toEqual(["m1"]);
});
});
describe("usage-service.queryErrors (error table paging)", () => {
let db: DatabaseSync;
let errors: ErrorsRepo;
let service: UsageService;
beforeEach(() => {
db = openDatabase(":memory:");
errors = new ErrorsRepo(db);
service = new UsageService(new UsageRepo(db), errors, async () => undefined);
// 25 rows, oldest first — so "newest first" ordering is observable across a page boundary.
for (let i = 0; i < 25; i += 1) {
errors.insert({
ts: `2026-07-27T00:00:${String(i).padStart(2, "0")}.000Z`,
date: "2026-07-27",
projectId: "p1",
agentId: "a1",
sessionId: "s1",
source: "http",
kind: "expected",
code: `code_${i}`,
status: 400,
message: `m${i}`,
});
}
});
afterEach(() => db.close());
it("pages newest-first and reports the filtered total, so the caller knows where the end is", () => {
const first = service.queryErrors("p1", { offset: 0, limit: 20 });
expect(first.total).toBe(25);
expect(first.items).toHaveLength(20);
expect(first.items[0]!.code).toBe("code_24"); // newest
const second = service.queryErrors("p1", { offset: 20, limit: 20 });
expect(second.total).toBe(25);
// The tail is the five oldest, still descending, with no overlap against page one.
expect(second.items.map((e) => e.code)).toEqual([
"code_4",
"code_3",
"code_2",
"code_1",
"code_0",
]);
});
it("past the end is empty rather than an error, and total stays the full count", () => {
const page = service.queryErrors("p1", { offset: 100, limit: 20 });
expect(page.items).toEqual([]);
expect(page.total).toBe(25);
});
it("filters before it offsets, so a later page never slides onto rows the summary excluded", () => {
// Five newer rows from another Agent, i.e. sitting at the head of the unfiltered table. If
// the offset were counted over that table and the filter applied afterwards, both pages
// below would come back shifted (and page two short); filtering first is what makes the
// 25-row filtered set page cleanly as 20 + 5.
for (let i = 0; i < 5; i += 1) {
errors.insert({
ts: `2026-07-28T00:00:0${i}.000Z`,
date: "2026-07-28",
projectId: "p1",
agentId: "other",
sessionId: "s2",
source: "http",
kind: "expected",
code: `other_${i}`,
status: 400,
message: "m",
});
}
const scoped = { offset: 0, limit: 20, agentId: "a1" };
const first = service.queryErrors("p1", scoped);
expect(first.total).toBe(25); // the other Agent's five are outside the count, not just the page
expect(first.items[0]!.code).toBe("code_24");
expect(first.items.at(-1)!.code).toBe("code_5");
const second = service.queryErrors("p1", { ...scoped, offset: 20 });
expect(second.items.map((e) => e.code)).toEqual([
"code_4",
"code_3",
"code_2",
"code_1",
"code_0",
]);
// Unfiltered, the same offset lands on entirely different rows — the filter is doing work.
const unscoped = service.queryErrors("p1", { offset: 20, limit: 20 });
expect(unscoped.total).toBe(30);
expect(unscoped.items.map((e) => e.code)).toEqual([
"code_9",
"code_8",
"code_7",
"code_6",
"code_5",
"code_4",
"code_3",
"code_2",
"code_1",
"code_0",
]);
});
});
+21
View File
@@ -63,6 +63,7 @@ import type {
UiPrefs,
UpdateCheckResponse,
UpdateRunResponse,
UsageErrorsPage,
UsageGroupBy,
UsageResponse,
VaultResponse,
@@ -360,6 +361,26 @@ export const importAgentTrace = (projectId: string, agentId: string, body: Trace
// Usage statistics ----------------------------------------------------------------------
/**
* One page of the cost center's error table (newest first). The dashboard response already
* carries the first page; this is for paging back to earlier ones without refetching the
* whole aggregate. Takes the dashboard's date/agent filter only — the model filter never
* applied to errors.
*/
export const getUsageErrors = (
projectId: string,
params: { offset: number; limit: number; from?: string; to?: string; agentId?: string },
) =>
apiFetch<UsageErrorsPage>(`/api/projects/${encodeURIComponent(projectId)}/usage/errors`, {
query: {
offset: String(params.offset),
limit: String(params.limit),
from: params.from,
to: params.to,
agentId: params.agentId,
},
});
export const getUsage = (
projectId: string,
params: {
+137 -11
View File
@@ -9,9 +9,11 @@
* exceptions) is a prominent rose; expected (HttpError, business 4xx) recedes into gray.
* The outer frame is provided by the caller's ChartCard (full width, below the four business charts).
*/
import { useState } from "react";
import type { UsageErrors } from "@prismshadow/penguin-server/api";
import { useEffect, useState } from "react";
import type { UsageErrorItem, UsageErrors } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { formatDateTime } from "../../lib/format";
import { Badge } from "../../components/ui/badge";
import { Empty } from "./usage-charts";
@@ -28,6 +30,18 @@ function kindOf(kind: string): ErrorKindKey {
return kind === "unexpected" ? "unexpected" : "expected";
}
/**
* Source labels are abbreviated so the bracket stays narrow beside a long code:
* `[env] tool_failed:exec_command`, `[http] password`. Only `environment` needs shortening
* -- every other source is already short enough to read at a glance.
*/
const SOURCE_ABBREV: Readonly<Record<string, string>> = { environment: "env" };
/** `[env] tool_failed:read_file` -- the bracketed source followed by the raw error code. */
function sourceCode(source: string, code: string): string {
return `[${SOURCE_ABBREV[source] ?? source}] ${code}`;
}
/** A single small stat: name + value, one row side by side (not turned into a chart). */
function Stat({
label,
@@ -67,10 +81,79 @@ function Th({ children, className = "" }: { children: React.ReactNode; className
* and clicking again collapses it. The full text is also in the hover title. Cells align to the
* top so an expanded multi-line message keeps the row tidy; the table scrolls past max height.
*/
export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
export function ErrorsPanel({
errors,
projectId,
filters,
}: {
errors: UsageErrors;
projectId: string;
/**
* The dashboard's own date/agent filter — a page must never widen what the summary counted.
* Memoize it at the call site: the fetch effect depends on the object's identity.
*/
filters: { from?: string; to?: string; agentId?: string };
}) {
const { total, unexpected, topCode, recent } = errors;
// Message rows expanded to their full text (index into `recent`); one line each by default.
// Page size is read off the first page rather than duplicating the server's ERROR_RECENT_N:
// whenever a second page exists at all, `recent` is exactly that many rows, so the two cannot
// drift apart into skipping or repeating rows. (Empty means a single empty page anyway.)
const pageSize = Math.max(1, recent.length);
// Paging: page 0 is the `recent` the dashboard response already carried, so it costs no
// request; later pages are fetched on demand.
const [page, setPage] = useState(0);
const [items, setItems] = useState<UsageErrorItem[]>(recent);
// The row count the pager counts pages against: seeded from the dashboard snapshot, then
// replaced by each page's own total. The snapshot goes stale (rows evicted by the row cap, an
// Agent deleted between load and click), and pinning to it computes a page count that can
// strand the caller on a page the data no longer has.
const [pagedTotal, setPagedTotal] = useState(total);
const [pageError, setPageError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// A new dashboard response invalidates the offsets being paged through, so paging goes back to
// page 0 — whose rows the effect below restores from that same response. Today the panel is
// remounted on every filter change, which resets this anyway; the reset does not rely on that.
useEffect(() => setPage(0), [recent]);
// Every value this effect reads is a dep, the page-0 branch's included: one left out would be
// served from a stale closure, and the repo has no lint rule that would catch it.
useEffect(() => {
// Page 0 is restored, never fetched — the dashboard response already carries it. Restoring
// is the whole point of the early return: leaving `items` untouched would keep the previous
// page's rows and its error on screen under a "page 1" label, with no way out short of
// changing a filter.
if (page === 0) {
setItems(recent);
setPagedTotal(total);
setPageError(null);
return;
}
let cancelled = false;
setLoading(true);
setPageError(null);
api
.getUsageErrors(projectId, { offset: page * pageSize, limit: pageSize, ...filters })
.then((res) => {
if (cancelled) return;
setItems(res.items);
setPagedTotal(res.total);
})
.catch((e: unknown) => {
// Keep the rows already on screen rather than blanking the table under an error.
if (!cancelled) setPageError(apiErrorText(e));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [page, pageSize, projectId, filters, recent, total]);
const pageCount = Math.max(1, Math.ceil(pagedTotal / pageSize));
// Message rows expanded to their full text (index into the current page); one line each by default.
const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set());
useEffect(() => setExpanded(new Set()), [items]);
const toggle = (i: number) =>
setExpanded((prev) => {
const next = new Set(prev);
@@ -94,13 +177,13 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
{topCode && (
<Stat
label={S.usage.errorsTopCode}
value={`${topCode.source} · ${topCode.code} ×${topCode.count}`}
value={`${sourceCode(topCode.source, topCode.code)} ×${topCode.count}`}
/>
)}
</div>
{/* Recent-errors table */}
{recent.length === 0 ? (
{items.length === 0 ? (
<Empty text={S.usage.errorsEmpty} />
) : (
<div className="mt-2.5 max-h-72 overflow-y-auto border-t border-gray-200 dark:border-gray-800">
@@ -109,7 +192,7 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
<tr>
<Th className="w-32">{S.common.time}</Th>
{/* Wide enough to fully fit the longest error code: a tool
failure's code carries the tool name (e.g. environment ·
failure's code carries the tool name (e.g. [env]
tool_failed:exec_command), and truncating it would hide which tool failed. */}
<Th className="w-72">{S.usage.errorsColCode}</Th>
<Th className="w-20">{S.usage.errorsColKind}</Th>
@@ -117,7 +200,7 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
</tr>
</thead>
<tbody>
{recent.map((e, i) => {
{items.map((e, i) => {
const key = kindOf(e.kind);
return (
<tr
@@ -128,9 +211,7 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
{formatDateTime(e.ts)}
</td>
<td className="py-1.5 pr-2 align-top font-mono text-gray-500 dark:text-gray-400">
<span className="block break-words">
{e.source} · {e.code}
</span>
<span className="block break-words">{sourceCode(e.source, e.code)}</span>
</td>
<td className="py-1.5 pr-2 align-top">
<Badge tone={key === "unexpected" ? "red" : "gray"}>{kindLabel(key)}</Badge>
@@ -155,6 +236,51 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
</table>
</div>
)}
{/* Pager: once there is more than one page — and unconditionally while paged away from
the first, so a later page that came back empty or shrank below the page count still
offers the way back instead of stranding the reader on a bare table. Kept outside the
scroll box so it stays reachable without scrolling to the bottom of the rows. */}
{(pageCount > 1 || page > 0) && (
<div className="mt-2 flex items-center justify-end gap-2 text-xs text-gray-500 dark:text-gray-400">
{pageError !== null && <span className="mr-auto text-rose-500">{pageError}</span>}
<span className="tabular-nums">
{S.usage.errorsPageOf(page + 1, pageCount, pagedTotal)}
</span>
<PagerButton
label={S.usage.errorsNewer}
disabled={page === 0 || loading}
onClick={() => setPage((p) => Math.max(0, p - 1))}
/>
<PagerButton
label={S.usage.errorsOlder}
disabled={page + 1 >= pageCount || loading}
onClick={() => setPage((p) => p + 1)}
/>
</div>
)}
</div>
);
}
/** Pager step button: same recessive treatment as the rest of the panel's chrome. */
function PagerButton({
label,
disabled,
onClick,
}: {
label: string;
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className="rounded-md border border-gray-200 px-2 py-0.5 transition-colors duration-150 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:border-gray-800 dark:hover:bg-gray-800/60 dark:disabled:hover:bg-transparent"
>
{label}
</button>
);
}
+11 -2
View File
@@ -12,7 +12,7 @@
* (stats + a recent-errors table).
* Currency follows the user's settings; a row with unconfigured pricing shows its cost as "—".
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import type { ModelRefDto, UsageBucket, UsageResponse } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
@@ -168,6 +168,15 @@ export function UsagePage() {
void load();
}, [load]);
// The error panel pages against this filter and depends on the object's identity, so it is
// built once per filter value rather than fresh on every render — a new object each render
// would refetch the current page on any unrelated state change (hovering a chart bucket).
// The model filter is deliberately absent: it never applied to errors.
const errorFilters = useMemo(
() => ({ from, to, ...(agentFilter ? { agentId: agentFilter } : {}) }),
[from, to, agentFilter],
);
if (!projectId) return null;
// Model filter candidates and the currently selected item's index (the option value uses the index, avoiding concatenating an id as the key).
@@ -294,7 +303,7 @@ export function UsagePage() {
{/* Errors (a single full-width panel: stats + a recent-errors table) */}
{data && (
<ChartCard title={S.usage.errors}>
<ErrorsPanel errors={data.errors} />
<ErrorsPanel errors={data.errors} projectId={projectId} filters={errorFilters} />
</ChartCard>
)}
+5
View File
@@ -868,6 +868,11 @@ When done, open index.html in a browser and self-test once.`,
errorsColKind: "Type",
errorsColMessage: "Message",
errorsEmpty: "No errors",
/** Detail-table pager: newer/older step back through pages of the same filtered set. */
errorsNewer: "Newer",
errorsOlder: "Older",
errorsPageOf: (page: number, pages: number, total: number) =>
`Page ${page} / ${pages} · ${total} total`,
},
traces: {
+5
View File
@@ -846,6 +846,11 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
errorsColKind: "类型",
errorsColMessage: "消息",
errorsEmpty: "暂无异常",
/** Detail-table pager: newer/older step back through pages of the same filtered set. */
errorsNewer: "较新",
errorsOlder: "更早",
errorsPageOf: (page: number, pages: number, total: number) =>
`第 ${page} / ${pages} 页 · 共 ${total} 条`,
},
traces: {