Archived
fix(sessions): guard, gate, and test session reload
Build on the original Reload action with the fixes raised in review: - Server reload() now refuses to run on archived (read-only) sessions and when the session has work in progress, mirroring archive(), so a reload can no longer silently abort an in-flight agent run. - Add a sessions.reload runtime capability; the client gates both the reloadSession call and the Reload menu entry on it so the action only appears for machines whose Pi-Web runtime supports it. - reloadSession ignores cached-new and archived sessions. - Add server (PiSessionService + routes) and client (SessionController) tests covering reload success, the active-work guard, archived rejection, route forwarding, capability gating, and error mapping. - Restore alphabetical parser import ordering in clients.ts. - Add a changeset documenting the feature and the sessiond restart note. Note: touches a session daemon code path, so pi-web-sessiond.service must be restarted manually for the server side to take effect.
This commit is contained in:
@@ -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 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.
|
||||
|
||||
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.
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
parseCommandResult,
|
||||
parseDeleted,
|
||||
parseDetached,
|
||||
parseReloaded,
|
||||
parseFileContentResponse,
|
||||
parseFileSuggestion,
|
||||
parseFileTreeResponse,
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
parsePiWebRuntimeResponse,
|
||||
parsePiWebStatusResponse,
|
||||
parseProject,
|
||||
parseReloaded,
|
||||
parseRestored,
|
||||
parseSavedAttachments,
|
||||
parseSessionInfo,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -227,7 +228,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
|
||||
`
|
||||
: html`
|
||||
<button title="Reload session from disk" @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload</button>
|
||||
${this.canReload ? html`<button title="Reload session from disk" @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload</button>` : null}
|
||||
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
|
||||
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
|
||||
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
|
||||
|
||||
@@ -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;
|
||||
@@ -157,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}
|
||||
|
||||
@@ -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] };
|
||||
|
||||
@@ -380,9 +380,15 @@ export class SessionController {
|
||||
}
|
||||
|
||||
async reloadSession(session = this.getState().selectedSession) {
|
||||
if (session === undefined) return;
|
||||
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, selectedMachineId(this.getState()));
|
||||
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 });
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
@@ -561,8 +561,10 @@ export class PiSessionService {
|
||||
}
|
||||
|
||||
async reload(ref: PiSessionLookup): Promise<void> {
|
||||
const active = await this.getActive(ref);
|
||||
await this.closeActive(active.runtime.session.sessionId);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -6,11 +6,12 @@ export type { PiWebCapability };
|
||||
export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES);
|
||||
const knownPiWebCapabilities: ReadonlySet<string> = 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<PiWebCapability, readonly PiWebServiceComponent[]>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user