mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge remote-tracking branch 'origin/main' into project-git-detection
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## 0.1.109 - 2026-07-16
|
||||
|
||||
> **Important update notice**
|
||||
>
|
||||
> If you installed Paseo Desktop 0.1.108, you need to [download and reinstall Paseo manually](https://paseo.sh/download) to get this fix. The bug in 0.1.108 prevents its automatic updater from installing 0.1.109. Users on 0.1.107 or earlier can update normally.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Paseo Desktop no longer gets stuck connecting or loses native window controls after updating ([#2111](https://github.com/getpaseo/paseo/pull/2111) by [@cleiter](https://github.com/cleiter))
|
||||
|
||||
@@ -147,6 +147,8 @@ Electron wrapper for macOS, Linux, and Windows.
|
||||
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
||||
>
|
||||
> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after `did-attach`, the renderer explicitly registers its browser id, workspace id, and guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs.
|
||||
>
|
||||
> **In-app browser window opens.** Ordinary link opens, including Shift-clicked links, become Paseo workspace tabs. Script-created opens with popup features or a named window target and POST-backed opens remain secured Electron child windows in the shared browser profile, preserving `window.opener`, `postMessage`, named-window reuse, request bodies, and `window.close()` for OAuth, payment, and similar popup protocols. Unsupported URL schemes are denied before either path.
|
||||
|
||||
> **In-app browser targets are not yet per-window.** Browser webviews are still tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus records the workspace-active browser for UI state and `list_tabs` reporting, while agent automation targets explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. Explicit attached-guest registration prevents concurrent windows from swapping different browser ids, but rendering the same saved browser tab in multiple windows can still make menu actions target the most recently registered guest. Making the registry window-scoped remains a follow-up.
|
||||
|
||||
|
||||
@@ -169,13 +169,13 @@ This does **not** apply to fresh releases cut via `npm run release:patch` — th
|
||||
|
||||
### Releasing during an active rollout
|
||||
|
||||
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it.
|
||||
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. Rollout-aware clients revalidate the manifest before installing a downloaded update on quit. If N+1 has replaced N but the client is not admitted to N+1 yet, it skips the downloaded N and waits rather than installing two updates in succession.
|
||||
|
||||
If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast.
|
||||
|
||||
### Limitations
|
||||
|
||||
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
|
||||
- **No pause / kill switch.** To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix `+1` patch.
|
||||
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
|
||||
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
|
||||
- **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission.
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getPromptPreview,
|
||||
getSessionTitle,
|
||||
resolveProvidersToFetch,
|
||||
requiresImportSessionsHostUpgrade,
|
||||
type SessionsQueryResult,
|
||||
sumFilteredAlreadyImportedCount,
|
||||
} from "@/components/import-session-sheet-view-model";
|
||||
@@ -70,6 +71,35 @@ describe("resolveProvidersToFetch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiresImportSessionsHostUpgrade", () => {
|
||||
it("allows home imports on hosts without workspace targeting", () => {
|
||||
expect(
|
||||
requiresImportSessionsHostUpgrade({
|
||||
supportsSnapshot: true,
|
||||
workspaceId: null,
|
||||
supportsWorkspaceTarget: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires host support for imports opened from a workspace", () => {
|
||||
expect(
|
||||
requiresImportSessionsHostUpgrade({
|
||||
supportsSnapshot: true,
|
||||
workspaceId: "ws-current",
|
||||
supportsWorkspaceTarget: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requiresImportSessionsHostUpgrade({
|
||||
supportsSnapshot: true,
|
||||
workspaceId: "ws-current",
|
||||
supportsWorkspaceTarget: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProviderLabelMap", () => {
|
||||
it("returns an empty map when snapshot entries are missing", () => {
|
||||
expect(buildProviderLabelMap(undefined).size).toBe(0);
|
||||
|
||||
@@ -5,6 +5,14 @@ import { i18n } from "@/i18n/i18next";
|
||||
export const PER_PROVIDER_LIMIT = 15;
|
||||
export const ALL_FILTER_VALUE = "__all__";
|
||||
|
||||
export function requiresImportSessionsHostUpgrade(input: {
|
||||
supportsSnapshot: boolean;
|
||||
workspaceId?: string | null;
|
||||
supportsWorkspaceTarget: boolean;
|
||||
}): boolean {
|
||||
return !input.supportsSnapshot || (Boolean(input.workspaceId) && !input.supportsWorkspaceTarget);
|
||||
}
|
||||
|
||||
export interface SessionsQueryResult {
|
||||
data:
|
||||
| {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
aggregateSessionEntries,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
getSessionTitle,
|
||||
PER_PROVIDER_LIMIT,
|
||||
resolveProvidersToFetch,
|
||||
requiresImportSessionsHostUpgrade,
|
||||
sumFilteredAlreadyImportedCount,
|
||||
} from "@/components/import-session-sheet-view-model";
|
||||
|
||||
@@ -44,6 +46,7 @@ interface ImportSessionSheetProps {
|
||||
client: RecentProviderSessionsClient | null;
|
||||
serverId: string | null;
|
||||
cwd?: string | null;
|
||||
workspaceId?: string | null;
|
||||
onClose: () => void;
|
||||
onImportedAgent?: (agentId: string) => void;
|
||||
onImported?: (agent: ImportedAgent) => void;
|
||||
@@ -260,6 +263,7 @@ export function ImportSessionSheet({
|
||||
client,
|
||||
serverId,
|
||||
cwd,
|
||||
workspaceId,
|
||||
onClose,
|
||||
onImportedAgent,
|
||||
onImported,
|
||||
@@ -272,10 +276,16 @@ export function ImportSessionSheet({
|
||||
cwd,
|
||||
enabled: visible,
|
||||
});
|
||||
const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget");
|
||||
const requiresHostUpgrade = requiresImportSessionsHostUpgrade({
|
||||
supportsSnapshot,
|
||||
workspaceId,
|
||||
supportsWorkspaceTarget,
|
||||
});
|
||||
|
||||
const providersToFetch = useMemo(
|
||||
() => resolveProvidersToFetch(supportsSnapshot, snapshotEntries),
|
||||
[supportsSnapshot, snapshotEntries],
|
||||
() => (requiresHostUpgrade ? null : resolveProvidersToFetch(supportsSnapshot, snapshotEntries)),
|
||||
[requiresHostUpgrade, supportsSnapshot, snapshotEntries],
|
||||
);
|
||||
|
||||
const providerLabelById = useMemo(
|
||||
@@ -408,6 +418,7 @@ export function ImportSessionSheet({
|
||||
providerId: entry.providerId,
|
||||
providerHandleId: entry.providerHandleId,
|
||||
cwd: entry.cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
});
|
||||
return agent;
|
||||
},
|
||||
@@ -450,7 +461,7 @@ export function ImportSessionSheet({
|
||||
[isRefreshing, handleRefresh, t],
|
||||
);
|
||||
|
||||
const isSnapshotUnsupported = !supportsSnapshot;
|
||||
const isSnapshotUnsupported = requiresHostUpgrade;
|
||||
const isWaitingForSnapshot = supportsSnapshot && snapshotEntries === undefined;
|
||||
const hasNoImportableProviders = providersToFetch !== null && providersToFetch.length === 0;
|
||||
const isQueryingProviders = queries.length > 0;
|
||||
|
||||
@@ -3709,6 +3709,7 @@ function WorkspaceScreenContent({
|
||||
client={client}
|
||||
serverId={normalizedServerId}
|
||||
cwd={workspaceDirectory}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
onClose={closeImportSheet}
|
||||
onImportedAgent={handleImportedAgent}
|
||||
/>
|
||||
|
||||
@@ -159,6 +159,7 @@ const PROJECT_GITHUB_CLONE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
interface ImportAgentInputBase {
|
||||
cwd?: string;
|
||||
workspaceId?: string;
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -2528,6 +2529,7 @@ export class DaemonClient {
|
||||
? { providerId: input.providerId, providerHandleId: input.providerHandleId }
|
||||
: { provider: input.provider, sessionId: input.sessionId }),
|
||||
...(input.cwd ? { cwd: input.cwd } : {}),
|
||||
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
||||
...(input.labels && Object.keys(input.labels).length > 0 ? { labels: input.labels } : {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
|
||||
import {
|
||||
@@ -23,96 +23,151 @@ describe("quit-lifecycle", () => {
|
||||
});
|
||||
|
||||
it("short-circuits without inspecting the daemon when keep-running is on", async () => {
|
||||
const isDesktopManagedDaemonRunning = vi.fn(() => true);
|
||||
const stopDaemon = vi.fn(async () => undefined);
|
||||
const showShutdownFeedback = vi.fn();
|
||||
const events: string[] = [];
|
||||
|
||||
const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({
|
||||
settingsStore: { get: async () => SETTINGS_KEEP_RUNNING },
|
||||
isDesktopManagedDaemonRunning,
|
||||
stopDaemon,
|
||||
showShutdownFeedback,
|
||||
isDesktopManagedDaemonRunning: () => {
|
||||
events.push("inspect");
|
||||
return true;
|
||||
},
|
||||
stopDaemon: async () => {
|
||||
events.push("stop");
|
||||
},
|
||||
showShutdownFeedback: () => {
|
||||
events.push("feedback");
|
||||
},
|
||||
});
|
||||
|
||||
expect(stopped).toBe(false);
|
||||
expect(isDesktopManagedDaemonRunning).not.toHaveBeenCalled();
|
||||
expect(stopDaemon).not.toHaveBeenCalled();
|
||||
expect(showShutdownFeedback).not.toHaveBeenCalled();
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not stop a manually started daemon on quit", async () => {
|
||||
const stopDaemon = vi.fn(async () => undefined);
|
||||
const showShutdownFeedback = vi.fn();
|
||||
const events: string[] = [];
|
||||
|
||||
const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({
|
||||
settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT },
|
||||
isDesktopManagedDaemonRunning: () => false,
|
||||
stopDaemon,
|
||||
showShutdownFeedback,
|
||||
stopDaemon: async () => {
|
||||
events.push("stop");
|
||||
},
|
||||
showShutdownFeedback: () => {
|
||||
events.push("feedback");
|
||||
},
|
||||
});
|
||||
|
||||
expect(stopped).toBe(false);
|
||||
expect(stopDaemon).not.toHaveBeenCalled();
|
||||
expect(showShutdownFeedback).not.toHaveBeenCalled();
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows feedback then stops a desktop-managed daemon", async () => {
|
||||
const stopDaemon = vi.fn(async () => undefined);
|
||||
const showShutdownFeedback = vi.fn();
|
||||
const events: string[] = [];
|
||||
|
||||
const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({
|
||||
settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT },
|
||||
isDesktopManagedDaemonRunning: () => true,
|
||||
stopDaemon,
|
||||
showShutdownFeedback,
|
||||
stopDaemon: async () => {
|
||||
events.push("stop");
|
||||
},
|
||||
showShutdownFeedback: () => {
|
||||
events.push("feedback");
|
||||
},
|
||||
});
|
||||
|
||||
expect(stopped).toBe(true);
|
||||
expect(showShutdownFeedback).toHaveBeenCalledTimes(1);
|
||||
expect(stopDaemon).toHaveBeenCalledTimes(1);
|
||||
expect(showShutdownFeedback.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
stopDaemon.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(events).toEqual(["feedback", "stop"]);
|
||||
});
|
||||
|
||||
it("preventDefaults the first quit, runs the async stop decision, then exits hard", async () => {
|
||||
it("revalidates updates after daemon shutdown before exiting", async () => {
|
||||
let resolveStopDecision: (() => void) | null = null;
|
||||
const app = { exit: vi.fn() };
|
||||
const closeTransportSessions = vi.fn();
|
||||
const onStopError = vi.fn();
|
||||
const preventDefault = vi.fn();
|
||||
const secondPreventDefault = vi.fn();
|
||||
let resolveUpdateDecision: (() => void) | null = null;
|
||||
const events: string[] = [];
|
||||
|
||||
const handleBeforeQuit = createBeforeQuitHandler({
|
||||
app,
|
||||
closeTransportSessions,
|
||||
stopDesktopManagedDaemonIfNeeded: vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveStopDecision = () => resolve(false);
|
||||
}),
|
||||
),
|
||||
onStopError,
|
||||
app: {
|
||||
exit: (code) => {
|
||||
events.push(`exit:${code}`);
|
||||
},
|
||||
},
|
||||
closeTransportSessions: () => {
|
||||
events.push("close-transports");
|
||||
},
|
||||
stopDesktopManagedDaemonIfNeeded: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveStopDecision = () => {
|
||||
events.push("daemon-stopped");
|
||||
resolve(false);
|
||||
};
|
||||
}),
|
||||
installAppUpdateOnQuit: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveUpdateDecision = () => {
|
||||
events.push("update-checked");
|
||||
resolve(false);
|
||||
};
|
||||
}),
|
||||
onStopError: () => {
|
||||
events.push("stop-error");
|
||||
},
|
||||
onUpdateError: () => {
|
||||
events.push("update-error");
|
||||
},
|
||||
});
|
||||
|
||||
handleBeforeQuit({ preventDefault });
|
||||
handleBeforeQuit({
|
||||
preventDefault: () => {
|
||||
events.push("prevent-default");
|
||||
},
|
||||
});
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(closeTransportSessions).toHaveBeenCalledTimes(1);
|
||||
expect(app.exit).not.toHaveBeenCalled();
|
||||
expect(events).toEqual(["close-transports", "prevent-default"]);
|
||||
expect(resolveStopDecision).not.toBeNull();
|
||||
|
||||
resolveStopDecision?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(app.exit).toHaveBeenCalledWith(0);
|
||||
expect(onStopError).not.toHaveBeenCalled();
|
||||
expect(events).toEqual(["close-transports", "prevent-default", "daemon-stopped"]);
|
||||
expect(resolveUpdateDecision).not.toBeNull();
|
||||
|
||||
handleBeforeQuit({ preventDefault: secondPreventDefault });
|
||||
resolveUpdateDecision?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(secondPreventDefault).not.toHaveBeenCalled();
|
||||
expect(closeTransportSessions).toHaveBeenCalledTimes(2);
|
||||
expect(app.exit).toHaveBeenCalledTimes(1);
|
||||
expect(events).toEqual([
|
||||
"close-transports",
|
||||
"prevent-default",
|
||||
"daemon-stopped",
|
||||
"update-checked",
|
||||
"exit:0",
|
||||
]);
|
||||
|
||||
handleBeforeQuit({
|
||||
preventDefault: () => {
|
||||
events.push("second-prevent-default");
|
||||
},
|
||||
});
|
||||
|
||||
expect(events.at(-1)).toBe("close-transports");
|
||||
expect(events).not.toContain("second-prevent-default");
|
||||
});
|
||||
|
||||
it("lets the updater own process exit when a validated update is installing", async () => {
|
||||
const exits: number[] = [];
|
||||
const handleBeforeQuit = createBeforeQuitHandler({
|
||||
app: { exit: (code) => exits.push(code) },
|
||||
closeTransportSessions: () => {},
|
||||
stopDesktopManagedDaemonIfNeeded: async () => false,
|
||||
installAppUpdateOnQuit: async () => true,
|
||||
onStopError: () => {},
|
||||
onUpdateError: () => {},
|
||||
});
|
||||
|
||||
handleBeforeQuit({ preventDefault: () => {} });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(exits).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,18 +46,20 @@ export function createBeforeQuitHandler({
|
||||
app,
|
||||
closeTransportSessions,
|
||||
stopDesktopManagedDaemonIfNeeded,
|
||||
installAppUpdateOnQuit,
|
||||
onStopError,
|
||||
onUpdateError,
|
||||
}: {
|
||||
app: BeforeQuitApp;
|
||||
closeTransportSessions: () => void;
|
||||
stopDesktopManagedDaemonIfNeeded: () => Promise<boolean>;
|
||||
installAppUpdateOnQuit: () => Promise<boolean>;
|
||||
onStopError: (error: unknown) => void;
|
||||
onUpdateError: (error: unknown) => void;
|
||||
}): (event: BeforeQuitEvent) => void {
|
||||
// We always preventDefault on first quit so we can run the async stop
|
||||
// decision, then call app.exit(0) — which bypasses Electron's
|
||||
// close → window-all-closed → will-quit chain. The window-all-closed
|
||||
// listener is a darwin no-op (macOS convention) and would otherwise
|
||||
// veto a re-fired app.quit().
|
||||
// The first quit waits for daemon shutdown and update revalidation. A validated
|
||||
// update re-fires app.quit(); otherwise app.exit(0) bypasses Electron's macOS
|
||||
// window-all-closed handler, which would veto that second quit.
|
||||
let quitting = false;
|
||||
|
||||
return (event) => {
|
||||
@@ -66,12 +68,23 @@ export function createBeforeQuitHandler({
|
||||
quitting = true;
|
||||
event.preventDefault();
|
||||
|
||||
void stopDesktopManagedDaemonIfNeeded()
|
||||
.catch((error) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await stopDesktopManagedDaemonIfNeeded();
|
||||
} catch (error) {
|
||||
onStopError(error);
|
||||
})
|
||||
.finally(() => {
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const installingUpdate = await installAppUpdateOnQuit();
|
||||
if (installingUpdate) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
onUpdateError(error);
|
||||
}
|
||||
|
||||
app.exit(0);
|
||||
})();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime {
|
||||
> = [];
|
||||
private gate: ((info: RuntimeUpdateInfo) => boolean | Promise<boolean>) | null = null;
|
||||
private configuration: AppUpdateRuntimeConfiguration | null = null;
|
||||
private downloadableUpdate: RuntimeUpdateInfo | null = null;
|
||||
private downloadedUpdate: RuntimeUpdateInfo | null = null;
|
||||
checkCount = 0;
|
||||
installedVersions: string[] = [];
|
||||
|
||||
configure(input: AppUpdateRuntimeConfiguration): void {
|
||||
this.configuration = input;
|
||||
@@ -59,6 +62,7 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime {
|
||||
}
|
||||
|
||||
finishUpdateDownload(info: RuntimeUpdateInfo): void {
|
||||
this.downloadedUpdate = info;
|
||||
this.configuration?.onUpdateDownloaded(info);
|
||||
}
|
||||
|
||||
@@ -80,12 +84,22 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime {
|
||||
}
|
||||
if (!result || !this.gate) return result;
|
||||
const admitted = await this.gate(result.updateInfo);
|
||||
return { ...result, isUpdateAvailable: result.isUpdateAvailable && admitted };
|
||||
const isUpdateAvailable = result.isUpdateAvailable && admitted;
|
||||
this.downloadableUpdate = isUpdateAvailable ? result.updateInfo : null;
|
||||
return { ...result, isUpdateAvailable };
|
||||
}
|
||||
|
||||
async downloadUpdate(): Promise<void> {}
|
||||
async downloadUpdate(): Promise<void> {
|
||||
if (this.downloadableUpdate) {
|
||||
this.finishUpdateDownload(this.downloadableUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
quitAndInstall(): void {}
|
||||
quitAndInstall(): void {
|
||||
if (this.downloadedUpdate) {
|
||||
this.installedVersions.push(this.downloadedUpdate.version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createService(input?: { now?: () => number; bucket?: () => Promise<number> }) {
|
||||
@@ -179,6 +193,111 @@ describe("app update service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces a downloaded update when a newer release is admitted", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
runtime.finishUpdateDownload(rolledOutUpdate);
|
||||
|
||||
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||
const result = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
hasUpdate: true,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.5",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("installs the newest admitted release when quitting with an older download", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
runtime.finishUpdateDownload(rolledOutUpdate);
|
||||
|
||||
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||
const installed = await service.installUpdateOnQuit({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
|
||||
expect(installed).toBe(true);
|
||||
expect(runtime.installedVersions).toEqual(["1.2.5"]);
|
||||
});
|
||||
|
||||
it("does not install an older download while its replacement is still rolling out", async () => {
|
||||
const now = Date.parse("2026-04-28T12:00:00.000Z");
|
||||
const { runtime, service } = createService({ now: () => now, bucket: async () => 0.4 });
|
||||
const olderUpdate = {
|
||||
...rolledOutUpdate,
|
||||
releaseDate: "2026-04-27T00:00:00.000Z",
|
||||
};
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: olderUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
runtime.finishUpdateDownload(olderUpdate);
|
||||
|
||||
const newerUpdate = {
|
||||
...rolledOutUpdate,
|
||||
version: "1.2.5",
|
||||
releaseDate: "2026-04-28T12:00:00.000Z",
|
||||
};
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||
const installed = await service.installUpdateOnQuit({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
|
||||
expect(installed).toBe(false);
|
||||
expect(runtime.installedVersions).toEqual([]);
|
||||
});
|
||||
|
||||
it("rechecks for the newest release before a manual install", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0.99 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
runtime.finishUpdateDownload(rolledOutUpdate);
|
||||
|
||||
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||
const result = await service.downloadAndInstallUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
|
||||
expect(result.installed).toBe(true);
|
||||
expect(runtime.installedVersions).toEqual(["1.2.5"]);
|
||||
});
|
||||
|
||||
it("trusts the runtime availability decision before comparing versions", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate });
|
||||
@@ -322,40 +441,8 @@ describe("app update service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps preparation errors emitted before the update check rejects", async () => {
|
||||
const { runtime, service } = createService();
|
||||
const deferredCheck = runtime.deferNextCheck();
|
||||
const pending = service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
|
||||
runtime.prepareUpdate(rolledOutUpdate);
|
||||
runtime.failRuntime(new Error("sha512 checksum mismatch"));
|
||||
deferredCheck.reject(new Error("sha512 checksum mismatch"));
|
||||
const checkResult = await pending;
|
||||
expect(checkResult.errorMessage).toBe("sha512 checksum mismatch");
|
||||
|
||||
const automaticResult = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
|
||||
expect(automaticResult).toEqual({
|
||||
hasUpdate: true,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.4",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: "sha512 checksum mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns runtime update errors after an update fails to prepare", async () => {
|
||||
const { runtime, service } = createService();
|
||||
it("discovers newer releases after an update fails to prepare", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
@@ -365,6 +452,8 @@ describe("app update service", () => {
|
||||
});
|
||||
runtime.failRuntime(new Error("sha512 checksum mismatch"));
|
||||
|
||||
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||
const result = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
@@ -375,10 +464,10 @@ describe("app update service", () => {
|
||||
hasUpdate: true,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.4",
|
||||
latestVersion: "1.2.5",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: "sha512 checksum mismatch",
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -445,91 +534,4 @@ describe("app update service", () => {
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns runtime update errors to multiple automatic checks before a manual retry clears them", async () => {
|
||||
const { runtime, service } = createService();
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
runtime.failRuntime(new Error("sha512 checksum mismatch"));
|
||||
|
||||
const firstAutomaticResult = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
const secondAutomaticResult = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
|
||||
expect(firstAutomaticResult).toEqual({
|
||||
hasUpdate: true,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.4",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: "sha512 checksum mismatch",
|
||||
});
|
||||
expect(secondAutomaticResult).toEqual(firstAutomaticResult);
|
||||
|
||||
runtime.nextCheck(null);
|
||||
const retryResult = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
|
||||
expect(runtime.checkCount).toBe(2);
|
||||
expect(retryResult).toEqual({
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.3",
|
||||
body: null,
|
||||
date: null,
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps runtime update errors visible after a manual retry fails", async () => {
|
||||
const { runtime, service } = createService();
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
runtime.failRuntime(new Error("sha512 checksum mismatch"));
|
||||
|
||||
runtime.failNextCheck(new Error("network down"));
|
||||
const retryResult = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
const automaticResult = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
|
||||
expect(retryResult.errorMessage).toBe("network down");
|
||||
expect(automaticResult).toEqual({
|
||||
hasUpdate: true,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.4",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: "sha512 checksum mismatch",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,10 @@ export interface AppUpdateService {
|
||||
},
|
||||
onBeforeQuit?: () => Promise<void>,
|
||||
): Promise<AppUpdateInstallResult>;
|
||||
installUpdateOnQuit(input: {
|
||||
currentVersion: string;
|
||||
releaseChannel: AppReleaseChannel;
|
||||
}): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface AppUpdateServiceDeps {
|
||||
@@ -112,10 +116,7 @@ function getErrorMessage(error: unknown): string {
|
||||
export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateService {
|
||||
let cachedUpdateInfo: RuntimeUpdateInfo | null = null;
|
||||
let downloadedUpdateVersion: string | null = null;
|
||||
let downloading = false;
|
||||
let configuredReleaseChannel: AppReleaseChannel | null = null;
|
||||
let runtimeErrorMessage: string | null = null;
|
||||
let inFlightUpdateCheckCount = 0;
|
||||
|
||||
function isReadyToInstallVersion(version: string): boolean {
|
||||
return downloadedUpdateVersion === version;
|
||||
@@ -124,23 +125,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
||||
function clearUpdateState(): void {
|
||||
cachedUpdateInfo = null;
|
||||
downloadedUpdateVersion = null;
|
||||
downloading = false;
|
||||
runtimeErrorMessage = null;
|
||||
}
|
||||
|
||||
function buildRuntimeErrorResult(currentVersion: string): AppUpdateCheckResult | null {
|
||||
if (!runtimeErrorMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const info = cachedUpdateInfo;
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: info?.version !== undefined && info.version !== currentVersion,
|
||||
readyToInstall: false,
|
||||
info,
|
||||
errorMessage: runtimeErrorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
function configureRuntime(releaseChannel: AppReleaseChannel, intent: AppUpdateCheckIntent): void {
|
||||
@@ -166,23 +150,15 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
||||
const alreadyReady = downloadedUpdateVersion === info.version;
|
||||
cachedUpdateInfo = info;
|
||||
downloadedUpdateVersion = alreadyReady ? info.version : null;
|
||||
downloading = !alreadyReady;
|
||||
runtimeErrorMessage = null;
|
||||
},
|
||||
onUpdateDownloaded(info) {
|
||||
cachedUpdateInfo = info;
|
||||
downloadedUpdateVersion = info.version;
|
||||
downloading = false;
|
||||
runtimeErrorMessage = null;
|
||||
},
|
||||
onUpdateNotAvailable() {
|
||||
clearUpdateState();
|
||||
},
|
||||
onError(error) {
|
||||
downloading = false;
|
||||
if (inFlightUpdateCheckCount === 0 || cachedUpdateInfo) {
|
||||
runtimeErrorMessage = getErrorMessage(error);
|
||||
}
|
||||
deps.reportRuntimeError?.(error);
|
||||
},
|
||||
});
|
||||
@@ -207,28 +183,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
||||
|
||||
configureRuntime(releaseChannel, intent);
|
||||
|
||||
const runtimeErrorResult = buildRuntimeErrorResult(currentVersion);
|
||||
if (runtimeErrorResult && intent === "automatic") {
|
||||
return runtimeErrorResult;
|
||||
}
|
||||
|
||||
const cachedVersion = cachedUpdateInfo?.version ?? null;
|
||||
if (
|
||||
!runtimeErrorResult &&
|
||||
intent === "automatic" &&
|
||||
cachedVersion &&
|
||||
cachedVersion !== currentVersion
|
||||
) {
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: true,
|
||||
readyToInstall: isReadyToInstallVersion(cachedVersion),
|
||||
info: cachedUpdateInfo,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
inFlightUpdateCheckCount += 1;
|
||||
const result = await deps.runtime.checkForUpdates();
|
||||
if (!result || !result.updateInfo || !result.isUpdateAvailable) {
|
||||
clearUpdateState();
|
||||
@@ -245,8 +200,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
||||
|
||||
if (hasUpdate) {
|
||||
cachedUpdateInfo = info;
|
||||
downloading = !isReadyToInstallVersion(latestVersion);
|
||||
runtimeErrorMessage = null;
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: true,
|
||||
@@ -269,8 +222,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
||||
readyToInstall: false,
|
||||
errorMessage: getErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
inFlightUpdateCheckCount -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +243,26 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
||||
};
|
||||
}
|
||||
|
||||
const check = await checkForAppUpdate({
|
||||
currentVersion,
|
||||
releaseChannel,
|
||||
intent: "manual",
|
||||
});
|
||||
if (!check.hasUpdate) {
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: check.errorMessage ?? "No update available.",
|
||||
};
|
||||
}
|
||||
|
||||
return installCachedUpdate(currentVersion, onBeforeQuit);
|
||||
}
|
||||
|
||||
async function installCachedUpdate(
|
||||
currentVersion: string,
|
||||
onBeforeQuit?: () => Promise<void>,
|
||||
): Promise<AppUpdateInstallResult> {
|
||||
if (!cachedUpdateInfo) {
|
||||
return {
|
||||
installed: false,
|
||||
@@ -300,8 +271,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
||||
};
|
||||
}
|
||||
|
||||
configureRuntime(releaseChannel, "manual");
|
||||
|
||||
const readyVersion = cachedUpdateInfo.version;
|
||||
if (isReadyToInstallVersion(readyVersion)) {
|
||||
await performQuitAndInstall(deps.runtime, onBeforeQuit);
|
||||
@@ -312,20 +281,16 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
||||
};
|
||||
}
|
||||
|
||||
if (downloading) {
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: "Update is still being prepared. Try again in a moment.",
|
||||
};
|
||||
}
|
||||
|
||||
downloading = true;
|
||||
|
||||
try {
|
||||
await deps.runtime.downloadUpdate();
|
||||
if (cachedUpdateInfo?.version !== readyVersion) {
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: "A newer update was found and will be installed later.",
|
||||
};
|
||||
}
|
||||
downloadedUpdateVersion = readyVersion;
|
||||
downloading = false;
|
||||
await performQuitAndInstall(deps.runtime, onBeforeQuit);
|
||||
|
||||
return {
|
||||
@@ -334,7 +299,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
||||
message: "Update downloaded. The app will restart shortly.",
|
||||
};
|
||||
} catch (error) {
|
||||
downloading = false;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.reportInstallError?.(message);
|
||||
return {
|
||||
@@ -345,8 +309,33 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
||||
}
|
||||
}
|
||||
|
||||
async function installUpdateOnQuit({
|
||||
currentVersion,
|
||||
releaseChannel,
|
||||
}: {
|
||||
currentVersion: string;
|
||||
releaseChannel: AppReleaseChannel;
|
||||
}): Promise<boolean> {
|
||||
if (!deps.isPackaged() || !downloadedUpdateVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const check = await checkForAppUpdate({
|
||||
currentVersion,
|
||||
releaseChannel,
|
||||
intent: "automatic",
|
||||
});
|
||||
if (!check.hasUpdate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await installCachedUpdate(currentVersion);
|
||||
return result.installed;
|
||||
}
|
||||
|
||||
return {
|
||||
checkForAppUpdate,
|
||||
downloadAndInstallUpdate,
|
||||
installUpdateOnQuit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,18 +19,8 @@ import {
|
||||
resolveStagingUserId,
|
||||
rolloutManifestSchema,
|
||||
shouldAdmitToRollout,
|
||||
shouldAutoInstallOnQuit,
|
||||
} from "./auto-updater";
|
||||
|
||||
describe("shouldAutoInstallOnQuit", () => {
|
||||
it("auto-installs on quit everywhere except Linux AppImage", () => {
|
||||
expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: true })).toBe(false);
|
||||
expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: false })).toBe(true);
|
||||
expect(shouldAutoInstallOnQuit({ platform: "darwin", isAppImage: false })).toBe(true);
|
||||
expect(shouldAutoInstallOnQuit({ platform: "win32", isAppImage: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldAdmitToRollout", () => {
|
||||
it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => {
|
||||
expect(
|
||||
|
||||
@@ -76,31 +76,16 @@ export function getStagingUserId(): Promise<string> {
|
||||
return cachedStagingUserIdPromise;
|
||||
}
|
||||
|
||||
// AppImages have no install step. electron-updater "installs" by unlinking the
|
||||
// running file and mv-ing the downloaded one into place; on app quit it does this
|
||||
// via a *blocking* execFileSync(newAppImage, { APPIMAGE_EXIT_AFTER_INSTALL: "true" }).
|
||||
// That env var is only honored by AppImageLauncher, so without it the freshly
|
||||
// launched process boots the full app and never exits — the quit hangs forever,
|
||||
// with the old binary already deleted. We therefore install AppImages only on
|
||||
// explicit quitAndInstall (the "Update now" button), which takes the non-blocking
|
||||
// spawn path. Every other target keeps auto-install-on-quit, which works there.
|
||||
export function shouldAutoInstallOnQuit(input: {
|
||||
platform: NodeJS.Platform;
|
||||
isAppImage: boolean;
|
||||
}): boolean {
|
||||
return !(input.platform === "linux" && input.isAppImage);
|
||||
}
|
||||
|
||||
class ElectronAppUpdateRuntime implements AppUpdateRuntime {
|
||||
private configured = false;
|
||||
|
||||
configure(input: AppUpdateRuntimeConfiguration): void {
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoRunAppAfterInstall = true;
|
||||
autoUpdater.autoInstallOnAppQuit = shouldAutoInstallOnQuit({
|
||||
platform: process.platform,
|
||||
isAppImage: Boolean(process.env.APPIMAGE),
|
||||
});
|
||||
// Paseo revalidates the current manifest before explicitly installing on quit.
|
||||
// Electron's built-in handler would install an older download without checking
|
||||
// whether a newer release has superseded it.
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
autoUpdater.allowPrerelease = input.releaseChannel === "beta";
|
||||
autoUpdater.channel = input.releaseChannel === "beta" ? "beta" : "latest";
|
||||
autoUpdater.allowDowngrade = false;
|
||||
@@ -194,3 +179,13 @@ export async function downloadAndInstallUpdate(
|
||||
onBeforeQuit,
|
||||
);
|
||||
}
|
||||
|
||||
export async function installAppUpdateOnQuit({
|
||||
currentVersion,
|
||||
releaseChannel,
|
||||
}: {
|
||||
currentVersion: string;
|
||||
releaseChannel: AppReleaseChannel;
|
||||
}): Promise<boolean> {
|
||||
return appUpdateService.installUpdateOnQuit({ currentVersion, releaseChannel });
|
||||
}
|
||||
|
||||
@@ -66,12 +66,12 @@ class FakeWebContents extends FakeLiveGuest {
|
||||
}
|
||||
|
||||
describe("listPaseoBrowserProfileGuests", () => {
|
||||
test("returns every live webview in the shared profile without deduplicating tabs", () => {
|
||||
test("returns every live webview and popup in the shared profile", () => {
|
||||
const profileSession = {};
|
||||
const firstWindowGuest = new FakeWebContents(1, profileSession, "webview");
|
||||
const secondWindowGuest = new FakeWebContents(2, profileSession, "webview");
|
||||
const foreignProfileGuest = new FakeWebContents(3, {}, "webview");
|
||||
const mainRenderer = new FakeWebContents(4, profileSession, "window");
|
||||
const popupWindow = new FakeWebContents(4, profileSession, "window");
|
||||
const destroyedGuest = new FakeWebContents(5, profileSession, "webview", true);
|
||||
|
||||
const guests = listPaseoBrowserProfileGuests({
|
||||
@@ -80,12 +80,12 @@ describe("listPaseoBrowserProfileGuests", () => {
|
||||
firstWindowGuest,
|
||||
secondWindowGuest,
|
||||
foreignProfileGuest,
|
||||
mainRenderer,
|
||||
popupWindow,
|
||||
destroyedGuest,
|
||||
],
|
||||
});
|
||||
|
||||
expect(guests).toEqual([firstWindowGuest, secondWindowGuest]);
|
||||
expect(guests).toEqual([firstWindowGuest, secondWindowGuest, popupWindow]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ export function listPaseoBrowserProfileGuests(
|
||||
return input.webContents.filter(
|
||||
(contents) =>
|
||||
!contents.isDestroyed() &&
|
||||
contents.getType() === "webview" &&
|
||||
(contents.getType() === "webview" || contents.getType() === "window") &&
|
||||
contents.session === input.profileSession,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { webContents as allWebContents, type WebContents } from "electron";
|
||||
import { PASEO_BROWSER_PROFILE_PARTITION } from "../browser-profile.js";
|
||||
import {
|
||||
BROWSER_NEW_TAB_REQUEST_EVENT,
|
||||
handleBrowserWindowOpenRequest,
|
||||
decideBrowserWindowOpenRequest,
|
||||
isAllowedBrowserWebviewUrl,
|
||||
PendingBrowserWindowOpenRequests,
|
||||
} from "./window-open.js";
|
||||
@@ -10,7 +10,7 @@ import { PaseoBrowserWebviewRegistry } from "./registry.js";
|
||||
|
||||
export {
|
||||
BROWSER_NEW_TAB_REQUEST_EVENT,
|
||||
handleBrowserWindowOpenRequest,
|
||||
decideBrowserWindowOpenRequest,
|
||||
PendingBrowserWindowOpenRequests,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,40 +1,162 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { handleBrowserWindowOpenRequest, PendingBrowserWindowOpenRequests } from ".";
|
||||
import { decideBrowserWindowOpenRequest, PendingBrowserWindowOpenRequests } from ".";
|
||||
|
||||
describe("browser webview window-open requests", () => {
|
||||
it("denies Electron window creation and requests a Paseo browser tab", () => {
|
||||
const requestNewTab = vi.fn();
|
||||
|
||||
const result = handleBrowserWindowOpenRequest({
|
||||
it("routes foreground tabs to a Paseo workspace tab", () => {
|
||||
const result = decideBrowserWindowOpenRequest({
|
||||
url: "https://example.com/target",
|
||||
sourceBrowserId: "browser-1",
|
||||
requestNewTab,
|
||||
disposition: "foreground-tab",
|
||||
frameName: "_blank",
|
||||
features: "",
|
||||
hasPostBody: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ action: "deny" });
|
||||
expect(requestNewTab).toHaveBeenCalledWith({
|
||||
sourceBrowserId: "browser-1",
|
||||
url: "https://example.com/target",
|
||||
});
|
||||
expect(result).toEqual({ kind: "workspace-tab", url: "https://example.com/target" });
|
||||
});
|
||||
|
||||
it("denies unsupported window-open requests before asking for a Paseo browser tab", () => {
|
||||
const requestNewTab = vi.fn();
|
||||
|
||||
const result = handleBrowserWindowOpenRequest({
|
||||
url: "file:///etc/passwd",
|
||||
sourceBrowserId: "browser-1",
|
||||
requestNewTab,
|
||||
it("keeps script-opened windows as real popups", () => {
|
||||
const result = decideBrowserWindowOpenRequest({
|
||||
url: "https://login.example.com/signin",
|
||||
disposition: "new-window",
|
||||
frameName: "oauth",
|
||||
features: "width=500,height=600",
|
||||
hasPostBody: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ action: "deny" });
|
||||
expect(requestNewTab).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ kind: "popup" });
|
||||
});
|
||||
|
||||
it("keeps named windows as real popups without a feature string", () => {
|
||||
const result = decideBrowserWindowOpenRequest({
|
||||
url: "https://login.example.com/signin",
|
||||
disposition: "new-window",
|
||||
frameName: "oauth",
|
||||
features: "",
|
||||
hasPostBody: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "popup" });
|
||||
});
|
||||
|
||||
it.each(["noopener", "noreferrer"])(
|
||||
"routes a named target with %s to a Paseo workspace tab",
|
||||
(features) => {
|
||||
const result = decideBrowserWindowOpenRequest({
|
||||
url: "https://example.com/target",
|
||||
disposition: "new-window",
|
||||
frameName: "secure-target",
|
||||
features,
|
||||
hasPostBody: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "workspace-tab", url: "https://example.com/target" });
|
||||
},
|
||||
);
|
||||
|
||||
it("routes Shift-clicked links to a Paseo workspace tab", () => {
|
||||
const result = decideBrowserWindowOpenRequest({
|
||||
url: "https://example.com/target",
|
||||
disposition: "new-window",
|
||||
frameName: "",
|
||||
features: "",
|
||||
hasPostBody: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "workspace-tab", url: "https://example.com/target" });
|
||||
});
|
||||
|
||||
it.each(["noopener", "noreferrer", "attributionsrc=https://example.com/register", "popup=false"])(
|
||||
"routes non-popup feature %s to a Paseo workspace tab",
|
||||
(features) => {
|
||||
const result = decideBrowserWindowOpenRequest({
|
||||
url: "https://example.com/target",
|
||||
disposition: "new-window",
|
||||
frameName: "_blank",
|
||||
features,
|
||||
hasPostBody: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "workspace-tab", url: "https://example.com/target" });
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an explicitly requested popup as a real popup", () => {
|
||||
const result = decideBrowserWindowOpenRequest({
|
||||
url: "https://login.example.com/signin",
|
||||
disposition: "new-window",
|
||||
frameName: "_blank",
|
||||
features: "noopener,popup=yes",
|
||||
hasPostBody: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "popup" });
|
||||
});
|
||||
|
||||
it("keeps legacy browser-chrome features as a real popup", () => {
|
||||
const result = decideBrowserWindowOpenRequest({
|
||||
url: "https://login.example.com/signin",
|
||||
disposition: "new-window",
|
||||
frameName: "_blank",
|
||||
features: "menubar=no,toolbar=no,status=no,scrollbars=no",
|
||||
hasPostBody: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "popup" });
|
||||
});
|
||||
|
||||
it("keeps unknown window features as a real popup", () => {
|
||||
const result = decideBrowserWindowOpenRequest({
|
||||
url: "https://login.example.com/signin",
|
||||
disposition: "new-window",
|
||||
frameName: "_blank",
|
||||
features: "dialog=yes",
|
||||
hasPostBody: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "popup" });
|
||||
});
|
||||
|
||||
it("routes an all-enabled browser-chrome request to a Paseo workspace tab", () => {
|
||||
const result = decideBrowserWindowOpenRequest({
|
||||
url: "https://example.com/target",
|
||||
disposition: "new-window",
|
||||
frameName: "_blank",
|
||||
features:
|
||||
"toolbar=yes,location=yes,menubar=yes,status=yes,scrollbars=yes,resizable=yes,noopener",
|
||||
hasPostBody: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "workspace-tab", url: "https://example.com/target" });
|
||||
});
|
||||
|
||||
it("keeps POST-backed foreground tabs as real popups", () => {
|
||||
const result = decideBrowserWindowOpenRequest({
|
||||
url: "https://example.com/submit",
|
||||
disposition: "foreground-tab",
|
||||
frameName: "_blank",
|
||||
features: "",
|
||||
hasPostBody: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "popup" });
|
||||
});
|
||||
|
||||
it("denies unsupported window-open requests", () => {
|
||||
const result = decideBrowserWindowOpenRequest({
|
||||
url: "file:///etc/passwd",
|
||||
disposition: "new-window",
|
||||
frameName: "oauth",
|
||||
features: "width=500,height=600",
|
||||
hasPostBody: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "deny" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pending browser window-open requests", () => {
|
||||
it("holds early allowed popups until browser identity registration", () => {
|
||||
it("holds early workspace-tab requests until browser identity registration", () => {
|
||||
const pending = new PendingBrowserWindowOpenRequests();
|
||||
pending.add(101, "https://example.com/first");
|
||||
pending.add(101, "file:///etc/passwd");
|
||||
@@ -44,7 +166,7 @@ describe("pending browser window-open requests", () => {
|
||||
expect(pending.take(101)).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops pending popups when an unregistered guest is destroyed", () => {
|
||||
it("drops pending workspace-tab requests when an unregistered guest is destroyed", () => {
|
||||
const pending = new PendingBrowserWindowOpenRequests();
|
||||
pending.add(202, "https://example.com/target");
|
||||
pending.delete(202);
|
||||
|
||||
@@ -1,11 +1,46 @@
|
||||
export const BROWSER_NEW_TAB_REQUEST_EVENT = "paseo:event:browser-new-tab-request";
|
||||
|
||||
export interface BrowserNewTabRequestPayload {
|
||||
sourceBrowserId: string;
|
||||
url: string;
|
||||
}
|
||||
export type BrowserWindowOpenDisposition =
|
||||
| "default"
|
||||
| "foreground-tab"
|
||||
| "background-tab"
|
||||
| "new-window"
|
||||
| "other";
|
||||
|
||||
export type BrowserWindowOpenDecision =
|
||||
| { kind: "deny" }
|
||||
| { kind: "popup" }
|
||||
| { kind: "workspace-tab"; url: string };
|
||||
|
||||
const MAX_PENDING_WINDOW_OPEN_REQUESTS_PER_GUEST = 20;
|
||||
const POPUP_WINDOW_GEOMETRY_FEATURE_NAMES = new Set([
|
||||
"height",
|
||||
"innerheight",
|
||||
"innerwidth",
|
||||
"left",
|
||||
"outerheight",
|
||||
"outerwidth",
|
||||
"screenx",
|
||||
"screeny",
|
||||
"top",
|
||||
"width",
|
||||
"x",
|
||||
"y",
|
||||
]);
|
||||
const POPUP_WINDOW_UI_FEATURE_NAMES = new Set([
|
||||
"location",
|
||||
"menubar",
|
||||
"resizable",
|
||||
"scrollbars",
|
||||
"status",
|
||||
"toolbar",
|
||||
]);
|
||||
const NON_POPUP_WINDOW_FEATURE_NAMES = new Set([
|
||||
"attributionsrc",
|
||||
"noopener",
|
||||
"noreferrer",
|
||||
"popup",
|
||||
]);
|
||||
|
||||
export class PendingBrowserWindowOpenRequests {
|
||||
private readonly urlsByWebContentsId = new Map<number, string[]>();
|
||||
@@ -47,18 +82,87 @@ export function isAllowedBrowserWebviewUrl(value: string | undefined): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function handleBrowserWindowOpenRequest(input: {
|
||||
export function decideBrowserWindowOpenRequest(input: {
|
||||
url: string;
|
||||
sourceBrowserId: string | null;
|
||||
requestNewTab: (payload: BrowserNewTabRequestPayload) => void;
|
||||
}): { action: "deny" } {
|
||||
if (!isAllowedBrowserWebviewUrl(input.url) || !input.sourceBrowserId) {
|
||||
return { action: "deny" };
|
||||
disposition: BrowserWindowOpenDisposition;
|
||||
frameName: string;
|
||||
features: string;
|
||||
hasPostBody: boolean;
|
||||
}): BrowserWindowOpenDecision {
|
||||
if (!isAllowedBrowserWebviewUrl(input.url)) {
|
||||
return { kind: "deny" };
|
||||
}
|
||||
|
||||
input.requestNewTab({
|
||||
sourceBrowserId: input.sourceBrowserId,
|
||||
url: input.url,
|
||||
});
|
||||
return { action: "deny" };
|
||||
const featureIntent = getBrowserWindowFeatureIntent(input.features);
|
||||
const hasNamedWindowTarget = input.frameName.length > 0 && input.frameName !== "_blank";
|
||||
const isScriptPopup =
|
||||
input.disposition === "new-window" &&
|
||||
(featureIntent.requestsPopup || (hasNamedWindowTarget && !featureIntent.disownsOpener));
|
||||
|
||||
// A real popup preserves window.opener, postMessage, named-window reuse, and
|
||||
// window.close(). OAuth and payment flows depend on those browser contracts.
|
||||
// POST-backed opens must also remain real windows because a workspace tab can
|
||||
// only carry the URL and would silently turn the request into a GET.
|
||||
if (isScriptPopup || input.hasPostBody) {
|
||||
return { kind: "popup" };
|
||||
}
|
||||
|
||||
return { kind: "workspace-tab", url: input.url };
|
||||
}
|
||||
|
||||
function getBrowserWindowFeatureIntent(features: string): {
|
||||
requestsPopup: boolean;
|
||||
disownsOpener: boolean;
|
||||
} {
|
||||
let requestsPopup = false;
|
||||
let disownsOpener = false;
|
||||
let hasPopupRelevantFeature = false;
|
||||
const enabledUiFeatures = new Map<string, boolean>();
|
||||
|
||||
for (const rawFeature of features.split(",")) {
|
||||
const separatorIndex = rawFeature.indexOf("=");
|
||||
const name = rawFeature
|
||||
.slice(0, separatorIndex === -1 ? undefined : separatorIndex)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const value =
|
||||
separatorIndex === -1
|
||||
? ""
|
||||
: rawFeature
|
||||
.slice(separatorIndex + 1)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (POPUP_WINDOW_GEOMETRY_FEATURE_NAMES.has(name)) {
|
||||
requestsPopup = true;
|
||||
}
|
||||
if (POPUP_WINDOW_UI_FEATURE_NAMES.has(name)) {
|
||||
hasPopupRelevantFeature = true;
|
||||
enabledUiFeatures.set(name, isEnabledWindowFeature(value));
|
||||
} else if (name.length > 0 && !NON_POPUP_WINDOW_FEATURE_NAMES.has(name)) {
|
||||
hasPopupRelevantFeature = true;
|
||||
}
|
||||
if (name === "popup" && isEnabledWindowFeature(value)) {
|
||||
requestsPopup = true;
|
||||
}
|
||||
if ((name === "noopener" || name === "noreferrer") && isEnabledWindowFeature(value)) {
|
||||
disownsOpener = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!requestsPopup && hasPopupRelevantFeature) {
|
||||
const isUiFeatureEnabled = (name: string): boolean => enabledUiFeatures.get(name) ?? false;
|
||||
requestsPopup =
|
||||
(!isUiFeatureEnabled("location") && !isUiFeatureEnabled("toolbar")) ||
|
||||
!isUiFeatureEnabled("menubar") ||
|
||||
!isUiFeatureEnabled("resizable") ||
|
||||
!isUiFeatureEnabled("scrollbars") ||
|
||||
!isUiFeatureEnabled("status");
|
||||
}
|
||||
|
||||
return { requestsPopup, disownsOpener };
|
||||
}
|
||||
|
||||
function isEnabledWindowFeature(value: string): boolean {
|
||||
return value !== "0" && value !== "false" && value !== "no";
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ import { registerEditorTargetHandlers } from "./features/editor-targets/ipc.js";
|
||||
import { setupApplicationMenu } from "./features/menu.js";
|
||||
import {
|
||||
BROWSER_NEW_TAB_REQUEST_EVENT,
|
||||
decideBrowserWindowOpenRequest,
|
||||
getPaseoBrowserIdForWebContents,
|
||||
getPaseoBrowserWebContents,
|
||||
handleBrowserWindowOpenRequest,
|
||||
listRegisteredPaseoBrowserIds,
|
||||
isPaseoBrowserWebviewAttach,
|
||||
preparePaseoBrowserWebContents,
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
import {
|
||||
clearPaseoBrowserProfile,
|
||||
getLegacyPaseoBrowserProfileSession,
|
||||
PASEO_BROWSER_PROFILE_PARTITION,
|
||||
getPaseoBrowserProfileSession,
|
||||
getPaseoBrowserProfileSessions,
|
||||
listPaseoBrowserProfileGuests,
|
||||
@@ -84,6 +85,7 @@ import {
|
||||
import { runDesktopStartup } from "./desktop-startup.js";
|
||||
import { autoUpdateInstalledSkills } from "./integrations/skills/index.js";
|
||||
import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js";
|
||||
import { installAppUpdateOnQuit } from "./features/auto-updater.js";
|
||||
|
||||
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
||||
const APP_SCHEME = "paseo";
|
||||
@@ -226,6 +228,79 @@ function showBrowserWebviewContextMenu(
|
||||
menu.popup({ window: win });
|
||||
}
|
||||
|
||||
function getBrowserPopupWindowOptions(
|
||||
mainWindow: BrowserWindow,
|
||||
): Electron.BrowserWindowConstructorOptions {
|
||||
return {
|
||||
parent: mainWindow,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
partition: PASEO_BROWSER_PROFILE_PARTITION,
|
||||
nodeIntegration: false,
|
||||
nodeIntegrationInSubFrames: false,
|
||||
nodeIntegrationInWorker: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
webviewTag: false,
|
||||
allowRunningInsecureContent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installBrowserWindowOpenHandler(input: {
|
||||
contents: Electron.WebContents;
|
||||
sourceContents: Electron.WebContents;
|
||||
mainWindow: BrowserWindow;
|
||||
}): void {
|
||||
const { contents, sourceContents, mainWindow } = input;
|
||||
|
||||
contents.setWindowOpenHandler(({ url, disposition, frameName, features, postBody }) => {
|
||||
const decision = decideBrowserWindowOpenRequest({
|
||||
url,
|
||||
disposition,
|
||||
frameName,
|
||||
features,
|
||||
hasPostBody: postBody !== undefined && postBody !== null,
|
||||
});
|
||||
|
||||
if (decision.kind === "deny") {
|
||||
return { action: "deny" };
|
||||
}
|
||||
if (decision.kind === "popup") {
|
||||
return {
|
||||
action: "allow",
|
||||
overrideBrowserWindowOptions: getBrowserPopupWindowOptions(mainWindow),
|
||||
};
|
||||
}
|
||||
|
||||
const sourceBrowserId = getPaseoBrowserIdForWebContents(sourceContents);
|
||||
if (sourceBrowserId) {
|
||||
mainWindow.webContents.send(BROWSER_NEW_TAB_REQUEST_EVENT, {
|
||||
sourceBrowserId,
|
||||
url: decision.url,
|
||||
});
|
||||
} else {
|
||||
pendingBrowserWindowOpenRequests.add(sourceContents.id, decision.url);
|
||||
}
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
contents.on("did-create-window", (popupWindow) => {
|
||||
const popupContents = popupWindow.webContents;
|
||||
registerBrowserWebviewNavigationGuards(popupContents);
|
||||
popupContents.on("context-menu", (_event, params) => {
|
||||
showBrowserWebviewContextMenu(popupWindow, popupContents, params);
|
||||
});
|
||||
installBrowserWindowOpenHandler({
|
||||
contents: popupContents,
|
||||
sourceContents,
|
||||
mainWindow,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// In dev mode, detect git worktrees and isolate each instance so multiple
|
||||
// Electron windows can run side-by-side (separate userData = separate lock).
|
||||
let devWorktreeName: string | null = null;
|
||||
@@ -722,18 +797,10 @@ async function createWindow(
|
||||
});
|
||||
}
|
||||
});
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
const sourceBrowserId = getPaseoBrowserIdForWebContents(contents);
|
||||
if (!sourceBrowserId) {
|
||||
pendingBrowserWindowOpenRequests.add(contents.id, url);
|
||||
}
|
||||
return handleBrowserWindowOpenRequest({
|
||||
url,
|
||||
sourceBrowserId,
|
||||
requestNewTab: (payload) => {
|
||||
mainWindow.webContents.send(BROWSER_NEW_TAB_REQUEST_EVENT, payload);
|
||||
},
|
||||
});
|
||||
installBrowserWindowOpenHandler({
|
||||
contents,
|
||||
sourceContents: contents,
|
||||
mainWindow,
|
||||
});
|
||||
contents.on("context-menu", (_contextMenuEvent, params) => {
|
||||
showBrowserWebviewContextMenu(mainWindow, contents, params);
|
||||
@@ -932,9 +999,19 @@ app.on(
|
||||
stopDaemon: () => stopDesktopDaemonViaCli("quit"),
|
||||
showShutdownFeedback: showDaemonShutdownDialog,
|
||||
}),
|
||||
installAppUpdateOnQuit: async () => {
|
||||
const settings = await getDesktopSettingsStore().get();
|
||||
return installAppUpdateOnQuit({
|
||||
currentVersion: app.getVersion(),
|
||||
releaseChannel: settings.releaseChannel,
|
||||
});
|
||||
},
|
||||
onStopError: (error) => {
|
||||
log.error("[desktop daemon] failed to stop managed daemon on quit", error);
|
||||
},
|
||||
onUpdateError: (error) => {
|
||||
log.error("[auto-updater] failed to validate downloaded update on quit", error);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -293,6 +293,21 @@ describe("agent detach RPC", () => {
|
||||
}
|
||||
expect(parsed.features?.agentDetach).toBe(true);
|
||||
});
|
||||
|
||||
test("parses the workspace-targeted session import feature gate", () => {
|
||||
const parsed = parseServerInfoStatusPayload({
|
||||
status: "server_info",
|
||||
serverId: "srv-test",
|
||||
features: {
|
||||
importSessionWorkspaceTarget: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!parsed) {
|
||||
throw new Error("Expected server info payload to parse");
|
||||
}
|
||||
expect(parsed.features?.importSessionWorkspaceTarget).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent setting action responses", () => {
|
||||
|
||||
@@ -1267,6 +1267,7 @@ export const ImportAgentRequestMessageSchema = z.object({
|
||||
sessionId: z.string().optional(),
|
||||
providerHandleId: z.string().optional(),
|
||||
cwd: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
labels: z.record(z.string(), z.string()).optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
@@ -2564,6 +2565,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
workspaceGithubClone: z.boolean().optional(),
|
||||
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
||||
stableProjectIdentity: z.boolean().optional(),
|
||||
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
|
||||
importSessionWorkspaceTarget: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
|
||||
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
|
||||
|
||||
type AgentLoaderManager = Pick<
|
||||
export type AgentLoaderManager = Pick<
|
||||
AgentManager,
|
||||
| "createAgent"
|
||||
| "getAgent"
|
||||
|
||||
@@ -5970,12 +5970,18 @@ test("unarchiveSnapshot unarchives native provider storage before clearing archi
|
||||
title: "Native unarchive target",
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
{
|
||||
workspaceId: undefined,
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: "archived-parent", retained: "yes" },
|
||||
},
|
||||
);
|
||||
await manager.archiveAgent(agent.id);
|
||||
client.readArchivedAtDuringUnarchive = async () => (await storage.get(agent.id))?.archivedAt;
|
||||
|
||||
const unarchived = await manager.unarchiveSnapshot(agent.id);
|
||||
const unarchived = await manager.unarchiveSnapshot(agent.id, {
|
||||
workspaceId: "ws-restored",
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: null, source: "reimport" },
|
||||
});
|
||||
const stored = await storage.get(agent.id);
|
||||
|
||||
expect(unarchived).toBe(true);
|
||||
@@ -5983,6 +5989,8 @@ test("unarchiveSnapshot unarchives native provider storage before clearing archi
|
||||
expect(client.unarchivedHandles).toEqual(client.archivedHandles);
|
||||
expect(client.archivedAtDuringUnarchive).toEqual(expect.any(String));
|
||||
expect(stored?.archivedAt).toBeNull();
|
||||
expect(stored?.workspaceId).toBe("ws-restored");
|
||||
expect(stored?.labels).toEqual({ retained: "yes", source: "reimport" });
|
||||
});
|
||||
|
||||
test("unarchiveSnapshotByHandle unarchives native provider storage for the matched snapshot", async () => {
|
||||
|
||||
@@ -1669,7 +1669,10 @@ export class AgentManager {
|
||||
return nextRecord;
|
||||
}
|
||||
|
||||
async unarchiveSnapshot(agentId: string): Promise<boolean> {
|
||||
async unarchiveSnapshot(
|
||||
agentId: string,
|
||||
updates?: { workspaceId?: string; labels?: AgentLabelPatch },
|
||||
): Promise<boolean> {
|
||||
const registry = this.requireRegistry();
|
||||
const record = await registry.get(agentId);
|
||||
if (!record || !record.archivedAt) {
|
||||
@@ -1680,6 +1683,8 @@ export class AgentManager {
|
||||
|
||||
await registry.upsert({
|
||||
...record,
|
||||
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
|
||||
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
|
||||
archivedAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
|
||||
export type AgentUnarchiveController = Pick<AgentManager, "notifyAgentState" | "unarchiveSnapshot">;
|
||||
|
||||
export type AgentRunController = Pick<
|
||||
AgentManager,
|
||||
"getAgent" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" | "streamAgent"
|
||||
@@ -91,10 +93,11 @@ export async function startAgentRun(
|
||||
*/
|
||||
export async function unarchiveAgentState(
|
||||
_agentStorage: AgentStorage,
|
||||
agentManager: AgentManager,
|
||||
agentManager: AgentUnarchiveController,
|
||||
agentId: string,
|
||||
updates?: { workspaceId?: string; labels?: Record<string, string | null> },
|
||||
): Promise<boolean> {
|
||||
const unarchived = await agentManager.unarchiveSnapshot(agentId);
|
||||
const unarchived = await agentManager.unarchiveSnapshot(agentId, updates);
|
||||
if (!unarchived) return false;
|
||||
agentManager.notifyAgentState(agentId);
|
||||
return true;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
@@ -7,10 +7,15 @@ import type {
|
||||
ManagedAgent,
|
||||
ManagedImportableProviderSession,
|
||||
} from "./agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
import { AgentStorage, type StoredAgentRecord } from "./agent-storage.js";
|
||||
import type { FetchRecentProviderSessionsRequestMessage } from "@getpaseo/protocol/messages";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import { createPersistedWorkspaceRecord } from "../workspace-registry.js";
|
||||
import type { WorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import {
|
||||
type ImportSessionAgentManager,
|
||||
ImportSessionsRequestError,
|
||||
importProviderSession,
|
||||
listImportableProviderSessions,
|
||||
@@ -18,6 +23,7 @@ import {
|
||||
} from "./import-sessions.js";
|
||||
|
||||
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
|
||||
const importTestDirectories: string[] = [];
|
||||
|
||||
const TEST_CAPABILITIES = {
|
||||
supportsStreaming: true,
|
||||
@@ -32,6 +38,12 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of importTestDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeImportableSession(args: {
|
||||
provider?: string;
|
||||
sessionId: string;
|
||||
@@ -98,6 +110,28 @@ function makeManagedAgent(args: {
|
||||
} satisfies ManagedAgent;
|
||||
}
|
||||
|
||||
function createImportWorkspace(
|
||||
workspaceId: string,
|
||||
): Pick<WorkspaceProvisioningService, "runInImportWorkspace"> {
|
||||
return {
|
||||
async runInImportWorkspace(input, operation) {
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: `project-${workspaceId}`,
|
||||
cwd: input.cwd,
|
||||
kind: "directory",
|
||||
displayName: "imported",
|
||||
createdAt: "2026-04-30T00:00:00.000Z",
|
||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||
});
|
||||
return {
|
||||
value: await operation(workspace),
|
||||
createdWorkspace: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeRequest(
|
||||
overrides: Partial<FetchRecentProviderSessionsRequestMessage> = {},
|
||||
): FetchRecentProviderSessionsRequestMessage {
|
||||
@@ -239,6 +273,87 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
|
||||
});
|
||||
});
|
||||
|
||||
test("listImportableProviderSessions includes a provider session after its Paseo agent is archived", async () => {
|
||||
const cwd = "/tmp/project";
|
||||
const archivedSession = makeImportableSession({
|
||||
provider: "claude",
|
||||
sessionId: "archived-session",
|
||||
cwd,
|
||||
title: "Archived import",
|
||||
lastActivityAt: "2026-04-30T12:00:00.000Z",
|
||||
firstPrompt: "import me again",
|
||||
});
|
||||
|
||||
const result = await listImportableProviderSessions({
|
||||
request: makeRequest({ cwd, providers: ["claude"] }),
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
listImportableSessions: async () => [archivedSession],
|
||||
},
|
||||
agentStorage: {
|
||||
list: async () => [
|
||||
{
|
||||
provider: "claude",
|
||||
archivedAt: "2026-04-30T12:01:00.000Z",
|
||||
persistence: {
|
||||
provider: "claude",
|
||||
sessionId: "archived-session",
|
||||
},
|
||||
} as StoredAgentRecord,
|
||||
],
|
||||
},
|
||||
providerSnapshotManager: { getProviderLabel: () => "Claude" },
|
||||
});
|
||||
|
||||
expect(result.entries.map((entry) => entry.providerHandleId)).toEqual(["archived-session"]);
|
||||
expect(result.filteredAlreadyImportedCount).toBe(0);
|
||||
});
|
||||
|
||||
test("listImportableProviderSessions includes an archived provider session still loaded in memory", async () => {
|
||||
const cwd = "/tmp/project";
|
||||
const agentId = "00000000-0000-4000-8000-000000000633";
|
||||
const archivedSession = makeImportableSession({
|
||||
provider: "claude",
|
||||
sessionId: "archived-live-session",
|
||||
cwd,
|
||||
title: "Archived live import",
|
||||
lastActivityAt: "2026-04-30T12:00:00.000Z",
|
||||
firstPrompt: "import the loaded session again",
|
||||
});
|
||||
|
||||
const result = await listImportableProviderSessions({
|
||||
request: makeRequest({ cwd, providers: ["claude"] }),
|
||||
agentManager: {
|
||||
listAgents: () => [
|
||||
makeManagedAgent({
|
||||
id: agentId,
|
||||
provider: "claude",
|
||||
cwd,
|
||||
sessionId: "archived-live-session",
|
||||
}),
|
||||
],
|
||||
listImportableSessions: async () => [archivedSession],
|
||||
},
|
||||
agentStorage: {
|
||||
list: async () => [
|
||||
{
|
||||
id: agentId,
|
||||
provider: "claude",
|
||||
archivedAt: "2026-04-30T12:01:00.000Z",
|
||||
persistence: {
|
||||
provider: "claude",
|
||||
sessionId: "archived-live-session",
|
||||
},
|
||||
} as StoredAgentRecord,
|
||||
],
|
||||
},
|
||||
providerSnapshotManager: { getProviderLabel: () => "Claude" },
|
||||
});
|
||||
|
||||
expect(result.entries.map((entry) => entry.providerHandleId)).toEqual(["archived-live-session"]);
|
||||
expect(result.filteredAlreadyImportedCount).toBe(0);
|
||||
});
|
||||
|
||||
test("listImportableProviderSessions filters out metadata generation sessions", async () => {
|
||||
const cwd = "/tmp/project";
|
||||
const sessions = [
|
||||
@@ -357,108 +472,335 @@ test("normalizeImportAgentRequest accepts new and legacy import handle shapes",
|
||||
});
|
||||
});
|
||||
|
||||
test("importProviderSession imports a selected provider session without listing", async () => {
|
||||
const cwd = "/tmp/imported-agent";
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
{ type: "user_message", text: "Trace recent provider sessions\n\nkeep it tight" },
|
||||
function makeStoredProviderSession(input: {
|
||||
id: string;
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
nativeHandle?: string;
|
||||
workspaceId?: string;
|
||||
labels?: Record<string, string>;
|
||||
archivedAt?: string | null;
|
||||
}): StoredAgentRecord {
|
||||
return {
|
||||
id: input.id,
|
||||
provider: "codex",
|
||||
cwd: input.cwd,
|
||||
workspaceId: input.workspaceId ?? "ws-archived",
|
||||
createdAt: "2026-04-30T10:00:00.000Z",
|
||||
updatedAt: "2026-04-30T11:00:00.000Z",
|
||||
lastActivityAt: "2026-04-30T10:30:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
labels: input.labels ?? {},
|
||||
config: { provider: "codex", cwd: input.cwd },
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: input.sessionId,
|
||||
nativeHandle: input.nativeHandle ?? input.sessionId,
|
||||
metadata: { provider: "codex", cwd: input.cwd },
|
||||
},
|
||||
archivedAt: input.archivedAt === undefined ? "2026-04-30T12:00:00.000Z" : input.archivedAt,
|
||||
};
|
||||
}
|
||||
|
||||
class ProviderImportHarness {
|
||||
readonly storage: AgentStorage;
|
||||
readonly manager: ImportSessionAgentManager;
|
||||
readonly snapshot: ManagedAgent;
|
||||
readonly freshImports: unknown[] = [];
|
||||
readonly closedAgentIds: string[] = [];
|
||||
timeline: AgentTimelineItem[] = [];
|
||||
activeAgent: ManagedAgent | null = null;
|
||||
resumeError: Error | null = null;
|
||||
resumeAttempts = 0;
|
||||
private unarchiveWait: Promise<void> | null = null;
|
||||
private releaseUnarchive: (() => void) | null = null;
|
||||
|
||||
private constructor(input: { storage: AgentStorage; snapshot: ManagedAgent }) {
|
||||
this.storage = input.storage;
|
||||
this.snapshot = input.snapshot;
|
||||
this.manager = {
|
||||
importProviderSession: async (request: unknown) => {
|
||||
this.freshImports.push(request);
|
||||
this.activeAgent = this.snapshot;
|
||||
return this.snapshot;
|
||||
},
|
||||
unarchiveSnapshot: async (
|
||||
agentId: string,
|
||||
updates?: { workspaceId?: string; labels?: Record<string, string | null> },
|
||||
) => {
|
||||
if (this.unarchiveWait) {
|
||||
await this.unarchiveWait;
|
||||
}
|
||||
const record = await this.storage.get(agentId);
|
||||
if (!record?.archivedAt) {
|
||||
return false;
|
||||
}
|
||||
const labels = { ...record.labels };
|
||||
for (const [key, value] of Object.entries(updates?.labels ?? {})) {
|
||||
if (value === null) {
|
||||
delete labels[key];
|
||||
} else {
|
||||
labels[key] = value;
|
||||
}
|
||||
}
|
||||
await this.storage.upsert({
|
||||
...record,
|
||||
workspaceId: updates?.workspaceId ?? record.workspaceId,
|
||||
labels,
|
||||
archivedAt: null,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
notifyAgentState: () => {},
|
||||
getAgent: () => this.activeAgent,
|
||||
getRegisteredProviderIds: () => ["codex"],
|
||||
createAgent: async () => {
|
||||
throw new Error("Stored provider imports must resume their persisted session");
|
||||
},
|
||||
resumeAgentFromPersistence: async (
|
||||
_handle: unknown,
|
||||
_overrides: unknown,
|
||||
_agentId?: string,
|
||||
_options?: unknown,
|
||||
) => {
|
||||
this.resumeAttempts += 1;
|
||||
if (this.resumeError) {
|
||||
this.activeAgent = this.snapshot;
|
||||
throw this.resumeError;
|
||||
}
|
||||
this.activeAgent = this.snapshot;
|
||||
return this.snapshot;
|
||||
},
|
||||
hydrateTimelineFromProvider: async () => {},
|
||||
getTimeline: () => this.timeline,
|
||||
closeAgent: async (agentId: string) => {
|
||||
this.closedAgentIds.push(agentId);
|
||||
this.activeAgent = null;
|
||||
},
|
||||
archiveSnapshot: async (agentId: string, archivedAt: string) => {
|
||||
const record = await this.storage.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error("Agent not found: " + agentId);
|
||||
}
|
||||
const archived = { ...record, archivedAt };
|
||||
await this.storage.upsert(archived);
|
||||
return archived;
|
||||
},
|
||||
} satisfies ImportSessionAgentManager;
|
||||
}
|
||||
|
||||
static async create(
|
||||
input: {
|
||||
id?: string;
|
||||
cwd?: string;
|
||||
sessionId?: string;
|
||||
nativeHandle?: string;
|
||||
} = {},
|
||||
): Promise<ProviderImportHarness> {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "provider-import-"));
|
||||
importTestDirectories.push(directory);
|
||||
const storage = new AgentStorage(path.join(directory, "agents"), createTestLogger());
|
||||
await storage.initialize();
|
||||
const cwd = input.cwd ?? "/tmp/imported-agent";
|
||||
const sessionId = input.sessionId ?? "thread-imported";
|
||||
const snapshot = makeManagedAgent({
|
||||
id: input.id,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
sessionId,
|
||||
nativeHandle: input.nativeHandle,
|
||||
});
|
||||
return new ProviderImportHarness({ storage, snapshot });
|
||||
}
|
||||
|
||||
async seed(record: StoredAgentRecord): Promise<void> {
|
||||
await this.storage.upsert(record);
|
||||
}
|
||||
|
||||
blockUnarchive(): () => void {
|
||||
this.unarchiveWait = new Promise<void>((resolve) => {
|
||||
this.releaseUnarchive = resolve;
|
||||
});
|
||||
return () => {
|
||||
this.releaseUnarchive?.();
|
||||
this.unarchiveWait = null;
|
||||
this.releaseUnarchive = null;
|
||||
};
|
||||
}
|
||||
|
||||
import(input: { providerHandleId: string; cwd?: string; labels?: Record<string, string> }) {
|
||||
return importProviderSession({
|
||||
request: {
|
||||
requestId: "import-thread",
|
||||
provider: "codex",
|
||||
providerHandleId: input.providerHandleId,
|
||||
cwd: input.cwd,
|
||||
labels: input.labels,
|
||||
},
|
||||
workspaceProvisioning: createImportWorkspace("ws-restored"),
|
||||
agentManager: this.manager,
|
||||
agentStorage: this.storage,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("importProviderSession uses the provider import path with the requested labels", async () => {
|
||||
const harness = await ProviderImportHarness.create();
|
||||
harness.timeline = [
|
||||
{ type: "user_message", text: "Trace recent provider sessions" },
|
||||
{ type: "assistant_message", text: "I will inspect the provider listing." },
|
||||
];
|
||||
const snapshot = makeManagedAgent({
|
||||
id: "00000000-0000-4000-8000-000000000633",
|
||||
provider: "custom-codex",
|
||||
cwd,
|
||||
sessionId: "thread-imported",
|
||||
nativeHandle: "provider-thread-imported",
|
||||
title: null,
|
||||
});
|
||||
const agentManager = {
|
||||
importProviderSession: vi.fn().mockResolvedValue(snapshot),
|
||||
getTimeline: vi.fn().mockReturnValue(timeline),
|
||||
unarchiveSnapshot: vi.fn().mockResolvedValue(false),
|
||||
} as unknown as AgentManager;
|
||||
const agentStorage = {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as AgentStorage;
|
||||
|
||||
const result = await importProviderSession({
|
||||
request: {
|
||||
requestId: "import-thread",
|
||||
provider: "custom-codex",
|
||||
providerHandleId: "provider-thread-imported",
|
||||
cwd,
|
||||
},
|
||||
workspaceId: "ws-imported",
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger: { warn: vi.fn(), error: vi.fn() } as never,
|
||||
});
|
||||
|
||||
expect(agentManager.importProviderSession).toHaveBeenCalledWith({
|
||||
provider: "custom-codex",
|
||||
providerHandleId: "provider-thread-imported",
|
||||
cwd,
|
||||
workspaceId: "ws-imported",
|
||||
labels: undefined,
|
||||
});
|
||||
expect(result).toEqual({ snapshot, timelineSize: 2 });
|
||||
});
|
||||
|
||||
test("importProviderSession passes labels through the manager import operation", async () => {
|
||||
const cwd = "/tmp/imported-agent";
|
||||
const snapshot = makeManagedAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
sessionId: "thread-imported",
|
||||
nativeHandle: "thread-imported",
|
||||
});
|
||||
const agentManager = {
|
||||
importProviderSession: vi.fn().mockResolvedValue(snapshot),
|
||||
getTimeline: vi.fn().mockReturnValue([]),
|
||||
unarchiveSnapshot: vi.fn().mockResolvedValue(false),
|
||||
} as unknown as AgentManager;
|
||||
const agentStorage = {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as AgentStorage;
|
||||
|
||||
await importProviderSession({
|
||||
request: {
|
||||
requestId: "import-thread",
|
||||
provider: "codex",
|
||||
providerHandleId: "thread-imported",
|
||||
cwd,
|
||||
labels: { source: "import" },
|
||||
},
|
||||
workspaceId: "ws-imported",
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger: { warn: vi.fn(), error: vi.fn() } as never,
|
||||
});
|
||||
|
||||
expect(agentManager.importProviderSession).toHaveBeenCalledWith({
|
||||
provider: "codex",
|
||||
const result = await harness.import({
|
||||
providerHandleId: "thread-imported",
|
||||
cwd,
|
||||
workspaceId: "ws-imported",
|
||||
cwd: "/tmp/imported-agent",
|
||||
labels: { source: "import" },
|
||||
});
|
||||
|
||||
expect(harness.freshImports).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
providerHandleId: "thread-imported",
|
||||
cwd: "/tmp/imported-agent",
|
||||
workspaceId: "ws-restored",
|
||||
labels: { source: "import" },
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
snapshot: harness.snapshot,
|
||||
timelineSize: 2,
|
||||
createdWorkspace: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("importProviderSession rejects a provider session with an active stored owner", async () => {
|
||||
const harness = await ProviderImportHarness.create({ sessionId: "thread-active" });
|
||||
await harness.seed(
|
||||
makeStoredProviderSession({
|
||||
id: harness.snapshot.id,
|
||||
cwd: harness.snapshot.cwd,
|
||||
sessionId: "thread-active",
|
||||
archivedAt: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
harness.import({ providerHandleId: "thread-active", cwd: harness.snapshot.cwd }),
|
||||
).rejects.toThrow("Provider session is already imported: thread-active");
|
||||
expect(harness.freshImports).toEqual([]);
|
||||
});
|
||||
|
||||
test("importProviderSession restores an archived session as the same standalone agent", async () => {
|
||||
const harness = await ProviderImportHarness.create({ sessionId: "thread-archived" });
|
||||
harness.timeline = [{ type: "user_message", text: "restored" }];
|
||||
const archived = makeStoredProviderSession({
|
||||
id: harness.snapshot.id,
|
||||
cwd: harness.snapshot.cwd,
|
||||
sessionId: "thread-archived",
|
||||
labels: { existing: "label", [PARENT_AGENT_ID_LABEL]: "archived-parent" },
|
||||
});
|
||||
await harness.seed(archived);
|
||||
|
||||
const result = await harness.import({
|
||||
providerHandleId: "thread-archived",
|
||||
cwd: harness.snapshot.cwd,
|
||||
labels: { source: "reimport" },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
snapshot: harness.snapshot,
|
||||
timelineSize: 1,
|
||||
createdWorkspace: null,
|
||||
});
|
||||
expect(await harness.storage.get(harness.snapshot.id)).toMatchObject({
|
||||
id: harness.snapshot.id,
|
||||
workspaceId: "ws-restored",
|
||||
labels: { existing: "label", source: "reimport" },
|
||||
archivedAt: null,
|
||||
});
|
||||
expect((await harness.storage.get(harness.snapshot.id))?.labels).not.toHaveProperty(
|
||||
PARENT_AGENT_ID_LABEL,
|
||||
);
|
||||
expect(harness.resumeAttempts).toBe(1);
|
||||
expect(harness.freshImports).toEqual([]);
|
||||
});
|
||||
|
||||
test("importProviderSession rejects an archived session from a different cwd before restoring", async () => {
|
||||
const harness = await ProviderImportHarness.create({ sessionId: "thread-other-cwd" });
|
||||
const archived = makeStoredProviderSession({
|
||||
id: harness.snapshot.id,
|
||||
cwd: "/tmp/other-agent",
|
||||
sessionId: "thread-other-cwd",
|
||||
});
|
||||
await harness.seed(archived);
|
||||
|
||||
await expect(
|
||||
harness.import({ providerHandleId: "thread-other-cwd", cwd: "/tmp/target-agent" }),
|
||||
).rejects.toThrow("Provider session cwd does not match import cwd: thread-other-cwd");
|
||||
expect(await harness.storage.get(harness.snapshot.id)).toEqual(archived);
|
||||
expect(harness.resumeAttempts).toBe(0);
|
||||
});
|
||||
|
||||
test("importProviderSession restores storage and closes a partial runtime when loading fails", async () => {
|
||||
const harness = await ProviderImportHarness.create({ sessionId: "thread-stale" });
|
||||
const archived = makeStoredProviderSession({
|
||||
id: harness.snapshot.id,
|
||||
cwd: harness.snapshot.cwd,
|
||||
sessionId: "thread-stale",
|
||||
});
|
||||
await harness.seed(archived);
|
||||
harness.resumeError = new Error("provider session is unavailable");
|
||||
|
||||
await expect(
|
||||
harness.import({ providerHandleId: "thread-stale", cwd: harness.snapshot.cwd }),
|
||||
).rejects.toThrow("provider session is unavailable");
|
||||
|
||||
expect(await harness.storage.get(harness.snapshot.id)).toEqual(archived);
|
||||
expect(harness.activeAgent).toBeNull();
|
||||
expect(harness.closedAgentIds).toEqual([harness.snapshot.id]);
|
||||
});
|
||||
|
||||
test("importProviderSession serializes legacy and native aliases for one archived session", async () => {
|
||||
const harness = await ProviderImportHarness.create({
|
||||
sessionId: "legacy-thread",
|
||||
nativeHandle: "native-thread",
|
||||
});
|
||||
await harness.seed(
|
||||
makeStoredProviderSession({
|
||||
id: harness.snapshot.id,
|
||||
cwd: harness.snapshot.cwd,
|
||||
sessionId: "legacy-thread",
|
||||
nativeHandle: "native-thread",
|
||||
}),
|
||||
);
|
||||
const releaseUnarchive = harness.blockUnarchive();
|
||||
|
||||
const winningRestore = harness.import({
|
||||
providerHandleId: "native-thread",
|
||||
cwd: harness.snapshot.cwd,
|
||||
});
|
||||
const duplicateRestore = harness.import({
|
||||
providerHandleId: "legacy-thread",
|
||||
cwd: harness.snapshot.cwd,
|
||||
});
|
||||
releaseUnarchive();
|
||||
|
||||
await expect(winningRestore).resolves.toMatchObject({
|
||||
snapshot: { id: harness.snapshot.id },
|
||||
timelineSize: 0,
|
||||
});
|
||||
await expect(duplicateRestore).rejects.toThrow(
|
||||
"Provider session is already imported: legacy-thread",
|
||||
);
|
||||
expect(harness.resumeAttempts).toBe(1);
|
||||
expect(harness.closedAgentIds).toEqual([]);
|
||||
});
|
||||
|
||||
test("importProviderSession requires cwd from the selected provider row", async () => {
|
||||
const agentManager = {} as unknown as AgentManager;
|
||||
const harness = await ProviderImportHarness.create();
|
||||
|
||||
await expect(
|
||||
importProviderSession({
|
||||
request: {
|
||||
requestId: "import-thread",
|
||||
provider: "opencode",
|
||||
providerHandleId: "thread-imported",
|
||||
},
|
||||
workspaceId: "ws-imported",
|
||||
agentManager,
|
||||
agentStorage: { list: vi.fn() } as unknown as AgentStorage,
|
||||
logger: { warn: vi.fn(), error: vi.fn() } as never,
|
||||
}),
|
||||
).rejects.toThrow("Import requires cwd from the selected provider session");
|
||||
await expect(harness.import({ providerHandleId: "thread-imported" })).rejects.toThrow(
|
||||
"Import requires cwd from the selected provider session",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,24 +8,44 @@ import type {
|
||||
} from "./agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
import type { AgentPersistenceHandle, AgentProvider } from "./agent-sdk-types.js";
|
||||
import { ensureAgentLoaded, type AgentLoaderManager } from "./agent-loading.js";
|
||||
import { unarchiveAgentState } from "./agent-prompt.js";
|
||||
import { toRecentProviderSessionDescriptorPayload } from "./agent-projections.js";
|
||||
import type { WorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
|
||||
import type {
|
||||
FetchRecentProviderSessionsRequestMessage,
|
||||
ImportAgentRequestMessageSchema,
|
||||
RecentProviderSessionDescriptorPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { getParentAgentIdFromLabels, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { createRealpathAwarePathMatcher } from "../../utils/path.js";
|
||||
|
||||
type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>;
|
||||
|
||||
const METADATA_GENERATION_PROMPT_PREFIX =
|
||||
"Generate metadata for a coding agent based on the user prompt.";
|
||||
export type ImportSessionAgentManager = AgentLoaderManager &
|
||||
Pick<
|
||||
AgentManager,
|
||||
| "archiveSnapshot"
|
||||
| "closeAgent"
|
||||
| "getTimeline"
|
||||
| "importProviderSession"
|
||||
| "notifyAgentState"
|
||||
| "unarchiveSnapshot"
|
||||
>;
|
||||
|
||||
const providerSessionImportMutations = new WeakMap<
|
||||
ImportSessionAgentManager,
|
||||
Map<string, Promise<unknown>>
|
||||
>();
|
||||
|
||||
export interface NormalizedImportAgentRequest {
|
||||
provider: AgentProvider;
|
||||
providerHandleId: string;
|
||||
cwd?: string;
|
||||
workspaceId?: string;
|
||||
labels?: Record<string, string>;
|
||||
requestId: string;
|
||||
}
|
||||
@@ -54,8 +74,8 @@ export interface ListImportableProviderSessionsResult {
|
||||
|
||||
export interface ImportProviderSessionInput {
|
||||
request: NormalizedImportAgentRequest;
|
||||
workspaceId: string;
|
||||
agentManager: AgentManager;
|
||||
workspaceProvisioning: Pick<WorkspaceProvisioningService, "runInImportWorkspace">;
|
||||
agentManager: ImportSessionAgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
logger: Logger;
|
||||
}
|
||||
@@ -63,6 +83,12 @@ export interface ImportProviderSessionInput {
|
||||
export interface ImportProviderSessionResult {
|
||||
snapshot: ManagedAgent;
|
||||
timelineSize: number;
|
||||
createdWorkspace: PersistedWorkspaceRecord | null;
|
||||
}
|
||||
|
||||
interface ImportedProviderSession {
|
||||
snapshot: ManagedAgent;
|
||||
timelineSize: number;
|
||||
}
|
||||
|
||||
// COMPAT(import-agent-request-v1): accept legacy {provider, sessionId} shape
|
||||
@@ -82,6 +108,7 @@ export function normalizeImportAgentRequest(
|
||||
provider: provider as AgentProvider,
|
||||
providerHandleId,
|
||||
cwd: msg.cwd,
|
||||
workspaceId: msg.workspaceId,
|
||||
labels: msg.labels,
|
||||
requestId: msg.requestId,
|
||||
};
|
||||
@@ -138,18 +165,72 @@ export async function listImportableProviderSessions(
|
||||
export async function importProviderSession(
|
||||
input: ImportProviderSessionInput,
|
||||
): Promise<ImportProviderSessionResult> {
|
||||
const { provider, providerHandleId, cwd, labels } = input.request;
|
||||
const cwd = input.request.cwd;
|
||||
if (!cwd) {
|
||||
throw new Error("Import requires cwd from the selected provider session");
|
||||
}
|
||||
const key = await resolveProviderSessionImportMutationKey(input);
|
||||
return serializeProviderSessionImport(input.agentManager, key, async () => {
|
||||
const placement = await input.workspaceProvisioning.runInImportWorkspace(
|
||||
{ cwd, requestedWorkspaceId: input.request.workspaceId },
|
||||
(workspace) => importProviderSessionNow(input, cwd, workspace.workspaceId),
|
||||
);
|
||||
return { ...placement.value, createdWorkspace: placement.createdWorkspace };
|
||||
});
|
||||
}
|
||||
|
||||
async function importProviderSessionNow(
|
||||
input: ImportProviderSessionInput,
|
||||
cwd: string,
|
||||
workspaceId: string,
|
||||
): Promise<ImportedProviderSession> {
|
||||
const { provider, providerHandleId, labels } = input.request;
|
||||
|
||||
const matchingRecords = (await input.agentStorage.list()).filter((record) =>
|
||||
recordMatchesProviderHandle(record, { provider, providerHandleId }),
|
||||
);
|
||||
const activeRecord = matchingRecords.find((record) => !record.archivedAt);
|
||||
if (activeRecord) {
|
||||
throw new Error(`Provider session is already imported: ${providerHandleId}`);
|
||||
}
|
||||
const archivedRecord = matchingRecords.find((record) => record.archivedAt);
|
||||
if (archivedRecord?.persistence && archivedRecord.archivedAt) {
|
||||
if (!createRealpathAwarePathMatcher(cwd)(archivedRecord.cwd)) {
|
||||
throw new Error(`Provider session cwd does not match import cwd: ${providerHandleId}`);
|
||||
}
|
||||
const requestedParentAgentId = getParentAgentIdFromLabels(input.request.labels);
|
||||
const labelPatch: Record<string, string | null> = { ...input.request.labels };
|
||||
if (
|
||||
Object.hasOwn(archivedRecord.labels, PARENT_AGENT_ID_LABEL) ||
|
||||
Object.hasOwn(input.request.labels ?? {}, PARENT_AGENT_ID_LABEL)
|
||||
) {
|
||||
labelPatch[PARENT_AGENT_ID_LABEL] = requestedParentAgentId;
|
||||
}
|
||||
await unarchiveAgentState(input.agentStorage, input.agentManager, archivedRecord.id, {
|
||||
workspaceId,
|
||||
labels: Object.keys(labelPatch).length > 0 ? labelPatch : undefined,
|
||||
});
|
||||
try {
|
||||
const snapshot = await ensureAgentLoaded(archivedRecord.id, {
|
||||
agentManager: input.agentManager,
|
||||
agentStorage: input.agentStorage,
|
||||
logger: input.logger,
|
||||
});
|
||||
return {
|
||||
snapshot,
|
||||
timelineSize: input.agentManager.getTimeline(snapshot.id).length,
|
||||
};
|
||||
} catch (error) {
|
||||
await rollbackArchivedImport(input, archivedRecord, archivedRecord.archivedAt);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const handle = buildImportPersistenceHandle({ provider, providerHandleId, cwd });
|
||||
await unarchiveAgentByHandle(input.agentStorage, input.agentManager, handle);
|
||||
const snapshot = await input.agentManager.importProviderSession({
|
||||
provider,
|
||||
providerHandleId,
|
||||
cwd,
|
||||
workspaceId: input.workspaceId,
|
||||
workspaceId,
|
||||
labels,
|
||||
});
|
||||
await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id);
|
||||
@@ -160,22 +241,80 @@ export async function importProviderSession(
|
||||
};
|
||||
}
|
||||
|
||||
async function unarchiveAgentByHandle(
|
||||
agentStorage: AgentStorage,
|
||||
agentManager: AgentManager,
|
||||
handle: AgentPersistenceHandle,
|
||||
): Promise<void> {
|
||||
const records = await agentStorage.list();
|
||||
const matched = records.find(
|
||||
(record) =>
|
||||
record.persistence?.provider === handle.provider &&
|
||||
(record.persistence.sessionId === handle.sessionId ||
|
||||
record.persistence.nativeHandle === handle.nativeHandle),
|
||||
);
|
||||
if (!matched) {
|
||||
return;
|
||||
async function serializeProviderSessionImport<T>(
|
||||
agentManager: ImportSessionAgentManager,
|
||||
key: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
let mutations = providerSessionImportMutations.get(agentManager);
|
||||
if (!mutations) {
|
||||
mutations = new Map();
|
||||
providerSessionImportMutations.set(agentManager, mutations);
|
||||
}
|
||||
await unarchiveAgentState(agentStorage, agentManager, matched.id);
|
||||
|
||||
const previous = mutations.get(key) ?? Promise.resolve();
|
||||
const next = previous.catch(() => undefined).then(operation);
|
||||
mutations.set(key, next);
|
||||
try {
|
||||
return await next;
|
||||
} finally {
|
||||
if (mutations.get(key) === next) {
|
||||
mutations.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveProviderSessionImportMutationKey(
|
||||
input: ImportProviderSessionInput,
|
||||
): Promise<string> {
|
||||
const identity = {
|
||||
provider: input.request.provider,
|
||||
providerHandleId: input.request.providerHandleId,
|
||||
};
|
||||
const matchingRecord = (await input.agentStorage.list()).find((record) =>
|
||||
recordMatchesProviderHandle(record, identity),
|
||||
);
|
||||
return matchingRecord
|
||||
? `agent\0${matchingRecord.id}`
|
||||
: `handle\0${toProviderSessionHandleKey(identity.provider, identity.providerHandleId)}`;
|
||||
}
|
||||
|
||||
async function rollbackArchivedImport(
|
||||
input: ImportProviderSessionInput,
|
||||
archivedRecord: StoredAgentRecord,
|
||||
archivedAt: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (input.agentManager.getAgent(archivedRecord.id)) {
|
||||
await input.agentManager.closeAgent(archivedRecord.id);
|
||||
}
|
||||
await input.agentManager.archiveSnapshot(archivedRecord.id, archivedAt);
|
||||
} catch (error) {
|
||||
input.logger.error(
|
||||
{ err: error, agentId: archivedRecord.id },
|
||||
"Failed to re-archive provider session after import failure",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await input.agentStorage.upsert(archivedRecord);
|
||||
} catch (error) {
|
||||
input.logger.error(
|
||||
{ err: error, agentId: archivedRecord.id },
|
||||
"Failed to restore archived agent record after import failure",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function recordMatchesProviderHandle(
|
||||
record: StoredAgentRecord,
|
||||
identity: { provider: string; providerHandleId: string },
|
||||
): boolean {
|
||||
return (
|
||||
record.persistence?.provider === identity.provider &&
|
||||
(record.persistence.sessionId === identity.providerHandleId ||
|
||||
record.persistence.nativeHandle === identity.providerHandleId)
|
||||
);
|
||||
}
|
||||
|
||||
function parseRecentProviderSessionsSince(since: string | undefined): number | null {
|
||||
@@ -189,33 +328,25 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function buildImportPersistenceHandle(input: {
|
||||
provider: string;
|
||||
providerHandleId: string;
|
||||
cwd: string;
|
||||
}): AgentPersistenceHandle {
|
||||
return {
|
||||
provider: input.provider,
|
||||
sessionId: input.providerHandleId,
|
||||
nativeHandle: input.providerHandleId,
|
||||
metadata: {
|
||||
provider: input.provider,
|
||||
cwd: input.cwd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function collectImportedProviderSessionHandles(
|
||||
agentManager: Pick<AgentManager, "listAgents">,
|
||||
agentStorage: Pick<AgentStorage, "list">,
|
||||
): Promise<Set<string>> {
|
||||
const handles = new Set<string>();
|
||||
const records = await agentStorage.list();
|
||||
const storedRecordsById = new Map(records.map((record) => [record.id, record]));
|
||||
|
||||
for (const agent of agentManager.listAgents()) {
|
||||
if (storedRecordsById.get(agent.id)?.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
collectProviderSessionHandleKeys(handles, agent.provider, agent.persistence);
|
||||
}
|
||||
|
||||
for (const record of await agentStorage.list()) {
|
||||
for (const record of records) {
|
||||
if (record.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
collectProviderSessionHandleKeys(handles, record.provider, record.persistence);
|
||||
}
|
||||
|
||||
|
||||
@@ -2208,8 +2208,7 @@ describe("ACPAgentSession", () => {
|
||||
expect(assistantMessages[2].messageId).not.toBe(assistantMessages[0].messageId);
|
||||
});
|
||||
|
||||
test("starts an autonomous turn for spontaneous session updates outside a foreground turn", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
test("keeps ACP configuration notifications outside the turn lifecycle", async () => {
|
||||
const session = createSession();
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
|
||||
@@ -2221,49 +2220,26 @@ describe("ACPAgentSession", () => {
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "autonomous-msg",
|
||||
content: { type: "text", text: "Autonomous update" },
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [
|
||||
selectConfigOption("mode", ["plan", "yolo"], "yolo"),
|
||||
selectConfigOption("model", ["kimi-code/kimi-for-coding"]),
|
||||
selectConfigOption("thought_level", ["off", "on"], "on"),
|
||||
],
|
||||
} as SessionUpdate,
|
||||
});
|
||||
|
||||
// Should emit turn_started before the timeline item.
|
||||
const turnStartedIndex = events.findIndex((e) => e.type === "turn_started");
|
||||
expect(turnStartedIndex).toBeGreaterThanOrEqual(0);
|
||||
const timelineIndex = events.findIndex(
|
||||
(e) =>
|
||||
e.type === "timeline" &&
|
||||
e.item.type === "assistant_message" &&
|
||||
e.item.text === "Autonomous update",
|
||||
);
|
||||
expect(timelineIndex).toBeGreaterThan(turnStartedIndex);
|
||||
|
||||
const turnStarted = events[turnStartedIndex];
|
||||
expect(turnStarted.type).toBe("turn_started");
|
||||
const autonomousTurnId = (turnStarted as { turnId?: string }).turnId;
|
||||
expect(autonomousTurnId).toEqual(expect.any(String));
|
||||
|
||||
// Timeline item should be tagged with the autonomous turn id.
|
||||
const timelineEvent = events[timelineIndex];
|
||||
expect(timelineEvent.type).toBe("timeline");
|
||||
expect((timelineEvent as { turnId?: string }).turnId).toBe(autonomousTurnId);
|
||||
|
||||
// Advance timers to complete the autonomous turn.
|
||||
await vi.advanceTimersByTimeAsync(ACPAgentSession["AUTONOMOUS_TURN_TIMEOUT_MS"] + 10);
|
||||
const turnCompleted = events.find((e) => e.type === "turn_completed");
|
||||
expect(turnCompleted).toBeDefined();
|
||||
expect((turnCompleted as { turnId?: string }).turnId).toBe(autonomousTurnId);
|
||||
|
||||
vi.useRealTimers();
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
"thread_started",
|
||||
"mode_changed",
|
||||
"model_changed",
|
||||
"thinking_option_changed",
|
||||
]);
|
||||
});
|
||||
|
||||
test("completes an existing autonomous turn before starting a foreground turn", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
test("forwards out-of-prompt ACP content without inventing a turn", async () => {
|
||||
const session = createSession();
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
asInternals<ACPSessionInternals>(session).connection = {
|
||||
prompt: vi.fn(() => new Promise(() => {})),
|
||||
};
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => {
|
||||
@@ -2274,25 +2250,27 @@ describe("ACPAgentSession", () => {
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "autonomous-msg",
|
||||
content: { type: "text", text: "Autonomous update" },
|
||||
messageId: "unscoped-message",
|
||||
content: { type: "text", text: "Unscoped ACP update" },
|
||||
} as SessionUpdate,
|
||||
});
|
||||
|
||||
const turnStarted = events.find((e) => e.type === "turn_started");
|
||||
expect(turnStarted).toBeDefined();
|
||||
const autonomousTurnId = (turnStarted as { turnId?: string }).turnId;
|
||||
|
||||
// Starting a foreground turn should complete the autonomous turn first.
|
||||
void session.startTurn("user prompt");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
const turnCompleted = events.find(
|
||||
(e) => e.type === "turn_completed" && (e as { turnId?: string }).turnId === autonomousTurnId,
|
||||
);
|
||||
expect(turnCompleted).toBeDefined();
|
||||
|
||||
vi.useRealTimers();
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "thread_started",
|
||||
provider: "claude-acp",
|
||||
sessionId: "session-1",
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude-acp",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "Unscoped ACP update",
|
||||
messageId: "unscoped-message",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("startTurn returns before the ACP prompt settles and completes later via subscribers", async () => {
|
||||
|
||||
@@ -1331,9 +1331,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private readonly extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
private currentTurnUsage: AgentUsage | undefined;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private autonomousTurnId: string | null = null;
|
||||
private autonomousTurnTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private static readonly AUTONOMOUS_TURN_TIMEOUT_MS = 30_000;
|
||||
private fallbackAssistantMessageId: string | null = null;
|
||||
private closed = false;
|
||||
private historyPending = false;
|
||||
@@ -1475,7 +1472,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
if (this.activeForegroundTurnId) {
|
||||
throw new Error("A foreground turn is already active");
|
||||
}
|
||||
this.completeAutonomousTurn();
|
||||
|
||||
const turnId = randomUUID();
|
||||
const messageId = options?.messageId ?? randomUUID();
|
||||
@@ -2119,7 +2115,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
agentId: this.agentId,
|
||||
provider: this.provider,
|
||||
sessionId: this.sessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurnId ?? undefined,
|
||||
turnId: this.activeForegroundTurnId ?? undefined,
|
||||
rawEvent: params,
|
||||
events,
|
||||
},
|
||||
@@ -2134,11 +2130,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (events.length > 0 && !this.activeForegroundTurnId) {
|
||||
this.startAutonomousTurn();
|
||||
this.resetAutonomousTurnTimer();
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
this.pushEvent(event);
|
||||
}
|
||||
@@ -2672,25 +2663,23 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurnId ?? undefined,
|
||||
turnId: this.activeForegroundTurnId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private pushEvent(event: AgentStreamEvent): void {
|
||||
const turnId = this.activeForegroundTurnId ?? this.autonomousTurnId;
|
||||
const tagged = event.type === "timeline" && turnId ? { ...event, turnId } : event;
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: this.provider,
|
||||
sessionId: this.sessionId,
|
||||
turnId: getAgentStreamEventTurnId(tagged) ?? turnId ?? undefined,
|
||||
event: tagged,
|
||||
turnId: getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? undefined,
|
||||
event,
|
||||
},
|
||||
"provider.acp.event_emit",
|
||||
);
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(tagged);
|
||||
subscriber(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2738,41 +2727,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.pushEvent(event);
|
||||
}
|
||||
|
||||
private startAutonomousTurn(): void {
|
||||
if (this.autonomousTurnId) {
|
||||
return;
|
||||
}
|
||||
this.autonomousTurnId = randomUUID();
|
||||
this.pushEvent({
|
||||
type: "turn_started",
|
||||
provider: this.provider,
|
||||
turnId: this.autonomousTurnId,
|
||||
});
|
||||
}
|
||||
|
||||
private completeAutonomousTurn(): void {
|
||||
if (!this.autonomousTurnId) {
|
||||
return;
|
||||
}
|
||||
if (this.autonomousTurnTimer) {
|
||||
clearTimeout(this.autonomousTurnTimer);
|
||||
this.autonomousTurnTimer = null;
|
||||
}
|
||||
const turnId = this.autonomousTurnId;
|
||||
this.autonomousTurnId = null;
|
||||
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||
}
|
||||
|
||||
private resetAutonomousTurnTimer(): void {
|
||||
if (this.autonomousTurnTimer) {
|
||||
clearTimeout(this.autonomousTurnTimer);
|
||||
}
|
||||
this.autonomousTurnTimer = setTimeout(() => {
|
||||
this.completeAutonomousTurn();
|
||||
}, ACPAgentSession.AUTONOMOUS_TURN_TIMEOUT_MS);
|
||||
this.autonomousTurnTimer.unref?.();
|
||||
}
|
||||
|
||||
private isSubmittedUserMessageEcho(
|
||||
item: Extract<AgentTimelineItem, { type: "user_message" }>,
|
||||
): boolean {
|
||||
|
||||
@@ -775,6 +775,7 @@ export async function createPaseoDaemon(
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
});
|
||||
const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" });
|
||||
const providerSnapshotManager = new ProviderSnapshotManager({
|
||||
|
||||
@@ -721,6 +721,7 @@ export class Session {
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.workspaceRecovery = createWorkspaceRecoveryService({
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
@@ -2855,19 +2856,16 @@ export class Session {
|
||||
if (!normalized.cwd) {
|
||||
throw new Error("Import requires cwd from the selected provider session");
|
||||
}
|
||||
// An imported agent mints its own workspace; ownership is its workspaceId,
|
||||
// never an existing same-cwd workspace resolved by path.
|
||||
const workspace = await this.workspaceProvisioning.createWorkspaceForDirectory(
|
||||
normalized.cwd,
|
||||
);
|
||||
const { snapshot, timelineSize } = await importProviderSession({
|
||||
const { snapshot, timelineSize, createdWorkspace } = await importProviderSession({
|
||||
request: normalized,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceProvisioning: this.workspaceProvisioning,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
await this.registerWorkspaceForImportedAgent(workspace);
|
||||
if (createdWorkspace) {
|
||||
await this.registerWorkspaceForImportedAgent(createdWorkspace);
|
||||
}
|
||||
const agentPayload = await this.buildAgentPayload(snapshot);
|
||||
this.emit({
|
||||
type: "status",
|
||||
|
||||
@@ -3977,6 +3977,88 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("import_agent_request imports into the workspace that opened the import sheet", async () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const workspaceId = "ws-repo-running";
|
||||
let importedWorkspaceId: string | undefined;
|
||||
let workspaceCreated = false;
|
||||
|
||||
session.projectRegistry.get = async () =>
|
||||
createPersistedProjectRecord({
|
||||
projectId: "proj-repo-running",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "non_git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
session.workspaceRegistry.upsert = async () => {
|
||||
workspaceCreated = true;
|
||||
};
|
||||
|
||||
session.agentManager.importProviderSession = async (input: unknown) => {
|
||||
importedWorkspaceId = (input as { workspaceId: string }).workspaceId;
|
||||
return makeManagedAgent({
|
||||
id: "imported-agent",
|
||||
cwd: REPO_CWD,
|
||||
workspaceId: importedWorkspaceId,
|
||||
lifecycle: "idle",
|
||||
updatedAt: "2026-05-21T00:00:00.000Z",
|
||||
});
|
||||
};
|
||||
session.agentManager.getTimeline = () => [];
|
||||
session.agentStorage.list = async () => [];
|
||||
session.agentStorage.get = async () => null;
|
||||
session.agentUpdates.forwardLiveAgent = async () => undefined;
|
||||
|
||||
await session.handleMessage({
|
||||
type: "import_agent_request",
|
||||
requestId: "req-import-current-workspace",
|
||||
providerId: "codex",
|
||||
providerHandleId: "session-xyz",
|
||||
cwd: REPO_CWD,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
expect(importedWorkspaceId).toBe(workspaceId);
|
||||
expect(workspaceCreated).toBe(false);
|
||||
});
|
||||
|
||||
test("import_agent_request maps an import failure to agent_create_failed", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => emitted.push(message),
|
||||
});
|
||||
session.projectRegistry.get = async () =>
|
||||
createPersistedProjectRecord({
|
||||
projectId: "proj-repo-running",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "non_git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
session.agentStorage.list = async () => [];
|
||||
session.agentManager.importProviderSession = async () => {
|
||||
throw new Error("provider session is unavailable");
|
||||
};
|
||||
|
||||
await session.handleMessage({
|
||||
type: "import_agent_request",
|
||||
requestId: "req-failed-import",
|
||||
providerId: "codex",
|
||||
providerHandleId: "stale-session",
|
||||
cwd: REPO_CWD,
|
||||
workspaceId: "ws-repo-running",
|
||||
});
|
||||
|
||||
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
||||
status: "agent_create_failed",
|
||||
requestId: "req-failed-import",
|
||||
error: "provider session is unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
test("open_project_response returns immediately even when the GitHub fetch is slow", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
||||
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FileBackedProjectRegistry,
|
||||
FileBackedWorkspaceRegistry,
|
||||
createPersistedWorkspaceRecord,
|
||||
type PersistedProjectRecord,
|
||||
type WorkspaceRegistry,
|
||||
} from "../../workspace-registry.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
|
||||
const logger = createTestLogger();
|
||||
const ARCHIVED_AT = "2026-01-01T00:00:00.000Z";
|
||||
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
|
||||
|
||||
let tmpDir: string;
|
||||
let gitRoots: Set<string>;
|
||||
@@ -80,6 +82,7 @@ beforeEach(async () => {
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: gitService(),
|
||||
logger,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,6 +172,7 @@ test("persists manual worktree ownership separately from its workspace kind", as
|
||||
mainRepoRoot,
|
||||
}),
|
||||
}),
|
||||
logger,
|
||||
});
|
||||
|
||||
const workspace = await manualWorktreeProvisioning.findOrCreateWorkspaceForDirectory(cwd);
|
||||
@@ -227,6 +231,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
}),
|
||||
logger,
|
||||
});
|
||||
|
||||
const reopened = await archivedProvisioning.ensureWorkspaceRecordUnarchived(workspace);
|
||||
@@ -265,6 +270,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async (
|
||||
workspaceRegistry: snapshotRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: gitService(),
|
||||
logger,
|
||||
});
|
||||
|
||||
const reopened = await snapshotProvisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
@@ -478,3 +484,137 @@ test("findOrCreateProjectForDirectory keeps nested selected roots independent",
|
||||
expect(second.rootPath).toBe(path.join(repo, "sub"));
|
||||
expect(await projectRegistry.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("runInImportWorkspace uses an active requested workspace without creating another", async () => {
|
||||
const cwd = path.join(tmpDir, "requested");
|
||||
mkdirSync(cwd);
|
||||
const workspace = await provisioning.createWorkspaceForDirectory(cwd);
|
||||
|
||||
const result = await provisioning.runInImportWorkspace(
|
||||
{ cwd, requestedWorkspaceId: workspace.workspaceId },
|
||||
async (target) => target.workspaceId,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: workspace.workspaceId, createdWorkspace: null });
|
||||
expect(await workspaceRegistry.list()).toEqual([workspace]);
|
||||
});
|
||||
|
||||
test.each(["missing", "archived"] as const)(
|
||||
"runInImportWorkspace rejects a %s requested workspace before importing",
|
||||
async (state) => {
|
||||
const cwd = path.join(tmpDir, "unavailable-workspace");
|
||||
mkdirSync(cwd);
|
||||
const workspace = await provisioning.createWorkspaceForDirectory(cwd);
|
||||
if (state === "archived") {
|
||||
await workspaceRegistry.archive(workspace.workspaceId, ARCHIVED_AT);
|
||||
} else {
|
||||
await workspaceRegistry.remove(workspace.workspaceId);
|
||||
}
|
||||
let imported = false;
|
||||
|
||||
await expect(
|
||||
provisioning.runInImportWorkspace(
|
||||
{ cwd, requestedWorkspaceId: workspace.workspaceId },
|
||||
async () => {
|
||||
imported = true;
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(`Workspace not found: ${workspace.workspaceId}`);
|
||||
expect(imported).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["missing", "archived"] as const)(
|
||||
"runInImportWorkspace rejects a requested workspace whose project is %s before importing",
|
||||
async (state) => {
|
||||
const cwd = path.join(tmpDir, "unavailable-project");
|
||||
mkdirSync(cwd);
|
||||
const workspace = await provisioning.createWorkspaceForDirectory(cwd);
|
||||
if (state === "archived") {
|
||||
await projectRegistry.archive(workspace.projectId, ARCHIVED_AT);
|
||||
} else {
|
||||
await projectRegistry.remove(workspace.projectId);
|
||||
}
|
||||
let imported = false;
|
||||
|
||||
await expect(
|
||||
provisioning.runInImportWorkspace(
|
||||
{ cwd, requestedWorkspaceId: workspace.workspaceId },
|
||||
async () => {
|
||||
imported = true;
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(`Project not found: ${workspace.projectId}`);
|
||||
expect(imported).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test("runInImportWorkspace accepts a filesystem-equivalent requested cwd", async () => {
|
||||
const cwd = path.join(tmpDir, "real-directory");
|
||||
const alias = path.join(tmpDir, "directory-alias");
|
||||
mkdirSync(cwd);
|
||||
symlinkSync(cwd, alias, directorySymlinkType);
|
||||
const workspace = await provisioning.createWorkspaceForDirectory(cwd);
|
||||
|
||||
const result = await provisioning.runInImportWorkspace(
|
||||
{ cwd: alias, requestedWorkspaceId: workspace.workspaceId },
|
||||
async (target) => target.workspaceId,
|
||||
);
|
||||
|
||||
expect(result.value).toBe(workspace.workspaceId);
|
||||
});
|
||||
|
||||
test("runInImportWorkspace rejects a requested workspace with a different cwd", async () => {
|
||||
const cwd = path.join(tmpDir, "workspace-directory");
|
||||
const otherCwd = path.join(tmpDir, "other-directory");
|
||||
mkdirSync(cwd);
|
||||
mkdirSync(otherCwd);
|
||||
const workspace = await provisioning.createWorkspaceForDirectory(cwd);
|
||||
let imported = false;
|
||||
|
||||
await expect(
|
||||
provisioning.runInImportWorkspace(
|
||||
{ cwd: otherCwd, requestedWorkspaceId: workspace.workspaceId },
|
||||
async () => {
|
||||
imported = true;
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(`Import cwd does not match workspace: ${workspace.workspaceId}`);
|
||||
expect(imported).toBe(false);
|
||||
});
|
||||
|
||||
test("runInImportWorkspace creates one fresh workspace for an untargeted import", async () => {
|
||||
const cwd = path.join(tmpDir, "fresh-import");
|
||||
mkdirSync(cwd);
|
||||
|
||||
const result = await provisioning.runInImportWorkspace(
|
||||
{ cwd },
|
||||
async (workspace) => workspace.workspaceId,
|
||||
);
|
||||
|
||||
expect(result.value).toBe(result.createdWorkspace?.workspaceId);
|
||||
expect(await workspaceRegistry.list()).toEqual([result.createdWorkspace]);
|
||||
});
|
||||
|
||||
test.each(["missing", "archived"] as const)(
|
||||
"runInImportWorkspace restores the exact %s project state when an untargeted import fails",
|
||||
async (state) => {
|
||||
const cwd = path.join(tmpDir, `failed-import-${state}`);
|
||||
mkdirSync(cwd);
|
||||
let previousProject: PersistedProjectRecord | null = null;
|
||||
if (state === "archived") {
|
||||
const project = await provisioning.findOrCreateProjectForDirectory(cwd);
|
||||
await projectRegistry.archive(project.projectId, ARCHIVED_AT);
|
||||
previousProject = await projectRegistry.get(project.projectId);
|
||||
}
|
||||
|
||||
await expect(
|
||||
provisioning.runInImportWorkspace({ cwd }, async () => {
|
||||
throw new Error("provider session is unavailable");
|
||||
}),
|
||||
).rejects.toThrow("provider session is unavailable");
|
||||
|
||||
expect(await workspaceRegistry.list()).toEqual([]);
|
||||
expect(await projectRegistry.list()).toEqual(previousProject ? [previousProject] : []);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { basename, resolve } from "node:path";
|
||||
import { areEquivalentPaths } from "../../../utils/path.js";
|
||||
|
||||
import type { Logger } from "pino";
|
||||
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
import {
|
||||
deriveWorkspaceDisplayName,
|
||||
deriveWorkspaceKind,
|
||||
@@ -23,7 +23,21 @@ export interface ResolveOrCreateWorkspaceIdInput {
|
||||
initialTitle: string | null;
|
||||
}
|
||||
|
||||
export interface ImportWorkspaceInput {
|
||||
cwd: string;
|
||||
requestedWorkspaceId?: string;
|
||||
}
|
||||
|
||||
export interface ImportWorkspaceResult<T> {
|
||||
value: T;
|
||||
createdWorkspace: PersistedWorkspaceRecord | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceProvisioningService {
|
||||
runInImportWorkspace<T>(
|
||||
input: ImportWorkspaceInput,
|
||||
operation: (workspace: PersistedWorkspaceRecord) => Promise<T>,
|
||||
): Promise<ImportWorkspaceResult<T>>;
|
||||
findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord>;
|
||||
resolveOrCreateWorkspaceIdForCreateAgent(input: ResolveOrCreateWorkspaceIdInput): Promise<string>;
|
||||
createWorkspaceForDirectory(
|
||||
@@ -57,8 +71,72 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">;
|
||||
logger: Logger;
|
||||
}): WorkspaceProvisioningService {
|
||||
const { workspaceRegistry, projectRegistry, workspaceGitService } = deps;
|
||||
const { workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps;
|
||||
|
||||
async function runInImportWorkspace<T>(
|
||||
input: ImportWorkspaceInput,
|
||||
operation: (workspace: PersistedWorkspaceRecord) => Promise<T>,
|
||||
): Promise<ImportWorkspaceResult<T>> {
|
||||
if (input.requestedWorkspaceId) {
|
||||
const workspace = await workspaceRegistry.get(input.requestedWorkspaceId);
|
||||
if (!workspace || workspace.archivedAt) {
|
||||
throw new Error(`Workspace not found: ${input.requestedWorkspaceId}`);
|
||||
}
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
if (!project || project.archivedAt) {
|
||||
throw new Error(`Project not found: ${workspace.projectId}`);
|
||||
}
|
||||
if (!createRealpathAwarePathMatcher(workspace.cwd)(input.cwd)) {
|
||||
throw new Error(`Import cwd does not match workspace: ${workspace.workspaceId}`);
|
||||
}
|
||||
return {
|
||||
value: await operation(workspace),
|
||||
createdWorkspace: null,
|
||||
};
|
||||
}
|
||||
|
||||
const projectsBeforeImport = await projectRegistry.list();
|
||||
const workspace = await createWorkspaceForDirectory(input.cwd);
|
||||
const previousProject =
|
||||
projectsBeforeImport.find((project) => project.projectId === workspace.projectId) ?? null;
|
||||
|
||||
try {
|
||||
return {
|
||||
value: await operation(workspace),
|
||||
createdWorkspace: workspace,
|
||||
};
|
||||
} catch (error) {
|
||||
await rollbackFailedImportWorkspace(workspace, previousProject);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackFailedImportWorkspace(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
previousProject: PersistedProjectRecord | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await workspaceRegistry.remove(workspace.workspaceId);
|
||||
const projectHasActiveWorkspace = (await workspaceRegistry.list()).some(
|
||||
(candidate) => candidate.projectId === workspace.projectId && !candidate.archivedAt,
|
||||
);
|
||||
if (projectHasActiveWorkspace) {
|
||||
return;
|
||||
}
|
||||
if (previousProject?.archivedAt) {
|
||||
await projectRegistry.upsert(previousProject);
|
||||
} else if (!previousProject) {
|
||||
await projectRegistry.remove(workspace.projectId);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ err: error, workspaceId: workspace.workspaceId, projectId: workspace.projectId },
|
||||
"Failed to restore workspace state after provider import failure",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord> {
|
||||
const rootPath = resolve(cwd);
|
||||
@@ -238,6 +316,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
}
|
||||
|
||||
return {
|
||||
runInImportWorkspace,
|
||||
findOrCreateWorkspaceForDirectory,
|
||||
resolveOrCreateWorkspaceIdForCreateAgent,
|
||||
createWorkspaceForDirectory,
|
||||
|
||||
@@ -1277,6 +1277,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
workspaceGithubClone: true,
|
||||
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
||||
stableProjectIdentity: true,
|
||||
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
|
||||
importSessionWorkspaceTarget: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -366,6 +366,7 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.changelog-release-notes blockquote,
|
||||
.docs-prose blockquote {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
@@ -376,10 +377,12 @@
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.changelog-release-notes blockquote > :first-child,
|
||||
.docs-prose blockquote > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.changelog-release-notes blockquote > :last-child,
|
||||
.docs-prose blockquote > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user