diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index f6b07c8b37..d81c036e62 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -169,6 +169,34 @@ jobs: NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }} run: | set -euo pipefail + export PASEO_REAL_XCRUN="$(command -v xcrun)" + stapler_bin="$(mktemp -d)" + trap 'rm -rf "$stapler_bin"' EXIT + cat > "$stapler_bin/xcrun" <<'STAPLER' + #!/bin/bash + if [[ "${1:-}" != stapler || "${2:-}" != staple ]]; then + exec "$PASEO_REAL_XCRUN" "$@" + fi + output="$(mktemp)" + trap 'rm -f "$output"' EXIT + for attempt in 1 2 3 4; do + if "$PASEO_REAL_XCRUN" "$@" >"$output" 2>&1; then + cat "$output" + exit 0 + else + status=$? + fi + cat "$output" + if [[ "$status" != 68 || "$attempt" == 4 ]] || + ! grep -Fq 'NSURLErrorDomain Code=-1005' "$output"; then + exit "$status" + fi + echo "::warning::Apple staple connection lost; retrying the same signed app ($attempt/4)" >&2 + sleep 30 + done + STAPLER + chmod +x "$stapler_bin/xcrun" + export PATH="$stapler_bin:$PATH" build_args=(-- --publish never --mac --${{ matrix.electron_arch }}) build_args+=("-c.publish.releaseType=$RELEASE_TYPE") build_args+=("-c.publish.channel=$RELEASE_CHANNEL") diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cab484e81..98d1c76900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.13.2 - 2026-10-26 + +### Fixed + +- Fixed repeated Android animation exceptions for views that have already unmounted ([#7907](https://github.com/getpaseo/paseo/pull/7907)) +- Fixed the Android animation run loop continuing while the app is in the background ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- Fixed finished Android chat messages being remeasured during streaming updates ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- Fixed bottom sheets retaining window-size listeners after closing ([#7914](https://github.com/getpaseo/paseo/pull/7914)) + ## 0.13.1 - 2026-10-26 ### Fixed diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index a46d3ea21a..421da176dd 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,5 +1,14 @@ # 更新日志 +## 0.13.2 - 2026-10-26 + +### 修复 + +- 修复 Android 对已卸载视图反复抛出动画异常的问题 ([#7907](https://github.com/getpaseo/paseo/pull/7907)) +- 修复 Android 应用进入后台后动画运行循环仍持续执行的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- 修复 Android 在流式更新时反复测量已完成聊天消息的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- 修复底部面板关闭后仍保留窗口尺寸监听器的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914)) + ## 0.13.1 - 2026-10-26 ### 修复 diff --git a/docs/plugins.md b/docs/plugins.md index 21a924c4e8..9cab47214e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -432,6 +432,9 @@ Server entries register lifecycle observers with `server.on()` and request trans `server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks) owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks. +Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents), +as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session; +executable definitions stay in the plugin process. Emit from the operation owner, not a client subscription. Provider history replay must not trigger live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or diff --git a/docs/qa-evidence/foreign-agent-tabs/after.png b/docs/qa-evidence/foreign-agent-tabs/after.png new file mode 100644 index 0000000000..77f3c92d8b Binary files /dev/null and b/docs/qa-evidence/foreign-agent-tabs/after.png differ diff --git a/docs/qa-evidence/foreign-agent-tabs/before.png b/docs/qa-evidence/foreign-agent-tabs/before.png new file mode 100644 index 0000000000..aeab42403e Binary files /dev/null and b/docs/qa-evidence/foreign-agent-tabs/before.png differ diff --git a/fastlane/metadata/android/en-US/changelogs/130021.txt b/fastlane/metadata/android/en-US/changelogs/130021.txt new file mode 100644 index 0000000000..e08d39f767 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/130021.txt @@ -0,0 +1,7 @@ +Fixed +- Fixed repeated Android animation exceptions for views that have already unmounted +- Fixed the Android animation run loop continuing while the app is in the background +- Fixed finished Android chat messages being remeasured during streaming updates +- Fixed bottom sheets retaining window-size listeners after closing + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/130022.txt b/fastlane/metadata/android/en-US/changelogs/130022.txt new file mode 100644 index 0000000000..e08d39f767 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/130022.txt @@ -0,0 +1,7 @@ +Fixed +- Fixed repeated Android animation exceptions for views that have already unmounted +- Fixed the Android animation run loop continuing while the app is in the background +- Fixed finished Android chat messages being remeasured during streaming updates +- Fixed bottom sheets retaining window-size listeners after closing + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/130023.txt b/fastlane/metadata/android/en-US/changelogs/130023.txt new file mode 100644 index 0000000000..e08d39f767 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/130023.txt @@ -0,0 +1,7 @@ +Fixed +- Fixed repeated Android animation exceptions for views that have already unmounted +- Fixed the Android animation run loop continuing while the app is in the background +- Fixed finished Android chat messages being remeasured during streaming updates +- Fixed bottom sheets retaining window-size listeners after closing + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/130024.txt b/fastlane/metadata/android/en-US/changelogs/130024.txt new file mode 100644 index 0000000000..e08d39f767 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/130024.txt @@ -0,0 +1,7 @@ +Fixed +- Fixed repeated Android animation exceptions for views that have already unmounted +- Fixed the Android animation run loop continuing while the app is in the background +- Fixed finished Android chat messages being remeasured during streaming updates +- Fixed bottom sheets retaining window-size listeners after closing + +Full notes: https://paseo.sh/changelog diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 05bf98631a..3e19f893c2 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-zXSLA7FJHhIW63kkY3HbWaTsCTtJ30l71O9/Gx5F7QM= +sha256-eNiKmiZ3XXZMRZRSoGxUbMn2RtY6AX079vUatBYM+qE= diff --git a/package-lock.json b/package-lock.json index 1f5cce1657..847bd24eb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.13.1", + "version": "0.13.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.13.1", + "version": "0.13.2", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -35695,7 +35695,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.13.1", + "version": "0.13.2", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -37961,12 +37961,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.13.1", + "version": "0.13.2", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.13.1", - "@getpaseo/protocol": "0.13.1", - "@getpaseo/server": "0.13.1", + "@getpaseo/client": "0.13.2", + "@getpaseo/protocol": "0.13.2", + "@getpaseo/server": "0.13.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -38213,10 +38213,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.13.1", + "version": "0.13.2", "dependencies": { - "@getpaseo/protocol": "0.13.1", - "@getpaseo/relay": "0.13.1", + "@getpaseo/protocol": "0.13.2", + "@getpaseo/relay": "0.13.2", "ws": "^8.20.0", "zod": "^4.4.3" }, @@ -38228,7 +38228,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.13.1", + "version": "0.13.2", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -38472,7 +38472,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.13.1", + "version": "0.13.2", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -42091,7 +42091,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.13.1", + "version": "0.13.2", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -42120,15 +42120,15 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.13.1", + "version": "0.13.2", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "i18next": "^26.3.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.13.1", - "@getpaseo/protocol": "0.13.1", + "@getpaseo/client": "0.13.2", + "@getpaseo/protocol": "0.13.2", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -42138,8 +42138,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.13.1", - "@getpaseo/protocol": "0.13.1", + "@getpaseo/client": "0.13.2", + "@getpaseo/protocol": "0.13.2", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -42161,7 +42161,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.13.1", + "version": "0.13.2", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -42188,7 +42188,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.13.1", + "version": "0.13.2", "dependencies": { "@noble/ciphers": "^2.4.0", "@noble/curves": "^2.4.0", @@ -42409,16 +42409,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.13.1", + "version": "0.13.2", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.13.1", - "@getpaseo/highlight": "0.13.1", - "@getpaseo/plugin": "0.13.1", - "@getpaseo/protocol": "0.13.1", - "@getpaseo/relay": "0.13.1", + "@getpaseo/client": "0.13.2", + "@getpaseo/highlight": "0.13.2", + "@getpaseo/plugin": "0.13.2", + "@getpaseo/protocol": "0.13.2", + "@getpaseo/relay": "0.13.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -43339,7 +43339,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.13.1", + "version": "0.13.2", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", @@ -44949,7 +44949,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.13.1", + "version": "0.13.2", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index 904e7e6af8..beeff7c613 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.13.1", + "version": "0.13.2", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts index 41da8ac3fa..35ec6f78bb 100644 --- a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts +++ b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts @@ -1,3 +1,4 @@ +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; import { expect, test } from "../support/fixtures"; import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; @@ -274,3 +275,123 @@ test.describe("Settings toggle tab regression", () => { } }); }); + +async function selectSendingOption( + page: import("@playwright/test").Page, + setting: string, + option: string, +) { + await page.getByRole("button", { name: new RegExp(`^${setting}:`) }).click(); + await page.getByRole("menuitem", { name: option, exact: true }).click(); + await expect( + page.getByRole("button", { name: `${setting}: ${option}`, exact: true }), + ).toBeVisible(); +} + +async function sendDraftWithShortcut( + page: import("@playwright/test").Page, + draft: string, + shortcut: string, +) { + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await composer.fill(draft); + await composer.press(shortcut); + await expect(composer).toHaveValue(""); + await expect(page.getByText(draft, { exact: true })).toBeVisible(); +} + +test("send shortcut selection persists and controls the composer", async ({ page }, testInfo) => { + test.setTimeout(180_000); + const workspace = await seedWorkspace({ repoPrefix: "send-shortcut-" }); + const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl"; + try { + const agent = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Send shortcut", + }); + await openAgentRouteAndExpectFocused({ + page, + serverId: getServerId(), + workspaceId: workspace.workspaceId, + agentId: agent.id, + }); + await openComposerTab(page, agent.id); + const composer = page.getByRole("textbox", { name: "Message agent..." }); + + await test.step("Enter sends by default", async () => { + await sendDraftWithShortcut(page, "Default Enter submission", "Enter"); + }); + await test.step("choose the explicit modifier shortcut and retain it after reload", async () => { + await pressSettingsToggleShortcut(page); + await expect( + page.getByRole("button", { name: "Send shortcut: Enter", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Send shortcut: Enter", exact: true }).click(); + await expect(page.getByRole("menuitem")).toHaveText(["Enter", `${modifierLabel}+Enter`]); + // Let the menu opening animation settle for visual QA. + await page.waitForTimeout(300); + await page.screenshot({ path: testInfo.outputPath("send-shortcut-selector.png") }); + await page.getByRole("menuitem", { name: `${modifierLabel}+Enter`, exact: true }).click(); + await page.reload(); + await expect( + page.getByRole("button", { name: `Send shortcut: ${modifierLabel}+Enter`, exact: true }), + ).toBeVisible(); + await pressSettingsToggleShortcut(page); + await expect(composer).toBeVisible(); + }); + await test.step("Enter and Shift+Enter keep editing; modifier Enter sends", async () => { + await composer.fill("First line"); + await composer.press("Enter"); + await expect(composer).toHaveValue("First line\n"); + await composer.press("Shift+Enter"); + await expect(composer).toHaveValue("First line\n\n"); + await sendDraftWithShortcut(page, "Sent with Control", "Control+Enter"); + await sendDraftWithShortcut(page, "Sent with Command", "Meta+Enter"); + }); + await test.step("switch back to Enter without changing the default send behavior", async () => { + await pressSettingsToggleShortcut(page); + await selectSendingOption(page, "Send shortcut", "Enter"); + await pressSettingsToggleShortcut(page); + await composer.fill("Shift still edits"); + await composer.press("Shift+Enter"); + await expect(composer).toHaveValue("Shift still edits\n"); + await sendDraftWithShortcut(page, "Restored Enter submission", "Enter"); + }); + } finally { + await workspace.cleanup(); + } +}); + +for (const behavior of ["Steer", "Interrupt", "Queue"] as const) { + test(`modifier send shortcut follows ${behavior} while an agent runs`, async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "running-send-shortcut-", + title: "Running send shortcut", + model: "one-minute-stream", + }); + const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl"; + try { + await openAgentRoute(page, agent); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); + await pressSettingsToggleShortcut(page); + await selectSendingOption(page, "Send shortcut", `${modifierLabel}+Enter`); + await selectSendingOption(page, "Default send", behavior); + await pressSettingsToggleShortcut(page); + await sendDraftWithShortcut(page, "Stay running while I write a follow-up", "Control+Enter"); + await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible(); + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await composer.fill("Follow-up"); + await composer.press("Enter"); + await expect(composer).toHaveValue("Follow-up\n"); + await composer.press("Control+Enter"); + await expect(composer).toHaveValue(""); + await expect(page.getByText("Follow-up", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Send queued message" })).toHaveCount( + behavior === "Queue" ? 1 : 0, + ); + } finally { + await agent.cleanup(); + } + }); +} diff --git a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts index 7d8b484c77..a902fde117 100644 --- a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts @@ -11,7 +11,10 @@ import { expectWorkspaceTabVisible, openWorkspaceWithAgents, } from "../support/helpers/archive-tab"; -import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout"; +import { + expectAgentAbsentFromWorkspaceLayout, + seedForeignPinnedAgentTab, +} from "../support/helpers/workspace-layout"; import { clickNewChat } from "../support/helpers/launcher"; import { expectComposerVisible } from "../support/helpers/composer"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; @@ -105,6 +108,83 @@ async function openWorkspaceTabUrl(page: Page, route: string): Promise { test.describe("Workspace navigation regression", () => { test.describe.configure({ timeout: 240_000 }); + test("removes a foreign pinned agent when restoring a saved workspace layout", async ({ + page, + }, testInfo) => { + const serverId = getServerId(); + const firstWorkspace = await seedWorkspace({ + repoPrefix: "workspace-owner-", + title: "Original workspace", + }); + const secondWorkspace = await seedWorkspace({ + repoPrefix: "workspace-restored-", + title: "Restored workspace", + }); + try { + const firstAgent = await createMockIdleAgent(firstWorkspace.client, { + cwd: firstWorkspace.repoPath, + workspaceId: firstWorkspace.workspaceId, + title: "Original workspace agent", + }); + const secondAgent = await createMockIdleAgent(secondWorkspace.client, { + cwd: secondWorkspace.repoPath, + workspaceId: secondWorkspace.workspaceId, + title: "Restored workspace agent", + }); + await gotoAppShell(page); + await openWorkspaceWithAgents(page, [firstAgent, secondAgent]); + await seedForeignPinnedAgentTab(page, { + serverId, + workspaceId: secondWorkspace.workspaceId, + agentId: firstAgent.id, + }); + + await page.reload(); + await waitForSidebarHydration(page); + await waitForWorkspaceTabsVisible(page); + await expectWorkspaceHeader(page, { + title: secondWorkspace.workspaceName, + subtitle: secondWorkspace.projectDisplayName, + }); + await testInfo.attach("hydrated-workspace", { + body: await page.screenshot({ path: testInfo.outputPath("hydrated-workspace.png") }), + contentType: "image/png", + }); + await expectAgentAbsentFromWorkspaceLayout(page, { + serverId, + workspaceId: secondWorkspace.workspaceId, + agentId: firstAgent.id, + }); + await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]); + await testInfo.attach("restored-workspace", { + body: await page.screenshot({ path: testInfo.outputPath("restored-workspace.png") }), + contentType: "image/png", + }); + + await switchWorkspaceViaSidebar({ + page, + serverId, + workspaceId: firstWorkspace.workspaceId, + }); + await waitForWorkspaceTabsVisible(page); + await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]); + const agents = await firstWorkspace.client.fetchAgents(); + expect( + agents.entries.find(({ agent }) => agent.id === firstAgent.id)?.agent.workspaceId, + ).toBe(firstWorkspace.workspaceId); + expect( + agents.entries.find(({ agent }) => agent.id === secondAgent.id)?.agent.workspaceId, + ).toBe(secondWorkspace.workspaceId); + await testInfo.attach("original-workspace", { + body: await page.screenshot({ path: testInfo.outputPath("original-workspace.png") }), + contentType: "image/png", + }); + } finally { + await secondWorkspace.cleanup(); + await firstWorkspace.cleanup(); + } + }); + test("keeps an offline notification on the agent route until the host connects", async ({ page, }) => { diff --git a/packages/app/e2e/support/helpers/workspace-layout.ts b/packages/app/e2e/support/helpers/workspace-layout.ts index 80c2536fbf..2888fa232a 100644 --- a/packages/app/e2e/support/helpers/workspace-layout.ts +++ b/packages/app/e2e/support/helpers/workspace-layout.ts @@ -1,12 +1,47 @@ import { expect, type Page } from "@playwright/test"; import { z } from "zod"; import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage"; +import { openTabInLayoutFocused } from "../../../src/stores/workspace-layout-actions"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTab, } from "../../../src/workspace-tabs/model"; import { getServerId } from "./server-id"; +export async function seedForeignPinnedAgentTab( + page: Page, + input: { serverId: string; workspaceId: string; agentId: string }, +): Promise { + const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state")); + if (raw === null) throw new Error("Workspace layout was not persisted"); + const stored = z + .object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number().optional() }) + .parse(JSON.parse(raw)); + const key = buildWorkspaceTabPersistenceKey(input); + const layout = key ? stored.state.layoutByWorkspace[key] : null; + if (!key || !layout) throw new Error(`No persisted layout for workspace ${input.workspaceId}`); + const opened = openTabInLayoutFocused({ + layout, + target: { kind: "agent", agentId: input.agentId }, + now: 1, + placement: { mode: "focused" }, + explorerSidebarPaneId: stored.state.explorerSidebarPaneIdByWorkspace?.[key] ?? null, + }); + if (!opened) throw new Error("Failed to seed foreign agent tab"); + stored.state = WorkspaceLayoutPersistedStateSchema.parse({ + ...stored.state, + layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: opened.layout }, + pinnedAgentIdsByWorkspace: { + ...stored.state.pinnedAgentIdsByWorkspace, + [key]: [...(stored.state.pinnedAgentIdsByWorkspace?.[key] ?? []), input.agentId], + }, + }); + await page.evaluate( + (value) => localStorage.setItem("workspace-layout-state", value), + JSON.stringify(stored), + ); +} + /** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */ export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise { const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state")); diff --git a/packages/app/package.json b/packages/app/package.json index e2e39ec03d..3eddd9b53f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.13.1", + "version": "0.13.2", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 16294e0a03..333eb68221 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -30,7 +30,7 @@ import { type ActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { density, densityIconBox } from "@/styles/density"; +import { density, densityIconBox, densityIconProps } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; @@ -463,12 +463,12 @@ const openInNewWindowLeadingIcon = ( ); +const kebabTriggerIconProps = densityIconProps("sm", foregroundMutedColorMapping); +const kebabTriggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping); + function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { return ( - + ); } @@ -2705,8 +2705,12 @@ const styles = StyleSheet.create((theme) => ({ marginRight: -6, }, projectKebabButton: { - width: 24, - height: 24, + // Larger on compact so the kebab is an easy tap target. The negative margins keep its + // dots on the same rail as the 24px desktop box and the row at its 44px height. + width: { xs: 32, md: 24 }, + height: { xs: 32, md: 24 }, + marginRight: { xs: -4, md: 0 }, + marginVertical: { xs: -4, md: 0 }, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", diff --git a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx index e1543fac41..f74c43c980 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx @@ -17,6 +17,7 @@ import { isWeb } from "@/constants/platform"; import { getForgePresentation, normalizeForge } from "@/git/forge"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import { useAppSettings } from "@/hooks/use-settings"; +import { densityIconProps } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { @@ -70,13 +71,11 @@ const archiveLeadingIcon = ; const unpinLeadingIcon = ; +const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping); +const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping); + function renderTriggerIcon({ hovered }: { hovered?: boolean }) { - return ( - - ); + return ; } export interface SidebarWorkspaceMenuProps { @@ -439,12 +438,18 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere const styles = StyleSheet.create((theme) => ({ trigger: { - padding: 2, + // Larger on compact so the kebab is an easy tap target. + width: { xs: 32, md: 18 }, + height: { xs: 32, md: 18 }, + alignItems: "center", + justifyContent: "center", borderRadius: 4, marginLeft: 2, + // Center the compact box on the 20px title line it is anchored to. + marginTop: { xs: -6, md: 0 }, // MoreVertical paints only around the center of its SVG. Keep the padded hit box, but // pull the painted dots through that unused view-box space onto the trailing-content rail. - marginRight: -7, + marginRight: { xs: -14, md: -7 }, }, triggerHovered: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index d000c269af..b7088ddeef 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -2495,6 +2495,7 @@ function ComposerContentImpl({ voiceAgentId={agentId} isAgentRunning={isAgentRunning} defaultSendBehavior={activeSendBehavior} + sendShortcut={appSettings.sendShortcut} onQueue={handleQueue} onSubmitLoadingPress={submitLoadingPressHandler} onKeyPress={handleCommandKeyPress} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 73c54331d1..16acede7e5 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1,3 +1,4 @@ +import type { SendShortcut } from "@/hooks/use-settings"; import { useComposerEditingSession, useComposerFullscreen, @@ -88,6 +89,7 @@ import { resolveAlternateSendAction, resolveComposerSurfacePresentation, resolveComposerSurfaceText, + resolveMessageInputEnterAction, resolveRetainedSelection, runAlternateSendAction, runDefaultSendAction, @@ -96,6 +98,11 @@ import { } from "./state"; const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]]; +const MOD_ENTER_SEND_KEYS: ShortcutKey[][] = [["mod", "Enter"]]; + +function resolveSendKeys(sendShortcut: SendShortcut): ShortcutKey[][] { + return sendShortcut === "mod-enter" ? MOD_ENTER_SEND_KEYS : DEFAULT_SEND_KEYS; +} const COMPOSER_INPUT_DATASET = { composerInput: "" } as const; export interface AttachmentMenuItem { @@ -167,6 +174,7 @@ export interface MessageInputProps { * running. "interrupt" and "steer" send immediately, "queue" queues. Required so the default * lives only in DEFAULT_CLIENT_SETTINGS. */ defaultSendBehavior: "interrupt" | "steer" | "queue"; + sendShortcut: SendShortcut; /** Callback for queue button when agent is running */ onQueue?: (payload: MessagePayload) => void; /** Optional handler used when submit button is in loading state. */ @@ -402,6 +410,9 @@ interface DesktopKeyPressContext { onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined; input: ComposerKeyPressEvent["input"]; submitOnEnter: boolean; + sendShortcut: SendShortcut; + isAgentRunning: boolean; + onQueue: ((payload: MessagePayload) => void) | undefined; isSubmitDisabled: boolean; isSubmitLoading: boolean; disabled: boolean; @@ -428,12 +439,21 @@ function handleDesktopKeyPressImpl( } if (event.nativeEvent.key !== "Enter") return; - if (!ctx.submitOnEnter) return; - if (shiftKey) return; + + const action = resolveMessageInputEnterAction({ + submitOnEnter: ctx.submitOnEnter, + sendShortcut: ctx.sendShortcut, + shiftKey: shiftKey ?? false, + metaKey: metaKey ?? false, + ctrlKey: ctrlKey ?? false, + isAgentRunning: ctx.isAgentRunning, + onQueue: ctx.onQueue, + }); + if (action === null) return; if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; event.preventDefault(); - if (metaKey || ctrlKey) { + if (action === "alternate-send") { ctx.handleAlternateSendAction(); return; } @@ -1112,6 +1132,7 @@ interface ResolvedMessageInputProps { voiceAgentId: string | undefined; isAgentRunning: boolean; defaultSendBehavior: "interrupt" | "steer" | "queue"; + sendShortcut: SendShortcut; onQueue: ((payload: MessagePayload) => void) | undefined; onSubmitLoadingPress: (() => void) | undefined; onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined; @@ -1159,6 +1180,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu voiceAgentId: props.voiceAgentId, isAgentRunning: props.isAgentRunning ?? false, defaultSendBehavior: props.defaultSendBehavior, + sendShortcut: props.sendShortcut, onQueue: props.onQueue, onSubmitLoadingPress: props.onSubmitLoadingPress, onKeyPressCallback: props.onKeyPress, @@ -1236,6 +1258,7 @@ export const MessageInput = forwardRef( voiceAgentId, isAgentRunning, defaultSendBehavior, + sendShortcut, onQueue, onSubmitLoadingPress, onKeyPressCallback, @@ -1755,6 +1778,9 @@ export const MessageInput = forwardRef( selectionRef.current, ), submitOnEnter: shouldSubmitOnEnter, + sendShortcut, + isAgentRunning, + onQueue, isSubmitDisabled, isSubmitLoading, disabled, @@ -1766,6 +1792,9 @@ export const MessageInput = forwardRef( shouldHandleWebKeyPress, onKeyPressCallback, shouldSubmitOnEnter, + sendShortcut, + isAgentRunning, + onQueue, isSubmitDisabled, isSubmitLoading, disabled, @@ -1980,7 +2009,7 @@ export const MessageInput = forwardRef( submitLabel={submitLabel} submitButtonTestID={submitButtonTestID} buttonIconSize={buttonIconSize} - sendKeys={DEFAULT_SEND_KEYS} + sendKeys={resolveSendKeys(sendShortcut)} sendTooltipLabel={sendTooltipLabel} /> ), @@ -2001,6 +2030,7 @@ export const MessageInput = forwardRef( submitLabel, submitButtonTestID, buttonIconSize, + sendShortcut, sendTooltipLabel, ], ); diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 82f30da364..ad492976b3 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import { applyDictationTranscript, computeCanStartDictation, + type MessageInputEnterContext, resolveActiveSendBehavior, resolveComposerSurfacePresentation, resolveComposerSurfaceText, resolveGlyphRailOffset, + resolveMessageInputEnterAction, resolveRetainedSelection, resolveAlternateSendAction, runAlternateSendAction, @@ -320,6 +322,212 @@ describe("composer send behavior", () => { }); }); +describe("resolveMessageInputEnterAction", () => { + function enterContext( + overrides: Partial = {}, + ): MessageInputEnterContext { + return { + submitOnEnter: true, + sendShortcut: "enter", + shiftKey: false, + metaKey: false, + ctrlKey: false, + isAgentRunning: false, + onQueue: () => undefined, + ...overrides, + }; + } + + it("returns null for plain, Shift, and modifier Enter when submit on enter is disabled", () => { + expect(resolveMessageInputEnterAction(enterContext({ submitOnEnter: false }))).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, shiftKey: true })), + ).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, metaKey: true })), + ).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, ctrlKey: true })), + ).toBeNull(); + }); + + it("still returns null when modifier Enter sends and submit on enter is disabled", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", submitOnEnter: false, metaKey: true }), + ), + ).toBeNull(); + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", submitOnEnter: false }), + ), + ).toBeNull(); + }); + + it("sends on plain Enter when Enter sends", () => { + expect(resolveMessageInputEnterAction(enterContext())).toBe("default-send"); + }); + + it("returns null for Shift+Enter when Enter sends", () => { + expect(resolveMessageInputEnterAction(enterContext({ shiftKey: true }))).toBeNull(); + }); + + it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter so Shift keeps its newline", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ shiftKey: true, metaKey: true, isAgentRunning: true }), + ), + ).toBeNull(); + expect( + resolveMessageInputEnterAction( + enterContext({ shiftKey: true, ctrlKey: true, isAgentRunning: true }), + ), + ).toBeNull(); + }); + + it("sends on Cmd+Enter and Ctrl+Enter while idle even with a queue handler", () => { + expect(resolveMessageInputEnterAction(enterContext({ metaKey: true }))).toBe("default-send"); + expect(resolveMessageInputEnterAction(enterContext({ ctrlKey: true }))).toBe("default-send"); + }); + + it("queues on Cmd+Enter and Ctrl+Enter while the agent is running with a queue handler", () => { + expect( + resolveMessageInputEnterAction(enterContext({ metaKey: true, isAgentRunning: true })), + ).toBe("alternate-send"); + expect( + resolveMessageInputEnterAction(enterContext({ ctrlKey: true, isAgentRunning: true })), + ).toBe("alternate-send"); + }); + + it("sends on Cmd+Enter and Ctrl+Enter while running when no queue handler exists", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ metaKey: true, isAgentRunning: true, onQueue: undefined }), + ), + ).toBe("default-send"); + expect( + resolveMessageInputEnterAction( + enterContext({ ctrlKey: true, isAgentRunning: true, onQueue: undefined }), + ), + ).toBe("default-send"); + }); + + it("returns null for plain and Shift+Enter when modifier Enter sends", () => { + expect(resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter" }))).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", shiftKey: true })), + ).toBeNull(); + }); + + it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter when modifier Enter sends", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", shiftKey: true, metaKey: true }), + ), + ).toBeNull(); + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", shiftKey: true, ctrlKey: true }), + ), + ).toBeNull(); + }); + + it("sends on Cmd+Enter and Ctrl+Enter while idle when modifier Enter sends", () => { + expect( + resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", metaKey: true })), + ).toBe("default-send"); + expect( + resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", ctrlKey: true })), + ).toBe("default-send"); + }); + + it("uses the default action on modifier Enter while running when modifier Enter sends", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }), + ), + ).toBe("default-send"); + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", ctrlKey: true, isAgentRunning: true }), + ), + ).toBe("default-send"); + }); + + describe("composed with the send actions", () => { + function actions() { + const calls: string[] = []; + return { + calls, + handleSendMessage: () => calls.push("send"), + handleQueueMessage: () => calls.push("queue"), + onQueue: () => undefined, + }; + } + + function resolveCommandEnterToSendModifierEnter() { + return resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }), + ); + } + + it("queues through the default send action when queue behavior is selected and the agent is running", () => { + const sendAction = actions(); + + expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send"); + + runDefaultSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: true, + onQueue: sendAction.onQueue, + handleSendMessage: sendAction.handleSendMessage, + handleQueueMessage: sendAction.handleQueueMessage, + }); + + expect(sendAction.calls).toEqual(["queue"]); + }); + + it("sends through the default send action when queue behavior is selected and the agent is idle", () => { + const sendAction = actions(); + + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: false }), + ), + ).toBe("default-send"); + + runDefaultSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: false, + onQueue: sendAction.onQueue, + handleSendMessage: sendAction.handleSendMessage, + handleQueueMessage: sendAction.handleQueueMessage, + }); + + expect(sendAction.calls).toEqual(["send"]); + }); + + it.each([{ defaultSendBehavior: "steer" }, { defaultSendBehavior: "interrupt" }] as const)( + "sends through the default send action when $defaultSendBehavior behavior is selected and the agent is running", + ({ defaultSendBehavior }) => { + const sendAction = actions(); + + expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send"); + + runDefaultSendAction({ + defaultSendBehavior, + isAgentRunning: true, + onQueue: sendAction.onQueue, + handleSendMessage: sendAction.handleSendMessage, + handleQueueMessage: sendAction.handleQueueMessage, + }); + + expect(sendAction.calls).toEqual(["send"]); + }, + ); + }); +}); + describe("stopRealtimeVoice", () => { it("keeps voice mode active when the running agent refuses cancellation", async () => { const cancellationError = new Error("active run cancellation was not acknowledged"); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 9cf6279482..67c8030745 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -1,3 +1,4 @@ +import type { SendShortcut } from "@/hooks/use-settings"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import type { MessagePayload, TextReplacement } from "@/composer/types"; @@ -151,6 +152,29 @@ export function runAlternateSendAction(ctx: SendActionContext): void { runDefaultSendAction(ctx); } +export type MessageInputEnterAction = "alternate-send" | "default-send"; + +export interface MessageInputEnterContext { + submitOnEnter: boolean; + sendShortcut: SendShortcut; + shiftKey: boolean; + metaKey: boolean; + ctrlKey: boolean; + isAgentRunning: boolean; + onQueue: ((payload: MessagePayload) => void) | undefined; +} + +export function resolveMessageInputEnterAction( + ctx: MessageInputEnterContext, +): MessageInputEnterAction | null { + if (!ctx.submitOnEnter) return null; + if (ctx.shiftKey) return null; + const hasModifier = ctx.metaKey || ctx.ctrlKey; + if (ctx.sendShortcut === "mod-enter") return hasModifier ? "default-send" : null; + if (hasModifier && ctx.isAgentRunning && ctx.onQueue) return "alternate-send"; + return "default-send"; +} + export function runMessageInputKeyboardAction( action: MessageInputKeyboardActionKind, actions: MessageInputKeyboardActions, diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts index 9f6f22fb23..382ab350fc 100644 --- a/packages/app/src/hooks/use-settings/index.ts +++ b/packages/app/src/hooks/use-settings/index.ts @@ -49,6 +49,7 @@ import { type KeyValueStorage, type ReleaseChannel, type SendBehavior, + type SendShortcut, type ServiceUrlBehavior, type Settings, type SidebarWorkspaceTrailing, @@ -93,6 +94,7 @@ export type { KeyValueStorage, ReleaseChannel, SendBehavior, + SendShortcut, ServiceUrlBehavior, Settings, SettingsDeps, diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index fe2ffdd2a0..0b169b5214 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -227,6 +227,39 @@ describe("loadAppSettingsFromStorage", () => { expect(result.chatOutlineEnabled).toBe(false); }); + it("uses Enter to send by default", async () => { + const deps = makeDeps(); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.sendShortcut).toBe("enter"); + expect(DEFAULT_CLIENT_SETTINGS.sendShortcut).toBe("enter"); + }); + + it("loads the modifier Enter send preference", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "mod-enter" }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.sendShortcut).toBe("mod-enter"); + }); + + it("falls back to Enter when the stored send shortcut is invalid", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "yes" }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.sendShortcut).toBe("enter"); + }); + it("defaults sidebar navigation items to an empty preference list", async () => { const deps = makeDeps(); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 3a5be63441..424536ac69 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -33,6 +33,8 @@ import { migrateAppSettings } from "./migrations"; export { APP_SETTINGS_KEY } from "./keys"; export const APP_SETTINGS_QUERY_KEY = ["app-settings"]; +export type SendShortcut = "enter" | "mod-enter"; + export type SendBehavior = ActiveTurnBehavior | "queue"; export type ReleaseChannel = "stable" | "beta"; export type ServiceUrlBehavior = "ask" | "in-app" | "external"; @@ -104,6 +106,7 @@ export interface AppSettings { chatDisplay: ChatDisplay; chatOutlineEnabled: boolean; vimKeybindings: boolean; + sendShortcut: SendShortcut; filePanelAssociations: Record; /** Desktop-only preferences for implicit opens into the ordinary side pane. */ openInSidePane: OpenInSidePanePreferences; @@ -160,6 +163,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { chatDisplay: "collapseTurns", chatOutlineEnabled: true, vimKeybindings: false, + sendShortcut: "enter", filePanelAssociations: {}, openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES, pullRequestOpenLocation: "explorer", @@ -264,6 +268,7 @@ const StoredAppSettingsSchema = z compactToolCalls: z.boolean().optional().catch(undefined), chatOutlineEnabled: z.boolean().catch(true), vimKeybindings: z.boolean().catch(false), + sendShortcut: z.enum(["enter", "mod-enter"]).catch("enter"), filePanelAssociations: z.record(z.string(), z.string()).catch({}), openInSidePane: z .object({ diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 108fad41d4..6bfc30095c 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2369,12 +2369,25 @@ export const ar: TranslationResources = { "عند تشغيل الوكيل، يوجّه Enter الجولة النشطة. يضيف Command/Ctrl+Enter إلى قائمة الانتظار.", queue: "عند تشغيل الوكيل، يضيف Enter إلى قائمة الانتظار. Command/Ctrl+Enter يرسل.", }, + commandEnterDescriptions: { + interrupt: "عند تشغيل الوكيل، يوقف {{modifier}}+Enter التشغيل. يُدرج Enter سطرًا جديدًا.", + steer: "عند تشغيل الوكيل، يوجّه {{modifier}}+Enter الجولة النشطة. يُدرج Enter سطرًا جديدًا.", + queue: + "عند تشغيل الوكيل، يضيف {{modifier}}+Enter إلى قائمة الانتظار. يُدرج Enter سطرًا جديدًا.", + }, options: { interrupt: "مقاطعة", steer: "توجيه", queue: "طابور", }, }, + sendShortcut: { + label: "اختصار الإرسال", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "بسأل", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 16bddd9cf9..ff1e80d88b 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2457,12 +2457,26 @@ export const en = { "When the agent is running, Enter steers the active turn. Command/Ctrl+Enter queues.", queue: "When the agent is running, Enter queues. Command/Ctrl+Enter submits.", }, + commandEnterDescriptions: { + interrupt: + "When the agent is running, {{modifier}}+Enter interrupts. Enter inserts a new line.", + steer: + "When the agent is running, {{modifier}}+Enter steers the active turn. Enter inserts a new line.", + queue: "When the agent is running, {{modifier}}+Enter queues. Enter inserts a new line.", + }, options: { interrupt: "Interrupt", steer: "Steer", queue: "Queue", }, }, + sendShortcut: { + label: "Send shortcut", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Ask", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 2226583781..8784adefb5 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2384,12 +2384,27 @@ export const es: TranslationResources = { queue: "Cuando el agente se está ejecutando, Enter pone en cola. Command/Ctrl+Enter envía.", }, + commandEnterDescriptions: { + interrupt: + "Cuando el agente se está ejecutando, {{modifier}}+Enter interrumpe. Enter inserta una línea nueva.", + steer: + "Cuando el agente se está ejecutando, {{modifier}}+Enter dirige el turno activo. Enter inserta una línea nueva.", + queue: + "Cuando el agente se está ejecutando, {{modifier}}+Enter pone en cola. Enter inserta una línea nueva.", + }, options: { interrupt: "Interrumpir", steer: "Dirigir", queue: "Cola", }, }, + sendShortcut: { + label: "Atajo de envío", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Preguntar", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 65d72d405b..bbafc03dda 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2383,12 +2383,27 @@ export const fr: TranslationResources = { queue: "Quand l’agent est en cours d’exécution, Entrée met en file d’attente. Cmd/Ctrl+Entrée envoie.", }, + commandEnterDescriptions: { + interrupt: + "Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée interrompt. Entrée insère une nouvelle ligne.", + steer: + "Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée oriente le tour actif. Entrée insère une nouvelle ligne.", + queue: + "Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée met en file d'attente. Entrée insère une nouvelle ligne.", + }, options: { interrupt: "Interrompre", steer: "Réorienter", queue: "File d’attente", }, }, + sendShortcut: { + label: "Raccourci d’envoi", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Demander", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 5829bf4fa3..e7dcc5b5e7 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2353,12 +2353,25 @@ export const ja: TranslationResources = { "エージェント実行中、Enterで現在のターンに指示を送ります。Command/Ctrl+Enterでキューに追加。", queue: "エージェント実行中、Enterでキューに追加します。Command/Ctrl+Enterで送信。", }, + commandEnterDescriptions: { + interrupt: "エージェント実行中、{{modifier}}+Enterで中断します。Enterで改行します。", + steer: + "エージェント実行中、{{modifier}}+Enterで現在のターンに指示を送ります。Enterで改行します。", + queue: "エージェント実行中、{{modifier}}+Enterでキューに追加します。Enterで改行します。", + }, options: { interrupt: "中断", steer: "指示", queue: "キュー", }, }, + sendShortcut: { + label: "送信ショートカット", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "確認する", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 36a27fc84e..d8d1fbc787 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2344,12 +2344,27 @@ export const ko: TranslationResources = { queue: "에이전트가 실행 중일 때 Enter는 대기열에 추가합니다. Command/Ctrl+Enter는 제출합니다.", }, + commandEnterDescriptions: { + interrupt: + "에이전트가 실행 중일 때 {{modifier}}+Enter는 중단합니다. Enter는 줄바꿈합니다.", + steer: + "에이전트가 실행 중일 때 {{modifier}}+Enter는 현재 턴에 지시합니다. Enter는 줄바꿈합니다.", + queue: + "에이전트가 실행 중일 때 {{modifier}}+Enter는 대기열에 추가합니다. Enter는 줄바꿈합니다.", + }, options: { interrupt: "중단", steer: "지시", queue: "대기열", }, }, + sendShortcut: { + label: "전송 단축키", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "물어보기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5a83b4345c..1c024dc64a 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2367,12 +2367,27 @@ export const ptBR: TranslationResources = { "Quando o agente está em execução, Enter orienta o turno ativo. Command/Ctrl+Enter enfileira.", queue: "Quando o agente está em execução, Enter enfileira. Command/Ctrl+Enter envia.", }, + commandEnterDescriptions: { + interrupt: + "Quando o agente está em execução, {{modifier}}+Enter interrompe. Enter insere uma nova linha.", + steer: + "Quando o agente está em execução, {{modifier}}+Enter orienta o turno ativo. Enter insere uma nova linha.", + queue: + "Quando o agente está em execução, {{modifier}}+Enter enfileira. Enter insere uma nova linha.", + }, options: { interrupt: "Interromper", steer: "Orientar", queue: "Fila", }, }, + sendShortcut: { + label: "Atalho de envio", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Perguntar", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 135d5fe2f9..093ac68c36 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2370,12 +2370,27 @@ export const ru: TranslationResources = { queue: "Когда агент работает, Enter ставит сообщение в очередь, а Command/Ctrl+Enter отправляет его.", }, + commandEnterDescriptions: { + interrupt: + "Когда агент работает, {{modifier}}+Enter прерывает его работу, а Enter вставляет новую строку.", + steer: + "Когда агент работает, {{modifier}}+Enter дополняет текущий ответ новым указанием, а Enter вставляет новую строку.", + queue: + "Когда агент работает, {{modifier}}+Enter ставит сообщение в очередь, а Enter вставляет новую строку.", + }, options: { interrupt: "Прерывать", steer: "Направить", queue: "Поставить в очередь", }, }, + sendShortcut: { + label: "Сочетание клавиш для отправки", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Спрашивать", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 78b0f06442..8ca178f206 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2308,12 +2308,24 @@ export const zhCN: TranslationResources = { steer: "Agent 运行时,Enter 会引导当前回合。Command/Ctrl+Enter 会排队。", queue: "Agent 运行时,Enter 会排队。Command/Ctrl+Enter 会提交。", }, + commandEnterDescriptions: { + interrupt: "Agent 运行时,{{modifier}}+Enter 会中断。Enter 会插入换行。", + steer: "Agent 运行时,{{modifier}}+Enter 会引导当前回合。Enter 会插入换行。", + queue: "Agent 运行时,{{modifier}}+Enter 会排队。Enter 会插入换行。", + }, options: { interrupt: "中断", steer: "引导", queue: "排队", }, }, + sendShortcut: { + label: "发送快捷键", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "询问", diff --git a/packages/app/src/screens/settings/general/sending-section.tsx b/packages/app/src/screens/settings/general/sending-section.tsx index 85da0fdf4e..c5d5f01760 100644 --- a/packages/app/src/screens/settings/general/sending-section.tsx +++ b/packages/app/src/screens/settings/general/sending-section.tsx @@ -1,13 +1,17 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { SettingsCard, SettingsSection, SettingsSelect } from "@/components/settings"; -import { useAppSettings, type SendBehavior } from "@/hooks/use-settings"; +import { useAppSettings, type SendShortcut, type SendBehavior } from "@/hooks/use-settings"; +import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; +import { getShortcutOs } from "@/utils/shortcut-platform"; const SEND_BEHAVIORS: readonly SendBehavior[] = ["interrupt", "steer", "queue"]; export function SendingSection() { const { t } = useTranslation(); const { settings, updateSettings } = useAppSettings(); + const shortcutsAvailable = useKeyboardShortcutsAvailable(); + const modifier = getShortcutOs() === "mac" ? "⌘" : "Ctrl"; const options = useMemo( () => SEND_BEHAVIORS.map((value) => ({ @@ -20,12 +24,37 @@ export function SendingSection() { (sendBehavior: SendBehavior) => void updateSettings({ sendBehavior }), [updateSettings], ); + const shortcutOptions = useMemo( + () => + (["enter", "mod-enter"] as const).map((value) => ({ + value, + label: t(`settings.general.sendShortcut.options.${value}`, { modifier }), + })), + [t, modifier], + ); + const changeSendShortcut = useCallback( + (sendShortcut: SendShortcut) => void updateSettings({ sendShortcut }), + [updateSettings], + ); return ( + {shortcutsAvailable ? ( + + ) : null} ; autoOpenAgentIds: Iterable; + foreignAgentIds?: Iterable; knownTerminalIds?: Iterable; standaloneTerminalIds: Iterable; hasActivePendingTerminalCreate?: boolean; @@ -2443,13 +2444,16 @@ export function reconcileWorkspaceTabs( const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds); - // An explicit open owns the tab until the agent joins the active directory. - // From then on it follows the normal server archive lifecycle. Detail/cache - // hydration never decides whether the user's target is allowed to stay open. + const foreignAgentIds = normalizeStringSet(snapshot.foreignAgentIds ?? []); + // Explicit opens survive absent directory entries, but not known foreign ownership. + // Once an agent joins this workspace's active directory, normal archive rules apply. const pinnedAgentIds = new Set( - [...(state.pinnedAgentIds ?? [])].filter( - (agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId), - ), + [...(state.pinnedAgentIds ?? [])].filter((agentId) => { + if (!snapshot.agentsHydrated) { + return true; + } + return !activeAgentIds.has(agentId) && !foreignAgentIds.has(agentId); + }), ); const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); const knownTerminalIds = snapshot.knownTerminalIds diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index 59f96fccbc..cc5b8f6e04 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -1,6 +1,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, it } from "vitest"; import { useSessionStore, type Agent } from "@/stores/session-store"; +import { createWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabSnapshot, createWorkspaceAgentVisibilitySelector, @@ -63,6 +64,118 @@ function makeAgent(input: { const WORKSPACE_ID = "ws-1"; describe("workspace agent visibility", () => { + it("removes a restored pinned tab from an unrelated workspace once its owner is known", async () => { + const values = new Map(); + const storage = { + getItem: async (key: string) => values.get(key) ?? null, + setItem: async (key: string, value: string) => { + values.set(key, value); + }, + removeItem: async (key: string) => { + values.delete(key); + }, + }; + const original = createWorkspaceLayoutStore(undefined, storage); + await original.persist.rehydrate(); + const agent = makeAgent({ id: "restored-agent", cwd: "/project-a", workspaceId: "ws-a" }); + for (const workspaceId of ["ws-a", "ws-b"]) { + original.getState().openTab({ + workspaceKey: `srv:${workspaceId}`, + target: { kind: "agent", agentId: agent.id }, + intent: "reveal", + pin: true, + }); + } + const restored = createWorkspaceLayoutStore(undefined, storage); + await restored.persist.rehydrate(); + const sessionAgents = new Map([[agent.id, agent]]); + const beforeHydration = buildWorkspaceTabSnapshot({ + agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId: "ws-b" }), + agentsHydrated: false, + terminalsHydrated: true, + knownTerminalIds: [], + standaloneTerminalIds: [], + hasActivePendingTerminalCreate: false, + hasActivePendingDraftCreate: false, + }); + restored.getState().reconcileTabs("srv:ws-b", beforeHydration); + expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toEqual(new Set([agent.id])); + for (const workspaceId of ["ws-a", "ws-b"]) { + restored.getState().reconcileTabs( + `srv:${workspaceId}`, + buildWorkspaceTabSnapshot({ + agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId }), + agentsHydrated: true, + terminalsHydrated: true, + knownTerminalIds: [], + standaloneTerminalIds: [], + hasActivePendingTerminalCreate: false, + hasActivePendingDraftCreate: false, + }), + ); + } + + expect( + restored + .getState() + .getWorkspaceTabs("srv:ws-a") + .filter((tab) => tab.target.kind === "agent") + .map((tab) => tab.target), + ).toEqual([{ kind: "agent", agentId: agent.id }]); + expect( + restored + .getState() + .getWorkspaceTabs("srv:ws-b") + .filter((tab) => tab.target.kind === "agent"), + ).toEqual([]); + expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toBeUndefined(); + }); + + it("uses directory ownership before historical details when identifying foreign agents", () => { + const own = makeAgent({ id: "own", cwd: "/repo", workspaceId: WORKSPACE_ID }); + const foreign = makeAgent({ id: "foreign", cwd: "/repo", workspaceId: "ws-2" }); + const unknown = makeAgent({ id: "unknown", cwd: "/repo" }); + const result = deriveWorkspaceAgentVisibility({ + workspaceId: WORKSPACE_ID, + sessionAgents: new Map([ + [own.id, own], + [foreign.id, foreign], + [unknown.id, unknown], + ]), + agentDetails: new Map([ + [own.id, { ...own, workspaceId: "ws-2" }], + [foreign.id, { ...foreign, workspaceId: WORKSPACE_ID }], + [unknown.id, { ...unknown, workspaceId: "ws-2" }], + ]), + }); + + expect(result.activeAgentIds).toEqual(new Set([own.id])); + expect(result.foreignAgentIds).toEqual(new Set([foreign.id])); + }); + + it("recognizes foreign historical ownership without making archived or unknown agents foreign", () => { + const archived = makeAgent({ + id: "archived", + cwd: "/repo", + workspaceId: WORKSPACE_ID, + archivedAt: new Date("2026-03-04T00:01:00.000Z"), + }); + const foreign = { ...archived, id: "foreign", workspaceId: "ws-2" }; + const unknown = { ...archived, id: "unknown", workspaceId: undefined }; + const result = deriveWorkspaceAgentVisibility({ + workspaceId: WORKSPACE_ID, + sessionAgents: new Map(), + agentDetails: new Map([ + [archived.id, archived], + [foreign.id, foreign], + [unknown.id, unknown], + ]), + }); + + expect(result.activeAgentIds).toEqual(new Set()); + expect(result.foreignAgentIds).toEqual(new Set([foreign.id])); + }); + it("keeps subagents active while excluding them from auto-open", () => { const parent = makeAgent({ id: "parent-agent", @@ -86,6 +199,7 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); + expect(result.foreignAgentIds).toEqual(new Set()); }); it("excludes archived subagents from active and auto-open", () => { @@ -295,6 +409,7 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); + expect(result.foreignAgentIds).toEqual(new Set(["other-ws-agent"])); }); it("excludes agents without a workspaceId", () => { @@ -308,12 +423,14 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); + expect(result.foreignAgentIds).toEqual(new Set()); }); it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => { const agentVisibility = { activeAgentIds: new Set(["active-agent"]), autoOpenAgentIds: new Set(["root-agent"]), + foreignAgentIds: new Set(["foreign-agent"]), }; expect( @@ -331,6 +448,7 @@ describe("workspace agent visibility", () => { terminalsHydrated: true, activeAgentIds: agentVisibility.activeAgentIds, autoOpenAgentIds: agentVisibility.autoOpenAgentIds, + foreignAgentIds: agentVisibility.foreignAgentIds, knownTerminalIds: ["terminal-1", "script-terminal"], standaloneTerminalIds: ["terminal-1"], hasActivePendingTerminalCreate: false, @@ -469,6 +587,24 @@ describe("workspace agent visibility", () => { expect(after.activeAgentIds).toEqual(new Set([child.id])); expect(after.autoOpenAgentIds).toEqual(new Set([child.id])); }); + + it("recomputes when only a previously unknown agent's foreign ownership becomes known", () => { + seedSession(new Map([[parent.id, parent]])); + const select = createWorkspaceAgentVisibilitySelector({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }); + const before = select(useSessionStore.getState()); + useSessionStore + .getState() + .setAgentDetails(SERVER_ID, new Map([[otherWorkspace.id, otherWorkspace]])); + const after = select(useSessionStore.getState()); + + expect(after).not.toBe(before); + expect(after.activeAgentIds).toEqual(before.activeAgentIds); + expect(after.autoOpenAgentIds).toEqual(before.autoOpenAgentIds); + expect(after.foreignAgentIds).toEqual(new Set([otherWorkspace.id])); + }); }); describe("workspaceAgentVisibilityEqual", () => { @@ -476,10 +612,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(["c"]), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(["c"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); @@ -488,10 +626,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(), }; const b = { activeAgentIds: new Set(["b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -500,10 +640,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["b"]), + foreignAgentIds: new Set(), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -512,10 +654,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), + foreignAgentIds: new Set(), }; const b = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), + foreignAgentIds: new Set(), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 3dd1881643..99e127c872 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -6,10 +6,7 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; export interface WorkspaceAgentVisibility { activeAgentIds: Set; autoOpenAgentIds: Set; -} - -function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean { - return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId; + foreignAgentIds: Set; } export function deriveWorkspaceAgentVisibility(input: { @@ -19,17 +16,23 @@ export function deriveWorkspaceAgentVisibility(input: { }): WorkspaceAgentVisibility { const { sessionAgents, agentDetails } = input; const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); - if ((!sessionAgents && !agentDetails) || !workspaceId) { + if (!workspaceId) { return { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), + foreignAgentIds: new Set(), }; } const activeAgentIds = new Set(); const autoOpenAgentIds = new Set(); + const foreignAgentIds = new Set(); for (const agent of sessionAgents?.values() ?? []) { - if (!agentBelongsToWorkspace(agent, workspaceId)) { + const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId); + if (ownerId !== workspaceId) { + if (ownerId) { + foreignAgentIds.add(agent.id); + } continue; } if (!agent.archivedAt) { @@ -42,7 +45,16 @@ export function deriveWorkspaceAgentVisibility(input: { } } } - return { activeAgentIds, autoOpenAgentIds }; + for (const agent of agentDetails?.values() ?? []) { + if (sessionAgents?.has(agent.id)) { + continue; + } + const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId); + if (ownerId && ownerId !== workspaceId) { + foreignAgentIds.add(agent.id); + } + } + return { activeAgentIds, autoOpenAgentIds, foreignAgentIds }; } interface AgentMapsSnapshot { @@ -99,6 +111,7 @@ export function buildWorkspaceTabSnapshot(input: { terminalsHydrated: input.terminalsHydrated, activeAgentIds: input.agentVisibility.activeAgentIds, autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds, + foreignAgentIds: input.agentVisibility.foreignAgentIds, knownTerminalIds: input.knownTerminalIds, standaloneTerminalIds: input.standaloneTerminalIds, hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate, @@ -113,7 +126,8 @@ export function workspaceAgentVisibilityEqual( return ( a === b || (setsEqual(a.activeAgentIds, b.activeAgentIds) && - setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds)) + setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) && + setsEqual(a.foreignAgentIds, b.foreignAgentIds)) ); } diff --git a/packages/cli/package.json b/packages/cli/package.json index f4eef852fa..be8188fff3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.13.1", + "version": "0.13.2", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.13.1", - "@getpaseo/protocol": "0.13.1", - "@getpaseo/server": "0.13.1", + "@getpaseo/client": "0.13.2", + "@getpaseo/protocol": "0.13.2", + "@getpaseo/server": "0.13.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 2a0927de17..df80ba532c 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.13.1", + "version": "0.13.2", "description": "Paseo client SDK package", "files": [ "dist", @@ -47,8 +47,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.13.1", - "@getpaseo/relay": "0.13.1", + "@getpaseo/protocol": "0.13.2", + "@getpaseo/relay": "0.13.2", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index a7bb17121c..1b11a3032f 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -1770,3 +1770,66 @@ test("qualified parents preserve foreign identity without resolving an equal loc await client.close(); expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" }); }); + +test("agent reload reopens through the daemon RPC then returns the observed snapshot; refresh remains a read", async () => { + const { client, ws } = await connectClient(); + try { + const agent = client.agents.ref("agent_sdk"); + const snapshot = createAgent(); + const reloaded = agent.reload({ requestId: "reload-sdk" }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "refresh_agent_request", + agentId: "agent_sdk", + requestId: "reload-sdk", + }); + ws.message( + sessionMessage({ + type: "status", + payload: { + status: "agent_refreshed", + requestId: "reload-sdk", + agentId: "agent_sdk", + timelineSize: 0, + }, + }), + ); + await expect + .poll(() => parseSentSessionMessage(ws.sent.at(-1)).type) + .toBe("fetch_agent_request"); + const fetched = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: "fetch_agent_response", + payload: { + requestId: fetched.requestId, + agentId: "agent_sdk", + agent: snapshot, + project: null, + error: null, + }, + }), + ); + expect(await reloaded).toEqual({ agent: snapshot, project: null }); + expect(agent.current()).toEqual(snapshot); + const read = agent.refresh("snapshot-read"); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "fetch_agent_request", + requestId: "snapshot-read", + }); + ws.message( + sessionMessage({ + type: "fetch_agent_response", + payload: { + requestId: "snapshot-read", + agentId: "agent_sdk", + agent: snapshot, + project: null, + error: null, + }, + }), + ); + expect(await read).toEqual({ agent: snapshot, project: null }); + } finally { + await client.close(); + } +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 455d377634..1437068f95 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -369,6 +369,11 @@ export interface PaseoAgentHandle { readonly timeline: PaseoAgentTimelineHandle; current(): PaseoAgent | null; refresh(requestId?: string): Promise; + /** + * Reopens the provider session with current configuration, rerunning session-open hooks. + * Interrupts a running turn and returns the refreshed snapshot. + */ + reload(options?: { requestId?: string }): Promise; send(text: string, options?: PaseoAgentSendOptions): Promise; respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise; /** Sends a prompt and resolves when that turn finishes or needs attention. */ @@ -981,6 +986,12 @@ function createAgentHandleFactory( current = result?.agent ?? null; return result; }, + reload: async (options) => { + await daemonClient.refreshAgent(id, options?.requestId); + const result = await daemonClient.fetchAgent({ agentId: id }); + current = result?.agent ?? null; + return result; + }, send: async (text, options) => { await daemonClient.sendAgentMessage(id, text, options); }, diff --git a/packages/desktop/package.json b/packages/desktop/package.json index b4af89d742..4735fb5192 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.13.1", + "version": "0.13.2", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index c60fbe6eb3..1a38528c25 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.13.1", + "version": "0.13.2", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 5d9aed78cf..d7708e5925 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.13.1", + "version": "0.13.2", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/highlight/src/__tests__/highlighter.test.ts b/packages/highlight/src/__tests__/highlighter.test.ts index fde3f9d740..095f9689a8 100644 --- a/packages/highlight/src/__tests__/highlighter.test.ts +++ b/packages/highlight/src/__tests__/highlighter.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { setImmediate } from "node:timers/promises"; +import { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "../budgeted-highlighter.js"; import type { HighlightToken } from "../types.js"; import { highlightCode, highlightCodeInSteps, highlightLine } from "../highlighter.js"; @@ -420,3 +422,55 @@ describe("highlightLine", () => { expect(tokens).toEqual([{ text: "hello", style: null }]); }); }); + +describe("highlightCodeWithBudget", () => { + it("yields to IO while producing byte-identical output below the source budget", async () => { + const code = "const value = Math.max(42, 7);\n".repeat(3_000); + const expected = JSON.stringify(highlightCode(code, "file.ts")); + let ticks = 0; + let longestGap = 0; + let lastTick = performance.now(); + const timer = setInterval(() => { + const now = performance.now(); + longestGap = Math.max(longestGap, now - lastTick); + lastTick = now; + ticks++; + }, 1); + try { + const actual = await highlightCodeWithBudget(code, "file.ts", { + yieldToEventLoop: setImmediate, + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(ticks).toBeGreaterThan(1); + expect(longestGap).toBeLessThan(50); + expect(JSON.stringify(actual)).toBe(expected); + } finally { + clearInterval(timer); + } + }); + + it("budgets UTF-8 bytes before parsing, including multibyte text", async () => { + expect( + await highlightCodeWithBudget("é".repeat(HIGHLIGHT_MAX_SOURCE_BYTES / 2 + 1), "file.ts", { + yieldToEventLoop: setImmediate, + }), + ).toBeNull(); + expect( + await highlightCodeWithBudget("a".repeat(HIGHLIGHT_MAX_SOURCE_BYTES + 1), "file.ts", { + yieldToEventLoop: setImmediate, + }), + ).toBeNull(); + }); + + it("stops a parser that is cancelled between real event-loop turns", async () => { + const controller = new AbortController(); + const pending = highlightCodeWithBudget( + "const value = Math.max(42, 7);\n".repeat(3_000), + "file.ts", + { yieldToEventLoop: setImmediate, signal: controller.signal }, + ); + await setImmediate(); + controller.abort(); + expect(await pending).toBeNull(); + }); +}); diff --git a/packages/highlight/src/budgeted-highlighter.ts b/packages/highlight/src/budgeted-highlighter.ts new file mode 100644 index 0000000000..e949a619ff --- /dev/null +++ b/packages/highlight/src/budgeted-highlighter.ts @@ -0,0 +1,49 @@ +import { highlightCodeInSteps } from "./highlighter.js"; +import type { HighlightToken } from "./types.js"; + +// Full source, rather than patch size, bounds parser and token allocation work. +// Each token consumes a source code unit or an empty line: at most the byte cap + 1 records. +export const HIGHLIGHT_MAX_SOURCE_BYTES = 768 * 1024; +const SLICE_MS = 4; + +/** + * Returns null when source exceeds the budget or the request is cancelled. + * The host supplies a real event-loop yield (setImmediate on the daemon). + * Existing synchronous highlighting remains available for its existing consumers. + */ +export async function highlightCodeWithBudget( + code: string, + filename: string, + options: { yieldToEventLoop: () => Promise; signal?: { readonly aborted: boolean } }, +): Promise { + if (options.signal?.aborted || exceedsSourceBudget(code)) return null; + const steps = highlightCodeInSteps(code, filename); + let sliceStarted = Date.now(); + try { + for (;;) { + if (options.signal?.aborted) return null; + const step = steps.next(); + if (step.done) return step.value; + if (Date.now() - sliceStarted >= SLICE_MS) { + await options.yieldToEventLoop(); + sliceStarted = Date.now(); + } + } + } finally { + steps.return([]); + } +} + +function exceedsSourceBudget(code: string): boolean { + if (code.length > HIGHLIGHT_MAX_SOURCE_BYTES) return true; + let bytes = 0; + for (const char of code) { + const point = char.codePointAt(0)!; + if (point < 0x80) bytes++; + else if (point < 0x800) bytes += 2; + else if (point < 0x10000) bytes += 3; + else bytes += 4; + if (bytes > HIGHLIGHT_MAX_SOURCE_BYTES) return true; + } + return false; +} diff --git a/packages/highlight/src/index.ts b/packages/highlight/src/index.ts index a69820ee28..da468e2f87 100644 --- a/packages/highlight/src/index.ts +++ b/packages/highlight/src/index.ts @@ -15,3 +15,4 @@ export { isSyntaxThemeId, resolveSyntaxColors, } from "./themes.js"; +export { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "./budgeted-highlighter.js"; diff --git a/packages/plugin/package.json b/packages/plugin/package.json index b40268e537..18421b41e1 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.13.1", + "version": "0.13.2", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -72,8 +72,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.13.1", - "@getpaseo/protocol": "0.13.1", + "@getpaseo/client": "0.13.2", + "@getpaseo/protocol": "0.13.2", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -83,8 +83,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.13.1", - "@getpaseo/protocol": "0.13.1", + "@getpaseo/client": "0.13.2", + "@getpaseo/protocol": "0.13.2", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index c9b54ecdb7..017b69bda5 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -92,7 +92,12 @@ function typeErrors(code: string): string[] { } describe("plugin SDK import boundaries", () => { it("publishes process capabilities without their Windows implementation helpers", () => { - expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]); + expect(Object.keys(server).sort()).toEqual([ + "defineAgentTool", + "execCommand", + "spawnProcess", + "terminateProcess", + ]); }); it("classifies every published entry", () => { @@ -209,3 +214,54 @@ describe("plugin panel SDK boundary", () => { ).toEqual([]); }); }); + +describe("plugin agent tools SDK boundary", () => { + it("lets a session hook attach typed tools without exposing their transport", () => { + expect( + typeErrors(` + import { z } from "zod"; + import { defineAgentTool, type PluginServerContext, type AgentToolResult, type AgentToolContext, type AgentToolDefinition } from "@getpaseo/plugin/server"; + const tool: AgentToolDefinition = defineAgentTool({ + name: "create_issue", + description: "Create an issue", + input: z.object({ title: z.string(), count: z.string().transform(Number) }), + async execute(input, { agent, paseo, signal }) { + const count: number = input.count; + // @ts-expect-error input is inferred from the schema + input.missing; + return { text: agent.id + input.title, structuredContent: { count } }; + }, + }); + export default function contribute(server: PluginServerContext) { + server.before("agent.session_open", ({ request }) => { + const title: string | null = request.title; + // @ts-expect-error labels are read-only + request.labels.x = "changed"; + if (request.labels.x !== "y") return; + return { ...request, tools: [...request.tools, tool] }; + }); + return () => {}; + } + `), + ).toEqual([]); + }); +}); + +describe("agent session reload SDK boundary", () => { + it("exposes reload separately from snapshot refresh", () => { + expect( + typeErrors(` + import { type PluginServerContext } from "@getpaseo/plugin/server"; + import { type PaseoAgentRefetchResult } from "@getpaseo/client"; + async function reload(server: PluginServerContext, paseo: import("@getpaseo/client").PaseoApi) { + const agent = paseo.agents.ref("chosen-agent"); + const read: PaseoAgentRefetchResult | null = await agent.refresh("read-snapshot"); + const reopened: PaseoAgentRefetchResult | null = await agent.reload({ requestId: "reload-session" }); + await agent.reload(); + // @ts-expect-error reload options can grow without a positional signature change + await agent.reload("reload-session"); + } + `), + ).toEqual([]); + }); +}); diff --git a/packages/plugin/src/server/agent-tools.ts b/packages/plugin/src/server/agent-tools.ts new file mode 100644 index 0000000000..202040cefb --- /dev/null +++ b/packages/plugin/src/server/agent-tools.ts @@ -0,0 +1,39 @@ +import type { PaseoApi } from "@getpaseo/client"; +import type { z } from "zod"; +import type { PluginHookAgent } from "./lifecycle.js"; + +export interface AgentToolContext { + agent: PluginHookAgent; + paseo: PaseoApi; + signal: AbortSignal; +} + +export interface AgentToolResult { + text?: string; + structuredContent?: unknown; + isError?: boolean; +} + +export interface AgentToolDefinition { + name: string; + title?: string; + description: string; + input: z.ZodType; + execute(input: unknown, context: AgentToolContext): AgentToolResult | Promise; +} + +export function defineAgentTool(tool: { + name: string; + title?: string; + description: string; + input: Input; + execute( + input: z.output, + context: AgentToolContext, + ): AgentToolResult | Promise; +}): AgentToolDefinition { + if (!/^[a-z][a-z0-9_]*$/.test(tool.name) || tool.name.length > 64) { + throw new Error(`Invalid agent tool name: ${tool.name}`); + } + return tool; +} diff --git a/packages/plugin/src/server/index.ts b/packages/plugin/src/server/index.ts index 9180023d19..264effafdf 100644 --- a/packages/plugin/src/server/index.ts +++ b/packages/plugin/src/server/index.ts @@ -24,3 +24,6 @@ export type { } from "./lifecycle.js"; export { spawnProcess, execCommand, terminateProcess } from "./process.js"; + +export { defineAgentTool } from "./agent-tools.js"; +export type { AgentToolDefinition, AgentToolResult, AgentToolContext } from "./agent-tools.js"; diff --git a/packages/plugin/src/server/lifecycle.ts b/packages/plugin/src/server/lifecycle.ts index 350039face..f3f2643a04 100644 --- a/packages/plugin/src/server/lifecycle.ts +++ b/packages/plugin/src/server/lifecycle.ts @@ -1,3 +1,4 @@ +import type { AgentToolDefinition } from "./agent-tools.js"; import type { AgentPermissionRequest, AgentPermissionResponse, @@ -36,6 +37,9 @@ export interface PluginSessionOpenRequest { cwd: string; reason: "create" | "resume" | "refresh" | "import"; purpose: "interactive" | "history"; + readonly title: string | null; + readonly labels: Readonly>; + tools: AgentToolDefinition[]; env: Record; } diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 99da1fc5bd..e463e681e7 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.13.1", + "version": "0.13.2", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index 82822177c1..b2e0c5187f 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.13.1", + "version": "0.13.2", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 5b8017659e..f39d284343 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.13.1", + "version": "0.13.2", "description": "Paseo backend server", "files": [ "dist/server", @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", @@ -107,11 +107,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.13.1", - "@getpaseo/highlight": "0.13.1", - "@getpaseo/plugin": "0.13.1", - "@getpaseo/protocol": "0.13.1", - "@getpaseo/relay": "0.13.1", + "@getpaseo/client": "0.13.2", + "@getpaseo/highlight": "0.13.2", + "@getpaseo/plugin": "0.13.2", + "@getpaseo/protocol": "0.13.2", + "@getpaseo/relay": "0.13.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index de823660da..1342576f7f 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1,3 +1,5 @@ +import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js"; +import type { AgentToolResult } from "@getpaseo/plugin/server"; import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js"; import { ensureAgentLoaded } from "./agent-loading.js"; import { projectTimelineRows } from "./timeline-projection.js"; @@ -333,6 +335,7 @@ export interface AgentManagerOptions { terminalManager?: TerminalManager | null; mcpBaseUrl?: string; mcpAuthToken?: string; + pluginAgentTools?: PluginAgentToolsRuntime; paseoToolsEnabled?: boolean; paseoToolCatalogFactory?: PaseoToolCatalogFactory; resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined; @@ -766,6 +769,10 @@ export class AgentManager { private readonly agentStreamCoalescer: AgentStreamCoalescer; private mcpBaseUrl: string | null; private readonly mcpAuthToken: string | null; + private sessionOpenGeneration = 0; + private readonly sessionOpenGenerations = new Map(); + private readonly attachedPluginTools = new Map(); + private readonly pluginAgentTools: PluginAgentToolsRuntime | undefined; private paseoToolsEnabled = true; private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null; private readonly paseoToolPolicies = new Map(); @@ -783,6 +790,7 @@ export class AgentManager { constructor(options: AgentManagerOptions) { this.pluginLifecycle = options.pluginLifecycle; + this.pluginAgentTools = options.pluginAgentTools; this.timelineStore = options.timelineStore ?? new InMemoryAgentTimelineStore(); this.idFactory = options.idFactory ?? (() => randomUUID()); this.registry = options?.registry; @@ -887,6 +895,62 @@ export class AgentManager { this.paseoToolCatalogFactory = factory; } + invalidatePluginSessions(): void { + this.sessionOpenGeneration++; + } + + async reloadStalePluginSession(agentId: string): Promise { + if (!this.needsPluginSessionReload(agentId)) return; + await this.trackAgentRegistrationOperation( + this.runLifecycleMutation(agentId, async () => { + if (!this.needsPluginSessionReload(agentId)) return; + await this.reloadAgentSessionInternal(agentId); + }), + ); + } + + private needsPluginSessionReload(agentId: string): boolean { + const agent = this.getAgent(agentId); + return ( + agent !== null && + agent.lifecycle !== "closed" && + this.sessionOpenGenerations.get(agentId) !== this.sessionOpenGeneration && + !this.hasInFlightRun(agentId) + ); + } + + getPluginTools(agentId: string): readonly AttachedAgentTool[] { + return (this.attachedPluginTools.get(agentId) ?? []).filter((tool) => + this.pluginAgentTools?.isToolPluginAvailable(tool.pluginId, tool.pluginInstanceId), + ); + } + + async executePluginTool( + tool: AttachedAgentTool, + agentId: string, + input: unknown, + signal?: AbortSignal, + ): Promise { + const agent = this.getAgent(agentId); + if (!agent || agent.lifecycle === "closed") throw new Error(`Agent is not live: ${agentId}`); + if (!this.pluginAgentTools) throw new Error("Plugin tools are unavailable"); + const record = await this.registry?.get(agentId); + return this.pluginAgentTools.callTool( + tool.pluginId, + tool.pluginInstanceId, + describeHookAgent({ ...agent, title: record?.title ?? agent.config.title }), + tool.name, + input, + signal, + ); + } + + private detachPluginTools(agentId: string): void { + this.attachedPluginTools.delete(agentId); + this.sessionOpenGenerations.delete(agentId); + this.pluginAgentTools?.detachTools(agentId); + } + getPaseoToolPolicy(agentId: string): ProviderPaseoToolsPolicy | undefined { return this.paseoToolPolicies.get(agentId); } @@ -1312,7 +1376,13 @@ export class AgentManager { storedConfig.cwd, paseoToolPolicy, options?.env, - { reason: "create", purpose: "interactive", workspaceId: options.workspaceId ?? null }, + { + reason: "create", + purpose: "interactive", + workspaceId: options.workspaceId ?? null, + title: options.initialTitle ?? storedConfig.title ?? null, + labels: options.labels ?? {}, + }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const createOptions = this.buildCreateSessionOptions(options); @@ -1434,6 +1504,8 @@ export class AgentManager { reason: "resume", purpose, workspaceId: options?.workspaceId ?? null, + title: record?.title ?? mergedConfig.title ?? null, + labels: record?.labels ?? options?.labels ?? {}, }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); @@ -1491,7 +1563,12 @@ export class AgentManager { storedConfig.cwd, paseoToolPolicy, undefined, - { reason: "import", purpose: "interactive", workspaceId: input.workspaceId }, + { + reason: "import", + purpose: "interactive", + workspaceId: input.workspaceId, + labels: input.labels ?? {}, + }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const imported = await client.importSession( @@ -1586,7 +1663,13 @@ export class AgentManager { storedConfig.cwd, paseoToolPolicy, undefined, - { reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId }, + { + reason: "refresh", + purpose: "interactive", + workspaceId: existing.workspaceId, + title: await this.resolveInitialPersistedTitle(agentId, existing.config, null), + labels: existing.labels, + }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); if ( @@ -1650,6 +1733,7 @@ export class AgentManager { throw error; } finally { if (!handedToRegistration) { + this.detachPluginTools(agentId); if (hadPreviousPaseoToolPolicy) { this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy); } else { @@ -1857,6 +1941,7 @@ export class AgentManager { } catch (error) { persistError = error; } + this.detachPluginTools(agentId); this.emitClosedAgent(closedAgent, { persist: false }); this.logger.trace( { @@ -3940,6 +4025,7 @@ export class AgentManager { private discardRetainedAgentState(agentId: string): void { this.timelineStore.delete(agentId); this.paseoToolPolicies.delete(agentId); + this.detachPluginTools(agentId); for (const event of this.providerSubagents.deleteParent(agentId)) { this.dispatch({ type: "provider_subagent", event }); } @@ -5426,10 +5512,15 @@ export class AgentManager { reason: PluginSessionOpenRequest["reason"]; purpose: PluginSessionOpenRequest["purpose"]; workspaceId?: string | null; + title?: string | null; + labels?: Record; }, ): Promise { + // A plugin changing during this open must still make the resulting session stale. + const generation = this.sessionOpenGeneration; + this.detachPluginTools(agentId); if (this.pluginLifecycle) { - const request: PluginSessionOpenRequest = { + const request = { agentId, provider: client.provider, cwd, @@ -5437,9 +5528,14 @@ export class AgentManager { reason: opening?.reason ?? "resume", purpose: opening?.purpose ?? "interactive", env: { ...env }, + title: opening?.title ?? null, + labels: opening?.labels ?? {}, + tools: [], }; const transformed = await this.pluginLifecycle.before("agent.session_open", request); env = transformed.env; + if (request.purpose === "interactive") + this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]); } const context: AgentLaunchContext = { agentId, @@ -5449,17 +5545,26 @@ export class AgentManager { PASEO_AGENT_CWD: cwd, }, }; - if ( + const useNativeTools = this.paseoToolsEnabled && isPaseoToolPolicyEnabled(paseoToolPolicy) && - client.capabilities.supportsNativePaseoTools && - this.paseoToolCatalogFactory + client.capabilities.supportsNativePaseoTools; + if ( + this.paseoToolCatalogFactory && + (useNativeTools || this.getPluginTools(agentId).length > 0) ) { - context.paseoTools = await this.paseoToolCatalogFactory({ - callerAgentId: agentId, - paseoToolPolicy, - }); + try { + const catalog = await this.paseoToolCatalogFactory({ + callerAgentId: agentId, + paseoToolPolicy, + }); + if (useNativeTools) context.paseoTools = catalog; + } catch (error) { + this.detachPluginTools(agentId); + throw error; + } } + this.sessionOpenGenerations.set(agentId, generation); return context; } diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 08aca8e626..dde5c02bbd 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -15,6 +15,7 @@ export type AgentRunController = Pick< | "getAgent" | "tryRunOutOfBand" | "hasInFlightRun" + | "reloadStalePluginSession" | "replaceAgentRun" | "steerOrReplaceActiveTurn" | "streamAgent" @@ -115,6 +116,7 @@ export async function startAgentRun( return { disposition: "out_of_band" }; } try { + await agentManager.reloadStalePluginSession(agentId); return await startAgentRunInner(agentManager, agentId, prompt, logger, options); } catch (error) { if (!isStaleProviderSessionError(error)) throw error; diff --git a/packages/server/src/server/agent/agent-timeline-store.test.ts b/packages/server/src/server/agent/agent-timeline-store.test.ts index 895aec2684..eb46916fe3 100644 --- a/packages/server/src/server/agent/agent-timeline-store.test.ts +++ b/packages/server/src/server/agent/agent-timeline-store.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js"; +import type { AgentTimelineItem } from "./agent-sdk-types.js"; import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; describe("InMemoryAgentTimelineStore", () => { @@ -177,7 +179,10 @@ describe("projected sequence ownership", () => { store.append("a", { type: "assistant_message", text: "Answer" }); store.append("a", { ...tool, status: "completed" }); store.initialize("b", { rows: store.getRows("a") }); + expect(store.getLastItem("b")).toMatchObject({ type: "tool_call", status: "completed" }); + expect(store.getLastAssistantMessage("b")).toBe("Answer"); expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4); + expect(store.getLastItem("b")).toEqual({ type: "user_message", text: "next" }); expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]); }); it("returns the last projected assistant message without joining different messages", () => { @@ -211,3 +216,151 @@ it("includes transitive tool updates in a contiguous projected tail", () => { expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]); expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false }); }); + +it("serves tail 200 from 100,000 retained rows without stalling the loop", async () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("large"); + for (let index = 0; index < 100_000; index++) { + store.append("large", { type: "user_message", text: String(index) }); + } + // Generation and its GC are outside the measured request window. + await new Promise((resolve) => setImmediate(resolve)); + const started = performance.now(); + const probe = new Promise((resolve) => + setImmediate(() => resolve(performance.now() - started)), + ); + let page = store.fetch("large", { limit: 200 }); + for (let repeat = 1; repeat < 8; repeat++) page = store.fetch("large", { limit: 200 }); + const duration = performance.now() - started; + const loopGap = await probe; + console.info({ duration, loopGap }); + expect(page.rows).toHaveLength(200); + expect(page.startSeq).toBe(99_801); + expect(page.endSeq).toBe(100_000); + // CI runners can pause for scheduling/GC; the local per-request 2 ms gate + // is measured separately. Eight requests amplify the old full-history work. + expect(duration).toBeLessThan(50); + expect(loopGap).toBeLessThan(50); +}); + +it("matches the previous reprojecting selector on seeded randomized timelines", () => { + let seed = 0x76aabbcc; + const random = (bound: number) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return Math.floor((seed / 0x1_0000_0000) * bound); + }; + for (let scenario = 0; scenario < 100; scenario++) { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a", { epoch: "e" }); + for (let seq = 1; seq <= 100; seq++) { + const identity = String(random(8)); + const text = String(seq); + const items: AgentTimelineItem[] = [ + { type: "user_message", text }, + { type: "assistant_message", text }, + { type: "assistant_message", text, messageId: identity }, + { type: "reasoning", text }, + { + type: "tool_call", + callId: identity, + name: "shell", + status: "running", + error: null, + detail: { type: "plain_text", label: text }, + }, + { + type: "plugin", + id: identity, + pluginId: "test", + kind: "state", + version: 1, + data: { text }, + }, + ]; + store.append("a", items[random(items.length)], { turnId: String(random(3)) }); + } + // Include rehydration of already-projected rows, with non-monotonic seqEnd. + store.initialize("b", { rows: store.getRows("a"), epoch: "e" }); + for (const agentId of ["a", "b"]) { + assertSelectionMatchesReference(store, agentId); + store.append(agentId, { type: "assistant_message", text: "later" }); + assertSelectionMatchesReference(store, agentId); + } + } +}); + +function assertSelectionMatchesReference(store: InMemoryAgentTimelineStore, agentId: string): void { + for (const direction of ["tail", "before", "after"] as const) { + for (const limit of [0, 1, 7, 200]) { + for (const cursorSeq of [0, 1, 25, 50, 99, 100, 150]) { + const actual = store.fetch(agentId, { + direction, + limit, + cursor: { epoch: "e", seq: cursorSeq }, + }); + const expected = selectReferenceProjectedTimelinePage({ + rows: store.getRows(agentId), + bounds: actual.window, + direction, + limit, + cursorSeq, + }); + expect({ + ...expected, + entries: undefined, + rows: expected.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)), + }).toEqual({ + entries: undefined, + rows: actual.rows, + startSeq: actual.startSeq, + endSeq: actual.endSeq, + hasOlder: actual.hasOlder, + hasNewer: actual.hasNewer, + }); + } + } + } +} + +it("continues a scroll across appends and lifecycle collapse without losing source coverage", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a", { epoch: "e" }); + const tool: AgentTimelineItem = { + type: "tool_call", + callId: "t", + name: "shell", + status: "running", + error: null, + detail: { type: "plain_text", label: "work" }, + }; + store.append("a", tool); + for (let seq = 2; seq <= 5; seq++) store.append("a", { type: "user_message", text: String(seq) }); + const first = store.fetch("a", { direction: "after", cursor: { epoch: "e", seq: 0 }, limit: 2 }); + expect(first.endSeq).toBe(2); + store.append("a", { ...tool, status: "completed" }); + store.append("a", { type: "assistant_message", text: "Done" }); + expect(first.rows[0].item).toMatchObject({ status: "running" }); + const second = store.fetch("a", { + direction: "after", + cursor: { epoch: "e", seq: first.endSeq! }, + limit: 2, + }); + expect(second.rows.map((row) => row.seqStart)).toEqual([3, 4]); + expect(second.endSeq).toBe(4); + const third = store.fetch("a", { + direction: "after", + cursor: { epoch: "e", seq: second.endSeq! }, + limit: 2, + }); + expect(third.rows.map((row) => row.seqStart)).toEqual([1, 5]); + expect(third.rows[0].item).toMatchObject({ status: "completed" }); + expect(third.endSeq).toBe(6); + const last = store.fetch("a", { + direction: "after", + cursor: { epoch: "e", seq: third.endSeq! }, + limit: 2, + }); + expect(last.rows.map((row) => row.item)).toEqual([{ type: "assistant_message", text: "Done" }]); + expect(last.endSeq).toBe(7); + expect(last.hasNewer).toBe(false); +}); diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index 6a37e11ef0..b9727e33e9 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -1,9 +1,5 @@ import { randomUUID } from "node:crypto"; -import { - TimelineProjection, - selectProjectedTimelinePage, - type ProjectedTimelineRow, -} from "./timeline-projection.js"; +import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { AgentTimelineFetchOptions, @@ -43,8 +39,7 @@ export class InMemoryAgentTimelineStore { ? options.rows.map(cloneRow) : this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp); const nextSeq = rows.reduce((next, row) => Math.max(next, row.seq + 1), options?.nextSeq ?? 1); - const projection = new TimelineProjection(); - for (const row of rows) projection.append(row); + const projection = new TimelineProjection(rows); this.states.set(agentId, { epoch: options?.epoch ?? randomUUID(), projection, @@ -97,18 +92,17 @@ export class InMemoryAgentTimelineStore { const state = this.requireState(agentId); const direction = options?.direction ?? "tail"; const cursor = options?.cursor; - const rows = state.projection.getRows(); + const hasRows = state.minSeq !== 0; const window = { minSeq: state.minSeq, maxSeq: state.nextSeq - 1, nextSeq: state.nextSeq }; const staleCursor = cursor !== undefined && cursor.epoch !== state.epoch; const gap = !staleCursor && direction === "after" && cursor !== undefined && - rows.length > 0 && + hasRows && cursor.seq < state.minSeq - 1; const reset = staleCursor || gap; - const page = selectProjectedTimelinePage({ - rows, + const page = state.projection.selectPage({ bounds: window, direction: reset ? "tail" : direction, cursorSeq: cursor?.seq, @@ -150,14 +144,11 @@ export class InMemoryAgentTimelineStore { getLastItem(agentId: string): AgentTimelineItem | null { const state = this.requireState(agentId); - return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null; + return state.projection.getLastItem(state.nextSeq - 1); } getLastAssistantMessage(agentId: string): string | null { - const row = this.requireState(agentId) - .projection.getRows() - .findLast((candidate) => candidate.item.type === "assistant_message"); - return row?.item.type === "assistant_message" ? row.item.text : null; + return this.requireState(agentId).projection.getLastAssistantMessage(); } private requireState(agentId: string): AgentTimelineState { diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index 5d2c9fddc1..daa5ba9ec1 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -69,6 +69,7 @@ test("session create forwards clientMessageId to the initial prompt run options" getAgent: vi.fn(() => snapshot), tryRunOutOfBand: vi.fn(() => false), hasInFlightRun: vi.fn(() => false), + reloadStalePluginSession: async () => {}, streamAgent, waitForAgentRunStart: vi.fn(async () => undefined), } as unknown as Parameters[0]["agentManager"], diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index b8a0d820ec..f2c7da8574 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -270,6 +270,8 @@ function buildAgentManagerSpies() { hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined), appendTimelineItem: vi.fn().mockResolvedValue(undefined), emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined), + getPluginTools: () => [], + reloadStalePluginSession: async () => {}, hasInFlightRun: vi.fn().mockReturnValue(false), steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }), tryRunOutOfBand: vi.fn().mockReturnValue(false), @@ -617,6 +619,10 @@ class FakeBrowserToolsBroker { } class BoundaryAgentManagerFake { + public getPluginTools(): [] { + return []; + } + private readonly agent = createManagedAgent({ id: "agent-1", cwd: REPO_CWD, diff --git a/packages/server/src/server/agent/mutable-provider-config-owner.test.ts b/packages/server/src/server/agent/mutable-provider-config-owner.test.ts index 53270c1a78..0307eabda6 100644 --- a/packages/server/src/server/agent/mutable-provider-config-owner.test.ts +++ b/packages/server/src/server/agent/mutable-provider-config-owner.test.ts @@ -99,16 +99,17 @@ afterEach(() => { }); describe("mutable provider config owner", () => { - test("publishes provider changes after commit and emits nothing on rollback", () => { + test("publishes provider changes after commit and emits nothing on rollback", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, mutableConfig({ version: 1 })); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), - providerOverrides: { codex: { enabled: true } }, + providerOverrides: CONTROLLED_PROVIDERS, + extraClients: { codex: controlledCodexClient(async () => catalog("initial")) }, }); const cwd = path.resolve("/tmp/provider-config-owner"); - manager.getSnapshot(cwd); + await manager.refreshSnapshotForCwd({ cwd }); const events: string[] = []; manager.on("change", ({ current }) => events.push(current.cwd)); let agentManagerState = manager.getAgentManagerProviderState(); @@ -125,7 +126,7 @@ describe("mutable provider config owner", () => { }); try { - store.patch({ providers: { codex: { enabled: false } } }); + await store.patch({ providers: { codex: { enabled: false } } }); expect(events).toEqual([cwd]); expect(agentManagerState.providerDefinitions.codex).toMatchObject({ enabled: false }); @@ -133,7 +134,7 @@ describe("mutable provider config owner", () => { const unsubscribeFailure = store.onApply(() => { throw new Error("later owner failed"); }); - expect(() => store.patch({ providers: { codex: { enabled: true } } })).toThrow( + await expect(store.patch({ providers: { codex: { enabled: true } } })).rejects.toThrow( "later owner failed", ); unsubscribeFailure(); @@ -150,7 +151,7 @@ describe("mutable provider config owner", () => { } }); - test("does no provider work for unrelated CORS, Git, and app URL reloads", () => { + test("does no provider work for unrelated CORS, Git, and app URL reloads", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-")); tempDirs.push(paseoHome); const initial: PersistedConfig = { @@ -174,9 +175,10 @@ describe("mutable provider config owner", () => { }); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), - providerOverrides: { codex: { enabled: true } }, + providerOverrides: CONTROLLED_PROVIDERS, + extraClients: { codex: controlledCodexClient(async () => catalog("initial")) }, }); - manager.getSnapshot("/tmp/provider-config-owner"); + await manager.refreshSnapshotForCwd({ cwd: "/tmp/provider-config-owner" }); const events: string[] = []; manager.on("change", ({ current }) => events.push(current.cwd)); let registryUpdates = 0; @@ -200,7 +202,7 @@ describe("mutable provider config owner", () => { }; writeFileSync(configPath, `${JSON.stringify(reloaded, null, 2)}\n`, "utf-8"); - expect(store.reload().appliedPaths).toEqual([ + expect((await store.reload()).appliedPaths).toEqual([ "app.baseUrl", "daemon.cors.allowedOrigins", "daemon.git.maxProcessConcurrency", @@ -245,7 +247,7 @@ describe("mutable provider config owner", () => { await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1)); expect(events).toEqual([]); - store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } }); + await store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } }); expect(events).toEqual(["loading:none"]); await vi.waitFor(() => expect(catalogResolvers).toHaveLength(2)); @@ -311,9 +313,9 @@ describe("mutable provider config owner", () => { expect(events).toEqual([]); const before = manager.getSnapshot(cwd); - expect(() => + await expect( store.patch({ providers: { codex: { enabled: true, label: "Rejected Codex" } } }), - ).toThrow("later owner failed"); + ).rejects.toThrow("later owner failed"); expect(events).toEqual([]); expect(observedDuringApply).toEqual([{ label: "Codex", snapshot: before }]); expect(observedDuringApply[0]!.snapshot).toBe(before); diff --git a/packages/server/src/server/agent/permission-response.test.ts b/packages/server/src/server/agent/permission-response.test.ts index 5ac7374e88..710599c588 100644 --- a/packages/server/src/server/agent/permission-response.test.ts +++ b/packages/server/src/server/agent/permission-response.test.ts @@ -40,6 +40,8 @@ class FakePermissionAgentManager { return undefined; } + async reloadStalePluginSession(): Promise {} + hasInFlightRun(): boolean { return this.hasRunInFlight; } diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 3131447098..72bc0f475b 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -310,7 +310,7 @@ describe("PluginAgentClientRegistry", () => { }); const own = { nested: { replace: "agent" } }; const agent = await manager.createAgent( - { provider: "plugin-direct", cwd: "/tmp", providerOptions: own }, + { provider: "plugin-direct", cwd: tmpdir(), providerOptions: own }, undefined, { workspaceId: undefined }, ); diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 3deaa60c11..e902493dcb 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -811,6 +811,46 @@ describe("ACPAgentSession terminal tools", () => { truncated: false, }); }); + + test("trims large terminal output without rescanning the retained suffix", async () => { + const session = createSession(); + const terminal = await session.createTerminal({ + sessionId: "session-1", + command: process.execPath, + args: ["-e", 'process.stdout.write("x".repeat(1024 * 1024))'], + cwd: process.cwd(), + outputByteLimit: 1024, + }); + await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId }); + + await expect( + session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }), + ).resolves.toEqual({ + output: "x".repeat(1024), + truncated: true, + exitStatus: { exitCode: 0, signal: null }, + }); + }, 1000); + + test("trims terminal output at a UTF-8 character boundary", async () => { + const session = createSession(); + const terminal = await session.createTerminal({ + sessionId: "session-1", + command: process.execPath, + args: ["-e", 'process.stdout.write("😀ab")'], + cwd: process.cwd(), + outputByteLimit: 3, + }); + await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId }); + + await expect( + session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }), + ).resolves.toEqual({ + output: "ab", + truncated: true, + exitStatus: { exitCode: 0, signal: null }, + }); + }); }); describe("mapACPUsage", () => { diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index ae765ecd2e..e26b6e4ade 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -4093,10 +4093,16 @@ function appendTerminalOutput(entry: TerminalEntry, chunk: string): void { if (!limit) { return; } - while (Buffer.byteLength(entry.output, "utf8") > limit && entry.output.length > 0) { - entry.output = entry.output.slice(1); - entry.truncated = true; + const output = Buffer.from(entry.output, "utf8"); + if (output.length <= limit) { + return; } + let start = output.length - limit; + while (start < output.length && (output[start] & 0xc0) === 0x80) { + start += 1; + } + entry.output = output.subarray(start).toString("utf8"); + entry.truncated = true; } function readRecord(value: unknown): Record | null { diff --git a/packages/server/src/server/agent/providers/claude/agent.env.test.ts b/packages/server/src/server/agent/providers/claude/agent.env.test.ts index 0d2748fd62..0806a0dbc6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.env.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.env.test.ts @@ -1,10 +1,11 @@ +import { once } from "node:events"; import type { Query } from "@anthropic-ai/claude-agent-sdk"; import { describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import type { AgentLaunchContext } from "../../agent-sdk-types.js"; import { ClaudeAgentClient } from "./agent.js"; -import type { ClaudeQueryInput } from "./query.js"; +import { claudeQuery, type ClaudeQueryInput } from "./query.js"; function createQueryMock(events: unknown[]): Query { let index = 0; @@ -29,6 +30,48 @@ function createQueryMock(events: unknown[]): Query { } describe("Claude SDK env", () => { + test.each(["node", "bun", process.execPath])( + "identifies the spawned %s process as Paseo after clearing parent session env", + async (command) => { + let output = ""; + let exited = Promise.resolve([]); + const query = claudeQuery( + { prompt: "", options: {} }, + { + runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } }, + launchEnv: { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" }, + queryFactory: ({ options }) => { + if (!options.spawnClaudeCodeProcess) { + throw new Error("Missing Claude process launcher"); + } + const child = options.spawnClaudeCodeProcess({ + command, + args: [ + "-e", + "process.stdout.write(JSON.stringify({ entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT, parentSession: process.env.CLAUDECODE ?? null }))", + ], + cwd: process.cwd(), + env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: "sdk-ts", CLAUDECODE: "1" }, + signal: new AbortController().signal, + }); + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + exited = once(child, "close"); + return createQueryMock([]); + }, + }, + ); + + try { + expect(await exited).toEqual([0, null]); + expect(JSON.parse(output)).toEqual({ entrypoint: "Paseo", parentSession: null }); + } finally { + query.close(); + } + }, + ); + test("forwards launch-context env through Claude process env", async () => { let capturedEnv: Record | undefined; let capturedPerTaskStopAffordance: boolean | undefined; diff --git a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts index b355f367b3..921b4018be 100644 --- a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts @@ -1,9 +1,13 @@ import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { open } from "node:fs/promises"; +import { performance } from "node:perf_hooks"; import * as os from "node:os"; import * as path from "node:path"; import pino from "pino"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js"; +import type { ClaudeRewindSdk } from "./rewind.js"; import { ClaudeAgentClient } from "./agent.js"; import { claudeProjectDirSync } from "./project-dir.js"; import { withClaudeReplayHistory } from "./transcript-history.js"; @@ -32,7 +36,10 @@ describe("ClaudeAgentSession persisted history load", () => { let configDir: string; let records: LogRecord[]; - async function resume(queryFactory?: ClaudeQueryFactory): Promise { + async function resume( + queryFactory?: ClaudeQueryFactory, + rewindSdk?: ClaudeRewindSdk, + ): Promise { const client = new ClaudeAgentClient({ logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }), queryFactory: @@ -42,10 +49,36 @@ describe("ClaudeAgentSession persisted history load", () => { }), resolveBinary: async () => process.execPath, resolveVersion: async () => "2.1.220", + rewindSdk, }); return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd }); } + function replyFactory(text: string, messageId = "live-reply"): ClaudeQueryFactory { + return (() => { + const messages = [ + { type: "assistant", uuid: messageId, message: { content: text } }, + { type: "result", subtype: "success", total_cost_usd: 0 }, + ]; + return { + async next() { + const value = messages.shift(); + return value ? { done: false, value } : { done: true, value: undefined }; + }, + close() {}, + async return() { + return { done: true, value: undefined }; + }, + async supportedCommands() { + return []; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + }) as ClaudeQueryFactory; + } + function transcriptPath(): string { return path.join(claudeProjectDirSync(cwd, { configDir }), `${SESSION_ID}.jsonl`); } @@ -65,6 +98,288 @@ describe("ClaudeAgentSession persisted history load", () => { rmSync(tempRoot, { recursive: true, force: true }); }); + test("serves unrelated health requests while resuming 32 MiB of ordered history", async () => { + const startedAt = performance.now(); + mkdirSync(path.dirname(transcriptPath()), { recursive: true }); + const file = await open(transcriptPath(), "w"); + const text = "x".repeat(16 * 1024); + let bytes = 0; + let count = 0; + try { + while (bytes < 32 * 1024 * 1024) { + const line = + JSON.stringify({ type: "assistant", message: { content: `${count}:${text}` } }) + "\n"; + bytes += Buffer.byteLength(line); + await file.write(line); + count++; + } + } finally { + await file.close(); + } + const daemon = await createTestPaseoDaemon(); + let lastTick = performance.now(); + let longestGap = 0; + const probe = setInterval(() => { + const now = performance.now(); + longestGap = Math.max(longestGap, now - lastTick); + lastTick = now; + }, 2); + let loading = true; + let answeredDuringLoad = false; + const health = fetch(`http://127.0.0.1:${daemon.port}/api/health`).then(async (response) => { + expect(response.status).toBe(200); + await response.text(); + answeredDuringLoad = loading; + return undefined; + }); + let session: AgentSession | undefined; + try { + session = await resume(replyFactory("live after large resume")); + let index = 0; + for await (const event of session.streamHistory()) { + expect(event).toEqual({ + type: "timeline", + provider: "claude", + timestamp: undefined, + item: { type: "assistant_message", text: `${index}:${text}` }, + }); + index++; + } + loading = false; + await health; + await new Promise((resolve) => setTimeout(resolve, 5)); + console.info( + JSON.stringify({ + bytes, + count, + longestGap, + answeredDuringLoad, + elapsedMs: performance.now() - startedAt, + }), + ); + expect(index).toBe(count); + expect(longestGap).toBeLessThan(50); + expect(answeredDuringLoad).toBe(true); + const live = await collectSessionTurnEvents(session, "follow up"); + expect( + live + .filter((event) => event.type === "timeline" && event.item.type === "assistant_message") + .map((event) => (event.type === "timeline" ? event.item : null)), + ).toEqual([ + { type: "assistant_message", messageId: "live-reply", text: "live after large resume" }, + ]); + } finally { + clearInterval(probe); + await session?.close(); + await daemon.close(); + } + }, 60_000); + + test("rebinds ordered history before returning from rewind and accepts a live turn", async () => { + const entries = [ + { type: "user", uuid: "u1", message: { content: "first" } }, + { type: "assistant", uuid: "a1", message: { content: "first reply" } }, + { type: "user", uuid: "u2", message: { content: "second" } }, + { type: "assistant", uuid: "a2", message: { content: "second reply" } }, + ]; + mkdirSync(path.dirname(transcriptPath()), { recursive: true }); + writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n")); + const forkId = "rebound-history"; + const forkFile = path.join(path.dirname(transcriptPath()), `${forkId}.jsonl`); + writeFileSync( + forkFile, + entries + .slice(0, 2) + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + const forks: unknown[] = []; + const late = { + type: "assistant", + uuid: "retired-reply", + message: { content: "retired query" }, + }; + let pendingRead: ((value: unknown) => void) | undefined; + let releaseRetirement!: () => void; + const retirementGate = new Promise((resolve) => { + releaseRetirement = resolve; + }); + let retirementStarted!: () => void; + const retiring = new Promise((resolve) => { + retirementStarted = resolve; + }); + let firstQuery = true; + let retired = false; + const queryFactory = (() => { + if (!firstQuery) return replyFactory("live after rebind")({} as never); + firstQuery = false; + const messages = [ + { type: "assistant", uuid: "before-rebind", message: { content: "before rebind" } }, + { type: "result", subtype: "success", total_cost_usd: 0 }, + ]; + return { + async next() { + const value = messages.shift(); + if (value) return { done: false, value }; + if (retired) return { done: true, value: undefined }; + return new Promise((resolve) => { + pendingRead = resolve; + }); + }, + close() { + retired = true; + pendingRead?.({ done: false, value: late }); + }, + async return() { + retirementStarted(); + await retirementGate; + return { done: true, value: undefined }; + }, + async supportedCommands() { + return []; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + }) as ClaudeQueryFactory; + const session = await resume(queryFactory, { + async forkSession(sessionId, options) { + forks.push({ sessionId, ...options }); + return { sessionId: forkId }; + }, + }); + try { + const original = await Array.fromAsync(session.streamHistory()); + expect(original).toHaveLength(4); + await collectSessionTurnEvents(session, "before rewind"); + const emitted: AgentStreamEvent[] = []; + const subscription = session.subscribe((event) => emitted.push(event)); + let rewindDone = false; + const rewind = (async () => { + await session.revertConversation!({ messageId: "u2" }); + rewindDone = true; + })(); + await retiring; + let historyDone = false; + const reading = (async () => { + const history = await Array.fromAsync(session.streamHistory()); + historyDone = true; + return history; + })(); + const readingAgain = Array.fromAsync(session.streamHistory()); + await Promise.resolve(); + expect(rewindDone).toBe(false); + expect(historyDone).toBe(false); + releaseRetirement(); + await rewind; + subscription(); + expect(retired).toBe(true); + expect( + emitted.filter( + (event) => event.type === "timeline" && event.item.type === "assistant_message", + ), + ).toEqual([ + { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: `Claude switched to a new session: ${SESSION_ID} -> ${forkId}`, + }, + }, + ]); + expect(forks).toEqual([ + { sessionId: SESSION_ID, upToMessageId: "a1", transcriptPath: transcriptPath() }, + ]); + expect(session.id).toBe(forkId); + const [history, secondRead] = await Promise.all([reading, readingAgain]); + expect(history).toEqual(original.slice(0, 2)); + expect(secondRead).toEqual([]); + const live = await collectSessionTurnEvents(session, "continue the fork"); + expect( + live + .filter((event) => event.type === "timeline" && event.item.type === "assistant_message") + .map((event) => (event.type === "timeline" ? event.item : null)), + ).toEqual([ + { type: "assistant_message", messageId: "live-reply", text: "live after rebind" }, + ]); + } finally { + releaseRetirement(); + await session.close(); + } + }); + + test("reports a failed rewind without poisoning readiness when query retirement throws", async () => { + mkdirSync(path.dirname(transcriptPath()), { recursive: true }); + writeFileSync( + transcriptPath(), + [ + { type: "user", uuid: "u1", message: { content: "first" } }, + { type: "assistant", uuid: "a1", message: { content: "reply" } }, + { type: "user", uuid: "u2", message: { content: "second" } }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + let firstQuery = true; + let finishRead: ((value: unknown) => void) | undefined; + const queryFactory = ((input) => { + const query = replyFactory( + firstQuery ? "before retirement" : "still available", + firstQuery ? "before-retirement" : "live-reply", + )(input); + if (!firstQuery) return query; + firstQuery = false; + let reads = 0; + return { + ...query, + async next() { + if (++reads <= 2) return query.next(); + return new Promise((resolve) => { + finishRead = resolve; + }); + }, + close() { + finishRead?.({ done: true, value: undefined }); + throw new Error("retirement failed"); + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + }) as ClaudeQueryFactory; + const session = await resume(queryFactory, { + async forkSession() { + return { sessionId: "failed-rebind" }; + }, + }); + try { + await Array.fromAsync(session.streamHistory()); + await collectSessionTurnEvents(session, "before rewind"); + await expect(session.revertConversation!({ messageId: "u2" })).rejects.toThrow( + "retirement failed", + ); + expect(records).toContainEqual( + expect.objectContaining({ + level: pino.levels.values.warn, + msg: "Failed to rebind Claude session", + sessionId: "failed-rebind", + err: expect.objectContaining({ message: "retirement failed" }), + }), + ); + expect(session.id).toBe(SESSION_ID); + const live = await collectSessionTurnEvents(session, "after failed rebind"); + expect( + live + .filter((event) => event.type === "timeline" && event.item.type === "assistant_message") + .map((event) => (event.type === "timeline" ? event.item : null)), + ).toEqual([{ type: "assistant_message", messageId: "live-reply", text: "still available" }]); + } finally { + await session.close(); + } + }); + test.each(["file_path", "files"])( "replays %s without probing targets and preserves live existence checks", async (field) => { @@ -300,21 +615,21 @@ describe("ClaudeAgentSession persisted history load", () => { ); }); - test("loads readable history when the temporary directory is unavailable", () => { + test("loads readable history when the temporary directory is unavailable", async () => { const file = transcriptPath(); const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } }; mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(entry)); const unavailable = path.join(tempRoot, "unavailable-tmp"); for (const variable of ["TMPDIR", "TEMP", "TMP"]) vi.stubEnv(variable, unavailable); - withClaudeReplayHistory(file, (history) => { - expect([...history.parentEntries]).toEqual([entry]); + await withClaudeReplayHistory(file, async (history) => { + expect(await Array.fromAsync(history.parentEntries)).toEqual([entry]); }); }); test.each(["parent", "child", "workflow"])( "preserves indexed %s history when its transcript disappears between passes", - (target) => { + async (target) => { const parent = transcriptPath(); const sessionDirectory = parent.slice(0, -".jsonl".length); const child = path.join(sessionDirectory, "subagents", "agent-child.jsonl"); @@ -334,14 +649,16 @@ describe("ClaudeAgentSession persisted history load", () => { writeFileSync(parent, JSON.stringify(parentEntry)); writeFileSync(child, JSON.stringify(childEntry)); writeFileSync(workflow, JSON.stringify(childEntry)); - withClaudeReplayHistory(parent, (history) => { + await withClaudeReplayHistory(parent, async (history) => { rmSync({ parent, child, workflow }[target]); // Ownership facts and visible payloads are separate passes over the same source. for (let pass = 0; pass < 2; pass++) { - expect([...history.parentEntries]).toEqual([parentEntry]); + expect(await Array.fromAsync(history.parentEntries)).toEqual([parentEntry]); expect(history.subagents).toHaveLength(1); - expect([...history.subagents[0]!.entries]).toEqual([childEntry]); - expect([...history.workflowEntriesByRunId.get("wf_test")!]).toEqual([childEntry]); + expect(await Array.fromAsync(history.subagents[0]!.entries)).toEqual([childEntry]); + expect(await Array.fromAsync(history.workflowEntriesByRunId.get("wf_test")!)).toEqual([ + childEntry, + ]); } }); }, diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 4d44c86a52..55fb128578 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1,3 +1,4 @@ +import { setImmediate } from "node:timers/promises"; import { ClaudeRuntimeSafety } from "./runtime-safety.js"; import { validateProviderOptions } from "../../provider-options.js"; import type { ChildProcess } from "node:child_process"; @@ -82,7 +83,7 @@ import { type ClaudeRewindSdk, } from "./rewind.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; -import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js"; +import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPath } from "./project-dir.js"; import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js"; import { isProviderImageMarkdown, @@ -1634,7 +1635,7 @@ export class ClaudeAgentClient implements AgentClient { cwd: merged.cwd, }; const claudeConfig = this.assertConfig(mergedConfig); - return new ClaudeAgentSession(claudeConfig, { + const session = new ClaudeAgentSession(claudeConfig, { defaults: this.defaults, runtimeSettings: this.runtimeSettings, handle, @@ -1645,6 +1646,8 @@ export class ClaudeAgentClient implements AgentClient { resolveBinary: this.resolveBinary, rewindSdk: this.rewindSdk, }); + await session.ensureHistoryReady(); + return session; } async getCatalogCacheKey(_options: FetchCatalogOptions): Promise { @@ -2329,6 +2332,9 @@ class ClaudeAgentSession implements AgentSession { { type: "provider_subagent" } >[] = []; private historyPending = false; + // Readiness never rejects; history/rebind failures are logged at their owning boundary. + private historyReady: Promise | null = null; + private historySessionId: string | null = null; private replayingHistory = false; private turnState: TurnState = "idle"; private nextTurnOrdinal = 1; @@ -2388,7 +2394,7 @@ class ClaudeAgentSession implements AgentSession { } this.claudeSessionId = handle.sessionId; this.persistence = handle; - this.loadPersistedHistory(handle.sessionId); + this.historySessionId = handle.sessionId; } else { this.claudeSessionId = null; this.persistence = null; @@ -2468,6 +2474,7 @@ class ClaudeAgentSession implements AgentSession { prompt: AgentPromptInput, options?: AgentRunOptions, ): Promise<{ turnId: string }> { + await this.ensureHistoryReady(); if (this.closed) { throw new Error("Claude session is closed"); } @@ -2639,6 +2646,7 @@ class ClaudeAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { + await this.ensureHistoryReady(); if ( !this.historyPending || (this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0) @@ -2650,7 +2658,9 @@ class ClaudeAgentSession implements AgentSession { this.persistedHistory = []; this.persistedProviderSubagentEvents = []; this.historyPending = false; + let replayed = 0; for (const entry of history) { + if (++replayed % 32 === 0) await setImmediate(); yield { type: "timeline", item: entry.item, @@ -2968,6 +2978,7 @@ class ClaudeAgentSession implements AgentSession { "provider.claude.session_close.start", ); this.closed = true; + await this.ensureHistoryReady(); this.rejectAllPendingPermissions(new Error("Claude session closed")); this.cancelCurrentTurn?.(); this.subscribers.clear(); @@ -3008,7 +3019,7 @@ class ClaudeAgentSession implements AgentSession { // (see `claude --help`), so the SDK's persistSession=false is silently dropped // in stream-json mode. Sweep the transcript ourselves so ephemeral runs // (metadata generator, branch-name generator) don't show up as resumable. - const historyPath = this.resolveHistoryPath(this.claudeSessionId); + const historyPath = await this.resolveHistoryPath(this.claudeSessionId); if (historyPath) { try { await promises.rm(historyPath, { force: true }); @@ -3052,6 +3063,7 @@ class ClaudeAgentSession implements AgentSession { } async revertConversation(input: { messageId: string }): Promise { + await this.ensureHistoryReady(); const target = this.resolveConversationRewindTarget(input.messageId); if (target.kind === "fresh-session") { this.startFreshConversationSession(); @@ -3061,16 +3073,15 @@ class ClaudeAgentSession implements AgentSession { await revertClaudeConversation({ sdk: this.rewindSdk, sessionId: currentSessionId, - transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null, + transcriptPath: currentSessionId ? await this.resolveHistoryPath(currentSessionId) : null, messageId: target.messageId, resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId), - setSessionId: (sessionId) => { - this.rebindConversationSession(sessionId); - }, + setSessionId: (sessionId) => this.rebindConversationSession(sessionId), }); } async revertFiles(input: { messageId: string }): Promise { + await this.ensureHistoryReady(); const messageId = await this.resolveClaudeMessageId(input.messageId); await revertClaudeFiles({ query: await this.ensureQuery(), @@ -3245,38 +3256,52 @@ class ClaudeAgentSession implements AgentSession { return candidates; } - private rebindConversationSession(sessionId: string): void { - const oldSessionId = this.claudeSessionId; - this.claudeSessionId = sessionId; - this.pendingFreshSessionId = null; - this.persistence = null; - this.cachedRuntimeInfo = null; - this.queryRestartNeeded = true; - this.persistedHistory = []; - this.persistedProviderSubagentEvents = []; - this.historyPending = false; - this.userMessageIds = []; - this.emittedUserMessageIds.clear(); - this.rewindTurnAnchors.length = 0; - this.taskState.reset(); - this.loadPersistedHistory(sessionId); - if (oldSessionId && oldSessionId !== sessionId) { - this.dispatchEvents([ - { - type: "timeline", - provider: "claude", - item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId), - }, - { - type: "thread_started", - provider: "claude", - sessionId, - }, - ]); - } + private rebindConversationSession(sessionId: string): Promise { + const previousReady = this.ensureHistoryReady(); + const rebind = (async () => { + await previousReady; + await this.retireQuery(); + if (this.closed) return; + const oldSessionId = this.claudeSessionId; + this.claudeSessionId = sessionId; + this.pendingFreshSessionId = null; + this.persistence = null; + this.cachedRuntimeInfo = null; + this.queryRestartNeeded = true; + this.persistedHistory = []; + this.persistedProviderSubagentEvents = []; + this.historyPending = false; + this.userMessageIds = []; + this.emittedUserMessageIds.clear(); + this.rewindTurnAnchors.length = 0; + this.taskState.reset(); + this.historySessionId = sessionId; + await this.loadPersistedHistory(sessionId); + if (this.closed) return; + if (oldSessionId && oldSessionId !== sessionId) { + this.dispatchEvents([ + { + type: "timeline", + provider: "claude", + item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId), + }, + { + type: "thread_started", + provider: "claude", + sessionId, + }, + ]); + } + })(); + this.historyReady = rebind.catch((error) => { + this.logger.warn({ err: error, sessionId }, "Failed to rebind Claude session"); + }); + return rebind; } private startFreshConversationSession(): void { + this.historySessionId = null; + this.historyReady = Promise.resolve(); const sessionId = randomUUID(); this.claudeSessionId = sessionId; this.pendingFreshSessionId = sessionId; @@ -3397,7 +3422,43 @@ class ClaudeAgentSession implements AgentSession { return { kind: "fresh-session" }; } + private async retireQuery(): Promise { + const oldQuery = this.query; + if (!oldQuery) return; + const oldInput = this.input; + // Null out query/input BEFORE awaiting the old iterator's return so the + // old pump sees this.query !== activeQuery and skips failActiveTurns. + this.query = null; + this.input = null; + this.queryPumpPromise = null; + this.queryRestartNeeded = false; + // Ending the input retires the process on purpose. Detach first so its + // exit is not reported as a crash. + const retiredChild = this.childProcess; + this.childProcess = null; + if (retiredChild) this.failRunningRuntimeTasks(); + oldInput?.end(); + oldQuery.close?.(); + try { + await oldQuery.return?.(); + } catch { + /* ignore */ + } + // Tree-kill the old process tree now that the SDK has cleaned up. + // If we skip this, MCP children of the previous claude process can + // survive as orphans when the session spawns a replacement query. + if (retiredChild) { + await terminateWithTreeKill(retiredChild, { + gracefulTimeoutMs: 2_000, + forceTimeoutMs: 2_000, + }).catch(() => { + /* process may already be dead */ + }); + } + } + private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise { + await this.ensureHistoryReady(); // Controls use the running turn's query. Pending launch settings take effect // after that turn finishes, when its active query handle has been cleared. if (this.query && (!this.queryRestartNeeded || this.activeForegroundQuery === this.query)) { @@ -3405,37 +3466,7 @@ class ClaudeAgentSession implements AgentSession { } if (this.queryRestartNeeded && this.query) { - const oldQuery = this.query; - const oldInput = this.input; - // Null out query/input BEFORE awaiting the old iterator's return so the - // old pump sees this.query !== activeQuery and skips failActiveTurns. - this.query = null; - this.input = null; - this.queryPumpPromise = null; - this.queryRestartNeeded = false; - // Ending the input retires the process on purpose. Detach first so its - // exit is not reported as a crash. - const retiredChild = this.childProcess; - this.childProcess = null; - if (retiredChild) this.failRunningRuntimeTasks(); - oldInput?.end(); - oldQuery.close?.(); - try { - await oldQuery.return?.(); - } catch { - /* ignore */ - } - // Tree-kill the old process tree now that the SDK has cleaned up. - // If we skip this, MCP children of the previous claude process can - // survive as orphans when the session spawns a replacement query. - if (retiredChild) { - await terminateWithTreeKill(retiredChild, { - gracefulTimeoutMs: 2_000, - forceTimeoutMs: 2_000, - }).catch(() => { - /* process may already be dead */ - }); - } + await this.retireQuery(); } // Preserve claudeSessionId across query recreation so buildOptions() passes @@ -4102,13 +4133,15 @@ class ClaudeAgentSession implements AgentSession { ); }; const handlePumpedMessage = async (message: SDKMessage): Promise => { + if (!this.ownsQuery(activeQuery)) return true; this.processingSdkMessages++; try { this.runtimeSafety.observe(message); logRawMessage(message); consecutiveInterruptAbortRecoveries = 0; if (await this.handleMissingResumedConversation(message, activeQuery)) return true; - await this.routeSdkMessageFromPump(message); + if (!this.ownsQuery(activeQuery)) return true; + this.routeSdkMessageFromPump(message); return false; } finally { this.processingSdkMessages--; @@ -4228,7 +4261,11 @@ class ClaudeAgentSession implements AgentSession { return isClaudeTurnInit(message) && !this.activeForegroundTurnId; } - private async routeSdkMessageFromPump(message: SDKMessage): Promise { + private ownsQuery(query: Query): boolean { + return !this.closed && this.query === query; + } + + private routeSdkMessageFromPump(message: SDKMessage): void { if (this.consumeSessionStateChange(message)) { return; } @@ -4262,7 +4299,7 @@ class ClaudeAgentSession implements AgentSession { "provider.claude.parsed_event", ); - const events = await this.buildPumpedMessageEvents(message, identifiers.messageId, turnId); + const events = this.buildPumpedMessageEvents(message, identifiers.messageId, turnId); if (events.length === 0) { return; @@ -4297,11 +4334,11 @@ class ClaudeAgentSession implements AgentSession { this.dispatchEvents(events); } - private async buildPumpedMessageEvents( + private buildPumpedMessageEvents( message: SDKMessage, messageIdHint: string | null, turnId: string | null, - ): Promise { + ): AgentStreamEvent[] { const messageEvents = this.translateMessageToEvents(message, { suppressAssistantText: true, suppressReasoning: true, @@ -5323,22 +5360,31 @@ class ClaudeAgentSession implements AgentSession { } } - private loadPersistedHistory(sessionId: string): void { + ensureHistoryReady(): Promise { + if (!this.historyReady) { + this.historyReady = this.historySessionId + ? this.loadPersistedHistory(this.historySessionId) + : Promise.resolve(); + } + return this.historyReady; + } + + private async loadPersistedHistory(sessionId: string): Promise { let historyPath: string | null = null; this.replayingHistory = true; try { this.taskState.reset(); - historyPath = this.resolveHistoryPath(sessionId); - if (!historyPath || !fs.existsSync(historyPath)) { + historyPath = await this.resolveHistoryPath(sessionId); + if (!historyPath || !(await transcriptExists(historyPath))) { this.logger.info( { sessionId, cwd: this.config.cwd, historyPath }, "No Claude transcript to load history from", ); return; } - withClaudeReplayHistory(historyPath, (history) => { - const replay = this.ingestPersistedSidechains(history); - this.ingestPersistedHistory(history.parentEntries, replay); + await withClaudeReplayHistory(historyPath, async (history) => { + const replay = await this.ingestPersistedSidechains(history); + await this.ingestPersistedHistory(history.parentEntries, replay); }); } catch (error) { this.logger.warn( @@ -5350,12 +5396,12 @@ class ClaudeAgentSession implements AgentSession { } } - private ingestPersistedHistory( - entries: Iterable, + private async ingestPersistedHistory( + entries: Iterable | AsyncIterable, replay: ClaudeReplayOwnership, - ): void { + ): Promise { const timeline: PersistedTimelineEntry[] = []; - for (const entry of entries) { + for await (const entry of entries) { this.ingestPersistedHistoryEntry(entry, timeline, replay); } @@ -5365,27 +5411,33 @@ class ClaudeAgentSession implements AgentSession { } } - private ingestPersistedSidechains(history: ClaudeReplayHistory): ClaudeReplayOwnership { - const parentFacts = readClaudeReplayParentFacts(history.parentEntries); + private async ingestPersistedSidechains( + history: ClaudeReplayHistory, + ): Promise { + const parentFacts = await readClaudeReplayParentFacts(history.parentEntries); // Replay produces the same observations the live task protocol produces, then folds them // with the same function, so identity and status are derived once for both paths. - const subagentReplay = observeReplaySubagents({ - subagents: history.subagents.map((subagent) => ({ + const subagents = []; + for (const subagent of history.subagents) { + subagents.push({ ...subagent, - parentFacts: readClaudeReplayParentFacts(subagent.entries), - })), + parentFacts: await readClaudeReplayParentFacts(subagent.entries), + }); + } + const subagentReplay = await observeReplaySubagents({ + subagents, parent: parentFacts, convertEntry: (entry) => this.convertHistoryEntry(entry), }); const observations = [ ...subagentReplay.observations, - ...observeReplayWorkflows({ + ...(await observeReplayWorkflows({ workflows: history.workflows, parentEntries: history.parentEntries, entriesByRunId: history.workflowEntriesByRunId, convertEntry: (entry) => this.convertHistoryEntry(entry), - }), + })), ]; const restoredProviderSubagentIds = new Set( observations @@ -5472,10 +5524,10 @@ class ClaudeAgentSession implements AgentSession { } } - private resolveHistoryPath(sessionId: string): string | null { + private async resolveHistoryPath(sessionId: string): Promise { const cwd = this.config.cwd; if (!cwd) return null; - return claudeTranscriptPathSync({ + return claudeTranscriptPath({ cwd, sessionId, configDir: claudeConfigDir(this.harnessEnvironment), @@ -6242,13 +6294,13 @@ function labelReplayedSubagentCard( * source consumes. The agentId scrape is kept only as a fallback for sessions recorded before * Claude Code wrote the meta sidecar. */ -function readClaudeReplayParentFacts( - entries: Iterable, -): ClaudeReplayParentFacts { +async function readClaudeReplayParentFacts( + entries: Iterable | AsyncIterable, +): Promise { const toolCalls = new Map(); const linksByAgentId = new Map(); const outcomes = new Map(); - for (const entry of entries) { + for await (const entry of entries) { const content = toObjectRecord(entry.message)?.content; if (!Array.isArray(content)) continue; for (const value of content) { @@ -6780,7 +6832,7 @@ async function readClaudeRenamedSessionTitle(input: { }): Promise { let content: string; try { - content = await fsPromises.readFile(claudeTranscriptPathSync(input), "utf8"); + content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8"); } catch (error) { // A session with no transcript on disk has no rename to carry over. Every // other read failure is a real fault and stays explicit. @@ -6880,3 +6932,13 @@ function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle | state: record.state as ClaudeCommandLifecycle["state"], }; } + +async function transcriptExists(file: string): Promise { + try { + await fs.promises.access(file); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} diff --git a/packages/server/src/server/agent/providers/claude/project-dir.ts b/packages/server/src/server/agent/providers/claude/project-dir.ts index 030a54c813..c9fa690101 100644 --- a/packages/server/src/server/agent/providers/claude/project-dir.ts +++ b/packages/server/src/server/agent/providers/claude/project-dir.ts @@ -1,5 +1,5 @@ -import { existsSync, readdirSync, realpathSync } from "node:fs"; -import { realpath } from "node:fs/promises"; +import { realpathSync } from "node:fs"; +import { realpath, access, readdir } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -35,21 +35,21 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti // one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the // cwd under another name (a Windows binary under WSL). Returns the expected path when no // transcript exists anywhere. -export function claudeTranscriptPathSync(input: { +export async function claudeTranscriptPath(input: { cwd: string; sessionId: string; configDir?: string; -}): string { +}): Promise { const options = { configDir: input.configDir }; const fileName = `${input.sessionId}.jsonl`; - const expected = join(claudeProjectDirSync(input.cwd, options), fileName); - if (existsSync(expected)) { + const expected = join(await claudeProjectDir(input.cwd, options), fileName); + if (await exists(expected)) { return expected; } const projectsRoot = join(resolveConfigDir(options), "projects"); let projectDirs: string[]; try { - projectDirs = readdirSync(projectsRoot); + projectDirs = await readdir(projectsRoot); } catch (error) { if (isMissingDirectoryError(error)) { return expected; @@ -58,13 +58,23 @@ export function claudeTranscriptPathSync(input: { } for (const projectDir of projectDirs) { const candidate = join(projectsRoot, projectDir, fileName); - if (existsSync(candidate)) { + if (await exists(candidate)) { return candidate; } } return expected; } +async function exists(file: string): Promise { + try { + await access(file); + return true; + } catch (error) { + if (isMissingDirectoryError(error)) return false; + throw error; + } +} + function isMissingDirectoryError(error: unknown): boolean { const code = (error as NodeJS.ErrnoException | null)?.code; return code === "ENOENT" || code === "ENOTDIR"; diff --git a/packages/server/src/server/agent/providers/claude/query.ts b/packages/server/src/server/agent/providers/claude/query.ts index ccd511816a..c8fe4ba036 100644 --- a/packages/server/src/server/agent/providers/claude/query.ts +++ b/packages/server/src/server/agent/providers/claude/query.ts @@ -2,11 +2,10 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:chi import { query, type Options, type Query, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk"; import { - createProviderEnv, createProviderEnvSpec, type ProviderRuntimeSettings, } from "../../provider-launch-config.js"; -import { buildSelfNodeCommand } from "../../../paseo-env.js"; +import { buildSelfNodeCommand, createExternalProcessEnv } from "../../../paseo-env.js"; import { spawnProcess } from "../../../../utils/spawn.js"; // Keep the raw SDK query import in this module only. Claude process launch behavior @@ -74,11 +73,9 @@ function applyRuntimeSettingsToClaudeOptions( runtimeSettings, overlays: [launchEnv], }); - const providerEnv = createProviderEnv({ - baseEnv: spawnOptions.env, - runtimeSettings, - overlays: [launchEnv], - }); + // Set our identity after sanitization clears the parent session's entrypoint. + providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = "Paseo"; + const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay); const selfNodeCommand = isDefaultRuntime ? buildSelfNodeCommand(resolved.args, providerEnv) : null; diff --git a/packages/server/src/server/agent/providers/claude/rewind.ts b/packages/server/src/server/agent/providers/claude/rewind.ts index 3b228aacc6..91183687e0 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.ts @@ -76,7 +76,7 @@ export async function revertClaudeConversation(input: { transcriptPath: string | null; messageId: string; resolveMessageId?: (messageId: string) => string | Promise; - setSessionId: (sessionId: string) => void; + setSessionId: (sessionId: string) => void | Promise; }): Promise { if (!input.sessionId || !input.transcriptPath) { throw new Error("Claude session is not ready for rewind"); @@ -86,7 +86,7 @@ export async function revertClaudeConversation(input: { upToMessageId: messageId, transcriptPath: input.transcriptPath, }); - input.setSessionId(fork.sessionId); + await input.setSessionId(fork.sessionId); } export async function revertClaudeFiles(input: { @@ -108,7 +108,7 @@ export async function revertClaudeConversationAndFiles(input: { transcriptPath: string | null; messageId: string; resolveMessageId?: (messageId: string) => string | Promise; - setSessionId: (sessionId: string) => void; + setSessionId: (sessionId: string) => void | Promise; }): Promise { await revertClaudeFiles({ query: input.query, diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts index e28d15b8c1..413f39d427 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts @@ -45,7 +45,7 @@ function applyToStore(observations: SubagentObservation[]): ProviderSubagentDesc } describe("parseClaudeSubagentMeta", () => { - it("reads the real sidecar shape", () => { + it("reads the real sidecar shape", async () => { expect( parseClaudeSubagentMeta( '{"agentType":"general-purpose","description":"Reply banana","toolUseId":"toolu_013i","spawnDepth":1}', @@ -68,7 +68,7 @@ describe("parseClaudeSubagentMeta", () => { expect(parseClaudeSubagentMeta(contents)).toBeNull(); }); - it("keeps the good fields when one field has the wrong type", () => { + it("keeps the good fields when one field has the wrong type", async () => { expect(parseClaudeSubagentMeta('{"agentType":"Explore","toolUseId":42}')).toEqual({ agentType: "Explore", description: undefined, @@ -79,12 +79,14 @@ describe("parseClaudeSubagentMeta", () => { }); describe("observeReplaySubagents", () => { - it("links a subagent to its Task call through meta.toolUseId", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + it("links a subagent to its Task call through meta.toolUseId", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations[0]).toMatchObject({ kind: "declared", @@ -95,31 +97,33 @@ describe("observeReplaySubagents", () => { }); }); - it("replays descendants beneath the sidechain that declared their Task", () => { + it("replays descendants beneath the sidechain that declared their Task", async () => { const nestedToolUseId = "toolu_012rzYnFZA"; - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, - entries: [], - parentFacts: { - toolCalls: new Map([ - [nestedToolUseId, { title: "Explore", description: "Nested audit" }], - ]), - linksByAgentId: new Map(), - outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]), + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, + entries: [], + parentFacts: { + toolCalls: new Map([ + [nestedToolUseId, { title: "Explore", description: "Nested audit" }], + ]), + linksByAgentId: new Map(), + outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]), + }, }, - }, - { - agentId: "a6acb4b898", - meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 }, - entries: [], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + { + agentId: "a6acb4b898", + meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 }, + entries: [], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations).toContainEqual( expect.objectContaining({ @@ -130,89 +134,143 @@ describe("observeReplaySubagents", () => { ); }); - it("keeps a subagent recorded before spawnDepth existed", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + it("keeps a subagent recorded before spawnDepth existed", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID }); }); - it("drops a meta-linked subagent when the parent never declared its Task", () => { - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" }, - entries: [], - }, - ], - parent: emptyParent(), - convertEntry: () => [], - }).observations; + it("drops a meta-linked subagent when the parent never declared its Task", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" }, + entries: [], + }, + ], + parent: emptyParent(), + convertEntry: () => [], + }) + ).observations; expect(observations).toEqual([]); }); - it("falls back to the scraped link when there is no meta file", () => { + it("falls back to the scraped link when there is no meta file", async () => { const parent: ClaudeReplayParentFacts = { toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]), linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]), outcomesByToolCallId: new Map(), }; - const [declared, ...rest] = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], - parent, - convertEntry: () => [], - }).observations; + const [declared, ...rest] = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], + parent, + convertEntry: () => [], + }) + ).observations; expect(declared).toMatchObject({ id: TOOL_USE_ID, toolCallId: TOOL_USE_ID, title: "Explore" }); expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" }); }); - it("drops an unlinkable subagent", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], - parent: emptyParent(), - convertEntry: () => [], - }).observations; + it("drops an unlinkable subagent", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], + parent: emptyParent(), + convertEntry: () => [], + }) + ).observations; expect(observations).toEqual([]); }); - it("drops a terminal subagent the parent has no Task call for", () => { + it("drops a terminal subagent the parent has no Task call for", async () => { // A legacy grandchild: its toolUseId names a Task call made inside a sibling's transcript, so // no tool_result for it can ever reach the parent. Left running, it never stops. const descriptor = applyToStore( - observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [ - { - type: "assistant", - timestamp: "2026-07-31T03:41:00.000Z", - message: { stop_reason: "end_turn" }, - }, - ], - }, - ], - parent: emptyParent(), - convertEntry: () => [], - }).observations, + ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-31T03:41:00.000Z", + message: { stop_reason: "end_turn" }, + }, + ], + }, + ], + parent: emptyParent(), + convertEntry: () => [], + }) + ).observations, ); expect(descriptor).toBeNull(); }); - it("drops a terminal skill-spawned subagent that has no link at all", () => { + it("drops a terminal skill-spawned subagent that has no link at all", async () => { // A /code-review run: no Task tool_use exists to name it, so its sidecar carries only // agentType and its id appears nowhere in the parent transcript. const descriptor = applyToStore( - observeReplaySubagents({ + ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { agentType: "general-purpose" }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-31T03:41:00.000Z", + message: { stop_reason: "end_turn" }, + }, + ], + }, + ], + parent: emptyParent(), + convertEntry: () => [], + }) + ).observations, + ); + + expect(descriptor).toBeNull(); + }); + + it("reports a scraped link's failure as failed", async () => { + const descriptor = applyToStore( + ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], + parent: { + toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]), + linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]), + outcomesByToolCallId: new Map(), + }, + convertEntry: () => [], + }) + ).observations, + ); + + expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" }); + }); + + it("drops a non-terminal linkless subagent", async () => { + const observations = ( + await observeReplaySubagents({ subagents: [ { agentId: AGENT_ID, @@ -221,111 +279,81 @@ describe("observeReplaySubagents", () => { { type: "assistant", timestamp: "2026-07-31T03:41:00.000Z", - message: { stop_reason: "end_turn" }, + message: { stop_reason: null }, }, ], }, ], parent: emptyParent(), convertEntry: () => [], - }).observations, - ); - - expect(descriptor).toBeNull(); - }); - - it("reports a scraped link's failure as failed", () => { - const descriptor = applyToStore( - observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], - parent: { - toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]), - linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]), - outcomesByToolCallId: new Map(), - }, - convertEntry: () => [], - }).observations, - ); - - expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" }); - }); - - it("drops a non-terminal linkless subagent", () => { - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { agentType: "general-purpose" }, - entries: [ - { - type: "assistant", - timestamp: "2026-07-31T03:41:00.000Z", - message: { stop_reason: null }, - }, - ], - }, - ], - parent: emptyParent(), - convertEntry: () => [], - }).observations; + }) + ).observations; expect(observations).toEqual([]); }); - it("finishes a terminal Task child when the parent has not recorded its outcome", () => { + it("finishes a terminal Task child when the parent has not recorded its outcome", async () => { const descriptor = applyToStore( - observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [ - { - type: "assistant", - timestamp: "2026-07-31T03:41:00.000Z", - message: { stop_reason: "end_turn" }, - }, - ], - }, - ], - parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, - convertEntry: () => [], - }).observations, + ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-31T03:41:00.000Z", + message: { stop_reason: "end_turn" }, + }, + ], + }, + ], + parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, + convertEntry: () => [], + }) + ).observations, ); expect(descriptor).toMatchObject({ status: "completed" }); }); - it("leaves a subagent with no recorded outcome running", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], - parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, - convertEntry: () => [], - }).observations; + it("leaves a subagent with no recorded outcome running", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, + convertEntry: () => [], + }) + ).observations; expect(observations.some((o) => o.kind === "status")).toBe(false); }); - it("reports a failed outcome as failed", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], - parent: parentWithTaskCall(true), - convertEntry: () => [], - }).observations; + it("reports a failed outcome as failed", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(true), + convertEntry: () => [], + }) + ).observations; expect(observations.at(-1)).toMatchObject({ kind: "status", status: "failed" }); }); - it("carries entry timestamps onto timeline observations", () => { - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [{ type: "reasoning", text: "thinking" }], - }).observations; + it("carries entry timestamps onto timeline observations", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [{ type: "reasoning", text: "thinking" }], + }) + ).observations; expect(observations.find((observation) => observation.kind === "timeline")).toMatchObject({ kind: "timeline", @@ -336,18 +364,20 @@ describe("observeReplaySubagents", () => { }); describe("replay runtime", () => { - it("reads the model and effort off the subagent's own transcript", () => { - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + it("reads the model and effort off the subagent's own transcript", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations.find((o) => o.kind === "subtitle")).toEqual({ kind: "subtitle", @@ -356,49 +386,55 @@ describe("replay runtime", () => { }); }); - it("takes the last observed values when the model changes mid-transcript", () => { - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [ - { type: "assistant", effort: "high", message: { model: "claude-opus-5" } }, - { type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } }, - ], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + it("takes the last observed values when the model changes mid-transcript", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { type: "assistant", effort: "high", message: { model: "claude-opus-5" } }, + { type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } }, + ], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({ subtitle: "general-purpose · Sonnet 5 · Low", }); }); - it("reports nothing when the transcript never names a model or effort", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + it("reports nothing when the transcript never names a model or effort", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations.some((o) => o.kind === "subtitle")).toBe(false); }); - it("passes through a model the manifest does not know", () => { + it("passes through a model the manifest does not know", async () => { // Claude Code is an Anthropic-compatible client; subagents legitimately report other models. - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [{ type: "assistant", message: { model: "glm-5.1" } }], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [{ type: "assistant", message: { model: "glm-5.1" } }], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({ subtitle: "general-purpose · glm-5.1", }); @@ -406,20 +442,24 @@ describe("replay runtime", () => { }); describe("replay usage", () => { - function subtitleOf(entries: ClaudeReplayEntry[]): SubagentObservation | undefined { - return observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations.find((observation) => observation.kind === "subtitle"); + async function subtitleOf( + entries: ClaudeReplayEntry[], + ): Promise { + return ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations.find((observation) => observation.kind === "subtitle"); } - it("derives the counters Claude Code itself reports, from a real transcript's shape", () => { + it("derives the counters Claude Code itself reports, from a real transcript's shape", async () => { // Shape and numbers taken from an on-disk subagent transcript. Claude Code finalizes a // subagent from the LAST assistant entry's usage, so the earlier, smaller turn must not win // and must not be added in. expect( - subtitleOf([ + await subtitleOf([ { type: "assistant", timestamp: "2026-07-23T23:18:43.023Z", @@ -462,9 +502,9 @@ describe("replay usage", () => { }); }); - it("formats tokens for a child that only answered", () => { + it("formats tokens for a child that only answered", async () => { expect( - subtitleOf([ + await subtitleOf([ { type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", @@ -479,12 +519,12 @@ describe("replay usage", () => { ).toMatchObject({ subtitle: "general-purpose · 16.4k tokens" }); }); - it("emits nothing for a transcript with no entries", () => { - expect(subtitleOf([])).toBeUndefined(); + it("emits nothing for a transcript with no entries", async () => { + expect(await subtitleOf([])).toBeUndefined(); }); - it("keeps duration out of the compact subtitle", () => { - const observation = subtitleOf([ + it("keeps duration out of the compact subtitle", async () => { + const observation = await subtitleOf([ { type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", @@ -494,8 +534,8 @@ describe("replay usage", () => { expect(observation).toMatchObject({ subtitle: "general-purpose · 10 tokens" }); }); - it("omits totalTokens when no entry carries a usage block", () => { - const observation = subtitleOf([ + it("omits totalTokens when no entry carries a usage block", async () => { + const observation = await subtitleOf([ { type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", message: { content: [] } }, { type: "assistant", @@ -506,9 +546,9 @@ describe("replay usage", () => { expect(observation).toBeUndefined(); }); - it("keeps the last usage-bearing entry when the final assistant entry has none", () => { + it("keeps the last usage-bearing entry when the final assistant entry has none", async () => { expect( - subtitleOf([ + await subtitleOf([ { type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", @@ -519,34 +559,36 @@ describe("replay usage", () => { ).toMatchObject({ subtitle: "general-purpose · 1k tokens" }); }); - it("only reports counters the transcript actually shows", () => { + it("only reports counters the transcript actually shows", async () => { // A user-only transcript says nothing about cost: no assistant turn, so no tool count either. - const observation = subtitleOf([ + const observation = await subtitleOf([ { type: "user", timestamp: "2026-07-27T16:42:14.698Z", message: { content: "prompt" } }, { type: "user", timestamp: "2026-07-27T16:42:20.000Z", message: { content: "more" } }, ]); expect(observation).toBeUndefined(); }); - it("lands the complete provider-owned subtitle on the descriptor", () => { + it("lands the complete provider-owned subtitle on the descriptor", async () => { const store = new ProviderSubagentStore(); - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [ - { - type: "assistant", - timestamp: "2026-07-27T16:42:14.698Z", - message: { content: [], usage: { input_tokens: 7 } }, - }, - ], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-27T16:42:14.698Z", + message: { content: [], usage: { input_tokens: 7 } }, + }, + ], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; for (const event of foldSubagentObservations(observations)) { store.apply("parent", "claude", event); } @@ -559,7 +601,7 @@ describe("live and replay agree", () => { // The property the design exists to guarantee: one session, two sources, one descriptor. // This is inexpressible against the inference implementation, where the paths share no // vocabulary and each derives identity and status its own way. - it("produces identical descriptor state from the wire and from disk", () => { + it("produces identical descriptor state from the wire and from disk", async () => { const live = new ClaudeTaskProtocolSource(); const liveObservations = [ ...live.observe({ @@ -579,21 +621,23 @@ describe("live and replay agree", () => { } as unknown as SDKMessage), ]; - const replayObservations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { - toolUseId: TOOL_USE_ID, - agentType: "general-purpose", - description: "Summarize hover and unistyles docs", + const replayObservations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { + toolUseId: TOOL_USE_ID, + agentType: "general-purpose", + description: "Summarize hover and unistyles docs", + }, + entries: [], }, - entries: [], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; const fromWire = applyToStore(liveObservations); const fromDisk = applyToStore(replayObservations); diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts index 23ba35c0fe..567a98dfe5 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts @@ -130,7 +130,7 @@ function readUsage(entry: ClaudeReplayEntry): ClaudeSubagentUsage | null { export interface ClaudeReplaySubagentInput { agentId: string; meta: ClaudeSubagentMeta | null; - entries: Iterable; + entries: Iterable | AsyncIterable; /** Task declarations made by this subagent, used to attach its direct children. */ parentFacts?: ClaudeReplayParentFacts; } @@ -250,13 +250,13 @@ function observeSubtitle( }; } -function observeSubagent( +async function observeSubagent( subagent: ClaudeReplaySubagentInput, parent: ClaudeReplayParentFacts, convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[], toolOwners: Map, parentSubagentId?: string, -): SubagentObservation[] { +): Promise { const link = resolveParentLink(subagent, parent); if (!link) return []; const toolCall = parent.toolCalls.get(link.toolCallId); @@ -269,7 +269,7 @@ function observeSubagent( let childTerminalStatus: ProviderSubagentStatus | null = null; // Consume each source once for presentation and timeline; keep no raw transcript records. - for (const entry of subagent.entries) { + for await (const entry of subagent.entries) { const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); if (!seenEntry) firstTimestamp = timestamp; seenEntry = true; @@ -320,11 +320,11 @@ function recordReplayToolOwners( } } -export function observeReplaySubagents(input: { +export async function observeReplaySubagents(input: { subagents: readonly ClaudeReplaySubagentInput[]; parent: ClaudeReplayParentFacts; convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; -}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap } { +}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap }> { const observations: SubagentObservation[] = []; const toolOwners = new Map(); const unresolved = [...input.subagents].sort( @@ -346,7 +346,7 @@ export function observeReplaySubagents(input: { const { ownerId, parent, link } = resolved; // Only proven descendants may own notifications; ambient sidecars cannot claim them. - for (const observation of observeSubagent( + for (const observation of await observeSubagent( subagent, parent, input.convertEntry, diff --git a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts index 59e1f23557..af03a515b7 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts @@ -35,7 +35,7 @@ function parentEntries( } describe("Claude workflow replay", () => { - it("parses the persisted run summary observed from Claude Code 2.1.220", () => { + it("parses the persisted run summary observed from Claude Code 2.1.220", async () => { expect( parseClaudeWorkflowRun( JSON.stringify({ @@ -62,8 +62,8 @@ describe("Claude workflow replay", () => { }); }); - it("rebuilds the same generic completed descriptor through the parent Workflow call", () => { - const observations = observeReplayWorkflows({ + it("rebuilds the same generic completed descriptor through the parent Workflow call", async () => { + const observations = await observeReplayWorkflows({ workflows: [ { runId: RUN_ID, @@ -91,8 +91,8 @@ describe("Claude workflow replay", () => { }); }); - it("rebuilds a workflow when Claude persists its result as structured text blocks", () => { - const observations = observeReplayWorkflows({ + it("rebuilds a workflow when Claude persists its result as structured text blocks", async () => { + const observations = await observeReplayWorkflows({ workflows: [{ runId: RUN_ID, summary: "Structured result workflow", status: "completed" }], parentEntries: parentEntries([ { @@ -114,7 +114,7 @@ describe("Claude workflow replay", () => { }); }); - it("replays the workflow result without exposing its script or child prompt", () => { + it("replays the workflow result without exposing its script or child prompt", async () => { const workflow = parseClaudeWorkflowRun( JSON.stringify({ runId: RUN_ID, @@ -128,7 +128,7 @@ describe("Claude workflow replay", () => { expect(workflow).not.toBeNull(); if (!workflow) throw new Error("expected workflow"); - const observations = observeReplayWorkflows({ + const observations = await observeReplayWorkflows({ workflows: [workflow], parentEntries: parentEntries(), }); @@ -145,9 +145,9 @@ describe("Claude workflow replay", () => { expect(JSON.stringify(observations)).not.toContain("SECRET CHILD PROMPT"); }); - it("does not duplicate a result already present in the child transcript", () => { + it("does not duplicate a result already present in the child transcript", async () => { const report = "What Paseo is\nA local-first coding-agent environment."; - const observations = observeReplayWorkflows({ + const observations = await observeReplayWorkflows({ workflows: [ { runId: RUN_ID, summary: "Inspect Paseo", status: "completed", result: { report } }, ], @@ -166,8 +166,8 @@ describe("Claude workflow replay", () => { ).toHaveLength(1); }); - it("fails a nonterminal persisted run because its former runtime cannot update it", () => { - const observations = observeReplayWorkflows({ + it("fails a nonterminal persisted run because its former runtime cannot update it", async () => { + const observations = await observeReplayWorkflows({ workflows: [{ runId: RUN_ID, summary: "Interrupted workflow", status: "running" }], parentEntries: parentEntries(), }); @@ -179,9 +179,9 @@ describe("Claude workflow replay", () => { }); }); - it("drops a run that no parent Workflow tool call declared", () => { + it("drops a run that no parent Workflow tool call declared", async () => { expect( - observeReplayWorkflows({ + await observeReplayWorkflows({ workflows: [{ runId: RUN_ID, status: "completed" }], parentEntries: [], }), diff --git a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts index 1d7ff2b1a8..7496ce7c8d 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts @@ -45,14 +45,17 @@ export function parseClaudeWorkflowRun(contents: string): ClaudeWorkflowRun | nu * provider runtime was recreated, so replay terminalizes it as failed instead of resurrecting a * row that can never receive another update. */ -export function observeReplayWorkflows(input: { +export async function observeReplayWorkflows(input: { workflows: readonly ClaudeWorkflowRun[]; - parentEntries: Iterable; + parentEntries: Iterable | AsyncIterable; /** Repeatable entries in chronological order, supplied by the transcript reader. */ - entriesByRunId?: ReadonlyMap>; + entriesByRunId?: ReadonlyMap< + string, + Iterable | AsyncIterable + >; convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; -}): SubagentObservation[] { - const toolCallIdByRunId = readWorkflowLinks(input.parentEntries); +}): Promise { + const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries); const observations: SubagentObservation[] = []; for (const workflow of input.workflows) { @@ -79,7 +82,7 @@ export function observeReplayWorkflows(input: { }); } - for (const observation of observeWorkflowTimeline({ + for (const observation of await observeWorkflowTimeline({ id, result: workflow.result, entries: input.entriesByRunId?.get(workflow.runId) ?? [], @@ -115,17 +118,17 @@ export function observeReplayWorkflows(input: { return observations; } -function observeWorkflowTimeline(input: { +async function observeWorkflowTimeline(input: { id: string; result: unknown; - entries: Iterable; + entries: Iterable | AsyncIterable; convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; finishedAt?: string; -}): SubagentObservation[] { +}): Promise { const observations: SubagentObservation[] = []; const replayedAssistantText = new Set(); - for (const entry of input.entries) { + for await (const entry of input.entries) { const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); const items = input.convertEntry?.(entry) ?? []; for (const item of items) { @@ -150,11 +153,13 @@ function observeWorkflowTimeline(input: { return observations; } -function readWorkflowLinks(entries: Iterable): Map { +async function readWorkflowLinks( + entries: Iterable | AsyncIterable, +): Promise> { const workflowToolCallIds = new Set(); const toolCallIdByRunId = new Map(); - for (const entry of entries) { + for await (const entry of entries) { const content = entry.message?.content; if (!Array.isArray(content)) continue; for (const value of content) { @@ -165,7 +170,7 @@ function readWorkflowLinks(entries: Iterable): Map(entries: Iterable): Generator { +export async function* assignTaskNotificationReplayIds( + entries: AsyncIterable, +): AsyncGenerator { const occurrences = new Map>(); - for (const entry of entries) { + for await (const entry of entries) { const record = TaskNotificationHistoryRecordSchema.safeParse(entry); if (!record.success) { yield entry; diff --git a/packages/server/src/server/agent/providers/claude/transcript-history.ts b/packages/server/src/server/agent/providers/claude/transcript-history.ts index f8663f83d0..09882b76dd 100644 --- a/packages/server/src/server/agent/providers/claude/transcript-history.ts +++ b/packages/server/src/server/agent/providers/claude/transcript-history.ts @@ -1,5 +1,6 @@ import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js"; -import fs from "node:fs"; +import fs, { type FileHandle } from "node:fs/promises"; +import { setImmediate } from "node:timers/promises"; import path from "node:path"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; import { @@ -24,11 +25,11 @@ interface RecordLocation { } /** Chunk in bytes so UTF-8 and multi-chunk records are decoded once, at a line boundary. */ -function* locatedRecords( +async function* locatedRecords( file: string, - fd: number, -): Generator<{ entry: TranscriptEntry; location: RecordLocation }> { - const stat = fs.fstatSync(fd); + fd: FileHandle, +): AsyncGenerator<{ entry: TranscriptEntry; location: RecordLocation }> { + const stat = await fd.stat(); // Opening a directory succeeds on some platforms; it must never look like empty history. if (stat.isDirectory()) { throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${file}'`), { @@ -43,7 +44,7 @@ function* locatedRecords( let length = 0; while (remaining > 0) { const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining)); - const count = fs.readSync(fd, buffer, 0, buffer.length, null); + const { bytesRead: count } = await fd.read(buffer, 0, buffer.length, null); if (count === 0) break; remaining -= count; let start = 0; @@ -64,6 +65,8 @@ function* locatedRecords( if (entry) yield { entry, location }; start = end + 1; } + // Bound parsing work as well as IO; microtasks alone do not service timers or sockets. + await setImmediate(); if (start < count) { pieces.push(buffer.subarray(start, count)); length += count - start; @@ -85,20 +88,25 @@ function* locatedRecords( /** Indexed sources parse only their own records using descriptors owned by this replay. */ function indexedRecords( locations: readonly RecordLocation[], - descriptors: ReadonlyMap, -): Iterable { + descriptors: ReadonlyMap, +): AsyncIterable { return { - *[Symbol.iterator]() { + async *[Symbol.asyncIterator]() { yield* assignTaskNotificationReplayIds(readEntries()); }, }; - function* readEntries(): Generator { + async function* readEntries(): AsyncGenerator { for (const location of locations) { const fd = descriptors.get(location.file)!; const buffer = Buffer.allocUnsafe(location.length); let read = 0; while (read < buffer.length) { - const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read); + const { bytesRead: count } = await fd.read( + buffer, + read, + buffer.length - read, + location.offset + read, + ); if (count === 0) throw new Error(`Claude transcript was truncated during replay: ${location.file}`); read += count; @@ -127,9 +135,9 @@ function parseRecord(line: string): TranscriptEntry | null { } } -function readOptionalFile(file: string): string | null { +async function readOptionalFile(file: string): Promise { try { - return fs.readFileSync(file, "utf8"); + return await fs.readFile(file, "utf8"); } catch { return null; } @@ -138,31 +146,40 @@ function readOptionalFile(file: string): string | null { /** Discover sources without retaining transcript contents. Unrelated sidechains stay excluded * by the replay ownership resolver; workflow children retain their separate run ownership. */ export interface ClaudeReplayHistory { - parentEntries: Iterable; + parentEntries: AsyncIterable; subagents: { agentId: string; meta: ClaudeSubagentMeta | null; - entries: Iterable; + entries: AsyncIterable; }[]; workflows: ClaudeWorkflowRun[]; - workflowEntriesByRunId: Map>; + workflowEntriesByRunId: Map>; } /** Own descriptors through every pass so unlink or rename cannot invalidate discovered history. * Sources must be consumed before the callback returns. No transcript copies are needed. */ -export function withClaudeReplayHistory( +export async function withClaudeReplayHistory( historyPath: string, - consume: (history: ClaudeReplayHistory) => T, -): T { - const descriptors = new Map(); + consume: (history: ClaudeReplayHistory) => Promise, +): Promise { + const descriptors = new Map(); try { - return consume(indexHistory(historyPath, descriptors)); + return await consume(await indexHistory(historyPath, descriptors)); } finally { - for (const fd of descriptors.values()) fs.closeSync(fd); + await closeReplaySources(descriptors.values()); } } -function indexHistory(historyPath: string, descriptors: Map): ClaudeReplayHistory { +async function closeReplaySources(descriptors: Iterable): Promise { + const results = await Promise.allSettled([...descriptors].map((fd) => fd.close())); + const failure = results.find((result) => result.status === "rejected"); + if (failure) throw failure.reason; +} + +async function indexHistory( + historyPath: string, + descriptors: Map, +): Promise { const sessionDirectory = path.join( path.dirname(historyPath), path.basename(historyPath, ".jsonl"), @@ -174,8 +191,7 @@ function indexHistory(historyPath: string, descriptors: Map): Cl const directories = [sidechainDirectory]; while (directories.length) { const directory = directories.pop()!; - if (!fs.existsSync(directory)) continue; - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + for (const entry of await optionalDirectory(directory)) { const file = path.join(directory, entry.name); if (entry.isDirectory()) { directories.push(file); @@ -194,23 +210,23 @@ function indexHistory(historyPath: string, descriptors: Map): Cl } const agentId = /^agent-(.+)\.meta\.json$/.exec(entry.name)?.[1]; if (!agentId) continue; - const contents = readOptionalFile(file); + const contents = await readOptionalFile(file); const meta = contents === null ? null : parseClaudeSubagentMeta(contents); if (meta) metaByAgentId.set(agentId, meta); } } const parentLocations: RecordLocation[] = []; const locationsByAgentId = new Map(); - function scan( + async function scan( file: string, visit: (entry: TranscriptEntry, location: RecordLocation) => void, - ): void { - const fd = fs.openSync(file, "r"); + ): Promise { + const fd = await fs.open(file, "r"); descriptors.set(file, fd); - for (const { entry, location } of locatedRecords(file, fd)) visit(entry, location); + for await (const { entry, location } of locatedRecords(file, fd)) visit(entry, location); } for (const file of sidechainFiles) { - scan(file, (entry, location) => { + await scan(file, (entry, location) => { if (file === historyPath && entry.isSidechain !== true) parentLocations.push(location); if (entry.isSidechain !== true || typeof entry.agentId !== "string") return; const locations = locationsByAgentId.get(entry.agentId) ?? []; @@ -218,11 +234,11 @@ function indexHistory(historyPath: string, descriptors: Map): Cl locationsByAgentId.set(entry.agentId, locations); }); } - const workflowEntriesByRunId = new Map>(); + const workflowEntriesByRunId = new Map>(); for (const [runId, files] of workflowFiles) { const locations: RecordLocation[] = []; for (const file of files) - scan(file, (entry, location) => { + await scan(file, (entry, location) => { if (entry.type !== "user" || isToolResult(entry)) locations.push(location); }); locations.sort((a, b) => { @@ -240,21 +256,28 @@ function indexHistory(historyPath: string, descriptors: Map): Cl meta: metaByAgentId.get(agentId) ?? null, entries: indexedRecords(locations, descriptors), })), - workflows: readWorkflows(sessionDirectory), + workflows: await readWorkflows(sessionDirectory), workflowEntriesByRunId, }; } -function readWorkflows(sessionDirectory: string): ClaudeWorkflowRun[] { +async function readWorkflows(sessionDirectory: string): Promise { const workflows: ClaudeWorkflowRun[] = []; const workflowDirectory = path.join(sessionDirectory, "workflows"); - if (fs.existsSync(workflowDirectory)) { - for (const entry of fs.readdirSync(workflowDirectory, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith(".json")) continue; - const contents = readOptionalFile(path.join(workflowDirectory, entry.name)); - const workflow = contents === null ? null : parseClaudeWorkflowRun(contents); - if (workflow) workflows.push(workflow); - } + for (const entry of await optionalDirectory(workflowDirectory)) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + const contents = await readOptionalFile(path.join(workflowDirectory, entry.name)); + const workflow = contents === null ? null : parseClaudeWorkflowRun(contents); + if (workflow) workflows.push(workflow); } return workflows; } + +async function optionalDirectory(directory: string) { + try { + return await fs.readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts index cc32a3c013..3018461ba9 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts @@ -183,3 +183,29 @@ describe("Codex app-server transport", () => { codex.child.stdin.end(); }); }); + +describe("Codex stdout byte admission", () => { + test("fails pending requests and the session before joining an oversized line", async () => { + const child = createCodexAppServerChildProcess(); + const client = new CodexAppServerClient(child, createTestLogger()); + const terminations: Error[] = []; + client.setUnexpectedTerminationHandler((error) => terminations.push(error)); + const request = client.request("thread/resume", {}, 1000).catch((error: Error) => error); + const chunk = Buffer.alloc(64 * 1024, "x"); + child.stdout.write('{"id":1,"result":"'); + for (let index = 0; index < 2048; index++) child.stdout.write(chunk); + const error = await request; + expect(error).toMatchObject({ + name: "ProviderStreamLimitError", + code: "PROVIDER_STREAM_LINE_TOO_LARGE", + limitBytes: 128 * 1024 * 1024, + }); + expect(terminations).toEqual([error]); + expect(client.hasPendingRequests()).toBe(false); + child.stdout.write('"}\n{"id":1,"result":"late"}\n'); + await expect(client.request("model/list")).rejects.toThrow("closed"); + await client.dispose(); + child.stdout.end(); + child.stderr.end(); + }); +}); diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.ts index 29b8cf8880..dfd4adfac4 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.ts @@ -1,10 +1,15 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; -import { StringDecoder } from "node:string_decoder"; import type { Logger } from "pino"; import { z } from "zod"; import { terminateWithTreeKill } from "../../../../utils/tree-kill.js"; +import { ProviderLineReader, ProviderStreamLimitError } from "../jsonl-frame-decoder.js"; + +// A measured long-thread resume was 19.75 MB; preserve the existing 32 MiB fixture +// with 4x margin for longer threads. This is provider stdout, not the client protocol. +export const CODEX_PROVIDER_LINE_BYTES = 128 * 1024 * 1024; + const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000; const APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; @@ -167,39 +172,6 @@ function readProviderTurnId(params: unknown): string | undefined { return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined; } -/** - * Splits stdout into lines on "\n" only. `node:readline` additionally treats U+2028 and U+2029 - * as line terminators, but Codex emits those characters raw inside JSON strings, so readline - * would cut a single JSON-RPC message into pieces that fail to parse. - */ -function createNewlineLineReader( - input: NodeJS.ReadableStream, - onLine: (line: string) => void, -): { close(): void } { - const decoder = new StringDecoder("utf8"); - // Text after the last "\n", kept as parts and joined once its line ends, so a large line - // arriving in many chunks is scanned and copied once instead of once per chunk. - let unterminated: string[] = []; - const onData = (chunk: Buffer | string): void => { - const text = typeof chunk === "string" ? chunk : decoder.write(chunk); - let lineStart = 0; - let index = text.indexOf("\n"); - while (index !== -1) { - unterminated.push(text.slice(lineStart, index)); - const line = unterminated.join(""); - unterminated = []; - onLine(line.endsWith("\r") ? line.slice(0, -1) : line); - lineStart = index + 1; - index = text.indexOf("\n", lineStart); - } - if (lineStart < text.length) { - unterminated.push(text.slice(lineStart)); - } - }; - input.on("data", onData); - return { close: () => void input.off("data", onData) }; -} - export class CodexAppServerClient { private readonly stdoutLineReader: { close(): void }; private activeProviderRequests = 0; @@ -216,11 +188,24 @@ export class CodexAppServerClient { private readonly logger: Logger, private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}), ) { - this.stdoutLineReader = createNewlineLineReader(child.stdout, (line) => { - void this.handleLine(line).catch((error) => { - this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line"); - }); - }); + // Split only on LF: U+2028/U+2029 are valid raw characters inside Codex JSON. + const reader = new ProviderLineReader( + CODEX_PROVIDER_LINE_BYTES, + (line) => { + void this.handleLine(line).catch((error) => { + this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line"); + }); + }, + (error) => this.handleStreamLimit(error), + ); + const onData = (chunk: Buffer | string) => reader.write(chunk); + child.stdout.on("data", onData); + this.stdoutLineReader = { + close: () => { + reader.close(); + child.stdout.off("data", onData); + }, + }; child.stderr.on("data", (chunk) => { this.stderrBuffer += chunk.toString(); @@ -318,6 +303,15 @@ export class CodexAppServerClient { } } + private handleStreamLimit(failure: ProviderStreamLimitError): void { + // A rejected response/notification cannot be replayed safely. Fail the session, + // including live turns with no pending RPC, rather than dropping a stream unit. + this.handleUnexpectedTermination(failure); + void this.dispose().catch((err) => { + this.logger.warn({ err }, "Failed to terminate oversized Codex stdout producer"); + }); + } + private handleUnexpectedTermination(error: Error): void { if (this.disposed) { return; diff --git a/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts b/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts index af2bdabf01..c1c81bdae8 100644 --- a/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts +++ b/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts @@ -1,3 +1,5 @@ +import { performance } from "node:perf_hooks"; +import { setTimeout as delay } from "node:timers/promises"; import { describe, expect, it } from "vitest"; import { JsonlFrameDecoder, type JsonlFrameProblem } from "./jsonl-frame-decoder.js"; @@ -36,6 +38,16 @@ describe("JsonlFrameDecoder", () => { expect(problems).toEqual([]); }); + it("preserves UTF-8 characters split across raw stdout buffers", () => { + const { decoder, frames, problems } = decoderHarness(); + const bytes = Buffer.from('{"type":"notice","text":"é🙂"}\n'); + const split = bytes.indexOf(Buffer.from("🙂")) + 1; + decoder.write(bytes.subarray(0, split)); + decoder.write(bytes.subarray(split)); + expect(frames).toEqual([{ type: "notice", text: "é🙂" }]); + expect(problems).toEqual([]); + }); + it("reassembles a large ordered v2 frame", () => { const { decoder, frames, problems } = decoderHarness(); const frame = { type: "models", value: "x".repeat(1024 * 1024) }; @@ -55,3 +67,57 @@ describe("JsonlFrameDecoder", () => { expect(problems).toEqual(["out-of-order-chunk"]); }); }); + +describe("provider line admission", () => { + it("keeps the loop responsive while accumulating a fragmented provider response", async () => { + const { decoder, frames, problems } = decoderHarness(); + const part = "x".repeat(512); + let last = performance.now(); + let longestGap = 0; + const probe = setInterval(() => { + const now = performance.now(); + longestGap = Math.max(longestGap, now - last); + last = now; + }, 1); + try { + await delay(5); + decoder.write('{"type":"notice","text":"'); + // 1 MiB is enough to expose the former rescan of every accumulated prefix. + for (let index = 0; index < 2048; index++) decoder.write(part); + decoder.write('"}\n'); + await delay(5); + expect(frames).toEqual([{ type: "notice", text: part.repeat(2048) }]); + expect(problems).toEqual([]); + expect(longestGap).toBeLessThan(50); + } finally { + clearInterval(probe); + } + }); + + it("rejects an unterminated over-cap UTF-8 line and stops accepting frames", () => { + const frames: Record[] = []; + const failures: Array<{ problem: string; detail: unknown }> = []; + const decoder = new JsonlFrameDecoder({ + frame: (frame) => frames.push(frame), + problem: (problem, detail) => failures.push({ problem, detail }), + }); + // 64 MiB of UTF-8, only 32 Mi UTF-16 code units. No newline or JSON parse needed. + const chunk = "é".repeat(32 * 1024); + for (let index = 0; index < 1024; index++) decoder.write(chunk); + expect(failures).toEqual([]); + decoder.write("é"); + expect(failures).toEqual([ + { + problem: "line-too-large", + detail: expect.objectContaining({ + name: "ProviderStreamLimitError", + code: "PROVIDER_STREAM_LINE_TOO_LARGE", + limitBytes: 64 * 1024 * 1024, + }), + }, + ]); + decoder.write('\n{"type":"ready"}\n'); + expect(frames).toEqual([]); + expect(failures).toHaveLength(1); + }); +}); diff --git a/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts b/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts index 1662e7719c..8b1b9767de 100644 --- a/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts +++ b/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts @@ -1,3 +1,60 @@ +// Provider-process stdout only; these are not app/daemon wire limits. +export const JSONL_PROVIDER_LINE_BYTES = 64 * 1024 * 1024; + +export class ProviderStreamLimitError extends Error { + readonly code = "PROVIDER_STREAM_LINE_TOO_LARGE"; + + constructor(readonly limitBytes: number) { + super( + `Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`, + ); + this.name = "ProviderStreamLimitError"; + } +} + +/** Admits raw UTF-8 bytes before retaining, decoding or joining a complete line. */ +export class ProviderLineReader { + private parts: Buffer[] = []; + private bytes = 0; + private stopped = false; + + constructor( + private readonly limitBytes: number, + private readonly onLine: (line: string) => void, + private readonly onLimit: (error: ProviderStreamLimitError) => void, + ) {} + + write(chunk: Buffer | string): void { + if (this.stopped) return; + const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk; + let start = 0; + while (start < buffer.byteLength && !this.stopped) { + const newline = buffer.indexOf(10, start); + const end = newline === -1 ? buffer.byteLength : newline; + const length = end - start; + if (length > this.limitBytes - this.bytes) { + this.close(); + this.onLimit(new ProviderStreamLimitError(this.limitBytes)); + return; + } + if (length) this.parts.push(buffer.subarray(start, end)); + this.bytes += length; + if (newline === -1) return; + const line = Buffer.concat(this.parts, this.bytes).toString("utf8"); + this.parts = []; + this.bytes = 0; + this.onLine(line.endsWith("\r") ? line.slice(0, -1) : line); + start = newline + 1; + } + } + + close(): void { + this.stopped = true; + this.parts = []; + this.bytes = 0; + } +} + const V2_FRAME_BYTES = 1024 * 1024; const V2_REASSEMBLED_BYTES = 64 * 1024 * 1024; const V2_CHUNK_BYTES = 256 * 1024; @@ -5,6 +62,7 @@ const V2_BASE64_CHARS = Math.ceil(V2_CHUNK_BYTES / 3) * 4; const V2_MAX_CHUNKS = Math.ceil(V2_REASSEMBLED_BYTES / V2_CHUNK_BYTES); export type JsonlFrameProblem = + | "line-too-large" | "invalid-json" | "invalid-chunk" | "invalid-base64" @@ -78,7 +136,7 @@ function chunkBytes(data: string): Buffer | null { } export class JsonlFrameDecoder { - private lineBuffer = ""; + private readonly lines: ProviderLineReader; private assembly: ChunkAssembly | null = null; constructor( @@ -86,17 +144,18 @@ export class JsonlFrameDecoder { frame(message: Record): void; problem(problem: JsonlFrameProblem, detail?: unknown): void; }, - ) {} + ) { + this.lines = new ProviderLineReader( + JSONL_PROVIDER_LINE_BYTES, + (line) => { + if (line.trim()) this.consumeLine(line); + }, + (error) => this.drop("line-too-large", error), + ); + } - write(text: string): void { - this.lineBuffer += text; - let newline = this.lineBuffer.indexOf("\n"); - while (newline !== -1) { - const line = this.lineBuffer.slice(0, newline).replace(/\r$/, ""); - this.lineBuffer = this.lineBuffer.slice(newline + 1); - if (line.trim()) this.consumeLine(line); - newline = this.lineBuffer.indexOf("\n"); - } + write(chunk: Buffer | string): void { + this.lines.write(chunk); } private consumeLine(line: string): void { diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts index 7dfbb6abb8..141b8f36f0 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts @@ -471,3 +471,25 @@ describe("JsonlRpcProcess", () => { } }); }); + +describe("JSONL stdout byte admission", () => { + test("fails pending requests and publishes the typed failure to live-turn consumers", async () => { + const child = createInMemoryChildProcess(); + const process = startProcess({ child }); + const exit = new Promise((resolve) => process.onExit(resolve)); + const request = process.request({ type: "hang" }, 1000).catch((error: Error) => error); + const chunk = Buffer.alloc(64 * 1024, "x"); + child.stdout.write('{"type":"notice","text":"'); + for (let index = 0; index < 1024; index++) child.stdout.write(chunk); + const error = await request; + expect(error).toMatchObject({ + name: "ProviderStreamLimitError", + code: "PROVIDER_STREAM_LINE_TOO_LARGE", + limitBytes: 64 * 1024 * 1024, + }); + expect((await exit).error).toBe(error); + await process.close(); + child.stdout.end(); + child.stderr.end(); + }); +}); diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts index 9d8b9af623..ddf85eb316 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts @@ -3,7 +3,7 @@ import type { Logger } from "pino"; import { spawnProcess } from "../../../utils/spawn.js"; import { terminateWithTreeKill } from "../../../utils/tree-kill.js"; -import { JsonlFrameDecoder } from "./jsonl-frame-decoder.js"; +import { JsonlFrameDecoder, ProviderStreamLimitError } from "./jsonl-frame-decoder.js"; export { supportsJsonlRpcProtocolV2 } from "./jsonl-frame-decoder.js"; /** Default wall-clock timeout for control-plane / short RPC calls. */ @@ -81,6 +81,7 @@ export class JsonlRpcProcess { private stderrBuffer = ""; private nextRequestId = 1; private disposed = false; + private streamFailure: ProviderStreamLimitError | null = null; private exited = false; private closing: Promise | null = null; private readonly frameDecoder: JsonlFrameDecoder; @@ -90,6 +91,14 @@ export class JsonlRpcProcess { this.frameDecoder = new JsonlFrameDecoder({ frame: (message) => this.dispatchFrame(message), problem: (problem, detail) => { + if (problem === "line-too-large" && detail instanceof ProviderStreamLimitError) { + // Stop the producer: silently skipping a response or turn event loses state. + this.streamFailure = detail; + void this.close(detail).catch((err) => { + this.options.logger.warn({ err }, `Failed to close ${this.diagnosticName}`); + }); + return; + } this.options.logger.warn( { problem, detail }, `Ignoring invalid ${this.diagnosticName} frame`, @@ -98,7 +107,7 @@ export class JsonlRpcProcess { }); this.child = (options.spawn ?? spawnJsonlRpcProcess)(options.launch); this.child.stdout.on("data", (chunk) => { - this.handleStdoutChunk(chunk.toString()); + this.handleStdoutChunk(chunk); }); this.child.stderr.on("data", (chunk) => { this.stderrBuffer += chunk.toString(); @@ -114,9 +123,11 @@ export class JsonlRpcProcess { }); this.child.on("exit", (code, signal) => { this.exited = true; - const error = new Error( - `${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(), - ); + const error = + this.streamFailure ?? + new Error( + `${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(), + ); const exit = { code, signal, error }; for (const subscriber of this.exitSubscribers) { subscriber(exit); @@ -254,7 +265,7 @@ export class JsonlRpcProcess { } } - private handleStdoutChunk(chunk: string): void { + private handleStdoutChunk(chunk: Buffer | string): void { this.frameDecoder.write(chunk); } diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 983f9cf55f..7b56c9bdec 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -1133,6 +1133,17 @@ describe("OMP agent client and session", () => { messageId: "assistant-history", }, ]); + await expect( + omp.runPrompt("continue after resume", "resumed follow-up complete"), + ).resolves.toMatchObject({ + finalText: "resumed follow-up complete", + }); + expect(omp.timeline()).toContainEqual( + expect.objectContaining({ + type: "assistant_message", + text: "resumed follow-up complete", + }), + ); expect(omp.usageSession()).toMatchObject({ provider: "omp", sessionKey: expect.any(String) }); await omp.close(); expect(omp.usageSession()).toBeNull(); diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index e64a93fd64..03c0ebe5ae 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -1,4 +1,5 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -8,7 +9,7 @@ import { streamOmpCoreHistory, type OmpCapturedUserMessageEntry } from "./messag import type { OmpAgentMessage } from "./rpc-types.js"; import { FakeOmp } from "./test-utils/fake-omp.js"; import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js"; -import { readOmpHistoryTodoState, streamOmpHistory } from "./history.js"; +import { readActiveOmpEntryChain, readOmpHistoryTodoState, streamOmpHistory } from "./history.js"; async function collectHistory( messages: OmpAgentMessage[], @@ -1245,3 +1246,53 @@ describe("OMP history mapper", () => { ); }); }); + +test("indexes out-of-order branches, duplicate IDs and cycles before selecting history", async () => { + const directory = await mkdtemp(join(tmpdir(), "omp-history-branches-")); + const sessionFile = join(directory, "session.jsonl"); + const root = { id: "root", parentId: null }; + const earlyLeaf = { + id: "early", + parentId: "root", + message: { role: "user", content: "你好🌍".repeat(15000) }, + }; + const branch = { id: "branch", parentId: "root" }; + const active = { id: "active", parentId: "branch" }; + const duplicate = { + id: "branch", + parentId: "root", + message: { role: "user", content: "replacement" }, + }; + try { + await writeFile( + sessionFile, + [earlyLeaf, active, root, branch, duplicate] + .map((entry) => JSON.stringify(entry)) + .join("\n") + '\n{"id":', + ); + // The final record is a branch ancestor, not the active leaf. Latest duplicate wins + // when following parents; the invalid tail contributes neither a leaf nor a parent. + await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual([root, duplicate, active]); + await expect(readActiveOmpEntryChain(sessionFile, "early")).resolves.toEqual([root, earlyLeaf]); + await expect(readActiveOmpEntryChain(sessionFile, "missing")).resolves.toEqual([ + root, + duplicate, + active, + ]); + const cycle = [ + { id: "a", parentId: "b" }, + { id: "b", parentId: "a" }, + ]; + await writeFile(sessionFile, cycle.map((entry) => JSON.stringify(entry)).join("\n")); + await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual(cycle); + await writeFile(sessionFile, "null\n{}\ninvalid\n"); + await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual([]); + await expect(readActiveOmpEntryChain(directory)).rejects.toMatchObject({ code: "EISDIR" }); + await expect(readActiveOmpEntryChain(join(directory, "missing"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(readOmpHistoryTodoState(join(directory, "missing"))).resolves.toBeNull(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/omp/history.e2e.test.ts b/packages/server/src/server/agent/providers/omp/history.e2e.test.ts new file mode 100644 index 0000000000..77521a9687 --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/history.e2e.test.ts @@ -0,0 +1,104 @@ +import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; +import { mkdtemp, open, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { performance } from "node:perf_hooks"; +import { setTimeout as delay } from "node:timers/promises"; +import { expect, test } from "vitest"; +import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../../../test-utils/daemon-client.js"; +import { readActiveOmpEntryChain } from "./history.js"; + +test("replays selected OMP history without blocking the event loop", async () => { + const directory = await mkdtemp(join(tmpdir(), "omp-history-budget-")); + const sessionFile = join(directory, "session.jsonl"); + const started = performance.now(); + try { + const daemon = await createTestPaseoDaemon({ + // This regression exercises file replay and daemon requests, not executable discovery. + providerOverrides: Object.fromEntries( + BUILTIN_PROVIDER_IDS.map((id) => [id, { enabled: false }]), + ), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.13.2", + }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await client.getDaemonStatus(); + const file = await open(sessionFile, "w"); + // Structured message blocks expose document-sized parsing at a modest byte size. + // Replay an earlier active entry so inactive payload retention cannot drive the probe. + const text = Array.from({ length: 64 }, () => ({ type: "text", text: "x" })); + let bytes = 0; + let count = 0; + try { + while (bytes < 28 * 1024 * 1024) { + const line = + JSON.stringify({ + type: "message", + id: `row-${count}`, + parentId: count ? `row-${count - 1}` : null, + message: { role: "user", content: text }, + }) + "\n"; + bytes += Buffer.byteLength(line); + await file.write(line); + count++; + } + } finally { + await file.close(); + } + let last = performance.now(); + let longestGap = 0; + const probe = setInterval(() => { + const now = performance.now(); + longestGap = Math.max(longestGap, now - last); + last = now; + }, 5); + try { + const replayState = { finished: false }; + let repliesDuringReplay = 0; + const replay = readActiveOmpEntryChain(sessionFile, "row-0").finally(() => { + replayState.finished = true; + }); + // A real WebSocket RPC served by the isolated daemon on the same event loop. + const requests = (async () => { + while (!replayState.finished) { + await client.getDaemonStatus(); + if (!replayState.finished) repliesDuringReplay++; + await delay(10); + } + })(); + const [entries] = await Promise.all([replay, requests]); + await delay(10); + console.log( + JSON.stringify({ + bytes, + count, + longestGap, + repliesDuringReplay, + generationAndLoadMs: performance.now() - started, + }), + ); + expect(entries).toEqual([ + { + type: "message", + id: "row-0", + parentId: null, + message: { role: "user", content: text }, + }, + ]); + expect(longestGap).toBeLessThan(50); + expect(repliesDuringReplay).toBeGreaterThan(0); + } finally { + clearInterval(probe); + } + } finally { + await client.close().finally(() => daemon.close()); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}, 30000); diff --git a/packages/server/src/server/agent/providers/omp/history.ts b/packages/server/src/server/agent/providers/omp/history.ts index 2c818dfc5a..5102dfd44b 100644 --- a/packages/server/src/server/agent/providers/omp/history.ts +++ b/packages/server/src/server/agent/providers/omp/history.ts @@ -1,4 +1,5 @@ -import { readFile } from "node:fs/promises"; +import { open, type FileHandle } from "node:fs/promises"; +import { setImmediate } from "node:timers/promises"; import { basename, extname, join } from "node:path"; import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; @@ -96,7 +97,9 @@ export async function* streamOmpHistory(input: { const messages: OmpAgentMessage[] = []; const replayEntries: OmpReplayEntry[] = []; const userEntries: OmpCapturedUserMessageEntry[] = []; + let prepared = 0; for (const entry of entries) { + if (++prepared % 32 === 0) await setImmediate(); if (entry.type === "compaction") { replayEntries.push({ entry, item: mapCompactionEntry(entry) }); continue; @@ -115,7 +118,9 @@ export async function* streamOmpHistory(input: { OMP_HISTORY_MAPPER_HOOKS, input.bridgedTools, ); + let replayed = 0; for (const replayEntry of replayEntries) { + if (++replayed % 32 === 0) await setImmediate(); const timestamp = normalizeProviderReplayTimestamp(replayEntry.entry.timestamp); const events: AgentStreamEvent[] = "item" in replayEntry @@ -329,34 +334,139 @@ function stripExtension(filePath: string): string { return extension ? filePath.slice(0, -extension.length) : filePath; } -export async function readActiveOmpEntryChain( - sessionFile: string, - activeEntryId?: string, -): Promise { - const content = await readFile(sessionFile, "utf8"); - const entries = content.split("\n").flatMap((line) => { - if (!line.trim()) return []; - try { - const value = JSON.parse(line) as OmpSessionEntry; - return value && typeof value === "object" && typeof value.id === "string" ? [value] : []; - } catch { - return []; +interface OmpEntryLocation { + id: string; + parentId?: string | null; + offset: number; +} + +// Yield after each bounded IO chunk, including chunks containing only tiny or invalid records. +// Keep bytes until a complete line so multi-byte UTF-8 characters survive chunk boundaries. +async function* readOmpLines( + file: FileHandle, + size: number, +): AsyncGenerator<{ + line: Buffer; + offset: number; +}> { + const chunkSize = 64 * 1024; + let fragments: Buffer[] = []; + let lineOffset = 0; + for (let position = 0; position < size; ) { + const chunk = Buffer.allocUnsafe(Math.min(chunkSize, size - position)); + const { bytesRead } = await file.read(chunk, 0, chunk.length, position); + if (!bytesRead) break; + let start = 0; + for (let index = 0; index < bytesRead; index++) { + if (chunk[index] !== 10) continue; + const tail = chunk.subarray(start, index); + const line = fragments.length ? Buffer.concat([...fragments, tail]) : tail; + yield { line, offset: lineOffset }; + fragments = []; + start = index + 1; + lineOffset = position + start; } - }); - if (entries.length === 0) return []; - const byId = new Map(entries.map((entry) => [entry.id!, entry])); - const parentIds = new Set(entries.flatMap((entry) => (entry.parentId ? [entry.parentId] : []))); - const leaves = entries.filter((entry) => !parentIds.has(entry.id!)); - let current: OmpSessionEntry | undefined = - (activeEntryId ? byId.get(activeEntryId) : undefined) ?? leaves.at(-1) ?? entries.at(-1); - const chain: OmpSessionEntry[] = []; + if (start < bytesRead) fragments.push(chunk.subarray(start, bytesRead)); + position += bytesRead; + await setImmediate(); + } + if (fragments.length) yield { line: Buffer.concat(fragments), offset: lineOffset }; +} + +function parseOmpEntry(line: Buffer): OmpSessionEntry | null { + try { + const value: unknown = JSON.parse(line.toString("utf8")); + return value && typeof value === "object" && typeof Reflect.get(value, "id") === "string" + ? (value as OmpSessionEntry) + : null; + } catch { + // OMP can leave a partial last record; malformed records have always been ignored. + return null; + } +} + +async function selectOmpChain( + locations: readonly OmpEntryLocation[], + byId: ReadonlyMap, + parentIds: ReadonlySet, + activeEntryId?: string, +): Promise { + let current = activeEntryId ? byId.get(activeEntryId) : undefined; + if (!current) { + for (let index = locations.length - 1; index >= 0; index--) { + const location = locations[index]!; + if (!parentIds.has(location.id)) { + current = location; + break; + } + if (index % 256 === 0) await setImmediate(); + } + current ??= locations.at(-1); + } + const chain: OmpEntryLocation[] = []; const seen = new Set(); while (current?.id && !seen.has(current.id)) { chain.push(current); seen.add(current.id); current = current.parentId ? byId.get(current.parentId) : undefined; + if (chain.length % 256 === 0) await setImmediate(); + } + return chain; +} + +export async function readActiveOmpEntryChain( + sessionFile: string, + activeEntryId?: string, +): Promise { + const file = await open(sessionFile, "r"); + try { + const stat = await file.stat(); + if (stat.isDirectory()) { + throw Object.assign( + new Error(`EISDIR: illegal operation on a directory, read '${sessionFile}'`), + { + code: "EISDIR", + }, + ); + } + // One descriptor and a fixed byte boundary exclude appends made while replay is yielding. + // Retain graph metadata only: abandoned branches must not retain their message payloads. + const locations: OmpEntryLocation[] = []; + const byId = new Map(); + const parentIds = new Set(); + for await (const { line, offset } of readOmpLines(file, stat.size)) { + const entry = parseOmpEntry(line); + if (!entry) continue; + const location = { id: entry.id!, parentId: entry.parentId, offset }; + locations.push(location); + byId.set(location.id, location); + if (location.parentId) parentIds.add(location.parentId); + } + const chain = await selectOmpChain(locations, byId, parentIds, activeEntryId); + if (chain.length === 0) return []; + const selected = new Map(); + for (let index = 0; index < chain.length; index++) { + selected.set(chain[index]!.offset, chain.length - index - 1); + if (index % 256 === 0) await setImmediate(); + } + const entries: OmpSessionEntry[] = []; + for await (const { line, offset } of readOmpLines(file, stat.size)) { + const index = selected.get(offset); + if (index === undefined) continue; + const entry = parseOmpEntry(line); + // Truncation in place invalidates the indexed snapshot; never publish a partial chain. + if (!entry || entry.id !== chain[chain.length - index - 1]!.id) { + throw new Error(`OMP transcript changed during replay: ${sessionFile}`); + } + entries[index] = entry; + selected.delete(offset); + if (selected.size === 0) break; + } + if (selected.size) throw new Error(`OMP transcript changed during replay: ${sessionFile}`); + return entries; + } finally { + await file.close(); } - return chain.toReversed(); } function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { diff --git a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts index 1396d1f784..277cad58fe 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts @@ -56,6 +56,7 @@ test("bridge preserves tool failure messages and classifies invalid requests as getDaemonTcpHost: null, serviceProxyPublicBaseUrl: null, resolveScriptHealth: null, + getGlobalServicePorts: async () => undefined, logger, emit: () => {}, publishStatusUpdate: () => {}, diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 1bad16ba30..b9f31b61b2 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -3063,6 +3063,81 @@ describe("PiRpcAgentClient", () => { ]); }); + test.each([ + { outcome: "successful", error: null }, + { outcome: "failed", error: new Error("summarizer failed") }, + ])("waits for $outcome manual compaction before sending a new prompt", async ({ error }) => { + const { pi, session, events } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + fakeSession.compactError = error; + const compactionFinished = Promise.withResolvers(); + fakeSession.compactCompletion = compactionFinished.promise; + const handler = session.tryHandleOutOfBand("/compact"); + const compaction = handler!.run({ emit: () => {} }); + + const { turnId } = await session.startTurn("continue after compaction"); + await flushTurnScheduling(); + expect(fakeSession.compactRequests).toEqual([{}]); + expect(fakeSession.prompts).toEqual([]); + + compactionFinished.resolve(); + await compaction; + await flushTurnScheduling(); + expect(fakeSession.prompts).toEqual([{ message: "continue after compaction", imageCount: 0 }]); + + fakeSession.finishTurn(); + await events.nextTurnCompletion(); + expect(events.turnLifecycleEvents()).toContainEqual({ type: "turn_completed", turnId }); + expect(events.eventTypes()).not.toContain("turn_failed"); + }); + + test.each(["interrupt", "close"] as const)( + "does not send a prompt waiting on compaction after %s", + async (action) => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + const compactionFinished = Promise.withResolvers(); + fakeSession.compactCompletion = compactionFinished.promise; + const handler = session.tryHandleOutOfBand("/compact"); + const compaction = handler!.run({ emit: () => {} }); + + await session.startTurn("do not send this prompt"); + await session[action](); + compactionFinished.resolve(); + await compaction; + await flushTurnScheduling(); + + expect(fakeSession.prompts).toEqual([]); + }, + ); + + test("does not send a prompt when compaction finishes during interruption", async () => { + const { pi, session, events } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + const compactionFinished = Promise.withResolvers(); + fakeSession.compactCompletion = compactionFinished.promise; + const clearQueueFinished = Promise.withResolvers(); + fakeSession.clearQueueCompletion = clearQueueFinished.promise; + const handler = session.tryHandleOutOfBand("/compact"); + const compaction = handler!.run({ emit: () => {} }); + const { turnId } = await session.startTurn("do not send this prompt"); + + const interruption = session.interrupt(); + compactionFinished.resolve(); + await compaction; + await flushTurnScheduling(); + expect(fakeSession.controlRequests).toEqual(["clear_queue"]); + expect(fakeSession.prompts).toEqual([]); + + clearQueueFinished.resolve(); + await interruption; + expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_canceled", turnId }]); + expect(fakeSession.prompts).toEqual([]); + }); + test("closes Pi compact loading marker when RPC rejects after compaction starts", async () => { const { pi, session } = await createSession(); const fakeSession = pi.latestSession(); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index cf193e213b..881f8cbb29 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -1189,6 +1189,7 @@ export class PiRpcAgentSession implements AgentSession { private readonly contextUserEntries: PiCapturedEntry[] = []; private readonly treeUserEntriesById = new Map(); private readonly pendingExtensionResults = new Map(); + private compactionPromise: Promise | null = null; private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null; private outOfBandCompactionStarted = false; private outOfBandCompactionCompleted = false; @@ -1296,6 +1297,15 @@ export class PiRpcAgentSession implements AgentSession { void (async () => { try { + if (this.compactionPromise) { + // Pi RPC rejects prompts until the manual compaction request settles. + await this.compactionPromise.catch(() => undefined); + const turnCanceled = + this.closed || this.activeTurnId !== turnId || this.interruptingTurn?.turnId === turnId; + if (turnCanceled) { + return; + } + } const ack = await this.runtimeSession.prompt(payload.text, payload.images); this.activePromptRequestId = ack.requestId ?? null; const correlatedResult = ack.requestId @@ -1804,7 +1814,8 @@ export class PiRpcAgentSession implements AgentSession { this.outOfBandCompactionStarted = false; this.outOfBandCompactionCompleted = false; try { - await this.runtimeSession.compact(customInstructions); + this.compactionPromise = this.runtimeSession.compact(customInstructions); + await this.compactionPromise; } catch (error) { const message = error instanceof Error ? error.message : String(error); if ( @@ -1830,6 +1841,7 @@ export class PiRpcAgentSession implements AgentSession { }, }); } finally { + this.compactionPromise = null; if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) { this.outOfBandCompactionEmit = null; this.outOfBandCompactionStarted = false; diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index b8c7930bc4..983b86c19d 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -108,6 +108,7 @@ export class FakePiSession implements PiRuntimeSession { steerError: Error | null = null; readonly controlRequests: string[] = []; clearQueueError: Error | null = null; + clearQueueCompletion: Promise | null = null; readonly compactRequests: Array<{ customInstructions?: string }> = []; readonly setAutoCompactionRequests: boolean[] = []; readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = []; @@ -140,6 +141,7 @@ export class FakePiSession implements PiRuntimeSession { subagentSubscriptionError: Error | null = null; setSessionNameError: Error | null = null; compactError: Error | null = null; + compactCompletion: Promise | null = null; emitCompactEnd = true; getStateError: Error | null = null; getSessionStatsError: Error | null = null; @@ -233,6 +235,7 @@ export class FakePiSession implements PiRuntimeSession { async clearQueue(): Promise { this.controlRequests.push("clear_queue"); + await this.clearQueueCompletion; if (this.clearQueueError) { throw this.clearQueueError; } @@ -241,6 +244,7 @@ export class FakePiSession implements PiRuntimeSession { async compact(customInstructions?: string): Promise { this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions }); this.emit({ type: "compaction_start", reason: "manual" }); + await this.compactCompletion; if (this.emitCompactEnd) { this.emit({ type: "compaction_end", reason: "manual" }); } diff --git a/packages/server/src/server/agent/test-utils/timeline-page-reference.ts b/packages/server/src/server/agent/test-utils/timeline-page-reference.ts new file mode 100644 index 0000000000..8e9ab228a6 --- /dev/null +++ b/packages/server/src/server/agent/test-utils/timeline-page-reference.ts @@ -0,0 +1,249 @@ +// Frozen pre-index page selection: differential oracle for retained projection tests. +import type { AgentTimelineRow } from "../agent-timeline-store-types.js"; +import { + TimelineProjection, + projectTimelineRows, + type TimelineProjectionEntry, + type ProjectedTimelineRow, + type TimelineLimitDirection, + type ProjectedTimelinePageSelection, +} from "../timeline-projection.js"; + +function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): TimelineProjectionEntry[] { + return rows.map((row) => { + if ("seqStart" in row) return { ...(row as ProjectedTimelineRow) }; + return { + item: row.item, + timestamp: row.timestamp, + ...(row.turnId ? { turnId: row.turnId } : {}), + ...(row.providerMessageId ? { providerMessageId: row.providerMessageId } : {}), + seqStart: row.seq, + seqEnd: row.seq, + sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }], + collapsed: [], + }; + }); +} + +function getTimelineBounds( + rows: readonly AgentTimelineRow[], +): { minSeq: number; maxSeq: number } | null { + const first = rows[0]; + const last = rows[rows.length - 1]; + if (!first || !last) { + return null; + } + const entries = makeCanonicalEntries(rows); + return { + minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity), + maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0), + }; +} + +function firstSourceSeqInRange( + entry: TimelineProjectionEntry, + startSeq: number, + endSeq: number, +): number | null { + for (const range of entry.sourceSeqRanges) { + const firstSeq = Math.max(range.startSeq, startSeq); + if (firstSeq <= Math.min(range.endSeq, endSeq)) return firstSeq; + } + return null; +} + +interface ProjectedEntryCandidate { + entry: TimelineProjectionEntry; + index: number; + firstSourceSeq: number; +} + +function selectProjectedEntriesAfter(input: { + entries: readonly TimelineProjectionEntry[]; + startSeq: number; + maxSeq: number; + limit: number; +}): { entries: TimelineProjectionEntry[]; endSeq: number | null } { + const eligible = input.entries + .map((entry, index) => ({ + entry, + index, + firstSourceSeq: firstSourceSeqInRange(entry, input.startSeq, input.maxSeq), + })) + .filter((candidate): candidate is ProjectedEntryCandidate => candidate.firstSourceSeq !== null) + .sort((left, right) => left.firstSourceSeq - right.firstSourceSeq || left.index - right.index); + const selected = input.limit === 0 ? eligible : eligible.slice(0, input.limit); + const selectedEntries = selected + .sort((left, right) => left.index - right.index) + .map((candidate) => candidate.entry); + if (selectedEntries.length === 0) return { entries: [], endSeq: null }; + + const selectedRanges = selectedEntries + .flatMap((entry) => entry.sourceSeqRanges) + .sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq); + // Wide projected entries can include future, discontiguous source ranges. The + // page cursor advances only through source rows covered without a gap. + let endSeq = input.startSeq - 1; + for (const range of selectedRanges) { + if (range.endSeq <= endSeq) continue; + if (range.startSeq > endSeq + 1) break; + endSeq = Math.min(range.endSeq, input.maxSeq); + } + + return { + entries: selectedEntries, + endSeq: endSeq >= input.startSeq ? endSeq : null, + }; +} + +function selectProjectedEntriesBefore(input: { + entries: readonly TimelineProjectionEntry[]; + endSeq: number; + limit: number; +}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } { + // Older history follows projected display order. Lifecycle updates can move an + // entry's seqEnd without moving its display anchor, so seqStart keeps the full + // projected item on exactly one backward page. + const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq); + const selected = + input.limit === 0 || input.limit >= eligible.length + ? eligible + : eligible.slice(eligible.length - input.limit); + + return { + entries: selected, + startSeq: selected[0]?.seqStart ?? null, + hasOlder: selected.length < eligible.length, + }; +} + +function selectProjectedEntriesTail( + projectedAll: TimelineProjectionEntry[], + limit: number, + bounds: { minSeq: number; maxSeq: number }, +): ProjectedTimelinePageSelection { + let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit); + // Include earlier display anchors with updates in the selected source window, + // so a tail certifies contiguous coverage even with interleaved tool updates. + for (let i = start - 1; i >= 0; i--) { + if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i; + } + const selected = projectedAll.slice(start); + return { + entries: selected, + startSeq: selected[0]?.seqStart ?? null, + endSeq: bounds.maxSeq, + hasOlder: start > 0, + hasNewer: false, + }; +} + +export function selectReferenceProjectedTimelinePage(input: { + rows: readonly AgentTimelineRow[]; + bounds?: { minSeq: number; maxSeq: number }; + direction: TimelineLimitDirection; + cursorSeq?: number; + limit?: number; +}): ProjectedTimelinePageSelection { + const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit)); + const bounds = input.bounds ?? getTimelineBounds(input.rows); + const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" }); + if (!bounds) { + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: false, + hasNewer: false, + }; + } + + if (projectedAll.length === 0) { + if (input.direction === "after") { + const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: cursorSeq >= bounds.minSeq, + hasNewer: cursorSeq < bounds.maxSeq, + }; + } + if (input.direction === "before") { + const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: cursorSeq > bounds.minSeq, + hasNewer: cursorSeq <= bounds.maxSeq, + }; + } + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: false, + hasNewer: false, + }; + } + + if (input.direction === "tail") { + return selectProjectedEntriesTail(projectedAll, limit, bounds); + } + + if (input.direction === "after") { + const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; + const startSeq = Math.max(bounds.minSeq, cursorSeq + 1); + const selected = selectProjectedEntriesAfter({ + entries: projectedAll, + startSeq, + maxSeq: bounds.maxSeq, + limit, + }); + return { + entries: selected.entries, + startSeq: selected.endSeq === null ? null : startSeq, + endSeq: selected.endSeq, + hasOlder: startSeq > bounds.minSeq, + hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq, + }; + } + + const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; + const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1); + if (endSeq < bounds.minSeq) { + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: false, + hasNewer: endSeq < bounds.maxSeq, + }; + } + + const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit }); + return { + entries: selected.entries, + startSeq: selected.startSeq, + endSeq, + hasOlder: selected.hasOlder, + hasNewer: endSeq < bounds.maxSeq, + }; +} + +// Existing selection fixtures exercise the retained owner through its public API. +export function selectProjectedTimelinePage( + input: Parameters[0], +) { + const projection = new TimelineProjection(input.rows); + const bounds = input.bounds ?? getTimelineBounds(input.rows) ?? { minSeq: 0, maxSeq: 0 }; + const page = projection.selectPage({ ...input, bounds }); + return { + ...page, + entries: page.entries.map((row) => { + const { seq: _seq, ...entry } = row as ProjectedTimelineRow; + return entry; + }), + }; +} diff --git a/packages/server/src/server/agent/timeline-projection.test.ts b/packages/server/src/server/agent/timeline-projection.test.ts index 2d8cf9d4a1..f2f832ffcf 100644 --- a/packages/server/src/server/agent/timeline-projection.test.ts +++ b/packages/server/src/server/agent/timeline-projection.test.ts @@ -1,9 +1,9 @@ +import { selectProjectedTimelinePage } from "./test-utils/timeline-page-reference.js"; import { describe, expect, test } from "vitest"; import type { AgentTimelineRow } from "./agent-manager.js"; import { projectTimelineRows, - selectProjectedTimelinePage, selectTimelineWindowByProjectedLimit, } from "./timeline-projection.js"; diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index 1f72cdc498..3a65f28531 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -295,12 +295,43 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[] /** A retained item. `seq` is its latest source event, not its display anchor. */ export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry; +interface ProjectedTimelinePageOptions { + bounds: { minSeq: number; maxSeq: number }; + direction: TimelineLimitDirection; + cursorSeq?: number; + limit?: number; +} + /** Owns projected state; source events are consumed, never retained. */ export class TimelineProjection { private readonly rows: ProjectedTimelineRow[] = []; private readonly identities = new Map(); + private lastAssistantRowIndex: number | null = null; + // Disjoint source-coverage spans in display order. A tail starting inside a + // span must include its first anchor to cover interleaved lifecycle updates. + private readonly spans: { start: number; endSeq: number }[] = []; + + private readonly sourceRanges: { startSeq: number; endSeq: number; rowIndex: number }[] = []; + + constructor(rows: readonly AgentTimelineRow[] = []) { + for (const row of rows) this.appendProjected(row); + for (const [rowIndex, row] of this.rows.entries()) { + for (const range of row.sourceSeqRanges) this.sourceRanges.push({ ...range, rowIndex }); + } + this.sourceRanges.sort((a, b) => a.startSeq - b.startSeq); + } append(row: AgentTimelineRow): void { + const rowIndex = this.appendProjected(row); + const last = this.sourceRanges.at(-1); + if (last && last.rowIndex === rowIndex && last.endSeq + 1 === row.seq) { + last.endSeq = row.seq; + } else { + this.sourceRanges.push({ startSeq: row.seq, endSeq: row.seq, rowIndex }); + } + } + + private appendProjected(row: AgentTimelineRow): number { const entry = makeCanonicalEntries([row])[0]; const identity = timelineItemIdentity(row.item); const index = identity === null ? undefined : this.identities.get(identity); @@ -308,7 +339,8 @@ export class TimelineProjection { const merged = existing ? mergeIdentityEntries(existing, entry) : null; if (merged && index !== undefined) { this.rows[index] = { ...merged, seq: merged.seqEnd }; - return; + this.extendSpan(index); + return index; } const previous = this.rows.at(-1); const adjacent = previous @@ -317,16 +349,159 @@ export class TimelineProjection { if (previous && adjacent.length === 1) { const mergedText = adjacent[0]; this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd }; - return; + this.extendSpan(this.rows.length - 1); + return this.rows.length - 1; } if (identity !== null) this.identities.set(identity, this.rows.length); + if (entry.item.type === "assistant_message") this.lastAssistantRowIndex = this.rows.length; this.rows.push({ ...entry, seq: entry.seqEnd }); + const last = this.spans.at(-1); + if (last && last.endSeq >= entry.seqStart) { + last.endSeq = Math.max(last.endSeq, entry.seqEnd); + } else { + this.spans.push({ start: this.rows.length - 1, endSeq: entry.seqEnd }); + } + return this.rows.length - 1; + } + + private spanIndex(rowIndex: number): number { + let low = 0; + let high = this.spans.length; + while (low + 1 < high) { + const middle = (low + high) >>> 1; + if (this.spans[middle].start <= rowIndex) low = middle; + else high = middle; + } + return low; + } + + private extendSpan(rowIndex: number): void { + const index = this.spanIndex(rowIndex); + const span = this.spans[index]; + span.endSeq = Math.max(span.endSeq, this.rows[rowIndex].seqEnd); + let end = index + 1; + while (end < this.spans.length && this.rows[this.spans[end].start].seqStart <= span.endSeq) { + span.endSeq = Math.max(span.endSeq, this.spans[end].endSeq); + end++; + } + this.spans.splice(index + 1, end - index - 1); } getRows(): ProjectedTimelineRow[] { return this.rows.map((row) => ({ ...row })); } + selectPage(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { + if (this.rows.length === 0) { + const cursor = + input.cursorSeq ?? + (input.direction === "before" ? input.bounds.maxSeq + 1 : input.bounds.minSeq - 1); + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: + input.direction === "after" + ? cursor >= input.bounds.minSeq + : input.direction === "before" && cursor > input.bounds.minSeq, + hasNewer: + input.direction === "after" + ? cursor < input.bounds.maxSeq + : input.direction === "before" && cursor <= input.bounds.maxSeq, + }; + } + switch (input.direction) { + case "tail": + return this.selectTail(input); + case "after": + return this.selectAfter(input); + case "before": + return this.selectBefore(input); + } + } + + private selectTail(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { + const limit = Math.max(0, Math.floor(input.limit ?? 0)); + const requestedStart = limit === 0 ? 0 : Math.max(0, this.rows.length - limit); + const start = this.spans[this.spanIndex(requestedStart)].start; + return { + entries: this.rows.slice(start), + startSeq: this.rows[start].seqStart, + endSeq: input.bounds.maxSeq, + hasOlder: start > 0, + hasNewer: false, + }; + } + + private selectAfter(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { + const startSeq = Math.max( + input.bounds.minSeq, + (input.cursorSeq ?? input.bounds.minSeq - 1) + 1, + ); + let low = 0; + let high = this.sourceRanges.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (this.sourceRanges[middle].endSeq < startSeq) low = middle + 1; + else high = middle; + } + const indexes = new Set(); + const limit = Math.max(0, Math.floor(input.limit ?? 0)); + for (let i = low; i < this.sourceRanges.length; i++) { + const range = this.sourceRanges[i]; + if (range.startSeq > input.bounds.maxSeq) break; + indexes.add(range.rowIndex); + if (limit > 0 && indexes.size === limit) break; + } + const entries = [...indexes].sort((a, b) => a - b).map((index) => this.rows[index]); + const selected = selectProjectedEntriesAfter({ + entries, + startSeq, + maxSeq: input.bounds.maxSeq, + limit, + }); + return { + entries: selected.entries, + startSeq: selected.endSeq === null ? null : startSeq, + endSeq: selected.endSeq, + hasOlder: startSeq > input.bounds.minSeq, + hasNewer: selected.endSeq !== null && selected.endSeq < input.bounds.maxSeq, + }; + } + + private selectBefore(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { + const endSeq = Math.min(input.bounds.maxSeq, (input.cursorSeq ?? input.bounds.maxSeq + 1) - 1); + if (endSeq >= input.bounds.minSeq) { + let low = 0; + let high = this.rows.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (this.rows[middle].seqStart <= endSeq) low = middle + 1; + else high = middle; + } + const limit = Math.max(0, Math.floor(input.limit ?? 0)); + const start = limit === 0 ? 0 : Math.max(0, low - limit); + return { + entries: this.rows.slice(start, low), + startSeq: this.rows[start]?.seqStart ?? null, + endSeq, + hasOlder: start > 0, + hasNewer: endSeq < input.bounds.maxSeq, + }; + } + return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true }; + } + + getLastItem(seq: number): AgentTimelineItem | null { + const last = this.sourceRanges.at(-1); + return last?.endSeq === seq ? this.rows[last.rowIndex].item : null; + } + + getLastAssistantMessage(): string | null { + const row = this.lastAssistantRowIndex === null ? null : this.rows[this.lastAssistantRowIndex]; + return row?.item.type === "assistant_message" ? row.item.text : null; + } + enrichSubmittedUserMessage( clientMessageId: string, providerMessageId: string, @@ -444,21 +619,6 @@ export function selectTimelineWindowByProjectedLimit(input: { }; } -function getTimelineBounds( - rows: readonly AgentTimelineRow[], -): { minSeq: number; maxSeq: number } | null { - const first = rows[0]; - const last = rows[rows.length - 1]; - if (!first || !last) { - return null; - } - const entries = makeCanonicalEntries(rows); - return { - minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity), - maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0), - }; -} - function firstSourceSeqInRange( entry: TimelineProjectionEntry, startSeq: number, @@ -515,142 +675,6 @@ function selectProjectedEntriesAfter(input: { }; } -function selectProjectedEntriesBefore(input: { - entries: readonly TimelineProjectionEntry[]; - endSeq: number; - limit: number; -}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } { - // Older history follows projected display order. Lifecycle updates can move an - // entry's seqEnd without moving its display anchor, so seqStart keeps the full - // projected item on exactly one backward page. - const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq); - const selected = - input.limit === 0 || input.limit >= eligible.length - ? eligible - : eligible.slice(eligible.length - input.limit); - - return { - entries: selected, - startSeq: selected[0]?.seqStart ?? null, - hasOlder: selected.length < eligible.length, - }; -} - -function selectProjectedEntriesTail( - projectedAll: TimelineProjectionEntry[], - limit: number, - bounds: { minSeq: number; maxSeq: number }, -): ProjectedTimelinePageSelection { - let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit); - // Include earlier display anchors with updates in the selected source window, - // so a tail certifies contiguous coverage even with interleaved tool updates. - for (let i = start - 1; i >= 0; i--) { - if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i; - } - const selected = projectedAll.slice(start); - return { - entries: selected, - startSeq: selected[0]?.seqStart ?? null, - endSeq: bounds.maxSeq, - hasOlder: start > 0, - hasNewer: false, - }; -} - -export function selectProjectedTimelinePage(input: { - rows: readonly AgentTimelineRow[]; - bounds?: { minSeq: number; maxSeq: number }; - direction: TimelineLimitDirection; - cursorSeq?: number; - limit?: number; -}): ProjectedTimelinePageSelection { - const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit)); - const bounds = input.bounds ?? getTimelineBounds(input.rows); - const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" }); - if (!bounds) { - return { - entries: [], - startSeq: null, - endSeq: null, - hasOlder: false, - hasNewer: false, - }; - } - - if (projectedAll.length === 0) { - if (input.direction === "after") { - const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; - return { - entries: [], - startSeq: null, - endSeq: null, - hasOlder: cursorSeq >= bounds.minSeq, - hasNewer: cursorSeq < bounds.maxSeq, - }; - } - if (input.direction === "before") { - const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; - return { - entries: [], - startSeq: null, - endSeq: null, - hasOlder: cursorSeq > bounds.minSeq, - hasNewer: cursorSeq <= bounds.maxSeq, - }; - } - return { - entries: [], - startSeq: null, - endSeq: null, - hasOlder: false, - hasNewer: false, - }; - } - - if (input.direction === "tail") { - return selectProjectedEntriesTail(projectedAll, limit, bounds); - } - - if (input.direction === "after") { - const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; - const startSeq = Math.max(bounds.minSeq, cursorSeq + 1); - const selected = selectProjectedEntriesAfter({ - entries: projectedAll, - startSeq, - maxSeq: bounds.maxSeq, - limit, - }); - return { - entries: selected.entries, - startSeq: selected.endSeq === null ? null : startSeq, - endSeq: selected.endSeq, - hasOlder: startSeq > bounds.minSeq, - hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq, - }; - } - - const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; - const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1); - if (endSeq < bounds.minSeq) { - return { - entries: [], - startSeq: null, - endSeq: null, - hasOlder: false, - hasNewer: endSeq < bounds.maxSeq, - }; - } - - const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit }); - return { - entries: selected.entries, - startSeq: selected.startSeq, - endSeq, - hasOlder: selected.hasOlder, - hasNewer: endSeq < bounds.maxSeq, - }; -} - /** * Apply a projected-count limit to a flat AgentTimelineItem[] without seq metadata. * Used by callers that only have items in hand (e.g. MCP tools reading diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 6b84f17161..0ca6c5a3e4 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -1,3 +1,4 @@ +import { registerPluginTools } from "./plugin-tools.js"; import type { FinishSubscriptions, FinishRegistration } from "../finish-subscriptions/index.js"; import type { AgentMessageDelivery } from "../agent-messages/index.js"; import { @@ -704,6 +705,7 @@ function createToolCatalog( } const localHostOperations = new Map(); const tools = new Map(); + const builtinNames = new Set(); const hostTools = new Map([ ["create_agent", "createAgent"], ["create_workspace", "createWorkspace"], @@ -738,6 +740,7 @@ function createToolCatalog( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool handlers are schema-validated at registration boundaries. handler: (input: any, context: PaseoToolExecutionContext) => Promise, ) => { + builtinNames.add(name); if (!isPaseoToolEnabled(options.paseoToolPolicy, name)) { return; } @@ -3272,6 +3275,14 @@ function createToolCatalog( }, ); + registerPluginTools({ + agentManager, + callerAgentId, + scope, + tools, + builtinNames, + policy: options.paseoToolPolicy, + }); return toCatalog(); } diff --git a/packages/server/src/server/agent/tools/plugin-tools.ts b/packages/server/src/server/agent/tools/plugin-tools.ts new file mode 100644 index 0000000000..c0c94d11ab --- /dev/null +++ b/packages/server/src/server/agent/tools/plugin-tools.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; +import type { AgentManager } from "../agent-manager.js"; +import { isPaseoToolEnabled } from "../paseo-tool-policy.js"; +import type { PaseoToolDefinition } from "./types.js"; +import { addModelVisibleStructuredContent } from "./paseo-tool-serialization.js"; + +export function registerPluginTools(options: { + agentManager: AgentManager; + callerAgentId?: string; + scope: "agent" | "top-level"; + tools: Map; + builtinNames: Set; + policy?: ProviderPaseoToolsPolicy; +}): void { + const { agentManager, callerAgentId, scope, tools, builtinNames, policy } = options; + if (scope !== "agent" || !callerAgentId) return; + for (const tool of agentManager.getPluginTools(callerAgentId)) { + if (builtinNames.has(tool.name)) { + throw new Error( + `Agent tool ${tool.name} from plugin ${tool.pluginId} collides with Paseo tool ${tool.name}`, + ); + } + if (!isPaseoToolEnabled(policy, tool.name)) continue; + tools.set(tool.name, { + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: z.fromJSONSchema(tool.inputSchema), + async handler(input, { signal }) { + const result = await agentManager.executePluginTool(tool, callerAgentId, input, signal); + return addModelVisibleStructuredContent({ + content: result.text === undefined ? [] : [{ type: "text", text: result.text }], + structuredContent: result.structuredContent, + isError: result.isError, + }); + }, + }); + } +} diff --git a/packages/server/src/server/atomic-file.test.ts b/packages/server/src/server/atomic-file.test.ts new file mode 100644 index 0000000000..a521a1cc3c --- /dev/null +++ b/packages/server/src/server/atomic-file.test.ts @@ -0,0 +1,147 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { writeFileAtomic, writeJsonFileAtomic } from "./atomic-file.js"; + +let directory: string; +beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-atomic-file-")); +}); +afterEach(async () => { + await fs.rm(directory, { recursive: true, force: true }); +}); + +it.each(["EPERM", "EACCES", "EBUSY"])( + "commits a completed receipt after transient Windows %s replace failures", + async (code) => { + const file = path.join(directory, "receipt.json"); + await writeJsonFileAtomic(file, { state: "pending" }); + let failures = 3; + let elapsed = 0; + await writeJsonFileAtomic( + file, + { state: "completed" }, + { + platform: "win32", + now: () => elapsed, + sleep: async (milliseconds) => { + elapsed += milliseconds; + }, + rename: async (source, destination) => { + if (failures-- > 0) { + expect(JSON.parse(await fs.readFile(destination, "utf8"))).toEqual({ + state: "pending", + }); + expect(JSON.parse(await fs.readFile(source, "utf8"))).toEqual({ state: "completed" }); + throw Object.assign(new Error("destination held open"), { code }); + } + await fs.rename(source, destination); + }, + }, + ); + expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "completed" }); + expect(await fs.readdir(directory)).toEqual(["receipt.json"]); + expect(elapsed).toBeGreaterThan(0); + }, +); + +it.each(["EPERM", "EACCES", "EBUSY"])( + "preserves the previous file and removes the temp file when Windows %s persists", + async (code) => { + const file = path.join(directory, "receipt.json"); + await writeJsonFileAtomic(file, { state: "pending" }); + const error = Object.assign(new Error("destination held open"), { code }); + const waits: number[] = []; + let elapsed = 0; + const attempts: number[] = []; + await expect( + writeJsonFileAtomic( + file, + { state: "completed" }, + { + platform: "win32", + now: () => elapsed, + sleep: async (milliseconds) => { + waits.push(milliseconds); + elapsed += milliseconds; + }, + rename: async () => { + attempts.push(elapsed); + throw error; + }, + }, + ), + ).rejects.toBe(error); + expect(elapsed).toBe(1000); + expect(waits.slice(0, 5)).toEqual([10, 20, 40, 80, 100]); + expect(Math.max(...waits)).toBe(100); + expect(attempts.every((time) => time < 1000)).toBe(true); + expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" }); + expect(await fs.readdir(directory)).toEqual(["receipt.json"]); + }, +); + +it.each([ + ["linux", "EPERM"], + ["darwin", "EACCES"], + ["linux", "EBUSY"], + ["win32", "ENOENT"], + ["win32", "ENOSPC"], + ["win32", "EXDEV"], +] as const)("does not retry %s %s failures", async (platform, code) => { + const file = path.join(directory, "receipt.json"); + await writeJsonFileAtomic(file, { state: "pending" }); + const error = Object.assign(new Error("rename failed"), { code }); + let attempts = 0; + const waits: number[] = []; + await expect( + writeFileAtomic(file, "replacement", { + platform, + now: () => 0, + sleep: async (milliseconds) => { + waits.push(milliseconds); + }, + rename: async () => { + attempts++; + throw error; + }, + }), + ).rejects.toBe(error); + expect(attempts).toBe(1); + expect(waits).toEqual([]); + expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" }); + expect(await fs.readdir(directory)).toEqual(["receipt.json"]); +}); + +it("does not retry after a rename consumes the Windows retry budget", async () => { + const file = path.join(directory, "receipt.json"); + const error = Object.assign(new Error("slow rename failed"), { code: "EPERM" }); + let elapsed = 0; + const waits: number[] = []; + await expect( + writeFileAtomic(file, "replacement", { + platform: "win32", + now: () => elapsed, + sleep: async (milliseconds) => { + waits.push(milliseconds); + }, + rename: async () => { + elapsed += 1000; + throw error; + }, + }), + ).rejects.toBe(error); + expect(waits).toEqual([]); + expect(await fs.readdir(directory)).toEqual([]); +}); + +it("creates parent directories and replaces binary and JSON files through the default runtime", async () => { + const file = path.join(directory, "nested", "file"); + const bytes = Buffer.from([0, 255, 128, 1]); + await writeFileAtomic(file, bytes); + expect(await fs.readFile(file)).toEqual(bytes); + await writeJsonFileAtomic(file, { state: "completed" }); + expect(await fs.readFile(file, "utf8")).toBe('{\n "state": "completed"\n}'); + expect(await fs.readdir(path.dirname(file))).toEqual(["file"]); +}); diff --git a/packages/server/src/server/atomic-file.ts b/packages/server/src/server/atomic-file.ts index cedc8d51ea..9f9c9f9568 100644 --- a/packages/server/src/server/atomic-file.ts +++ b/packages/server/src/server/atomic-file.ts @@ -1,10 +1,26 @@ import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; + +interface AtomicFileRuntime { + platform: NodeJS.Platform; + rename: (source: string, destination: string) => Promise; + now: () => number; + sleep: (milliseconds: number) => Promise; +} + +const runtime: AtomicFileRuntime = { + platform: process.platform, + rename: fs.rename, + now: () => performance.now(), + sleep, +}; export async function writeFileAtomic( filePath: string, data: string | NodeJS.ArrayBufferView, + operations: AtomicFileRuntime = runtime, ): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); const tempPath = path.join( @@ -13,13 +29,48 @@ export async function writeFileAtomic( ); try { await fs.writeFile(tempPath, data, "utf8"); - await fs.rename(tempPath, filePath); + await replaceFile(tempPath, filePath, operations); } catch (error) { await fs.rm(tempPath, { force: true }); throw error; } } -export async function writeJsonFileAtomic(filePath: string, value: unknown): Promise { - await writeFileAtomic(filePath, JSON.stringify(value, null, 2)); +async function replaceFile( + source: string, + destination: string, + operations: AtomicFileRuntime, +): Promise { + // Windows readers/scanners can briefly deny replacement. Yield with backoff, + // but keep interactive persistence failures bounded to a one-second retry budget. + const deadline = operations.now() + 1000; + let delay = 10; + for (;;) { + try { + await operations.rename(source, destination); + return; + } catch (error) { + if ( + operations.platform !== "win32" || + !(error instanceof Error) || + !("code" in error) || + !["EPERM", "EACCES", "EBUSY"].includes(String(error.code)) + ) { + throw error; + } + const remaining = deadline - operations.now(); + if (remaining <= 0) throw error; + await operations.sleep(Math.min(delay, remaining)); + if (operations.now() >= deadline) throw error; + delay = Math.min(delay * 2, 100); + } + } +} + +export async function writeJsonFileAtomic( + filePath: string, + value: unknown, + operations: AtomicFileRuntime = runtime, +): Promise { + await writeFileAtomic(filePath, JSON.stringify(value, null, 2), operations); } diff --git a/packages/server/src/server/bootstrap.test.ts b/packages/server/src/server/bootstrap.test.ts index 0881e828d0..23d3dcd1a7 100644 --- a/packages/server/src/server/bootstrap.test.ts +++ b/packages/server/src/server/bootstrap.test.ts @@ -141,11 +141,11 @@ function liveCleanupHost() { test("bootstrap's host toggle prevents collection live, persists, and leaves other hosts enabled", async () => { const host = liveCleanupHost(); const other = liveCleanupHost(); - host.store.patch({ idleRuntimeCleanup: false }); + await host.store.patch({ idleRuntimeCleanup: false }); expect(loadPersistedConfig(host.home).agents?.idleRuntimeCleanup).toBe(false); expect((await host.sweep()).collected).toEqual([]); expect((await other.sweep()).collected).toHaveLength(1); - host.store.patch({ idleRuntimeCleanup: true }); + await host.store.patch({ idleRuntimeCleanup: true }); host.protect(true); expect((await host.sweep()).collected).toEqual([]); host.protect(false); @@ -160,7 +160,7 @@ test("bootstrap's config-file reload binding disables and enables eligible colle path.join(host.home, "config.json"), JSON.stringify({ version: 1, agents: { idleRuntimeCleanup: enabled } }), ); - expect(host.store.reload()).toEqual({ + expect(await host.store.reload()).toEqual({ appliedPaths: ["agents.idleRuntimeCleanup"], restartRequiredPaths: [], overrideControlledPaths: [], @@ -173,12 +173,12 @@ test.each([false, true])( "failed config apply restores prior idle cleanup behavior (previous: %s)", async (previous) => { const host = liveCleanupHost(); - host.store.patch({ idleRuntimeCleanup: previous }); + await host.store.patch({ idleRuntimeCleanup: previous }); const persistedBefore = loadPersistedConfig(host.home); const refuse = host.store.onApply(() => { throw new Error("Another live owner refused"); }); - expect(() => host.store.patch({ idleRuntimeCleanup: !previous })).toThrow( + await expect(host.store.patch({ idleRuntimeCleanup: !previous })).rejects.toThrow( "Another live owner refused", ); expect(host.store.get().idleRuntimeCleanup).toBe(previous); @@ -191,6 +191,6 @@ test.each([false, true])( test("disposing bootstrap's binding stops configuration delivery", async () => { const host = liveCleanupHost(); host.unsubscribe(); - host.store.patch({ idleRuntimeCleanup: false }); + await host.store.patch({ idleRuntimeCleanup: false }); expect((await host.sweep()).collected).toHaveLength(1); }); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 2eeccfb653..83ef2acb83 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -161,7 +161,11 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js"; import { createOrchestrationSkills } from "./orchestration-skills/index.js"; -import { resolveConfigFromPersisted, type CliConfigOverrides } from "./config.js"; +import { + resolveReloadableConfig, + type ResolvedDaemonSettings, + type CliConfigOverrides, +} from "./config.js"; import { resolvePaseoToolPolicy } from "./agent/paseo-tool-policy.js"; import { BrowserToolsBroker } from "./browser-tools/broker.js"; import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js"; @@ -196,7 +200,7 @@ import type { AgentProviderRuntimeSettingsMap, ProviderOverride, } from "./agent/provider-launch-config.js"; -import { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; +import { type PersistedConfig } from "./persisted-config.js"; import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js"; import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { ScriptHealthMonitor } from "./script-health-monitor.js"; @@ -551,7 +555,7 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri return config.trustedProxies ?? ["loopback"]; } -function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfig["relay"] { +function createInitialRelayConfig(config: ResolvedDaemonSettings): MutableDaemonConfig["relay"] { return { enabled: config.relayEnabled ?? true, requireProtectedSecurity: config.relayRequireProtectedSecurity ?? false, @@ -562,7 +566,7 @@ function resolveReloadEnv(config: PaseoDaemonConfig): NodeJS.ProcessEnv { return config.configReload?.env ?? process.env; } -function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig { +function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): MutableDaemonConfig { const providers = config.providerOverrides ?? {}; const initialConfig: MutableDaemonConfig = { @@ -638,14 +642,14 @@ export async function createPaseoDaemon( }), reloadSource: { resolve: (persisted) => { - const reloaded = resolveConfigFromPersisted(config.paseoHome, persisted, { + const reloaded = resolveReloadableConfig(persisted, { env: resolveReloadEnv(config), cli: config.configReload?.cli, relayEnabledFallback: config.configReload?.relayEnabledFallback, }); return { mutable: createInitialMutableDaemonConfig(reloaded), - overrideControlledPaths: reloaded.configReload?.overrideControlledPaths ?? [], + overrideControlledPaths: reloaded.overrideControlledPaths, }; }, }, @@ -995,6 +999,7 @@ export async function createPaseoDaemon( const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState(); const agentManager = new AgentManager({ pluginLifecycle: pluginRuntime, + pluginAgentTools: pluginRuntime, clients: initialAgentManagerState.clients, providerDefinitions: initialAgentManagerState.providerDefinitions, registry: agentStorage, @@ -1015,6 +1020,10 @@ export async function createPaseoDaemon( const unsubscribePluginProviders = pluginRuntime.subscribeProviderRegistrations(syncPluginProviders); + const unsubscribePluginSessionChanges = pluginRuntime.subscribeSessionOpenChanges(() => { + agentManager.invalidatePluginSessions(); + }); + const detachAgentStoragePersistence = attachAgentStoragePersistence( logger, agentManager, @@ -1536,7 +1545,7 @@ export async function createPaseoDaemon( spawnWorkspaceScript, assertAutomationAllowed: (workspaceId) => assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, workspaceId), - globalServicePorts: loadPersistedConfig(config.paseoHome).worktrees?.servicePorts, + getGlobalServicePorts: () => daemonConfigStore.readServicePorts(), }), markWorkspaceArchiving: markWorkspaceArchivingExternal, clearWorkspaceArchiving: clearWorkspaceArchivingExternal, @@ -1914,6 +1923,7 @@ export async function createPaseoDaemon( localCredential = null; await deleteLocalCredential(config.paseoHome); unsubscribePluginProviders(); + unsubscribePluginSessionChanges(); await pluginRuntime.stopAllPlugins().catch(() => undefined); await serviceProxy.stopStandalone().catch(() => undefined); await agentProviderRuntime.shutdown().catch(() => undefined); @@ -1933,6 +1943,7 @@ export async function createPaseoDaemon( // that is still open. Plugins themselves are stopped once every session // they serve has been closed, further down. unsubscribePluginProviders(); + unsubscribePluginSessionChanges(); await hubRelationships.stop(); workspaceReconciliation.dispose(); scriptHealthMonitor.stop(); diff --git a/packages/server/src/server/checkout-diff-cache.test.ts b/packages/server/src/server/checkout-diff-cache.test.ts index 37f9283320..a587f89894 100644 --- a/packages/server/src/server/checkout-diff-cache.test.ts +++ b/packages/server/src/server/checkout-diff-cache.test.ts @@ -132,3 +132,22 @@ test.each(["base", "uncommitted"] as const)( expect(await read("next", "next-after")).toEqual({ diff: "next-after" }); }, ); + +test("superseding a diff cancels its highlighting while identical readers share work", async () => { + const cache = new CheckoutDiffCache(() => 0); + const release = Promise.withResolvers<{ diff: string }>(); + let activeSignal: AbortSignal | undefined; + const load = (signal: AbortSignal) => { + activeSignal = signal; + return release.promise; + }; + const first = cache.read({ cwd: "repo", compare: { mode: "base" }, load }); + await Promise.resolve(); + expect(activeSignal).toBeDefined(); + expect(activeSignal!.aborted).toBe(false); + expect(cache.read({ cwd: "repo", compare: { mode: "base" }, load })).toBe(first); + cache.invalidate("repo", "base"); + expect(activeSignal!.aborted).toBe(true); + release.resolve({ diff: "old snapshot" }); + await first; +}); diff --git a/packages/server/src/server/checkout-diff-cache.ts b/packages/server/src/server/checkout-diff-cache.ts index 4547cdf2e5..71d7d95497 100644 --- a/packages/server/src/server/checkout-diff-cache.ts +++ b/packages/server/src/server/checkout-diff-cache.ts @@ -4,6 +4,7 @@ import type { CheckoutDiffCompare, CheckoutDiffResult } from "../utils/checkout- interface ActiveRead { promise: Promise; invalidated: boolean; + controller: AbortController; } interface CachedDiff { @@ -17,7 +18,7 @@ interface CheckoutDiffReadInput { compare: CheckoutDiffCompare; force?: boolean; reason?: string; - load: () => Promise; + load: (signal: AbortSignal) => Promise; } /** Completed payloads are bounded; active work survives invalidation and eviction. */ @@ -51,6 +52,7 @@ export class CheckoutDiffCache { if (pending) { if (force) { pending.invalidated = true; + pending.controller.abort(); this.values.delete(key); } if (!pending.invalidated) return pending.promise; @@ -66,8 +68,9 @@ export class CheckoutDiffCache { // Defer load until ownership is installed, including synchronous invalidations. const read: ActiveRead = { invalidated: false, + controller: new AbortController(), promise: Promise.resolve() - .then(load) + .then(() => load(read.controller.signal)) .then((value) => { // Never repopulate the cache with a snapshot invalidated during its build. if (!read.invalidated) @@ -86,7 +89,10 @@ export class CheckoutDiffCache { if (cachedCwd !== cwd || (cachedMode !== mode && cachedMode !== "all")) continue; this.values.delete(key); const pending = this.active.get(key); - if (pending) pending.invalidated = true; + if (pending) { + pending.invalidated = true; + pending.controller.abort(); + } } } } diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index e8c5f2faa0..72d1c30f7d 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -4,7 +4,12 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, test } from "vitest"; -import { loadConfig, resolveBundledWebUiDistDir, resolveConfigFromPersisted } from "./config.js"; +import { + loadConfig, + resolveBundledWebUiDistDir, + resolveConfigFromPersisted, + resolveReloadableConfig, +} from "./config.js"; import { loadPersistedConfig } from "./persisted-config.js"; const roots: string[] = []; @@ -14,6 +19,30 @@ describe("server config", () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + test("resolves reloadable settings without resolving the startup password", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-auth-reload-")); + roots.push(paseoHome); + const env = { PASEO_PASSWORD: "synthetic-audit-password" }; + const persisted = loadPersistedConfig(paseoHome); + const startup = resolveConfigFromPersisted(paseoHome, persisted, { env }); + const started = performance.now(); + const timer = new Promise((resolve) => + setTimeout(() => resolve(performance.now() - started), 0), + ); + const reloaded = resolveReloadableConfig( + { + ...persisted, + daemon: { ...persisted.daemon, browserTools: { enabled: true } }, + }, + { env }, + ); + const gap = await timer; + expect(reloaded).not.toHaveProperty("auth"); + expect(reloaded.browserToolsEnabled).toBe(true); + expect(startup.auth?.password).toBeTypeOf("string"); + expect(gap).toBeLessThan(50); + }); + test("records when the daemon is managed by Paseo Desktop", async () => { const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-desktop-managed-")); roots.push(paseoHome); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 74a8037d07..add78aa7f5 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -568,31 +568,43 @@ interface ResolveConfigFromPersistedOptions { relayEnabledFallback?: boolean; } +// Startup values, including authentication, never enter runtime settings resolution. +export type ResolvedDaemonSettings = Pick< + PaseoDaemonConfig, + | "corsAllowedOrigins" + | "hostnames" + | "trustedProxies" + | "mcpEnabled" + | "mcpInjectIntoAgents" + | "browserToolsEnabled" + | "git" + | "autoArchiveAfterMerge" + | "enableTerminalAgentHooks" + | "appendSystemPrompt" + | "terminalProfiles" + | "agentProfiles" + | "skillSelection" + | "pluginsEnabled" + | "plugins" + | "relayEnabled" + | "relayRequireProtectedSecurity" + | "appBaseUrl" + | "idleRuntimeCleanup" + | "providerCatalogRefreshTimeoutMs" + | "metadataGeneration" + | "providerOverrides" +>; + export function resolveConfigFromPersisted( paseoHome: string, persisted: PersistedConfig, options?: ResolveConfigFromPersistedOptions, ): PaseoDaemonConfig { - const resolvedOptions = options ?? {}; - const env = configurationEnvironment(resolvedOptions.env ?? process.env); - const cli = resolvedOptions.cli; + const env = configurationEnvironment(options?.env ?? process.env); + const cli = options?.cli; const relayEnabledFallback = - resolvedOptions.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined; - - const listen = resolveListenAddress(env, cli, persisted); - const { - mcpEnabled, - mcpInjectIntoAgents, - browserToolsEnabled, - autoArchiveAfterMerge, - appendSystemPrompt, - terminalProfiles, - agentProfiles, - hostnames, - trustedProxies, - appBaseUrl, - } = resolveStaticLoadConfigSettings(env, cli, persisted); - + options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined; + const { overrideControlledPaths, ...settings } = resolveReloadableConfig(persisted, options); const relay = resolveRelayConfig({ env, persisted, @@ -600,42 +612,14 @@ export function resolveConfigFromPersisted( cliRelayUseTls: cli?.relayUseTls, enabledFallback: relayEnabledFallback, }); - const serviceProxy = resolveServiceProxyConfig(env, persisted); - const webUi = resolveWebUiConfig(paseoHome, env, cli, persisted); - - const { openai, speech } = resolveSpeechConfig({ - paseoHome, - env, - persisted, - }); - + const { openai, speech } = resolveSpeechConfig({ paseoHome, env, persisted }); const voiceLlm = resolveVoiceLlmConfig(env, persisted); - const providerOverrides = extractProviderOverrides( - persisted.agents?.providers as Record | undefined, - ); - - const overrideControlledPaths = resolveOverrideControlledPaths(env, cli, persisted); - return { - listen, + ...settings, + listen: resolveListenAddress(env, cli, persisted), paseoHome, desktopManaged: env.PASEO_DESKTOP_MANAGED === "1", worktreesRoot: resolveWorktreesRoot(paseoHome, persisted), - corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted), - hostnames, - trustedProxies, - mcpEnabled, - mcpInjectIntoAgents, - browserToolsEnabled, - git: resolveGitProcessConfig(env, persisted), - autoArchiveAfterMerge, - enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false, - appendSystemPrompt, - terminalProfiles, - agentProfiles, - skillSelection: persisted.agents?.skills?.selection, - pluginsEnabled: persisted.pluginsEnabled ?? false, - plugins: persisted.plugins, pluginRegistries: persisted.pluginRegistries, pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY, mcpDebug: env.MCP_DEBUG === "1", @@ -643,27 +627,20 @@ export function resolveConfigFromPersisted( agentStoragePath: path.join(paseoHome, "agents"), staticDir: "public", agentClients: {}, - relayEnabled: relay.enabled, relayEnabledMutable: relay.enabledMutable, - relayRequireProtectedSecurity: relay.requireProtectedSecurity, relayEndpoint: relay.endpoint, relayPublicEndpoint: relay.publicEndpoint, relayUseTls: relay.useTls, relayPublicUseTls: relay.publicUseTls, - serviceProxy, - webUi, - appBaseUrl, + serviceProxy: resolveServiceProxyConfig(env, persisted), + webUi: resolveWebUiConfig(paseoHome, env, cli, persisted), auth: resolveAuthConfig(env, persisted), openai, speech, voiceLlmProvider: voiceLlm.provider, voiceLlmProviderExplicit: voiceLlm.providerExplicit, voiceLlmModel: voiceLlm.model, - agentProviderSettings: extractAgentProviderSettings(providerOverrides), - idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true, - providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs, - metadataGeneration: persisted.agents?.metadataGeneration, - providerOverrides, + agentProviderSettings: extractAgentProviderSettings(settings.providerOverrides), log: resolveLogConfigFromEnv(env, persisted), configReload: { env: { ...env }, @@ -675,6 +652,40 @@ export function resolveConfigFromPersisted( }; } +export function resolveReloadableConfig( + persisted: PersistedConfig, + options?: ResolveConfigFromPersistedOptions, +): ResolvedDaemonSettings & { overrideControlledPaths: string[] } { + const env = configurationEnvironment(options?.env ?? process.env); + const cli = options?.cli; + const relay = resolveRelayConfig({ + env, + persisted, + cliRelayEnabled: cli?.relayEnabled, + cliRelayUseTls: cli?.relayUseTls, + enabledFallback: + options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined, + }); + return { + ...resolveStaticLoadConfigSettings(env, cli, persisted), + corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted), + git: resolveGitProcessConfig(env, persisted), + enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false, + skillSelection: persisted.agents?.skills?.selection, + pluginsEnabled: persisted.pluginsEnabled ?? false, + plugins: persisted.plugins, + relayEnabled: relay.enabled, + relayRequireProtectedSecurity: relay.requireProtectedSecurity, + idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true, + providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs, + metadataGeneration: persisted.agents?.metadataGeneration, + providerOverrides: extractProviderOverrides( + persisted.agents?.providers as Record | undefined, + ), + overrideControlledPaths: resolveOverrideControlledPaths(env, cli, persisted), + }; +} + export function loadConfig( paseoHome: string, options?: Omit, diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index d8d5533cf6..b6765051f6 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -1544,7 +1544,7 @@ test( ); test( - "remote agent reports, local CLI sends and automatic completion share the recipient batch", + "remote agent reports, local CLI sends and automatic completion reach the same recipient", { timeout: 30000 }, async () => { const parentPrompts: string[] = []; @@ -1633,7 +1633,10 @@ test( }); expect(report.isError).not.toBe(true); expect(report.structuredContent).toMatchObject({ deliveryStatus: "queued", serverId: aId }); - expect(parentPrompts).toEqual([]); + // A CLI process can take longer than the two-second quiet window to start. + // Exercise each delivery across windows; delivery.test.ts owns batching with a clock. + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1); + expect(parentPrompts[0]).toContain("REMOTE_REPORT <ready>"); const cli = await promisify(execFile)( process.execPath, @@ -1656,7 +1659,8 @@ test( }, ); expect(JSON.parse(cli.stdout)).toMatchObject({ status: "queued" }); - expect(parentPrompts).toEqual([]); + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2); + expect(parentPrompts[1]).toContain("LOCAL_CLI_REPORT"); const receiptStates = async () => { const directory = join(a.paseoHome, "agent-requests"); const receipts = await Promise.all( @@ -1671,13 +1675,10 @@ test( .map((receipt) => receipt.state) .sort(); }; - expect(await receiptStates()).toEqual(["pending", "pending"]); finishChild.resolve(); - await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1); - expect(parentPrompts[0]).toContain("REMOTE_REPORT <ready>"); - expect(parentPrompts[0]).toContain("LOCAL_CLI_REPORT"); - expect(parentPrompts[0]).toContain('event="finished"'); - expect(parentPrompts[0]).toContain(`source-agent-id="${child.id}"`); + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(3); + expect(parentPrompts[2]).toContain('event="finished"'); + expect(parentPrompts[2]).toContain(`source-agent-id="${child.id}"`); await expect .poll(receiptStates, { timeout: 10000, @@ -1870,6 +1871,7 @@ test( }); const a = await daemon({ mcpEnabled: true, + logger: completionLogger(), agentClients: createTestAgentClients({ onStartTurn: (prompt) => { if (String(prompt).includes('kind="agent-notification"')) @@ -1931,8 +1933,14 @@ test( await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2); expect(parentPrompts[1]).toContain('event="finished"'); await expect - .poll(async () => - JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + // Provider start precedes dispatch acknowledgment and the durable removal commit. + .poll( + async () => + JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + { + timeout: 10000, + message: "Parent accepted completion; waiting for durable intent removal", + }, ) .toEqual([]); }, diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index f4c1a02de9..e3b6f48895 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -101,7 +101,7 @@ describe("DaemonConfigStore", () => { } }); - test("patch persists relay state and emits its field change", () => { + test("patch persists relay state and emits its field change", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, { @@ -117,13 +117,13 @@ describe("DaemonConfigStore", () => { const changes: unknown[] = []; store.onFieldChange("relay.enabled", (value) => changes.push(value)); - store.patch({ relay: { enabled: true } }); + await store.patch({ relay: { enabled: true } }); expect(changes).toEqual([true]); expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true); }); - test("patch persists the relay protected security requirement independently of relay enablement", () => { + test("patch persists the relay protected security requirement independently of relay enablement", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore( @@ -145,14 +145,14 @@ describe("DaemonConfigStore", () => { const changes: unknown[] = []; store.onFieldChange("relay.requireProtectedSecurity", (value) => changes.push(value)); - store.patch({ relay: { requireProtectedSecurity: true } }); + await store.patch({ relay: { requireProtectedSecurity: true } }); expect(changes).toEqual([true]); expect(store.get().relay).toEqual({ enabled: true, requireProtectedSecurity: true }); expect(loadPersistedConfig(paseoHome).daemon?.relay?.requireProtectedSecurity).toBe(true); }); - test("patch round-trips agent profiles through the strictly-parsed persisted config", () => { + test("patch round-trips agent profiles through the strictly-parsed persisted config", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, { @@ -166,7 +166,7 @@ describe("DaemonConfigStore", () => { appendSystemPrompt: "", }); - store.patch({ + await store.patch({ agentProfiles: [ { id: "profile_ui", @@ -198,7 +198,7 @@ describe("DaemonConfigStore", () => { expect(store.get().agentProfiles).toHaveLength(1); }); - test("patch replaces the whole agent profile list rather than merging entries", () => { + test("patch replaces the whole agent profile list rather than merging entries", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, { @@ -216,13 +216,13 @@ describe("DaemonConfigStore", () => { ], }); - store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] }); + await store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] }); expect(store.get().agentProfiles).toEqual([{ id: "a", name: "Keep", provider: "claude" }]); expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toHaveLength(1); }); - test("rolls back config when a field transition fails", () => { + test("rolls back config when a field transition fails", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, { @@ -241,14 +241,14 @@ describe("DaemonConfigStore", () => { } }); - expect(() => store.patch({ relay: { enabled: true } })).toThrow( + await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow( "Relay transport failed to start", ); expect(store.get().relay?.enabled).toBe(false); expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false); }); - test("rolls back live owners when a later transactional owner fails", () => { + test("rolls back live owners when a later transactional owner fails", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, { @@ -272,7 +272,7 @@ describe("DaemonConfigStore", () => { throw new Error("Provider refresh failed"); }); - expect(() => store.patch({ browserTools: { enabled: true } })).toThrow( + await expect(store.patch({ browserTools: { enabled: true } })).rejects.toThrow( "Provider refresh failed", ); expect(browserToolsEnabled).toBe(false); @@ -280,7 +280,7 @@ describe("DaemonConfigStore", () => { expect(loadPersistedConfig(paseoHome).daemon?.browserTools?.enabled).toBeUndefined(); }); - test("rejects relay patches when a launch override owns the setting", () => { + test("rejects relay patches when a launch override owns the setting", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore( @@ -299,12 +299,12 @@ describe("DaemonConfigStore", () => { { relayEnabledMutable: false }, ); - expect(() => store.patch({ relay: { enabled: true } })).toThrow( + await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow( "Relay is controlled by a daemon launch override", ); }); - test("unrelated patches do not persist a one-launch relay override", () => { + test("unrelated patches do not persist a one-launch relay override", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const persisted = loadPersistedConfig(paseoHome); @@ -331,12 +331,12 @@ describe("DaemonConfigStore", () => { { relayEnabledMutable: false }, ); - store.patch({ browserTools: { enabled: true } }); + await store.patch({ browserTools: { enabled: true } }); expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false); }); - test("unrelated patches persist only requested file intent", () => { + test("unrelated patches persist only requested file intent", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const before = loadPersistedConfig(paseoHome); @@ -362,7 +362,7 @@ describe("DaemonConfigStore", () => { { relayEnabledMutable: false }, ); - store.patch({ + await store.patch({ appendSystemPrompt: "Only this field", // Reload-only runtime state is accepted as unknown wire data for forward // compatibility but is not part of the patch capability. @@ -376,7 +376,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch persists provider enabled flags into config.json", () => { + test("patch persists provider enabled flags into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -417,7 +417,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ providers: { gemini: { enabled: false }, }, @@ -432,7 +432,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch persists provider Paseo-tool policy without changing availability", () => { + test("patch persists provider Paseo-tool policy without changing availability", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); writeFileSync( @@ -449,14 +449,14 @@ describe("DaemonConfigStore", () => { appendSystemPrompt: "", }); - store.patch({ + await store.patch({ providers: { claude: { paseoTools: { enabled: true, disabledTools: ["list_agents"] }, }, }, }); - store.patch({ + await store.patch({ providers: { claude: { paseoTools: { disabledTools: ["create_agent"] }, @@ -474,7 +474,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch removes provider entries from config.json", () => { + test("patch removes provider entries from config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -519,7 +519,7 @@ describe("DaemonConfigStore", () => { undefined, ); - const next = store.patch({ removeProviders: ["gemini"] }); + const next = await store.patch({ removeProviders: ["gemini"] }); expect(next.providers.gemini).toBeUndefined(); expect(next.providers.claude).toEqual({ enabled: false }); @@ -528,7 +528,7 @@ describe("DaemonConfigStore", () => { expect(persisted.agents?.providers?.claude).toEqual({ enabled: false }); }); - test("patch removes the providers object when the last provider is deleted", () => { + test("patch removes the providers object when the last provider is deleted", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -567,13 +567,13 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ removeProviders: ["gemini"] }); + await store.patch({ removeProviders: ["gemini"] }); const persisted = loadPersistedConfig(paseoHome); expect(persisted.agents?.providers).toBeUndefined(); }); - test("patch removes deleted providers from metadata generation", () => { + test("patch removes deleted providers from metadata generation", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -629,7 +629,7 @@ describe("DaemonConfigStore", () => { undefined, ); - const next = store.patch({ removeProviders: ["gemini"] }); + const next = await store.patch({ removeProviders: ["gemini"] }); expect(next.metadataGeneration.providers).toEqual([{ provider: "claude", model: "haiku" }]); const persisted = loadPersistedConfig(paseoHome); @@ -638,7 +638,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch persists provider removal when in-memory config is already clean", () => { + test("patch persists provider removal when in-memory config is already clean", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -680,7 +680,7 @@ describe("DaemonConfigStore", () => { undefined, ); - const next = store.patch({ removeProviders: ["gemini"] }); + const next = await store.patch({ removeProviders: ["gemini"] }); expect(next.providers.gemini).toBeUndefined(); const persisted = loadPersistedConfig(paseoHome); @@ -688,7 +688,7 @@ describe("DaemonConfigStore", () => { expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] }); }); - test("patch persists append system prompt into config.json", () => { + test("patch persists append system prompt into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -706,7 +706,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ appendSystemPrompt: "Prefer terse replies.", }); @@ -714,7 +714,7 @@ describe("DaemonConfigStore", () => { expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies."); }); - test("patch persists browser tools opt-in into config.json", () => { + test("patch persists browser tools opt-in into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -731,13 +731,13 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ browserTools: { enabled: true } }); + await store.patch({ browserTools: { enabled: true } }); const persisted = loadPersistedConfig(paseoHome); expect(persisted.daemon?.browserTools).toEqual({ enabled: true }); }); - test("patch persists provider additional models into config.json", () => { + test("patch persists provider additional models into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -755,7 +755,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ providers: { claude: { additionalModels: [ @@ -779,7 +779,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch persists daemon append system prompt into config.json", () => { + test("patch persists daemon append system prompt into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -797,7 +797,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ appendSystemPrompt: "Prefer terse replies.", }); @@ -805,7 +805,7 @@ describe("DaemonConfigStore", () => { expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies."); }); - test("patch persists enable terminal agent hooks into config.json", () => { + test("patch persists enable terminal agent hooks into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -822,13 +822,13 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ enableTerminalAgentHooks: true }); + await store.patch({ enableTerminalAgentHooks: true }); const persisted = loadPersistedConfig(paseoHome); expect(persisted.daemon?.enableTerminalAgentHooks).toBe(true); }); - test("patch persists metadata generation providers into config.json", () => { + test("patch persists metadata generation providers into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -846,7 +846,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ metadataGeneration: { providers: [ { provider: "claude", model: "haiku" }, @@ -864,7 +864,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch persists clearing metadata generation providers into config.json", () => { + test("patch persists clearing metadata generation providers into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -899,13 +899,13 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ metadataGeneration: { providers: [] } }); + await store.patch({ metadataGeneration: { providers: [] } }); const persisted = loadPersistedConfig(paseoHome); expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] }); }); - test("patch persists the host idle runtime cleanup opt-out for the next daemon start", () => { + test("patch persists the host idle runtime cleanup opt-out for the next daemon start", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); writeFileSync( @@ -930,7 +930,7 @@ describe("DaemonConfigStore", () => { undefined, ); - expect(store.patch({ idleRuntimeCleanup: false }).idleRuntimeCleanup).toBe(false); + expect((await store.patch({ idleRuntimeCleanup: false })).idleRuntimeCleanup).toBe(false); expect(loadPersistedConfig(paseoHome).agents).toEqual({ metadataGeneration: { providers: [{ provider: "claude" }] }, @@ -938,11 +938,11 @@ describe("DaemonConfigStore", () => { }); expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false); - store.patch({ idleRuntimeCleanup: true }); + await store.patch({ idleRuntimeCleanup: true }); expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true); }); - test("patch persists custom ACP provider overrides into config.json", () => { + test("patch persists custom ACP provider overrides into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -960,7 +960,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ providers: { "paseo-e2e-acp": { extends: "acp", @@ -1028,7 +1028,49 @@ describe("DaemonConfigStore reload", () => { writeFileSync(path.join(paseoHome, "config.json"), `${JSON.stringify(config, null, 2)}\n`); } - test("applies mutable edits and reports startup-only edits", () => { + test("applies overlapping plugin mutations to the latest committed sources", async () => { + const { store } = createReloadableStore({ + initialPersisted: { + plugins: { + a: { source: "directory", path: "/a", enabled: false }, + b: { source: "directory", path: "/b", enabled: true }, + }, + }, + }); + const enable = store.patch((current) => ({ + plugins: { + ...current.plugins, + a: { ...current.plugins.a!, enabled: true }, + }, + })); + const disable = store.patch((current) => ({ + plugins: { + ...current.plugins, + b: { ...current.plugins.b!, enabled: false }, + }, + })); + await Promise.all([enable, disable]); + expect(store.get().plugins).toEqual({ + a: { source: "directory", path: "/a", enabled: true }, + b: { source: "directory", path: "/b", enabled: false }, + }); + }); + + test("serializes overlapping patches before committing their combined state", async () => { + const { paseoHome, store } = createReloadableStore(); + const first = store.patch({ appendSystemPrompt: "first" }); + const second = store.patch({ browserTools: { enabled: true } }); + expect(store.get().appendSystemPrompt).toBe(""); + await Promise.all([first, second]); + expect(store.get().appendSystemPrompt).toBe("first"); + expect(store.get().browserTools.enabled).toBe(true); + expect(loadPersistedConfig(paseoHome).daemon).toMatchObject({ + appendSystemPrompt: "first", + browserTools: { enabled: true }, + }); + }); + + test("applies mutable edits and reports startup-only edits", async () => { const { paseoHome, store, persisted } = createReloadableStore(); writeConfig(paseoHome, { ...persisted, @@ -1040,7 +1082,7 @@ describe("DaemonConfigStore reload", () => { }, }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [ "daemon.browserTools.enabled", "daemon.git.maxProcessConcurrency", @@ -1053,13 +1095,13 @@ describe("DaemonConfigStore reload", () => { expect(store.get().git).toEqual({ maxProcessesPerSecond: 12, maxProcessConcurrency: 3 }); }); - test("reloads idle runtime cleanup in both directions without requiring a restart", () => { + test("reloads idle runtime cleanup in both directions without requiring a restart", async () => { const { paseoHome, store, persisted } = createReloadableStore(); const changes: unknown[] = []; store.onFieldChange("idleRuntimeCleanup", (value) => changes.push(value)); for (const enabled of [false, true]) { writeConfig(paseoHome, { ...persisted, agents: { idleRuntimeCleanup: enabled } }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: ["agents.idleRuntimeCleanup"], restartRequiredPaths: [], overrideControlledPaths: [], @@ -1067,8 +1109,8 @@ describe("DaemonConfigStore reload", () => { expect(store.get().idleRuntimeCleanup).toBe(enabled); } expect(changes).toEqual([false, true]); - store.patch({ idleRuntimeCleanup: false }); - expect(store.reload()).toEqual({ + await store.patch({ idleRuntimeCleanup: false }); + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: [], @@ -1076,7 +1118,7 @@ describe("DaemonConfigStore reload", () => { expect(store.get().idleRuntimeCleanup).toBe(false); }); - test("applies the global plugin switch in both directions", () => { + test("applies the global plugin switch in both directions", async () => { const { paseoHome, store, persisted } = createReloadableStore({ initialPersisted: { version: 1, pluginsEnabled: false }, }); @@ -1084,7 +1126,7 @@ describe("DaemonConfigStore reload", () => { store.onFieldChange("pluginsEnabled", (value) => changes.push(value)); writeConfig(paseoHome, { ...persisted, pluginsEnabled: true }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: ["pluginsEnabled"], restartRequiredPaths: [], overrideControlledPaths: [], @@ -1092,7 +1134,7 @@ describe("DaemonConfigStore reload", () => { expect(store.get().pluginsEnabled).toBe(true); writeConfig(paseoHome, { ...persisted, pluginsEnabled: false }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: ["pluginsEnabled"], restartRequiredPaths: [], overrideControlledPaths: [], @@ -1101,7 +1143,7 @@ describe("DaemonConfigStore reload", () => { expect(changes).toEqual([true, false]); }); - test("classifies every leaf when a parent subtree is added", () => { + test("classifies every leaf when a parent subtree is added", async () => { const { paseoHome, store } = createReloadableStore({ initialPersisted: { version: 1 }, }); @@ -1116,14 +1158,14 @@ describe("DaemonConfigStore reload", () => { }, }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: ["daemon.relay.enabled"], restartRequiredPaths: ["daemon.relay.endpoint", "daemon.relay.useTls"], overrideControlledPaths: [], }); }); - test("classifies every leaf when the daemon subtree is removed", () => { + test("classifies every leaf when the daemon subtree is removed", async () => { const { paseoHome, store } = createReloadableStore({ initialPersisted: { version: 1, @@ -1144,7 +1186,7 @@ describe("DaemonConfigStore reload", () => { }); writeConfig(paseoHome, { version: 1 }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: [ "daemon.listen", @@ -1158,7 +1200,7 @@ describe("DaemonConfigStore reload", () => { expect(store.get().relay?.enabled).toBe(false); }); - test("keeps overridden leaves separate from restart-required siblings", () => { + test("keeps overridden leaves separate from restart-required siblings", async () => { const { paseoHome, store } = createReloadableStore({ initialPersisted: { version: 1 }, overrideControlledPaths: ["daemon.relay.enabled"], @@ -1170,25 +1212,25 @@ describe("DaemonConfigStore reload", () => { }, }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: ["daemon.relay.endpoint"], overrideControlledPaths: ["daemon.relay.enabled"], }); }); - test("invalid JSON and invalid schema apply nothing", () => { + test("invalid JSON and invalid schema apply nothing", async () => { const { paseoHome, store } = createReloadableStore(); writeFileSync(path.join(paseoHome, "config.json"), "{ nope\n"); - expect(() => store.reload()).toThrow("Invalid JSON"); + await expect(store.reload()).rejects.toThrow("Invalid JSON"); expect(store.get().browserTools.enabled).toBe(false); writeConfig(paseoHome, { daemon: { browserTools: { enabled: "yes" } } }); - expect(() => store.reload()).toThrow("Invalid config"); + await expect(store.reload()).rejects.toThrow("Invalid config"); expect(store.get().browserTools.enabled).toBe(false); }); - test("removing providers and optional profiles clears live state", () => { + test("removing providers and optional profiles clears live state", async () => { const { paseoHome, store, persisted } = createReloadableStore(); writeConfig(paseoHome, { ...persisted, @@ -1203,10 +1245,10 @@ describe("DaemonConfigStore reload", () => { }, }, }); - store.reload(); + await store.reload(); writeConfig(paseoHome, persisted); - const result = store.reload(); + const result = await store.reload(); expect(result.appliedPaths).toEqual([ "agents.providers", @@ -1218,7 +1260,7 @@ describe("DaemonConfigStore reload", () => { expect(store.get().agentProfiles).toBeUndefined(); }); - test("reports a launch-controlled edit without changing live state", () => { + test("reports a launch-controlled edit without changing live state", async () => { const { paseoHome, store, persisted } = createReloadableStore({ overrideControlledPaths: ["daemon.relay.enabled"], }); @@ -1228,7 +1270,7 @@ describe("DaemonConfigStore reload", () => { daemon: { ...persisted.daemon, relay: { enabled: !initialRelay } }, }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: ["daemon.relay.enabled"], @@ -1236,7 +1278,7 @@ describe("DaemonConfigStore reload", () => { expect(store.get().relay?.enabled).toBe(initialRelay); }); - test("an unrelated patch does not mark a manual override-owned edit as applied", () => { + test("an unrelated patch does not mark a manual override-owned edit as applied", async () => { const { paseoHome, store, persisted } = createReloadableStore({ overrideControlledPaths: ["daemon.relay.enabled"], }); @@ -1244,16 +1286,16 @@ describe("DaemonConfigStore reload", () => { ...persisted, daemon: { ...persisted.daemon, relay: { enabled: true } }, }); - store.patch({ appendSystemPrompt: "patched elsewhere" }); + await store.patch({ appendSystemPrompt: "patched elsewhere" }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: ["daemon.relay.enabled"], }); }); - test("reports startup-only launch overrides instead of restart warnings", () => { + test("reports startup-only launch overrides instead of restart warnings", async () => { const { paseoHome, store, persisted } = createReloadableStore({ overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"], }); @@ -1269,16 +1311,16 @@ describe("DaemonConfigStore reload", () => { }, }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"], }); }); - test("a no-op reload returns empty path lists", () => { + test("a no-op reload returns empty path lists", async () => { const { store } = createReloadableStore(); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: [], @@ -1317,25 +1359,25 @@ describe("DaemonConfigStore speech settings", () => { return { paseoHome, store, applied, persisted }; } - test("reads, patches, and clears local STT language without losing it on other saves", () => { + test("reads, patches, and clears local STT language without losing it on other saves", async () => { const { paseoHome, store } = createSpeechStore({ features: { dictation: { stt: { language: "fr" } } }, }); expect(store.get().features?.dictation.stt).toEqual({ provider: "local", language: "fr" }); - store.patch({ features: { voiceMode: { enabled: true } } }); + await store.patch({ features: { voiceMode: { enabled: true } } }); expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ provider: "local", language: "fr", }); - const patched = store.patch({ + const patched = await store.patch({ features: { dictation: { stt: { provider: "local", language: "de" } } }, }); expect(patched.features?.dictation.stt).toEqual({ provider: "local", language: "de" }); - store.patch({ features: { dictation: { stt: { language: null } } } }); + await store.patch({ features: { dictation: { stt: { language: null } } } }); expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ provider: "local" }); }); - test("reports language env overrides for both local STT sites", () => { + test("reports language env overrides for both local STT sites", async () => { const { store } = createSpeechStore( {}, { PASEO_DICTATION_LANGUAGE: "fr", PASEO_VOICE_LANGUAGE: "de" }, @@ -1346,22 +1388,22 @@ describe("DaemonConfigStore speech settings", () => { }); }); - test("keeps voice mode off until it is turned on", () => { + test("keeps voice mode off until it is turned on", async () => { const { paseoHome, store } = createSpeechStore(); expect(store.get().features?.dictation.enabled).toBe(true); expect(store.get().features?.voiceMode.enabled).toBe(false); - const result = store.patch({ features: { voiceMode: { enabled: true } } }); + const result = await store.patch({ features: { voiceMode: { enabled: true } } }); expect(result.features?.voiceMode.enabled).toBe(true); expect(loadPersistedConfig(paseoHome).features?.voiceMode?.enabled).toBe(true); }); - test("stores the OpenAI key without ever returning it", () => { + test("stores the OpenAI key without ever returning it", async () => { const { paseoHome, store, applied } = createSpeechStore(); - const result = store.patch({ + const result = await store.patch({ features: { dictation: { stt: { provider: "openai", apiKey: " sk-test " } } }, }); @@ -1373,26 +1415,26 @@ describe("DaemonConfigStore speech settings", () => { expect(applied).toHaveLength(1); }); - test("applies a replaced key even though the visible settings do not change", () => { + test("applies a replaced key even though the visible settings do not change", async () => { const { store, applied } = createSpeechStore({ version: 1, features: { dictation: { stt: { provider: "openai", apiKey: "sk-old" } } }, }); - store.patch({ features: { dictation: { stt: { apiKey: "sk-new" } } } }); + await store.patch({ features: { dictation: { stt: { apiKey: "sk-new" } } } }); expect(applied.map((next) => next.features?.dictation?.stt)).toEqual([ { provider: "openai", apiKey: "sk-new" }, ]); }); - test("switching provider clears the previous provider's model", () => { + test("switching provider clears the previous provider's model", async () => { const { paseoHome, store } = createSpeechStore({ version: 1, features: { dictation: { stt: { provider: "local", model: "parakeet-tdt-0.6b-v3-int8" } } }, }); - const result = store.patch({ features: { dictation: { stt: { provider: "openai" } } } }); + const result = await store.patch({ features: { dictation: { stt: { provider: "openai" } } } }); expect(result.features?.dictation.stt).toEqual({ provider: "openai", hasApiKey: false }); expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ @@ -1400,62 +1442,62 @@ describe("DaemonConfigStore speech settings", () => { }); }); - test("rejects a local model id outside the catalog without persisting it", () => { + test("rejects a local model id outside the catalog without persisting it", async () => { const { paseoHome, store, applied } = createSpeechStore(); - expect(() => + await expect( store.patch({ features: { voiceMode: { tts: { provider: "local", model: "nope" } } } }), - ).toThrow("Unknown local voice model: nope"); + ).rejects.toThrow("Unknown local voice model: nope"); expect(loadPersistedConfig(paseoHome).features?.voiceMode).toBeUndefined(); expect(applied).toEqual([]); }); - test("reload applies hand-edited speech settings without a restart", () => { + test("reload applies hand-edited speech settings without a restart", async () => { const { paseoHome, store, applied, persisted } = createSpeechStore(); writeFileSync( path.join(paseoHome, "config.json"), `${JSON.stringify({ ...persisted, features: { voiceMode: { enabled: false } } }, null, 2)}\n`, ); - const result = store.reload(); + const result = await store.reload(); expect(result.appliedPaths).toContain("features.voiceMode"); expect(result.restartRequiredPaths).toEqual([]); expect(store.get().features?.voiceMode.enabled).toBe(false); expect(applied).toHaveLength(1); }); - test("merges same-provider fields and replaces the whole variant when provider changes", () => { + test("merges same-provider fields and replaces the whole variant when provider changes", async () => { const { paseoHome, store } = createSpeechStore(); - store.patch({ + await store.patch({ features: { voiceMode: { stt: { provider: "openai", apiKey: "secret", baseUrl: "https://site/v1", model: "first" }, }, }, }); - store.patch({ features: { voiceMode: { stt: { model: "second", baseUrl: null } } } }); + await store.patch({ features: { voiceMode: { stt: { model: "second", baseUrl: null } } } }); expect(loadPersistedConfig(paseoHome).features?.voiceMode?.stt).toEqual({ provider: "openai", apiKey: "secret", model: "second", }); - store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "third" } } } }); + await store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "third" } } } }); expect(store.get().features?.voiceMode.stt).toEqual({ provider: "openai", model: "third", hasApiKey: true, }); - store.patch({ features: { voiceMode: { stt: { apiKey: "new-secret" } } } }); - store.patch({ features: { voiceMode: { stt: { provider: "local" } } } }); - store.patch({ features: { voiceMode: { stt: { provider: "openai" } } } }); + await store.patch({ features: { voiceMode: { stt: { apiKey: "new-secret" } } } }); + await store.patch({ features: { voiceMode: { stt: { provider: "local" } } } }); + await store.patch({ features: { voiceMode: { stt: { provider: "openai" } } } }); expect(store.get().features?.voiceMode.stt).toEqual({ provider: "openai", hasApiKey: false }); }); - test("keeps a saved OpenAI key when a full variant patch omits the write-only key", () => { + test("keeps a saved OpenAI key when a full variant patch omits the write-only key", async () => { const { paseoHome, store } = createSpeechStore({ features: { voiceMode: { stt: { provider: "openai", apiKey: "saved-secret" } } }, }); - const result = store.patch({ + const result = await store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "whisper-1" } } }, }); expect(result.features?.voiceMode.stt).toEqual({ @@ -1471,7 +1513,7 @@ describe("DaemonConfigStore speech settings", () => { expect(JSON.stringify(result)).not.toContain("saved-secret"); }); - test("loads a legacy host, redacts its key and writes the normalized config after any save", () => { + test("loads a legacy host, redacts its key and writes the normalized config after any save", async () => { const { paseoHome, store } = createSpeechStore({ providers: { openai: { apiKey: "legacy-secret", stt: { baseUrl: "https://old/v1" } } }, features: { voiceMode: { enabled: true, stt: { provider: "openai" } } }, @@ -1482,13 +1524,13 @@ describe("DaemonConfigStore speech settings", () => { hasApiKey: true, }); expect(JSON.stringify(store.get())).not.toContain("legacy-secret"); - store.patch({ autoArchiveAfterMerge: true }); + await store.patch({ autoArchiveAfterMerge: true }); const disk = JSON.parse(readFileSync(path.join(paseoHome, "config.json"), "utf8")); expect(disk.providers).not.toHaveProperty("openai"); expect(disk.features.voiceMode.stt.apiKey).toBe("legacy-secret"); }); - test("reports env overrides and removes fallback provenance after saving a key", () => { + test("reports env overrides and removes fallback provenance after saving a key", async () => { const { store } = createSpeechStore( { features: { voiceMode: { stt: { provider: "openai", model: "configured" } } } }, { @@ -1514,7 +1556,7 @@ describe("DaemonConfigStore speech settings", () => { "features.voiceMode.stt.model": "STT_MODEL", }); expect(other.get().features?.voiceMode.stt.hasApiKey).toBe(false); - other.patch({ + await other.patch({ features: { voiceMode: { stt: { apiKey: "saved-secret", baseUrl: "https://saved/v1" } } }, }); expect(other.get().envOverrides).toEqual({ "features.voiceMode.stt.model": "STT_MODEL" }); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 51957e15f6..57b9a6b916 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -1,6 +1,7 @@ import { loadPersistedConfig, - savePersistedConfig, + savePersistedConfigAsync, + loadPersistedConfigAsync, type PersistedConfig, } from "./persisted-config.js"; import { ProviderOverrideSchema } from "./agent/provider-launch-config.js"; @@ -335,6 +336,7 @@ export function applyMutableProviderConfigToOverrides( } export class DaemonConfigStore { + private mutations: Promise = Promise.resolve(); private current: MutableDaemonConfig; private readonly paseoHome: string; private readonly logger: LoggerLike | undefined; @@ -376,16 +378,29 @@ export class DaemonConfigStore { return this.current; } - public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig { - const parsedPatch = pickSupportedPatchFields(MutableDaemonConfigPatchSchema.parse(partial)); - return this.applySupportedPatch(parsedPatch); + public patch( + partial: + | MutableDaemonConfigPatch + | ((current: MutableDaemonConfig) => MutableDaemonConfigPatch), + ): Promise { + return this.enqueue(() => + this.applySupportedPatch( + pickSupportedPatchFields( + MutableDaemonConfigPatchSchema.parse( + typeof partial === "function" ? partial(this.current) : partial, + ), + ), + ), + ); } - public setAgentSkillSelection(selection: AgentSkillSelection): MutableDaemonConfig { - return this.applySupportedPatch({ skills: { selection } }); + public setAgentSkillSelection(selection: AgentSkillSelection): Promise { + return this.enqueue(() => this.applySupportedPatch({ skills: { selection } })); } - private applySupportedPatch(parsedPatch: SupportedMutableConfigPatch): MutableDaemonConfig { + private async applySupportedPatch( + parsedPatch: SupportedMutableConfigPatch, + ): Promise { if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) { throw new Error( "Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.", @@ -414,7 +429,7 @@ export class DaemonConfigStore { return this.current; } - const { previous: persistedBeforePatch, knownNext } = this.persistConfig( + const { previous: persistedBeforePatch, knownNext } = await this.persistConfig( patch, removedProviders, ); @@ -423,7 +438,7 @@ export class DaemonConfigStore { try { this.applyReplacement(next, { removedProviders }); } catch (error) { - savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger); + await savePersistedConfigAsync(this.paseoHome, persistedBeforePatch, this.logger); throw error; } } @@ -454,12 +469,16 @@ export class DaemonConfigStore { ); } - public reload(): DaemonConfigReloadResult { + public reload(): Promise { + return this.enqueue(() => this.reloadPersisted()); + } + + private async reloadPersisted(): Promise { if (!this.reloadSource) { throw new Error("Daemon config reload is unavailable for this daemon instance"); } - const persisted = loadPersistedConfig(this.paseoHome, this.logger); + const persisted = await loadPersistedConfigAsync(this.paseoHome, this.logger); const resolved = this.reloadSource.resolve(persisted); // Plugin source changes require the plugin lifecycle operation or a daemon // restart. The global switch is independently reloadable. @@ -621,11 +640,24 @@ export class DaemonConfigStore { }; } - private persistConfig( + private enqueue(operation: () => Promise): Promise { + const result = this.mutations.then(operation); + this.mutations = result.catch(() => {}); + return result; + } + + public readServicePorts(): Promise["servicePorts"]> { + return this.enqueue( + async () => + (await loadPersistedConfigAsync(this.paseoHome, this.logger)).worktrees?.servicePorts, + ); + } + + private async persistConfig( patch: Omit, removeProviders: readonly string[], - ): { previous: PersistedConfig; knownNext: PersistedConfig } { - const persisted = loadPersistedConfig(this.paseoHome, this.logger); + ): Promise<{ previous: PersistedConfig; knownNext: PersistedConfig }> { + const persisted = await loadPersistedConfigAsync(this.paseoHome, this.logger); const merge = (source: PersistedConfig) => mergeMutablePatchIntoPersistedConfig({ persisted: source, @@ -635,7 +667,7 @@ export class DaemonConfigStore { }); const nextPersisted = merge(persisted); const knownNext = merge(this.lastKnownPersisted); - savePersistedConfig(this.paseoHome, nextPersisted, this.logger); + await savePersistedConfigAsync(this.paseoHome, nextPersisted, this.logger); return { previous: persisted, knownNext }; } } diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts index 09da7506c9..fe83c21305 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts @@ -1,6 +1,7 @@ -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { rm } from "node:fs/promises"; +import { rm, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; @@ -223,3 +224,76 @@ test("All changes subscriptions refresh on file edits and commits", async () => await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 60_000); + +test("large source diffs stay renderable for reads, subscriptions, and commit files", async () => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-budget-")); + const runGit = promisify(execFile); + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + git(cwd, "config", "core.autocrlf", "false"); + const original = "const value = Math.max(42, 7);\n".repeat(Math.ceil((5 * 1024 * 1024) / 30)); + writeFileSync(join(cwd, "generated.ts"), original); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + await writeFile(join(cwd, "generated.ts"), original.replace("42", "43")); + const pending = ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); + const rpcStarted = performance.now(); + await ctx.client.fetchAgents({}); + const rpcMs = performance.now() - rpcStarted; + const initial = await pending; + expect(initial.error).toBeNull(); + const check = (file: (typeof initial.files)[number] | null | undefined, value: string) => { + expect(file).toMatchObject({ path: "generated.ts", additions: 1, deletions: 1 }); + const lines = file!.hunks.flatMap((hunk) => hunk.lines); + expect(lines.filter((line) => line.type === "add").map((line) => line.content)).toEqual([ + `const value = Math.max(${value}, 7);`, + ]); + expect(lines.find((line) => line.type === "add")?.tokens).toContainEqual({ + text: value, + style: "number", + }); + }; + check(initial.files[0], "43"); + const subscription = ctx.client.observeCheckoutDiff(cwd, { mode: "uncommitted" }); + try { + let latest = await subscription.ready; + check(latest.files[0], "43"); + subscription.subscribe({ + snapshot: (snapshot) => { + latest = snapshot; + }, + update: (message) => { + if (message.type === "checkout_diff_update") latest = message.payload; + }, + }); + await writeFile(join(cwd, "generated.ts"), original.replace("42", "44")); + await expect + .poll( + () => + latest.files[0]?.hunks.flatMap((hunk) => hunk.lines).find((line) => line.type === "add") + ?.content, + { timeout: 15_000 }, + ) + .toBe("const value = Math.max(44, 7);"); + check(latest.files[0], "44"); + } finally { + await subscription.release(); + } + await runGit("git", ["add", "."], { cwd }); + await runGit("git", ["-c", "commit.gpgsign=false", "commit", "-m", "edit"], { cwd }); + const { stdout } = await runGit("git", ["rev-parse", "HEAD"], { cwd }); + const commit = await ctx.client.getCommitFileDiff(cwd, stdout.trim(), "generated.ts"); + check(commit.file, "44"); + console.info({ + sourceBytes: Buffer.byteLength(original), + cheapRpcMs: rpcMs, + isolatedDaemonPort: ctx.daemon.port, + }); + } finally { + await ctx.cleanup(); + await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +}, 60_000); diff --git a/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts b/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts index d8388f9dd2..55750831c8 100644 --- a/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts @@ -87,3 +87,53 @@ test("logs --follow preserves the same assistant text as one-shot logs", async ( await daemon.close(); } }, 60_000); + +test("omitted after limit pages forward while explicit zero preserves complete CLI history", async () => { + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome }); + const initial = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 }); + const cursor = { epoch: initial.epoch, seq: initial.window.maxSeq }; + for (let index = 0; index < 451; index++) { + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "user_message", + text: String(index), + }); + } + const first = await client.fetchAgentTimeline(agent.id, { direction: "after", cursor }); + expect(first.entries).toHaveLength(200); + expect(first.hasNewer).toBe(true); + expect(first.endCursor).toEqual({ epoch: cursor.epoch, seq: cursor.seq + 200 }); + const second = await client.fetchAgentTimeline(agent.id, { + direction: "after", + cursor: first.endCursor!, + }); + const third = await client.fetchAgentTimeline(agent.id, { + direction: "after", + cursor: second.endCursor!, + }); + expect(second.entries).toHaveLength(200); + expect(third.entries).toHaveLength(51); + expect(third.hasNewer).toBe(false); + expect( + [...first.entries, ...second.entries, ...third.entries].map((entry) => entry.item), + ).toEqual( + Array.from({ length: 451 }, (_, index) => ({ type: "user_message", text: String(index) })), + ); + const allAfter = await client.fetchAgentTimeline(agent.id, { + direction: "after", + cursor, + limit: 0, + }); + const allTail = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 }); + expect(allAfter.entries).toHaveLength(451); + expect(allAfter.hasNewer).toBe(false); + expect(allTail.entries).toHaveLength(initial.entries.length + 451); + } finally { + await client.close(); + await daemon.close(); + } +}, 60_000); diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index 984aa2fdcc..d2d839122e 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -219,7 +219,7 @@ test( ); test( - "real Pi daemon executes manual compact out-of-band instead of prompt text", + "real Pi daemon accepts a prompt after manual compact", async () => { const cwd = tmpCwd("pi-manual-compact-"); @@ -234,20 +234,24 @@ test( cwd, title: "pi-manual-compact", provider: "pi", - model: PI_COMPACTION_TEST_MODEL, + model: PI_REAL_TEST_MODEL, }); await client.sendMessage(agent.id, "Reply exactly: compact-ready"); const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); - expect(finish.status).toBe("idle"); + expect(finish.status, JSON.stringify(await fetchCanonicalTimeline(client, agent.id))).toBe( + "idle", + ); await client.sendMessage(agent.id, "/compact summarize this e2e run"); - await waitForTimelineItem( client, agent.id, (item) => item.type === "compaction" && item.status === "completed", ); + await client.sendMessage(agent.id, "Reply exactly: after-compaction"); + const followUpFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); + expect(followUpFinish.status).toBe("idle"); const items = await fetchCanonicalTimeline(client, agent.id); expect(items).toEqual( @@ -263,9 +267,12 @@ test( }), ]), ); - expect( - items.some((item) => item.type === "user_message" && item.text.includes("/compact")), - ).toBe(false); + expect(items).toContainEqual( + expect.objectContaining({ + type: "assistant_message", + text: "after-compaction", + }), + ); expect( items .filter((item) => item.type === "assistant_message") diff --git a/packages/server/src/server/git-observation-paths.ts b/packages/server/src/server/git-observation-paths.ts new file mode 100644 index 0000000000..1ef0a4e571 --- /dev/null +++ b/packages/server/src/server/git-observation-paths.ts @@ -0,0 +1,106 @@ +import { realpath } from "node:fs"; +import { readlink } from "node:fs/promises"; +import { promisify } from "node:util"; +import pLimit from "p-limit"; +import { dirname, resolve as resolvePath, sep } from "node:path"; +import { looksLikeDefiniteWindowsPath, normalizePathPreservingCase } from "../utils/path.js"; + +const nativeRealpath = promisify(realpath.native); +const jsRealpath = promisify(realpath); +const identityIo = pLimit(2); + +/** A subscription owns its lexical namespace, including the OS's real-root alias. */ +export class GitObservationPaths { + private constructor( + private readonly root: string, + private readonly aliases: string[], + private readonly windows: boolean, + ) {} + + static async create(root: string): Promise { + const windows = looksLikeDefiniteWindowsPath(root); + const normalizePath = (value: string) => + windows + ? normalizePathPreservingCase(value, true).replaceAll("\\", "/").toLowerCase() + : normalizePathPreservingCase(value, false); + const aliases = new Set([normalizePath(root)]); + // Sequential: foreign paths must not fan out into the shared libuv pool. + for (const resolve of [nativeRealpath, jsRealpath]) { + try { + aliases.add(normalizePath(await identityIo(() => resolve(root)))); + } catch { + /* Missing roots retain lexical identity. */ + } + } + // A link may name /var/... while realpath returns /private/var/... on macOS. + // Retain literal targets too, including chains, without following links in callbacks. + let current = root; + const visited = new Set(); + for (let remaining = 40; remaining > 0; remaining--) { + const alias = normalizePath(current); + if (visited.has(alias)) break; + visited.add(alias); + aliases.add(alias); + try { + const target = await identityIo(() => readlink(current)); + current = resolvePath(dirname(current), target); + } catch { + break; + } + } + return new GitObservationPaths(root, [...aliases], windows); + } + + relative(candidate: string): string | null { + const normalized = this.normalizeEventPath(candidate); + const comparable = this.windows ? normalized.toLowerCase() : normalized; + for (const alias of this.aliases) { + if (comparable === alias) return ""; + const prefix = alias.endsWith("/") ? alias : `${alias}/`; + if (comparable.startsWith(prefix)) { + // Slice the original spelling, preserving case in ref names on Windows. + return normalized.slice(prefix.length); + } + } + return null; + } + + // Compile excluded subtrees in every established root namespace. The event callback + // only compares strings; neither an ignored root nor an event needs another realpath. + createEventFilter(excludedRoots: Iterable): (candidate: string) => boolean { + const excluded: string[] = []; + for (const root of excludedRoots) { + const relative = this.relative(root); + if (relative === null) continue; + for (const alias of this.aliases) { + const subtree = relative ? `${alias}/${relative}` : alias; + excluded.push(this.windows ? subtree.toLowerCase() : subtree); + } + } + const prefixes = excluded.map((root) => `${root}/`); + return (candidate) => { + const normalized = this.normalizeEventPath(candidate); + const comparable = this.windows ? normalized.toLowerCase() : normalized; + if (this.aliases.includes(comparable)) return false; + for (let i = 0; i < excluded.length; i++) { + if (comparable === excluded[i] || comparable.startsWith(prefixes[i])) return false; + } + return true; + }; + } + + private normalizeEventPath(candidate: string): string { + if (this.windows) return normalizePathPreservingCase(candidate, true).replaceAll("\\", "/"); + if (candidate.includes("/.") || candidate.includes("//") || candidate.endsWith("/")) + return normalizePathPreservingCase(candidate, false); + return candidate; + } + + matches = (candidate: string): boolean => this.relative(candidate) === ""; + + toWatchPath(candidate: string): string { + const relative = this.relative(candidate); + if (relative === null) return candidate; + return relative ? `${this.root}${sep}${relative}` : this.root; + } +} diff --git a/packages/server/src/server/orchestration-skills/internal/selection-store.test.ts b/packages/server/src/server/orchestration-skills/internal/selection-store.test.ts index 5a42fd0dac..50fb071fef 100644 --- a/packages/server/src/server/orchestration-skills/internal/selection-store.test.ts +++ b/packages/server/src/server/orchestration-skills/internal/selection-store.test.ts @@ -57,7 +57,7 @@ describe("daemon agent skill selection", () => { it("does not expose selection through the generic config patch path", async () => { const { config, root, store } = await createStore(); - config.patch({ + await config.patch({ skills: { selection: { mode: "custom", skills: ["paseo"] } }, } as MutableDaemonConfigPatch); diff --git a/packages/server/src/server/orchestration-skills/internal/selection-store.ts b/packages/server/src/server/orchestration-skills/internal/selection-store.ts index 6fa00af6e6..a3f7f95569 100644 --- a/packages/server/src/server/orchestration-skills/internal/selection-store.ts +++ b/packages/server/src/server/orchestration-skills/internal/selection-store.ts @@ -43,7 +43,7 @@ export function createSkillSelectionStore( }, async set(selection) { const parsed = coerceSkillSelection(selection); - configStore.setAgentSkillSelection(parsed); + await configStore.setAgentSkillSelection(parsed); return parsed; }, async isSet() { diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 66740d2ae2..1579f92d5f 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -548,7 +548,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex ); expect(created.worktree.branchName).toBe("dazzling-yak"); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "pending", @@ -575,7 +575,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex branchName: "renamed-from-agent-context", }); expect(branchAfterFirst).toBe("renamed-from-agent-context"); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "attempted", @@ -762,7 +762,7 @@ test("leaves the branch alone when generated branch text is invalid", async () = .toString() .trim(), ).toBe("dazzling-yak"); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "attempted", @@ -794,7 +794,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename // baseRefName must be the repo's default branch, not the checked-out branch // itself — otherwise the diff panel compares the branch against itself and // always shows no changes. - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 1, baseRefName: "main", }); @@ -831,7 +831,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren createDeps(), ); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 1, baseRefName: "main", }); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 61e00723d0..4080efd580 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -77,7 +77,7 @@ async function createPaseoWorktreeWithPriority( const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService); const createdWorktree = await createWorktreeCore(input, deps); try { - maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); + await maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({ relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd, targetWorktreePath: createdWorktree.worktree.worktreePath, @@ -182,9 +182,9 @@ export async function attemptFirstAgentBranchAutoName(options: { return { attempted: false, renamed: false, branchName: null }; } - let metadata: ReturnType; + let metadata: Awaited>; try { - metadata = readPaseoWorktreeMetadata(options.cwd); + metadata = await readPaseoWorktreeMetadata(options.cwd); } catch { return { attempted: false, renamed: false, branchName: null }; } @@ -199,11 +199,11 @@ export async function attemptFirstAgentBranchAutoName(options: { const getCurrentBranchImpl = options.getCurrentBranch ?? getCurrentBranch; const placeholderBranchName = metadata.firstAgentBranchAutoName.placeholderBranchName; if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) { - markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); + await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); return { attempted: true, renamed: false, branchName: null }; } - markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); + await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); const branchName = await options.generateBranchNameFromContext({ cwd: options.cwd, @@ -264,15 +264,15 @@ async function findAvailableBranchName(options: { return null; } -function maybeMarkFirstAgentBranchAutoNameEligible(options: { +async function maybeMarkFirstAgentBranchAutoNameEligible(options: { createdWorktree: Awaited>; -}): void { +}): Promise { const { createdWorktree } = options; if (!createdWorktree.created || createdWorktree.intent.kind !== "branch-off") { return; } - writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, { + await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, { placeholderBranchName: createdWorktree.worktree.branchName, }); } diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 019bcbb0f8..fb38e41591 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -16,9 +16,11 @@ import { describe, expect, test } from "vitest"; import { loadPersistedConfig, + loadPersistedConfigAsync, PersistedConfigSchema, readPersistedConfig, savePersistedConfig, + savePersistedConfigAsync, } from "./persisted-config.js"; import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js"; @@ -827,11 +829,14 @@ describe("readPersistedConfig with an unreadable config.json", () => { }); }); -describe.skipIf(process.platform === "win32")("persisted config file permissions", () => { - test("initializes config.json with private permissions", () => { +describe.skipIf(process.platform === "win32").each([ + { name: "startup", load: loadPersistedConfig, save: savePersistedConfig }, + { name: "runtime", load: loadPersistedConfigAsync, save: savePersistedConfigAsync }, +])("persisted config file permissions ($name)", ({ load, save }) => { + test("initializes config.json with private permissions", async () => { const home = createTempHome(); try { - loadPersistedConfig(home); + await load(home); expect(modeOf(path.join(home, "config.json"))).toBe(PRIVATE_FILE_MODE); } finally { @@ -839,14 +844,14 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions } }); - test("repairs permissive config.json permissions when loading", () => { + test("repairs permissive config.json permissions when loading", async () => { const home = createTempHome(); const configPath = path.join(home, "config.json"); try { writeFileSync(configPath, "{}\n", { mode: PERMISSIVE_FILE_MODE }); chmodSync(configPath, PERMISSIVE_FILE_MODE); - loadPersistedConfig(home); + await load(home); expect(modeOf(configPath)).toBe(PRIVATE_FILE_MODE); } finally { @@ -854,11 +859,24 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions } }); - test("saves config.json with private permissions", () => { + test("repairs unreadable owner permissions before reading config.json", async () => { + const home = createTempHome(); + const configPath = path.join(home, "config.json"); + try { + writeFileSync(configPath, "{}\n"); + chmodSync(configPath, 0); + expect(await load(home)).toEqual({}); + expect(modeOf(configPath)).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("saves config.json with private permissions", async () => { const parent = createTempHome(); const home = path.join(parent, "home"); try { - savePersistedConfig(home, { + await save(home, { features: { dictation: { stt: { provider: "openai", apiKey: "secret" } } }, }); @@ -870,37 +888,37 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions }); }); -describe.skipIf(process.platform === "win32")( - "config.json symlinked from another directory", - () => { - test("saving writes through the symlink and leaves it in place", () => { - const parent = createTempHome(); - const home = path.join(parent, "home"); - const dotfiles = path.join(parent, "dotfiles"); - const linkedConfigPath = path.join(dotfiles, "config.json"); - const configPath = path.join(home, "config.json"); - try { - mkdirSync(home); - mkdirSync(dotfiles, { mode: 0o755 }); - chmodSync(dotfiles, 0o755); - writeFileSync(linkedConfigPath, '{ "version": 1 }\n'); - symlinkSync(linkedConfigPath, configPath); +describe.skipIf(process.platform === "win32").each([ + { name: "startup", save: savePersistedConfig }, + { name: "runtime", save: savePersistedConfigAsync }, +])("config.json symlinked from another directory ($name)", ({ save }) => { + test("saving writes through the symlink and leaves it in place", async () => { + const parent = createTempHome(); + const home = path.join(parent, "home"); + const dotfiles = path.join(parent, "dotfiles"); + const linkedConfigPath = path.join(dotfiles, "config.json"); + const configPath = path.join(home, "config.json"); + try { + mkdirSync(home); + mkdirSync(dotfiles, { mode: 0o755 }); + chmodSync(dotfiles, 0o755); + writeFileSync(linkedConfigPath, '{ "version": 1 }\n'); + symlinkSync(linkedConfigPath, configPath); - savePersistedConfig(home, { version: 1, daemon: { relay: { enabled: false } } }); + await save(home, { version: 1, daemon: { relay: { enabled: false } } }); - expect(lstatSync(configPath).isSymbolicLink()).toBe(true); - expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({ - version: 1, - daemon: { relay: { enabled: false } }, - }); - expect(modeOf(dotfiles)).toBe(0o755); - expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE); - } finally { - rmSync(parent, { recursive: true, force: true }); - } - }); - }, -); + expect(lstatSync(configPath).isSymbolicLink()).toBe(true); + expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({ + version: 1, + daemon: { relay: { enabled: false } }, + }); + expect(modeOf(dotfiles)).toBe(0o755); + expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); +}); describe("speech provider config normalization", () => { test.each([undefined, "local"])( diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index c6c3f9c7e1..51bfb3d3a4 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -1,3 +1,4 @@ +import { readFile } from "node:fs/promises"; import { PluginRegistriesSchema } from "@getpaseo/protocol/plugin-registry"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; @@ -9,7 +10,12 @@ import { ProviderOverridesSchema, } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; -import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js"; +import { + ensurePrivateFile, + ensurePrivateFileAsync, + writePrivateFileAtomic, + writePrivateFileAtomicSync, +} from "./private-files.js"; import { AgentProfileSchema, AgentSkillSelectionSchema } from "@getpaseo/protocol/agent-profile"; import { PluginIdSchema, PluginSourceSchema } from "@getpaseo/protocol/plugin-config"; import { TerminalProfileSchema } from "@getpaseo/protocol/terminal-profile"; @@ -523,6 +529,40 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per return config; } +/** Runtime config IO. Startup and offline CLI consumers retain their synchronous entry points. */ +export async function loadPersistedConfigAsync( + paseoHome: string, + logger?: LoggerLike, +): Promise { + const log = getLogger(logger); + const configPath = getConfigPath(paseoHome); + let raw: string; + await ensurePrivateFileAsync(configPath); + try { + raw = await readFile(configPath, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw new Error( + `[Config] Failed to read ${configPath}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + try { + raw = JSON.stringify(DEFAULT_PERSISTED_CONFIG, null, 2) + "\n"; + await writePrivateFileAtomic(configPath, raw); + log?.info(`Initialized config file at ${configPath}`); + } catch (initializationError) { + throw new Error( + `[Config] Failed to initialize ${configPath}: ${initializationError instanceof Error ? initializationError.message : String(initializationError)}`, + { cause: initializationError }, + ); + } + } + const config = parseConfigFile(configPath, raw, log); + log?.info(`Loaded from ${configPath}`); + return config; +} + /** Observe the file without initializing a home, identity, or default configuration. */ export function readPersistedConfig( paseoHome: string, @@ -642,16 +682,10 @@ export function savePersistedConfig( const log = getLogger(logger); const configPath = getConfigPath(paseoHome); - const result = PersistedConfigSchema.safeParse(config); - if (!result.success) { - const issues = result.error.issues - .map((i) => ` - ${i.path.join(".")}: ${i.message}`) - .join("\n"); - throw new Error(`[Config] Invalid config to save:\n${issues}`); - } + const data = serializePersistedConfig(config); try { - writePrivateFileAtomicSync(configPath, JSON.stringify(result.data, null, 2) + "\n"); + writePrivateFileAtomicSync(configPath, data); log?.info(`Saved to ${configPath}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -660,3 +694,33 @@ export function savePersistedConfig( }); } } + +function serializePersistedConfig(config: PersistedConfig): string { + const result = PersistedConfigSchema.safeParse(config); + if (!result.success) { + const issues = result.error.issues + .map((i) => ` - ${i.path.join(".")}: ${i.message}`) + .join("\n"); + throw new Error(`[Config] Invalid config to save:\n${issues}`); + } + + return JSON.stringify(result.data, null, 2) + "\n"; +} + +export async function savePersistedConfigAsync( + paseoHome: string, + config: PersistedConfig, + logger?: LoggerLike, +): Promise { + const configPath = getConfigPath(paseoHome); + const data = serializePersistedConfig(config); + try { + await writePrivateFileAtomic(configPath, data); + getLogger(logger)?.info(`Saved to ${configPath}`); + } catch (error) { + throw new Error( + `[Config] Failed to write ${configPath}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } +} diff --git a/packages/server/src/server/plugins/agent-tools.e2e.test.ts b/packages/server/src/server/plugins/agent-tools.e2e.test.ts new file mode 100644 index 0000000000..14629aa15e --- /dev/null +++ b/packages/server/src/server/plugins/agent-tools.e2e.test.ts @@ -0,0 +1,752 @@ +import { fileURLToPath } from "node:url"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { + createTestAgentClient, + type TestAgentClientOptions, +} from "../test-utils/fake-agent-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { BuiltinPluginLoader } from "./builtin/index.js"; +import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js"; +import type { + AgentClient, + AgentLaunchContext, + AgentSessionConfig, + AgentPromptInput, +} from "../agent/agent-sdk-types.js"; + +function recordingProvider(native: boolean, callbacks: TestAgentClientOptions = {}) { + const launches: { config: AgentSessionConfig; context?: AgentLaunchContext }[] = []; + const turns: { prompt: AgentPromptInput; launchCount: number }[] = []; + const steers: AgentPromptInput[] = []; + const events = { interrupts: 0 }; + const inner = createTestAgentClient("codex", { + ...callbacks, + supportsMcpServers: true, + onStartTurn: (prompt, config) => { + turns.push({ prompt, launchCount: launches.length }); + callbacks.onStartTurn?.(prompt, config); + }, + steerActiveTurn: async (prompt) => { + steers.push(prompt); + return { status: "accepted" }; + }, + onInterrupt: () => { + events.interrupts++; + callbacks.onInterrupt?.(); + }, + }); + const provider: AgentClient = { + provider: inner.provider, + capabilities: { ...inner.capabilities, supportsNativePaseoTools: native }, + createSession: async (config, context, options) => { + launches.push({ config, context }); + return inner.createSession(config, context, options); + }, + resumeSession: async (handle, config, context, options) => { + launches.push({ config: config as AgentSessionConfig, context }); + return inner.resumeSession(handle, config, context, options); + }, + isAvailable: () => inner.isAvailable(), + fetchCatalog: (options) => inner.fetchCatalog(options), + }; + return { provider, launches, turns, steers, events }; +} + +const pluginSource = ` +import { z } from "zod"; +import { defineAgentTool } from "@getpaseo/plugin/server"; +let calls = 0; +const tool = defineAgentTool({ + name: "plugin_echo", description: "Echo for the chosen agent", + input: z.object({ text: z.string().min(1).refine(value => value !== "forbidden", "Forbidden by plugin").describe("Message") }), + async execute(input, { agent, paseo, signal }) { + calls++; + if (input.text === "throw") throw new Error("handler failed"); + return { text: JSON.stringify({ agent, input, calls, sdk: typeof paseo.agents.ref, aborted: signal.aborted }) }; + }, +}); +export default function contribute(server) { + server.before("agent.session_open", ({ request }) => { + if (request.labels.x !== "y") return; + return { ...request, env: { ...request.env, OPEN_INFO: JSON.stringify({ title: request.title, labels: request.labels, reason: request.reason }) }, tools: [...request.tools, tool] }; + }); + return () => {}; +} +`; + +async function setup( + native = false, + source = pluginSource, + policy?: { enabled?: boolean; disabledTools?: string[] }, + providerOptions?: TestAgentClientOptions, +) { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-tools-")); + const recorder = recordingProvider(native, providerOptions); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.13.2", + agentClients: { codex: recorder.provider }, + mcpEnabled: true, + providerOverrides: { codex: { paseoTools: policy } }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.13.2", + }); + const mcps: Client[] = []; + async function install(id: string, code: string) { + const pluginDirectory = path.join(directory, id); + await mkdir(pluginDirectory); + await writeFile( + path.join(pluginDirectory, "paseo-plugin.json"), + JSON.stringify({ id, requirements: { paseo: ">=0.13.2" } }), + ); + await writeFile(path.join(pluginDirectory, "index.server.ts"), code); + await client.installDirectoryPlugin(pluginDirectory); + } + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + try { + await install("tools-test", source); + } catch (error) { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + throw error; + } + return { + ...recorder, + daemon, + client, + directory, + install, + async create(labels: Record = {}) { + return daemon.daemon.agentManager.createAgent( + { provider: "codex", cwd: directory }, + undefined, + { labels, initialTitle: "Chosen title" }, + ); + }, + async mcp(agentId?: string) { + const server = recorder.launches.at(-1)!.config.mcpServers?.paseo; + let url: string; + if (agentId) url = `http://127.0.0.1:${daemon.port}/mcp/agents?callerAgentId=${agentId}`; + else { + if (server?.type !== "http") throw new Error("Expected HTTP server"); + url = server.url; + } + const mcp = new Client({ name: "plugin-tools-test", version: "1" }); + mcps.push(mcp); + await mcp.connect(new StreamableHTTPClientTransport(new URL(url))); + return mcp; + }, + async close() { + await Promise.all(mcps.map((mcp) => mcp.close())); + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + }, + }; +} + +test("session hooks attach tools only to matching agents over MCP, with validated input and caller context", async () => { + const h = await setup(); + try { + const a = await h.create({ x: "y" }); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO)).toEqual({ + title: "Chosen title", + labels: { x: "y" }, + reason: "create", + }); + const mcpA = await h.mcp(); + const listed = (await mcpA.listTools()).tools.find((tool) => tool.name === "plugin_echo"); + expect(listed).toMatchObject({ + name: "plugin_echo", + inputSchema: { + type: "object", + properties: { text: { type: "string", minLength: 1, description: "Message" } }, + required: ["text"], + }, + }); + const b = await h.create(); + const mcpB = await h.mcp(); + expect((await mcpB.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo"); + const invalid = await mcpA.callTool({ name: "plugin_echo", arguments: { text: "" } }); + expect(invalid.isError).toBe(true); + expect(JSON.stringify(invalid)).toContain("text"); + expect( + await mcpA.callTool({ name: "plugin_echo", arguments: { text: "forbidden" } }), + ).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("Forbidden by plugin") }], + }); + const result = await mcpA.callTool({ name: "plugin_echo", arguments: { text: "hello" } }); + expect(result.isError).not.toBe(true); + expect(JSON.parse((result.content as { text: string }[])[0].text)).toMatchObject({ + agent: { id: a.id, cwd: h.directory, title: "Chosen title" }, + input: { text: "hello" }, + calls: 1, + sdk: "function", + aborted: false, + }); + expect(a.id).not.toBe(b.id); + expect( + await mcpA.callTool({ name: "plugin_echo", arguments: { text: "throw" } }), + ).toMatchObject({ isError: true, content: [{ type: "text", text: "handler failed" }] }); + expect( + (await mcpA.callTool({ name: "plugin_echo", arguments: { text: "still alive" } })).isError, + ).not.toBe(true); + } finally { + await h.close(); + } +}, 60_000); + +test("native providers get the same attached schema and execution", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + const catalog = h.launches.at(-1)!.context!.paseoTools!; + expect(catalog.getTool("plugin_echo")?.description).toBe("Echo for the chosen agent"); + const mcp = await h.mcp(a.id); + const advertised = (await mcp.listTools()).tools.find((tool) => tool.name === "plugin_echo")!; + expect(serializePaseoToolInputParameters(catalog.getTool("plugin_echo")!)).toEqual( + advertised.inputSchema, + ); + const result = await catalog.executeTool("plugin_echo", { text: "native" }); + expect(JSON.parse(result.content[0].text!)).toMatchObject({ + agent: { id: a.id }, + input: { text: "native" }, + }); + } finally { + await h.close(); + } +}, 60_000); + +test("resume uses the stored title and labels, and close removes the attachment", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + const catalog = h.launches.at(-1)!.context!.paseoTools!; + const handle = a.persistence!; + await h.daemon.daemon.agentManager.setTitle(a.id, "Stored title"); + await h.daemon.daemon.agentManager.closeAgent(a.id); + expect(h.daemon.daemon.agentManager.getPluginTools(a.id)).toEqual([]); + await expect(catalog.executeTool("plugin_echo", { text: "closed" })).rejects.toThrow( + "Agent is not live", + ); + await h.daemon.daemon.agentManager.resumeAgentFromPersistence(handle, a.config, a.id); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO)).toEqual({ + title: "Stored title", + labels: { x: "y" }, + reason: "resume", + }); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")?.name).toBe( + "plugin_echo", + ); + } finally { + await h.close(); + } +}, 60_000); + +test.each([ + { + name: "Paseo", + source: pluginSource.replace('name: "plugin_echo"', 'name: "list_agents"'), + other: false, + message: /plugin tools-test.*Paseo tool list_agents/, + }, + { + name: "another plugin", + source: pluginSource, + other: true, + message: /plugin tools-test.*plugin other-tools/, + }, + { + name: "the same plugin", + source: pluginSource.replace("tools: [...request.tools, tool]", "tools: [tool, tool]"), + other: false, + message: /Duplicate agent tool: plugin_echo/, + }, +])( + "collisions with $name fail only targeted session opens", + async ({ source, other, message }) => { + const h = await setup(false, source); + try { + if (other) await h.install("other-tools", pluginSource); + await expect(h.create({ x: "y" })).rejects.toThrow(message); + expect(h.launches).toHaveLength(0); + await h.create(); + expect(h.launches).toHaveLength(1); + expect((await (await h.mcp()).listTools()).tools.map((tool) => tool.name)).not.toContain( + "plugin_echo", + ); + } finally { + await h.close(); + } + }, + 60_000, +); + +test("each plugin owns its tool array and callbacks can remove earlier attachments", async () => { + const source = pluginSource.replace( + " return () => {};", + ` + server.before("agent.session_open", ({ request }) => { + if (request.labels.x === "y" && request.tools[0]?.name !== "plugin_echo") throw new Error("Lost plugin-local definitions"); + return { ...request, tools: [] }; + }); + return () => {};`, + ); + const h = await setup(false, source); + try { + await h.install( + "other-tools", + pluginSource + .replace('name: "plugin_echo"', 'name: "other_echo"') + .replace( + ' if (request.labels.x !== "y") return;', + ' if (request.tools.length !== 0) throw new Error("Saw another plugin tools");\n if (request.labels.x !== "y") return;', + ), + ); + await h.create({ x: "y" }); + const names = (await (await h.mcp()).listTools()).tools.map((tool) => tool.name); + expect(names).toContain("other_echo"); + expect(names).not.toContain("plugin_echo"); + } finally { + await h.close(); + } +}, 60_000); + +test("disabled plugins remove tools from existing and new MCP catalogs", async () => { + const h = await setup(); + try { + await h.create({ x: "y" }); + const mcp = await h.mcp(); + expect((await mcp.listTools()).tools.map((tool) => tool.name)).toContain("plugin_echo"); + await h.client.patchDaemonConfig({ pluginsEnabled: false }); + expect((await mcp.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo"); + await h.create({ x: "y" }); + expect((await (await h.mcp()).listTools()).tools.map((tool) => tool.name)).not.toContain( + "plugin_echo", + ); + } finally { + await h.close(); + } +}, 60_000); + +test.each([false, true])( + "provider tools disabled gates plugin tools (native=%s)", + async (native) => { + const h = await setup(native, pluginSource, { enabled: false }); + try { + await h.create({ x: "y" }); + expect(h.launches.at(-1)!.context!.paseoTools).toBeUndefined(); + expect(h.launches.at(-1)!.config.mcpServers?.paseo).toBeUndefined(); + } finally { + await h.close(); + } + }, + 60_000, +); + +test("disabledTools filters plugin tool names", async () => { + const h = await setup(true, pluginSource, { disabledTools: ["plugin_echo"] }); + try { + await h.create({ x: "y" }); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +const waitingSource = pluginSource + .replace("import { z }", 'import { writeFile } from "node:fs/promises";\nimport { z }') + .replace( + " calls++;", + ` + calls++; + if (input.text === "wait") { + await writeFile(agent.cwd + "/started", agent.id); + await new Promise(resolve => signal.addEventListener("abort", resolve, { once: true })); + await writeFile(agent.cwd + "/aborted", agent.id); + return { text: "discard this result" }; + } +`, + ); + +test("caller cancellation aborts the plugin handler and discards its result", async () => { + const h = await setup(true, waitingSource); + try { + const a = await h.create({ x: "y" }); + const catalog = h.launches.at(-1)!.context!.paseoTools!; + const controller = new AbortController(); + const call = catalog.executeTool( + "plugin_echo", + { text: "wait" }, + { signal: controller.signal }, + ); + const rejected = expect(call).rejects.toThrow("Canceled by caller"); + await expect.poll(() => readFile(path.join(h.directory, "started"), "utf8")).toBe(a.id); + controller.abort(new Error("Canceled by caller")); + await rejected; + await expect.poll(() => readFile(path.join(h.directory, "aborted"), "utf8")).toBe(a.id); + } finally { + await h.close(); + } +}, 60_000); + +test("a plugin stopping mid-call returns a tool error", async () => { + const h = await setup(true, waitingSource); + try { + const a = await h.create({ x: "y" }); + const call = h.launches + .at(-1)! + .context!.paseoTools!.executeTool("plugin_echo", { text: "wait" }); + await expect.poll(() => readFile(path.join(h.directory, "started"), "utf8")).toBe(a.id); + await h.client.patchDaemonConfig({ pluginsEnabled: false }); + expect(await call).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("Plugin stopped") }], + }); + } finally { + await h.close(); + } +}, 60_000); + +test("history sessions ignore attached tools", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + const handle = a.persistence!; + await h.client.archiveAgent(a.id); + await h.daemon.daemon.agentManager.resumeAgentFromPersistence(handle, a.config, a.id); + expect(h.daemon.daemon.agentManager.getPluginTools(a.id)).toEqual([]); + expect(h.launches.at(-1)!.context!.paseoTools?.getTool("plugin_echo")).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +test("disabled plugins attach no tools to a new native session", async () => { + const h = await setup(true); + try { + await h.client.patchDaemonConfig({ pluginsEnabled: false }); + await h.create({ x: "y" }); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +test("structured tool results get model-visible text when text is omitted", async () => { + const h = await setup( + true, + pluginSource.replace( + " calls++;", + ' return { structuredContent: { status: "created" } };', + ), + ); + try { + await h.create({ x: "y" }); + expect( + await h.launches + .at(-1)! + .context!.paseoTools!.executeTool("plugin_echo", { text: "structured" }), + ).toEqual({ + content: [{ type: "text", text: JSON.stringify({ status: "created" }, null, 2) }], + structuredContent: { status: "created" }, + isError: undefined, + }); + } finally { + await h.close(); + } +}, 60_000); + +test("reloading a plugin retires its old attachments before an agent reopens", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + const mcp = await h.mcp(a.id); + const oldCatalog = h.launches.at(-1)!.context!.paseoTools!; + expect((await mcp.listTools()).tools.map((tool) => tool.name)).toContain("plugin_echo"); + await h.client.reloadPlugin("tools-test"); + expect((await mcp.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo"); + expect(h.daemon.daemon.agentManager.getPluginTools(a.id)).toEqual([]); + expect(await oldCatalog.executeTool("plugin_echo", { text: "retired" })).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("not available") }], + }); + } finally { + await h.close(); + } +}, 60_000); + +test("built-in plugins attach and execute tools while configured plugins are disabled", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-builtin-tools-")); + const directory = path.join(root, "builtin-tools"); + await mkdir(directory); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "builtin-tools", requirements: { paseo: ">=0.13.2" } }), + ); + await writeFile(path.join(directory, "index.server.ts"), pluginSource); + const recorder = recordingProvider(true); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.13.2", + agentClients: { codex: recorder.provider }, + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(root, ["builtin-tools"]), + }); + try { + const a = await daemon.daemon.agentManager.createAgent( + { provider: "codex", cwd: root }, + undefined, + { labels: { x: "y" } }, + ); + const catalog = recorder.launches.at(-1)!.context!.paseoTools!; + expect(catalog.getTool("plugin_echo")?.name).toBe("plugin_echo"); + const result = await catalog.executeTool("plugin_echo", { text: "builtin" }); + expect(result.isError).not.toBe(true); + expect(JSON.parse(result.content[0].text!)).toMatchObject({ + agent: { id: a.id }, + input: { text: "builtin" }, + }); + } finally { + await daemon.close(); + await rm(root, { recursive: true, force: true }); + } +}, 60_000); + +test("a session hook missing tools reports a schema issue", async () => { + const h = await setup( + false, + pluginSource.replace("tools: [...request.tools, tool]", "tools: undefined"), + ); + try { + await expect(h.create({ x: "y" })).rejects.toThrow('"tools"'); + } finally { + await h.close(); + } +}, 60_000); + +async function sendAndFinish(h: Awaited>, agentId: string, text: string) { + const previous = h.turns.length; + await h.client.sendMessage(agentId, text, { activeTurnBehavior: "steer" }); + await expect.poll(() => h.turns.length).toBe(previous + 1); + await expect.poll(() => h.daemon.daemon.agentManager.hasInFlightRun(agentId)).toBe(false); +} + +test("an idle agent reopens once before its next prompt after a session-hook plugin reload", async () => { + const h = await setup(); + try { + const b = await h.create(); + const mcp = await h.mcp(); + expect((await mcp.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo"); + await writeFile( + path.join(h.directory, "tools-test", "index.server.ts"), + pluginSource.replace(' if (request.labels.x !== "y") return;', ""), + ); + await h.client.reloadPlugin("tools-test"); + expect(h.launches).toHaveLength(1); + await sendAndFinish(h, b.id, "first prompt"); + expect(h.launches).toHaveLength(2); + expect(h.turns.map((turn) => turn.launchCount)).toEqual([2]); + expect((await mcp.listTools()).tools.map((tool) => tool.name)).toContain("plugin_echo"); + await sendAndFinish(h, b.id, "second prompt"); + expect(h.launches).toHaveLength(2); + expect(h.turns.map((turn) => turn.launchCount)).toEqual([2, 2]); + expect(h.events.interrupts).toBe(0); + } finally { + await h.close(); + } +}, 60_000); + +test("a busy agent is steered after plugin reload and reopens only at its next idle prompt", async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const h = await setup(true, pluginSource, undefined, { beforeTurnComplete: async () => blocked }); + try { + const a = await h.create({ x: "y" }); + await h.client.sendMessage(a.id, "busy prompt", { activeTurnBehavior: "steer" }); + await expect.poll(() => h.daemon.daemon.agentManager.hasInFlightRun(a.id)).toBe(true); + await expect + .poll(() => h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId) + .not.toBeNull(); + const turnId = h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId; + await h.client.reloadPlugin("tools-test"); + await h.client.sendMessage(a.id, "steering prompt", { activeTurnBehavior: "steer" }); + expect(h.steers).toHaveLength(1); + expect(h.launches).toHaveLength(1); + expect(h.events.interrupts).toBe(0); + expect(h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId).toBe(turnId); + release(); + await expect.poll(() => h.daemon.daemon.agentManager.hasInFlightRun(a.id)).toBe(false); + await sendAndFinish(h, a.id, "idle prompt"); + expect(h.launches).toHaveLength(2); + expect(h.events.interrupts).toBe(0); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")?.name).toBe( + "plugin_echo", + ); + await sendAndFinish(h, a.id, "stable prompt"); + expect(h.launches).toHaveLength(2); + } finally { + release(); + await h.close(); + } +}, 60_000); + +const reloadRpcSource = pluginSource + .replace("import { z }", 'import { defineRpc } from "@getpaseo/plugin";\nimport { z }') + .replace( + " return () => {};", + ` + server.handle(defineRpc({ name: "reload_agent", input: z.object({ id: z.string() }), output: z.object({ id: z.string() }) }), async ({ id }, { paseo }) => { + const agent = paseo.agents.ref(id); + await agent.refresh(); + await agent.reload(); + return { id: agent.current().id }; + }); + return () => {}; +`, + ); + +test("a plugin can reload an agent through the SDK and rerun session hooks with reason refresh", async () => { + const h = await setup(true, reloadRpcSource); + try { + const a = await h.create({ x: "y" }); + expect(h.launches).toHaveLength(1); + expect(await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id })).toEqual({ + id: a.id, + }); + expect(h.launches).toHaveLength(2); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh"); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")?.name).toBe( + "plugin_echo", + ); + } finally { + await h.close(); + } +}, 60_000); + +test("starting and stopping a session-hook plugin each causes one idle reopen", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + await h.install( + "added-tools", + pluginSource.replace('name: "plugin_echo"', 'name: "added_echo"'), + ); + await sendAndFinish(h, a.id, "after start"); + expect(h.launches).toHaveLength(2); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("added_echo")?.name).toBe("added_echo"); + await h.client.disablePlugin("added-tools"); + await sendAndFinish(h, a.id, "after stop"); + expect(h.launches).toHaveLength(3); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("added_echo")).toBeUndefined(); + await sendAndFinish(h, a.id, "stable after stop"); + expect(h.launches).toHaveLength(3); + expect(h.events.interrupts).toBe(0); + } finally { + await h.close(); + } +}, 60_000); + +test("plugins without a session-open hook do not make agent sessions stale", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + await h.install( + "no-session-hook", + "export default function contribute(server) { server.on('agent.created', () => {}); return () => {}; }", + ); + await sendAndFinish(h, a.id, "after unrelated start"); + expect(h.launches).toHaveLength(1); + await h.client.reloadPlugin("no-session-hook"); + await sendAndFinish(h, a.id, "after unrelated reload"); + expect(h.launches).toHaveLength(1); + await h.client.disablePlugin("no-session-hook"); + await sendAndFinish(h, a.id, "after unrelated stop"); + expect(h.launches).toHaveLength(1); + } finally { + await h.close(); + } +}, 60_000); + +test("explicit SDK reload interrupts a running turn before reopening its session", async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const h = await setup(true, reloadRpcSource, undefined, { + beforeTurnComplete: async () => blocked, + onInterrupt: () => release(), + }); + try { + const a = await h.create({ x: "y" }); + await h.client.sendMessage(a.id, "running before explicit reload", { + activeTurnBehavior: "steer", + }); + await expect + .poll(() => h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId) + .not.toBeNull(); + expect(await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id })).toEqual({ + id: a.id, + }); + expect(h.events.interrupts).toBe(1); + expect(h.launches).toHaveLength(2); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh"); + } finally { + release(); + await h.close(); + } +}, 60_000); + +test("the agent-tools example appends notes only for labeled agents", async () => { + const h = await setup(); + try { + await h.client.installDirectoryPlugin( + fileURLToPath(new URL("../../../../../plugin-examples/agent-tools", import.meta.url)), + ); + const a = await h.create({ "notes/enabled": "true" }); + const mcpA = await h.mcp(a.id); + expect((await mcpA.listTools()).tools.map((tool) => tool.name)).toContain( + "append_workspace_note", + ); + expect( + await mcpA.callTool({ + name: "append_workspace_note", + arguments: { note: "Run the targeted tests before committing." }, + }), + ).toMatchObject({ + content: [ + { type: "text", text: `Saved note to ${path.join(h.directory, "AGENT_NOTES.md")}` }, + ], + structuredContent: { path: path.join(h.directory, "AGENT_NOTES.md") }, + }); + expect(await readFile(path.join(h.directory, "AGENT_NOTES.md"), "utf8")).toBe( + "Run the targeted tests before committing.\n", + ); + await mcpA.callTool({ + name: "append_workspace_note", + arguments: { note: "Keep existing notes." }, + }); + expect(await readFile(path.join(h.directory, "AGENT_NOTES.md"), "utf8")).toBe( + "Run the targeted tests before committing.\nKeep existing notes.\n", + ); + await h.create(); + const mcpB = await h.mcp(); + expect((await mcpB.listTools()).tools.map((tool) => tool.name)).not.toContain( + "append_workspace_note", + ); + } finally { + await h.close(); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/agent-tools.test.ts b/packages/server/src/server/plugins/agent-tools.test.ts new file mode 100644 index 0000000000..f3bf7b886f --- /dev/null +++ b/packages/server/src/server/plugins/agent-tools.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "vitest"; +import { z } from "zod"; +import { defineAgentTool } from "@getpaseo/plugin/server"; +import { serializeAgentTool } from "./agent-tools.js"; +import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js"; + +test("agent tool schemas survive the process boundary and catalog round trip", () => { + const input = z + .object({ + title: z.string().min(1).describe("Issue title"), + state: z.enum(["open", "closed"]), + tags: z.array(z.string()).optional(), + nested: z.object({ enabled: z.boolean() }), + }) + .describe("Create an issue"); + const tool = defineAgentTool({ + name: "create_issue", + description: "Create", + input, + execute: () => ({ text: "done" }), + }); + const metadata = JSON.parse(JSON.stringify(serializeAgentTool(tool))); + const restored = z.fromJSONSchema(metadata.inputSchema); + expect( + serializePaseoToolInputParameters({ + ...metadata, + inputSchema: restored, + handler: async () => ({ content: [] }), + }), + ).toEqual({ + ...metadata.inputSchema, + additionalProperties: {}, + properties: { + ...metadata.inputSchema.properties, + nested: { ...metadata.inputSchema.properties.nested, additionalProperties: {} }, + }, + }); + expect(restored.parse({ title: "Bug", state: "open", nested: { enabled: true } })).toEqual({ + title: "Bug", + state: "open", + nested: { enabled: true }, + }); + expect( + restored.safeParse({ title: "", state: "unknown", nested: { enabled: true } }).success, + ).toBe(false); +}); diff --git a/packages/server/src/server/plugins/agent-tools.ts b/packages/server/src/server/plugins/agent-tools.ts new file mode 100644 index 0000000000..266234b446 --- /dev/null +++ b/packages/server/src/server/plugins/agent-tools.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import type { + AgentToolDefinition, + AgentToolResult, + PluginHookContext, + PluginSessionOpenRequest, + PluginHookAgent, +} from "@getpaseo/plugin/server"; +import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js"; + +export const agentToolMetadataSchema = z + .object({ + name: z + .string() + .regex(/^[a-z][a-z0-9_]*$/) + .max(64), + title: z.string().optional(), + description: z.string(), + inputSchema: z.record(z.string(), z.unknown()), + pluginId: z.string().optional(), + pluginInstanceId: z.string().optional(), + }) + .strict(); +export type AgentToolMetadata = z.infer; +export type AttachedAgentTool = AgentToolMetadata & { pluginId: string; pluginInstanceId: string }; +export type SessionOpenWireRequest = Omit & { + tools: AgentToolMetadata[]; +}; + +export interface PluginAgentToolsRuntime { + isToolPluginAvailable(pluginId: string, pluginInstanceId: string): boolean; + detachTools(agentId: string): void; + callTool( + pluginId: string, + pluginInstanceId: string, + agent: PluginHookAgent, + name: string, + input: unknown, + signal?: AbortSignal, + ): Promise; +} + +export function serializeAgentTool(tool: AgentToolDefinition): AgentToolMetadata { + return agentToolMetadataSchema.parse({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: toJsonSchemaCompat(tool.input, { strictUnions: true, pipeStrategy: "input" }), + }); +} + +// This store lives only in the plugin process. Definitions and parsed values never cross IPC. +export class PluginAgentTools { + private readonly agents = new Map(); + private readonly calls = new Map(); + + attach(agentId: string, tools: AgentToolDefinition[]): AgentToolMetadata[] { + const names = new Set(); + const metadata = tools.map((tool) => { + const serialized = serializeAgentTool(tool); + if (names.has(tool.name)) throw new Error(`Duplicate agent tool: ${tool.name}`); + names.add(tool.name); + return serialized; + }); + this.agents.set(agentId, tools); + return metadata; + } + + detach(agentId: string): void { + this.agents.delete(agentId); + for (const call of this.calls.values()) { + if (call.agentId === agentId) call.controller.abort(); + } + } + + cancel(requestId: string): void { + this.calls.get(requestId)?.controller.abort(); + } + + close(): void { + for (const call of this.calls.values()) call.controller.abort(); + this.agents.clear(); + } + + async call( + requestId: string, + agent: PluginHookAgent, + name: string, + input: unknown, + paseo: PluginHookContext["paseo"], + ): Promise { + const controller = new AbortController(); + this.calls.set(requestId, { agentId: agent.id, controller }); + try { + const tool = this.agents.get(agent.id)?.find((definition) => definition.name === name); + if (!tool) throw new Error(`Agent tool not attached: ${name}`); + const parsed = await tool.input.parseAsync(input); + controller.signal.throwIfAborted(); + const result = await tool.execute(parsed, { agent, paseo, signal: controller.signal }); + controller.signal.throwIfAborted(); + return result; + } catch (error) { + return { isError: true, text: error instanceof Error ? error.message : String(error) }; + } finally { + this.calls.delete(requestId); + } + } +} diff --git a/packages/server/src/server/plugins/compiler-build.ts b/packages/server/src/server/plugins/compiler-build.ts new file mode 100644 index 0000000000..5abd0df6ef --- /dev/null +++ b/packages/server/src/server/plugins/compiler-build.ts @@ -0,0 +1,431 @@ +import { stat } from "node:fs/promises"; +import { readPluginManifest } from "./manifest.js"; +import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { isBuiltin } from "node:module"; +import path from "node:path"; +import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js"; +import type { Metafile, OnResolveResult, Plugin } from "esbuild"; +import { + isPluginClientOnlySdkSpecifier, + isPluginServerOnlySdkSpecifier, + PLUGIN_SDK_SPECIFIERS, +} from "./plugin-sdk-specifiers.js"; + +import { loadEsbuild, SERVER_HOST_MODULES } from "./compiler-esbuild.js"; + +type PluginBuildTarget = "client" | "server"; + +type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid"; + +function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null { + const relative = path.relative(pluginDirectory, filePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid"; + const segments = relative.split(path.sep); + if (segments.includes("node_modules")) return null; + if (segments[0] === "client") return "client"; + if (segments[0] === "server") return "server"; + if (segments[0] === "shared") return "shared"; + if (/^index\.client\.tsx?$/.test(relative)) return "client"; + if (/^index\.server\.tsx?$/.test(relative)) return "server"; + return "invalid"; +} + +function containsPath(directory: string, filePath: string): boolean { + const relative = path.relative(directory, filePath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function dependencyName(specifier: string): string { + const segments = specifier.split("/"); + return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]; +} + +function findDependencyRoot( + resolvedPath: string, + specifier: string, + pluginDirectory: string, +): string | null { + const expectedName = dependencyName(specifier); + const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`; + let directory = path.dirname(resolvedPath); + for (;;) { + const manifestPath = path.join(directory, "package.json"); + if (existsSync(manifestPath)) { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown }; + if (manifest.name === expectedName || manifest.name === ambientName) { + if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) { + return null; + } + return directory; + } + } + const parent = path.dirname(directory); + if (parent === directory) return null; + directory = parent; + } +} + +function moduleBoundaryError( + moduleLocation: PluginModuleLocation | null, + target: PluginBuildTarget | "shared", + filePath: string, +): OnResolveResult | null { + if (moduleLocation === "invalid") { + return { + errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }], + }; + } + if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) { + return null; + } + return { + errors: [ + { + text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`, + }, + ], + }; +} + +function lexicalBoundaryError( + specifier: string, + resolveDirectory: string, + pluginDirectory: string, + target: PluginBuildTarget | "shared", +): OnResolveResult | null { + if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null; + const lexicalPath = path.resolve(resolveDirectory, specifier); + if (containsPath(pluginDirectory, lexicalPath)) { + return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath); + } + // Normalize only a containing root alias. Resolving the whole import would erase + // an authored server/ or client/ location when the final file is a symlink. + for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) { + if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) { + const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath)); + return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath); + } + if (path.dirname(ancestor) === ancestor) return null; + } +} + +function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin { + const boundaryResolution = {}; + const linkedDependencyRoots = new Set(); + return { + name: `paseo-plugin-${target}-runtime-boundary`, + setup(buildContext) { + const checked = new Set(); + const imports = createPluginImportReader(pluginDirectory); + function resolvedBoundaryError( + resolvedFile: string, + specifier: string, + importer: string, + owner: PluginBuildTarget | "shared", + ) { + const file = realpathSync.native(resolvedFile); + const location = directoryTarget(file, pluginDirectory); + if (location === "invalid") { + if ( + [...linkedDependencyRoots].some( + (root) => containsPath(root, importer) && containsPath(root, file), + ) + ) + return null; + if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) { + const root = findDependencyRoot(file, specifier, pluginDirectory); + if (root) { + linkedDependencyRoots.add(root); + return null; + } + } + } + return moduleBoundaryError(location, owner, file); + } + async function resolveImportFiles( + file: string, + specifier: string, + kind: PluginImportKind, + typeOnly: boolean, + ): Promise> { + const declaration = imports.resolve(specifier, file, kind); + const dependencyFiles = new Set(); + if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration))) + dependencyFiles.add(declaration); + if (typeOnly && !declaration) { + throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`); + } + if (!typeOnly && kind !== "type-reference") { + const resolution = await buildContext.resolve(specifier, { + importer: file, + resolveDir: path.dirname(file), + kind, + }); + // esbuild owns missing-runtime errors: erased imports and guarded optional + // requires are legal. Inspect every dependency that actually resolves. + if (!resolution.errors.length && !resolution.external && resolution.namespace === "file") + dependencyFiles.add(realpathSync.native(resolution.path)); + } + return dependencyFiles; + } + async function checkSourceImports( + sourcePath: string, + inheritedOwner: PluginBuildTarget | "shared", + ): Promise { + const file = realpathSync.native(sourcePath); + const owner = + directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner; + const key = `${owner}:${file}`; + if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null; + checked.add(key); + for (const { specifier, kind, typeOnly } of imports.read(file)) { + const packageSpecifier = + kind === "type-reference" + ? `@types/${specifier.replace(/^@/, "").replace("/", "__")}` + : specifier; + const error = + runtimeSpecifierError(specifier, owner, file) ?? + runtimeSpecifierError(packageSpecifier, owner, file) ?? + lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner); + if (error) return error; + // Protocol types are shared host contracts, also exposed by the SDK's peer + // dependency during development. Erased imports need no plugin-local install; + // runtime imports still go through normal dependency resolution below. + if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue; + // Host modules have separately enforced SDK boundaries and need no local installation. + if ( + (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) || + /^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) || + isBuiltin(specifier) || + packageSpecifier === "@types/node" + ) + continue; + const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly); + for (const dependencyFile of dependencyFiles) { + const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner); + if (boundaryError) return boundaryError; + const dependencyError = await checkSourceImports(dependencyFile, owner); + if (dependencyError) return dependencyError; + } + } + return null; + } + buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) => + checkSourceImports(args.path, target), + ); + buildContext.onResolve({ filter: /.*/ }, async (args) => { + if (args.kind === "entry-point") return null; + if (args.pluginData === boundaryResolution) return null; + const importer = + args.namespace === "file" ? realpathSync.native(args.importer) : args.importer; + const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir; + const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target; + const specifierError = runtimeSpecifierError(args.path, owner, importer); + if (specifierError) return specifierError; + const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner); + if (lexicalError) return lexicalError; + const resolution = await buildContext.resolve(args.path, { + importer, + namespace: args.namespace, + resolveDir, + kind: args.kind, + pluginData: boundaryResolution, + with: args.with, + }); + if ( + resolution.errors.length > 0 || + resolution.external || + resolution.namespace !== "file" + ) { + return null; + } + return resolvedBoundaryError(resolution.path, args.path, importer, owner); + }); + }, + }; +} + +function wrapCommonJsBundle(code: string): string { + return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`; +} + +function makeHermesInteropEager(code: string): string { + // Hermes evaluates esbuild's lazy CommonJS interop getters from a string with + // the final loop binding, so every named import can resolve to the last export. + // Plugin bundles execute once and do not need live bindings from host modules. + return code.replaceAll("get: () => from[key]", "value: from[key]"); +} + +function runtimeSpecifierError( + specifier: string, + target: PluginBuildTarget | "shared", + importer: string, +): OnResolveResult | null { + let kind: string | null = null; + if (specifier === "@getpaseo/plugin/client/host") kind = "host-private"; + else if ( + (specifier === "@getpaseo/plugin" || + specifier.startsWith("@getpaseo/plugin/") || + specifier === "@paseo/plugin" || + specifier.startsWith("@paseo/plugin/")) && + !(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) + ) + kind = "Unknown SDK"; + else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node")) + kind = "Node"; + else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only"; + else if ( + target !== "client" && + (isPluginClientOnlySdkSpecifier(specifier) || + /^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test( + specifier, + )) + ) + kind = "client-only"; + return kind + ? { + errors: [ + { + text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`, + }, + ], + } + : null; +} + +function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void { + function inputLocation(file: string): PluginModuleLocation | null { + const absolutePath = path.resolve(file); + // Metafile keys must stay unchanged for graph traversal. Only filesystem + // inputs have canonical paths; data URLs and external specifiers do not. + return directoryTarget( + existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath, + pluginDirectory, + ); + } + const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared"); + const visited = new Set(); + while (pending.length) { + const file = pending.pop()!; + if (visited.has(file)) continue; + visited.add(file); + for (const dependency of inputs[file]?.imports ?? []) { + const location = dependency.external ? null : inputLocation(dependency.path); + const error = + runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ?? + (dependency.external + ? null + : moduleBoundaryError( + location === "invalid" ? null : location, + "shared", + dependency.path, + )); + if (error?.errors?.length) throw new Error(error.errors[0].text); + if (!dependency.external) pending.push(dependency.path); + } + } +} + +async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise { + const { build } = loadEsbuild(); + // Use native canonical paths throughout: TypeScript expands Windows short names + // when resolving type references, while the JS realpath implementation retains them. + const pluginDirectory = realpathSync.native(path.dirname(entryPath)); + const result = await build({ + entryPoints: [realpathSync.native(entryPath)], + bundle: true, + format: "cjs", + jsx: "automatic", + // `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon. + loader: { ".svg": "text" }, + platform: target === "server" ? "node" : "neutral", + target: target === "server" ? "node20" : "es2020", + // The neutral platform reads no package.json entry fields, so packages without + // `exports` would not resolve. Read the same fields as esbuild's browser platform + // minus `browser`, since the bundle also runs in React Native. + mainFields: target === "client" ? ["module", "main"] : undefined, + // Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro, + // so apply the same compatibility transform before the app evaluates them from source. + supported: target === "client" ? { "async-await": false } : undefined, + external: + target === "client" + ? [ + ...PLUGIN_SDK_SPECIFIERS, + "@tanstack/react-query", + "react", + "react/jsx-runtime", + "react-native", + "zod", + ] + : SERVER_HOST_MODULES, + plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)], + metafile: true, + logLevel: "silent", + treeShaking: true, + write: false, + }); + checkSharedDependencies(result.metafile.inputs, pluginDirectory); + const output = result.outputFiles[0]?.text; + if (!output) throw new Error(`Plugin ${target} compilation produced no output`); + return wrapCommonJsBundle(makeHermesInteropEager(output)); +} + +export async function compilePlugin(entryPaths: { + client: string | null; + server: string | null; +}): Promise<{ + clientBundle: string | null; + serverBundle: string | null; +}> { + const [clientBundle, serverBundle] = await Promise.all([ + entryPaths.client ? compileTarget(entryPaths.client, "client") : null, + entryPaths.server ? compileTarget(entryPaths.server, "server") : null, + ]); + return { clientBundle, serverBundle }; +} + +const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const; +const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const; +async function findEntry(directory: string, filenames: readonly string[]): Promise { + for (const filename of filenames) { + const filePath = path.join(directory, filename); + const info = await stat(filePath).catch(() => null); + if (info?.isFile()) return filePath; + } + return null; +} + +async function resolveEntryPaths(directory: string): Promise<{ + client: string | null; + server: string | null; +}> { + const [client, server] = await Promise.all([ + findEntry(directory, CLIENT_ENTRY_FILENAMES), + findEntry(directory, SERVER_ENTRY_FILENAMES), + ]); + if (client || server) return { client, server }; + const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]); + if (legacyEntry) { + throw new Error( + "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration", + ); + } + throw new Error( + `Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`, + ); +} + +export async function compileDirectoryPlugin( + directory: string, + version: string, + builtinId?: string, +) { + const manifest = await readPluginManifest(directory); + if (builtinId !== undefined && manifest.id !== builtinId) { + throw new Error(`Built-in plugin ${builtinId} has manifest ID ${manifest.id}`); + } + assertPluginCompatibility({ ...manifest, version, runtime: "daemon" }); + const bundles = await compilePlugin(await resolveEntryPaths(directory)); + return { ...bundles, manifest }; +} diff --git a/packages/server/src/server/plugins/compiler-esbuild.ts b/packages/server/src/server/plugins/compiler-esbuild.ts new file mode 100644 index 0000000000..c36c9c22f9 --- /dev/null +++ b/packages/server/src/server/plugins/compiler-esbuild.ts @@ -0,0 +1,64 @@ +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { PLUGIN_SDK_SPECIFIERS } from "./plugin-sdk-specifiers.js"; +const nodeRequire = createRequire(import.meta.url); +export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"]; +const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH"; + +// esbuild resolves its own platform binary via require.resolve() the first time its +// module is evaluated. Inside the packaged desktop app that resolves to a path under +// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked. +// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path +// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads. +export function unpackedEsbuildBinaryFromPackageDir( + esbuildPackageDir: string, + platform: NodeJS.Platform, + arch: string, +): string | null { + const asarSegment = `${path.sep}app.asar${path.sep}`; + const asarIndex = esbuildPackageDir.indexOf(asarSegment); + if (asarIndex === -1) return null; + return path.join( + esbuildPackageDir.slice(0, asarIndex), + "app.asar.unpacked", + "node_modules", + `@esbuild/${platform}-${arch}`, + ...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]), + ); +} + +export function resolveExistingAsarUnpackedEsbuildBinary( + esbuildPackageDir: string, + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, + exists: (file: string) => boolean = existsSync, +): string | null { + const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch); + return binaryPath && exists(binaryPath) ? binaryPath : null; +} + +function resolveAsarUnpackedEsbuildBinary(): string | null { + let esbuildDir: string; + try { + esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json")); + } catch { + return null; + } + return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir); +} + +export function loadEsbuild(): typeof import("esbuild") { + const previousBinaryPath = process.env[ESBUILD_BINARY_PATH]; + const unpackedBinary = resolveAsarUnpackedEsbuildBinary(); + if (unpackedBinary) process.env[ESBUILD_BINARY_PATH] = unpackedBinary; + + try { + // esbuild reads this variable while its CommonJS module is evaluated. Keep + // the compatibility bridge local so it cannot become an agent's environment. + return nodeRequire("esbuild") as typeof import("esbuild"); + } finally { + if (previousBinaryPath === undefined) delete process.env[ESBUILD_BINARY_PATH]; + else process.env[ESBUILD_BINARY_PATH] = previousBinaryPath; + } +} diff --git a/packages/server/src/server/plugins/compiler-worker.ts b/packages/server/src/server/plugins/compiler-worker.ts new file mode 100644 index 0000000000..ddf2544b8b --- /dev/null +++ b/packages/server/src/server/plugins/compiler-worker.ts @@ -0,0 +1,62 @@ +import { parentPort } from "node:worker_threads"; +import type { ZodIssue } from "zod"; +import { compilePlugin, compileDirectoryPlugin } from "./compiler-build.js"; +import { runPluginBuild } from "./preparation-build.js"; +import type { CompilerTask } from "./compiler.js"; + +export type CompilerReply = + | { kind: "log"; data: Record; message: string } + | { kind: "result"; value: unknown } + | { + kind: "error"; + error: Error; + properties: Record; + issues?: ZodIssue[]; + errors?: unknown[]; + warnings?: unknown[]; + }; + +const port = parentPort!; +// The compiler owner serializes requests; the port queues the first request +// until this entrypoint has loaded, including in source/tsx development. +port.on("message", (task: CompilerTask) => { + void run(task); +}); + +async function run(task: CompilerTask): Promise { + try { + let value: unknown; + switch (task.kind) { + case "entries": + value = await compilePlugin(task.entries); + break; + case "directory": + value = await compileDirectoryPlugin(task.directory, task.version, task.builtinId); + break; + case "prepare": + await runPluginBuild( + task.directory, + task.commands, + { + info: (data, message) => + port.postMessage({ kind: "log", data, message } satisfies CompilerReply), + }, + task.environment, + ); + break; + } + port.postMessage({ kind: "result", value } satisfies CompilerReply); + } catch (cause) { + const error = cause instanceof Error ? cause : new Error(String(cause)); + port.postMessage({ + kind: "error", + error, + // Error cloning drops own metadata such as filesystem code/path. Zod's + // readonly issues are reconstructed separately by the compiler owner. + properties: Object.fromEntries(Object.entries(error).filter(([key]) => key !== "issues")), + ...("issues" in error ? { issues: error.issues as ZodIssue[] } : {}), + ...("errors" in error ? { errors: error.errors as unknown[] } : {}), + ...("warnings" in error ? { warnings: error.warnings as unknown[] } : {}), + } satisfies CompilerReply); + } +} diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index 6f694fbaa3..67ca96e596 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -3,8 +3,10 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { ZodError } from "zod"; import { compilePlugin, + compileDirectoryPlugin, resolveExistingAsarUnpackedEsbuildBinary, unpackedEsbuildBinaryFromPackageDir, } from "./compiler.js"; @@ -975,3 +977,52 @@ export default function contribute() { void value; void secret; return () => und expect(serverBundle).toBeNull(); }); }); + +it("preserves esbuild diagnostics across the compiler boundary", async () => { + const { server } = await createSplitPlugin(); + await writeFile(server, "export default function contribute( {"); + const error = await compilePlugin({ client: null, server }).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ + name: "Error", + message: expect.stringContaining("Expected identifier but found end of file"), + errors: [ + expect.objectContaining({ + text: "Expected identifier but found end of file", + location: expect.objectContaining({ + line: 1, + lineText: "export default function contribute( {", + }), + }), + ], + warnings: [], + }); + // A failed task does not poison the next build. + await writeFile(server, 'export default () => "recovered";'); + const bundles = await compilePlugin({ client: null, server }); + expect(bundles.serverBundle).toContain("recovered"); +}); + +it("preserves manifest validation errors across the compiler boundary", async () => { + const { directory } = await createSplitPlugin(); + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: 42 })); + const error = await compileDirectoryPlugin(directory, "0.13.2").catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(ZodError); + expect(error).toMatchObject({ + name: "ZodError", + issues: [expect.objectContaining({ path: ["id"], code: "invalid_type" })], + }); + await writeFile(path.join(directory, "paseo-plugin.json"), "{"); + await expect(compileDirectoryPlugin(directory, "0.13.2")).rejects.toBeInstanceOf(SyntaxError); +}); + +it("preserves filesystem error metadata for a missing entry", async () => { + const { server } = await createSplitPlugin(); + await rm(server); + await expect(compilePlugin({ client: null, server })).rejects.toMatchObject({ + code: "ENOENT", + errno: expect.any(Number), + syscall: "realpath", + path: server, + }); +}); diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 4cf89a902c..b96c8ecd4e 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -1,442 +1,152 @@ -import { existsSync, readFileSync, realpathSync } from "node:fs"; -import { createRequire, isBuiltin } from "node:module"; -import path from "node:path"; -import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js"; -import type { Metafile, OnResolveResult, Plugin } from "esbuild"; -import { - isPluginClientOnlySdkSpecifier, - isPluginServerOnlySdkSpecifier, - PLUGIN_SDK_SPECIFIERS, -} from "./plugin-sdk-specifiers.js"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import { Worker } from "node:worker_threads"; +import pLimit from "p-limit"; +import { ZodError } from "zod"; +import type * as build from "./compiler-build.js"; +import type { PluginManifest } from "./manifest.js"; +import type { CompilerReply } from "./compiler-worker.js"; -const nodeRequire = createRequire(import.meta.url); -export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"]; -const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH"; +export { + SERVER_HOST_MODULES, + resolveExistingAsarUnpackedEsbuildBinary, + unpackedEsbuildBinaryFromPackageDir, +} from "./compiler-esbuild.js"; -// esbuild resolves its own platform binary via require.resolve() the first time its -// module is evaluated. Inside the packaged desktop app that resolves to a path under -// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked. -// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path -// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads. -export function unpackedEsbuildBinaryFromPackageDir( - esbuildPackageDir: string, - platform: NodeJS.Platform, - arch: string, -): string | null { - const asarSegment = `${path.sep}app.asar${path.sep}`; - const asarIndex = esbuildPackageDir.indexOf(asarSegment); - if (asarIndex === -1) return null; - return path.join( - esbuildPackageDir.slice(0, asarIndex), - "app.asar.unpacked", - "node_modules", - `@esbuild/${platform}-${arch}`, - ...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]), - ); -} - -export function resolveExistingAsarUnpackedEsbuildBinary( - esbuildPackageDir: string, - platform: NodeJS.Platform = process.platform, - arch: string = process.arch, - exists: (file: string) => boolean = existsSync, -): string | null { - const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch); - return binaryPath && exists(binaryPath) ? binaryPath : null; -} - -function resolveAsarUnpackedEsbuildBinary(): string | null { - let esbuildDir: string; - try { - esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json")); - } catch { - return null; - } - return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir); -} - -function loadEsbuild(): typeof import("esbuild") { - const previousBinaryPath = process.env[ESBUILD_BINARY_PATH]; - const unpackedBinary = resolveAsarUnpackedEsbuildBinary(); - if (unpackedBinary) process.env[ESBUILD_BINARY_PATH] = unpackedBinary; - - try { - // esbuild reads this variable while its CommonJS module is evaluated. Keep - // the compatibility bridge local so it cannot become an agent's environment. - return nodeRequire("esbuild") as typeof import("esbuild"); - } finally { - if (previousBinaryPath === undefined) delete process.env[ESBUILD_BINARY_PATH]; - else process.env[ESBUILD_BINARY_PATH] = previousBinaryPath; - } -} - -type PluginBuildTarget = "client" | "server"; - -type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid"; - -function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null { - const relative = path.relative(pluginDirectory, filePath); - if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid"; - const segments = relative.split(path.sep); - if (segments.includes("node_modules")) return null; - if (segments[0] === "client") return "client"; - if (segments[0] === "server") return "server"; - if (segments[0] === "shared") return "shared"; - if (/^index\.client\.tsx?$/.test(relative)) return "client"; - if (/^index\.server\.tsx?$/.test(relative)) return "server"; - return "invalid"; -} - -function containsPath(directory: string, filePath: string): boolean { - const relative = path.relative(directory, filePath); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function dependencyName(specifier: string): string { - const segments = specifier.split("/"); - return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]; -} - -function findDependencyRoot( - resolvedPath: string, - specifier: string, - pluginDirectory: string, -): string | null { - const expectedName = dependencyName(specifier); - const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`; - let directory = path.dirname(resolvedPath); - for (;;) { - const manifestPath = path.join(directory, "package.json"); - if (existsSync(manifestPath)) { - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown }; - if (manifest.name === expectedName || manifest.name === ambientName) { - if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) { - return null; - } - return directory; - } - } - const parent = path.dirname(directory); - if (parent === directory) return null; - directory = parent; - } -} - -function moduleBoundaryError( - moduleLocation: PluginModuleLocation | null, - target: PluginBuildTarget | "shared", - filePath: string, -): OnResolveResult | null { - if (moduleLocation === "invalid") { - return { - errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }], +export type CompilerTask = + | { kind: "entries"; entries: Parameters[0] } + | { kind: "directory"; directory: string; version: string; builtinId?: string } + | { + kind: "prepare"; + directory: string; + commands: PluginManifest["build"]; + environment: NodeJS.ProcessEnv; }; - } - if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) { - return null; - } - return { - errors: [ - { - text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`, - }, - ], - }; + +interface TaskResult { + entries: Awaited>; + directory: Awaited>; + prepare: void; } -function lexicalBoundaryError( - specifier: string, - resolveDirectory: string, - pluginDirectory: string, - target: PluginBuildTarget | "shared", -): OnResolveResult | null { - if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null; - const lexicalPath = path.resolve(resolveDirectory, specifier); - if (containsPath(pluginDirectory, lexicalPath)) { - return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath); - } - // Normalize only a containing root alias. Resolving the whole import would erase - // an authored server/ or client/ location when the final file is a symlink. - for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) { - if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) { - const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath)); - return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath); - } - if (path.dirname(ancestor) === ancestor) return null; - } -} +const IDLE_TIMEOUT_MS = 1_000; +const source = import.meta.url.endsWith(".ts"); +const workerUrl = new URL( + source ? "./compiler-worker.ts" : "./compiler-worker.js", + import.meta.url, +); +// Resolve the development loader once at module initialization, never during a +// compile request. Packaged workers execute ordinary JavaScript without tsx. +const workerEntrypoint = source + ? `import(${JSON.stringify(pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm/api")).href)}).then(({ register }) => { register(); return import(${JSON.stringify(workerUrl.href)}); });` + : workerUrl; -function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin { - const boundaryResolution = {}; - const linkedDependencyRoots = new Set(); - return { - name: `paseo-plugin-${target}-runtime-boundary`, - setup(buildContext) { - const checked = new Set(); - const imports = createPluginImportReader(pluginDirectory); - function resolvedBoundaryError( - resolvedFile: string, - specifier: string, - importer: string, - owner: PluginBuildTarget | "shared", - ) { - const file = realpathSync.native(resolvedFile); - const location = directoryTarget(file, pluginDirectory); - if (location === "invalid") { - if ( - [...linkedDependencyRoots].some( - (root) => containsPath(root, importer) && containsPath(root, file), - ) - ) - return null; - if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) { - const root = findDependencyRoot(file, specifier, pluginDirectory); - if (root) { - linkedDependencyRoots.add(root); - return null; - } +// Reuse the isolate during a build batch (notably daemon bootstrap), then release +// TypeScript's heap when idle. Only this owner admits work or stops the worker. +class PluginCompiler { + private readonly queue = pLimit(1); + private worker: Worker | null = null; + private idleTimer: ReturnType | null = null; + private stopping: Promise = Promise.resolve(); + + run( + task: T, + onLog?: (data: Record, message: string) => void, + ): Promise { + return this.queue(async () => { + if (this.idleTimer) clearTimeout(this.idleTimer); + this.idleTimer = null; + await this.stopping; + const worker = this.worker ?? this.startWorker(); + worker.ref(); + try { + return await this.request(worker, task, onLog); + } finally { + if (this.worker === worker) { + worker.unref(); + this.idleTimer = setTimeout(() => this.stopWorker(worker), IDLE_TIMEOUT_MS); + this.idleTimer.unref(); + } else { + await this.stopping; + } + } + }); + } + + private startWorker(): Worker { + const worker = new Worker(workerEntrypoint, { eval: source, execArgv: [] }); + this.worker = worker; + // Keep error/exit ownership while idle too. The next caller gets a fresh + // isolate after a crash; author build commands are never retried. + worker.on("error", () => this.stopWorker(worker)); + worker.on("exit", () => { + if (this.worker === worker) this.stopWorker(worker); + }); + return worker; + } + + private stopWorker(worker: Worker): void { + if (this.worker !== worker) return; + this.worker = null; + if (this.idleTimer) clearTimeout(this.idleTimer); + this.idleTimer = null; + this.stopping = worker.terminate(); + } + + private async request( + worker: Worker, + task: T, + onLog?: (data: Record, message: string) => void, + ): Promise { + let onMessage: (reply: CompilerReply) => void; + let onError: (error: Error) => void; + let onExit: (code: number) => void; + try { + return await new Promise((resolve, reject) => { + onError = reject; + onExit = (code) => reject(new Error(`Plugin compiler worker exited (code ${code})`)); + onMessage = (reply) => { + if (reply.kind === "log") { + onLog?.(reply.data, reply.message); + } else if (reply.kind === "result") { + resolve(reply.value as TaskResult[T["kind"]]); + } else { + const error = reply.issues ? new ZodError(reply.issues) : reply.error; + Object.assign(error, reply.properties); + if (reply.errors) Object.assign(error, { errors: reply.errors }); + if (reply.warnings) Object.assign(error, { warnings: reply.warnings }); + // Promise.all can reject while the other target is still compiling. + // Retire the isolate before another request can enter it. + this.stopWorker(worker); + reject(error); } - } - return moduleBoundaryError(location, owner, file); - } - async function resolveImportFiles( - file: string, - specifier: string, - kind: PluginImportKind, - typeOnly: boolean, - ): Promise> { - const declaration = imports.resolve(specifier, file, kind); - const dependencyFiles = new Set(); - if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration))) - dependencyFiles.add(declaration); - if (typeOnly && !declaration) { - throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`); - } - if (!typeOnly && kind !== "type-reference") { - const resolution = await buildContext.resolve(specifier, { - importer: file, - resolveDir: path.dirname(file), - kind, - }); - // esbuild owns missing-runtime errors: erased imports and guarded optional - // requires are legal. Inspect every dependency that actually resolves. - if (!resolution.errors.length && !resolution.external && resolution.namespace === "file") - dependencyFiles.add(realpathSync.native(resolution.path)); - } - return dependencyFiles; - } - async function checkSourceImports( - sourcePath: string, - inheritedOwner: PluginBuildTarget | "shared", - ): Promise { - const file = realpathSync.native(sourcePath); - const owner = - directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner; - const key = `${owner}:${file}`; - if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null; - checked.add(key); - for (const { specifier, kind, typeOnly } of imports.read(file)) { - const packageSpecifier = - kind === "type-reference" - ? `@types/${specifier.replace(/^@/, "").replace("/", "__")}` - : specifier; - const error = - runtimeSpecifierError(specifier, owner, file) ?? - runtimeSpecifierError(packageSpecifier, owner, file) ?? - lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner); - if (error) return error; - // Protocol types are shared host contracts, also exposed by the SDK's peer - // dependency during development. Erased imports need no plugin-local install; - // runtime imports still go through normal dependency resolution below. - if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue; - // Host modules have separately enforced SDK boundaries and need no local installation. - if ( - (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) || - /^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) || - isBuiltin(specifier) || - packageSpecifier === "@types/node" - ) - continue; - const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly); - for (const dependencyFile of dependencyFiles) { - const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner); - if (boundaryError) return boundaryError; - const dependencyError = await checkSourceImports(dependencyFile, owner); - if (dependencyError) return dependencyError; - } - } - return null; - } - buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) => - checkSourceImports(args.path, target), - ); - buildContext.onResolve({ filter: /.*/ }, async (args) => { - if (args.kind === "entry-point") return null; - if (args.pluginData === boundaryResolution) return null; - const importer = - args.namespace === "file" ? realpathSync.native(args.importer) : args.importer; - const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir; - const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target; - const specifierError = runtimeSpecifierError(args.path, owner, importer); - if (specifierError) return specifierError; - const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner); - if (lexicalError) return lexicalError; - const resolution = await buildContext.resolve(args.path, { - importer, - namespace: args.namespace, - resolveDir, - kind: args.kind, - pluginData: boundaryResolution, - with: args.with, - }); - if ( - resolution.errors.length > 0 || - resolution.external || - resolution.namespace !== "file" - ) { - return null; - } - return resolvedBoundaryError(resolution.path, args.path, importer, owner); + }; + worker.on("error", onError); + worker.on("exit", onExit); + worker.on("message", onMessage); + worker.postMessage(task, []); }); - }, - }; -} - -function wrapCommonJsBundle(code: string): string { - return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`; -} - -function makeHermesInteropEager(code: string): string { - // Hermes evaluates esbuild's lazy CommonJS interop getters from a string with - // the final loop binding, so every named import can resolve to the last export. - // Plugin bundles execute once and do not need live bindings from host modules. - return code.replaceAll("get: () => from[key]", "value: from[key]"); -} - -function runtimeSpecifierError( - specifier: string, - target: PluginBuildTarget | "shared", - importer: string, -): OnResolveResult | null { - let kind: string | null = null; - if (specifier === "@getpaseo/plugin/client/host") kind = "host-private"; - else if ( - (specifier === "@getpaseo/plugin" || - specifier.startsWith("@getpaseo/plugin/") || - specifier === "@paseo/plugin" || - specifier.startsWith("@paseo/plugin/")) && - !(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) - ) - kind = "Unknown SDK"; - else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node")) - kind = "Node"; - else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only"; - else if ( - target !== "client" && - (isPluginClientOnlySdkSpecifier(specifier) || - /^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test( - specifier, - )) - ) - kind = "client-only"; - return kind - ? { - errors: [ - { - text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`, - }, - ], - } - : null; -} - -function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void { - function inputLocation(file: string): PluginModuleLocation | null { - const absolutePath = path.resolve(file); - // Metafile keys must stay unchanged for graph traversal. Only filesystem - // inputs have canonical paths; data URLs and external specifiers do not. - return directoryTarget( - existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath, - pluginDirectory, - ); - } - const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared"); - const visited = new Set(); - while (pending.length) { - const file = pending.pop()!; - if (visited.has(file)) continue; - visited.add(file); - for (const dependency of inputs[file]?.imports ?? []) { - const location = dependency.external ? null : inputLocation(dependency.path); - const error = - runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ?? - (dependency.external - ? null - : moduleBoundaryError( - location === "invalid" ? null : location, - "shared", - dependency.path, - )); - if (error?.errors?.length) throw new Error(error.errors[0].text); - if (!dependency.external) pending.push(dependency.path); + } finally { + worker.off("message", onMessage!); + worker.off("error", onError!); + worker.off("exit", onExit!); } } } -async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise { - const { build } = loadEsbuild(); - // Use native canonical paths throughout: TypeScript expands Windows short names - // when resolving type references, while the JS realpath implementation retains them. - const pluginDirectory = realpathSync.native(path.dirname(entryPath)); - const result = await build({ - entryPoints: [realpathSync.native(entryPath)], - bundle: true, - format: "cjs", - jsx: "automatic", - // `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon. - loader: { ".svg": "text" }, - platform: target === "server" ? "node" : "neutral", - target: target === "server" ? "node20" : "es2020", - // The neutral platform reads no package.json entry fields, so packages without - // `exports` would not resolve. Read the same fields as esbuild's browser platform - // minus `browser`, since the bundle also runs in React Native. - mainFields: target === "client" ? ["module", "main"] : undefined, - // Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro, - // so apply the same compatibility transform before the app evaluates them from source. - supported: target === "client" ? { "async-await": false } : undefined, - external: - target === "client" - ? [ - ...PLUGIN_SDK_SPECIFIERS, - "@tanstack/react-query", - "react", - "react/jsx-runtime", - "react-native", - "zod", - ] - : SERVER_HOST_MODULES, - plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)], - metafile: true, - logLevel: "silent", - treeShaking: true, - write: false, - }); - checkSharedDependencies(result.metafile.inputs, pluginDirectory); - const output = result.outputFiles[0]?.text; - if (!output) throw new Error(`Plugin ${target} compilation produced no output`); - return wrapCommonJsBundle(makeHermesInteropEager(output)); +const compiler = new PluginCompiler(); + +export function runCompilerTask( + task: T, + onLog?: (data: Record, message: string) => void, +): Promise { + return compiler.run(task, onLog); } -export async function compilePlugin(entryPaths: { - client: string | null; - server: string | null; -}): Promise<{ - clientBundle: string | null; - serverBundle: string | null; -}> { - const [clientBundle, serverBundle] = await Promise.all([ - entryPaths.client ? compileTarget(entryPaths.client, "client") : null, - entryPaths.server ? compileTarget(entryPaths.server, "server") : null, - ]); - return { clientBundle, serverBundle }; +export function compilePlugin(entries: Parameters[0]) { + return runCompilerTask({ kind: "entries", entries }); +} + +export function compileDirectoryPlugin(directory: string, version: string, builtinId?: string) { + return runCompilerTask({ kind: "directory", directory, version, builtinId }); } diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 71fe2b96e9..4ca5aeefd5 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -526,7 +526,7 @@ describe("PluginService", () => { if (!address || typeof address === "string") throw new Error("Missing registry address"); const url = `http://127.0.0.1:${address.port}`; const store = createStore(home); - store.patch({ pluginsEnabled: false }); + await store.patch({ pluginsEnabled: false }); const service = bindTestSessionHost( new PluginService(pino({ level: "silent" }), store, "0.4.0", { managedSources: new ManagedPluginSources(home, { defaultUrl: url }), @@ -1079,13 +1079,13 @@ export default function contribute(plugin: unknown) { await service.installDirectory({ path: first }); await service.installDirectory({ path: second }); - store.patch({ pluginsEnabled: false }); + await store.patch({ pluginsEnabled: false }); expect(service.catalog()).toEqual([]); await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow( "Plugin is not available", ); - store.patch({ pluginsEnabled: true }); + await store.patch({ pluginsEnabled: true }); await service.reloadPlugin("first"); expect((await service.listPlugins()).map(({ id, status }) => ({ id, status }))).toEqual([ { id: "first", status: "running" }, @@ -1100,16 +1100,16 @@ export default function contribute(plugin: unknown) { const store = createStore(home, { slow: { source: "directory", path: "/plugins/slow", enabled: true }, }); - store.patch({ pluginsEnabled: false }); + await store.patch({ pluginsEnabled: false }); const paused = createPausedRuntime(); const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", { runtime: paused.runtime, }); await service.start(); - store.patch({ pluginsEnabled: true }); + await store.patch({ pluginsEnabled: true }); await paused.started; - store.patch({ pluginsEnabled: false }); + await store.patch({ pluginsEnabled: false }); paused.releaseStart(); await service.stopAllPlugins(); @@ -1119,6 +1119,47 @@ export default function contribute(plugin: unknown) { ]); }); + it("persists overlapping enable and disable without losing either source mutation", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const store = createStore(home, { + a: { source: "directory", path: "/plugins/a", enabled: false }, + b: { source: "directory", path: "/plugins/b", enabled: true }, + }); + const paused = createPluginSelectivePausedRuntime("unused"); + const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", { + runtime: paused.runtime, + }); + await service.start(); + await Promise.all([service.enablePlugin("a"), service.disablePlugin("b")]); + expect(store.get().plugins).toEqual({ + a: { source: "directory", path: "/plugins/a", enabled: true }, + b: { source: "directory", path: "/plugins/b", enabled: false }, + }); + await service.stopAllPlugins(); + }); + + it("clears a failed disable intent so the previous enabled state can publish", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const store = createStore(home, { + slow: { source: "directory", path: "/plugins/slow", enabled: false }, + }); + const paused = createPausedRuntime(); + const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", { + runtime: paused.runtime, + }); + await service.start(); + const enabling = service.enablePlugin("slow"); + await paused.started; + await writeFile(path.join(home, "config.json"), "invalid JSON"); + await expect(service.disablePlugin("slow")).rejects.toThrow("Invalid JSON"); + paused.releaseStart(); + await expect(enabling).resolves.toMatchObject({ enabled: true, status: "running" }); + expect(service.catalog()).toEqual([{ id: "slow", clientBundle: "bundle" }]); + await service.stopAllPlugins(); + }); + it("does not publish an in-flight enable after a later plugin disable", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index a5ecea8097..98e7ce5623 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -1,3 +1,4 @@ +import type { PluginAgentToolsRuntime } from "./agent-tools.js"; import type { PluginLifecycle } from "./lifecycle/index.js"; import path from "node:path"; import { stat, rm } from "node:fs/promises"; @@ -37,6 +38,10 @@ import type { PluginUsageSourceMetadata } from "./plugin-process-protocol.js"; const BUILTIN_PROVIDER_ID_SET: ReadonlySet = new Set(BUILTIN_PROVIDER_IDS); interface PluginRuntimePort { + subscribeSessionOpenChanges?: PluginRuntime["subscribeSessionOpenChanges"]; + isToolPluginAvailable?: PluginRuntime["isToolPluginAvailable"]; + callTool?: PluginRuntime["callTool"]; + detachTools?: PluginRuntime["detachTools"]; emit?: PluginLifecycle["emit"]; drainEvents?: PluginRuntime["drainEvents"]; before?: PluginLifecycle["before"]; @@ -92,6 +97,10 @@ export class PluginService { private readonly providerListeners = new Set<() => void>(); private lifecycle = Promise.resolve(); private globalStartsBlocked = true; + private readonly pendingOperations = new Map< + string, + { kind: "disable" | "remove"; completion: Promise } + >(); private started = false; private readonly settingsListeners = new Set<(pluginId: string, settingsId: string) => void>(); @@ -131,6 +140,34 @@ export class PluginService { this.runtime.emit?.(name, event); }; + subscribeSessionOpenChanges(listener: () => void): () => void { + return this.runtime.subscribeSessionOpenChanges?.(listener) ?? (() => {}); + } + + readonly isToolPluginAvailable: PluginAgentToolsRuntime["isToolPluginAvailable"] = ( + pluginId, + pluginInstanceId, + ) => { + return this.runtime.isToolPluginAvailable?.(pluginId, pluginInstanceId) ?? false; + }; + + readonly detachTools: PluginAgentToolsRuntime["detachTools"] = (agentId) => { + this.runtime.detachTools?.(agentId); + }; + + readonly callTool: PluginAgentToolsRuntime["callTool"] = async ( + pluginId, + pluginInstanceId, + agent, + name, + input, + signal, + ) => { + if (!this.isToolPluginAvailable(pluginId, pluginInstanceId) || !this.runtime.callTool) + return { isError: true, text: `Plugin is not available: ${pluginId}` }; + return this.runtime.callTool(pluginId, pluginInstanceId, agent, name, input, signal); + }; + async drainEvents(): Promise { await this.runtime.drainEvents?.(); } @@ -265,11 +302,7 @@ export class PluginService { `Plugin ID "${pluginId}" is already configured; choose another ID with --id`, ); } - const sources = { - ...this.configStore.get().plugins, - [pluginId]: { source: "directory" as const, path: directory, enabled: true }, - }; - this.configStore.patch({ plugins: sources }); + await this.patchSource(pluginId, { source: "directory", path: directory, enabled: true }); if (this.canPublish(pluginId)) await this.startConfigured(pluginId); this.notify(pluginId); const installed = await this.requireItem(pluginId); @@ -328,15 +361,17 @@ export class PluginService { await managedSources.discard(candidate); throw error; } - this.patchSource(pluginId, { source: "directory", path: candidate.directory, enabled: true }); + await this.patchSource(pluginId, { + source: "directory", + path: candidate.directory, + enabled: true, + }); try { if (this.canPublish(pluginId)) await this.startExplicit(pluginId, candidate.directory); managedSources.commit(pluginId, candidate.record); } catch (error) { await this.stopPlugin(pluginId); - const sources = { ...this.configStore.get().plugins }; - delete sources[pluginId]; - this.configStore.patch({ plugins: sources }); + await this.removeSource(pluginId); await managedSources.discard(candidate); this.errors.delete(pluginId); this.notify(pluginId); @@ -428,10 +463,11 @@ export class PluginService { } async enablePlugin(pluginId: string): Promise { - const source = this.requireSource(pluginId); - this.patchSource(pluginId, { ...source, enabled: true }); + await this.patchSource(pluginId, (source) => ({ ...source, enabled: true })); this.errors.delete(pluginId); return this.enqueue(async () => { + // Cancellation persistence uses the store queue, so it never waits behind a blocked start. + await this.pendingOperations.get(pluginId)?.completion.catch(() => {}); const current = this.requireSource(pluginId); if (current.enabled !== false && this.configStore.get().pluginsEnabled === true) { await this.startExplicit(pluginId, current.path); @@ -442,8 +478,17 @@ export class PluginService { } async disablePlugin(pluginId: string): Promise { - const source = this.requireSource(pluginId); - this.patchSource(pluginId, { ...source, enabled: false }); + const operation = { + kind: "disable", + completion: this.patchSource(pluginId, (source) => ({ ...source, enabled: false })), + } as const; + this.pendingOperations.set(pluginId, operation); + try { + await operation.completion; + } finally { + if (this.pendingOperations.get(pluginId) === operation) + this.pendingOperations.delete(pluginId); + } const stopping = this.stopPlugin(pluginId); return this.enqueue(async () => { await stopping; @@ -455,10 +500,15 @@ export class PluginService { async removePlugin(pluginId: string): Promise { this.requireSource(pluginId); + const operation = { kind: "remove", completion: this.removeSource(pluginId) } as const; + this.pendingOperations.set(pluginId, operation); + try { + await operation.completion; + } finally { + if (this.pendingOperations.get(pluginId) === operation) + this.pendingOperations.delete(pluginId); + } const stopping = this.stopPlugin(pluginId); - const sources = { ...this.configStore.get().plugins }; - delete sources[pluginId]; - this.configStore.patch({ plugins: sources }); await this.enqueue(async () => { await stopping; this.runtime.clearLogs(pluginId); @@ -531,6 +581,7 @@ export class PluginService { private canPublish(pluginId: string): boolean { const config = this.configStore.get(); return ( + !this.pendingOperations.has(pluginId) && !this.globalStartsBlocked && config.pluginsEnabled === true && config.plugins?.[pluginId]?.enabled !== false && @@ -744,7 +795,10 @@ export class PluginService { await managedSources.discard(candidate); throw new Error(`Plugin is no longer configured: ${pluginId}`); } - this.patchSource(pluginId, { ...activatedSource, path: candidate.directory }); + await this.patchSource(pluginId, (currentSource) => ({ + ...currentSource, + path: candidate.directory, + })); this.errors.delete(pluginId); this.notify(pluginId); let warning: string | undefined; @@ -786,9 +840,28 @@ export class PluginService { return source; } - private patchSource(pluginId: string, source: PluginSource): void { - this.configStore.patch({ - plugins: { ...this.configStore.get().plugins, [pluginId]: source }, + private async patchSource( + pluginId: string, + update: PluginSource | ((source: PluginSource) => PluginSource), + ): Promise { + await this.configStore.patch((current) => { + const source = current.plugins?.[pluginId]; + if (typeof update === "function" && !source) + throw new Error(`Plugin is not configured: ${pluginId}`); + return { + plugins: { + ...current.plugins, + [pluginId]: typeof update === "function" ? update(source!) : update, + }, + }; + }); + } + + private async removeSource(pluginId: string): Promise { + await this.configStore.patch((current) => { + const sources = { ...current.plugins }; + delete sources[pluginId]; + return { plugins: sources }; }); } diff --git a/packages/server/src/server/plugins/lifecycle/handlers.test.ts b/packages/server/src/server/plugins/lifecycle/handlers.test.ts index 5e30535953..bafd1f9b94 100644 --- a/packages/server/src/server/plugins/lifecycle/handlers.test.ts +++ b/packages/server/src/server/plugins/lifecycle/handlers.test.ts @@ -93,8 +93,39 @@ test("session-open hooks reject changes to session identity instead of silently reason: "resume", purpose: "interactive", env: {}, + title: null, + labels: {}, + tools: [], }, paseo, ), ).rejects.toThrow("agent.session_open hooks can only change env"); }); + +test.each([{ title: "Changed" }, { labels: { changed: "true" } }])( + "session-open targeting metadata is read-only: %j", + async (patch) => { + const hooks = new PluginHookHandlers(() => {}); + hooks.before("agent.session_open", ({ request }) => ({ ...request, ...patch })); + await expect( + hooks.invoke( + "operation", + "before", + "agent.session_open", + { + agentId: "agent", + workspaceId: null, + provider: "codex", + cwd: "/project", + title: "Original", + labels: {}, + tools: [], + env: {}, + reason: "create", + purpose: "interactive", + }, + paseo, + ), + ).rejects.toThrow("agent.session_open hooks can only change env and tools"); + }, +); diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts index 29d5fda145..2b379eb7f8 100644 --- a/packages/server/src/server/plugins/lifecycle/index.ts +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -1,3 +1,11 @@ +import { isDeepStrictEqual } from "node:util"; +import { + serializeAgentTool, + agentToolMetadataSchema, + type SessionOpenWireRequest, + type PluginAgentTools, +} from "../agent-tools.js"; +import type { AgentToolDefinition } from "@getpaseo/plugin/server"; import type { AgentStreamEvent } from "../../agent/agent-sdk-types.js"; import { z } from "zod"; import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages"; @@ -15,6 +23,10 @@ import type { import { WorkspaceCreateRequestSchema } from "@getpaseo/protocol/messages"; import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; +export type PluginWireBeforeRequests = Omit & { + "agent.session_open": SessionOpenWireRequest; +}; + export const lifecycleEventNames = [ "agent.created", "agent.closed", @@ -38,28 +50,44 @@ const beforeSchemas = { cwd: z.string(), reason: z.enum(["create", "resume", "refresh", "import"]), purpose: z.enum(["interactive", "history"]), + title: z.string().nullable(), + labels: z.record(z.string(), z.string()), + tools: z.array(agentToolMetadataSchema), env: z.record(z.string(), z.string()), }) .strict(), "workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(), }; +const sessionOpenLocalResultSchema = beforeSchemas["agent.session_open"].extend({ + tools: z.array( + z.custom( + (tool) => + typeof tool === "object" && + tool !== null && + "input" in tool && + "execute" in tool && + typeof tool.execute === "function", + ), + ), +}); + export interface PluginLifecycle { emit( name: Name, event: PluginLifecycleEvents[Name], ): void; - before( + before( name: Name, - request: PluginBeforeRequests[Name], - ): Promise; + request: PluginWireBeforeRequests[Name], + ): Promise; } -export function validateBeforeRequest( +export function validateBeforeRequest( name: Name, value: unknown, -): PluginBeforeRequests[Name] { - return beforeSchemas[name].parse(value) as PluginBeforeRequests[Name]; +): PluginWireBeforeRequests[Name] { + return beforeSchemas[name].parse(value) as PluginWireBeforeRequests[Name]; } export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): PluginHookWorkspace { @@ -129,11 +157,11 @@ export function publishAgentStream( } } -export function validateBeforeResult( +export function validateBeforeResult( name: Name, - input: PluginBeforeRequests[Name], + input: PluginWireBeforeRequests[Name], output: unknown, -): PluginBeforeRequests[Name] { +): PluginWireBeforeRequests[Name] { const result = validateBeforeRequest(name, output); if (name === "agent.session_open") { const previous = beforeSchemas["agent.session_open"].parse(input); @@ -144,9 +172,11 @@ export function validateBeforeResult( previous.provider !== next.provider || previous.cwd !== next.cwd || previous.reason !== next.reason || - previous.purpose !== next.purpose + previous.purpose !== next.purpose || + previous.title !== next.title || + !isDeepStrictEqual(previous.labels, next.labels) ) { - throw new Error("agent.session_open hooks can only change env"); + throw new Error("agent.session_open hooks can only change env and tools"); } } if (name === "agent.create") { @@ -168,7 +198,10 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { private readonly changed: () => void; - constructor(changed: () => void) { + private readonly agentTools: PluginAgentTools | undefined; + + constructor(changed: () => void, agentTools?: PluginAgentTools) { + this.agentTools = agentTools; this.changed = changed; } @@ -209,6 +242,9 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { throw new Error(`Unknown before hook: ${name}`); } const hookName = name as keyof import("@getpaseo/plugin/server").PluginBeforeRequests; + if (hookName === "agent.session_open") { + return await this.openSession(input, { paseo, signal: controller.signal }); + } let request = validateBeforeRequest(hookName, input); for (const handler of this.transforms.get(name) ?? []) { controller.signal.throwIfAborted(); @@ -216,9 +252,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { { request: structuredClone(request) }, { paseo, signal: controller.signal }, ); - if (result !== undefined) { - request = validateBeforeResult(hookName, request, result); - } + if (result !== undefined) request = validateBeforeResult(hookName, request, result); } return request; } @@ -236,6 +270,31 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { } } + private async openSession( + input: unknown, + context: PluginHookContext, + ): Promise { + let request = validateBeforeRequest("agent.session_open", input); + this.agentTools?.detach(request.agentId); + let tools: AgentToolDefinition[] = []; + for (const handler of this.transforms.get("agent.session_open") ?? []) { + context.signal.throwIfAborted(); + const result = await handler( + { request: { ...structuredClone(request), tools: [...tools] } }, + context, + ); + if (result === undefined) continue; + const local = sessionOpenLocalResultSchema.parse(result); + const metadata = local.tools.map(serializeAgentTool); + request = validateBeforeResult("agent.session_open", request, { ...local, tools: metadata }); + tools = local.tools; + } + context.signal.throwIfAborted(); + if (request.purpose === "history") request.tools = []; + else this.agentTools?.attach(request.agentId, tools); + return request; + } + cancel(id: string): void { this.active.get(id)?.abort(); } diff --git a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts index 5da355d5df..cad97ce743 100644 --- a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts +++ b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts @@ -1,3 +1,9 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { performance } from "node:perf_hooks"; +import pino from "pino"; +import { createPaseoDaemon } from "../bootstrap.js"; +import { loadConfig } from "../config.js"; import { createRequire } from "node:module"; import { runInNewContext } from "node:vm"; import { fileURLToPath } from "node:url"; @@ -198,6 +204,60 @@ export default function contribute(server: PluginServerContext) { } }, 60_000); +test("password-protected config reload preserves startup auth and session construction does not read config", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-protected-config-reload-")); + roots.push(home); + const password = "synthetic-audit-password"; + const config = loadConfig(home, { env: { PASEO_PASSWORD: password, PASEO_RELAY_ENABLED: "0" } }); + const daemon = await createPaseoDaemon( + { + ...config, + listen: "127.0.0.1:0", + mcpEnabled: false, + agentClients: createTestAgentClients(), + staticDir: home, + }, + pino({ level: "silent" }), + ); + await daemon.start(); + const target = daemon.getListenTarget(); + if (target?.type !== "tcp") throw new Error("Expected TCP daemon"); + const connect = () => + new DaemonClient({ url: `ws://127.0.0.1:${target.port}/ws`, password, appVersion: "0.4.0" }); + const client = connect(); + const replacement = connect(); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const configPath = path.join(home, "config.json"); + await writeFile(configPath, JSON.stringify({ daemon: { browserTools: { enabled: true } } })); + let longestGap = 0; + let previous = performance.now(); + const probe = setInterval(() => { + const now = performance.now(); + longestGap = Math.max(longestGap, now - previous); + previous = now; + }, 1); + try { + const reloaded = await client.reloadDaemonConfig(); + longestGap = Math.max(longestGap, performance.now() - previous); + expect(reloaded.appliedPaths).toContain("daemon.browserTools.enabled"); + expect(longestGap).toBeLessThan(50); + } finally { + clearInterval(probe); + } + expect((await client.getDaemonConfig()).config.browserTools.enabled).toBe(true); + // A new physical session uses ready daemon state; corrupt config is reported only by an explicit read. + await writeFile(configPath, "invalid JSON"); + await replacement.connect(); + await replacement.fetchAgents({ subscribe: {} }); + await expect(replacement.reloadDaemonConfig()).rejects.toThrow("Invalid JSON"); + } finally { + await Promise.all([client.close(), replacement.close()]); + await daemon.stop(); + } +}, 60_000); + test("daemon config reload enables and disables configured plugins without restarting", async () => { const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-reload-plugin-")); const paseoHomeRoot = await mkdtemp(path.join(tmpdir(), "paseo-reload-home-")); @@ -465,3 +525,24 @@ export default function contribute(server: PluginServerContext) { await daemon.close(); } }, 60_000); + +test("keeps reload and enable responsive while compiling a real plugin", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-responsive-plugin-")); + roots.push(directory); + const { stdout, stderr } = await promisify(execFile)( + process.execPath, + [ + "--import", + "tsx", + fileURLToPath(new URL("./test-fixtures/compile-responsiveness.e2e.ts", import.meta.url)), + directory, + ], + { timeout: 20_000 }, + ); + process.stderr.write(stderr); + const results = z + .array(z.object({ blockingMs: z.number() })) + .length(2) + .parse(JSON.parse(stdout)); + for (const result of results) expect(result.blockingMs).toBeLessThan(20); +}); diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index b3f363cb29..c3fa5dda26 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -1,3 +1,4 @@ +import type { PluginHookAgent } from "@getpaseo/plugin/server"; import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage"; import type { ProviderConnectRequest, @@ -31,6 +32,9 @@ export interface PluginUsageSourceMetadata { } export type PluginProcessRequest = + | { type: "tool.call"; requestId: string; agent: PluginHookAgent; name: string; input: unknown } + | { type: "tool.cancel"; requestId: string } + | { type: "tools.detach"; agentId: string } | { type: "provider.status"; requestId: string; @@ -141,6 +145,26 @@ const frameFields = { export const PluginProcessRequestSchema: z.ZodType = z.discriminatedUnion( "type", [ + z + .object({ + type: z.literal("tool.call"), + requestId: z.string(), + agent: z + .object({ + id: z.string(), + workspaceId: z.string().nullable(), + parentAgentId: z.string().nullable(), + provider: z.string(), + cwd: z.string(), + title: z.string().nullable(), + }) + .strict(), + name: z.string(), + input: z.unknown(), + }) + .strict(), + z.object({ type: z.literal("tool.cancel"), requestId: z.string() }).strict(), + z.object({ type: z.literal("tools.detach"), agentId: z.string() }).strict(), z .object({ type: z.literal("provider.status"), diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 5e6efa1925..e93ded990e 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -1,3 +1,4 @@ +import { PluginAgentTools } from "./agent-tools.js"; import { PluginHookHandlers } from "./lifecycle/index.js"; import { evaluateBundle } from "./bundle-evaluator.js"; import { @@ -59,9 +60,10 @@ export function createPluginWorker(options: { handler: RpcHandler; } + const agentTools = new PluginAgentTools(); const hooks = new PluginHookHandlers(() => { send({ type: "hooks.changed", hooks: hooks.catalog() }); - }); + }, agentTools); const handlers = new Map(); const providers = new Map(); const usageSources = new Map(); @@ -322,6 +324,7 @@ export function createPluginWorker(options: { ?.dispose() .catch((error) => console.error("Plugin API cleanup failed", error)); hooks.close(); + agentTools.close(); for (const pending of pendingProviderConnections.values()) pending.tombstoned = true; const currentCleanup = cleanup; cleanup = null; @@ -381,6 +384,42 @@ export function createPluginWorker(options: { } } + function handleToolMessage( + message: PluginProcessRequest, + ): message is Extract< + PluginProcessRequest, + { type: "tool.call" | "tool.cancel" | "tools.detach" } + > { + if (message.type === "tools.detach") { + agentTools.detach(message.agentId); + return true; + } + if (message.type === "tool.cancel") { + agentTools.cancel(message.requestId); + return true; + } + if (message.type === "tool.call") { + if (!paseo) { + send({ type: "error", requestId: message.requestId, error: "Plugin API unavailable" }); + return true; + } + void agentTools + .call(message.requestId, message.agent, message.name, message.input, paseo) + .then((output) => + send({ + type: "result", + requestId: message.requestId, + output: jsonTransportValue(output), + }), + ) + .catch((error) => + send({ type: "error", requestId: message.requestId, error: describeError(error) }), + ); + return true; + } + return false; + } + function handleMessage(rawMessage: unknown): void { const parsed = PluginProcessRequestSchema.safeParse(rawMessage); if (!parsed.success) { @@ -422,6 +461,7 @@ export function createPluginWorker(options: { rejectWhileStopping(message); return; } + if (handleToolMessage(message)) return; if (message.type === "provider.status") { void (async () => { const provider = providers.get(message.providerId); diff --git a/packages/server/src/server/plugins/preparation-build.ts b/packages/server/src/server/plugins/preparation-build.ts new file mode 100644 index 0000000000..cd42772617 --- /dev/null +++ b/packages/server/src/server/plugins/preparation-build.ts @@ -0,0 +1,76 @@ +import { spawnProcess } from "../../utils/spawn.js"; + +import type { PluginManifest } from "./manifest.js"; + +const OUTPUT_LIMIT = 64 * 1024; + +export async function runPluginBuild( + directory: string, + commands: PluginManifest["build"], + logger: { info(data: Record, message: string): void }, + environment: NodeJS.ProcessEnv, +): Promise { + for (const command of commands ?? []) { + logger.info({ directory, command }, "Running plugin build command"); + const result = await run(command, directory, environment); + if (result.stdout) + logger.info({ directory, command, output: result.stdout }, "Plugin build stdout"); + if (result.stderr) + logger.info({ directory, command, output: result.stderr }, "Plugin build stderr"); + if (result.error) { + throw new Error( + `Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`, + ); + } + if (result.exitCode !== 0) { + throw new Error( + `Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`, + ); + } + } +} + +function run( + command: string[], + directory: string, + environment: NodeJS.ProcessEnv, +): Promise { + return new Promise((resolve) => { + const executable = command[0]!; + const arguments_ = command.slice(1); + const child = spawnProcess(executable, arguments_, { cwd: directory, env: environment }); + let stdout = ""; + let stderr = ""; + let error: Error | undefined; + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout = appendOutput(stdout, chunk.toString()); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr = appendOutput(stderr, chunk.toString()); + }); + child.once("error", (spawnError) => { + error = spawnError; + }); + child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error })); + }); +} + +function appendOutput(current: string, next: string): string { + return `${current}${next}`.slice(-OUTPUT_LIMIT); +} + +function formatCommand(command: string[]): string { + return command.map((argument) => JSON.stringify(argument)).join(" "); +} + +function formatOutput(result: CommandResult): string { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + return output ? `\n${output}` : ""; +} + +interface CommandResult { + exitCode: number | null; + stdout: string; + stderr: string; + error?: Error; +} diff --git a/packages/server/src/server/plugins/preparation.test.ts b/packages/server/src/server/plugins/preparation.test.ts index 0fdb0e34fc..d83fa012e3 100644 --- a/packages/server/src/server/plugins/preparation.test.ts +++ b/packages/server/src/server/plugins/preparation.test.ts @@ -1,6 +1,8 @@ import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { Writable } from "node:stream"; +import pino from "pino"; import { afterEach, describe, expect, it } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { runPluginBuild } from "./preparation.js"; @@ -117,6 +119,52 @@ describe("runPluginBuild", () => { ).rejects.toThrow(/Plugin build command failed \(exit 2\)/); }); + it("forwards build progress and output from the worker to the caller's logger", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); + directories.push(directory); + const records: Array<{ msg: string; output?: string }> = []; + const logger = pino( + new Writable({ + write(chunk, _encoding, callback) { + records.push(JSON.parse(chunk.toString())); + callback(); + }, + }), + ); + await runPluginBuild( + directory, + [[process.execPath, "-e", 'console.log("prepared"); console.error("diagnostic");']], + logger, + ); + expect(records).toMatchObject([ + { msg: "Running plugin build command" }, + { msg: "Plugin build stdout", output: "prepared\n" }, + { msg: "Plugin build stderr", output: expect.stringContaining("diagnostic\n") }, + ]); + }); + + it("uses the caller's current environment across consecutive builds", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); + directories.push(directory); + const previous = process.env.PASEO_PLUGIN_TEST_BUILD_VALUE; + const command = [ + process.execPath, + "-e", + 'require("node:fs").writeFileSync(process.argv[1], process.env.PASEO_PLUGIN_TEST_BUILD_VALUE);', + ]; + try { + process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = "first"; + await runPluginBuild(directory, [[...command, "first.txt"]], createTestLogger()); + process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = "second"; + await runPluginBuild(directory, [[...command, "second.txt"]], createTestLogger()); + expect(await readFile(path.join(directory, "first.txt"), "utf8")).toBe("first"); + expect(await readFile(path.join(directory, "second.txt"), "utf8")).toBe("second"); + } finally { + if (previous === undefined) delete process.env.PASEO_PLUGIN_TEST_BUILD_VALUE; + else process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = previous; + } + }); + it("handles empty or undefined build commands gracefully", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); directories.push(directory); diff --git a/packages/server/src/server/plugins/preparation.ts b/packages/server/src/server/plugins/preparation.ts index 267f9697e9..79aec65cdc 100644 --- a/packages/server/src/server/plugins/preparation.ts +++ b/packages/server/src/server/plugins/preparation.ts @@ -1,71 +1,15 @@ -import { spawnProcess } from "../../utils/spawn.js"; import type pino from "pino"; import type { PluginManifest } from "./manifest.js"; - -const OUTPUT_LIMIT = 64 * 1024; +import { runCompilerTask } from "./compiler.js"; export async function runPluginBuild( directory: string, commands: PluginManifest["build"], logger: pino.Logger, ): Promise { - for (const command of commands ?? []) { - logger.info({ directory, command }, "Running plugin build command"); - const result = await run(command, directory); - if (result.stdout) - logger.info({ directory, command, output: result.stdout }, "Plugin build stdout"); - if (result.stderr) - logger.info({ directory, command, output: result.stderr }, "Plugin build stderr"); - if (result.error) { - throw new Error( - `Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`, - ); - } - if (result.exitCode !== 0) { - throw new Error( - `Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`, - ); - } - } -} - -function run(command: string[], directory: string): Promise { - return new Promise((resolve) => { - const executable = command[0]!; - const arguments_ = command.slice(1); - const child = spawnProcess(executable, arguments_, { cwd: directory }); - let stdout = ""; - let stderr = ""; - let error: Error | undefined; - child.stdout?.on("data", (chunk: Buffer | string) => { - stdout = appendOutput(stdout, chunk.toString()); - }); - child.stderr?.on("data", (chunk: Buffer | string) => { - stderr = appendOutput(stderr, chunk.toString()); - }); - child.once("error", (spawnError) => { - error = spawnError; - }); - child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error })); - }); -} - -function appendOutput(current: string, next: string): string { - return `${current}${next}`.slice(-OUTPUT_LIMIT); -} - -function formatCommand(command: string[]): string { - return command.map((argument) => JSON.stringify(argument)).join(" "); -} - -function formatOutput(result: CommandResult): string { - const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); - return output ? `\n${output}` : ""; -} - -interface CommandResult { - exitCode: number | null; - stdout: string; - stderr: string; - error?: Error; + if (!commands?.length) return; + await runCompilerTask( + { kind: "prepare", directory, commands, environment: { ...process.env } }, + (data, message) => logger.info(data, message), + ); } diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 8dd80ce499..30ada955a6 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { fork } from "node:child_process"; import { tmpdir } from "node:os"; import { PassThrough } from "node:stream"; @@ -1849,9 +1849,9 @@ export default function contribute(plugin: any) { const child = createReloadChild("blocked", events); const runtime = createTestRuntime({ spawnChild: () => child }); - await expect(runtime.startPlugin("blocked", directory, () => false)).rejects.toThrow( - "Plugin start cancelled: blocked", - ); + await expect( + runtime.startPlugin("blocked", directory, () => events.length === 0), + ).rejects.toThrow("Plugin start cancelled: blocked"); expect(runtime.catalog()).toEqual([]); expect(events).toEqual(["start:blocked", "shutdown:blocked", "exit:blocked"]); @@ -2055,3 +2055,50 @@ export default function contribute(server) { await runtime.stopAll(); } }); + +const examplesDirectory = fileURLToPath( + new URL("../../../../../plugin-examples/", import.meta.url), +); +// The Markdown example owns npm preparation in the real app fixture: +// packages/app/e2e/browser/plugin-file-panel-examples.spec.ts. +const examples = (await readdir(examplesDirectory, { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && entry.name !== "markdown-panel") + .map((entry) => entry.name); + +it.each(examples)("builds and loads the %s plugin example", async (example) => { + const settingsDirectory = await mkdtemp(path.join(tmpdir(), "paseo-example-settings-")); + temporaryDirectories.push(settingsDirectory); + const runtime = createTestRuntime({ settingsDirectory }, undefined, "0.13.2"); + try { + await runtime.startPlugin(example, path.join(examplesDirectory, example)); + expect(runtime.catalog()).toMatchObject([{ id: example }]); + expect(pluginMessages(runtime, example)).toContain("[paseo] Plugin ready"); + } finally { + await runtime.stopAll(); + } +}); + +it("does not initialize a plugin cancelled during compilation", async () => { + const directory = await createPlugin("cancelled-build", "export default () => {};"); + const marker = path.join(directory, "initialized.txt"); + await writeFile( + path.join(directory, "index.server.ts"), + ` +import { writeFileSync } from "node:fs"; +export default function contribute() { + writeFileSync(${JSON.stringify(marker)}, "initialized"); + return () => {}; +}`, + ); + const runtime = createTestRuntime(); + let allowed = true; + try { + const loading = runtime.startPlugin("cancelled-build", directory, () => allowed); + allowed = false; + await expect(loading).rejects.toThrow("Plugin start cancelled: cancelled-build"); + expect(await readTextIfPresent(marker)).toBeNull(); + expect(runtime.catalog()).toEqual([]); + } finally { + await runtime.stopAll(); + } +}); diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 9886a368e7..bc06d5d991 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -1,13 +1,18 @@ +import type { PluginWireBeforeRequests } from "./lifecycle/index.js"; +import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js"; import type { UsageScope } from "@getpaseo/plugin/server/usage"; import { ProviderStatusSchema, type ProviderStatus, type ProviderStatusRequest, } from "@getpaseo/plugin/server/provider"; -import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server"; +import type { + PluginLifecycleEvents, + PluginHookAgent, + AgentToolResult, +} from "@getpaseo/plugin/server"; import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js"; import { fork } from "node:child_process"; -import { stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { randomUUID } from "node:crypto"; @@ -22,10 +27,8 @@ import { type ProviderInput, } from "@getpaseo/plugin/server/provider"; import type { PluginLogEntry } from "@getpaseo/protocol/messages"; -import { compilePlugin } from "./compiler.js"; -import { readPluginManifest } from "./manifest.js"; +import { compileDirectoryPlugin } from "./compiler.js"; import type { PluginRequirements } from "@getpaseo/protocol/messages"; -import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; import type { PluginProcessMessage, PluginProcessRequest, @@ -37,9 +40,8 @@ import { PluginSessionSocket } from "./session-socket.js"; import { InternalPluginChild } from "./internal-child.js"; import { evaluateBundle } from "./bundle-evaluator.js"; -const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const; -const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const; const REQUEST_TIMEOUT_MS = 30_000; +const TOOL_CALL_TIMEOUT_MS = 10 * 60_000; const MAX_LOG_ENTRIES = 500; const MAX_LOG_BYTES = 256 * 1024; const MAX_LOG_LINE_BYTES = 16 * 1024; @@ -69,6 +71,7 @@ interface PendingInvocation { interface LoadedPlugin { id: string; + instanceId: string; requirements: PluginRequirements | undefined; clientBundle: string; methods: ReadonlySet; @@ -265,35 +268,6 @@ function send(child: PluginChild, message: PluginProcessRequest): Promise }); } -async function findEntry(directory: string, filenames: readonly string[]): Promise { - for (const filename of filenames) { - const filePath = path.join(directory, filename); - const info = await stat(filePath).catch(() => null); - if (info?.isFile()) return filePath; - } - return null; -} - -async function resolveEntryPaths(directory: string): Promise<{ - client: string | null; - server: string | null; -}> { - const [client, server] = await Promise.all([ - findEntry(directory, CLIENT_ENTRY_FILENAMES), - findEntry(directory, SERVER_ENTRY_FILENAMES), - ]); - if (client || server) return { client, server }; - const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]); - if (legacyEntry) { - throw new Error( - "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration", - ); - } - throw new Error( - `Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`, - ); -} - export class PluginRuntime { private readonly plugins = new Map(); private readonly pendingEvents = new Set>(); @@ -302,6 +276,7 @@ export class PluginRuntime { private readonly spawnChild: () => PluginChild; private sessionHost: PluginPaseoSessionHost | null; private readonly listeners = new Set<(pluginId: string, error?: string) => void>(); + private readonly sessionOpenListeners = new Set<() => void>(); constructor( logger: pino.Logger, @@ -324,6 +299,16 @@ export class PluginRuntime { return () => this.listeners.delete(listener); } + subscribeSessionOpenChanges(listener: () => void): () => void { + this.sessionOpenListeners.add(listener); + return () => this.sessionOpenListeners.delete(listener); + } + + private notifySessionOpenChange(loaded: LoadedPlugin): void { + if (!loaded.hooks.before.includes("agent.session_open")) return; + for (const listener of this.sessionOpenListeners) listener(); + } + async startPlugin( pluginId: string, configuredPath: string, @@ -331,15 +316,22 @@ export class PluginRuntime { ): Promise { if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`); this.appendLog(pluginId, "stdout", "[paseo] Loading plugin"); - const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath).catch((error) => { - this.appendLog(pluginId, "stderr", `[paseo] Plugin failed to load: ${describeError(error)}`); - throw error; - }); + const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath, canPublish).catch( + (error) => { + this.appendLog( + pluginId, + "stderr", + `[paseo] Plugin failed to load: ${describeError(error)}`, + ); + throw error; + }, + ); if (!canPublish()) { await this.stopPlugin(loaded); throw new Error(`Plugin start cancelled: ${pluginId}`); } this.plugins.set(pluginId, loaded); + this.notifySessionOpenChange(loaded); this.appendLog(pluginId, "stdout", "[paseo] Plugin ready"); } @@ -347,12 +339,8 @@ export class PluginRuntime { if (this.plugins.has(input.id)) throw new Error(`Plugin is already running: ${input.id}`); this.appendLog(input.id, "stdout", "[paseo] Loading plugin"); const directory = path.resolve(input.directory); - const manifest = await readPluginManifest(directory); - if (manifest.id !== input.id) { - throw new Error(`Built-in plugin ${input.id} has manifest ID ${manifest.id}`); - } - assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); - const bundles = await compilePlugin(await resolveEntryPaths(directory)); + const bundles = await compileDirectoryPlugin(directory, this.daemonVersion, input.id); + const { manifest } = bundles; if (!bundles.serverBundle) throw new Error(`Built-in plugin ${input.id} needs a server entry`); const loaded = await this.launchPlugin({ pluginId: input.id, @@ -363,21 +351,19 @@ export class PluginRuntime { clientBundle: bundles.clientBundle ?? "", }); this.plugins.set(input.id, loaded); + this.notifySessionOpenChange(loaded); this.appendLog(input.id, "stdout", "[paseo] Plugin ready"); } async validatePlugin(configuredPath: string): Promise { - const directory = path.resolve(configuredPath); - const manifest = await readPluginManifest(directory); - assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); - const entryPaths = await resolveEntryPaths(directory); - await compilePlugin(entryPaths); + await compileDirectoryPlugin(path.resolve(configuredPath), this.daemonVersion); } async stopPluginById(pluginId: string): Promise { const loaded = this.plugins.get(pluginId); if (!loaded) return false; this.plugins.delete(pluginId); + this.notifySessionOpenChange(loaded); this.rejectPending(loaded, `Plugin stopped: ${pluginId}`); await this.stopPlugin(loaded); return true; @@ -546,14 +532,15 @@ export class PluginRuntime { await Promise.all(this.pendingEvents); } - async before( + async before( name: Name, - input: PluginBeforeRequests[Name], - ): Promise { + input: PluginWireBeforeRequests[Name], + ): Promise { let request = validateBeforeRequest(name, input); const plugins = [...this.plugins.values()].sort((left, right) => { return left.id.localeCompare(right.id); }); + const attached: AttachedAgentTool[] = []; for (const loaded of plugins) { if (!loaded.hooks.before.includes(name)) { continue; @@ -564,15 +551,21 @@ export class PluginRuntime { requestId: randomUUID(), kind: "before", name, - input: request, + input: name === "agent.session_open" ? { ...request, tools: [] } : request, }); request = validateBeforeResult(name, request, output); + if (name === "agent.session_open") { + this.collectAgentTools(attached, loaded, (request as SessionOpenWireRequest).tools); + } } catch (error) { + if (name === "agent.session_open") + this.detachTools((request as SessionOpenWireRequest).agentId); throw new Error(`Plugin ${loaded.id} before ${name} failed: ${describeError(error)}`, { cause: error, }); } } + if (name === "agent.session_open") (request as SessionOpenWireRequest).tools = attached; return request; } @@ -594,25 +587,102 @@ export class PluginRuntime { return output; } + private collectAgentTools( + attached: AttachedAgentTool[], + loaded: LoadedPlugin, + tools: SessionOpenWireRequest["tools"], + ): void { + for (const tool of tools) { + const collision = attached.find((entry) => entry.name === tool.name); + if (collision) + throw new Error( + `Agent tool ${tool.name} from plugin ${loaded.id} collides with plugin ${collision.pluginId}`, + ); + attached.push({ ...tool, pluginId: loaded.id, pluginInstanceId: loaded.instanceId }); + } + } + + isToolPluginAvailable(pluginId: string, pluginInstanceId: string): boolean { + return this.plugins.get(pluginId)?.instanceId === pluginInstanceId; + } + + detachTools(agentId: string): void { + for (const loaded of this.plugins.values()) { + if (loaded.child) void send(loaded.child, { type: "tools.detach", agentId }).catch(() => {}); + } + } + + async callTool( + pluginId: string, + pluginInstanceId: string, + agent: PluginHookAgent, + name: string, + input: unknown, + signal?: AbortSignal, + ): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded || loaded.instanceId !== pluginInstanceId) + return { isError: true, text: `Plugin is not available: ${pluginId}` }; + const requestId = randomUUID(); + try { + return (await this.request( + loaded, + { type: "tool.call", requestId, agent, name, input }, + signal, + )) as AgentToolResult; + } catch (error) { + if (signal?.aborted) throw error; + return { isError: true, text: describeError(error) }; + } + } + private request( loaded: LoadedPlugin, message: Extract, + signal?: AbortSignal, ): Promise { const child = loaded.child; const pluginId = loaded.id; if (!child) throw new Error(`Plugin ${pluginId} has no server entry`); const requestId = message.requestId; + signal?.throwIfAborted(); return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { + const cancel = () => { + clearTimeout(timeout); loaded.pending.delete(requestId); - if (message.type === "hook") { - void send(child, { type: "hook.cancel", requestId }).catch(() => {}); - } - reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`)); - }, REQUEST_TIMEOUT_MS); - loaded.pending.set(requestId, { resolve, reject, timeout }); + if (message.type === "tool.call") + void send(child, { type: "tool.cancel", requestId }).catch(() => {}); + signal?.removeEventListener("abort", cancel); + reject(signal?.reason ?? new Error("Tool call canceled")); + }; + const timeout = setTimeout( + () => { + loaded.pending.delete(requestId); + signal?.removeEventListener("abort", cancel); + if (message.type === "tool.call") + void send(child, { type: "tool.cancel", requestId }).catch(() => {}); + if (message.type === "hook") { + void send(child, { type: "hook.cancel", requestId }).catch(() => {}); + } + reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`)); + }, + message.type === "tool.call" ? TOOL_CALL_TIMEOUT_MS : REQUEST_TIMEOUT_MS, + ); + signal?.addEventListener("abort", cancel, { once: true }); + loaded.pending.set(requestId, { + resolve: (value) => { + signal?.removeEventListener("abort", cancel); + resolve(value); + }, + reject: (error) => { + signal?.removeEventListener("abort", cancel); + reject(error); + }, + timeout, + }); void send(child, message).catch((error) => { clearTimeout(timeout); + signal?.removeEventListener("abort", cancel); loaded.pending.delete(requestId); reject(error); }); @@ -623,6 +693,7 @@ export class PluginRuntime { const loaded = [...this.plugins.values()]; this.plugins.clear(); for (const plugin of loaded) { + this.notifySessionOpenChange(plugin); this.rejectPending(plugin, `Plugin stopped: ${plugin.id}`); } await Promise.all(loaded.map((plugin) => this.stopPlugin(plugin))); @@ -631,16 +702,17 @@ export class PluginRuntime { private async loadDirectoryPlugin( pluginId: string, configuredPath: string, + canPublish: () => boolean, ): Promise { const directory = path.resolve(configuredPath); - const manifest = await readPluginManifest(directory); - assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); - const entryPaths = await resolveEntryPaths(directory); - const bundles = await compilePlugin(entryPaths); + const bundles = await compileDirectoryPlugin(directory, this.daemonVersion); + const { manifest } = bundles; + if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`); const serverBundle = bundles.serverBundle; if (!serverBundle) { return { id: pluginId, + instanceId: randomUUID(), clientBundle: bundles.clientBundle ?? "", requirements: manifest.requirements, methods: new Set(), @@ -769,6 +841,7 @@ export class PluginRuntime { } loaded = { id: pluginId, + instanceId: randomUUID(), clientBundle, requirements, methods: new Set(ready.methods), @@ -852,7 +925,14 @@ export class PluginRuntime { private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { if (message.type === "hooks.changed") { + const hadSessionOpen = loaded.hooks.before.includes("agent.session_open"); loaded.hooks = message.hooks; + if ( + this.plugins.get(loaded.id) === loaded && + hadSessionOpen !== loaded.hooks.before.includes("agent.session_open") + ) { + for (const listener of this.sessionOpenListeners) listener(); + } return; } if (message.type === "settings.changed") { @@ -1166,6 +1246,7 @@ export class PluginRuntime { const wasPublished = this.plugins.get(loaded.id) === loaded; if (wasPublished) { this.plugins.delete(loaded.id); + this.notifySessionOpenChange(loaded); } this.rejectPending(loaded, `Plugin process exited: ${loaded.id}`); for (const state of loaded.providerConnections.values()) { diff --git a/packages/server/src/server/plugins/test-fixtures/compile-responsiveness.e2e.ts b/packages/server/src/server/plugins/test-fixtures/compile-responsiveness.e2e.ts new file mode 100644 index 0000000000..229e388b34 --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/compile-responsiveness.e2e.ts @@ -0,0 +1,187 @@ +import assert from "node:assert/strict"; +import { createHook, executionAsyncResource } from "node:async_hooks"; +import { open as openFile, writeFile } from "node:fs/promises"; +import { performance, PerformanceObserver } from "node:perf_hooks"; +import path from "node:path"; +import { MessagePort } from "node:worker_threads"; +import pino from "pino"; +import { DaemonConfigStore } from "../../daemon-config-store.js"; +import { PluginService } from "../index.js"; + +// A fresh process excludes the E2E harness's transform/bootstrap garbage from +// the compiler measurement. All work and dependencies below are real. +const directory = process.argv[2]; +assert.ok(directory, "Fixture directory is required"); +const results: { blockingMs: number }[] = []; +await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "responsive-build" }), +); +// Erased types stress TypeScript's AST walk without a multi-MiB runtime bundle. +// Payload transfer is a different cost from import validation. +const entryFile = await openFile(path.join(directory, "index.client.ts"), "w"); +try { + await entryFile.write("type FixtureValues = ["); + for (let offset = 0; offset < 350_000; offset += 10_000) { + await entryFile.write( + Array.from({ length: 10_000 }, (_, index) => offset + index).join(",") + ",", + ); + } + await entryFile.write( + ']; export default function contribute() { return () => "compiled fixture"; }', + ); +} finally { + await entryFile.close(); +} +const store = new DaemonConfigStore(directory, { + mcp: { injectIntoAgents: true }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + pluginsEnabled: true, + plugins: { + "responsive-build": { source: "directory", path: directory, enabled: false }, + }, +}); +const service = new PluginService(pino({ level: "silent" }), store, "0.4.0"); +await service.start(); +try { + for (const action of [ + () => service.enablePlugin("responsive-build"), + () => service.reloadPlugin("responsive-build"), + ]) { + // Node 22.19+ exposes per-thread CPU accounting; the repository's older + // Node declarations do not include it yet. .tool-versions pins Node 22.20. + const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get( + process, + "threadCpuUsage", + ); + const startedAt = performance.now(); + let previous = startedAt; + let cpu = threadCpuUsage(); + let longestWall = 0; + let longestBlocking = 0; + let longestWallCpu = 0; + let workerStarts = 0; + let portCallbacks = 0; + let longestWallEvents = { workerStarts: 0, portCallbacks: 0 }; + let longestBlockingWindow = { wallMs: 0, cpuMs: 0, resource: "" }; + let probe: ReturnType; + const callbacks = new Map(); + const recordBlocking = ( + blockingMs: number, + window: { wallMs: number; cpuMs: number; resource: string }, + ) => { + if (blockingMs > longestBlocking) { + longestBlocking = blockingMs; + longestBlockingWindow = window; + } + }; + let longestGc = 0; + const observeGc = (entries: readonly import("node:perf_hooks").PerformanceEntry[]) => { + for (const entry of entries) { + if (entry.startTime >= startedAt) longestGc = Math.max(longestGc, entry.duration); + } + }; + const gc = new PerformanceObserver((list) => observeGc(list.getEntries())); + gc.observe({ entryTypes: ["gc"] }); + // Measure work within callbacks, not CPU-counter increments attributed to + // the idle interval between them. Exclude this probe's own timer callback. + const activity = createHook({ + init(_id, type) { + if (type === "WORKER") workerStarts++; + }, + before(id) { + const resource = executionAsyncResource(); + if (resource instanceof MessagePort) portCallbacks++; + if (resource === probe) return; + callbacks.set(id, { + wall: performance.now(), + cpu: threadCpuUsage(), + resource: Object.prototype.toString.call(resource), + }); + }, + after(id) { + const start = callbacks.get(id); + if (!start) return; + callbacks.delete(id); + const wallMs = performance.now() - start.wall; + const { user, system } = threadCpuUsage(start.cpu); + const cpuMs = (user + system) / 1_000; + // A callback prevents the loop from serving IO for its entire span. + // Use the high-resolution clock, not Windows' coarse CPU increments. + recordBlocking(wallMs, { wallMs, cpuMs, resource: start.resource }); + }, + }).enable(); + const sample = () => { + const now = performance.now(); + const currentCpu = threadCpuUsage(); + const wallMs = now - previous; + const cpuMs = (currentCpu.user - cpu.user + currentCpu.system - cpu.system) / 1_000; + // Native worker construction and message deserialization can precede + // async_hooks.before. Include their interval when those events occurred. + if (workerStarts || portCallbacks) { + recordBlocking(Math.min(wallMs, cpuMs), { + wallMs, + cpuMs, + resource: "worker/port interval", + }); + } + if (wallMs > longestWall) { + longestWall = wallMs; + longestWallCpu = cpuMs; + longestWallEvents = { workerStarts, portCallbacks }; + } + workerStarts = 0; + portCallbacks = 0; + previous = now; + cpu = currentCpu; + }; + // Sleep between probes: a continuously runnable setImmediate loop consumes + // a core while the worker compiles. Raw gaps remain diagnostics; the + // assertion measures elapsed daemon-thread callback spans and GC pauses. + probe = setInterval(sample, 1); + try { + // The caller's current callback began before the hook was enabled. + // Include synchronous work before the action's first await explicitly. + const actionStartedAt = performance.now(); + const actionCpu = threadCpuUsage(); + const pending = action(); + const actionWallMs = performance.now() - actionStartedAt; + const actionCost = threadCpuUsage(actionCpu); + recordBlocking(actionWallMs, { + wallMs: actionWallMs, + cpuMs: (actionCost.user + actionCost.system) / 1_000, + resource: "action()", + }); + await pending; + await new Promise((resolve) => setImmediate(resolve)); + sample(); + observeGc(gc.takeRecords()); + } finally { + clearInterval(probe); + activity.disable(); + gc.disconnect(); + } + longestBlocking = Math.max(longestBlocking, longestGc); + process.stderr.write( + `plugin compile main-thread blocking: ${longestBlocking.toFixed(2)} ms ${JSON.stringify({ longestWall, longestWallCpu, ...longestWallEvents, longestBlockingWindow, longestGc })}\n`, + ); + assert.ok(service.catalog()[0]?.clientBundle.includes("compiled fixture")); + results.push({ blockingMs: longestBlocking }); + } + // Evaluate after both probes so parsing the returned code is not charged to compilation. + const factory = globalThis.eval(service.catalog()[0]!.clientBundle) as ( + require: (specifier: string) => never, + ) => { default: () => () => string }; + const result = factory((specifier) => { + throw new Error(`Unexpected fixture dependency: ${specifier}`); + }).default()(); + assert.equal(result, "compiled fixture"); +} finally { + await service.stopAllPlugins(); +} +process.stdout.write(JSON.stringify(results)); diff --git a/packages/server/src/server/private-files.ts b/packages/server/src/server/private-files.ts index 0e12457be1..182101149e 100644 --- a/packages/server/src/server/private-files.ts +++ b/packages/server/src/server/private-files.ts @@ -1,3 +1,4 @@ +import { chmod, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises"; import { chmodSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; import path from "node:path"; @@ -52,3 +53,30 @@ function resolveSymlinkedFile(filePath: string): string { throw error; } } + +export async function ensurePrivateFileAsync(filePath: string): Promise { + if (process.platform === "win32") return; + await chmod(filePath, PRIVATE_FILE_MODE).catch(() => {}); +} + +export async function writePrivateFileAtomic(filePath: string, data: string): Promise { + const directory = path.dirname(filePath); + await mkdir(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); + if (process.platform !== "win32") await chmod(directory, PRIVATE_DIRECTORY_MODE).catch(() => {}); + const destination = await realpath(filePath).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return filePath; + throw error; + }); + const temporary = path.join( + path.dirname(destination), + `.${path.basename(destination)}.${process.pid}.${randomUUID()}`, + ); + try { + await writeFile(temporary, data, { mode: PRIVATE_FILE_MODE }); + await rename(temporary, destination); + await ensurePrivateFileAsync(destination); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } +} diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 3b462cc5b1..6121b7e6bd 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -226,6 +226,7 @@ type ScheduleAgentManager = Pick< AgentRunController, | "getAgent" | "reloadAgentSession" + | "reloadStalePluginSession" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" diff --git a/packages/server/src/server/script-status-projection.test.ts b/packages/server/src/server/script-status-projection.test.ts index 3c963f907c..b6ef6e18f5 100644 --- a/packages/server/src/server/script-status-projection.test.ts +++ b/packages/server/src/server/script-status-projection.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { tmpdir } from "node:os"; @@ -7,6 +7,7 @@ import { ScriptRouteStore } from "./script-proxy.js"; import { buildWorkspaceScriptPayloads, createScriptStatusEmitter, + readPaseoConfigForProjection, } from "./script-status-projection.js"; import { WorkspaceScriptPayloadSchema } from "@getpaseo/protocol/messages"; import type { ScriptHealthState } from "./script-health-monitor.js"; @@ -50,7 +51,7 @@ function createWorkspaceRepo(options?: { }; } -function buildPayloads(input: { +async function buildPayloads(input: { workspaceId: string; workspaceDirectory: string; paseoConfig?: PaseoConfig | null; @@ -63,7 +64,9 @@ function buildPayloads(input: { resolveHealth?: (hostname: string) => ScriptHealthState | null; }) { const paseoConfig = - input.paseoConfig !== undefined ? input.paseoConfig : loadConfig(input.workspaceDirectory); + input.paseoConfig !== undefined + ? input.paseoConfig + : await loadConfig(input.workspaceDirectory); const { routeStore, serviceProxy, ...rest } = input; return buildWorkspaceScriptPayloads({ ...rest, @@ -72,8 +75,8 @@ function buildPayloads(input: { }); } -function loadConfig(repoRoot: string): PaseoConfig | null { - const result = readPaseoConfig(repoRoot); +async function loadConfig(repoRoot: string): Promise { + const result = await readPaseoConfig(repoRoot); return result.ok ? result.config : null; } @@ -93,7 +96,7 @@ describe("script-status-projection", () => { ).toBeNull(); }); - it("projects plain scripts and services differently", () => { + it("projects plain scripts and services differently", async () => { const workspaceId = "workspace-plain-and-service"; const workspace = createWorkspaceRepo({ paseoConfig: { @@ -116,7 +119,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -154,7 +157,7 @@ describe("script-status-projection", () => { } }); - it("builds service hostnames from service-provided git metadata", () => { + it("builds service hostnames from service-provided git metadata", async () => { const workspaceId = "workspace-service-metadata"; const workspace = createWorkspaceRepo({ branchName: "local-branch-that-should-not-be-read", @@ -168,7 +171,7 @@ describe("script-status-projection", () => { const runtimeStore = new WorkspaceScriptRuntimeStore(); try { - const payloads = buildPayloads({ + const payloads = await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, serviceProxy: routeStore, @@ -200,7 +203,7 @@ describe("script-status-projection", () => { } }); - it("projects local and public service URLs while keeping proxyUrl public-first", () => { + it("projects local and public service URLs while keeping proxyUrl public-first", async () => { const workspaceId = "workspace-public-service"; const workspace = createWorkspaceRepo({ paseoConfig: { @@ -214,7 +217,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -243,7 +246,7 @@ describe("script-status-projection", () => { } }); - it("overlays runtime, route, and health state for running services", () => { + it("overlays runtime, route, and health state for running services", async () => { const workspaceId = "workspace-running-service"; const workspace = createWorkspaceRepo({ branchName: "feature/card", @@ -273,7 +276,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -301,7 +304,7 @@ describe("script-status-projection", () => { } }); - it("maps internal pending health to null on the wire", () => { + it("maps internal pending health to null on the wire", async () => { const workspaceId = "workspace-pending-health"; const workspace = createWorkspaceRepo({ paseoConfig: { @@ -330,7 +333,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -358,7 +361,7 @@ describe("script-status-projection", () => { } }); - it("includes orphaned running runtime entries even after config removal", () => { + it("includes orphaned running runtime entries even after config removal", async () => { const workspaceId = "workspace-orphaned-service"; const workspace = createWorkspaceRepo(); const routeStore = new ScriptRouteStore(); @@ -381,7 +384,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -408,7 +411,7 @@ describe("script-status-projection", () => { } }); - it("projects orphaned plain scripts as scripts instead of services", () => { + it("projects orphaned plain scripts as scripts instead of services", async () => { const workspaceId = "workspace-orphaned-script"; const workspace = createWorkspaceRepo(); const routeStore = new ScriptRouteStore(); @@ -424,7 +427,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -449,7 +452,7 @@ describe("script-status-projection", () => { } }); - it("readPaseoConfig fails with configPath and error when paseo.json is malformed", () => { + it("readPaseoConfig fails with configPath and error when paseo.json is malformed", async () => { const workspace = createWorkspaceRepo(); const configPath = path.join(workspace.repoDir, "paseo.json"); writeFileSync( @@ -458,7 +461,7 @@ describe("script-status-projection", () => { ); try { - const result = readPaseoConfig(workspace.repoDir); + const result = await readPaseoConfig(workspace.repoDir); expect(result.ok).toBe(false); if (result.ok) throw new Error("unreachable"); expect(result.configPath).toBe(configPath); @@ -468,7 +471,7 @@ describe("script-status-projection", () => { } }); - it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", () => { + it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", async () => { const workspaceId = "workspace-null-config"; const workspace = createWorkspaceRepo(); const routeStore = new ScriptRouteStore(); @@ -484,7 +487,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, paseoConfig: null, @@ -538,7 +541,12 @@ describe("script-status-projection", () => { exitCode: null, }); - const session = { emit: vi.fn() }; + const messages: unknown[] = []; + const session = { + emit: (message: unknown) => { + messages.push(message); + }, + }; const emitUpdate = createScriptStatusEmitter({ sessions: () => [session], serviceProxy: routeStore, @@ -550,7 +558,11 @@ describe("script-status-projection", () => { }); try { - emitUpdate(workspaceId, [ + const originalConfig = await readPaseoConfigForProjection( + workspace.repoDir, + createTestLogger(), + ); + await emitUpdate(workspaceId, [ { scriptName: "api", hostname: "api--repo.localhost", @@ -558,9 +570,25 @@ describe("script-status-projection", () => { health: "healthy", }, ]); - await Promise.resolve(); - expect(session.emit).toHaveBeenCalledWith({ + expect(await readPaseoConfigForProjection(workspace.repoDir, createTestLogger())).toBe( + originalConfig, + ); + await emitUpdate(workspaceId, [ + { scriptName: "api", hostname: "api--repo.localhost", port: 3001, health: "unhealthy" }, + ]); + expect(messages.at(-1)).toMatchObject({ + payload: { + scripts: [ + { scriptName: "api", health: "unhealthy" }, + { scriptName: "typecheck", health: null }, + ], + }, + }); + expect(await readPaseoConfigForProjection(workspace.repoDir, createTestLogger())).toBe( + originalConfig, + ); + expect(messages).toContainEqual({ type: "script_status_update", payload: { workspaceId, diff --git a/packages/server/src/server/script-status-projection.ts b/packages/server/src/server/script-status-projection.ts index 2d02751fc6..a66f5fb073 100644 --- a/packages/server/src/server/script-status-projection.ts +++ b/packages/server/src/server/script-status-projection.ts @@ -33,11 +33,11 @@ interface BuildWorkspaceScriptPayloadsOptions { resolveHealth?: (hostname: string) => ScriptHealthState | null; } -export function readPaseoConfigForProjection( +export async function readPaseoConfigForProjection( workspaceDirectory: string, logger: Logger, -): PaseoConfig | null { - const result = readPaseoConfig(workspaceDirectory); +): Promise { + const result = await readPaseoConfig(workspaceDirectory); if (result.ok) { return result.config; } @@ -288,9 +288,9 @@ export function createScriptStatusEmitter({ serviceProxyPublicBaseUrl?: string | null; resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise; logger: Logger; -}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void { - return (workspaceId, scripts) => { - void (async () => { +}): (workspaceId: string, scripts: ScriptHealthEntry[]) => Promise { + return async (workspaceId, scripts) => { + try { const workspaceDirectory = await resolveWorkspaceDirectory(workspaceId); if (!workspaceDirectory) { return; @@ -304,7 +304,7 @@ export function createScriptStatusEmitter({ const projected = buildWorkspaceScriptPayloads({ workspaceId, workspaceDirectory, - paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger), + paseoConfig: await readPaseoConfigForProjection(workspaceDirectory, logger), serviceProxy, runtimeStore, daemonPort: resolvedDaemonPort, @@ -320,6 +320,8 @@ export function createScriptStatusEmitter({ for (const session of sessions()) { session.emit(message); } - })(); + } catch (error) { + logger.warn({ err: error, workspaceId }, "Failed to project workspace script status"); + } }; } diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 2fc05d0131..7321312826 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -425,6 +425,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { github: asGitHubService(github), workspaceGitService: asWorkspaceGitService(workspaceGitService), daemonConfigStore: asDaemonConfigStore({ + readServicePorts: async () => undefined, get: vi.fn(() => ({ mcp: { injectIntoAgents: false }, providers: {}, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f573b1dd95..9d25328079 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -81,7 +81,6 @@ import { type WorkspaceScriptsService, } from "./session/workspace-scripts/workspace-scripts-service.js"; import type { DaemonConfigStore } from "./daemon-config-store.js"; -import { loadPersistedConfig } from "./persisted-config.js"; import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket"; @@ -1232,7 +1231,7 @@ export class Session { spawnWorkspaceScript, assertAutomationAllowed: (workspaceId) => assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId), - globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts, + getGlobalServicePorts: () => this.daemonConfigStore.readServicePorts(), }); this.workspaceDirectory = new WorkspaceDirectory({ logger: this.sessionLogger, @@ -2927,8 +2926,7 @@ export class Session { case "daemon.get_pairing_offer.request": return this.daemonSession.handleGetPairingOfferRequest(msg); case "daemon.config.reload.request": - this.daemonSession.handleConfigReloadRequest(msg); - return undefined; + return this.daemonSession.handleConfigReloadRequest(msg); case "hub.management.daemon.connect.request": case "hub.management.daemon.get_status.request": case "hub.management.daemon.disconnect.request": @@ -2939,14 +2937,15 @@ export class Session { case "daemon.update.request": return this.daemonSession.handleUpdateRequest(msg); case "set_daemon_config_request": - this.emit({ - type: "set_daemon_config_response", - payload: { - requestId: msg.requestId, - config: this.daemonConfigStore.patch(msg.config), - }, + return this.daemonConfigStore.patch(msg.config).then((config) => { + return this.emit({ + type: "set_daemon_config_response", + payload: { + requestId: msg.requestId, + config, + }, + }); }); - return undefined; case "read_project_config_request": return this.projectConfigSession.handleReadProjectConfigRequest(msg); case "write_project_config_request": @@ -2964,7 +2963,7 @@ export class Session { case "checkout.commits.list.request": return this.checkoutSession.handleCommitsListRequest(msg); case "checkout.commits.file_diff.request": - return this.checkoutSession.handleCommitFileDiffRequest(msg); + return this.checkoutSession.handleCommitFileDiffRequest(msg, this.delivery.requestSignal); case "validate_branch_request": return this.checkoutSession.handleValidateBranchRequest(msg); case "branch_suggestions_request": @@ -5670,7 +5669,7 @@ export class Session { activityAt: null, createdAt: workspace.createdAt, diffStat, - scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord), + scripts: await this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord), ...(resolvedProjectRecord ? { project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord), @@ -7263,7 +7262,7 @@ export class Session { private buildWorkspaceScriptPayloadSnapshot( workspace: PersistedWorkspaceRecord, project: PersistedProjectRecord | null, - ): WorkspaceDescriptorPayload["scripts"] { + ): Promise { return this.workspaceScripts.buildSnapshot(workspace, project); } @@ -7787,8 +7786,6 @@ export class Session { ): Promise { const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); const projection = "projected" as const; - const requestedLimit = msg.limit; - const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200); const cursor: AgentTimelineCursor | undefined = msg.cursor ? { epoch: msg.cursor.epoch, @@ -7807,7 +7804,7 @@ export class Session { const fetchedControlTimeline = this.agentManager.fetchTimeline(msg.agentId, { direction, cursor, - limit: pageLimit, + limit: msg.limit, }); const selectedTimeline = { timeline: fetchedControlTimeline, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index e4a0ca7668..3755ff56d8 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -9228,8 +9228,8 @@ test("workspace auto-name uses the backing root for a nested worktree", async () execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", "-M", "placeholder-branch"], { cwd: repoDir, stdio: "pipe" }); - writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" }); - writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, { + await writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" }); + await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, { placeholderBranchName: "placeholder-branch", }); const workspaceCwd = path.join(repoDir, "packages", "app"); @@ -9288,6 +9288,7 @@ test("workspace auto-name uses the backing root for a nested worktree", async () firstAgentContext: { prompt: "Fix checkout title" }, }); await vi.runAllTimersAsync(); + await vi.waitFor(() => expect(emittedCwds).toEqual([workspaceCwd])); expect(generateCalls).toBe(1); expect(stored.get(workspace.workspaceId)).toMatchObject({ @@ -9299,7 +9300,7 @@ test("workspace auto-name uses the backing root for a nested worktree", async () .toString() .trim(), ).toBe("placeholder-branch"); - expect(readPaseoWorktreeMetadata(repoDir)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(repoDir)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "attempted", diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 419d418050..ceb69e9623 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -290,7 +290,10 @@ export class CheckoutSession { } } - async handleCommitFileDiffRequest(msg: CheckoutCommitFileDiffRequest): Promise { + async handleCommitFileDiffRequest( + msg: CheckoutCommitFileDiffRequest, + signal: AbortSignal, + ): Promise { const { cwd, sha, path, requestId } = msg; try { @@ -298,7 +301,7 @@ export class CheckoutSession { if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) { throw new Error(`Invalid path: ${path}`); } - const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path }); + const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path, signal }); this.host.emit({ type: "checkout.commits.file_diff.response", payload: { cwd, sha, path, file, error: null, requestId }, diff --git a/packages/server/src/server/session/daemon/daemon-session.test.ts b/packages/server/src/server/session/daemon/daemon-session.test.ts index cac6a91ea6..e96256c4cf 100644 --- a/packages/server/src/server/session/daemon/daemon-session.test.ts +++ b/packages/server/src/server/session/daemon/daemon-session.test.ts @@ -43,7 +43,7 @@ function makeSubsystem(overrides: { listProviderAvailability?: () => Promise; getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; hubRelationships?: HubRelationshipManagement; - reloadConfig?: () => DaemonConfigReloadResult; + reloadConfig?: () => Promise; }) { const emitted: SessionOutboundMessage[] = []; const restartIntents: Parameters[0][] = []; @@ -67,7 +67,7 @@ function makeSubsystem(overrides: { hubRelationships: overrides.hubRelationships, reloadConfig: overrides.reloadConfig ?? - (() => ({ + (async () => ({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: [], @@ -78,16 +78,16 @@ function makeSubsystem(overrides: { } describe("DaemonSession", () => { - test("config reload returns the daemon-owned classification", () => { + test("config reload returns the daemon-owned classification", async () => { const { subsystem, emitted } = makeSubsystem({ - reloadConfig: () => ({ + reloadConfig: async () => ({ appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: ["daemon.listen"], overrideControlledPaths: ["app.baseUrl"], }), }); - subsystem.handleConfigReloadRequest({ + await subsystem.handleConfigReloadRequest({ type: "daemon.config.reload.request", requestId: "reload-1", }); @@ -105,14 +105,14 @@ describe("DaemonSession", () => { ]); }); - test("config reload failures return a correlated RPC error", () => { + test("config reload failures return a correlated RPC error", async () => { const { subsystem, emitted } = makeSubsystem({ - reloadConfig: () => { + reloadConfig: async () => { throw new Error("Invalid config"); }, }); - subsystem.handleConfigReloadRequest({ + await subsystem.handleConfigReloadRequest({ type: "daemon.config.reload.request", requestId: "reload-2", }); diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index fbe1a06415..ccc9e66e6d 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -53,7 +53,7 @@ export interface DaemonSessionOptions { getObservationMetrics?: () => Record; logger: pino.Logger; hubRelationships?: HubRelationshipManagement; - reloadConfig: () => DaemonConfigReloadResult; + reloadConfig: () => Promise; } /** @@ -79,7 +79,7 @@ export class DaemonSession { private readonly logger: pino.Logger; private readonly selfUpdate: DaemonSelfUpdateSessionController; private readonly hubRelationships: HubRelationshipManagement | null; - private readonly reloadConfig: () => DaemonConfigReloadResult; + private readonly reloadConfig: () => Promise; constructor(options: DaemonSessionOptions) { this.host = options.host; @@ -252,13 +252,13 @@ export class DaemonSession { } } - handleConfigReloadRequest( + async handleConfigReloadRequest( msg: Extract, - ): void { + ): Promise { try { this.host.emit({ type: "daemon.config.reload.response", - payload: { requestId: msg.requestId, ...this.reloadConfig() }, + payload: { requestId: msg.requestId, ...(await this.reloadConfig()) }, }); } catch (error) { this.logger.error({ err: error }, "Failed to reload daemon config"); diff --git a/packages/server/src/server/session/project-config/project-config-session.test.ts b/packages/server/src/server/session/project-config/project-config-session.test.ts index 6e93dff805..f8f6b59304 100644 --- a/packages/server/src/server/session/project-config/project-config-session.test.ts +++ b/packages/server/src/server/session/project-config/project-config-session.test.ts @@ -228,7 +228,7 @@ describe("ProjectConfigSession", () => { requestId: "write-uncommitted-setup", repoRoot, config: { worktree: { setup: "npm install" } }, - expectedRevision: statPaseoConfigPath(repoRoot), + expectedRevision: await statPaseoConfigPath(repoRoot), }); expect(emitted).toEqual([ diff --git a/packages/server/src/server/session/project-config/project-config-session.ts b/packages/server/src/server/session/project-config/project-config-session.ts index 6056d08826..d2e8c419f0 100644 --- a/packages/server/src/server/session/project-config/project-config-session.ts +++ b/packages/server/src/server/session/project-config/project-config-session.ts @@ -1,10 +1,10 @@ -import { realpathSync } from "node:fs"; -import { resolve, sep } from "path"; +import { resolve } from "path"; import type pino from "pino"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; import type { ProjectRegistry } from "../../workspace-registry.js"; import { readPaseoConfigForEdit, + resolveProjectConfigRoot, writePaseoConfigForEdit, type ProjectConfigRpcError, } from "../../../utils/paseo-config-file.js"; @@ -47,7 +47,7 @@ export class ProjectConfigSession { return; } - const result = readPaseoConfigForEdit(repoRoot); + const result = await readPaseoConfigForEdit(repoRoot); if (!result.ok) { this.logger.warn( { repoRoot, requestId: msg.requestId, outcome: result.error.code }, @@ -94,7 +94,7 @@ export class ProjectConfigSession { { repoRoot, requestId: msg.requestId, outcome: "write_attempt" }, "Writing project config", ); - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot, config: msg.config, expectedRevision: msg.expectedRevision, @@ -173,34 +173,21 @@ export class ProjectConfigSession { } private async resolveKnownProjectRoot(repoRoot: string): Promise { - const requestedRoot = canonicalizeConfigRoot(repoRoot); + const requestedRoot = resolve(repoRoot); const projects = await this.projectRegistry.list(); - for (const project of projects) { - if (project.archivedAt !== null) { - continue; - } - const projectRoot = canonicalizeConfigRoot(project.rootPath); - if (requestedRoot === projectRoot) { - return projectRoot; - } - } - return null; + const activeRoots = new Set( + projects + .filter((project) => project.archivedAt === null) + .map((project) => resolve(project.rootPath)), + ); + const root = await resolveProjectConfigRoot(requestedRoot, activeRoots); + if (!root) return null; + // Authorization follows live registry state after alias IO, including archive/removal. + const current = await this.projectRegistry.list(); + return current.some( + (project) => project.archivedAt === null && resolve(project.rootPath) === root, + ) + ? root + : null; } } - -function canonicalizeConfigRoot(repoRoot: string): string { - const resolved = resolve(repoRoot); - try { - return stripTrailingPathSeparators(realpathSync(resolved)); - } catch { - return stripTrailingPathSeparators(resolved); - } -} - -function stripTrailingPathSeparators(path: string): string { - let normalized = path; - while (normalized.length > 1 && normalized.endsWith(sep)) { - normalized = normalized.slice(0, -1); - } - return normalized; -} diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts index f738a0b0d6..22e903b5f1 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts @@ -347,7 +347,7 @@ async function createBaseRecoveryFixture(baseBranch: string | null) { test("preserves ordinary checkout behavior when no separate base was recorded", async () => { const fixture = await createBaseRecoveryFixture(null); - writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" }); + await writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" }); await fixture.archiveAndRemove(); await fixture.service.restore(fixture.workspace.workspaceId); expect((await fixture.registry.get(fixture.workspace.workspaceId))?.baseBranch).toBeNull(); @@ -430,7 +430,7 @@ test("restores the branch HEAD with a base name when the exact base is missing", }); expect(existsSync(fixture.workspace.cwd)).toBe(true); expect((await fixture.registry.get(fixture.workspace.workspaceId))?.archivedAt).toBeNull(); - const metadata = readPaseoWorktreeMetadata(fixture.workspace.cwd); + const metadata = await readPaseoWorktreeMetadata(fixture.workspace.cwd); expect(metadata).toMatchObject({ baseRefName: "main" }); expect(metadata?.baseRef).toBeUndefined(); expect( @@ -450,7 +450,7 @@ test("restore fetches a deleted local branch from origin and retains its base", await fixture.archiveAndRemove(); execFileSync("git", ["branch", "-D", "feature"], { cwd: fixture.repoDir }); await fixture.service.restore(fixture.workspace.workspaceId); - expect(readPaseoWorktreeMetadata(fixture.workspace.cwd)?.baseRef).toBe("refs/heads/main"); + expect((await readPaseoWorktreeMetadata(fixture.workspace.cwd))?.baseRef).toBe("refs/heads/main"); expect( await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome }), ).toMatchObject({ diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index 18ae1f1366..664fcc14de 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -1,3 +1,5 @@ +import { DaemonConfigStore } from "../../daemon-config-store.js"; +import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -82,6 +84,7 @@ interface BuildOptions { spawnThrows?: string; runtimeStore?: Pick; automationError?: Error; + getGlobalServicePorts?: () => Promise; } function buildService(options: BuildOptions = {}) { @@ -111,6 +114,7 @@ function buildService(options: BuildOptions = {}) { getDaemonTcpHost: () => "127.0.0.1", serviceProxyPublicBaseUrl: null, resolveScriptHealth: null, + getGlobalServicePorts: options.getGlobalServicePorts ?? (async () => undefined), logger, emit: (message) => emitted.push(message), publishStatusUpdate: (message) => published.push(message), @@ -152,18 +156,42 @@ afterEach(() => { } }); +test("reads external service-port edits at the next launch without construction IO", async () => { + const home = mkdtempSync(join(tmpdir(), "workspace-service-ports-")); + tempDirs.push(home); + const store = new DaemonConfigStore(home, { + providers: {}, + mcp: { enabled: true, injectIntoAgents: false }, + }); + const { service, spawnCalls } = buildService({ + getGlobalServicePorts: () => store.readServicePorts(), + }); + writeFileSync( + join(home, "config.json"), + JSON.stringify({ worktrees: { servicePorts: { range: "9000-9999" } } }), + ); + await service.start(request); + expect(spawnCalls.map((call) => call.globalServicePorts)).toEqual([{ range: "9000-9999" }]); +}); + describe("buildSnapshot", () => { test("returns no scripts when the service proxy is unavailable", async () => { const { service } = buildService({ serviceProxy: null }); expect( - service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord), + await service.buildSnapshot({ + workspaceId: "ws-1", + cwd: "/tmp/repo", + } as PersistedWorkspaceRecord), ).toEqual([]); }); test("returns no scripts when the runtime store is unavailable", async () => { const { service } = buildService({ scriptRuntimeStore: null }); expect( - service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord), + await service.buildSnapshot({ + workspaceId: "ws-1", + cwd: "/tmp/repo", + } as PersistedWorkspaceRecord), ).toEqual([]); }); @@ -172,7 +200,7 @@ describe("buildSnapshot", () => { tempDirs.push(dir); const { service } = buildService(); expect( - service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord), + await service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord), ).toEqual([]); }); @@ -207,7 +235,7 @@ describe("buildSnapshot", () => { runtimeStore: { get: () => null }, }); - expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe( + expect((await service.buildSnapshot(workspace, project))[0]?.hostname).toBe( serviceProxy.projectWorkspaceService({ projectSlug: deriveProjectServiceSlug(project), branchName: workspace.branch, @@ -352,10 +380,12 @@ describe("start", () => { daemonPort: 6767, daemonListenHost: "127.0.0.1", }); - expect(published).toContainEqual({ - type: "script_status_update", - payload: { workspaceId: "ws-1", scripts: [] }, - }); + await expect + .poll(() => published) + .toContainEqual({ + type: "script_status_update", + payload: { workspaceId: "ws-1", scripts: [] }, + }); expect(emitted).toContainEqual({ type: "start_workspace_script_response", payload: { @@ -449,7 +479,7 @@ describe("start", () => { const serviceProxy = createServiceProxySubsystem({ logger }); const { service, spawnCalls } = buildService({ workspace, project, serviceProxy }); - const snapshot = service.buildSnapshot(workspace, project); + const snapshot = await service.buildSnapshot(workspace, project); await service.start({ ...request, workspaceId: workspace.workspaceId }); const started = spawnCalls[0]!; diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index 103c3ed16b..eebad7f1e5 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -43,7 +43,7 @@ export interface WorkspaceScriptsService { buildSnapshot( workspace: PersistedWorkspaceRecord, project?: PersistedProjectRecord | null, - ): WorkspaceScriptsPayload; + ): Promise; emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise; list(workspaceId: string): Promise; launch(input: { workspaceId: string; scriptName: string }): Promise; @@ -62,7 +62,7 @@ export function createWorkspaceScriptsService(deps: { getDaemonTcpHost: (() => string | null) | null; serviceProxyPublicBaseUrl: string | null; resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null; - globalServicePorts?: PaseoServicePortAllocation; + getGlobalServicePorts: () => Promise; logger: pino.Logger; emit: (message: SessionOutboundMessage) => void; // Script status is daemon state, so it reaches every client, not only the requester. @@ -81,7 +81,7 @@ export function createWorkspaceScriptsService(deps: { getDaemonTcpHost, serviceProxyPublicBaseUrl, resolveScriptHealth, - globalServicePorts, + getGlobalServicePorts, logger, emit, publishStatusUpdate, @@ -110,17 +110,17 @@ export function createWorkspaceScriptsService(deps: { }; } - function buildSnapshot( + async function buildSnapshot( workspace: PersistedWorkspaceRecord, project: PersistedProjectRecord | null = null, - ): WorkspaceScriptsPayload { + ): Promise { if (!serviceProxy || !scriptRuntimeStore) { return []; } return buildWorkspaceScriptPayloads({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.cwd, - paseoConfig: readPaseoConfigForProjection(workspace.cwd, logger), + paseoConfig: await readPaseoConfigForProjection(workspace.cwd, logger), serviceProxy, runtimeStore: scriptRuntimeStore, daemonPort: getDaemonTcpPort?.() ?? null, @@ -137,7 +137,7 @@ export function createWorkspaceScriptsService(deps: { const project = await projectRegistry.get(workspace.projectId); publishStatusUpdate({ type: "script_status_update", - payload: { workspaceId, scripts: buildSnapshot(workspace, project) }, + payload: { workspaceId, scripts: await buildSnapshot(workspace, project) }, }); } catch (error) { logger.warn({ err: error, workspaceId }, "Failed to project workspace script status"); @@ -172,6 +172,7 @@ export function createWorkspaceScriptsService(deps: { async function launchProcess(input: { workspaceId: string; scriptName: string }) { const available = requireAvailable(); + const globalServicePorts = await getGlobalServicePorts(); const workspace = await getWorkspace(input.workspaceId); await assertAutomationAllowed(workspace.workspaceId); const project = await projectRegistry.get(workspace.projectId); @@ -202,7 +203,7 @@ export function createWorkspaceScriptsService(deps: { scriptName: string; }): Promise { const { workspace, project } = await launchProcess(input); - const script = buildSnapshot(workspace, project).find( + const script = (await buildSnapshot(workspace, project)).find( (entry) => entry.scriptName === input.scriptName, ); if (!script) { @@ -230,7 +231,7 @@ export function createWorkspaceScriptsService(deps: { // The launcher's terminal exit listener owns route removal and runtime state updates. await available.terminalManager.killTerminalAndWait(runtime.terminalId); - const script = buildSnapshot(workspace, project).find( + const script = (await buildSnapshot(workspace, project)).find( (entry) => entry.scriptName === input.scriptName, ); if (!script) { diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 1c7127d144..42ac0c7c79 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -60,6 +60,8 @@ interface FakeAgentSessionOptions { closeSession?: () => Promise; onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; beforeTurnComplete?: (prompt: AgentPromptInput) => Promise; + steerActiveTurn?: AgentSession["steerActiveTurn"]; + onInterrupt?: () => void; historyEvents?: readonly AgentStreamEvent[]; } @@ -68,6 +70,8 @@ export interface TestAgentClientOptions { closeSession?: () => Promise; onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; beforeTurnComplete?: (prompt: AgentPromptInput) => Promise; + steerActiveTurn?: AgentSession["steerActiveTurn"]; + onInterrupt?: () => void; supportsMcpServers?: boolean; historyEvents?: readonly AgentStreamEvent[]; } @@ -340,6 +344,8 @@ class FakeAgentSession implements AgentSession { private nextTurnOrdinal = 0; private activeForegroundTurnId: string | null = null; + readonly steerActiveTurn: AgentSession["steerActiveTurn"]; + private readonly onInterrupt: (() => void) | undefined; private readonly closeSession: (() => Promise) | undefined; private readonly onStartTurn: TestAgentClientOptions["onStartTurn"]; private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise) | undefined; @@ -354,6 +360,8 @@ class FakeAgentSession implements AgentSession { this.config = options.config; this.id = options.sessionId ?? randomUUID(); this.memoryMarker = options.memoryMarker ?? null; + this.steerActiveTurn = options.steerActiveTurn; + this.onInterrupt = options.onInterrupt; this.closeSession = options.closeSession; this.onStartTurn = options.onStartTurn; this.beforeTurnComplete = options.beforeTurnComplete; @@ -896,6 +904,7 @@ class FakeAgentSession implements AgentSession { } async interrupt(): Promise { + this.onInterrupt?.(); this.interruptSignal.resolve(); } @@ -1234,6 +1243,8 @@ class FakeAgentClient implements AgentClient { closeSession: this.options.closeSession, onStartTurn: this.options.onStartTurn, beforeTurnComplete: this.options.beforeTurnComplete, + steerActiveTurn: this.options.steerActiveTurn, + onInterrupt: this.options.onInterrupt, historyEvents: this.options.historyEvents, }); } @@ -1261,6 +1272,8 @@ class FakeAgentClient implements AgentClient { closeSession: this.options.closeSession, onStartTurn: this.options.onStartTurn, beforeTurnComplete: this.options.beforeTurnComplete, + steerActiveTurn: this.options.steerActiveTurn, + onInterrupt: this.options.onInterrupt, historyEvents: this.options.historyEvents, }); } diff --git a/packages/server/src/server/utils/diff-highlighter.test.ts b/packages/server/src/server/utils/diff-highlighter.test.ts index fda98d6cbf..3ecb0372ad 100644 --- a/packages/server/src/server/utils/diff-highlighter.test.ts +++ b/packages/server/src/server/utils/diff-highlighter.test.ts @@ -1,7 +1,11 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { setImmediate } from "node:timers/promises"; +import { performance } from "node:perf_hooks"; +import { SessionDelivery } from "../session/owned-subscriptions/index.js"; +import { highlightCode, highlightCodeWithBudget } from "@getpaseo/highlight"; import { parseDiff, reconstructNewFile, @@ -819,3 +823,152 @@ index 1111111..2222222 100644 } }); }); + +describe("diff highlighting budget", () => { + it.each(["supplied", "local"] as const)( + "keeps a one-line diff on a 5 MiB %s source responsive using hunk tokens", + async (source) => { + const cwd = mkdtempSync(join(tmpdir(), "diff-highlight-budget-")); + const oldFileContent = "const value = Math.max(42, 7);\n".repeat( + Math.ceil((5 * 1024 * 1024) / 30), + ); + const newFileContent = oldFileContent.replace("42", "43"); + writeFileSync(join(cwd, "example.ts"), newFileContent); + const file = parseDiff(`diff --git a/example.ts b/example.ts +--- a/example.ts ++++ b/example.ts +@@ -1 +1 @@ +-const value = Math.max(42, 7); ++const value = Math.max(43, 7);`)[0]; + let lastTick = performance.now(); + let longestGap = 0; + const timer = setInterval(() => { + const now = performance.now(); + longestGap = Math.max(longestGap, now - lastTick); + lastTick = now; + }, 1); + try { + await setImmediate(); + const highlighted = await highlightDiffWithFileContent( + file, + cwd, + source === "supplied" ? { oldFileContent } : {}, + ); + await new Promise((resolve) => setTimeout(resolve, 5)); + console.info({ sourceBytes: Buffer.byteLength(oldFileContent), longestGap }); + expect(longestGap).toBeLessThan(50); + expect(highlighted).toEqual(highlightDiffFromHunks(file)); + } finally { + clearInterval(timer); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 30_000, + ); + + it("preserves byte-identical tokens below the budget, including multiline syntax", async () => { + const oldFileContent = "/*\nold comment\n*/\nconst value = 42;"; + const newFileContent = oldFileContent.replace("old", "new"); + const file = parseDiff(`diff --git a/example.ts b/example.ts +--- a/example.ts ++++ b/example.ts +@@ -2 +2 @@ +-old comment ++new comment`)[0]; + const highlighted = await highlightDiffWithFileContent(file, ".", { + oldFileContent, + newFileContent, + }); + expect(JSON.stringify(highlighted.hunks[0].lines[1].tokens)).toBe( + JSON.stringify(highlightCode(oldFileContent, file.path)[1]), + ); + expect(JSON.stringify(highlighted.hunks[0].lines[2].tokens)).toBe( + JSON.stringify(highlightCode(newFileContent, file.path)[1]), + ); + }); +}); + +it("disconnecting the requesting source stops a parser between highlight steps", async () => { + const delivery = new SessionDelivery(() => {}); + const source = {}; + delivery.attach(source, true); + const yielded = Promise.withResolvers(); + let result: Awaited> = []; + const pending = delivery.request(source, { type: "ping", requestId: "highlight" }, async () => { + result = await highlightCodeWithBudget( + "const value = Math.max(42, 7);\n".repeat(3_000), + "file.ts", + { + signal: delivery.requestSignal, + yieldToEventLoop: () => { + yielded.resolve(); + return setImmediate(); + }, + }, + ); + }); + await yielded.promise; + await delivery.detach(source); + await pending; + expect(result).toBeNull(); +}); + +it("keeps real session.ts diffs highlighted and byte-identical under the source budget", async () => { + const cwd = mkdtempSync(join(tmpdir(), "diff-highlight-real-source-")); + try { + const oldFileContent = readFileSync(new URL("../session.ts", import.meta.url), "utf8"); + const newFileContent = oldFileContent.replace( + "WorkspaceRuntimeStore", + "WorkspaceRuntimeSnapshot", + ); + writeFileSync(join(cwd, "session.ts"), newFileContent); + const oldLine = oldFileContent.split("\n")[0]; + const newLine = newFileContent.split("\n")[0]; + const [file] = parseDiff(`diff --git a/session.ts b/session.ts +--- a/session.ts ++++ b/session.ts +@@ -1 +1 @@ +-${oldLine} ++${newLine}`); + const highlighted = await highlightDiffWithFileContent(file, cwd, { oldFileContent }); + const removed = highlighted.hunks[0].lines.find((line) => line.type === "remove"); + const added = highlighted.hunks[0].lines.find((line) => line.type === "add"); + expect(removed?.tokens).toEqual(highlightCode(oldFileContent, "session.ts")[0]); + expect(added?.tokens).toEqual(highlightCode(newFileContent, "session.ts")[0]); + expect(added?.tokens?.some((token) => token.style !== null)).toBe(true); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +it("highlights a one-line hunk from an over-budget JSON source without blocking the loop", async () => { + const cwd = mkdtempSync(join(tmpdir(), "diff-highlight-large-json-")); + const oldFileContent = `{\n${' "value": 42,\n'.repeat(100_000)} "last": true\n}`; + const newFileContent = oldFileContent.replace("42", "43"); + writeFileSync(join(cwd, "package-lock.json"), newFileContent); + const [file] = parseDiff(`diff --git a/package-lock.json b/package-lock.json +--- a/package-lock.json ++++ b/package-lock.json +@@ -2 +2 @@ +- "value": 42, ++ "value": 43,`); + let last = performance.now(), + longestGap = 0; + const timer = setInterval(() => { + const now = performance.now(); + longestGap = Math.max(longestGap, now - last); + last = now; + }, 1); + try { + const result = await highlightDiffWithFileContent(file, cwd, { oldFileContent }); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(longestGap).toBeLessThan(50); + expect(result.hunks[0].lines.find((line) => line.type === "add")?.tokens).toContainEqual({ + text: "43", + style: "number", + }); + } finally { + clearInterval(timer); + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index 3c2df279e2..e3a0460322 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -1,6 +1,14 @@ -import { readFile } from "node:fs/promises"; +import { open } from "node:fs/promises"; +import { constants } from "node:fs"; +import { setImmediate } from "node:timers/promises"; import { resolve } from "node:path"; -import { highlightCode, isLanguageSupported, type HighlightToken } from "@getpaseo/highlight"; +import { + highlightCode, + highlightCodeWithBudget, + HIGHLIGHT_MAX_SOURCE_BYTES, + isLanguageSupported, + type HighlightToken, +} from "@getpaseo/highlight"; import { unquoteGitPath } from "../../utils/git-quoted-path.js"; const MAX_DIFF_HIGHLIGHT_LINE_CHARS = 10_000; @@ -30,11 +38,13 @@ export interface ParsedDiffFile { } interface HighlightDiffWithFileContentOptions { + signal?: AbortSignal; oldFileContent?: string | null; newFileContent?: string | null; } interface ParseAndHighlightDiffOptions { + signal?: AbortSignal; getOldFileContent?: (file: ParsedDiffFile) => Promise; getNewFileContent?: (file: ParsedDiffFile) => Promise; } @@ -269,20 +279,71 @@ function buildTokenLookup( return lookup; } -function buildFullFileTokenLookup( +async function buildFullFileTokenLookup( fileContent: string, path: string, -): Map | null { - if (hasOversizedLine(fileContent)) return null; + signal?: AbortSignal, +): Promise | null> { + const highlighted = await highlightCodeWithBudget(fileContent, path, { + yieldToEventLoop: setImmediate, + signal, + }); + if (!highlighted) return null; + return new Map(highlighted.map((tokens, index) => [index + 1, tokens])); +} - const lookup = new Map(); - const highlighted = highlightCode(fileContent, path); - - for (let i = 0; i < highlighted.length; i++) { - lookup.set(i + 1, highlighted[i]); +async function readHighlightSource(path: string): Promise { + let handle; + try { + handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK); + const stat = await handle.stat(); + if (!stat.isFile() || stat.size > HIGHLIGHT_MAX_SOURCE_BYTES) return null; + const buffer = Buffer.alloc(Math.min(stat.size, HIGHLIGHT_MAX_SOURCE_BYTES) + 1); + let offset = 0; + while (offset < buffer.length) { + const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, null); + if (bytesRead === 0) break; + offset += bytesRead; + } + // A full buffer includes the extra byte: the source grew after stat. + return offset === buffer.length ? null : buffer.toString("utf8", 0, offset); + } catch { + return null; + } finally { + await handle?.close(); } +} - return lookup; +function exceedsSourceBudget(content: string): boolean { + return ( + content.length > HIGHLIGHT_MAX_SOURCE_BYTES || + Buffer.byteLength(content) > HIGHLIGHT_MAX_SOURCE_BYTES + ); +} + +async function buildBudgetedReconstructedTokens(file: ParsedDiffFile, signal?: AbortSignal) { + const newLines = reconstructNewFile(file.hunks); + const oldLines = reconstructOldFile(file.hunks); + const build = async (lines: Map) => { + // Sparse hunks can be far apart. Admit the span before filling its gaps. + const numbers = Array.from(lines.keys()); + if (numbers.length && numbers[numbers.length - 1] - numbers[0] > HIGHLIGHT_MAX_SOURCE_BYTES) + return null; + let bytes = 0; + for (const line of lines.values()) { + bytes += Buffer.byteLength(line) + 1; + if (bytes > HIGHLIGHT_MAX_SOURCE_BYTES) return null; + } + const highlighted = await highlightCodeWithBudget(buildFileContent(lines), file.path, { + yieldToEventLoop: setImmediate, + signal, + }); + return highlighted ? buildTokenLookup(lines, highlighted) : null; + }; + const newTokensByLine = await build(newLines); + if (!newTokensByLine) return null; + const oldTokensByLine = await build(oldLines); + return oldTokensByLine ? { newTokensByLine, oldTokensByLine } : null; } function buildReconstructedTokenLookups(file: ParsedDiffFile): { @@ -328,36 +389,50 @@ export async function highlightDiffWithFileContent( cwd: string, options: HighlightDiffWithFileContentOptions = {}, ): Promise { - if (!isLanguageSupported(file.path) || hasOversizedDiffLine(file)) { - return file; - } + if (!isDiffHighlightEligible(file, options)) return file; - let newTokensByLine: Map | null = null; - const oldTokensByLine = - typeof options.oldFileContent === "string" - ? buildFullFileTokenLookup(options.oldFileContent, file.path) - : null; - if (typeof options.newFileContent === "string") { - newTokensByLine = buildFullFileTokenLookup(options.newFileContent, file.path); - } else { - try { - const fileContent = await readFile(resolve(cwd, file.path), "utf-8"); - newTokensByLine = buildFullFileTokenLookup(fileContent, file.path); - } catch { - // Deleted or unavailable files use reconstructed tokens below. - } - } + const newContent = + typeof options.newFileContent === "string" + ? options.newFileContent + : await readHighlightSource(resolve(cwd, file.path)); + if (options.signal?.aborted) return file; - if (newTokensByLine && oldTokensByLine) - return applyTokensToHunks(file, newTokensByLine, oldTokensByLine); - const reconstructedTokens = buildReconstructedTokenLookups(file); + const oldTokens = await highlightAvailableSource( + options.oldFileContent, + file.path, + options.signal, + ); + if (options.signal?.aborted) return file; + const newTokens = await highlightAvailableSource(newContent, file.path, options.signal); + if (options.signal?.aborted) return file; + if (oldTokens && newTokens) return applyTokensToHunks(file, newTokens, oldTokens); + const reconstructed = await buildBudgetedReconstructedTokens(file, options.signal); + if (!reconstructed || options.signal?.aborted) return file; return applyTokensToHunks( file, - newTokensByLine ?? reconstructedTokens.newTokensByLine, - oldTokensByLine ?? reconstructedTokens.oldTokensByLine, + newTokens ?? reconstructed.newTokensByLine, + oldTokens ?? reconstructed.oldTokensByLine, ); } +function isDiffHighlightEligible( + file: ParsedDiffFile, + options: HighlightDiffWithFileContentOptions, +): boolean { + return !options.signal?.aborted && isLanguageSupported(file.path) && !hasOversizedDiffLine(file); +} + +async function highlightAvailableSource( + content: string | null | undefined, + path: string, + signal?: AbortSignal, +): Promise | null> { + // Preserve the existing hunk-context fallback for oversized individual lines. + if (typeof content !== "string" || exceedsSourceBudget(content) || hasOversizedLine(content)) + return null; + return buildFullFileTokenLookup(content, path, signal); +} + function applyTokensToHunks( file: ParsedDiffFile, newTokensByLine: Map, @@ -406,20 +481,24 @@ export async function parseAndHighlightDiff( ): Promise { const files = parseDiff(diffText); - const highlightedFiles = await Promise.all( - files.map(async (file) => { - const [oldFileContent, newFileContent] = await Promise.all([ - options.getOldFileContent?.(file), - options.getNewFileContent?.(file), - ]); - - return highlightDiffWithFileContent(file, cwd, { + const highlightedFiles: ParsedDiffFile[] = []; + for (const file of files) { + if (options.signal?.aborted) { + highlightedFiles.push(file); + continue; + } + const [oldFileContent, newFileContent] = await Promise.all([ + options.getOldFileContent?.(file), + options.getNewFileContent?.(file), + ]); + highlightedFiles.push( + await highlightDiffWithFileContent(file, cwd, { oldFileContent: oldFileContent ?? undefined, newFileContent: newFileContent ?? undefined, - }); - }), - ); - + signal: options.signal, + }), + ); + } return highlightedFiles; } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 53172b2835..4473a4004d 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -358,7 +358,7 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry { get: async () => null, update: async () => null, upsert: async () => {}, - archive: async () => {}, + archive: async () => false, remove: async () => {}, }; } diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index fc9efe1684..cd9ec71d83 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -493,6 +493,7 @@ async function createRestorableWorkspace(baseRef: string) { commitFile(cwd, "feature.txt", "feature change\n"); const head = runGit(cwd, "rev-parse", "HEAD"); await client.checkoutRefresh(cwd); + expect(await client.getCheckoutStatus(cwd)).toMatchObject({ isPaseoOwnedWorktree: true }); const before = await client.getCheckoutDiff(cwd, { mode: "base", baseRef: "main" }); expect(before.error).toBeNull(); expect(before.files.map((file) => file.path)).toEqual(["feature.txt"]); diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index a8a1f11dfb..55265416dc 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -526,3 +526,183 @@ test("a late Git-ignored tree is pruned while tracked changes still notify consu rmSync(tempDir, { recursive: true, force: true }); } }, 15_000); + +test("Git refresh, command tracing and 10,000 ignored alias events do no synchronous IO", async () => { + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const { fileURLToPath } = await import("node:url"); + const repositoryRoot = fileURLToPath(new URL("../../../../", import.meta.url)); + const source = ` + import assert from 'node:assert/strict'; + import { write } from 'node:fs'; + import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + import { setImmediate as tick } from 'node:timers/promises'; + import { createHook } from 'node:async_hooks'; + import pino from 'pino'; + import { WorkspaceGitServiceImpl } from './packages/server/src/server/workspace-git-service.ts'; + import { runGitCommand } from './packages/server/src/utils/run-git-command.ts'; + import { writePaseoWorktreeMetadata } from './packages/server/src/utils/worktree-metadata.ts'; + const root = await mkdtemp(join(tmpdir(), 'git-async-contract-')); + const home = join(root, 'home'); + const repo = join(root, 'repo'); + const worktree = join(home, 'worktrees', 'project', 'topic'); + const alias = join(root, 'alias'); + const logger = pino({ level: 'trace' }, { write() {} }); + // Async descriptor writes avoid Windows stderr's synchronous stream API. + const marker = (label) => new Promise((resolve, reject) => { + write(2, label + '\\n', (error) => error ? reject(error) : resolve()); + }); + let callback; + let notifications = 0; + const service = new WorkspaceGitServiceImpl({ logger, paseoHome: home, deps: { + subscribe: async (_root, onChange) => { + callback = onChange; + return { updateIgnore: async () => {}, unsubscribe: async () => {} }; + } + } }); + try { + await mkdir(repo); + await runGitCommand(['init', '-b', 'main'], { cwd: repo }); + await runGitCommand(['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--allow-empty', '-m', 'base'], { cwd: repo }); + await mkdir(join(home, 'worktrees', 'project'), { recursive: true }); + await runGitCommand(['worktree', 'add', '-b', 'topic', worktree], { cwd: repo }); + await writePaseoWorktreeMetadata(worktree, { baseRefName: 'main' }); + await writeFile(join(worktree, '.gitignore'), 'ignored/\\n'); + await mkdir(join(worktree, 'ignored')); + await writeFile(join(worktree, 'ignored', 'noise'), 'noise'); + await symlink(worktree, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const subscription = await service.requestWorkingTreeWatch(alias, () => { notifications++; }); + const events = Array.from({ length: process.env.GIT_IO_TRACE ? 2 : 10000 }, (_, i) => ({ type: 'update', path: join(i % 2 ? alias : worktree, 'ignored', 'noise') })); + // A shared CI runner can deschedule the child during a wall-clock sample. + // Keep the 20ms gate, but take three independent batches and require one + // responsive callback/timer pair. All batches must remain filesystem-free. + const samples = []; + for (let trial = 0; trial < (process.env.GIT_IO_TRACE ? 1 : 3); trial++) { + await tick(); + let fsCalls = 0; + const hook = createHook({ init(_id, type) { if (type.startsWith('FSREQ')) fsCalls++; } }); + await marker('FILTER_START'); + hook.enable(); + const started = performance.now(); + const pulse = new Promise((resolve) => setTimeout(() => resolve(performance.now() - started), 0)); + callback(null, events); + const filterMs = performance.now() - started; + hook.disable(); + const loopGapMs = await pulse; + await marker('FILTER_END'); + assert.equal(fsCalls, 0, "filesystem calls"); + assert.equal(notifications, 0, "ignored event notification"); + samples.push({ filterMs, loopGapMs, fsCalls }); + } + console.log(JSON.stringify({ ...samples[0], samples })); + assert.ok(process.env.GIT_IO_TRACE || samples.some(({ filterMs, loopGapMs }) => filterMs < 20 && loopGapMs < 20), 'no responsive filter sample: ' + JSON.stringify(samples)); + // Alias identity remains useful after deletion: no per-event realpath can recover this file. + callback(null, [{ type: 'delete', path: join(worktree, 'tracked.txt') }]); + assert.equal(notifications, 1); + await marker('REFRESH_START'); + const snapshot = await service.getSnapshot(worktree); + assert.equal(snapshot.git.isGit, true); + assert.equal(snapshot.git.isPaseoOwnedWorktree, true); + await runGitCommand(['status', '--porcelain'], { cwd: alias, logger }); + await marker('REFRESH_END'); + subscription.unsubscribe(); + } finally { + await service.dispose(); + await rm(root, { recursive: true, force: true }); + } + `; + for (const tracing of [false, true]) { + const { stdout, stderr } = await promisify(execFile)( + process.execPath, + [ + "--import", + "tsx", + ...(tracing ? ["--trace-sync-io"] : []), + "--input-type=module", + "--eval", + source, + ], + { + cwd: repositoryRoot, + env: { ...process.env, GIT_IO_TRACE: tracing ? "1" : "" }, + timeout: 30_000, + maxBuffer: 4 * 1024 * 1024, + }, + ).catch((error) => { + throw new Error(String(error.stderr).replaceAll(/at .*/g, "[child stack omitted]")); + }); + for (const phase of ["FILTER", "REFRESH"]) { + const output = stderr.split(`${phase}_START\n`)[1]?.split(`${phase}_END\n`)[0]; + expect(output, `${phase}: synchronous filesystem IO`).toBe(""); + } + expect(JSON.parse(stdout.trim().split("\n")[0])).toMatchObject({ fsCalls: 0 }); + } +}, 60_000); + +test("Git event identity preserves Windows namespace, case and subtree boundaries", async () => { + const { GitObservationPaths } = await import("./git-observation-paths.js"); + const paths = await GitObservationPaths.create("C:\\paseo-observation-contract"); + const accept = paths.createEventFilter(["C:\\paseo-observation-contract\\Build"]); + expect(paths.relative("\\\\?\\C:\\PASEO-OBSERVATION-CONTRACT\\refs\\heads\\MixedCase")).toBe( + "refs/heads/MixedCase", + ); + expect(accept("\\\\?\\C:\\paseo-observation-contract\\build\\noise")).toBe(false); + expect(accept("C:\\paseo-observation-contract\\BuildSibling\\file")).toBe(true); +}); + +test("concurrent alias requests establish one working-tree subscription", async () => { + const { symlink } = await import("node:fs/promises"); + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "git-observation-concurrent-"))); + const repo = path.join(root, "repo"); + const alias = path.join(root, "alias"); + execFileSync("git", ["init", "-b", "main", repo], { stdio: "pipe" }); + await symlink(repo, alias, process.platform === "win32" ? "junction" : "dir"); + let subscriptions = 0; + const service = new WorkspaceGitServiceImpl({ + logger: pino({ enabled: false }), + paseoHome: path.join(root, "home"), + deps: { + subscribe: async () => { + subscriptions++; + return { updateIgnore: async () => {}, unsubscribe: async () => {} }; + }, + }, + }); + try { + const watches = await Promise.all([ + service.requestWorkingTreeWatch(repo, () => {}), + service.requestWorkingTreeWatch(alias, () => {}), + ]); + expect(subscriptions).toBe(1); + for (const watch of watches) watch.unsubscribe(); + } finally { + await service.dispose(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("Git event identity retains a symlink's literal target before canonicalizing its parent", async () => { + const { mkdir, symlink } = await import("node:fs/promises"); + const { GitObservationPaths } = await import("./git-observation-paths.js"); + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "git-observation-literal-target-"))); + const repository = path.join(root, "repository"); + const parentAlias = path.join(root, "parent-alias"); + const workspaceAlias = path.join(root, "workspace-alias"); + const literalTarget = path.join(parentAlias, "repository"); + const linkType = process.platform === "win32" ? "junction" : "dir"; + try { + await mkdir(repository); + await symlink(root, parentAlias, linkType); + await symlink(literalTarget, workspaceAlias, linkType); + const paths = await GitObservationPaths.create(workspaceAlias); + const accept = paths.createEventFilter([path.join(workspaceAlias, "ignored")]); + for (const spelling of [workspaceAlias, literalTarget, repository]) { + expect(paths.relative(path.join(spelling, "deleted.txt"))).toBe("deleted.txt"); + expect(accept(path.join(spelling, "ignored", "deleted.txt"))).toBe(false); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index f91a96e464..ab93558793 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -1189,7 +1189,7 @@ describe("WorkspaceGitService checkout observation", () => { ]); await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => { - expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A, WORKTREE_B]); + expect(getCalledCwds(getCheckoutStatus).sort()).toEqual([WORKTREE_A, WORKTREE_B].sort()); }); first.unsubscribe(); @@ -1624,6 +1624,12 @@ describe("WorkspaceGitService checkout observation", () => { const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { + expect(watcher.subscribe).toHaveBeenCalledWith( + GIT_DIR, + expect.any(Function), + expect.any(Object), + ); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); }); @@ -1642,14 +1648,17 @@ describe("WorkspaceGitService checkout observation", () => { [60_000, 6], ] as const) { await vi.advanceTimersByTimeAsync(elapsedMs); - expect(pollCount()).toBe(expectedPolls); + await vi.waitFor(() => { + expect(pollCount()).toBe(expectedPolls); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + }); } isDirty = true; await vi.advanceTimersByTimeAsync(60_000); - expect(pollCount()).toBe(7); + await vi.waitFor(() => expect(pollCount()).toBe(7)); await vi.advanceTimersByTimeAsync(5_000); - expect(pollCount()).toBe(8); + await vi.waitFor(() => expect(pollCount()).toBe(8)); subscription.unsubscribe(); service.dispose(); @@ -1860,8 +1869,10 @@ describe("WorkspaceGitService checkout observation", () => { expect(watcher.subscribe).toHaveBeenCalledTimes(1); }); await vi.advanceTimersByTimeAsync(35_000); - expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0); - expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0); + await vi.waitFor(() => { + expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0); + expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0); + }); expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(1); openedSubscription.resolve({ diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 9a3599740c..1b43026c7d 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -991,7 +991,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await vi.advanceTimersByTimeAsync(60_000); await flushPromises(); - expect(subscribe).toHaveBeenCalledTimes(2); + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2)); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); subscription.unsubscribe(); @@ -2221,7 +2221,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getPullRequestStatus).toHaveBeenCalledTimes(2); - service.dispose(); + await service.dispose(); }); test("onWorkspaceStateMayHaveChanged preserves includeForge when a file watcher fires within the debounce window", async () => { diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index dcd2ead214..b5672846bd 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -42,18 +42,13 @@ import { type ForgeResolver, } from "../services/forge-resolver.js"; import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; -import { - createRealpathAwarePathMatcher, - getRealpathAwareRelativePath, - isPathInsideRoot, - isRealpathInsideRoot, - looksLikeDefiniteWindowsPath, -} from "../utils/path.js"; +import { isPathInsideRoot, looksLikeDefiniteWindowsPath } from "../utils/path.js"; import { createRunGitCommand, runGitCommand, type RunGitCommand, } from "../utils/run-git-command.js"; +import { GitObservationPaths } from "./git-observation-paths.js"; import { branchNameFromRef } from "../utils/worktree-metadata.js"; import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js"; import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; @@ -417,6 +412,7 @@ class WorkspaceGitWatcherSubscriptionTimeoutError extends Error { } interface WorkspaceGitTarget { + gitDirPaths: GitObservationPaths | null; cwd: string; listeners: Set; workingTreeWatchTarget: WorkingTreeWatchTarget | null; @@ -443,6 +439,7 @@ interface WorkspaceGitTarget { } interface RepoGitTarget { + paths: GitObservationPaths; repoGitRoot: string; cwd: string; workspaceKeys: Set; @@ -470,6 +467,8 @@ interface RepoMetadataWorkspaceRefresh { } interface WorkingTreeWatchTarget { + paths: GitObservationPaths; + acceptsEvent: (candidate: string) => boolean; cwd: string; watchPath: string; repoRoot: string | null; @@ -815,11 +814,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { cwd: normalizedCwd, compare: normalizedOptions, ...readOptions, - load: () => - this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, - }), + load: (signal) => + this.deps.getCheckoutDiff( + normalizedCwd, + normalizedOptions, + { + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + }, + signal, + ), }); } @@ -1165,6 +1169,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private async resolveWorkingTreeWatchTarget(cwd: string): Promise { const repoRoot = await this.resolveCheckoutWatchRoot(cwd); + const cwdPaths = repoRoot ? await GitObservationPaths.create(cwd) : null; + this.assertNotDisposed(); const targetCwd = repoRoot ?? cwd; const existingTarget = this.workingTreeWatchTargets.get(targetCwd); if (existingTarget) { @@ -1179,7 +1185,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return target; } - const watchPath = repoRoot && createRealpathAwarePathMatcher(repoRoot)(cwd) ? cwd : targetCwd; + const watchPath = repoRoot && cwdPaths?.matches(repoRoot) ? cwd : targetCwd; const setup = this.createWorkingTreeWatchTarget(targetCwd, watchPath, repoRoot).finally(() => { if (this.workingTreeWatchSetups.get(targetCwd) === setup) { this.workingTreeWatchSetups.delete(targetCwd); @@ -1198,6 +1204,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private createWorkspaceTarget(cwd: string): WorkspaceGitTarget { const target: WorkspaceGitTarget = { + gitDirPaths: null, cwd, listeners: new Set(), workingTreeWatchTarget: null, @@ -1299,6 +1306,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } await this.promoteWorkingTreeWatchTarget(workingTreeTarget, facts.worktreeRoot); const gitDir = facts.absoluteGitDir; + const gitDirPaths = target.gitDirPaths?.matches(gitDir) + ? target.gitDirPaths + : await GitObservationPaths.create(gitDir); + if (!this.isActiveObservedWorkspaceTarget(target)) return; + target.gitDirPaths = gitDirPaths; const repoGitRoot = facts.gitCommonDir ?? (await this.resolveWorkspaceGitRefsRoot(gitDir)); if (!this.isActiveObservedWorkspaceTarget(target)) { return; @@ -1331,7 +1343,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const promise = this.deps .getCheckoutSnapshotFacts(target.cwd, context) .then((facts) => { - target.latestFacts = facts; + if (!target.closed) target.latestFacts = facts; return facts; }) .finally(() => { @@ -1359,7 +1371,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const ignoredDirectories = repoRoot ? (await this.loadIgnoredDirs(watchPath)).ignored : new Set(); + const paths = await GitObservationPaths.create(watchPath); + this.assertNotDisposed(); const target: WorkingTreeWatchTarget = { + paths, + acceptsEvent: paths.createEventFilter([join(watchPath, ".git"), ...ignoredDirectories]), cwd, watchPath, repoRoot, @@ -1523,15 +1539,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (watcherErrored) { return; } + events = this.relevantWorkingTreeEvents(target, events); + if (events.length === 0) return; const discovered = this.noteWorkingTreeDirectories(target, events); if (events.some((event) => basename(event.path) === ".gitignore")) { void this.refreshWorkingTreeIgnoredDirectories(target); } else if (discovered) { this.scheduleWorkingTreeIgnoreRefresh(target); } - if (!this.hasRelevantWorkingTreeEvent(target, events)) { - return; - } this.notifyWorkingTreeChanged(target, "working-tree-watch"); }, { ignore }, @@ -1763,20 +1778,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } this.removeDeletedKnownDirectories(target, events); const gitDir = join(target.watchPath, ".git"); - // `matchesWatchPath` is realpath-aware because it compares an event-reported - // directory against `target.watchPath` itself — a config-time root that can - // legitimately differ from the watcher's reported form (e.g. a symlinked - // temp dir). It is now gated behind the plain Set lookup below, so it only - // runs for a directory this target has never seen before, not on every - // event. - const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath); + // Event paths have already been mapped into this subscription's established namespace. + const matchesWatchPath = target.paths.matches; // One new directory is enough: the scheduled Git query inventories every // ignored directory that exists by then. Stop before a large event batch // multiplies containment checks across all its new directories. for (const event of events) { const directory = dirname(event.path); // Cheap Set lookup first — the steady-state case where the directory is - // already known must never reach the realpath-aware matcher below. + // already known do not need another root comparison. if (target.knownDirectories.has(directory) || matchesWatchPath(directory)) { continue; } @@ -1955,28 +1965,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.pendingKnownDirectories.clear(); } - private hasRelevantWorkingTreeEvent( + private relevantWorkingTreeEvents( target: WorkingTreeWatchTarget, events: FileChange[], - ): boolean { - const gitDir = join(target.watchPath, ".git"); - const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath); - return events.some((event) => { - // Directory metadata changes at the watch root do not change Git state. - // FSEvents may emit these when every changed descendant was ignored. - if (matchesWatchPath(event.path)) { - return false; + ): FileChange[] { + const relevant: FileChange[] = []; + // Watch backends may report the same path more than once in a batch. + const acceptedPaths = new Map(); + for (const event of events) { + let accepted = acceptedPaths.get(event.path); + if (accepted === undefined) { + accepted = target.acceptsEvent(event.path); + acceptedPaths.set(event.path, accepted); } - if (isRealpathInsideRoot(gitDir, event.path)) { - return false; - } - for (const ignoredDirectory of target.ignoredDirectories) { - if (isRealpathInsideRoot(ignoredDirectory, event.path)) { - return false; - } - } - return true; - }); + if (accepted) relevant.push({ ...event, path: target.paths.toWatchPath(event.path) }); + } + return relevant; } private refreshWorkingTreeIgnoredDirectories(target: WorkingTreeWatchTarget): Promise { @@ -2031,6 +2035,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } if (!this.haveSamePaths(target.ignoredDirectories, ignoredDirectories)) { target.ignoredDirectories = ignoredDirectories; + target.acceptsEvent = target.paths.createEventFilter(this.workingTreeIgnoreList(target)); this.pruneKnownDirectories(target); } if (target.fallbackPolling) { @@ -2152,7 +2157,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } + const paths = await GitObservationPaths.create(repoGitRoot); + if (!this.isActiveObservedWorkspaceTarget(workspaceTarget)) return; + const establishedTarget = this.repoTargets.get(repoGitRoot); + if (establishedTarget) { + establishedTarget.workspaceKeys.add(workspaceTarget.cwd); + return; + } const repoTarget: RepoGitTarget = { + paths, repoGitRoot, cwd: workspaceTarget.cwd, workspaceKeys: new Set([workspaceTarget.cwd]), @@ -2205,10 +2218,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target: RepoGitTarget, options?: { replaceFallback?: boolean }, ): Promise { + const paths = target.paths; const ignore = getPrunedGitMetadataPaths("common").map((path) => join(target.repoGitRoot, path), ); - const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot); + const matchesRepoGitRoot = paths.matches; const canary = this.deps.createWatcherLivenessCanary(target.repoGitRoot); let openedSubscription: FileObserverSubscription | null = null; let watcherErrored = false; @@ -2223,7 +2237,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const subscription = await this.subscribeWithDeadline( target.repoGitRoot, (error, events) => { - const liveEvents = canary.filterEvents(events); + const liveEvents = canary.filterEvents( + events.map((event) => ({ ...event, path: paths.toWatchPath(event.path) })), + ); if (error) { if (watcherErrored) { return; @@ -2249,7 +2265,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const relevantEvents = liveEvents.filter( (event) => !matchesRepoGitRoot(event.path) && - ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)), + ignore.every((ignoredPath) => !isPathInsideRoot(ignoredPath, event.path)), ); if (relevantEvents.length > 0) { const immediateEvents = target.fetchInFlight @@ -2362,7 +2378,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { events: FileChange[], ): Map | null { const refreshes = new Map(); - const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot); + const matchesRepoGitRoot = target.paths.matches; for (const event of events) { if (!this.routeRepoMetadataEvent(target, event, matchesRepoGitRoot, refreshes)) return null; @@ -2377,10 +2393,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ): void { const workspaceKeys = new Set(); for (const event of events) { - const commonRelativePath = getRealpathAwareRelativePath( - target.repoGitRoot, - event.path, - )?.replaceAll("\\", "/"); + const commonRelativePath = target.paths.relative(event.path)?.replaceAll("\\", "/"); if (commonRelativePath === "config" || commonRelativePath === "info/exclude") { for (const workspaceKey of target.workspaceKeys) workspaceKeys.add(workspaceKey); continue; @@ -2390,8 +2403,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if ( facts?.isGit && facts.absoluteGitDir && - getRealpathAwareRelativePath(facts.absoluteGitDir, event.path)?.replaceAll("\\", "/") === - "config.worktree" + this.workspaceTargets + .get(workspaceKey) + ?.gitDirPaths?.relative(event.path) + ?.replaceAll("\\", "/") === "config.worktree" ) { workspaceKeys.add(workspaceKey); } @@ -2407,7 +2422,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } private isFetchRemoteMetadataEvent(target: RepoGitTarget, event: FileChange): boolean { - const relativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path); + const relativePath = target.paths.relative(event.path); const effect = classifyGitMetadataPath("common", relativePath ?? ""); return ( (effect.kind === "ref" && effect.namespace === "remote") || @@ -2419,12 +2434,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private routeRepoMetadataEvent( target: RepoGitTarget, event: FileChange, - matchesRepoGitRoot: ReturnType, + matchesRepoGitRoot: (candidate: string) => boolean, refreshes: Map, ): boolean { if (this.routePrivateGitDirEvent(target, event, matchesRepoGitRoot, refreshes)) return true; - const commonRelativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path); + const commonRelativePath = target.paths.relative(event.path); const effect = classifyGitMetadataPath("common", commonRelativePath ?? ""); switch (effect.kind) { case "ignore": @@ -2476,7 +2491,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private routePrivateGitDirEvent( target: RepoGitTarget, event: FileChange, - matchesRepoGitRoot: ReturnType, + matchesRepoGitRoot: (candidate: string) => boolean, refreshes: Map, ): boolean { let matched = false; @@ -2485,8 +2500,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (!facts?.isGit || !facts.absoluteGitDir || matchesRepoGitRoot(facts.absoluteGitDir)) { continue; } - const relativePath = getRealpathAwareRelativePath(facts.absoluteGitDir, event.path); - if (relativePath === null) continue; + const relativePath = this.workspaceTargets + .get(workspaceKey) + ?.gitDirPaths?.relative(event.path); + if (relativePath == null) continue; matched = true; const effect = classifyGitMetadataPath("worktree", relativePath); if (effect.kind === "owner") { @@ -2500,7 +2517,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private routeMainCheckoutMetadata( target: RepoGitTarget, - matchesRepoGitRoot: ReturnType, + matchesRepoGitRoot: (candidate: string) => boolean, refreshBase: boolean, refreshes: Map, ): void { diff --git a/packages/server/src/server/workspace-reconciliation-observation.test.ts b/packages/server/src/server/workspace-reconciliation-observation.test.ts index 8977641bab..44bb9de08e 100644 --- a/packages/server/src/server/workspace-reconciliation-observation.test.ts +++ b/packages/server/src/server/workspace-reconciliation-observation.test.ts @@ -495,6 +495,10 @@ describe("observed workspace placement", () => { expect((await observed.placement("workspace-one"))?.archivedAt).toEqual(expect.any(String)); expect(observed.workspaceBatches).toEqual([["workspace-one"]]); + const archived = await observed.placement("workspace-one"); + await observed.advanceBy(RESCAN_INTERVAL_MS); + expect(await observed.placement("workspace-one")).toEqual(archived); + expect(observed.workspaceBatches).toEqual([["workspace-one"]]); observed.dispose(); }); diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 52c7d41065..e1a8860e3b 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -31,6 +31,14 @@ function canonicalLocalProjectKey(rootPath: string): string { } function createTestRegistries() { + let archiveGate: { + started: ReturnType; + release: ReturnType; + } | null = null; + const holdNextArchive = () => { + archiveGate = { started: deferred(), release: deferred() }; + return archiveGate; + }; const projects = new Map(); const workspaces = new Map(); @@ -55,6 +63,13 @@ function createTestRegistries() { projects.set(record.projectId, record); return record; }, + update: async (id, updater) => { + const current = projects.get(id); + if (!current) return null; + const updated = updater(current); + projects.set(id, updated); + return updated; + }, upsert: async (record: PersistedProjectRecord) => { projects.set(record.projectId, record); }, @@ -85,17 +100,23 @@ function createTestRegistries() { workspaces.set(record.workspaceId, record); }, archive: async (id: string, archivedAt: string) => { - const existing = workspaces.get(id); - if (existing) { - workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt }); + const gate = archiveGate; + archiveGate = null; + if (gate) { + gate.started.resolve(); + await gate.release.promise; } + const existing = workspaces.get(id); + if (!existing || existing.archivedAt) return false; + workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt }); + return true; }, remove: async (id: string) => { workspaces.delete(id); }, }; - return { projects, workspaces, projectRegistry, workspaceRegistry }; + return { projects, workspaces, projectRegistry, workspaceRegistry, holdNextArchive }; } function createTestLogger() { @@ -117,6 +138,7 @@ interface CapturedLogRecord { function createCapturingLogger() { const infoRecords: CapturedLogRecord[] = []; + const warnRecords: CapturedLogRecord[] = []; const logger = { child: () => logger, trace: () => undefined, @@ -124,10 +146,12 @@ function createCapturingLogger() { info: (payload: unknown, message?: string) => { infoRecords.push({ payload, message: message ?? "" }); }, - warn: () => undefined, + warn: (payload: unknown, message?: string) => { + warnRecords.push({ payload, message: message ?? "" }); + }, error: () => undefined, }; - return { logger: logger as unknown as pino.Logger, infoRecords }; + return { logger: logger as unknown as pino.Logger, infoRecords, warnRecords }; } function createWorkspaceGitServiceStub( @@ -241,6 +265,419 @@ describe("WorkspaceReconciliationService", () => { tempDirs.length = 0; }); + test("does not emit a duplicate archive when user archival commits before reconciliation's archive", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-archive-commit-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry, holdNextArchive } = + createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "w1", + createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd: path.join(rootPath, "missing"), + kind: "directory", + displayName: "workspace", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const callbacks: string[] = []; + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + onWorkspaceArchived: (id) => { + callbacks.push(id); + }, + }); + const gate = holdNextArchive(); + try { + const scan = service.runOnce(); + await gate.started.promise; + expect(await workspaceRegistry.archive("w1", timestamp)).toBe(true); + gate.release.resolve(); + expect((await scan).changesApplied).toEqual([]); + expect(callbacks).toEqual([]); + expect(workspaces.get("w1")?.archivedAt).toBe(timestamp); + } finally { + gate.release.resolve(); + service.dispose(); + } + }); + + test("preserves paths while two inspections saturate capacity, logs once, and recovers after they drain", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-saturated-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + const { logger, warnRecords, infoRecords } = createCapturingLogger(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const ids = ["hung1", "hung2", "healthy"]; + for (const workspaceId of ids) { + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "p1", + cwd: path.join(rootPath, workspaceId), + kind: "directory", + displayName: workspaceId, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + } + const release = deferred(); + const calls: string[] = []; + let recovered = false; + let ticks = 0; + const probe = setInterval(() => ticks++, 1); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger, + inspectionTimeoutMs: 30, + inspectDirectory: async (targetPath) => { + calls.push(targetPath); + if (!recovered && path.basename(targetPath).startsWith("hung")) await release.promise; + return "directory"; + }, + }); + try { + const result = await service.runOnce(); + expect(ticks).toBeGreaterThan(0); + expect(result.durationMs).toBeLessThan(1000); + expect(result.changesApplied).toEqual([]); + expect(calls).toEqual(ids.slice(0, 2).map((id) => path.join(rootPath, id))); + expect([...workspaces.values()].map((workspace) => workspace.archivedAt)).toEqual([ + null, + null, + null, + ]); + expect(warnRecords.filter((record) => record.message.includes("saturated"))).toEqual([ + { + message: "Workspace directory inspection saturated; preserving uninspected workspaces", + payload: { paths: ids.slice(0, 2).map((id) => path.join(rootPath, id)) }, + }, + ]); + recovered = true; + release.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + expect(infoRecords).toEqual([ + { + message: + "Timed-out workspace directory inspections drained; reconciliation capacity restored", + payload: {}, + }, + ]); + calls.length = 0; + await service.runOnce(); + expect(calls).toEqual([...ids.map((id) => path.join(rootPath, id)), rootPath]); + expect(warnRecords.filter((record) => record.message.includes("saturated"))).toHaveLength(1); + } finally { + release.resolve(); + clearInterval(probe); + service.dispose(); + } + }); + + test("inspects every healthy path when one hung inspection times out during a longer scan", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-long-hung-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const healthyIds = Array.from({ length: 12 }, (_, i) => `healthy${i}`); + for (const workspaceId of ["hung", ...healthyIds]) { + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "p1", + cwd: path.join(rootPath, workspaceId), + kind: "directory", + displayName: workspaceId, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + } + const calls: string[] = []; + let outstanding = 0; + let maximumOutstanding = 0; + const { logger, warnRecords } = createCapturingLogger(); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger, + inspectionTimeoutMs: 100, + inspectDirectory: async (targetPath) => { + calls.push(targetPath); + maximumOutstanding = Math.max(maximumOutstanding, ++outstanding); + if (path.basename(targetPath) === "hung") return new Promise(() => {}); + await new Promise((resolve) => setTimeout(resolve, 20)); + outstanding--; + return "directory"; + }, + }); + try { + const result = await service.runOnce(); + expect(result.durationMs).toBeGreaterThan(100); + expect(calls).toEqual( + ["hung", ...healthyIds].map((id) => path.join(rootPath, id)).concat(rootPath), + ); + expect(maximumOutstanding).toBe(2); + expect(outstanding).toBe(1); + expect(workspaces.get("hung")?.archivedAt).toBeNull(); + expect(result.changesApplied).toEqual([]); + expect(warnRecords.filter((record) => record.message.includes("saturated"))).toEqual([]); + } finally { + service.dispose(); + } + }); + + test("times out a hung inspection without archiving it or blocking healthy paths, and retains its IO slot", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-hung-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + for (const workspaceId of ["hung", "healthy", "healthy2", "healthy3"]) { + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "p1", + cwd: path.join(rootPath, workspaceId), + kind: "directory", + displayName: workspaceId, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + } + const calls: string[] = []; + let outstanding = 0; + let maximumOutstanding = 0; + const healthyInspected = deferred(); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + inspectionTimeoutMs: 30, + inspectDirectory: async (targetPath) => { + calls.push(targetPath); + maximumOutstanding = Math.max(maximumOutstanding, ++outstanding); + if (path.basename(targetPath) === "hung") return new Promise(() => {}); + healthyInspected.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + outstanding--; + return "directory"; + }, + }); + try { + const scan = service.runOnce(); + await Promise.race([ + healthyInspected.promise, + scan.then(() => { + throw new Error("Reconciliation did not use its directory inspection capability"); + }), + ]); + expect(outstanding).toBeLessThanOrEqual(2); + expect(workspaces.get("hung")?.archivedAt).toBeNull(); + const result = await scan; + expect(result.changesApplied).toEqual([]); + await service.runOnce(); + expect(calls.filter((cwd) => path.basename(cwd) === "hung")).toHaveLength(1); + expect(calls.filter((cwd) => path.basename(cwd) === "healthy3")).toHaveLength(2); + expect(maximumOutstanding).toBe(2); + expect([...workspaces.values()].map((workspace) => workspace.archivedAt)).toEqual([ + null, + null, + null, + null, + ]); + } finally { + service.dispose(); + } + }); + + test.each(["missing", "directory"] as const)( + "does not process or archive a workspace archived during %s inspection", + async (state) => { + const rootPath = realpathSync( + mkdtempSync(path.join(tmpdir(), "reconcile-inspection-archive-")), + ); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const cwd = path.join(rootPath, "missing"); + workspaces.set( + "w1", + createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd, + kind: "directory", + displayName: "workspace", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const started = deferred(); + const release = deferred(); + const archivedCallbacks: string[] = []; + const checkouts = new TestCheckouts(); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + workspaceGitService: checkouts, + onWorkspaceArchived: (id) => { + archivedCallbacks.push(id); + }, + inspectDirectory: async (targetPath) => { + if (targetPath === cwd) { + started.resolve(); + await release.promise; + return state; + } + return "directory"; + }, + }); + try { + const scan = service.runOnce(); + await Promise.race([ + started.promise, + scan.then(() => { + throw new Error("Reconciliation did not use its directory inspection capability"); + }), + ]); + await workspaceRegistry.archive("w1", "2026-01-01T00:00:00.000Z"); + const archived = workspaces.get("w1"); + release.resolve(); + const result = await scan; + expect(workspaces.get("w1")).toEqual(archived); + expect(archivedCallbacks).toEqual([]); + expect(checkouts.reads).toEqual([rootPath]); + expect(result.changesApplied).toEqual([]); + } finally { + release.resolve(); + service.dispose(); + } + }, + ); + + test("keeps the event loop responsive while inspecting 20000 real workspace paths", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-many-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + for (let i = 0; i < 20000; i++) { + const workspaceId = `w${i}`; + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "p1", + cwd: path.join(rootPath, workspaceId), + kind: "directory", + displayName: workspaceId, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + } + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + }); + let last = performance.now(); + let longestGap = 0; + const probe = setInterval(() => { + const now = performance.now(); + longestGap = Math.max(longestGap, now - last); + last = now; + }, 1); + let result; + try { + result = await service.runOnce(); + await new Promise((resolve) => setTimeout(resolve, 1)); + } finally { + clearInterval(probe); + service.dispose(); + } + expect(longestGap).toBeLessThan(20); + expect(result.changesApplied).toHaveLength(20000); + expect([...workspaces.values()].every((workspace) => workspace.archivedAt !== null)).toBe(true); + }, 30000); + test("preserves workspace archival that lands during boot reconciliation", async () => { const workspaceRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-archive-race-"))); tempDirs.push(workspaceRoot); diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index eb79dbacd2..5333da8f83 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -1,4 +1,6 @@ -import { statSync, watch as watchPath } from "node:fs"; +import { watch as watchPath } from "node:fs"; +import { stat } from "node:fs/promises"; +import { setImmediate as yieldToLoop } from "node:timers/promises"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import type pino from "pino"; import type { @@ -19,6 +21,8 @@ import { deriveProjectKey } from "./project-key.js"; const DEFAULT_RESCAN_INTERVAL_MS = 5 * 60_000; const DEFAULT_DEBOUNCE_MS = 100; +const INSPECTION_CONCURRENCY = 2; +const DEFAULT_INSPECTION_TIMEOUT_MS = 5000; export type ProjectUpdate = | { kind: "upsert"; project: PersistedProjectRecord } @@ -95,6 +99,8 @@ export interface WorkspaceReconciliationServiceOptions { clock?: ReconciliationClock; rescanIntervalMs?: number; debounceMs?: number; + inspectDirectory?: (targetPath: string) => Promise; + inspectionTimeoutMs?: number; } interface ProjectReconciliationInput { @@ -110,9 +116,17 @@ interface CachedCheckoutRead { checkout: Promise; } -type DirectoryState = "directory" | "missing" | "unreadable"; +export type DirectoryState = "directory" | "missing" | "unreadable"; export class WorkspaceReconciliationService { + // Timed-out fs operations retain their slot until the OS returns. Never use + // the deadline to admit more work into libuv's shared threadpool. + private readonly timedOutPaths = new Set(); + private inspectionSaturated = false; + private readonly inspectionWaiters = new Set<() => void>(); + private readonly pendingInspections = new Map>(); + private readonly directoryInspection: (targetPath: string) => Promise; + private readonly inspectionTimeoutMs: number; private readonly serverId: string | undefined; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; @@ -136,6 +150,10 @@ export class WorkspaceReconciliationService { private reconcileQueuedMode: "metadata" | "full" | null = null; constructor(options: WorkspaceReconciliationServiceOptions) { + this.directoryInspection = + options.inspectDirectory ?? + (async (targetPath) => ((await stat(targetPath)).isDirectory() ? "directory" : "missing")); + this.inspectionTimeoutMs = options.inspectionTimeoutMs ?? DEFAULT_INSPECTION_TIMEOUT_MS; this.serverId = options.serverId; this.projectRegistry = options.projectRegistry; this.workspaceRegistry = options.workspaceRegistry; @@ -180,6 +198,7 @@ export class WorkspaceReconciliationService { dispose(): void { this.disposed = true; + this.wakeInspectionWaiters(); this.unsubscribeRegistry?.(); this.unsubscribeRegistry = null; if (this.rescanTimer) this.clock.clearInterval(this.rescanTimer); @@ -197,16 +216,23 @@ export class WorkspaceReconciliationService { this.workspaceRegistry.list(), ]); const workspacesByProject = new Map(); - for (const workspace of workspaces) { - if (workspace.archivedAt || this.inspectDirectory(workspace.cwd) !== "directory") continue; + const workspaceStates = await mapBounded( + workspaces.filter((w) => !w.archivedAt), + async (workspace) => ({ workspace, state: await this.inspectDirectory(workspace.cwd) }), + ); + for (const { workspace, state } of workspaceStates) { + if (state !== "directory") continue; + if ((await this.workspaceRegistry.get(workspace.workspaceId))?.archivedAt !== null) continue; const siblings = workspacesByProject.get(workspace.projectId) ?? []; siblings.push(workspace); workspacesByProject.set(workspace.projectId, siblings); } + const projectStates = await mapBounded( + projects.filter((p) => !p.archivedAt), + async (project) => ({ project, state: await this.inspectDirectory(project.rootPath) }), + ); await this.reconcileGitMetadataForProjects( - projects.filter( - (project) => !project.archivedAt && this.inspectDirectory(project.rootPath) === "directory", - ), + projectStates.filter(({ state }) => state === "directory").map(({ project }) => project), workspacesByProject, changes, ); @@ -222,23 +248,30 @@ export class WorkspaceReconciliationService { const allWorkspaces = await this.workspaceRegistry.list(); const activeProjects = allProjects.filter((p) => !p.archivedAt); + const activeProjectsById = new Map( + activeProjects.map((project) => [project.projectId, project]), + ); const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt); - const workspaceDirectoryStates = activeWorkspaces.map((workspace) => ({ + const workspaceDirectoryStates = await mapBounded(activeWorkspaces, async (workspace) => ({ workspace, - state: this.inspectDirectory(workspace.cwd), + state: await this.inspectDirectory(workspace.cwd), + })); + // Read project roots after workspaces: losing a volume can withhold an + // archive, never turn a workspace's absence into proof of deletion. + const projectStates = await mapBounded(activeProjects, async (project) => ({ + project, + state: await this.inspectDirectory(project.rootPath), })); - // Project roots are read after the workspace directories, so a volume that - // goes away mid-pass leaves its project unreachable rather than its workspaces - // alone. The skew can only withhold an archive, never produce one. const reachableProjectIds = new Set( - activeProjects - .filter((project) => this.inspectDirectory(project.rootPath) === "directory") - .map((project) => project.projectId), + projectStates + .filter(({ state }) => state === "directory") + .map(({ project }) => project.projectId), ); const workspacesByProject = new Map(); for (const { workspace, state } of workspaceDirectoryStates) { if (state !== "directory") continue; + if ((await this.workspaceRegistry.get(workspace.workspaceId))?.archivedAt !== null) continue; const list = workspacesByProject.get(workspace.projectId) ?? []; list.push(workspace); workspacesByProject.set(workspace.projectId, list); @@ -255,26 +288,29 @@ export class WorkspaceReconciliationService { state === "missing" && reachableProjectIds.has(workspace.projectId), ) .map(({ workspace }) => workspace); - await Promise.all( - missingWorkspaces.map(async (workspace) => { - const timestamp = new Date().toISOString(); - await this.workspaceRegistry.archive(workspace.workspaceId, timestamp); - await this.onWorkspaceArchived?.(workspace.workspaceId); - changes.push({ - kind: "workspace_archived", - workspaceId: workspace.workspaceId, - directory: workspace.cwd, - reason: "directory_missing", - }); - - // Update the in-memory list for the project orphan check below - const siblings = workspacesByProject.get(workspace.projectId); - if (siblings) { - const updated = siblings.filter((w) => w.workspaceId !== workspace.workspaceId); - workspacesByProject.set(workspace.projectId, updated); - } - }), - ); + await mapBounded(missingWorkspaces, async (workspace) => { + const current = await this.workspaceRegistry.get(workspace.workspaceId); + const project = await this.projectRegistry.get(workspace.projectId); + if ( + !current || + current.archivedAt || + current.cwd !== workspace.cwd || + current.projectId !== workspace.projectId || + !project || + project.archivedAt || + project.rootPath !== activeProjectsById.get(project.projectId)?.rootPath + ) + return; + const timestamp = new Date().toISOString(); + if (!(await this.workspaceRegistry.archive(workspace.workspaceId, timestamp))) return; + await this.onWorkspaceArchived?.(workspace.workspaceId); + changes.push({ + kind: "workspace_archived", + workspaceId: workspace.workspaceId, + directory: workspace.cwd, + reason: "directory_missing", + }); + }); // 2. Reconcile mutable git metadata without changing identity or membership. // Projects persist until explicitly removed, even when they currently have @@ -351,13 +387,17 @@ export class WorkspaceReconciliationService { } private async reconcileProject(input: ProjectReconciliationInput): Promise { - const { project, siblings, currentGit, readCheckout, changes } = input; + const { siblings, currentGit, readCheckout, changes } = input; const workspaceCheckouts = await Promise.all( - siblings.map(async (workspace) => ({ - workspace, - checkout: await readCheckout(workspace.cwd), - })), + siblings.map(async (workspace) => { + const current = await this.workspaceRegistry.get(workspace.workspaceId); + if (!current || current.cwd !== workspace.cwd || current.projectId !== workspace.projectId) + return null; + return { workspace: current, checkout: await readCheckout(current.cwd) }; + }), ); + const project = await this.projectRegistry.get(input.project.projectId); + if (!project || project.archivedAt || project.rootPath !== input.project.rootPath) return; const projectUpdates: Partial> = {}; const mappedKind = deriveProjectKind(currentGit); const projectKey = deriveProjectKey({ @@ -377,40 +417,47 @@ export class WorkspaceReconciliationService { if (Object.keys(projectUpdates).length > 0) { const timestamp = new Date().toISOString(); - await this.projectRegistry.upsert({ - ...project, - ...projectUpdates, - updatedAt: timestamp, - }); - changes.push({ - kind: "project_updated", - projectId: project.projectId, - directory: project.rootPath, - fields: projectUpdates, + let applied = false; + await this.projectRegistry.update(project.projectId, (current) => { + if (current.archivedAt || current.rootPath !== project.rootPath) return current; + applied = true; + return { ...current, ...projectUpdates, updatedAt: timestamp }; }); + if (applied) + changes.push({ + kind: "project_updated", + projectId: project.projectId, + directory: project.rootPath, + fields: projectUpdates, + }); } await Promise.all( - workspaceCheckouts.map(async ({ workspace, checkout: wsGit }) => { + workspaceCheckouts.map(async (entry) => { + if (!entry) return; + const { workspace, checkout: wsGit } = entry; const timestamp = new Date().toISOString(); - const update = reconcileWorkspacePlacement({ - workspace, - checkout: wsGit, - updatedAt: timestamp, + if (!reconcileWorkspacePlacement({ workspace, checkout: wsGit, updatedAt: timestamp })) + return; + let fields: Partial | null = null; + await this.workspaceRegistry.update(workspace.workspaceId, (current) => { + if (current.cwd !== workspace.cwd || current.projectId !== workspace.projectId) + return current; + const update = reconcileWorkspacePlacement({ + workspace: current, + checkout: wsGit, + updatedAt: timestamp, + }); + if (!update) return current; + fields = update.fields; + return { ...current, ...fields, updatedAt: timestamp }; }); - if (!update) return; - - const updated = await this.workspaceRegistry.update(workspace.workspaceId, (current) => ({ - ...current, - ...update.fields, - updatedAt: timestamp, - })); - if (!updated) return; + if (!fields) return; changes.push({ kind: "workspace_updated", workspaceId: workspace.workspaceId, directory: workspace.cwd, - fields: update.fields, + fields, }); }), ); @@ -537,16 +584,74 @@ export class WorkspaceReconciliationService { return this.workspaceGitService.getCheckout(cwd); } - private inspectDirectory(targetPath: string): DirectoryState { + private wakeInspectionWaiters(): void { + for (const wake of this.inspectionWaiters) wake(); + this.inspectionWaiters.clear(); + } + + private async inspectDirectory(targetPath: string): Promise { + // A healthy in-flight inspection owns capacity temporarily. Wait for it; + // only unresolved operations that have all timed out justify skipping paths. + while ( + !this.disposed && + !this.pendingInspections.has(targetPath) && + this.pendingInspections.size >= INSPECTION_CONCURRENCY + ) { + if (this.timedOutPaths.size === INSPECTION_CONCURRENCY) return "unreadable"; + await new Promise((resolve) => this.inspectionWaiters.add(resolve)); + } + if (this.disposed) return "unreadable"; + let pending = this.pendingInspections.get(targetPath); + if (!pending) { + pending = Promise.resolve() + .then(() => this.directoryInspection(targetPath)) + .catch((error) => { + if (isMissingPathError(error)) return "missing" as const; + this.logger.warn( + { err: error, targetPath }, + "Skipped workspace reconciliation after directory inspection failed", + ); + return "unreadable" as const; + }) + .finally(() => { + this.pendingInspections.delete(targetPath); + this.timedOutPaths.delete(targetPath); + this.wakeInspectionWaiters(); + if (this.inspectionSaturated && this.timedOutPaths.size === 0) { + this.inspectionSaturated = false; + this.logger.info( + {}, + "Timed-out workspace directory inspections drained; reconciliation capacity restored", + ); + } + }); + this.pendingInspections.set(targetPath, pending); + } + let timer: ReturnType | undefined; try { - return statSync(targetPath).isDirectory() ? "directory" : "missing"; - } catch (error) { - if (isMissingPathError(error)) return "missing"; - this.logger.warn( - { err: error, targetPath }, - "Skipped workspace reconciliation after directory inspection failed", - ); - return "unreadable"; + return await Promise.race([ + pending, + new Promise((resolve) => { + timer = setTimeout(() => { + this.timedOutPaths.add(targetPath); + this.wakeInspectionWaiters(); + if (!this.inspectionSaturated && this.timedOutPaths.size === INSPECTION_CONCURRENCY) { + this.inspectionSaturated = true; + this.logger.warn( + { paths: [...this.timedOutPaths] }, + "Workspace directory inspection saturated; preserving uninspected workspaces", + ); + } + this.logger.warn( + { targetPath }, + "Skipped workspace reconciliation after directory inspection timed out", + ); + resolve("unreadable"); + }, this.inspectionTimeoutMs); + }), + ]); + } finally { + clearTimeout(timer); } } } @@ -555,3 +660,21 @@ function isMissingPathError(error: unknown): boolean { if (typeof error !== "object" || error === null || !("code" in error)) return false; return error.code === "ENOENT" || error.code === "ENOTDIR"; } + +// Two workers bound admission; yielding also bounds passes whose adapters settle +// immediately (including unavailable paths after both IO slots time out). +async function mapBounded(items: T[], visit: (item: T) => Promise): Promise { + const results: R[] = []; + let next = 0; + await Promise.all( + Array.from({ length: Math.min(INSPECTION_CONCURRENCY, items.length) }, async () => { + let count = 0; + while (next < items.length) { + const index = next++; + results[index] = await visit(items[index]!); + if (++count % 32 === 0) await yieldToLoop(); + } + }), + ); + return results; +} diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index cf192a44b6..817853dad9 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -74,7 +74,7 @@ export async function bootstrapWorkspaceRegistries(options: { continue; } try { - pinPaseoWorktreeBranchIdentityIfMissing(workspace.worktreeRoot, workspace.branch); + await pinPaseoWorktreeBranchIdentityIfMissing(workspace.worktreeRoot, workspace.branch); } catch (error) { options.logger.warn( { err: error, workspaceId: workspace.workspaceId }, diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 6c27027edc..15031e7b69 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -116,6 +116,35 @@ describe("workspace registries", () => { expect(await projectRegistry.list()).toEqual([]); }); + test("writes once per mutation and skips missing-record mutations", async () => { + let writes = 0; + const registry = new FileBackedProjectRegistry( + path.join(tmpDir, "projects", "write-count.json"), + logger, + { + writeRecords: async (filePath, records) => { + writes++; + await writeJsonFileAtomic(filePath, records); + }, + }, + ); + const project = createPersistedProjectRecord({ + projectId: "counted", + rootPath: tmpDir, + kind: "git", + displayName: "Counted", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + await registry.upsert(project); + expect(writes).toBe(1); + await registry.update("missing", (current) => current); + await registry.remove("missing"); + expect(writes).toBe(1); + await registry.update(project.projectId, (current) => ({ ...current, customName: "Changed" })); + expect(writes).toBe(2); + }); + test("preserves a concurrent project update when archiving", async () => { let pauseNextWrite = false; let releaseWrite!: () => void; @@ -474,7 +503,7 @@ describe("workspace registries", () => { expect(await workspaceRegistry.list()).toEqual([]); }); - test("refreshes workspace archive timestamps when an archive is repeated", async () => { + test("preserves the first workspace archive and reports whether a queued archive applied", async () => { await workspaceRegistry.initialize(); await workspaceRegistry.upsert( createPersistedWorkspaceRecord({ @@ -488,12 +517,24 @@ describe("workspace registries", () => { }), ); - await workspaceRegistry.archive("workspace-one", "2026-03-02T00:00:00.000Z"); - await workspaceRegistry.archive("workspace-one", "2026-03-03T00:00:00.000Z"); - - expect(await workspaceRegistry.get("workspace-one")).toMatchObject({ - archivedAt: "2026-03-03T00:00:00.000Z", - updatedAt: "2026-03-03T00:00:00.000Z", + const events: WorkspaceMutation[] = []; + workspaceRegistry.subscribeToMutations((event) => { + events.push(event); + }); + expect( + await Promise.all([ + workspaceRegistry.archive("workspace-one", "2026-03-02T00:00:00.000Z"), + workspaceRegistry.archive("workspace-one", "2026-03-03T00:00:00.000Z"), + ]), + ).toEqual([true, false]); + expect(events.map((event) => event.kind)).toEqual(["archive"]); + const reloaded = new FileBackedWorkspaceRegistry( + path.join(tmpDir, "projects", "workspaces.json"), + logger, + ); + expect(await reloaded.get("workspace-one")).toMatchObject({ + archivedAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", }); }); diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 0e0fb4e62c..61fcb4f720 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -167,7 +167,7 @@ export interface WorkspaceRegistry { workspaceId: string, archivedAt: string, context?: WorkspaceArchiveContext, - ): Promise; + ): Promise; remove(workspaceId: string): Promise; /** Central lifecycle seam for daemon-global workspace observers. */ subscribeToMutations?( @@ -247,20 +247,6 @@ class FileBackedRegistry { }); } - async archive(id: string, archivedAt: string): Promise { - await this.archiveIfPresent(id, archivedAt); - } - - protected async archiveIfPresent(id: string, archivedAt: string): Promise { - return this.mutateCache((records) => { - const existing = records.get(id); - if (!existing) return null; - const next = this.schema.parse({ ...existing, updatedAt: archivedAt, archivedAt }); - records.set(id, next); - return next; - }); - } - protected async archiveIfActive(id: string, archivedAt: string): Promise { return this.mutateCache((records) => { const existing = records.get(id); @@ -485,7 +471,7 @@ export class FileBackedProjectRegistry return project; } - override async archive(projectId: string, archivedAt: string): Promise { + async archive(projectId: string, archivedAt: string): Promise { const project = await this.archiveIfActive(projectId, archivedAt); if (!project) return; await this.notifyMutation({ kind: "archive", projectId, project }); @@ -565,21 +551,28 @@ export class FileBackedWorkspaceRegistry }); } - override async archive( + async archive( workspaceId: string, archivedAt: string, context?: WorkspaceArchiveContext, - ): Promise { - const workspace = await super.update(workspaceId, (existing) => ({ - ...existing, - updatedAt: archivedAt, - archivedAt, - ...(context?.autoArchivedChangeRequestUrl - ? { autoArchivedChangeRequestUrl: context.autoArchivedChangeRequestUrl } - : {}), - })); - if (!workspace) return; + ): Promise { + const [workspace] = await this.mutateMany((records) => { + const existing = records.get(workspaceId); + if (!existing || existing.archivedAt) return []; + return [ + { + ...existing, + updatedAt: archivedAt, + archivedAt, + ...(context?.autoArchivedChangeRequestUrl + ? { autoArchivedChangeRequestUrl: context.autoArchivedChangeRequestUrl } + : {}), + }, + ]; + }); + if (!workspace) return false; await this.notifyMutation({ kind: "archive", workspaceId, workspace }); + return true; } override async remove(workspaceId: string): Promise { diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 6e138e8ab4..d656738bd4 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -335,7 +335,7 @@ export async function runWorktreeAutoTerminals(options: { if (!options.terminalManager) return; const terminalManager = options.terminalManager; await Promise.all( - getWorktreeTerminalSpecs(options.workspaceCwd).map(async (spec) => { + (await getWorktreeTerminalSpecs(options.workspaceCwd)).map(async (spec) => { try { const terminal = await terminalManager.createTerminal({ cwd: options.workspaceCwd, @@ -545,7 +545,7 @@ export async function spawnWorkspaceScript( logger, onLifecycleChanged, } = options; - const configResult = readPaseoConfig(repoRoot); + const configResult = await readPaseoConfig(repoRoot); if (!configResult.ok) { throw paseoConfigParseError(configResult); } diff --git a/packages/server/src/server/worktree-branch-name-generator.test.ts b/packages/server/src/server/worktree-branch-name-generator.test.ts index 8eb1625265..186dcbc0b1 100644 --- a/packages/server/src/server/worktree-branch-name-generator.test.ts +++ b/packages/server/src/server/worktree-branch-name-generator.test.ts @@ -343,8 +343,8 @@ describe("generateBranchNameFromFirstAgentContext", () => { const repoRoot = createTempDir("paseo-branch-config-"); const worktreeRoot = createTempDir("paseo-branch-worktree-"); mkdirSync(path.join(worktreeRoot, ".git")); - writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" }); - writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, { + await writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" }); + await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, { placeholderBranchName: "dazzling-yak", }); writeConfig(repoRoot, { diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts index 9d02d0a2ec..4ec533d0c3 100644 --- a/packages/server/src/server/worktree-core.posix.test.ts +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -854,7 +854,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { second.worktree.worktreePath, "remote.paseo-pr-14.url", ); - const metadata = readPaseoWorktreeMetadata(second.worktree.worktreePath); + const metadata = await readPaseoWorktreeMetadata(second.worktree.worktreePath); const facts = await getCheckoutSnapshotFacts(second.worktree.worktreePath, { paseoHome }); expect(second.worktree.branchName).toBe("feature/gitlab-mr-1"); @@ -1113,7 +1113,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { createCoreDeps({ github }), ); writeFileSync( - getPaseoWorktreeMetadataPath(second.worktree.worktreePath), + await getPaseoWorktreeMetadataPath(second.worktree.worktreePath), `${JSON.stringify({ version: 1, baseRefName: "main" }, null, 2)}\n`, "utf8", ); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 4ba1010bd3..1cbad931eb 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -554,7 +554,7 @@ export async function runWorktreeSetupInBackground( emitSetupProgress("completed", null); } else { const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath; - const setupCommands = getWorktreeSetupCommands(workspaceCwd); + const setupCommands = await getWorktreeSetupCommands(workspaceCwd); const runtimeEnv = await resolveWorktreeRuntimeEnv({ worktreePath: worktree.worktreePath, branchName: worktree.branchName, diff --git a/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.test.ts b/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.test.ts index aa9e8e01dc..eaa306c185 100644 --- a/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.test.ts +++ b/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.test.ts @@ -80,7 +80,7 @@ describe("applyTerminalAgentHookSetting", () => { expect(existsSync(paths.opencode)).toBe(true); }); - it("installs on enable and removes hooks on disable when toggled live", () => { + it("installs on enable and removes hooks on disable when toggled live", async () => { const root = createTempDir("paseo-hook-setting-"); const store = createStore(createTempDir("paseo-hook-setting-home-"), false); const paths = hookPaths(root); @@ -88,11 +88,11 @@ describe("applyTerminalAgentHookSetting", () => { applyTerminalAgentHookSetting({ store, install: createInstallEnv(root) }); expect(existsSync(paths.opencode)).toBe(false); - store.patch({ enableTerminalAgentHooks: true }); + await store.patch({ enableTerminalAgentHooks: true }); expect(existsSync(paths.codex)).toBe(true); expect(existsSync(paths.opencode)).toBe(true); - store.patch({ enableTerminalAgentHooks: false }); + await store.patch({ enableTerminalAgentHooks: false }); expect(existsSync(paths.opencode)).toBe(false); }); }); diff --git a/packages/server/src/utils/build-metadata-prompt.ts b/packages/server/src/utils/build-metadata-prompt.ts index 9f4f008308..b68f660515 100644 --- a/packages/server/src/utils/build-metadata-prompt.ts +++ b/packages/server/src/utils/build-metadata-prompt.ts @@ -1,8 +1,5 @@ -import { readPaseoConfigJson } from "./paseo-config-file.js"; -import { - PaseoConfigSchema, - type PaseoMetadataGeneration, -} from "@getpaseo/protocol/paseo-config-schema"; +import { readPaseoConfig } from "./paseo-config-file.js"; +import { type PaseoMetadataGeneration } from "@getpaseo/protocol/paseo-config-schema"; export type MetadataConfigKey = "title" | "branchName" | "commitMessage" | "pullRequest"; @@ -52,8 +49,8 @@ async function readProjectMetadataOverrides( } try { const repoRoot = await options.workspaceGitService.resolveRepoRoot(options.cwd); - const json = readPaseoConfigJson(repoRoot); - return PaseoConfigSchema.parse(json).metadataGeneration; + const result = await readPaseoConfig(repoRoot); + return result.ok ? result.config?.metadataGeneration : undefined; } catch { return undefined; } diff --git a/packages/server/src/utils/checkout-git.commit-file-diff.test.ts b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts index 576e9b0c80..efda5be92c 100644 --- a/packages/server/src/utils/checkout-git.commit-file-diff.test.ts +++ b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it } from "vitest"; import { getCommitFileDiff, listCheckoutCommits } from "./checkout-git.js"; +import { SessionDelivery } from "../server/session/owned-subscriptions/index.js"; import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js"; const tempDirs: string[] = []; @@ -302,3 +303,44 @@ describe("getCommitFileDiff", () => { expect(file?.deletions).toBe(0); }); }); + +it("identical commit-file requests from two requesters both retain syntax tokens", async () => { + const repoDir = initRepo(); + const original = "const value = Math.max(42, 7);\n".repeat(3_000); + commitFile(repoDir, "file.ts", original, "initial"); + commitFile(repoDir, "file.ts", original.replace("42", "43"), "edit"); + const input = { cwd: repoDir, sha: headSha(repoDir), path: "file.ts" }; + const results = await Promise.all([getCommitFileDiff(input), getCommitFileDiff(input)]); + for (const file of results) { + expect( + file?.hunks[0].lines + .find((line) => line.type === "add") + ?.tokens?.some((token) => token.style !== null), + ).toBe(true); + } +}); + +it("a disconnected requester cancels commit highlighting before its sources arrive", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "file.ts", "const value = 42;\n", "initial"); + commitFile(repoDir, "file.ts", "const value = 43;\n", "edit"); + const delivery = new SessionDelivery(() => {}); + const source = {}; + delivery.attach(source, true); + const request = { + type: "checkout.commits.file_diff.request" as const, + cwd: repoDir, + sha: headSha(repoDir), + path: "file.ts", + requestId: "disconnect", + }; + const results: Awaited>[] = []; + const pending = delivery.request(source, request, async () => { + results.push(await getCommitFileDiff({ ...request, signal: delivery.requestSignal })); + }); + await delivery.detach(source); + await pending; + expect( + results[0]?.hunks.flatMap((hunk) => hunk.lines).every((line) => line.tokens === undefined), + ).toBe(true); +}); diff --git a/packages/server/src/utils/checkout-git.commits.test.ts b/packages/server/src/utils/checkout-git.commits.test.ts index d614e32f9b..ee29579f55 100644 --- a/packages/server/src/utils/checkout-git.commits.test.ts +++ b/packages/server/src/utils/checkout-git.commits.test.ts @@ -172,7 +172,7 @@ describe("listCheckoutCommits", () => { mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true }); git(["worktree", "add", "-b", "feature", worktreeDir], repoDir); commitFile(worktreeDir, "feature.txt", "feature\n", "Feature work"); - writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" }); + await writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" }); const { baseRef, commits } = await listCheckoutCommits({ cwd: worktreeDir, diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 3cc5c3dace..bd52b0e49f 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -2859,7 +2859,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -2899,7 +2899,7 @@ const x = 1; const workspaceDir = join(paseoHome, "worktrees", "repo", "renamed-by-agent"); mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true }); execFileSync("git", ["worktree", "add", workspaceDir, "placeholder"], { cwd: repoDir }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "placeholder", @@ -2936,7 +2936,7 @@ const x = 1; const workspaceDir = join(paseoHome, "worktrees", "repo", "pinned-worktree"); mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true }); execFileSync("git", ["worktree", "add", workspaceDir, "feature/pinned"], { cwd: repoDir }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "feature/pinned", @@ -2975,7 +2975,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change-1"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -2997,7 +2997,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -3062,7 +3062,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "mixedowner/old-change"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -3114,7 +3114,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "feature/gitlab-mr"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "feature/gitlab-mr", @@ -3128,7 +3128,9 @@ const x = 1; const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets }); await renameCurrentBranch(workspaceCwd, "feature/renamed"); - expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toMatchObject({ + expect( + (await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget, + ).toMatchObject({ localBranchName: "feature/renamed", }); const renamedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome }); @@ -3164,7 +3166,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "feature/placeholder"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "feature/placeholder", @@ -3174,7 +3176,7 @@ const x = 1; await renameCurrentBranch(workspaceDir, "feature/generated"); - expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toEqual({ + expect((await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget).toEqual({ headRef: "feature/generated", localBranchName: "feature/generated", }); @@ -3951,7 +3953,7 @@ const x = 1; cwd: worktree.worktreePath, }); - const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath); rmSync(metadataPath, { force: true }); const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome }); @@ -3970,7 +3972,7 @@ const x = 1; paseoHome, }); - const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath); rmSync(metadataPath, { force: true }); const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index fbbb494b4a..eed39f63e5 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,7 +1,15 @@ import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { resolve, dirname, basename } from "path"; -import { existsSync, realpathSync } from "fs"; -import { open as openFile, readFile, stat as statFile, mkdtemp, copyFile, rm } from "fs/promises"; +import { + open as openFile, + readFile, + stat as statFile, + mkdtemp, + copyFile, + realpath, + access, + rm, +} from "fs/promises"; import { tmpdir } from "node:os"; import { setImmediate } from "node:timers/promises"; import { TTLCache } from "@isaacs/ttlcache"; @@ -1104,7 +1112,7 @@ async function getMainRepoRootFromCommonDir( if (!commonDir) { throw new Error("Not in a git repository"); } - const normalized = realpathSync(commonDir); + const normalized = await realpath(commonDir); if (basename(normalized) === ".git") { return dirname(normalized); @@ -1229,7 +1237,7 @@ export async function renameCurrentBranch( const currentBranch = await getCurrentBranch(cwd); if (currentBranch) { - rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch); + await rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch); } return { previousBranch, currentBranch }; } @@ -1274,8 +1282,8 @@ function storedBaseRefFromMetadata(metadata: PaseoWorktreeMetadata | null): stri return metadata?.baseRef ?? metadata?.baseRefName ?? null; } -function readPaseoWorktreeBaseRef(worktreeRoot: string): string | null { - return storedBaseRefFromMetadata(readPaseoWorktreeMetadata(worktreeRoot)); +async function readPaseoWorktreeBaseRef(worktreeRoot: string): Promise { + return storedBaseRefFromMetadata(await readPaseoWorktreeMetadata(worktreeRoot)); } async function getStoredBaseRefForCwd( @@ -1549,7 +1557,12 @@ async function abortGitPullConflictState(cwd: string): Promise { const rebaseMergePath = resolve(gitDir, "rebase-merge"); const rebaseApplyPath = resolve(gitDir, "rebase-apply"); - if (existsSync(mergeHeadPath)) { + if ( + await access(mergeHeadPath).then( + () => true, + () => false, + ) + ) { try { await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 }); } catch { @@ -1557,7 +1570,16 @@ async function abortGitPullConflictState(cwd: string): Promise { } } - if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) { + if ( + (await access(rebaseMergePath).then( + () => true, + () => false, + )) || + (await access(rebaseApplyPath).then( + () => true, + () => false, + )) + ) { try { await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 }); } catch { @@ -2078,7 +2100,7 @@ export async function getCheckoutSnapshotFacts( } const paseoWorktreeMetadata = inspected.paseoWorktree.isPaseoOwnedWorktree - ? readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot) + ? await readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot) : null; const storedBaseRef = storedBaseRefFromMetadata(paseoWorktreeMetadata); const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd, context)); @@ -2719,10 +2741,12 @@ export async function getCommitFileDiff({ cwd, sha, path, + signal, }: { cwd: string; sha: string; path: string; + signal?: AbortSignal; }): Promise { const worktreeRoot = await requireGitWorktreeRoot(cwd); const renames = await getCommitRenames(worktreeRoot, sha); @@ -2750,6 +2774,7 @@ export async function getCommitFileDiff({ } const parsedFiles = await parseAndHighlightDiff(stdout, worktreeRoot, { + signal, getOldFileContent: (file) => readGitFileContentAtRef(worktreeRoot, `${sha}^`, file.path), getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path), }); @@ -3132,6 +3157,7 @@ export function warmCheckoutShortstatInBackground( } interface AppendStructuredTrackedDiffsInput { + signal?: AbortSignal; cwd: string; trackedChanges: CheckoutFileChange[]; trackedNumstatByPath: Map; @@ -3147,6 +3173,7 @@ interface AppendStructuredTrackedDiffsInput { } async function buildHighlightedTrackedDiffFile(input: { + signal?: AbortSignal; cwd: string; change: CheckoutFileChange; parsedFile: ParsedDiffFile; @@ -3169,6 +3196,7 @@ async function buildHighlightedTrackedDiffFile(input: { : null, ]); const highlightedFile = await highlightDiffWithFileContent(parsedFile, cwd, { + signal: input.signal, oldFileContent, newFileContent, }); @@ -3254,6 +3282,7 @@ async function appendStructuredTrackedDiffs( const parsedFile = parsedTrackedByPath.get(change.path); if (parsedFile) { const file = await buildHighlightedTrackedDiffFile({ + signal: input.signal, cwd, change, parsedFile, @@ -3295,6 +3324,7 @@ async function appendStructuredTrackedDiffs( } interface ProcessUntrackedChangeInput { + signal?: AbortSignal; cwd: string; change: CheckoutFileChange; ignoreWhitespace: boolean; @@ -3345,7 +3375,7 @@ async function processUntrackedChange(input: ProcessUntrackedChangeInput): Promi } appendDiff(text); - const parsed = await parseAndHighlightDiff(text, cwd); + const parsed = await parseAndHighlightDiff(text, cwd, { signal: input.signal }); const parsedFile = parsed[0] ?? ({ @@ -3541,6 +3571,7 @@ export async function getCheckoutDiff( cwd: string, compare: CheckoutDiffCompare, context?: CheckoutContext, + signal?: AbortSignal, ): Promise { cwd = await requireGitWorktreeRoot(cwd); @@ -3552,10 +3583,15 @@ export async function getCheckoutDiff( const index = compare.mode === "all" ? await prepareAllChangesIndex(cwd, refsForDiff.baseRef) : null; try { - return await readCheckoutDiff(cwd, compare, { - ...refsForDiff, - indexEnv: index ? { GIT_INDEX_FILE: index.indexFile } : undefined, - }); + return await readCheckoutDiff( + cwd, + compare, + { + ...refsForDiff, + indexEnv: index ? { GIT_INDEX_FILE: index.indexFile } : undefined, + }, + signal, + ); } finally { await index?.cleanup(); } @@ -3565,6 +3601,7 @@ async function readCheckoutDiff( cwd: string, compare: CheckoutDiffCompare, refsForDiff: CheckoutDiffRefs, + signal?: AbortSignal, ): Promise { const ignoreWhitespace = compare.ignoreWhitespace === true; let effectiveRefsForDiff = refsForDiff; @@ -3625,6 +3662,7 @@ async function readCheckoutDiff( if (compare.includeStructured) { const didAppendTrackedDiffs = await appendStructuredTrackedDiffs({ + signal, cwd, trackedChanges, trackedNumstatByPath: trackedDiff.trackedNumstatByPath, @@ -3652,6 +3690,7 @@ async function readCheckoutDiff( break; } const didAppendUntrackedDiff = await processUntrackedChange({ + signal, cwd, change, ignoreWhitespace, diff --git a/packages/server/src/utils/paseo-config-file.test.ts b/packages/server/src/utils/paseo-config-file.test.ts index 02cbd90542..075ddd9720 100644 --- a/packages/server/src/utils/paseo-config-file.test.ts +++ b/packages/server/src/utils/paseo-config-file.test.ts @@ -1,9 +1,20 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { writeFile, truncate, rm } from "node:fs/promises"; +import { setImmediate as nextTurn } from "node:timers/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; -import { getWorktreeSetupCommands, getWorktreeTeardownCommands } from "./worktree.js"; +import { + readPaseoConfig, + PaseoConfigSchema, + PaseoConfigRawSchema, + getWorktreeSetupCommands, + getWorktreeTeardownCommands, +} from "./worktree.js"; import { readPaseoConfigForEdit, statPaseoConfigPath, @@ -21,16 +32,179 @@ describe("paseo config file substrate", () => { rmSync(tempDir, { recursive: true, force: true }); }); - it("returns null config and revision when paseo.json is missing", () => { - const result = readPaseoConfigForEdit(tempDir); + it("rejects an oversized config before parsing its otherwise valid JSON", async () => { + writeFileSync( + join(tempDir, "paseo.json"), + JSON.stringify({ extra: "x".repeat(2 * 1024 * 1024) }), + ); + expect(await readPaseoConfigForEdit(tempDir)).toEqual({ + ok: false, + error: { code: "invalid_project_config" }, + }); + }); + + it("rejects a sparse oversized file without allocating its contents", async () => { + const configPath = join(tempDir, "paseo.json"); + await writeFile(configPath, ""); + await truncate(configPath, 1024 * 1024 * 1024); + expect(await readPaseoConfigForEdit(tempDir)).toEqual({ + ok: false, + error: { code: "invalid_project_config" }, + }); + }); + + it.skipIf(isPlatform("win32"))("rejects a FIFO without waiting for a writer", async () => { + await promisify(execFile)("mkfifo", [join(tempDir, "paseo.json")]); + const modulePath = fileURLToPath(new URL("./paseo-config-file.ts", import.meta.url)); + const script = `import { readPaseoConfigForEdit } from ${JSON.stringify(modulePath)}; + const result = await readPaseoConfigForEdit(${JSON.stringify(tempDir)}); + if (JSON.stringify(result) !== JSON.stringify({ok:false,error:{code:"invalid_project_config"}})) process.exit(1);`; + // Isolate the regression: the old sync reader blocks on the pipe and must be killed. + await promisify(execFile)( + process.execPath, + ["--import", "tsx", "--input-type=module", "-e", script], + { timeout: 2_000 }, + ); + }); + + it("keeps the longest loop stall below 20ms while admitting large configs", async () => { + const config = { + scripts: Object.fromEntries( + Array.from({ length: 7_000 }, (_, i) => [`script${i}`, { command: "echo hi" }]), + ), + worktree: { setup: "npm ci" }, + }; + await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config)); + let previous = performance.now(); + let longestGap = 0; + const probe = setInterval(() => { + const now = performance.now(); + longestGap = Math.max(longestGap, now - previous); + previous = now; + }, 1); + let result: Awaited>; + try { + result = await readPaseoConfigForEdit(tempDir); + await nextTurn(); + longestGap = Math.max(longestGap, performance.now() - previous); + } finally { + clearInterval(probe); + } + expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) }); + process.stdout.write(`project config 7,000 scripts max loop gap: ${longestGap.toFixed(2)}ms\n`); + expect(longestGap).toBeLessThan(20); + }); + + it("rejects a 32MiB config with a loop stall below 20ms", async () => { + await writeFile( + join(tempDir, "paseo.json"), + JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }), + ); + let previous = performance.now(); + let longestGap = 0; + const probe = setInterval(() => { + const now = performance.now(); + longestGap = Math.max(longestGap, now - previous); + previous = now; + }, 1); + let result: Awaited>; + try { + result = await readPaseoConfigForEdit(tempDir); + await nextTurn(); + longestGap = Math.max(longestGap, performance.now() - previous); + } finally { + clearInterval(probe); + } + process.stdout.write(`project config 32MiB max loop gap: ${longestGap.toFixed(2)}ms\n`); + expect(longestGap).toBeLessThan(20); + expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } }); + }); + + it.each([ + { + scripts: Object.fromEntries( + Array.from({ length: 130 }, (_, i) => [ + `script${i}`, + { command: "echo hi", extra: { keep: true } }, + ]), + ), + }, + { + scripts: { valid: { command: "echo hi" }, invalid: 42 }, + worktree: { setup: ["one", "", 42] }, + }, + { scripts: ["invalid record"], worktree: { setup: "echo hi" } }, + JSON.parse( + '{"scripts":{"__proto__":{"command":"echo hi"},"constructor":{"command":"echo hi"}}}', + ), + ])("preserves edit and runtime schema behavior with grouped validation (%#)", async (config) => { + await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config)); + const expected = PaseoConfigRawSchema.safeParse(config); + expect(await readPaseoConfigForEdit(tempDir)).toEqual( + expected.success + ? { ok: true, config: expected.data, revision: await statPaseoConfigPath(tempDir) } + : { ok: false, error: { code: "invalid_project_config" } }, + ); + expect(await readPaseoConfig(tempDir)).toEqual({ + ok: true, + config: PaseoConfigSchema.parse(config), + }); + }); + + it("refreshes snapshots after an external edit, repair, and removal", async () => { + const configPath = join(tempDir, "paseo.json"); + await writeFile(configPath, JSON.stringify({ worktree: { setup: "old" } })); + const original = await readPaseoConfig(tempDir); + expect(original).toEqual({ ok: true, config: { worktree: { setup: ["old"], teardown: [] } } }); + await writeFile(configPath, "{ malformed"); + expect(await readPaseoConfigForEdit(tempDir)).toEqual({ + ok: false, + error: { code: "invalid_project_config" }, + }); + await writeFile(configPath, JSON.stringify({ worktree: { setup: "repaired" } })); + expect(await readPaseoConfig(tempDir)).toEqual({ + ok: true, + config: { worktree: { setup: ["repaired"], teardown: [] } }, + }); + await rm(configPath); + expect(await readPaseoConfigForEdit(tempDir)).toEqual({ + ok: true, + config: null, + revision: null, + }); + }); + + it("serializes competing edits so exactly one matching revision commits", async () => { + const results = await Promise.all( + ["first", "second"].map((setup) => + writePaseoConfigForEdit({ + repoRoot: tempDir, + config: { worktree: { setup } }, + expectedRevision: null, + }), + ), + ); + const saved = await readPaseoConfigForEdit(tempDir); + expect(results[0]).toEqual(saved); + expect(results[1]).toEqual({ + ok: false, + error: { + code: "stale_project_config", + currentRevision: await statPaseoConfigPath(tempDir), + }, + }); + }); + + it("returns null config and revision when paseo.json is missing", async () => { + const result = await readPaseoConfigForEdit(tempDir); expect(result).toEqual({ ok: true, config: null, revision: null }); }); - it("returns invalid_project_config for invalid JSON", () => { + it("returns invalid_project_config for invalid JSON", async () => { writeFileSync(join(tempDir, "paseo.json"), "{ invalid json\n"); - const result = readPaseoConfigForEdit(tempDir); + const result = await readPaseoConfigForEdit(tempDir); expect(result).toEqual({ ok: false, @@ -38,7 +212,7 @@ describe("paseo config file substrate", () => { }); }); - it("preserves raw lifecycle string and array forms with a revision token", () => { + it("preserves raw lifecycle string and array forms with a revision token", async () => { writeFileSync( join(tempDir, "paseo.json"), JSON.stringify({ @@ -49,7 +223,7 @@ describe("paseo config file substrate", () => { }), ); - const result = readPaseoConfigForEdit(tempDir); + const result = await readPaseoConfigForEdit(tempDir); expect(result).toEqual({ ok: true, @@ -59,11 +233,11 @@ describe("paseo config file substrate", () => { teardown: ["npm run clean", "npm run reset"], }, }, - revision: statPaseoConfigPath(tempDir), + revision: await statPaseoConfigPath(tempDir), }); }); - it("keeps runtime lifecycle commands normalized for execution", () => { + it("keeps runtime lifecycle commands normalized for execution", async () => { writeFileSync( join(tempDir, "paseo.json"), JSON.stringify({ @@ -74,15 +248,15 @@ describe("paseo config file substrate", () => { }), ); - expect(getWorktreeSetupCommands(tempDir)).toEqual(["npm install"]); - expect(getWorktreeTeardownCommands(tempDir)).toEqual(["npm run clean", "npm run reset"]); + expect(await getWorktreeSetupCommands(tempDir)).toEqual(["npm install"]); + expect(await getWorktreeTeardownCommands(tempDir)).toEqual(["npm run clean", "npm run reset"]); }); - it("writes pretty JSON with a trailing newline when revision matches", () => { + it("writes pretty JSON with a trailing newline when revision matches", async () => { writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } })); - const expectedRevision = statPaseoConfigPath(tempDir); + const expectedRevision = await statPaseoConfigPath(tempDir); - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot: tempDir, config: { worktree: { setup: "npm install" } }, expectedRevision, @@ -91,7 +265,7 @@ describe("paseo config file substrate", () => { expect(result).toEqual({ ok: true, config: { worktree: { setup: "npm install" } }, - revision: statPaseoConfigPath(tempDir), + revision: await statPaseoConfigPath(tempDir), }); expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe( '{\n "worktree": {\n "setup": "npm install"\n }\n}\n', @@ -101,13 +275,13 @@ describe("paseo config file substrate", () => { // POSIX-only: Windows mtime granularity can collapse the two revisions in this fixture. it.skipIf(isPlatform("win32"))( "rejects stale writes when the current revision changed before rename", - () => { + async () => { writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } })); - const expectedRevision = statPaseoConfigPath(tempDir); + const expectedRevision = await statPaseoConfigPath(tempDir); writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } })); - const currentRevision = statPaseoConfigPath(tempDir); + const currentRevision = await statPaseoConfigPath(tempDir); - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot: tempDir, config: { worktree: { setup: "from editor" } }, expectedRevision, @@ -123,7 +297,7 @@ describe("paseo config file substrate", () => { }, ); - it("round-trips unknown top-level, worktree, and script-entry fields", () => { + it("round-trips unknown top-level, worktree, and script-entry fields", async () => { const config = { extraTop: { keep: true }, worktree: { @@ -139,7 +313,7 @@ describe("paseo config file substrate", () => { }, }; - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot: tempDir, config, expectedRevision: null, @@ -148,20 +322,20 @@ describe("paseo config file substrate", () => { expect(result).toEqual({ ok: true, config, - revision: statPaseoConfigPath(tempDir), + revision: await statPaseoConfigPath(tempDir), }); - expect(readPaseoConfigForEdit(tempDir)).toEqual({ + expect(await readPaseoConfigForEdit(tempDir)).toEqual({ ok: true, config, - revision: statPaseoConfigPath(tempDir), + revision: await statPaseoConfigPath(tempDir), }); }); - it("returns write_failed for filesystem write exceptions", () => { + it("returns write_failed for filesystem write exceptions", async () => { const fileRoot = join(tempDir, "not-a-directory"); writeFileSync(fileRoot, "file"); - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot: fileRoot, config: { worktree: { setup: "npm install" } }, expectedRevision: null, @@ -173,10 +347,10 @@ describe("paseo config file substrate", () => { }); }); - it("creates paseo.json when the file is still missing and expected revision is null", () => { + it("creates paseo.json when the file is still missing and expected revision is null", async () => { mkdirSync(join(tempDir, "nested")); - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot: join(tempDir, "nested"), config: { scripts: { dev: { command: "npm run dev" } } }, expectedRevision: null, @@ -185,7 +359,7 @@ describe("paseo config file substrate", () => { expect(result).toEqual({ ok: true, config: { scripts: { dev: { command: "npm run dev" } } }, - revision: statPaseoConfigPath(join(tempDir, "nested")), + revision: await statPaseoConfigPath(join(tempDir, "nested")), }); }); }); diff --git a/packages/server/src/utils/paseo-config-file.ts b/packages/server/src/utils/paseo-config-file.ts index 9b31918e63..b8152c4f22 100644 --- a/packages/server/src/utils/paseo-config-file.ts +++ b/packages/server/src/utils/paseo-config-file.ts @@ -1,8 +1,14 @@ -import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { constants, type Stats } from "node:fs"; +import { open, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import pLimit from "p-limit"; +import { withTimeout } from "./promise-timeout.js"; +import { setImmediate as nextTurn } from "node:timers/promises"; import { randomUUID } from "node:crypto"; import { PaseoConfigRawSchema, + PaseoConfigSchema, + type PaseoConfig, type PaseoConfigRaw, type PaseoConfigRevision, type ProjectConfigRpcError, @@ -15,15 +21,18 @@ export { } from "@getpaseo/protocol/paseo-config-schema"; export const PASEO_CONFIG_FILE_NAME = "paseo.json"; +// Configuration is deliberately small. Bound both IO and main-thread JSON/schema work. +export const MAX_PASEO_CONFIG_BYTES = 256 * 1024; export type ReadPaseoConfigForEditResult = | { ok: true; config: PaseoConfigRaw | null; revision: PaseoConfigRevision | null } | { ok: false; error: ProjectConfigRpcError }; - +export type ReadPaseoConfigResult = + | { ok: true; config: PaseoConfig | null } + | { ok: false; configPath: string; error: unknown }; export type WritePaseoConfigForEditResult = | { ok: true; config: PaseoConfigRaw; revision: PaseoConfigRevision } | { ok: false; error: ProjectConfigRpcError }; - export interface WritePaseoConfigForEditInput { repoRoot: string; config: PaseoConfigRaw; @@ -34,96 +43,272 @@ export function resolvePaseoConfigPath(repoRoot: string): string { return join(repoRoot, PASEO_CONFIG_FILE_NAME); } -export function statPaseoConfigPath(repoRoot: string): PaseoConfigRevision | null { - const configPath = resolvePaseoConfigPath(repoRoot); - if (!existsSync(configPath)) { - return null; - } - const stats = statSync(configPath); - return { - mtimeMs: stats.mtimeMs, - size: stats.size, - }; +function revision(stats: Stats): PaseoConfigRevision { + return { mtimeMs: stats.mtimeMs, size: stats.size }; } -export function readPaseoConfigJson(repoRoot: string): unknown { - const configPath = resolvePaseoConfigPath(repoRoot); - if (!existsSync(configPath)) { - return null; - } - return JSON.parse(readFileSync(configPath, "utf8")); -} - -export function readPaseoConfigForEdit(repoRoot: string): ReadPaseoConfigForEditResult { +async function inspect(repoRoot: string): Promise { try { - const json = readPaseoConfigJson(repoRoot); - if (json === null) { - return { ok: true, config: null, revision: null }; + return await stat(resolvePaseoConfigPath(repoRoot)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +export async function statPaseoConfigPath(repoRoot: string): Promise { + const stats = await inspect(repoRoot); + return stats ? revision(stats) : null; +} + +function admit(stats: Stats): void { + if (!stats.isFile() || stats.size > MAX_PASEO_CONFIG_BYTES) { + throw new Error(`paseo.json must be a regular file of at most ${MAX_PASEO_CONFIG_BYTES} bytes`); + } +} + +function fingerprint(stats: Stats | null): string { + return stats + ? `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}` + : "missing"; +} + +// The record schema validates every script synchronously. Validate bounded groups +// through the same schemas so thousands of script entries do not monopolize a turn. +async function validateConfig(json: unknown): Promise<{ + edit: ReturnType; + runtime: PaseoConfig; +}> { + if (json === null || typeof json !== "object" || Array.isArray(json)) { + return { edit: PaseoConfigRawSchema.safeParse(json), runtime: PaseoConfigSchema.parse(json) }; + } + const scripts = (json as Record).scripts; + if (scripts === null || typeof scripts !== "object" || Array.isArray(scripts)) { + return { edit: PaseoConfigRawSchema.safeParse(json), runtime: PaseoConfigSchema.parse(json) }; + } + const withoutScripts = { ...json, scripts: {} }; + let edit = PaseoConfigRawSchema.safeParse(withoutScripts); + const runtime = PaseoConfigSchema.parse(withoutScripts); + const entries = Object.entries(scripts); + if (entries.length > 32) await nextTurn(); + let lastYield = performance.now(); + for (let offset = 0; offset < entries.length; offset += 32) { + const group = { scripts: Object.fromEntries(entries.slice(offset, offset + 32)) }; + if (edit.success) { + const parsed = PaseoConfigRawSchema.safeParse(group); + if (parsed.success) Object.assign(edit.data.scripts!, parsed.data.scripts); + else edit = parsed; } - return { - ok: true, - config: PaseoConfigRawSchema.parse(json), - revision: statPaseoConfigPath(repoRoot), - }; + Object.assign(runtime.scripts!, PaseoConfigSchema.parse(group).scripts); + if (performance.now() - lastYield >= 2) { + await nextTurn(); + lastYield = performance.now(); + } + } + return { edit, runtime }; +} + +interface Snapshot { + fingerprint: string; + edit: ReadPaseoConfigForEditResult; + runtime: ReadPaseoConfigResult; +} + +/** Owns config admission, snapshots and daemon read/modify/write ordering for every consumer. */ +class ProjectConfigOwner { + // A stalled mount retains its slot until the OS returns; never consume the whole fs pool. + private readonly filesystemOperations = pLimit(2); + private readonly pendingAliases = new Map>(); + private readonly snapshots = new Map(); + private readonly pending = new Map< + string, + { kind: "read" | "write"; promise: Promise } + >(); + + async resolveRoot(repoRoot: string, activeRoots: Set): Promise { + const root = resolve(repoRoot); + if (activeRoots.has(root)) return root; + let pending = this.pendingAliases.get(root); + if (!pending) { + pending = this.filesystemOperations(() => realpath(root)).finally(() => + this.pendingAliases.delete(root), + ); + this.pendingAliases.set(root, pending); + } + try { + const alias = await withTimeout(pending, 5_000, "Project root unavailable"); + return activeRoots.has(alias) ? alias : null; + } catch { + return null; + } + } + + private async serialized( + root: string, + kind: "read" | "write", + operation: () => Promise, + ): Promise { + const previous = this.pending.get(root); + if (kind === "read" && previous?.kind === "read") return previous.promise as Promise; + const next = (async () => { + await previous?.promise.catch(() => undefined); + return this.filesystemOperations(operation); + })(); + this.pending.set(root, { kind, promise: next }); + try { + return await next; + } finally { + if (this.pending.get(root)?.promise === next) this.pending.delete(root); + } + } + + private remember(root: string, snapshot: Snapshot): Snapshot { + this.snapshots.delete(root); + this.snapshots.set(root, snapshot); + // Worktrees come and go; retain only a bounded set of reusable snapshots. + if (this.snapshots.size > 256) this.snapshots.delete(this.snapshots.keys().next().value!); + return snapshot; + } + + async read(repoRoot: string): Promise { + const root = resolve(repoRoot); + return withTimeout( + this.serialized(root, "read", () => this.load(root)), + 5_000, + "Project config unavailable", + ); + } + + private async load(root: string): Promise { + const stats = await inspect(root); + if (stats) admit(stats); + const key = fingerprint(stats); + const cached = this.snapshots.get(root); + if (cached?.fingerprint === key) return this.remember(root, cached); + let json: unknown = null; + if (stats) { + // O_NONBLOCK prevents a regular-file -> FIFO replacement between stat and open + // from waiting for a writer. Check the opened descriptor again before any read. + const handle = await open( + resolvePaseoConfigPath(root), + constants.O_RDONLY | constants.O_NONBLOCK, + ); + try { + const openedStats = await handle.stat(); + admit(openedStats); + const bytes = Buffer.alloc(MAX_PASEO_CONFIG_BYTES + 1); + let length = 0; + while (length < bytes.length) { + const { bytesRead } = await handle.read(bytes, length, bytes.length - length, null); + if (bytesRead === 0) break; + length += bytesRead; + } + if (length > MAX_PASEO_CONFIG_BYTES) throw new Error("paseo.json exceeds byte limit"); + // Never publish bytes under a revision that did not describe the opened file. + if (fingerprint(await handle.stat()) !== fingerprint(openedStats)) { + throw new Error("paseo.json changed while reading; retry the operation"); + } + try { + json = JSON.parse(bytes.toString("utf8", 0, length)); + } catch (error) { + return this.remember(root, { + fingerprint: key, + edit: { ok: false, error: { code: "invalid_project_config" } }, + runtime: { ok: false, configPath: resolvePaseoConfigPath(root), error }, + }); + } + if (fingerprint(openedStats) !== key) { + throw new Error("paseo.json was replaced while reading; retry the operation"); + } + } finally { + await handle.close(); + } + } + const validated = json === null ? null : await validateConfig(json); + const parsed = validated?.edit ?? null; + let edit: ReadPaseoConfigForEditResult = { ok: true, config: null, revision: null }; + if (parsed !== null) { + edit = parsed.success + ? { ok: true, config: parsed.data, revision: stats ? revision(stats) : null } + : { ok: false, error: { code: "invalid_project_config" } }; + } + return this.remember(root, { + fingerprint: key, + edit, + runtime: { ok: true, config: validated?.runtime ?? null }, + }); + } + + async write(input: WritePaseoConfigForEditInput): Promise { + const root = resolve(input.repoRoot); + return this.serialized(root, "write", async () => { + const { edit: parsed } = await validateConfig(input.config); + if (!parsed.success) return { ok: false, error: { code: "invalid_project_config" } }; + const content = `${JSON.stringify(parsed.data, null, 2)}\n`; + if (Buffer.byteLength(content) > MAX_PASEO_CONFIG_BYTES) { + return { ok: false, error: { code: "invalid_project_config" } }; + } + const tempPath = join(root, `.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`); + try { + await writeFile(tempPath, content, { flag: "wx" }); + const savedRevision = revision(await stat(tempPath)); + const currentStats = await inspect(root); + if (currentStats) admit(currentStats); + const currentRevision = currentStats ? revision(currentStats) : null; + if (!paseoConfigRevisionsEqual(currentRevision, input.expectedRevision)) { + return { ok: false, error: { code: "stale_project_config", currentRevision } }; + } + // Serialized against daemon edits. External writers still have the existing + // optimistic stat/rename race; revision tokens remain mtime + size. + await rename(tempPath, resolvePaseoConfigPath(root)); + this.snapshots.delete(root); + return { ok: true, config: parsed.data, revision: savedRevision }; + } catch { + return { ok: false, error: { code: "write_failed" } }; + } finally { + await rm(tempPath, { force: true }).catch(() => undefined); + } + }); + } +} + +const projectConfig = new ProjectConfigOwner(); + +export function resolveProjectConfigRoot( + repoRoot: string, + activeRoots: Set, +): Promise { + return projectConfig.resolveRoot(repoRoot, activeRoots); +} + +export async function readPaseoConfig(repoRoot: string): Promise { + try { + return (await projectConfig.read(repoRoot)).runtime; + } catch (error) { + return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error }; + } +} + +export async function readPaseoConfigForEdit( + repoRoot: string, +): Promise { + try { + return (await projectConfig.read(repoRoot)).edit; } catch { - return { - ok: false, - error: { code: "invalid_project_config" }, - }; + return { ok: false, error: { code: "invalid_project_config" } }; } } export function writePaseoConfigForEdit( input: WritePaseoConfigForEditInput, -): WritePaseoConfigForEditResult { - const parsed = PaseoConfigRawSchema.safeParse(input.config); - if (!parsed.success) { - return { ok: false, error: { code: "invalid_project_config" } }; - } - - const configPath = resolvePaseoConfigPath(input.repoRoot); - const tempPath = join( - input.repoRoot, - `.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`, - ); - - try { - writeFileSync(tempPath, `${JSON.stringify(parsed.data, null, 2)}\n`); - const currentRevision = statPaseoConfigPath(input.repoRoot); - if (!paseoConfigRevisionsEqual(currentRevision, input.expectedRevision)) { - removeTempPaseoConfig(tempPath); - return { - ok: false, - error: { code: "stale_project_config", currentRevision }, - }; - } - - renameSync(tempPath, configPath); - const revision = statPaseoConfigPath(input.repoRoot); - if (!revision) { - return { ok: false, error: { code: "write_failed" } }; - } - return { ok: true, config: parsed.data, revision }; - } catch { - removeTempPaseoConfig(tempPath); - return { ok: false, error: { code: "write_failed" } }; - } +): Promise { + return projectConfig.write(input); } function paseoConfigRevisionsEqual( left: PaseoConfigRevision | null, right: PaseoConfigRevision | null, ): boolean { - if (left === null || right === null) { - return left === right; - } + if (left === null || right === null) return left === right; return left.mtimeMs === right.mtimeMs && left.size === right.size; } - -function removeTempPaseoConfig(tempPath: string): void { - try { - rmSync(tempPath, { force: true }); - } catch { - // Best-effort cleanup only; callers need the original write outcome. - } -} diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index a0ec908fbb..45cb8b01b5 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -107,7 +107,7 @@ export function isRealpathInsideRoot(root: string, candidate: string): boolean { return getRealpathAwareRelativePath(root, candidate) !== null; } -function getRelativePathInsideRoot(root: string, candidate: string): string | null { +export function getRelativePathInsideRoot(root: string, candidate: string): string | null { const compareAsWindows = shouldCompareAsWindows(root, candidate); const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix; const normalizedRoot = normalizePathForComparison(root, compareAsWindows); @@ -175,7 +175,7 @@ function normalizePathForComparison(value: string, compareAsWindows: boolean): s return compareAsWindows ? normalized.toLowerCase() : normalized; } -function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string { +export function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string { const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix; const comparableValue = compareAsWindows ? stripWindowsNamespacePrefix(value) : value; const platformNormalized = platformPath.normalize(comparableValue); diff --git a/packages/server/src/utils/run-git-command.test.ts b/packages/server/src/utils/run-git-command.test.ts index 32844d0de8..5153b606dc 100644 --- a/packages/server/src/utils/run-git-command.test.ts +++ b/packages/server/src/utils/run-git-command.test.ts @@ -507,7 +507,6 @@ describe("runGitCommand", () => { command: "git", args: ["status", "--short"], cwd: process.cwd(), - cwdExists: true, timeout: 30_000, maxOutputBytes: 20 * 1024 * 1024, acceptExitCodes: [0, 1], @@ -520,7 +519,6 @@ describe("runGitCommand", () => { command: "git", args: ["status", "--short"], cwd: process.cwd(), - cwdExists: true, durationMs: expect.any(Number), exitCode: 0, signal: null, @@ -530,6 +528,9 @@ describe("runGitCommand", () => { }), "Git command closed", ); + for (const [record] of trace.mock.calls) { + expect(record).not.toHaveProperty("cwdExists"); + } }); it("rejects non-zero exit codes that are not accepted and frees the slot", async () => { diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index bdf7bc1f32..3a3cbf5b4c 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -1,5 +1,4 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { existsSync } from "node:fs"; import type { Logger } from "pino"; import type { ProcessEnvRecord } from "../server/paseo-env.js"; import { @@ -311,7 +310,6 @@ function executeGitCommand( command: "git", args, cwd: options.cwd, - cwdExists: existsSync(options.cwd), timeout, maxOutputBytes, acceptExitCodes, diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index b977f7952b..13c95567db 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -1,7 +1,12 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; +import { mkdir, open, realpath, rename, stat, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import pLimit from "p-limit"; import { isAbsolute, join, resolve } from "path"; import { z } from "zod"; +const MAX_WORKTREE_METADATA_BYTES = 64 * 1024; + const ChangeRequestLookupTargetSchema = z.object({ headRef: z.string().min(1), headRepositoryOwner: z.string().min(1).optional(), @@ -90,75 +95,78 @@ function normalizeLegacyGitHubOwnerForBranch(owner: string): string | null { return /^[a-z0-9-]+$/.test(normalized) ? normalized : null; } -export function rebindPaseoWorktreeChangeRequestHint( +export async function rebindPaseoWorktreeChangeRequestHint( worktreeRoot: string, previousBranch: string, currentBranch: string, -): boolean { - const metadata = readPaseoWorktreeMetadata(worktreeRoot); - const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch); - if (!metadata || !target) { - return false; - } +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const metadata = await readMetadataFile(root); + const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch); + if (!metadata || !target) { + return false; + } - writePaseoWorktreeMetadataFile(worktreeRoot, { - ...metadata, - changeRequestLookupTarget: { - ...target, - ...(target.headRef === previousBranch && - !target.headRepositoryOwner && - target.changeRequestNumber === undefined - ? { headRef: currentBranch } - : {}), - localBranchName: currentBranch, - }, + await writePaseoWorktreeMetadataFile(root, { + ...metadata, + changeRequestLookupTarget: { + ...target, + ...(target.headRef === previousBranch && + !target.headRepositoryOwner && + target.changeRequestNumber === undefined + ? { headRef: currentBranch } + : {}), + localBranchName: currentBranch, + }, + }); + return true; }); - return true; } -export function pinPaseoWorktreeBranchIdentityIfMissing( +export async function pinPaseoWorktreeBranchIdentityIfMissing( worktreeRoot: string, branch: string, -): boolean { - const metadata = readPaseoWorktreeMetadata(worktreeRoot); - if (!metadata || metadata.changeRequestLookupTarget) { - return false; - } - const target = createPaseoWorktreeChangeRequestHint({ - headRef: branch, - localBranchName: branch, +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const metadata = await readMetadataFile(root); + if (!metadata || metadata.changeRequestLookupTarget) { + return false; + } + const target = createPaseoWorktreeChangeRequestHint({ + headRef: branch, + localBranchName: branch, + }); + await writePaseoWorktreeMetadataFile(root, { + ...metadata, + changeRequestLookupTarget: target, + }); + return true; }); - writePaseoWorktreeMetadataFile(worktreeRoot, { - ...metadata, - changeRequestLookupTarget: target, - }); - return true; } -function getGitDirForWorktreeRoot(worktreeRoot: string): string { +async function getGitDirForWorktreeRoot(worktreeRoot: string): Promise { const gitPath = join(worktreeRoot, ".git"); - if (!existsSync(gitPath)) { - throw new Error(`Not a git repository: ${worktreeRoot}`); - } - - // In a worktree checkout, `.git` is a file containing `gitdir: `. - // In a normal checkout, `.git` is a directory. + let info; try { - const gitFileContent = readFileSync(gitPath, "utf8"); - const match = gitFileContent.match(/gitdir:\s*(.+)/); - if (match?.[1]) { - const raw = match[1].trim(); - return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw); - } - } catch { - // If `.git` is a directory, readFileSync will throw; fall through. + info = await stat(gitPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + throw new Error(`Not a git repository: ${worktreeRoot}`, { cause: error }); + throw error; + } + if (info.isDirectory()) return gitPath; + const gitFileContent = await readBoundedFile(gitPath); + const match = gitFileContent.match(/gitdir:\s*(.+)/); + if (match?.[1]) { + const raw = match[1].trim(); + return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw); } return gitPath; } -export function getPaseoWorktreeMetadataPath(worktreeRoot: string): string { - const gitDir = getGitDirForWorktreeRoot(worktreeRoot); +export async function getPaseoWorktreeMetadataPath(worktreeRoot: string): Promise { + const gitDir = await getGitDirForWorktreeRoot(worktreeRoot); return join(gitDir, "paseo", "worktree.json"); } @@ -213,120 +221,166 @@ function assertValidBaseRef(value: string): void { } } -export function writePaseoWorktreeMetadata( +export async function writePaseoWorktreeMetadata( worktreeRoot: string, options: { baseRefName: string; baseRef?: string; changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint; }, -): void { - const baseRefName = normalizeBaseRefName(options.baseRefName); - assertValidBaseRef(baseRefName); - const baseRef = options.baseRef?.trim(); - if (baseRef) { - assertValidBaseRef(baseRef); - } +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const baseRefName = normalizeBaseRefName(options.baseRefName); + assertValidBaseRef(baseRefName); + const baseRef = options.baseRef?.trim(); + if (baseRef) { + assertValidBaseRef(baseRef); + } - const metadata: PaseoWorktreeMetadata = { - version: 1, - baseRefName, - ...(baseRef ? { baseRef } : {}), - ...(options.changeRequestLookupTarget - ? { changeRequestLookupTarget: options.changeRequestLookupTarget } - : {}), - }; - writePaseoWorktreeMetadataFile(worktreeRoot, metadata); -} - -export function writePaseoWorktreeRuntimeMetadata( - worktreeRoot: string, - options: { worktreePort: number }, -): void { - if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) { - throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`); - } - - const current = readPaseoWorktreeMetadata(worktreeRoot); - if (!current) { - throw new Error("Cannot persist worktree runtime metadata: missing base metadata"); - } - - const next: PaseoWorktreeMetadata = { - ...current, - version: 2, - runtime: { - worktreePort: options.worktreePort, - }, - }; - writePaseoWorktreeMetadataFile(worktreeRoot, next); -} - -export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata( - worktreeRoot: string, - options: { placeholderBranchName: string }, -): void { - const placeholderBranchName = options.placeholderBranchName.trim(); - if (!placeholderBranchName) { - throw new Error("Placeholder branch name is required"); - } - - const current = readPaseoWorktreeMetadata(worktreeRoot); - if (!current) { - throw new Error("Cannot persist first-agent branch auto-name metadata: missing base metadata"); - } - - writePaseoWorktreeMetadataFile(worktreeRoot, { - ...current, - version: 2, - firstAgentBranchAutoName: { - status: "pending", - placeholderBranchName, - }, + const metadata: PaseoWorktreeMetadata = { + version: 1, + baseRefName, + ...(baseRef ? { baseRef } : {}), + ...(options.changeRequestLookupTarget + ? { changeRequestLookupTarget: options.changeRequestLookupTarget } + : {}), + }; + await writePaseoWorktreeMetadataFile(root, metadata); }); } -export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted( +export async function writePaseoWorktreeRuntimeMetadata( + worktreeRoot: string, + options: { worktreePort: number }, +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) { + throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`); + } + + const current = await readMetadataFile(root); + if (!current) { + throw new Error("Cannot persist worktree runtime metadata: missing base metadata"); + } + + const next: PaseoWorktreeMetadata = { + ...current, + version: 2, + runtime: { + worktreePort: options.worktreePort, + }, + }; + await writePaseoWorktreeMetadataFile(root, next); + }); +} + +export async function writePaseoWorktreeFirstAgentBranchAutoNameMetadata( + worktreeRoot: string, + options: { placeholderBranchName: string }, +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const placeholderBranchName = options.placeholderBranchName.trim(); + if (!placeholderBranchName) { + throw new Error("Placeholder branch name is required"); + } + + const current = await readMetadataFile(root); + if (!current) { + throw new Error( + "Cannot persist first-agent branch auto-name metadata: missing base metadata", + ); + } + + await writePaseoWorktreeMetadataFile(root, { + ...current, + version: 2, + firstAgentBranchAutoName: { + status: "pending", + placeholderBranchName, + }, + }); + }); +} + +export async function markPaseoWorktreeFirstAgentBranchAutoNameAttempted( worktreeRoot: string, options: { attemptedAt?: string } = {}, -): PaseoWorktreeMetadata | null { - const current = readPaseoWorktreeMetadata(worktreeRoot); - if (!current || current.version !== 2 || current.firstAgentBranchAutoName?.status !== "pending") { - return current; - } +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const current = await readMetadataFile(root); + if ( + !current || + current.version !== 2 || + current.firstAgentBranchAutoName?.status !== "pending" + ) { + return current; + } - const next: PaseoWorktreeMetadata = { - ...current, - firstAgentBranchAutoName: { - status: "attempted", - placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName, - attemptedAt: options.attemptedAt ?? new Date().toISOString(), - }, - }; - writePaseoWorktreeMetadataFile(worktreeRoot, next); - return next; + const next: PaseoWorktreeMetadata = { + ...current, + firstAgentBranchAutoName: { + status: "attempted", + placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName, + attemptedAt: options.attemptedAt ?? new Date().toISOString(), + }, + }; + await writePaseoWorktreeMetadataFile(root, next); + return next; + }); } -export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null { - const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); - if (!existsSync(metadataPath)) { - return null; - } - const parsed = JSON.parse(readFileSync(metadataPath, "utf8")); - return PaseoWorktreeMetadataSchema.parse(parsed); +export async function readPaseoWorktreeMetadata( + worktreeRoot: string, +): Promise { + const key = await resolveMetadataIdentity(worktreeRoot); + await metadataMutations.get(key)?.catch(() => {}); + return metadataIo(() => readMetadataFile(key)); } -export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string { - const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); - const metadata = readPaseoWorktreeMetadata(worktreeRoot); +async function readMetadataFile(worktreeRoot: string): Promise { + const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot); + try { + return PaseoWorktreeMetadataSchema.parse(JSON.parse(await readBoundedFile(metadataPath))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +// O_NONBLOCK lets us reject FIFOs by kind without hanging in open(). A fixed read also +// caps a regular file that grows between stat and read; readFile's whole-file loop does not. +async function readBoundedFile(path: string): Promise { + const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK); + try { + const info = await file.stat(); + const limit = MAX_WORKTREE_METADATA_BYTES; + if (!info.isFile() || info.size > limit) + throw new Error(`Invalid Paseo worktree metadata file: ${path}`); + const buffer = Buffer.alloc(limit + 1); + let length = 0; + while (length < buffer.length) { + const { bytesRead } = await file.read(buffer, length, buffer.length - length); + if (bytesRead === 0) break; + length += bytesRead; + } + if (length > limit) throw new Error(`Invalid Paseo worktree metadata file: ${path}`); + return buffer.toString("utf8", 0, length); + } finally { + await file.close(); + } +} + +export async function requirePaseoWorktreeBaseRefName(worktreeRoot: string): Promise { + const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot); + const metadata = await readPaseoWorktreeMetadata(worktreeRoot); if (!metadata) { throw new Error(`Missing Paseo worktree base metadata: ${metadataPath}`); } return metadata.baseRefName; } -export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | null { - const metadata = readPaseoWorktreeMetadata(worktreeRoot); +export async function readPaseoWorktreeRuntimePort(worktreeRoot: string): Promise { + const metadata = await readPaseoWorktreeMetadata(worktreeRoot); if (!metadata) { return null; } @@ -336,13 +390,61 @@ export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | nul return null; } -function writePaseoWorktreeMetadataFile( +async function writePaseoWorktreeMetadataFile( worktreeRoot: string, metadata: PaseoWorktreeMetadata, -): void { - const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); - mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true }); - const tempPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); - renameSync(tempPath, metadataPath); +): Promise { + const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot); + const contents = `${JSON.stringify(metadata, null, 2)}\n`; + if (Buffer.byteLength(contents) > MAX_WORKTREE_METADATA_BYTES) { + throw new Error(`Invalid Paseo worktree metadata file: ${metadataPath}`); + } + // Never recreate an archived Git directory while a queued mutation is pending. + try { + await mkdir(join(await getGitDirForWorktreeRoot(worktreeRoot), "paseo")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + const tempPath = `${metadataPath}.${randomUUID()}.tmp`; + await writeFile(tempPath, contents, "utf8"); + await rename(tempPath, metadataPath); +} + +const metadataIo = pLimit(2); +const metadataMutations = new Map>(); + +// Only share in-flight identity resolution: a replaced symlink must not retain an old owner. +const metadataIdentities = new Map>(); + +function resolveMetadataIdentity(worktreeRoot: string): Promise { + const path = resolve(worktreeRoot); + const existing = metadataIdentities.get(path); + if (existing) return existing; + const identity = metadataIo(async () => { + try { + return await realpath(path); + } catch { + return path; + } + }).finally(() => { + if (metadataIdentities.get(path) === identity) metadataIdentities.delete(path); + }); + metadataIdentities.set(path, identity); + return identity; +} + +async function mutateMetadata( + worktreeRoot: string, + mutation: (root: string) => Promise, +): Promise { + const key = await resolveMetadataIdentity(worktreeRoot); + const previous = metadataMutations.get(key) ?? Promise.resolve(); + const pending = previous.catch(() => {}).then(() => metadataIo(() => mutation(key))); + metadataMutations.set(key, pending); + void pending + .finally(() => { + if (metadataMutations.get(key) === pending) metadataMutations.delete(key); + }) + .catch(() => {}); + return pending; } diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index a550995fb3..aede7cf8e3 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -21,7 +21,13 @@ import { type WorktreeConfig, } from "./worktree"; import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; -import { getPaseoWorktreeMetadataPath, readPaseoWorktreeMetadata } from "./worktree-metadata.js"; +import { + getPaseoWorktreeMetadataPath, + readPaseoWorktreeMetadata, + writePaseoWorktreeMetadata, + writePaseoWorktreeRuntimeMetadata, + writePaseoWorktreeFirstAgentBranchAutoNameMetadata, +} from "./worktree-metadata.js"; import { getCheckoutDiff, getCheckoutStatus, @@ -48,8 +54,8 @@ import { delimiter, dirname, join } from "path"; import { tmpdir } from "os"; import net from "node:net"; -function loadConfigForTest(repoRoot: string): PaseoConfig | null { - const result = readPaseoConfig(repoRoot); +async function loadConfigForTest(repoRoot: string): Promise { + const result = await readPaseoConfig(repoRoot); return result.ok ? result.config : null; } @@ -125,7 +131,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello-world")); expect(existsSync(result.worktreePath)).toBe(true); expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); expect(existsSync(metadataPath)).toBe(true); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ @@ -256,7 +262,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: result.worktreePath, }); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ version: 1, @@ -319,7 +325,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { // baseRefName must be the repo's default branch, not the checked-out branch // itself — otherwise the diff panel compares the branch against itself and // always shows no changes. - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ version: 1, @@ -370,7 +376,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); expect(existsSync(result.worktreePath)).toBe(true); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ version: 1, baseRefName: "develop" }); }); @@ -406,7 +412,9 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(result.branchName).toBe("main-1"); expect(existsSync(result.worktreePath)).toBe(true); - expect(readPaseoWorktreeMetadata(result.worktreePath)?.changeRequestLookupTarget).toEqual({ + expect( + (await readPaseoWorktreeMetadata(result.worktreePath))?.changeRequestLookupTarget, + ).toEqual({ headRef: "main-1", localBranchName: "main-1", }); @@ -459,7 +467,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { .trim(); expect(currentBranch).toBe("user/feature"); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ baseRefName: "main" }); }); @@ -566,7 +574,9 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { "from-origin\n", ); expect( - JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8")), + JSON.parse( + readFileSync(await getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8"), + ), ).toMatchObject({ baseRefName: "main" }); }); @@ -607,7 +617,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { // to the commit the worktree was cut from: "main" resolves local-first, which here is // a different commit than the fork's upstream. expect( - JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), + JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), ).toMatchObject({ baseRefName: "main", baseRef: "refs/remotes/upstream/main" }); // An untouched child must report no work of its own. Comparing against the wrong base @@ -694,7 +704,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); expect( - JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), + JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), ).toMatchObject({ baseRefName: "release+hotfix", baseRef: "refs/remotes/upstream/release+hotfix", @@ -894,7 +904,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { worktreePath: result.worktreePath, branchName: result.branchName, }); - expect(getWorktreeSetupCommands(result.worktreePath)).toEqual([ + expect(await getWorktreeSetupCommands(result.worktreePath)).toEqual([ 'greeting="hello from string setup"\necho "$greeting" > setup.log', ]); expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8").trim()).toBe( @@ -963,7 +973,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(readFileSync(join(repoDir, "setup-path.log"), "utf8").trim()).toBe("shim:ok"); }); - it("treats blank lifecycle strings as empty", () => { + it("treats blank lifecycle strings as empty", async () => { writeFileSync( join(repoDir, "paseo.json"), JSON.stringify({ @@ -974,11 +984,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }), ); - expect(getWorktreeSetupCommands(repoDir)).toEqual([]); - expect(getWorktreeTeardownCommands(repoDir)).toEqual([]); + expect(await getWorktreeSetupCommands(repoDir)).toEqual([]); + expect(await getWorktreeTeardownCommands(repoDir)).toEqual([]); }); - it("filters non-string and blank entries from lifecycle arrays", () => { + it("filters non-string and blank entries from lifecycle arrays", async () => { writeFileSync( join(repoDir, "paseo.json"), JSON.stringify({ @@ -999,11 +1009,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }), ); - expect(getWorktreeSetupCommands(repoDir)).toEqual([ + expect(await getWorktreeSetupCommands(repoDir)).toEqual([ 'echo "first" > setup-array.log', 'echo "second" >> setup-array.log', ]); - expect(getWorktreeTeardownCommands(repoDir)).toEqual([ + expect(await getWorktreeTeardownCommands(repoDir)).toEqual([ 'echo "first" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', 'echo "second" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', ]); @@ -1134,7 +1144,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }; writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - expect(getWorktreeTerminalSpecs(repoDir)).toEqual([ + expect(await getWorktreeTerminalSpecs(repoDir)).toEqual([ { name: "Dev Server", command: "npm run dev" }, { command: "cd packages/app && npm run dev" }, ]); @@ -1154,7 +1164,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }; writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - expect(getWorktreeTerminalSpecs(repoDir)).toEqual([ + expect(await getWorktreeTerminalSpecs(repoDir)).toEqual([ { name: "Watch", command: "npm run watch" }, { command: "npm run test" }, ]); @@ -1172,7 +1182,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }), ); - const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir)); + const scriptConfigs = getScriptConfigs(await loadConfigForTest(repoDir)); const typecheck = scriptConfigs.get("typecheck"); expect(typecheck).toEqual({ @@ -1196,7 +1206,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }), ); - const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir)); + const scriptConfigs = getScriptConfigs(await loadConfigForTest(repoDir)); const server = scriptConfigs.get("server"); expect(server).toEqual({ @@ -1236,7 +1246,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }), ); - expect(getScriptConfigs(loadConfigForTest(repoDir))).toEqual( + expect(getScriptConfigs(await loadConfigForTest(repoDir))).toEqual( new Map([ ["valid", { command: "npm run valid" }], ["invalidType", { command: "npm run worker" }], @@ -1547,7 +1557,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); - expect(getWorktreeTeardownCommands(repoDir)).toEqual([ + expect(await getWorktreeTeardownCommands(repoDir)).toEqual([ 'cleanup_message="teardown string"\necho "$cleanup_message" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', ]); expect(readFileSync(join(repoDir, "teardown.log"), "utf8").trim()).toBe("teardown string"); @@ -1617,3 +1627,104 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); }); + +describe.skipIf(isPlatform("win32"))("async worktree metadata", () => { + it("serializes metadata mutations per worktree while another worktree progresses", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-metadata-order-")); + const first = join(root, "first"); + const second = join(root, "second"); + mkdirSync(join(first, ".git"), { recursive: true }); + mkdirSync(join(second, ".git"), { recursive: true }); + try { + await writePaseoWorktreeMetadata(first, { baseRefName: "main" }); + await writePaseoWorktreeMetadata(second, { baseRefName: "release" }); + const order: string[] = []; + const runtime = writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4101 }).then(() => + order.push("first-runtime"), + ); + const unrelated = writePaseoWorktreeRuntimeMetadata(second, { worktreePort: 4102 }).then(() => + order.push("second-runtime"), + ); + const naming = writePaseoWorktreeFirstAgentBranchAutoNameMetadata(first, { + placeholderBranchName: "topic", + }).then(() => order.push("first-naming")); + // A read issued while writes are pending observes their completed snapshot. + const snapshot = readPaseoWorktreeMetadata(first); + await Promise.all([runtime, unrelated, naming]); + expect(order.indexOf("first-runtime")).toBeLessThan(order.indexOf("first-naming")); + expect(await snapshot).toMatchObject({ + baseRefName: "main", + runtime: { worktreePort: 4101 }, + firstAgentBranchAutoName: { status: "pending", placeholderBranchName: "topic" }, + }); + expect(await readPaseoWorktreeMetadata(second)).toMatchObject({ + baseRefName: "release", + runtime: { worktreePort: 4102 }, + }); + const alias = join(root, "first-alias"); + symlinkSync(first, alias, "dir"); + await Promise.all([ + writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4103 }), + writePaseoWorktreeFirstAgentBranchAutoNameMetadata(alias, { + placeholderBranchName: "renamed", + }), + ]); + expect(await readPaseoWorktreeMetadata(first)).toMatchObject({ + runtime: { worktreePort: 4103 }, + firstAgentBranchAutoName: { placeholderBranchName: "renamed" }, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("a queued metadata mutation cannot recreate an archived Git directory", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-metadata-archive-")); + mkdirSync(join(root, ".git")); + try { + await writePaseoWorktreeMetadata(root, { baseRefName: "main" }); + const pending = writePaseoWorktreeRuntimeMetadata(root, { worktreePort: 4101 }); + // The mutation is queued in a microtask, so archive wins this ordering. + rmSync(join(root, ".git"), { recursive: true }); + await expect(pending).rejects.toThrow("Not a git repository"); + expect(existsSync(join(root, ".git"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("recognizes a removed worktree under a symlinked ownership root", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-ownership-alias-")); + const actual = join(root, "actual"); + const alias = join(root, "worktrees"); + mkdirSync(actual); + symlinkSync(actual, alias, "dir"); + try { + const cwd = join(alias, "project", "removed"); + expect( + await isPaseoOwnedWorktreeCwd(cwd, { worktreesRoot: alias, knownGitCommonDir: null }), + ).toMatchObject({ allowed: true, worktreePath: cwd }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects oversized metadata and named pipes without reading them whole", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-metadata-admission-")); + mkdirSync(join(root, ".git", "paseo"), { recursive: true }); + try { + const path = await getPaseoWorktreeMetadataPath(root); + writeFileSync(path, JSON.stringify({ version: 1, baseRefName: "x".repeat(64 * 1024) })); + await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow( + "Invalid Paseo worktree metadata file", + ); + rmSync(path); + execFileSync("mkfifo", [path]); + await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow( + "Invalid Paseo worktree metadata file", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 4d6f50bba6..605e2b8c20 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -5,6 +5,7 @@ import { deletePaseoWorktree, isPaseoOwnedWorktreeCwd, mapWorkspaceCwdToWorktree, + runWorktreeTeardownCommands, slugify, type CreateWorktreeOptions, type WorktreeConfig, @@ -171,7 +172,15 @@ describe("paseo worktree manager", () => { expect(existsSync(created.worktreePath)).toBe(true); const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome }); - expect(ownership.allowed).toBe(true); + expect(ownership).toMatchObject({ allowed: true, worktreePath: created.worktreePath }); + // Windows may report the same root through native 8.3 and JS long spellings. + const nativeWorktreePath = realpathSync.native(created.worktreePath); + await expect( + runWorktreeTeardownCommands({ + worktreePath: created.worktreePath, + teardownCwd: nativeWorktreePath, + }), + ).resolves.toEqual([]); await expect( isPaseoOwnedWorktreeCwd(join(created.worktreePath, "packages", "app"), { paseoHome }), ).resolves.toMatchObject({ diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index ca921fb219..3ddc71b472 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,16 +1,17 @@ import { execFile } from "child_process"; import { promisify } from "util"; -import { constants as fsConstants, existsSync, mkdirSync, realpathSync, statSync } from "fs"; -import { copyFile, rm, stat } from "fs/promises"; +import { constants as fsConstants, realpath } from "fs"; +import { copyFile, mkdir, rm, stat } from "fs/promises"; import { join, basename, dirname, isAbsolute, resolve, sep } from "path"; import net from "node:net"; import { createHash } from "node:crypto"; import stripAnsi from "strip-ansi"; +import pLimit from "p-limit"; import { buildStringCommandShellInvocation, createStringCommandShellEnv, } from "./string-command-shell.js"; -import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js"; +import { readPaseoConfig } from "./paseo-config-file.js"; export { PaseoConfigRawSchema, PaseoLifecycleCommandRawSchema, @@ -20,7 +21,7 @@ export { type PaseoConfig, type PaseoConfigRaw, } from "@getpaseo/protocol/paseo-config-schema"; -import { PaseoConfigSchema, type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; +import { type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; import { createPaseoWorktreeChangeRequestHint, normalizeBaseRefName, @@ -37,7 +38,12 @@ import { spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; -import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js"; +import { + expandTilde, + getRealpathAwareRelativePath, + getRelativePathInsideRoot, + isPathInsideRoot, +} from "./path.js"; import { terminateWithTreeKill } from "./tree-kill.js"; export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -246,21 +252,7 @@ export class InvalidGitBranchNameError extends Error { } } -export type ReadPaseoConfigResult = - | { ok: true; config: PaseoConfig | null } - | { ok: false; configPath: string; error: unknown }; - -export function readPaseoConfig(repoRoot: string): ReadPaseoConfigResult { - try { - const json = readPaseoConfigJson(repoRoot); - if (json === null) { - return { ok: true, config: null }; - } - return { ok: true, config: PaseoConfigSchema.parse(json) }; - } catch (error) { - return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error }; - } -} +export { readPaseoConfig, type ReadPaseoConfigResult } from "./paseo-config-file.js"; export function paseoConfigParseError(failure: { configPath: string; error: unknown }): Error { const detail = failure.error instanceof Error ? failure.error.message : String(failure.error); @@ -269,24 +261,26 @@ export function paseoConfigParseError(failure: { configPath: string; error: unkn }); } -function readPaseoConfigOrThrow(repoRoot: string): PaseoConfig | null { - const result = readPaseoConfig(repoRoot); +async function readPaseoConfigOrThrow(repoRoot: string): Promise { + const result = await readPaseoConfig(repoRoot); if (!result.ok) { throw paseoConfigParseError(result); } return result.config; } -export function getWorktreeSetupCommands(repoRoot: string): string[] { - return readPaseoConfigOrThrow(repoRoot)?.worktree?.setup ?? []; +export async function getWorktreeSetupCommands(repoRoot: string): Promise { + return (await readPaseoConfigOrThrow(repoRoot))?.worktree?.setup ?? []; } -export function getWorktreeTeardownCommands(repoRoot: string): string[] { - return readPaseoConfigOrThrow(repoRoot)?.worktree?.teardown ?? []; +export async function getWorktreeTeardownCommands(repoRoot: string): Promise { + return (await readPaseoConfigOrThrow(repoRoot))?.worktree?.teardown ?? []; } -export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] { - const terminals = readPaseoConfigOrThrow(repoRoot)?.worktree?.terminals; +export async function getWorktreeTerminalSpecs( + repoRoot: string, +): Promise { + const terminals = (await readPaseoConfigOrThrow(repoRoot))?.worktree?.terminals; if (!Array.isArray(terminals) || terminals.length === 0) { return []; } @@ -607,7 +601,7 @@ async function assertPortAvailable(port: number): Promise { async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise { try { const commonDir = await getGitCommonDir(worktreePath); - const normalizedCommonDir = normalizePathForOwnership(commonDir); + const normalizedCommonDir = await normalizePathForOwnership(commonDir); // Normal repo/worktree: common dir is /.git if (basename(normalizedCommonDir) === ".git") { return dirname(normalizedCommonDir); @@ -623,12 +617,12 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise< }); const topLevel = parseGitRevParsePath(stdout); if (topLevel) { - return normalizePathForOwnership(topLevel); + return await normalizePathForOwnership(topLevel); } } catch { // ignore } - return normalizePathForOwnership(worktreePath); + return await normalizePathForOwnership(worktreePath); } } @@ -641,7 +635,7 @@ export async function runWorktreeSetupCommands(options: { onEvent?: (event: WorktreeSetupCommandProgressEvent) => void; }): Promise { // Read paseo.json from the worktree (it will have the same content as the source repo) - const setupCommands = getWorktreeSetupCommands(options.worktreePath); + const setupCommands = await getWorktreeSetupCommands(options.worktreePath); if (setupCommands.length === 0) { return []; } @@ -711,12 +705,12 @@ export async function resolveWorktreeRuntimeEnv(options: { const branchName = options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); - let worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath); + let worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath); if (worktreePort === null) { worktreePort = await getAvailablePort(); - const metadata = readPaseoWorktreeMetadata(options.worktreePath); + const metadata = await readPaseoWorktreeMetadata(options.worktreePath); if (metadata) { - writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort }); + await writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort }); } } else { await assertPortAvailable(worktreePort); @@ -742,10 +736,10 @@ export async function runWorktreeTeardownCommands(options: { repoRootPath?: string; }): Promise { const teardownCwd = options.teardownCwd ?? options.worktreePath; - if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) { + if ((await getOwnershipRelativePath(options.worktreePath, teardownCwd)) === null) { throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`); } - const teardownCommands = getWorktreeTeardownCommands(teardownCwd); + const teardownCommands = await getWorktreeTeardownCommands(teardownCwd); if (teardownCommands.length === 0) { return []; } @@ -754,7 +748,7 @@ export async function runWorktreeTeardownCommands(options: { options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath)); const branchName = options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); - const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath); + const worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath); const teardownEnv: NodeJS.ProcessEnv = createStringCommandShellEnv( createExternalProcessEnv(process.env, { @@ -831,12 +825,12 @@ function deriveShortAlphanumericHash(value: string): string { export async function deriveWorktreeProjectHash(cwd: string): Promise { try { const commonDir = await getGitCommonDir(cwd); - const normalizedCommonDir = normalizePathForOwnership(commonDir); + const normalizedCommonDir = await normalizePathForOwnership(commonDir); const repoRoot = basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir; return deriveShortAlphanumericHash(repoRoot); } catch { - return deriveShortAlphanumericHash(normalizePathForOwnership(cwd)); + return deriveShortAlphanumericHash(await normalizePathForOwnership(cwd)); } } @@ -904,16 +898,46 @@ export function mapWorkspaceRelativeCwdToWorktree(input: { return mappedCwd; } -function normalizePathForOwnership(input: string): string { +// Keep the former realpathSync spelling on Windows: promises.realpath uses the +// native resolver and can return an 8.3 alias instead of the long path. +const resolveOwnershipPath = promisify(realpath); +const resolveNativeOwnershipPath = promisify(realpath.native); +const ownershipIdentityIo = pLimit(2); + +async function normalizePathForOwnership(input: string): Promise { try { - return realpathSync(input); + return await ownershipIdentityIo(() => resolveOwnershipPath(input)); } catch { return resolve(input); } } -function resolveRepoRootFromGitCommonDir(commonDir: string): string { - const normalizedCommonDir = normalizePathForOwnership(commonDir); +async function ownershipPathVariants(input: string): Promise { + const variants = new Set([input, await normalizePathForOwnership(input)]); + try { + variants.add(await ownershipIdentityIo(() => resolveNativeOwnershipPath(input))); + } catch { + // Missing descendants still match a lexical or established root spelling. + } + return [...variants]; +} + +// Keep lexical variants for missing paths: archive must still recognize a removed +// worktree below a symlinked worktrees root, whose full realpath no longer exists. +async function getOwnershipRelativePath(root: string, candidate: string): Promise { + const roots = await ownershipPathVariants(root); + const candidates = await ownershipPathVariants(candidate); + for (const rootPath of roots) { + for (const candidatePath of candidates) { + const relative = getRelativePathInsideRoot(rootPath, candidatePath); + if (relative !== null) return relative; + } + } + return null; +} + +async function resolveRepoRootFromGitCommonDir(commonDir: string): Promise { + const normalizedCommonDir = await normalizePathForOwnership(commonDir); return basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir; @@ -923,25 +947,25 @@ export async function isPaseoOwnedWorktreeCwd( cwd: string, options?: PaseoWorktreeOwnershipOptions, ): Promise { - const resolvedCwd = normalizePathForOwnership(cwd); + const resolvedCwd = await normalizePathForOwnership(cwd); // repoRoot is best-effort: git may be unreachable from the worktree (e.g. a // previous archive attempt removed the admin dir before the working tree // could be fully cleaned up). We still want to allow archiving in that case. let repoRoot: string | undefined; if (options?.knownGitCommonDir) { - repoRoot = resolveRepoRootFromGitCommonDir(options.knownGitCommonDir); + repoRoot = await resolveRepoRootFromGitCommonDir(options.knownGitCommonDir); } else if (options?.knownGitCommonDir === undefined) { try { const gitCommonDir = await getGitCommonDir(cwd); - repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir); + repoRoot = await resolveRepoRootFromGitCommonDir(gitCommonDir); } catch { // ignore } } const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options); - const relativePath = getRealpathAwareRelativePath(worktreesBaseRoot, resolvedCwd); + const relativePath = await getOwnershipRelativePath(worktreesBaseRoot, resolvedCwd); // Ownership is defined by the path living under //[/...]. // The / prefix is Paseo-private — nothing else writes there — so the @@ -1012,9 +1036,9 @@ function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] { return entries; } -function resolveWorktreeCreatedAtIso(worktreePath: string): string { +async function resolveWorktreeCreatedAtIso(worktreePath: string): Promise { try { - const stats = statSync(worktreePath); + const stats = await stat(worktreePath); const birthtimeMs = stats.birthtimeMs; const createdAtMs = Number.isFinite(birthtimeMs) && birthtimeMs > 0 ? birthtimeMs : stats.ctimeMs; @@ -1039,12 +1063,15 @@ export async function listPaseoWorktrees({ envOverlay: READ_ONLY_GIT_ENV, }); - return parseWorktreeList(stdout) - .map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) })) - .filter((entry) => getRealpathAwareRelativePath(projectWorktreesRoot, entry.path) !== null) - .map((entry) => - Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }), - ); + const roots = await ownershipPathVariants(projectWorktreesRoot); + const worktrees: PaseoWorktreeInfo[] = []; + // Inspect foreign paths sequentially; a large worktree list must not consume the FS pool. + for (const entry of parseWorktreeList(stdout)) { + const path = await normalizePathForOwnership(entry.path); + if (!roots.some((root) => getRelativePathInsideRoot(root, path) !== null)) continue; + worktrees.push({ ...entry, path, createdAt: await resolveWorktreeCreatedAtIso(path) }); + } + return worktrees; } export interface DeletePaseoWorktreeOptions { @@ -1083,7 +1110,7 @@ export async function deletePaseoWorktree({ } const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!); - const resolvedRequested = normalizePathForOwnership(requestedPath); + const resolvedRequested = await normalizePathForOwnership(requestedPath); const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, { paseoHome, worktreesRoot: worktreesBaseRoot, @@ -1091,7 +1118,7 @@ export async function deletePaseoWorktree({ const resolvedWorktree = ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested; - const relativeWorktreePath = getRealpathAwareRelativePath( + const relativeWorktreePath = await getOwnershipRelativePath( resolvedWorktreesRoot, resolvedWorktree, ); @@ -1207,12 +1234,12 @@ export const createWorktree = async ({ }: CreateWorktreeOptions): Promise => { const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug }); let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug); - mkdirSync(dirname(worktreePath), { recursive: true }); + await mkdir(dirname(worktreePath), { recursive: true }); // Also handle worktree path collision let finalWorktreePath = worktreePath; let pathSuffix = 1; - while (existsSync(finalWorktreePath)) { + while (await pathExists(finalWorktreePath)) { finalWorktreePath = `${worktreePath}-${pathSuffix}`; pathSuffix++; } @@ -1222,7 +1249,7 @@ export const createWorktree = async ({ cwd, timeout: 120_000, }); - worktreePath = normalizePathForOwnership(finalWorktreePath); + worktreePath = await normalizePathForOwnership(finalWorktreePath); if (sourcePlan.pushRemote) { await configureWorktreePushRemote({ @@ -1239,7 +1266,7 @@ export const createWorktree = async ({ }); } - writePaseoWorktreeMetadata(worktreePath, { + await writePaseoWorktreeMetadata(worktreePath, { baseRefName: sourcePlan.metadataBaseRefName, ...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}), ...(sourcePlan.changeRequestLookupTarget diff --git a/packages/website/package.json b/packages/website/package.json index 5574ad4a46..2166f1a22e 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.13.1", + "version": "0.13.2", "private": true, "type": "module", "scripts": { diff --git a/plugin-examples/agent-tools/README.md b/plugin-examples/agent-tools/README.md new file mode 100644 index 0000000000..e6ece3978e --- /dev/null +++ b/plugin-examples/agent-tools/README.md @@ -0,0 +1,33 @@ +# Agent tools + +Attaches `append_workspace_note` to agents whose `notes/enabled` label is `"true"`. +Calling the tool with `{ "note": "Run the targeted tests before committing." }` appends that +line to `AGENT_NOTES.md` in the calling agent's working directory and returns the file path. +It creates the file if it does not exist. + +Install on a daemon with plugins enabled: + +```bash +paseo plugin install /absolute/path/to/plugin-examples/agent-tools +``` + +Create a labeled agent through the SDK: + +```ts +const agent = await paseo.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/projects/shop", + labels: { "notes/enabled": "true" }, +}); +``` + +The session-open callback also runs when an agent resumes or reloads. After changing an existing +agent's labels, call `paseo.agents.ref(id).reload()` to apply the selection immediately. This +interrupts a running turn. `refresh()` reads the agent snapshot without reopening its session. + +From the repository root, `npm run typecheck` includes this example. Outside this repository, +use `paseo plugin init` to create the package and TypeScript setup, then copy `index.server.ts` +and the manifest into that project. + +See [Attach tools to agents](../../public-docs/plugins/reference.md#attach-tools-to-agents) +for targeting, execution, provider gates, collisions, and plugin reload behavior. diff --git a/plugin-examples/agent-tools/index.server.ts b/plugin-examples/agent-tools/index.server.ts new file mode 100644 index 0000000000..600d743fbd --- /dev/null +++ b/plugin-examples/agent-tools/index.server.ts @@ -0,0 +1,26 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; + +const appendWorkspaceNote = defineAgentTool({ + name: "append_workspace_note", + description: "Append a note to AGENT_NOTES.md in this agent's working directory.", + input: z.object({ note: z.string().min(1).describe("Note to append") }), + async execute({ note }, { agent, signal }) { + const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); + await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); + return { + text: `Saved note to ${notePath}`, + structuredContent: { path: notePath }, + }; + }, +}); + +export default function contribute(server: PluginServerContext) { + server.before("agent.session_open", ({ request }) => { + if (request.labels["notes/enabled"] !== "true") return; + return { ...request, tools: [...request.tools, appendWorkspaceNote] }; + }); + return () => {}; +} diff --git a/plugin-examples/agent-tools/paseo-plugin.json b/plugin-examples/agent-tools/paseo-plugin.json new file mode 100644 index 0000000000..eaec7ffaf6 --- /dev/null +++ b/plugin-examples/agent-tools/paseo-plugin.json @@ -0,0 +1,4 @@ +{ + "id": "agent-tools", + "requirements": { "paseo": ">=0.13.2" } +} diff --git a/plugins/package.json b/plugins/package.json index fd0a74d13b..30d9929eef 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.13.1", + "version": "0.13.2", "private": true, "type": "module", "scripts": { diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 81b99b0dd0..69608a83a7 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -711,6 +711,120 @@ server.before("agent.session_open", ({ request }) => { This runs on create, resume, refresh, and import. To inject only on creation, set `env` in an `agent.create` callback instead. +### Attach tools to agents + +Define a tool in `index.server.ts` and attach it in `before("agent.session_open")`. +This example gives `append_workspace_note` only to agents with the label `notes/enabled: "true"`: + +```ts +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; + +const appendWorkspaceNote = defineAgentTool({ + name: "append_workspace_note", + description: "Append a note to AGENT_NOTES.md in this agent's working directory.", + input: z.object({ note: z.string().min(1).describe("Note to append") }), + async execute({ note }, { agent, signal }) { + const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); + await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); + return { + text: `Saved note to ${notePath}`, + structuredContent: { path: notePath }, + }; + }, +}); + +export default function contribute(server: PluginServerContext) { + server.before("agent.session_open", ({ request }) => { + if (request.labels["notes/enabled"] !== "true") return; + return { ...request, tools: [...request.tools, appendWorkspaceNote] }; + }); + return () => {}; +} +``` + +Set the label when creating an agent with the SDK: + +```ts +const agent = await paseo.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/projects/shop", + labels: { "notes/enabled": "true" }, +}); +``` + +Select agents using any of the session-open request fields: + +| Select by | Condition in the callback | +| ---------------- | -------------------------------------------- | +| Label | `request.labels["notes/enabled"] === "true"` | +| Provider | `request.provider === "codex"` | +| Workspace | `request.workspaceId === "workspace-456"` | +| Individual agent | `request.agentId === chosenAgentId` | + +`request.tools` starts empty for each plugin. Each callback sees the tools returned by earlier +callbacks in that plugin; replace the array to add or remove tools. Returning `undefined` preserves +the request. Tools are ignored for `purpose: "history"`. + +Import `defineAgentTool`, `AgentToolDefinition`, `AgentToolContext`, and `AgentToolResult` from +`@getpaseo/plugin/server`. The helper infers the validated input from your Zod schema: + +```ts +declare function defineAgentTool(tool: { + name: string; + title?: string; + description: string; + input: Input; + execute( + input: z.output, + context: AgentToolContext, + ): AgentToolResult | Promise; +}): AgentToolDefinition; + +interface AgentToolResult { + text?: string; + structuredContent?: unknown; + isError?: boolean; +} +``` + +| Execution context | Value | +| ----------------- | --------------------------------------------------------------------------------------------------- | +| `agent` | Calling agent as a `PluginHookAgent`, including `id`, `workspaceId`, `provider`, `cwd`, and `title` | +| `paseo` | SDK connected to this daemon | +| `signal` | Abort signal for this tool call; pass it to file operations or external requests | + +`execute` runs in the plugin process. Closures and the original Zod schema stay there; only tool +metadata and call inputs/results cross the process boundary. Paseo validates input before calling +`execute`. Invalid input returns schema issues to the agent. A thrown error becomes +`{ isError: true, text: message }` and the plugin keeps running. Canceling the call or closing its +transport aborts `signal` and discards the result. Tool calls have a 10 minute ceiling, separate +from the 30 second hook timeout. Closing or reopening an agent session drops its old definitions. + +Return model-visible text in `text`. `structuredContent` must be JSON-serializable; when `text` is +omitted, Paseo formats that value as text for the model. Set `isError: true` to report a tool error. + +Paseo delivers attached tools through its agent tool transport, using MCP or native registration +as the provider supports. The same definition and callback work with either. Attached tools obey +the [same gates as Paseo tools](/docs/mcp#limit-paseo-tools-by-provider). Only that agent sees them. + +Names must match `/^[a-z][a-z0-9_]*$/` and be at most 64 characters. A name colliding with a Paseo +tool or another plugin's tool on the same agent fails the session open and names both owners. +A duplicate name within one plugin also fails the session open. Agents the callback does not +target are unaffected. + +| Change | When the agent receives it | +| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Start, stop, or reload a plugin declaring `agent.session_open` | Open sessions become stale. Each reopens before its next idle prompt; a running turn continues without interruption. The next prompt after that reopen reuses the session. | +| Change which agents the callback targets | Run `paseo plugin reload agent-tools` after editing this example, then send the agent a prompt when it is idle. | +| Apply immediately | Call `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`. `requestId` is optional. This reopens the provider session, reruns hooks with `reason: "refresh"`, and interrupts a running turn. | +| Read the current snapshot | Call `await paseo.agents.ref(id).refresh()`. This reads state without reopening the session. | + +If an automatic reopen fails, the triggering prompt fails with that error. See the +[complete agent-tools example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools). + ### Choose workspace isolation ```ts @@ -878,11 +992,11 @@ type PluginTurnOutcome = ### Before hooks -| Name | Request fields | Editable | -| -------------------- | ----------------------------------------------------------------------- | --------------------------------------- | -| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` | -| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, `reason`, `purpose`, `env` | Only `env` | -| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request | +| Name | Request fields | Editable | +| -------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` | +| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, read-only `title` and `labels`, `reason`, `purpose`, `env`, `tools` | `env` and `tools` | +| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request | **`agent.create.config`** uses `AgentSessionConfig`: @@ -903,6 +1017,9 @@ type PluginTurnOutcome = "workspaceId": "workspace-456", "provider": "codex", "cwd": "/projects/shop", + "title": "Workspace notes", + "labels": { "notes/enabled": "true" }, + "tools": [], "reason": "resume", "purpose": "interactive", "env": { "COMPANY_ENV": "development" } @@ -912,6 +1029,9 @@ type PluginTurnOutcome = | Field | Values | | ------------- | ----------------------------------------------------------------------------------------------------------------------- | | `workspaceId` | String or `null` | +| `title` | Read-only string or `null`; creation title on create, stored title on resume. | +| `labels` | Read-only string map; creation labels on create, stored labels on resume. | +| `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). | | `reason` | `create`, `resume`, `refresh`, `import` | | `purpose` | `interactive`, `history` | | `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. | @@ -923,7 +1043,7 @@ Creation request → agent.create hooks (plugin-ID order; registration order within each plugin) → resolve defaults and validate provider configuration → derive launch configuration with Paseo runtime tools and daemon prompt - → agent.session_open hooks (same ordering; env only) + → agent.session_open hooks (same ordering; env and plugin-local tools) → set PASEO_AGENT_ID and PASEO_AGENT_CWD → open provider session and save agent configuration ``` @@ -960,6 +1080,7 @@ saved; environment overrides are not persisted with it. | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | All eleven hooks; JSON logs with environment values redacted | | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection | | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options | +| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | Label-targeted tools and file operations in the plugin process | Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`. diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 62caec83e5..3c84f8cc0c 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -585,6 +585,117 @@ server.before("agent.session_open", ({ request }) => { 它会在创建、恢复、刷新和导入时运行。如果只想在创建时注入,请改为在 `agent.create` 回调中设置 `env`。 +### 为 Agent 附加工具 {#attach-tools-to-agents} + +在 `index.server.ts` 中定义工具,并在 `before("agent.session_open")` 中附加它。 +此示例只向带有 `notes/enabled: "true"` 标签的 Agent 提供 `append_workspace_note`: + +```ts +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; + +const appendWorkspaceNote = defineAgentTool({ + name: "append_workspace_note", + description: "Append a note to AGENT_NOTES.md in this agent's working directory.", + input: z.object({ note: z.string().min(1).describe("Note to append") }), + async execute({ note }, { agent, signal }) { + const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); + await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); + return { + text: `Saved note to ${notePath}`, + structuredContent: { path: notePath }, + }; + }, +}); + +export default function contribute(server: PluginServerContext) { + server.before("agent.session_open", ({ request }) => { + if (request.labels["notes/enabled"] !== "true") return; + return { ...request, tools: [...request.tools, appendWorkspaceNote] }; + }); + return () => {}; +} +``` + +通过 SDK 创建 Agent 时设置标签: + +```ts +const agent = await paseo.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/projects/shop", + labels: { "notes/enabled": "true" }, +}); +``` + +使用会话打开请求中的字段选择 Agent: + +| 选择依据 | 回调中的条件 | +| ---------- | -------------------------------------------- | +| 标签 | `request.labels["notes/enabled"] === "true"` | +| Provider | `request.provider === "codex"` | +| 工作区 | `request.workspaceId === "workspace-456"` | +| 单个 Agent | `request.agentId === chosenAgentId` | + +每个插件的 `request.tools` 都从空数组开始。同一插件中的后续回调会看到前面回调返回的工具; +替换数组即可添加或移除工具。返回 `undefined` 会保留请求。`purpose: "history"` 时会忽略工具。 + +从 `@getpaseo/plugin/server` 导入 `defineAgentTool`、`AgentToolDefinition`、`AgentToolContext` +和 `AgentToolResult`。辅助函数会根据 Zod schema 推断验证后的输入类型: + +```ts +declare function defineAgentTool(tool: { + name: string; + title?: string; + description: string; + input: Input; + execute( + input: z.output, + context: AgentToolContext, + ): AgentToolResult | Promise; +}): AgentToolDefinition; + +interface AgentToolResult { + text?: string; + structuredContent?: unknown; + isError?: boolean; +} +``` + +| 执行 context | 值 | +| ------------ | --------------------------------------------------------------------------------------------------- | +| `agent` | 发起调用的 Agent,以 `PluginHookAgent` 表示,包括 `id`、`workspaceId`、`provider`、`cwd` 和 `title` | +| `paseo` | 已连接到此 daemon 的 SDK | +| `signal` | 此次工具调用的中止信号;请把它传给文件操作或外部请求 | + +`execute` 在插件进程中运行。闭包和原始 Zod schema 保留在该进程中;只有工具元数据和调用的 +输入、结果会跨进程传递。Paseo 在调用 `execute` 前验证输入。无效输入会将 schema 问题返回给 +Agent。抛出的错误会转换为 `{ isError: true, text: message }`,插件继续运行。取消调用或关闭 +其传输连接会中止 `signal` 并丢弃结果。工具调用最长运行 10 分钟,与钩子的 30 秒超时分开。 +关闭或重新打开 Agent 会话会移除旧定义。 + +在 `text` 中返回模型可见的文本。`structuredContent` 必须可序列化为 JSON;省略 `text` 时, +Paseo 会将该值格式化为供模型读取的文本。设置 `isError: true` 可报告工具错误。 + +Paseo 通过 Agent 工具传输提供附加的工具,按 Provider 的支持情况使用 MCP 或原生注册。 +同一份定义和回调适用于这两种方式。附加的工具遵循[与 Paseo 工具相同的限制](/docs/mcp#limit-paseo-tools-by-provider)。 +只有该 Agent 能看到这些工具。 + +名称必须匹配 `/^[a-z][a-z0-9_]*$/`,且不超过 64 个字符。如果名称与同一 Agent 的 Paseo +工具或另一插件的工具冲突,会话打开会失败,并指出双方。一个插件内的重复名称也会导致 +会话打开失败。回调未选中的 Agent 不受影响。 + +| 变更 | Agent 何时收到变更 | +| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 启动、停止或重新加载声明了 `agent.session_open` 的插件 | 已打开的会话会标记为过期。每个会话在下一次空闲提示词前重新打开;正在运行的轮次会继续,不会被中断。重新打开后的下一条提示词复用该会话。 | +| 修改回调选择的 Agent | 编辑此示例后运行 `paseo plugin reload agent-tools`,然后在 Agent 空闲时发送提示词。 | +| 立即应用 | 调用 `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`。`requestId` 可选。这会重新打开 Provider 会话,以 `reason: "refresh"` 再次运行钩子,并中断正在运行的轮次。 | +| 读取当前快照 | 调用 `await paseo.agents.ref(id).refresh()`。这只读取状态,不会重新打开会话。 | + +如果自动重新打开失败,触发它的提示词会返回该错误。参阅 +[完整 agent-tools 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools)。 + ### 选择工作区隔离方式 {#choose-workspace-isolation} ```ts @@ -741,11 +852,11 @@ type PluginTurnOutcome = ### before 钩子 {#before-hooks} -| 名称 | 请求字段 | 可编辑 | -| -------------------- | ----------------------------------------------------------------------- | ----------------------------------- | -| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` | -| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, `reason`, `purpose`, `env` | 仅 `env` | -| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 | +| 名称 | 请求字段 | 可编辑 | +| -------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------- | +| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` | +| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`,只读的 `title` 和 `labels`,`reason`, `purpose`, `env`, `tools` | `env` 和 `tools` | +| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 | **`agent.create.config`** 使用 `AgentSessionConfig`: @@ -766,18 +877,24 @@ type PluginTurnOutcome = "workspaceId": "workspace-456", "provider": "codex", "cwd": "/projects/shop", + "title": "Workspace notes", + "labels": { "notes/enabled": "true" }, + "tools": [], "reason": "resume", "purpose": "interactive", "env": { "COMPANY_ENV": "development" } } ``` -| 字段 | 取值 | -| ------------- | ------------------------------------------------------------------------------------ | -| `workspaceId` | 字符串或 `null` | -| `reason` | `create`、`resume`、`refresh`、`import` | -| `purpose` | `interactive`、`history` | -| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 | +| 字段 | 取值 | +| ------------- | -------------------------------------------------------------------------------------------------------- | +| `workspaceId` | 字符串或 `null` | +| `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 | +| `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 | +| `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 | +| `reason` | `create`、`resume`、`refresh`、`import` | +| `purpose` | `interactive`、`history` | +| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 | ### 顺序与返回值 {#ordering-and-returned-values} @@ -786,7 +903,7 @@ Creation request → agent.create hooks (plugin-ID order; registration order within each plugin) → resolve defaults and validate provider configuration → derive launch configuration with Paseo runtime tools and daemon prompt - → agent.session_open hooks (same ordering; env only) + → agent.session_open hooks (same ordering; env and plugin-local tools) → set PASEO_AGENT_ID and PASEO_AGENT_CWD → open provider session and save agent configuration ``` @@ -822,6 +939,7 @@ Creation request | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | 全部十一个钩子;隐去环境变量值的 JSON 日志 | | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | 后续消息、权限、环境变量、切换 Provider、选择 worktree | | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP 注入以及 Codex 沙盒/批准选项 | +| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | 按标签选择 Agent,并在插件进程中执行文件操作的工具 | 用 `paseo plugin logs lifecycle-logger` 或主机的 `daemon.log` 读取 logger 的输出。 diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index c16e772172..c7e631c529 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; -import { readFileSync, readdirSync } from "node:fs"; +import { readFileSync, readdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { relative as relativePath } from "node:path"; import test from "node:test"; import { runInNewContext } from "node:vm"; @@ -480,14 +483,14 @@ test("Windows units and integration run independently with complete routed cover assert.equal(scripts.test, "npm run test:unit && npm run test:integration"); assert.equal(scripts["test:unit"], 'vitest run --fileParallelism --exclude "**/*.e2e.test.ts"'); const invocations = scripts["test:integration"].split(" && "); - // Each vitest start costs about half a minute on Windows, so the four + // Each vitest start costs about half a minute on Windows, so the // name-filtered files share one invocation and one combined filter. assert.equal(invocations.length, 2); assert.ok(invocations.every((command) => command.startsWith("vitest run --maxWorkers=1 "))); assert.doesNotMatch(invocations[0], / -t /); assert.equal( invocations[1], - 'vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t "merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses"', + 'vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t "merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history"', ); const serverConfig = readFileSync(new URL("packages/server/vitest.config.ts", repoRoot), "utf8"); assert.doesNotMatch(serverConfig, /maxWorkers/); @@ -530,3 +533,111 @@ test("Windows units and integration run independently with complete routed cover assert.equal(selected(integrationCondition, outputs, true), false); } }); + +test("macOS release retries only transient stapling failures without rebuilding the signed app", async (t) => { + const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8"); + const wrapper = workflow + .match(/<<'STAPLER'\n([\s\S]*?)\n STAPLER/)[1] + .replace(/^ /gm, ""); + const networkError = + 'Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."'; + const cases = [ + { + name: "success", + outcomes: [0], + output: "stapled", + command: "stapler", + expected: 0, + attempts: 1, + }, + { + name: "transient then success", + outcomes: [68, 0], + output: networkError, + command: "stapler", + expected: 0, + attempts: 2, + }, + { + name: "exhausted network retries", + outcomes: [68, 68, 68, 68, 0], + output: networkError, + command: "stapler", + expected: 68, + attempts: 4, + }, + { + name: "non-network staple error", + outcomes: [68, 0], + output: "ticket not found", + command: "stapler", + expected: 68, + attempts: 1, + }, + { + name: "different exit code", + outcomes: [1, 0], + output: networkError, + command: "stapler", + expected: 1, + attempts: 1, + }, + { + name: "notary submission is not retried", + outcomes: [68, 0], + output: networkError, + command: "notarytool", + expected: 68, + attempts: 1, + }, + ]; + for (const scenario of cases) { + await t.test(scenario.name, () => { + const directory = mkdtempSync(join(tmpdir(), "stapler-retry-")); + try { + const statePath = join(directory, "state.json"); + const realXcrun = join(directory, "real-xcrun"); + const wrapperPath = join(directory, "xcrun"); + writeFileSync(wrapperPath, wrapper, { mode: 0o755 }); + writeFileSync(join(directory, "sleep"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + writeFileSync( + statePath, + JSON.stringify({ outcomes: scenario.outcomes, output: scenario.output, calls: [] }), + ); + writeFileSync( + realXcrun, + `#!/usr/bin/env node +const fs = require("node:fs"); +const state = JSON.parse(fs.readFileSync(process.env.STAPLER_STATE, "utf8")); +state.calls.push(process.argv.slice(2)); +const code = state.outcomes.shift(); +fs.writeFileSync(process.env.STAPLER_STATE, JSON.stringify(state)); +console.log(state.output); +process.exit(code); +`, + { mode: 0o755 }, + ); + const args = [scenario.command, "staple", "-v", "Paseo signed.app"]; + const result = spawnSync("bash", [wrapperPath, ...args], { + encoding: "utf8", + env: { + ...process.env, + PASEO_REAL_XCRUN: realXcrun, + STAPLER_STATE: statePath, + PATH: `${directory}:${process.env.PATH}`, + }, + timeout: 5000, + }); + assert.equal(result.status, scenario.expected, result.stdout + result.stderr); + const state = JSON.parse(readFileSync(statePath, "utf8")); + assert.deepEqual( + state.calls, + Array.from({ length: scenario.attempts }, () => args), + ); + assert.match(result.stdout, /stapled|network connection was lost|ticket not found/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + } +});