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 { 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}
+11 -5
View File
@@ -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", () => {
+11 -5
View File
@@ -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`
<div
@@ -315,7 +317,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
}
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));
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");
@@ -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}
@@ -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,
@@ -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<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;
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}`;
}
+9 -5
View File
@@ -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;
+12 -3
View File
@@ -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);
+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 { 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<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";
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";
}
+1
View File
@@ -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",
+14
View File
@@ -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();
+3
View File
@@ -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"],