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 @@ -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-"));
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 @@ -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`, {
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,
);
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);
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 @@ -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,
Expand Down Expand Up @@ -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") {
Expand Down
4 changes: 4 additions & 0 deletions web/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading