From 287ca547494f89076acb5a282e7f337420407fd9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 26 Oct 2026 18:12:56 +0700 Subject: [PATCH 01/21] fix(app): enlarge sidebar kebab tap targets on compact layouts (#7928) On phones the project and workspace row kebabs were 24px and 18px boxes with a 14px glyph, too small to hit reliably. Compact layouts now use a 32px box and the density-sized glyph (18px); desktop sizes are unchanged. Negative margins keep the dots on the existing right rail and the rows at their 44px compact height. --- .../src/components/sidebar-workspace-list.tsx | 18 +++++++++------- .../sidebar/sidebar-workspace-menu.tsx | 21 ++++++++++++------- 2 files changed, 24 insertions(+), 15 deletions(-) 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, From 678fe308db3acfa3b58866cb7ae603a9a331a07b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 26 Oct 2026 18:35:29 +0700 Subject: [PATCH 02/21] chore(release): reconcile 0.13.2 metadata with main (#7931) * docs(release): add 0.13.2 native patch notes (cherry picked from commit 870c969fdb927f422b892841dd4b941ee7ce1aaa) * chore(release): cut 0.13.2 (cherry picked from commit d2941157a8f91512daf54fc688241d416e8f5ebd) * chore(release): refresh Nix dependency hash for 0.13.2 --- CHANGELOG.md | 9 +++ CHANGELOG.zh-CN.md | 9 +++ .../android/en-US/changelogs/130021.txt | 7 +++ .../android/en-US/changelogs/130022.txt | 7 +++ .../android/en-US/changelogs/130023.txt | 7 +++ .../android/en-US/changelogs/130024.txt | 7 +++ nix/npm-deps.hash | 2 +- package-lock.json | 56 +++++++++---------- package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 +-- packages/client/package.json | 6 +- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/plugin/package.json | 10 ++-- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 12 ++-- packages/website/package.json | 2 +- plugins/package.json | 2 +- 21 files changed, 102 insertions(+), 56 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/130021.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/130022.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/130023.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/130024.txt 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/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/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/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/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/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/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..4aeb01dc3f 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", @@ -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/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/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": { From 42d74c254652c43c03cf9bcdf7c0bb253e4706c0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 26 Oct 2026 19:20:17 +0700 Subject: [PATCH 03/21] fix(ci): retry transient Apple staple connection failures (#7933) --- .github/workflows/desktop-release.yml | 28 +++++++ scripts/ci-workflow.test.mjs | 113 +++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 1 deletion(-) 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/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index c16e772172..4842870cc3 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"; @@ -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 }); + } + }); + } +}); From e3bbed3c73fac7cbd2b5e340037ff6a9201206b8 Mon Sep 17 00:00:00 2001 From: Simon Gurcke Date: Tue, 27 Oct 2026 00:02:44 +1000 Subject: [PATCH 04/21] fix(pi): queue prompts until manual compaction finishes (#7848) * fix(pi): wait for manual compaction before sending prompts * test(pi): simplify compaction regressions and stabilize live smoke --- .../server/agent/providers/pi/agent.test.ts | 75 +++++++++++++++++++ .../src/server/agent/providers/pi/agent.ts | 14 +++- .../agent/providers/pi/test-utils/fake-pi.ts | 4 + .../src/server/daemon-e2e/pi.real.e2e.test.ts | 21 ++++-- 4 files changed, 106 insertions(+), 8 deletions(-) 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/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") From 28834a91aeb31c4058bfa92558f9b45b5e5121c0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 26 Oct 2026 23:13:25 +0700 Subject: [PATCH 05/21] fix(server): tolerate brief Windows atomic replace failures (#7955) A receipt could remain pending after provider delivery when Windows rejected the completed-state rename with EPERM. Retry transient replace errors within a one-second budget while retaining the original file and cleanup semantics. --- .../server/src/server/atomic-file.test.ts | 147 ++++++++++++++++++ packages/server/src/server/atomic-file.ts | 57 ++++++- 2 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/server/atomic-file.test.ts 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); } From 1f3266e2ba0292a63f19c8d37c978702b055b90d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 26 Oct 2026 23:56:19 +0700 Subject: [PATCH 06/21] test(server): remove connected-host delivery timing assumptions (#7961) CLI startup is not bounded by the recipient quiet window. Exercise the three delivery sources in separate windows and retain exact timeline and durable receipt assertions. Await permission-finish cleanup after dispatch acknowledgment using the integration cleanup budget. --- .../server/connected-hosts/hosts.e2e.test.ts | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) 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([]); }, From 4f9d9d6de2236486ecc2ad886a2c37f4b92271e0 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:04:42 +0000 Subject: [PATCH 07/21] Keep ACP terminal output from stalling daemon requests (#7953) * fix(acp): trim terminal output in linear time * test(acp): reproduce terminal output stalls with a larger workload The 128 KiB workload completes inside the regression deadline on faster machines even with quadratic trimming. One MiB exceeds that deadline on the old implementation while the bounded UTF-8 suffix implementation finishes promptly. Co-authored-by: liyang --------- Co-authored-by: liyang Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../server/agent/providers/acp-agent.test.ts | 40 +++++++++++++++++++ .../src/server/agent/providers/acp-agent.ts | 12 ++++-- 2 files changed, 49 insertions(+), 3 deletions(-) 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 { From 65ce4b590a09106372c555cb7322f6da73b9d419 Mon Sep 17 00:00:00 2001 From: odoo-mvds <59664044+odoo-mvds@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:54:25 +0100 Subject: [PATCH 08/21] fix(app): remove restored agent tabs from foreign workspaces (#7956) A persisted explicit pin outlives archive recovery because its agent never joins the unrelated workspace directory. Drop positively foreign pins after hydration and let existing reconciliation remove only the misplaced view. Preserve unknown ownership and legitimate historical opens. Adapts the repair proposed by kaicot in getpaseo/paseo#6901 to current main, with persisted-store and real-browser regression evidence. Refs getpaseo/paseo#6900. --- docs/qa-evidence/foreign-agent-tabs/after.png | Bin 0 -> 60096 bytes .../qa-evidence/foreign-agent-tabs/before.png | Bin 0 -> 59333 bytes .../workspace-navigation-regression.spec.ts | 82 +++++++++- .../e2e/support/helpers/workspace-layout.ts | 35 +++++ .../src/stores/workspace-layout-actions.ts | 16 +- .../workspace-tabs/agent-visibility.test.ts | 144 ++++++++++++++++++ .../src/workspace-tabs/agent-visibility.ts | 30 +++- 7 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 docs/qa-evidence/foreign-agent-tabs/after.png create mode 100644 docs/qa-evidence/foreign-agent-tabs/before.png 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 0000000000000000000000000000000000000000..77f3c92d8be4380a1d6b47249a6e99249e5cd433 GIT binary patch literal 60096 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 { 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/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 79e0b4fc76..cd727a6915 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -271,6 +271,7 @@ export interface WorkspaceTabSnapshot { terminalsHydrated: boolean; activeAgentIds: Iterable; 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)) ); } From 10bc10c99f1cccc3027ee2e7c97a23cad48bd300 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 27 Oct 2026 09:23:50 +0700 Subject: [PATCH 09/21] Let plugins give specific agents tools (#7938) * feat(plugins): attach tools to individual agent sessions * feat(plugins): reopen idle agent sessions after hook changes Defer automatic plugin changes until the next idle prompt so running turns keep steering. Expose session reload separately from snapshot refresh in the SDK. * docs(plugins): show how to attach tools to agents * test(agent): update permission follow-up adapter --- docs/plugins.md | 3 + packages/client/src/index.test.ts | 63 ++ packages/client/src/index.ts | 11 + packages/plugin/src/boundaries.test.ts | 58 +- packages/plugin/src/server/agent-tools.ts | 39 + packages/plugin/src/server/index.ts | 3 + packages/plugin/src/server/lifecycle.ts | 4 + .../server/src/server/agent/agent-manager.ts | 127 ++- .../server/src/server/agent/agent-prompt.ts | 2 + .../server/agent/create-agent/create.test.ts | 1 + .../src/server/agent/mcp-server.test.ts | 6 + .../server/agent/permission-response.test.ts | 2 + .../src/server/agent/tools/paseo-tools.ts | 11 + .../src/server/agent/tools/plugin-tools.ts | 40 + packages/server/src/server/bootstrap.ts | 7 + .../server/plugins/agent-tools.e2e.test.ts | 752 ++++++++++++++++++ .../src/server/plugins/agent-tools.test.ts | 46 ++ .../server/src/server/plugins/agent-tools.ts | 108 +++ packages/server/src/server/plugins/index.ts | 33 + .../server/plugins/lifecycle/handlers.test.ts | 31 + .../src/server/plugins/lifecycle/index.ts | 89 ++- .../server/plugins/plugin-process-protocol.ts | 24 + .../src/server/plugins/plugin-process.ts | 42 +- packages/server/src/server/plugins/runtime.ts | 141 +++- .../server/src/server/schedule/service.ts | 1 + .../server/test-utils/fake-agent-client.ts | 13 + plugin-examples/agent-tools/README.md | 33 + plugin-examples/agent-tools/index.server.ts | 26 + plugin-examples/agent-tools/paseo-plugin.json | 4 + public-docs/plugins/reference.md | 133 +++- public-docs/plugins/reference.zh-CN.md | 142 +++- 31 files changed, 1937 insertions(+), 58 deletions(-) create mode 100644 packages/plugin/src/server/agent-tools.ts create mode 100644 packages/server/src/server/agent/tools/plugin-tools.ts create mode 100644 packages/server/src/server/plugins/agent-tools.e2e.test.ts create mode 100644 packages/server/src/server/plugins/agent-tools.test.ts create mode 100644 packages/server/src/server/plugins/agent-tools.ts create mode 100644 plugin-examples/agent-tools/README.md create mode 100644 plugin-examples/agent-tools/index.server.ts create mode 100644 plugin-examples/agent-tools/paseo-plugin.json 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/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/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/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/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/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/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/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 2eeccfb653..2bbde7de34 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -995,6 +995,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 +1016,10 @@ export async function createPaseoDaemon( const unsubscribePluginProviders = pluginRuntime.subscribeProviderRegistrations(syncPluginProviders); + const unsubscribePluginSessionChanges = pluginRuntime.subscribeSessionOpenChanges(() => { + agentManager.invalidatePluginSessions(); + }); + const detachAgentStoragePersistence = attachAgentStoragePersistence( logger, agentManager, @@ -1914,6 +1919,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 +1939,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/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/index.ts b/packages/server/src/server/plugins/index.ts index a5ecea8097..8385dc2122 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"]; @@ -131,6 +136,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?.(); } 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-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/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 9886a368e7..f5a644ef9d 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -1,10 +1,16 @@ +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"; @@ -40,6 +46,7 @@ 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 +76,7 @@ interface PendingInvocation { interface LoadedPlugin { id: string; + instanceId: string; requirements: PluginRequirements | undefined; clientBundle: string; methods: ReadonlySet; @@ -302,6 +310,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 +333,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, @@ -340,6 +359,7 @@ export class PluginRuntime { throw new Error(`Plugin start cancelled: ${pluginId}`); } this.plugins.set(pluginId, loaded); + this.notifySessionOpenChange(loaded); this.appendLog(pluginId, "stdout", "[paseo] Plugin ready"); } @@ -363,6 +383,7 @@ export class PluginRuntime { clientBundle: bundles.clientBundle ?? "", }); this.plugins.set(input.id, loaded); + this.notifySessionOpenChange(loaded); this.appendLog(input.id, "stdout", "[paseo] Plugin ready"); } @@ -378,6 +399,7 @@ export class PluginRuntime { 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 +568,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 +587,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 +623,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 +729,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))); @@ -641,6 +748,7 @@ export class PluginRuntime { if (!serverBundle) { return { id: pluginId, + instanceId: randomUUID(), clientBundle: bundles.clientBundle ?? "", requirements: manifest.requirements, methods: new Set(), @@ -769,6 +877,7 @@ export class PluginRuntime { } loaded = { id: pluginId, + instanceId: randomUUID(), clientBundle, requirements, methods: new Set(ready.methods), @@ -852,7 +961,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 +1282,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/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/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/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/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 的输出。 From 64b0afe0107b4b9b26f6fc404ac51f03678ae41f Mon Sep 17 00:00:00 2001 From: david828019-oss Date: Tue, 27 Oct 2026 10:38:49 +0800 Subject: [PATCH 10/21] fix(claude): identify SDK processes with the Paseo entrypoint (#7976) Co-authored-by: QTom --- .../agent/providers/claude/agent.env.test.ts | 45 ++++++++++++++++++- .../server/agent/providers/claude/query.ts | 11 ++--- 2 files changed, 48 insertions(+), 8 deletions(-) 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/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; From fc0cbbb32391a85b190130f267a8903a61b1b7a3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 27 Oct 2026 09:56:21 +0700 Subject: [PATCH 11/21] fix(server): keep workspace reconciliation responsive on slow paths (#7941) * fix(server): keep workspace reconciliation responsive on slow paths Bound asynchronous directory inspection without releasing timed-out filesystem slots. Preserve unavailable records and make workspace archival atomic and idempotent so concurrent user archival cannot be repeated by a scan. * fix(server): wait for healthy reconciliation inspection capacity --- .../server/src/server/websocket-server.ts | 2 +- ...rkspace-reconciliation-observation.test.ts | 4 + .../workspace-reconciliation-service.test.ts | 449 +++++++++++++++++- .../workspace-reconciliation-service.ts | 269 ++++++++--- .../src/server/workspace-registry.test.ts | 26 +- .../server/src/server/workspace-registry.ts | 47 +- 6 files changed, 683 insertions(+), 114 deletions(-) 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-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.test.ts b/packages/server/src/server/workspace-registry.test.ts index 6c27027edc..d72da7c072 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -474,7 +474,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 +488,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 { From 8d971876bf720cce616d8686439894c57b127cc1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 27 Oct 2026 09:56:27 +0700 Subject: [PATCH 12/21] fix(omp): keep transcript replay responsive (#7948) * fix(omp): keep transcript replay responsive Index transcript branches in bounded asynchronous chunks and retain payloads only for the active chain, so large histories do not block unrelated daemon requests. * test(omp): enforce responsive history replay in CI Use a smaller structured transcript and a short selected chain to expose whole-file parsing without making large-output GC part of the CI gate. Disable unrelated provider discovery and route the regression through the existing integration job. * test(ci): include OMP replay in the routing contract --- packages/server/package.json | 2 +- .../server/agent/providers/omp/agent.test.ts | 11 ++ .../providers/omp/history-mapper.test.ts | 53 +++++- .../agent/providers/omp/history.e2e.test.ts | 104 ++++++++++++ .../src/server/agent/providers/omp/history.ts | 154 +++++++++++++++--- scripts/ci-workflow.test.mjs | 4 +- 6 files changed, 302 insertions(+), 26 deletions(-) create mode 100644 packages/server/src/server/agent/providers/omp/history.e2e.test.ts diff --git a/packages/server/package.json b/packages/server/package.json index 4aeb01dc3f..f39d284343 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", 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/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 4842870cc3..c7e631c529 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -483,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/); From 9c7d4ace653aee635c33358e7034419b7464acb9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 27 Oct 2026 09:56:31 +0700 Subject: [PATCH 13/21] fix(server): reject oversized provider stdout before parsing (#7960) Admit raw UTF-8 line bytes before accumulation and fail the provider session explicitly on overflow. Share line framing so fragmented JSONL input no longer repeatedly scans and copies the complete prefix. --- .../codex/app-server-transport.test.ts | 26 ++++++ .../providers/codex/app-server-transport.ts | 72 ++++++++--------- .../providers/jsonl-frame-decoder.test.ts | 66 +++++++++++++++ .../agent/providers/jsonl-frame-decoder.ts | 81 ++++++++++++++++--- .../agent/providers/jsonl-rpc-process.test.ts | 22 +++++ .../agent/providers/jsonl-rpc-process.ts | 23 ++++-- .../src/server/workspace-registry.test.ts | 29 +++++++ 7 files changed, 263 insertions(+), 56 deletions(-) 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/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index d72da7c072..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; From 6077bc6219c0e25003ec84a331c5f75e6cea42af Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 27 Oct 2026 09:56:36 +0700 Subject: [PATCH 14/21] Keep daemon diff highlighting responsive and bounded (#7945) * fix(server): keep diff highlighting responsive and bounded Tiny patches previously parsed both full source versions synchronously. Yield between parser work units and admit source by UTF-8 bytes, keeping large patches renderable without tokens. Cancel obsolete highlighting when a newer diff supersedes it. * fix(server): keep diff cancellation with its requester Independent commit viewers must not cancel each other. Use request-source cancellation for commit highlighting and cache invalidation for checkout highlighting. Oversized source versions use the existing budgeted hunk reconstruction so large lockfiles retain useful syntax tokens. --- .../src/__tests__/highlighter.test.ts | 54 ++++++ .../highlight/src/budgeted-highlighter.ts | 49 +++++ packages/highlight/src/index.ts | 1 + .../src/server/checkout-diff-cache.test.ts | 19 ++ .../server/src/server/checkout-diff-cache.ts | 12 +- .../checkout-diff-content.e2e.test.ts | 78 +++++++- packages/server/src/server/session.ts | 2 +- .../session/checkout/checkout-session.ts | 7 +- .../src/server/utils/diff-highlighter.test.ts | 155 +++++++++++++++- .../src/server/utils/diff-highlighter.ts | 171 +++++++++++++----- .../src/server/workspace-git-service.ts | 15 +- .../checkout-git.commit-file-diff.test.ts | 42 +++++ packages/server/src/utils/checkout-git.ts | 27 ++- 13 files changed, 567 insertions(+), 65 deletions(-) create mode 100644 packages/highlight/src/budgeted-highlighter.ts 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/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/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/session.ts b/packages/server/src/server/session.ts index f573b1dd95..db4939aa31 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2964,7 +2964,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": 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/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/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index dcd2ead214..493daa1ddd 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -815,11 +815,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, + ), }); } 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.ts b/packages/server/src/utils/checkout-git.ts index fbbb494b4a..a39b6445e2 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -2719,10 +2719,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 +2752,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 +3135,7 @@ export function warmCheckoutShortstatInBackground( } interface AppendStructuredTrackedDiffsInput { + signal?: AbortSignal; cwd: string; trackedChanges: CheckoutFileChange[]; trackedNumstatByPath: Map; @@ -3147,6 +3151,7 @@ interface AppendStructuredTrackedDiffsInput { } async function buildHighlightedTrackedDiffFile(input: { + signal?: AbortSignal; cwd: string; change: CheckoutFileChange; parsedFile: ParsedDiffFile; @@ -3169,6 +3174,7 @@ async function buildHighlightedTrackedDiffFile(input: { : null, ]); const highlightedFile = await highlightDiffWithFileContent(parsedFile, cwd, { + signal: input.signal, oldFileContent, newFileContent, }); @@ -3254,6 +3260,7 @@ async function appendStructuredTrackedDiffs( const parsedFile = parsedTrackedByPath.get(change.path); if (parsedFile) { const file = await buildHighlightedTrackedDiffFile({ + signal: input.signal, cwd, change, parsedFile, @@ -3295,6 +3302,7 @@ async function appendStructuredTrackedDiffs( } interface ProcessUntrackedChangeInput { + signal?: AbortSignal; cwd: string; change: CheckoutFileChange; ignoreWhitespace: boolean; @@ -3345,7 +3353,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 +3549,7 @@ export async function getCheckoutDiff( cwd: string, compare: CheckoutDiffCompare, context?: CheckoutContext, + signal?: AbortSignal, ): Promise { cwd = await requireGitWorktreeRoot(cwd); @@ -3552,10 +3561,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 +3579,7 @@ async function readCheckoutDiff( cwd: string, compare: CheckoutDiffCompare, refsForDiff: CheckoutDiffRefs, + signal?: AbortSignal, ): Promise { const ignoreWhitespace = compare.ignoreWhitespace === true; let effectiveRefsForDiff = refsForDiff; @@ -3625,6 +3640,7 @@ async function readCheckoutDiff( if (compare.includeStructured) { const didAppendTrackedDiffs = await appendStructuredTrackedDiffs({ + signal, cwd, trackedChanges, trackedNumstatByPath: trackedDiff.trackedNumstatByPath, @@ -3652,6 +3668,7 @@ async function readCheckoutDiff( break; } const didAppendUntrackedDiff = await processUntrackedChange({ + signal, cwd, change, ignoreWhitespace, From a25f4f506541ebadcb3175be5ee013fcf1ca4dbc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 27 Oct 2026 09:56:42 +0700 Subject: [PATCH 15/21] perf(server): serve timeline pages from retained projection (#7958) * perf(server): select timeline pages from retained projection Keep page selection and source coverage indexes with the retained projection so bounded fetches and last-message lookups do not copy or reproject history. Omitted after limits use the existing 200-item store default; explicit zero remains unlimited for released CLI clients. * test(server): keep timeline page oracle outside production builds --- .../server/agent/agent-timeline-store.test.ts | 153 ++++++++ .../src/server/agent/agent-timeline-store.ts | 23 +- .../test-utils/timeline-page-reference.ts | 249 +++++++++++++ .../server/agent/timeline-projection.test.ts | 2 +- .../src/server/agent/timeline-projection.ts | 330 ++++++++++-------- .../daemon-e2e/cli-logs-follow.e2e.test.ts | 50 +++ packages/server/src/server/session.ts | 4 +- 7 files changed, 638 insertions(+), 173 deletions(-) create mode 100644 packages/server/src/server/agent/test-utils/timeline-page-reference.ts 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/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/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/session.ts b/packages/server/src/server/session.ts index db4939aa31..da7bf99b72 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -7787,8 +7787,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 +7805,7 @@ export class Session { const fetchedControlTimeline = this.agentManager.fetchTimeline(msg.agentId, { direction, cursor, - limit: pageLimit, + limit: msg.limit, }); const selectedTimeline = { timeline: fetchedControlTimeline, From f14a4cc112583f317c44114f7ad5fa7e28e101f8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 27 Oct 2026 09:56:47 +0700 Subject: [PATCH 16/21] fix(server): keep password-protected config reload responsive (#7959) * fix(server): keep password-protected config reload responsive Resolve authentication only during startup. Runtime config writes and reloads share an async queue so durable state and live owners stay ordered, including overlapping plugin changes and rollback. * test(server): adapt config consumers to async service-port reads Provide the service-port capability in the existing Session and tool-bridge fixtures. Use the platform temporary directory for the plugin agent fixture so its working directory exists on Windows. --- .../mutable-provider-config-owner.test.ts | 26 +- .../src/server/agent/plugin-provider.test.ts | 2 +- .../opencode/bridge-input-errors.test.ts | 1 + packages/server/src/server/bootstrap.test.ts | 12 +- packages/server/src/server/bootstrap.ts | 18 +- packages/server/src/server/config.test.ts | 31 +- packages/server/src/server/config.ts | 131 +++++---- .../src/server/daemon-config-store.test.ts | 264 ++++++++++-------- .../server/src/server/daemon-config-store.ts | 62 +++- .../internal/selection-store.test.ts | 2 +- .../internal/selection-store.ts | 2 +- .../src/server/persisted-config.test.ts | 90 +++--- .../server/src/server/persisted-config.ts | 82 +++++- .../src/server/plugins/index.posix.test.ts | 53 +++- packages/server/src/server/plugins/index.ts | 80 ++++-- .../plugins/plugin-paseo-api.e2e.test.ts | 57 ++++ packages/server/src/server/private-files.ts | 28 ++ packages/server/src/server/session.test.ts | 1 + packages/server/src/server/session.ts | 21 +- .../session/daemon/daemon-session.test.ts | 16 +- .../server/session/daemon/daemon-session.ts | 10 +- .../workspace-scripts-service.test.ts | 22 ++ .../workspace-scripts-service.ts | 5 +- .../terminal-agent-hook-setting.test.ts | 6 +- 24 files changed, 707 insertions(+), 315 deletions(-) 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/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/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/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 2bbde7de34..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, }; }, }, @@ -1541,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, 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/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/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/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/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 8385dc2122..98e7ce5623 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -97,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>(); @@ -298,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); @@ -361,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); @@ -461,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); @@ -475,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; @@ -488,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); @@ -564,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 && @@ -777,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; @@ -819,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/plugin-paseo-api.e2e.test.ts b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts index 5da355d5df..729b36fa1c 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,6 @@ +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 +201,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-")); 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/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 da7bf99b72..9f8dcafc95 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": 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/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index 18ae1f1366..0e876fd881 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,6 +156,24 @@ 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 }); 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..54962f5076 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 @@ -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, @@ -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); 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); }); }); From 67a2459254cfde9d9b186215b7268de12aaa6537 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 27 Oct 2026 09:56:51 +0700 Subject: [PATCH 17/21] fix(server): keep project config work off the daemon hot path (#7944) Project settings and script health updates synchronously read unbounded user files. Share admitted async snapshots across consumers, budget script validation, and serialize revision-checked edits while keeping lexical project identity. --- .../server/script-status-projection.test.ts | 82 +++-- .../src/server/script-status-projection.ts | 18 +- packages/server/src/server/session.ts | 4 +- .../project-config-session.test.ts | 2 +- .../project-config/project-config-session.ts | 51 +-- .../workspace-scripts-service.test.ts | 26 +- .../workspace-scripts-service.ts | 14 +- .../server/src/server/worktree-bootstrap.ts | 4 +- .../server/src/server/worktree-session.ts | 2 +- .../server/src/utils/build-metadata-prompt.ts | 11 +- .../src/utils/paseo-config-file.test.ts | 232 ++++++++++-- .../server/src/utils/paseo-config-file.ts | 343 ++++++++++++++---- .../server/src/utils/worktree.posix.test.ts | 30 +- packages/server/src/utils/worktree.ts | 42 +-- 14 files changed, 615 insertions(+), 246 deletions(-) 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.ts b/packages/server/src/server/session.ts index 9f8dcafc95..9d25328079 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5669,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), @@ -7262,7 +7262,7 @@ export class Session { private buildWorkspaceScriptPayloadSnapshot( workspace: PersistedWorkspaceRecord, project: PersistedProjectRecord | null, - ): WorkspaceDescriptorPayload["scripts"] { + ): Promise { return this.workspaceScripts.buildSnapshot(workspace, project); } 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-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index 0e876fd881..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 @@ -178,14 +178,20 @@ 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([]); }); @@ -194,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([]); }); @@ -229,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, @@ -374,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: { @@ -471,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 54962f5076..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; @@ -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"); @@ -203,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) { @@ -231,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/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-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/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/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/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index a550995fb3..7819e2275c 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -48,8 +48,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; } @@ -894,7 +894,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 +963,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 +974,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 +999,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 +1134,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 +1154,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 +1172,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 +1196,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 +1236,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 +1547,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"); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index ca921fb219..dd6dfe7a44 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -10,7 +10,7 @@ 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 +20,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, @@ -246,21 +246,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 +255,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 []; } @@ -641,7 +629,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 []; } @@ -745,7 +733,7 @@ export async function runWorktreeTeardownCommands(options: { if (getRealpathAwareRelativePath(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 []; } From 9bc998fa38e91b76df469f142fbdcdfd5dbaaa59 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 27 Oct 2026 09:57:35 +0700 Subject: [PATCH 18/21] fix(server): keep Git observation and worktree IO off the event loop (#7943) * fix(server): keep Git observation and worktree IO off the event loop Resolve subscription path identities before callbacks and serialize async worktree metadata mutations by canonical ownership. Git command tracing must not probe the working directory before spawning. * test(server): verify Git trace records without filesystem probes * fix(server): establish Git identities at observation setup Read-only checkout refreshes should not resolve watcher namespaces. Observation tests await setup and compare cross-workspace fan-out without depending on completion order. * fix(server): retain literal symlink targets in Git event identity Canonical realpaths can erase a symlink target spelling when its parent is also a symlink, such as /var on macOS. Resolve these aliases once during setup so deleted and ignored event paths still match without per-event IO. * fix(server): preserve Windows worktree path spelling * fix(server): retain native aliases in worktree ownership * test(server): write Git IO trace markers asynchronously --- .../src/server/git-observation-paths.ts | 106 +++++ .../src/server/paseo-worktree-service.test.ts | 10 +- .../src/server/paseo-worktree-service.ts | 16 +- .../src/server/session.workspaces.test.ts | 7 +- .../workspace-recovery-service.test.ts | 6 +- ...rkspace-create-worktree-source.e2e.test.ts | 1 + ...it-service.observation.integration.test.ts | 180 ++++++++ .../workspace-git-service.observation.test.ts | 23 +- .../workspace-git-service.primitive.test.ts | 4 +- .../src/server/workspace-git-service.ts | 124 +++--- .../server/workspace-registry-bootstrap.ts | 2 +- .../worktree-branch-name-generator.test.ts | 4 +- .../src/server/worktree-core.posix.test.ts | 4 +- .../src/utils/checkout-git.commits.test.ts | 2 +- .../server/src/utils/checkout-git.test.ts | 26 +- packages/server/src/utils/checkout-git.ts | 40 +- packages/server/src/utils/path.ts | 4 +- .../server/src/utils/run-git-command.test.ts | 5 +- packages/server/src/utils/run-git-command.ts | 2 - .../server/src/utils/worktree-metadata.ts | 400 +++++++++++------- .../server/src/utils/worktree.posix.test.ts | 131 +++++- packages/server/src/utils/worktree.test.ts | 11 +- packages/server/src/utils/worktree.ts | 109 +++-- 23 files changed, 906 insertions(+), 311 deletions(-) create mode 100644 packages/server/src/server/git-observation-paths.ts 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/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/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/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/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 493daa1ddd..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; @@ -1170,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) { @@ -1184,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); @@ -1203,6 +1204,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private createWorkspaceTarget(cwd: string): WorkspaceGitTarget { const target: WorkspaceGitTarget = { + gitDirPaths: null, cwd, listeners: new Set(), workingTreeWatchTarget: null, @@ -1304,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; @@ -1336,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(() => { @@ -1364,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, @@ -1528,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 }, @@ -1768,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; } @@ -1960,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 { @@ -2036,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) { @@ -2157,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]), @@ -2210,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; @@ -2228,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; @@ -2254,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 @@ -2367,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; @@ -2382,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; @@ -2395,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); } @@ -2412,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") || @@ -2424,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": @@ -2481,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; @@ -2490,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") { @@ -2505,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-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/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/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 a39b6445e2..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)); 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 7819e2275c..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, @@ -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", @@ -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 dd6dfe7a44..3ddc71b472 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,11 +1,12 @@ 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, @@ -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"; @@ -595,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); @@ -611,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); } } @@ -699,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); @@ -730,7 +736,7 @@ 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 = await getWorktreeTeardownCommands(teardownCwd); @@ -742,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, { @@ -819,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)); } } @@ -892,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; @@ -911,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 @@ -1000,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; @@ -1027,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 { @@ -1071,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, @@ -1079,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, ); @@ -1195,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++; } @@ -1210,7 +1249,7 @@ export const createWorktree = async ({ cwd, timeout: 120_000, }); - worktreePath = normalizePathForOwnership(finalWorktreePath); + worktreePath = await normalizePathForOwnership(finalWorktreePath); if (sourcePlan.pushRemote) { await configureWorktreePushRemote({ @@ -1227,7 +1266,7 @@ export const createWorktree = async ({ }); } - writePaseoWorktreeMetadata(worktreePath, { + await writePaseoWorktreeMetadata(worktreePath, { baseRefName: sourcePlan.metadataBaseRefName, ...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}), ...(sourcePlan.changeRequestLookupTarget From 7f6a805e0e011c44b0596054bc9bbf600a1ca30c Mon Sep 17 00:00:00 2001 From: Luke LaBonte Date: Mon, 26 Oct 2026 22:05:16 -0500 Subject: [PATCH 19/21] feat(app): choose the composer send shortcut (#5503) Add a client-local Send shortcut selector with Enter and Command/Ctrl+Enter choices. Preserve Enter as the default and route the selected shortcut through the existing send behavior. Cover selection, persistence, editing, and running-agent send behavior with browser tests. Co-authored-by: Luke LaBonte --- .../settings-toggle-tab-regression.spec.ts | 121 ++++++++++ packages/app/src/composer/index.tsx | 1 + packages/app/src/composer/input/input.tsx | 38 +++- packages/app/src/composer/input/state.test.ts | 208 ++++++++++++++++++ packages/app/src/composer/input/state.ts | 24 ++ packages/app/src/hooks/use-settings/index.ts | 2 + .../src/hooks/use-settings/storage.test.ts | 33 +++ .../app/src/hooks/use-settings/storage.ts | 5 + packages/app/src/i18n/resources/ar.ts | 13 ++ packages/app/src/i18n/resources/en.ts | 14 ++ packages/app/src/i18n/resources/es.ts | 15 ++ packages/app/src/i18n/resources/fr.ts | 15 ++ packages/app/src/i18n/resources/ja.ts | 13 ++ packages/app/src/i18n/resources/ko.ts | 15 ++ packages/app/src/i18n/resources/pt-BR.ts | 15 ++ packages/app/src/i18n/resources/ru.ts | 15 ++ packages/app/src/i18n/resources/zh-CN.ts | 12 + .../settings/general/sending-section.tsx | 33 ++- 18 files changed, 586 insertions(+), 6 deletions(-) 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/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} Date: Tue, 27 Oct 2026 10:23:59 +0700 Subject: [PATCH 20/21] Keep the daemon responsive during Claude history replay (#7940) * fix(server): keep Claude history replay responsive Load persisted history through session readiness rather than the constructor. Async descriptor-owned replay preserves ordering while serving other requests, and rebind retires the old query before rebuilding history and rewind state. * fix(server): recover Claude history readiness after rebind errors * test(server): calibrate Claude replay fixture for CI * fix(server): report Claude rewind rebind failures to callers --- .../claude/agent.history-load.test.ts | 335 ++++++++++- .../server/agent/providers/claude/agent.ts | 258 +++++--- .../agent/providers/claude/project-dir.ts | 26 +- .../server/agent/providers/claude/rewind.ts | 6 +- .../claude/subagents/replay-source.test.ts | 568 ++++++++++-------- .../claude/subagents/replay-source.ts | 14 +- .../subagents/workflow-replay-source.test.ts | 26 +- .../subagents/workflow-replay-source.ts | 31 +- .../claude/task-notification-tool-call.ts | 6 +- .../providers/claude/transcript-history.ts | 107 ++-- 10 files changed, 920 insertions(+), 457 deletions(-) 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 201fa349eb..734272dd02 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; @@ -2382,7 +2388,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; @@ -2462,6 +2468,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"); } @@ -2632,6 +2639,7 @@ class ClaudeAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { + await this.ensureHistoryReady(); if ( !this.historyPending || (this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0) @@ -2643,7 +2651,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, @@ -2961,6 +2971,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(); @@ -3001,7 +3012,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 }); @@ -3045,6 +3056,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(); @@ -3054,16 +3066,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(), @@ -3238,38 +3249,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; @@ -3390,7 +3415,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)) { @@ -3398,37 +3459,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 @@ -4095,13 +4126,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--; @@ -4221,7 +4254,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; } @@ -4255,7 +4292,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; @@ -4290,11 +4327,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, @@ -5272,22 +5309,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( @@ -5299,12 +5345,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); } @@ -5314,27 +5360,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 @@ -5421,10 +5473,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), @@ -6191,13 +6243,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) { @@ -6729,7 +6781,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. @@ -6829,3 +6881,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/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; + } +} From 9e4aab16cc74f945f9bb676fcab3d8a9a8b686ca Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 27 Oct 2026 10:30:41 +0700 Subject: [PATCH 21/21] fix(server): keep the daemon responsive during plugin builds (#7967) * fix(server): keep plugin compilation off the daemon event loop TypeScript import validation runs in esbuild callbacks on the daemon thread. Isolate preparation and compilation behind one bounded compiler queue, returning bundles and metadata only after runtime-boundary validation. Recheck lifecycle intent before executing a compiled plugin. * fix(server): reuse the compiler worker during plugin build batches Amortize TypeScript startup across built-ins and queued builds, then retire the worker after one idle second. Carry preparation environments explicitly across worker reuse. Keep dependency-backed example coverage with its existing app fixture. * fix(server): retire the compiler worker after build errors A failed parallel target can leave the other target compiling. Await worker termination before the next queued request instead of reusing an isolate with unfinished build work. * fix(server): preserve compiler error metadata across workers * test(server): isolate plugin validation in the loop-gap fixture Exercise erased TypeScript syntax with a small runtime bundle so parser validation, not multi-MiB result transfer, determines the CI fixture. Keep the 20 ms gate and report event-loop active/idle time for scheduler diagnostics. Resolve the development loader at module initialization. * test(server): probe event-loop turns during plugin compilation Windows timer wake-up lateness measured 22 ms while the loop was idle for 21.96 ms. Use check-phase callbacks to measure loop availability, retaining the raw 20 ms limit and the failing base-branch reproduction. * test: run plugin responsiveness probe in serial integration suite * test: diagnose compiler probe gaps with per-thread CPU readings * test: distinguish compiler CPU blocking from runner scheduling * test: classify the largest compiler CPU accounting window * test: isolate compiler responsiveness from harness GC and idle accounting * test: measure callback blocking with a high-resolution clock --- .../src/server/plugins/compiler-build.ts | 431 +++++++++++++ .../src/server/plugins/compiler-esbuild.ts | 64 ++ .../src/server/plugins/compiler-worker.ts | 62 ++ .../src/server/plugins/compiler.test.ts | 51 ++ .../server/src/server/plugins/compiler.ts | 564 +++++------------- .../plugins/plugin-paseo-api.e2e.test.ts | 24 + .../src/server/plugins/preparation-build.ts | 76 +++ .../src/server/plugins/preparation.test.ts | 48 ++ .../server/src/server/plugins/preparation.ts | 68 +-- .../src/server/plugins/runtime.posix.test.ts | 55 +- packages/server/src/server/plugins/runtime.ts | 72 +-- .../compile-responsiveness.e2e.ts | 187 ++++++ 12 files changed, 1155 insertions(+), 547 deletions(-) create mode 100644 packages/server/src/server/plugins/compiler-build.ts create mode 100644 packages/server/src/server/plugins/compiler-esbuild.ts create mode 100644 packages/server/src/server/plugins/compiler-worker.ts create mode 100644 packages/server/src/server/plugins/preparation-build.ts create mode 100644 packages/server/src/server/plugins/test-fixtures/compile-responsiveness.e2e.ts 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/plugin-paseo-api.e2e.test.ts b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts index 729b36fa1c..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,6 @@ +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"; @@ -522,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/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 f5a644ef9d..bc06d5d991 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -13,7 +13,6 @@ import type { } 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"; @@ -28,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, @@ -43,8 +40,6 @@ 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; @@ -273,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>(); @@ -350,10 +316,16 @@ 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}`); @@ -367,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, @@ -388,11 +356,7 @@ export class PluginRuntime { } 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 { @@ -738,12 +702,12 @@ 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 { 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));