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:
@@ -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