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:
Federico Jaramillo Martinez
2026-06-14 14:17:51 +02:00
parent ea1ec1b595
commit 82db15f894
12 changed files with 218 additions and 8 deletions
+1 -1
View File
@@ -10,7 +10,6 @@ import {
parseCommandResult,
parseDeleted,
parseDetached,
parseReloaded,
parseFileContentResponse,
parseFileSuggestion,
parseFileTreeResponse,
@@ -28,6 +27,7 @@ import {
parsePiWebRuntimeResponse,
parsePiWebStatusResponse,
parseProject,
parseReloaded,
parseRestored,
parseSavedAttachments,
parseSessionInfo,
+6
View File
@@ -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}
+2 -1
View File
@@ -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 });