Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions tests/web/pi-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,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-"));
Expand Down
24 changes: 24 additions & 0 deletions tests/web/web-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,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`, {
Expand Down
141 changes: 141 additions & 0 deletions web/adapter/pi-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string>();
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Bind the cursor to a unique Session file identity

Using only session.id cannot distinguish copied Session files that retain their original ID. With three archived files ordered newest/middle/oldest, where the first two share an ID and limit=1, actual requests return newest -> middle -> middle -> middle indefinitely: findIndex keeps resolving the first matching ID, and the oldest entry is unreachable. Bind the cursor to the file identity (and validate it against the same result set) rather than ID alone. Add a real-file duplicate-ID pagination regression proving each file is visited once and pagination terminates.

);
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({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Ensure every generated cursor is valid for the next request

Valid queries can produce cursors that the same endpoint rejects. Real-file reproduction: two matching archived Sessions, query="中".repeat(160), limit=1 gives status ok and a 740-character nextCursor, but replaying it returns invalid/HTTP400 because WEB_MAX_ARCHIVED_SESSION_CURSOR is512. Separately query="ffi".repeat(60) has raw length60 but NFKC length180; the generated cursor is only296 characters, yet decoding rejects its normalized query against the160-character raw-query cap. Keep input normalization, encoding and decoder limits consistent, or use a bounded query fingerprint, and test replay of the returned cursor for Chinese and NFKC-expanding queries.

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);
Expand Down
43 changes: 43 additions & 0 deletions web/host/web-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { loadSetupConfig } from "../../extensions/shared/setup-config.ts";
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,
Expand Down Expand Up @@ -684,6 +685,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/capabilities")
Expand Down
4 changes: 4 additions & 0 deletions web/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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;

Expand Down
Loading