mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c05f69402 | ||
|
|
8fdc41f06c | ||
|
|
928c124356 | ||
|
|
0a398833d8 | ||
|
|
71c66823aa | ||
|
|
a1821d5863 | ||
|
|
5db070a4d9 | ||
|
|
461be47c91 | ||
|
|
92d7066601 | ||
|
|
d28e174b38 | ||
|
|
a257a1c5f6 | ||
|
|
144f951a79 | ||
|
|
51fea4b7e0 | ||
|
|
e98ba1beb1 | ||
|
|
3bbbfbc880 | ||
|
|
f86226a196 | ||
|
|
120842264e | ||
|
|
a017ddfc29 | ||
|
|
03dcda7c41 | ||
|
|
ada0955334 | ||
|
|
ca5c786ccf | ||
|
|
5ae53c7e55 |
@@ -1,11 +1,8 @@
|
||||
name: Deploy Relay
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/relay/**"
|
||||
- ".github/workflows/deploy-relay.yml"
|
||||
# Manual-only while relay.paseo.sh bridges traffic to the Fly deployment.
|
||||
# A release or main push must not redeploy the temporary Cloudflare bridge.
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.106 - 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
- Approve Codex MCP permission requests in Paseo ([#2001](https://github.com/getpaseo/paseo/pull/2001))
|
||||
|
||||
### Improved
|
||||
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reduced mobile chat freezes and blank screens when switching workspaces while agents are streaming ([#1989](https://github.com/getpaseo/paseo/pull/1989))
|
||||
- OpenCode sessions start reliably instead of occasionally losing the first turn ([#2015](https://github.com/getpaseo/paseo/pull/2015) by [@mcowger](https://github.com/mcowger))
|
||||
- Switching between workspaces no longer flashes a white screen
|
||||
- Pi keeps your existing MCP tools and settings when Paseo adds its own ([#1990](https://github.com/getpaseo/paseo/pull/1990) by [@mcowger](https://github.com/mcowger))
|
||||
|
||||
## 0.1.105 - 2026-07-10
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -218,7 +218,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||
- Color changes for disabled state. Opacity only.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Archive workspace is confirmed only when its worktree backing reports uncommitted changes or unpushed commits; otherwise it archives immediately.
|
||||
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
||||
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
||||
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
||||
|
||||
@@ -96,6 +96,18 @@ Keep workspace identity and retention outside native-stack `getId` and
|
||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||
reordering an already-mounted workspace screen.
|
||||
|
||||
Use `ThemedStack` from `packages/app/src/navigation/themed-stack.tsx` for every
|
||||
Expo Router stack. React Navigation otherwise paints each native stack screen
|
||||
with its light default background. A screen-level wrapper can hide that surface
|
||||
while settled, but Android may expose it for one frame when navigation crosses
|
||||
from a nested stack to its parent stack. This is especially visible when an
|
||||
app-wide route such as `/new` opens from a dark workspace.
|
||||
|
||||
Do not read the active theme with `useUnistyles()` in a layout to build
|
||||
`screenOptions`. `ThemedStack` keeps that third-party prop theme-reactive through
|
||||
a small `withUnistyles` boundary without subscribing the route tree itself to
|
||||
every Unistyles runtime update.
|
||||
|
||||
## Regression Shape
|
||||
|
||||
Pure helper tests are useful but not enough. The failure mode here is native
|
||||
|
||||
@@ -4,6 +4,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append
|
||||
|
||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
|
||||
|
||||
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
||||
|
||||
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
|
||||
|
||||
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
|
||||
|
||||
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
|
||||
|
||||
@@ -9,6 +9,11 @@ The invariant is:
|
||||
|
||||
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
|
||||
|
||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output and failed
|
||||
shell error text are capped at 64 KiB of UTF-8 data, and the same bounded item is used for durable
|
||||
timeline rows and live stream events. Provider history hydration applies the same rule so reopening
|
||||
an agent cannot restore an oversized tool payload.
|
||||
|
||||
## Presence is not delivery
|
||||
|
||||
Client heartbeat reports presence:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-cU6qRXY9fnr80pllmqJts5w8+OiE6F99SRo2d9G0HDA=
|
||||
sha256-lYvP0RKgxshO68+bjkkUXX9YJtzgoGIp8kGdGFYOT+o=
|
||||
|
||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -35152,7 +35152,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -36170,12 +36170,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/server": "0.1.105",
|
||||
"@getpaseo/client": "0.1.106",
|
||||
"@getpaseo/protocol": "0.1.106",
|
||||
"@getpaseo/server": "0.1.106",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36421,10 +36421,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/relay": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.106",
|
||||
"@getpaseo/relay": "0.1.106",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36435,7 +36435,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36678,7 +36678,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37574,7 +37574,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37806,7 +37806,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37819,7 +37819,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38037,15 +38037,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.105",
|
||||
"@getpaseo/highlight": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/relay": "0.1.105",
|
||||
"@getpaseo/client": "0.1.106",
|
||||
"@getpaseo/highlight": "0.1.106",
|
||||
"@getpaseo/protocol": "0.1.106",
|
||||
"@getpaseo/relay": "0.1.106",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38582,7 +38582,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
@@ -11,7 +12,7 @@ function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(workspaceRowTestId(workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
@@ -21,10 +22,6 @@ async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promis
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
// Hiding a checkout from the sidebar raises a browser confirm; accept it so the
|
||||
// user-confirmed archive proceeds deterministically.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
|
||||
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
|
||||
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
|
||||
await archiveItem.click();
|
||||
@@ -159,17 +156,21 @@ test.describe("Project with no workspaces persists", () => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
||||
const workspaceRow = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
await expect(workspaceRow).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await workspaceRow.click();
|
||||
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||
|
||||
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await archiveWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
// The workspace row goes away, but its project parent stays and exposes a
|
||||
// child row for creating the next workspace.
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(existsSync(workspace.repoPath)).toBe(true);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toContainText("New workspace");
|
||||
|
||||
@@ -39,10 +39,9 @@ export async function clickArchiveWorkspaceMenuItem(
|
||||
await archiveItem.click();
|
||||
}
|
||||
|
||||
export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// A clean worktree archives with no prompt; if the host reports unsynced work the app
|
||||
// raises a browser confirm. Accept it so the user-confirmed archive stays deterministic
|
||||
// either way.
|
||||
export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// A clean workspace archives with no prompt. Managed worktree backing may raise
|
||||
// a browser confirm for unsynced work, so accept it when present.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await clickArchiveWorkspaceMenuItem(page, workspaceId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { expectWorkspaceAbsentFromSidebar, selectWorkspaceInSidebar } from "./helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Workspace archive shortcut", () => {
|
||||
test("archives the selected workspace without removing its local checkout", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "archive-shortcut-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+Shift+Backspace`);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
|
||||
expect(existsSync(workspace.repoPath)).toBe(true);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ async function clickArchiveAndAnswerWarning(
|
||||
return warning;
|
||||
}
|
||||
|
||||
test.describe("Worktree archive risk warning", () => {
|
||||
test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
@@ -97,7 +97,7 @@ test.describe("Worktree archive risk warning", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("a risky worktree archive is gated by confirmation and removes the directory after acceptance", async ({
|
||||
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { archiveWorktreeFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Worktree archive", () => {
|
||||
test.describe("Workspace archive with worktree backing", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
@@ -33,9 +33,7 @@ test.describe("Worktree archive", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("archiving a worktree from the sidebar removes its row and worktree directory", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
@@ -49,11 +47,51 @@ test.describe("Worktree archive", () => {
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
|
||||
await archiveWorktreeFromSidebar(page, worktree.workspaceId);
|
||||
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const first = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `shared-archive-${Date.now()}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(first.workspaceDirectory);
|
||||
|
||||
const siblingPayload = await client.createWorkspace({
|
||||
source: {
|
||||
kind: "directory",
|
||||
path: first.workspaceDirectory,
|
||||
},
|
||||
title: "Second workspace",
|
||||
});
|
||||
if (!siblingPayload.workspace) {
|
||||
throw new Error(siblingPayload.error ?? "Failed to create a workspace on the worktree");
|
||||
}
|
||||
const sibling = siblingPayload.workspace;
|
||||
expect(sibling.workspaceKind).toBe("worktree");
|
||||
expect(sibling.workspaceDirectory).toBe(first.workspaceDirectory);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: first.workspaceId });
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, first.workspaceId);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, first.workspaceId);
|
||||
expect(existsSync(first.workspaceDirectory)).toBe(true);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, sibling.id);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, sibling.id);
|
||||
await expect.poll(() => existsSync(first.workspaceDirectory), { timeout: 30_000 }).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
@@ -784,21 +785,15 @@ function FaviconStatusSync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ROOT_STACK_SCREEN_OPTIONS = {
|
||||
headerShown: false,
|
||||
animation: "none" as const,
|
||||
};
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
const { theme } = useUnistyles();
|
||||
const stackScreenOptions = useMemo(
|
||||
() => ({
|
||||
headerShown: false,
|
||||
animation: "none" as const,
|
||||
contentStyle: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
}),
|
||||
[theme.colors.surface0],
|
||||
);
|
||||
return (
|
||||
<Stack screenOptions={stackScreenOptions}>
|
||||
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Protected guard={storeReady}>
|
||||
<Stack.Screen name="welcome" />
|
||||
@@ -815,7 +810,7 @@ function RootStack() {
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
||||
</Stack>
|
||||
</ThemedStack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Redirect, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { HostRouteProvider } from "@/navigation/host-route-context";
|
||||
import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||
|
||||
const HOST_STACK_SCREEN_OPTIONS = {
|
||||
@@ -33,14 +34,14 @@ function KnownHostRoute() {
|
||||
}
|
||||
|
||||
const stack = (
|
||||
<Stack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||
<ThemedStack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="workspace/[workspaceId]/index" />
|
||||
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="settings" />
|
||||
</Stack>
|
||||
</ThemedStack>
|
||||
);
|
||||
|
||||
if (!routeServerId) {
|
||||
|
||||
@@ -77,7 +77,6 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
|
||||
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||
@@ -112,7 +111,7 @@ import {
|
||||
} from "@/utils/sidebar-project-row-model";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import {
|
||||
getCurrentProjectRemoveReadiness,
|
||||
@@ -284,16 +283,6 @@ interface WorkspaceRowInnerProps {
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
function getWorkspaceArchiveStatus(
|
||||
isWorktree: boolean,
|
||||
archiveStatus: "idle" | "pending" | "success",
|
||||
isArchivingWorkspace: boolean,
|
||||
): "idle" | "pending" | "success" {
|
||||
if (isWorktree) return archiveStatus;
|
||||
if (isArchivingWorkspace) return "pending";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -1454,20 +1443,7 @@ function WorkspaceRowWithMenu({
|
||||
const toast = useToast();
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
workspaceDirectory
|
||||
? state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspaceDirectory,
|
||||
actionId: "archive-worktree",
|
||||
})
|
||||
: "idle",
|
||||
);
|
||||
const isWorktree = workspace.workspaceKind === "worktree";
|
||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
||||
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
@@ -1479,7 +1455,6 @@ function WorkspaceRowWithMenu({
|
||||
const archiveController = useWorkspaceArchive({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
@@ -1546,7 +1521,7 @@ function WorkspaceRowWithMenu({
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
@@ -1558,8 +1533,8 @@ function WorkspaceRowWithMenu({
|
||||
}, [clearAttention, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
||||
actions: ["worktree.archive"],
|
||||
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||
actions: ["workspace.archive"],
|
||||
enabled: selected && !isArchiving,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
@@ -1584,11 +1559,7 @@ function WorkspaceRowWithMenu({
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||
archiveStatus={getWorkspaceArchiveStatus(
|
||||
isWorktree,
|
||||
worktreeArchiveStatus,
|
||||
isHidingWorkspace,
|
||||
)}
|
||||
archiveStatus={isArchiving ? "pending" : "idle"}
|
||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
|
||||
@@ -23,10 +23,9 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
@@ -330,20 +329,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
const toast = useToast();
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
workspaceDirectory
|
||||
? state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspaceDirectory,
|
||||
actionId: "archive-worktree",
|
||||
})
|
||||
: "idle",
|
||||
);
|
||||
const isWorktree = workspace.workspaceKind === "worktree";
|
||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
||||
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
||||
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
@@ -358,7 +344,6 @@ function StatusWorkspaceRowWithMenu({
|
||||
const archiveController = useWorkspaceArchive({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
@@ -408,7 +393,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
@@ -420,8 +405,8 @@ function StatusWorkspaceRowWithMenu({
|
||||
}, [clearAttention, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
||||
actions: ["worktree.archive"],
|
||||
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||
actions: ["workspace.archive"],
|
||||
enabled: selected && !isArchiving,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
@@ -430,13 +415,6 @@ function StatusWorkspaceRowWithMenu({
|
||||
},
|
||||
});
|
||||
|
||||
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
|
||||
if (isWorktree) {
|
||||
computedArchiveStatus = worktreeArchiveStatus;
|
||||
} else if (isHidingWorkspace) {
|
||||
computedArchiveStatus = "pending";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusWorkspaceRowInner
|
||||
@@ -448,7 +426,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
onPress={onPress}
|
||||
isArchiving={isArchiving}
|
||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||
archiveStatus={computedArchiveStatus}
|
||||
archiveStatus={isArchiving ? "pending" : "idle"}
|
||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
|
||||
|
||||
@@ -11,14 +11,13 @@ import { DiffStat } from "@/components/diff-stat";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||
@@ -66,20 +65,7 @@ export function SidebarWorkspaceRow({
|
||||
const toast = useToast();
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
workspaceDirectory
|
||||
? state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspaceDirectory,
|
||||
actionId: "archive-worktree",
|
||||
})
|
||||
: "idle",
|
||||
);
|
||||
const isWorktree = workspace.workspaceKind === "worktree";
|
||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
||||
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
||||
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
@@ -94,7 +80,6 @@ export function SidebarWorkspaceRow({
|
||||
const archiveController = useWorkspaceArchive({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
@@ -161,7 +146,7 @@ export function SidebarWorkspaceRow({
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
@@ -173,8 +158,8 @@ export function SidebarWorkspaceRow({
|
||||
}, [clearAttention, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
||||
actions: ["worktree.archive"],
|
||||
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||
actions: ["workspace.archive"],
|
||||
enabled: selected && !isArchiving,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
@@ -183,13 +168,6 @@ export function SidebarWorkspaceRow({
|
||||
},
|
||||
});
|
||||
|
||||
let archiveStatus: "idle" | "pending" | "success" = "idle";
|
||||
if (isWorktree) {
|
||||
archiveStatus = worktreeArchiveStatus;
|
||||
} else if (isHidingWorkspace) {
|
||||
archiveStatus = "pending";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspaceRowBody
|
||||
@@ -205,7 +183,7 @@ export function SidebarWorkspaceRow({
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||
archiveStatus={archiveStatus}
|
||||
archiveStatus={isArchiving ? "pending" : "idle"}
|
||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
|
||||
interface PendingWorkspaceArchive {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
}
|
||||
|
||||
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
|
||||
@@ -15,7 +14,6 @@ function pendingArchiveKey(input: { serverId: string; workspaceId: string }): st
|
||||
export function markWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceDirectory?: string | null;
|
||||
}): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
@@ -26,7 +24,6 @@ export function markWorkspaceArchivePending(input: {
|
||||
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
|
||||
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
|
||||
workspaceId,
|
||||
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
|
||||
});
|
||||
pendingWorkspaceArchivesByServer.set(serverId, archives);
|
||||
}
|
||||
@@ -54,7 +51,6 @@ export function clearWorkspaceArchivePending(input: {
|
||||
export function isWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceDirectory?: string | null;
|
||||
}): boolean {
|
||||
const serverId = input.serverId.trim();
|
||||
if (!serverId) {
|
||||
@@ -67,21 +63,7 @@ export function isWorkspaceArchivePending(input: {
|
||||
}
|
||||
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
|
||||
if (!workspaceDirectory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const archive of archives.values()) {
|
||||
if (archive.workspaceDirectory === workspaceDirectory) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return Boolean(workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId })));
|
||||
}
|
||||
|
||||
export function shouldSuppressWorkspaceForLocalArchive(input: {
|
||||
@@ -91,6 +73,5 @@ export function shouldSuppressWorkspaceForLocalArchive(input: {
|
||||
return isWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,10 +68,10 @@ const CATALOG_DATA = [
|
||||
id: "codebuddy-code",
|
||||
title: "Codebuddy Code",
|
||||
description: "Tencent Cloud's official intelligent coding tool",
|
||||
version: "2.118.2",
|
||||
version: "2.119.2",
|
||||
iconId: "codebuddy-code",
|
||||
installLink: "https://www.codebuddy.cn/cli/",
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.118.2", "--acp"],
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.119.2", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codewhale",
|
||||
@@ -140,10 +140,10 @@ const CATALOG_DATA = [
|
||||
id: "dimcode",
|
||||
title: "DimCode",
|
||||
description: "A coding agent that puts leading models at your command.",
|
||||
version: "0.2.26",
|
||||
version: "0.2.27",
|
||||
iconId: "dimcode",
|
||||
installLink: "https://dimcode.dev/docs/acp.html",
|
||||
command: ["npx", "-y", "dimcode@0.2.26", "acp"],
|
||||
command: ["npx", "-y", "dimcode@0.2.27", "acp"],
|
||||
},
|
||||
{
|
||||
id: "dirac",
|
||||
@@ -159,10 +159,10 @@ const CATALOG_DATA = [
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.169.0",
|
||||
version: "0.170.0",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.169.0", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.170.0", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
|
||||
id: "qoder",
|
||||
title: "Qoder CLI",
|
||||
description: "AI coding assistant with agentic capabilities",
|
||||
version: "1.0.41",
|
||||
version: "1.0.43",
|
||||
iconId: "qoder",
|
||||
installLink: "https://qoder.com",
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.41", "--acp"],
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.43", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "qwen-code",
|
||||
title: "Qwen Code",
|
||||
description: "Alibaba's Qwen coding assistant",
|
||||
version: "0.19.8",
|
||||
version: "0.19.9",
|
||||
iconId: "qwen-code",
|
||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.8", "--acp", "--experimental-skills"],
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.9", "--acp", "--experimental-skills"],
|
||||
},
|
||||
{
|
||||
id: "sigit",
|
||||
|
||||
@@ -48,7 +48,7 @@ function GitActionMenuItem({
|
||||
const handleSelect = useCallback(() => onSelect(action), [onSelect, action]);
|
||||
const trailing = useMemo(
|
||||
() =>
|
||||
action.id === "archive-worktree" && archiveShortcutKeys ? (
|
||||
action.id === "archive-workspace" && archiveShortcutKeys ? (
|
||||
<Shortcut chord={archiveShortcutKeys} />
|
||||
) : undefined,
|
||||
[action.id, archiveShortcutKeys],
|
||||
@@ -58,7 +58,7 @@ function GitActionMenuItem({
|
||||
{needsSeparator && showSeparator ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
testID={
|
||||
action.id === "archive-worktree"
|
||||
action.id === "archive-workspace"
|
||||
? "workspace-archive-action"
|
||||
: `changes-menu-${action.id}`
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
|
||||
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
||||
if (action.status === "pending") return action.pendingLabel;
|
||||
|
||||
@@ -2,16 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { queryClient as appQueryClient } from "@/data/query-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
__resetCheckoutGitActionsStoreForTests,
|
||||
isLocalWorktreeArchivePending,
|
||||
useCheckoutGitActionsStore,
|
||||
} from "@/git/actions-store";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
isWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
@@ -31,33 +25,13 @@ function createDeferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">) {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project",
|
||||
projectRootPath: input.projectRootPath ?? "/tmp/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/tmp/repo/worktrees/feature",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "worktree",
|
||||
name: input.name ?? input.id,
|
||||
status: input.status ?? "done",
|
||||
archivingAt: input.archivingAt ?? null,
|
||||
statusEnteredAt: null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
scripts: input.scripts ?? [],
|
||||
} satisfies WorkspaceDescriptor;
|
||||
}
|
||||
|
||||
describe("checkout-git-actions-store", () => {
|
||||
const serverId = "server-1";
|
||||
const cwd = "/tmp/repo/worktrees/feature";
|
||||
const workspaceId = "ws-feature";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId });
|
||||
appQueryClient.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
@@ -65,7 +39,6 @@ describe("checkout-git-actions-store", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId });
|
||||
appQueryClient.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
@@ -294,118 +267,4 @@ describe("checkout-git-actions-store", () => {
|
||||
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
|
||||
const deferred = createDeferred<Record<string, never>>();
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
||||
};
|
||||
const featureWorkspace = workspace({
|
||||
id: workspaceId,
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
||||
appQueryClient.setQueryData(
|
||||
["sidebarPaseoWorktreeList", serverId, "/tmp"],
|
||||
[{ worktreePath: cwd }, { worktreePath: "/tmp/other" }],
|
||||
);
|
||||
|
||||
const archive = useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId });
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
|
||||
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
|
||||
{ worktreePath: "/tmp/other" },
|
||||
]);
|
||||
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
|
||||
|
||||
deferred.resolve({});
|
||||
await archive;
|
||||
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("archives on the server even when its workspace cannot be resolved", async () => {
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(async () => ({})),
|
||||
};
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
|
||||
await useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
||||
|
||||
// The server archive is keyed by worktreePath and must run regardless.
|
||||
expect(client.archivePaseoWorktree).toHaveBeenCalledWith({ worktreePath: cwd });
|
||||
// The optimistic client-side mark is never keyed by the path.
|
||||
expect(isWorkspaceArchivePending({ serverId, workspaceId: cwd })).toBe(false);
|
||||
});
|
||||
|
||||
it("restores an optimistically hidden worktree when archive fails", async () => {
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(async () => ({ error: { message: "archive failed" } })),
|
||||
};
|
||||
const featureWorkspace = workspace({
|
||||
id: workspaceId,
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
const listSnapshot = [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }];
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
||||
appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot);
|
||||
|
||||
await expect(
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId }),
|
||||
).rejects.toThrow("archive failed");
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(workspaceId)).toEqual(
|
||||
featureWorkspace,
|
||||
);
|
||||
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual(
|
||||
listSnapshot,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports local archive pending only while the archive action is in flight", async () => {
|
||||
const deferred = createDeferred<Record<string, never>>();
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
||||
};
|
||||
const featureWorkspace = workspace({
|
||||
id: workspaceId,
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
||||
|
||||
const archive = useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
||||
|
||||
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
|
||||
|
||||
deferred.resolve({});
|
||||
await archive;
|
||||
|
||||
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
import { create } from "zustand";
|
||||
import { queryClient as appQueryClient } from "@/data/query-client";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -36,8 +24,7 @@ export type CheckoutGitAsyncActionId =
|
||||
| "enable-pr-auto-merge-rebase"
|
||||
| "disable-pr-auto-merge"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
| "merge-from-base";
|
||||
|
||||
type CheckoutKey = string;
|
||||
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
|
||||
@@ -89,119 +76,6 @@ function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
|
||||
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd });
|
||||
}
|
||||
|
||||
function invalidateWorktreeList() {
|
||||
void appQueryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
|
||||
});
|
||||
void appQueryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === "sidebarPaseoWorktreeList",
|
||||
});
|
||||
}
|
||||
|
||||
function removeWorktreeFromCachedLists(input: { serverId: string; worktreePath: string }): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const worktreePath = input.worktreePath.trim();
|
||||
if (!serverId || !worktreePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removeFromList = (current: unknown) => {
|
||||
if (!Array.isArray(current)) {
|
||||
return current;
|
||||
}
|
||||
const filtered = current.filter((entry) => entry?.worktreePath !== worktreePath);
|
||||
return filtered.length === current.length ? current : filtered;
|
||||
};
|
||||
|
||||
appQueryClient.setQueriesData(
|
||||
{
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) &&
|
||||
query.queryKey[0] === "paseoWorktreeList" &&
|
||||
query.queryKey[1] === serverId,
|
||||
},
|
||||
removeFromList,
|
||||
);
|
||||
|
||||
appQueryClient.setQueriesData(
|
||||
{
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) &&
|
||||
query.queryKey[0] === "sidebarPaseoWorktreeList" &&
|
||||
query.queryKey[1] === serverId,
|
||||
},
|
||||
removeFromList,
|
||||
);
|
||||
}
|
||||
|
||||
interface WorktreeArchiveSnapshot {
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
worktreeLists: Array<[QueryKey, unknown]>;
|
||||
}
|
||||
|
||||
function isWorktreeListQuery(input: { queryKey: QueryKey; serverId: string }): boolean {
|
||||
return (
|
||||
Array.isArray(input.queryKey) &&
|
||||
(input.queryKey[0] === "paseoWorktreeList" ||
|
||||
input.queryKey[0] === "sidebarPaseoWorktreeList") &&
|
||||
input.queryKey[1] === input.serverId
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotWorktreeArchiveState(input: {
|
||||
serverId: string;
|
||||
workspaceId: string | undefined;
|
||||
}): WorktreeArchiveSnapshot {
|
||||
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
|
||||
const workspaceKey = input.workspaceId
|
||||
? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: input.workspaceId })
|
||||
: null;
|
||||
return {
|
||||
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
|
||||
worktreeLists: appQueryClient.getQueriesData({
|
||||
predicate: (query) =>
|
||||
isWorktreeListQuery({ queryKey: query.queryKey, serverId: input.serverId }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function removeWorktreeFromSessionStore(input: { serverId: string; workspaceId: string }): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = input.workspaceId.trim();
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
useSessionStore.getState().removeWorkspace(serverId, workspaceId);
|
||||
}
|
||||
|
||||
function restoreWorktreeArchiveState(input: {
|
||||
serverId: string;
|
||||
snapshot: WorktreeArchiveSnapshot;
|
||||
}): void {
|
||||
if (input.snapshot.workspace) {
|
||||
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]);
|
||||
}
|
||||
|
||||
for (const [queryKey, data] of input.snapshot.worktreeLists) {
|
||||
appQueryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
|
||||
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = input.workspaceId.trim();
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (workspaceKey) {
|
||||
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
|
||||
}
|
||||
useWorkspaceTabsStore.getState().purgeWorkspace({ serverId, workspaceId });
|
||||
}
|
||||
|
||||
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const inFlight = new Map<string, Promise<unknown>>();
|
||||
|
||||
@@ -209,16 +83,6 @@ function inFlightKey(key: CheckoutKey, actionId: CheckoutGitAsyncActionId): stri
|
||||
return `${key}::${actionId}`;
|
||||
}
|
||||
|
||||
export function isLocalWorktreeArchivePending(input: { serverId: string; cwd: string }): boolean {
|
||||
return (
|
||||
useCheckoutGitActionsStore.getState().getStatus({
|
||||
serverId: input.serverId,
|
||||
cwd: input.cwd,
|
||||
actionId: "archive-worktree",
|
||||
}) === "pending"
|
||||
);
|
||||
}
|
||||
|
||||
interface CheckoutGitActionsStoreState {
|
||||
statusByCheckout: Record<CheckoutKey, StatusMap>;
|
||||
|
||||
@@ -247,12 +111,6 @@ interface CheckoutGitActionsStoreState {
|
||||
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
archiveWorktree: (params: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
worktreePath: string;
|
||||
workspaceId?: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
async function runCheckoutAction({
|
||||
@@ -490,50 +348,6 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
archiveWorktree: async ({ serverId, cwd, worktreePath, workspaceId }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: "archive-worktree",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const snapshot = snapshotWorktreeArchiveState({ serverId, workspaceId });
|
||||
// The server archive is keyed by worktreePath and must always run. The
|
||||
// optimistic client-side updates are keyed by workspace id, so they only
|
||||
// apply when the caller passes the workspace id and it resolves in the
|
||||
// local store.
|
||||
const workspace = snapshot.workspace;
|
||||
if (workspace) {
|
||||
markWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
removeWorktreeFromSessionStore({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
removeWorktreeFromCachedLists({ serverId, worktreePath });
|
||||
try {
|
||||
const payload = await client.archivePaseoWorktree({
|
||||
worktreePath,
|
||||
...(workspaceId !== undefined ? { workspaceId } : {}),
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
if (workspace) {
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
restoreWorktreeArchiveState({ serverId, snapshot });
|
||||
throw error;
|
||||
}
|
||||
invalidateWorktreeList();
|
||||
if (workspace) {
|
||||
purgeArchivedWorkspaceState({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
export function __resetCheckoutGitActionsStoreForTests() {
|
||||
|
||||
@@ -122,7 +122,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"archive-worktree": {
|
||||
"archive-workspace": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
@@ -140,7 +140,13 @@ describe("git-actions-policy", () => {
|
||||
it("shows only remote sync actions on the base branch", () => {
|
||||
const actions = buildGitActions(createInput({ hasRemote: true }));
|
||||
|
||||
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push", "pull-and-push"]);
|
||||
expect(actions.primary).toBeNull();
|
||||
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||
"pull",
|
||||
"push",
|
||||
"pull-and-push",
|
||||
"archive-workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
it("prioritizes pull when the branch is behind origin", () => {
|
||||
@@ -272,6 +278,7 @@ describe("git-actions-policy", () => {
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
"archive-workspace",
|
||||
]);
|
||||
expect(
|
||||
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
||||
@@ -352,12 +359,33 @@ describe("git-actions-policy", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("only shows archive worktree for paseo worktrees", () => {
|
||||
const hidden = buildGitActions(createInput());
|
||||
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
||||
it("hides Git actions for a non-Git workspace", () => {
|
||||
const directory = buildGitActions(createInput({ isGit: false }));
|
||||
|
||||
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
|
||||
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
|
||||
expect(directory).toEqual({ primary: null, secondary: [], menu: [] });
|
||||
});
|
||||
|
||||
it("offers archive workspace for Git checkouts and worktrees", () => {
|
||||
const localCheckout = buildGitActions(createInput({ hasUncommittedChanges: true }));
|
||||
const worktree = buildGitActions(
|
||||
createInput({ hasUncommittedChanges: true, isPaseoOwnedWorktree: true }),
|
||||
);
|
||||
|
||||
expect(localCheckout.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
|
||||
expect(worktree.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not promote archive to primary for an idle regular Git checkout", () => {
|
||||
const actions = buildGitActions(createInput());
|
||||
|
||||
expect(actions.primary).toBeNull();
|
||||
expect(actions.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
|
||||
});
|
||||
|
||||
it("still promotes archive as primary for an idle Paseo-owned worktree", () => {
|
||||
const actions = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
||||
|
||||
expect(actions.primary).toMatchObject({ id: "archive-workspace" });
|
||||
});
|
||||
|
||||
it("promotes squash-and-merge when an open PR is mergeable and the branch is in sync", () => {
|
||||
@@ -541,6 +569,7 @@ describe("git-actions-policy", () => {
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
"archive-workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -717,6 +746,7 @@ describe("git-actions-policy", () => {
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
"archive-workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -757,6 +787,7 @@ describe("git-actions-policy", () => {
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"enable-pr-auto-merge-squash",
|
||||
"archive-workspace",
|
||||
]);
|
||||
expect(
|
||||
actions.secondary.some((action) =>
|
||||
@@ -899,6 +930,7 @@ describe("git-actions-policy", () => {
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-pr-merge",
|
||||
"archive-workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -952,7 +984,7 @@ describe("git-actions-policy", () => {
|
||||
.filter((action) => !action.startsGroup)
|
||||
.map((action) => action.id);
|
||||
|
||||
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-worktree"]);
|
||||
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-workspace"]);
|
||||
expect(nonGroupStarters).toEqual([
|
||||
"pull",
|
||||
"push",
|
||||
|
||||
@@ -23,7 +23,7 @@ export type GitActionId =
|
||||
| "disable-pr-auto-merge"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
| "archive-workspace";
|
||||
|
||||
export interface GitAction {
|
||||
id: GitActionId;
|
||||
@@ -282,20 +282,16 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
handler: input.runtime["merge-from-base"].handler,
|
||||
});
|
||||
|
||||
allActions.set("archive-worktree", {
|
||||
id: "archive-worktree",
|
||||
allActions.set("archive-workspace", {
|
||||
id: "archive-workspace",
|
||||
label: i18n.t("workspace.git.actions.archive.label"),
|
||||
pendingLabel: i18n.t("workspace.git.actions.archive.pending"),
|
||||
successLabel: i18n.t("workspace.git.actions.archive.success"),
|
||||
disabled: input.runtime["archive-worktree"].disabled,
|
||||
status: input.runtime["archive-worktree"].status,
|
||||
unavailableMessage:
|
||||
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
|
||||
? undefined
|
||||
: i18n.t("workspace.git.actions.unavailable.archiveNotWorktree"),
|
||||
icon: input.runtime["archive-worktree"].icon,
|
||||
disabled: input.runtime["archive-workspace"].disabled,
|
||||
status: input.runtime["archive-workspace"].status,
|
||||
icon: input.runtime["archive-workspace"].icon,
|
||||
startsGroup: true,
|
||||
handler: input.runtime["archive-worktree"].handler,
|
||||
handler: input.runtime["archive-workspace"].handler,
|
||||
});
|
||||
|
||||
const primaryActionId = getPrimaryActionId(input);
|
||||
@@ -305,20 +301,20 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
if (!input.isOnBaseBranch) {
|
||||
secondaryIds.push(...getFeatureActionIds(input));
|
||||
}
|
||||
if (input.isPaseoOwnedWorktree) {
|
||||
secondaryIds.push("archive-worktree");
|
||||
}
|
||||
secondaryIds.push("archive-workspace");
|
||||
|
||||
return {
|
||||
primary,
|
||||
secondary: secondaryIds.map((id) => allActions.get(id)!),
|
||||
secondary: secondaryIds
|
||||
.filter((id) => id !== "archive-workspace" || primaryActionId !== "archive-workspace")
|
||||
.map((id) => allActions.get(id)!),
|
||||
menu: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (input.shouldPromoteArchive && input.isPaseoOwnedWorktree) {
|
||||
return "archive-worktree";
|
||||
if (input.shouldPromoteArchive) {
|
||||
return "archive-workspace";
|
||||
}
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "commit";
|
||||
@@ -350,6 +346,13 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
||||
return "pr";
|
||||
}
|
||||
|
||||
// Only Paseo-owned worktrees get Archive as a fallback primary action.
|
||||
// Regular Git checkouts should not show the destructive archive CTA by default.
|
||||
if (input.isPaseoOwnedWorktree) {
|
||||
return "archive-workspace";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
useActiveWorkspaceSelection,
|
||||
type ActiveWorkspaceSelection,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||
|
||||
@@ -97,15 +98,12 @@ function extractGitCommitCounts(gitStatus: CheckoutStatusPayload | null): GitCom
|
||||
}
|
||||
|
||||
function computeShouldPromoteArchive(input: {
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
hasUncommittedChanges: boolean;
|
||||
postShipArchiveSuggested: boolean;
|
||||
isMergedPullRequest: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
input.isPaseoOwnedWorktree &&
|
||||
!input.hasUncommittedChanges &&
|
||||
(input.postShipArchiveSuggested || input.isMergedPullRequest)
|
||||
!input.hasUncommittedChanges && (input.postShipArchiveSuggested || input.isMergedPullRequest)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,7 +129,6 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel,
|
||||
shouldPromoteArchive: computeShouldPromoteArchive({
|
||||
isPaseoOwnedWorktree,
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
isMergedPullRequest,
|
||||
@@ -173,6 +170,52 @@ interface UseWorkspaceScreenArchiveControllerInput {
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
function resolveArchiveWorkspaceDescriptor(input: {
|
||||
workspaces: Map<string, WorkspaceDescriptor> | undefined;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): WorkspaceDescriptor | null {
|
||||
const activeWorkspaceKey = input.activeWorkspaceSelection
|
||||
? resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces: input.workspaces,
|
||||
workspaceId: input.activeWorkspaceSelection.workspaceId,
|
||||
})
|
||||
: null;
|
||||
if (activeWorkspaceKey) {
|
||||
return input.workspaces?.get(activeWorkspaceKey) ?? null;
|
||||
}
|
||||
if (!input.workspaceDirectory) {
|
||||
return null;
|
||||
}
|
||||
for (const candidate of input.workspaces?.values() ?? []) {
|
||||
if (candidate.workspaceDirectory === input.workspaceDirectory) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveWorkspaceArchiveRisk(
|
||||
workspace: WorkspaceDescriptor | null,
|
||||
gitStatus: CheckoutStatusPayload | null,
|
||||
): { isDirty: boolean | null | undefined; aheadOfOrigin: number | null | undefined } {
|
||||
return {
|
||||
isDirty: gitStatus?.isDirty ?? workspace?.gitRuntime?.isDirty,
|
||||
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? workspace?.gitRuntime?.aheadOfOrigin,
|
||||
};
|
||||
}
|
||||
|
||||
function canArchiveWorkspace(
|
||||
workspace: WorkspaceDescriptor | null,
|
||||
risk: ReturnType<typeof resolveWorkspaceArchiveRisk>,
|
||||
): boolean {
|
||||
return (
|
||||
workspace !== null &&
|
||||
(workspace.workspaceKind !== "worktree" ||
|
||||
(risk.isDirty !== undefined && risk.aheadOfOrigin !== undefined))
|
||||
);
|
||||
}
|
||||
|
||||
function useWorkspaceScreenArchiveController({
|
||||
serverId,
|
||||
activeWorkspaceSelection,
|
||||
@@ -182,28 +225,28 @@ function useWorkspaceScreenArchiveController({
|
||||
t,
|
||||
}: UseWorkspaceScreenArchiveControllerInput) {
|
||||
const sessionWorkspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
|
||||
const archiveWorkspaceRecord = useMemo(() => {
|
||||
if (!workspaceDirectory) {
|
||||
return null;
|
||||
}
|
||||
for (const candidate of sessionWorkspaces?.values() ?? []) {
|
||||
if (candidate.workspaceDirectory === workspaceDirectory) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [sessionWorkspaces, workspaceDirectory]);
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const workspaceDescriptor = useMemo(
|
||||
() =>
|
||||
resolveArchiveWorkspaceDescriptor({
|
||||
workspaces: sessionWorkspaces,
|
||||
activeWorkspaceSelection,
|
||||
workspaceDirectory,
|
||||
}),
|
||||
[activeWorkspaceSelection, sessionWorkspaces, workspaceDirectory],
|
||||
);
|
||||
const archiveRisk = resolveWorkspaceArchiveRisk(workspaceDescriptor, gitStatus);
|
||||
|
||||
return useWorkspaceArchive({
|
||||
const controller = useWorkspaceArchive({
|
||||
serverId,
|
||||
workspaceId: activeWorkspaceSelection?.workspaceId ?? archiveWorkspaceRecord?.id ?? "",
|
||||
workspaceDirectory,
|
||||
workspaceKind: gitStatus?.isPaseoOwnedWorktree ? "worktree" : "local_checkout",
|
||||
name: archiveWorkspaceRecord?.name ?? branchLabel,
|
||||
isDirty: gitStatus?.isDirty,
|
||||
aheadOfOrigin: gitStatus?.aheadOfOrigin,
|
||||
diffStat: archiveWorkspaceRecord?.diffStat ?? null,
|
||||
workspaceId: workspaceDescriptor?.id ?? "",
|
||||
workspaceKind: workspaceDescriptor?.workspaceKind ?? "directory",
|
||||
name: workspaceDescriptor?.name ?? branchLabel,
|
||||
isDirty: archiveRisk.isDirty,
|
||||
aheadOfOrigin: archiveRisk.aheadOfOrigin,
|
||||
diffStat: workspaceDescriptor?.diffStat ?? null,
|
||||
warningLabels: getWorktreeArchiveWarningLabels(t),
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
onArchiveStarted: () => {
|
||||
if (!activeWorkspaceSelection) {
|
||||
return;
|
||||
@@ -215,6 +258,12 @@ function useWorkspaceScreenArchiveController({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...controller,
|
||||
isArchiving: workspaceDescriptor?.archivingAt != null || isHidingWorkspace,
|
||||
canArchive: canArchiveWorkspace(workspaceDescriptor, archiveRisk),
|
||||
};
|
||||
}
|
||||
|
||||
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
|
||||
@@ -342,9 +391,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
const mergeFromBaseStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
|
||||
);
|
||||
const archiveStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
|
||||
);
|
||||
|
||||
const runCommit = useCheckoutGitActionsStore((s) => s.commit);
|
||||
const runPull = useCheckoutGitActionsStore((s) => s.pull);
|
||||
@@ -533,7 +579,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
t,
|
||||
});
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
archiveController.archive();
|
||||
}, [archiveController]);
|
||||
|
||||
@@ -678,11 +724,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
icon: icons.mergeFromBase,
|
||||
handler: handleMergeFromBase,
|
||||
},
|
||||
"archive-worktree": {
|
||||
disabled: isActionDisabled(actionsDisabled, archiveStatus),
|
||||
status: archiveStatus,
|
||||
"archive-workspace": {
|
||||
disabled: !archiveController.canArchive || archiveController.isArchiving,
|
||||
status: archiveController.isArchiving ? "pending" : "idle",
|
||||
icon: icons.archive,
|
||||
handler: handleArchiveWorktree,
|
||||
handler: handleArchiveWorkspace,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -723,7 +769,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
disablePrAutoMergeStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveStatus,
|
||||
archiveController.canArchive,
|
||||
archiveController.isArchiving,
|
||||
handleCommit,
|
||||
handlePull,
|
||||
handlePush,
|
||||
@@ -734,7 +781,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
handleDisablePrAutoMerge,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorktree,
|
||||
handleArchiveWorkspace,
|
||||
icons,
|
||||
baseRef,
|
||||
],
|
||||
@@ -890,7 +937,7 @@ function getTranslatedGitActionLabels(
|
||||
pendingLabel: t("workspace.git.actions.mergeFromBase.pending"),
|
||||
successLabel: t("workspace.git.actions.mergeFromBase.success"),
|
||||
};
|
||||
case "archive-worktree":
|
||||
case "archive-workspace":
|
||||
return {
|
||||
label: t("workspace.git.actions.archive.label"),
|
||||
pendingLabel: t("workspace.git.actions.archive.pending"),
|
||||
@@ -945,8 +992,6 @@ function translateGitActionUnavailableMessage(
|
||||
"workspace.git.actions.unavailable.updateNoBase",
|
||||
"Update isn't available while you have local changes so commit or stash them first":
|
||||
"workspace.git.actions.unavailable.updateDirty",
|
||||
"Archive isn't available here because this workspace was not created as a Paseo worktree":
|
||||
"workspace.git.actions.unavailable.archiveNotWorktree",
|
||||
"Merge PR isn't available right now because GitHub isn't connected":
|
||||
"workspace.git.actions.unavailable.mergePrNoGithub",
|
||||
"Merge PR isn't available because there isn't a pull request yet":
|
||||
@@ -979,7 +1024,7 @@ function getWorktreeArchiveWarningLabels(
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): WorktreeArchiveWarningLabels {
|
||||
return {
|
||||
title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }),
|
||||
title: (workspaceName) => t("workspace.git.actions.archiveWarning.title", { workspaceName }),
|
||||
confirm: t("workspace.git.actions.archiveWarning.confirm"),
|
||||
cancel: t("workspace.git.actions.archiveWarning.cancel"),
|
||||
uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"),
|
||||
|
||||
@@ -13,7 +13,7 @@ import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
interface WorkspaceGitActionsProps {
|
||||
interface WorkspaceActionsProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
hideLabels?: boolean;
|
||||
@@ -47,16 +47,12 @@ const ICONS = {
|
||||
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
|
||||
};
|
||||
|
||||
export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) {
|
||||
const { gitActions, isGit } = useGitActions({
|
||||
export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
|
||||
const { gitActions } = useGitActions({
|
||||
serverId,
|
||||
cwd,
|
||||
icons: ICONS,
|
||||
});
|
||||
|
||||
if (!isGit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
toWorktreeArchiveRisk,
|
||||
} from "@/git/worktree-archive-warning";
|
||||
|
||||
describe("worktree archive warning", () => {
|
||||
describe("workspace archive warning for worktree backing", () => {
|
||||
it("does not require a confirmation for clean and pushed worktrees", () => {
|
||||
expect(
|
||||
buildWorktreeArchiveConfirmationMessage({
|
||||
worktreeName: "feature",
|
||||
workspaceName: "feature",
|
||||
isDirty: false,
|
||||
aheadOfOrigin: 0,
|
||||
diffStat: null,
|
||||
@@ -51,7 +51,7 @@ describe("worktree archive warning", () => {
|
||||
it("includes every archive risk in the confirmation copy", () => {
|
||||
expect(
|
||||
buildWorktreeArchiveConfirmationMessage({
|
||||
worktreeName: "risky-feature",
|
||||
workspaceName: "risky-feature",
|
||||
isDirty: true,
|
||||
aheadOfOrigin: 1,
|
||||
diffStat: { additions: 1, deletions: 3 },
|
||||
|
||||
@@ -14,11 +14,11 @@ export interface WorktreeArchiveRiskInput {
|
||||
}
|
||||
|
||||
export interface WorktreeArchiveConfirmationInput extends WorktreeArchiveRisk {
|
||||
worktreeName: string;
|
||||
workspaceName: string;
|
||||
}
|
||||
|
||||
export interface WorktreeArchiveWarningLabels {
|
||||
title: (worktreeName: string) => string;
|
||||
title: (workspaceName: string) => string;
|
||||
confirm: string;
|
||||
cancel: string;
|
||||
uncommittedChanges: string;
|
||||
@@ -29,7 +29,7 @@ export interface WorktreeArchiveWarningLabels {
|
||||
}
|
||||
|
||||
export const DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS: WorktreeArchiveWarningLabels = {
|
||||
title: (worktreeName) => i18n.t("workspace.git.actions.archiveWarning.title", { worktreeName }),
|
||||
title: (workspaceName) => i18n.t("workspace.git.actions.archiveWarning.title", { workspaceName }),
|
||||
confirm: i18n.t("workspace.git.actions.archiveWarning.confirm"),
|
||||
cancel: i18n.t("workspace.git.actions.archiveWarning.cancel"),
|
||||
uncommittedChanges: i18n.t("workspace.git.actions.archiveWarning.uncommittedChanges"),
|
||||
@@ -123,7 +123,7 @@ export async function confirmRiskyWorktreeArchive(
|
||||
}
|
||||
|
||||
return await confirmDialog({
|
||||
title: labels.title(input.worktreeName),
|
||||
title: labels.title(input.workspaceName),
|
||||
message,
|
||||
confirmLabel: labels.confirm,
|
||||
cancelLabel: labels.cancel,
|
||||
|
||||
@@ -619,7 +619,7 @@ export const ar: TranslationResources = {
|
||||
success: "تم التحديث",
|
||||
},
|
||||
archive: {
|
||||
label: "أرشفة شجرة العمل",
|
||||
label: "أرشفة مساحة العمل",
|
||||
pending: "أرشفة...",
|
||||
success: "مؤرشف",
|
||||
},
|
||||
@@ -661,8 +661,6 @@ export const ar: TranslationResources = {
|
||||
updateNoBase: "التحديث غير متاح لأننا لم نتمكن من تحديد الفرع الأساسي",
|
||||
updateDirty: "التحديث غير متاح أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً",
|
||||
updateCurrent: "التحديث غير متاح لأن هذا الفرع محدث بالفعل باستخدام{{baseRef}}",
|
||||
archiveNotWorktree:
|
||||
"الأرشيف غير متاح هنا لأنه لم يتم إنشاء مساحة العمل هذه كشجرة عمل Paseo",
|
||||
mergePrNoGithub: "دمج PR غير متاح الآن لأن GitHub غير متصل",
|
||||
mergePrMissing: "دمج PR غير متاح لأنه لا يوجد طلب سحب حتى الآن",
|
||||
mergePrDraft: "دمج PR غير متاح لأن طلب السحب لا يزال مسودة",
|
||||
@@ -685,11 +683,9 @@ export const ar: TranslationResources = {
|
||||
baseRefUnavailable: "المرجع الأساسي غير متاح",
|
||||
failedMerge: "فشل الدمج",
|
||||
failedMergeFromBase: "فشل الدمج من القاعدة",
|
||||
worktreePathUnavailable: "مسار شجرة العمل غير متوفر",
|
||||
failedArchive: "فشل في أرشفة شجرة العمل",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: 'الأرشيف "{{worktreeName}}"؟',
|
||||
title: 'الأرشيف "{{workspaceName}}"؟',
|
||||
confirm: "أرشيف",
|
||||
cancel: "يلغي",
|
||||
uncommittedChanges: "تغييرات غير ملتزم بها",
|
||||
@@ -839,7 +835,7 @@ export const ar: TranslationResources = {
|
||||
copyBranchName: "انسخ اسم الفرع",
|
||||
rename: "إعادة تسمية مساحة العمل",
|
||||
archive: "أرشيف",
|
||||
archiveWorktree: "أرشفة شجرة العمل",
|
||||
archiveWorkspace: "أرشفة مساحة العمل",
|
||||
hideFromSidebar: "إخفاء من الشريط الجانبي",
|
||||
archiving: "أرشفة...",
|
||||
hiding: "إخفاء...",
|
||||
@@ -862,7 +858,7 @@ export const ar: TranslationResources = {
|
||||
branchNameCopied: "تم نسخ اسم الفرع",
|
||||
hostDisconnected: "Host غير متصل",
|
||||
hideFailed: "فشل في إخفاء مساحة العمل",
|
||||
archiveFailed: "فشل في أرشفة شجرة العمل",
|
||||
archiveFailed: "فشل في أرشفة مساحة العمل",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1605,7 +1601,7 @@ export const ar: TranslationResources = {
|
||||
openProject: "مشروع مفتوح",
|
||||
newWorkspace: "مساحة عمل جديدة",
|
||||
newWorktree: "شجرة عمل جديدة",
|
||||
archiveWorktree: "أرشفة شجرة العمل",
|
||||
archiveWorkspace: "أرشفة مساحة العمل",
|
||||
newTab: "علامة تبويب جديدة",
|
||||
closeCurrentTab: "إغلاق علامة التبويب الحالية",
|
||||
jumpToWorkspace: "انتقل إلى مساحة العمل",
|
||||
|
||||
@@ -618,7 +618,7 @@ export const en = {
|
||||
success: "Updated",
|
||||
},
|
||||
archive: {
|
||||
label: "Archive worktree",
|
||||
label: "Archive workspace",
|
||||
pending: "Archiving...",
|
||||
success: "Archived",
|
||||
},
|
||||
@@ -667,8 +667,6 @@ export const en = {
|
||||
"Update isn't available while you have local changes so commit or stash them first",
|
||||
updateCurrent:
|
||||
"Update isn't available because this branch is already up to date with {{baseRef}}",
|
||||
archiveNotWorktree:
|
||||
"Archive isn't available here because this workspace was not created as a Paseo worktree",
|
||||
mergePrNoGithub: "Merge PR isn't available right now because GitHub isn't connected",
|
||||
mergePrMissing: "Merge PR isn't available because there isn't a pull request yet",
|
||||
mergePrDraft: "Merge PR isn't available because the pull request is still a draft",
|
||||
@@ -692,11 +690,9 @@ export const en = {
|
||||
baseRefUnavailable: "Base ref unavailable",
|
||||
failedMerge: "Failed to merge",
|
||||
failedMergeFromBase: "Failed to merge from base",
|
||||
worktreePathUnavailable: "Worktree path unavailable",
|
||||
failedArchive: "Failed to archive worktree",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: 'Archive "{{worktreeName}}"?',
|
||||
title: 'Archive "{{workspaceName}}"?',
|
||||
confirm: "Archive",
|
||||
cancel: "Cancel",
|
||||
uncommittedChanges: "Uncommitted changes",
|
||||
@@ -846,7 +842,7 @@ export const en = {
|
||||
copyBranchName: "Copy branch name",
|
||||
rename: "Rename workspace",
|
||||
archive: "Archive",
|
||||
archiveWorktree: "Archive worktree",
|
||||
archiveWorkspace: "Archive workspace",
|
||||
hideFromSidebar: "Hide from sidebar",
|
||||
archiving: "Archiving...",
|
||||
hiding: "Hiding...",
|
||||
@@ -869,7 +865,7 @@ export const en = {
|
||||
branchNameCopied: "Branch name copied",
|
||||
hostDisconnected: "Host is not connected",
|
||||
hideFailed: "Failed to hide workspace",
|
||||
archiveFailed: "Failed to archive worktree",
|
||||
archiveFailed: "Failed to archive workspace",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1612,7 +1608,7 @@ export const en = {
|
||||
openProject: "Open project",
|
||||
newWorkspace: "New workspace",
|
||||
newWorktree: "New worktree",
|
||||
archiveWorktree: "Archive worktree",
|
||||
archiveWorkspace: "Archive workspace",
|
||||
newTab: "New tab",
|
||||
closeCurrentTab: "Close current tab",
|
||||
jumpToWorkspace: "Jump to workspace",
|
||||
|
||||
@@ -625,7 +625,7 @@ export const es: TranslationResources = {
|
||||
success: "Actualizado",
|
||||
},
|
||||
archive: {
|
||||
label: "Árbol de trabajo de archivo",
|
||||
label: "Archivar espacio de trabajo",
|
||||
pending: "Archivando...",
|
||||
success: "Archivado",
|
||||
},
|
||||
@@ -679,8 +679,6 @@ export const es: TranslationResources = {
|
||||
"La actualización no está disponible mientras tenga cambios locales, así que confírmelos o guárdelos primero",
|
||||
updateCurrent:
|
||||
"La actualización no está disponible porque esta rama ya está actualizada con{{baseRef}}",
|
||||
archiveNotWorktree:
|
||||
"El archivo no está disponible aquí porque este espacio de trabajo no se creó como un árbol de trabajoPaseo",
|
||||
mergePrNoGithub:
|
||||
"FusionarPRno está disponible en este momento porqueGitHubno está conectado",
|
||||
mergePrMissing:
|
||||
@@ -712,11 +710,9 @@ export const es: TranslationResources = {
|
||||
baseRefUnavailable: "Referencia base no disponible",
|
||||
failedMerge: "No se pudo fusionar",
|
||||
failedMergeFromBase: "No se pudo fusionar desde la base",
|
||||
worktreePathUnavailable: "Ruta del árbol de trabajo no disponible",
|
||||
failedArchive: "No se pudo archivar el árbol de trabajo",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: '¿Archivo "{{worktreeName}}"?',
|
||||
title: '¿Archivo "{{workspaceName}}"?',
|
||||
confirm: "Archivo",
|
||||
cancel: "Cancelar",
|
||||
uncommittedChanges: "Cambios no confirmados",
|
||||
@@ -866,7 +862,7 @@ export const es: TranslationResources = {
|
||||
copyBranchName: "Copiar nombre de sucursal",
|
||||
rename: "Cambiar nombre del espacio de trabajo",
|
||||
archive: "Archivo",
|
||||
archiveWorktree: "Árbol de trabajo de archivo",
|
||||
archiveWorkspace: "Archivar espacio de trabajo",
|
||||
hideFromSidebar: "Ocultar de la barra lateral",
|
||||
archiving: "Archivando...",
|
||||
hiding: "Ocultación...",
|
||||
@@ -889,7 +885,7 @@ export const es: TranslationResources = {
|
||||
branchNameCopied: "Nombre de la sucursal copiado",
|
||||
hostDisconnected: "Hostno está conectado",
|
||||
hideFailed: "No se pudo ocultar el espacio de trabajo",
|
||||
archiveFailed: "No se pudo archivar el árbol de trabajo",
|
||||
archiveFailed: "No se pudo archivar el espacio de trabajo",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1647,7 +1643,7 @@ export const es: TranslationResources = {
|
||||
openProject: "Abrir proyecto",
|
||||
newWorkspace: "Nuevo espacio de trabajo",
|
||||
newWorktree: "Nuevo árbol de trabajo",
|
||||
archiveWorktree: "Árbol de trabajo de archivo",
|
||||
archiveWorkspace: "Archivar espacio de trabajo",
|
||||
newTab: "Nueva pestaña",
|
||||
closeCurrentTab: "Cerrar pestaña actual",
|
||||
jumpToWorkspace: "Saltar al espacio de trabajo",
|
||||
|
||||
@@ -625,7 +625,7 @@ export const fr: TranslationResources = {
|
||||
success: "Mis à jour",
|
||||
},
|
||||
archive: {
|
||||
label: "Arbre de travail d'archivage",
|
||||
label: "Archiver l’espace de travail",
|
||||
pending: "Archivage...",
|
||||
success: "Archivé",
|
||||
},
|
||||
@@ -679,8 +679,6 @@ export const fr: TranslationResources = {
|
||||
"La mise à jour n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord",
|
||||
updateCurrent:
|
||||
"La mise à jour n'est pas disponible car cette branche est déjà à jour avec{{baseRef}}",
|
||||
archiveNotWorktree:
|
||||
"L'archive n'est pas disponible ici car cet espace de travail n'a pas été créé en tant qu'arbre de travailPaseo",
|
||||
mergePrNoGithub:
|
||||
"La fusionPRn'est pas disponible pour le moment carGitHubn'est pas connecté",
|
||||
mergePrMissing:
|
||||
@@ -711,11 +709,9 @@ export const fr: TranslationResources = {
|
||||
baseRefUnavailable: "Réf de base indisponible",
|
||||
failedMerge: "Échec de la fusion",
|
||||
failedMergeFromBase: "Échec de la fusion à partir de la base",
|
||||
worktreePathUnavailable: "Chemin d'accès à l'arbre de travail indisponible",
|
||||
failedArchive: "Échec de l'archivage de l'arbre de travail",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: "Archiver «{{worktreeName}}»?",
|
||||
title: "Archiver «{{workspaceName}}»?",
|
||||
confirm: "Archive",
|
||||
cancel: "Annuler",
|
||||
uncommittedChanges: "Modifications non validées",
|
||||
@@ -865,7 +861,7 @@ export const fr: TranslationResources = {
|
||||
copyBranchName: "Copier le nom de la branche",
|
||||
rename: "Renommer l'espace de travail",
|
||||
archive: "Archive",
|
||||
archiveWorktree: "Arbre de travail d'archivage",
|
||||
archiveWorkspace: "Archiver l’espace de travail",
|
||||
hideFromSidebar: "Masquer de la barre latérale",
|
||||
archiving: "Archivage...",
|
||||
hiding: "Dissimulation...",
|
||||
@@ -888,7 +884,7 @@ export const fr: TranslationResources = {
|
||||
branchNameCopied: "Nom de la succursale copié",
|
||||
hostDisconnected: "Hostn'est pas connecté",
|
||||
hideFailed: "Échec du masquage de l'espace de travail",
|
||||
archiveFailed: "Échec de l'archivage de l'arbre de travail",
|
||||
archiveFailed: "Échec de l'archivage de l'espace de travail",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1650,7 +1646,7 @@ export const fr: TranslationResources = {
|
||||
openProject: "Projet ouvert",
|
||||
newWorkspace: "Nouvel espace de travail",
|
||||
newWorktree: "Nouvel arbre de travail",
|
||||
archiveWorktree: "Arbre de travail d'archivage",
|
||||
archiveWorkspace: "Archiver l’espace de travail",
|
||||
newTab: "Nouvel onglet",
|
||||
closeCurrentTab: "Fermer l'onglet actuel",
|
||||
jumpToWorkspace: "Accéder à l'espace de travail",
|
||||
|
||||
@@ -623,7 +623,7 @@ export const ja: TranslationResources = {
|
||||
success: "更新しました",
|
||||
},
|
||||
archive: {
|
||||
label: "ワークツリーをアーカイブ",
|
||||
label: "ワークスペースをアーカイブ",
|
||||
pending: "アーカイブ中...",
|
||||
success: "アーカイブしました",
|
||||
},
|
||||
@@ -670,8 +670,6 @@ export const ja: TranslationResources = {
|
||||
updateDirty:
|
||||
"ローカルに変更があるため更新は利用できません。先にコミットまたはスタッシュしてください",
|
||||
updateCurrent: "このブランチはすでに{{baseRef}}と最新の状態のため、更新は利用できません",
|
||||
archiveNotWorktree:
|
||||
"このワークスペースはPaseoワークツリーとして作成されていないため、アーカイブはここでは利用できません",
|
||||
mergePrNoGithub: "GitHubが接続されていないため、PRのマージは現在利用できません",
|
||||
mergePrMissing: "プルリクエストがまだないため、PRのマージは利用できません",
|
||||
mergePrDraft: "プルリクエストがまだドラフトのため、PRのマージは利用できません",
|
||||
@@ -697,11 +695,9 @@ export const ja: TranslationResources = {
|
||||
baseRefUnavailable: "ベースRefが利用できません",
|
||||
failedMerge: "マージに失敗しました",
|
||||
failedMergeFromBase: "ベースからのマージに失敗しました",
|
||||
worktreePathUnavailable: "ワークツリーパスが利用できません",
|
||||
failedArchive: "ワークツリーのアーカイブに失敗しました",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: '"{{worktreeName}}"をアーカイブしますか?',
|
||||
title: '"{{workspaceName}}"をアーカイブしますか?',
|
||||
confirm: "アーカイブ",
|
||||
cancel: "キャンセル",
|
||||
uncommittedChanges: "未コミットの変更",
|
||||
@@ -851,7 +847,7 @@ export const ja: TranslationResources = {
|
||||
copyBranchName: "ブランチ名をコピー",
|
||||
rename: "ワークスペースの名前を変更",
|
||||
archive: "アーカイブ",
|
||||
archiveWorktree: "ワークツリーをアーカイブ",
|
||||
archiveWorkspace: "ワークスペースをアーカイブ",
|
||||
hideFromSidebar: "サイドバーから非表示",
|
||||
archiving: "アーカイブ中...",
|
||||
hiding: "非表示にしています...",
|
||||
@@ -874,7 +870,7 @@ export const ja: TranslationResources = {
|
||||
branchNameCopied: "ブランチ名をコピーしました",
|
||||
hostDisconnected: "ホストが接続されていません",
|
||||
hideFailed: "ワークスペースの非表示に失敗しました",
|
||||
archiveFailed: "ワークツリーのアーカイブに失敗しました",
|
||||
archiveFailed: "ワークスペースのアーカイブに失敗しました",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1622,7 +1618,7 @@ export const ja: TranslationResources = {
|
||||
openProject: "プロジェクトを開く",
|
||||
newWorkspace: "新しいワークスペース",
|
||||
newWorktree: "新しいワークツリー",
|
||||
archiveWorktree: "ワークツリーをアーカイブ",
|
||||
archiveWorkspace: "ワークスペースをアーカイブ",
|
||||
newTab: "新しいタブ",
|
||||
closeCurrentTab: "現在のタブを閉じる",
|
||||
jumpToWorkspace: "ワークスペースにジャンプ",
|
||||
|
||||
@@ -623,7 +623,7 @@ export const ptBR: TranslationResources = {
|
||||
success: "Atualizado",
|
||||
},
|
||||
archive: {
|
||||
label: "Arquivar worktree",
|
||||
label: "Arquivar workspace",
|
||||
pending: "Arquivando...",
|
||||
success: "Arquivado",
|
||||
},
|
||||
@@ -675,8 +675,6 @@ export const ptBR: TranslationResources = {
|
||||
"Atualizar não está disponível enquanto há alterações locais. Faça commit ou stash primeiro",
|
||||
updateCurrent:
|
||||
"Atualizar não está disponível porque esta branch já está atualizada com {{baseRef}}",
|
||||
archiveNotWorktree:
|
||||
"Arquivar não está disponível aqui porque este workspace não foi criado como um worktree do Paseo",
|
||||
mergePrNoGithub:
|
||||
"Merge da PR não está disponível agora porque o GitHub não está conectado",
|
||||
mergePrMissing: "Merge da PR não está disponível porque ainda não há uma pull request",
|
||||
@@ -703,11 +701,9 @@ export const ptBR: TranslationResources = {
|
||||
baseRefUnavailable: "Ref base indisponível",
|
||||
failedMerge: "Falha ao fazer merge",
|
||||
failedMergeFromBase: "Falha ao fazer merge da base",
|
||||
worktreePathUnavailable: "Caminho do worktree indisponível",
|
||||
failedArchive: "Falha ao arquivar worktree",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: 'Arquivar "{{worktreeName}}"?',
|
||||
title: 'Arquivar "{{workspaceName}}"?',
|
||||
confirm: "Arquivar",
|
||||
cancel: "Cancelar",
|
||||
uncommittedChanges: "Alterações sem commit",
|
||||
@@ -857,7 +853,7 @@ export const ptBR: TranslationResources = {
|
||||
copyBranchName: "Copiar nome da branch",
|
||||
rename: "Renomear workspace",
|
||||
archive: "Arquivar",
|
||||
archiveWorktree: "Arquivar worktree",
|
||||
archiveWorkspace: "Arquivar workspace",
|
||||
hideFromSidebar: "Ocultar da barra lateral",
|
||||
archiving: "Arquivando...",
|
||||
hiding: "Ocultando...",
|
||||
@@ -880,7 +876,7 @@ export const ptBR: TranslationResources = {
|
||||
branchNameCopied: "Nome da branch copiado",
|
||||
hostDisconnected: "Host não está conectado",
|
||||
hideFailed: "Falha ao ocultar workspace",
|
||||
archiveFailed: "Falha ao arquivar worktree",
|
||||
archiveFailed: "Falha ao arquivar workspace",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1631,7 +1627,7 @@ export const ptBR: TranslationResources = {
|
||||
openProject: "Abrir projeto",
|
||||
newWorkspace: "Novo workspace",
|
||||
newWorktree: "Novo worktree",
|
||||
archiveWorktree: "Arquivar worktree",
|
||||
archiveWorkspace: "Arquivar workspace",
|
||||
newTab: "Nova aba",
|
||||
closeCurrentTab: "Fechar aba atual",
|
||||
jumpToWorkspace: "Ir para workspace",
|
||||
|
||||
@@ -624,7 +624,7 @@ export const ru: TranslationResources = {
|
||||
success: "Обновлено",
|
||||
},
|
||||
archive: {
|
||||
label: "Архив рабочего дерева",
|
||||
label: "Архивировать рабочее пространство",
|
||||
pending: "Архивирование...",
|
||||
success: "В архиве",
|
||||
},
|
||||
@@ -675,8 +675,6 @@ export const ru: TranslationResources = {
|
||||
"Обновление недоступно, пока у вас есть локальные изменения, поэтому сначала зафиксируйте или сохраните их.",
|
||||
updateCurrent:
|
||||
"Обновление недоступно, поскольку эта ветка уже обновлена до версии{{baseRef}}.",
|
||||
archiveNotWorktree:
|
||||
"Архив здесь недоступен, поскольку это рабочее пространство не было создано как рабочее дерево Paseo.",
|
||||
mergePrNoGithub: "Объединение PR сейчас недоступно, поскольку GitHub не подключен.",
|
||||
mergePrMissing: "Объединение PR недоступно, поскольку еще нет запроса на включение",
|
||||
mergePrDraft:
|
||||
@@ -704,11 +702,9 @@ export const ru: TranslationResources = {
|
||||
baseRefUnavailable: "Базовый номер недоступен.",
|
||||
failedMerge: "Не удалось объединиться",
|
||||
failedMergeFromBase: "Не удалось объединиться с базой.",
|
||||
worktreePathUnavailable: "Путь к рабочему дереву недоступен.",
|
||||
failedArchive: "Не удалось заархивировать рабочее дерево.",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: 'Архив "{{worktreeName}}"?',
|
||||
title: 'Архив "{{workspaceName}}"?',
|
||||
confirm: "Архив",
|
||||
cancel: "Отмена",
|
||||
uncommittedChanges: "Незафиксированные изменения",
|
||||
@@ -858,7 +854,7 @@ export const ru: TranslationResources = {
|
||||
copyBranchName: "Скопировать название ветки",
|
||||
rename: "Переименовать рабочую область",
|
||||
archive: "Архив",
|
||||
archiveWorktree: "Архив рабочего дерева",
|
||||
archiveWorkspace: "Архивировать рабочее пространство",
|
||||
hideFromSidebar: "Скрыть с боковой панели",
|
||||
archiving: "Архивирование...",
|
||||
hiding: "Скрытие...",
|
||||
@@ -881,7 +877,7 @@ export const ru: TranslationResources = {
|
||||
branchNameCopied: "Название филиала скопировано.",
|
||||
hostDisconnected: "Host не подключен",
|
||||
hideFailed: "Не удалось скрыть рабочую область.",
|
||||
archiveFailed: "Не удалось заархивировать рабочее дерево.",
|
||||
archiveFailed: "Не удалось заархивировать рабочее пространство.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1639,7 +1635,7 @@ export const ru: TranslationResources = {
|
||||
openProject: "Открыть проект",
|
||||
newWorkspace: "Новое рабочее пространство",
|
||||
newWorktree: "Новое рабочее дерево",
|
||||
archiveWorktree: "Архив рабочего дерева",
|
||||
archiveWorkspace: "Архивировать рабочее пространство",
|
||||
newTab: "Новая вкладка",
|
||||
closeCurrentTab: "Закрыть текущую вкладку",
|
||||
jumpToWorkspace: "Перейти в рабочую область",
|
||||
|
||||
@@ -617,7 +617,7 @@ export const zhCN: TranslationResources = {
|
||||
success: "已更新",
|
||||
},
|
||||
archive: {
|
||||
label: "归档 worktree",
|
||||
label: "归档工作区",
|
||||
pending: "正在归档...",
|
||||
success: "已归档",
|
||||
},
|
||||
@@ -656,7 +656,6 @@ export const zhCN: TranslationResources = {
|
||||
updateNoBase: "无法更新,因为无法确定 base branch",
|
||||
updateDirty: "有本地变更时无法更新,请先 commit 或 stash",
|
||||
updateCurrent: "无法更新,因为此分支已与 {{baseRef}} 保持最新",
|
||||
archiveNotWorktree: "此处无法归档,因为此 workspace 不是作为 Paseo worktree 创建的",
|
||||
mergePrNoGithub: "当前无法 merge PR,因为 GitHub 未连接",
|
||||
mergePrMissing: "无法 merge PR,因为还没有 pull request",
|
||||
mergePrDraft: "无法 merge PR,因为 pull request 仍是 draft",
|
||||
@@ -679,11 +678,9 @@ export const zhCN: TranslationResources = {
|
||||
baseRefUnavailable: "Base ref 不可用",
|
||||
failedMerge: "Merge 失败",
|
||||
failedMergeFromBase: "从 base merge 失败",
|
||||
worktreePathUnavailable: "Worktree 路径不可用",
|
||||
failedArchive: "归档 worktree 失败",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: "归档「{{worktreeName}}」?",
|
||||
title: "归档「{{workspaceName}}」?",
|
||||
confirm: "归档",
|
||||
cancel: "取消",
|
||||
uncommittedChanges: "未 commit 的变更",
|
||||
@@ -831,7 +828,7 @@ export const zhCN: TranslationResources = {
|
||||
copyBranchName: "复制分支名称",
|
||||
rename: "重命名 workspace",
|
||||
archive: "归档",
|
||||
archiveWorktree: "归档 worktree",
|
||||
archiveWorkspace: "归档工作区",
|
||||
hideFromSidebar: "从侧边栏隐藏",
|
||||
archiving: "正在归档...",
|
||||
hiding: "正在隐藏...",
|
||||
@@ -853,7 +850,7 @@ export const zhCN: TranslationResources = {
|
||||
branchNameCopied: "分支名称已复制",
|
||||
hostDisconnected: "Host 未连接",
|
||||
hideFailed: "隐藏 workspace 失败",
|
||||
archiveFailed: "归档 worktree 失败",
|
||||
archiveFailed: "归档工作区失败",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1586,7 +1583,7 @@ export const zhCN: TranslationResources = {
|
||||
openProject: "打开项目",
|
||||
newWorkspace: "新建 workspace",
|
||||
newWorktree: "新建 worktree",
|
||||
archiveWorktree: "归档 worktree",
|
||||
archiveWorkspace: "归档工作区",
|
||||
newTab: "新建标签",
|
||||
closeCurrentTab: "关闭当前标签",
|
||||
jumpToWorkspace: "跳转到 workspace",
|
||||
|
||||
@@ -44,7 +44,7 @@ export type KeyboardActionId =
|
||||
| "workspace.terminal.new"
|
||||
| "workspace.new"
|
||||
| "worktree.new"
|
||||
| "worktree.archive"
|
||||
| "workspace.archive"
|
||||
| "view.toggle.focus"
|
||||
| "theme.cycle"
|
||||
| "message-input.action";
|
||||
|
||||
@@ -29,7 +29,7 @@ export type KeyboardActionId =
|
||||
| "sidebar.toggle.right"
|
||||
| "workspace.new"
|
||||
| "worktree.new"
|
||||
| "worktree.archive";
|
||||
| "workspace.archive";
|
||||
|
||||
export type KeyboardActionDefinition =
|
||||
| { id: "agent.interrupt"; scope: KeyboardActionScope }
|
||||
@@ -60,7 +60,7 @@ export type KeyboardActionDefinition =
|
||||
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.new"; scope: KeyboardActionScope }
|
||||
| { id: "worktree.new"; scope: KeyboardActionScope }
|
||||
| { id: "worktree.archive"; scope: KeyboardActionScope };
|
||||
| { id: "workspace.archive"; scope: KeyboardActionScope };
|
||||
|
||||
export interface KeyboardActionHandler {
|
||||
handlerId: string;
|
||||
|
||||
@@ -121,7 +121,7 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
|
||||
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
||||
"new-agent": "settings.shortcuts.help.openProject",
|
||||
"new-workspace": "settings.shortcuts.help.newWorkspace",
|
||||
"archive-worktree": "settings.shortcuts.help.archiveWorktree",
|
||||
"archive-workspace": "settings.shortcuts.help.archiveWorkspace",
|
||||
"workspace-tab-new": "settings.shortcuts.help.newTab",
|
||||
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
|
||||
"workspace-jump-index": "settings.shortcuts.help.jumpToWorkspace",
|
||||
@@ -221,28 +221,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// --- Archive worktree ---
|
||||
// --- Archive workspace ---
|
||||
{
|
||||
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
|
||||
// 2027-01-11 with a stored-override migration. Keeps existing custom chords.
|
||||
id: "worktree-archive-cmd-shift-backspace-mac",
|
||||
action: "worktree.archive",
|
||||
action: "workspace.archive",
|
||||
combo: "Cmd+Shift+Backspace",
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "archive-worktree",
|
||||
id: "archive-workspace",
|
||||
section: "projects",
|
||||
label: "Archive worktree",
|
||||
label: "Archive workspace",
|
||||
keys: ["mod", "shift", "Backspace"],
|
||||
},
|
||||
},
|
||||
{
|
||||
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
|
||||
// 2027-01-11 with a stored-override migration. Keeps existing custom chords.
|
||||
id: "worktree-archive-ctrl-shift-backspace-non-mac",
|
||||
action: "worktree.archive",
|
||||
action: "workspace.archive",
|
||||
combo: "Ctrl+Shift+Backspace",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "archive-worktree",
|
||||
id: "archive-workspace",
|
||||
section: "projects",
|
||||
label: "Archive worktree",
|
||||
label: "Archive workspace",
|
||||
keys: ["mod", "shift", "Backspace"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => {
|
||||
["agent.interrupt", { id: "agent.interrupt", scope: "global" }],
|
||||
["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }],
|
||||
["workspace.new", { id: "workspace.new", scope: "sidebar" }],
|
||||
["worktree.archive", { id: "worktree.archive", scope: "sidebar" }],
|
||||
["workspace.archive", { id: "workspace.archive", scope: "sidebar" }],
|
||||
["worktree.new", { id: "worktree.new", scope: "sidebar" }],
|
||||
["workspace.terminal.new", { id: "workspace.terminal.new", scope: "workspace" }],
|
||||
["workspace.tab.close.current", { id: "workspace.tab.close-current", scope: "workspace" }],
|
||||
|
||||
@@ -46,7 +46,7 @@ const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
|
||||
"agent.interrupt": { id: "agent.interrupt", scope: "global" },
|
||||
"workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" },
|
||||
"workspace.new": { id: "workspace.new", scope: "sidebar" },
|
||||
"worktree.archive": { id: "worktree.archive", scope: "sidebar" },
|
||||
"workspace.archive": { id: "workspace.archive", scope: "sidebar" },
|
||||
"worktree.new": { id: "worktree.new", scope: "sidebar" },
|
||||
"workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" },
|
||||
"workspace.tab.close.current": { id: "workspace.tab.close-current", scope: "workspace" },
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
|
||||
import { Stack } from "expo-router";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
|
||||
interface ThemedStackBaseProps {
|
||||
backgroundColor: string;
|
||||
children?: ReactNode;
|
||||
screenOptions?: NativeStackNavigationOptions;
|
||||
}
|
||||
|
||||
function ThemedStackBase({ backgroundColor, children, screenOptions }: ThemedStackBaseProps) {
|
||||
const themedScreenOptions = useMemo<NativeStackNavigationOptions>(
|
||||
() => ({
|
||||
...screenOptions,
|
||||
contentStyle: [{ backgroundColor }, screenOptions?.contentStyle],
|
||||
}),
|
||||
[backgroundColor, screenOptions],
|
||||
);
|
||||
|
||||
return <Stack screenOptions={themedScreenOptions}>{children}</Stack>;
|
||||
}
|
||||
|
||||
export const ThemedStack = withUnistyles(ThemedStackBase, (theme) => ({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
}));
|
||||
@@ -77,11 +77,7 @@ function hasPendingArchiveForProject(input: {
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceDirectory = getHostProjectSourceDirectory(input.project, input.selectedServerId);
|
||||
return isWorkspaceArchivePending({
|
||||
serverId: input.selectedServerId,
|
||||
workspaceDirectory,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useNewWorkspaceProjectPicker({
|
||||
|
||||
@@ -62,7 +62,7 @@ import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { SplitContainer } from "@/components/split-container";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||
import { WorkspaceGitActions } from "@/git/workspace-actions";
|
||||
import { WorkspaceActions } from "@/git/workspace-actions";
|
||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
@@ -3276,56 +3276,56 @@ function WorkspaceScreenContent({
|
||||
hideLabels
|
||||
/>
|
||||
) : null}
|
||||
{!isMobile && isGitCheckout ? (
|
||||
{!isMobile && workspaceDirectory ? (
|
||||
<>
|
||||
{workspaceDirectory ? (
|
||||
<WorkspaceGitActions
|
||||
serverId={normalizedServerId}
|
||||
cwd={workspaceDirectory}
|
||||
hideLabels={showCompactButtonLabels}
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
testID="workspace-explorer-toggle"
|
||||
onPress={handleToggleExplorer}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={explorerToggleLabel}
|
||||
accessibilityState={explorerToggleAccessibilityState}
|
||||
style={explorerToggleStyle}
|
||||
<WorkspaceActions
|
||||
serverId={normalizedServerId}
|
||||
cwd={workspaceDirectory}
|
||||
hideLabels={showCompactButtonLabels}
|
||||
/>
|
||||
{isGitCheckout ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
testID="workspace-explorer-toggle"
|
||||
onPress={handleToggleExplorer}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={explorerToggleLabel}
|
||||
accessibilityState={explorerToggleAccessibilityState}
|
||||
style={explorerToggleStyle}
|
||||
>
|
||||
{({ hovered, pressed }) => {
|
||||
const active = isExplorerOpen || hovered || pressed;
|
||||
const colorMapping = active ? foregroundColorMapping : mutedColorMapping;
|
||||
return (
|
||||
<>
|
||||
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
|
||||
{workspaceDescriptor?.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspaceDescriptor.diffStat.additions}
|
||||
deletions={workspaceDescriptor.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
testID="workspace-explorer-toggle-tooltip"
|
||||
side="left"
|
||||
align="center"
|
||||
offset={8}
|
||||
>
|
||||
{({ hovered, pressed }) => {
|
||||
const active = isExplorerOpen || hovered || pressed;
|
||||
const colorMapping = active ? foregroundColorMapping : mutedColorMapping;
|
||||
return (
|
||||
<>
|
||||
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
|
||||
{workspaceDescriptor?.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspaceDescriptor.diffStat.additions}
|
||||
deletions={workspaceDescriptor.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
testID="workspace-explorer-toggle-tooltip"
|
||||
side="left"
|
||||
align="center"
|
||||
offset={8}
|
||||
>
|
||||
<View style={styles.explorerTooltipRow}>
|
||||
<Text style={styles.explorerTooltipText}>
|
||||
{t("workspace.tabs.explorer.toggle")}
|
||||
</Text>
|
||||
<Shortcut keys={EXPLORER_TOGGLE_KEYS} style={styles.explorerTooltipShortcut} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<View style={styles.explorerTooltipRow}>
|
||||
<Text style={styles.explorerTooltipText}>
|
||||
{t("workspace.tabs.explorer.toggle")}
|
||||
</Text>
|
||||
<Shortcut keys={EXPLORER_TOGGLE_KEYS} style={styles.explorerTooltipShortcut} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{!isMobile && !isGitCheckout ? (
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("selectProjectWorkspacesToArchive", () => {
|
||||
|
||||
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
|
||||
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
|
||||
worktreeName: "feature/risky",
|
||||
workspaceName: "feature/risky",
|
||||
isDirty: true,
|
||||
aheadOfOrigin: 2,
|
||||
diffStat: { additions: 5, deletions: 1 },
|
||||
@@ -73,7 +73,7 @@ describe("selectProjectWorkspacesToArchive", () => {
|
||||
|
||||
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
|
||||
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
|
||||
worktreeName: "feature/risky",
|
||||
workspaceName: "feature/risky",
|
||||
isDirty: true,
|
||||
aheadOfOrigin: 2,
|
||||
diffStat: { additions: 5, deletions: 1 },
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function selectProjectWorkspacesToArchive(
|
||||
for (const workspace of workspaces) {
|
||||
if (workspace.workspaceKind === "worktree") {
|
||||
const shouldArchive = await confirmWorktreeArchive({
|
||||
worktreeName: workspace.name,
|
||||
workspaceName: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
});
|
||||
if (!shouldArchive) {
|
||||
|
||||
@@ -2,20 +2,30 @@ import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import {
|
||||
confirmRiskyWorktreeArchive,
|
||||
DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
|
||||
type WorktreeArchiveWarningLabels,
|
||||
} from "@/git/worktree-archive-warning";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
|
||||
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
|
||||
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey(input);
|
||||
if (workspaceKey) {
|
||||
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
|
||||
}
|
||||
useWorkspaceTabsStore.getState().purgeWorkspace(input);
|
||||
}
|
||||
|
||||
export interface ArchiveWorkspaceInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
name: string;
|
||||
isDirty?: boolean | null;
|
||||
@@ -34,7 +44,6 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
const {
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
workspaceKind,
|
||||
name,
|
||||
isDirty,
|
||||
@@ -46,37 +55,8 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
} = input;
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
|
||||
const archiveWorktreeRecord = useCallback(() => {
|
||||
let archiveDirectory: string;
|
||||
try {
|
||||
archiveDirectory = requireWorkspaceDirectory({
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
onArchiveStarted();
|
||||
void archiveWorktree({
|
||||
serverId,
|
||||
cwd: archiveDirectory,
|
||||
worktreePath: archiveDirectory,
|
||||
workspaceId,
|
||||
}).catch((error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
|
||||
);
|
||||
});
|
||||
}, [archiveWorktree, onArchiveStarted, serverId, t, toast, workspaceDirectory, workspaceId]);
|
||||
|
||||
const archiveNonWorktreeRecord = useCallback(async () => {
|
||||
const archiveWorkspaceRecord = useCallback(async () => {
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) {
|
||||
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
@@ -84,17 +64,18 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
}
|
||||
onSetHiding?.(true);
|
||||
try {
|
||||
onArchiveStarted();
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace: {
|
||||
serverId,
|
||||
workspaceId,
|
||||
},
|
||||
afterHide: onArchiveStarted,
|
||||
});
|
||||
purgeArchivedWorkspaceState({ serverId, workspaceId });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
|
||||
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
|
||||
);
|
||||
} finally {
|
||||
onSetHiding?.(false);
|
||||
@@ -106,7 +87,7 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
if (workspaceKind === "worktree") {
|
||||
const confirmed = await confirmRiskyWorktreeArchive(
|
||||
{
|
||||
worktreeName: name,
|
||||
workspaceName: name,
|
||||
isDirty,
|
||||
aheadOfOrigin,
|
||||
diffStat,
|
||||
@@ -116,15 +97,12 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
archiveWorktreeRecord();
|
||||
return;
|
||||
}
|
||||
await archiveNonWorktreeRecord();
|
||||
await archiveWorkspaceRecord();
|
||||
})();
|
||||
}, [
|
||||
aheadOfOrigin,
|
||||
archiveNonWorktreeRecord,
|
||||
archiveWorktreeRecord,
|
||||
archiveWorkspaceRecord,
|
||||
diffStat,
|
||||
isDirty,
|
||||
name,
|
||||
|
||||
@@ -144,23 +144,6 @@ describe("archiveWorkspaceOptimistically", () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("runs the after-hide hook after local state is hidden", async () => {
|
||||
const archived = workspace();
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
|
||||
const client = createClient(vi.fn(async () => archivePayload({ workspaceId: archived.id })));
|
||||
const afterHide = vi.fn(() => {
|
||||
expect(storedWorkspace(archived.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace: target(),
|
||||
afterHide,
|
||||
});
|
||||
|
||||
expect(afterHide).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("archiveWorkspacesOptimistically", () => {
|
||||
@@ -183,10 +166,7 @@ describe("archiveWorkspacesOptimistically", () => {
|
||||
|
||||
const failures = await archiveWorkspacesOptimistically({
|
||||
getClient: () => client,
|
||||
workspaces: [
|
||||
target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }),
|
||||
target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }),
|
||||
],
|
||||
workspaces: [target({ workspaceId: first.id }), target({ workspaceId: second.id })],
|
||||
});
|
||||
|
||||
expect(failures).toHaveLength(1);
|
||||
@@ -219,12 +199,10 @@ describe("archiveWorkspacesOptimistically", () => {
|
||||
target({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: first.id,
|
||||
workspaceDirectory: first.workspaceDirectory,
|
||||
}),
|
||||
target({
|
||||
serverId: SECOND_SERVER_ID,
|
||||
workspaceId: second.id,
|
||||
workspaceDirectory: second.workspaceDirectory,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import { i18n } from "@/i18n/i18next";
|
||||
export interface WorkspaceArchiveTarget {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceDirectory?: string | null;
|
||||
}
|
||||
|
||||
interface WorkspaceArchiveClient {
|
||||
@@ -50,7 +49,6 @@ function hideWorkspaceOptimistically(
|
||||
markWorkspaceArchivePending({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
return { workspace: snapshot };
|
||||
@@ -83,10 +81,8 @@ async function archiveWorkspaceOrThrow(input: {
|
||||
export async function archiveWorkspaceOptimistically(input: {
|
||||
client: WorkspaceArchiveClient;
|
||||
workspace: WorkspaceArchiveTarget;
|
||||
afterHide?: () => void;
|
||||
}): Promise<void> {
|
||||
const snapshot = hideWorkspaceOptimistically(input.workspace);
|
||||
input.afterHide?.();
|
||||
|
||||
try {
|
||||
await archiveWorkspaceOrThrow({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -27,9 +27,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/server": "0.1.105",
|
||||
"@getpaseo/client": "0.1.106",
|
||||
"@getpaseo/protocol": "0.1.106",
|
||||
"@getpaseo/server": "0.1.106",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -35,8 +35,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/relay": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.106",
|
||||
"@getpaseo/relay": "0.1.106",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createCutoverProxy } from "./cutover-proxy.js";
|
||||
import type { ConnectionRole, RelaySessionAttachment } from "./types.js";
|
||||
|
||||
type RelayProtocolVersion = "1" | "2";
|
||||
@@ -97,6 +98,7 @@ function getGlobalWebSocketPair(): (new () => WebSocketPair) | undefined {
|
||||
|
||||
interface Env {
|
||||
RELAY: DurableObjectNamespace;
|
||||
PASEO_RELAY_UPSTREAM?: string;
|
||||
}
|
||||
|
||||
interface DurableObjectNamespace {
|
||||
@@ -572,6 +574,10 @@ export class RelayDurableObject {
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
if (env.PASEO_RELAY_UPSTREAM) {
|
||||
return createCutoverProxy(env.PASEO_RELAY_UPSTREAM).fetch(request);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Health check
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createServer, type IncomingMessage, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import relayWorker from "./cloudflare-adapter.js";
|
||||
|
||||
type RelayEnv = Parameters<typeof relayWorker.fetch>[1];
|
||||
|
||||
interface ReceivedRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
probe: string;
|
||||
}
|
||||
|
||||
class RecordingOrigin {
|
||||
private readonly requests: ReceivedRequest[] = [];
|
||||
private server: Server | null = null;
|
||||
|
||||
async start(): Promise<string> {
|
||||
this.server = createServer((request, response) => {
|
||||
this.requests.push(recordRequest(request));
|
||||
response.writeHead(202, { "content-type": "application/json", "x-relay-origin": "fly" });
|
||||
response.end(JSON.stringify({ status: "forwarded" }));
|
||||
});
|
||||
await new Promise<void>((resolve) => this.server!.listen(0, "127.0.0.1", resolve));
|
||||
const address = this.server.address() as AddressInfo;
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
received(): ReceivedRequest[] {
|
||||
return this.requests;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const server = this.server;
|
||||
this.server = null;
|
||||
if (!server) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function recordRequest(request: IncomingMessage): ReceivedRequest {
|
||||
return {
|
||||
method: request.method ?? "",
|
||||
path: request.url ?? "",
|
||||
probe: String(request.headers["x-relay-probe"] ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
describe("cutover proxy", () => {
|
||||
const origins: RecordingOrigin[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(origins.splice(0).map((origin) => origin.close()));
|
||||
});
|
||||
|
||||
it("forwards the request to the configured origin without changing its route", async () => {
|
||||
const origin = new RecordingOrigin();
|
||||
origins.push(origin);
|
||||
const originUrl = await origin.start();
|
||||
|
||||
const response = await relayWorker.fetch(
|
||||
new Request("https://relay.paseo.sh/ws?serverId=srv_prod&role=server&v=2", {
|
||||
headers: { "x-relay-probe": "production" },
|
||||
}),
|
||||
{ PASEO_RELAY_UPSTREAM: originUrl } as RelayEnv,
|
||||
);
|
||||
|
||||
expect(origin.received()).toEqual([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/ws?serverId=srv_prod&role=server&v=2",
|
||||
probe: "production",
|
||||
},
|
||||
]);
|
||||
expect(response.status).toBe(202);
|
||||
await expect(response.json()).resolves.toEqual({ status: "forwarded" });
|
||||
expect(response.headers.get("x-relay-origin")).toBe("fly");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
interface CutoverProxy {
|
||||
fetch(request: Request): Promise<Response>;
|
||||
}
|
||||
|
||||
export function createCutoverProxy(origin: string): CutoverProxy {
|
||||
const originUrl = new URL(origin);
|
||||
|
||||
return {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const upstreamUrl = new URL(request.url);
|
||||
upstreamUrl.protocol = originUrl.protocol;
|
||||
upstreamUrl.host = originUrl.host;
|
||||
|
||||
return fetch(new Request(upstreamUrl, request));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
// This live test uses the hosted relay's real TLS endpoint. Self-hosted relay TLS
|
||||
// opt-in is covered at URL-building/integration level so the local E2E does not
|
||||
// need to provision trusted certificates.
|
||||
const RELAY_BASE_URL = "wss://relay.paseo.sh";
|
||||
const RELAY_BASE_URL = process.env.PASEO_LIVE_RELAY_URL ?? "wss://relay.paseo.sh";
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
@@ -119,6 +119,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
|
||||
// === Connect ===
|
||||
const daemonControlWs = new WebSocket(serverControlUrl);
|
||||
const clientWs = new WebSocket(clientUrl);
|
||||
const connected = waitForConnected(daemonControlWs, connectionId);
|
||||
let daemonWs: WebSocket | null = null;
|
||||
|
||||
try {
|
||||
@@ -127,7 +128,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
|
||||
waitOpen(clientWs, "client"),
|
||||
]);
|
||||
|
||||
await waitForConnected(daemonControlWs, connectionId);
|
||||
await connected;
|
||||
|
||||
daemonWs = new WebSocket(serverDataUrl);
|
||||
await waitOpen(daemonWs, "server-data");
|
||||
|
||||
@@ -8,6 +8,9 @@ routes = [{ pattern = "relay.paseo.sh", custom_domain = true }]
|
||||
[observability]
|
||||
enabled = true
|
||||
|
||||
[vars]
|
||||
PASEO_RELAY_UPSTREAM = "https://paseo-relay-next.fly.dev"
|
||||
|
||||
[[durable_objects.bindings]]
|
||||
name = "RELAY"
|
||||
class_name = "RelayDurableObject"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -67,10 +67,10 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.105",
|
||||
"@getpaseo/highlight": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/relay": "0.1.105",
|
||||
"@getpaseo/client": "0.1.106",
|
||||
"@getpaseo/highlight": "0.1.106",
|
||||
"@getpaseo/protocol": "0.1.106",
|
||||
"@getpaseo/relay": "0.1.106",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
|
||||
const COALESCE_WINDOW_MS = AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS;
|
||||
const BEFORE_COALESCE_WINDOW_MS = Math.max(COALESCE_WINDOW_MS - 1, 0);
|
||||
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
|
||||
|
||||
const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: false,
|
||||
@@ -377,6 +378,148 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("target coalesced behavior", () => {
|
||||
test("bounds tool output before persisting and streaming it", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
});
|
||||
|
||||
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
expect(getTimelineItems(rows)).toEqual([expectedItem]);
|
||||
expect(
|
||||
events.map((event) => (event.type === "agent_stream" ? event.event.item : null)),
|
||||
).toEqual([expectedItem]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds tool output by UTF-8 bytes", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
const output = "漢".repeat(TOOL_CALL_CONTENT_MAX_BYTES);
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "漢".repeat(Math.floor(TOOL_CALL_CONTENT_MAX_BYTES / 3)),
|
||||
});
|
||||
|
||||
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
||||
expectedItem,
|
||||
]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds appended tool output before persisting and streaming it", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId } = await createManagedSession(harness);
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
});
|
||||
|
||||
await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output }));
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
expect(getTimelineItems(rows)).toEqual([expectedItem]);
|
||||
expect(
|
||||
events.map((event) => (event.type === "agent_stream" ? event.event.item : null)),
|
||||
).toEqual([expectedItem]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds tool output while hydrating provider history", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
});
|
||||
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
|
||||
|
||||
await harness.manager.hydrateTimelineFromProvider(agentId);
|
||||
|
||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
||||
expectedItem,
|
||||
]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds tool output emitted only to the live stream", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId } = await createManagedSession(harness);
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
});
|
||||
|
||||
await harness.manager.emitLiveTimelineItem(
|
||||
agentId,
|
||||
toolCall({ status: "completed", output }),
|
||||
);
|
||||
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
expect(await harness.manager.getTimelineRows(agentId)).toEqual([]);
|
||||
expect(
|
||||
events.map((event) => (event.type === "agent_stream" ? event.event.item : null)),
|
||||
).toEqual([expectedItem]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds failed shell output carried in error content and message", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
const content = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "failed",
|
||||
error: {
|
||||
content: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
message: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
},
|
||||
});
|
||||
|
||||
session.pushEvent(
|
||||
timelineEvent(toolCall({ status: "failed", error: { content, message: content } })),
|
||||
);
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
||||
expectedItem,
|
||||
]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test(`coalesces a same-tick assistant burst after the ${COALESCE_WINDOW_MS}ms window`, async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -8,6 +8,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import {
|
||||
AgentManager,
|
||||
AgentManagerShuttingDownError,
|
||||
commandMayHaveChangedExternalState,
|
||||
type AgentManagerEvent,
|
||||
type ManagedAgent,
|
||||
@@ -139,6 +140,117 @@ class TestAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
class HeldAgentCreationClient extends TestAgentClient {
|
||||
private readonly creationStarted = deferred<void>();
|
||||
private readonly creationAllowed = deferred<void>();
|
||||
createdSessionClosed = false;
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const recordSessionClosed = () => {
|
||||
this.createdSessionClosed = true;
|
||||
};
|
||||
const session = new (class extends TestAgentSession {
|
||||
override async close(): Promise<void> {
|
||||
recordSessionClosed();
|
||||
}
|
||||
})(config);
|
||||
this.creationStarted.resolve();
|
||||
await this.creationAllowed.promise;
|
||||
return session;
|
||||
}
|
||||
|
||||
waitForCreationToStart(): Promise<void> {
|
||||
return this.creationStarted.promise;
|
||||
}
|
||||
|
||||
finishCreating(): void {
|
||||
this.creationAllowed.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
class HeldAgentCreationAndCloseClient extends TestAgentClient {
|
||||
private readonly creationStarted = deferred<void>();
|
||||
private readonly creationAllowed = deferred<void>();
|
||||
private readonly closeStarted = deferred<void>();
|
||||
private readonly closeAllowed = deferred<void>();
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.creationStarted.resolve();
|
||||
await this.creationAllowed.promise;
|
||||
const signalCloseStarted = () => this.closeStarted.resolve();
|
||||
const waitForClose = () => this.closeAllowed.promise;
|
||||
return new (class extends TestAgentSession {
|
||||
override async close(): Promise<void> {
|
||||
signalCloseStarted();
|
||||
await waitForClose();
|
||||
}
|
||||
})(config);
|
||||
}
|
||||
|
||||
waitForCreationToStart(): Promise<void> {
|
||||
return this.creationStarted.promise;
|
||||
}
|
||||
|
||||
finishCreating(): void {
|
||||
this.creationAllowed.resolve();
|
||||
}
|
||||
|
||||
waitForCloseToStart(): Promise<void> {
|
||||
return this.closeStarted.promise;
|
||||
}
|
||||
|
||||
finishClosing(): void {
|
||||
this.closeAllowed.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
class HeldReloadCloseClient extends TestAgentClient {
|
||||
private readonly closeStarted = deferred<void>();
|
||||
private readonly closeAllowed = deferred<void>();
|
||||
originalSessionClosed = false;
|
||||
replacementSessionClosed = false;
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const signalCloseStarted = () => this.closeStarted.resolve();
|
||||
const waitForClose = () => this.closeAllowed.promise;
|
||||
const recordOriginalClosed = () => {
|
||||
this.originalSessionClosed = true;
|
||||
};
|
||||
return new (class extends TestAgentSession {
|
||||
override async close(): Promise<void> {
|
||||
signalCloseStarted();
|
||||
await waitForClose();
|
||||
recordOriginalClosed();
|
||||
}
|
||||
})(config);
|
||||
}
|
||||
|
||||
override async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
const recordReplacementClosed = () => {
|
||||
this.replacementSessionClosed = true;
|
||||
};
|
||||
return new (class extends TestAgentSession {
|
||||
override async close(): Promise<void> {
|
||||
recordReplacementClosed();
|
||||
}
|
||||
})({
|
||||
provider: "codex",
|
||||
cwd: config?.cwd ?? process.cwd(),
|
||||
});
|
||||
}
|
||||
|
||||
waitForCloseToStart(): Promise<void> {
|
||||
return this.closeStarted.promise;
|
||||
}
|
||||
|
||||
finishClosing(): void {
|
||||
this.closeAllowed.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
class NativeArchiveRecordingClient extends TestAgentClient {
|
||||
readonly archivedHandles: AgentPersistenceHandle[] = [];
|
||||
readonly unarchivedHandles: AgentPersistenceHandle[] = [];
|
||||
@@ -290,6 +402,52 @@ class TestAgentSession implements AgentSession {
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
class HeldRuntimeInfoSession extends TestAgentSession {
|
||||
private readonly runtimeInfoRequested = deferred<void>();
|
||||
private readonly runtimeInfoAllowed = deferred<void>();
|
||||
|
||||
override async getRuntimeInfo() {
|
||||
this.runtimeInfoRequested.resolve();
|
||||
await this.runtimeInfoAllowed.promise;
|
||||
return await super.getRuntimeInfo();
|
||||
}
|
||||
|
||||
waitForRuntimeInfo(): Promise<void> {
|
||||
return this.runtimeInfoRequested.promise;
|
||||
}
|
||||
|
||||
finishRuntimeInfo(): void {
|
||||
this.runtimeInfoAllowed.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
class HeldRuntimeInfoClient extends TestAgentClient {
|
||||
private readonly sessionCreated = deferred<HeldRuntimeInfoSession>();
|
||||
private session: HeldRuntimeInfoSession | null = null;
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.session = new HeldRuntimeInfoSession(config);
|
||||
this.sessionCreated.resolve(this.session);
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async waitForRuntimeInfo(): Promise<void> {
|
||||
const session = await this.sessionCreated.promise;
|
||||
await session.waitForRuntimeInfo();
|
||||
}
|
||||
|
||||
finishRuntimeInfo(): void {
|
||||
this.requireSession().finishRuntimeInfo();
|
||||
}
|
||||
|
||||
private requireSession(): HeldRuntimeInfoSession {
|
||||
if (!this.session) {
|
||||
throw new Error("Expected a created session");
|
||||
}
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
|
||||
class StreamingAssistantSession implements AgentSession {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
@@ -449,6 +607,211 @@ function fakeCodexEmitting(args: FakeCodexEmitterArgs): AgentClient {
|
||||
|
||||
const logger = createTestLogger();
|
||||
|
||||
test("does not register a session that finishes starting after shutdown begins", async () => {
|
||||
const client = new HeldAgentCreationClient();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000100",
|
||||
});
|
||||
|
||||
const creation = manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
await client.waitForCreationToStart();
|
||||
|
||||
manager.prepareForShutdown();
|
||||
client.finishCreating();
|
||||
|
||||
await expect(creation).rejects.toThrow("Agent manager is shutting down");
|
||||
expect({ agents: manager.listAgents(), sessionClosed: client.createdSessionClosed }).toEqual({
|
||||
agents: [],
|
||||
sessionClosed: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("flush waits for rejected session cleanup that starts after shutdown", async () => {
|
||||
const client = new HeldAgentCreationAndCloseClient();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000098",
|
||||
});
|
||||
|
||||
const creation = manager
|
||||
.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
)
|
||||
.catch((error: unknown) => error);
|
||||
await client.waitForCreationToStart();
|
||||
|
||||
manager.prepareForShutdown();
|
||||
let flushResolved = false;
|
||||
const flushing = manager.flushForShutdown().then(() => {
|
||||
flushResolved = true;
|
||||
return undefined;
|
||||
});
|
||||
client.finishCreating();
|
||||
await client.waitForCloseToStart();
|
||||
|
||||
try {
|
||||
expect(flushResolved).toBe(false);
|
||||
} finally {
|
||||
client.finishClosing();
|
||||
}
|
||||
|
||||
expect(await creation).toBeInstanceOf(AgentManagerShuttingDownError);
|
||||
await flushing;
|
||||
expect(manager.listAgents()).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not persist an initializing session after shutdown closes it", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-shutdown-register-test-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new HeldRuntimeInfoClient();
|
||||
const agentId = "00000000-0000-4000-8000-000000000099";
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => agentId,
|
||||
});
|
||||
|
||||
try {
|
||||
const creation = manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
await client.waitForRuntimeInfo();
|
||||
|
||||
manager.prepareForShutdown();
|
||||
const closing = manager.closeAgent(agentId);
|
||||
client.finishRuntimeInfo();
|
||||
|
||||
await expect(creation).rejects.toBeInstanceOf(AgentManagerShuttingDownError);
|
||||
await closing;
|
||||
await storage.flush();
|
||||
expect({ agents: manager.listAgents(), record: await storage.get(agentId) }).toMatchObject({
|
||||
agents: [],
|
||||
record: { lastStatus: "closed" },
|
||||
});
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("reload leaves a closed durable snapshot when shutdown starts during the swap", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-shutdown-reload-test-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new HeldReloadCloseClient();
|
||||
const agentId = "00000000-0000-4000-8000-000000000097";
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => agentId,
|
||||
});
|
||||
|
||||
try {
|
||||
await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const reload = manager.reloadAgentSession(agentId).catch((error: unknown) => error);
|
||||
await client.waitForCloseToStart();
|
||||
|
||||
manager.prepareForShutdown();
|
||||
const closing = Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
|
||||
client.finishClosing();
|
||||
|
||||
await closing;
|
||||
expect(await reload).toBeInstanceOf(AgentManagerShuttingDownError);
|
||||
await manager.flush();
|
||||
await storage.flush();
|
||||
expect({
|
||||
agents: manager.listAgents(),
|
||||
record: await storage.get(agentId),
|
||||
replacementSessionClosed: client.replacementSessionClosed,
|
||||
}).toMatchObject({
|
||||
agents: [],
|
||||
record: { lastStatus: "closed" },
|
||||
replacementSessionClosed: true,
|
||||
});
|
||||
} finally {
|
||||
client.finishClosing();
|
||||
await manager.flush().catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("reload closes both sessions when the closed snapshot cannot be persisted", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-persist-failure-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const client = new HeldReloadCloseClient();
|
||||
const agentId = "00000000-0000-4000-8000-000000000096";
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => agentId,
|
||||
});
|
||||
|
||||
try {
|
||||
await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
await storage.flush();
|
||||
rmSync(storagePath, { recursive: true, force: true });
|
||||
writeFileSync(storagePath, "blocks the storage directory");
|
||||
|
||||
const reload = manager.reloadAgentSession(agentId).catch((error: unknown) => error);
|
||||
await client.waitForCloseToStart();
|
||||
client.finishClosing();
|
||||
|
||||
expect(await reload).toBeInstanceOf(Error);
|
||||
await manager.flushForShutdown();
|
||||
expect({
|
||||
agents: manager.listAgents(),
|
||||
originalSessionClosed: client.originalSessionClosed,
|
||||
replacementSessionClosed: client.replacementSessionClosed,
|
||||
}).toEqual({
|
||||
agents: [],
|
||||
originalSessionClosed: true,
|
||||
replacementSessionClosed: true,
|
||||
});
|
||||
} finally {
|
||||
client.finishClosing();
|
||||
await manager.flushForShutdown().catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizeConfig injects the provider default model when omitted", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
@@ -1951,6 +2314,22 @@ test("importProviderSession imports the selected session without listing and pub
|
||||
item: { type: "assistant_message" as const, text: "Done" },
|
||||
timestamp: "2026-01-02T00:00:01.000Z",
|
||||
},
|
||||
{
|
||||
item: {
|
||||
type: "tool_call" as const,
|
||||
callId: "large-shell-result",
|
||||
name: "shell",
|
||||
status: "completed" as const,
|
||||
error: null,
|
||||
detail: {
|
||||
type: "shell" as const,
|
||||
command: "print output",
|
||||
output: "x".repeat(1024 * 1024),
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
timestamp: "2026-01-02T00:00:02.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1980,6 +2359,19 @@ test("importProviderSession imports the selected session without listing and pub
|
||||
expect(manager.getTimeline(imported.id)).toEqual([
|
||||
{ type: "user_message", text: "Trace provider imports" },
|
||||
{ type: "assistant_message", text: "Done" },
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "large-shell-result",
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "print output",
|
||||
output: "x".repeat(64 * 1024),
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,
|
||||
AgentStreamCoalescer,
|
||||
} from "./agent-stream-coalescer.js";
|
||||
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
||||
import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js";
|
||||
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
||||
@@ -82,6 +83,13 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
|
||||
type TimeoutResult = "completed" | "timed_out";
|
||||
|
||||
export class AgentManagerShuttingDownError extends Error {
|
||||
constructor() {
|
||||
super("Agent manager is shutting down");
|
||||
this.name = "AgentManagerShuttingDownError";
|
||||
}
|
||||
}
|
||||
|
||||
interface PreparedSessionConfig {
|
||||
storedConfig: AgentSessionConfig;
|
||||
launchConfig: AgentSessionConfig;
|
||||
@@ -486,7 +494,7 @@ function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): A
|
||||
rows.push({
|
||||
seq: rows.length + 1,
|
||||
timestamp: entry.timestamp ?? new Date().toISOString(),
|
||||
item: entry.item,
|
||||
item: limitAgentTimelineItemContent(entry.item),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
@@ -535,6 +543,7 @@ export class AgentManager {
|
||||
private readonly durableTimelineStore?: AgentTimelineStore;
|
||||
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private readonly agentRegistrationTasks = new Set<Promise<void>>();
|
||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||
private mcpBaseUrl: string | null;
|
||||
private readonly mcpAuthToken: string | null;
|
||||
@@ -546,6 +555,7 @@ export class AgentManager {
|
||||
private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
|
||||
private logger: Logger;
|
||||
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
|
||||
private acceptingAgentRegistrations = true;
|
||||
|
||||
constructor(options: AgentManagerOptions) {
|
||||
this.idFactory = options?.idFactory ?? (() => randomUUID());
|
||||
@@ -619,6 +629,10 @@ export class AgentManager {
|
||||
this.mcpBaseUrl = url;
|
||||
}
|
||||
|
||||
prepareForShutdown(): void {
|
||||
this.acceptingAgentRegistrations = false;
|
||||
}
|
||||
|
||||
setPaseoToolsEnabled(enabled: boolean): void {
|
||||
this.paseoToolsEnabled = enabled;
|
||||
}
|
||||
@@ -922,11 +936,20 @@ export class AgentManager {
|
||||
return this.timelineStore.fetch(id, options);
|
||||
}
|
||||
|
||||
async createAgent(
|
||||
createAgent(
|
||||
config: AgentSessionConfig,
|
||||
agentId: string | undefined,
|
||||
options: CreateAgentOptions,
|
||||
): Promise<ManagedAgent> {
|
||||
return this.trackAgentRegistrationOperation(this.createAgentInternal(config, agentId, options));
|
||||
}
|
||||
|
||||
private async createAgentInternal(
|
||||
config: AgentSessionConfig,
|
||||
agentId: string | undefined,
|
||||
options: CreateAgentOptions,
|
||||
): Promise<ManagedAgent> {
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||
const { storedConfig, launchConfig } = await this.prepareSessionConfig(config, resolvedAgentId);
|
||||
this.requireEnabledProvider(storedConfig.provider);
|
||||
@@ -954,7 +977,7 @@ export class AgentManager {
|
||||
|
||||
// Reconstruct an agent from provider persistence. Callers should explicitly
|
||||
// hydrate timeline history after resume.
|
||||
async resumeAgentFromPersistence(
|
||||
resumeAgentFromPersistence(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
agentId?: string,
|
||||
@@ -966,6 +989,24 @@ export class AgentManager {
|
||||
workspaceId?: string;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
return this.trackAgentRegistrationOperation(
|
||||
this.resumeAgentFromPersistenceInternal(handle, overrides, agentId, options),
|
||||
);
|
||||
}
|
||||
|
||||
private async resumeAgentFromPersistenceInternal(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
agentId?: string,
|
||||
options?: {
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
const resolvedAgentId = validateAgentId(
|
||||
agentId ?? this.idFactory(),
|
||||
"resumeAgentFromPersistence",
|
||||
@@ -994,13 +1035,24 @@ export class AgentManager {
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, options);
|
||||
}
|
||||
|
||||
async importProviderSession(input: {
|
||||
importProviderSession(input: {
|
||||
provider: AgentProvider;
|
||||
providerHandleId: string;
|
||||
cwd: string;
|
||||
workspaceId: string;
|
||||
labels?: Record<string, string>;
|
||||
}): Promise<ManagedAgent> {
|
||||
return this.trackAgentRegistrationOperation(this.importProviderSessionInternal(input));
|
||||
}
|
||||
|
||||
private async importProviderSessionInternal(input: {
|
||||
provider: AgentProvider;
|
||||
providerHandleId: string;
|
||||
cwd: string;
|
||||
workspaceId: string;
|
||||
labels?: Record<string, string>;
|
||||
}): Promise<ManagedAgent> {
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession");
|
||||
this.requireEnabledProvider(input.provider);
|
||||
|
||||
@@ -1025,20 +1077,30 @@ export class AgentManager {
|
||||
},
|
||||
{ config: providerLaunchConfig, storedConfig, launchContext },
|
||||
);
|
||||
const importedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(imported.config));
|
||||
const timelineRows = buildImportedTimelineRows(imported.timeline);
|
||||
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
|
||||
let handedToRegistration = false;
|
||||
try {
|
||||
const importedConfig = await this.normalizeConfig(
|
||||
stripInternalPaseoMcpServer(imported.config),
|
||||
);
|
||||
const timelineRows = buildImportedTimelineRows(imported.timeline);
|
||||
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
|
||||
|
||||
return this.registerSession(imported.session, importedConfig, resolvedAgentId, {
|
||||
labels: input.labels,
|
||||
workspaceId: input.workspaceId,
|
||||
timelineRows,
|
||||
timelineNextSeq: timelineRows.length + 1,
|
||||
persistence: imported.persistence,
|
||||
historyPrimed: true,
|
||||
initialTitle,
|
||||
publishWhenReady: true,
|
||||
});
|
||||
handedToRegistration = true;
|
||||
return this.registerSession(imported.session, importedConfig, resolvedAgentId, {
|
||||
labels: input.labels,
|
||||
workspaceId: input.workspaceId,
|
||||
timelineRows,
|
||||
timelineNextSeq: timelineRows.length + 1,
|
||||
persistence: imported.persistence,
|
||||
historyPrimed: true,
|
||||
initialTitle,
|
||||
publishWhenReady: true,
|
||||
});
|
||||
} finally {
|
||||
if (!handedToRegistration) {
|
||||
await this.closeUnregisteredSession(imported.session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hot-reload an active agent session with config overrides. By default the
|
||||
@@ -1047,11 +1109,22 @@ export class AgentManager {
|
||||
// new epoch is minted and provider history is re-streamed — this is what the
|
||||
// user-facing "Reload agent" action wants when the on-disk session was
|
||||
// mutated outside Paseo.
|
||||
async reloadAgentSession(
|
||||
reloadAgentSession(
|
||||
agentId: string,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
options?: { rehydrateFromDisk?: boolean },
|
||||
): Promise<ManagedAgent> {
|
||||
return this.trackAgentRegistrationOperation(
|
||||
this.reloadAgentSessionInternal(agentId, overrides, options),
|
||||
);
|
||||
}
|
||||
|
||||
private async reloadAgentSessionInternal(
|
||||
agentId: string,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
options?: { rehydrateFromDisk?: boolean },
|
||||
): Promise<ManagedAgent> {
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
let existing = this.requireSessionAgent(agentId);
|
||||
if (this.hasInFlightRun(agentId)) {
|
||||
await this.cancelAgentRun(agentId);
|
||||
@@ -1078,36 +1151,43 @@ export class AgentManager {
|
||||
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
|
||||
: await client.createSession(providerLaunchConfig, launchContext);
|
||||
|
||||
this.agentStreamCoalescer.flushAndDiscard(agentId);
|
||||
// Remove the existing agent entry before swapping sessions
|
||||
this.agents.delete(agentId);
|
||||
if (existing.unsubscribeSession) {
|
||||
existing.unsubscribeSession();
|
||||
existing.unsubscribeSession = null;
|
||||
}
|
||||
this.foregroundRuns.clearAgent(agentId, existing);
|
||||
await this.closeReloadedSession(existing.session, agentId);
|
||||
let handedToRegistration = false;
|
||||
try {
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
|
||||
if (rehydrateFromDisk) {
|
||||
// Wipe both durable and in-memory timeline so registerSession mints a
|
||||
// new epoch and hydrateTimelineFromProvider re-streams the freshly read
|
||||
// provider history into an empty timeline.
|
||||
await this.deleteCommittedTimeline(agentId);
|
||||
this.timelineStore.delete(agentId);
|
||||
}
|
||||
const closedExisting = this.prepareAgentForClosure(existing, "agent reloaded");
|
||||
try {
|
||||
await this.persistSnapshot(closedExisting);
|
||||
} finally {
|
||||
await this.closeReloadedSession(existing.session, agentId);
|
||||
}
|
||||
|
||||
// Preserve existing labels and timeline during reload.
|
||||
return this.registerSession(session, storedConfig, agentId, {
|
||||
labels: existing.labels,
|
||||
workspaceId: existing.workspaceId,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
lastUserMessageAt: existing.lastUserMessageAt,
|
||||
historyPrimed: rehydrateFromDisk ? false : preservedHistoryPrimed,
|
||||
lastUsage: preservedLastUsage,
|
||||
lastError: preservedLastError,
|
||||
attention: preservedAttention,
|
||||
});
|
||||
if (rehydrateFromDisk) {
|
||||
// Wipe both durable and in-memory timeline so registerSession mints a
|
||||
// new epoch and hydrateTimelineFromProvider re-streams the freshly read
|
||||
// provider history into an empty timeline.
|
||||
await this.deleteCommittedTimeline(agentId);
|
||||
this.timelineStore.delete(agentId);
|
||||
}
|
||||
|
||||
// Preserve existing labels and timeline during reload.
|
||||
handedToRegistration = true;
|
||||
return this.registerSession(session, storedConfig, agentId, {
|
||||
labels: existing.labels,
|
||||
workspaceId: existing.workspaceId,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
lastUserMessageAt: existing.lastUserMessageAt,
|
||||
historyPrimed: rehydrateFromDisk ? false : preservedHistoryPrimed,
|
||||
lastUsage: preservedLastUsage,
|
||||
lastError: preservedLastError,
|
||||
attention: preservedAttention,
|
||||
});
|
||||
} finally {
|
||||
if (!handedToRegistration) {
|
||||
await this.closeUnregisteredSession(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async closeReloadedSession(session: AgentSession, agentId: string): Promise<void> {
|
||||
@@ -1673,6 +1753,7 @@ export class AgentManager {
|
||||
|
||||
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
item = limitAgentTimelineItemContent(item);
|
||||
this.touchUpdatedAt(agent);
|
||||
const row = this.recordTimeline(agentId, item);
|
||||
this.dispatchStream(
|
||||
@@ -2474,49 +2555,84 @@ export class AgentManager {
|
||||
workspaceId?: string;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
const resolvedAgentId = validateAgentId(agentId, "registerSession");
|
||||
if (this.agents.has(resolvedAgentId)) {
|
||||
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
|
||||
}
|
||||
const initialPersistedTitle = await this.resolveInitialPersistedTitle(
|
||||
resolvedAgentId,
|
||||
config,
|
||||
options?.initialTitle ?? null,
|
||||
);
|
||||
let registered = false;
|
||||
try {
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
const resolvedAgentId = validateAgentId(agentId, "registerSession");
|
||||
if (this.agents.has(resolvedAgentId)) {
|
||||
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
|
||||
}
|
||||
const initialPersistedTitle = await this.resolveInitialPersistedTitle(
|
||||
resolvedAgentId,
|
||||
config,
|
||||
options?.initialTitle ?? null,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const { durableTimelineHasRows } = await this.initializeAgentTimelineForRegister({
|
||||
agentId: resolvedAgentId,
|
||||
now,
|
||||
options,
|
||||
});
|
||||
const now = new Date();
|
||||
const { durableTimelineHasRows } = await this.initializeAgentTimelineForRegister({
|
||||
agentId: resolvedAgentId,
|
||||
now,
|
||||
options,
|
||||
});
|
||||
|
||||
const managed = this.buildManagedAgentForRegister({
|
||||
resolvedAgentId,
|
||||
session,
|
||||
config,
|
||||
now,
|
||||
durableTimelineHasRows,
|
||||
options,
|
||||
});
|
||||
const managed = this.buildManagedAgentForRegister({
|
||||
resolvedAgentId,
|
||||
session,
|
||||
config,
|
||||
now,
|
||||
durableTimelineHasRows,
|
||||
options,
|
||||
});
|
||||
|
||||
this.agents.set(resolvedAgentId, managed);
|
||||
// Initialize previousStatus to track transitions
|
||||
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
|
||||
await this.refreshRuntimeInfo(managed, { emit: !options?.publishWhenReady });
|
||||
await this.persistSnapshot(managed, {
|
||||
title: initialPersistedTitle,
|
||||
});
|
||||
if (!options?.publishWhenReady) {
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
this.agents.set(resolvedAgentId, managed);
|
||||
registered = true;
|
||||
// Initialize previousStatus to track transitions
|
||||
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
|
||||
await this.refreshRuntimeInfo(managed, { emit: false });
|
||||
this.assertAgentRegistrationActive(managed);
|
||||
await this.persistSnapshot(managed, {
|
||||
title: initialPersistedTitle,
|
||||
});
|
||||
this.assertAgentRegistrationActive(managed);
|
||||
if (!options?.publishWhenReady) {
|
||||
this.emitState(managed, { persist: false });
|
||||
}
|
||||
|
||||
await this.refreshSessionState(managed, { emit: false });
|
||||
this.assertAgentRegistrationActive(managed);
|
||||
managed.lifecycle = "idle";
|
||||
await this.persistSnapshot(managed);
|
||||
this.assertAgentRegistrationActive(managed);
|
||||
this.emitState(managed, { persist: false });
|
||||
this.subscribeToSession(managed);
|
||||
return { ...managed };
|
||||
} catch (error) {
|
||||
if (!registered) {
|
||||
await this.closeUnregisteredSession(session);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await this.refreshSessionState(managed, { emit: !options?.publishWhenReady });
|
||||
managed.lifecycle = "idle";
|
||||
await this.persistSnapshot(managed);
|
||||
this.emitState(managed, { persist: false });
|
||||
this.subscribeToSession(managed);
|
||||
return { ...managed };
|
||||
private assertAcceptingAgentRegistrations(): void {
|
||||
if (!this.acceptingAgentRegistrations) {
|
||||
throw new AgentManagerShuttingDownError();
|
||||
}
|
||||
}
|
||||
|
||||
private assertAgentRegistrationActive(agent: ActiveManagedAgent): void {
|
||||
if (!this.acceptingAgentRegistrations || this.agents.get(agent.id) !== agent) {
|
||||
throw new AgentManagerShuttingDownError();
|
||||
}
|
||||
}
|
||||
|
||||
private async closeUnregisteredSession(session: AgentSession): Promise<void> {
|
||||
try {
|
||||
await session.close();
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to close unregistered agent session");
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeAgentTimelineForRegister(params: {
|
||||
@@ -2876,17 +2992,22 @@ export class AgentManager {
|
||||
agent.historyPrimed = true;
|
||||
|
||||
for (const event of historyEvents) {
|
||||
const item = limitAgentTimelineItemContent(event.item);
|
||||
const row = this.recordTimeline(
|
||||
agent.id,
|
||||
event.item,
|
||||
item,
|
||||
event.timestamp ? { timestamp: event.timestamp } : undefined,
|
||||
);
|
||||
if (options?.broadcast) {
|
||||
this.dispatchStream(agent.id, event, {
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
{ ...event, item },
|
||||
{
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
@@ -2943,6 +3064,12 @@ export class AgentManager {
|
||||
event: AgentStreamEvent,
|
||||
options?: HandleStreamEventOptions,
|
||||
): Promise<boolean> {
|
||||
if (event.type === "timeline") {
|
||||
event = {
|
||||
...event,
|
||||
item: limitAgentTimelineItemContent(event.item),
|
||||
};
|
||||
}
|
||||
const eventTurnId = getAgentStreamEventTurnId(event);
|
||||
const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
|
||||
this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent);
|
||||
@@ -3439,6 +3566,7 @@ export class AgentManager {
|
||||
item: AgentTimelineItem,
|
||||
options?: { timestamp?: string },
|
||||
): AgentTimelineRow {
|
||||
item = limitAgentTimelineItemContent(item);
|
||||
const row = this.timelineStore.append(agentId, item, options);
|
||||
this.enqueueDurableTimelineAppend(agentId, row);
|
||||
return row;
|
||||
@@ -3565,15 +3693,45 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
private trackAgentRegistrationOperation<T>(result: Promise<T>): Promise<T> {
|
||||
const settled = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.agentRegistrationTasks.add(settled);
|
||||
void settled.then(() => {
|
||||
this.agentRegistrationTasks.delete(settled);
|
||||
return undefined;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any background persistence work (best-effort).
|
||||
* Used by daemon shutdown paths to avoid unhandled rejections after cleanup.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
await this.flushTasks({ includeAgentRegistrations: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush persistence and agent registrations that crossed the synchronous
|
||||
* shutdown barrier. Those registrations own provider sessions until they
|
||||
* either install them or close them.
|
||||
*/
|
||||
async flushForShutdown(): Promise<void> {
|
||||
await this.flushTasks({ includeAgentRegistrations: true });
|
||||
}
|
||||
|
||||
private async flushTasks(options: { includeAgentRegistrations: boolean }): Promise<void> {
|
||||
this.agentStreamCoalescer.flushAll();
|
||||
// Drain tasks, including tasks spawned while awaiting.
|
||||
while (this.backgroundTasks.size > 0) {
|
||||
const pending = Array.from(this.backgroundTasks);
|
||||
while (
|
||||
this.backgroundTasks.size > 0 ||
|
||||
(options.includeAgentRegistrations && this.agentRegistrationTasks.size > 0)
|
||||
) {
|
||||
const pending = options.includeAgentRegistrations
|
||||
? [...this.backgroundTasks, ...this.agentRegistrationTasks]
|
||||
: [...this.backgroundTasks];
|
||||
await Promise.allSettled(pending);
|
||||
}
|
||||
}
|
||||
@@ -3598,6 +3756,12 @@ export class AgentManager {
|
||||
event: AgentStreamEvent,
|
||||
metadata?: { seq?: number; epoch?: string; timestamp?: string },
|
||||
): void {
|
||||
if (event.type === "timeline") {
|
||||
event = {
|
||||
...event,
|
||||
item: limitAgentTimelineItemContent(event.item),
|
||||
};
|
||||
}
|
||||
const agent = this.agents.get(agentId);
|
||||
this.logger.trace(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
|
||||
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
|
||||
|
||||
function limitTextContent(value: string): string {
|
||||
if (Buffer.byteLength(value, "utf8") <= TOOL_CALL_CONTENT_MAX_BYTES) {
|
||||
return value;
|
||||
}
|
||||
const bytes = Buffer.from(value, "utf8").subarray(0, TOOL_CALL_CONTENT_MAX_BYTES);
|
||||
return new StringDecoder("utf8").write(bytes);
|
||||
}
|
||||
|
||||
function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
|
||||
if (
|
||||
item.type !== "tool_call" ||
|
||||
item.detail.type !== "shell" ||
|
||||
item.status !== "failed" ||
|
||||
typeof item.error !== "object" ||
|
||||
item.error === null
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const error: Record<string, unknown> = { ...item.error };
|
||||
let changed = false;
|
||||
for (const key of ["content", "message"] as const) {
|
||||
const value = error[key];
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const limitedValue = limitTextContent(value);
|
||||
if (limitedValue !== value) {
|
||||
error[key] = limitedValue;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTimelineItem {
|
||||
item = limitFailedShellError(item);
|
||||
if (
|
||||
item.type !== "tool_call" ||
|
||||
item.detail.type !== "shell" ||
|
||||
typeof item.detail.output !== "string"
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
const output = limitTextContent(item.detail.output);
|
||||
if (output === item.detail.output) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
detail: {
|
||||
...item.detail,
|
||||
output,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1197,6 +1197,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
test("keeps a turn active while OpenCode is retrying", async () => {
|
||||
vi.useFakeTimers();
|
||||
const eventsGate = createTestDeferred<void>();
|
||||
let eventStreamSignal: AbortSignal | undefined;
|
||||
const retryStream: AsyncIterable<unknown> = {
|
||||
[Symbol.asyncIterator]: () => {
|
||||
let emitted = false;
|
||||
@@ -1222,14 +1223,27 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
},
|
||||
};
|
||||
}
|
||||
return new Promise(() => {});
|
||||
return new Promise<IteratorResult<unknown>>((resolve) => {
|
||||
if (eventStreamSignal?.aborted) {
|
||||
resolve({ done: true, value: undefined });
|
||||
return;
|
||||
}
|
||||
eventStreamSignal?.addEventListener(
|
||||
"abort",
|
||||
() => resolve({ done: true, value: undefined }),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const fakeClient = {
|
||||
global: {
|
||||
event: vi.fn().mockResolvedValue({ stream: retryStream }),
|
||||
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => {
|
||||
eventStreamSignal = signal;
|
||||
return { stream: retryStream };
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
abort: vi.fn().mockResolvedValue({ error: null }),
|
||||
@@ -1319,6 +1333,52 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(fakeClient.session.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("waits for the OpenCode event stream to finish after close aborts it", async () => {
|
||||
const streamAborted = createTestDeferred<void>();
|
||||
const finishStreamCleanup = createTestDeferred<void>();
|
||||
const fakeClient = {
|
||||
global: {
|
||||
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => ({
|
||||
stream: {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => {
|
||||
if (!signal.aborted) {
|
||||
await waitForAbort(signal);
|
||||
}
|
||||
streamAborted.resolve();
|
||||
await finishStreamCleanup.promise;
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
}),
|
||||
},
|
||||
})),
|
||||
},
|
||||
session: {
|
||||
abort: vi.fn().mockResolvedValue({ error: null }),
|
||||
update: vi.fn().mockResolvedValue({ error: null }),
|
||||
},
|
||||
} as never;
|
||||
const session = new __openCodeInternals.OpenCodeAgentSession(
|
||||
{ provider: "opencode", cwd: "/tmp/test" },
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
);
|
||||
let closeSettled = false;
|
||||
|
||||
const closePromise = session.close().then(() => {
|
||||
closeSettled = true;
|
||||
return undefined;
|
||||
});
|
||||
await streamAborted.promise;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(closeSettled).toBe(false);
|
||||
|
||||
finishStreamCleanup.resolve();
|
||||
await closePromise;
|
||||
});
|
||||
|
||||
test("streamHistory preserves OpenCode replay timestamps from message and part times", async () => {
|
||||
const fakeClient = {
|
||||
session: {
|
||||
@@ -2178,6 +2238,15 @@ function createTestDeferred<T>(): {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function waitForAbort(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function abortableOpenCodeStream(signal: AbortSignal): AsyncIterable<OpenCodeEvent> {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => {
|
||||
|
||||
@@ -2789,6 +2789,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private releaseServer: (() => void) | null;
|
||||
private eventStreamAbortController: AbortController | null = null;
|
||||
private eventStreamReady: Deferred<void> | null = null;
|
||||
private eventStreamTask: Promise<void> | null = null;
|
||||
private suppressTerminalUntilNextUserMessage = false;
|
||||
private closed = false;
|
||||
private readonly persistSession: boolean;
|
||||
@@ -3160,11 +3161,24 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const eventStreamReady = createDeferred<void>();
|
||||
this.eventStreamAbortController = eventStreamAbortController;
|
||||
this.eventStreamReady = eventStreamReady;
|
||||
void this.consumeEventStream(eventStreamAbortController, eventStreamReady).finally(() => {
|
||||
const eventStreamTask = this.consumeEventStream(
|
||||
eventStreamAbortController,
|
||||
eventStreamReady,
|
||||
).finally(() => {
|
||||
if (this.eventStreamAbortController === eventStreamAbortController) {
|
||||
this.eventStreamAbortController = null;
|
||||
this.eventStreamReady = null;
|
||||
}
|
||||
if (this.eventStreamTask === eventStreamTask) {
|
||||
this.eventStreamTask = null;
|
||||
}
|
||||
});
|
||||
this.eventStreamTask = eventStreamTask;
|
||||
void eventStreamTask.catch((error) => {
|
||||
this.logger.warn(
|
||||
{ err: error, sessionId: this.sessionId },
|
||||
"OpenCode event stream task failed",
|
||||
);
|
||||
});
|
||||
|
||||
return eventStreamReady.promise;
|
||||
@@ -3561,9 +3575,19 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
// unhandled rejection in whichever test the daemon hops to next.
|
||||
this.closed = true;
|
||||
this.abortController?.abort();
|
||||
const eventStreamTask = this.eventStreamTask;
|
||||
this.eventStreamAbortController?.abort();
|
||||
if (eventStreamTask) {
|
||||
await eventStreamTask.catch((error) => {
|
||||
this.logger.debug(
|
||||
{ err: error, sessionId: this.sessionId },
|
||||
"OpenCode event stream failed during close",
|
||||
);
|
||||
});
|
||||
}
|
||||
this.eventStreamAbortController = null;
|
||||
this.eventStreamReady = null;
|
||||
this.eventStreamTask = null;
|
||||
this.subscribers.clear();
|
||||
await reconcileOpenCodeSessionClose({
|
||||
client: this.client,
|
||||
|
||||
+36
-1
@@ -156,7 +156,10 @@ export class TestOpenCodeClient {
|
||||
global: {
|
||||
event: async (options: unknown) => {
|
||||
this.calls.globalEvent.push(options);
|
||||
return { stream: this.eventStream };
|
||||
const signal = (options as { signal?: AbortSignal }).signal;
|
||||
return {
|
||||
stream: signal ? stopEventStreamOnAbort(this.eventStream, signal) : this.eventStream,
|
||||
};
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
@@ -247,6 +250,38 @@ export class TestOpenCodeClient {
|
||||
}
|
||||
}
|
||||
|
||||
function stopEventStreamOnAbort(
|
||||
stream: AsyncIterable<unknown>,
|
||||
signal: AbortSignal,
|
||||
): AsyncIterable<unknown> {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => {
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
return {
|
||||
next: () => {
|
||||
if (signal.aborted) {
|
||||
return Promise.resolve({ done: true, value: undefined });
|
||||
}
|
||||
return new Promise<IteratorResult<unknown>>((resolve, reject) => {
|
||||
const onAbort = () => resolve({ done: true, value: undefined });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
void iterator.next().then(
|
||||
(result) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
return resolve(result);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
return reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createEventStream(events: unknown[]): AsyncGenerator<unknown> {
|
||||
return (async function* () {
|
||||
for (const event of events) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, onTestFinished, test } from "vitest";
|
||||
|
||||
import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js";
|
||||
@@ -550,12 +550,14 @@ describe("PiRpcAgentSession", () => {
|
||||
},
|
||||
},
|
||||
{},
|
||||
{ env: { RESUME_PROBE: "expected" } },
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches).toHaveLength(1);
|
||||
const actualLaunch = pi.recordedLaunches[0]!;
|
||||
expect(actualLaunch).toMatchObject({
|
||||
cwd: "/workspace/project",
|
||||
env: { RESUME_PROBE: "expected" },
|
||||
session: "/tmp/native-pi-session",
|
||||
});
|
||||
expect(actualLaunch.extensionPaths).toHaveLength(1);
|
||||
@@ -1221,7 +1223,21 @@ describe("PiRpcAgentClient", () => {
|
||||
expect(pi.latestSession().treeNavigationRequests).toEqual(["entry-1"]);
|
||||
});
|
||||
|
||||
test("injects MCP servers through pi-mcp-adapter when the extension is loaded", async () => {
|
||||
test("injects MCP servers without replacing the Pi global MCP config", async () => {
|
||||
const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-agent-"));
|
||||
onTestFinished(() => rmSync(agentDir, { recursive: true, force: true }));
|
||||
writeFileSync(
|
||||
path.join(agentDir, "mcp.json"),
|
||||
JSON.stringify({
|
||||
settings: { toolPrefix: "none", disableProxyTool: true },
|
||||
"mcp-servers": {
|
||||
"brave-search": {
|
||||
url: "https://example.com/mcp/brave",
|
||||
directTools: ["brave_llm_context"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const pi = new FakePi();
|
||||
pi.queueCommands([
|
||||
{
|
||||
@@ -1248,6 +1264,7 @@ describe("PiRpcAgentClient", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ env: { PI_CODING_AGENT_DIR: agentDir } },
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches).toHaveLength(2);
|
||||
@@ -1276,7 +1293,12 @@ describe("PiRpcAgentClient", () => {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
expect(injectedConfig).toEqual({
|
||||
settings: { toolPrefix: "none", disableProxyTool: true },
|
||||
mcpServers: {
|
||||
"brave-search": {
|
||||
url: "https://example.com/mcp/brave",
|
||||
directTools: ["brave_llm_context"],
|
||||
},
|
||||
paseo: {
|
||||
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
|
||||
auth: false,
|
||||
@@ -1294,6 +1316,27 @@ describe("PiRpcAgentClient", () => {
|
||||
expect(existsSync(configPath!)).toBe(false);
|
||||
});
|
||||
|
||||
test("reports the path of a malformed Pi global MCP config", async () => {
|
||||
const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-agent-"));
|
||||
onTestFinished(() => rmSync(agentDir, { recursive: true, force: true }));
|
||||
const configPath = path.join(agentDir, "mcp.json");
|
||||
writeFileSync(configPath, "{ invalid");
|
||||
const pi = new FakePi();
|
||||
pi.queueCommands([{ name: "mcp", source: "extension" }]);
|
||||
const client = createClient(pi);
|
||||
|
||||
await expect(
|
||||
client.createSession(
|
||||
createConfig({
|
||||
mcpServers: {
|
||||
paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" },
|
||||
},
|
||||
}),
|
||||
{ env: { PI_CODING_AGENT_DIR: agentDir } },
|
||||
),
|
||||
).rejects.toThrow(`Failed to parse Pi MCP config: ${configPath}`);
|
||||
});
|
||||
|
||||
test("does not pass MCP config when pi-mcp-adapter is not loaded", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.queueCommands([]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve as resolvePath } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -497,14 +497,63 @@ function toPiMcpConfig(config: McpServerConfig): PiMcpServerConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function createPiMcpConfigFile(servers: Record<string, McpServerConfig>): PiMcpConfigFile {
|
||||
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-mcp-"));
|
||||
const filePath = join(dir, "mcp.json");
|
||||
const mcpServers: Record<string, PiMcpServerConfig> = {};
|
||||
function resolvePiAgentDir(env: Record<string, string> | undefined): string {
|
||||
// Match pi-mcp-adapter's agent-directory resolution so we preserve the config it replaces.
|
||||
const configured = env?.PI_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
|
||||
if (!configured) {
|
||||
return join(homedir(), ".pi", "agent");
|
||||
}
|
||||
if (configured === "~") {
|
||||
return homedir();
|
||||
}
|
||||
if (configured.startsWith("~/")) {
|
||||
return resolvePath(homedir(), configured.slice(2));
|
||||
}
|
||||
return resolvePath(configured);
|
||||
}
|
||||
|
||||
function createPiMcpConfigFile(
|
||||
servers: Record<string, McpServerConfig>,
|
||||
env: Record<string, string> | undefined,
|
||||
): PiMcpConfigFile {
|
||||
// pi-mcp-adapter treats --mcp-config as a replacement for its Pi global layer, not an
|
||||
// additional layer. Rebuild that layer here; the adapter still loads shared and project files.
|
||||
const globalConfigPath = join(resolvePiAgentDir(env), "mcp.json");
|
||||
let globalConfig: unknown = {};
|
||||
if (existsSync(globalConfigPath)) {
|
||||
const contents = readFileSync(globalConfigPath, "utf8");
|
||||
try {
|
||||
globalConfig = JSON.parse(contents) as unknown;
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`Failed to parse Pi MCP config: ${globalConfigPath}`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!isRecord(globalConfig)) {
|
||||
throw new Error(`Pi MCP config must contain a JSON object: ${globalConfigPath}`);
|
||||
}
|
||||
let configuredServers: Record<string, unknown> = {};
|
||||
if (isRecord(globalConfig.mcpServers)) {
|
||||
configuredServers = globalConfig.mcpServers;
|
||||
} else if (isRecord(globalConfig["mcp-servers"])) {
|
||||
configuredServers = globalConfig["mcp-servers"];
|
||||
}
|
||||
const mcpServers: Record<string, unknown> = { ...configuredServers };
|
||||
for (const [name, serverConfig] of Object.entries(servers)) {
|
||||
mcpServers[name] = toPiMcpConfig(serverConfig);
|
||||
}
|
||||
writeFileSync(filePath, `${JSON.stringify({ mcpServers }, null, 2)}\n`, "utf8");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-mcp-"));
|
||||
const filePath = join(dir, "mcp.json");
|
||||
const mergedConfig: Record<string, unknown> = { ...globalConfig, mcpServers };
|
||||
// Emit one canonical server key so a stale alias cannot shadow the injected definitions.
|
||||
delete mergedConfig["mcp-servers"];
|
||||
writeFileSync(filePath, `${JSON.stringify(mergedConfig, null, 2)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
return {
|
||||
path: filePath,
|
||||
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
||||
@@ -1911,7 +1960,10 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers);
|
||||
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers, {
|
||||
...this.runtimeSettings?.env,
|
||||
...launchContext?.env,
|
||||
});
|
||||
const paseoExtension = createPiPaseoExtensionFile();
|
||||
let runtimeSession: PiRuntimeSession;
|
||||
try {
|
||||
@@ -1953,7 +2005,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const sessionFile = handle.nativeHandle;
|
||||
if (!sessionFile) {
|
||||
@@ -1963,12 +2015,20 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
|
||||
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides);
|
||||
|
||||
const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, resumeConfig.config.mcpServers);
|
||||
const mcpConfig = await this.prepareMcpConfig(
|
||||
resumeConfig.cwd,
|
||||
resumeConfig.config.mcpServers,
|
||||
{
|
||||
...this.runtimeSettings?.env,
|
||||
...launchContext?.env,
|
||||
},
|
||||
);
|
||||
const paseoExtension = createPiPaseoExtensionFile();
|
||||
let runtimeSession: PiRuntimeSession;
|
||||
try {
|
||||
runtimeSession = await this.runtime.startSession({
|
||||
cwd: resumeConfig.cwd,
|
||||
env: launchContext?.env,
|
||||
session: sessionFile,
|
||||
model: resumeConfig.model,
|
||||
thinkingOptionId: normalizePiThinkingOption(resumeConfig.thinkingOptionId) ?? undefined,
|
||||
@@ -2079,18 +2139,19 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
private async prepareMcpConfig(
|
||||
cwd: string,
|
||||
servers: Record<string, McpServerConfig> | undefined,
|
||||
env: Record<string, string> | undefined,
|
||||
): Promise<PiMcpConfigFile | null> {
|
||||
if (!servers || Object.keys(servers).length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!(await this.detectMcpAdapter(cwd))) {
|
||||
if (!(await this.detectMcpAdapter(cwd, env))) {
|
||||
return null;
|
||||
}
|
||||
return createPiMcpConfigFile(servers);
|
||||
return createPiMcpConfigFile(servers, env);
|
||||
}
|
||||
|
||||
private async detectMcpAdapter(cwd: string): Promise<boolean> {
|
||||
const runtimeSession = await this.runtime.startSession({ cwd }).catch((error) => {
|
||||
private async detectMcpAdapter(cwd: string, env?: Record<string, string>): Promise<boolean> {
|
||||
const runtimeSession = await this.runtime.startSession({ cwd, env }).catch((error) => {
|
||||
this.logger.debug({ err: error, cwd }, "Pi MCP adapter probe failed to start");
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
|
||||
import { AgentManagerShuttingDownError } from "./agent/agent-manager.js";
|
||||
import { hashDaemonPassword } from "./auth.js";
|
||||
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
@@ -14,6 +15,23 @@ import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { findFreePort } from "./service-proxy.js";
|
||||
|
||||
interface HeldAgentClose {
|
||||
started: Promise<void>;
|
||||
arm(): void;
|
||||
closeSession(): Promise<void>;
|
||||
finish(): void;
|
||||
}
|
||||
|
||||
interface BlockedDaemonShutdown {
|
||||
probeReconnect(): Promise<WebSocketProbeResult>;
|
||||
tryCreateAgent(): Promise<"created" | "rejected">;
|
||||
finish(): Promise<void>;
|
||||
}
|
||||
|
||||
type WebSocketProbeResult =
|
||||
| { status: "connected" }
|
||||
| { status: "rejected"; statusCode: number | null };
|
||||
|
||||
describe("paseo daemon bootstrap", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -206,6 +224,17 @@ describe("paseo daemon bootstrap", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("stops new connections and agent registrations before closing agents", async () => {
|
||||
const shutdown = await beginDaemonShutdownWithAgentClosing();
|
||||
try {
|
||||
await expect(
|
||||
Promise.all([shutdown.probeReconnect(), shutdown.tryCreateAgent()]),
|
||||
).resolves.toEqual([{ status: "rejected", statusCode: 503 }, "rejected"]);
|
||||
} finally {
|
||||
await shutdown.finish();
|
||||
}
|
||||
});
|
||||
|
||||
test("standalone listener exposes services only", async () => {
|
||||
const standalonePort = await findFreePort();
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
@@ -522,3 +551,97 @@ describe("paseo daemon bootstrap", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function holdAgentClose(): HeldAgentClose {
|
||||
let armed = false;
|
||||
let markStarted = () => {};
|
||||
let finish = () => {};
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
return {
|
||||
started,
|
||||
arm() {
|
||||
armed = true;
|
||||
},
|
||||
async closeSession() {
|
||||
if (!armed) {
|
||||
return;
|
||||
}
|
||||
markStarted();
|
||||
await finished;
|
||||
},
|
||||
finish: () => finish(),
|
||||
};
|
||||
}
|
||||
|
||||
async function beginDaemonShutdownWithAgentClosing(): Promise<BlockedDaemonShutdown> {
|
||||
const heldAgentClose = holdAgentClose();
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
cleanup: false,
|
||||
agentClients: createTestAgentClients({ closeSession: heldAgentClose.closeSession }),
|
||||
});
|
||||
const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-shutdown-agent-"));
|
||||
await daemonHandle.daemon.agentManager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: agentCwd,
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
heldAgentClose.arm();
|
||||
const stopPromise = daemonHandle.daemon.stop();
|
||||
await heldAgentClose.started;
|
||||
|
||||
return {
|
||||
probeReconnect: () => probeWebSocketConnection(`ws://127.0.0.1:${daemonHandle.port}/ws`),
|
||||
async tryCreateAgent() {
|
||||
try {
|
||||
await daemonHandle.daemon.agentManager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: agentCwd,
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
return "created";
|
||||
} catch (error) {
|
||||
if (error instanceof AgentManagerShuttingDownError) {
|
||||
return "rejected";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async finish() {
|
||||
heldAgentClose.finish();
|
||||
await stopPromise;
|
||||
await daemonHandle.daemon.agentManager.flush().catch(() => undefined);
|
||||
await Promise.all([
|
||||
rm(path.dirname(daemonHandle.paseoHome), { recursive: true, force: true }),
|
||||
rm(daemonHandle.staticDir, { recursive: true, force: true }),
|
||||
rm(agentCwd, { recursive: true, force: true }),
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function probeWebSocketConnection(url: string): Promise<WebSocketProbeResult> {
|
||||
const ws = new WebSocket(url);
|
||||
return new Promise((resolve) => {
|
||||
ws.once("open", () => {
|
||||
ws.close();
|
||||
resolve({ status: "connected" });
|
||||
});
|
||||
ws.once("error", () => resolve({ status: "rejected", statusCode: null }));
|
||||
ws.once("unexpected-response", (_request, response) => {
|
||||
response.resume();
|
||||
resolve({ status: "rejected", statusCode: response.statusCode ?? null });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1443,8 +1443,11 @@ export async function createPaseoDaemon(
|
||||
|
||||
const stop = async () => {
|
||||
scriptHealthMonitor.stop();
|
||||
// Freeze both ingress and registration before taking the agent closure snapshot.
|
||||
wsServer?.prepareForShutdown();
|
||||
agentManager.prepareForShutdown();
|
||||
await closeAllAgents(logger, agentManager);
|
||||
await agentManager.flush().catch(() => undefined);
|
||||
await agentManager.flushForShutdown().catch(() => undefined);
|
||||
detachAgentStoragePersistence();
|
||||
await agentStorage.flush().catch(() => undefined);
|
||||
await providerSnapshotManager.shutdown();
|
||||
|
||||
@@ -51,6 +51,18 @@ interface Deferred<T> {
|
||||
reject: (err: unknown) => void;
|
||||
}
|
||||
|
||||
interface FakeAgentSessionOptions {
|
||||
providerName: string;
|
||||
config: AgentSessionConfig;
|
||||
sessionId?: string;
|
||||
memoryMarker?: string | null;
|
||||
closeSession?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TestAgentClientOptions {
|
||||
closeSession?: () => Promise<void>;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (err: unknown) => void;
|
||||
@@ -316,16 +328,14 @@ class FakeAgentSession implements AgentSession {
|
||||
private nextTurnOrdinal = 0;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
|
||||
constructor(
|
||||
providerName: string,
|
||||
config: AgentSessionConfig,
|
||||
sessionId?: string,
|
||||
memoryMarker?: string | null,
|
||||
) {
|
||||
this.providerName = providerName;
|
||||
this.config = config;
|
||||
this.id = sessionId ?? randomUUID();
|
||||
this.memoryMarker = memoryMarker ?? null;
|
||||
private readonly closeSession: (() => Promise<void>) | undefined;
|
||||
|
||||
constructor(options: FakeAgentSessionOptions) {
|
||||
this.providerName = options.providerName;
|
||||
this.config = options.config;
|
||||
this.id = options.sessionId ?? randomUUID();
|
||||
this.memoryMarker = options.memoryMarker ?? null;
|
||||
this.closeSession = options.closeSession;
|
||||
this.historyPath = path.join(
|
||||
tmpdir(),
|
||||
"paseo-fake-provider-history",
|
||||
@@ -847,7 +857,9 @@ class FakeAgentSession implements AgentSession {
|
||||
this.interruptSignal.resolve();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
async close(): Promise<void> {
|
||||
await this.closeSession?.();
|
||||
}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
if (this.providerName === "codex") {
|
||||
@@ -1155,13 +1167,20 @@ class FakeAgentSession implements AgentSession {
|
||||
|
||||
class FakeAgentClient implements AgentClient {
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
constructor(public readonly provider: string) {}
|
||||
constructor(
|
||||
public readonly provider: string,
|
||||
private readonly options: TestAgentClientOptions,
|
||||
) {}
|
||||
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
return new FakeAgentSession(this.provider, { ...config });
|
||||
return new FakeAgentSession({
|
||||
providerName: this.provider,
|
||||
config: { ...config },
|
||||
closeSession: this.options.closeSession,
|
||||
});
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
@@ -1178,12 +1197,13 @@ class FakeAgentClient implements AgentClient {
|
||||
(handle.metadata as Record<string, unknown> | undefined)?.marker ??
|
||||
(handle.metadata as Record<string, unknown> | undefined)?.conversationId ??
|
||||
null;
|
||||
return new FakeAgentSession(
|
||||
this.provider,
|
||||
cfg,
|
||||
handle.sessionId,
|
||||
typeof marker === "string" ? marker : null,
|
||||
);
|
||||
return new FakeAgentSession({
|
||||
providerName: this.provider,
|
||||
config: cfg,
|
||||
sessionId: handle.sessionId,
|
||||
memoryMarker: typeof marker === "string" ? marker : null,
|
||||
closeSession: this.options.closeSession,
|
||||
});
|
||||
}
|
||||
|
||||
async fetchCatalog(
|
||||
@@ -1222,10 +1242,12 @@ class FakeAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
export function createTestAgentClients(): Record<string, AgentClient> {
|
||||
export function createTestAgentClients(
|
||||
options: TestAgentClientOptions = {},
|
||||
): Record<string, AgentClient> {
|
||||
return {
|
||||
claude: new FakeAgentClient("claude"),
|
||||
codex: new FakeAgentClient("codex"),
|
||||
opencode: new FakeAgentClient("opencode"),
|
||||
claude: new FakeAgentClient("claude", options),
|
||||
codex: new FakeAgentClient("codex", options),
|
||||
opencode: new FakeAgentClient("opencode", options),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -454,6 +454,21 @@ function holdNextSessionMessage(session: (typeof sessionMock.instances)[number])
|
||||
};
|
||||
}
|
||||
|
||||
function holdSessionCleanup(session: (typeof sessionMock.instances)[number]): {
|
||||
finish: () => void;
|
||||
} {
|
||||
let finish = () => {};
|
||||
session.cleanup.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
return {
|
||||
finish: () => finish(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("relay external socket reconnect behavior", () => {
|
||||
beforeEach(() => {
|
||||
sessionMock.instances.length = 0;
|
||||
@@ -517,6 +532,36 @@ describe("relay external socket reconnect behavior", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("rejects sockets attached after shutdown begins", async () => {
|
||||
const server = createServer();
|
||||
const existingSocket = new MockSocket();
|
||||
await attachRelayAndHello({
|
||||
server,
|
||||
socket: existingSocket,
|
||||
clientId: "existing-client",
|
||||
});
|
||||
|
||||
const heldCleanup = holdSessionCleanup(sessionMock.instances[0]);
|
||||
const closePromise = server.close();
|
||||
|
||||
const lateSocket = new MockSocket();
|
||||
try {
|
||||
await server.attachExternalSocket(lateSocket, { transport: "relay" });
|
||||
lateSocket.emit("message", JSON.stringify(createHelloMessage("late-client")));
|
||||
|
||||
expect({
|
||||
readyState: lateSocket.readyState,
|
||||
sessionCount: sessionMock.instances.length,
|
||||
}).toEqual({
|
||||
readyState: 3,
|
||||
sessionCount: 1,
|
||||
});
|
||||
} finally {
|
||||
heldCleanup.finish();
|
||||
await closePromise;
|
||||
}
|
||||
});
|
||||
|
||||
test("closes pending connection when hello timeout elapses", async () => {
|
||||
const server = createServer();
|
||||
|
||||
|
||||
@@ -351,6 +351,7 @@ const HELLO_TIMEOUT_MS = 15_000;
|
||||
const WS_CLOSE_HELLO_TIMEOUT = 4001;
|
||||
const WS_CLOSE_INVALID_HELLO = 4002;
|
||||
const WS_CLOSE_INCOMPATIBLE_PROTOCOL = 4003;
|
||||
const WS_CLOSE_SERVER_SHUTDOWN = 1001;
|
||||
const WS_PROTOCOL_VERSION = 1;
|
||||
const WS_RUNTIME_METRICS_FLUSH_MS = 30_000;
|
||||
|
||||
@@ -463,6 +464,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private unsubscribeTerminalActivity: (() => void) | null = null;
|
||||
private readonly browserToolsBroker: BrowserToolsBroker | null;
|
||||
private readonly browserToolsRegistrations = new Map<string, BrowserToolsRegistration>();
|
||||
private acceptingConnections = true;
|
||||
|
||||
constructor(
|
||||
server: HTTPServer,
|
||||
@@ -708,6 +710,11 @@ export class VoiceAssistantWebSocketServer {
|
||||
hostnames: HostnamesConfig | undefined,
|
||||
callback: (res: boolean, code?: number, message?: string) => void,
|
||||
): void {
|
||||
if (!this.acceptingConnections) {
|
||||
callback(false, 503, "Server shutting down");
|
||||
return;
|
||||
}
|
||||
|
||||
const requestMetadata = extractSocketRequestMetadata(req);
|
||||
const origin = requestMetadata.origin;
|
||||
const requestHost = requestMetadata.host ?? null;
|
||||
@@ -799,7 +806,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
await this.attachSocket(ws, undefined, metadata);
|
||||
}
|
||||
|
||||
public prepareForShutdown(): void {
|
||||
this.acceptingConnections = false;
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
this.prepareForShutdown();
|
||||
this.unsubscribeSpeechReadiness?.();
|
||||
this.unsubscribeSpeechReadiness = null;
|
||||
this.unsubscribeDaemonConfigChange?.();
|
||||
@@ -921,6 +933,15 @@ export class VoiceAssistantWebSocketServer {
|
||||
request?: unknown,
|
||||
metadata?: ExternalSocketMetadata,
|
||||
): Promise<void> {
|
||||
if (!this.acceptingConnections) {
|
||||
try {
|
||||
ws.close(WS_CLOSE_SERVER_SHUTDOWN, "Server shutting down");
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const requestMetadata = extractSocketRequestMetadata(request);
|
||||
const identity = createWebSocketConnectionIdentity(requestMetadata, metadata);
|
||||
this.socketIdentities.set(ws, identity);
|
||||
@@ -1609,6 +1630,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
ws: WebSocketLike,
|
||||
data: Buffer | ArrayBuffer | Buffer[] | string,
|
||||
): void {
|
||||
if (!this.acceptingConnections) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeConnection = this.sessions.get(ws);
|
||||
const pendingConnection = this.pendingConnections.get(ws);
|
||||
const log =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.106",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user