From ce94c254399a274ff83ab72582797842c290dc50 Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:14:35 +0800 Subject: [PATCH] feat(web): list archived sessions --- tests/web/pi-adapter.test.ts | 70 +++++++++++++++++ tests/web/web-host.test.ts | 24 ++++++ web/adapter/pi-adapter.ts | 141 +++++++++++++++++++++++++++++++++++ web/host/web-host.ts | 43 +++++++++++ web/protocol/types.ts | 4 + 5 files changed, 282 insertions(+) diff --git a/tests/web/pi-adapter.test.ts b/tests/web/pi-adapter.test.ts index af2f7476..ace01c0f 100644 --- a/tests/web/pi-adapter.test.ts +++ b/tests/web/pi-adapter.test.ts @@ -233,6 +233,76 @@ test("first archive mutation preserves previously persisted archive metadata", a } }); +test("archived Session listing is queryable, cursor-bounded, and stale-safe", async () => { + const root = await mkdtemp(join(tmpdir(), "openpi-web-archived-list-")); + const sessionDirectory = join(root, "sessions"); + try { + const current = SessionManager.inMemory(root); + const alphaOne = SessionManager.create(root, sessionDirectory); + persistSession(alphaOne, "alpha first", 1); + alphaOne.appendSessionInfo("Alpha One"); + const alphaTwo = SessionManager.create(root, sessionDirectory); + persistSession(alphaTwo, "alpha second", 2); + alphaTwo.appendSessionInfo("Alpha Two"); + const beta = SessionManager.create(root, sessionDirectory); + persistSession(beta, "beta only", 3); + beta.appendSessionInfo("Beta"); + const paths = [ + alphaOne.getSessionFile(), + alphaTwo.getSessionFile(), + beta.getSessionFile(), + ]; + assert.ok(paths.every((path) => path !== undefined)); + + const adapter = new PiWebAdapter( + runtimeFor(root, sessionDirectory, current), + ); + for (const path of paths) await adapter.archiveSession(path!); + + const first = await adapter.listArchivedSessions({ + query: "ALPHA", + limit: 1, + }); + assert.equal(first.status, "ok"); + if (first.status !== "ok") return; + assert.equal(first.sessions.length, 1); + assert.equal(first.sessions[0]?.archived, true); + assert.ok(first.nextCursor); + assert.equal(first.truncation.matchesOmitted, 1); + + const second = await adapter.listArchivedSessions({ + query: "alpha", + limit: 1, + cursor: first.nextCursor, + }); + assert.equal(second.status, "ok"); + if (second.status !== "ok") return; + assert.equal(second.sessions.length, 1); + assert.equal(second.nextCursor, undefined); + assert.deepEqual( + new Set([...first.sessions, ...second.sessions].map((item) => item.id)), + new Set([alphaOne.getSessionId(), alphaTwo.getSessionId()]), + ); + + assert.deepEqual( + await adapter.listArchivedSessions({ + cursor: first.nextCursor, + query: "different-query", + }), + { status: "stale_cursor" }, + ); + assert.deepEqual( + await adapter.listArchivedSessions({ cursor: "not-a-valid-cursor" }), + { status: "invalid" }, + ); + assert.deepEqual(await adapter.listArchivedSessions({ limit: 51 }), { + status: "invalid", + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("corrupt package metadata fails closed without overwriting it", async () => { const root = await mkdtemp(join(tmpdir(), "openpi-web-corrupt-state-")); const imported = await mkdtemp(join(tmpdir(), "openpi-web-corrupt-import-")); diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index d80df4fa..87cde950 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -306,6 +306,30 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn truncated: false, sessionsOmitted: 0, }); + const archivedSessionsResponse = await fetch( + `${launched.origin}/api/sessions/archived?limit=10&q=current`, + { headers: authorized }, + ); + assert.equal(archivedSessionsResponse.status, 200); + assert.deepEqual(await archivedSessionsResponse.json(), { + sessions: [], + truncation: { + truncated: false, + matchesOmitted: 0, + recordsUnscanned: 0, + maxPageSize: 50, + maxScanned: 5_000, + }, + }); + const invalidArchiveQuery = await fetch( + `${launched.origin}/api/sessions/archived?limit=51`, + { headers: authorized }, + ); + assert.equal(invalidArchiveQuery.status, 400); + assert.deepEqual(await invalidArchiveQuery.json(), { + code: "INVALID_ARCHIVED_SESSION_QUERY", + error: "archived Session limit must be a bounded positive integer", + }); const currentSessionPath = listedSessions.sessions[0]?.path; assert.ok(currentSessionPath); const sessionRename = await fetch(`${launched.origin}/api/sessions`, { diff --git a/web/adapter/pi-adapter.ts b/web/adapter/pi-adapter.ts index fb4c0bc9..3e2171aa 100644 --- a/web/adapter/pi-adapter.ts +++ b/web/adapter/pi-adapter.ts @@ -8,6 +8,10 @@ import { projectEntries, projectEntry, WEB_MAX_MODELS, + WEB_MAX_ARCHIVED_SESSION_PAGE, + WEB_MAX_ARCHIVED_SESSION_CURSOR, + WEB_MAX_ARCHIVED_SESSION_QUERY, + WEB_MAX_ARCHIVED_SESSION_SCAN, WEB_MAX_SESSIONS, WEB_MAX_SESSION_PREVIEW, WEB_MAX_SNAPSHOT_BYTES, @@ -27,6 +31,47 @@ type WorkspaceStateSnapshot = { restoreInitialWorkspace: boolean; }; +export interface ArchivedSessionQuery { + readonly cursor?: string; + readonly limit?: number; + readonly query?: string; +} + +interface ArchivedSessionCursor { + readonly version: 1; + readonly sessionId: string; + readonly query: string; +} + +function encodeArchivedSessionCursor(cursor: ArchivedSessionCursor) { + return Buffer.from(JSON.stringify(cursor)).toString("base64url"); +} + +function decodeArchivedSessionCursor(value: string) { + try { + const bytes = Buffer.from(value, "base64url"); + if (bytes.toString("base64url") !== value) return undefined; + const parsed: unknown = JSON.parse(bytes.toString("utf8")); + if ( + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) || + (parsed as { version?: unknown }).version !== 1 || + typeof (parsed as { sessionId?: unknown }).sessionId !== "string" || + (parsed as { sessionId: string }).sessionId.length === 0 || + (parsed as { sessionId: string }).sessionId.length > 160 || + typeof (parsed as { query?: unknown }).query !== "string" || + (parsed as { query: string }).query.length > + WEB_MAX_ARCHIVED_SESSION_QUERY + ) { + return undefined; + } + return parsed as ArchivedSessionCursor; + } catch { + return undefined; + } +} + export class PiWebAdapter { private readonly runtime: WebRuntimeController; private readonly importedWorkspaces = new Set(); @@ -284,6 +329,102 @@ export class PiWebAdapter { }); } + async listArchivedSessions(options: ArchivedSessionQuery = {}) { + await this.ensureWorkspaceStateLoaded(); + await this.ensureArchivesLoaded(); + const limit = options.limit ?? 25; + const query = options.query?.trim() ?? ""; + if ( + !Number.isSafeInteger(limit) || + limit <= 0 || + limit > WEB_MAX_ARCHIVED_SESSION_PAGE || + query.length > WEB_MAX_ARCHIVED_SESSION_QUERY || + /[\u0000-\u001f\u007f]/u.test(query) || + (options.cursor !== undefined && + (options.cursor.length === 0 || + options.cursor.length > WEB_MAX_ARCHIVED_SESSION_CURSOR || + /[\u0000-\u001f\u007f]/u.test(options.cursor))) + ) { + return { status: "invalid" as const }; + } + + const allSessions = await SessionManager.listAll( + this.runtime.sessionDirectory, + ); + const scanned = allSessions.slice(0, WEB_MAX_ARCHIVED_SESSION_SCAN); + const normalizedQuery = query.normalize("NFKC").toLocaleLowerCase(); + const matches = scanned.filter((session) => { + if (!this.archivedSessions.has(resolve(session.path))) return false; + if (!normalizedQuery) return true; + const source = [ + session.id, + session.name ?? "", + session.cwd, + session.firstMessage.slice(0, 2_000), + ] + .join("\n") + .normalize("NFKC") + .toLocaleLowerCase(); + return source.includes(normalizedQuery); + }); + let start = 0; + if (options.cursor !== undefined) { + const cursor = decodeArchivedSessionCursor(options.cursor); + if (!cursor) return { status: "invalid" as const }; + if (cursor.query !== normalizedQuery) { + return { status: "stale_cursor" as const }; + } + const cursorIndex = matches.findIndex( + (session) => session.id === cursor.sessionId, + ); + if (cursorIndex < 0) return { status: "stale_cursor" as const }; + start = cursorIndex + 1; + } + const selected = matches.slice(start, start + limit); + const sessions = selected.map((session) => ({ + id: session.id, + path: session.path, + cwd: resolve(session.cwd), + ...(session.name + ? { name: boundedText(session.name, WEB_MAX_SESSION_PREVIEW) } + : {}), + modified: session.modified.toISOString(), + created: session.created.toISOString(), + messageCount: session.messageCount, + firstMessage: boundedText( + session.firstMessage, + WEB_MAX_SESSION_PREVIEW, + ), + archived: true, + ...(this.ungroupedSessions.has(resolve(session.path)) + ? { ungrouped: true } + : {}), + })); + const pageEnd = start + sessions.length; + const hasMoreMatches = pageEnd < matches.length; + const recordsUnscanned = Math.max(0, allSessions.length - scanned.length); + return { + status: "ok" as const, + sessions, + ...(hasMoreMatches && sessions.length > 0 + ? { + nextCursor: encodeArchivedSessionCursor({ + version: 1, + sessionId: sessions.at(-1)!.id, + query: normalizedQuery, + }), + } + : {}), + truncation: { + truncated: hasMoreMatches || recordsUnscanned > 0, + matchesOmitted: Math.max(0, matches.length - pageEnd), + recordsUnscanned, + maxPageSize: WEB_MAX_ARCHIVED_SESSION_PAGE, + maxScanned: WEB_MAX_ARCHIVED_SESSION_SCAN, + }, + }; + } + async removeWorkspace(path: string) { await this.ensureWorkspaceStateLoaded(); const canonical = resolve(path); diff --git a/web/host/web-host.ts b/web/host/web-host.ts index 6203e647..a15baf9f 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -13,6 +13,7 @@ import { subscribeWebCapabilities } from "../../extensions/shared/web-observer-r import { PiWebAdapter } from "../adapter/pi-adapter.ts"; import { jsonByteLength, + WEB_MAX_ARCHIVED_SESSION_PAGE, WEB_MAX_EVENT_BYTES, WEB_MAX_EVENTS, WEB_MAX_SNAPSHOT_BYTES, @@ -555,6 +556,48 @@ export class WebHost { }, }); } + if (url.pathname === "/api/sessions/archived") { + const rawLimit = url.searchParams.get("limit"); + const limit = rawLimit === null ? undefined : Number(rawLimit); + if ( + rawLimit !== null && + (!/^\d+$/u.test(rawLimit) || + !Number.isSafeInteger(limit) || + limit! <= 0 || + limit! > WEB_MAX_ARCHIVED_SESSION_PAGE) + ) { + return this.json(response, 400, { + code: "INVALID_ARCHIVED_SESSION_QUERY", + error: "archived Session limit must be a bounded positive integer", + }); + } + const result = await this.adapter.listArchivedSessions({ + ...(url.searchParams.has("cursor") + ? { cursor: url.searchParams.get("cursor") ?? "" } + : {}), + ...(limit !== undefined ? { limit } : {}), + ...(url.searchParams.has("q") + ? { query: url.searchParams.get("q") ?? "" } + : {}), + }); + if (result.status === "invalid") { + return this.json(response, 400, { + code: "INVALID_ARCHIVED_SESSION_QUERY", + error: "archived Session query is invalid or exceeds its bounds", + }); + } + if (result.status === "stale_cursor") { + return this.json(response, 409, { + code: "ARCHIVED_SESSION_CURSOR_STALE", + error: "archived Session cursor is stale for this query", + }); + } + return this.json(response, 200, { + sessions: result.sessions, + ...(result.nextCursor ? { nextCursor: result.nextCursor } : {}), + truncation: result.truncation, + }); + } if (url.pathname === "/api/models") return this.json(response, 200, { models: this.runtime.listModels() }); if (url.pathname === "/api/snapshot") { diff --git a/web/protocol/types.ts b/web/protocol/types.ts index a7429b6e..fde67ed1 100644 --- a/web/protocol/types.ts +++ b/web/protocol/types.ts @@ -12,6 +12,10 @@ export const WEB_MAX_MESSAGE_PARTS = 64; export const WEB_MAX_SESSIONS = 500; export const WEB_MAX_WORKSPACES = 250; export const WEB_MAX_MODELS = 250; +export const WEB_MAX_ARCHIVED_SESSION_PAGE = 50; +export const WEB_MAX_ARCHIVED_SESSION_QUERY = 160; +export const WEB_MAX_ARCHIVED_SESSION_CURSOR = 512; +export const WEB_MAX_ARCHIVED_SESSION_SCAN = 5_000; export const WEB_MAX_SELECTED_TRANSCRIPT_BYTES = 2 * 1024 * 1024; export const WEB_MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024;