diff --git a/.changeset/legacy-federated-session-actions.md b/.changeset/legacy-federated-session-actions.md new file mode 100644 index 0000000..45098d9 --- /dev/null +++ b/.changeset/legacy-federated-session-actions.md @@ -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. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index cdc4f6e..2626f9d 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -20,6 +20,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; import { selectedMachineId } from "../controllers/types"; import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi"; +import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence"; import { RealtimeSocket } from "../sessionSocket"; 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"; @@ -1016,7 +1017,10 @@ export class PiWebApp extends LitElement { private canDeleteArchivedSessions(): boolean { 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 { @@ -1029,6 +1033,10 @@ export class PiWebApp extends LitElement { return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsCleanup); } + private hasAuthoritativeSessionPersistence(): boolean { + return runtimeHasAuthoritativeSessionPersistence(this.selectedMachineRuntime()); + } + private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean { if (machineId === "local") return true; // COMPAT-CAP workspace.fileSuggestions: remote machines without this @@ -1124,6 +1132,7 @@ export class PiWebApp extends LitElement { .canDeleteArchivedSessions=${this.canDeleteArchivedSessions()} .canReloadSessions=${this.canReloadSessions()} .canCleanupSessions=${this.canCleanupSessions()} + .authoritativeSessionPersistence=${this.hasAuthoritativeSessionPersistence()} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()} .cleanupUnavailableMessage=${this.sessionCleanupUnavailableMessage()} .collapsible=${true} diff --git a/src/client/src/components/SessionList.test.ts b/src/client/src/components/SessionList.test.ts index a1009fc..648a595 100644 --- a/src/client/src/components/SessionList.test.ts +++ b/src/client/src/components/SessionList.test.ts @@ -27,11 +27,17 @@ describe("sessionRowActivityKind", () => { }); describe("session action eligibility", () => { - it("requires a persisted server signal before archiving", () => { - expect(isArchivableSessionInfo(session("persisted", { persisted: true }))).toBe(true); - expect(isArchivableSessionInfo(session("unknown"))).toBe(false); - expect(isArchivableSessionInfo(session("transient", { persisted: false }))).toBe(false); - expect(isArchivableSessionInfo({ ...session("archived", { persisted: true }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" })).toBe(false); + it("requires a persisted server signal before archiving when persistence is authoritative", () => { + const authoritative = { authoritative: true }; + expect(isArchivableSessionInfo(session("persisted", { persisted: true }), undefined, authoritative)).toBe(true); + expect(isArchivableSessionInfo(session("unknown"), undefined, authoritative)).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", () => { diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index 67cbc55..ccde342 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -36,6 +36,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection @property({ type: Boolean }) canDeleteArchived = false; @property({ type: Boolean }) canReload = 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 }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions."; @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; 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 visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length; return html` @@ -234,8 +235,9 @@ export class SessionList extends LitElement implements KeyboardNavigableSection const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id); const status = this.statuses[session.id]; const activity = this.activities[session.id]; - const canArchive = isArchivableSessionInfo(session, status); - const canDeleteTransient = isTransientNewSessionInfo(session, status); + const persistenceOptions = this.sessionPersistenceOptions(); + const canArchive = isArchivableSessionInfo(session, status, persistenceOptions); + const canDeleteTransient = isTransientNewSessionInfo(session, status, persistenceOptions); const canReloadSession = canArchive && this.canReload; return html`
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)); 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) { - if (isTransientNewSessionInfo(session, status)) { + if (isTransientNewSessionInfo(session, status, this.sessionPersistenceOptions())) { if (activity?.phase === "active") return "creating · "; if (activity?.phase === "error") return "error · "; return "new · "; @@ -404,6 +406,10 @@ export class SessionList extends LitElement implements KeyboardNavigableSection return ""; } + private sessionPersistenceOptions() { + return { authoritative: this.authoritativeSessionPersistence }; + } + private renderActivity(session: SessionInfo) { 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"); diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 5a2fc40..9029fa7 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -44,6 +44,7 @@ export class AppNavigationPanel extends LitElement { @property({ type: Boolean }) canDeleteArchivedSessions = false; @property({ type: Boolean }) canReloadSessions = 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 }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions."; @property({ attribute: false }) onShowActions?: () => void; @@ -165,6 +166,7 @@ export class AppNavigationPanel extends LitElement { .canDeleteArchived=${this.canDeleteArchivedSessions} .canReload=${this.canReloadSessions} .canCleanup=${this.canCleanupSessions} + .authoritativeSessionPersistence=${this.authoritativeSessionPersistence} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage} .cleanupUnavailableMessage=${this.cleanupUnavailableMessage} .collapsible=${this.collapsible} diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 385de44..ba2977a 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -1124,6 +1124,31 @@ describe("SessionController", () => { 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 () => { const persistedSession = { ...oldSession, 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(); }); - 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 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 = { ...defaultApi, deleteArchived: (session) => { @@ -1414,6 +1439,32 @@ describe("SessionController", () => { 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 () => { const persistedSession = { ...oldSession, persisted: true }; 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"); }); - 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[] = []; let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: 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 = { ...defaultApi, diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 619687a..d9ec288 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -8,7 +8,7 @@ import { ChatTranscriptStore } from "../chatTranscriptStore"; import { isShellInput } from "../inputModes"; import { fileCompletionInsertText } from "../promptCompletions"; 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 { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities"; import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; @@ -389,11 +389,12 @@ export class SessionController { async archiveSession(session = this.getState().selectedSession) { if (!session) return; const status = this.statusForSession(session); - if (isTransientNewSessionInfo(session, status)) { + const persistenceOptions = this.sessionPersistenceOptions(); + if (isTransientNewSessionInfo(session, status, persistenceOptions)) { await this.deleteCachedNewSession(session); return; } - if (!isArchivableSessionInfo(session, status)) return; + if (!isArchivableSessionInfo(session, status, persistenceOptions)) return; try { await this.api.archive(session, selectedMachineId(this.getState())); const state = this.getState(); @@ -409,7 +410,7 @@ export class SessionController { } 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 { const response = await this.api.archiveWithDescendants(session, selectedMachineId(this.getState())); 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 { - 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; try { @@ -453,7 +455,9 @@ export class SessionController { const machineId = selectedMachineId(this.getState()); 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." }); return; } @@ -559,7 +563,7 @@ export class SessionController { } 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; if (pendingStart !== undefined) { pendingStart.discarded = true; @@ -605,7 +609,7 @@ export class SessionController { } 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 runtime = this.getState().machineRuntimes[machineId]; if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) { @@ -757,6 +761,11 @@ export class SessionController { return state.sessionStatuses[session.id]; } + private sessionPersistenceOptions() { + const state = this.getState(); + return sessionPersistenceOptionsForRuntime(state.machineRuntimes[selectedMachineId(state)]); + } + private workspaceSelectionKey(cwd: string): string { return `${selectedMachineId(this.getState())}:${cwd}`; } diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index 622fc42..322e1e9 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -2,7 +2,7 @@ import { isSessionActive } from "../../../../shared/activity"; import { PI_WEB_CAPABILITIES, supportsPiWebCapability, type PiWebCapability } from "../../../../shared/capabilities"; import type { AppState } from "../../appState"; import { selectedMachineId } from "../../controllers/types"; -import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../../sessionPersistence"; +import { isArchivableSessionInfo, isTransientNewSessionInfo, sessionPersistenceOptionsForRuntime } from "../../sessionPersistence"; import { isWorkspaceDeletionPending } from "../../workspaceDeletion"; import type { PluginAction } from "../types"; @@ -217,25 +217,29 @@ function hasDeletableWorkspace(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 { - 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 { - 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; return !isSessionActive(context.state.status, context.state.activity); } 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; 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 { const runtime = state.machineRuntimes[selectedMachineId(state)]; if (runtime?.ok === true && supportsPiWebCapability(runtime, capability)) return undefined; diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index ce47538..bdb1de3 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -177,14 +177,19 @@ describe("PluginRegistry", () => { const registry = new PluginRegistry(); 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.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.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); expect(transientActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false); 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", () => { 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 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 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); 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); expect(transient.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); diff --git a/src/client/src/sessionPersistence.ts b/src/client/src/sessionPersistence.ts index 32db583..8cc8e3e 100644 --- a/src/client/src/sessionPersistence.ts +++ b/src/client/src/sessionPersistence.ts @@ -1,21 +1,40 @@ -import type { SessionInfo, SessionStatus } from "./api"; +import type { MachineRuntime, SessionInfo, SessionStatus } from "./api"; import { isCachedNewSessionInfo } from "./cachedNewSessions"; +import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../shared/capabilities"; 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 | undefined): boolean { + return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsPersistedState); +} + +export function sessionPersistenceOptionsForRuntime(runtime: Pick | undefined): SessionPersistenceOptions { + return { authoritative: hasAuthoritativeSessionPersistence(runtime) }; +} + +export function sessionPersistenceState(session: SessionInfo | undefined, status?: SessionStatus, options: SessionPersistenceOptions = {}): SessionPersistenceState { if (session === undefined) return "unknown"; const statusPersisted = status?.sessionId === session.id ? status.persisted : undefined; const persisted = statusPersisted ?? session.persisted; if (persisted === true) return "persisted"; if (persisted === false || isCachedNewSessionInfo(session)) return "transient"; + if (options.authoritative !== true) return "persisted"; return "unknown"; } -export function isArchivableSessionInfo(session: SessionInfo | undefined, status?: SessionStatus): boolean { - return session !== undefined && session.archived !== true && sessionPersistenceState(session, status) === "persisted"; +export function isArchivableSessionInfo(session: SessionInfo | undefined, status?: SessionStatus, options?: SessionPersistenceOptions): boolean { + return session !== undefined && session.archived !== true && sessionPersistenceState(session, status, options) === "persisted"; } -export function isTransientNewSessionInfo(session: SessionInfo | undefined, status?: SessionStatus): boolean { - return session !== undefined && session.archived !== true && sessionPersistenceState(session, status) === "transient"; +export function isTransientNewSessionInfo(session: SessionInfo | undefined, status?: SessionStatus, options?: SessionPersistenceOptions): boolean { + return session !== undefined && session.archived !== true && sessionPersistenceState(session, status, options) === "transient"; } diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 78e86b5..ee25823 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -6,6 +6,7 @@ export const PI_WEB_CAPABILITIES = { sessionsBulkMutations: "sessions.bulkMutations", sessionsCleanup: "sessions.cleanup", sessionsReload: "sessions.reload", + sessionsPersistedState: "sessions.persistedState", promptAttachments: "prompt.attachments", workspaceFileSuggestions: "workspace.fileSuggestions", piPackagesManage: "piPackages.manage", diff --git a/src/shared/capabilities.test.ts b/src/shared/capabilities.test.ts index 2e75074..aa03c5c 100644 --- a/src/shared/capabilities.test.ts +++ b/src/shared/capabilities.test.ts @@ -14,6 +14,20 @@ describe("PI WEB capabilities", () => { })).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", () => { 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(); diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts index b009081..8d00989 100644 --- a/src/shared/capabilities.ts +++ b/src/shared/capabilities.ts @@ -11,6 +11,7 @@ export const WEB_RUNTIME_CAPABILITIES = [ PI_WEB_CAPABILITIES.sessionsBulkMutations, PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsReload, + PI_WEB_CAPABILITIES.sessionsPersistedState, PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.workspaceFileSuggestions, PI_WEB_CAPABILITIES.piPackagesManage, @@ -22,6 +23,7 @@ export const SESSIOND_RUNTIME_CAPABILITIES = [ PI_WEB_CAPABILITIES.sessionsBulkMutations, PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsReload, + PI_WEB_CAPABILITIES.sessionsPersistedState, PI_WEB_CAPABILITIES.promptAttachments, ] as const satisfies readonly PiWebCapability[]; @@ -30,6 +32,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = { [PI_WEB_CAPABILITIES.sessionsBulkMutations]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"], + [PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"], [PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],