Archived
Merge pull request #24 from jmfederico/fix/session-reload-up-to-date
feat(sessions): add Reload action to refresh session from disk
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a **Reload** action to the session three-dot menu that re-reads the session from disk. The session daemon keeps an in-memory `SessionManager` per session and never re-reads the session file, so when the same session is also driven by another process (for example the `pi` CLI), new on-disk entries were invisible to the web UI and the tail of the conversation appeared truncated. Reloading closes the active session, re-opens it from disk, discards the cached transcript, and re-fetches the history.
|
||||||
|
|
||||||
|
Reload is also available from the command palette as **Reload Session**, so it can be triggered from the keyboard and assigned a custom shortcut. Reload refuses to run while the session has work in progress and on archived (read-only) sessions, and is gated behind a new `sessions.reload` runtime capability so it only appears for machines whose Pi-Web runtime supports it (both the menu item and the palette action are disabled otherwise).
|
||||||
|
|
||||||
|
Note: this changes a session daemon code path, so `pi-web-sessiond.service` must be restarted manually for the server side of this change to take effect.
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
parsePiWebRuntimeResponse,
|
parsePiWebRuntimeResponse,
|
||||||
parsePiWebStatusResponse,
|
parsePiWebStatusResponse,
|
||||||
parseProject,
|
parseProject,
|
||||||
|
parseReloaded,
|
||||||
parseRestored,
|
parseRestored,
|
||||||
parseSavedAttachments,
|
parseSavedAttachments,
|
||||||
parseSessionInfo,
|
parseSessionInfo,
|
||||||
@@ -143,6 +144,7 @@ export const sessionsApi = {
|
|||||||
restore: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "restore", machineId), parseRestored, { method: "POST", body: sessionBody(session) }),
|
restore: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "restore", machineId), parseRestored, { method: "POST", body: sessionBody(session) }),
|
||||||
deleteArchived: (session: SessionLookup, machineId = "local") => request(sessionBaseQueryUrl(session, machineId), parseDeleted, { method: "DELETE" }),
|
deleteArchived: (session: SessionLookup, machineId = "local") => request(sessionBaseQueryUrl(session, machineId), parseDeleted, { method: "DELETE" }),
|
||||||
detachParent: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "detach-parent", machineId), parseDetached, { method: "POST", body: sessionBody(session) }),
|
detachParent: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "detach-parent", machineId), parseDetached, { method: "POST", body: sessionBody(session) }),
|
||||||
|
reloadSession: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "reload", machineId), parseReloaded, { method: "POST", body: sessionBody(session) }),
|
||||||
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
|
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options?.mode !== undefined) params.set("mode", options.mode);
|
if (options?.mode !== undefined) params.set("mode", options.mode);
|
||||||
|
|||||||
@@ -717,6 +717,12 @@ export function parseDetached(value: unknown): { detached: true } {
|
|||||||
return { detached: true };
|
return { detached: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseReloaded(value: unknown): { reloaded: true } {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
if (record["reloaded"] !== true) throw new Error("Expected reloaded response");
|
||||||
|
return { reloaded: true };
|
||||||
|
}
|
||||||
|
|
||||||
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
|
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
|
||||||
const value = record[key];
|
const value = record[key];
|
||||||
if (value === undefined) return undefined;
|
if (value === undefined) return undefined;
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ export function writeChatHistoryCache(sessionId: string, page: RawMessagePage):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removeChatHistoryCache(sessionId: string): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(cacheKey(sessionId));
|
||||||
|
} catch {
|
||||||
|
// Ignore storage access errors; cache may simply be unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeChatHistory(existing: RawMessagePage | undefined, incoming: RawMessagePage): RawMessagePage {
|
export function mergeChatHistory(existing: RawMessagePage | undefined, incoming: RawMessagePage): RawMessagePage {
|
||||||
if (existing === undefined || !isValidMessagePage(existing)) return incoming;
|
if (existing === undefined || !isValidMessagePage(existing)) return incoming;
|
||||||
if (!isValidMessagePage(incoming)) return existing;
|
if (!isValidMessagePage(incoming)) return existing;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { normalizeMessages } from "./chatMessages";
|
import { normalizeMessages } from "./chatMessages";
|
||||||
import { applyTranscriptEvent } from "./chatTranscript";
|
import { applyTranscriptEvent } from "./chatTranscript";
|
||||||
import { mergeChatHistory, readChatHistoryCache, writeChatHistoryCache, type RawMessagePage } from "./chatHistoryCache";
|
import { mergeChatHistory, readChatHistoryCache, removeChatHistoryCache, writeChatHistoryCache, type RawMessagePage } from "./chatHistoryCache";
|
||||||
import type { ChatLine } from "./components/shared";
|
import type { ChatLine } from "./components/shared";
|
||||||
import type { SessionUiEvent } from "./sessionSocket";
|
import type { SessionUiEvent } from "./sessionSocket";
|
||||||
|
|
||||||
@@ -16,11 +16,13 @@ export interface ChatTranscriptView {
|
|||||||
export interface ChatHistoryCacheAdapter {
|
export interface ChatHistoryCacheAdapter {
|
||||||
read(sessionId: string): RawMessagePage | undefined;
|
read(sessionId: string): RawMessagePage | undefined;
|
||||||
write(sessionId: string, page: RawMessagePage): void;
|
write(sessionId: string, page: RawMessagePage): void;
|
||||||
|
remove?(sessionId: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserChatHistoryCache: ChatHistoryCacheAdapter = {
|
const browserChatHistoryCache: ChatHistoryCacheAdapter = {
|
||||||
read: readChatHistoryCache,
|
read: readChatHistoryCache,
|
||||||
write: writeChatHistoryCache,
|
write: writeChatHistoryCache,
|
||||||
|
remove: removeChatHistoryCache,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ChatTranscriptStore {
|
export class ChatTranscriptStore {
|
||||||
@@ -43,6 +45,11 @@ export class ChatTranscriptStore {
|
|||||||
return applyTranscriptEvent(messages, event);
|
return applyTranscriptEvent(messages, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
discard(sessionId: string): void {
|
||||||
|
this.rawHistoryPages.delete(sessionId);
|
||||||
|
this.cache.remove?.(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
rawHistoryPage(sessionId: string): RawMessagePage | undefined {
|
rawHistoryPage(sessionId: string): RawMessagePage | undefined {
|
||||||
const cached = this.rawHistoryPages.get(sessionId) ?? this.cache.read(sessionId);
|
const cached = this.rawHistoryPages.get(sessionId) ?? this.cache.read(sessionId);
|
||||||
if (cached !== undefined) this.rawHistoryPages.set(sessionId, cached);
|
if (cached !== undefined) this.rawHistoryPages.set(sessionId, cached);
|
||||||
|
|||||||
@@ -999,6 +999,11 @@ export class PiWebApp extends LitElement {
|
|||||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived);
|
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private canReloadSessions(): boolean {
|
||||||
|
const runtime = this.selectedMachineRuntime();
|
||||||
|
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload);
|
||||||
|
}
|
||||||
|
|
||||||
private archivedDeleteUnavailableMessage(): string {
|
private archivedDeleteUnavailableMessage(): string {
|
||||||
const machineName = this.state.selectedMachine?.name ?? "this machine";
|
const machineName = this.state.selectedMachine?.name ?? "this machine";
|
||||||
return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`;
|
return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`;
|
||||||
@@ -1033,6 +1038,7 @@ export class PiWebApp extends LitElement {
|
|||||||
.selectedSession=${this.state.selectedSession}
|
.selectedSession=${this.state.selectedSession}
|
||||||
.canStartSession=${!!this.state.selectedWorkspace}
|
.canStartSession=${!!this.state.selectedWorkspace}
|
||||||
.canDeleteArchivedSessions=${this.canDeleteArchivedSessions()}
|
.canDeleteArchivedSessions=${this.canDeleteArchivedSessions()}
|
||||||
|
.canReloadSessions=${this.canReloadSessions()}
|
||||||
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()}
|
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()}
|
||||||
.collapsible=${true}
|
.collapsible=${true}
|
||||||
.compact=${this.appShell.isMobileNavigationLayout}
|
.compact=${this.appShell.isMobileNavigationLayout}
|
||||||
@@ -1060,6 +1066,7 @@ export class PiWebApp extends LitElement {
|
|||||||
.onDeleteArchivedSession=${(session: SessionInfo) => this.sessions.deleteArchivedSessions([session])}
|
.onDeleteArchivedSession=${(session: SessionInfo) => this.sessions.deleteArchivedSessions([session])}
|
||||||
.onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)}
|
.onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)}
|
||||||
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
|
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
|
||||||
|
.onReloadSession=${(session: SessionInfo) => this.sessions.reloadSession(session)}
|
||||||
.onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }}
|
.onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }}
|
||||||
.onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }}
|
.onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }}
|
||||||
></app-navigation-panel>
|
></app-navigation-panel>
|
||||||
@@ -1391,6 +1398,7 @@ export class PiWebApp extends LitElement {
|
|||||||
deleteWorkspace: (workspace) => this.deleteWorkspace(workspace),
|
deleteWorkspace: (workspace) => this.deleteWorkspace(workspace),
|
||||||
startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()),
|
startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()),
|
||||||
archiveSession: () => this.sessions.archiveSession(),
|
archiveSession: () => this.sessions.archiveSession(),
|
||||||
|
reloadSession: () => this.sessions.reloadSession(),
|
||||||
deleteCachedNewSession: () => this.sessions.deleteCachedNewSession(),
|
deleteCachedNewSession: () => this.sessions.deleteCachedNewSession(),
|
||||||
stopActiveWork: () => this.sessions.stopActiveWork(),
|
stopActiveWork: () => this.sessions.stopActiveWork(),
|
||||||
}, createContext);
|
}, createContext);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
|||||||
@property({ attribute: false }) selected?: SessionInfo;
|
@property({ attribute: false }) selected?: SessionInfo;
|
||||||
@property({ type: Boolean }) canStart = false;
|
@property({ type: Boolean }) canStart = false;
|
||||||
@property({ type: Boolean }) canDeleteArchived = false;
|
@property({ type: Boolean }) canDeleteArchived = false;
|
||||||
|
@property({ type: Boolean }) canReload = false;
|
||||||
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
|
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
|
||||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||||
@property({ type: Boolean, reflect: true }) collapsed = false;
|
@property({ type: Boolean, reflect: true }) collapsed = false;
|
||||||
@@ -49,6 +50,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
|||||||
@property({ attribute: false }) onDeleteArchived?: (session: SessionInfo) => void | Promise<void>;
|
@property({ attribute: false }) onDeleteArchived?: (session: SessionInfo) => void | Promise<void>;
|
||||||
@property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise<void>;
|
@property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||||
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
|
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
|
||||||
|
@property({ attribute: false }) onReload?: (session: SessionInfo) => void;
|
||||||
|
|
||||||
@state() private openMenuSessionId: string | undefined;
|
@state() private openMenuSessionId: string | undefined;
|
||||||
@state() private menuStyle = "";
|
@state() private menuStyle = "";
|
||||||
@@ -226,6 +228,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
|||||||
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
|
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
|
||||||
`
|
`
|
||||||
: html`
|
: html`
|
||||||
|
${this.canReload ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading" : "Reload session from disk"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload</button>` : null}
|
||||||
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
|
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
|
||||||
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
|
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
|
||||||
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
|
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export class AppNavigationPanel extends LitElement {
|
|||||||
@property({ type: Boolean }) sessionsCollapsed = false;
|
@property({ type: Boolean }) sessionsCollapsed = false;
|
||||||
@property({ type: Boolean }) canStartSession = false;
|
@property({ type: Boolean }) canStartSession = false;
|
||||||
@property({ type: Boolean }) canDeleteArchivedSessions = false;
|
@property({ type: Boolean }) canDeleteArchivedSessions = false;
|
||||||
|
@property({ type: Boolean }) canReloadSessions = false;
|
||||||
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
|
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
|
||||||
@property({ attribute: false }) onShowActions?: () => void;
|
@property({ attribute: false }) onShowActions?: () => void;
|
||||||
@property({ attribute: false }) onToggleMachines?: () => void;
|
@property({ attribute: false }) onToggleMachines?: () => void;
|
||||||
@@ -61,6 +62,7 @@ export class AppNavigationPanel extends LitElement {
|
|||||||
@property({ attribute: false }) onDeleteArchivedSession?: (session: SessionInfo) => void | Promise<void>;
|
@property({ attribute: false }) onDeleteArchivedSession?: (session: SessionInfo) => void | Promise<void>;
|
||||||
@property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise<void>;
|
@property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||||
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
|
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
|
||||||
|
@property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise<void>;
|
||||||
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
|
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
|
||||||
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>;
|
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>;
|
||||||
@property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>;
|
@property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>;
|
||||||
@@ -156,6 +158,7 @@ export class AppNavigationPanel extends LitElement {
|
|||||||
.selected=${this.selectedSession}
|
.selected=${this.selectedSession}
|
||||||
.canStart=${this.canStartSession}
|
.canStart=${this.canStartSession}
|
||||||
.canDeleteArchived=${this.canDeleteArchivedSessions}
|
.canDeleteArchived=${this.canDeleteArchivedSessions}
|
||||||
|
.canReload=${this.canReloadSessions}
|
||||||
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage}
|
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage}
|
||||||
.collapsible=${this.collapsible}
|
.collapsible=${this.collapsible}
|
||||||
.collapsed=${this.sessionsCollapsed}
|
.collapsed=${this.sessionsCollapsed}
|
||||||
@@ -171,6 +174,7 @@ export class AppNavigationPanel extends LitElement {
|
|||||||
.onDeleteArchived=${(session: SessionInfo) => this.onDeleteArchivedSession?.(session)}
|
.onDeleteArchived=${(session: SessionInfo) => this.onDeleteArchivedSession?.(session)}
|
||||||
.onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)}
|
.onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)}
|
||||||
.onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)}
|
.onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)}
|
||||||
|
.onReload=${(session: SessionInfo) => this.onReloadSession?.(session)}
|
||||||
.onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }}
|
.onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }}
|
||||||
.onFocusNextSection=${() => { this.focusNextFrom("sessions"); }}
|
.onFocusNextSection=${() => { this.focusNextFrom("sessions"); }}
|
||||||
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
|
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
|
||||||
|
|||||||
@@ -487,6 +487,73 @@ describe("SessionController", () => {
|
|||||||
expect(state.error).toContain("requires an updated Pi-Web runtime");
|
expect(state.error).toContain("requires an updated Pi-Web runtime");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reloads the selected session, discards the cached transcript, and re-fetches history", async () => {
|
||||||
|
Object.defineProperty(globalThis, "localStorage", { value: new MemoryStorage(), configurable: true });
|
||||||
|
const reloadCalls: string[] = [];
|
||||||
|
const messageCalls: string[] = [];
|
||||||
|
let state: AppState = {
|
||||||
|
...initialAppState(),
|
||||||
|
selectedWorkspace: workspace,
|
||||||
|
selectedSession: oldSession,
|
||||||
|
sessions: [oldSession],
|
||||||
|
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } },
|
||||||
|
};
|
||||||
|
const api: typeof defaultApi = {
|
||||||
|
...defaultApi,
|
||||||
|
reloadSession: (session) => {
|
||||||
|
reloadCalls.push(sessionLookupId(session));
|
||||||
|
return Promise.resolve({ reloaded: true });
|
||||||
|
},
|
||||||
|
messages: (session) => {
|
||||||
|
messageCalls.push(sessionLookupId(session));
|
||||||
|
return Promise.resolve(emptyPage);
|
||||||
|
},
|
||||||
|
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||||
|
};
|
||||||
|
const controller = new SessionController(
|
||||||
|
() => state,
|
||||||
|
(patch) => { state = { ...state, ...patch }; },
|
||||||
|
() => undefined,
|
||||||
|
new InMemorySessionSelectionMemory(),
|
||||||
|
{ api, socket: new FakeSocket() },
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.reloadSession(oldSession);
|
||||||
|
|
||||||
|
expect(reloadCalls).toEqual([oldSession.id]);
|
||||||
|
expect(messageCalls).toContain(oldSession.id);
|
||||||
|
expect(state.error).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reload sessions when the selected machine runtime does not support it", async () => {
|
||||||
|
const reloadCalls: string[] = [];
|
||||||
|
let state: AppState = {
|
||||||
|
...initialAppState(),
|
||||||
|
selectedWorkspace: workspace,
|
||||||
|
selectedSession: oldSession,
|
||||||
|
sessions: [oldSession],
|
||||||
|
};
|
||||||
|
const api: typeof defaultApi = {
|
||||||
|
...defaultApi,
|
||||||
|
reloadSession: (session) => {
|
||||||
|
reloadCalls.push(sessionLookupId(session));
|
||||||
|
return Promise.resolve({ reloaded: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const controller = new SessionController(
|
||||||
|
() => state,
|
||||||
|
(patch) => { state = { ...state, ...patch }; },
|
||||||
|
() => undefined,
|
||||||
|
new InMemorySessionSelectionMemory(),
|
||||||
|
{ api, socket: new FakeSocket() },
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.reloadSession(oldSession);
|
||||||
|
|
||||||
|
expect(reloadCalls).toEqual([]);
|
||||||
|
expect(state.error).toContain("requires an updated Pi-Web runtime");
|
||||||
|
});
|
||||||
|
|
||||||
it("forgets archived selections when the archived section collapse clears selection", async () => {
|
it("forgets archived selections when the archived section collapse clears selection", async () => {
|
||||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
|
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
|
||||||
|
|||||||
@@ -379,6 +379,25 @@ export class SessionController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reloadSession(session = this.getState().selectedSession) {
|
||||||
|
if (session === undefined || isCachedNewSessionInfo(session) || session.archived === true) return;
|
||||||
|
const machineId = selectedMachineId(this.getState());
|
||||||
|
const runtime = this.getState().machineRuntimes[machineId];
|
||||||
|
if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) {
|
||||||
|
this.setState({ error: "Reloading sessions requires an updated Pi-Web runtime on this machine." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.api.reloadSession(session.id, machineId);
|
||||||
|
this.transcripts.discard(this.sessionCacheKey(session.id));
|
||||||
|
if (this.getState().selectedSession?.id === session.id) {
|
||||||
|
await this.selectSession(session, { updateUrl: false });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.setState({ error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async detachParent(session = this.getState().selectedSession) {
|
async detachParent(session = this.getState().selectedSession) {
|
||||||
if (session?.parentSessionPath === undefined) return;
|
if (session?.parentSessionPath === undefined) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { isSessionActive } from "../../../../shared/activity";
|
import { isSessionActive } from "../../../../shared/activity";
|
||||||
|
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities";
|
||||||
import type { AppState } from "../../appState";
|
import type { AppState } from "../../appState";
|
||||||
import { isCachedNewSessionInfo } from "../../cachedNewSessions";
|
import { isCachedNewSessionInfo } from "../../cachedNewSessions";
|
||||||
|
import { selectedMachineId } from "../../controllers/types";
|
||||||
import { isWorkspaceDeletionPending } from "../../workspaceDeletion";
|
import { isWorkspaceDeletionPending } from "../../workspaceDeletion";
|
||||||
import type { PluginAction } from "../types";
|
import type { PluginAction } from "../types";
|
||||||
|
|
||||||
@@ -173,6 +175,14 @@ export function createCoreActions(): PluginAction[] {
|
|||||||
enabled: hasArchivableSession,
|
enabled: hasArchivableSession,
|
||||||
run: (context) => context.archiveSession(),
|
run: (context) => context.archiveSession(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "session.reload",
|
||||||
|
title: "Reload Session",
|
||||||
|
description: "Re-read the selected session from disk to pick up entries written by another process",
|
||||||
|
group: "Session",
|
||||||
|
enabled: hasReloadableSession,
|
||||||
|
run: (context) => context.reloadSession(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "session.delete",
|
id: "session.delete",
|
||||||
title: "Delete New Session",
|
title: "Delete New Session",
|
||||||
@@ -213,3 +223,11 @@ function hasArchivableSession(context: { state: AppState }): boolean {
|
|||||||
function hasCachedNewSession(context: { state: AppState }): boolean {
|
function hasCachedNewSession(context: { state: AppState }): boolean {
|
||||||
return isCachedNewSessionInfo(context.state.selectedSession);
|
return isCachedNewSessionInfo(context.state.selectedSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasReloadableSession(context: { state: AppState }): boolean {
|
||||||
|
const session = context.state.selectedSession;
|
||||||
|
if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return false;
|
||||||
|
const runtime = context.state.machineRuntimes[selectedMachineId(context.state)];
|
||||||
|
if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) return false;
|
||||||
|
return !isSessionActive(context.state.status, context.state.activity);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { html } from "lit";
|
import { html } from "lit";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { FileContentResponse, SessionInfo, Workspace } from "../api";
|
import type { FileContentResponse, SessionInfo, SessionStatus, Workspace } from "../api";
|
||||||
import { initialAppState, type AppState } from "../appState";
|
import { initialAppState, type AppState } from "../appState";
|
||||||
import { markCachedNewSessionInfo } from "../cachedNewSessions";
|
import { markCachedNewSessionInfo } from "../cachedNewSessions";
|
||||||
|
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||||
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
|
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
|
||||||
import { corePlugin } from "./core";
|
import { corePlugin } from "./core";
|
||||||
import { PluginRegistry } from "./registry";
|
import { PluginRegistry } from "./registry";
|
||||||
@@ -42,6 +43,7 @@ function createContext(statePatch: Partial<AppState> = {}) {
|
|||||||
deleteWorkspace: vi.fn(() => { calls.push("deleteWorkspace"); }),
|
deleteWorkspace: vi.fn(() => { calls.push("deleteWorkspace"); }),
|
||||||
startSession: vi.fn(() => { calls.push("startSession"); }),
|
startSession: vi.fn(() => { calls.push("startSession"); }),
|
||||||
archiveSession: vi.fn(() => { calls.push("archiveSession"); }),
|
archiveSession: vi.fn(() => { calls.push("archiveSession"); }),
|
||||||
|
reloadSession: vi.fn(() => { calls.push("reloadSession"); }),
|
||||||
deleteCachedNewSession: vi.fn(() => { calls.push("deleteCachedNewSession"); }),
|
deleteCachedNewSession: vi.fn(() => { calls.push("deleteCachedNewSession"); }),
|
||||||
stopActiveWork: vi.fn(() => { calls.push("stopActiveWork"); }),
|
stopActiveWork: vi.fn(() => { calls.push("stopActiveWork"); }),
|
||||||
};
|
};
|
||||||
@@ -149,6 +151,35 @@ describe("PluginRegistry", () => {
|
|||||||
expect(archivedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
|
expect(archivedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("enables session 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 reloadable = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime }).context);
|
||||||
|
expect(reloadable.find((action) => action.id === "core:session.reload")?.enabled).toBe(true);
|
||||||
|
|
||||||
|
const noCapability = registry.getActions(createContext({ selectedSession: testSession() }).context);
|
||||||
|
expect(noCapability.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
|
||||||
|
|
||||||
|
const archived = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" }, machineRuntimes: reloadRuntime }).context);
|
||||||
|
expect(archived.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
|
||||||
|
|
||||||
|
const busy = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime, status: testStatus({ isStreaming: true }) }).context);
|
||||||
|
expect(busy.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes session reload through the runtime context", () => {
|
||||||
|
const registry = new PluginRegistry();
|
||||||
|
registry.register({ id: "core", plugin: corePlugin });
|
||||||
|
const { context, calls } = createContext({ selectedSession: testSession(), machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } } });
|
||||||
|
const action = registry.getActions(context).find((candidate) => candidate.id === "core:session.reload");
|
||||||
|
|
||||||
|
if (action !== undefined) void action.run();
|
||||||
|
|
||||||
|
expect(calls).toEqual(["reloadSession"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("routes browser-cached new session delete through the runtime context", () => {
|
it("routes browser-cached new session delete through the runtime context", () => {
|
||||||
const registry = new PluginRegistry();
|
const registry = new PluginRegistry();
|
||||||
registry.register({ id: "core", plugin: corePlugin });
|
registry.register({ id: "core", plugin: corePlugin });
|
||||||
@@ -568,6 +599,20 @@ function testFileContent(path = "README.md"): FileContentResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function testStatus(patch: Partial<SessionStatus> = {}): SessionStatus {
|
||||||
|
return {
|
||||||
|
sessionId: "s1",
|
||||||
|
isStreaming: false,
|
||||||
|
isCompacting: false,
|
||||||
|
isBashRunning: false,
|
||||||
|
pendingMessageCount: 0,
|
||||||
|
queuedMessages: [],
|
||||||
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
cost: 0,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function testMachine(id: string) {
|
function testMachine(id: string) {
|
||||||
return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" };
|
return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export interface PluginRuntimeContext {
|
|||||||
deleteWorkspace: (workspace?: Workspace) => void | Promise<void>;
|
deleteWorkspace: (workspace?: Workspace) => void | Promise<void>;
|
||||||
startSession: () => void | Promise<void>;
|
startSession: () => void | Promise<void>;
|
||||||
archiveSession: () => void | Promise<void>;
|
archiveSession: () => void | Promise<void>;
|
||||||
|
reloadSession: () => void | Promise<void>;
|
||||||
deleteCachedNewSession: () => void | Promise<void>;
|
deleteCachedNewSession: () => void | Promise<void>;
|
||||||
stopActiveWork: () => void | Promise<void>;
|
stopActiveWork: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,6 +425,74 @@ describe("PiSessionService", () => {
|
|||||||
await service.dispose();
|
await service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reloads a session by closing the active runtime and re-opening it from disk", async () => {
|
||||||
|
const first = fakeRuntime("reload-session");
|
||||||
|
const second = fakeRuntime("reload-session");
|
||||||
|
const runtimes = [first.runtime, second.runtime];
|
||||||
|
let createCalls = 0;
|
||||||
|
const createAgentRuntime: RuntimeCreator = async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
const runtime = runtimes[createCalls];
|
||||||
|
createCalls += 1;
|
||||||
|
if (runtime === undefined) throw new Error("unexpected runtime creation");
|
||||||
|
return runtime;
|
||||||
|
};
|
||||||
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
createAgentRuntime,
|
||||||
|
sessionManager: sessionGateway([sessionRecord("reload-session")]),
|
||||||
|
heartbeatIntervalMs: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open once so there is an active runtime to reload.
|
||||||
|
await service.status(sessionRef("reload-session"));
|
||||||
|
expect(createCalls).toBe(1);
|
||||||
|
|
||||||
|
await expect(service.reload(sessionRef("reload-session"))).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// The original runtime was torn down and a fresh one opened from disk.
|
||||||
|
expect(first.calls.abort).toBe(1);
|
||||||
|
expect(first.calls.dispose).toBe(1);
|
||||||
|
expect(createCalls).toBe(2);
|
||||||
|
expect(service.activeCount()).toBe(1);
|
||||||
|
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to reload a session that has active work in progress", async () => {
|
||||||
|
const fake = fakeRuntime("busy-session", { isStreaming: true });
|
||||||
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
|
sessionManager: sessionGateway([sessionRecord("busy-session")]),
|
||||||
|
heartbeatIntervalMs: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.reload(sessionRef("busy-session"))).rejects.toThrow("Stop current session activity before reloading");
|
||||||
|
expect(fake.calls.abort).toBe(0);
|
||||||
|
expect(fake.calls.dispose).toBe(0);
|
||||||
|
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to reload an archived session", async () => {
|
||||||
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
archiveStore: {
|
||||||
|
list: () => Promise.resolve([]),
|
||||||
|
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
|
||||||
|
? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" }
|
||||||
|
: undefined),
|
||||||
|
archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }),
|
||||||
|
restore: () => Promise.resolve(),
|
||||||
|
isArchived: () => Promise.resolve(true),
|
||||||
|
},
|
||||||
|
sessionManager: sessionGateway([]),
|
||||||
|
heartbeatIntervalMs: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.reload(sessionRef("archived"))).rejects.toThrow("Archived sessions are read-only");
|
||||||
|
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("reconciles workspace activity when listing only archived sessions", async () => {
|
it("reconciles workspace activity when listing only archived sessions", async () => {
|
||||||
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
|
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
|||||||
@@ -560,6 +560,15 @@ export class PiSessionService {
|
|||||||
await this.archiveStore.deleteArchived(record.sessionId);
|
await this.archiveStore.deleteArchived(record.sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reload(ref: PiSessionLookup): Promise<void> {
|
||||||
|
await this.assertWritable(ref);
|
||||||
|
const session = await this.getOrOpen(ref);
|
||||||
|
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
|
||||||
|
await this.closeActive(session.sessionId);
|
||||||
|
const reopened = await this.getActive(ref);
|
||||||
|
this.publishStatus(reopened.runtime.session);
|
||||||
|
}
|
||||||
|
|
||||||
async detachParent(ref: PiSessionLookup): Promise<void> {
|
async detachParent(ref: PiSessionLookup): Promise<void> {
|
||||||
const session = await this.getOrOpen(ref);
|
const session = await this.getOrOpen(ref);
|
||||||
const sessionFile = session.sessionFile;
|
const sessionFile = session.sessionFile;
|
||||||
|
|||||||
@@ -97,15 +97,62 @@ describe("session routes", () => {
|
|||||||
await routeApp.close();
|
await routeApp.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reloads a session through the reload route, forwarding workspace context", async () => {
|
||||||
|
const routeApp = Fastify({ logger: false });
|
||||||
|
await routeApp.register(fastifyWebsocket);
|
||||||
|
const eventHub = new SessionEventHub();
|
||||||
|
const routeService = new CapturingRouteSessionService(eventHub);
|
||||||
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestCwd = resolve("/repo");
|
||||||
|
const reloadResponse = await routeApp.inject({ method: "POST", url: "/sessions/session-1/reload", payload: { cwd: requestCwd } });
|
||||||
|
|
||||||
|
expect(reloadResponse.statusCode).toBe(200);
|
||||||
|
expect(reloadResponse.json()).toEqual({ reloaded: true });
|
||||||
|
expect(routeService.reloadCalls).toEqual([{ id: "session-1", cwd: requestCwd }]);
|
||||||
|
} finally {
|
||||||
|
await routeService.dispose();
|
||||||
|
await routeApp.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps reload failures to a mutation error status", async () => {
|
||||||
|
const routeApp = Fastify({ logger: false });
|
||||||
|
await routeApp.register(fastifyWebsocket);
|
||||||
|
const eventHub = new SessionEventHub();
|
||||||
|
const routeService = new CapturingRouteSessionService(eventHub);
|
||||||
|
routeService.reloadError = new Error("Stop current session activity before reloading");
|
||||||
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reloadResponse = await routeApp.inject({ method: "POST", url: "/sessions/session-1/reload", payload: {} });
|
||||||
|
|
||||||
|
expect(reloadResponse.statusCode).toBe(400);
|
||||||
|
expect(reloadResponse.json()).toEqual({ error: "Stop current session activity before reloading" });
|
||||||
|
} finally {
|
||||||
|
await routeService.dispose();
|
||||||
|
await routeApp.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
class CapturingRouteSessionService extends PiSessionService {
|
class CapturingRouteSessionService extends PiSessionService {
|
||||||
readonly calls: unknown[] = [];
|
readonly calls: unknown[] = [];
|
||||||
|
readonly reloadCalls: (string | PiSessionRef)[] = [];
|
||||||
|
reloadError: Error | undefined;
|
||||||
|
|
||||||
constructor(eventHub: SessionEventHub) {
|
constructor(eventHub: SessionEventHub) {
|
||||||
super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 });
|
super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override reload(lookup: string | PiSessionRef): Promise<void> {
|
||||||
|
this.reloadCalls.push(lookup);
|
||||||
|
if (this.reloadError !== undefined) return Promise.reject(this.reloadError);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
override status(lookup: string | PiSessionRef) {
|
override status(lookup: string | PiSessionRef) {
|
||||||
this.calls.push(lookup);
|
this.calls.push(lookup);
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
|
|||||||
@@ -230,6 +230,15 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/reload`, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
await sessions.reload(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
|
||||||
|
return { reloaded: true };
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => {
|
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
await sessions.detachParent(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
|
await sessions.detachParent(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type MachineStatus = "unknown" | "online" | "offline" | "error";
|
|||||||
|
|
||||||
export const PI_WEB_CAPABILITIES = {
|
export const PI_WEB_CAPABILITIES = {
|
||||||
sessionsDeleteArchived: "sessions.deleteArchived",
|
sessionsDeleteArchived: "sessions.deleteArchived",
|
||||||
|
sessionsReload: "sessions.reload",
|
||||||
promptAttachments: "prompt.attachments",
|
promptAttachments: "prompt.attachments",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ export type { PiWebCapability };
|
|||||||
export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES);
|
export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES);
|
||||||
const knownPiWebCapabilities: ReadonlySet<string> = new Set(KNOWN_PI_WEB_CAPABILITIES);
|
const knownPiWebCapabilities: ReadonlySet<string> = new Set(KNOWN_PI_WEB_CAPABILITIES);
|
||||||
|
|
||||||
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
|
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
|
||||||
export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
|
export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
|
||||||
|
|
||||||
const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
|
const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
|
||||||
[PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"],
|
[PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"],
|
||||||
|
[PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
|
||||||
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
|
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
|
||||||
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
|
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user