feat: apply agent profile settings atomically

This commit is contained in:
Federico Jaramillo Martinez
2026-07-14 00:11:39 +02:00
parent adc2e297a4
commit 8b5ccc2fd9
33 changed files with 794 additions and 163 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+6 -2
View File
@@ -79,12 +79,16 @@ describe("machine-scoped runtime API", () => {
});
it("reads machine runtime through the gateway route", async () => {
const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
const response = { machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] };
const fetchMock = stubSequenceFetch([jsonResponse(response), jsonResponse(response)]);
await machinesApi.runtime("remote a");
await machinesApi.runtime("remote a", true);
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime");
expect(fetchCall(fetchMock, 1)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime?refresh=1");
expect(fetchCall(fetchMock, 1)[1]?.cache).toBe("no-store");
});
});
+1 -1
View File
@@ -115,7 +115,7 @@ export const machinesApi = {
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
deleteMachine: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
health: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
runtime: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
runtime: (machineId: string, refresh = false) => request(`api/machines/${encodeURIComponent(machineId)}/runtime${refresh ? "?refresh=1" : ""}`, parseMachineRuntime, refresh ? { cache: "no-store" } : {}),
};
function configPath(machineId?: string): string {
+65 -6
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("parses PI WEB config responses", () => {
@@ -9,26 +9,85 @@ describe("API parsers", () => {
exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
})).toEqual({
path: "/tmp/config.json",
exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
});
});
it("parses PI WEB runtime responses", () => {
it("parses PI WEB runtime responses including the daemon-owned active profile", () => {
expect(parsePiWebRuntimeResponse({
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
sessiond: {
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.0.0",
available: true,
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
activeAgentProfile: {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command: "agent-lab",
dir: "/srv/agent-lab",
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
},
},
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
})).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
})).toMatchObject({
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage],
components: { sessiond: { activeAgentProfile: { command: "agent-lab", dir: "/srv/agent-lab" } } },
});
});
it("retains portable active profiles in machine runtime snapshots and rejects invalid ownership", () => {
const profile = {
schemaVersion: 1,
revision: `sha256:${"b".repeat(64)}`,
command: "C:\\tools\\pi.exe",
dir: "C:\\agent-profiles\\work",
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
const components = {
web: { component: "web", label: "Web/UI", available: true, capabilities: [] },
sessiond: { component: "sessiond", label: "Session daemon", available: true, capabilities: [], activeAgentProfile: profile },
};
const parsed = parseMachineRuntime({ machineId: "remote-a", ok: true, checkedAt: "now", components, capabilities: [] });
expect(parsed.components?.sessiond.activeAgentProfile).toMatchObject({ command: profile.command, dir: profile.dir });
expect(Object.isFrozen(parsed.components?.sessiond.activeAgentProfile)).toBe(true);
expect(() => parseMachineRuntime({
machineId: "remote-a",
ok: true,
checkedAt: "now",
components: { ...components, web: { ...components.web, activeAgentProfile: profile } },
capabilities: [],
})).toThrow("Invalid active agent profile descriptor");
expect(() => parseMachineRuntime({
machineId: "remote-a",
ok: true,
checkedAt: "now",
components: { ...components, sessiond: { ...components.sessiond, activeAgentProfile: { ...profile, token: "secret" } } },
capabilities: [],
})).toThrow("Invalid active agent profile descriptor");
});
it("rejects malformed agent directory override metadata", () => {
expect(() => parsePiWebConfigResponse({
path: "/tmp/config.json",
exists: true,
config: {},
effectiveConfig: {},
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentDirSource: "future" },
})).toThrow("Invalid PI WEB agentDirSource field");
});
it("parses Pi package list and mutation responses", () => {
+16 -2
View File
@@ -1,5 +1,6 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -647,10 +648,18 @@ function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
subsessions: requireBoolean(record, "subsessions"),
agentCommand: optionalBoolean(record, "agentCommand") ?? false,
agentDir: optionalBoolean(record, "agentDir") ?? false,
...optionalAgentDirSource(record),
agentSessionDir: optionalBoolean(record, "agentSessionDir") ?? false,
};
}
function optionalAgentDirSource(record: Record<string, unknown>): { agentDirSource?: PiWebAgentDirEnvSource } {
const value = record["agentDirSource"];
if (value === undefined) return {};
if (value !== "pi-web" && value !== "pi-compatibility") throw new Error("Invalid PI WEB agentDirSource field");
return { agentDirSource: value };
}
export function parsePiPackagesResponse(value: unknown): PiPackagesResponse {
const record = requireRecord(value);
return { packages: arrayOf(parsePiPackageInfo)(record["packages"]) };
@@ -752,12 +761,17 @@ function parsePiWebRuntimeComponents(value: unknown): PiWebRuntimeResponse["comp
function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponent {
const record = requireRecord(value);
const component = parsePiWebServiceComponent(record["component"]);
const activeAgentProfileValue = record["activeAgentProfile"];
const activeAgentProfile = activeAgentProfileValue === undefined ? undefined : parseActiveAgentProfileDescriptor(activeAgentProfileValue);
if (activeAgentProfileValue !== undefined && (component !== "sessiond" || activeAgentProfile === undefined)) throw new Error("Invalid active agent profile descriptor");
return {
component: parsePiWebServiceComponent(record["component"]),
component,
label: requireString(record, "label"),
...optionalField("runtimeVersion", optionalString(record, "runtimeVersion")),
available: requireBoolean(record, "available"),
capabilities: parsePiWebCapabilities(record["capabilities"]),
...optionalField("activeAgentProfile", activeAgentProfile),
...optionalField("error", optionalString(record, "error")),
};
}
+1 -1
View File
@@ -1947,7 +1947,7 @@ export class PiWebApp extends LitElement {
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
${this.sessionCleanupDialog !== undefined ? html`<session-cleanup-dialog .canCleanup=${this.canCleanupSessions()} .unavailableMessage=${this.sessionCleanupUnavailableMessage()} .preview=${this.sessionCleanupDialog.preview} .previewRequest=${this.sessionCleanupDialog.previewRequest} .result=${this.sessionCleanupDialog.result} .loading=${this.sessionCleanupDialog.loading === true} .running=${this.sessionCleanupDialog.running === true} .error=${this.sessionCleanupDialog.error ?? ""} .onPreview=${(request: SessionCleanupRequest) => { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}></session-cleanup-dialog>` : null}
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }} .onRefreshMachineRuntime=${(machineId: string) => this.machines.refreshMachineRuntime(machineId)}></settings-dialog>` : null}
</div>
`;
}
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api";
import { SettingsDialog } from "./SettingsDialog";
import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement as runtimeWithoutSelectedMachineSettings, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
@@ -40,6 +41,21 @@ describe("settings-dialog session daemon machine targeting", () => {
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
});
it("reloads desired config and the active runtime descriptor together", async () => {
const config = configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } });
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const runtimeRefresh = vi.fn(() => Promise.resolve());
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.onRefreshMachineRuntime = runtimeRefresh;
await callDialogPromise(dialog, "reloadSessiondState");
expect(configSpy).toHaveBeenCalledWith(remoteMachine.id);
expect(runtimeRefresh).toHaveBeenCalledWith(remoteMachine.id);
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config);
});
it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => {
stubWindowTimers();
const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false });
@@ -57,6 +73,43 @@ describe("settings-dialog session daemon machine targeting", () => {
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("fails closed for a remote agent-profile save without granular support", async () => {
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } }));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.machineRuntime = {
machineId: remoteMachine.id,
ok: true,
checkedAt: "now",
capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings],
};
await callDialogPromise(dialog, "saveSessiondConfig", { agent: { command: "agent-lab", dir: "/srv/agent-lab" } });
expect(saveSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "sessiondError")).toBe("Agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
});
it("saves a remote agent profile when granular support is advertised", async () => {
stubWindowTimers();
const patch = { agent: { command: "agent-lab", dir: "/srv/agent-lab" } };
const saved = configResponse(patch);
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(saved);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.machineRuntime = {
machineId: remoteMachine.id,
ok: true,
checkedAt: "now",
capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig],
};
await callDialogPromise(dialog, "saveSessiondConfig", patch);
expect(saveSpy).toHaveBeenCalledWith(patch, remoteMachine.id);
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(saved);
});
it("ignores stale session-daemon load responses after the selected machine changes", async () => {
const load = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
+24 -3
View File
@@ -11,7 +11,7 @@ import "./settings/SettingsShortcutsPanel";
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageManagementSupport, piPackageManagementSupportKey, piPackageMutationFollowUpMessage, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
import { loadGatewaySettingsData, loadPiPackagesData } from "./settings/settingsDataLoading";
import { mergeSelectedMachineAccessConfig } from "./settings/settingsMachineAccessConfig";
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget";
import { agentProfileSettingsSupport, friendlySelectedMachineSettingsErrorMessage, isAgentProfileSettingsSupported, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type AgentProfileSettingsSupport, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget";
import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settings/settingsPluginConfig";
import { mergeSelectedMachineSessiondConfig } from "./settings/settingsSessiondConfig";
@@ -24,6 +24,7 @@ export class SettingsDialog extends LitElement {
@property({ attribute: false }) onNavigate?: (section: SettingsSection) => void;
@property({ attribute: false }) onClose?: () => void;
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
@property({ attribute: false }) onRefreshMachineRuntime?: (machineId: string) => void | Promise<void>;
@state() private configResponse: PiWebConfigResponse | undefined;
@state() private accessConfigResponse: PiWebConfigResponse | undefined;
@state() private sessiondConfigResponse: PiWebConfigResponse | undefined;
@@ -57,7 +58,7 @@ export class SettingsDialog extends LitElement {
super.connectedCallback();
void this.loadConfig();
void this.loadAccessConfigForTarget();
void this.loadSessiondConfigForTarget();
void this.reloadSessiondState();
void this.loadPluginsForTarget();
void this.loadPackagesForTarget();
}
@@ -137,7 +138,9 @@ export class SettingsDialog extends LitElement {
.error=${this.sessiondError}
.savedMessage=${this.savedMessage}
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
.onReload=${() => this.loadSessiondConfigForTarget()}
.activeAgentProfile=${this.machineRuntime?.components?.sessiond.activeAgentProfile}
.agentProfileSupport=${this.agentProfileSettingsSupport()}
.onReload=${() => this.reloadSessiondState()}
.onSave=${(config: PiWebConfigValues) => this.saveSessiondConfig(config)}
></settings-sessiond-panel>
`;
@@ -264,6 +267,13 @@ export class SettingsDialog extends LitElement {
}
}
private async reloadSessiondState(target = this.settingsTarget()): Promise<void> {
await Promise.all([
this.loadSessiondConfigForTarget(target),
this.onRefreshMachineRuntime?.(target.id),
]);
}
private async loadSessiondConfigForTarget(target = this.settingsTarget()): Promise<void> {
const requestSeq = ++this.sessiondLoadRequestSeq;
const support = this.selectedMachineSettingsSupport(target);
@@ -424,6 +434,13 @@ export class SettingsDialog extends LitElement {
this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
if (config.agent !== undefined) {
const profileSupport = this.agentProfileSettingsSupport(target);
if (!isAgentProfileSettingsSupported(profileSupport)) {
this.sessiondError = profileSupport.message ?? `Agent profile settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
}
this.saving = true;
this.sessiondError = "";
this.savedMessage = "";
@@ -521,6 +538,10 @@ export class SettingsDialog extends LitElement {
return selectedMachineSettingsSupport(target, this.machineRuntime);
}
private agentProfileSettingsSupport(target = this.settingsTarget()): AgentProfileSettingsSupport {
return agentProfileSettingsSupport(target, this.machineRuntime);
}
private selectedMachineSettingsSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: SettingsMachineTarget): boolean {
const previousSupport = selectedMachineSettingsSupport(target, previousRuntime);
const currentSupport = this.selectedMachineSettingsSupport(target);
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { TemplateResult } from "lit";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { SettingsSessiondPanel } from "./SettingsSessiondPanel";
import type { SettingsNotice } from "./SettingsPanelFrame";
@@ -8,11 +8,12 @@ describe("settings-sessiond-panel layout", () => {
it("names the selected machine in the scope and restart notice when config is available", () => {
const panel = new SettingsSessiondPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.configResponse = configResponse({
setPanelConfig(panel, configResponse({
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
spawnSessions: true,
subsessions: false,
});
}));
panel.activeAgentProfile = activeProfile("pi", "/srv/pi");
const rendered = flattenTemplateContent(panel.render());
@@ -20,10 +21,10 @@ describe("settings-sessiond-panel layout", () => {
"Session daemon",
"These settings affect the long-lived session runtime on Lab Mac (remote machine).",
"Reload",
"Restart required on Lab Mac (remote machine)",
"run <code>pi-web restart</code> on that machine",
"Agent profile restart required on Lab Mac (remote machine)",
"Run <code>pi-web restart</code> on that machine",
"Config file",
"Agent command for diagnostics",
"Companion CLI command",
"agent-lab",
"Agent state directory",
"/srv/agent-lab",
@@ -33,7 +34,8 @@ describe("settings-sessiond-panel layout", () => {
it("orders save/load notices before the restart notice and settings content", () => {
const panel = new SettingsSessiondPanel();
panel.configResponse = configResponse({ spawnSessions: false });
setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" }, spawnSessions: false }));
panel.activeAgentProfile = activeProfile("pi", "/srv/pi");
panel.error = "Failed to save session-daemon config.";
panel.savedMessage = "Session daemon settings saved.";
@@ -42,11 +44,55 @@ describe("settings-sessiond-panel layout", () => {
expectTextOrder(rendered, [
"Failed to save session-daemon config.",
"Session daemon settings saved.",
"Restart required on local (local gateway)",
"Agent profile restart required on local (local gateway)",
"Config file",
]);
});
it("shows the profile as active without restart guidance when desired and active match", () => {
const panel = new SettingsSessiondPanel();
setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } }));
panel.activeAgentProfile = activeProfile("agent-lab", "/srv/agent-lab");
const rendered = flattenTemplateContent(panel.render());
expect(rendered).toContain("Profile status");
expect(rendered).toContain("Active");
expect(rendered).not.toContain("restart required on");
});
it("submits command and directory together as one profile save", async () => {
const panel = new SettingsSessiondPanel();
const onSave = vi.fn();
setPanelConfig(panel, configResponse({ agent: { command: "pi", dir: "/srv/pi" } }));
setPanelProperty(panel, "agentDraft", { command: " alternate-agent ", dir: " /srv/alternate " });
panel.onSave = onSave;
const event = new Event("submit", { cancelable: true });
await callPanelPromise(panel, "saveAgentProfile", event);
expect(event.defaultPrevented).toBe(true);
expect(onSave.mock.calls).toEqual([[{ agent: { command: "alternate-agent", dir: "/srv/alternate" } }]]);
});
it("preserves a dirty profile draft when an unrelated daemon setting is saved", () => {
const panel = new SettingsSessiondPanel();
const initial = configResponse({ agent: { command: "pi", dir: "/srv/pi" }, spawnSessions: false });
setPanelConfig(panel, initial);
callPanelMethod(panel, "updateAgentDraft", { command: "alternate-agent", dir: "/srv/alternate" });
const toggled = configResponse({ agent: { command: "pi", dir: "/srv/pi" }, spawnSessions: true });
panel.configResponse = toggled;
callPanelMethod(panel, "willUpdate", new Map([["configResponse", initial]]));
expect(Reflect.get(panel, "agentDraft")).toEqual({ command: "alternate-agent", dir: "/srv/alternate" });
const saved = configResponse({ agent: { command: "alternate-agent", dir: "/srv/alternate" }, spawnSessions: true });
panel.configResponse = saved;
callPanelMethod(panel, "willUpdate", new Map([["configResponse", toggled]]));
expect(Reflect.get(panel, "agentDraftDirty")).toBe(false);
});
it("shows one blocked content state without restart guidance or toggles when config is unavailable", () => {
const panel = new SettingsSessiondPanel();
panel.targetLabel = "Lab Mac (remote machine)";
@@ -65,6 +111,37 @@ describe("settings-sessiond-panel layout", () => {
});
});
function activeProfile(command: string, dir: string): ActiveAgentProfileDescriptor {
return {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command,
dir,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
}
function setPanelConfig(panel: SettingsSessiondPanel, config: PiWebConfigResponse): void {
panel.configResponse = config;
callPanelMethod(panel, "willUpdate", new Map([["configResponse", undefined]]));
}
function setPanelProperty(panel: SettingsSessiondPanel, property: string, value: unknown): void {
if (!Reflect.set(panel, property, value)) throw new Error(`Failed to set SettingsSessiondPanel property ${property}`);
}
async function callPanelPromise(panel: SettingsSessiondPanel, methodName: string, ...args: readonly unknown[]): Promise<void> {
const result = callPanelMethod(panel, methodName, ...args);
if (!(result instanceof Promise)) throw new Error(`SettingsSessiondPanel.${methodName} did not return a promise`);
await result;
}
function callPanelMethod(panel: SettingsSessiondPanel, methodName: string, ...args: readonly unknown[]): unknown {
const method: unknown = Reflect.get(panel, methodName);
if (typeof method !== "function") throw new Error(`SettingsSessiondPanel.${methodName} is not callable`);
return Reflect.apply(method, panel, args);
}
function flattenTemplateContent(template: TemplateResult): string {
const chunks: string[] = [];
visitTemplate(template);
@@ -1,9 +1,11 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
import "./SettingsPanelFrame";
import type { SettingsNotice } from "./SettingsPanelFrame";
import { agentFieldConfigPatch, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
import { agentProfileConfigPatchFromDraft, agentProfileDraftFromConfig, agentProfileDraftMatchesConfig, emptyAgentProfileConfigDraft, type AgentProfileConfigDraft } from "./settingsConfigDraft";
import type { AgentProfileSettingsSupport } from "./settingsMachineTarget";
import { agentDirFieldOverridden, agentProfileActivationState, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
@customElement("settings-sessiond-panel")
export class SettingsSessiondPanel extends LitElement {
@@ -13,8 +15,28 @@ export class SettingsSessiondPanel extends LitElement {
@property() error = "";
@property() savedMessage = "";
@property() targetLabel = "local (local gateway)";
@property({ attribute: false }) activeAgentProfile: ActiveAgentProfileDescriptor | undefined;
@property({ attribute: false }) agentProfileSupport: AgentProfileSettingsSupport = { state: "supported" };
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
@state() private agentDraft: AgentProfileConfigDraft = emptyAgentProfileConfigDraft();
@state() private agentDraftDirty = false;
@state() private agentLocalError = "";
protected override willUpdate(changed: PropertyValues<this>): void {
if (!changed.has("configResponse")) return;
if (this.configResponse === undefined) {
this.agentDraft = emptyAgentProfileConfigDraft();
this.agentDraftDirty = false;
this.agentLocalError = "";
return;
}
if (!this.agentDraftDirty || agentProfileDraftMatchesConfig(this.agentDraft, this.configResponse.config)) {
this.agentDraft = agentProfileDraftFromConfig(this.configResponse.config);
this.agentDraftDirty = false;
this.agentLocalError = "";
}
}
override render(): TemplateResult {
const config = this.configResponse;
@@ -26,8 +48,12 @@ export class SettingsSessiondPanel extends LitElement {
// Beta, off by default; also requires spawn to be enabled.
const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn;
const agentCommandOverridden = config?.envOverrides.agentCommand === true;
const agentDirOverridden = config?.envOverrides.agentDir === true;
const profileEditingSupported = this.agentProfileSupport.state === "supported";
const draftCommand = agentCommandOverridden ? (config.effectiveConfig.agent?.command ?? this.agentDraft.command) : this.agentDraft.command;
const agentDirLocked = agentDirFieldOverridden(config?.envOverrides, draftCommand);
const effectiveAgentDirOverridden = config?.envOverrides.agentDir === true;
const effectiveAgent = config?.effectiveConfig.agent;
const profileActivation = agentProfileActivationState(config, this.activeAgentProfile);
return html`
<settings-panel-frame
heading="Session daemon"
@@ -42,40 +68,46 @@ export class SettingsSessiondPanel extends LitElement {
<span>Config file</span>
<code>${config.path}</code>
</div>
<label class="field">
<span class="field-heading">
<span>Agent command for diagnostics</span>
${agentCommandOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<input
class="text-input"
type="text"
autocomplete="off"
spellcheck="false"
.value=${config.config.agent?.command ?? ""}
placeholder="pi"
?disabled=${this.loading || this.saving || agentCommandOverridden}
@change=${(event: Event) => { void this.saveAgentField("command", event); }}
>
<small>Set an alternate Pi-compatible CLI when doctor/update checks should target a different command. The embedded session runtime remains PI WEB's SDK path, so this does not dynamically load a different agent implementation.</small>
</label>
<label class="field">
<span class="field-heading">
<span>Agent state directory</span>
${agentDirOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<input
class="text-input"
type="text"
autocomplete="off"
spellcheck="false"
.value=${config.config.agent?.dir ?? ""}
placeholder="~/.pi/agent or ~/agent-profiles/work"
?disabled=${this.loading || this.saving || agentDirOverridden}
@change=${(event: Event) => { void this.saveAgentField("dir", event); }}
>
<small>Choose which compatible auth, models, settings, and sessions PI WEB reads. Non-<code>pi</code> commands require an explicit state directory, then a session daemon restart.</small>
</label>
<form class="profile-form" aria-label="Pi-compatible agent profile" @submit=${(event: Event) => { void this.saveAgentProfile(event); }}>
${profileEditingSupported ? null : html`<div class="profile-support-message">${this.agentProfileSupport.message ?? "Agent profile editing is unavailable for this machine."}</div>`}
<label class="field">
<span class="field-heading">
<span>Companion CLI command</span>
${agentCommandOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<input
class="text-input"
type="text"
autocomplete="off"
spellcheck="false"
.value=${this.agentDraft.command}
placeholder="pi"
?disabled=${this.loading || this.saving || !profileEditingSupported || agentCommandOverridden}
@input=${(event: Event) => { this.updateAgentDraft({ command: inputValue(event) }); }}
>
<small>Set the Pi-compatible companion CLI used for doctor and update checks. The embedded session runtime remains PI WEB's bundled Pi SDK.</small>
</label>
<label class="field">
<span class="field-heading">
<span>Agent state directory</span>
${effectiveAgentDirOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<input
class="text-input"
type="text"
autocomplete="off"
spellcheck="false"
.value=${this.agentDraft.dir}
placeholder="~/.pi/agent or ~/agent-profiles/work"
?disabled=${this.loading || this.saving || !profileEditingSupported || agentDirLocked}
@input=${(event: Event) => { this.updateAgentDraft({ dir: inputValue(event) }); }}
>
<small>Choose the compatible auth, models, settings, and sessions PI WEB reads. An alternate command and its required state directory are saved together.</small>
</label>
<footer class="form-actions">
<button class="primary" type="submit" ?disabled=${this.loading || this.saving || !profileEditingSupported || (agentCommandOverridden && agentDirLocked)}>${this.saving ? "Saving…" : "Save agent profile"}</button>
</footer>
</form>
<div class="field">
<span class="field-heading">
<span>Allow agents to start sessions</span>
@@ -109,11 +141,14 @@ export class SettingsSessiondPanel extends LitElement {
</label>
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
</div>
<section class="effective-card" aria-label="Effective configuration summary">
<h3>Effective after environment overrides</h3>
<section class="effective-card" aria-label="Desired and active session daemon configuration summary">
<h3>Desired after environment overrides</h3>
<dl>
<div><dt>Agent command</dt><dd>${effectiveAgent?.command ?? html`<span class="muted">pi default</span>`}</dd></div>
<div><dt>Agent state</dt><dd>${effectiveAgent?.dir ?? html`<span class="muted">~/.pi/agent default</span>`}</dd></div>
<div><dt>Desired command</dt><dd>${effectiveAgent?.command ?? html`<span class="muted">Unavailable</span>`}</dd></div>
<div><dt>Desired state</dt><dd>${effectiveAgent?.dir ?? html`<span class="muted">Unavailable</span>`}</dd></div>
<div><dt>Active command</dt><dd>${this.activeAgentProfile?.command ?? html`<span class="muted">Unavailable</span>`}</dd></div>
<div><dt>Active state</dt><dd>${this.activeAgentProfile?.dir ?? html`<span class="muted">Unavailable</span>`}</dd></div>
<div><dt>Profile status</dt><dd>${profileActivationLabel(profileActivation)}</dd></div>
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
<div><dt>Subsessions</dt><dd>${effectiveSubsessions ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
</dl>
@@ -125,13 +160,21 @@ export class SettingsSessiondPanel extends LitElement {
private panelNotices(config: PiWebConfigResponse | undefined): readonly SettingsNotice[] {
const notices: SettingsNotice[] = [];
if (this.error !== "") notices.push({ type: "error", content: this.error });
const error = this.agentLocalError || this.error;
if (error !== "") notices.push({ type: "error", content: error });
if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage });
if (config !== undefined) {
const activation = agentProfileActivationState(config, this.activeAgentProfile);
if (activation === "restart-required") {
notices.push({
type: "warning",
title: `Restart required on ${this.targetLabel}`,
content: html`run <code>pi-web restart</code> on that machine (or restart its session daemon service) after changing these settings.`,
title: `Agent profile restart required on ${this.targetLabel}`,
content: html`The desired profile differs from the active session-daemon profile. Run <code>pi-web restart</code> on that machine (or restart its session daemon service) to apply the command and state directory together.`,
});
} else if (config !== undefined && activation === "unavailable" && this.agentProfileSupport.state === "supported") {
notices.push({
type: "info",
title: `Active agent profile unavailable on ${this.targetLabel}`,
content: "PI WEB cannot compare the desired profile with the running session daemon. Reload after the daemon is available.",
});
}
return notices;
@@ -141,9 +184,20 @@ export class SettingsSessiondPanel extends LitElement {
return html`<div class="loading-card">${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}</div>`;
}
private async saveAgentField(field: "command" | "dir", event: Event): Promise<void> {
if (!(event.target instanceof HTMLInputElement)) return;
await this.onSave?.(agentFieldConfigPatch(this.configResponse?.config ?? {}, field, event.target.value));
private async saveAgentProfile(event: Event): Promise<void> {
event.preventDefault();
this.agentLocalError = "";
try {
await this.onSave?.(agentProfileConfigPatchFromDraft(this.agentDraft));
} catch (error) {
this.agentLocalError = errorMessage(error);
}
}
private updateAgentDraft(patch: Partial<AgentProfileConfigDraft>): void {
this.agentDraft = { ...this.agentDraft, ...patch };
this.agentDraftDirty = true;
this.agentLocalError = "";
}
private async toggleSpawnSessions(event: Event): Promise<void> {
@@ -162,9 +216,13 @@ export class SettingsSessiondPanel extends LitElement {
button, input { font: inherit; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
button:disabled { opacity: .55; cursor: not-allowed; }
.loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.loading-card, .config-path-card, .effective-card, .profile-support-message { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.loading-card { color: var(--pi-muted); }
.config-path-card { display: grid; gap: 5px; }
.profile-form { display: grid; gap: 14px; }
.profile-support-message { color: var(--pi-muted); line-height: 1.45; }
.form-actions { display: flex; justify-content: flex-end; }
.primary { border-color: var(--pi-accent); background: var(--pi-accent); color: var(--pi-accent-contrast); }
.config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
.field { display: grid; gap: 7px; }
@@ -201,6 +259,20 @@ export class SettingsSessiondPanel extends LitElement {
`;
}
function profileActivationLabel(state: ReturnType<typeof agentProfileActivationState>): string | TemplateResult {
if (state === "active") return "Active";
if (state === "restart-required") return "Restart required";
return html`<span class="muted">Unavailable</span>`;
}
function inputValue(event: Event): string {
return event.target instanceof HTMLInputElement ? event.target.value : "";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function sessiondDescription(targetLabel: string): string {
return `These settings affect the long-lived session runtime on ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`;
}
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import {
agentProfileConfigPatchFromDraft,
agentProfileDraftFromConfig,
agentProfileDraftMatchesConfig,
gatewayServerConfigFromDraft,
gatewayServerDraftFromConfig,
machineAccessConfigPatchFromDraft,
@@ -29,6 +32,22 @@ describe("settings config drafts", () => {
expect(gatewayServerDraftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
});
it("builds one atomic agent profile patch from both draft fields", () => {
expect(agentProfileDraftFromConfig({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } })).toEqual({
command: "agent-lab",
dir: "/srv/agent-lab",
});
expect(agentProfileConfigPatchFromDraft({ command: " alternate-agent ", dir: " /srv/alternate-agent " })).toEqual({
agent: { command: "alternate-agent", dir: "/srv/alternate-agent" },
});
expect(agentProfileConfigPatchFromDraft({ command: " ", dir: " " })).toEqual({ agent: {} });
expect(agentProfileConfigPatchFromDraft({ command: " C:\\tools\\pi.exe ", dir: " C:\\agent-profiles\\work " })).toEqual({
agent: { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\work" },
});
expect(agentProfileDraftMatchesConfig({ command: " agent-lab ", dir: " /srv/agent-lab " }, { agent: { command: "agent-lab", dir: "/srv/agent-lab" } })).toBe(true);
expect(agentProfileDraftMatchesConfig({ command: "agent-lab", dir: "/draft" }, { agent: { command: "agent-lab", dir: "/saved" } })).toBe(false);
});
it("builds gateway server saves without dropping preserved config values", () => {
expect(gatewayServerConfigFromDraft({
host: " gateway.local ",
@@ -12,6 +12,11 @@ export interface MachineAccessConfigDraft {
uploadDefaultFolder: string;
}
export interface AgentProfileConfigDraft {
command: string;
dir: string;
}
export function emptyGatewayServerConfigDraft(): GatewayServerConfigDraft {
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
}
@@ -20,6 +25,10 @@ export function emptyMachineAccessConfigDraft(): MachineAccessConfigDraft {
return { allowedPathsText: "", uploadDefaultFolder: "" };
}
export function emptyAgentProfileConfigDraft(): AgentProfileConfigDraft {
return { command: "", dir: "" };
}
export function gatewayServerDraftFromConfig(config: PiWebConfigValues): GatewayServerConfigDraft {
return {
host: config.host ?? "",
@@ -36,6 +45,30 @@ export function machineAccessDraftFromConfig(config: PiWebConfigValues): Machine
};
}
export function agentProfileDraftFromConfig(config: PiWebConfigValues): AgentProfileConfigDraft {
return {
command: config.agent?.command ?? "",
dir: config.agent?.dir ?? "",
};
}
export function agentProfileConfigPatchFromDraft(draft: AgentProfileConfigDraft): PiWebConfigValues {
const command = draft.command.trim();
const dir = draft.dir.trim();
return {
agent: {
...(command === "" ? {} : { command }),
...(dir === "" ? {} : { dir }),
},
};
}
export function agentProfileDraftMatchesConfig(draft: AgentProfileConfigDraft, config: PiWebConfigValues): boolean {
const normalizedDraft = agentProfileConfigPatchFromDraft(draft).agent ?? {};
const configured = config.agent ?? {};
return normalizedDraft.command === configured.command && normalizedDraft.dir === configured.dir;
}
export function gatewayServerConfigFromDraft(draft: GatewayServerConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
const config = preservedGatewayConfigRemainder(baseConfig);
const host = draft.host.trim();
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { Machine, MachineRuntime } from "../../api";
import { PI_WEB_CAPABILITIES } from "../../../../shared/capabilities";
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, selectedMachineSettingsUnavailableMessage, settingsMachineTarget, settingsMachineTargetLabel } from "./settingsMachineTarget";
import { agentProfileSettingsSupport, friendlySelectedMachineSettingsErrorMessage, isAgentProfileSettingsSupported, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, selectedMachineSettingsUnavailableMessage, settingsMachineTarget, settingsMachineTargetLabel } from "./settingsMachineTarget";
const remoteMachine: Machine = {
id: "remote-a",
@@ -39,6 +39,27 @@ describe("selected-machine settings target helpers", () => {
expect(selectedMachineSettingsSupportKey(unsupported)).toBe(`unsupported:${selectedMachineSettingsUnavailableMessage(target)}`);
});
it("gates remote agent profile edits on their granular capability", () => {
const target = settingsMachineTarget(remoteMachine);
expect(agentProfileSettingsSupport({ id: "local", name: "local", kind: "local" }, undefined)).toEqual({ state: "supported" });
expect(agentProfileSettingsSupport(target, undefined)).toEqual({
state: "unknown",
message: "Agent profile support could not be verified on Lab Mac. Reload machine status before changing the profile.",
});
expect(agentProfileSettingsSupport(target, {
ok: true,
capabilities: [PI_WEB_CAPABILITIES.agentProfileConfig],
})).toEqual({ state: "supported" });
const unsupported = agentProfileSettingsSupport(target, { ok: true, capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings] });
expect(isAgentProfileSettingsSupported(unsupported)).toBe(false);
expect(unsupported).toEqual({
state: "unsupported",
message: "Agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.",
});
});
it("turns older remote config route failures into selected-machine compatibility guidance", () => {
const target = settingsMachineTarget(remoteMachine);
@@ -14,6 +14,8 @@ export interface SelectedMachineSettingsSupport {
message?: string;
}
export type AgentProfileSettingsSupport = SelectedMachineSettingsSupport;
export function settingsMachineTarget(machine: Pick<Machine, "id" | "name" | "kind"> | undefined): SettingsMachineTarget {
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
return { id: "local", name: "local", kind: "local" };
@@ -30,6 +32,21 @@ export function selectedMachineSettingsSupport(target: SettingsMachineTarget, ru
return { state: "unsupported", message: selectedMachineSettingsUnavailableMessage(target) };
}
export function agentProfileSettingsSupport(target: SettingsMachineTarget, runtime: Pick<MachineRuntime, "ok" | "capabilities"> | undefined): AgentProfileSettingsSupport {
if (target.kind === "local") return { state: "supported" };
if (runtime?.ok !== true) {
return {
state: "unknown",
message: `Agent profile support could not be verified on ${target.name}. Reload machine status before changing the profile.`,
};
}
if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.agentProfileConfig)) return { state: "supported" };
return {
state: "unsupported",
message: `Agent profile settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`,
};
}
export function selectedMachineSettingsSupportKey(support: SelectedMachineSettingsSupport): string {
return `${support.state}:${support.message ?? ""}`;
}
@@ -38,6 +55,10 @@ export function isSelectedMachineSettingsUnsupported(support: SelectedMachineSet
return support?.state === "unsupported";
}
export function isAgentProfileSettingsSupported(support: AgentProfileSettingsSupport | undefined): boolean {
return support?.state === "supported";
}
export function selectedMachineSettingsUnavailableMessage(target: SettingsMachineTarget): string {
return `Selected-machine settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`;
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { agentFieldConfigPatch, mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { agentDirFieldOverridden, agentProfileActivationState, mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
describe("session daemon settings config helpers", () => {
it("builds daemon-only save patches for the sessiond toggles", () => {
@@ -8,20 +8,37 @@ describe("session daemon settings config helpers", () => {
expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true });
});
it("builds agent-only patches while preserving sibling agent fields", () => {
const base = {
host: "127.0.0.1",
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
};
it("compares the desired effective profile with the daemon-owned active profile", () => {
const config = configResponse(
{ agent: { command: "configured-agent", dir: "/configured" } },
{},
{ agent: { command: "effective-agent", dir: "/effective" } },
);
expect(agentFieldConfigPatch(base, "command", " alternate-agent ")).toEqual({
agent: { command: "alternate-agent", dir: "/srv/agent-lab" },
});
expect(agentFieldConfigPatch(base, "dir", " ")).toEqual({
agent: { command: "agent-lab" },
});
expect(agentFieldConfigPatch({ agent: { dir: "/srv/agent-lab" } }, "dir", "")).toEqual({ agent: {} });
expect(agentFieldConfigPatch(base, "command", "agent-lab")).not.toHaveProperty("host");
expect(agentProfileActivationState(config, activeProfile("effective-agent", "/effective"))).toBe("active");
expect(agentProfileActivationState(config, activeProfile("other-agent", "/effective"))).toBe("restart-required");
expect(agentProfileActivationState(config, activeProfile("effective-agent", "/other"))).toBe("restart-required");
expect(agentProfileActivationState(configResponse({}, {}, { agent: { command: "pi", dir: "/effective" } }), activeProfile("pi", "/effective"))).toBe("restart-required");
expect(agentProfileActivationState(configResponse({}, {}, { agent: { command: "pi", dir: "/effective" } }), activeProfile("pi", "/effective", ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]))).toBe("active");
expect(agentProfileActivationState(config, undefined)).toBe("unavailable");
expect(agentProfileActivationState(undefined, activeProfile("effective-agent", "/effective"))).toBe("unavailable");
});
it("releases only Pi's compatibility directory override when the draft selects an alternate command", () => {
const baseOverrides = configResponse({}).envOverrides;
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "pi")).toBe(true);
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "pi.exe")).toBe(true);
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "alternate-agent")).toBe(false);
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-web" }, "alternate-agent")).toBe(true);
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true }, "alternate-agent")).toBe(true);
});
it("does not leak the gateway agent directory source into a selected-machine response", () => {
const gateway = configResponse({}, { agentDir: true, agentDirSource: "pi-web" });
const selectedMachine = configResponse({}, { agentDir: false });
expect(mergeSelectedMachineSessiondConfig(gateway, selectedMachine).envOverrides.agentDirSource).toBeUndefined();
});
it("merges local selected-machine daemon config into gateway config without dropping gateway-only values", () => {
@@ -37,7 +54,7 @@ describe("session daemon settings config helpers", () => {
});
const selectedMachine = configResponse(
{ spawnSessions: true, subsessions: true, agent: { command: "machine-agent", dir: "/srv/machine-agent" } },
{ spawnSessions: true, subsessions: false, agentCommand: true, agentDir: false, agentSessionDir: true },
{ spawnSessions: true, subsessions: false, agentCommand: true, agentDir: false, agentDirSource: "pi-compatibility", agentSessionDir: true },
{ spawnSessions: true, subsessions: true, agent: { command: "env-agent", dir: "/srv/machine-agent" } },
);
@@ -71,12 +88,23 @@ describe("session daemon settings config helpers", () => {
subsessions: false,
agentCommand: true,
agentDir: false,
agentDirSource: "pi-compatibility",
agentSessionDir: true,
},
});
});
});
function activeProfile(command: string, dir: string, sessionDirEnvKeys: readonly string[] = ["PI_WEB_AGENT_SESSION_DIR"]): ActiveAgentProfileDescriptor {
return {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command,
dir,
sessionDirEnvKeys,
};
}
function configResponse(
config: PiWebConfigValues,
overrides: Partial<PiWebConfigResponse["envOverrides"]> = {},
@@ -1,4 +1,7 @@
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { usesPiCodingAgentStateCompatibility } from "../../../../shared/activeAgentProfile";
import type { ActiveAgentProfileDescriptor, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../../api";
export type AgentProfileActivationState = "active" | "restart-required" | "unavailable";
export function spawnSessionsConfigPatch(enabled: boolean): PiWebConfigValues {
return { spawnSessions: enabled };
@@ -8,36 +11,51 @@ export function subsessionsConfigPatch(enabled: boolean): PiWebConfigValues {
return { subsessions: enabled };
}
export function agentFieldConfigPatch(
baseConfig: PiWebConfigValues,
field: "command" | "dir",
rawValue: string,
): PiWebConfigValues {
const value = rawValue.trim();
const agent: NonNullable<PiWebConfigValues["agent"]> = { ...(baseConfig.agent ?? {}) };
if (field === "command") {
if (value === "") delete agent.command;
else agent.command = value;
} else if (value === "") {
delete agent.dir;
} else {
agent.dir = value;
}
return { agent };
export function agentProfileActivationState(
config: PiWebConfigResponse | undefined,
activeProfile: ActiveAgentProfileDescriptor | undefined,
): AgentProfileActivationState {
const desiredProfile = config?.effectiveConfig.agent;
if (desiredProfile?.command === undefined || desiredProfile.dir === undefined || activeProfile === undefined) return "unavailable";
const desiredSessionDirEnvKeys = [
"PI_WEB_AGENT_SESSION_DIR",
...(usesPiCodingAgentStateCompatibility(desiredProfile.command) ? ["PI_CODING_AGENT_SESSION_DIR"] : []),
];
return desiredProfile.command === activeProfile.command
&& desiredProfile.dir === activeProfile.dir
&& sameStrings(activeProfile.sessionDirEnvKeys, desiredSessionDirEnvKeys)
? "active"
: "restart-required";
}
export function agentDirFieldOverridden(envOverrides: PiWebConfigEnvOverrides | undefined, draftCommand: string): boolean {
if (envOverrides?.agentDirSource === "pi-web") return true;
if (envOverrides?.agentDirSource === "pi-compatibility") return usesPiCodingAgentStateCompatibility(draftCommand.trim() || "pi");
// Older remote responses do not identify the source. Keep their override
// read-only rather than incorrectly treating a PI_WEB_AGENT_DIR as conditional.
return envOverrides?.agentDir === true;
}
export function mergeSelectedMachineSessiondConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
const envOverrides: PiWebConfigEnvOverrides = {
...base.envOverrides,
spawnSessions: selectedMachine.envOverrides.spawnSessions,
subsessions: selectedMachine.envOverrides.subsessions,
agentCommand: selectedMachine.envOverrides.agentCommand,
agentDir: selectedMachine.envOverrides.agentDir,
agentSessionDir: selectedMachine.envOverrides.agentSessionDir,
};
if (selectedMachine.envOverrides.agentDirSource === undefined) delete envOverrides.agentDirSource;
else envOverrides.agentDirSource = selectedMachine.envOverrides.agentDirSource;
return {
...base,
config: { ...base.config, ...selectedMachine.config },
effectiveConfig: { ...base.effectiveConfig, ...selectedMachine.effectiveConfig },
envOverrides: {
...base.envOverrides,
spawnSessions: selectedMachine.envOverrides.spawnSessions,
subsessions: selectedMachine.envOverrides.subsessions,
agentCommand: selectedMachine.envOverrides.agentCommand,
agentDir: selectedMachine.envOverrides.agentDir,
agentSessionDir: selectedMachine.envOverrides.agentSessionDir,
},
envOverrides,
};
}
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
@@ -93,7 +93,7 @@ describe("MachineController", () => {
expect(projects.loadProjects).toHaveBeenCalledOnce();
expect(updateUrl).toHaveBeenCalledOnce();
expect(health).toHaveBeenCalledWith(addedMachine.id);
expect(runtime).toHaveBeenCalledWith(addedMachine.id);
expect(runtime).toHaveBeenCalledWith(addedMachine.id, true);
});
it("preserves the current machine state when adding a machine fails", async () => {
@@ -102,7 +102,7 @@ export class MachineController {
async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<void> {
try {
const runtime = await api.runtime(machineId);
const runtime = await api.runtime(machineId, true);
this.setState({ machineRuntimes: { ...this.getState().machineRuntimes, [runtime.machineId]: runtime } });
} catch (error) {
this.setState({ error: String(error) });