Archived
feat: apply agent profile settings atomically
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) });
|
||||
|
||||
Reference in New Issue
Block a user