feat(server,web): DB-served session list, CLI-session visibility toggle, and sidebar group paging (#158)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-03 13:51:14 +08:00
committed by GitHub
parent e6a58ac131
commit aeffc8a02f
16 changed files with 359 additions and 42 deletions
+6
View File
@@ -110,6 +110,12 @@ export interface UiPrefs {
lastProjectId?: string;
/** Whether the "no API key configured" guide has already been shown: once ever (on first visit to the chat page). */
credentialGuideSeen?: boolean;
/**
* Also list CLI-created Sessions in the sidebar (`cli=1` on the sessions list). Default
* off: the list then serves web rows straight from the DB, with no Trace-directory
* scanning (#139).
*/
showCliSessions?: boolean;
[key: string]: unknown;
}
+12
View File
@@ -24,5 +24,17 @@ export function openDatabase(dbPath: string): DatabaseSync {
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
db.exec(SCHEMA_SQL);
// Columns added to the schema after a web.db was formed: CREATE TABLE IF NOT EXISTS never
// touches an existing table, so they are ALTERed in here. Keep the list in sync with
// schema.ts; drop entries only in a release allowed to break existing web.db files.
ensureColumn(db, "sessions", "client", "TEXT");
ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0");
return db;
}
/** Idempotent per-column upgrade for databases formed before the column existed. */
function ensureColumn(db: DatabaseSync, table: string, column: string, ddl: string): void {
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
if (cols.some((c) => c.name === column)) return;
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
}
+36 -8
View File
@@ -19,8 +19,16 @@ export interface SessionRow {
title: string | null;
/** Archive timestamp, ISO; NULL = not archived (omitting on insert defaults to NULL). */
archivedAt?: string | null;
// The Session origin (schedule / subagent) is deliberately NOT a row field: core
// session_meta in the Trace is the single source of truth (runtime/session-sources.ts).
/**
* Creating client: "web" (created via the Web App/server) or "cli" (adopted from a Trace
* the CLI left behind); NULL = legacy row from before the column existed, treated as web.
* The schedule/subagent SOURCE is deliberately NOT a row field — core session_meta in the
* Trace stays the single source of truth for it (runtime/session-sources.ts); `client` is
* a separate, DB-only axis that meta never records.
*/
client?: "web" | "cli" | null;
/** Cache: a Trace record exists (set at task start / adoption / subagent registration; backfilled by list hydration). */
hasTrace?: boolean;
createdAt: string;
}
@@ -35,6 +43,8 @@ function mapRow(r: Record<string, unknown>): SessionRow {
approvalMode: r.approval_mode as ApprovalMode,
title: (r.title as string | null) ?? null,
archivedAt: (r.archived_at as string | null) ?? null,
client: (r.client as "web" | "cli" | null) ?? null,
hasTrace: (r.has_trace as number) === 1,
createdAt: r.created_at as string,
};
}
@@ -45,8 +55,8 @@ export class SessionsRepo {
insert(row: SessionRow): void {
this.db
.prepare(
`INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
row.sessionId,
@@ -57,6 +67,8 @@ export class SessionsRepo {
row.workspace,
row.approvalMode,
row.title,
row.client ?? null,
row.hasTrace ? 1 : 0,
row.createdAt,
);
}
@@ -65,8 +77,8 @@ export class SessionsRepo {
insertOrIgnore(row: SessionRow): void {
this.db
.prepare(
`INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
row.sessionId,
@@ -77,18 +89,34 @@ export class SessionsRepo {
row.workspace,
row.approvalMode,
row.title,
row.client ?? null,
row.hasTrace ? 1 : 0,
row.createdAt,
);
}
/** Flip the has_trace cache once a Trace record exists (task start / discovery hydration); idempotent. */
markHasTrace(sessionId: string): void {
this.db.prepare("UPDATE sessions SET has_trace = 1 WHERE session_id = ?").run(sessionId);
}
findById(sessionId: string): SessionRow | null {
const r = this.db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
return r ? mapRow(r) : null;
}
listByAgent(projectId: string, agentId: string): SessionRow[] {
/**
* An Agent's rows, newest first (the list order the sidebar shows; served by
* idx_sessions_agent_created). `webOnly` keeps only web-created rows — NULL counts as
* web (legacy rows predate the column and the user chose to grandfather them as visible).
*/
listByAgent(projectId: string, agentId: string, opts: { webOnly?: boolean } = {}): SessionRow[] {
const filter = opts.webOnly ? " AND (client IS NULL OR client = 'web')" : "";
const rows = this.db
.prepare("SELECT * FROM sessions WHERE project_id = ? AND agent_id = ?")
.prepare(
`SELECT * FROM sessions WHERE project_id = ? AND agent_id = ?${filter}
ORDER BY created_at DESC, session_id DESC`,
)
.all(projectId, agentId);
return rows.map(mapRow);
}
+8 -2
View File
@@ -4,7 +4,10 @@
* SQLite stores only indexes and aggregates: users / login sessions / Project authorization /
* Agent & Session indexes / usage summaries / error records / UI preferences. Agent State,
* Trace, and Workspace still follow the local directory-based storage rules.
* Product not yet released: no migration branches — everything is CREATE IF NOT EXISTS, formed once.
* Product not yet released: no migration branches — everything is CREATE IF NOT EXISTS, formed
* once. The one exception: columns added to an existing table after release of a web.db are
* ALTERed in by the idempotent per-column guard in database.ts (ensureColumn), since CREATE
* TABLE IF NOT EXISTS never touches an existing table.
*/
export const SCHEMA_SQL = `
@@ -48,8 +51,11 @@ CREATE TABLE IF NOT EXISTS sessions (
approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask
title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat")
archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived")
client TEXT, -- creating client: 'web' (created via the Web App) | 'cli' (adopted from a CLI Trace); NULL = legacy row, treated as web
has_trace INTEGER NOT NULL DEFAULT 0, -- cache: a Trace record exists (set at task start / adoption / subagent registration; lazily backfilled by list hydration)
created_at TEXT NOT NULL
); -- session origin is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts)
); -- the schedule/subagent SOURCE is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts); "client" is a different, DB-only axis (who created the row)
CREATE INDEX IF NOT EXISTS idx_sessions_agent_created ON sessions(project_id, agent_id, created_at DESC);
CREATE TABLE IF NOT EXISTS usage_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
@@ -217,6 +217,8 @@ function parseGoalField(body: Record<string, unknown>): { budget: number } | nul
export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
// `cli=1` widens the list to CLI-created Sessions (Trace-directory discovery + adoption);
// the default serves web rows straight from the DB (see SessionService.listSessions).
app.get("/", async (c) => {
// Id validity is checked before any path is constructed (FD-4: guards against agentId path traversal across Projects).
const projectId = requireValidId(c, "projectId");
@@ -234,6 +236,8 @@ export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
}
const rawCounts = c.req.query("counts");
if (rawCounts !== undefined && rawCounts !== "1") throw badRequest("counts only accepts 1.");
const rawCli = c.req.query("cli");
if (rawCli !== undefined && rawCli !== "1") throw badRequest("cli only accepts 1.");
const { sessions, counts, workspaceCounts } = await deps.sessionService.listSessions(
projectId,
agentId,
@@ -241,6 +245,7 @@ export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
...(paging ? { paging } : {}),
...(rawCategory !== undefined ? { category: rawCategory as SessionCategory } : {}),
...(rawCounts !== undefined ? { withCounts: true } : {}),
...(rawCli !== undefined ? { includeCli: true } : {}),
},
);
return c.json({
@@ -1056,6 +1056,10 @@ export class SessionManager {
gen: AsyncGenerator<OmniMessage>,
titleSource?: { userExcerpt: string },
): Promise<void> {
// Every driven run (task, goal round, compaction) writes Trace lines: flip the row's
// has_trace cache here, the single choke point, so listing can serve it from the DB
// without a directory walk (see SessionService.listSessions).
this.deps.sessions.markHasTrace(entry.sessionId);
let earlyTitleFired = false;
let mainBodyChars = 0;
const ctx: UsageContext = {
@@ -1276,6 +1280,8 @@ export class SessionManager {
// inserted with defaults (matches the convention for Sessions discovered by the CLI).
approvalMode: "allow-all",
title: null,
// Spawned by this server's run (client NULL = web); its Trace exists by construction.
hasTrace: true,
createdAt: new Date().toISOString(),
});
// Make the subagent appear immediately in the sidebar: notify via the parent
+58 -21
View File
@@ -1,12 +1,14 @@
/**
* Session index service.
*
* The list is DB index ∪ Trace directory discovery: scans
* `<agent>/traces/<date>/<session_id>_<index3>.jsonl`; an unmanaged Session (e.g.
* one started via the CLI) has its first line's session_meta read for
* (provider, model_id) / workspace, which is backfilled into a DB row
* (approval_mode defaults, createdAt is taken from the timestamp embedded in
* session_id).
* The default list is served from the DB index alone (web rows: `client` web/NULL) — no
* Trace directory scanning in steady state (#139). `includeCli` widens it to DB ∪ Trace
* directory discovery: scans `<agent>/traces/<date>/<session_id>_<index3>.jsonl`; an
* unmanaged Session (one started via the CLI) has its first line's session_meta read for
* (provider, model_id) / workspace, which is backfilled into a DB row marked
* `client: "cli"` (approval_mode defaults, createdAt is taken from the timestamp embedded
* in session_id) — the default list keeps excluding it, but it becomes individually
* reachable (deep links).
* Create: via core's `agent.createSession` (the model reference is always a complete
* (provider, modelId) pair — both or neither; omitting both falls back to the
* Project's default reference, 400 if there is none); the new Session is
@@ -175,11 +177,19 @@ export class SessionService {
}
/**
* List: DB ∪ Trace directory discovery, sorted by createdAt descending. Optional
* `paging` returns just that slice (the sidebar pages with limit+1 to detect "has
* more"); slicing happens before toInfo, so per-request source derivation (lazy
* Trace-head reads) stays bounded by the page size. Discovery/adoption still scans
* the whole directory — the union and global ordering need every id.
* List, sorted by createdAt descending. The default serves **web sessions straight from
* the DB** (`client` web/NULL rows) with no Trace directory scanning — the answer to
* many-session sidebar reloads re-walking the filesystem on every request (#139). One
* lazy discovery walk still runs for a list call that contains rows this process has not
* classified yet (no in-process source entry): it supplies the Trace locations for the
* one-time head reads and backfills the `has_trace` cache; once every row is classified,
* list calls touch only the DB. `includeCli` widens the list to DB ∪ Trace directory
* discovery (adopting unmanaged CLI Traces as `client: "cli"` rows), which inherently
* scans — that path is opt-in via the "show CLI sessions" preference.
*
* Optional `paging` returns just that slice (the sidebar pages with limit+1 to detect
* "has more"); slicing happens before toInfo, so per-request source derivation (lazy
* Trace-head reads) stays bounded by the page size.
*
* `category` filters to one sidebar bucket **before** paging, so offset/limit page
* within the category. Filtering needs each walked row's category (a possible
@@ -196,30 +206,50 @@ export class SessionService {
paging?: { offset: number; limit: number };
category?: SessionCategory;
withCounts?: boolean;
includeCli?: boolean;
} = {},
): Promise<{
sessions: SessionInfo[];
counts?: SessionCategoryCounts;
workspaceCounts?: Record<string, SessionCategoryCounts>;
}> {
const { paging, category, withCounts } = opts;
const traces = await this.discoverTraces(projectId, agentId);
const { paging, category, withCounts, includeCli } = opts;
const rows = new Map(
this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r]),
this.deps.sessions
.listByAgent(projectId, agentId, { webOnly: !includeCli })
.map((r) => [r.sessionId, r]),
);
// Unmanaged Trace Sessions: backfill an index row by reading the first line's session_meta.
for (const [sessionId, location] of traces) {
if (rows.has(sessionId)) continue;
const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location);
if (discovered) rows.set(sessionId, discovered);
let traces: ReadonlyMap<string, TraceLocation> | undefined;
if (includeCli) {
traces = await this.discoverTraces(projectId, agentId);
// Unmanaged Trace Sessions (the CLI's): backfill an index row by reading the first
// line's session_meta, marked client "cli" so the default list can exclude them.
for (const [sessionId, location] of traces) {
if (rows.has(sessionId)) continue;
const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location);
if (discovered) rows.set(sessionId, discovered);
}
} else if ([...rows.values()].some((r) => this.deps.sources.get(r.sessionId) === undefined)) {
// Hydration walk: some rows predate this process and are unclassified — locate their
// Traces once so sourceOf's head reads (cached afterwards) and the has_trace cache
// don't have to walk per row. Steady state (everything classified) skips this.
traces = await this.discoverTraces(projectId, agentId);
for (const row of rows.values()) {
if (!row.hasTrace && traces.has(row.sessionId)) {
row.hasTrace = true;
this.deps.sessions.markHasTrace(row.sessionId);
}
}
}
const sorted = [...rows.values()].sort(
(a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId),
);
const rowHasTrace = (row: SessionRow): boolean =>
traces ? traces.has(row.sessionId) : row.hasTrace === true;
const toPage = (page: SessionRow[]) =>
Promise.all(page.map((row) => this.toInfo(row, traces.has(row.sessionId), traces)));
Promise.all(page.map((row) => this.toInfo(row, rowHasTrace(row), traces)));
// No classification asked for: slice straight away (the pre-category behavior).
if (category === undefined && !withCounts) {
@@ -236,7 +266,7 @@ export class SessionService {
const matched: SessionRow[] = [];
for (const row of sorted) {
if (!withCounts && matched.length >= want) break;
const cat = await this.categoryOf(row, traces.has(row.sessionId), traces);
const cat = await this.categoryOf(row, rowHasTrace(row), traces);
counts[cat] += 1;
if (withCounts) {
const ws = (workspaceCounts[row.workspace] ??= {
@@ -384,6 +414,9 @@ export class SessionService {
workspace: session.workspaceDir,
approvalMode: args.approvalMode ?? "allow-all",
title: null,
// Everything created through this service is the Web App's (schedule runs included);
// "cli" is stamped only by Trace adoption. NULL means a legacy row, treated as web.
client: "web",
createdAt: new Date().toISOString(),
};
this.deps.sessions.insert(row);
@@ -474,6 +507,10 @@ export class SessionService {
// The approval mode for an unmanaged Session (started via the CLI) isn't in the Trace, so it's backfilled with the default value.
approvalMode: "allow-all",
title: null,
// Adopted = a Trace this server never created, i.e. the CLI's: the default list
// (web-only) excludes these rows; the "show CLI sessions" preference includes them.
client: "cli",
hasTrace: true,
createdAt: sessionIdCreatedAt(sessionId) ?? meta.timestamp,
};
// Idempotent backfill: concurrent list calls may discover the same Session for the first time simultaneously (consistent with AgentsRepo's convention).
+99
View File
@@ -0,0 +1,99 @@
/**
* openDatabase's per-column upgrade guard (ensureColumn): a web.db formed before a column
* existed gets it ALTERed in on open — CREATE TABLE IF NOT EXISTS alone never touches an
* existing table, so without the guard, code writing the new columns would break on every
* pre-existing database.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { openDatabase } from "../src/db/database.js";
import { SessionsRepo } from "../src/db/repos/sessions.js";
const sqlite = process.getBuiltinModule("node:sqlite");
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "penguin-db-upgrade-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
describe("openDatabase column upgrade", () => {
it("adds client/has_trace to a sessions table formed before the columns existed", () => {
const dbPath = path.join(dir, "web.db");
// A database formed by the pre-#139 schema: sessions without client / has_trace.
const old = new sqlite.DatabaseSync(dbPath);
old.exec(`CREATE TABLE sessions (
session_id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
provider TEXT NOT NULL,
model_id TEXT NOT NULL,
workspace TEXT NOT NULL,
approval_mode TEXT NOT NULL DEFAULT 'allow-all',
title TEXT,
archived_at TEXT,
created_at TEXT NOT NULL
);`);
old
.prepare(
`INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, created_at)
VALUES ('session-legacy', 'p1', 'a1', 'custom', 'm1', '/w', '2026-01-01T00:00:00.000Z')`,
)
.run();
old.close();
const db = openDatabase(dbPath);
try {
const repo = new SessionsRepo(db);
// The legacy row reads back with the grandfathered defaults: client NULL (treated as
// web — it stays in the default list) and has_trace false.
const legacy = repo.findById("session-legacy");
expect(legacy).not.toBeNull();
expect(legacy!.client).toBeNull();
expect(legacy!.hasTrace).toBe(false);
expect(repo.listByAgent("p1", "a1", { webOnly: true }).map((r) => r.sessionId)).toEqual([
"session-legacy",
]);
// The upgraded table accepts writes to the new columns.
repo.insert({
sessionId: "session-new",
projectId: "p1",
agentId: "a1",
provider: "custom",
modelId: "m1",
workspace: "/w",
approvalMode: "allow-all",
title: null,
client: "cli",
hasTrace: true,
createdAt: "2026-01-02T00:00:00.000Z",
});
expect(repo.findById("session-new")!.client).toBe("cli");
expect(repo.listByAgent("p1", "a1", { webOnly: true }).map((r) => r.sessionId)).toEqual([
"session-legacy",
]);
repo.markHasTrace("session-legacy");
expect(repo.findById("session-legacy")!.hasTrace).toBe(true);
} finally {
db.close();
}
});
it("is idempotent: reopening an already-upgraded database changes nothing", () => {
const dbPath = path.join(dir, "web.db");
openDatabase(dbPath).close();
const db = openDatabase(dbPath);
try {
const cols = db.prepare("PRAGMA table_info(sessions)").all() as { name: string }[];
expect(cols.filter((c) => c.name === "client")).toHaveLength(1);
expect(cols.filter((c) => c.name === "has_trace")).toHaveLength(1);
} finally {
db.close();
}
});
});
+32 -4
View File
@@ -194,7 +194,7 @@ describe("session-index", () => {
sessionMeta(junkMeta),
userText("junk"),
]);
const list = (await (await api.get(base())).json()) as SessionsResponse;
const list = (await (await api.get(`${base()}?cli=1`)).json()) as SessionsResponse;
expect(list.sessions.find((s) => s.sessionId === adopted)?.source).toBe("schedule");
expect(list.sessions.find((s) => s.sessionId === junk)?.source).toBeUndefined();
});
@@ -395,7 +395,13 @@ describe("session-index", () => {
userText("cli session"),
]);
const list = (await (await api.get(base())).json()) as SessionsResponse;
// The default list is DB-only (web rows): an unmanaged CLI Trace is neither listed
// nor adopted by it (#139 — no Trace-directory scanning on the default path).
const webOnly = (await (await api.get(base())).json()) as SessionsResponse;
expect(webOnly.sessions.find((s) => s.sessionId === discovered)).toBeUndefined();
expect((await api.get(`/api/sessions/${discovered}`)).status).toBe(404);
const list = (await (await api.get(`${base()}?cli=1`)).json()) as SessionsResponse;
const found = list.sessions.find((s) => s.sessionId === discovered);
expect(found).toBeDefined();
expect(found!.modelId).toBe("cli-model");
@@ -404,11 +410,33 @@ describe("session-index", () => {
expect(found!.hasTrace).toBe(true);
expect(found!.createdAt).toBe(sessionIdCreatedAt(discovered));
// Already indexed: visible via the single-lookup endpoint.
// Adopted as client "cli": the default list still excludes it afterwards, and counts
// follow the same filter…
const after = (await (await api.get(`${base()}?counts=1`)).json()) as SessionsResponse;
expect(after.sessions.find((s) => s.sessionId === discovered)).toBeUndefined();
expect(after.counts!.active).toBe(0);
// …but the adopted row makes the Session individually reachable (deep links work).
const single = await api.get(`/api/sessions/${discovered}`);
expect(single.status).toBe(200);
});
it("legacy rows without a client marker stay visible by default (grandfathered as web)", async () => {
const legacy = "session-2026-07-02-09-00-00-0abc0001";
t.deps.sessionsRepo.insert({
sessionId: legacy,
projectId,
agentId: "default_agent",
provider: "custom",
modelId: "m-legacy",
workspace: "/tmp/w-legacy",
approvalMode: "allow-all",
title: null,
createdAt: "2026-07-02T09:00:00.000Z",
});
const list = (await (await api.get(base())).json()) as SessionsResponse;
expect(list.sessions.find((s) => s.sessionId === legacy)).toBeDefined();
});
it("DELETE Session: clears the index row and every Trace shard; the list doesn't resurrect it; re-delete 404", async () => {
await configureModels();
const { session } = (await (await api.post(base(), {})).json()) as SessionCreateResponse;
@@ -484,7 +512,7 @@ describe("session-index", () => {
}),
]);
const created = (await (await api.post(base(), {})).json()) as SessionCreateResponse;
const list = (await (await api.get(base())).json()) as SessionsResponse;
const list = (await (await api.get(`${base()}?cli=1`)).json()) as SessionsResponse;
expect(list.sessions[0]!.sessionId).toBe(created.session.sessionId);
expect(list.sessions[list.sessions.length - 1]!.sessionId).toBe(older);
});
@@ -147,6 +147,8 @@ describe("session-manager", () => {
expect(first.payload.text).toBe("hello");
const states = serverEvents(events).filter((e) => e.type === "task_state");
expect(states.map((s) => s.state)).toEqual(["running", "idle"]);
// Driving a run flips the row's has_trace cache (listing then never walks for it).
expect(sessions.findById("session-1")!.hasTrace).toBe(true);
// Outputs and events are forwarded one by one and handed to the recorder.
expect(recordedCtx[0]).toEqual({
projectId: "p1",