diff --git a/.changeset/session-reload-from-disk.md b/.changeset/session-reload-from-disk.md new file mode 100644 index 0000000..e0e57ac --- /dev/null +++ b/.changeset/session-reload-from-disk.md @@ -0,0 +1,9 @@ +--- +"@jmfederico/pi-web": minor +--- + +Add a **Reload** action to the session three-dot menu that re-reads the session from disk. The session daemon keeps an in-memory `SessionManager` per session and never re-reads the session file, so when the same session is also driven by another process (for example the `pi` CLI), new on-disk entries were invisible to the web UI and the tail of the conversation appeared truncated. Reloading closes the active session, re-opens it from disk, discards the cached transcript, and re-fetches the history. + +Reload is also available from the command palette as **Reload Session**, so it can be triggered from the keyboard and assigned a custom shortcut. Reload refuses to run while the session has work in progress and on archived (read-only) sessions, and is gated behind a new `sessions.reload` runtime capability so it only appears for machines whose Pi-Web runtime supports it (both the menu item and the palette action are disabled otherwise). + +Note: this changes a session daemon code path, so `pi-web-sessiond.service` must be restarted manually for the server side of this change to take effect. diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index bfda54a..628cdd5 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -27,6 +27,7 @@ import { parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseProject, + parseReloaded, parseRestored, parseSavedAttachments, parseSessionInfo, @@ -143,6 +144,7 @@ export const sessionsApi = { restore: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "restore", machineId), parseRestored, { method: "POST", body: sessionBody(session) }), deleteArchived: (session: SessionLookup, machineId = "local") => request(sessionBaseQueryUrl(session, machineId), parseDeleted, { method: "DELETE" }), detachParent: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "detach-parent", machineId), parseDetached, { method: "POST", body: sessionBody(session) }), + reloadSession: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "reload", machineId), parseReloaded, { method: "POST", body: sessionBody(session) }), authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => { const params = new URLSearchParams(); if (options?.mode !== undefined) params.set("mode", options.mode); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index a9edf01..23250cd 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -717,6 +717,12 @@ export function parseDetached(value: unknown): { detached: true } { return { detached: true }; } +export function parseReloaded(value: unknown): { reloaded: true } { + const record = requireRecord(value); + if (record["reloaded"] !== true) throw new Error("Expected reloaded response"); + return { reloaded: true }; +} + function optionalNumber(record: Record, key: string): number | undefined { const value = record[key]; if (value === undefined) return undefined; diff --git a/src/client/src/chatHistoryCache.ts b/src/client/src/chatHistoryCache.ts index b32cb1e..5e56011 100644 --- a/src/client/src/chatHistoryCache.ts +++ b/src/client/src/chatHistoryCache.ts @@ -35,6 +35,14 @@ export function writeChatHistoryCache(sessionId: string, page: RawMessagePage): } } +export function removeChatHistoryCache(sessionId: string): void { + try { + sessionStorage.removeItem(cacheKey(sessionId)); + } catch { + // Ignore storage access errors; cache may simply be unavailable. + } +} + export function mergeChatHistory(existing: RawMessagePage | undefined, incoming: RawMessagePage): RawMessagePage { if (existing === undefined || !isValidMessagePage(existing)) return incoming; if (!isValidMessagePage(incoming)) return existing; diff --git a/src/client/src/chatTranscriptStore.ts b/src/client/src/chatTranscriptStore.ts index cd8eb95..097b46b 100644 --- a/src/client/src/chatTranscriptStore.ts +++ b/src/client/src/chatTranscriptStore.ts @@ -1,6 +1,6 @@ import { normalizeMessages } from "./chatMessages"; import { applyTranscriptEvent } from "./chatTranscript"; -import { mergeChatHistory, readChatHistoryCache, writeChatHistoryCache, type RawMessagePage } from "./chatHistoryCache"; +import { mergeChatHistory, readChatHistoryCache, removeChatHistoryCache, writeChatHistoryCache, type RawMessagePage } from "./chatHistoryCache"; import type { ChatLine } from "./components/shared"; import type { SessionUiEvent } from "./sessionSocket"; @@ -16,11 +16,13 @@ export interface ChatTranscriptView { export interface ChatHistoryCacheAdapter { read(sessionId: string): RawMessagePage | undefined; write(sessionId: string, page: RawMessagePage): void; + remove?(sessionId: string): void; } const browserChatHistoryCache: ChatHistoryCacheAdapter = { read: readChatHistoryCache, write: writeChatHistoryCache, + remove: removeChatHistoryCache, }; export class ChatTranscriptStore { @@ -43,6 +45,11 @@ export class ChatTranscriptStore { return applyTranscriptEvent(messages, event); } + discard(sessionId: string): void { + this.rawHistoryPages.delete(sessionId); + this.cache.remove?.(sessionId); + } + rawHistoryPage(sessionId: string): RawMessagePage | undefined { const cached = this.rawHistoryPages.get(sessionId) ?? this.cache.read(sessionId); if (cached !== undefined) this.rawHistoryPages.set(sessionId, cached); diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index b58b1c2..43dfa04 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -999,6 +999,11 @@ export class PiWebApp extends LitElement { return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived); } + private canReloadSessions(): boolean { + const runtime = this.selectedMachineRuntime(); + return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload); + } + private archivedDeleteUnavailableMessage(): string { const machineName = this.state.selectedMachine?.name ?? "this machine"; return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`; @@ -1033,6 +1038,7 @@ export class PiWebApp extends LitElement { .selectedSession=${this.state.selectedSession} .canStartSession=${!!this.state.selectedWorkspace} .canDeleteArchivedSessions=${this.canDeleteArchivedSessions()} + .canReloadSessions=${this.canReloadSessions()} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()} .collapsible=${true} .compact=${this.appShell.isMobileNavigationLayout} @@ -1060,6 +1066,7 @@ export class PiWebApp extends LitElement { .onDeleteArchivedSession=${(session: SessionInfo) => this.sessions.deleteArchivedSessions([session])} .onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)} .onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)} + .onReloadSession=${(session: SessionInfo) => this.sessions.reloadSession(session)} .onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }} .onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }} > @@ -1391,6 +1398,7 @@ export class PiWebApp extends LitElement { deleteWorkspace: (workspace) => this.deleteWorkspace(workspace), startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()), archiveSession: () => this.sessions.archiveSession(), + reloadSession: () => this.sessions.reloadSession(), deleteCachedNewSession: () => this.sessions.deleteCachedNewSession(), stopActiveWork: () => this.sessions.stopActiveWork(), }, createContext); diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index 17fd55d..d9229ac 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -31,6 +31,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection @property({ attribute: false }) selected?: SessionInfo; @property({ type: Boolean }) canStart = false; @property({ type: Boolean }) canDeleteArchived = false; + @property({ type: Boolean }) canReload = false; @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; @property({ type: Boolean, reflect: true }) collapsible = false; @property({ type: Boolean, reflect: true }) collapsed = false; @@ -49,6 +50,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection @property({ attribute: false }) onDeleteArchived?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise; @property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void; + @property({ attribute: false }) onReload?: (session: SessionInfo) => void; @state() private openMenuSessionId: string | undefined; @state() private menuStyle = ""; @@ -226,6 +228,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection ` : html` + ${this.canReload ? html`` : null} ${session.parentSessionPath !== undefined ? html`` : null} ${descendantCount > 0 ? html`` : null} diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 3bed5f9..4b8bb77 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -41,6 +41,7 @@ export class AppNavigationPanel extends LitElement { @property({ type: Boolean }) sessionsCollapsed = false; @property({ type: Boolean }) canStartSession = false; @property({ type: Boolean }) canDeleteArchivedSessions = false; + @property({ type: Boolean }) canReloadSessions = false; @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; @property({ attribute: false }) onShowActions?: () => void; @property({ attribute: false }) onToggleMachines?: () => void; @@ -61,6 +62,7 @@ export class AppNavigationPanel extends LitElement { @property({ attribute: false }) onDeleteArchivedSession?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise; @property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise; @property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise; @property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise; @@ -156,6 +158,7 @@ export class AppNavigationPanel extends LitElement { .selected=${this.selectedSession} .canStart=${this.canStartSession} .canDeleteArchived=${this.canDeleteArchivedSessions} + .canReload=${this.canReloadSessions} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage} .collapsible=${this.collapsible} .collapsed=${this.sessionsCollapsed} @@ -171,6 +174,7 @@ export class AppNavigationPanel extends LitElement { .onDeleteArchived=${(session: SessionInfo) => this.onDeleteArchivedSession?.(session)} .onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)} .onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)} + .onReload=${(session: SessionInfo) => this.onReloadSession?.(session)} .onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }} .onFocusNextSection=${() => { this.focusNextFrom("sessions"); }} .onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }} diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 52c7224..93a6bf0 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -487,6 +487,73 @@ describe("SessionController", () => { expect(state.error).toContain("requires an updated Pi-Web runtime"); }); + it("reloads the selected session, discards the cached transcript, and re-fetches history", async () => { + Object.defineProperty(globalThis, "localStorage", { value: new MemoryStorage(), configurable: true }); + const reloadCalls: string[] = []; + const messageCalls: string[] = []; + let state: AppState = { + ...initialAppState(), + selectedWorkspace: workspace, + selectedSession: oldSession, + sessions: [oldSession], + machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } }, + }; + const api: typeof defaultApi = { + ...defaultApi, + reloadSession: (session) => { + reloadCalls.push(sessionLookupId(session)); + return Promise.resolve({ reloaded: true }); + }, + messages: (session) => { + messageCalls.push(sessionLookupId(session)); + return Promise.resolve(emptyPage); + }, + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.reloadSession(oldSession); + + expect(reloadCalls).toEqual([oldSession.id]); + expect(messageCalls).toContain(oldSession.id); + expect(state.error).toBe(""); + }); + + it("does not reload sessions when the selected machine runtime does not support it", async () => { + const reloadCalls: string[] = []; + let state: AppState = { + ...initialAppState(), + selectedWorkspace: workspace, + selectedSession: oldSession, + sessions: [oldSession], + }; + const api: typeof defaultApi = { + ...defaultApi, + reloadSession: (session) => { + reloadCalls.push(sessionLookupId(session)); + return Promise.resolve({ reloaded: true }); + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.reloadSession(oldSession); + + expect(reloadCalls).toEqual([]); + expect(state.error).toContain("requires an updated Pi-Web runtime"); + }); + it("forgets archived selections when the archived section collapse clears selection", async () => { const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] }; diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 58c5403..74ddb30 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -379,6 +379,25 @@ export class SessionController { } } + async reloadSession(session = this.getState().selectedSession) { + if (session === undefined || isCachedNewSessionInfo(session) || session.archived === true) return; + const machineId = selectedMachineId(this.getState()); + const runtime = this.getState().machineRuntimes[machineId]; + if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) { + this.setState({ error: "Reloading sessions requires an updated Pi-Web runtime on this machine." }); + return; + } + try { + await this.api.reloadSession(session.id, machineId); + this.transcripts.discard(this.sessionCacheKey(session.id)); + if (this.getState().selectedSession?.id === session.id) { + await this.selectSession(session, { updateUrl: false }); + } + } catch (error) { + this.setState({ error: String(error) }); + } + } + async detachParent(session = this.getState().selectedSession) { if (session?.parentSessionPath === undefined) return; try { diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index e46b84f..90f0337 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -1,6 +1,8 @@ import { isSessionActive } from "../../../../shared/activity"; +import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities"; import type { AppState } from "../../appState"; import { isCachedNewSessionInfo } from "../../cachedNewSessions"; +import { selectedMachineId } from "../../controllers/types"; import { isWorkspaceDeletionPending } from "../../workspaceDeletion"; import type { PluginAction } from "../types"; @@ -173,6 +175,14 @@ export function createCoreActions(): PluginAction[] { enabled: hasArchivableSession, run: (context) => context.archiveSession(), }, + { + id: "session.reload", + title: "Reload Session", + description: "Re-read the selected session from disk to pick up entries written by another process", + group: "Session", + enabled: hasReloadableSession, + run: (context) => context.reloadSession(), + }, { id: "session.delete", title: "Delete New Session", @@ -213,3 +223,11 @@ function hasArchivableSession(context: { state: AppState }): boolean { function hasCachedNewSession(context: { state: AppState }): boolean { return isCachedNewSessionInfo(context.state.selectedSession); } + +function hasReloadableSession(context: { state: AppState }): boolean { + const session = context.state.selectedSession; + if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return false; + const runtime = context.state.machineRuntimes[selectedMachineId(context.state)]; + if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) return false; + return !isSessionActive(context.state.status, context.state.activity); +} diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 3853f5a..7be8258 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -1,8 +1,9 @@ import { html } from "lit"; import { describe, expect, it, vi } from "vitest"; -import type { FileContentResponse, SessionInfo, Workspace } from "../api"; +import type { FileContentResponse, SessionInfo, SessionStatus, Workspace } from "../api"; import { initialAppState, type AppState } from "../appState"; import { markCachedNewSessionInfo } from "../cachedNewSessions"; +import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { machineScopedPluginId } from "../../../shared/machinePluginIds"; import { corePlugin } from "./core"; import { PluginRegistry } from "./registry"; @@ -42,6 +43,7 @@ function createContext(statePatch: Partial = {}) { deleteWorkspace: vi.fn(() => { calls.push("deleteWorkspace"); }), startSession: vi.fn(() => { calls.push("startSession"); }), archiveSession: vi.fn(() => { calls.push("archiveSession"); }), + reloadSession: vi.fn(() => { calls.push("reloadSession"); }), deleteCachedNewSession: vi.fn(() => { calls.push("deleteCachedNewSession"); }), stopActiveWork: vi.fn(() => { calls.push("stopActiveWork"); }), }; @@ -149,6 +151,35 @@ describe("PluginRegistry", () => { expect(archivedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false); }); + it("enables session reload only for a writable session on a capable, idle runtime", () => { + const registry = new PluginRegistry(); + registry.register({ id: "core", plugin: corePlugin }); + const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } }; + + const reloadable = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime }).context); + expect(reloadable.find((action) => action.id === "core:session.reload")?.enabled).toBe(true); + + const noCapability = registry.getActions(createContext({ selectedSession: testSession() }).context); + expect(noCapability.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); + + const archived = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" }, machineRuntimes: reloadRuntime }).context); + expect(archived.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); + + const busy = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime, status: testStatus({ isStreaming: true }) }).context); + expect(busy.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); + }); + + it("routes session reload through the runtime context", () => { + const registry = new PluginRegistry(); + registry.register({ id: "core", plugin: corePlugin }); + const { context, calls } = createContext({ selectedSession: testSession(), machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } } }); + const action = registry.getActions(context).find((candidate) => candidate.id === "core:session.reload"); + + if (action !== undefined) void action.run(); + + expect(calls).toEqual(["reloadSession"]); + }); + it("routes browser-cached new session delete through the runtime context", () => { const registry = new PluginRegistry(); registry.register({ id: "core", plugin: corePlugin }); @@ -568,6 +599,20 @@ function testFileContent(path = "README.md"): FileContentResponse { }; } +function testStatus(patch: Partial = {}): SessionStatus { + return { + sessionId: "s1", + isStreaming: false, + isCompacting: false, + isBashRunning: false, + pendingMessageCount: 0, + queuedMessages: [], + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost: 0, + ...patch, + }; +} + function testMachine(id: string) { return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" }; } diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 4b954d7..2b44063 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -106,6 +106,7 @@ export interface PluginRuntimeContext { deleteWorkspace: (workspace?: Workspace) => void | Promise; startSession: () => void | Promise; archiveSession: () => void | Promise; + reloadSession: () => void | Promise; deleteCachedNewSession: () => void | Promise; stopActiveWork: () => void | Promise; } diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index a73c808..ed444a0 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -425,6 +425,74 @@ describe("PiSessionService", () => { await service.dispose(); }); + it("reloads a session by closing the active runtime and re-opening it from disk", async () => { + const first = fakeRuntime("reload-session"); + const second = fakeRuntime("reload-session"); + const runtimes = [first.runtime, second.runtime]; + let createCalls = 0; + const createAgentRuntime: RuntimeCreator = async () => { + await Promise.resolve(); + const runtime = runtimes[createCalls]; + createCalls += 1; + if (runtime === undefined) throw new Error("unexpected runtime creation"); + return runtime; + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: sessionGateway([sessionRecord("reload-session")]), + heartbeatIntervalMs: 60_000, + }); + + // Open once so there is an active runtime to reload. + await service.status(sessionRef("reload-session")); + expect(createCalls).toBe(1); + + await expect(service.reload(sessionRef("reload-session"))).resolves.toBeUndefined(); + + // The original runtime was torn down and a fresh one opened from disk. + expect(first.calls.abort).toBe(1); + expect(first.calls.dispose).toBe(1); + expect(createCalls).toBe(2); + expect(service.activeCount()).toBe(1); + + await service.dispose(); + }); + + it("refuses to reload a session that has active work in progress", async () => { + const fake = fakeRuntime("busy-session", { isStreaming: true }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("busy-session")]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.reload(sessionRef("busy-session"))).rejects.toThrow("Stop current session activity before reloading"); + expect(fake.calls.abort).toBe(0); + expect(fake.calls.dispose).toBe(0); + + await service.dispose(); + }); + + it("refuses to reload an archived session", async () => { + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: { + list: () => Promise.resolve([]), + get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) + ? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" } + : undefined), + archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }), + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(true), + }, + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.reload(sessionRef("archived"))).rejects.toThrow("Archived sessions are read-only"); + + await service.dispose(); + }); + it("reconciles workspace activity when listing only archived sessions", async () => { const reconciliations: { cwd: string; sessionIds: string[] }[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 2aaa249..2049988 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -560,6 +560,15 @@ export class PiSessionService { await this.archiveStore.deleteArchived(record.sessionId); } + async reload(ref: PiSessionLookup): Promise { + await this.assertWritable(ref); + const session = await this.getOrOpen(ref); + if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading"); + await this.closeActive(session.sessionId); + const reopened = await this.getActive(ref); + this.publishStatus(reopened.runtime.session); + } + async detachParent(ref: PiSessionLookup): Promise { const session = await this.getOrOpen(ref); const sessionFile = session.sessionFile; diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts index eda1831..7cc4ca7 100644 --- a/src/server/sessions/sessionRoutes.test.ts +++ b/src/server/sessions/sessionRoutes.test.ts @@ -97,15 +97,62 @@ describe("session routes", () => { await routeApp.close(); } }); + + it("reloads a session through the reload route, forwarding workspace context", async () => { + const routeApp = Fastify({ logger: false }); + await routeApp.register(fastifyWebsocket); + const eventHub = new SessionEventHub(); + const routeService = new CapturingRouteSessionService(eventHub); + registerSessionRoutes(routeApp, routeService, eventHub); + + try { + const requestCwd = resolve("/repo"); + const reloadResponse = await routeApp.inject({ method: "POST", url: "/sessions/session-1/reload", payload: { cwd: requestCwd } }); + + expect(reloadResponse.statusCode).toBe(200); + expect(reloadResponse.json()).toEqual({ reloaded: true }); + expect(routeService.reloadCalls).toEqual([{ id: "session-1", cwd: requestCwd }]); + } finally { + await routeService.dispose(); + await routeApp.close(); + } + }); + + it("maps reload failures to a mutation error status", async () => { + const routeApp = Fastify({ logger: false }); + await routeApp.register(fastifyWebsocket); + const eventHub = new SessionEventHub(); + const routeService = new CapturingRouteSessionService(eventHub); + routeService.reloadError = new Error("Stop current session activity before reloading"); + registerSessionRoutes(routeApp, routeService, eventHub); + + try { + const reloadResponse = await routeApp.inject({ method: "POST", url: "/sessions/session-1/reload", payload: {} }); + + expect(reloadResponse.statusCode).toBe(400); + expect(reloadResponse.json()).toEqual({ error: "Stop current session activity before reloading" }); + } finally { + await routeService.dispose(); + await routeApp.close(); + } + }); }); class CapturingRouteSessionService extends PiSessionService { readonly calls: unknown[] = []; + readonly reloadCalls: (string | PiSessionRef)[] = []; + reloadError: Error | undefined; constructor(eventHub: SessionEventHub) { super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 }); } + override reload(lookup: string | PiSessionRef): Promise { + this.reloadCalls.push(lookup); + if (this.reloadError !== undefined) return Promise.reject(this.reloadError); + return Promise.resolve(); + } + override status(lookup: string | PiSessionRef) { this.calls.push(lookup); return Promise.resolve({ diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 27d1550..22078c8 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -230,6 +230,15 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS } }); + app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/reload`, async (request, reply) => { + try { + await sessions.reload(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body))); + return { reloaded: true }; + } catch (error) { + return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) }); + } + }); + app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => { try { await sessions.detachParent(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body))); diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 3b9421c..6ce5db7 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -3,6 +3,7 @@ export type MachineStatus = "unknown" | "online" | "offline" | "error"; export const PI_WEB_CAPABILITIES = { sessionsDeleteArchived: "sessions.deleteArchived", + sessionsReload: "sessions.reload", promptAttachments: "prompt.attachments", } as const; diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts index 4795ed3..8c5a255 100644 --- a/src/shared/capabilities.ts +++ b/src/shared/capabilities.ts @@ -6,11 +6,12 @@ export type { PiWebCapability }; export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES); const knownPiWebCapabilities: ReadonlySet = new Set(KNOWN_PI_WEB_CAPABILITIES); -export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[]; -export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[]; +export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[]; +export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[]; const EFFECTIVE_CAPABILITY_REQUIREMENTS = { [PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"], + [PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"], } as const satisfies Record;