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
61 changes: 61 additions & 0 deletions tests/web/pi-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,67 @@ test("first archive mutation preserves previously persisted archive metadata", a
}
});

test("deletes a persisted non-active session and cleans derived metadata", async () => {
const root = await mkdtemp(join(tmpdir(), "openpi-web-session-delete-"));
const sessionDirectory = join(root, "sessions");
try {
await mkdir(sessionDirectory, { recursive: true });
const current = SessionManager.inMemory(root);
const candidate = SessionManager.create(root, sessionDirectory);
persistSession(candidate, "delete me", 2);
const candidatePath = candidate.getSessionFile();
assert.ok(candidatePath);
const adapter = new PiWebAdapter(
runtimeFor(root, sessionDirectory, current),
);
await adapter.archiveSession(candidatePath);
await adapter.removeWorkspace(root);

const deletedPath = await adapter.deleteSession(candidatePath);
assert.equal(deletedPath, candidatePath);
await assert.rejects(readFile(candidatePath));
const archived = JSON.parse(
await readFile(join(sessionDirectory, "archived-sessions.json"), "utf8"),
) as string[];
assert.equal(archived.includes(candidatePath), false);
const workspaceState = JSON.parse(
await readFile(join(sessionDirectory, "workspace-state.json"), "utf8"),
) as { ungroupedSessions: string[] };
assert.equal(
workspaceState.ungroupedSessions.includes(candidatePath),
false,
);
assert.equal(
(await adapter.listSessions()).some(
(session) => session.path === candidatePath,
),
false,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("refuses to delete the active session", async () => {
const root = await mkdtemp(
join(tmpdir(), "openpi-web-session-delete-active-"),
);
const sessionDirectory = join(root, "sessions");
try {
await mkdir(sessionDirectory, { recursive: true });
const current = SessionManager.inMemory(root);
const adapter = new PiWebAdapter(
runtimeFor(root, sessionDirectory, current),
);
await assert.rejects(
adapter.deleteSession(`current:${current.getSessionId()}`),
/active Session/u,
);
} 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
61 changes: 61 additions & 0 deletions tests/web/web-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,35 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
};
const deletable = SessionManager.create(cwd, cwd);
deletable.appendMessage({
role: "user",
content: "deletable session",
timestamp: 1,
});
deletable.appendMessage({
role: "assistant",
content: [],
api: "openai-responses",
provider: "fixture",
model: "fixture",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
stopReason: "stop",
timestamp: 1,
});

const page = await fetch(`${launched.origin}/`);
assert.equal(page.status, 200);
Expand Down Expand Up @@ -402,6 +431,10 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn
});
const currentSessionPath = listedSessions.sessions[0]?.path;
assert.ok(currentSessionPath);
const deletableSessionPath = listedSessions.sessions.find(
(session) => session.path !== currentSessionPath,
)?.path;
assert.equal(deletableSessionPath, deletable.getSessionFile());
const sessionRename = await fetch(`${launched.origin}/api/sessions`, {
method: "PATCH",
headers: authorized,
Expand All @@ -426,6 +459,34 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn
true,
);

const deleteActive = await fetch(
`${launched.origin}/api/sessions?path=${encodeURIComponent(currentSessionPath)}`,
{ method: "DELETE", headers: authorized },
);
assert.equal(deleteActive.status, 409);
assert.deepEqual(await deleteActive.json(), {
code: "SESSION_CONFLICT",
error: "Cannot delete the active Session",
});
const deleteSession = await fetch(
`${launched.origin}/api/sessions?path=${encodeURIComponent(deletableSessionPath!)}`,
{ method: "DELETE", headers: authorized },
);
assert.equal(deleteSession.status, 200);
assert.deepEqual(await deleteSession.json(), {
path: deletableSessionPath,
deleted: true,
});
const afterDelete = (await (
await fetch(`${launched.origin}/api/sessions`, { headers: authorized })
).json()) as { sessions: Array<{ path: string }> };
assert.equal(
afterDelete.sessions.some(
(session) => session.path === deletableSessionPath,
),
false,
);

const wrongSession = await fetch(`${launched.origin}/api/prompt`, {
method: "POST",
headers: authorized,
Expand Down
45 changes: 44 additions & 1 deletion web/adapter/pi-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
import { SessionManager } from "@earendil-works/pi-coding-agent";
import { webCapabilitySnapshot } from "../../extensions/shared/web-observer-registry.ts";
import {
Expand All @@ -19,6 +19,16 @@ import {
} from "../protocol/types.ts";
import type { WebRuntimeController } from "../runtime/types.ts";

export class WebSessionDeletionError extends Error {
readonly code = "SESSION_CONFLICT" as const;
readonly statusCode = 409 as const;

constructor(message: string) {
super(message);
this.name = "WebSessionDeletionError";
}
}

type WorkspaceStateSnapshot = {
importedWorkspaces: Set<string>;
hiddenWorkspaces: Set<string>;
Expand Down Expand Up @@ -284,6 +294,39 @@ export class PiWebAdapter {
});
}

async deleteSession(path: string) {
await this.ensureWorkspaceStateLoaded();
await this.ensureArchivesLoaded();
const session = await this.requireSession(path);
const canonical = resolve(session.path);
const activePath = this.runtime.sessionManager.getSessionFile();
if (
session.id === this.runtime.sessionManager.getSessionId() ||
(activePath !== undefined && resolve(activePath) === canonical)
) {
throw new WebSessionDeletionError("Cannot delete the active Session");
}
const sessionDirectory = resolve(this.runtime.sessionDirectory);
const relativePath = relative(sessionDirectory, canonical);
if (
!relativePath ||
isAbsolute(relativePath) ||
relativePath === ".." ||
relativePath.startsWith(`..${sep}`) ||
!canonical.endsWith(".jsonl")
) {
throw new Error("Session target is outside the Web Session directory");
}
await rm(canonical);
await this.enqueueArchiveMutation((draft) => {
draft.delete(canonical);
});
await this.enqueueWorkspaceMutation((draft) => {
draft.ungroupedSessions.delete(canonical);
});
return canonical;
}

async removeWorkspace(path: string) {
await this.ensureWorkspaceStateLoaded();
const canonical = resolve(path);
Expand Down
23 changes: 22 additions & 1 deletion web/host/web-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import {
webCapabilitySnapshot,
} from "../../extensions/shared/web-observer-registry.ts";
import { loadSetupConfig } from "../../extensions/shared/setup-config.ts";
import { PiWebAdapter } from "../adapter/pi-adapter.ts";
import {
PiWebAdapter,
WebSessionDeletionError,
} from "../adapter/pi-adapter.ts";
import {
jsonByteLength,
WEB_MAX_EVENT_BYTES,
Expand Down Expand Up @@ -492,6 +495,24 @@ export class WebHost {
this.publish("session_archived", { sessionPath: path });
return this.json(response, 200, { path, archived: true });
}
if (url.pathname === "/api/sessions" && request.method === "DELETE") {
const path = url.searchParams.get("path");
if (!path)
return this.json(response, 400, { error: "session path is required" });
try {
const deletedPath = await this.adapter.deleteSession(path);
this.publish("session_deleted", { sessionPath: deletedPath });
return this.json(response, 200, { path: deletedPath, deleted: true });
} catch (error) {
if (error instanceof WebSessionDeletionError) {
return this.json(response, error.statusCode, {
code: error.code,
error: error.message,
});
}
throw error;
}
}
if (url.pathname === "/api/sessions/select" && request.method === "POST") {
const body = await this.readJson(request);
if (typeof body.path !== "string" || body.path.trim().length === 0) {
Expand Down
Loading