Archived
fix: preserve legacy federated session actions
This commit is contained in:
@@ -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.
|
||||
@@ -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}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user