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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user