fix: preserve legacy federated session actions

This commit is contained in:
Federico Jaramillo Martinez
2026-07-04 22:39:55 +02:00
parent 10efb7f221
commit eb1727688f
13 changed files with 175 additions and 37 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Preserve archive and archived-session delete actions for older federated PI WEB machines that do not yet advertise session persistence or delete capabilities.
+10 -1
View File
@@ -20,6 +20,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types"; import { selectedMachineId } from "../controllers/types";
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi"; import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
import { RealtimeSocket } from "../sessionSocket"; import { RealtimeSocket } from "../sessionSocket";
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types"; import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme"; import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
@@ -1016,7 +1017,10 @@ export class PiWebApp extends LitElement {
private canDeleteArchivedSessions(): boolean { private canDeleteArchivedSessions(): boolean {
const runtime = this.selectedMachineRuntime(); const runtime = this.selectedMachineRuntime();
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived); // COMPAT-CAP sessions.deleteArchived: older federated machines may support
// the legacy DELETE route without advertising runtime capabilities. Only
// block when capability discovery succeeds and reports no support.
return runtime?.ok !== true || supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived);
} }
private canReloadSessions(): boolean { private canReloadSessions(): boolean {
@@ -1029,6 +1033,10 @@ export class PiWebApp extends LitElement {
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsCleanup); return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsCleanup);
} }
private hasAuthoritativeSessionPersistence(): boolean {
return runtimeHasAuthoritativeSessionPersistence(this.selectedMachineRuntime());
}
private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean { private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean {
if (machineId === "local") return true; if (machineId === "local") return true;
// COMPAT-CAP workspace.fileSuggestions: remote machines without this // COMPAT-CAP workspace.fileSuggestions: remote machines without this
@@ -1124,6 +1132,7 @@ export class PiWebApp extends LitElement {
.canDeleteArchivedSessions=${this.canDeleteArchivedSessions()} .canDeleteArchivedSessions=${this.canDeleteArchivedSessions()}
.canReloadSessions=${this.canReloadSessions()} .canReloadSessions=${this.canReloadSessions()}
.canCleanupSessions=${this.canCleanupSessions()} .canCleanupSessions=${this.canCleanupSessions()}
.authoritativeSessionPersistence=${this.hasAuthoritativeSessionPersistence()}
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()}
.cleanupUnavailableMessage=${this.sessionCleanupUnavailableMessage()} .cleanupUnavailableMessage=${this.sessionCleanupUnavailableMessage()}
.collapsible=${true} .collapsible=${true}
+11 -5
View File
@@ -27,11 +27,17 @@ describe("sessionRowActivityKind", () => {
}); });
describe("session action eligibility", () => { describe("session action eligibility", () => {
it("requires a persisted server signal before archiving", () => { it("requires a persisted server signal before archiving when persistence is authoritative", () => {
expect(isArchivableSessionInfo(session("persisted", { persisted: true }))).toBe(true); const authoritative = { authoritative: true };
expect(isArchivableSessionInfo(session("unknown"))).toBe(false); expect(isArchivableSessionInfo(session("persisted", { persisted: true }), undefined, authoritative)).toBe(true);
expect(isArchivableSessionInfo(session("transient", { persisted: false }))).toBe(false); expect(isArchivableSessionInfo(session("unknown"), undefined, authoritative)).toBe(false);
expect(isArchivableSessionInfo({ ...session("archived", { persisted: true }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" })).toBe(false); expect(isArchivableSessionInfo(session("transient", { persisted: false }), undefined, authoritative)).toBe(false);
expect(isArchivableSessionInfo({ ...session("archived", { persisted: true }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" }, undefined, authoritative)).toBe(false);
});
it("preserves legacy archiving when persistence support is not advertised", () => {
expect(isArchivableSessionInfo(session("legacy"))).toBe(true);
expect(isTransientNewSessionInfo(session("legacy"))).toBe(false);
}); });
it("allows deleting transient non-archived sessions from server or browser-cached signals", () => { it("allows deleting transient non-archived sessions from server or browser-cached signals", () => {
+11 -5
View File
@@ -36,6 +36,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ type: Boolean }) canDeleteArchived = false; @property({ type: Boolean }) canDeleteArchived = false;
@property({ type: Boolean }) canReload = false; @property({ type: Boolean }) canReload = false;
@property({ type: Boolean }) canCleanup = false; @property({ type: Boolean }) canCleanup = false;
@property({ type: Boolean }) authoritativeSessionPersistence = false;
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
@property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions."; @property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions.";
@property({ type: Boolean, reflect: true }) collapsible = false; @property({ type: Boolean, reflect: true }) collapsible = false;
@@ -193,7 +194,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
if (visibleSessions.length === 0 || !this.selectionScopes.has("current")) return null; if (visibleSessions.length === 0 || !this.selectionScopes.has("current")) return null;
const selectedSessions = this.selectedSessions("current"); const selectedSessions = this.selectedSessions("current");
const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id])); const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions()));
const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id)); const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id));
const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length; const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length;
return html` return html`
@@ -234,8 +235,9 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id); const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id);
const status = this.statuses[session.id]; const status = this.statuses[session.id];
const activity = this.activities[session.id]; const activity = this.activities[session.id];
const canArchive = isArchivableSessionInfo(session, status); const persistenceOptions = this.sessionPersistenceOptions();
const canDeleteTransient = isTransientNewSessionInfo(session, status); const canArchive = isArchivableSessionInfo(session, status, persistenceOptions);
const canDeleteTransient = isTransientNewSessionInfo(session, status, persistenceOptions);
const canReloadSession = canArchive && this.canReload; const canReloadSession = canArchive && this.canReload;
return html` return html`
<div <div
@@ -315,7 +317,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
} }
private archiveSelectedCurrent(): void { private archiveSelectedCurrent(): void {
const sessions = this.selectedSessions("current").filter((session) => isArchivableSessionInfo(session, this.statuses[session.id])); const sessions = this.selectedSessions("current").filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions()));
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessions.map((session) => session.id)); this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessions.map((session) => session.id));
void this.onArchiveMany?.(sessions); void this.onArchiveMany?.(sessions);
} }
@@ -395,7 +397,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
} }
private renderSessionMetaPrefix(session: SessionInfo, status: SessionStatus | undefined, activity: SessionActivity | undefined) { private renderSessionMetaPrefix(session: SessionInfo, status: SessionStatus | undefined, activity: SessionActivity | undefined) {
if (isTransientNewSessionInfo(session, status)) { if (isTransientNewSessionInfo(session, status, this.sessionPersistenceOptions())) {
if (activity?.phase === "active") return "creating · "; if (activity?.phase === "active") return "creating · ";
if (activity?.phase === "error") return "error · "; if (activity?.phase === "error") return "error · ";
return "new · "; return "new · ";
@@ -404,6 +406,10 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
return ""; return "";
} }
private sessionPersistenceOptions() {
return { authoritative: this.authoritativeSessionPersistence };
}
private renderActivity(session: SessionInfo) { private renderActivity(session: SessionInfo) {
const kind = sessionRowActivityKind(session, this.statuses[session.id], this.activities[session.id], this.sending[session.id] === true); const kind = sessionRowActivityKind(session, this.statuses[session.id], this.activities[session.id], this.sending[session.id] === true);
return renderActionActivityIndicator(kind, kind === "sending" ? "Sending message" : "Session active"); return renderActionActivityIndicator(kind, kind === "sending" ? "Sending message" : "Session active");
@@ -44,6 +44,7 @@ export class AppNavigationPanel extends LitElement {
@property({ type: Boolean }) canDeleteArchivedSessions = false; @property({ type: Boolean }) canDeleteArchivedSessions = false;
@property({ type: Boolean }) canReloadSessions = false; @property({ type: Boolean }) canReloadSessions = false;
@property({ type: Boolean }) canCleanupSessions = false; @property({ type: Boolean }) canCleanupSessions = false;
@property({ type: Boolean }) authoritativeSessionPersistence = false;
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
@property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions."; @property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions.";
@property({ attribute: false }) onShowActions?: () => void; @property({ attribute: false }) onShowActions?: () => void;
@@ -165,6 +166,7 @@ export class AppNavigationPanel extends LitElement {
.canDeleteArchived=${this.canDeleteArchivedSessions} .canDeleteArchived=${this.canDeleteArchivedSessions}
.canReload=${this.canReloadSessions} .canReload=${this.canReloadSessions}
.canCleanup=${this.canCleanupSessions} .canCleanup=${this.canCleanupSessions}
.authoritativeSessionPersistence=${this.authoritativeSessionPersistence}
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage}
.cleanupUnavailableMessage=${this.cleanupUnavailableMessage} .cleanupUnavailableMessage=${this.cleanupUnavailableMessage}
.collapsible=${this.collapsible} .collapsible=${this.collapsible}
@@ -1124,6 +1124,31 @@ describe("SessionController", () => {
expect(urlUpdates).toEqual([undefined]); expect(urlUpdates).toEqual([undefined]);
}); });
it("archives legacy sessions when persistence support is not advertised", async () => {
const legacySession = { ...oldSession };
const archivedIds: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: legacySession, sessions: [legacySession] };
const api: typeof defaultApi = {
...defaultApi,
archive: (session) => {
archivedIds.push(sessionLookupId(session));
return Promise.resolve({ archived: true });
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.archiveSession(legacySession);
expect(archivedIds).toEqual([legacySession.id]);
expect(state.sessions[0]).toMatchObject({ id: legacySession.id, archived: true });
});
it("archives selected session descendants and selects the next active session", async () => { it("archives selected session descendants and selects the next active session", async () => {
const persistedSession = { ...oldSession, persisted: true }; const persistedSession = { ...oldSession, persisted: true };
const childSession = { ...oldSession, id: "child-session", path: "/tmp/child-session.jsonl", parentSessionPath: persistedSession.path, persisted: true }; const childSession = { ...oldSession, id: "child-session", path: "/tmp/child-session.jsonl", parentSessionPath: persistedSession.path, persisted: true };
@@ -1388,10 +1413,10 @@ describe("SessionController", () => {
expect(state.sessionActivities[oldSession.id]).toBeUndefined(); expect(state.sessionActivities[oldSession.id]).toBeUndefined();
}); });
it("does not delete archived sessions when the selected machine runtime does not support it", async () => { it("does not delete archived sessions when the selected machine runtime reports no support", async () => {
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
const deletedIds: string[] = []; const deletedIds: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] }; let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession], machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [] } } };
const api: typeof defaultApi = { const api: typeof defaultApi = {
...defaultApi, ...defaultApi,
deleteArchived: (session) => { deleteArchived: (session) => {
@@ -1414,6 +1439,32 @@ describe("SessionController", () => {
expect(state.error).toContain("requires an updated Pi-Web runtime"); expect(state.error).toContain("requires an updated Pi-Web runtime");
}); });
it("allows legacy archived-session deletion when runtime support is unknown", async () => {
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
const deletedIds: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: archivedSession, sessions: [archivedSession] };
const api: typeof defaultApi = {
...defaultApi,
deleteArchived: (session) => {
deletedIds.push(sessionLookupId(session));
return Promise.resolve({ deleted: true });
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.deleteArchivedSessions([archivedSession]);
expect(deletedIds).toEqual([archivedSession.id]);
expect(state.sessions).toEqual([]);
expect(state.error).toBe("");
});
it("reloads the selected session from disk, discards the cached transcript, and re-fetches history", async () => { it("reloads the selected session from disk, discards the cached transcript, and re-fetches history", async () => {
const persistedSession = { ...oldSession, persisted: true }; const persistedSession = { ...oldSession, persisted: true };
const cacheKey = sessionKey(oldSession.id); const cacheKey = sessionKey(oldSession.id);
@@ -1496,14 +1547,14 @@ describe("SessionController", () => {
expect(state.error).toContain("Reloading sessions from disk requires an updated Pi-Web runtime"); expect(state.error).toContain("Reloading sessions from disk requires an updated Pi-Web runtime");
}); });
it("does not reload sessions from disk without a persisted server signal", async () => { it("does not reload sessions from disk without a persisted server signal when persistence is authoritative", async () => {
const reloadCalls: string[] = []; const reloadCalls: string[] = [];
let state: AppState = { let state: AppState = {
...initialAppState(), ...initialAppState(),
selectedWorkspace: workspace, selectedWorkspace: workspace,
selectedSession: oldSession, selectedSession: oldSession,
sessions: [oldSession], sessions: [oldSession],
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } }, machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsPersistedState] } },
}; };
const api: typeof defaultApi = { const api: typeof defaultApi = {
...defaultApi, ...defaultApi,
@@ -8,7 +8,7 @@ import { ChatTranscriptStore } from "../chatTranscriptStore";
import { isShellInput } from "../inputModes"; import { isShellInput } from "../inputModes";
import { fileCompletionInsertText } from "../promptCompletions"; import { fileCompletionInsertText } from "../promptCompletions";
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket"; import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence"; import { isArchivableSessionInfo, isTransientNewSessionInfo, sessionPersistenceOptionsForRuntime } from "../sessionPersistence";
import { isSessionActive } from "../../../shared/activity"; import { isSessionActive } from "../../../shared/activity";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities"; import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
@@ -389,11 +389,12 @@ export class SessionController {
async archiveSession(session = this.getState().selectedSession) { async archiveSession(session = this.getState().selectedSession) {
if (!session) return; if (!session) return;
const status = this.statusForSession(session); const status = this.statusForSession(session);
if (isTransientNewSessionInfo(session, status)) { const persistenceOptions = this.sessionPersistenceOptions();
if (isTransientNewSessionInfo(session, status, persistenceOptions)) {
await this.deleteCachedNewSession(session); await this.deleteCachedNewSession(session);
return; return;
} }
if (!isArchivableSessionInfo(session, status)) return; if (!isArchivableSessionInfo(session, status, persistenceOptions)) return;
try { try {
await this.api.archive(session, selectedMachineId(this.getState())); await this.api.archive(session, selectedMachineId(this.getState()));
const state = this.getState(); const state = this.getState();
@@ -409,7 +410,7 @@ export class SessionController {
} }
async archiveSessionWithDescendants(session = this.getState().selectedSession) { async archiveSessionWithDescendants(session = this.getState().selectedSession) {
if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session))) return; if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session), this.sessionPersistenceOptions())) return;
try { try {
const response = await this.api.archiveWithDescendants(session, selectedMachineId(this.getState())); const response = await this.api.archiveWithDescendants(session, selectedMachineId(this.getState()));
const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id]; const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id];
@@ -426,7 +427,8 @@ export class SessionController {
} }
async archiveSessions(sessions: readonly SessionInfo[]): Promise<void> { async archiveSessions(sessions: readonly SessionInfo[]): Promise<void> {
const candidates = uniqueSessionsById(sessions).filter((session) => isArchivableSessionInfo(session, this.statusForSession(session))); const persistenceOptions = this.sessionPersistenceOptions();
const candidates = uniqueSessionsById(sessions).filter((session) => isArchivableSessionInfo(session, this.statusForSession(session), persistenceOptions));
if (candidates.length === 0) return; if (candidates.length === 0) return;
try { try {
@@ -453,7 +455,9 @@ export class SessionController {
const machineId = selectedMachineId(this.getState()); const machineId = selectedMachineId(this.getState());
const runtime = this.getState().machineRuntimes[machineId]; const runtime = this.getState().machineRuntimes[machineId];
if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived)) { // Preserve legacy federated deletes when capability discovery is unavailable;
// only a positive runtime response without support should block the action.
if (runtime?.ok === true && !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived)) {
this.setState({ error: "Deleting archived sessions requires an updated Pi-Web runtime on this machine." }); this.setState({ error: "Deleting archived sessions requires an updated Pi-Web runtime on this machine." });
return; return;
} }
@@ -559,7 +563,7 @@ export class SessionController {
} }
async deleteCachedNewSession(session = this.getState().selectedSession) { async deleteCachedNewSession(session = this.getState().selectedSession) {
if (session === undefined || !isTransientNewSessionInfo(session, this.statusForSession(session))) return; if (session === undefined || !isTransientNewSessionInfo(session, this.statusForSession(session), this.sessionPersistenceOptions())) return;
const pendingStart = isClientPendingStartSessionInfo(session) ? this.pendingSessionStarts.get(session.id) : undefined; const pendingStart = isClientPendingStartSessionInfo(session) ? this.pendingSessionStarts.get(session.id) : undefined;
if (pendingStart !== undefined) { if (pendingStart !== undefined) {
pendingStart.discarded = true; pendingStart.discarded = true;
@@ -605,7 +609,7 @@ export class SessionController {
} }
async reloadSession(session = this.getState().selectedSession) { async reloadSession(session = this.getState().selectedSession) {
if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session))) return; if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session), this.sessionPersistenceOptions())) return;
const machineId = selectedMachineId(this.getState()); const machineId = selectedMachineId(this.getState());
const runtime = this.getState().machineRuntimes[machineId]; const runtime = this.getState().machineRuntimes[machineId];
if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) { if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) {
@@ -757,6 +761,11 @@ export class SessionController {
return state.sessionStatuses[session.id]; return state.sessionStatuses[session.id];
} }
private sessionPersistenceOptions() {
const state = this.getState();
return sessionPersistenceOptionsForRuntime(state.machineRuntimes[selectedMachineId(state)]);
}
private workspaceSelectionKey(cwd: string): string { private workspaceSelectionKey(cwd: string): string {
return `${selectedMachineId(this.getState())}:${cwd}`; return `${selectedMachineId(this.getState())}:${cwd}`;
} }
+9 -5
View File
@@ -2,7 +2,7 @@ import { isSessionActive } from "../../../../shared/activity";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability, type PiWebCapability } from "../../../../shared/capabilities"; import { PI_WEB_CAPABILITIES, supportsPiWebCapability, type PiWebCapability } from "../../../../shared/capabilities";
import type { AppState } from "../../appState"; import type { AppState } from "../../appState";
import { selectedMachineId } from "../../controllers/types"; import { selectedMachineId } from "../../controllers/types";
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../../sessionPersistence"; import { isArchivableSessionInfo, isTransientNewSessionInfo, sessionPersistenceOptionsForRuntime } from "../../sessionPersistence";
import { isWorkspaceDeletionPending } from "../../workspaceDeletion"; import { isWorkspaceDeletionPending } from "../../workspaceDeletion";
import type { PluginAction } from "../types"; import type { PluginAction } from "../types";
@@ -217,25 +217,29 @@ function hasDeletableWorkspace(context: { state: AppState }): boolean {
} }
function hasArchivableSession(context: { state: AppState }): boolean { function hasArchivableSession(context: { state: AppState }): boolean {
return isArchivableSessionInfo(context.state.selectedSession, context.state.status); return isArchivableSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state));
} }
function hasTransientNewSession(context: { state: AppState }): boolean { function hasTransientNewSession(context: { state: AppState }): boolean {
return isTransientNewSessionInfo(context.state.selectedSession, context.state.status); return isTransientNewSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state));
} }
function hasReloadableSession(context: { state: AppState }): boolean { function hasReloadableSession(context: { state: AppState }): boolean {
if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status)) return false; if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state))) return false;
if (reloadSessionDisabledReason(context) !== undefined) return false; if (reloadSessionDisabledReason(context) !== undefined) return false;
return !isSessionActive(context.state.status, context.state.activity); return !isSessionActive(context.state.status, context.state.activity);
} }
function reloadSessionDisabledReason(context: { state: AppState }): string | undefined { function reloadSessionDisabledReason(context: { state: AppState }): string | undefined {
if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status)) return undefined; if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state))) return undefined;
if (isSessionActive(context.state.status, context.state.activity)) return undefined; if (isSessionActive(context.state.status, context.state.activity)) return undefined;
return missingCapabilityReason(context.state, PI_WEB_CAPABILITIES.sessionsReload, "reload sessions from disk"); return missingCapabilityReason(context.state, PI_WEB_CAPABILITIES.sessionsReload, "reload sessions from disk");
} }
function sessionPersistenceOptions(state: AppState) {
return sessionPersistenceOptionsForRuntime(state.machineRuntimes[selectedMachineId(state)]);
}
function missingCapabilityReason(state: AppState, capability: PiWebCapability, action: string): string | undefined { function missingCapabilityReason(state: AppState, capability: PiWebCapability, action: string): string | undefined {
const runtime = state.machineRuntimes[selectedMachineId(state)]; const runtime = state.machineRuntimes[selectedMachineId(state)];
if (runtime?.ok === true && supportsPiWebCapability(runtime, capability)) return undefined; if (runtime?.ok === true && supportsPiWebCapability(runtime, capability)) return undefined;
+12 -3
View File
@@ -177,14 +177,19 @@ describe("PluginRegistry", () => {
const registry = new PluginRegistry(); const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin }); registry.register({ id: "core", plugin: corePlugin });
const persistedActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }) }).context); const persistedStateRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] } };
const persistedActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: persistedStateRuntime }).context);
expect(persistedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(true); expect(persistedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(true);
expect(persistedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false); expect(persistedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
const unknownActions = registry.getActions(createContext({ selectedSession: testSession() }).context); const unknownActions = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: persistedStateRuntime }).context);
expect(unknownActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false); expect(unknownActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(unknownActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false); expect(unknownActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
const legacyUnknownActions = registry.getActions(createContext({ selectedSession: testSession() }).context);
expect(legacyUnknownActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(true);
expect(legacyUnknownActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
const transientActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }) }).context); const transientActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }) }).context);
expect(transientActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false); expect(transientActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(transientActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(true); expect(transientActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(true);
@@ -214,7 +219,8 @@ describe("PluginRegistry", () => {
it("enables session disk reload only for a writable session on a capable, idle runtime", () => { it("enables session disk reload only for a writable session on a capable, idle runtime", () => {
const registry = new PluginRegistry(); const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin }); registry.register({ id: "core", plugin: corePlugin });
const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } }; const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsPersistedState] } };
const legacyReloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } };
const reloadable = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: reloadRuntime }).context); const reloadable = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: reloadRuntime }).context);
const reloadableAction = reloadable.find((action) => action.id === "core:session.reload"); const reloadableAction = reloadable.find((action) => action.id === "core:session.reload");
@@ -230,6 +236,9 @@ describe("PluginRegistry", () => {
const unknown = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime }).context); const unknown = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime }).context);
expect(unknown.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); expect(unknown.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
const legacyUnknown = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: legacyReloadRuntime }).context);
expect(legacyUnknown.find((action) => action.id === "core:session.reload")?.enabled).toBe(true);
const transient = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }), machineRuntimes: reloadRuntime }).context); const transient = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }), machineRuntimes: reloadRuntime }).context);
expect(transient.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); expect(transient.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
+25 -6
View File
@@ -1,21 +1,40 @@
import type { SessionInfo, SessionStatus } from "./api"; import type { MachineRuntime, SessionInfo, SessionStatus } from "./api";
import { isCachedNewSessionInfo } from "./cachedNewSessions"; import { isCachedNewSessionInfo } from "./cachedNewSessions";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../shared/capabilities";
export type SessionPersistenceState = "persisted" | "transient" | "unknown"; export type SessionPersistenceState = "persisted" | "transient" | "unknown";
export function sessionPersistenceState(session: SessionInfo | undefined, status?: SessionStatus): SessionPersistenceState { export interface SessionPersistenceOptions {
/**
* True when the selected runtime advertises reliable persisted/transient
* session state. Legacy federated runtimes omit this field, so missing data
* must preserve the old "listed sessions are persisted" behavior.
*/
authoritative?: boolean;
}
export function hasAuthoritativeSessionPersistence(runtime: Pick<MachineRuntime, "ok" | "capabilities"> | undefined): boolean {
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsPersistedState);
}
export function sessionPersistenceOptionsForRuntime(runtime: Pick<MachineRuntime, "ok" | "capabilities"> | undefined): SessionPersistenceOptions {
return { authoritative: hasAuthoritativeSessionPersistence(runtime) };
}
export function sessionPersistenceState(session: SessionInfo | undefined, status?: SessionStatus, options: SessionPersistenceOptions = {}): SessionPersistenceState {
if (session === undefined) return "unknown"; if (session === undefined) return "unknown";
const statusPersisted = status?.sessionId === session.id ? status.persisted : undefined; const statusPersisted = status?.sessionId === session.id ? status.persisted : undefined;
const persisted = statusPersisted ?? session.persisted; const persisted = statusPersisted ?? session.persisted;
if (persisted === true) return "persisted"; if (persisted === true) return "persisted";
if (persisted === false || isCachedNewSessionInfo(session)) return "transient"; if (persisted === false || isCachedNewSessionInfo(session)) return "transient";
if (options.authoritative !== true) return "persisted";
return "unknown"; return "unknown";
} }
export function isArchivableSessionInfo(session: SessionInfo | undefined, status?: SessionStatus): boolean { export function isArchivableSessionInfo(session: SessionInfo | undefined, status?: SessionStatus, options?: SessionPersistenceOptions): boolean {
return session !== undefined && session.archived !== true && sessionPersistenceState(session, status) === "persisted"; return session !== undefined && session.archived !== true && sessionPersistenceState(session, status, options) === "persisted";
} }
export function isTransientNewSessionInfo(session: SessionInfo | undefined, status?: SessionStatus): boolean { export function isTransientNewSessionInfo(session: SessionInfo | undefined, status?: SessionStatus, options?: SessionPersistenceOptions): boolean {
return session !== undefined && session.archived !== true && sessionPersistenceState(session, status) === "transient"; return session !== undefined && session.archived !== true && sessionPersistenceState(session, status, options) === "transient";
} }
+1
View File
@@ -6,6 +6,7 @@ export const PI_WEB_CAPABILITIES = {
sessionsBulkMutations: "sessions.bulkMutations", sessionsBulkMutations: "sessions.bulkMutations",
sessionsCleanup: "sessions.cleanup", sessionsCleanup: "sessions.cleanup",
sessionsReload: "sessions.reload", sessionsReload: "sessions.reload",
sessionsPersistedState: "sessions.persistedState",
promptAttachments: "prompt.attachments", promptAttachments: "prompt.attachments",
workspaceFileSuggestions: "workspace.fileSuggestions", workspaceFileSuggestions: "workspace.fileSuggestions",
piPackagesManage: "piPackages.manage", piPackagesManage: "piPackages.manage",
+14
View File
@@ -14,6 +14,20 @@ describe("PI WEB capabilities", () => {
})).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]); })).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
}); });
it("requires web and session daemon support for authoritative session persistence", () => {
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState);
expect(SESSIOND_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] },
sessiond: { available: false, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] },
})).not.toContain(PI_WEB_CAPABILITIES.sessionsPersistedState);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] },
sessiond: { available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] },
})).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState);
});
it("keeps only known string capabilities when parsing runtime data", () => { it("keeps only known string capabilities when parsing runtime data", () => {
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]); expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined(); expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined();
+3
View File
@@ -11,6 +11,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsBulkMutations, PI_WEB_CAPABILITIES.sessionsBulkMutations,
PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsCleanup,
PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsReload,
PI_WEB_CAPABILITIES.sessionsPersistedState,
PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.promptAttachments,
PI_WEB_CAPABILITIES.workspaceFileSuggestions, PI_WEB_CAPABILITIES.workspaceFileSuggestions,
PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.piPackagesManage,
@@ -22,6 +23,7 @@ export const SESSIOND_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsBulkMutations, PI_WEB_CAPABILITIES.sessionsBulkMutations,
PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsCleanup,
PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsReload,
PI_WEB_CAPABILITIES.sessionsPersistedState,
PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.promptAttachments,
] as const satisfies readonly PiWebCapability[]; ] as const satisfies readonly PiWebCapability[];
@@ -30,6 +32,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
[PI_WEB_CAPABILITIES.sessionsBulkMutations]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsBulkMutations]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"], [PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
[PI_WEB_CAPABILITIES.piPackagesManage]: ["web"], [PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],