Skip to content
Merged
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
158 changes: 158 additions & 0 deletions tests/web/pi-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,164 @@ 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("archived cursors bind duplicate IDs to their file and stay replayable", async () => {
const root = await mkdtemp(join(tmpdir(), "openpi-web-archived-cursor-"));
const sessionDirectory = join(root, "sessions");
try {
const current = SessionManager.inMemory(root);
const first = SessionManager.create(root, sessionDirectory);
persistSession(first, "中".repeat(160), 1);
const firstPath = first.getSessionFile();
assert.ok(firstPath);
const duplicatePath = join(sessionDirectory, "duplicate.jsonl");
await writeFile(duplicatePath, await readFile(firstPath));
const second = SessionManager.create(root, sessionDirectory);
persistSession(second, "ffi".repeat(50), 2);
const secondPath = second.getSessionFile();
assert.ok(secondPath);
const normalizedSecond = SessionManager.create(root, sessionDirectory);
persistSession(normalizedSecond, "ffi".repeat(50), 3);
const normalizedSecondPath = normalizedSecond.getSessionFile();
assert.ok(normalizedSecondPath);
const adapter = new PiWebAdapter(
runtimeFor(root, sessionDirectory, current),
);
for (const path of [
firstPath,
duplicatePath,
secondPath,
normalizedSecondPath,
]) {
await adapter.archiveSession(path);
}

const chinese = await adapter.listArchivedSessions({
query: "中".repeat(160),
limit: 1,
});
assert.equal(chinese.status, "ok");
if (chinese.status !== "ok") return;
assert.ok(chinese.nextCursor);
assert.ok(chinese.nextCursor.length <= 512);
const chineseReplay = await adapter.listArchivedSessions({
query: "中".repeat(160),
limit: 1,
cursor: chinese.nextCursor,
});
assert.equal(chineseReplay.status, "ok");

const normalized = await adapter.listArchivedSessions({
query: "ffi".repeat(50),
limit: 1,
});
assert.equal(normalized.status, "ok");
if (normalized.status !== "ok") return;
assert.ok(normalized.nextCursor);
assert.ok(normalized.nextCursor.length <= 512);
const normalizedReplay = await adapter.listArchivedSessions({
query: "ffi".repeat(50),
limit: 1,
cursor: normalized.nextCursor,
});
assert.equal(normalizedReplay.status, "ok");

const duplicateFirst = await adapter.listArchivedSessions({
query: "中",
limit: 1,
});
assert.equal(duplicateFirst.status, "ok");
if (duplicateFirst.status !== "ok") return;
const duplicateSeen = new Set(
duplicateFirst.sessions.map((item) => item.path),
);
let cursor = duplicateFirst.nextCursor;
while (cursor) {
const page = await adapter.listArchivedSessions({
query: "中",
limit: 1,
cursor,
});
assert.equal(page.status, "ok");
if (page.status !== "ok") break;
for (const session of page.sessions) duplicateSeen.add(session.path);
cursor = page.nextCursor;
}
assert.deepEqual(duplicateSeen, new Set([firstPath, duplicatePath]));
} 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 @@ -591,6 +591,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
148 changes: 148 additions & 0 deletions web/adapter/pi-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { basename, join, resolve } from "node:path";
import { SessionManager } from "@earendil-works/pi-coding-agent";
import { webCapabilitySnapshot } from "../../extensions/shared/web-observer-registry.ts";
Expand All @@ -8,6 +9,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 +32,50 @@ type WorkspaceStateSnapshot = {
restoreInitialWorkspace: boolean;
};

export interface ArchivedSessionQuery {
readonly cursor?: string;
readonly limit?: number;
readonly query?: string;
}

interface ArchivedSessionCursor {
readonly version: 1;
readonly sessionPath: string;
readonly queryHash: string;
}

function encodeArchivedSessionCursor(cursor: ArchivedSessionCursor) {
return Buffer.from(JSON.stringify(cursor)).toString("base64url");
}

function archivedQueryHash(query: string) {
return createHash("sha256").update(query).digest("hex");
}

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 { sessionPath?: unknown }).sessionPath !== "string" ||
(parsed as { sessionPath: string }).sessionPath.length === 0 ||
(parsed as { sessionPath: string }).sessionPath.length > 320 ||
typeof (parsed as { queryHash?: unknown }).queryHash !== "string" ||
!/^[0-9a-f]{64}$/u.test((parsed as { queryHash: string }).queryHash)
) {
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 +333,105 @@ 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();
if (normalizedQuery.length > WEB_MAX_ARCHIVED_SESSION_QUERY) {
return { status: "invalid" as const };
}
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.queryHash !== archivedQueryHash(normalizedQuery)) {
return { status: "stale_cursor" as const };
}
const cursorIndex = matches.findIndex(
(session) => session.path === cursor.sessionPath,
);
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({
Comment thread
testikun marked this conversation as resolved.
version: 1,
sessionPath: sessions.at(-1)!.path,
queryHash: archivedQueryHash(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 unarchiveSession(path: string) {
await this.ensureArchivesLoaded();
await this.enqueueArchiveMutation(async (draft) => {
Expand Down
Loading
Loading