mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
34 Commits
refactor-t
...
v0.1.73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4141c76258 | ||
|
|
7f44323686 | ||
|
|
152b07b599 | ||
|
|
84f36d2e20 | ||
|
|
25d4c5023a | ||
|
|
3f5acfff31 | ||
|
|
b9940e285c | ||
|
|
9993c6c6c3 | ||
|
|
3b7971a463 | ||
|
|
d75d2d857d | ||
|
|
cab42985a5 | ||
|
|
ef892bd27d | ||
|
|
3014576c4c | ||
|
|
b8c77bf0e3 | ||
|
|
17073fe8ff | ||
|
|
6220b47073 | ||
|
|
bf7f8f686b | ||
|
|
93cd4734ce | ||
|
|
e4acd6cb7a | ||
|
|
ca11fc667b | ||
|
|
36e54a097e | ||
|
|
ecd3137d34 | ||
|
|
f881f9ae32 | ||
|
|
3f6b84899a | ||
|
|
5e64a1340c | ||
|
|
2d0ed004e2 | ||
|
|
183cda2b66 | ||
|
|
ed2a97fda8 | ||
|
|
2fed0f09bb | ||
|
|
478aa4b70e | ||
|
|
fd74abcdca | ||
|
|
444e265275 | ||
|
|
2ee9329663 | ||
|
|
b30aafc2bd |
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -5,8 +5,13 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.73 - 2026-05-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OpenCode agents work again on OpenCode 1.14.42+.** ([#895](https://github.com/getpaseo/paseo/pull/895), [#902](https://github.com/getpaseo/paseo/pull/902), [#904](https://github.com/getpaseo/paseo/pull/904) by [@atomlink-ye](https://github.com/atomlink-ye), [@plutofog](https://github.com/plutofog))
|
||||
- Web: opening a workspace no longer hangs in browsers without `crypto.randomUUID`. ([#858](https://github.com/getpaseo/paseo/pull/858) by [@cokekitten](https://github.com/cokekitten))
|
||||
- Codex sub-agent child tool calls now report a final failure state instead of staying as "running". ([#899](https://github.com/getpaseo/paseo/pull/899))
|
||||
- Old relay pairing URLs without an explicit TLS flag work again. ([#896](https://github.com/getpaseo/paseo/pull/896))
|
||||
- macOS: the tab-jump shortcut no longer collides with system shortcuts. ([#859](https://github.com/getpaseo/paseo/pull/859) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Web: the composer no longer triggers a bottom-sheet keyboard on desktop browsers. ([#898](https://github.com/getpaseo/paseo/pull/898) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Windows: git operations no longer flash a console window on each invocation. ([#897](https://github.com/getpaseo/paseo/pull/897))
|
||||
- File explorer no longer follows symlinks outside the workspace root. ([#847](https://github.com/getpaseo/paseo/pull/847) by [@joaosa](https://github.com/joaosa))
|
||||
- Desktop only opens external URLs via http(s) and mailto schemes. ([#845](https://github.com/getpaseo/paseo/pull/845) by [@joaosa](https://github.com/joaosa))
|
||||
- MCP debug request logs now redact request bodies. ([#842](https://github.com/getpaseo/paseo/pull/842) by [@joaosa](https://github.com/joaosa))
|
||||
|
||||
## 0.1.72 - 2026-05-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Codex approval prompts no longer hang.** Fixes a regression introduced in 0.1.70 where Codex agents would wait forever on command and file approvals — the prompt never reached the app and the agent stayed stuck in "running". ([#866](https://github.com/getpaseo/paseo/pull/866), [#869](https://github.com/getpaseo/paseo/pull/869))
|
||||
- **Windows: daemon no longer crashes when Codex emits non-JSON output.** Localized stdout lines from the Codex CLI are now ignored instead of taking down the daemon worker. ([#866](https://github.com/getpaseo/paseo/pull/866))
|
||||
- Drag-and-drop images onto the new workspace screen now works. ([#850](https://github.com/getpaseo/paseo/pull/850))
|
||||
- Archiving a worktree from the toolbar redirects you immediately instead of leaving you on the dead screen for a beat. ([#852](https://github.com/getpaseo/paseo/pull/852))
|
||||
- Pi-backed sessions now shut down cleanly when you close them, releasing extension resources on the Pi side. ([#863](https://github.com/getpaseo/paseo/pull/863))
|
||||
|
||||
## 0.1.71 - 2026-05-09
|
||||
|
||||
### Added
|
||||
@@ -23,6 +48,7 @@
|
||||
- iOS project picker now submits the typed path. ([#831](https://github.com/getpaseo/paseo/pull/831))
|
||||
- System messages and chat mentions routed to multiple agents now reach every recipient consistently. ([#830](https://github.com/getpaseo/paseo/pull/830))
|
||||
- Clicking a Markdown link in agent output no longer reloads the desktop app on top of opening the link.
|
||||
- macOS desktop tab-jump shortcuts now use Cmd+Option+1-9, avoiding conflicts with Option-based international keyboard characters such as `@`.
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -9397,12 +9397,6 @@
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
|
||||
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
@@ -38854,7 +38848,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -38980,10 +38974,10 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.71",
|
||||
"@getpaseo/server": "0.1.73",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -39026,7 +39020,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -39075,7 +39069,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -39111,7 +39105,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -39137,7 +39131,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39152,18 +39146,18 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/highlight": "0.1.71",
|
||||
"@getpaseo/relay": "0.1.71",
|
||||
"@getpaseo/highlight": "0.1.73",
|
||||
"@getpaseo/relay": "0.1.73",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
"@mariozechner/pi-coding-agent": "^0.70.2",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
@@ -39344,6 +39338,15 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.14.46",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.46.tgz",
|
||||
"integrity": "sha512-7KOMuoCkNI+bLOw3GCg0nWZ5m7A/MzNsyLfTbZYmE/DIaUqkV2LNRULtrW6PHL1WtYVmJEFPws4dbw/4dVxjzA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -39691,7 +39694,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -21,12 +21,12 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
|
||||
}
|
||||
|
||||
export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByTestId("menu-button").click();
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
}
|
||||
|
||||
// force=true: the overlay covers the button when the mobile sidebar is open.
|
||||
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByTestId("menu-button").click({ force: true });
|
||||
await page.getByRole("button", { name: "Close menu" }).click({ force: true });
|
||||
}
|
||||
|
||||
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
|
||||
type EscHandler = () => void;
|
||||
const escStack: EscHandler[] = [];
|
||||
@@ -333,7 +333,7 @@ export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
||||
function AdaptiveTextInput(props, ref) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
if (isMobile) {
|
||||
if (isMobile && isNative) {
|
||||
return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
@@ -443,7 +443,7 @@ function ProviderSearchInput({
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
|
||||
const InputComponent = isMobile && isNative ? BottomSheetTextInput : TextInput;
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {};
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
shouldShowCustomComboboxOption,
|
||||
} from "./combobox-options";
|
||||
import type { ComboboxOptionModel } from "./combobox-options";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
@@ -148,7 +148,7 @@ export function SearchInput({
|
||||
}: SearchInputProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
|
||||
const InputComponent = useBottomSheetInput && isNative ? BottomSheetTextInput : TextInput;
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && IS_WEB && inputRef.current) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWorktreeSetupCalloutPolicy,
|
||||
selectActiveGitWorkspaceProject,
|
||||
shouldShowWorktreeSetupCallout,
|
||||
type WorktreeSetupWorkspaceInput,
|
||||
} from "./worktree-setup-callout-policy";
|
||||
|
||||
function gitWorkspace(
|
||||
overrides: Partial<WorktreeSetupWorkspaceInput> = {},
|
||||
): WorktreeSetupWorkspaceInput {
|
||||
return {
|
||||
projectId: "project-1",
|
||||
projectKind: "git",
|
||||
projectRootPath: "/repo/project-1",
|
||||
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("selectActiveGitWorkspaceProject", () => {
|
||||
it("selects the active git workspace project from checkout metadata", () => {
|
||||
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
|
||||
serverId: "server-1",
|
||||
projectKey: "project-1",
|
||||
repoRoot: "/repo/main-project-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the workspace project root when checkout metadata has no main root", () => {
|
||||
expect(
|
||||
selectActiveGitWorkspaceProject(
|
||||
"server-1",
|
||||
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
|
||||
),
|
||||
).toEqual({
|
||||
serverId: "server-1",
|
||||
projectKey: "project-1",
|
||||
repoRoot: "/repo/project-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores non-git workspaces and blank project coordinates", () => {
|
||||
expect(
|
||||
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })),
|
||||
).toBe(null);
|
||||
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe(
|
||||
null,
|
||||
);
|
||||
expect(
|
||||
selectActiveGitWorkspaceProject(
|
||||
"server-1",
|
||||
gitWorkspace({ projectRootPath: " ", project: null }),
|
||||
),
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowWorktreeSetupCallout", () => {
|
||||
it("shows the callout when paseo config was read and setup commands are missing", () => {
|
||||
expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true);
|
||||
expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show the callout when setup commands are present", () => {
|
||||
expect(
|
||||
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowWorktreeSetupCallout({
|
||||
ok: true,
|
||||
config: { worktree: { setup: [" ", "npm install"] } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not show the callout when reading paseo config fails or has not completed", () => {
|
||||
expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false);
|
||||
expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorktreeSetupCalloutPolicy", () => {
|
||||
it("builds the stable sidebar callout identity and action route", () => {
|
||||
expect(
|
||||
buildWorktreeSetupCalloutPolicy({
|
||||
serverId: "server-1",
|
||||
projectKey: "project-1",
|
||||
repoRoot: "/repo/project-1",
|
||||
}),
|
||||
).toEqual({
|
||||
id: "worktree-setup-missing:project-1",
|
||||
dismissalKey: "worktree-setup-missing:project-1",
|
||||
priority: 100,
|
||||
title: "Set up worktree scripts",
|
||||
description:
|
||||
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
|
||||
actionLabel: "Open project settings",
|
||||
projectSettingsRoute: "/settings/projects/project-1",
|
||||
testID: "worktree-setup-callout-project-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
85
packages/app/src/components/worktree-setup-callout-policy.ts
Normal file
85
packages/app/src/components/worktree-setup-callout-policy.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { PaseoConfigRaw } from "@server/shared/messages";
|
||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
export interface WorktreeSetupWorkspaceInput {
|
||||
projectId: string;
|
||||
projectKind: string;
|
||||
projectRootPath: string;
|
||||
project?: {
|
||||
checkout?: {
|
||||
mainRepoRoot?: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ActiveGitWorkspaceProject {
|
||||
serverId: string;
|
||||
projectKey: string;
|
||||
repoRoot: string;
|
||||
}
|
||||
|
||||
interface ReadProjectConfigResult {
|
||||
ok: boolean;
|
||||
config?: PaseoConfigRaw | null;
|
||||
}
|
||||
|
||||
export interface WorktreeSetupCalloutPolicy {
|
||||
id: string;
|
||||
dismissalKey: string;
|
||||
priority: number;
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel: string;
|
||||
projectSettingsRoute: ReturnType<typeof buildProjectSettingsRoute>;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
export function selectActiveGitWorkspaceProject(
|
||||
serverId: string,
|
||||
workspace: WorktreeSetupWorkspaceInput,
|
||||
): ActiveGitWorkspaceProject | null {
|
||||
if (workspace.projectKind !== "git") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectKey = workspace.projectId.trim();
|
||||
const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim();
|
||||
if (!projectKey || !repoRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, projectKey, repoRoot };
|
||||
}
|
||||
|
||||
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
|
||||
return readResult?.ok === true && !hasSetupCommands(readResult.config ?? {});
|
||||
}
|
||||
|
||||
export function buildWorktreeSetupCalloutPolicy(
|
||||
project: ActiveGitWorkspaceProject,
|
||||
): WorktreeSetupCalloutPolicy {
|
||||
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
|
||||
|
||||
return {
|
||||
id: calloutKey,
|
||||
dismissalKey: calloutKey,
|
||||
priority: 100,
|
||||
title: "Set up worktree scripts",
|
||||
description:
|
||||
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
|
||||
actionLabel: "Open project settings",
|
||||
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
|
||||
testID: `worktree-setup-callout-${project.projectKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
function hasSetupCommands(config: PaseoConfigRaw): boolean {
|
||||
const setup = config.worktree?.setup;
|
||||
if (typeof setup === "string") {
|
||||
return setup.trim().length > 0;
|
||||
}
|
||||
if (Array.isArray(setup)) {
|
||||
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
|
||||
const { theme } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
|
||||
borderWidth: { 1: 1 },
|
||||
borderRadius: { md: 6 },
|
||||
fontSize: { xs: 11, sm: 13 },
|
||||
fontWeight: { medium: "500", semibold: "600" },
|
||||
colors: {
|
||||
surface0: "#000",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
border: "#555",
|
||||
destructive: "#f44",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const asyncStorage = vi.hoisted(() => ({
|
||||
values: new Map<string, string>(),
|
||||
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
asyncStorage.values.set(key, value);
|
||||
}),
|
||||
}));
|
||||
|
||||
const router = vi.hoisted(() => ({
|
||||
navigate: vi.fn(),
|
||||
}));
|
||||
|
||||
const activeSelection = vi.hoisted(() => ({
|
||||
value: { serverId: "server-1", workspaceId: "workspace-1" } as {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
} | null,
|
||||
}));
|
||||
|
||||
const activeWorkspace = vi.hoisted(() => ({
|
||||
value: {
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectKind: "git",
|
||||
projectRootPath: "/repo/project-1",
|
||||
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
|
||||
} as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
const client = vi.hoisted(() => ({
|
||||
readProjectConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorage,
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
useRouter: () => router,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
useActiveWorkspaceSelection: () => activeSelection.value,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/session-store-hooks", () => ({
|
||||
useWorkspaceFields: (
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
project: (workspace: Record<string, unknown>) => unknown,
|
||||
) => {
|
||||
if (
|
||||
!activeWorkspace.value ||
|
||||
serverId !== activeSelection.value?.serverId ||
|
||||
workspaceId !== activeWorkspace.value.id
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return project(activeWorkspace.value);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeClient: (serverId: string) => (serverId === "server-1" ? client : null),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const X = (props: Record<string, unknown>) => React.createElement("span", props);
|
||||
return { X };
|
||||
});
|
||||
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
import { WorktreeSetupCalloutSource } from "./worktree-setup-callout-source";
|
||||
|
||||
function readOk(config: Record<string, unknown>) {
|
||||
return {
|
||||
ok: true,
|
||||
config,
|
||||
revision: { exists: true, mtimeMs: 1, size: 2 },
|
||||
};
|
||||
}
|
||||
|
||||
function readError() {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: "project_not_found", message: "Project not found" },
|
||||
};
|
||||
}
|
||||
|
||||
function Harness({ queryClient }: { queryClient: QueryClient }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarCalloutProvider>
|
||||
<WorktreeSetupCalloutSource />
|
||||
<SidebarCalloutSlot />
|
||||
</SidebarCalloutProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function renderHarness(root: Root, queryClient: QueryClient): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(<Harness queryClient={queryClient} />);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function findByTestId(testID: string): Promise<HTMLElement | null> {
|
||||
let element: HTMLElement | null = null;
|
||||
for (let index = 0; index < 10 && !element; index += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
element = document.querySelector(`[data-testid="${testID}"]`) as HTMLElement | null;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
describe("WorktreeSetupCalloutSource", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
let queryClient: QueryClient | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
activeSelection.value = { serverId: "server-1", workspaceId: "workspace-1" };
|
||||
activeWorkspace.value = {
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectKind: "git",
|
||||
projectRootPath: "/repo/project-1",
|
||||
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
|
||||
};
|
||||
client.readProjectConfig.mockReset();
|
||||
client.readProjectConfig.mockResolvedValue(readOk({}));
|
||||
router.navigate.mockClear();
|
||||
asyncStorage.values.clear();
|
||||
asyncStorage.getItem.mockClear();
|
||||
asyncStorage.setItem.mockClear();
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
queryClient?.clear();
|
||||
queryClient = null;
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
it("registers a callout for an active git workspace with missing setup", async () => {
|
||||
await renderHarness(root!, queryClient!);
|
||||
|
||||
expect(await findByTestId("worktree-setup-callout-project-1")).not.toBeNull();
|
||||
expect(container?.textContent).toContain("Set up worktree scripts");
|
||||
expect(container?.textContent).toContain("Open project settings");
|
||||
expect(client.readProjectConfig).toHaveBeenCalledWith("/repo/project-1");
|
||||
});
|
||||
|
||||
it("does not register a callout for a non-git workspace", async () => {
|
||||
activeWorkspace.value = {
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectKind: "local",
|
||||
projectRootPath: "/repo/project-1",
|
||||
};
|
||||
|
||||
await renderHarness(root!, queryClient!);
|
||||
|
||||
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
|
||||
expect(client.readProjectConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not register a callout when setup is present", async () => {
|
||||
client.readProjectConfig.mockResolvedValue(readOk({ worktree: { setup: "npm install" } }));
|
||||
|
||||
await renderHarness(root!, queryClient!);
|
||||
|
||||
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not register a callout without an active workspace", async () => {
|
||||
activeSelection.value = null;
|
||||
|
||||
await renderHarness(root!, queryClient!);
|
||||
|
||||
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
|
||||
expect(client.readProjectConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not register a callout when reading paseo.json fails", async () => {
|
||||
client.readProjectConfig.mockResolvedValue(readError());
|
||||
|
||||
await renderHarness(root!, queryClient!);
|
||||
|
||||
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("opens project settings from the callout action", async () => {
|
||||
await renderHarness(root!, queryClient!);
|
||||
|
||||
const action = await findByTestId("worktree-setup-callout-project-1-action-0");
|
||||
expect(action).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
action?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith("/settings/projects/project-1");
|
||||
});
|
||||
|
||||
it("persists dismissal for the project", async () => {
|
||||
await renderHarness(root!, queryClient!);
|
||||
|
||||
const dismiss = await findByTestId("worktree-setup-callout-project-1-dismiss");
|
||||
expect(dismiss).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(asyncStorage.setItem).toHaveBeenCalledWith(
|
||||
"@paseo:sidebar-callout-dismissals",
|
||||
JSON.stringify(["worktree-setup-missing:project-1"]),
|
||||
);
|
||||
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,48 +1,16 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { PaseoConfigRaw } from "@server/shared/messages";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
interface ActiveGitWorkspaceProject {
|
||||
serverId: string;
|
||||
projectKey: string;
|
||||
repoRoot: string;
|
||||
}
|
||||
|
||||
function selectActiveGitWorkspaceProject(
|
||||
serverId: string,
|
||||
workspace: WorkspaceDescriptor,
|
||||
): ActiveGitWorkspaceProject | null {
|
||||
if (workspace.projectKind !== "git") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectKey = workspace.projectId.trim();
|
||||
const repoRoot = (workspace.project?.checkout.mainRepoRoot ?? workspace.projectRootPath).trim();
|
||||
if (!projectKey || !repoRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, projectKey, repoRoot };
|
||||
}
|
||||
|
||||
function hasSetupCommands(config: PaseoConfigRaw): boolean {
|
||||
const setup = config.worktree?.setup;
|
||||
if (typeof setup === "string") {
|
||||
return setup.trim().length > 0;
|
||||
}
|
||||
if (Array.isArray(setup)) {
|
||||
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
import {
|
||||
buildWorktreeSetupCalloutPolicy,
|
||||
selectActiveGitWorkspaceProject,
|
||||
shouldShowWorktreeSetupCallout,
|
||||
} from "./worktree-setup-callout-policy";
|
||||
|
||||
export function WorktreeSetupCalloutSource() {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
@@ -58,7 +26,7 @@ export function WorktreeSetupCalloutSource() {
|
||||
if (!activeProject) {
|
||||
return;
|
||||
}
|
||||
router.navigate(buildProjectSettingsRoute(activeProject.projectKey));
|
||||
router.navigate(buildWorktreeSetupCalloutPolicy(activeProject).projectSettingsRoute);
|
||||
});
|
||||
|
||||
const readQuery = useQuery({
|
||||
@@ -73,29 +41,31 @@ export function WorktreeSetupCalloutSource() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const shouldShow =
|
||||
activeProject !== null &&
|
||||
readQuery.data?.ok === true &&
|
||||
!hasSetupCommands(readQuery.data.config ?? {});
|
||||
const calloutPolicy = useMemo(
|
||||
() =>
|
||||
activeProject && shouldShowWorktreeSetupCallout(readQuery.data)
|
||||
? buildWorktreeSetupCalloutPolicy(activeProject)
|
||||
: null,
|
||||
[activeProject, readQuery.data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShow || !activeProject) {
|
||||
if (!calloutPolicy) {
|
||||
return;
|
||||
}
|
||||
|
||||
return callouts.show({
|
||||
id: `worktree-setup-missing:${activeProject.projectKey}`,
|
||||
dismissalKey: `worktree-setup-missing:${activeProject.projectKey}`,
|
||||
priority: 100,
|
||||
title: "Set up worktree scripts",
|
||||
description:
|
||||
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
|
||||
id: calloutPolicy.id,
|
||||
dismissalKey: calloutPolicy.dismissalKey,
|
||||
priority: calloutPolicy.priority,
|
||||
title: calloutPolicy.title,
|
||||
description: calloutPolicy.description,
|
||||
actions: [
|
||||
{ label: "Open project settings", onPress: openProjectSettings, variant: "primary" },
|
||||
{ label: calloutPolicy.actionLabel, onPress: openProjectSettings, variant: "primary" },
|
||||
],
|
||||
testID: `worktree-setup-callout-${activeProject.projectKey}`,
|
||||
testID: calloutPolicy.testID,
|
||||
});
|
||||
}, [activeProject, callouts, openProjectSettings, shouldShow]);
|
||||
}, [calloutPolicy, callouts, openProjectSettings]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act, useEffect } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { theme } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
|
||||
borderWidth: { 1: 1 },
|
||||
borderRadius: { md: 6 },
|
||||
fontSize: { xs: 11, sm: 13 },
|
||||
fontWeight: { medium: "500", semibold: "600" },
|
||||
colors: {
|
||||
surface0: "#000",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
border: "#555",
|
||||
destructive: "#f44",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const asyncStorage = vi.hoisted(() => ({
|
||||
values: new Map<string, string>(),
|
||||
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
asyncStorage.values.set(key, value);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorage,
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const X = (props: Record<string, unknown>) => React.createElement("span", props);
|
||||
return { X };
|
||||
});
|
||||
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
import {
|
||||
SidebarCalloutProvider,
|
||||
type SidebarCalloutsApi,
|
||||
SidebarCalloutViewport,
|
||||
useSidebarCallouts,
|
||||
} from "./sidebar-callout-context";
|
||||
|
||||
const apiSink: { current: SidebarCalloutsApi | null } = { current: null };
|
||||
|
||||
function handleApi(nextApi: SidebarCalloutsApi): void {
|
||||
apiSink.current = nextApi;
|
||||
}
|
||||
|
||||
function CaptureApi({ onApi }: { onApi: (api: SidebarCalloutsApi) => void }) {
|
||||
const api = useSidebarCallouts();
|
||||
onApi(api);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("SidebarCalloutProvider", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
let api: SidebarCalloutsApi | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = null;
|
||||
apiSink.current = null;
|
||||
asyncStorage.values.clear();
|
||||
asyncStorage.getItem.mockClear();
|
||||
asyncStorage.setItem.mockClear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<SidebarCalloutProvider>
|
||||
<CaptureApi onApi={handleApi} />
|
||||
<SidebarCalloutViewport />
|
||||
</SidebarCalloutProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
api = apiSink.current;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
api = null;
|
||||
});
|
||||
|
||||
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
|
||||
act(() => {
|
||||
api?.show({ id: "onboarding", priority: 10, title: "Set up scripts" });
|
||||
api?.show({ id: "update", priority: 200, title: "Update available" });
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain("Update available");
|
||||
expect(container?.textContent).not.toContain("Set up scripts");
|
||||
|
||||
act(() => {
|
||||
api?.dismiss("update");
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain("Set up scripts");
|
||||
expect(container?.textContent).not.toContain("Update available");
|
||||
});
|
||||
|
||||
it("replaces a callout by id without duplicating the queue item", () => {
|
||||
act(() => {
|
||||
api?.show({ id: "daemon", title: "Old daemon", description: "v1" });
|
||||
api?.show({ id: "daemon", title: "New daemon", description: "v2" });
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain("New daemon");
|
||||
expect(container?.textContent).toContain("v2");
|
||||
expect(container?.textContent).not.toContain("Old daemon");
|
||||
});
|
||||
|
||||
it("keeps API consumers from rerendering when callout state changes", () => {
|
||||
const renders = vi.fn();
|
||||
function Producer() {
|
||||
const callouts = useSidebarCallouts();
|
||||
renders(callouts);
|
||||
useEffect(() => {
|
||||
callouts.show({ id: "initial", title: "Initial" });
|
||||
}, [callouts]);
|
||||
return null;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<SidebarCalloutProvider>
|
||||
<Producer />
|
||||
<CaptureApi onApi={handleApi} />
|
||||
<SidebarCalloutViewport />
|
||||
</SidebarCalloutProvider>,
|
||||
);
|
||||
});
|
||||
api = apiSink.current;
|
||||
const firstApi = renders.mock.calls[0]?.[0];
|
||||
|
||||
act(() => {
|
||||
api?.show({ id: "later", priority: 10, title: "Later" });
|
||||
});
|
||||
|
||||
expect(renders).toHaveBeenCalledTimes(1);
|
||||
expect(renders.mock.calls[0]?.[0]).toBe(firstApi);
|
||||
});
|
||||
|
||||
it("unregisters only the registration returned by show", () => {
|
||||
let unregisterOld: (() => void) | null = null;
|
||||
act(() => {
|
||||
unregisterOld = api?.show({ id: "update", title: "Old" }) ?? null;
|
||||
api?.show({ id: "update", title: "New" });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
unregisterOld?.();
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain("New");
|
||||
});
|
||||
|
||||
it("persists dismissals by dismissal key", () => {
|
||||
act(() => {
|
||||
api?.show({
|
||||
id: "update",
|
||||
dismissalKey: "desktop-update:available:1.2.3",
|
||||
title: "Update available",
|
||||
});
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain("Update available");
|
||||
|
||||
act(() => {
|
||||
api?.dismiss("update");
|
||||
});
|
||||
|
||||
expect(container?.textContent).not.toContain("Update available");
|
||||
expect(asyncStorage.setItem).toHaveBeenCalledWith(
|
||||
"@paseo:sidebar-callout-dismissals",
|
||||
JSON.stringify(["desktop-update:available:1.2.3"]),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
api?.show({
|
||||
id: "update",
|
||||
dismissalKey: "desktop-update:available:1.2.3",
|
||||
title: "Dismissed update",
|
||||
});
|
||||
});
|
||||
|
||||
expect(container?.textContent).not.toContain("Dismissed update");
|
||||
|
||||
act(() => {
|
||||
api?.show({
|
||||
id: "update",
|
||||
dismissalKey: "desktop-update:available:1.2.4",
|
||||
title: "New update",
|
||||
});
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain("New update");
|
||||
});
|
||||
});
|
||||
@@ -8,27 +8,24 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
SidebarCallout,
|
||||
type SidebarCalloutAction,
|
||||
type SidebarCalloutProps,
|
||||
type SidebarCalloutVariant,
|
||||
} from "@/components/sidebar-callout";
|
||||
import { SidebarCallout, type SidebarCalloutProps } from "@/components/sidebar-callout";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
clearSidebarCallouts,
|
||||
createSidebarCalloutState,
|
||||
dismissSidebarCallout,
|
||||
loadDismissedCalloutKeys,
|
||||
parseDismissedCalloutKeys,
|
||||
selectActiveSidebarCallout,
|
||||
serializeDismissedCalloutKeys,
|
||||
showSidebarCallout,
|
||||
type SidebarCalloutEntry,
|
||||
type SidebarCalloutOptions,
|
||||
type SidebarCalloutState,
|
||||
unregisterSidebarCallout,
|
||||
} from "./sidebar-callout-state";
|
||||
|
||||
export interface SidebarCalloutOptions {
|
||||
id: string;
|
||||
dismissalKey?: string;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
variant?: SidebarCalloutVariant;
|
||||
actions?: readonly SidebarCalloutAction[];
|
||||
dismissible?: boolean;
|
||||
priority?: number;
|
||||
onDismiss?: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
export type { SidebarCalloutOptions } from "./sidebar-callout-state";
|
||||
|
||||
export interface SidebarCalloutsApi {
|
||||
show: (callout: SidebarCalloutOptions) => () => void;
|
||||
@@ -36,96 +33,48 @@ export interface SidebarCalloutsApi {
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
type SidebarCalloutEntry = SidebarCalloutOptions & {
|
||||
order: number;
|
||||
priority: number;
|
||||
token: number;
|
||||
};
|
||||
|
||||
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
|
||||
|
||||
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
|
||||
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
|
||||
|
||||
function normalizeDismissalKey(key: string | null | undefined): string | null {
|
||||
const trimmed = key?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseDismissedCalloutKeys(value: string | null): Set<string> {
|
||||
if (!value) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
|
||||
void AsyncStorage.setItem(DISMISSED_CALLOUTS_STORAGE_KEY, JSON.stringify([...keys])).catch(
|
||||
(error) => {
|
||||
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function selectActiveCallout(input: {
|
||||
callouts: readonly SidebarCalloutEntry[];
|
||||
dismissedKeys: ReadonlySet<string>;
|
||||
dismissalStorageLoaded: boolean;
|
||||
}): SidebarCalloutEntry | null {
|
||||
const visibleCallouts = input.callouts.filter((entry) => {
|
||||
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
|
||||
if (!dismissalKey) {
|
||||
return true;
|
||||
}
|
||||
return input.dismissalStorageLoaded && !input.dismissedKeys.has(dismissalKey);
|
||||
void AsyncStorage.setItem(
|
||||
DISMISSED_CALLOUTS_STORAGE_KEY,
|
||||
serializeDismissedCalloutKeys(keys),
|
||||
).catch((error) => {
|
||||
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
|
||||
});
|
||||
|
||||
if (visibleCallouts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
|
||||
const [callouts, setCallouts] = useState<SidebarCalloutEntry[]>([]);
|
||||
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
|
||||
const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false);
|
||||
const calloutsRef = useRef<SidebarCalloutEntry[]>([]);
|
||||
const dismissedKeysRef = useRef<Set<string>>(new Set());
|
||||
const orderRef = useRef(0);
|
||||
const tokenRef = useRef(0);
|
||||
const [state, setState] = useState<SidebarCalloutState>(createSidebarCalloutState);
|
||||
const stateRef = useRef<SidebarCalloutState>(state);
|
||||
|
||||
function commitState(next: SidebarCalloutState): void {
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
void AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY)
|
||||
.then((value) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
const nextKeys = parseDismissedCalloutKeys(value);
|
||||
dismissedKeysRef.current = nextKeys;
|
||||
setDismissedKeys(nextKeys);
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
async function loadDismissedKeys(): Promise<void> {
|
||||
let dismissedKeys: ReadonlySet<string>;
|
||||
try {
|
||||
const value = await AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY);
|
||||
dismissedKeys = parseDismissedCalloutKeys(value);
|
||||
} catch (error) {
|
||||
console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) {
|
||||
setDismissalStorageLoaded(true);
|
||||
}
|
||||
});
|
||||
dismissedKeys = stateRef.current.dismissedKeys;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
commitState(loadDismissedCalloutKeys(stateRef.current, dismissedKeys));
|
||||
}
|
||||
}
|
||||
|
||||
void loadDismissedKeys();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
@@ -133,60 +82,33 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const show = useStableEvent((callout: SidebarCalloutOptions) => {
|
||||
tokenRef.current += 1;
|
||||
const token = tokenRef.current;
|
||||
const current = calloutsRef.current;
|
||||
const existing = current.find((entry) => entry.id === callout.id);
|
||||
const nextEntry: SidebarCalloutEntry = {
|
||||
...callout,
|
||||
priority: callout.priority ?? 0,
|
||||
order: existing?.order ?? ++orderRef.current,
|
||||
token,
|
||||
};
|
||||
const next = existing
|
||||
? current.map((entry) => (entry.id === callout.id ? nextEntry : entry))
|
||||
: [...current, nextEntry];
|
||||
|
||||
calloutsRef.current = next;
|
||||
setCallouts(next);
|
||||
const result = showSidebarCallout(stateRef.current, callout);
|
||||
commitState(result.state);
|
||||
|
||||
return () => {
|
||||
const updated = calloutsRef.current.filter(
|
||||
(entry) => entry.id !== callout.id || entry.token !== token,
|
||||
commitState(
|
||||
unregisterSidebarCallout(stateRef.current, { id: callout.id, token: result.token }),
|
||||
);
|
||||
calloutsRef.current = updated;
|
||||
setCallouts(updated);
|
||||
};
|
||||
});
|
||||
|
||||
const dismiss = useStableEvent((id: string) => {
|
||||
const dismissed = calloutsRef.current.find((entry) => entry.id === id) ?? null;
|
||||
const next = calloutsRef.current.filter((entry) => entry.id !== id);
|
||||
calloutsRef.current = next;
|
||||
setCallouts(next);
|
||||
const result = dismissSidebarCallout(stateRef.current, id);
|
||||
commitState(result.state);
|
||||
|
||||
const dismissalKey = normalizeDismissalKey(dismissed?.dismissalKey);
|
||||
if (dismissalKey) {
|
||||
const nextKeys = new Set(dismissedKeysRef.current);
|
||||
nextKeys.add(dismissalKey);
|
||||
dismissedKeysRef.current = nextKeys;
|
||||
setDismissedKeys(nextKeys);
|
||||
persistDismissedCalloutKeys(nextKeys);
|
||||
if (result.dismissalKey) {
|
||||
persistDismissedCalloutKeys(result.state.dismissedKeys);
|
||||
}
|
||||
|
||||
dismissed?.onDismiss?.();
|
||||
result.dismissedCallout?.onDismiss?.();
|
||||
});
|
||||
|
||||
const clear = useStableEvent(() => {
|
||||
calloutsRef.current = [];
|
||||
setCallouts([]);
|
||||
commitState(clearSidebarCallouts(stateRef.current));
|
||||
});
|
||||
|
||||
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
|
||||
const activeCallout = useMemo(
|
||||
() => selectActiveCallout({ callouts, dismissedKeys, dismissalStorageLoaded }),
|
||||
[callouts, dismissedKeys, dismissalStorageLoaded],
|
||||
);
|
||||
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]);
|
||||
|
||||
return (
|
||||
<SidebarCalloutApiContext.Provider value={api}>
|
||||
|
||||
136
packages/app/src/contexts/sidebar-callout-state.test.ts
Normal file
136
packages/app/src/contexts/sidebar-callout-state.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearSidebarCallouts,
|
||||
createSidebarCalloutState,
|
||||
dismissSidebarCallout,
|
||||
loadDismissedCalloutKeys,
|
||||
parseDismissedCalloutKeys,
|
||||
selectActiveSidebarCallout,
|
||||
serializeDismissedCalloutKeys,
|
||||
showSidebarCallout,
|
||||
unregisterSidebarCallout,
|
||||
} from "./sidebar-callout-state";
|
||||
|
||||
describe("sidebar callout state", () => {
|
||||
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
|
||||
let state = createSidebarCalloutState();
|
||||
state = showSidebarCallout(state, {
|
||||
id: "onboarding",
|
||||
priority: 10,
|
||||
title: "Set up scripts",
|
||||
}).state;
|
||||
state = showSidebarCallout(state, {
|
||||
id: "update",
|
||||
priority: 200,
|
||||
title: "Update available",
|
||||
}).state;
|
||||
|
||||
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
|
||||
|
||||
state = dismissSidebarCallout(state, "update").state;
|
||||
|
||||
expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts");
|
||||
});
|
||||
|
||||
it("replaces a callout by id without duplicating the queue item", () => {
|
||||
let state = createSidebarCalloutState();
|
||||
state = showSidebarCallout(state, {
|
||||
id: "daemon",
|
||||
title: "Old daemon",
|
||||
description: "v1",
|
||||
}).state;
|
||||
state = showSidebarCallout(state, {
|
||||
id: "daemon",
|
||||
title: "New daemon",
|
||||
description: "v2",
|
||||
}).state;
|
||||
|
||||
expect(state.callouts).toMatchObject([
|
||||
{
|
||||
id: "daemon",
|
||||
title: "New daemon",
|
||||
description: "v2",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("unregisters only the registration returned by show", () => {
|
||||
let state = createSidebarCalloutState();
|
||||
const oldRegistration = showSidebarCallout(state, { id: "update", title: "Old" });
|
||||
state = oldRegistration.state;
|
||||
state = showSidebarCallout(state, { id: "update", title: "New" }).state;
|
||||
|
||||
state = unregisterSidebarCallout(state, { id: "update", token: oldRegistration.token });
|
||||
|
||||
expect(selectActiveSidebarCallout(state)?.title).toBe("New");
|
||||
});
|
||||
|
||||
it("persists dismissals by dismissal key and hides matching future callouts", () => {
|
||||
const onDismiss = vi.fn();
|
||||
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set());
|
||||
state = showSidebarCallout(state, {
|
||||
id: "update",
|
||||
dismissalKey: "desktop-update:available:1.2.3",
|
||||
title: "Update available",
|
||||
onDismiss,
|
||||
}).state;
|
||||
|
||||
const result = dismissSidebarCallout(state, "update");
|
||||
state = result.state;
|
||||
|
||||
expect(result.dismissalKey).toBe("desktop-update:available:1.2.3");
|
||||
expect(serializeDismissedCalloutKeys(state.dismissedKeys)).toBe(
|
||||
JSON.stringify(["desktop-update:available:1.2.3"]),
|
||||
);
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
result.dismissedCallout?.onDismiss?.();
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
|
||||
state = showSidebarCallout(state, {
|
||||
id: "update",
|
||||
dismissalKey: "desktop-update:available:1.2.3",
|
||||
title: "Dismissed update",
|
||||
}).state;
|
||||
|
||||
expect(selectActiveSidebarCallout(state)).toBeNull();
|
||||
|
||||
state = showSidebarCallout(state, {
|
||||
id: "update",
|
||||
dismissalKey: "desktop-update:available:1.2.4",
|
||||
title: "New update",
|
||||
}).state;
|
||||
|
||||
expect(selectActiveSidebarCallout(state)?.title).toBe("New update");
|
||||
});
|
||||
|
||||
it("waits for dismissal storage before showing dismissible callouts", () => {
|
||||
let state = createSidebarCalloutState();
|
||||
state = showSidebarCallout(state, {
|
||||
id: "update",
|
||||
dismissalKey: "desktop-update:available:1.2.3",
|
||||
title: "Update available",
|
||||
}).state;
|
||||
|
||||
expect(selectActiveSidebarCallout(state)).toBeNull();
|
||||
|
||||
state = loadDismissedCalloutKeys(state, new Set());
|
||||
|
||||
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
|
||||
});
|
||||
|
||||
it("parses stored dismissal keys defensively", () => {
|
||||
expect(parseDismissedCalloutKeys(JSON.stringify(["a", 4, "b"]))).toEqual(new Set(["a", "b"]));
|
||||
expect(parseDismissedCalloutKeys("{")).toEqual(new Set());
|
||||
expect(parseDismissedCalloutKeys(JSON.stringify({ key: "a" }))).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("clears visible callouts without dropping dismissal state", () => {
|
||||
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set(["dismissed"]));
|
||||
state = showSidebarCallout(state, { id: "visible", title: "Visible" }).state;
|
||||
|
||||
state = clearSidebarCallouts(state);
|
||||
|
||||
expect(state.callouts).toEqual([]);
|
||||
expect(state.dismissedKeys).toEqual(new Set(["dismissed"]));
|
||||
});
|
||||
});
|
||||
162
packages/app/src/contexts/sidebar-callout-state.ts
Normal file
162
packages/app/src/contexts/sidebar-callout-state.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { SidebarCalloutAction, SidebarCalloutVariant } from "@/components/sidebar-callout";
|
||||
|
||||
export interface SidebarCalloutOptions {
|
||||
id: string;
|
||||
dismissalKey?: string;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
variant?: SidebarCalloutVariant;
|
||||
actions?: readonly SidebarCalloutAction[];
|
||||
dismissible?: boolean;
|
||||
priority?: number;
|
||||
onDismiss?: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export interface SidebarCalloutEntry extends SidebarCalloutOptions {
|
||||
order: number;
|
||||
priority: number;
|
||||
token: number;
|
||||
}
|
||||
|
||||
export interface SidebarCalloutState {
|
||||
callouts: readonly SidebarCalloutEntry[];
|
||||
dismissedKeys: ReadonlySet<string>;
|
||||
dismissalStorageLoaded: boolean;
|
||||
nextOrder: number;
|
||||
nextToken: number;
|
||||
}
|
||||
|
||||
export function createSidebarCalloutState(): SidebarCalloutState {
|
||||
return {
|
||||
callouts: [],
|
||||
dismissedKeys: new Set(),
|
||||
dismissalStorageLoaded: false,
|
||||
nextOrder: 0,
|
||||
nextToken: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDismissalKey(key: string | null | undefined): string | null {
|
||||
const trimmed = key?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
export function parseDismissedCalloutKeys(value: string | null): Set<string> {
|
||||
if (!value) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeDismissedCalloutKeys(keys: ReadonlySet<string>): string {
|
||||
return JSON.stringify([...keys]);
|
||||
}
|
||||
|
||||
export function loadDismissedCalloutKeys(
|
||||
state: SidebarCalloutState,
|
||||
dismissedKeys: ReadonlySet<string>,
|
||||
): SidebarCalloutState {
|
||||
return {
|
||||
...state,
|
||||
dismissedKeys: new Set(dismissedKeys),
|
||||
dismissalStorageLoaded: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function showSidebarCallout(
|
||||
state: SidebarCalloutState,
|
||||
callout: SidebarCalloutOptions,
|
||||
): { state: SidebarCalloutState; token: number } {
|
||||
const token = state.nextToken + 1;
|
||||
const existing = state.callouts.find((entry) => entry.id === callout.id);
|
||||
const nextEntry: SidebarCalloutEntry = {
|
||||
...callout,
|
||||
priority: callout.priority ?? 0,
|
||||
order: existing?.order ?? state.nextOrder + 1,
|
||||
token,
|
||||
};
|
||||
const callouts = existing
|
||||
? state.callouts.map((entry) => (entry.id === callout.id ? nextEntry : entry))
|
||||
: [...state.callouts, nextEntry];
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
callouts,
|
||||
nextOrder: existing ? state.nextOrder : state.nextOrder + 1,
|
||||
nextToken: token,
|
||||
},
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
export function unregisterSidebarCallout(
|
||||
state: SidebarCalloutState,
|
||||
input: { id: string; token: number },
|
||||
): SidebarCalloutState {
|
||||
const callouts = state.callouts.filter(
|
||||
(entry) => entry.id !== input.id || entry.token !== input.token,
|
||||
);
|
||||
return callouts.length === state.callouts.length ? state : { ...state, callouts };
|
||||
}
|
||||
|
||||
export function dismissSidebarCallout(
|
||||
state: SidebarCalloutState,
|
||||
id: string,
|
||||
): {
|
||||
state: SidebarCalloutState;
|
||||
dismissedCallout: SidebarCalloutEntry | null;
|
||||
dismissalKey: string | null;
|
||||
} {
|
||||
const dismissedCallout = state.callouts.find((entry) => entry.id === id) ?? null;
|
||||
const callouts = state.callouts.filter((entry) => entry.id !== id);
|
||||
const dismissalKey = normalizeDismissalKey(dismissedCallout?.dismissalKey);
|
||||
const dismissedKeys = dismissalKey
|
||||
? new Set([...state.dismissedKeys, dismissalKey])
|
||||
: state.dismissedKeys;
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
callouts,
|
||||
dismissedKeys,
|
||||
},
|
||||
dismissedCallout,
|
||||
dismissalKey,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearSidebarCallouts(state: SidebarCalloutState): SidebarCalloutState {
|
||||
return { ...state, callouts: [] };
|
||||
}
|
||||
|
||||
export function selectActiveSidebarCallout(
|
||||
state: Pick<SidebarCalloutState, "callouts" | "dismissedKeys" | "dismissalStorageLoaded">,
|
||||
): SidebarCalloutEntry | null {
|
||||
const visibleCallouts = state.callouts.filter((entry) => {
|
||||
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
|
||||
if (!dismissalKey) {
|
||||
return true;
|
||||
}
|
||||
return state.dismissalStorageLoaded && !state.dismissedKeys.has(dismissalKey);
|
||||
});
|
||||
|
||||
if (visibleCallouts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
|
||||
);
|
||||
}
|
||||
@@ -148,9 +148,16 @@ describe("keyboard-shortcuts", () => {
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches tab index jump on desktop via Alt+digit",
|
||||
name: "matches tab index jump on mac desktop via Cmd+Alt+digit",
|
||||
event: { key: "@", code: "Digit2", metaKey: true, altKey: true },
|
||||
context: { isMac: true, isDesktop: true },
|
||||
action: "workspace.tab.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches tab index jump on non-mac desktop via Alt+digit",
|
||||
event: { key: "2", code: "Digit2", altKey: true },
|
||||
context: { isDesktop: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
action: "workspace.tab.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
@@ -333,6 +340,11 @@ describe("keyboard-shortcuts", () => {
|
||||
event: { key: "t", code: "KeyT", ctrlKey: true },
|
||||
context: { isMac: true },
|
||||
},
|
||||
{
|
||||
name: "keeps mac Option+digit available for international text input",
|
||||
event: { key: "@", code: "Digit2", altKey: true },
|
||||
context: { isMac: true, isDesktop: true, focusScope: "message-input" },
|
||||
},
|
||||
{
|
||||
name: "does not match Ctrl+K for command center on non-mac in terminal",
|
||||
event: { key: "k", code: "KeyK", ctrlKey: true },
|
||||
@@ -477,16 +489,17 @@ describe("keyboard-shortcut help sections", () => {
|
||||
"new-agent": ["mod", "shift", "O"],
|
||||
"workspace-tab-new": ["mod", "T"],
|
||||
"workspace-jump-index": ["mod", "1-9"],
|
||||
"workspace-tab-jump-index": ["alt", "1-9"],
|
||||
"workspace-tab-jump-index": ["mod", "alt", "1-9"],
|
||||
"workspace-tab-close-current": ["meta", "W"],
|
||||
"workspace-pane-split-right": ["mod", "\\"],
|
||||
"workspace-pane-close": ["mod", "shift", "W"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shows Ctrl+W close tab for non-mac desktop",
|
||||
name: "uses non-mac desktop defaults for tab jump and close tab",
|
||||
context: { isMac: false, isDesktop: true },
|
||||
expectedKeys: {
|
||||
"workspace-tab-jump-index": ["alt", "1-9"],
|
||||
"workspace-tab-close-current": ["ctrl", "W"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -292,11 +292,24 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
|
||||
// --- Tab index jump ---
|
||||
{
|
||||
id: "workspace-tab-navigate-index-cmd-alt-digit-mac-desktop",
|
||||
action: "workspace.tab.navigate.index",
|
||||
combo: "Cmd+Alt+Digit",
|
||||
when: { mac: true, desktop: true, commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
section: "navigation",
|
||||
label: "Jump to tab",
|
||||
keys: ["mod", "alt", "1-9"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-navigate-index-alt-digit-desktop",
|
||||
action: "workspace.tab.navigate.index",
|
||||
combo: "Alt+Digit",
|
||||
when: { desktop: true, commandCenter: false },
|
||||
when: { mac: false, desktop: true, commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
|
||||
40
packages/app/src/polyfills/crypto.test.ts
Normal file
40
packages/app/src/polyfills/crypto.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const expoCryptoMock = vi.hoisted(() => ({
|
||||
getRandomValues: vi.fn(<T extends ArrayBufferView>(array: T): T => array),
|
||||
randomUUID: vi.fn(() => {
|
||||
throw new Error("ExpoCrypto.randomUUID should not be used for the web fallback");
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("expo-crypto", () => expoCryptoMock);
|
||||
|
||||
describe("polyfillCrypto", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
expoCryptoMock.getRandomValues.mockClear();
|
||||
expoCryptoMock.randomUUID.mockClear();
|
||||
});
|
||||
|
||||
it("generates randomUUID from getRandomValues when Web Crypto randomUUID is unavailable", async () => {
|
||||
const sourceBytes = Uint8Array.from([
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
|
||||
0xff,
|
||||
]);
|
||||
const getRandomValues = vi.fn(<T extends ArrayBufferView | null>(array: T): T => {
|
||||
if (array && ArrayBuffer.isView(array)) {
|
||||
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(sourceBytes);
|
||||
}
|
||||
return array;
|
||||
});
|
||||
vi.stubGlobal("crypto", { getRandomValues });
|
||||
|
||||
const { polyfillCrypto } = await import("./crypto");
|
||||
polyfillCrypto();
|
||||
|
||||
expect(globalThis.crypto.randomUUID()).toBe("00112233-4455-4677-8899-aabbccddeeff");
|
||||
expect(getRandomValues).toHaveBeenCalledTimes(1);
|
||||
expect(expoCryptoMock.randomUUID).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,26 @@ interface MutableGlobal {
|
||||
crypto?: Crypto;
|
||||
}
|
||||
|
||||
type RandomUUID = `${string}-${string}-${string}-${string}-${string}`;
|
||||
type FillRandomValues = <T extends ArrayBufferView | null>(array: T) => T;
|
||||
|
||||
function createUuidV4(fillRandomValues: FillRandomValues): RandomUUID {
|
||||
const bytes = fillRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
||||
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
|
||||
.slice(6, 8)
|
||||
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}` as RandomUUID;
|
||||
}
|
||||
|
||||
export function polyfillCrypto(): void {
|
||||
const g = globalThis as unknown as MutableGlobal;
|
||||
const nativeGetRandomValues =
|
||||
typeof g.crypto?.getRandomValues === "function"
|
||||
? g.crypto.getRandomValues.bind(g.crypto)
|
||||
: null;
|
||||
|
||||
// Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport).
|
||||
// Hermes may not provide them in all configurations.
|
||||
@@ -47,17 +65,21 @@ export function polyfillCrypto(): void {
|
||||
g.crypto = {} as Crypto;
|
||||
}
|
||||
|
||||
const fillRandomValues: FillRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
if (array === null) return array;
|
||||
if (nativeGetRandomValues) {
|
||||
return nativeGetRandomValues(array as unknown as ArrayBufferView<ArrayBuffer>) as T;
|
||||
}
|
||||
return ExpoCrypto.getRandomValues(
|
||||
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
|
||||
) as unknown as T;
|
||||
};
|
||||
|
||||
if (typeof g.crypto.randomUUID !== "function") {
|
||||
g.crypto.randomUUID = () =>
|
||||
ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`;
|
||||
g.crypto.randomUUID = () => createUuidV4(fillRandomValues);
|
||||
}
|
||||
|
||||
if (typeof g.crypto.getRandomValues !== "function") {
|
||||
g.crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
if (array === null) return array;
|
||||
return ExpoCrypto.getRandomValues(
|
||||
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
|
||||
) as unknown as T;
|
||||
};
|
||||
g.crypto.getRandomValues = fillRandomValues;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,15 @@ function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
|
||||
};
|
||||
}
|
||||
|
||||
function encodeOfferUrl(payload: unknown): string {
|
||||
const encoded = Buffer.from(JSON.stringify(payload), "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
return `https://app.paseo.sh/#offer=${encoded}`;
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
latencyByConnectionId: Record<string, number | Error>,
|
||||
createdClients: FakeDaemonClient[],
|
||||
@@ -1734,6 +1743,41 @@ describe("HostRuntimeStore", () => {
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("uses TLS for old pairing URLs that omit relay TLS on port 443", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
const oldPairingUrl = encodeOfferUrl({
|
||||
v: 2,
|
||||
serverId: "srv_offer",
|
||||
daemonPublicKeyB64: "pk_test_offer",
|
||||
relay: { endpoint: "relay.paseo.sh:443" },
|
||||
});
|
||||
|
||||
await store.upsertConnectionFromOfferUrl(oldPairingUrl, "old relay");
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.connections).toEqual([
|
||||
{
|
||||
id: "relay:wss:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
useTls: true,
|
||||
daemonPublicKeyB64: "pk_test_offer",
|
||||
},
|
||||
]);
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
|
||||
@@ -1506,10 +1506,12 @@ export class HostRuntimeStore {
|
||||
}
|
||||
|
||||
async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise<HostProfile> {
|
||||
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
|
||||
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
|
||||
return this.upsertRelayConnection({
|
||||
serverId: offer.serverId,
|
||||
relayEndpoint: offer.relay.endpoint,
|
||||
useTls: offer.relay.useTls,
|
||||
useTls,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
label,
|
||||
});
|
||||
|
||||
99
packages/app/src/screens/workspace/terminals/state.test.ts
Normal file
99
packages/app/src/screens/workspace/terminals/state.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectKnownTerminalIds,
|
||||
collectScriptTerminalIds,
|
||||
collectStandaloneTerminalIds,
|
||||
reconcilePendingScriptTerminals,
|
||||
removeTerminalFromPayload,
|
||||
upsertCreatedTerminalPayload,
|
||||
type ListTerminalsPayload,
|
||||
} from "@/screens/workspace/terminals/state";
|
||||
import type { CreateTerminalResponse } from "@server/shared/messages";
|
||||
|
||||
function listedTerminal(id: string): ListTerminalsPayload["terminals"][number] {
|
||||
return { id, name: id, title: id };
|
||||
}
|
||||
|
||||
function createdTerminal(id: string): NonNullable<CreateTerminalResponse["payload"]["terminal"]> {
|
||||
return { id, name: id, cwd: "/repo", title: id };
|
||||
}
|
||||
|
||||
describe("workspace terminal state", () => {
|
||||
it("keeps pending script terminals until they appear or a fresher list arrives", () => {
|
||||
const pending = new Map([
|
||||
["older-than-list", 10],
|
||||
["now-live", 20],
|
||||
["still-pending", 30],
|
||||
]);
|
||||
|
||||
const reconciled = reconcilePendingScriptTerminals(["now-live"], 20)(pending);
|
||||
|
||||
expect(reconciled).toEqual(new Map([["still-pending", 30]]));
|
||||
});
|
||||
|
||||
it("returns the same pending map when reconciliation changes nothing", () => {
|
||||
const pending = new Map([["still-pending", 30]]);
|
||||
|
||||
const reconciled = reconcilePendingScriptTerminals([], 20)(pending);
|
||||
|
||||
expect(reconciled).toBe(pending);
|
||||
});
|
||||
|
||||
it("combines live and pending terminal ids without duplicating script terminals", () => {
|
||||
const pendingScriptTerminalIds = new Map([
|
||||
["script-pending", 10],
|
||||
["terminal-1", 10],
|
||||
]);
|
||||
|
||||
expect(
|
||||
collectKnownTerminalIds({
|
||||
liveTerminalIds: ["terminal-1", "terminal-2"],
|
||||
pendingScriptTerminalIds,
|
||||
}),
|
||||
).toEqual(["terminal-1", "terminal-2", "script-pending"]);
|
||||
expect(
|
||||
collectScriptTerminalIds({
|
||||
pendingScriptTerminalIds,
|
||||
scripts: [{ terminalId: "script-live" }, { terminalId: null }],
|
||||
}),
|
||||
).toEqual(new Set(["script-pending", "terminal-1", "script-live"]));
|
||||
expect(
|
||||
collectStandaloneTerminalIds({
|
||||
terminals: [
|
||||
listedTerminal("terminal-1"),
|
||||
listedTerminal("terminal-2"),
|
||||
listedTerminal("script-live"),
|
||||
],
|
||||
scriptTerminalIds: new Set(["terminal-1", "script-live"]),
|
||||
}),
|
||||
).toEqual(["terminal-2"]);
|
||||
});
|
||||
|
||||
it("updates terminal cache entries for created and closed terminals", () => {
|
||||
const current: ListTerminalsPayload = {
|
||||
cwd: "/repo",
|
||||
requestId: "existing",
|
||||
terminals: [listedTerminal("terminal-1")],
|
||||
};
|
||||
|
||||
expect(
|
||||
upsertCreatedTerminalPayload({
|
||||
current,
|
||||
terminal: createdTerminal("terminal-2"),
|
||||
workspaceDirectory: "/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
cwd: "/repo",
|
||||
requestId: "existing",
|
||||
terminals: [
|
||||
listedTerminal("terminal-1"),
|
||||
{ id: "terminal-2", name: "terminal-2", title: "terminal-2" },
|
||||
],
|
||||
});
|
||||
expect(removeTerminalFromPayload("terminal-1")(current)).toEqual({
|
||||
cwd: "/repo",
|
||||
requestId: "existing",
|
||||
terminals: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/app/src/screens/workspace/terminals/state.ts
Normal file
106
packages/app/src/screens/workspace/terminals/state.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { CreateTerminalResponse, ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
|
||||
export const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
|
||||
export type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
type TerminalEntry = ListTerminalsPayload["terminals"][number];
|
||||
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
|
||||
|
||||
export function buildTerminalsQueryKey(serverId: string, workspaceDirectory: string | null) {
|
||||
return ["terminals", serverId, workspaceDirectory] as const;
|
||||
}
|
||||
|
||||
export function canCreateWorkspaceTerminal(input: {
|
||||
isRouteFocused: boolean;
|
||||
client: unknown;
|
||||
isConnected: boolean;
|
||||
workspaceDirectory: string | null;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
|
||||
);
|
||||
}
|
||||
|
||||
export function reconcilePendingScriptTerminals(liveTerminalIds: string[], dataUpdatedAt: number) {
|
||||
return function update(pendingTerminalIds: Map<string, number>): Map<string, number> {
|
||||
if (pendingTerminalIds.size === 0) {
|
||||
return pendingTerminalIds;
|
||||
}
|
||||
const liveIds = new Set(liveTerminalIds);
|
||||
let changed = false;
|
||||
const nextTerminalIds = new Map<string, number>();
|
||||
for (const [terminalId, listedAt] of pendingTerminalIds) {
|
||||
if (liveIds.has(terminalId) || dataUpdatedAt > listedAt) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
nextTerminalIds.set(terminalId, listedAt);
|
||||
}
|
||||
return changed ? nextTerminalIds : pendingTerminalIds;
|
||||
};
|
||||
}
|
||||
|
||||
export function collectKnownTerminalIds(input: {
|
||||
liveTerminalIds: string[];
|
||||
pendingScriptTerminalIds: Map<string, number>;
|
||||
}): string[] {
|
||||
const terminalIds = new Set(input.liveTerminalIds);
|
||||
for (const terminalId of input.pendingScriptTerminalIds.keys()) {
|
||||
terminalIds.add(terminalId);
|
||||
}
|
||||
return Array.from(terminalIds);
|
||||
}
|
||||
|
||||
export function collectScriptTerminalIds(input: {
|
||||
pendingScriptTerminalIds: Map<string, number>;
|
||||
scripts: Array<{ terminalId?: string | null }>;
|
||||
}): Set<string> {
|
||||
const terminalIds = new Set(input.pendingScriptTerminalIds.keys());
|
||||
for (const script of input.scripts) {
|
||||
if (script.terminalId) {
|
||||
terminalIds.add(script.terminalId);
|
||||
}
|
||||
}
|
||||
return terminalIds;
|
||||
}
|
||||
|
||||
export function collectStandaloneTerminalIds(input: {
|
||||
terminals: TerminalEntry[];
|
||||
scriptTerminalIds: Set<string>;
|
||||
}): string[] {
|
||||
return input.terminals
|
||||
.filter((terminal) => !input.scriptTerminalIds.has(terminal.id))
|
||||
.map((terminal) => terminal.id);
|
||||
}
|
||||
|
||||
export function removeTerminalFromPayload(terminalId: string) {
|
||||
return function updatePayload(
|
||||
current: ListTerminalsPayload | undefined,
|
||||
): ListTerminalsPayload | undefined {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertCreatedTerminalPayload(input: {
|
||||
current: ListTerminalsPayload | undefined;
|
||||
terminal: CreatedTerminal;
|
||||
workspaceDirectory: string | null;
|
||||
}): ListTerminalsPayload {
|
||||
const nextTerminals = upsertTerminalListEntry({
|
||||
terminals: input.current?.terminals ?? [],
|
||||
terminal: input.terminal,
|
||||
});
|
||||
const cwd = input.current?.cwd ?? input.workspaceDirectory;
|
||||
return {
|
||||
...(cwd ? { cwd } : {}),
|
||||
terminals: nextTerminals,
|
||||
requestId: input.current?.requestId ?? `terminal-create-${input.terminal.id}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
buildTerminalsQueryKey,
|
||||
canCreateWorkspaceTerminal,
|
||||
collectKnownTerminalIds,
|
||||
collectScriptTerminalIds,
|
||||
collectStandaloneTerminalIds,
|
||||
reconcilePendingScriptTerminals,
|
||||
removeTerminalFromPayload,
|
||||
TERMINALS_QUERY_STALE_TIME,
|
||||
type ListTerminalsPayload,
|
||||
upsertCreatedTerminalPayload,
|
||||
} from "@/screens/workspace/terminals/state";
|
||||
|
||||
interface PendingTerminalCreateInput {
|
||||
paneId?: string;
|
||||
}
|
||||
|
||||
interface UseWorkspaceTerminalsInput {
|
||||
client: DaemonClient | null;
|
||||
isConnected: boolean;
|
||||
isRouteFocused: boolean;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
workspaceScripts: WorkspaceDescriptor["scripts"];
|
||||
hasHydratedWorkspaces: boolean;
|
||||
isMissingWorkspaceExecutionAuthority: boolean;
|
||||
onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
|
||||
onScriptTerminalSelected: (terminalId: string) => void;
|
||||
onWorkspacePathUnavailable: () => void;
|
||||
onTerminalCreateQueued: () => void;
|
||||
}
|
||||
|
||||
export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
const {
|
||||
client,
|
||||
isConnected,
|
||||
isRouteFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
workspaceScripts,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
onTerminalCreated,
|
||||
onScriptTerminalSelected,
|
||||
onWorkspacePathUnavailable,
|
||||
onTerminalCreateQueued,
|
||||
} = input;
|
||||
const queryClient = useQueryClient();
|
||||
const [pendingCreateInput, setPendingCreateInput] = useState<PendingTerminalCreateInput | null>(
|
||||
null,
|
||||
);
|
||||
const canCreateNow = useMemo(
|
||||
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
|
||||
[isRouteFocused, client, isConnected, workspaceDirectory],
|
||||
);
|
||||
const queryKey = useMemo(
|
||||
() => buildTerminalsQueryKey(normalizedServerId, workspaceDirectory),
|
||||
[normalizedServerId, workspaceDirectory],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
enabled: canCreateNow,
|
||||
queryFn: async () => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.listTerminals(workspaceDirectory);
|
||||
},
|
||||
staleTime: TERMINALS_QUERY_STALE_TIME,
|
||||
});
|
||||
const terminals = useMemo(() => query.data?.terminals ?? [], [query.data]);
|
||||
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
|
||||
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingScriptTerminalIds(new Map());
|
||||
}, [normalizedServerId, normalizedWorkspaceId]);
|
||||
|
||||
const dataUpdatedAt = query.dataUpdatedAt;
|
||||
useEffect(() => {
|
||||
setPendingScriptTerminalIds(reconcilePendingScriptTerminals(liveTerminalIds, dataUpdatedAt));
|
||||
}, [liveTerminalIds, dataUpdatedAt]);
|
||||
|
||||
const knownTerminalIds = useMemo(
|
||||
() => collectKnownTerminalIds({ liveTerminalIds, pendingScriptTerminalIds }),
|
||||
[liveTerminalIds, pendingScriptTerminalIds],
|
||||
);
|
||||
const scriptTerminalIds = useMemo(
|
||||
() => collectScriptTerminalIds({ pendingScriptTerminalIds, scripts: workspaceScripts }),
|
||||
[pendingScriptTerminalIds, workspaceScripts],
|
||||
);
|
||||
const standaloneTerminalIds = useMemo(
|
||||
() => collectStandaloneTerminalIds({ terminals, scriptTerminalIds }),
|
||||
[scriptTerminalIds, terminals],
|
||||
);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (_input?: PendingTerminalCreateInput) => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.createTerminal(workspaceDirectory);
|
||||
},
|
||||
onSuccess: (payload, createInput) => {
|
||||
const createdTerminal = payload.terminal;
|
||||
if (createdTerminal) {
|
||||
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) =>
|
||||
upsertCreatedTerminalPayload({
|
||||
current,
|
||||
terminal: createdTerminal,
|
||||
workspaceDirectory,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
if (createdTerminal) {
|
||||
onTerminalCreated({
|
||||
terminalId: createdTerminal.id,
|
||||
paneId: createInput?.paneId,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
const killMutation = useMutation({
|
||||
mutationFn: async (terminalId: string) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.killTerminal(terminalId);
|
||||
if (!payload.success) {
|
||||
throw new Error("Unable to close terminal");
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribeChanged = client.on("terminals_changed", (message) => {
|
||||
if (message.payload.cwd !== workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) => ({
|
||||
cwd: message.payload.cwd,
|
||||
terminals: message.payload.terminals,
|
||||
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
|
||||
}));
|
||||
});
|
||||
|
||||
client.subscribeTerminals({ cwd: workspaceDirectory });
|
||||
|
||||
return () => {
|
||||
unsubscribeChanged();
|
||||
client.unsubscribeTerminals({ cwd: workspaceDirectory });
|
||||
};
|
||||
}, [client, isConnected, isRouteFocused, queryClient, queryKey, workspaceDirectory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingCreateInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canCreateNow && !createMutation.isPending) {
|
||||
const pendingInput = pendingCreateInput;
|
||||
setPendingCreateInput(null);
|
||||
createMutation.mutate(pendingInput);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
||||
setPendingCreateInput(null);
|
||||
onWorkspacePathUnavailable();
|
||||
}
|
||||
}, [
|
||||
canCreateNow,
|
||||
createMutation,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
onWorkspacePathUnavailable,
|
||||
pendingCreateInput,
|
||||
]);
|
||||
|
||||
const createTerminal = useCallback(
|
||||
(createInput?: PendingTerminalCreateInput) => {
|
||||
if (createMutation.isPending || pendingCreateInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canCreateNow) {
|
||||
createMutation.mutate(createInput);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
||||
onWorkspacePathUnavailable();
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingCreateInput(createInput ?? {});
|
||||
onTerminalCreateQueued();
|
||||
},
|
||||
[
|
||||
canCreateNow,
|
||||
createMutation,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
onTerminalCreateQueued,
|
||||
onWorkspacePathUnavailable,
|
||||
pendingCreateInput,
|
||||
],
|
||||
);
|
||||
|
||||
const handleScriptTerminalStarted = useCallback(
|
||||
(terminalId: string) => {
|
||||
setPendingScriptTerminalIds((pendingTerminalIds) => {
|
||||
if (pendingTerminalIds.get(terminalId) === query.dataUpdatedAt) {
|
||||
return pendingTerminalIds;
|
||||
}
|
||||
const nextTerminalIds = new Map(pendingTerminalIds);
|
||||
nextTerminalIds.set(terminalId, query.dataUpdatedAt);
|
||||
return nextTerminalIds;
|
||||
});
|
||||
onScriptTerminalSelected(terminalId);
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
[onScriptTerminalSelected, query.dataUpdatedAt, queryClient, queryKey],
|
||||
);
|
||||
|
||||
const handleViewScriptTerminal = useCallback(
|
||||
(terminalId: string) => {
|
||||
onScriptTerminalSelected(terminalId);
|
||||
},
|
||||
[onScriptTerminalSelected],
|
||||
);
|
||||
|
||||
const removeTerminalFromCache = useCallback(
|
||||
(terminalId: string) => {
|
||||
queryClient.setQueryData<ListTerminalsPayload>(
|
||||
queryKey,
|
||||
removeTerminalFromPayload(terminalId),
|
||||
);
|
||||
},
|
||||
[queryClient, queryKey],
|
||||
);
|
||||
|
||||
const invalidateTerminals = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
}, [queryClient, queryKey]);
|
||||
|
||||
return {
|
||||
canCreateNow,
|
||||
createMutation,
|
||||
createTerminal,
|
||||
handleScriptTerminalStarted,
|
||||
handleViewScriptTerminal,
|
||||
invalidateTerminals,
|
||||
killMutation,
|
||||
knownTerminalIds,
|
||||
liveTerminalIds,
|
||||
pendingCreateInput,
|
||||
query,
|
||||
queryKey,
|
||||
removeTerminalFromCache,
|
||||
standaloneTerminalIds,
|
||||
terminals,
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter, type Href } from "expo-router";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
@@ -94,8 +94,6 @@ import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||
import type { CheckoutStatusPayload } from "@/git/use-status-query";
|
||||
import { checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -158,10 +156,12 @@ import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/l
|
||||
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
|
||||
import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals";
|
||||
|
||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
|
||||
const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
|
||||
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
|
||||
const EMPTY_SET = new Set<string>();
|
||||
|
||||
@@ -1028,8 +1028,6 @@ function WorkspaceHeaderTitleBar({
|
||||
);
|
||||
}
|
||||
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
type PaneDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
function parsePaneDirection(actionId: string): PaneDirection | null {
|
||||
@@ -1180,39 +1178,6 @@ function resolveWorkspaceAuthorityState(
|
||||
};
|
||||
}
|
||||
|
||||
function reconcilePendingScriptTerminals(liveTerminalIds: string[], dataUpdatedAt: number) {
|
||||
return function update(pendingTerminalIds: Map<string, number>): Map<string, number> {
|
||||
if (pendingTerminalIds.size === 0) {
|
||||
return pendingTerminalIds;
|
||||
}
|
||||
const liveIds = new Set(liveTerminalIds);
|
||||
let changed = false;
|
||||
const nextTerminalIds = new Map<string, number>();
|
||||
for (const [terminalId, listedAt] of pendingTerminalIds) {
|
||||
if (liveIds.has(terminalId) || dataUpdatedAt > listedAt) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
nextTerminalIds.set(terminalId, listedAt);
|
||||
}
|
||||
return changed ? nextTerminalIds : pendingTerminalIds;
|
||||
};
|
||||
}
|
||||
|
||||
function removeTerminalFromPayload(terminalId: string) {
|
||||
return function updatePayload(
|
||||
current: ListTerminalsPayload | undefined,
|
||||
): ListTerminalsPayload | undefined {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string {
|
||||
const trimmed = host?.label?.trim();
|
||||
return trimmed ? trimmed : fallback;
|
||||
@@ -1359,17 +1324,6 @@ function shouldShowWorkspaceExplorerSidebar(input: {
|
||||
return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
|
||||
}
|
||||
|
||||
function canCreateWorkspaceTerminal(input: {
|
||||
isRouteFocused: boolean;
|
||||
client: unknown;
|
||||
isConnected: boolean;
|
||||
workspaceDirectory: string | null;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
|
||||
);
|
||||
}
|
||||
|
||||
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
@@ -1377,6 +1331,108 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
|
||||
return `${serverId}:${workspaceId}`;
|
||||
}
|
||||
|
||||
interface WorkspaceTerminalTabActionsInput {
|
||||
persistenceKey: string | null;
|
||||
focusWorkspacePane: (workspaceKey: string, paneId: string) => void;
|
||||
openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
toast: {
|
||||
error: (message: string) => void;
|
||||
show: (message: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkspaceTerminalTabActions {
|
||||
handleTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
|
||||
handleScriptTerminalSelected: (terminalId: string) => void;
|
||||
handleWorkspacePathUnavailable: () => void;
|
||||
handleTerminalCreateQueued: () => void;
|
||||
}
|
||||
|
||||
function useWorkspaceTerminalTabActions({
|
||||
persistenceKey,
|
||||
focusWorkspacePane,
|
||||
openWorkspaceTabFocused,
|
||||
toast,
|
||||
}: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions {
|
||||
const handleTerminalCreated = useCallback(
|
||||
({ terminalId, paneId }: { terminalId: string; paneId?: string }) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
if (paneId) {
|
||||
focusWorkspacePane(persistenceKey, paneId);
|
||||
}
|
||||
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
|
||||
},
|
||||
[focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
|
||||
);
|
||||
const handleScriptTerminalSelected = useCallback(
|
||||
(terminalId: string) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
|
||||
},
|
||||
[openWorkspaceTabFocused, persistenceKey],
|
||||
);
|
||||
const handleWorkspacePathUnavailable = useCallback(() => {
|
||||
toast.error("Workspace path is not available yet");
|
||||
}, [toast]);
|
||||
const handleTerminalCreateQueued = useCallback(() => {
|
||||
toast.show("Preparing workspace, opening terminal when ready...");
|
||||
}, [toast]);
|
||||
|
||||
return {
|
||||
handleTerminalCreated,
|
||||
handleScriptTerminalSelected,
|
||||
handleWorkspacePathUnavailable,
|
||||
handleTerminalCreateQueued,
|
||||
};
|
||||
}
|
||||
|
||||
function useWorkspaceCheckoutStatus(input: {
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
isConnected: boolean;
|
||||
isRouteFocused: boolean;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
}) {
|
||||
const isCheckoutQueryEnabled = useMemo(
|
||||
() =>
|
||||
canCreateWorkspaceTerminal({
|
||||
isRouteFocused: input.isRouteFocused,
|
||||
client: input.client,
|
||||
isConnected: input.isConnected,
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
}),
|
||||
[input.isRouteFocused, input.client, input.isConnected, input.workspaceDirectory],
|
||||
);
|
||||
const checkoutQuery = useQuery({
|
||||
queryKey: checkoutStatusQueryKey(
|
||||
input.normalizedServerId,
|
||||
input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`,
|
||||
),
|
||||
enabled: isCheckoutQueryEnabled,
|
||||
queryFn: async () => {
|
||||
if (!input.client || !input.workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await input.client.getCheckoutStatus(input.workspaceDirectory);
|
||||
},
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const isCheckoutStatusLoading = useMemo(
|
||||
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
|
||||
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
|
||||
);
|
||||
|
||||
return { checkoutQuery, isCheckoutStatusLoading };
|
||||
}
|
||||
|
||||
function WorkspaceScreenContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -1405,7 +1461,6 @@ function WorkspaceScreenContent({
|
||||
scopeKey: workspaceTerminalScopeKey,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const workspaceAuthority = useMemo(
|
||||
@@ -1430,12 +1485,19 @@ function WorkspaceScreenContent({
|
||||
useProvidersSnapshot(normalizedServerId, {
|
||||
enabled: isRouteFocused,
|
||||
});
|
||||
const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{
|
||||
paneId?: string;
|
||||
} | null>(null);
|
||||
const canCreateTerminalNow = useMemo(
|
||||
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
|
||||
[isRouteFocused, client, isConnected, workspaceDirectory],
|
||||
|
||||
const persistenceKey = useMemo(
|
||||
() =>
|
||||
buildWorkspaceTabPersistenceKey({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
}),
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
);
|
||||
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
||||
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||
);
|
||||
|
||||
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
||||
@@ -1449,167 +1511,56 @@ function WorkspaceScreenContent({
|
||||
workspaceAgentVisibilityEqual,
|
||||
);
|
||||
|
||||
const terminalsQueryKey = useMemo(
|
||||
() => ["terminals", normalizedServerId, workspaceDirectory] as const,
|
||||
[normalizedServerId, workspaceDirectory],
|
||||
);
|
||||
const terminalsQuery = useQuery({
|
||||
queryKey: terminalsQueryKey,
|
||||
enabled: canCreateTerminalNow,
|
||||
queryFn: async () => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.listTerminals(workspaceDirectory);
|
||||
},
|
||||
staleTime: TERMINALS_QUERY_STALE_TIME,
|
||||
const {
|
||||
handleTerminalCreated,
|
||||
handleScriptTerminalSelected,
|
||||
handleWorkspacePathUnavailable,
|
||||
handleTerminalCreateQueued,
|
||||
} = useWorkspaceTerminalTabActions({
|
||||
persistenceKey,
|
||||
focusWorkspacePane,
|
||||
openWorkspaceTabFocused,
|
||||
toast,
|
||||
});
|
||||
const terminals = useMemo(() => terminalsQuery.data?.terminals ?? [], [terminalsQuery.data]);
|
||||
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
|
||||
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
useEffect(() => {
|
||||
setPendingScriptTerminalIds(new Map());
|
||||
}, [normalizedServerId, normalizedWorkspaceId]);
|
||||
const terminalsDataUpdatedAt = terminalsQuery.dataUpdatedAt;
|
||||
useEffect(() => {
|
||||
setPendingScriptTerminalIds(
|
||||
reconcilePendingScriptTerminals(liveTerminalIds, terminalsDataUpdatedAt),
|
||||
);
|
||||
}, [liveTerminalIds, terminalsDataUpdatedAt]);
|
||||
const knownTerminalIds = useMemo(() => {
|
||||
const terminalIds = new Set(liveTerminalIds);
|
||||
for (const terminalId of pendingScriptTerminalIds.keys()) {
|
||||
terminalIds.add(terminalId);
|
||||
}
|
||||
return Array.from(terminalIds);
|
||||
}, [liveTerminalIds, pendingScriptTerminalIds]);
|
||||
const scriptTerminalIds = useMemo(() => {
|
||||
const terminalIds = new Set(pendingScriptTerminalIds.keys());
|
||||
for (const script of workspaceDescriptor?.scripts ?? []) {
|
||||
if (script.terminalId) {
|
||||
terminalIds.add(script.terminalId);
|
||||
}
|
||||
}
|
||||
return terminalIds;
|
||||
}, [pendingScriptTerminalIds, workspaceDescriptor?.scripts]);
|
||||
const standaloneTerminalIds = useMemo(
|
||||
() =>
|
||||
terminals
|
||||
.filter((terminal) => !scriptTerminalIds.has(terminal.id))
|
||||
.map((terminal) => terminal.id),
|
||||
[scriptTerminalIds, terminals],
|
||||
);
|
||||
const createTerminalMutation = useMutation({
|
||||
mutationFn: async (_input?: { paneId?: string }) => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.createTerminal(workspaceDirectory);
|
||||
},
|
||||
onSuccess: (payload, input) => {
|
||||
const createdTerminal = payload.terminal;
|
||||
if (createdTerminal) {
|
||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
||||
const nextTerminals = upsertTerminalListEntry({
|
||||
terminals: current?.terminals ?? [],
|
||||
terminal: createdTerminal,
|
||||
});
|
||||
const cwd = current?.cwd ?? workspaceDirectory;
|
||||
return {
|
||||
...(cwd ? { cwd } : {}),
|
||||
terminals: nextTerminals,
|
||||
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
||||
if (createdTerminal) {
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
if (!workspaceKey) {
|
||||
return;
|
||||
}
|
||||
if (input?.paneId) {
|
||||
focusWorkspacePane(workspaceKey, input.paneId);
|
||||
}
|
||||
useWorkspaceLayoutStore
|
||||
.getState()
|
||||
.openTabFocused(workspaceKey, { kind: "terminal", terminalId: createdTerminal.id });
|
||||
}
|
||||
},
|
||||
});
|
||||
const killTerminalMutation = useMutation({
|
||||
mutationFn: async (terminalId: string) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.killTerminal(terminalId);
|
||||
if (!payload.success) {
|
||||
throw new Error("Unable to close terminal");
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
const {
|
||||
createMutation: createTerminalMutation,
|
||||
createTerminal,
|
||||
handleScriptTerminalStarted,
|
||||
handleViewScriptTerminal,
|
||||
invalidateTerminals,
|
||||
killMutation: killTerminalMutation,
|
||||
knownTerminalIds,
|
||||
liveTerminalIds,
|
||||
pendingCreateInput: pendingTerminalCreateInput,
|
||||
query: terminalsQuery,
|
||||
removeTerminalFromCache,
|
||||
standaloneTerminalIds,
|
||||
terminals,
|
||||
} = useWorkspaceTerminals({
|
||||
client,
|
||||
isConnected,
|
||||
isRouteFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
workspaceScripts: workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
onTerminalCreated: handleTerminalCreated,
|
||||
onScriptTerminalSelected: handleScriptTerminalSelected,
|
||||
onWorkspacePathUnavailable: handleWorkspacePathUnavailable,
|
||||
onTerminalCreateQueued: handleTerminalCreateQueued,
|
||||
});
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribeChanged = client.on("terminals_changed", (message) => {
|
||||
if (message.payload.cwd !== workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => ({
|
||||
cwd: message.payload.cwd,
|
||||
terminals: message.payload.terminals,
|
||||
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
|
||||
}));
|
||||
});
|
||||
|
||||
client.subscribeTerminals({ cwd: workspaceDirectory });
|
||||
|
||||
return () => {
|
||||
unsubscribeChanged();
|
||||
client.unsubscribeTerminals({ cwd: workspaceDirectory });
|
||||
};
|
||||
}, [client, isConnected, isRouteFocused, queryClient, terminalsQueryKey, workspaceDirectory]);
|
||||
|
||||
const isCheckoutQueryEnabled = useMemo(
|
||||
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
|
||||
[isRouteFocused, client, isConnected, workspaceDirectory],
|
||||
);
|
||||
const checkoutQuery = useQuery({
|
||||
queryKey: checkoutStatusQueryKey(
|
||||
normalizedServerId,
|
||||
workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`,
|
||||
),
|
||||
enabled: isCheckoutQueryEnabled,
|
||||
queryFn: async () => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.getCheckoutStatus(workspaceDirectory);
|
||||
},
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({
|
||||
client,
|
||||
isConnected,
|
||||
isRouteFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
});
|
||||
const isCheckoutStatusLoading = useMemo(
|
||||
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
|
||||
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
|
||||
);
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||
);
|
||||
const hasHydratedAgents = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
|
||||
);
|
||||
@@ -1618,30 +1569,6 @@ function WorkspaceScreenContent({
|
||||
workspace: workspaceDescriptor,
|
||||
hasHydratedWorkspaces,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!pendingTerminalCreateInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canCreateTerminalNow && !createTerminalMutation.isPending) {
|
||||
const pendingInput = pendingTerminalCreateInput;
|
||||
setPendingTerminalCreateInput(null);
|
||||
createTerminalMutation.mutate(pendingInput);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
||||
setPendingTerminalCreateInput(null);
|
||||
toast.error("Workspace path is not available yet");
|
||||
}
|
||||
}, [
|
||||
canCreateTerminalNow,
|
||||
createTerminalMutation,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
pendingTerminalCreateInput,
|
||||
toast,
|
||||
]);
|
||||
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
|
||||
isCheckoutStatusLoading,
|
||||
isError: checkoutQuery.isError,
|
||||
@@ -1738,15 +1665,6 @@ function WorkspaceScreenContent({
|
||||
return () => handler.remove();
|
||||
}, [isExplorerOpen, isRouteFocused, showMobileAgent]);
|
||||
|
||||
const persistenceKey = useMemo(
|
||||
() =>
|
||||
buildWorkspaceTabPersistenceKey({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
}),
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
);
|
||||
|
||||
const workspaceLayout = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
|
||||
);
|
||||
@@ -1761,7 +1679,6 @@ function WorkspaceScreenContent({
|
||||
[workspaceLayout],
|
||||
);
|
||||
useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused });
|
||||
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
||||
const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
|
||||
(state) => state.openTabInBackground,
|
||||
);
|
||||
@@ -1777,39 +1694,6 @@ function WorkspaceScreenContent({
|
||||
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
|
||||
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
||||
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
|
||||
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
|
||||
const handleScriptTerminalStarted = useCallback(
|
||||
(terminalId: string) => {
|
||||
setPendingScriptTerminalIds((pendingTerminalIds) => {
|
||||
if (pendingTerminalIds.get(terminalId) === terminalsQuery.dataUpdatedAt) {
|
||||
return pendingTerminalIds;
|
||||
}
|
||||
const nextTerminalIds = new Map(pendingTerminalIds);
|
||||
nextTerminalIds.set(terminalId, terminalsQuery.dataUpdatedAt);
|
||||
return nextTerminalIds;
|
||||
});
|
||||
if (persistenceKey) {
|
||||
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
||||
},
|
||||
[
|
||||
openWorkspaceTabFocused,
|
||||
persistenceKey,
|
||||
queryClient,
|
||||
terminalsQuery.dataUpdatedAt,
|
||||
terminalsQueryKey,
|
||||
],
|
||||
);
|
||||
const handleViewScriptTerminal = useCallback(
|
||||
(terminalId: string) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
|
||||
},
|
||||
[openWorkspaceTabFocused, persistenceKey],
|
||||
);
|
||||
const paneFocusSuppressedRef = useRef(false);
|
||||
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
|
||||
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
||||
@@ -2200,24 +2084,7 @@ function WorkspaceScreenContent({
|
||||
[focusWorkspacePane, openWorkspaceDraftTab, persistenceKey],
|
||||
);
|
||||
|
||||
const handleCreateTerminal = useStableEvent((input?: { paneId?: string }) => {
|
||||
if (createTerminalMutation.isPending || pendingTerminalCreateInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canCreateTerminalNow) {
|
||||
createTerminalMutation.mutate(input);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
||||
toast.error("Workspace path is not available yet");
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingTerminalCreateInput(input ?? {});
|
||||
toast.show("Preparing workspace, opening terminal when ready...");
|
||||
});
|
||||
const handleCreateTerminal = useStableEvent(createTerminal);
|
||||
|
||||
const handleCreateBrowserTab = useCallback(
|
||||
(input?: { paneId?: string }) => {
|
||||
@@ -2284,10 +2151,7 @@ function WorkspaceScreenContent({
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<ListTerminalsPayload>(
|
||||
terminalsQueryKey,
|
||||
removeTerminalFromPayload(terminalId),
|
||||
);
|
||||
removeTerminalFromCache(terminalId);
|
||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||
if (persistenceKey) {
|
||||
@@ -2297,18 +2161,16 @@ function WorkspaceScreenContent({
|
||||
});
|
||||
}
|
||||
|
||||
void killTerminalAsync(terminalId).catch(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
||||
});
|
||||
void killTerminalAsync(terminalId).catch(invalidateTerminals);
|
||||
});
|
||||
},
|
||||
[
|
||||
closeTab,
|
||||
closeWorkspaceTabWithCleanup,
|
||||
invalidateTerminals,
|
||||
killTerminalAsync,
|
||||
persistenceKey,
|
||||
queryClient,
|
||||
terminalsQueryKey,
|
||||
removeTerminalFromCache,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import invariant from "tiny-invariant";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
|
||||
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
@@ -86,7 +88,7 @@ interface InsertSplitInternalInput {
|
||||
targetPaneId: string;
|
||||
tabId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
createNodeId: (prefix: "pane" | "group") => string;
|
||||
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
|
||||
}
|
||||
|
||||
interface InsertSplitInternalResult {
|
||||
@@ -142,7 +144,7 @@ interface SplitPaneInLayoutInput {
|
||||
tabId: string;
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
createNodeId: (prefix: "pane" | "group") => string;
|
||||
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
|
||||
maxTreeDepth: number;
|
||||
}
|
||||
|
||||
@@ -155,7 +157,7 @@ interface SplitPaneEmptyInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
createNodeId: (prefix: "pane" | "group") => string;
|
||||
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
|
||||
maxTreeDepth: number;
|
||||
}
|
||||
|
||||
@@ -232,14 +234,6 @@ function normalizeTabIds(list: unknown): string[] {
|
||||
return next;
|
||||
}
|
||||
|
||||
function generateNodeId(prefix: "pane" | "group"): string {
|
||||
const randomValue =
|
||||
typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `${prefix}_${randomValue}`;
|
||||
}
|
||||
|
||||
function createPaneNode(input: {
|
||||
id: string;
|
||||
tabs?: WorkspaceTab[];
|
||||
@@ -996,13 +990,16 @@ export function insertSplit(
|
||||
targetPaneId: string,
|
||||
tabId: string,
|
||||
position: "left" | "right" | "top" | "bottom",
|
||||
createNodeId: (
|
||||
prefix: WorkspaceLayoutNodeIdPrefix,
|
||||
) => string = defaultWorkspaceLayoutIds.createNodeId,
|
||||
): SplitNode {
|
||||
return insertSplitInternal({
|
||||
root: asInternalNode(root),
|
||||
targetPaneId,
|
||||
tabId,
|
||||
position,
|
||||
createNodeId: generateNodeId,
|
||||
createNodeId,
|
||||
}).root;
|
||||
}
|
||||
|
||||
|
||||
17
packages/app/src/stores/workspace-layout-ids.ts
Normal file
17
packages/app/src/stores/workspace-layout-ids.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type WorkspaceLayoutNodeIdPrefix = "pane" | "group";
|
||||
|
||||
export interface WorkspaceLayoutIdSource {
|
||||
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
|
||||
createFocusRestorationToken: () => string;
|
||||
}
|
||||
|
||||
function createRandomIdValue(): string {
|
||||
return typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export const defaultWorkspaceLayoutIds: WorkspaceLayoutIdSource = {
|
||||
createNodeId: (prefix) => `${prefix}_${createRandomIdValue()}`,
|
||||
createFocusRestorationToken: () => `workspace-focus-${createRandomIdValue()}`,
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
createWorkspaceLayoutStore,
|
||||
createDefaultLayout,
|
||||
findPaneById,
|
||||
findPaneContainingTab,
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
insertSplit,
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
useWorkspaceLayoutStore,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
@@ -36,6 +36,40 @@ import {
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "ws-main";
|
||||
|
||||
function createDeterministicWorkspaceLayoutIds() {
|
||||
let values: string[] = [];
|
||||
let fallbackIndex = 0;
|
||||
|
||||
function nextValue(): string {
|
||||
const value = values.shift();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
fallbackIndex += 1;
|
||||
return `generated-${fallbackIndex}`;
|
||||
}
|
||||
|
||||
return {
|
||||
useValues: (nextValues: string[]) => {
|
||||
values = nextValues.slice();
|
||||
fallbackIndex = 0;
|
||||
},
|
||||
reset: () => {
|
||||
values = [];
|
||||
fallbackIndex = 0;
|
||||
},
|
||||
createNodeId: (prefix: "pane" | "group") => `${prefix}_${nextValue()}`,
|
||||
createFocusRestorationToken: () => `workspace-focus-${nextValue()}`,
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds();
|
||||
const workspaceLayoutStore = createWorkspaceLayoutStore(workspaceLayoutIds);
|
||||
|
||||
function useWorkspaceLayoutIds(...values: string[]) {
|
||||
workspaceLayoutIds.useValues(values);
|
||||
}
|
||||
|
||||
function createTab(tabId: string, target?: WorkspaceTab["target"]): WorkspaceTab {
|
||||
return {
|
||||
tabId,
|
||||
@@ -168,13 +202,14 @@ describe("workspace-layout-store helpers", () => {
|
||||
|
||||
describe("workspace-layout-store tree transforms", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
workspaceLayoutIds.reset();
|
||||
});
|
||||
|
||||
it("insertSplit wraps root-level same-direction splits in a nested group", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
|
||||
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
|
||||
useWorkspaceLayoutIds(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
);
|
||||
|
||||
const root: SplitNode = {
|
||||
kind: "group",
|
||||
@@ -189,7 +224,7 @@ describe("workspace-layout-store tree transforms", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const nextRoot = insertSplit(root, "right", "tab-c", "right");
|
||||
const nextRoot = insertSplit(root, "right", "tab-c", "right", workspaceLayoutIds.createNodeId);
|
||||
const nextGroup = expectGroup(nextRoot);
|
||||
const nestedGroup = expectGroup(nextGroup.group.children[1]);
|
||||
|
||||
@@ -270,22 +305,20 @@ describe("workspace-layout-store tree transforms", () => {
|
||||
|
||||
describe("workspace-layout-store actions", () => {
|
||||
beforeEach(() => {
|
||||
useWorkspaceLayoutStore.setState({
|
||||
workspaceLayoutIds.reset();
|
||||
workspaceLayoutStore.setState({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
focusRestorationByWorkspace: {},
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
);
|
||||
useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -308,7 +341,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/b.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(firstTabId).toBe("file_/repo/worktree/a.ts");
|
||||
expect(secondTabId).toBe("file_/repo/worktree/b.ts");
|
||||
@@ -322,14 +355,14 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("openTabInBackground inserts a tab without stealing focus", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const setupTabId = store.openTabInBackground(workspaceKey, {
|
||||
kind: "setup",
|
||||
workspaceId: "ws-main",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const pane = findPaneById(layout.root, "main")!;
|
||||
|
||||
expect(agentTabId).toBe("agent_agent-1");
|
||||
@@ -341,7 +374,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("openTabInBackground on an existing target is a no-op", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -355,7 +388,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/a.ts",
|
||||
});
|
||||
const layoutAfter = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layoutAfter = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const pane = findPaneById(layoutAfter.root, "main")!;
|
||||
|
||||
expect(duplicateTabId).toBe(firstTabId);
|
||||
@@ -365,27 +398,25 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("unfocuses and restores the previous focused pane", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const token = store.unfocusPane(workspaceKey);
|
||||
expect(token).toBeTruthy();
|
||||
expect(
|
||||
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
).toBeNull();
|
||||
|
||||
store.restorePaneFocus(workspaceKey, token!);
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not restore stale focus after another pane is focused", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
);
|
||||
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
||||
store.splitPane(workspaceKey, {
|
||||
@@ -399,14 +430,14 @@ describe("workspace-layout-store actions", () => {
|
||||
store.focusPane(workspaceKey, "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
store.restorePaneFocus(workspaceKey, token!);
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
"pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for nested focus restorations before restoring", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const outerToken = store.unfocusPane(workspaceKey);
|
||||
@@ -414,22 +445,22 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
store.restorePaneFocus(workspaceKey, outerToken!);
|
||||
expect(
|
||||
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
).toBeNull();
|
||||
|
||||
store.restorePaneFocus(workspaceKey, innerToken!);
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(firstTabId).toBe("draft-1");
|
||||
expect(secondTabId).toBe("draft-2");
|
||||
@@ -450,11 +481,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("splitPaneEmpty plus openTab opens a draft tab in the new pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
|
||||
"77777777-7777-7777-7777-777777777777",
|
||||
);
|
||||
useWorkspaceLayoutIds("77777777-7777-7777-7777-777777777777");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const newPaneId = store.splitPaneEmpty(workspaceKey, {
|
||||
@@ -465,7 +494,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "draft",
|
||||
draftId: "draft-split",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777");
|
||||
expect(draftTabId).toBe("draft-split");
|
||||
@@ -476,11 +505,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("focusTab moves workspace focus to the pane containing the tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
);
|
||||
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const fileTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -497,22 +524,20 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.focusTab(workspaceKey, fileTabId!);
|
||||
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
|
||||
store.focusTab(workspaceKey, terminalTabId!);
|
||||
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId);
|
||||
});
|
||||
|
||||
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"12121212-1212-1212-1212-121212121212",
|
||||
);
|
||||
useWorkspaceLayoutIds("12121212-1212-1212-1212-121212121212");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
|
||||
@@ -523,7 +548,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1");
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const splitPane = findPaneById(layout.root, splitPaneId);
|
||||
const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId);
|
||||
|
||||
@@ -540,7 +565,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("retargetTab keeps a draft tab in place while updating its target", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const draftTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "draft",
|
||||
@@ -550,7 +575,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/retargeted.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(draftTabId).toBe("draft-retarget");
|
||||
expect(nextTabId).toBe(draftTabId);
|
||||
@@ -565,11 +590,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
|
||||
"55555555-5555-5555-5555-555555555555",
|
||||
);
|
||||
useWorkspaceLayoutIds("55555555-5555-5555-5555-555555555555");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const existingFileTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -590,7 +613,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/existing.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts");
|
||||
expect(draftTabId).toBe("draft-dup");
|
||||
@@ -606,7 +629,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("retargetTab closes a draft tab and focuses an existing matching target tab", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstDraftTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "draft",
|
||||
@@ -625,7 +648,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "agent",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(firstAgentTabId).toBe(firstDraftTabId);
|
||||
expect(nextTabId).toBe(firstDraftTabId);
|
||||
@@ -641,7 +664,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("reorderTabs reorders tabs within the focused pane", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -657,7 +680,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(findPaneById(layout.root, "main")).toEqual({
|
||||
id: "main",
|
||||
@@ -684,11 +707,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("reorderTabsInPane reorders tabs in the requested pane without changing focused pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"34343434-3434-3434-3434-343434343434",
|
||||
);
|
||||
useWorkspaceLayoutIds("34343434-3434-3434-3434-343434343434");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
store.openTabFocused(workspaceKey, {
|
||||
@@ -712,7 +733,7 @@ describe("workspace-layout-store actions", () => {
|
||||
store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!);
|
||||
store.focusPane(workspaceKey, "main");
|
||||
store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434");
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
@@ -736,11 +757,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("focusPane switches workspace focus to a different pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"56565656-5656-5656-5656-565656565656",
|
||||
);
|
||||
useWorkspaceLayoutIds("56565656-5656-5656-5656-565656565656");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, {
|
||||
@@ -754,22 +773,20 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.focusPane(workspaceKey, "main");
|
||||
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
|
||||
store.focusPane(workspaceKey, splitPaneId!);
|
||||
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656");
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
});
|
||||
|
||||
it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||||
);
|
||||
useWorkspaceLayoutIds("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, {
|
||||
@@ -783,7 +800,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.closeTab(workspaceKey, secondTabId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
@@ -791,18 +808,19 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("splitPane enforces the maximum depth of four", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
|
||||
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222")
|
||||
.mockReturnValueOnce("33333333-3333-3333-3333-333333333333")
|
||||
.mockReturnValueOnce("44444444-4444-4444-4444-444444444444")
|
||||
.mockReturnValueOnce("55555555-5555-5555-5555-555555555555")
|
||||
.mockReturnValueOnce("66666666-6666-6666-6666-666666666666")
|
||||
.mockReturnValueOnce("77777777-7777-7777-7777-777777777777")
|
||||
.mockReturnValueOnce("88888888-8888-8888-8888-888888888888");
|
||||
useWorkspaceLayoutIds(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
"33333333-3333-3333-3333-333333333333",
|
||||
"44444444-4444-4444-4444-444444444444",
|
||||
"55555555-5555-5555-5555-555555555555",
|
||||
"66666666-6666-6666-6666-666666666666",
|
||||
"77777777-7777-7777-7777-777777777777",
|
||||
"88888888-8888-8888-8888-888888888888",
|
||||
);
|
||||
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const c = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
|
||||
@@ -831,7 +849,7 @@ describe("workspace-layout-store actions", () => {
|
||||
position: "bottom",
|
||||
});
|
||||
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111");
|
||||
expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333");
|
||||
expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555");
|
||||
@@ -840,11 +858,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("moveTabToPane collapses the source pane when its last tab moves out", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"dddddddd-dddd-dddd-dddd-dddddddddddd",
|
||||
);
|
||||
useWorkspaceLayoutIds("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const leftTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -861,7 +877,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]);
|
||||
@@ -872,13 +888,14 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("78787878-7878-7878-7878-787878787878")
|
||||
.mockReturnValueOnce("89898989-8989-8989-8989-898989898989")
|
||||
.mockReturnValueOnce("9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a");
|
||||
useWorkspaceLayoutIds(
|
||||
"78787878-7878-7878-7878-787878787878",
|
||||
"89898989-8989-8989-8989-898989898989",
|
||||
"9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a",
|
||||
);
|
||||
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, {
|
||||
@@ -901,7 +918,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.closeTab(workspaceKey, secondTabId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const rootGroup = expectGroup(layout.root);
|
||||
|
||||
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
|
||||
@@ -937,11 +954,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("openTab focuses the existing tab instead of creating a duplicate entry", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"abababab-abab-abab-abab-abababababab",
|
||||
);
|
||||
useWorkspaceLayoutIds("abababab-abab-abab-abab-abababababab");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, {
|
||||
@@ -959,7 +974,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/b.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab");
|
||||
expect(duplicateTabId).toBe(secondTabId);
|
||||
@@ -971,13 +986,14 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")
|
||||
.mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111");
|
||||
useWorkspaceLayoutIds(
|
||||
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
|
||||
"ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
);
|
||||
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
@@ -995,12 +1011,12 @@ describe("workspace-layout-store actions", () => {
|
||||
position: "right",
|
||||
});
|
||||
|
||||
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||
const splitRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||
const splitGroup = expectGroup(splitRoot);
|
||||
const nestedGroup = expectGroup(splitGroup.group.children[1]);
|
||||
store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]);
|
||||
|
||||
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||
const resizedRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||
const resizedGroup = expectGroup(resizedRoot);
|
||||
const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]);
|
||||
const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
|
||||
@@ -1014,11 +1030,11 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("closing the last tab keeps a single empty pane in the layout", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const tabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
||||
store.closeTab(workspaceKey, tabId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(layout).toEqual(createDefaultLayout());
|
||||
});
|
||||
@@ -1032,12 +1048,12 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
expect(otherWorkspaceKey).toBeTruthy();
|
||||
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
store.pinAgent(workspaceKey, "agent-1");
|
||||
store.pinAgent(workspaceKey, "agent-1");
|
||||
store.pinAgent(otherWorkspaceKey as string, "agent-2");
|
||||
|
||||
let state = useWorkspaceLayoutStore.getState();
|
||||
let state = workspaceLayoutStore.getState();
|
||||
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
|
||||
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
||||
"agent-2",
|
||||
@@ -1045,13 +1061,13 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
store.unpinAgent(workspaceKey, "agent-1");
|
||||
|
||||
state = useWorkspaceLayoutStore.getState();
|
||||
state = workspaceLayoutStore.getState();
|
||||
expect(state.pinnedAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
||||
"agent-2",
|
||||
]);
|
||||
|
||||
const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
|
||||
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
|
||||
expect(partialize).toBeTypeOf("function");
|
||||
expect(partialize?.(state)).toEqual({
|
||||
layoutByWorkspace: {},
|
||||
@@ -1068,12 +1084,12 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
expect(otherWorkspaceKey).toBeTruthy();
|
||||
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
store.hideAgent(otherWorkspaceKey as string, "agent-2");
|
||||
|
||||
let state = useWorkspaceLayoutStore.getState();
|
||||
let state = workspaceLayoutStore.getState();
|
||||
expect(Array.from(state.hiddenAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
|
||||
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
||||
"agent-2",
|
||||
@@ -1081,13 +1097,13 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
store.unhideAgent(workspaceKey, "agent-1");
|
||||
|
||||
state = useWorkspaceLayoutStore.getState();
|
||||
state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
||||
"agent-2",
|
||||
]);
|
||||
|
||||
const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
|
||||
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
|
||||
expect(partialize).toBeTypeOf("function");
|
||||
expect(partialize?.(state)).toEqual({
|
||||
layoutByWorkspace: {},
|
||||
@@ -1096,11 +1112,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"67676767-6767-6767-6767-676767676767",
|
||||
);
|
||||
useWorkspaceLayoutIds("67676767-6767-6767-6767-676767676767");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const draftTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "draft",
|
||||
@@ -1114,7 +1128,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1");
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767");
|
||||
expect(nextTabId).toBe("agent_agent-1");
|
||||
@@ -1126,7 +1140,7 @@ describe("workspace-layout-store actions", () => {
|
||||
it("reconcileTabs canonicalizes duplicates and prunes stale entity tabs from hydrated snapshots", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
useWorkspaceLayoutStore.setState((state) => ({
|
||||
workspaceLayoutStore.setState((state) => ({
|
||||
...state,
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
@@ -1169,7 +1183,7 @@ describe("workspace-layout-store actions", () => {
|
||||
},
|
||||
}));
|
||||
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["agent-1"],
|
||||
@@ -1179,7 +1193,7 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const tabs = collectAllTabs(layout.root);
|
||||
|
||||
expect(tabs.map((tab) => tab.tabId)).toEqual([
|
||||
@@ -1200,14 +1214,14 @@ describe("workspace-layout-store actions", () => {
|
||||
it("reconcileTabs does not re-add locally hidden agent tabs", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
useWorkspaceLayoutStore.setState((state) => ({
|
||||
workspaceLayoutStore.setState((state) => ({
|
||||
...state,
|
||||
hiddenAgentIdsByWorkspace: {
|
||||
[workspaceKey]: new Set<string>(["agent-1"]),
|
||||
},
|
||||
}));
|
||||
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["agent-1"],
|
||||
@@ -1217,13 +1231,13 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["parent-agent", "child-agent"],
|
||||
@@ -1234,7 +1248,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
useWorkspaceLayoutStore
|
||||
workspaceLayoutStore
|
||||
.getState()
|
||||
.getWorkspaceTabs(workspaceKey)
|
||||
.map((tab) => tab.tabId),
|
||||
@@ -1243,7 +1257,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("reconcileTabs keeps manually opened subagent tabs that remain active", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
|
||||
|
||||
@@ -1258,7 +1272,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
useWorkspaceLayoutStore
|
||||
workspaceLayoutStore
|
||||
.getState()
|
||||
.getWorkspaceTabs(workspaceKey)
|
||||
.map((tab) => tab.tabId),
|
||||
@@ -1267,7 +1281,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("reconcileTabs prunes archived subagent tabs that are no longer active", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
|
||||
|
||||
@@ -1282,7 +1296,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
useWorkspaceLayoutStore
|
||||
workspaceLayoutStore
|
||||
.getState()
|
||||
.getWorkspaceTabs(workspaceKey)
|
||||
.map((tab) => tab.tabId),
|
||||
@@ -1291,7 +1305,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("openTabFocused reopens hidden subagent tabs and clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.hideAgent(workspaceKey, "child-agent");
|
||||
store.reconcileTabs(workspaceKey, {
|
||||
@@ -1304,11 +1318,11 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
|
||||
"agent_child-agent",
|
||||
@@ -1317,7 +1331,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("reconcileTabs auto-opens only standalone terminals while keeping explicitly opened live terminals", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const scriptTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "terminal",
|
||||
@@ -1335,8 +1349,8 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
const tabs = useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const tabs = workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]);
|
||||
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
|
||||
});
|
||||
@@ -1344,7 +1358,7 @@ describe("workspace-layout-store actions", () => {
|
||||
it("reconcileTabs does not auto-open live non-standalone terminals", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: [],
|
||||
@@ -1355,46 +1369,44 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
});
|
||||
|
||||
it("explicitly opening an agent tab clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
|
||||
});
|
||||
|
||||
it("pinning an agent clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
expect(
|
||||
useWorkspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey],
|
||||
).toBeDefined();
|
||||
expect(workspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey]).toBeDefined();
|
||||
|
||||
store.pinAgent(workspaceKey, "agent-1");
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
|
||||
});
|
||||
|
||||
it("retargeting a tab to an agent clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
const tabId = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
store.retargetTab(workspaceKey, tabId!, { kind: "agent", agentId: "agent-1" });
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,154 +1,142 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClientConfig } from "@server/client/daemon-client";
|
||||
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
|
||||
|
||||
const daemonClientMock = vi.hoisted(() => {
|
||||
const createdConfigs: Array<{ clientId?: string; url?: string; password?: string }> = [];
|
||||
let nextConnectError: Error | null = null;
|
||||
let nextLastError: string | null = null;
|
||||
class FakeDaemonClient implements DaemonProbeClient {
|
||||
readonly lastError: string | null;
|
||||
|
||||
class MockDaemonClient {
|
||||
public lastError: string | null = nextLastError;
|
||||
private lastServerInfo = {
|
||||
status: "server_info" as const,
|
||||
serverId: "srv_probe_test",
|
||||
hostname: "probe-host" as string | null,
|
||||
version: "0.0.0",
|
||||
};
|
||||
constructor(
|
||||
private readonly probe: FakeDaemonProbe,
|
||||
readonly config: DaemonClientConfig,
|
||||
) {
|
||||
this.lastError = probe.nextLastError;
|
||||
}
|
||||
|
||||
constructor(config: { clientId?: string; url?: string; password?: string }) {
|
||||
createdConfigs.push(config);
|
||||
}
|
||||
|
||||
subscribeConnectionStatus(): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
on(): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (nextConnectError) {
|
||||
throw nextConnectError;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
getLastServerInfoMessage() {
|
||||
return this.lastServerInfo;
|
||||
}
|
||||
|
||||
async ping(): Promise<{ rttMs: number }> {
|
||||
return { rttMs: 42 };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
return;
|
||||
async connect(): Promise<void> {
|
||||
if (this.probe.nextConnectError) {
|
||||
throw this.probe.nextConnectError;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
MockDaemonClient,
|
||||
createdConfigs,
|
||||
setNextConnectFailure: (error: Error, lastError: string | null) => {
|
||||
nextConnectError = error;
|
||||
nextLastError = lastError;
|
||||
getLastServerInfoMessage() {
|
||||
return {
|
||||
serverId: "srv_probe_test",
|
||||
hostname: "probe-host",
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.probe.closedClients.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDaemonProbe {
|
||||
createdClients: FakeDaemonClient[] = [];
|
||||
closedClients: FakeDaemonClient[] = [];
|
||||
clientIdsRequested = 0;
|
||||
nextConnectError: Error | null = null;
|
||||
nextLastError: string | null = null;
|
||||
|
||||
readonly deps: DaemonConnectionDependencies<FakeDaemonClient> = {
|
||||
getClientId: async () => {
|
||||
this.clientIdsRequested += 1;
|
||||
return "cid_shared_probe_test";
|
||||
},
|
||||
reset: () => {
|
||||
createdConfigs.length = 0;
|
||||
nextConnectError = null;
|
||||
nextLastError = null;
|
||||
resolveAppVersion: () => null,
|
||||
createLocalTransportFactory: () => null,
|
||||
buildLocalTransportUrl: ({ transportType, transportPath }) =>
|
||||
`paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
|
||||
createClient: (config) => {
|
||||
const client = new FakeDaemonClient(this, config);
|
||||
this.createdClients.push(client);
|
||||
return client;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const clientIdMock = vi.hoisted(() => ({
|
||||
getOrCreateClientId: vi.fn(async () => "cid_shared_probe_test"),
|
||||
}));
|
||||
failNextConnection(error: Error, lastError: string | null): void {
|
||||
this.nextConnectError = error;
|
||||
this.nextLastError = lastError;
|
||||
}
|
||||
|
||||
vi.mock("@server/client/daemon-client", () => ({
|
||||
DaemonClient: daemonClientMock.MockDaemonClient,
|
||||
}));
|
||||
|
||||
vi.mock("./client-id", () => ({
|
||||
getOrCreateClientId: clientIdMock.getOrCreateClientId,
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/daemon/desktop-daemon-transport", () => ({
|
||||
createDesktopLocalDaemonTransportFactory: vi.fn(() => null),
|
||||
buildLocalDaemonTransportUrl: vi.fn(
|
||||
({
|
||||
transportType,
|
||||
transportPath,
|
||||
}: {
|
||||
transportType: "socket" | "pipe";
|
||||
transportPath: string;
|
||||
}) => `paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
|
||||
),
|
||||
}));
|
||||
createdConfigs(): DaemonClientConfig[] {
|
||||
return this.createdClients.map((client) => client.config);
|
||||
}
|
||||
}
|
||||
|
||||
describe("test-daemon-connection connectToDaemon", () => {
|
||||
let probe: FakeDaemonProbe;
|
||||
|
||||
beforeEach(() => {
|
||||
daemonClientMock.reset();
|
||||
clientIdMock.getOrCreateClientId.mockClear();
|
||||
vi.stubGlobal("__DEV__", false);
|
||||
probe = new FakeDaemonProbe();
|
||||
});
|
||||
|
||||
it("reuses the app clientId for direct connections", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const first = await mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
});
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const first = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await first.client.close();
|
||||
|
||||
const second = await mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
});
|
||||
const second = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await second.client.close();
|
||||
|
||||
const [firstConfig, secondConfig] = daemonClientMock.createdConfigs;
|
||||
const [firstConfig, secondConfig] = probe.createdConfigs();
|
||||
expect(firstConfig?.clientId).toBe("cid_shared_probe_test");
|
||||
expect(secondConfig?.clientId).toBe("cid_shared_probe_test");
|
||||
expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
|
||||
expect(probe.clientIdsRequested).toBe(2);
|
||||
});
|
||||
|
||||
it("encodes the local socket target into the client config", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const result = await mod.connectToDaemon({
|
||||
id: "socket:/tmp/paseo.sock",
|
||||
type: "directSocket",
|
||||
path: "/tmp/paseo.sock",
|
||||
});
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
{
|
||||
id: "socket:/tmp/paseo.sock",
|
||||
type: "directSocket",
|
||||
path: "/tmp/paseo.sock",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await result.client.close();
|
||||
|
||||
expect(daemonClientMock.createdConfigs[0]?.url).toBe(
|
||||
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
|
||||
);
|
||||
expect(probe.createdConfigs()[0]?.url).toBe("paseo+local://socket?path=%2Ftmp%2Fpaseo.sock");
|
||||
});
|
||||
|
||||
it("passes direct TCP connection passwords into the client config", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const result = await mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
});
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await result.client.close();
|
||||
|
||||
expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret");
|
||||
expect(probe.createdConfigs()[0]?.password).toBe("shared-secret");
|
||||
});
|
||||
|
||||
it("uses relay TLS from the stored connection", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const tlsResult = await mod.connectToDaemon(
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const tlsResult = await connectToDaemon(
|
||||
{
|
||||
id: "relay:wss:[::1]:443",
|
||||
type: "relay",
|
||||
@@ -157,10 +145,11 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
},
|
||||
{ serverId: "srv_probe_test" },
|
||||
probe.deps,
|
||||
);
|
||||
await tlsResult.client.close();
|
||||
|
||||
const plainResult = await mod.connectToDaemon(
|
||||
const plainResult = await connectToDaemon(
|
||||
{
|
||||
id: "relay:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
@@ -169,43 +158,52 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
},
|
||||
{ serverId: "srv_probe_test" },
|
||||
probe.deps,
|
||||
);
|
||||
await plainResult.client.close();
|
||||
|
||||
expect(daemonClientMock.createdConfigs[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
|
||||
expect(daemonClientMock.createdConfigs[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
|
||||
expect(probe.createdConfigs()[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
|
||||
expect(probe.createdConfigs()[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
|
||||
});
|
||||
|
||||
it("surfaces auth rejection as an incorrect password", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
daemonClientMock.setNextConnectFailure(
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
probe.failNextConnection(
|
||||
new Error("Transport closed (code 4001)"),
|
||||
"Transport closed (code 4001)",
|
||||
);
|
||||
|
||||
await expect(
|
||||
mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "wrong-secret",
|
||||
}),
|
||||
connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "wrong-secret",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: "Incorrect password",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps generic transport failures generic when a password was supplied", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
daemonClientMock.setNextConnectFailure(new Error("Transport error"), "Transport error");
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
probe.failNextConnection(new Error("Transport error"), "Transport error");
|
||||
|
||||
await expect(
|
||||
mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
}),
|
||||
connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: "Transport error",
|
||||
});
|
||||
|
||||
@@ -13,6 +13,34 @@ import {
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
|
||||
export interface DaemonProbeClient {
|
||||
readonly lastError: string | null;
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null;
|
||||
}
|
||||
|
||||
interface LocalTransportUrlInput {
|
||||
transportType: "socket" | "pipe";
|
||||
transportPath: string;
|
||||
}
|
||||
|
||||
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
|
||||
getClientId(): Promise<string>;
|
||||
resolveAppVersion(): string | null;
|
||||
createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null;
|
||||
buildLocalTransportUrl(input: LocalTransportUrlInput): string;
|
||||
createClient(config: DaemonClientConfig): TClient;
|
||||
}
|
||||
|
||||
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
|
||||
getClientId: getOrCreateClientId,
|
||||
resolveAppVersion,
|
||||
createLocalTransportFactory: createDesktopLocalDaemonTransportFactory,
|
||||
buildLocalTransportUrl: buildLocalDaemonTransportUrl,
|
||||
createClient: (config) => new DaemonClient(config),
|
||||
};
|
||||
|
||||
function normalizeNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -69,13 +97,17 @@ export class DaemonConnectionTestError extends Error {
|
||||
export async function buildClientConfig(
|
||||
connection: HostConnection,
|
||||
serverId?: string,
|
||||
deps: Pick<
|
||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
|
||||
> = defaultDaemonConnectionDependencies,
|
||||
): Promise<DaemonClientConfig> {
|
||||
const clientId = await getOrCreateClientId();
|
||||
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||
const clientId = await deps.getClientId();
|
||||
const localTransportFactory = deps.createLocalTransportFactory();
|
||||
const base = {
|
||||
clientId,
|
||||
clientType: "mobile" as const,
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
appVersion: deps.resolveAppVersion() ?? undefined,
|
||||
suppressSendErrors: true,
|
||||
reconnect: { enabled: false },
|
||||
...((connection.type === "directSocket" || connection.type === "directPipe") &&
|
||||
@@ -87,7 +119,7 @@ export async function buildClientConfig(
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return {
|
||||
...base,
|
||||
url: buildLocalDaemonTransportUrl({
|
||||
url: deps.buildLocalTransportUrl({
|
||||
transportType: connection.type === "directSocket" ? "socket" : "pipe",
|
||||
transportPath: connection.path,
|
||||
}),
|
||||
@@ -120,10 +152,23 @@ export async function buildClientConfig(
|
||||
export function connectAndProbe(
|
||||
config: DaemonClientConfig,
|
||||
timeoutMs: number,
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
|
||||
const client = new DaemonClient(config);
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
|
||||
export function connectAndProbe<TClient extends DaemonProbeClient>(
|
||||
config: DaemonClientConfig,
|
||||
timeoutMs: number,
|
||||
deps: Pick<DaemonConnectionDependencies<TClient>, "createClient">,
|
||||
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
|
||||
export function connectAndProbe(
|
||||
config: DaemonClientConfig,
|
||||
timeoutMs: number,
|
||||
deps: Pick<
|
||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||
"createClient"
|
||||
> = defaultDaemonConnectionDependencies,
|
||||
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
|
||||
const client = deps.createClient(config);
|
||||
|
||||
return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>(
|
||||
return new Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }>(
|
||||
(resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
void client.close().catch(() => undefined);
|
||||
@@ -183,10 +228,20 @@ function resolveTimeout(connection: HostConnection, options?: ProbeOptions): num
|
||||
return connection.type === "relay" ? 10_000 : 6_000;
|
||||
}
|
||||
|
||||
export function connectToDaemon(
|
||||
connection: HostConnection,
|
||||
options?: ProbeOptions,
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
|
||||
export function connectToDaemon<TClient extends DaemonProbeClient>(
|
||||
connection: HostConnection,
|
||||
options: ProbeOptions | undefined,
|
||||
deps: DaemonConnectionDependencies<TClient>,
|
||||
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
|
||||
export async function connectToDaemon(
|
||||
connection: HostConnection,
|
||||
options?: ProbeOptions,
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
|
||||
const config = await buildClientConfig(connection, options?.serverId);
|
||||
return connectAndProbe(config, resolveTimeout(connection, options));
|
||||
deps: DaemonConnectionDependencies<DaemonProbeClient> = defaultDaemonConnectionDependencies,
|
||||
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
|
||||
const config = await buildClientConfig(connection, options?.serverId, deps);
|
||||
return connectAndProbe(config, resolveTimeout(connection, options), deps);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.71",
|
||||
"@getpaseo/server": "0.1.73",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,32 +1,67 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
spawnSync: vi.fn(),
|
||||
spawnProcess: vi.fn(),
|
||||
}));
|
||||
import {
|
||||
type DaemonLaunchRuntime,
|
||||
type DetachedDaemonProcess,
|
||||
startLocalDaemonDetached,
|
||||
startLocalDaemonForeground,
|
||||
} from "./local-daemon.js";
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
...actual,
|
||||
spawnSync: mocks.spawnSync,
|
||||
};
|
||||
});
|
||||
type RecordedDaemonLaunch =
|
||||
| {
|
||||
mode: "detached";
|
||||
command: string;
|
||||
args: string[];
|
||||
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2];
|
||||
}
|
||||
| {
|
||||
mode: "foreground";
|
||||
command: string;
|
||||
args: string[];
|
||||
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2];
|
||||
};
|
||||
|
||||
vi.mock("@getpaseo/server", async () => {
|
||||
const actual = await vi.importActual<typeof import("@getpaseo/server")>("@getpaseo/server");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({ listen: "127.0.0.1:6767" }),
|
||||
resolvePaseoHome: (env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo",
|
||||
spawnProcess: mocks.spawnProcess,
|
||||
};
|
||||
});
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
class FakeDaemonProcess extends EventEmitter implements DetachedDaemonProcess {
|
||||
pid = 4242;
|
||||
unref = vi.fn();
|
||||
wasUnreferenced = false;
|
||||
|
||||
unref(): void {
|
||||
this.wasUnreferenced = true;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDaemonRuntime implements DaemonLaunchRuntime {
|
||||
readonly recordedLaunches: RecordedDaemonLaunch[] = [];
|
||||
readonly daemonProcess = new FakeDaemonProcess();
|
||||
foregroundStatus = 0;
|
||||
runnerEntry = "/repo/packages/server/scripts/supervisor-entrypoint.ts";
|
||||
|
||||
resolveRunnerEntry(): string {
|
||||
return this.runnerEntry;
|
||||
}
|
||||
|
||||
resolveHome(env: NodeJS.ProcessEnv): string {
|
||||
return env.PASEO_HOME ?? "/tmp/paseo";
|
||||
}
|
||||
|
||||
spawnDetached(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2],
|
||||
): DetachedDaemonProcess {
|
||||
this.recordedLaunches.push({ mode: "detached", command, args, options });
|
||||
return this.daemonProcess;
|
||||
}
|
||||
|
||||
spawnForeground(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2],
|
||||
) {
|
||||
this.recordedLaunches.push({ mode: "foreground", command, args, options });
|
||||
return { status: this.foregroundStatus, error: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
function expectSupervisorLaunch(argv: string[]): void {
|
||||
@@ -41,59 +76,59 @@ function expectSupervisorLaunch(argv: string[]): void {
|
||||
describe("local daemon launch supervision", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mocks.spawnSync.mockReset();
|
||||
mocks.spawnProcess.mockReset();
|
||||
});
|
||||
|
||||
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
|
||||
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
|
||||
const runtime = new FakeDaemonRuntime();
|
||||
|
||||
const { startLocalDaemonForeground } = await import("./local-daemon.js");
|
||||
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false });
|
||||
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }, runtime);
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect(mocks.spawnSync).toHaveBeenCalledOnce();
|
||||
const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
|
||||
expect(command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(argv);
|
||||
expect(argv).toContain("--no-relay");
|
||||
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
|
||||
const launch = runtime.recordedLaunches[0];
|
||||
expect(launch?.mode).toBe("foreground");
|
||||
expect(launch?.command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(launch?.args ?? []);
|
||||
expect(launch?.args).toContain("--no-relay");
|
||||
});
|
||||
|
||||
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = new FakeChildProcess();
|
||||
mocks.spawnProcess.mockReturnValue(child);
|
||||
const runtime = new FakeDaemonRuntime();
|
||||
|
||||
const { startLocalDaemonDetached } = await import("./local-daemon.js");
|
||||
const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
|
||||
const resultPromise = startLocalDaemonDetached(
|
||||
{ home: "/tmp/paseo-test", mcp: false },
|
||||
runtime,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1200);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
|
||||
expect(child.unref).toHaveBeenCalledOnce();
|
||||
expect(mocks.spawnProcess).toHaveBeenCalledOnce();
|
||||
const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
|
||||
expect(command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(argv);
|
||||
expect(argv).toContain("--no-mcp");
|
||||
expect(runtime.daemonProcess.wasUnreferenced).toBe(true);
|
||||
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]);
|
||||
const launch = runtime.recordedLaunches[0];
|
||||
expect(launch?.mode).toBe("detached");
|
||||
expect(launch?.command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(launch?.args ?? []);
|
||||
expect(launch?.args).toContain("--no-mcp");
|
||||
});
|
||||
|
||||
test("relay TLS flag is passed to the supervised daemon", async () => {
|
||||
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
|
||||
const runtime = new FakeDaemonRuntime();
|
||||
|
||||
const { startLocalDaemonForeground } = await import("./local-daemon.js");
|
||||
const status = startLocalDaemonForeground({
|
||||
home: "/tmp/paseo-test",
|
||||
relayUseTls: true,
|
||||
});
|
||||
const status = startLocalDaemonForeground(
|
||||
{
|
||||
home: "/tmp/paseo-test",
|
||||
relayUseTls: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(status).toBe(0);
|
||||
const [, argv, options] = mocks.spawnSync.mock.calls[0] as [
|
||||
string,
|
||||
string[],
|
||||
{ env?: NodeJS.ProcessEnv },
|
||||
];
|
||||
expect(argv).toContain("--relay-use-tls");
|
||||
expect(options.env?.PASEO_RELAY_USE_TLS).toBe("true");
|
||||
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
|
||||
const launch = runtime.recordedLaunches[0];
|
||||
expect(launch?.mode).toBe("foreground");
|
||||
expect(launch?.args).toContain("--relay-use-tls");
|
||||
expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
@@ -68,6 +68,28 @@ interface ProcessExitDetails {
|
||||
|
||||
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
|
||||
|
||||
export interface DetachedDaemonProcess extends Pick<ChildProcess, "once" | "pid" | "unref"> {}
|
||||
|
||||
export interface ForegroundDaemonProcessResult {
|
||||
status: number | null;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface DaemonLaunchRuntime {
|
||||
resolveRunnerEntry(): string;
|
||||
resolveHome(env: NodeJS.ProcessEnv): string;
|
||||
spawnDetached(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: Parameters<typeof spawnProcess>[2],
|
||||
): DetachedDaemonProcess;
|
||||
spawnForeground(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: Parameters<typeof spawnSync>[2],
|
||||
): ForegroundDaemonProcessResult;
|
||||
}
|
||||
|
||||
const DETACHED_STARTUP_GRACE_MS = 1200;
|
||||
const PID_POLL_INTERVAL_MS = 100;
|
||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
@@ -78,6 +100,13 @@ export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = {
|
||||
resolveRunnerEntry: resolveDaemonRunnerEntry,
|
||||
resolveHome: resolvePaseoHome,
|
||||
spawnDetached: spawnProcess,
|
||||
spawnForeground: spawnSync,
|
||||
};
|
||||
|
||||
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
|
||||
|
||||
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
|
||||
@@ -395,17 +424,18 @@ export function tailDaemonLog(home?: string, lines = 30): string | null {
|
||||
|
||||
export async function startLocalDaemonDetached(
|
||||
options: DaemonStartOptions,
|
||||
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
|
||||
): Promise<DetachedStartResult> {
|
||||
if (options.listen && options.port) {
|
||||
throw new Error("Cannot use --listen and --port together");
|
||||
}
|
||||
|
||||
const daemonRunnerEntry = resolveDaemonRunnerEntry();
|
||||
const daemonRunnerEntry = runtime.resolveRunnerEntry();
|
||||
const childEnv = buildChildEnv(options);
|
||||
|
||||
const paseoHome = resolvePaseoHome(childEnv);
|
||||
const paseoHome = runtime.resolveHome(childEnv);
|
||||
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
|
||||
const child = spawnProcess(
|
||||
const child = runtime.spawnDetached(
|
||||
process.execPath,
|
||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||
{
|
||||
@@ -461,14 +491,17 @@ export async function startLocalDaemonDetached(
|
||||
};
|
||||
}
|
||||
|
||||
export function startLocalDaemonForeground(options: DaemonStartOptions): number {
|
||||
export function startLocalDaemonForeground(
|
||||
options: DaemonStartOptions,
|
||||
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
|
||||
): number {
|
||||
if (options.listen && options.port) {
|
||||
throw new Error("Cannot use --listen and --port together");
|
||||
}
|
||||
|
||||
const daemonRunnerEntry = resolveDaemonRunnerEntry();
|
||||
const daemonRunnerEntry = runtime.resolveRunnerEntry();
|
||||
const childEnv = buildChildEnv(options);
|
||||
const result = spawnSync(
|
||||
const result = runtime.spawnForeground(
|
||||
process.execPath,
|
||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||
{
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCreateWorktreeInput,
|
||||
toDaemonCreateInput,
|
||||
type WorktreeCreateOptions,
|
||||
} from "./create.js";
|
||||
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js";
|
||||
|
||||
const REPO = "/tmp/repo";
|
||||
|
||||
function build(options: WorktreeCreateOptions): unknown {
|
||||
try {
|
||||
return buildCreateWorktreeInput(options, REPO);
|
||||
return buildCreateWorktreeRequest(options, REPO);
|
||||
} catch (err) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
describe("buildCreateWorktreeInput", () => {
|
||||
describe("buildCreateWorktreeRequest", () => {
|
||||
it("requires --mode", () => {
|
||||
expect(build({})).toMatchObject({ code: "MISSING_MODE" });
|
||||
});
|
||||
@@ -28,17 +24,20 @@ describe("buildCreateWorktreeInput", () => {
|
||||
expect(build({ mode: "branch-off" })).toMatchObject({ code: "MISSING_NEW_BRANCH" });
|
||||
});
|
||||
|
||||
it("branch-off parses with new branch only", () => {
|
||||
it("branch-off builds a daemon request with a new branch", () => {
|
||||
expect(build({ mode: "branch-off", newBranch: "feature-x" })).toEqual({
|
||||
cwd: REPO,
|
||||
target: { mode: "branch-off", newBranch: "feature-x" },
|
||||
worktreeSlug: "feature-x",
|
||||
action: "branch-off",
|
||||
});
|
||||
});
|
||||
|
||||
it("branch-off parses with base ref", () => {
|
||||
it("branch-off includes the base ref when provided", () => {
|
||||
expect(build({ mode: "branch-off", newBranch: "feature-x", base: "main" })).toEqual({
|
||||
cwd: REPO,
|
||||
target: { mode: "branch-off", newBranch: "feature-x", base: "main" },
|
||||
worktreeSlug: "feature-x",
|
||||
action: "branch-off",
|
||||
refName: "main",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,10 +45,11 @@ describe("buildCreateWorktreeInput", () => {
|
||||
expect(build({ mode: "checkout-branch" })).toMatchObject({ code: "MISSING_BRANCH" });
|
||||
});
|
||||
|
||||
it("checkout-branch parses with branch", () => {
|
||||
it("checkout-branch builds a checkout request for the branch", () => {
|
||||
expect(build({ mode: "checkout-branch", branch: "feat/x" })).toEqual({
|
||||
cwd: REPO,
|
||||
target: { mode: "checkout-branch", branch: "feat/x" },
|
||||
action: "checkout",
|
||||
refName: "feat/x",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,62 +69,8 @@ describe("buildCreateWorktreeInput", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("checkout-pr parses positive integers", () => {
|
||||
it("checkout-pr builds a checkout request for the pull request", () => {
|
||||
expect(build({ mode: "checkout-pr", prNumber: "42" })).toEqual({
|
||||
cwd: REPO,
|
||||
target: { mode: "checkout-pr", prNumber: 42 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toDaemonCreateInput", () => {
|
||||
it("maps branch-off without base", () => {
|
||||
expect(
|
||||
toDaemonCreateInput({
|
||||
cwd: REPO,
|
||||
target: { mode: "branch-off", newBranch: "feature-x" },
|
||||
}),
|
||||
).toEqual({
|
||||
cwd: REPO,
|
||||
worktreeSlug: "feature-x",
|
||||
action: "branch-off",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps branch-off with base ref", () => {
|
||||
expect(
|
||||
toDaemonCreateInput({
|
||||
cwd: REPO,
|
||||
target: { mode: "branch-off", newBranch: "feature-x", base: "main" },
|
||||
}),
|
||||
).toEqual({
|
||||
cwd: REPO,
|
||||
worktreeSlug: "feature-x",
|
||||
action: "branch-off",
|
||||
refName: "main",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps checkout-branch to action=checkout + refName", () => {
|
||||
expect(
|
||||
toDaemonCreateInput({
|
||||
cwd: REPO,
|
||||
target: { mode: "checkout-branch", branch: "feat/x" },
|
||||
}),
|
||||
).toEqual({
|
||||
cwd: REPO,
|
||||
action: "checkout",
|
||||
refName: "feat/x",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps checkout-pr to action=checkout + githubPrNumber", () => {
|
||||
expect(
|
||||
toDaemonCreateInput({
|
||||
cwd: REPO,
|
||||
target: { mode: "checkout-pr", prNumber: 42 },
|
||||
}),
|
||||
).toEqual({
|
||||
cwd: REPO,
|
||||
action: "checkout",
|
||||
githubPrNumber: 42,
|
||||
104
packages/cli/src/commands/worktree/create-input.ts
Normal file
104
packages/cli/src/commands/worktree/create-input.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { DaemonClient } from "@getpaseo/server";
|
||||
import type { CommandError, CommandOptions } from "../../output/index.js";
|
||||
|
||||
export interface WorktreeCreateOptions extends CommandOptions {
|
||||
host?: string;
|
||||
cwd?: string;
|
||||
mode?: string;
|
||||
newBranch?: string;
|
||||
base?: string;
|
||||
branch?: string;
|
||||
prNumber?: string;
|
||||
}
|
||||
|
||||
const VALID_MODES = ["branch-off", "checkout-branch", "checkout-pr"] as const;
|
||||
|
||||
type CreatePaseoWorktreeRequest = Parameters<DaemonClient["createPaseoWorktree"]>[0];
|
||||
|
||||
export function buildCreateWorktreeRequest(
|
||||
options: WorktreeCreateOptions,
|
||||
cwd: string,
|
||||
): CreatePaseoWorktreeRequest {
|
||||
const mode = options.mode;
|
||||
if (!mode) {
|
||||
throw cmdError(
|
||||
"MISSING_MODE",
|
||||
"--mode is required",
|
||||
`Expected one of: ${VALID_MODES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case "branch-off":
|
||||
return buildBranchOffRequest(options, cwd);
|
||||
case "checkout-branch":
|
||||
return buildCheckoutBranchRequest(options, cwd);
|
||||
case "checkout-pr":
|
||||
return buildCheckoutPrRequest(options, cwd);
|
||||
default:
|
||||
throw cmdError(
|
||||
"INVALID_MODE",
|
||||
`Invalid --mode: ${mode}`,
|
||||
`Expected one of: ${VALID_MODES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildBranchOffRequest(
|
||||
options: WorktreeCreateOptions,
|
||||
cwd: string,
|
||||
): CreatePaseoWorktreeRequest {
|
||||
if (!options.newBranch) {
|
||||
throw cmdError("MISSING_NEW_BRANCH", "--new-branch is required for --mode branch-off");
|
||||
}
|
||||
|
||||
return {
|
||||
cwd,
|
||||
worktreeSlug: options.newBranch,
|
||||
action: "branch-off",
|
||||
...(options.base ? { refName: options.base } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCheckoutBranchRequest(
|
||||
options: WorktreeCreateOptions,
|
||||
cwd: string,
|
||||
): CreatePaseoWorktreeRequest {
|
||||
if (!options.branch) {
|
||||
throw cmdError("MISSING_BRANCH", "--branch is required for --mode checkout-branch");
|
||||
}
|
||||
|
||||
return {
|
||||
cwd,
|
||||
action: "checkout",
|
||||
refName: options.branch,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCheckoutPrRequest(
|
||||
options: WorktreeCreateOptions,
|
||||
cwd: string,
|
||||
): CreatePaseoWorktreeRequest {
|
||||
if (options.prNumber === undefined || options.prNumber === "") {
|
||||
throw cmdError("MISSING_PR_NUMBER", "--pr-number is required for --mode checkout-pr");
|
||||
}
|
||||
|
||||
const prNumber = Number(options.prNumber);
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
throw cmdError(
|
||||
"INVALID_PR_NUMBER",
|
||||
`Invalid --pr-number: ${options.prNumber}`,
|
||||
"Expected a positive integer",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
cwd,
|
||||
action: "checkout",
|
||||
githubPrNumber: prNumber,
|
||||
};
|
||||
}
|
||||
|
||||
function cmdError(code: string, message: string, details?: string): CommandError {
|
||||
return details ? { code, message, details } : { code, message };
|
||||
}
|
||||
@@ -2,12 +2,8 @@ import path from "node:path";
|
||||
import type { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/server";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandError,
|
||||
CommandOptions,
|
||||
OutputSchema,
|
||||
SingleResult,
|
||||
} from "../../output/index.js";
|
||||
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js";
|
||||
|
||||
export interface WorktreeCreateResult {
|
||||
name: string;
|
||||
@@ -24,110 +20,6 @@ export const createSchema: OutputSchema<WorktreeCreateResult> = {
|
||||
],
|
||||
};
|
||||
|
||||
export interface WorktreeCreateOptions extends CommandOptions {
|
||||
host?: string;
|
||||
cwd?: string;
|
||||
mode?: string;
|
||||
newBranch?: string;
|
||||
base?: string;
|
||||
branch?: string;
|
||||
prNumber?: string;
|
||||
}
|
||||
|
||||
export type WorktreeCreateTarget =
|
||||
| { mode: "branch-off"; newBranch: string; base?: string }
|
||||
| { mode: "checkout-branch"; branch: string }
|
||||
| { mode: "checkout-pr"; prNumber: number };
|
||||
|
||||
export interface ParsedWorktreeCreateInput {
|
||||
cwd: string;
|
||||
target: WorktreeCreateTarget;
|
||||
}
|
||||
|
||||
const VALID_MODES = ["branch-off", "checkout-branch", "checkout-pr"] as const;
|
||||
|
||||
export function buildCreateWorktreeInput(
|
||||
options: WorktreeCreateOptions,
|
||||
cwd: string,
|
||||
): ParsedWorktreeCreateInput {
|
||||
const mode = options.mode;
|
||||
if (!mode) {
|
||||
throw cmdError(
|
||||
"MISSING_MODE",
|
||||
"--mode is required",
|
||||
`Expected one of: ${VALID_MODES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case "branch-off": {
|
||||
if (!options.newBranch) {
|
||||
throw cmdError("MISSING_NEW_BRANCH", "--new-branch is required for --mode branch-off");
|
||||
}
|
||||
return {
|
||||
cwd,
|
||||
target: {
|
||||
mode: "branch-off",
|
||||
newBranch: options.newBranch,
|
||||
...(options.base ? { base: options.base } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
case "checkout-branch": {
|
||||
if (!options.branch) {
|
||||
throw cmdError("MISSING_BRANCH", "--branch is required for --mode checkout-branch");
|
||||
}
|
||||
return { cwd, target: { mode: "checkout-branch", branch: options.branch } };
|
||||
}
|
||||
case "checkout-pr": {
|
||||
if (options.prNumber === undefined || options.prNumber === "") {
|
||||
throw cmdError("MISSING_PR_NUMBER", "--pr-number is required for --mode checkout-pr");
|
||||
}
|
||||
const parsed = Number(options.prNumber);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw cmdError(
|
||||
"INVALID_PR_NUMBER",
|
||||
`Invalid --pr-number: ${options.prNumber}`,
|
||||
"Expected a positive integer",
|
||||
);
|
||||
}
|
||||
return { cwd, target: { mode: "checkout-pr", prNumber: parsed } };
|
||||
}
|
||||
default:
|
||||
throw cmdError(
|
||||
"INVALID_MODE",
|
||||
`Invalid --mode: ${mode}`,
|
||||
`Expected one of: ${VALID_MODES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function toDaemonCreateInput(parsed: ParsedWorktreeCreateInput) {
|
||||
switch (parsed.target.mode) {
|
||||
case "branch-off":
|
||||
return {
|
||||
cwd: parsed.cwd,
|
||||
worktreeSlug: parsed.target.newBranch,
|
||||
action: "branch-off" as const,
|
||||
...(parsed.target.base ? { refName: parsed.target.base } : {}),
|
||||
};
|
||||
case "checkout-branch":
|
||||
return {
|
||||
cwd: parsed.cwd,
|
||||
action: "checkout" as const,
|
||||
refName: parsed.target.branch,
|
||||
};
|
||||
case "checkout-pr":
|
||||
return {
|
||||
cwd: parsed.cwd,
|
||||
action: "checkout" as const,
|
||||
githubPrNumber: parsed.target.prNumber,
|
||||
};
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
function cmdError(code: string, message: string, details?: string): CommandError {
|
||||
return details ? { code, message, details } : { code, message };
|
||||
}
|
||||
@@ -137,7 +29,7 @@ export async function runCreateCommand(
|
||||
_command: Command,
|
||||
): Promise<SingleResult<WorktreeCreateResult>> {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const parsed = buildCreateWorktreeInput(options, cwd);
|
||||
const request = buildCreateWorktreeRequest(options, cwd);
|
||||
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
let client: DaemonClient;
|
||||
@@ -153,7 +45,7 @@ export async function runCreateCommand(
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.createPaseoWorktree(toDaemonCreateInput(parsed));
|
||||
const response = await client.createPaseoWorktree(request);
|
||||
|
||||
const workspace = response.workspace;
|
||||
if (!workspace || response.error) {
|
||||
|
||||
@@ -8,6 +8,26 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForLoopInList(
|
||||
ctx: Awaited<ReturnType<typeof createE2ETestContext>>,
|
||||
id: string,
|
||||
) {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const listed = await ctx.paseo(["loop", "ls", "--json"]);
|
||||
assert.strictEqual(listed.exitCode, 0, listed.stderr);
|
||||
const listedJson = JSON.parse(listed.stdout);
|
||||
assert(Array.isArray(listedJson), listed.stdout);
|
||||
if (listedJson.some((item: { id: string }) => item.id === id)) {
|
||||
return listedJson;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
const listed = await ctx.paseo(["loop", "ls", "--json"]);
|
||||
assert.strictEqual(listed.exitCode, 0, listed.stderr);
|
||||
return JSON.parse(listed.stdout);
|
||||
}
|
||||
|
||||
console.log("=== Loop And Schedule Command Tests ===\n");
|
||||
|
||||
const ctx = await createE2ETestContext({ timeout: 30000 });
|
||||
@@ -143,13 +163,10 @@ try {
|
||||
const runJson = JSON.parse(run.stdout);
|
||||
assert.strictEqual(runJson.name, "smoke-loop");
|
||||
|
||||
const listed = await ctx.paseo(["loop", "ls", "--json"]);
|
||||
assert.strictEqual(listed.exitCode, 0, listed.stderr);
|
||||
const listedJson = JSON.parse(listed.stdout);
|
||||
assert(Array.isArray(listedJson), listed.stdout);
|
||||
const listedJson = await waitForLoopInList(ctx, runJson.id);
|
||||
assert(
|
||||
listedJson.some((item: { id: string }) => item.id === runJson.id),
|
||||
listed.stdout,
|
||||
JSON.stringify(listedJson),
|
||||
);
|
||||
|
||||
async function pollStatus(attempt: number): Promise<string> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
53
packages/desktop/src/features/opener.test.ts
Normal file
53
packages/desktop/src/features/opener.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ipcMain, shell } from "electron";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { isAllowedExternalUrl, registerOpenerHandlers } from "./opener";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
ipcMain: { handle: vi.fn() },
|
||||
shell: { openExternal: vi.fn() },
|
||||
}));
|
||||
|
||||
function getRegisteredOpenUrlHandler(): (_event: unknown, url: unknown) => Promise<void> {
|
||||
registerOpenerHandlers();
|
||||
const handler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => {
|
||||
return channel === "paseo:opener:openUrl";
|
||||
})?.[1];
|
||||
if (typeof handler !== "function") {
|
||||
throw new Error("open URL handler was not registered");
|
||||
}
|
||||
return handler as (_event: unknown, url: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
describe("desktop opener", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(ipcMain.handle).mockReset();
|
||||
vi.mocked(shell.openExternal).mockReset();
|
||||
});
|
||||
|
||||
it("allows only http and https external URLs", () => {
|
||||
expect(isAllowedExternalUrl("https://example.com/path")).toBe(true);
|
||||
expect(isAllowedExternalUrl("http://localhost:8081")).toBe(true);
|
||||
expect(isAllowedExternalUrl("file:///etc/passwd")).toBe(false);
|
||||
expect(isAllowedExternalUrl("javascript:alert(1)")).toBe(false);
|
||||
expect(isAllowedExternalUrl("paseo://settings")).toBe(false);
|
||||
expect(isAllowedExternalUrl("/relative/path")).toBe(false);
|
||||
expect(isAllowedExternalUrl(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("opens allowed URLs through Electron shell", async () => {
|
||||
const handler = getRegisteredOpenUrlHandler();
|
||||
|
||||
await handler({}, "https://example.com");
|
||||
|
||||
expect(shell.openExternal).toHaveBeenCalledWith("https://example.com");
|
||||
});
|
||||
|
||||
it("rejects blocked URLs before invoking Electron shell", async () => {
|
||||
const handler = getRegisteredOpenUrlHandler();
|
||||
|
||||
await expect(handler({}, "file:///etc/passwd")).rejects.toThrow("Unsupported external URL");
|
||||
|
||||
expect(shell.openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,25 @@
|
||||
import { shell, ipcMain } from "electron";
|
||||
|
||||
const ALLOWED_EXTERNAL_URL_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
|
||||
export function isAllowedExternalUrl(value: unknown): value is string {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ALLOWED_EXTERNAL_URL_PROTOCOLS.has(url.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerOpenerHandlers(): void {
|
||||
ipcMain.handle("paseo:opener:openUrl", async (_event, url: string) => {
|
||||
ipcMain.handle("paseo:opener:openUrl", async (_event, url: unknown) => {
|
||||
if (!isAllowedExternalUrl(url)) {
|
||||
throw new Error("Unsupported external URL");
|
||||
}
|
||||
await shell.openExternal(url);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ function resolveShellEnv(): Record<string, string> | undefined {
|
||||
const result = spawnSync(shell, [...shellArgs, command], {
|
||||
encoding: "utf8",
|
||||
timeout: RESOLVE_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...shellEnv,
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
|
||||
@@ -191,6 +191,7 @@ if (forcedUserDataDir) {
|
||||
const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
}).trim();
|
||||
devWorktreeName = path.basename(topLevel);
|
||||
// Main checkout (e.g. "paseo") gets default userData — only worktrees diverge.
|
||||
@@ -200,6 +201,7 @@ if (forcedUserDataDir) {
|
||||
cwd: topLevel,
|
||||
encoding: "utf-8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
}).trim(),
|
||||
);
|
||||
const isWorktree = path.resolve(topLevel, ".git") !== commonDir;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -58,14 +58,14 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/highlight": "0.1.71",
|
||||
"@getpaseo/relay": "0.1.71",
|
||||
"@getpaseo/highlight": "0.1.73",
|
||||
"@getpaseo/relay": "0.1.73",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
"@mariozechner/pi-coding-agent": "^0.70.2",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
|
||||
70
packages/server/src/server/agent/agent-archive.test.ts
Normal file
70
packages/server/src/server/agent/agent-archive.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { buildArchivedAgentRecord } from "./agent-archive.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
|
||||
const BASE_RECORD: StoredAgentRecord = {
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "/workspace/project",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
config: null,
|
||||
};
|
||||
|
||||
test("archives a stored agent without changing terminal statuses", () => {
|
||||
const statuses: Array<StoredAgentRecord["lastStatus"]> = ["idle", "error", "closed"];
|
||||
|
||||
for (const status of statuses) {
|
||||
const archived = buildArchivedAgentRecord(
|
||||
{ ...BASE_RECORD, lastStatus: status },
|
||||
{ archivedAt: "2025-01-03T00:00:00.000Z" },
|
||||
);
|
||||
|
||||
expect(archived.lastStatus).toBe(status);
|
||||
expect(archived.archivedAt).toBe("2025-01-03T00:00:00.000Z");
|
||||
expect(archived.updatedAt).toBe(BASE_RECORD.updatedAt);
|
||||
}
|
||||
});
|
||||
|
||||
test("archives busy stored agents as idle", () => {
|
||||
const statuses: Array<StoredAgentRecord["lastStatus"]> = ["initializing", "running"];
|
||||
|
||||
for (const status of statuses) {
|
||||
const archived = buildArchivedAgentRecord(
|
||||
{ ...BASE_RECORD, lastStatus: status },
|
||||
{ archivedAt: "2025-01-03T00:00:00.000Z" },
|
||||
);
|
||||
|
||||
expect(archived.lastStatus).toBe("idle");
|
||||
}
|
||||
});
|
||||
|
||||
test("clears persisted attention when archiving", () => {
|
||||
const archived = buildArchivedAgentRecord(
|
||||
{
|
||||
...BASE_RECORD,
|
||||
requiresAttention: true,
|
||||
attentionReason: "finished",
|
||||
attentionTimestamp: "2025-01-02T12:00:00.000Z",
|
||||
},
|
||||
{ archivedAt: "2025-01-03T00:00:00.000Z" },
|
||||
);
|
||||
|
||||
expect(archived).toMatchObject({
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("can stamp updatedAt to the archive timestamp", () => {
|
||||
const archived = buildArchivedAgentRecord(BASE_RECORD, {
|
||||
archivedAt: "2025-01-03T00:00:00.000Z",
|
||||
updatedAt: "2025-01-03T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(archived.updatedAt).toBe("2025-01-03T00:00:00.000Z");
|
||||
});
|
||||
30
packages/server/src/server/agent/agent-archive.ts
Normal file
30
packages/server/src/server/agent/agent-archive.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
|
||||
export type ArchivedStoredAgentRecord = StoredAgentRecord & { archivedAt: string };
|
||||
|
||||
interface BuildArchivedAgentRecordOptions {
|
||||
archivedAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export function buildArchivedAgentRecord(
|
||||
record: StoredAgentRecord,
|
||||
options?: BuildArchivedAgentRecordOptions,
|
||||
): ArchivedStoredAgentRecord {
|
||||
const archivedAt = options?.archivedAt ?? new Date().toISOString();
|
||||
return {
|
||||
...record,
|
||||
archivedAt,
|
||||
updatedAt: options?.updatedAt ?? record.updatedAt,
|
||||
lastStatus: normalizeArchivedStatus(record.lastStatus),
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeArchivedStatus(
|
||||
status: StoredAgentRecord["lastStatus"],
|
||||
): StoredAgentRecord["lastStatus"] {
|
||||
return status === "running" || status === "initializing" ? "idle" : status;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
||||
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
|
||||
import {
|
||||
InMemoryAgentTimelineStore,
|
||||
@@ -66,7 +67,6 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
};
|
||||
|
||||
type TimeoutResult = "completed" | "timed_out";
|
||||
type ArchivedStoredAgentRecord = StoredAgentRecord & { archivedAt: string };
|
||||
|
||||
interface TimeoutOptions {
|
||||
operation: Promise<void>;
|
||||
@@ -1071,19 +1071,7 @@ export class AgentManager {
|
||||
private async markRecordArchived(record: StoredAgentRecord): Promise<ArchivedStoredAgentRecord> {
|
||||
const registry = this.requireRegistry();
|
||||
const archivedAt = new Date().toISOString();
|
||||
const normalizedStatus =
|
||||
record.lastStatus === "running" || record.lastStatus === "initializing"
|
||||
? "idle"
|
||||
: record.lastStatus;
|
||||
const archivedRecord: ArchivedStoredAgentRecord = {
|
||||
...record,
|
||||
archivedAt,
|
||||
updatedAt: archivedAt,
|
||||
lastStatus: normalizedStatus,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
};
|
||||
const archivedRecord = buildArchivedAgentRecord(record, { archivedAt, updatedAt: archivedAt });
|
||||
|
||||
await registry.upsert(archivedRecord);
|
||||
|
||||
@@ -1259,19 +1247,7 @@ export class AgentManager {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
|
||||
const normalizedStatus =
|
||||
record.lastStatus === "running" || record.lastStatus === "initializing"
|
||||
? "idle"
|
||||
: record.lastStatus;
|
||||
|
||||
const nextRecord: StoredAgentRecord = {
|
||||
...record,
|
||||
archivedAt,
|
||||
lastStatus: normalizedStatus,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
};
|
||||
const nextRecord = buildArchivedAgentRecord(record, { archivedAt });
|
||||
await registry.upsert(nextRecord);
|
||||
|
||||
await this.archiveNativeSessionBestEffort(record.provider, record.persistence);
|
||||
|
||||
@@ -230,6 +230,7 @@ export function findExecutable(name: string): string | null {
|
||||
const result = execFileSync(cmd, [trimmed], {
|
||||
encoding: "utf8",
|
||||
env: createProviderEnv({ baseEnv: process.env }),
|
||||
windowsHide: true,
|
||||
}).trim();
|
||||
const lines = result.split(/\r?\n/).filter((l: string) => l.trim());
|
||||
const candidate = lines.at(-1)?.trim() ?? null;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
import { __codexAppServerInternals } from "./codex-app-server-agent.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
type FakeCodexAppServer,
|
||||
} from "./codex/test-utils/fake-app-server.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
const CODEX_PROVIDER = "codex";
|
||||
@@ -27,39 +31,7 @@ const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
|
||||
},
|
||||
];
|
||||
|
||||
interface CodexRequestFn {
|
||||
(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface CodexClientLike {
|
||||
request: CodexRequestFn;
|
||||
}
|
||||
|
||||
interface CodexSessionTestAccess {
|
||||
client: CodexClientLike | null;
|
||||
connected: boolean;
|
||||
currentThreadId: string | null;
|
||||
serviceTier: "fast" | null;
|
||||
planModeEnabled: boolean;
|
||||
cachedRuntimeInfo: unknown;
|
||||
ensureThreadLoaded: () => Promise<void>;
|
||||
ensureThread: () => Promise<void>;
|
||||
buildUserInput: (...args: unknown[]) => Promise<unknown>;
|
||||
resolveSlashCommandInvocation: (...args: unknown[]) => Promise<unknown>;
|
||||
collaborationModes: CollaborationModeRecord[];
|
||||
refreshResolvedCollaborationMode(): void;
|
||||
}
|
||||
|
||||
type CodexFeaturesTestSession = AgentSession & {
|
||||
connected: boolean;
|
||||
currentThreadId: string | null;
|
||||
collaborationModes: CollaborationModeRecord[];
|
||||
refreshResolvedCollaborationMode(): void;
|
||||
};
|
||||
|
||||
function asInternals(session: CodexFeaturesTestSession): CodexSessionTestAccess {
|
||||
return session as unknown as CodexSessionTestAccess;
|
||||
}
|
||||
type CodexFeaturesTestSession = AgentSession;
|
||||
|
||||
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
|
||||
return {
|
||||
@@ -71,28 +43,36 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
|
||||
};
|
||||
}
|
||||
|
||||
function createSession(
|
||||
configOverrides: Partial<AgentSessionConfig> = {},
|
||||
): CodexFeaturesTestSession {
|
||||
function createSessionHarness(configOverrides: Partial<AgentSessionConfig> = {}): {
|
||||
session: CodexFeaturesTestSession;
|
||||
appServer: FakeCodexAppServer;
|
||||
} {
|
||||
const config = createConfig(configOverrides);
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
|
||||
});
|
||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
||||
{ ...config, provider: CODEX_PROVIDER },
|
||||
null,
|
||||
createTestLogger(),
|
||||
() => {
|
||||
throw new Error("Test session cannot spawn Codex app-server");
|
||||
},
|
||||
) as unknown as CodexFeaturesTestSession;
|
||||
session.connected = true;
|
||||
session.currentThreadId = "test-thread";
|
||||
session.collaborationModes = TEST_COLLABORATION_MODES;
|
||||
session.refreshResolvedCollaborationMode();
|
||||
return session;
|
||||
async () => appServer.child,
|
||||
) as CodexFeaturesTestSession;
|
||||
return { session, appServer };
|
||||
}
|
||||
|
||||
async function createConnectedSession(configOverrides: Partial<AgentSessionConfig> = {}): Promise<{
|
||||
session: CodexFeaturesTestSession;
|
||||
appServer: FakeCodexAppServer;
|
||||
}> {
|
||||
const harness = createSessionHarness(configOverrides);
|
||||
await harness.session.connect();
|
||||
harness.appServer.assertNoErrors();
|
||||
return harness;
|
||||
}
|
||||
|
||||
describe("Codex app-server provider features", () => {
|
||||
test("features returns fast and plan toggles when supported", async () => {
|
||||
const session = createSession();
|
||||
const { session } = await createConnectedSession();
|
||||
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
@@ -140,8 +120,8 @@ describe("Codex app-server provider features", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("features returns only plan toggle when model does not support fast mode", () => {
|
||||
const session = createSession({ model: "gpt-3.5-turbo" });
|
||||
test("features returns only plan toggle when model does not support fast mode", async () => {
|
||||
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
|
||||
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
@@ -157,49 +137,56 @@ describe("Codex app-server provider features", () => {
|
||||
});
|
||||
|
||||
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
|
||||
const session = createSession();
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.startTurn("hello");
|
||||
|
||||
expect(asInternals(session).serviceTier).toBe("fast");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "fast",
|
||||
});
|
||||
});
|
||||
|
||||
test("setFeature('fast_mode', false) clears serviceTier to null", async () => {
|
||||
const session = createSession({
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
|
||||
await session.setFeature?.("fast_mode", false);
|
||||
await session.startTurn("hello");
|
||||
|
||||
expect(asInternals(session).serviceTier).toBeNull();
|
||||
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
|
||||
serviceTier: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
test("setFeature invalidates cachedRuntimeInfo", async () => {
|
||||
const session = createSession();
|
||||
test("setFeature invalidates runtime info", async () => {
|
||||
const { session } = await createConnectedSession();
|
||||
|
||||
await session.getRuntimeInfo();
|
||||
expect(asInternals(session).cachedRuntimeInfo).not.toBeNull();
|
||||
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({
|
||||
extra: { collaborationMode: "Plan" },
|
||||
});
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
|
||||
expect(asInternals(session).cachedRuntimeInfo).toBeNull();
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
||||
extra: { collaborationMode: "Plan" },
|
||||
});
|
||||
});
|
||||
|
||||
test("setFeature throws for unknown feature ids", async () => {
|
||||
const session = createSession();
|
||||
const { session } = createSessionHarness();
|
||||
|
||||
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
|
||||
"Unknown Codex feature: unknown_feature",
|
||||
);
|
||||
});
|
||||
|
||||
test("constructor restores feature flags from config.featureValues", () => {
|
||||
const session = createSession({
|
||||
test("constructor restores feature flags from config.featureValues", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
featureValues: { fast_mode: true, plan_mode: true },
|
||||
});
|
||||
|
||||
expect(asInternals(session).serviceTier).toBe("fast");
|
||||
expect(asInternals(session).planModeEnabled).toBe(true);
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
@@ -220,41 +207,29 @@ describe("Codex app-server provider features", () => {
|
||||
value: true,
|
||||
},
|
||||
]);
|
||||
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "fast",
|
||||
collaborationMode: expect.objectContaining({
|
||||
mode: "plan",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("startTurn includes serviceTier when fast mode is enabled", async () => {
|
||||
const session = createSession();
|
||||
const request = vi.fn().mockResolvedValue(undefined);
|
||||
asInternals(session).client = { request };
|
||||
asInternals(session).connected = true;
|
||||
asInternals(session).currentThreadId = "thread-123";
|
||||
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
|
||||
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
|
||||
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.startTurn("hello");
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"turn/start",
|
||||
expect.objectContaining({
|
||||
serviceTier: "fast",
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "fast",
|
||||
});
|
||||
});
|
||||
|
||||
test("setModel clears fast mode when switching to an unsupported model", async () => {
|
||||
const session = createSession();
|
||||
const request = vi.fn().mockResolvedValue(undefined);
|
||||
asInternals(session).client = { request };
|
||||
asInternals(session).connected = true;
|
||||
asInternals(session).currentThreadId = "thread-123";
|
||||
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
|
||||
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
|
||||
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.setModel("gpt-3.5-turbo");
|
||||
@@ -270,41 +245,23 @@ describe("Codex app-server provider features", () => {
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
expect(asInternals(session).serviceTier).toBeNull();
|
||||
|
||||
await session.startTurn("hello");
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"turn/start",
|
||||
expect.not.objectContaining({
|
||||
serviceTier: expect.anything(),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
|
||||
serviceTier: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
|
||||
const session = createSession();
|
||||
const request = vi.fn().mockResolvedValue(undefined);
|
||||
asInternals(session).client = { request };
|
||||
asInternals(session).connected = true;
|
||||
asInternals(session).currentThreadId = "thread-123";
|
||||
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
|
||||
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
|
||||
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
await session.startTurn("hello");
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"turn/start",
|
||||
expect.objectContaining({
|
||||
collaborationMode: expect.objectContaining({
|
||||
mode: "plan",
|
||||
}),
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
collaborationMode: expect.objectContaining({
|
||||
mode: "plan",
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
CodexAppServerAgentClient,
|
||||
codexAppServerTurnInputFromPrompt,
|
||||
} from "./codex-app-server-agent.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
waitForNextPermission,
|
||||
} from "./codex/test-utils/fake-app-server.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
|
||||
|
||||
@@ -98,122 +102,6 @@ function markdownImageSource(markdown: string): string {
|
||||
return match[1].replace(/\\\)/g, ")");
|
||||
}
|
||||
|
||||
function createChildProcessStub(): ChildProcessWithoutNullStreams {
|
||||
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
|
||||
child.stdin = new PassThrough() as ChildProcessWithoutNullStreams["stdin"];
|
||||
child.stdout = new PassThrough() as ChildProcessWithoutNullStreams["stdout"];
|
||||
child.stderr = new PassThrough() as ChildProcessWithoutNullStreams["stderr"];
|
||||
child.exitCode = null;
|
||||
child.signalCode = null;
|
||||
child.kill = vi.fn((signal?: NodeJS.Signals | number) => {
|
||||
queueMicrotask(() => child.emit("exit", null, signal ?? null));
|
||||
return true;
|
||||
}) as ChildProcessWithoutNullStreams["kill"];
|
||||
return child;
|
||||
}
|
||||
|
||||
function createScriptedCodexPeer(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
handlers: Record<string, (params: unknown) => unknown>,
|
||||
) {
|
||||
const messages: Record<string, unknown>[] = [];
|
||||
const errors: Error[] = [];
|
||||
const waiters = new Set<{
|
||||
predicate: (message: Record<string, unknown>) => boolean;
|
||||
resolve: (message: Record<string, unknown>) => void;
|
||||
}>();
|
||||
let buffer = "";
|
||||
|
||||
const processMessage = (message: Record<string, unknown>) => {
|
||||
messages.push(message);
|
||||
for (const waiter of Array.from(waiters)) {
|
||||
if (waiter.predicate(message)) {
|
||||
waiters.delete(waiter);
|
||||
waiter.resolve(message);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof message.id !== "number" || typeof message.method !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = handlers[message.method];
|
||||
if (!handler) {
|
||||
errors.push(new Error(`Unexpected Codex app-server request: ${message.method}`));
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve(handler(message.params))
|
||||
.then((result) => {
|
||||
child.stdout.write(`${JSON.stringify({ id: message.id, result })}\n`);
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: message.id,
|
||||
error: { message: error instanceof Error ? error.message : String(error) },
|
||||
})}\n`,
|
||||
);
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
child.stdin.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
for (;;) {
|
||||
const newlineIndex = buffer.indexOf("\n");
|
||||
if (newlineIndex === -1) {
|
||||
break;
|
||||
}
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(line);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
processMessage(parsed as Record<string, unknown>);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
assertNoErrors() {
|
||||
if (errors.length > 0) {
|
||||
throw errors[0];
|
||||
}
|
||||
},
|
||||
waitForMessage(
|
||||
predicate: (message: Record<string, unknown>) => boolean,
|
||||
label: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const existing = messages.find(predicate);
|
||||
if (existing) {
|
||||
return Promise.resolve(existing);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
waiters.delete(waiter);
|
||||
reject(new Error(`Timed out waiting for ${label}`));
|
||||
}, 1000);
|
||||
const waiter = {
|
||||
predicate,
|
||||
resolve: (message: Record<string, unknown>) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(message);
|
||||
},
|
||||
};
|
||||
waiters.add(waiter);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Codex app-server provider", () => {
|
||||
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
@@ -301,8 +189,7 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
test("round-trips server-initiated command approvals through the real app-server transport", async () => {
|
||||
const child = createChildProcessStub();
|
||||
const peer = createScriptedCodexPeer(child, {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
initialize: () => ({}),
|
||||
"collaborationMode/list": () => ({ data: [] }),
|
||||
"skills/list": () => ({ data: [] }),
|
||||
@@ -311,55 +198,22 @@ describe("Codex app-server provider", () => {
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => child,
|
||||
async () => appServer.child,
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.connect();
|
||||
peer.assertNoErrors();
|
||||
appServer.assertNoErrors();
|
||||
|
||||
const permissionRequested = new Promise<
|
||||
Extract<AgentStreamEvent, { type: "permission_requested" }>
|
||||
>((resolve, reject) => {
|
||||
const existing = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||
event.type === "permission_requested",
|
||||
);
|
||||
if (existing) {
|
||||
resolve(existing);
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error("Timed out waiting for permission_requested"));
|
||||
}, 1000);
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
if (event.type !== "permission_requested") {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(event);
|
||||
});
|
||||
const permissionRequested = waitForNextPermission(session);
|
||||
appServer.requestCommandApproval({
|
||||
itemId: "exec-approval-1",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
command: "git restore README.md",
|
||||
cwd: "/workspace/project",
|
||||
reason: "requires escalated permissions",
|
||||
});
|
||||
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 41,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
itemId: "exec-approval-1",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
command: "git restore README.md",
|
||||
cwd: "/workspace/project",
|
||||
reason: "requires escalated permissions",
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const permissionEvent = await permissionRequested;
|
||||
expect(permissionEvent.request).toMatchObject({
|
||||
id: "permission-exec-approval-1",
|
||||
@@ -381,19 +235,10 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
await session.respondToPermission(permissionEvent.request.id, { behavior: "allow" });
|
||||
|
||||
await expect(
|
||||
peer.waitForMessage(
|
||||
(message) =>
|
||||
message.id === 41 &&
|
||||
!("method" in message) &&
|
||||
JSON.stringify(message.result) === JSON.stringify({ decision: "accept" }),
|
||||
"command approval response",
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
id: 41,
|
||||
result: { decision: "accept" },
|
||||
await expect(appServer.waitForCommandApprovalDecision("exec-approval-1")).resolves.toEqual({
|
||||
decision: "accept",
|
||||
});
|
||||
peer.assertNoErrors();
|
||||
appServer.assertNoErrors();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
@@ -1058,6 +903,52 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the parent sub-agent running when a child command fails during the child turn", () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
asInternals(session).handleNotification("item/completed", {
|
||||
threadId: "test-thread",
|
||||
item: {
|
||||
type: "collabAgentToolCall",
|
||||
id: "call-sub-agent-child-command-failure",
|
||||
tool: "spawnAgent",
|
||||
status: "completed",
|
||||
prompt: "Fix the regression test-first.",
|
||||
receiverThreadIds: ["child-thread-1"],
|
||||
agentsStates: {
|
||||
"child-thread-1": { status: "running", message: null },
|
||||
},
|
||||
},
|
||||
});
|
||||
asInternals(session).handleNotification("item/completed", {
|
||||
threadId: "child-thread-1",
|
||||
item: {
|
||||
type: "commandExecution",
|
||||
id: "child-failing-command",
|
||||
status: "failed",
|
||||
command: "npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts",
|
||||
aggregatedOutput: "expected false to be true",
|
||||
exitCode: 1,
|
||||
error: { message: "Command failed" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(events.at(-1)?.item).toMatchObject({
|
||||
type: "tool_call",
|
||||
callId: "call-sub-agent-child-command-failure",
|
||||
name: "Sub-agent",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Sub-agent",
|
||||
description: "Fix the regression test-first.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("loads Codex persisted history from the app-server thread", async () => {
|
||||
const session = createSession();
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
|
||||
@@ -3786,10 +3786,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
this.pendingCommandOutputDeltas.delete(itemId);
|
||||
this.pendingFileChangeOutputDeltas.delete(itemId);
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(
|
||||
callId,
|
||||
timelineItem.type === "tool_call" && timelineItem.status === "failed" ? "failed" : "running",
|
||||
);
|
||||
this.emitSubAgentActivityUpdate(callId, "running");
|
||||
}
|
||||
|
||||
private shouldSkipCompletedThreadItem(
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { createCodexAppServerChildProcess } from "./test-utils/fake-app-server.js";
|
||||
import { CodexAppServerClient } from "./app-server-transport.js";
|
||||
|
||||
function createChildProcessStub(): ChildProcessWithoutNullStreams {
|
||||
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
|
||||
child.stdin = new PassThrough() as ChildProcessWithoutNullStreams["stdin"];
|
||||
child.stdout = new PassThrough() as ChildProcessWithoutNullStreams["stdout"];
|
||||
child.stderr = new PassThrough() as ChildProcessWithoutNullStreams["stderr"];
|
||||
child.exitCode = null;
|
||||
child.signalCode = null;
|
||||
child.kill = (() => true) as ChildProcessWithoutNullStreams["kill"];
|
||||
return child;
|
||||
}
|
||||
|
||||
describe("Codex app-server transport", () => {
|
||||
test("ignores non-JSON stdout lines without dropping pending requests", async () => {
|
||||
const child = createChildProcessStub();
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
|
||||
const request = client.request("model/list", {});
|
||||
@@ -38,7 +25,7 @@ describe("Codex app-server transport", () => {
|
||||
"item/tool/requestUserInput",
|
||||
"tool/requestUserInput",
|
||||
])("answers server-initiated %s requests through registered handlers", async (method) => {
|
||||
const child = createChildProcessStub();
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
const handlerCalls: unknown[] = [];
|
||||
client.setRequestHandler(method, async (params) => {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type FakeCodexAppServerHandler = (params: unknown) => unknown;
|
||||
type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
|
||||
stdin: PassThrough;
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
};
|
||||
|
||||
export interface FakeCodexAppServer {
|
||||
readonly child: CodexAppServerChildProcess;
|
||||
assertNoErrors(): void;
|
||||
waitForTurnStart(): Promise<JsonObject>;
|
||||
requestCommandApproval(params: {
|
||||
itemId: string;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
command: string;
|
||||
cwd: string;
|
||||
reason: string;
|
||||
}): void;
|
||||
waitForCommandApprovalDecision(itemId: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
export function createCodexAppServerChildProcess(): CodexAppServerChildProcess {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdin: new PassThrough(),
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
}) as CodexAppServerChildProcess;
|
||||
child.kill = ((signal?: NodeJS.Signals | number) => {
|
||||
queueMicrotask(() => child.emit("exit", null, signal ?? null));
|
||||
return true;
|
||||
}) as ChildProcessWithoutNullStreams["kill"];
|
||||
return child;
|
||||
}
|
||||
|
||||
export function createFakeCodexAppServer(
|
||||
handlers: Record<string, FakeCodexAppServerHandler> = {},
|
||||
): FakeCodexAppServer {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const responseHandlers: Record<string, FakeCodexAppServerHandler> = {
|
||||
initialize: () => ({}),
|
||||
"collaborationMode/list": () => ({ data: [] }),
|
||||
"config/read": () => ({ config: {} }),
|
||||
getUserSavedConfig: () => ({ config: {} }),
|
||||
"model/list": () => ({
|
||||
data: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
isDefault: true,
|
||||
defaultReasoningEffort: "medium",
|
||||
},
|
||||
],
|
||||
}),
|
||||
"skills/list": () => ({ data: [] }),
|
||||
"thread/start": () => ({ thread: { id: "thread-1" } }),
|
||||
"thread/loaded/list": () => ({ data: [] }),
|
||||
"thread/resume": () => ({}),
|
||||
"turn/start": () => ({}),
|
||||
...handlers,
|
||||
};
|
||||
const messages: JsonObject[] = [];
|
||||
const errors: Error[] = [];
|
||||
const approvalRequestIds = new Map<string, number>();
|
||||
const waiters = new Set<{
|
||||
predicate: (message: JsonObject) => boolean;
|
||||
resolve: (message: JsonObject) => void;
|
||||
}>();
|
||||
let buffer = "";
|
||||
let nextServerRequestId = 1;
|
||||
|
||||
function processMessage(message: JsonObject): void {
|
||||
messages.push(message);
|
||||
for (const waiter of Array.from(waiters)) {
|
||||
if (waiter.predicate(message)) {
|
||||
waiters.delete(waiter);
|
||||
waiter.resolve(message);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof message.id !== "number" || typeof message.method !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = responseHandlers[message.method];
|
||||
if (!handler) {
|
||||
errors.push(new Error(`Unexpected Codex app-server request: ${message.method}`));
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve(handler(message.params))
|
||||
.then((result) => {
|
||||
child.stdout.write(`${JSON.stringify({ id: message.id, result })}\n`);
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: message.id,
|
||||
error: { message: error instanceof Error ? error.message : String(error) },
|
||||
})}\n`,
|
||||
);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
child.stdin.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
for (;;) {
|
||||
const newlineIndex = buffer.indexOf("\n");
|
||||
if (newlineIndex === -1) {
|
||||
break;
|
||||
}
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(line);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
processMessage(parsed as JsonObject);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function waitForMessage(
|
||||
predicate: (message: JsonObject) => boolean,
|
||||
label: string,
|
||||
): Promise<JsonObject> {
|
||||
const existing = messages.find(predicate);
|
||||
if (existing) {
|
||||
return Promise.resolve(existing);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
waiters.delete(waiter);
|
||||
reject(new Error(`Timed out waiting for ${label}`));
|
||||
}, 1000);
|
||||
const waiter = {
|
||||
predicate,
|
||||
resolve: (message: JsonObject) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(message);
|
||||
},
|
||||
};
|
||||
waiters.add(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
child,
|
||||
assertNoErrors() {
|
||||
if (errors.length > 0) {
|
||||
throw errors[0];
|
||||
}
|
||||
},
|
||||
async waitForTurnStart() {
|
||||
const message = await waitForMessage(
|
||||
(candidate) => candidate.method === "turn/start",
|
||||
"turn start request",
|
||||
);
|
||||
return toJsonObject(message.params);
|
||||
},
|
||||
requestCommandApproval(params) {
|
||||
const requestId = nextServerRequestId;
|
||||
nextServerRequestId += 1;
|
||||
approvalRequestIds.set(params.itemId, requestId);
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: requestId,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params,
|
||||
})}\n`,
|
||||
);
|
||||
},
|
||||
async waitForCommandApprovalDecision(itemId) {
|
||||
const requestId = approvalRequestIds.get(itemId);
|
||||
if (requestId === undefined) {
|
||||
throw new Error(`No pending fake Codex app-server approval for ${itemId}`);
|
||||
}
|
||||
const message = await waitForMessage(
|
||||
(candidate) =>
|
||||
candidate.id === requestId && !("method" in candidate) && "result" in candidate,
|
||||
"command approval response",
|
||||
);
|
||||
return message.result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toJsonObject(value: unknown): JsonObject {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as JsonObject;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function waitForNextPermission(
|
||||
session: AgentSession,
|
||||
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error("Timed out waiting for permission_requested"));
|
||||
}, 1000);
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
if (event.type !== "permission_requested") {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(event);
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,22 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
const OPENCODE_BUILD_MODE_ID = "build";
|
||||
const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
|
||||
const OPENCODE_STORAGE_SESSION_LIMIT = 200;
|
||||
// COMPAT(opencodeEofRecovery): added in v0.1.73 to compensate for OpenCode 1.14.42+
|
||||
// closing the /event SSE stream cleanly after `server.connected`. Drop this whole
|
||||
// recovery path once OpenCode upstream restores live event delivery and the floor
|
||||
// version reflects that.
|
||||
// Upstream: anomalyco/opencode#26697 (SSE /event closes immediately after
|
||||
// server.connected) and anomalyco/opencode#26635 (prompt_async silently discards
|
||||
// requests; SSE path broken).
|
||||
const OPENCODE_EOF_RECOVERY_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const OPENCODE_EOF_RECOVERY_POLL_INTERVAL_MS = 1_000;
|
||||
const OPENCODE_RECOVERY_ABORT_TIMEOUT_MS = 2_000;
|
||||
const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000;
|
||||
// If OpenCode silently rejects the prompt (invalid model/mode/auth), no assistant
|
||||
// message is ever persisted. Bound the wait so the turn fails in seconds instead
|
||||
// of hanging until the completion cap. Valid models normally persist their first
|
||||
// message within a second of LLM start, so 10s leaves comfortable headroom.
|
||||
const OPENCODE_EOF_RECOVERY_LIVENESS_MS = 10_000;
|
||||
|
||||
const DEFAULT_MODES: AgentMode[] = [
|
||||
{
|
||||
@@ -651,6 +667,19 @@ function mergeOpenCodeStepFinishUsage(
|
||||
}
|
||||
}
|
||||
|
||||
function formatOpenCodeAssistantErrorMessage(
|
||||
error: NonNullable<OpenCodeAssistantMessage["error"]>,
|
||||
): string {
|
||||
const data = (error as { data?: unknown }).data;
|
||||
if (data && typeof data === "object" && "message" in data) {
|
||||
const message = (data as { message?: unknown }).message;
|
||||
if (typeof message === "string" && message.trim().length > 0) {
|
||||
return message.trim();
|
||||
}
|
||||
}
|
||||
return error.name;
|
||||
}
|
||||
|
||||
function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean {
|
||||
return [
|
||||
usage.inputTokens,
|
||||
@@ -818,18 +847,32 @@ async function readOpenCodeSessionTimeline(
|
||||
}
|
||||
|
||||
async function readOpenCodeMessageText(storageRoot: string, messageId: string): Promise<string> {
|
||||
const parts = await readOpenCodeStoredParts(storageRoot, messageId);
|
||||
return readOpenCodeTextFromParts(parts);
|
||||
}
|
||||
|
||||
async function readOpenCodeStoredParts(
|
||||
storageRoot: string,
|
||||
messageId: string,
|
||||
): Promise<OpenCodeStoredPart[]> {
|
||||
const partRoot = path.join(storageRoot, "part", messageId);
|
||||
const partFiles = await findJsonFiles(partRoot);
|
||||
const parts: OpenCodeStoredPart[] = [];
|
||||
for (const file of partFiles) {
|
||||
const parsed = await readJsonFile(file, OpenCodeStoredPartSchema);
|
||||
if (parsed?.type === "text" && typeof parsed.text === "string") {
|
||||
if (parsed) {
|
||||
parts.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.sort(
|
||||
(left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right),
|
||||
);
|
||||
}
|
||||
|
||||
function readOpenCodeTextFromParts(parts: OpenCodeStoredPart[]): string {
|
||||
return parts
|
||||
.sort((left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right))
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => part.text?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
@@ -898,8 +941,15 @@ export const __openCodeInternals = {
|
||||
},
|
||||
};
|
||||
|
||||
interface OpenCodeRecoveryOptions {
|
||||
timeoutMs: number;
|
||||
pollIntervalMs: number;
|
||||
livenessMs: number;
|
||||
}
|
||||
|
||||
interface OpenCodeAgentClientDeps {
|
||||
runtime?: OpenCodeRuntime;
|
||||
recovery?: OpenCodeRecoveryOptions;
|
||||
}
|
||||
|
||||
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
|
||||
@@ -931,6 +981,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly modelContextWindows = new Map<string, number>();
|
||||
private readonly storageRoot: string;
|
||||
private readonly recovery: OpenCodeRecoveryOptions;
|
||||
|
||||
constructor(
|
||||
logger: Logger,
|
||||
@@ -941,6 +992,11 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
this.logger = logger.child({ module: "agent", provider: "opencode" });
|
||||
this.runtimeSettings = runtimeSettings;
|
||||
this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot();
|
||||
this.recovery = deps.recovery ?? {
|
||||
timeoutMs: OPENCODE_EOF_RECOVERY_TIMEOUT_MS,
|
||||
pollIntervalMs: OPENCODE_EOF_RECOVERY_POLL_INTERVAL_MS,
|
||||
livenessMs: OPENCODE_EOF_RECOVERY_LIVENESS_MS,
|
||||
};
|
||||
this.runtime =
|
||||
deps.runtime ??
|
||||
new ProductionOpenCodeRuntime(
|
||||
@@ -984,9 +1040,11 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
client,
|
||||
session.id,
|
||||
this.logger,
|
||||
this.storageRoot,
|
||||
new Map(this.modelContextWindows),
|
||||
acquisition.release,
|
||||
options?.persistSession,
|
||||
this.recovery,
|
||||
);
|
||||
} catch (error) {
|
||||
acquisition.release();
|
||||
@@ -1025,8 +1083,11 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
client,
|
||||
handle.sessionId,
|
||||
this.logger,
|
||||
this.storageRoot,
|
||||
new Map(this.modelContextWindows),
|
||||
acquisition.release,
|
||||
undefined,
|
||||
this.recovery,
|
||||
);
|
||||
} catch (error) {
|
||||
acquisition.release();
|
||||
@@ -2122,6 +2183,18 @@ function createDeferred<T>(): Deferred<T> {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const OPENCODE_TRACE_ENABLED = process.env.PASEO_OPENCODE_TRACE === "1";
|
||||
|
||||
function traceOpenCode(tag: string, data: Record<string, unknown> = {}): void {
|
||||
if (!OPENCODE_TRACE_ENABLED) return;
|
||||
const line = JSON.stringify({ ts: new Date().toISOString(), tag, ...data }, (_k, v) => {
|
||||
if (v instanceof Error) return { name: v.name, message: v.message, stack: v.stack };
|
||||
if (typeof v === "bigint") return v.toString();
|
||||
return v;
|
||||
});
|
||||
process.stderr.write(`[opencode-trace] ${line}\n`);
|
||||
}
|
||||
|
||||
class OpenCodeAgentSession implements AgentSession {
|
||||
readonly provider = "opencode" as const;
|
||||
readonly capabilities = OPENCODE_CAPABILITIES;
|
||||
@@ -2130,10 +2203,12 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private readonly client: OpencodeClient;
|
||||
private readonly sessionId: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly storageRoot: string;
|
||||
private readonly modelContextWindowsByModelKey: ReadonlyMap<string, number>;
|
||||
private currentMode: string = "default";
|
||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||
private abortController: AbortController | null = null;
|
||||
private pendingAbortPromise: Promise<void> | null = null;
|
||||
private accumulatedUsage: AgentUsage = {};
|
||||
private mcpConfigured = false;
|
||||
private mcpSetupPromise: Promise<void> | null = null;
|
||||
@@ -2157,23 +2232,41 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private releaseServer: (() => void) | null;
|
||||
private readonly persistSession: boolean;
|
||||
private deletedFromProvider = false;
|
||||
private foregroundAssistantMessageEmitted = false;
|
||||
private foregroundAssistantText = "";
|
||||
private foregroundUsageUpdated = false;
|
||||
private foregroundKnownMessageIds = new Set<string>();
|
||||
private foregroundEmittedQuestionIds = new Set<string>();
|
||||
private foregroundEmittedPermissionIds = new Set<string>();
|
||||
private foregroundEmittedReasoningTextLengthByPartId = new Map<string, number>();
|
||||
private foregroundEmittedToolCallSignatureByCallId = new Map<string, string>();
|
||||
private foregroundTurnStartedAt: number | null = null;
|
||||
private readonly recovery: OpenCodeRecoveryOptions;
|
||||
constructor(
|
||||
config: OpenCodeAgentConfig,
|
||||
client: OpencodeClient,
|
||||
sessionId: string,
|
||||
logger: Logger,
|
||||
storageRoot: string,
|
||||
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
|
||||
releaseServer?: () => void,
|
||||
persistSession = true,
|
||||
recovery: OpenCodeRecoveryOptions = {
|
||||
timeoutMs: OPENCODE_EOF_RECOVERY_TIMEOUT_MS,
|
||||
pollIntervalMs: OPENCODE_EOF_RECOVERY_POLL_INTERVAL_MS,
|
||||
livenessMs: OPENCODE_EOF_RECOVERY_LIVENESS_MS,
|
||||
},
|
||||
) {
|
||||
this.config = config;
|
||||
this.client = client;
|
||||
this.sessionId = sessionId;
|
||||
this.logger = logger;
|
||||
this.storageRoot = storageRoot;
|
||||
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
|
||||
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
||||
this.releaseServer = releaseServer ?? null;
|
||||
this.persistSession = persistSession;
|
||||
this.recovery = recovery;
|
||||
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
|
||||
config.model,
|
||||
);
|
||||
@@ -2223,9 +2316,18 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const turnId = this.activeForegroundTurnId;
|
||||
const turnAbortController = this.abortController;
|
||||
turnAbortController?.abort();
|
||||
await this.client.session.abort({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
// COMPAT(opencodeSlowAbort): OpenCode 1.14.42+ blocks session.abort until
|
||||
// the running tool actually stops, which can be tens of seconds for
|
||||
// long-running tools. Cap the wait so the user-visible cancel lands
|
||||
// quickly while still giving OpenCode a chance to confirm the abort
|
||||
// cleanly. Drop the timeout once upstream returns abort acknowledgement
|
||||
// before tool teardown.
|
||||
const abortPromise = this.beginSessionAbort(turnId, "interrupt");
|
||||
await withTimeout(abortPromise, 2_000, "OpenCode session.abort").catch((error) => {
|
||||
this.logger.warn(
|
||||
{ err: error, sessionId: this.sessionId, turnId },
|
||||
"OpenCode session.abort exceeded the cancel cap; proceeding with local cancel",
|
||||
);
|
||||
});
|
||||
if (turnId) {
|
||||
this.finishForegroundTurn(
|
||||
@@ -2235,6 +2337,46 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private beginSessionAbort(turnId: string | null, reason: string): Promise<void> {
|
||||
const abortPromise = this.client.session
|
||||
.abort({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch((error) => {
|
||||
this.logger.warn(
|
||||
{ err: error, sessionId: this.sessionId, turnId, reason },
|
||||
"OpenCode session.abort rejected",
|
||||
);
|
||||
});
|
||||
const trackedAbortPromise = abortPromise.finally(() => {
|
||||
if (this.pendingAbortPromise === trackedAbortPromise) {
|
||||
this.pendingAbortPromise = null;
|
||||
}
|
||||
});
|
||||
this.pendingAbortPromise = trackedAbortPromise;
|
||||
return trackedAbortPromise;
|
||||
}
|
||||
|
||||
private async awaitPendingAbortBeforeStartingTurn(): Promise<void> {
|
||||
const pendingAbortPromise = this.pendingAbortPromise;
|
||||
if (!pendingAbortPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
await withTimeout(
|
||||
pendingAbortPromise,
|
||||
OPENCODE_PENDING_ABORT_START_TIMEOUT_MS,
|
||||
"OpenCode pending session.abort",
|
||||
).catch((error) => {
|
||||
this.logger.warn(
|
||||
{ err: error, sessionId: this.sessionId },
|
||||
"OpenCode session.abort was still pending before starting the next turn",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async startTurn(
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
@@ -2242,11 +2384,21 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
if (this.activeForegroundTurnId) {
|
||||
throw new Error("A foreground turn is already active");
|
||||
}
|
||||
await this.awaitPendingAbortBeforeStartingTurn();
|
||||
|
||||
this.foregroundTurnStartedAt = Date.now();
|
||||
this.runningToolCalls.clear();
|
||||
this.subAgentsByCallId.clear();
|
||||
this.subAgentCallIdByChildSessionId.clear();
|
||||
this.pendingChildToolPartsBySessionId.clear();
|
||||
this.foregroundAssistantMessageEmitted = false;
|
||||
this.foregroundAssistantText = "";
|
||||
this.foregroundUsageUpdated = false;
|
||||
this.foregroundEmittedQuestionIds.clear();
|
||||
this.foregroundEmittedPermissionIds.clear();
|
||||
this.foregroundEmittedReasoningTextLengthByPartId.clear();
|
||||
this.foregroundEmittedToolCallSignatureByCallId.clear();
|
||||
this.foregroundKnownMessageIds = await this.readPersistedSessionMessageIds();
|
||||
const turnAbortController = new AbortController();
|
||||
this.abortController = turnAbortController;
|
||||
await this.ensureMcpServersConfigured();
|
||||
@@ -2380,6 +2532,14 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
// SDK input validation) is caught alongside async rejections. A plain
|
||||
// `.then().catch()` chain would let a sync throw escape unhandled.
|
||||
void (async () => {
|
||||
traceOpenCode("promptAsync.start", {
|
||||
turnId,
|
||||
sessionId: this.sessionId,
|
||||
model,
|
||||
effectiveMode,
|
||||
effectiveVariant,
|
||||
partTypes: parts.map((p) => p.type),
|
||||
});
|
||||
try {
|
||||
const promptResponse = await this.client.session.promptAsync({
|
||||
sessionID: this.sessionId,
|
||||
@@ -2398,6 +2558,12 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||
});
|
||||
traceOpenCode("promptAsync.response", {
|
||||
turnId,
|
||||
hasError: promptResponse.error !== undefined,
|
||||
error: promptResponse.error,
|
||||
data: promptResponse.data,
|
||||
});
|
||||
if (promptResponse.error) {
|
||||
this.finishForegroundTurn(
|
||||
{
|
||||
@@ -2409,6 +2575,13 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
traceOpenCode("promptAsync.throw", {
|
||||
turnId,
|
||||
error:
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message, stack: error.stack }
|
||||
: String(error),
|
||||
});
|
||||
this.finishForegroundTurn(
|
||||
{
|
||||
type: "turn_failed",
|
||||
@@ -2436,21 +2609,44 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
turnAbortController: AbortController,
|
||||
subscriptionReady: Deferred<void>,
|
||||
): Promise<void> {
|
||||
traceOpenCode("subscribe.start", { turnId, sessionId: this.sessionId, cwd: this.config.cwd });
|
||||
try {
|
||||
const result = await this.client.event.subscribe(
|
||||
{ directory: this.config.cwd },
|
||||
{ signal: turnAbortController.signal, sseMaxRetryAttempts: 0 },
|
||||
{ signal: turnAbortController.signal },
|
||||
);
|
||||
traceOpenCode("subscribe.ready", { turnId, sessionId: this.sessionId });
|
||||
subscriptionReady.resolve();
|
||||
|
||||
let eventCount = 0;
|
||||
for await (const event of result.stream) {
|
||||
eventCount += 1;
|
||||
traceOpenCode("event.raw", {
|
||||
turnId,
|
||||
n: eventCount,
|
||||
type: (event as { type?: string }).type,
|
||||
properties: (event as { properties?: unknown }).properties,
|
||||
});
|
||||
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
|
||||
traceOpenCode("event.skip", {
|
||||
turnId,
|
||||
n: eventCount,
|
||||
aborted: turnAbortController.signal.aborted,
|
||||
activeTurnId: this.activeForegroundTurnId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const translated = await this.translateEvent(event);
|
||||
traceOpenCode("event.translated", {
|
||||
turnId,
|
||||
n: eventCount,
|
||||
count: translated.length,
|
||||
types: translated.map((t) => t.type),
|
||||
});
|
||||
for (const e of translated) {
|
||||
if (this.activeForegroundTurnId !== turnId) {
|
||||
traceOpenCode("event.translated.skip-active", { turnId, type: e.type });
|
||||
return;
|
||||
}
|
||||
if (e.type === "timeline" && e.item.type === "tool_call") {
|
||||
@@ -2458,6 +2654,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
const terminalEvent = toTerminalTurnEvent(e);
|
||||
if (terminalEvent) {
|
||||
traceOpenCode("event.terminal", { turnId, type: terminalEvent.type });
|
||||
this.finishForegroundTurn(terminalEvent, turnId);
|
||||
return;
|
||||
}
|
||||
@@ -2465,7 +2662,20 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
traceOpenCode("stream.eof", {
|
||||
turnId,
|
||||
eventCount,
|
||||
aborted: turnAbortController.signal.aborted,
|
||||
stillActive: this.activeForegroundTurnId === turnId,
|
||||
});
|
||||
|
||||
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
|
||||
const recovered = await this.recoverTurnFromPersistedCompletion(turnId);
|
||||
traceOpenCode("recovery.result", { turnId, recovered });
|
||||
if (recovered) {
|
||||
return;
|
||||
}
|
||||
traceOpenCode("turn.fail.eof", { turnId, eventCount });
|
||||
this.finishForegroundTurn(
|
||||
{
|
||||
type: "turn_failed",
|
||||
@@ -2476,6 +2686,11 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
traceOpenCode("subscribe.error", {
|
||||
turnId,
|
||||
error:
|
||||
error instanceof Error ? { name: error.name, message: error.message } : String(error),
|
||||
});
|
||||
subscriptionReady.reject(error);
|
||||
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
|
||||
this.finishForegroundTurn(
|
||||
@@ -2504,10 +2719,448 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private async recoverTurnFromPersistedCompletion(turnId: string): Promise<boolean> {
|
||||
traceOpenCode("recovery.start", {
|
||||
turnId,
|
||||
foregroundTurnStartedAt: this.foregroundTurnStartedAt,
|
||||
knownMessageIds: Array.from(this.foregroundKnownMessageIds),
|
||||
sessionId: this.sessionId,
|
||||
timeoutMs: this.recovery.timeoutMs,
|
||||
pollIntervalMs: this.recovery.pollIntervalMs,
|
||||
});
|
||||
const startedAt = this.foregroundTurnStartedAt;
|
||||
if (startedAt === null) {
|
||||
traceOpenCode("recovery.no-start-time", { turnId });
|
||||
return false;
|
||||
}
|
||||
|
||||
const completionDeadline = Date.now() + this.recovery.timeoutMs;
|
||||
const livenessDeadline = Date.now() + this.recovery.livenessMs;
|
||||
let attempt = 0;
|
||||
let observedActivity = false;
|
||||
while (true) {
|
||||
if (this.activeForegroundTurnId !== turnId) {
|
||||
traceOpenCode("recovery.cancelled", { turnId, attempt });
|
||||
return true;
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
const emittedPromptIds = await this.pollPendingQuestionsAndPermissions(turnId);
|
||||
if (emittedPromptIds > 0) {
|
||||
observedActivity = true;
|
||||
}
|
||||
const outcome = await this.fetchAssistantOutcomeFromMessagesApi(startedAt);
|
||||
traceOpenCode("recovery.poll", {
|
||||
turnId,
|
||||
attempt,
|
||||
kind: outcome?.kind ?? "none",
|
||||
messageId: outcome?.messageId,
|
||||
emittedPromptIds,
|
||||
});
|
||||
if (outcome?.kind === "failure") {
|
||||
this.foregroundKnownMessageIds.add(outcome.messageId);
|
||||
this.finishForegroundTurn(
|
||||
{
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: outcome.error,
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (outcome?.kind === "completion") {
|
||||
this.emitIncrementalAssistantParts(outcome.parts, turnId);
|
||||
return this.applyRecoveredAssistantCompletion(outcome, turnId);
|
||||
}
|
||||
if (outcome?.kind === "in-progress") {
|
||||
observedActivity = true;
|
||||
this.emitIncrementalAssistantParts(outcome.parts, turnId);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (!observedActivity && now >= livenessDeadline) {
|
||||
const deferred = await this.deferForPendingPermissionOrFailRecoveredTurnAfterCap(
|
||||
turnId,
|
||||
attempt,
|
||||
"liveness",
|
||||
);
|
||||
if (deferred) {
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (now >= completionDeadline) {
|
||||
const deferred = await this.deferForPendingPermissionOrFailRecoveredTurnAfterCap(
|
||||
turnId,
|
||||
attempt,
|
||||
"completion",
|
||||
);
|
||||
if (deferred) {
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const waitMs = Math.min(this.recovery.pollIntervalMs, completionDeadline - now);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
}
|
||||
|
||||
private async deferForPendingPermissionOrFailRecoveredTurnAfterCap(
|
||||
turnId: string,
|
||||
attempt: number,
|
||||
cap: "liveness" | "completion",
|
||||
): Promise<boolean> {
|
||||
if (this.pendingPermissions.size > 0) {
|
||||
// A pending OpenCode question/permission means the turn is blocked on
|
||||
// user input, not dead. Keep polling until the user response lets the
|
||||
// assistant finish or the turn is canceled.
|
||||
traceOpenCode(`recovery.${cap}-deferred-for-permission`, {
|
||||
turnId,
|
||||
attempt,
|
||||
pendingPermissionIds: Array.from(this.pendingPermissions.keys()),
|
||||
});
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, this.recovery.pollIntervalMs));
|
||||
return true;
|
||||
}
|
||||
|
||||
traceOpenCode(cap === "liveness" ? "recovery.liveness-exhausted" : "recovery.exhausted", {
|
||||
turnId,
|
||||
attempt,
|
||||
});
|
||||
await this.failRecoveredTurnAfterCap(turnId, cap);
|
||||
return false;
|
||||
}
|
||||
|
||||
private async failRecoveredTurnAfterCap(
|
||||
turnId: string,
|
||||
cap: "liveness" | "completion",
|
||||
): Promise<void> {
|
||||
await this.abortOpenCodeSessionAfterRecoveryCap(turnId, cap);
|
||||
this.finishForegroundTurn(
|
||||
{
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: "OpenCode event stream ended before the turn reached a terminal state",
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
}
|
||||
|
||||
private async abortOpenCodeSessionAfterRecoveryCap(
|
||||
turnId: string,
|
||||
cap: "liveness" | "completion",
|
||||
): Promise<void> {
|
||||
const abortPromise = this.beginSessionAbort(turnId, `recovery-${cap}`);
|
||||
await withTimeout(
|
||||
abortPromise,
|
||||
OPENCODE_RECOVERY_ABORT_TIMEOUT_MS,
|
||||
"OpenCode session.abort",
|
||||
).catch((error) => {
|
||||
this.logger.warn(
|
||||
{ err: error, sessionId: this.sessionId, turnId, cap },
|
||||
"OpenCode session.abort exceeded the EOF recovery cap",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async pollPendingQuestionsAndPermissions(turnId: string): Promise<number> {
|
||||
const [questionsResponse, permissionsResponse] = await Promise.all([
|
||||
Promise.resolve()
|
||||
.then(() => this.client.question.list({ directory: this.config.cwd }))
|
||||
.catch((error) => {
|
||||
traceOpenCode("recovery.question-list.throw", {
|
||||
turnId,
|
||||
error:
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message, stack: error.stack }
|
||||
: String(error),
|
||||
});
|
||||
return null;
|
||||
}),
|
||||
Promise.resolve()
|
||||
.then(() => this.client.permission.list({ directory: this.config.cwd }))
|
||||
.catch((error) => {
|
||||
traceOpenCode("recovery.permission-list.throw", {
|
||||
turnId,
|
||||
error:
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message, stack: error.stack }
|
||||
: String(error),
|
||||
});
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
|
||||
if (this.activeForegroundTurnId !== turnId) return 0;
|
||||
|
||||
let emitted = 0;
|
||||
for (const question of questionsResponse?.data ?? []) {
|
||||
if (question.sessionID !== this.sessionId) continue;
|
||||
if (this.foregroundEmittedQuestionIds.has(question.id)) continue;
|
||||
this.foregroundEmittedQuestionIds.add(question.id);
|
||||
emitted += 1;
|
||||
const synthetic = {
|
||||
id: question.id,
|
||||
type: "question.asked",
|
||||
properties: question,
|
||||
} as unknown as OpenCodeEvent;
|
||||
const events = await this.translateEvent(synthetic);
|
||||
for (const event of events) {
|
||||
this.notifySubscribers(event, turnId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const permission of permissionsResponse?.data ?? []) {
|
||||
if (permission.sessionID !== this.sessionId) continue;
|
||||
if (this.foregroundEmittedPermissionIds.has(permission.id)) continue;
|
||||
this.foregroundEmittedPermissionIds.add(permission.id);
|
||||
emitted += 1;
|
||||
const synthetic = {
|
||||
id: permission.id,
|
||||
type: "permission.asked",
|
||||
properties: permission,
|
||||
} as unknown as OpenCodeEvent;
|
||||
const events = await this.translateEvent(synthetic);
|
||||
for (const event of events) {
|
||||
this.notifySubscribers(event, turnId);
|
||||
}
|
||||
}
|
||||
return emitted;
|
||||
}
|
||||
|
||||
private async fetchAssistantOutcomeFromMessagesApi(startedAt: number): Promise<
|
||||
| {
|
||||
kind: "completion";
|
||||
messageId: string;
|
||||
text: string;
|
||||
parts: readonly OpenCodePart[];
|
||||
usage: AgentUsage;
|
||||
}
|
||||
| { kind: "failure"; messageId: string; error: string }
|
||||
| { kind: "in-progress"; messageId: string; parts: readonly OpenCodePart[] }
|
||||
| null
|
||||
> {
|
||||
const response = await Promise.resolve()
|
||||
.then(() =>
|
||||
this.client.session.messages({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
traceOpenCode("recovery.messages.throw", {
|
||||
error:
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message, stack: error.stack }
|
||||
: String(error),
|
||||
});
|
||||
return null;
|
||||
});
|
||||
if (response === null) {
|
||||
return null;
|
||||
}
|
||||
if (response.error || !response.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let index = response.data.length - 1; index >= 0; index -= 1) {
|
||||
const item = response.data[index];
|
||||
if (!item) continue;
|
||||
const info = item.info;
|
||||
if (info.role !== "assistant") continue;
|
||||
if (this.foregroundKnownMessageIds.has(info.id)) continue;
|
||||
if (typeof info.time?.created === "number" && info.time.created < startedAt) continue;
|
||||
|
||||
if (info.error) {
|
||||
return {
|
||||
kind: "failure",
|
||||
messageId: info.id,
|
||||
error: formatOpenCodeAssistantErrorMessage(info.error),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof info.time?.completed !== "number") {
|
||||
return { kind: "in-progress", messageId: info.id, parts: item.parts };
|
||||
}
|
||||
|
||||
let text = item.parts
|
||||
.filter((part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text")
|
||||
.map((part) => (part.text ?? "").trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.join("\n\n");
|
||||
|
||||
if (!text) {
|
||||
text = stringifyStructuredAssistantMessage(info.structured) ?? "";
|
||||
}
|
||||
if (!text) continue;
|
||||
|
||||
const usage: AgentUsage = {};
|
||||
mergeOpenCodeStepFinishUsage(usage, { cost: info.cost, tokens: info.tokens });
|
||||
|
||||
return { kind: "completion", messageId: info.id, text, parts: item.parts, usage };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private emitIncrementalAssistantParts(parts: readonly OpenCodePart[], turnId: string): void {
|
||||
for (const part of parts) {
|
||||
if (part.type === "reasoning" && part.text) {
|
||||
const emittedTextLength =
|
||||
this.foregroundEmittedReasoningTextLengthByPartId.get(part.id) ?? 0;
|
||||
if (part.text.length <= emittedTextLength) continue;
|
||||
const text = part.text.slice(emittedTextLength);
|
||||
this.foregroundEmittedReasoningTextLengthByPartId.set(part.id, part.text.length);
|
||||
this.notifySubscribers(
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "reasoning", text },
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (part.type !== "tool") continue;
|
||||
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
|
||||
if (!parsedToolPart.success || !parsedToolPart.data) continue;
|
||||
const callId = parsedToolPart.data.callId;
|
||||
const signature = this.createRecoveredToolCallSignature(part, parsedToolPart.data);
|
||||
const lastSignature = this.foregroundEmittedToolCallSignatureByCallId.get(callId);
|
||||
if (lastSignature === signature) continue;
|
||||
this.foregroundEmittedToolCallSignatureByCallId.set(callId, signature);
|
||||
this.trackToolCall(parsedToolPart.data);
|
||||
this.notifySubscribers(
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: parsedToolPart.data,
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private createRecoveredToolCallSignature(
|
||||
part: Extract<OpenCodePart, { type: "tool" }>,
|
||||
item: ToolCallTimelineItem,
|
||||
): string {
|
||||
const state = (part as { state?: { input?: unknown; output?: unknown; error?: unknown } })
|
||||
.state;
|
||||
return JSON.stringify([
|
||||
item.callId,
|
||||
item.status,
|
||||
state?.input ?? null,
|
||||
state?.output ?? null,
|
||||
state?.error ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
private applyRecoveredAssistantCompletion(
|
||||
completion: {
|
||||
messageId: string;
|
||||
text: string;
|
||||
parts: readonly OpenCodePart[];
|
||||
usage: AgentUsage;
|
||||
},
|
||||
turnId: string,
|
||||
): boolean {
|
||||
if (this.activeForegroundTurnId !== turnId) {
|
||||
return false;
|
||||
}
|
||||
this.foregroundKnownMessageIds.add(completion.messageId);
|
||||
|
||||
this.logger.warn(
|
||||
{ sessionId: this.sessionId, turnId },
|
||||
"Recovered OpenCode turn completion via messages API after SSE EOF",
|
||||
);
|
||||
|
||||
const recoveryText = this.resolvePersistedAssistantRecoveryText(completion.text);
|
||||
if (recoveryText === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (recoveryText.length > 0) {
|
||||
this.notifySubscribers(
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "assistant_message", text: recoveryText },
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
this.foregroundAssistantMessageEmitted = true;
|
||||
}
|
||||
|
||||
if (hasNormalizedOpenCodeUsage(completion.usage) && !this.foregroundUsageUpdated) {
|
||||
this.accumulatedUsage = {
|
||||
...this.accumulatedUsage,
|
||||
...completion.usage,
|
||||
};
|
||||
this.notifySubscribers(
|
||||
{
|
||||
type: "usage_updated",
|
||||
provider: "opencode",
|
||||
usage: { ...this.accumulatedUsage },
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
this.foregroundUsageUpdated = true;
|
||||
}
|
||||
|
||||
this.finishForegroundTurn(
|
||||
{
|
||||
type: "turn_completed",
|
||||
provider: "opencode",
|
||||
usage: hasNormalizedOpenCodeUsage(this.accumulatedUsage)
|
||||
? { ...this.accumulatedUsage }
|
||||
: undefined,
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private resolvePersistedAssistantRecoveryText(completedText: string): string | null {
|
||||
if (!this.foregroundAssistantMessageEmitted) {
|
||||
return completedText;
|
||||
}
|
||||
|
||||
if (completedText === this.foregroundAssistantText) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return completedText.startsWith(this.foregroundAssistantText)
|
||||
? completedText.slice(this.foregroundAssistantText.length)
|
||||
: null;
|
||||
}
|
||||
|
||||
private async readPersistedSessionMessageIds(): Promise<Set<string>> {
|
||||
const messageRoot = path.join(this.storageRoot, "message", this.sessionId);
|
||||
const messageFiles = await findJsonFiles(messageRoot);
|
||||
const messageIds = new Set<string>();
|
||||
|
||||
for (const file of messageFiles) {
|
||||
const parsed = await readJsonFile(file, OpenCodeStoredMessageSchema);
|
||||
if (parsed?.sessionID === this.sessionId) {
|
||||
messageIds.add(parsed.id);
|
||||
}
|
||||
}
|
||||
|
||||
return messageIds;
|
||||
}
|
||||
|
||||
private finishForegroundTurn(
|
||||
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
|
||||
turnId: string,
|
||||
): void {
|
||||
traceOpenCode("finishForegroundTurn", {
|
||||
turnId,
|
||||
activeTurnId: this.activeForegroundTurnId,
|
||||
type: event.type,
|
||||
error: event.type === "turn_failed" ? event.error : undefined,
|
||||
reason: event.type === "turn_canceled" ? event.reason : undefined,
|
||||
});
|
||||
if (this.activeForegroundTurnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
@@ -2516,6 +3169,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
} else {
|
||||
this.runningToolCalls.clear();
|
||||
}
|
||||
this.foregroundTurnStartedAt = null;
|
||||
this.activeForegroundTurnId = null;
|
||||
// Abort the SSE connection so the SDK tears down the underlying fetch.
|
||||
this.abortController?.abort();
|
||||
@@ -2561,6 +3215,13 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
|
||||
private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void {
|
||||
const turnId = turnIdOverride ?? this.activeForegroundTurnId;
|
||||
if (event.type === "timeline" && event.item.type === "assistant_message") {
|
||||
this.foregroundAssistantMessageEmitted = true;
|
||||
this.foregroundAssistantText += event.item.text;
|
||||
}
|
||||
if (event.type === "usage_updated") {
|
||||
this.foregroundUsageUpdated = true;
|
||||
}
|
||||
const tagged = turnId ? { ...event, turnId } : event;
|
||||
for (const callback of this.subscribers) {
|
||||
try {
|
||||
|
||||
@@ -41,6 +41,52 @@ describe("paseo daemon bootstrap", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("redacts Agent MCP debug request credentials and bodies", async () => {
|
||||
const logLines: string[] = [];
|
||||
const logger = pino(
|
||||
{ level: "debug" },
|
||||
{
|
||||
write: (line: string) => {
|
||||
logLines.push(line);
|
||||
},
|
||||
},
|
||||
);
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
logger,
|
||||
mcpDebug: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer secret-debug-token",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
apiKey: "secret-body-token",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const logs = logLines.join("\n");
|
||||
expect(logs).toContain("Agent MCP request");
|
||||
expect(logs).toContain("[redacted]");
|
||||
expect(logs).toContain('"method":"tools/call"');
|
||||
expect(logs).toContain('"hasParams":true');
|
||||
expect(logs).not.toContain("secret-debug-token");
|
||||
expect(logs).not.toContain("secret-body-token");
|
||||
expect(logs).not.toContain("apiKey");
|
||||
} finally {
|
||||
await daemonHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("fails fast when OpenAI speech provider is configured without credentials", async () => {
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-openai-config-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import express from "express";
|
||||
import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http";
|
||||
import { createReadStream, unlinkSync, existsSync } from "fs";
|
||||
import { stat } from "fs/promises";
|
||||
import { constants, existsSync, unlinkSync } from "fs";
|
||||
import { open } from "fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hostname as getHostname } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -119,6 +119,7 @@ import { createConfiguredTerminalManager } from "../terminal/terminal-manager-fa
|
||||
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
|
||||
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
|
||||
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
|
||||
import type { PushNotificationSender } from "./push/notifications.js";
|
||||
import { getOrCreateServerId } from "./server-id.js";
|
||||
import { resolveDaemonVersion } from "./daemon-version.js";
|
||||
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
@@ -140,6 +141,11 @@ import { createRequireBearerMiddleware, type DaemonAuthConfig } from "./auth.js"
|
||||
|
||||
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
|
||||
|
||||
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
|
||||
const REDACTED_LOG_VALUE = "[redacted]";
|
||||
const DOWNLOAD_OPEN_FLAGS =
|
||||
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
|
||||
|
||||
function formatHostForHttpUrl(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
@@ -154,6 +160,38 @@ function createAgentMcpBaseUrl(listenTarget: ListenTarget | null): string | null
|
||||
).toString();
|
||||
}
|
||||
|
||||
function summarizeAgentMcpDebugMessage(body: unknown): Record<string, unknown> {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
return {
|
||||
type: body === null ? "null" : typeof body,
|
||||
};
|
||||
}
|
||||
|
||||
const record = body as Record<string, unknown>;
|
||||
const method = typeof record.method === "string" ? record.method : undefined;
|
||||
return {
|
||||
type: "object",
|
||||
...(typeof record.jsonrpc === "string" ? { jsonrpc: record.jsonrpc } : {}),
|
||||
...(method ? { method } : {}),
|
||||
hasId: Object.prototype.hasOwnProperty.call(record, "id"),
|
||||
hasParams: Object.prototype.hasOwnProperty.call(record, "params"),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeAgentMcpDebugBody(body: unknown): Record<string, unknown> {
|
||||
if (!Array.isArray(body)) {
|
||||
return summarizeAgentMcpDebugMessage(body);
|
||||
}
|
||||
|
||||
const messages = body.slice(0, MAX_MCP_DEBUG_BATCH_ITEMS).map(summarizeAgentMcpDebugMessage);
|
||||
return {
|
||||
type: "batch",
|
||||
count: body.length,
|
||||
messages,
|
||||
...(body.length > messages.length ? { omitted: body.length - messages.length } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig;
|
||||
export type PaseoLocalSpeechConfig = LocalSpeechProviderConfig;
|
||||
|
||||
@@ -205,6 +243,7 @@ export interface PaseoDaemonConfig {
|
||||
providerOverrides?: Record<string, ProviderOverride>;
|
||||
log?: PersistedConfig["log"];
|
||||
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
|
||||
pushNotificationSender?: PushNotificationSender;
|
||||
}
|
||||
|
||||
export interface PaseoDaemon {
|
||||
@@ -378,8 +417,10 @@ export async function createPaseoDaemon(
|
||||
return;
|
||||
}
|
||||
|
||||
let fileHandle: Awaited<ReturnType<typeof open>> | null = null;
|
||||
try {
|
||||
const fileStats = await stat(entry.absolutePath);
|
||||
fileHandle = await open(entry.absolutePath, DOWNLOAD_OPEN_FLAGS);
|
||||
const fileStats = await fileHandle.stat();
|
||||
if (!fileStats.isFile()) {
|
||||
res.status(404).json({ error: "File not found" });
|
||||
return;
|
||||
@@ -388,9 +429,10 @@ export async function createPaseoDaemon(
|
||||
const safeFileName = entry.fileName.replace(/["\r\n]/g, "_");
|
||||
res.setHeader("Content-Type", entry.mimeType);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`);
|
||||
res.setHeader("Content-Length", entry.size.toString());
|
||||
res.setHeader("Content-Length", fileStats.size.toString());
|
||||
|
||||
const stream = createReadStream(entry.absolutePath);
|
||||
const stream = fileHandle.createReadStream();
|
||||
fileHandle = null;
|
||||
stream.on("error", (err) => {
|
||||
logger.error({ err }, "Failed to stream download");
|
||||
if (!res.headersSent) {
|
||||
@@ -405,6 +447,8 @@ export async function createPaseoDaemon(
|
||||
if (!res.headersSent) {
|
||||
res.status(404).json({ error: "File not found" });
|
||||
}
|
||||
} finally {
|
||||
await fileHandle?.close().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -669,8 +713,8 @@ export async function createPaseoDaemon(
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
sessionId: req.header("mcp-session-id"),
|
||||
authorization: req.header("authorization"),
|
||||
body: req.body,
|
||||
authorization: req.header("authorization") ? REDACTED_LOG_VALUE : undefined,
|
||||
body: summarizeAgentMcpDebugBody(req.body),
|
||||
},
|
||||
"Agent MCP request",
|
||||
);
|
||||
@@ -842,6 +886,7 @@ export async function createPaseoDaemon(
|
||||
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
|
||||
workspaceGitService,
|
||||
github,
|
||||
config.pushNotificationSender,
|
||||
);
|
||||
|
||||
if (relayEnabled) {
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||
import type { AgentStreamEventPayload } from "../shared/messages.js";
|
||||
import type { AgentSnapshotPayload } from "./messages.js";
|
||||
import { PushService } from "./push/push-service.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
|
||||
import { PRESENCE_THRESHOLD_MS } from "./agent-attention-policy.js";
|
||||
|
||||
class RecordingPushNotificationSender implements PushNotificationSender {
|
||||
readonly sent: PushPayload[] = [];
|
||||
|
||||
async send(payload: PushPayload): Promise<void> {
|
||||
this.sent.push(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests for client activity tracking and smart notifications.
|
||||
*
|
||||
@@ -31,23 +38,17 @@ describe("client activity tracking", () => {
|
||||
let daemon: TestPaseoDaemon;
|
||||
let client1: DaemonClient;
|
||||
let client2: DaemonClient;
|
||||
let sendPushSpy: ReturnType<typeof vi.spyOn>;
|
||||
let getAllTokensSpy: ReturnType<typeof vi.spyOn>;
|
||||
let pushNotifications: RecordingPushNotificationSender;
|
||||
|
||||
beforeEach(async () => {
|
||||
sendPushSpy = vi.spyOn(PushService.prototype, "sendPush").mockResolvedValue(undefined);
|
||||
getAllTokensSpy = vi
|
||||
.spyOn(PushTokenStore.prototype, "getAllTokens")
|
||||
.mockReturnValue(["ExponentPushToken[activity-test]"]);
|
||||
daemon = await createTestPaseoDaemon();
|
||||
pushNotifications = new RecordingPushNotificationSender();
|
||||
daemon = await createTestPaseoDaemon({ pushNotificationSender: pushNotifications });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (client1) await client1.close().catch(() => {});
|
||||
if (client2) await client2.close().catch(() => {});
|
||||
await daemon.close();
|
||||
sendPushSpy.mockRestore();
|
||||
getAllTokensSpy.mockRestore();
|
||||
}, 30000);
|
||||
|
||||
async function createClient(): Promise<DaemonClient> {
|
||||
@@ -205,7 +206,7 @@ describe("client activity tracking", () => {
|
||||
|
||||
expect(attention.reason).toBe("finished");
|
||||
expect(attention.shouldNotify).toBe(false);
|
||||
expect(sendPushSpy).toHaveBeenCalledTimes(1);
|
||||
expect(pushNotifications.sent).toHaveLength(1);
|
||||
}, 120000);
|
||||
|
||||
test("notification when no heartbeat received (legacy/new client)", async () => {
|
||||
@@ -225,7 +226,7 @@ describe("client activity tracking", () => {
|
||||
|
||||
expect(attention.reason).toBe("finished");
|
||||
expect(attention.shouldNotify).toBe(false);
|
||||
expect(sendPushSpy).toHaveBeenCalledTimes(1);
|
||||
expect(pushNotifications.sent).toHaveLength(1);
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
@@ -309,7 +310,7 @@ describe("client activity tracking", () => {
|
||||
// No stale client is selected for in-app; push handles the fallback.
|
||||
expect(attention1.shouldNotify).toBe(false);
|
||||
expect(attention2.shouldNotify).toBe(false);
|
||||
expect(sendPushSpy).toHaveBeenCalledTimes(1);
|
||||
expect(pushNotifications.sent).toHaveLength(1);
|
||||
}, 120000);
|
||||
|
||||
test("notifies only the present Electron-style web client when Firefox is stale", async () => {
|
||||
@@ -345,7 +346,7 @@ describe("client activity tracking", () => {
|
||||
|
||||
expect(attention1.shouldNotify).toBe(false);
|
||||
expect(attention2.shouldNotify).toBe(true);
|
||||
expect(sendPushSpy).not.toHaveBeenCalled();
|
||||
expect(pushNotifications.sent).toEqual([]);
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
@@ -465,7 +466,7 @@ describe("client activity tracking", () => {
|
||||
|
||||
expect(attention1.shouldNotify).toBe(false);
|
||||
expect(attention2.shouldNotify).toBe(true);
|
||||
expect(sendPushSpy).not.toHaveBeenCalled();
|
||||
expect(pushNotifications.sent).toEqual([]);
|
||||
}, 120000);
|
||||
|
||||
test("notify web when user active on web but looking at different agent", async () => {
|
||||
@@ -543,7 +544,7 @@ describe("client activity tracking", () => {
|
||||
|
||||
expect(attention1.shouldNotify).toBe(false);
|
||||
expect(attention2.shouldNotify).toBe(false);
|
||||
expect(sendPushSpy).toHaveBeenCalledTimes(1);
|
||||
expect(pushNotifications.sent).toHaveLength(1);
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
@@ -581,7 +582,7 @@ describe("client activity tracking", () => {
|
||||
|
||||
expect(attention1.shouldNotify).toBe(false);
|
||||
expect(attention2.shouldNotify).toBe(false);
|
||||
expect(sendPushSpy).toHaveBeenCalledTimes(1);
|
||||
expect(pushNotifications.sent).toHaveLength(1);
|
||||
}, 120000);
|
||||
|
||||
test("notification when app not visible but activity is recent", async () => {
|
||||
@@ -647,7 +648,7 @@ describe("client activity tracking", () => {
|
||||
|
||||
expect(attention1.shouldNotify).toBe(true);
|
||||
expect(attention2.shouldNotify).toBe(false);
|
||||
expect(sendPushSpy).not.toHaveBeenCalled();
|
||||
expect(pushNotifications.sent).toEqual([]);
|
||||
}, 120000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,16 +13,6 @@ function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-"));
|
||||
}
|
||||
|
||||
function pickOpenCodeModel(
|
||||
models: Array<{ id: string }>,
|
||||
preferences: string[] = ["gpt-5-nano", "gpt-4.1-nano", "mini", "free"],
|
||||
): string {
|
||||
const preferred = models.find((model) =>
|
||||
preferences.some((fragment) => model.id.includes(fragment)),
|
||||
);
|
||||
return preferred?.id ?? models[0].id;
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<{
|
||||
client: DaemonClient;
|
||||
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>>;
|
||||
@@ -63,7 +53,7 @@ describe("daemon E2E (real opencode) - plan mode and clarifying questions", () =
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "OpenCode question regression",
|
||||
model: pickOpenCodeModel(modelList.models, ["minimax-m2.5-free", "minimax", "free"]),
|
||||
model: "opencode/big-pickle",
|
||||
modeId: "plan",
|
||||
});
|
||||
|
||||
@@ -111,7 +101,7 @@ describe("daemon E2E (real opencode) - plan mode and clarifying questions", () =
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "OpenCode plan mode regression",
|
||||
model: pickOpenCodeModel(modelList.models),
|
||||
model: "opencode/big-pickle",
|
||||
modeId: "plan",
|
||||
});
|
||||
|
||||
|
||||
@@ -22,23 +22,6 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function pickOpenCodeModel(
|
||||
models: Array<{ id: string }>,
|
||||
preferences: string[] = [
|
||||
"minimax-m2.5-free",
|
||||
"kimi-k2.5-free",
|
||||
"glm-5-free",
|
||||
"free",
|
||||
"mini",
|
||||
"gpt-5-nano",
|
||||
],
|
||||
): string {
|
||||
const preferred = models.find((model) =>
|
||||
preferences.some((fragment) => model.id.includes(fragment)),
|
||||
);
|
||||
return preferred?.id ?? models[0].id;
|
||||
}
|
||||
|
||||
function hasRunningBashToolCall(messages: SessionOutboundMessage[], agentId: string): boolean {
|
||||
return messages.some(
|
||||
(message) =>
|
||||
@@ -305,8 +288,8 @@ describe("daemon E2E (real opencode) - send while working and interrupt", () =>
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "OpenCode send while working",
|
||||
model: pickOpenCodeModel(modelList.models),
|
||||
modeId: "default",
|
||||
model: "opencode/big-pickle",
|
||||
modeId: "build",
|
||||
});
|
||||
|
||||
await client.sendMessage(
|
||||
@@ -365,8 +348,8 @@ describe("daemon E2E (real opencode) - send while working and interrupt", () =>
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "OpenCode explicit interrupt",
|
||||
model: pickOpenCodeModel(modelList.models),
|
||||
modeId: "default",
|
||||
model: "opencode/big-pickle",
|
||||
modeId: "build",
|
||||
});
|
||||
|
||||
await client.sendMessage(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// POSIX-only: symlink fixtures
|
||||
/* eslint-disable max-nested-callbacks */
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { listDirectoryEntries, readExplorerFile } from "./service.js";
|
||||
import { getDownloadableFileInfo, listDirectoryEntries, readExplorerFile } from "./service.js";
|
||||
import { isPlatform } from "../../test-utils/platform.js";
|
||||
|
||||
async function createTempDir(prefix: string): Promise<string> {
|
||||
@@ -55,4 +55,53 @@ describe.skipIf(isPlatform("win32"))("service POSIX-only", () => {
|
||||
await rm(outsideRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips listed symlink entries that resolve outside the workspace", async () => {
|
||||
const root = await createTempDir("paseo-file-explorer-");
|
||||
const outsideRoot = await createTempDir("paseo-file-explorer-outside-");
|
||||
|
||||
try {
|
||||
await writeFile(path.join(root, "visible.txt"), "visible\n", "utf-8");
|
||||
const externalFile = path.join(outsideRoot, "secret.txt");
|
||||
await writeFile(externalFile, "top secret\n", "utf-8");
|
||||
await symlink(externalFile, path.join(root, "secret-link.txt"));
|
||||
|
||||
const result = await listDirectoryEntries({ root });
|
||||
|
||||
const names = result.entries.map((entry) => entry.name);
|
||||
expect(names).toContain("visible.txt");
|
||||
expect(names).not.toContain("secret-link.txt");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(outsideRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses canonical paths for downloadable symlink targets inside the workspace", async () => {
|
||||
const root = await createTempDir("paseo-file-explorer-");
|
||||
|
||||
try {
|
||||
const target = path.join(root, "safe.txt");
|
||||
const link = path.join(root, "safe-link.txt");
|
||||
await writeFile(target, "safe\n", "utf-8");
|
||||
await symlink("safe.txt", link);
|
||||
|
||||
const file = await readExplorerFile({
|
||||
root,
|
||||
relativePath: "safe-link.txt",
|
||||
});
|
||||
const info = await getDownloadableFileInfo({
|
||||
root,
|
||||
relativePath: "safe-link.txt",
|
||||
});
|
||||
|
||||
expect(file.path).toBe("safe-link.txt");
|
||||
expect(file.content).toBe("safe\n");
|
||||
expect(info.path).toBe("safe-link.txt");
|
||||
expect(info.fileName).toBe("safe-link.txt");
|
||||
expect(info.absolutePath).toBe(await realpath(target));
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { promises as fs } from "fs";
|
||||
import { constants, promises as fs } from "fs";
|
||||
import type { FileHandle } from "fs/promises";
|
||||
import path from "path";
|
||||
import { resolvePathFromBase } from "../path-utils.js";
|
||||
|
||||
@@ -54,6 +55,10 @@ const TEXT_MIME_TYPES: Record<string, string> = {
|
||||
};
|
||||
|
||||
const DEFAULT_TEXT_MIME_TYPE = "text/plain";
|
||||
const FILE_TYPE_SAMPLE_BYTES = 8192;
|
||||
const READ_FILE_OPEN_FLAGS =
|
||||
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
|
||||
const ACCESS_OUTSIDE_WORKSPACE_MESSAGE = "Access outside of workspace is not allowed";
|
||||
|
||||
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
@@ -69,6 +74,11 @@ interface ScopedPathParams {
|
||||
relativePath?: string;
|
||||
}
|
||||
|
||||
interface ScopedPath {
|
||||
requestedPath: string;
|
||||
resolvedPath: string;
|
||||
}
|
||||
|
||||
interface EntryPayloadParams {
|
||||
root: string;
|
||||
targetPath: string;
|
||||
@@ -81,17 +91,17 @@ export async function listDirectoryEntries({
|
||||
relativePath = ".",
|
||||
}: ListDirectoryParams): Promise<FileExplorerDirectory> {
|
||||
const directoryPath = await resolveScopedPath({ root, relativePath });
|
||||
const stats = await fs.stat(directoryPath);
|
||||
const stats = await fs.stat(directoryPath.resolvedPath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error("Requested path is not a directory");
|
||||
}
|
||||
|
||||
const dirents = await fs.readdir(directoryPath, { withFileTypes: true });
|
||||
const dirents = await fs.readdir(directoryPath.resolvedPath, { withFileTypes: true });
|
||||
|
||||
const entriesWithNulls = await Promise.all(
|
||||
dirents.map(async (dirent) => {
|
||||
const targetPath = path.join(directoryPath, dirent.name);
|
||||
const targetPath = path.join(directoryPath.requestedPath, dirent.name);
|
||||
const kind: ExplorerEntryKind = dirent.isDirectory() ? "directory" : "file";
|
||||
try {
|
||||
return await buildEntryPayload({
|
||||
@@ -103,7 +113,7 @@ export async function listDirectoryEntries({
|
||||
} catch (error) {
|
||||
// Directories can contain dangling links (e.g. AGENTS.md -> CLAUDE.md).
|
||||
// Skip entries whose targets disappeared instead of failing the whole listing.
|
||||
if (isMissingEntryError(error)) {
|
||||
if (isMissingEntryError(error) || isOutsideWorkspaceError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
@@ -121,7 +131,7 @@ export async function listDirectoryEntries({
|
||||
});
|
||||
|
||||
return {
|
||||
path: normalizeRelativePath({ root, targetPath: directoryPath }),
|
||||
path: normalizeRelativePath({ root, targetPath: directoryPath.requestedPath }),
|
||||
entries,
|
||||
};
|
||||
}
|
||||
@@ -171,48 +181,53 @@ export async function readExplorerFileBytes({
|
||||
relativePath,
|
||||
}: ReadFileParams): Promise<FileExplorerFileBytes> {
|
||||
const filePath = await resolveScopedPath({ root, relativePath });
|
||||
const stats = await fs.stat(filePath);
|
||||
const handle = await openFileForRead(filePath.resolvedPath);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
throw new Error("Requested path is not a file");
|
||||
}
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const basePayload = {
|
||||
path: normalizeRelativePath({ root, targetPath: filePath }),
|
||||
size: stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
};
|
||||
if (!stats.isFile()) {
|
||||
throw new Error("Requested path is not a file");
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath.resolvedPath).toLowerCase();
|
||||
const basePayload = {
|
||||
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
|
||||
size: stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
};
|
||||
|
||||
const buffer = await handle.readFile();
|
||||
if (ext in IMAGE_MIME_TYPES) {
|
||||
return {
|
||||
...basePayload,
|
||||
kind: "image",
|
||||
encoding: "binary",
|
||||
bytes: buffer,
|
||||
mimeType: IMAGE_MIME_TYPES[ext],
|
||||
};
|
||||
}
|
||||
|
||||
if (isLikelyBinary(buffer)) {
|
||||
return {
|
||||
...basePayload,
|
||||
kind: "binary",
|
||||
encoding: "binary",
|
||||
bytes: buffer,
|
||||
mimeType: "application/octet-stream",
|
||||
};
|
||||
}
|
||||
|
||||
if (ext in IMAGE_MIME_TYPES) {
|
||||
const buffer = await fs.readFile(filePath);
|
||||
return {
|
||||
...basePayload,
|
||||
kind: "image",
|
||||
encoding: "binary",
|
||||
kind: "text",
|
||||
encoding: "utf-8",
|
||||
bytes: buffer,
|
||||
mimeType: IMAGE_MIME_TYPES[ext],
|
||||
mimeType: textMimeTypeForExtension(ext),
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
const buffer = await fs.readFile(filePath);
|
||||
if (isLikelyBinary(buffer)) {
|
||||
return {
|
||||
...basePayload,
|
||||
kind: "binary",
|
||||
encoding: "binary",
|
||||
bytes: buffer,
|
||||
mimeType: "application/octet-stream",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...basePayload,
|
||||
kind: "text",
|
||||
encoding: "utf-8",
|
||||
bytes: buffer,
|
||||
mimeType: textMimeTypeForExtension(ext),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDownloadableFileInfo({ root, relativePath }: ReadFileParams): Promise<{
|
||||
@@ -223,47 +238,50 @@ export async function getDownloadableFileInfo({ root, relativePath }: ReadFilePa
|
||||
size: number;
|
||||
}> {
|
||||
const filePath = await resolveScopedPath({ root, relativePath });
|
||||
const stats = await fs.stat(filePath);
|
||||
const handle = await openFileForRead(filePath.resolvedPath);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
throw new Error("Requested path is not a file");
|
||||
}
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
let mimeType = "application/octet-stream";
|
||||
if (ext in IMAGE_MIME_TYPES) {
|
||||
mimeType = IMAGE_MIME_TYPES[ext];
|
||||
} else {
|
||||
// Read only a small prefix to classify likely text vs binary.
|
||||
const handle = await fs.open(filePath, "r");
|
||||
const sample = Buffer.alloc(8192);
|
||||
try {
|
||||
if (!stats.isFile()) {
|
||||
throw new Error("Requested path is not a file");
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath.resolvedPath).toLowerCase();
|
||||
let mimeType = "application/octet-stream";
|
||||
if (ext in IMAGE_MIME_TYPES) {
|
||||
mimeType = IMAGE_MIME_TYPES[ext];
|
||||
} else {
|
||||
const sample = Buffer.alloc(FILE_TYPE_SAMPLE_BYTES);
|
||||
const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
|
||||
const chunk = bytesRead < sample.length ? sample.subarray(0, bytesRead) : sample;
|
||||
if (!isLikelyBinary(chunk)) {
|
||||
mimeType = textMimeTypeForExtension(ext);
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: normalizeRelativePath({ root, targetPath: filePath }),
|
||||
absolutePath: filePath,
|
||||
fileName: path.basename(filePath),
|
||||
mimeType,
|
||||
size: stats.size,
|
||||
};
|
||||
return {
|
||||
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
|
||||
absolutePath: filePath.resolvedPath,
|
||||
fileName: path.basename(filePath.requestedPath),
|
||||
mimeType,
|
||||
size: stats.size,
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveScopedPath({ root, relativePath = "." }: ScopedPathParams): Promise<string> {
|
||||
async function resolveScopedPath({
|
||||
root,
|
||||
relativePath = ".",
|
||||
}: ScopedPathParams): Promise<ScopedPath> {
|
||||
const normalizedRoot = path.resolve(root);
|
||||
const requestedPath = resolvePathFromBase(normalizedRoot, relativePath);
|
||||
const relative = path.relative(normalizedRoot, requestedPath);
|
||||
|
||||
if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) {
|
||||
throw new Error("Access outside of workspace is not allowed");
|
||||
throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE);
|
||||
}
|
||||
|
||||
const realRoot = await fs.realpath(normalizedRoot);
|
||||
@@ -272,24 +290,32 @@ async function resolveScopedPath({ root, relativePath = "." }: ScopedPathParams)
|
||||
const realPath = await fs.realpath(requestedPath);
|
||||
const realRelative = path.relative(realRoot, realPath);
|
||||
if (realRelative !== "" && (realRelative.startsWith("..") || path.isAbsolute(realRelative))) {
|
||||
throw new Error("Access outside of workspace is not allowed");
|
||||
throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE);
|
||||
}
|
||||
return requestedPath;
|
||||
return { requestedPath, resolvedPath: realPath };
|
||||
} catch (error) {
|
||||
if (isMissingEntryError(error)) {
|
||||
return requestedPath;
|
||||
return { requestedPath, resolvedPath: requestedPath };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function openFileForRead(filePath: string): Promise<FileHandle> {
|
||||
return fs.open(filePath, READ_FILE_OPEN_FLAGS);
|
||||
}
|
||||
|
||||
async function buildEntryPayload({
|
||||
root,
|
||||
targetPath,
|
||||
name,
|
||||
kind,
|
||||
}: EntryPayloadParams): Promise<FileExplorerEntry> {
|
||||
const stats = await fs.stat(targetPath);
|
||||
const entryPath = await resolveScopedPath({
|
||||
root,
|
||||
relativePath: normalizeRelativePath({ root, targetPath }),
|
||||
});
|
||||
const stats = await fs.stat(entryPath.resolvedPath);
|
||||
return {
|
||||
name,
|
||||
path: normalizeRelativePath({ root, targetPath }),
|
||||
@@ -304,6 +330,10 @@ function isMissingEntryError(error: unknown): boolean {
|
||||
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
|
||||
}
|
||||
|
||||
function isOutsideWorkspaceError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === ACCESS_OUTSIDE_WORKSPACE_MESSAGE;
|
||||
}
|
||||
|
||||
function normalizeRelativePath({ root, targetPath }: { root: string; targetPath: string }): string {
|
||||
const normalizedRoot = path.resolve(root);
|
||||
const normalizedTarget = path.resolve(targetPath);
|
||||
|
||||
29
packages/server/src/server/push/notifications.ts
Normal file
29
packages/server/src/server/push/notifications.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type pino from "pino";
|
||||
|
||||
import { PushService, type PushPayload } from "./push-service.js";
|
||||
import type { PushTokenStore } from "./token-store.js";
|
||||
|
||||
export type { PushPayload };
|
||||
|
||||
export interface PushNotificationSender {
|
||||
send(payload: PushPayload): Promise<void>;
|
||||
}
|
||||
|
||||
export function createPushNotificationSender(
|
||||
logger: pino.Logger,
|
||||
tokenStore: PushTokenStore,
|
||||
): PushNotificationSender {
|
||||
const pushService = new PushService(logger, tokenStore);
|
||||
|
||||
return {
|
||||
async send(payload) {
|
||||
const tokens = tokenStore.getAllTokens();
|
||||
logger.info({ tokenCount: tokens.length }, "Sending push notification");
|
||||
if (tokens.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pushService.sendPush(tokens, payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PushTokenStore } from "./token-store.js";
|
||||
import type pino from "pino";
|
||||
|
||||
interface PushPayload {
|
||||
export interface PushPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, unknown>;
|
||||
|
||||
@@ -1,124 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const wsMock = vi.hoisted(() => {
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
static instances: MockWebSocket[] = [];
|
||||
|
||||
readonly url: string;
|
||||
readonly options: unknown;
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
sent: string[] = [];
|
||||
terminateCalls = 0;
|
||||
private listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
|
||||
constructor(url: string, options?: unknown) {
|
||||
this.url = url;
|
||||
this.options = options;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
static reset() {
|
||||
MockWebSocket.instances = [];
|
||||
}
|
||||
|
||||
on(event: string, listener: (...args: unknown[]) => void) {
|
||||
const handlers = this.listeners.get(event) ?? [];
|
||||
handlers.push(listener);
|
||||
this.listeners.set(event, handlers);
|
||||
return this;
|
||||
}
|
||||
|
||||
once(event: string, listener: (...args: unknown[]) => void) {
|
||||
const wrapped = (...args: unknown[]) => {
|
||||
this.off(event, wrapped);
|
||||
listener(...args);
|
||||
};
|
||||
return this.on(event, wrapped);
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string) {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.emit("close", code ?? 1000, reason ?? "");
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.terminateCalls += 1;
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.emit("close", 1006, "");
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
if (this.readyState !== MockWebSocket.OPEN) {
|
||||
throw new Error(`WebSocket not open (readyState=${this.readyState})`);
|
||||
}
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
this.emit("open");
|
||||
}
|
||||
|
||||
message(data: unknown) {
|
||||
this.emit("message", data);
|
||||
}
|
||||
|
||||
error(err: unknown) {
|
||||
this.emit("error", err);
|
||||
}
|
||||
|
||||
private off(event: string, listener: (...args: unknown[]) => void) {
|
||||
const handlers = this.listeners.get(event) ?? [];
|
||||
this.listeners.set(
|
||||
event,
|
||||
handlers.filter((handler) => handler !== listener),
|
||||
);
|
||||
}
|
||||
|
||||
private emit(event: string, ...args: unknown[]) {
|
||||
const handlers = this.listeners.get(event) ?? [];
|
||||
for (const handler of handlers.slice()) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { MockWebSocket };
|
||||
});
|
||||
|
||||
vi.mock("ws", () => ({
|
||||
default: wsMock.MockWebSocket,
|
||||
WebSocket: wsMock.MockWebSocket,
|
||||
}));
|
||||
|
||||
import type pino from "pino";
|
||||
import { startRelayTransport } from "./relay-transport";
|
||||
|
||||
function createMockLogger() {
|
||||
const messages: { level: "debug" | "info" | "warn" | "error"; args: unknown[] }[] = [];
|
||||
const logger = {
|
||||
child: vi.fn(() => logger),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
messages,
|
||||
child: () => logger,
|
||||
debug: (...args: unknown[]) => messages.push({ level: "debug", args }),
|
||||
info: (...args: unknown[]) => messages.push({ level: "info", args }),
|
||||
warn: (...args: unknown[]) => messages.push({ level: "warn", args }),
|
||||
error: (...args: unknown[]) => messages.push({ level: "error", args }),
|
||||
};
|
||||
return logger;
|
||||
}
|
||||
|
||||
function hasLogMessage(mockFn: ReturnType<typeof vi.fn>, message: string): boolean {
|
||||
return mockFn.mock.calls.some((call) => call.some((arg) => arg === message));
|
||||
type TestLogger = ReturnType<typeof createMockLogger>;
|
||||
|
||||
function hasLogMessage(logger: TestLogger, level: "info" | "warn", message: string): boolean {
|
||||
return logger.messages.some((entry) => {
|
||||
return entry.level === level && entry.args.some((arg) => arg === message);
|
||||
});
|
||||
}
|
||||
|
||||
class FakeRelayWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
readyState = FakeRelayWebSocket.CONNECTING;
|
||||
sent: Array<string | Uint8Array | ArrayBuffer> = [];
|
||||
terminateCalls = 0;
|
||||
private readonly listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
|
||||
constructor(readonly url: string) {}
|
||||
|
||||
on(event: string, listener: (...args: unknown[]) => void) {
|
||||
const handlers = this.listeners.get(event) ?? [];
|
||||
handlers.push(listener);
|
||||
this.listeners.set(event, handlers);
|
||||
}
|
||||
|
||||
once(event: string, listener: (...args: unknown[]) => void) {
|
||||
const wrapped = (...args: unknown[]) => {
|
||||
this.off(event, wrapped);
|
||||
listener(...args);
|
||||
};
|
||||
this.on(event, wrapped);
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string) {
|
||||
this.readyState = FakeRelayWebSocket.CLOSED;
|
||||
this.emit("close", code ?? 1000, reason ?? "");
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.terminateCalls += 1;
|
||||
this.readyState = FakeRelayWebSocket.CLOSED;
|
||||
this.emit("close", 1006, "");
|
||||
}
|
||||
|
||||
send(data: string | Uint8Array | ArrayBuffer) {
|
||||
if (this.readyState !== FakeRelayWebSocket.OPEN) {
|
||||
throw new Error(`WebSocket not open (readyState=${this.readyState})`);
|
||||
}
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = FakeRelayWebSocket.OPEN;
|
||||
this.emit("open");
|
||||
}
|
||||
|
||||
message(data: unknown) {
|
||||
this.emit("message", data);
|
||||
}
|
||||
|
||||
private off(event: string, listener: (...args: unknown[]) => void) {
|
||||
const handlers = this.listeners.get(event) ?? [];
|
||||
this.listeners.set(
|
||||
event,
|
||||
handlers.filter((handler) => handler !== listener),
|
||||
);
|
||||
}
|
||||
|
||||
private emit(event: string, ...args: unknown[]) {
|
||||
const handlers = this.listeners.get(event) ?? [];
|
||||
for (const handler of handlers.slice()) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createFakeWebSockets() {
|
||||
const sockets: FakeRelayWebSocket[] = [];
|
||||
return {
|
||||
sockets,
|
||||
createWebSocket(url: string) {
|
||||
const socket = new FakeRelayWebSocket(url);
|
||||
sockets.push(socket);
|
||||
return socket;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("relay-transport control lifecycle", () => {
|
||||
const controllers: Array<{ stop: () => Promise<void> }> = [];
|
||||
const MockWebSocket = wsMock.MockWebSocket;
|
||||
let relay: ReturnType<typeof createFakeWebSockets>;
|
||||
|
||||
beforeEach(() => {
|
||||
MockWebSocket.reset();
|
||||
relay = createFakeWebSockets();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -135,18 +126,19 @@ describe("relay-transport control lifecycle", () => {
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
relayUseTls: true,
|
||||
serverId: "srv_test",
|
||||
createWebSocket: relay.createWebSocket,
|
||||
});
|
||||
controllers.push(controller);
|
||||
|
||||
const control = MockWebSocket.instances[0];
|
||||
const control = relay.sockets[0];
|
||||
expect(control).toBeDefined();
|
||||
|
||||
control.open();
|
||||
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(false);
|
||||
expect(hasLogMessage(logger, "info", "relay_control_connected")).toBe(false);
|
||||
expect(control.sent.length).toBeGreaterThan(0);
|
||||
|
||||
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
|
||||
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(true);
|
||||
expect(hasLogMessage(logger, "info", "relay_control_connected")).toBe(true);
|
||||
});
|
||||
|
||||
test("terminates and reconnects when control socket opens but never becomes ready", () => {
|
||||
@@ -158,18 +150,19 @@ describe("relay-transport control lifecycle", () => {
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
relayUseTls: true,
|
||||
serverId: "srv_test",
|
||||
createWebSocket: relay.createWebSocket,
|
||||
});
|
||||
controllers.push(controller);
|
||||
|
||||
const firstControl = MockWebSocket.instances[0];
|
||||
const firstControl = relay.sockets[0];
|
||||
firstControl.open();
|
||||
|
||||
vi.advanceTimersByTime(8_000);
|
||||
expect(hasLogMessage(logger.warn, "relay_control_ready_timeout_terminating")).toBe(true);
|
||||
expect(hasLogMessage(logger, "warn", "relay_control_ready_timeout_terminating")).toBe(true);
|
||||
expect(firstControl.terminateCalls).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
||||
expect(relay.sockets.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("terminates stale control sockets in under one minute", () => {
|
||||
@@ -181,47 +174,56 @@ describe("relay-transport control lifecycle", () => {
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
relayUseTls: true,
|
||||
serverId: "srv_test",
|
||||
createWebSocket: relay.createWebSocket,
|
||||
});
|
||||
controllers.push(controller);
|
||||
|
||||
const control = MockWebSocket.instances[0];
|
||||
const control = relay.sockets[0];
|
||||
control.open();
|
||||
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
|
||||
logger.warn.mockClear();
|
||||
logger.messages.length = 0;
|
||||
|
||||
vi.advanceTimersByTime(40_000);
|
||||
expect(hasLogMessage(logger.warn, "relay_control_stale_terminating")).toBe(true);
|
||||
expect(hasLogMessage(logger, "warn", "relay_control_stale_terminating")).toBe(true);
|
||||
expect(control.terminateCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("passes stable relay external session metadata when attaching data socket", async () => {
|
||||
const logger = createMockLogger();
|
||||
const attachSocket = vi.fn(async () => {});
|
||||
const attachedSockets: unknown[] = [];
|
||||
const attachedMetadata: unknown[] = [];
|
||||
const attachSocket = async (socket: unknown, metadata: unknown) => {
|
||||
attachedSockets.push(socket);
|
||||
attachedMetadata.push(metadata);
|
||||
};
|
||||
const controller = startRelayTransport({
|
||||
logger: logger as unknown as pino.Logger,
|
||||
attachSocket,
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
relayUseTls: true,
|
||||
serverId: "srv_test",
|
||||
createWebSocket: relay.createWebSocket,
|
||||
});
|
||||
controllers.push(controller);
|
||||
|
||||
const control = MockWebSocket.instances[0];
|
||||
const control = relay.sockets[0];
|
||||
control.open();
|
||||
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
|
||||
control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }));
|
||||
|
||||
const dataSocket = MockWebSocket.instances[1];
|
||||
const dataSocket = relay.sockets[1];
|
||||
expect(dataSocket).toBeDefined();
|
||||
dataSocket.open();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(attachSocket).toHaveBeenCalledTimes(1);
|
||||
expect(attachSocket).toHaveBeenCalledWith(dataSocket, {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:clt_test",
|
||||
});
|
||||
expect(attachedSockets).toEqual([dataSocket]);
|
||||
expect(attachedMetadata).toEqual([
|
||||
{
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:clt_test",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses relayUseTls for control and data socket URLs", () => {
|
||||
@@ -232,15 +234,16 @@ describe("relay-transport control lifecycle", () => {
|
||||
relayEndpoint: "[::1]:443",
|
||||
relayUseTls: true,
|
||||
serverId: "srv_test",
|
||||
createWebSocket: relay.createWebSocket,
|
||||
});
|
||||
controllers.push(controller);
|
||||
|
||||
const control = MockWebSocket.instances[0];
|
||||
const control = relay.sockets[0];
|
||||
control.open();
|
||||
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
|
||||
control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }));
|
||||
|
||||
expect(MockWebSocket.instances[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
|
||||
expect(MockWebSocket.instances[1]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
|
||||
expect(relay.sockets[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
|
||||
expect(relay.sockets[1]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ interface RelayTransportOptions {
|
||||
relayUseTls: boolean;
|
||||
serverId: string;
|
||||
daemonKeyPair?: KeyPair;
|
||||
createWebSocket?: RelayWebSocketFactory;
|
||||
}
|
||||
|
||||
export interface RelayTransportController {
|
||||
@@ -32,6 +33,16 @@ interface RelaySocketLike {
|
||||
once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void;
|
||||
}
|
||||
|
||||
interface RelayWebSocketLike extends RelaySocketLike {
|
||||
terminate: () => void;
|
||||
on: (
|
||||
event: "open" | "message" | "close" | "error",
|
||||
listener: (...args: unknown[]) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
type RelayWebSocketFactory = (url: string) => RelayWebSocketLike;
|
||||
|
||||
type ControlMessage =
|
||||
| { type: "sync"; connectionIds: string[] }
|
||||
| { type: "connected"; connectionId: string }
|
||||
@@ -42,6 +53,11 @@ type ControlMessage =
|
||||
const CONTROL_PING_INTERVAL_MS = 10_000;
|
||||
const CONTROL_STALE_TIMEOUT_MS = 30_000;
|
||||
const CONTROL_READY_TIMEOUT_MS = 8_000;
|
||||
const RELAY_WEBSOCKET_OPTIONS = { handshakeTimeout: 10_000, perMessageDeflate: false } as const;
|
||||
|
||||
function createDefaultRelayWebSocket(url: string): RelayWebSocketLike {
|
||||
return new WebSocket(url, RELAY_WEBSOCKET_OPTIONS);
|
||||
}
|
||||
|
||||
function normalizeRelaySendPayload(data: string | Uint8Array | ArrayBuffer): string | ArrayBuffer {
|
||||
if (typeof data === "string") return data;
|
||||
@@ -106,14 +122,15 @@ export function startRelayTransport({
|
||||
relayUseTls,
|
||||
serverId,
|
||||
daemonKeyPair,
|
||||
createWebSocket = createDefaultRelayWebSocket,
|
||||
}: RelayTransportOptions): RelayTransportController {
|
||||
const relayLogger = logger.child({ module: "relay-transport" });
|
||||
|
||||
let stopped = false;
|
||||
let controlWs: WebSocket | null = null;
|
||||
let controlWs: RelayWebSocketLike | null = null;
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempt = 0;
|
||||
const dataSockets = new Map<string, WebSocket>(); // connectionId -> ws
|
||||
const dataSockets = new Map<string, RelayWebSocketLike>(); // connectionId -> ws
|
||||
let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let controlLastSeenAt = 0;
|
||||
@@ -161,7 +178,7 @@ export function startRelayTransport({
|
||||
serverId,
|
||||
role: "server",
|
||||
});
|
||||
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
|
||||
const socket = createWebSocket(url);
|
||||
controlWs = socket;
|
||||
let controlConnected = false;
|
||||
|
||||
@@ -338,7 +355,7 @@ export function startRelayTransport({
|
||||
role: "server",
|
||||
connectionId,
|
||||
});
|
||||
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
|
||||
const socket = createWebSocket(url);
|
||||
dataSockets.set(connectionId, socket);
|
||||
|
||||
let attached = false;
|
||||
@@ -397,7 +414,7 @@ export function startRelayTransport({
|
||||
}
|
||||
|
||||
async function attachEncryptedSocket(
|
||||
socket: WebSocket,
|
||||
socket: RelayWebSocketLike,
|
||||
daemonKeyPair: KeyPair,
|
||||
logger: pino.Logger,
|
||||
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>,
|
||||
@@ -426,7 +443,10 @@ async function attachEncryptedSocket(
|
||||
}
|
||||
}
|
||||
|
||||
function createRelayTransportAdapter(socket: WebSocket, logger: pino.Logger): RelayTransport {
|
||||
function createRelayTransportAdapter(
|
||||
socket: RelayWebSocketLike,
|
||||
logger: pino.Logger,
|
||||
): RelayTransport {
|
||||
const relayTransport: RelayTransport = {
|
||||
send: (data) => {
|
||||
try {
|
||||
@@ -445,10 +465,11 @@ function createRelayTransportAdapter(socket: WebSocket, logger: pino.Logger): Re
|
||||
};
|
||||
|
||||
socket.on("message", (data, isBinary) => {
|
||||
relayTransport.onmessage?.(normalizeMessageData(data, isBinary));
|
||||
relayTransport.onmessage?.(normalizeMessageData(data, isBinary === true));
|
||||
});
|
||||
socket.on("close", (code, reason) => {
|
||||
relayTransport.onclose?.(code, reason.toString());
|
||||
const closeCode = typeof code === "number" ? code : 1006;
|
||||
relayTransport.onclose?.(closeCode, String(reason ?? ""));
|
||||
});
|
||||
socket.on("error", (err) => {
|
||||
relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err)));
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
} from "../bootstrap.js";
|
||||
import type { AgentClient, AgentProvider } from "../agent/agent-sdk-types.js";
|
||||
import { createTestAgentClients } from "./fake-agent-client.js";
|
||||
import type { PushNotificationSender } from "../push/notifications.js";
|
||||
|
||||
interface TestPaseoDaemonOptions {
|
||||
downloadTokenTtlMs?: number;
|
||||
corsAllowedOrigins?: string[];
|
||||
listen?: string;
|
||||
logger?: Parameters<typeof createPaseoDaemon>[1];
|
||||
mcpDebug?: boolean;
|
||||
relayEnabled?: boolean;
|
||||
relayEndpoint?: string;
|
||||
agentClients?: Partial<Record<AgentProvider, AgentClient>>;
|
||||
@@ -30,6 +32,7 @@ interface TestPaseoDaemonOptions {
|
||||
voiceLlmModel?: string | null;
|
||||
dictationFinalTimeoutMs?: number;
|
||||
auth?: PaseoDaemonConfig["auth"];
|
||||
pushNotificationSender?: PushNotificationSender;
|
||||
}
|
||||
|
||||
export interface TestPaseoDaemon {
|
||||
@@ -150,13 +153,14 @@ async function prepareTestDaemonConfig(
|
||||
hostnames: true,
|
||||
mcpEnabled: true,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
mcpDebug: options.mcpDebug ?? false,
|
||||
agentClients: options.agentClients ?? createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
relayEnabled: options.relayEnabled ?? false,
|
||||
relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443",
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
auth: options.auth,
|
||||
pushNotificationSender: options.pushNotificationSender,
|
||||
openai: options.openai,
|
||||
speech: options.speech,
|
||||
voiceLlmProvider: options.voiceLlmProvider ?? null,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { LoopService } from "./loop-service.js";
|
||||
import type { ScheduleService } from "./schedule/service.js";
|
||||
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import { asInternals, createStub } from "./test-utils/class-mocks.js";
|
||||
import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
|
||||
|
||||
const wsModuleMock = vi.hoisted(() => {
|
||||
class MockWebSocketServer {
|
||||
@@ -28,11 +29,6 @@ const wsModuleMock = vi.hoisted(() => {
|
||||
return { MockWebSocketServer };
|
||||
});
|
||||
|
||||
const pushMocks = vi.hoisted(() => ({
|
||||
getAllTokens: vi.fn(() => ["ExponentPushToken[token-1]"]),
|
||||
sendPush: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("ws", () => ({
|
||||
WebSocketServer: wsModuleMock.MockWebSocketServer,
|
||||
}));
|
||||
@@ -43,19 +39,6 @@ vi.mock("./session.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./push/token-store.js", () => ({
|
||||
PushTokenStore: class {
|
||||
getAllTokens = pushMocks.getAllTokens;
|
||||
removeToken = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./push/push-service.js", () => ({
|
||||
PushService: class {
|
||||
sendPush = pushMocks.sendPush;
|
||||
},
|
||||
}));
|
||||
|
||||
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||
|
||||
interface WebSocketServerInternals {
|
||||
@@ -81,7 +64,16 @@ function createLogger() {
|
||||
return logger;
|
||||
}
|
||||
|
||||
class RecordingPushNotificationSender implements PushNotificationSender {
|
||||
readonly sent: PushPayload[] = [];
|
||||
|
||||
async send(payload: PushPayload): Promise<void> {
|
||||
this.sent.push(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
const pushNotifications = new RecordingPushNotificationSender();
|
||||
const agentManager = {
|
||||
setAgentAttentionCallback: vi.fn(),
|
||||
getAgent: vi.fn(() => null),
|
||||
@@ -137,9 +129,18 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
})),
|
||||
dispose: vi.fn(),
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
pushNotifications,
|
||||
);
|
||||
|
||||
return { server, agentManager };
|
||||
return { server, agentManager, pushNotifications };
|
||||
}
|
||||
|
||||
function createOpenSocket() {
|
||||
@@ -208,7 +209,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||
const getLastAssistantMessage = vi.fn(
|
||||
async () => "**Done**. Updated `README.md` and [link](https://example.com).",
|
||||
);
|
||||
const { server } = createServer({
|
||||
const { server, pushNotifications } = createServer({
|
||||
getAgent: vi.fn(() => ({
|
||||
config: { title: null },
|
||||
cwd: "/tmp/worktree",
|
||||
@@ -223,21 +224,23 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||
reason: "finished",
|
||||
});
|
||||
|
||||
expect(pushMocks.sendPush).toHaveBeenCalledWith(["ExponentPushToken[token-1]"], {
|
||||
title: "Agent finished",
|
||||
body: "Done. Updated README.md and link.",
|
||||
data: {
|
||||
serverId: "srv-test",
|
||||
agentId: "agent-1",
|
||||
reason: "finished",
|
||||
expect(pushNotifications.sent).toEqual([
|
||||
{
|
||||
title: "Agent finished",
|
||||
body: "Done. Updated README.md and link.",
|
||||
data: {
|
||||
serverId: "srv-test",
|
||||
agentId: "agent-1",
|
||||
reason: "finished",
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-1");
|
||||
});
|
||||
|
||||
it("sends push notifications regardless of UI label presence", async () => {
|
||||
const getLastAssistantMessage = vi.fn(async () => "Done.");
|
||||
const { server } = createServer({
|
||||
const { server, pushNotifications } = createServer({
|
||||
getAgent: vi.fn(() => ({
|
||||
config: { title: null },
|
||||
cwd: "/tmp/worktree",
|
||||
@@ -253,12 +256,12 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||
reason: "finished",
|
||||
});
|
||||
|
||||
expect(pushMocks.sendPush).toHaveBeenCalledTimes(1);
|
||||
expect(pushNotifications.sent).toHaveLength(1);
|
||||
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-2");
|
||||
});
|
||||
|
||||
it("routes a hidden stale focused browser tab's notification to the present Electron web client", async () => {
|
||||
const { server } = createServer();
|
||||
const { server, pushNotifications } = createServer();
|
||||
const nowMs = Date.now();
|
||||
const electronWs = connectClient(server, {
|
||||
deviceType: "web",
|
||||
@@ -281,11 +284,11 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||
|
||||
expect(readAttentionRequiredMessage(electronWs).shouldNotify).toBe(true);
|
||||
expect(readAttentionRequiredMessage(firefoxWs).shouldNotify).toBe(false);
|
||||
expect(pushMocks.sendPush).not.toHaveBeenCalled();
|
||||
expect(pushNotifications.sent).toEqual([]);
|
||||
});
|
||||
|
||||
it("pushes non-error attention when the only connected client has never sent a heartbeat", async () => {
|
||||
const { server } = createServer();
|
||||
const { server, pushNotifications } = createServer();
|
||||
const ws = connectClient(server, null);
|
||||
|
||||
await asInternals<WebSocketServerInternals>(server).broadcastAgentAttention({
|
||||
@@ -295,11 +298,11 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||
});
|
||||
|
||||
expect(readAttentionRequiredMessage(ws).shouldNotify).toBe(false);
|
||||
expect(pushMocks.sendPush).toHaveBeenCalledTimes(1);
|
||||
expect(pushNotifications.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not push error attention when the only connected client has never sent a heartbeat", async () => {
|
||||
const { server } = createServer();
|
||||
const { server, pushNotifications } = createServer();
|
||||
const ws = connectClient(server, null);
|
||||
|
||||
await asInternals<WebSocketServerInternals>(server).broadcastAgentAttention({
|
||||
@@ -309,6 +312,6 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||
});
|
||||
|
||||
expect(readAttentionRequiredMessage(ws).shouldNotify).toBe(false);
|
||||
expect(pushMocks.sendPush).not.toHaveBeenCalled();
|
||||
expect(pushNotifications.sent).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import type { Server as HTTPServer } from "http";
|
||||
import type pino from "pino";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
import type { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { DaemonConfigStore } from "./daemon-config-store.js";
|
||||
import type { FileBackedChatService } from "./chat/chat-service.js";
|
||||
import type { LoopService } from "./loop-service.js";
|
||||
import type { ScheduleService } from "./schedule/service.js";
|
||||
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import type { SessionOutboundMessage, WSOutboundMessage } from "./messages.js";
|
||||
|
||||
const wsModuleMock = vi.hoisted(() => {
|
||||
class MockWebSocketServer {
|
||||
readonly handlers = new Map<string, (...args: unknown[]) => void>();
|
||||
|
||||
on(event: string, handler: (...args: unknown[]) => void) {
|
||||
this.handlers.set(event, handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
close() {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
return { MockWebSocketServer };
|
||||
});
|
||||
|
||||
vi.mock("ws", () => ({
|
||||
WebSocketServer: wsModuleMock.MockWebSocketServer,
|
||||
}));
|
||||
|
||||
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||
import { wrapSessionMessage } from "./messages.js";
|
||||
|
||||
interface WebSocketServerInternals {
|
||||
flushRuntimeMetrics(options?: { final?: boolean }): void;
|
||||
sendToClient(ws: unknown, message: WSOutboundMessage): void;
|
||||
sendBinaryToClient(ws: unknown, frame: Uint8Array): void;
|
||||
sessions: Map<unknown, unknown>;
|
||||
}
|
||||
|
||||
const RuntimeMetricsLogSchema = z.object({
|
||||
outboundMessageTypesTop: z.array(z.tuple([z.string(), z.number()])),
|
||||
outboundSessionMessageTypesTop: z.array(z.tuple([z.string(), z.number()])),
|
||||
outboundAgentStreamTypesTop: z.array(z.tuple([z.string(), z.number()])),
|
||||
outboundAgentStreamAgentsTop: z.array(z.tuple([z.string(), z.number()])),
|
||||
outboundBinaryFrameTypesTop: z.array(z.tuple([z.string(), z.number()])),
|
||||
bufferedAmount: z.object({
|
||||
p95: z.number(),
|
||||
max: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
type RuntimeMetricsLog = z.infer<typeof RuntimeMetricsLogSchema>;
|
||||
|
||||
interface TestSocket {
|
||||
readyState: number;
|
||||
bufferedAmount: number;
|
||||
sent: Array<string | Uint8Array | ArrayBuffer>;
|
||||
afterSendBufferedAmounts: number[];
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
||||
close: () => void;
|
||||
on: () => void;
|
||||
once: () => void;
|
||||
}
|
||||
|
||||
function createLogger() {
|
||||
const logger = {
|
||||
child: vi.fn(() => logger),
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
return logger;
|
||||
}
|
||||
|
||||
function createServer(logger: ReturnType<typeof createLogger>) {
|
||||
const daemonConfigStore = {
|
||||
onChange: vi.fn(() => () => {}),
|
||||
};
|
||||
|
||||
return new VoiceAssistantWebSocketServer(
|
||||
{} as unknown as HTTPServer,
|
||||
logger as unknown as pino.Logger,
|
||||
"srv-test",
|
||||
{
|
||||
setAgentAttentionCallback: vi.fn(),
|
||||
getAgent: vi.fn(() => null),
|
||||
getMetricsSnapshot: vi.fn(() => ({
|
||||
totalAgents: 0,
|
||||
idleAgents: 0,
|
||||
runningAgents: 0,
|
||||
pendingPermissionAgents: 0,
|
||||
erroredAgents: 0,
|
||||
})),
|
||||
} as unknown as AgentManager,
|
||||
{} as unknown as AgentStorage,
|
||||
{} as unknown as DownloadTokenStore,
|
||||
"/tmp/paseo-test",
|
||||
daemonConfigStore as unknown as DaemonConfigStore,
|
||||
null,
|
||||
{ allowedOrigins: new Set() },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
"1.2.3-test",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{} as unknown as FileBackedChatService,
|
||||
{} as unknown as LoopService,
|
||||
{} as unknown as ScheduleService,
|
||||
{
|
||||
subscribe: vi.fn(),
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
getMetrics: vi.fn(() => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
})),
|
||||
dispose: vi.fn(),
|
||||
} as unknown as CheckoutDiffManager,
|
||||
);
|
||||
}
|
||||
|
||||
function createSocket(afterSendBufferedAmounts: number[]): TestSocket {
|
||||
const socket: TestSocket = {
|
||||
readyState: 1,
|
||||
bufferedAmount: 0,
|
||||
sent: [],
|
||||
afterSendBufferedAmounts,
|
||||
send: vi.fn((data) => {
|
||||
socket.sent.push(data);
|
||||
socket.bufferedAmount = afterSendBufferedAmounts.shift() ?? socket.bufferedAmount;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
return socket;
|
||||
}
|
||||
|
||||
function flushRuntimeMetrics(server: VoiceAssistantWebSocketServer): void {
|
||||
(server as unknown as WebSocketServerInternals).flushRuntimeMetrics({ final: true });
|
||||
}
|
||||
|
||||
function getRuntimeMetricsLog(logger: ReturnType<typeof createLogger>): RuntimeMetricsLog {
|
||||
const metricsCall = logger.info.mock.calls.find((call) => call[1] === "ws_runtime_metrics");
|
||||
expect(metricsCall).toBeDefined();
|
||||
return RuntimeMetricsLogSchema.parse(metricsCall![0]);
|
||||
}
|
||||
|
||||
function sendToClient(
|
||||
server: VoiceAssistantWebSocketServer,
|
||||
socket: TestSocket,
|
||||
message: WSOutboundMessage,
|
||||
) {
|
||||
(server as unknown as WebSocketServerInternals).sendToClient(socket, message);
|
||||
}
|
||||
|
||||
function sendBinaryToClient(
|
||||
server: VoiceAssistantWebSocketServer,
|
||||
socket: TestSocket,
|
||||
frame: Uint8Array,
|
||||
) {
|
||||
(server as unknown as WebSocketServerInternals).sendBinaryToClient(socket, frame);
|
||||
}
|
||||
|
||||
function attachSessionSocket(server: VoiceAssistantWebSocketServer, socket: TestSocket): void {
|
||||
(server as unknown as WebSocketServerInternals).sessions.set(socket, {
|
||||
session: {},
|
||||
clientId: "client-1",
|
||||
appVersion: null,
|
||||
connectionLogger: createLogger(),
|
||||
sockets: new Set([socket]),
|
||||
externalDisconnectCleanupTimeout: null,
|
||||
});
|
||||
}
|
||||
|
||||
function agentStreamMessage(params: {
|
||||
agentId: string;
|
||||
event: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"];
|
||||
}): WSOutboundMessage {
|
||||
return wrapSessionMessage({
|
||||
type: "agent_stream",
|
||||
payload: {
|
||||
agentId: params.agentId,
|
||||
event: params.event,
|
||||
timestamp: "2026-04-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("VoiceAssistantWebSocketServer runtime metrics", () => {
|
||||
it("records outbound message type counts in the ws runtime metrics window", () => {
|
||||
const logger = createLogger();
|
||||
const server = createServer(logger);
|
||||
const broadcastSocket = createSocket([0]);
|
||||
const directSocket = createSocket([0, 0]);
|
||||
attachSessionSocket(server, broadcastSocket);
|
||||
|
||||
server.broadcast(
|
||||
agentStreamMessage({
|
||||
agentId: "agent-1",
|
||||
event: {
|
||||
type: "turn_completed",
|
||||
provider: "codex",
|
||||
},
|
||||
}),
|
||||
);
|
||||
sendToClient(
|
||||
server,
|
||||
directSocket,
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "ok",
|
||||
message: "ready",
|
||||
},
|
||||
}),
|
||||
);
|
||||
sendToClient(server, directSocket, { type: "pong" });
|
||||
|
||||
flushRuntimeMetrics(server);
|
||||
|
||||
const metrics = getRuntimeMetricsLog(logger);
|
||||
expect(metrics.outboundMessageTypesTop).toEqual([
|
||||
["session_message", 2],
|
||||
["pong", 1],
|
||||
]);
|
||||
expect(metrics.outboundSessionMessageTypesTop).toEqual([
|
||||
["agent_stream", 1],
|
||||
["status", 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it("records agent_stream subtypes and top agents", () => {
|
||||
const logger = createLogger();
|
||||
const server = createServer(logger);
|
||||
const socket = createSocket([0, 0, 0, 0]);
|
||||
|
||||
sendToClient(
|
||||
server,
|
||||
socket,
|
||||
agentStreamMessage({
|
||||
agentId: "agent-1",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "hello" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
sendToClient(
|
||||
server,
|
||||
socket,
|
||||
agentStreamMessage({
|
||||
agentId: "agent-1",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "reasoning", text: "thinking" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
sendToClient(
|
||||
server,
|
||||
socket,
|
||||
agentStreamMessage({
|
||||
agentId: "agent-1",
|
||||
event: {
|
||||
type: "turn_completed",
|
||||
provider: "codex",
|
||||
},
|
||||
}),
|
||||
);
|
||||
sendToClient(
|
||||
server,
|
||||
socket,
|
||||
agentStreamMessage({
|
||||
agentId: "agent-2",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "there" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
flushRuntimeMetrics(server);
|
||||
|
||||
const metrics = getRuntimeMetricsLog(logger);
|
||||
expect(metrics.outboundAgentStreamTypesTop).toEqual([
|
||||
["timeline:assistant_message", 2],
|
||||
["timeline:reasoning", 1],
|
||||
["turn_completed", 1],
|
||||
]);
|
||||
expect(metrics.outboundAgentStreamAgentsTop).toEqual([
|
||||
["agent-1", 3],
|
||||
["agent-2", 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it("records bufferedAmount p95 and max from samples taken after send", () => {
|
||||
const logger = createLogger();
|
||||
const server = createServer(logger);
|
||||
const broadcastSocket = createSocket([0]);
|
||||
const directSocket = createSocket([10, 50]);
|
||||
const binarySocket = createSocket([100]);
|
||||
attachSessionSocket(server, broadcastSocket);
|
||||
|
||||
server.broadcast(
|
||||
agentStreamMessage({
|
||||
agentId: "agent-1",
|
||||
event: {
|
||||
type: "turn_completed",
|
||||
provider: "codex",
|
||||
},
|
||||
}),
|
||||
);
|
||||
sendToClient(
|
||||
server,
|
||||
directSocket,
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
payload: { status: "ok" },
|
||||
}),
|
||||
);
|
||||
sendToClient(server, directSocket, { type: "pong" });
|
||||
sendBinaryToClient(server, binarySocket, new Uint8Array([1, 2, 3]));
|
||||
|
||||
flushRuntimeMetrics(server);
|
||||
|
||||
const metrics = getRuntimeMetricsLog(logger);
|
||||
expect(metrics.bufferedAmount).toEqual({
|
||||
p95: 100,
|
||||
max: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts binary frames without decoding", () => {
|
||||
const logger = createLogger();
|
||||
const server = createServer(logger);
|
||||
const socket = createSocket([12, 24]);
|
||||
const frame: Uint8Array = new Proxy(Buffer.from([0xff, 0xfe, 0xfd, 0x00, 0xc0]), {
|
||||
get(_target, property) {
|
||||
throw new Error(`Binary frame payload was unexpectedly accessed via ${String(property)}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => sendBinaryToClient(server, socket, frame)).not.toThrow();
|
||||
|
||||
flushRuntimeMetrics(server);
|
||||
|
||||
const metrics = getRuntimeMetricsLog(logger);
|
||||
expect(metrics.outboundBinaryFrameTypesTop).toEqual([["binary", 1]]);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,6 @@ import type { DaemonConfigStore, MutableDaemonConfig } from "./daemon-config-sto
|
||||
import { applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
|
||||
import {
|
||||
type ServerInfoStatusPayload,
|
||||
type SessionOutboundMessage,
|
||||
type WorkspaceSetupSnapshot,
|
||||
type WSHelloMessage,
|
||||
type WSInboundMessage,
|
||||
@@ -40,7 +39,7 @@ import { buildProviderRegistry, createClientsFromRegistry } from "./agent/provid
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import { PushService } from "./push/push-service.js";
|
||||
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
|
||||
import type { ScriptHealthState } from "./script-health-monitor.js";
|
||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
@@ -58,6 +57,10 @@ import {
|
||||
isBearerTokenValid,
|
||||
type DaemonAuthConfig,
|
||||
} from "./auth.js";
|
||||
import {
|
||||
WebSocketRuntimeMetricsWindow,
|
||||
type WebSocketRuntimeCounters,
|
||||
} from "./websocket/runtime-metrics.js";
|
||||
|
||||
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
|
||||
|
||||
@@ -270,24 +273,6 @@ interface SessionConnection {
|
||||
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
interface WebSocketRuntimeCounters {
|
||||
connectedAwaitingHello: number;
|
||||
helloResumed: number;
|
||||
helloNew: number;
|
||||
pendingDisconnected: number;
|
||||
sessionDisconnectedWaitingReconnect: number;
|
||||
sessionSocketDisconnectedAttached: number;
|
||||
sessionCleanup: number;
|
||||
validationFailed: number;
|
||||
binaryBeforeHelloRejected: number;
|
||||
pendingMessageRejectedBeforeHello: number;
|
||||
missingConnectionForMessage: number;
|
||||
unexpectedHelloOnActiveConnection: number;
|
||||
relayExternalSocketAttached: number;
|
||||
originRejected: number;
|
||||
hostRejected: number;
|
||||
}
|
||||
|
||||
const SLOW_REQUEST_THRESHOLD_MS = 500;
|
||||
const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000;
|
||||
const HELLO_TIMEOUT_MS = 15_000;
|
||||
@@ -358,7 +343,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly paseoHome: string;
|
||||
private readonly daemonConfigStore: DaemonConfigStore;
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
private readonly pushService: PushService;
|
||||
private readonly pushNotificationSender: PushNotificationSender;
|
||||
private readonly mcpBaseUrl: string | null;
|
||||
private speech!: SpeechService | null;
|
||||
private terminalManager!: TerminalManager | null;
|
||||
@@ -382,33 +367,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
| ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void)
|
||||
| null;
|
||||
private serverCapabilities: ServerCapabilities | undefined;
|
||||
private runtimeWindowStartedAt = Date.now();
|
||||
private readonly runtimeCounters: WebSocketRuntimeCounters = {
|
||||
connectedAwaitingHello: 0,
|
||||
helloResumed: 0,
|
||||
helloNew: 0,
|
||||
pendingDisconnected: 0,
|
||||
sessionDisconnectedWaitingReconnect: 0,
|
||||
sessionSocketDisconnectedAttached: 0,
|
||||
sessionCleanup: 0,
|
||||
validationFailed: 0,
|
||||
binaryBeforeHelloRejected: 0,
|
||||
pendingMessageRejectedBeforeHello: 0,
|
||||
missingConnectionForMessage: 0,
|
||||
unexpectedHelloOnActiveConnection: 0,
|
||||
relayExternalSocketAttached: 0,
|
||||
originRejected: 0,
|
||||
hostRejected: 0,
|
||||
};
|
||||
private readonly inboundMessageCounts = new Map<string, number>();
|
||||
private readonly inboundSessionRequestCounts = new Map<string, number>();
|
||||
private readonly outboundMessageCounts = new Map<string, number>();
|
||||
private readonly outboundSessionMessageCounts = new Map<string, number>();
|
||||
private readonly outboundAgentStreamCounts = new Map<string, number>();
|
||||
private readonly outboundAgentStreamByAgentCounts = new Map<string, number>();
|
||||
private readonly outboundBinaryFrameCounts = new Map<string, number>();
|
||||
private readonly bufferedAmountSamples: number[] = [];
|
||||
private readonly requestLatencies = new Map<string, number[]>();
|
||||
private readonly runtimeMetrics = new WebSocketRuntimeMetricsWindow();
|
||||
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private unsubscribeSpeechReadiness: (() => void) | null = null;
|
||||
private unsubscribeDaemonConfigChange: (() => void) | null = null;
|
||||
@@ -453,6 +412,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
|
||||
workspaceGitService?: WorkspaceGitService,
|
||||
github?: GitHubService,
|
||||
pushNotificationSender?: PushNotificationSender,
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.serverId = serverId;
|
||||
@@ -529,7 +489,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
const pushLogger = this.logger.child({ module: "push" });
|
||||
this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json"));
|
||||
this.pushService = new PushService(pushLogger, this.pushTokenStore);
|
||||
this.pushNotificationSender =
|
||||
pushNotificationSender ?? createPushNotificationSender(pushLogger, this.pushTokenStore);
|
||||
|
||||
this.agentManager.setAgentAttentionCallback((params) => {
|
||||
void this.broadcastAgentAttention(params).catch((err) => {
|
||||
@@ -666,7 +627,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
// WebSocket.OPEN = 1
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(payload);
|
||||
this.recordOutboundMessage(message, ws);
|
||||
this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -783,7 +744,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
try {
|
||||
ws.send(JSON.stringify(message));
|
||||
this.recordOutboundMessage(message, ws);
|
||||
this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount);
|
||||
} catch (err) {
|
||||
this.logger.warn({ err }, "ws_send_failed");
|
||||
}
|
||||
@@ -795,7 +756,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
try {
|
||||
ws.send(frame);
|
||||
this.recordOutboundBinaryFrame(ws);
|
||||
this.runtimeMetrics.recordOutboundBinaryFrame(ws.bufferedAmount);
|
||||
} catch (err) {
|
||||
this.logger.warn({ err }, "ws_send_binary_failed");
|
||||
}
|
||||
@@ -1567,116 +1528,19 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
private incrementRuntimeCounter(counter: keyof WebSocketRuntimeCounters): void {
|
||||
this.runtimeCounters[counter] += 1;
|
||||
}
|
||||
|
||||
private incrementCount(map: Map<string, number>, key: string): void {
|
||||
map.set(key, (map.get(key) ?? 0) + 1);
|
||||
this.runtimeMetrics.incrementCounter(counter);
|
||||
}
|
||||
|
||||
private recordInboundMessageType(type: string): void {
|
||||
this.incrementCount(this.inboundMessageCounts, type);
|
||||
this.runtimeMetrics.recordInboundMessage(type);
|
||||
}
|
||||
|
||||
private recordInboundSessionRequestType(type: string): void {
|
||||
this.incrementCount(this.inboundSessionRequestCounts, type);
|
||||
}
|
||||
|
||||
private recordOutboundMessage(message: WSOutboundMessage, ws: WebSocketLike): void {
|
||||
if (message.type !== "session") {
|
||||
this.incrementCount(this.outboundMessageCounts, message.type);
|
||||
this.recordBufferedAmount(ws);
|
||||
return;
|
||||
}
|
||||
|
||||
this.incrementCount(this.outboundMessageCounts, "session_message");
|
||||
this.incrementCount(this.outboundSessionMessageCounts, message.message.type);
|
||||
|
||||
if (message.message.type === "agent_stream") {
|
||||
this.recordOutboundAgentStreamMessage(message.message.payload);
|
||||
}
|
||||
|
||||
this.recordBufferedAmount(ws);
|
||||
}
|
||||
|
||||
private recordOutboundAgentStreamMessage(
|
||||
payload: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"],
|
||||
): void {
|
||||
const { agentId, event } = payload;
|
||||
const eventType = event.type === "timeline" ? `timeline:${event.item.type}` : event.type;
|
||||
this.incrementCount(this.outboundAgentStreamCounts, eventType);
|
||||
this.incrementCount(this.outboundAgentStreamByAgentCounts, agentId);
|
||||
}
|
||||
|
||||
private recordOutboundBinaryFrame(ws: WebSocketLike): void {
|
||||
this.incrementCount(this.outboundBinaryFrameCounts, "binary");
|
||||
this.recordBufferedAmount(ws);
|
||||
}
|
||||
|
||||
private recordBufferedAmount(ws: WebSocketLike): void {
|
||||
if (typeof ws.bufferedAmount !== "number") {
|
||||
return;
|
||||
}
|
||||
this.bufferedAmountSamples.push(ws.bufferedAmount);
|
||||
this.runtimeMetrics.recordInboundSessionRequest(type);
|
||||
}
|
||||
|
||||
private recordRequestLatency(type: string, durationMs: number): void {
|
||||
let latencies = this.requestLatencies.get(type);
|
||||
if (!latencies) {
|
||||
latencies = [];
|
||||
this.requestLatencies.set(type, latencies);
|
||||
}
|
||||
latencies.push(durationMs);
|
||||
}
|
||||
|
||||
private getTopCounts(map: Map<string, number>, limit: number): Array<[string, number]> {
|
||||
return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
|
||||
}
|
||||
|
||||
private computeLatencyStats(): Array<{
|
||||
type: string;
|
||||
count: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
p50Ms: number;
|
||||
totalMs: number;
|
||||
}> {
|
||||
const stats: Array<{
|
||||
type: string;
|
||||
count: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
p50Ms: number;
|
||||
totalMs: number;
|
||||
}> = [];
|
||||
for (const [type, latencies] of this.requestLatencies) {
|
||||
if (latencies.length === 0) continue;
|
||||
latencies.sort((a, b) => a - b);
|
||||
const count = latencies.length;
|
||||
const minMs = Math.round(latencies[0]);
|
||||
const maxMs = Math.round(latencies[count - 1]);
|
||||
const p50Ms = Math.round(latencies[Math.floor(count / 2)]);
|
||||
const totalMs = Math.round(latencies.reduce((sum, v) => sum + v, 0));
|
||||
stats.push({ type, count, minMs, maxMs, p50Ms, totalMs });
|
||||
}
|
||||
stats.sort((a, b) => b.totalMs - a.totalMs);
|
||||
return stats.slice(0, 15);
|
||||
}
|
||||
|
||||
private computeBufferedAmountStats(): {
|
||||
p95: number;
|
||||
max: number;
|
||||
} {
|
||||
if (this.bufferedAmountSamples.length === 0) {
|
||||
return { p95: 0, max: 0 };
|
||||
}
|
||||
|
||||
const samples = [...this.bufferedAmountSamples].sort((a, b) => a - b);
|
||||
const p95Index = Math.ceil(samples.length * 0.95) - 1;
|
||||
return {
|
||||
p95: samples[p95Index] ?? 0,
|
||||
max: samples[samples.length - 1] ?? 0,
|
||||
};
|
||||
this.runtimeMetrics.recordRequestLatency(type, durationMs);
|
||||
}
|
||||
|
||||
private collectSessionRuntimeMetrics(): WebSocketRuntimeMetrics {
|
||||
@@ -1705,8 +1569,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
private flushRuntimeMetrics(options?: { final?: boolean }): void {
|
||||
const now = Date.now();
|
||||
const windowMs = Math.max(0, now - this.runtimeWindowStartedAt);
|
||||
const runtimeMetrics = this.runtimeMetrics.snapshotAndReset();
|
||||
const activeConnections = new Set<SessionConnection>(this.sessions.values()).size;
|
||||
const activeSockets = this.sessions.size;
|
||||
const pendingConnections = this.pendingConnections.size;
|
||||
@@ -1715,13 +1578,11 @@ export class VoiceAssistantWebSocketServer {
|
||||
connection.sockets.size === 0 && connection.externalDisconnectCleanupTimeout !== null,
|
||||
).length;
|
||||
const sessionMetrics = this.collectSessionRuntimeMetrics();
|
||||
const latencyStats = this.computeLatencyStats();
|
||||
const bufferedAmountStats = this.computeBufferedAmountStats();
|
||||
const agentSnapshot = this.agentManager.getMetricsSnapshot();
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
windowMs,
|
||||
windowMs: runtimeMetrics.windowMs,
|
||||
final: Boolean(options?.final),
|
||||
sessions: {
|
||||
activeConnections,
|
||||
@@ -1732,37 +1593,21 @@ export class VoiceAssistantWebSocketServer {
|
||||
activeSockets,
|
||||
pendingConnections,
|
||||
},
|
||||
counters: { ...this.runtimeCounters },
|
||||
inboundMessageTypesTop: this.getTopCounts(this.inboundMessageCounts, 12),
|
||||
inboundSessionRequestTypesTop: this.getTopCounts(this.inboundSessionRequestCounts, 20),
|
||||
outboundMessageTypesTop: this.getTopCounts(this.outboundMessageCounts, 12),
|
||||
outboundSessionMessageTypesTop: this.getTopCounts(this.outboundSessionMessageCounts, 20),
|
||||
outboundAgentStreamTypesTop: this.getTopCounts(this.outboundAgentStreamCounts, 20),
|
||||
outboundAgentStreamAgentsTop: this.getTopCounts(this.outboundAgentStreamByAgentCounts, 20),
|
||||
outboundBinaryFrameTypesTop: this.getTopCounts(this.outboundBinaryFrameCounts, 12),
|
||||
bufferedAmount: bufferedAmountStats,
|
||||
counters: runtimeMetrics.counters,
|
||||
inboundMessageTypesTop: runtimeMetrics.inboundMessageTypesTop,
|
||||
inboundSessionRequestTypesTop: runtimeMetrics.inboundSessionRequestTypesTop,
|
||||
outboundMessageTypesTop: runtimeMetrics.outboundMessageTypesTop,
|
||||
outboundSessionMessageTypesTop: runtimeMetrics.outboundSessionMessageTypesTop,
|
||||
outboundAgentStreamTypesTop: runtimeMetrics.outboundAgentStreamTypesTop,
|
||||
outboundAgentStreamAgentsTop: runtimeMetrics.outboundAgentStreamAgentsTop,
|
||||
outboundBinaryFrameTypesTop: runtimeMetrics.outboundBinaryFrameTypesTop,
|
||||
bufferedAmount: runtimeMetrics.bufferedAmount,
|
||||
runtime: sessionMetrics,
|
||||
latency: latencyStats,
|
||||
latency: runtimeMetrics.latency,
|
||||
agents: agentSnapshot,
|
||||
},
|
||||
"ws_runtime_metrics",
|
||||
);
|
||||
|
||||
for (const counter of Object.keys(this.runtimeCounters) as Array<
|
||||
keyof WebSocketRuntimeCounters
|
||||
>) {
|
||||
this.runtimeCounters[counter] = 0;
|
||||
}
|
||||
this.inboundMessageCounts.clear();
|
||||
this.inboundSessionRequestCounts.clear();
|
||||
this.outboundMessageCounts.clear();
|
||||
this.outboundSessionMessageCounts.clear();
|
||||
this.outboundAgentStreamCounts.clear();
|
||||
this.outboundAgentStreamByAgentCounts.clear();
|
||||
this.outboundBinaryFrameCounts.clear();
|
||||
this.bufferedAmountSamples.length = 0;
|
||||
this.requestLatencies.clear();
|
||||
this.runtimeWindowStartedAt = now;
|
||||
}
|
||||
|
||||
private getClientActivityState(session: Session): ClientPresenceState {
|
||||
@@ -1819,11 +1664,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
});
|
||||
|
||||
if (plan.shouldPush) {
|
||||
const tokens = this.pushTokenStore.getAllTokens();
|
||||
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
|
||||
if (tokens.length > 0) {
|
||||
void this.pushService.sendPush(tokens, notification);
|
||||
}
|
||||
void this.pushNotificationSender.send(notification).catch((err) => {
|
||||
this.logger.warn({ err, agentId: params.agentId }, "Failed to send push notification");
|
||||
});
|
||||
}
|
||||
|
||||
for (const [clientIndex, { ws }] of clientEntries.entries()) {
|
||||
|
||||
202
packages/server/src/server/websocket/runtime-metrics.test.ts
Normal file
202
packages/server/src/server/websocket/runtime-metrics.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionOutboundMessage, WSOutboundMessage } from "../messages.js";
|
||||
import { wrapSessionMessage } from "../messages.js";
|
||||
import { WebSocketRuntimeMetricsWindow } from "./runtime-metrics.js";
|
||||
|
||||
function createMetricsWindow(): {
|
||||
metrics: WebSocketRuntimeMetricsWindow;
|
||||
advanceClock(ms: number): void;
|
||||
} {
|
||||
let now = 1_000;
|
||||
return {
|
||||
metrics: new WebSocketRuntimeMetricsWindow(() => now),
|
||||
advanceClock(ms: number) {
|
||||
now += ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function agentStreamMessage(params: {
|
||||
agentId: string;
|
||||
event: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"];
|
||||
}): WSOutboundMessage {
|
||||
return wrapSessionMessage({
|
||||
type: "agent_stream",
|
||||
payload: {
|
||||
agentId: params.agentId,
|
||||
event: params.event,
|
||||
timestamp: "2026-04-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("WebSocketRuntimeMetricsWindow", () => {
|
||||
it("records outbound message type counts in the runtime metrics window", () => {
|
||||
const { metrics } = createMetricsWindow();
|
||||
|
||||
metrics.recordOutboundMessage(
|
||||
agentStreamMessage({
|
||||
agentId: "agent-1",
|
||||
event: {
|
||||
type: "turn_completed",
|
||||
provider: "codex",
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
metrics.recordOutboundMessage(
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "ok",
|
||||
message: "ready",
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
metrics.recordOutboundMessage({ type: "pong" }, 0);
|
||||
|
||||
const snapshot = metrics.snapshotAndReset();
|
||||
|
||||
expect(snapshot.outboundMessageTypesTop).toEqual([
|
||||
["session_message", 2],
|
||||
["pong", 1],
|
||||
]);
|
||||
expect(snapshot.outboundSessionMessageTypesTop).toEqual([
|
||||
["agent_stream", 1],
|
||||
["status", 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it("records agent_stream subtypes and top agents", () => {
|
||||
const { metrics } = createMetricsWindow();
|
||||
|
||||
metrics.recordOutboundMessage(
|
||||
agentStreamMessage({
|
||||
agentId: "agent-1",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "hello" },
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
metrics.recordOutboundMessage(
|
||||
agentStreamMessage({
|
||||
agentId: "agent-1",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "reasoning", text: "thinking" },
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
metrics.recordOutboundMessage(
|
||||
agentStreamMessage({
|
||||
agentId: "agent-1",
|
||||
event: {
|
||||
type: "turn_completed",
|
||||
provider: "codex",
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
metrics.recordOutboundMessage(
|
||||
agentStreamMessage({
|
||||
agentId: "agent-2",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "there" },
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
const snapshot = metrics.snapshotAndReset();
|
||||
|
||||
expect(snapshot.outboundAgentStreamTypesTop).toEqual([
|
||||
["timeline:assistant_message", 2],
|
||||
["timeline:reasoning", 1],
|
||||
["turn_completed", 1],
|
||||
]);
|
||||
expect(snapshot.outboundAgentStreamAgentsTop).toEqual([
|
||||
["agent-1", 3],
|
||||
["agent-2", 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it("records bufferedAmount p95 and max from samples taken after send", () => {
|
||||
const { metrics } = createMetricsWindow();
|
||||
|
||||
metrics.recordOutboundMessage(
|
||||
agentStreamMessage({
|
||||
agentId: "agent-1",
|
||||
event: {
|
||||
type: "turn_completed",
|
||||
provider: "codex",
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
metrics.recordOutboundMessage(
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
payload: { status: "ok" },
|
||||
}),
|
||||
10,
|
||||
);
|
||||
metrics.recordOutboundMessage({ type: "pong" }, 50);
|
||||
metrics.recordOutboundBinaryFrame(100);
|
||||
|
||||
const snapshot = metrics.snapshotAndReset();
|
||||
|
||||
expect(snapshot.bufferedAmount).toEqual({
|
||||
p95: 100,
|
||||
max: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts binary frames without decoding", () => {
|
||||
const { metrics } = createMetricsWindow();
|
||||
|
||||
metrics.recordOutboundBinaryFrame(24);
|
||||
|
||||
const snapshot = metrics.snapshotAndReset();
|
||||
expect(snapshot.outboundBinaryFrameTypesTop).toEqual([["binary", 1]]);
|
||||
});
|
||||
|
||||
it("resets the runtime window after producing a snapshot", () => {
|
||||
const { metrics, advanceClock } = createMetricsWindow();
|
||||
metrics.incrementCounter("helloNew");
|
||||
metrics.recordInboundMessage("session");
|
||||
metrics.recordInboundSessionRequest("send");
|
||||
metrics.recordRequestLatency("send", 12.4);
|
||||
advanceClock(250);
|
||||
|
||||
const firstSnapshot = metrics.snapshotAndReset();
|
||||
const secondSnapshot = metrics.snapshotAndReset();
|
||||
|
||||
expect(firstSnapshot.windowMs).toBe(250);
|
||||
expect(firstSnapshot.counters.helloNew).toBe(1);
|
||||
expect(firstSnapshot.inboundMessageTypesTop).toEqual([["session", 1]]);
|
||||
expect(firstSnapshot.inboundSessionRequestTypesTop).toEqual([["send", 1]]);
|
||||
expect(firstSnapshot.latency).toEqual([
|
||||
{
|
||||
type: "send",
|
||||
count: 1,
|
||||
minMs: 12,
|
||||
maxMs: 12,
|
||||
p50Ms: 12,
|
||||
totalMs: 12,
|
||||
},
|
||||
]);
|
||||
expect(secondSnapshot.windowMs).toBe(0);
|
||||
expect(secondSnapshot.counters.helloNew).toBe(0);
|
||||
expect(secondSnapshot.inboundMessageTypesTop).toEqual([]);
|
||||
expect(secondSnapshot.inboundSessionRequestTypesTop).toEqual([]);
|
||||
expect(secondSnapshot.latency).toEqual([]);
|
||||
});
|
||||
});
|
||||
215
packages/server/src/server/websocket/runtime-metrics.ts
Normal file
215
packages/server/src/server/websocket/runtime-metrics.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import type { SessionOutboundMessage, WSOutboundMessage } from "../messages.js";
|
||||
|
||||
export interface WebSocketRuntimeCounters {
|
||||
connectedAwaitingHello: number;
|
||||
helloResumed: number;
|
||||
helloNew: number;
|
||||
pendingDisconnected: number;
|
||||
sessionDisconnectedWaitingReconnect: number;
|
||||
sessionSocketDisconnectedAttached: number;
|
||||
sessionCleanup: number;
|
||||
validationFailed: number;
|
||||
binaryBeforeHelloRejected: number;
|
||||
pendingMessageRejectedBeforeHello: number;
|
||||
missingConnectionForMessage: number;
|
||||
unexpectedHelloOnActiveConnection: number;
|
||||
relayExternalSocketAttached: number;
|
||||
originRejected: number;
|
||||
hostRejected: number;
|
||||
}
|
||||
|
||||
export interface WebSocketRuntimeMetricsSnapshot {
|
||||
windowMs: number;
|
||||
counters: WebSocketRuntimeCounters;
|
||||
inboundMessageTypesTop: Array<[string, number]>;
|
||||
inboundSessionRequestTypesTop: Array<[string, number]>;
|
||||
outboundMessageTypesTop: Array<[string, number]>;
|
||||
outboundSessionMessageTypesTop: Array<[string, number]>;
|
||||
outboundAgentStreamTypesTop: Array<[string, number]>;
|
||||
outboundAgentStreamAgentsTop: Array<[string, number]>;
|
||||
outboundBinaryFrameTypesTop: Array<[string, number]>;
|
||||
bufferedAmount: {
|
||||
p95: number;
|
||||
max: number;
|
||||
};
|
||||
latency: Array<{
|
||||
type: string;
|
||||
count: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
p50Ms: number;
|
||||
totalMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
type Clock = () => number;
|
||||
|
||||
export class WebSocketRuntimeMetricsWindow {
|
||||
private windowStartedAt: number;
|
||||
private readonly counters: WebSocketRuntimeCounters = createRuntimeCounters();
|
||||
private readonly inboundMessageCounts = new Map<string, number>();
|
||||
private readonly inboundSessionRequestCounts = new Map<string, number>();
|
||||
private readonly outboundMessageCounts = new Map<string, number>();
|
||||
private readonly outboundSessionMessageCounts = new Map<string, number>();
|
||||
private readonly outboundAgentStreamCounts = new Map<string, number>();
|
||||
private readonly outboundAgentStreamByAgentCounts = new Map<string, number>();
|
||||
private readonly outboundBinaryFrameCounts = new Map<string, number>();
|
||||
private readonly bufferedAmountSamples: number[] = [];
|
||||
private readonly requestLatencies = new Map<string, number[]>();
|
||||
|
||||
constructor(private readonly clock: Clock = Date.now) {
|
||||
this.windowStartedAt = this.clock();
|
||||
}
|
||||
|
||||
incrementCounter(counter: keyof WebSocketRuntimeCounters): void {
|
||||
this.counters[counter] += 1;
|
||||
}
|
||||
|
||||
recordInboundMessage(type: string): void {
|
||||
incrementCount(this.inboundMessageCounts, type);
|
||||
}
|
||||
|
||||
recordInboundSessionRequest(type: string): void {
|
||||
incrementCount(this.inboundSessionRequestCounts, type);
|
||||
}
|
||||
|
||||
recordOutboundMessage(message: WSOutboundMessage, bufferedAmount?: number): void {
|
||||
if (message.type !== "session") {
|
||||
incrementCount(this.outboundMessageCounts, message.type);
|
||||
this.recordBufferedAmount(bufferedAmount);
|
||||
return;
|
||||
}
|
||||
|
||||
incrementCount(this.outboundMessageCounts, "session_message");
|
||||
incrementCount(this.outboundSessionMessageCounts, message.message.type);
|
||||
|
||||
if (message.message.type === "agent_stream") {
|
||||
this.recordOutboundAgentStreamMessage(message.message.payload);
|
||||
}
|
||||
|
||||
this.recordBufferedAmount(bufferedAmount);
|
||||
}
|
||||
|
||||
recordOutboundBinaryFrame(bufferedAmount?: number): void {
|
||||
incrementCount(this.outboundBinaryFrameCounts, "binary");
|
||||
this.recordBufferedAmount(bufferedAmount);
|
||||
}
|
||||
|
||||
recordRequestLatency(type: string, durationMs: number): void {
|
||||
let latencies = this.requestLatencies.get(type);
|
||||
if (!latencies) {
|
||||
latencies = [];
|
||||
this.requestLatencies.set(type, latencies);
|
||||
}
|
||||
latencies.push(durationMs);
|
||||
}
|
||||
|
||||
snapshotAndReset(): WebSocketRuntimeMetricsSnapshot {
|
||||
const now = this.clock();
|
||||
const snapshot: WebSocketRuntimeMetricsSnapshot = {
|
||||
windowMs: Math.max(0, now - this.windowStartedAt),
|
||||
counters: { ...this.counters },
|
||||
inboundMessageTypesTop: getTopCounts(this.inboundMessageCounts, 12),
|
||||
inboundSessionRequestTypesTop: getTopCounts(this.inboundSessionRequestCounts, 20),
|
||||
outboundMessageTypesTop: getTopCounts(this.outboundMessageCounts, 12),
|
||||
outboundSessionMessageTypesTop: getTopCounts(this.outboundSessionMessageCounts, 20),
|
||||
outboundAgentStreamTypesTop: getTopCounts(this.outboundAgentStreamCounts, 20),
|
||||
outboundAgentStreamAgentsTop: getTopCounts(this.outboundAgentStreamByAgentCounts, 20),
|
||||
outboundBinaryFrameTypesTop: getTopCounts(this.outboundBinaryFrameCounts, 12),
|
||||
bufferedAmount: this.computeBufferedAmountStats(),
|
||||
latency: this.computeLatencyStats(),
|
||||
};
|
||||
|
||||
this.reset(now);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private recordOutboundAgentStreamMessage(
|
||||
payload: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"],
|
||||
): void {
|
||||
const { agentId, event } = payload;
|
||||
const eventType = event.type === "timeline" ? `timeline:${event.item.type}` : event.type;
|
||||
incrementCount(this.outboundAgentStreamCounts, eventType);
|
||||
incrementCount(this.outboundAgentStreamByAgentCounts, agentId);
|
||||
}
|
||||
|
||||
private recordBufferedAmount(bufferedAmount: number | undefined): void {
|
||||
if (typeof bufferedAmount !== "number") {
|
||||
return;
|
||||
}
|
||||
this.bufferedAmountSamples.push(bufferedAmount);
|
||||
}
|
||||
|
||||
private computeLatencyStats(): WebSocketRuntimeMetricsSnapshot["latency"] {
|
||||
const stats: WebSocketRuntimeMetricsSnapshot["latency"] = [];
|
||||
for (const [type, latencies] of this.requestLatencies) {
|
||||
if (latencies.length === 0) continue;
|
||||
const sortedLatencies = [...latencies].sort((a, b) => a - b);
|
||||
const count = sortedLatencies.length;
|
||||
const minMs = Math.round(sortedLatencies[0]);
|
||||
const maxMs = Math.round(sortedLatencies[count - 1]);
|
||||
const p50Ms = Math.round(sortedLatencies[Math.floor(count / 2)]);
|
||||
const totalMs = Math.round(sortedLatencies.reduce((sum, value) => sum + value, 0));
|
||||
stats.push({ type, count, minMs, maxMs, p50Ms, totalMs });
|
||||
}
|
||||
stats.sort((a, b) => b.totalMs - a.totalMs);
|
||||
return stats.slice(0, 15);
|
||||
}
|
||||
|
||||
private computeBufferedAmountStats(): WebSocketRuntimeMetricsSnapshot["bufferedAmount"] {
|
||||
if (this.bufferedAmountSamples.length === 0) {
|
||||
return { p95: 0, max: 0 };
|
||||
}
|
||||
|
||||
const samples = [...this.bufferedAmountSamples].sort((a, b) => a - b);
|
||||
const p95Index = Math.ceil(samples.length * 0.95) - 1;
|
||||
return {
|
||||
p95: samples[p95Index] ?? 0,
|
||||
max: samples[samples.length - 1] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
private reset(windowStartedAt: number): void {
|
||||
for (const counter of Object.keys(this.counters) as Array<keyof WebSocketRuntimeCounters>) {
|
||||
this.counters[counter] = 0;
|
||||
}
|
||||
this.inboundMessageCounts.clear();
|
||||
this.inboundSessionRequestCounts.clear();
|
||||
this.outboundMessageCounts.clear();
|
||||
this.outboundSessionMessageCounts.clear();
|
||||
this.outboundAgentStreamCounts.clear();
|
||||
this.outboundAgentStreamByAgentCounts.clear();
|
||||
this.outboundBinaryFrameCounts.clear();
|
||||
this.bufferedAmountSamples.length = 0;
|
||||
this.requestLatencies.clear();
|
||||
this.windowStartedAt = windowStartedAt;
|
||||
}
|
||||
}
|
||||
|
||||
function createRuntimeCounters(): WebSocketRuntimeCounters {
|
||||
return {
|
||||
connectedAwaitingHello: 0,
|
||||
helloResumed: 0,
|
||||
helloNew: 0,
|
||||
pendingDisconnected: 0,
|
||||
sessionDisconnectedWaitingReconnect: 0,
|
||||
sessionSocketDisconnectedAttached: 0,
|
||||
sessionCleanup: 0,
|
||||
validationFailed: 0,
|
||||
binaryBeforeHelloRejected: 0,
|
||||
pendingMessageRejectedBeforeHello: 0,
|
||||
missingConnectionForMessage: 0,
|
||||
unexpectedHelloOnActiveConnection: 0,
|
||||
relayExternalSocketAttached: 0,
|
||||
originRejected: 0,
|
||||
hostRejected: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function incrementCount(map: Map<string, number>, key: string): void {
|
||||
map.set(key, (map.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
function getTopCounts(map: Map<string, number>, limit: number): Array<[string, number]> {
|
||||
return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
|
||||
}
|
||||
@@ -13,11 +13,6 @@ import { isPlatform } from "../test-utils/platform.js";
|
||||
|
||||
const REPO_CWD = path.resolve("/tmp/repo");
|
||||
|
||||
interface ServiceInternals {
|
||||
workingTreeWatchTargets: Map<string, { fallbackRefreshInterval: unknown; repoWatchPath: string }>;
|
||||
scheduleWorkspaceRefresh(cwd: string, options: { force: boolean; reason: string }): void;
|
||||
}
|
||||
|
||||
function createLogger() {
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
@@ -740,9 +735,8 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
|
||||
const service = createService({ watch });
|
||||
const subscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn());
|
||||
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(REPO_CWD);
|
||||
|
||||
expect(target?.fallbackRefreshInterval).not.toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
@@ -762,7 +756,6 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
|
||||
const plainCwd = path.join(os.tmpdir(), "plain");
|
||||
const subscription = await service.requestWorkingTreeWatch(plainCwd, vi.fn());
|
||||
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(plainCwd);
|
||||
|
||||
expect(subscription.repoRoot).toBeNull();
|
||||
const expectedRecursive = process.platform !== "linux";
|
||||
@@ -771,14 +764,13 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
{ recursive: expectedRecursive },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(target?.repoWatchPath).toBe(plainCwd);
|
||||
expect(target?.fallbackRefreshInterval).not.toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("working tree changes notify listeners and schedule workspace refresh", async () => {
|
||||
test("working tree changes notify watch listeners immediately", async () => {
|
||||
const watchCallbacks: Array<() => void> = [];
|
||||
const watch = vi.fn(
|
||||
(_watchPath: string, _options: { recursive: boolean }, callback: () => void) => {
|
||||
@@ -787,7 +779,6 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
},
|
||||
);
|
||||
const service = createService({ watch });
|
||||
const refreshSpy = vi.spyOn(service as unknown as ServiceInternals, "scheduleWorkspaceRefresh");
|
||||
const listener = vi.fn();
|
||||
|
||||
const subscription = await service.requestWorkingTreeWatch(REPO_CWD, listener);
|
||||
@@ -796,10 +787,6 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
watchCallbacks[0]?.();
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(refreshSpy).toHaveBeenCalledWith(REPO_CWD, {
|
||||
force: true,
|
||||
reason: "working-tree-watch",
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("connection offer", () => {
|
||||
expect(parseConnectionOfferFromUrl(`https://app.paseo.sh/#offer=${encoded}`)).toEqual(offer);
|
||||
});
|
||||
|
||||
it("defaults relay TLS to false when absent", () => {
|
||||
it("leaves relay TLS unset when absent", () => {
|
||||
expect(
|
||||
ConnectionOfferSchema.parse({
|
||||
v: 2,
|
||||
@@ -52,7 +52,7 @@ describe("connection offer", () => {
|
||||
v: 2,
|
||||
serverId: "server-123",
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
relay: { endpoint: "relay.example.com:80", useTls: false },
|
||||
relay: { endpoint: "relay.example.com:80" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export const ConnectionOfferV2Schema = z.object({
|
||||
daemonPublicKeyB64: z.string().min(1),
|
||||
relay: z.object({
|
||||
endpoint: z.string().min(1),
|
||||
useTls: z.boolean().optional().default(false),
|
||||
useTls: z.boolean().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
134
packages/server/src/tasks/task-document.ts
Normal file
134
packages/server/src/tasks/task-document.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import type { Task, TaskStatus } from "./types.js";
|
||||
|
||||
function serializeTask(task: Task): string {
|
||||
const frontmatterLines = [
|
||||
"---",
|
||||
`id: ${task.id}`,
|
||||
`title: ${task.title}`,
|
||||
`status: ${task.status}`,
|
||||
`deps: [${task.deps.join(", ")}]`,
|
||||
`created: ${task.created}`,
|
||||
];
|
||||
|
||||
if (task.parentId) {
|
||||
frontmatterLines.push(`parentId: ${task.parentId}`);
|
||||
}
|
||||
|
||||
if (task.assignee) {
|
||||
frontmatterLines.push(`assignee: ${task.assignee}`);
|
||||
}
|
||||
|
||||
if (task.priority !== undefined) {
|
||||
frontmatterLines.push(`priority: ${task.priority}`);
|
||||
}
|
||||
|
||||
frontmatterLines.push("---");
|
||||
|
||||
const frontmatter = frontmatterLines.join("\n");
|
||||
|
||||
let content = "";
|
||||
if (task.body) {
|
||||
content += task.body + "\n";
|
||||
}
|
||||
|
||||
if (task.acceptanceCriteria.length > 0) {
|
||||
content += "\n## Acceptance Criteria\n\n";
|
||||
for (const criterion of task.acceptanceCriteria) {
|
||||
content += `- [ ] ${criterion}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (task.notes.length > 0) {
|
||||
content += "\n## Notes\n";
|
||||
for (const note of task.notes) {
|
||||
content += `\n**${note.timestamp}**\n\n${note.content}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return frontmatter + "\n\n" + content;
|
||||
}
|
||||
|
||||
function parseTask(content: string): Task {
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
|
||||
if (!frontmatterMatch) {
|
||||
throw new Error("Invalid task file: missing frontmatter");
|
||||
}
|
||||
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const fileBody = content.slice(frontmatterMatch[0].length);
|
||||
|
||||
const getValue = (key: string): string => {
|
||||
const match = frontmatter.match(new RegExp(`^${key}: (.*)$`, "m"));
|
||||
return match ? match[1] : "";
|
||||
};
|
||||
|
||||
const depsStr = getValue("deps");
|
||||
const depsMatch = depsStr.match(/\[(.*)\]/);
|
||||
const deps =
|
||||
depsMatch && depsMatch[1].trim()
|
||||
? depsMatch[1]
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const notes: Task["notes"] = [];
|
||||
const notesSection = fileBody.match(/## Notes\n([\s\S]*?)$/);
|
||||
if (notesSection) {
|
||||
const noteMatches = notesSection[1].matchAll(
|
||||
/\*\*(\d{4}-\d{2}-\d{2}T[\d:.Z]+)\*\*\n\n([\s\S]*?)(?=\n\*\*\d{4}|$)/g,
|
||||
);
|
||||
for (const match of noteMatches) {
|
||||
notes.push({
|
||||
timestamp: match[1],
|
||||
content: match[2].trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const acceptanceCriteria: string[] = [];
|
||||
const criteriaSection = fileBody.match(/## Acceptance Criteria\n\n([\s\S]*?)(?=\n## Notes|$)/);
|
||||
if (criteriaSection) {
|
||||
const criteriaMatches = criteriaSection[1].matchAll(/- \[[ x]\] (.+)$/gm);
|
||||
for (const match of criteriaMatches) {
|
||||
acceptanceCriteria.push(match[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
let taskBody = fileBody;
|
||||
const firstSection = fileBody.match(/\n## (Acceptance Criteria|Notes)\n/);
|
||||
if (firstSection) {
|
||||
taskBody = fileBody.slice(0, firstSection.index).trim();
|
||||
}
|
||||
taskBody = taskBody.trim();
|
||||
|
||||
const assignee = getValue("assignee");
|
||||
const parentId = getValue("parentId");
|
||||
const priorityStr = getValue("priority");
|
||||
const priority = priorityStr ? parseInt(priorityStr, 10) : undefined;
|
||||
|
||||
return {
|
||||
id: getValue("id"),
|
||||
title: getValue("title"),
|
||||
status: getValue("status") as TaskStatus,
|
||||
deps,
|
||||
parentId: parentId || undefined,
|
||||
body: taskBody,
|
||||
acceptanceCriteria,
|
||||
notes,
|
||||
created: getValue("created") || new Date().toISOString(),
|
||||
assignee: assignee || undefined,
|
||||
priority,
|
||||
raw: content,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readTaskDocument(filePath: string): Promise<Task> {
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
return parseTask(content);
|
||||
}
|
||||
|
||||
export async function writeTaskDocument(filePath: string, task: Task): Promise<void> {
|
||||
await writeFile(filePath, serializeTask(task), "utf-8");
|
||||
}
|
||||
@@ -1,144 +1,19 @@
|
||||
import { readdir, readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
||||
import { readdir, mkdir, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { Task, TaskStore, CreateTaskOptions, TaskStatus } from "./types.js";
|
||||
import type { Task, TaskStore, CreateTaskOptions } from "./types.js";
|
||||
import {
|
||||
isBlockedTask,
|
||||
isReadyTask,
|
||||
loadScopedTaskGraph,
|
||||
sortByPriorityThenCreated,
|
||||
} from "./task-graph.js";
|
||||
import { readTaskDocument, writeTaskDocument } from "./task-document.js";
|
||||
|
||||
function generateId(): string {
|
||||
return randomBytes(4).toString("hex");
|
||||
}
|
||||
|
||||
function serializeTask(task: Task): string {
|
||||
const frontmatterLines = [
|
||||
"---",
|
||||
`id: ${task.id}`,
|
||||
`title: ${task.title}`,
|
||||
`status: ${task.status}`,
|
||||
`deps: [${task.deps.join(", ")}]`,
|
||||
`created: ${task.created}`,
|
||||
];
|
||||
|
||||
if (task.parentId) {
|
||||
frontmatterLines.push(`parentId: ${task.parentId}`);
|
||||
}
|
||||
|
||||
if (task.assignee) {
|
||||
frontmatterLines.push(`assignee: ${task.assignee}`);
|
||||
}
|
||||
|
||||
if (task.priority !== undefined) {
|
||||
frontmatterLines.push(`priority: ${task.priority}`);
|
||||
}
|
||||
|
||||
frontmatterLines.push("---");
|
||||
|
||||
const frontmatter = frontmatterLines.join("\n");
|
||||
|
||||
let content = "";
|
||||
if (task.body) {
|
||||
content += task.body + "\n";
|
||||
}
|
||||
|
||||
if (task.acceptanceCriteria.length > 0) {
|
||||
content += "\n## Acceptance Criteria\n\n";
|
||||
for (const criterion of task.acceptanceCriteria) {
|
||||
content += `- [ ] ${criterion}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (task.notes.length > 0) {
|
||||
content += "\n## Notes\n";
|
||||
for (const note of task.notes) {
|
||||
content += `\n**${note.timestamp}**\n\n${note.content}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return frontmatter + "\n\n" + content;
|
||||
}
|
||||
|
||||
function parseTask(content: string): Task {
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
|
||||
if (!frontmatterMatch) {
|
||||
throw new Error("Invalid task file: missing frontmatter");
|
||||
}
|
||||
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const fileBody = content.slice(frontmatterMatch[0].length);
|
||||
|
||||
const getValue = (key: string): string => {
|
||||
const match = frontmatter.match(new RegExp(`^${key}: (.*)$`, "m"));
|
||||
return match ? match[1] : "";
|
||||
};
|
||||
|
||||
const depsStr = getValue("deps");
|
||||
const depsMatch = depsStr.match(/\[(.*)\]/);
|
||||
const deps =
|
||||
depsMatch && depsMatch[1].trim()
|
||||
? depsMatch[1]
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
// Parse notes from body
|
||||
const notes: Task["notes"] = [];
|
||||
const notesSection = fileBody.match(/## Notes\n([\s\S]*?)$/);
|
||||
if (notesSection) {
|
||||
const noteMatches = notesSection[1].matchAll(
|
||||
/\*\*(\d{4}-\d{2}-\d{2}T[\d:.Z]+)\*\*\n\n([\s\S]*?)(?=\n\*\*\d{4}|$)/g,
|
||||
);
|
||||
for (const match of noteMatches) {
|
||||
notes.push({
|
||||
timestamp: match[1],
|
||||
content: match[2].trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse acceptance criteria
|
||||
const acceptanceCriteria: string[] = [];
|
||||
const criteriaSection = fileBody.match(/## Acceptance Criteria\n\n([\s\S]*?)(?=\n## Notes|$)/);
|
||||
if (criteriaSection) {
|
||||
const criteriaMatches = criteriaSection[1].matchAll(/- \[[ x]\] (.+)$/gm);
|
||||
for (const match of criteriaMatches) {
|
||||
acceptanceCriteria.push(match[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
// Body is everything before ## Acceptance Criteria or ## Notes
|
||||
let taskBody = fileBody;
|
||||
const firstSection = fileBody.match(/\n## (Acceptance Criteria|Notes)\n/);
|
||||
if (firstSection) {
|
||||
taskBody = fileBody.slice(0, firstSection.index).trim();
|
||||
}
|
||||
taskBody = taskBody.trim();
|
||||
|
||||
const assignee = getValue("assignee");
|
||||
const parentId = getValue("parentId");
|
||||
const priorityStr = getValue("priority");
|
||||
const priority = priorityStr ? parseInt(priorityStr, 10) : undefined;
|
||||
|
||||
return {
|
||||
id: getValue("id"),
|
||||
title: getValue("title"),
|
||||
status: getValue("status") as TaskStatus,
|
||||
deps,
|
||||
parentId: parentId || undefined,
|
||||
body: taskBody,
|
||||
acceptanceCriteria,
|
||||
notes,
|
||||
created: getValue("created") || new Date().toISOString(),
|
||||
assignee: assignee || undefined,
|
||||
priority,
|
||||
raw: content,
|
||||
};
|
||||
}
|
||||
|
||||
export class FileTaskStore implements TaskStore {
|
||||
constructor(private readonly dir: string) {}
|
||||
|
||||
@@ -152,8 +27,7 @@ export class FileTaskStore implements TaskStore {
|
||||
|
||||
private async readTask(id: string): Promise<Task | null> {
|
||||
try {
|
||||
const content = await readFile(this.taskPath(id), "utf-8");
|
||||
return parseTask(content);
|
||||
return await readTaskDocument(this.taskPath(id));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
@@ -164,7 +38,7 @@ export class FileTaskStore implements TaskStore {
|
||||
|
||||
private async writeTask(task: Task): Promise<void> {
|
||||
await this.ensureDir();
|
||||
await writeFile(this.taskPath(task.id), serializeTask(task), "utf-8");
|
||||
await writeTaskDocument(this.taskPath(task.id), task);
|
||||
}
|
||||
|
||||
async list(): Promise<Task[]> {
|
||||
|
||||
@@ -54,6 +54,7 @@ export function runGitCommand(
|
||||
const child = spawnProcess("git", args, {
|
||||
cwd: options.cwd,
|
||||
envOverlay: mergeEnvOverlays(options.env, options.envOverlay),
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { runGitCommand } from "./run-git-command.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempRepo(): string {
|
||||
const repo = mkdtempSync(path.join(tmpdir(), "paseo-git-shell-"));
|
||||
tempDirs.push(repo);
|
||||
return repo;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
describe("runGitCommand shell behavior", () => {
|
||||
it("passes git arguments directly instead of through the platform shell", async () => {
|
||||
const repo = makeTempRepo();
|
||||
const literalName = "%PASEO_GIT_SHELL_SENTINEL%";
|
||||
const expandedName = "expanded-by-cmd";
|
||||
|
||||
await runGitCommand(["init"], { cwd: repo });
|
||||
writeFileSync(path.join(repo, literalName), "literal\n");
|
||||
writeFileSync(path.join(repo, expandedName), "expanded\n");
|
||||
await runGitCommand(["add", literalName, expandedName], { cwd: repo });
|
||||
|
||||
const result = await runGitCommand(["ls-files", "--error-unmatch", literalName], {
|
||||
cwd: repo,
|
||||
envOverlay: {
|
||||
PASEO_GIT_SHELL_SENTINEL: expandedName,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.stdout.trim()).toBe(literalName);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.73",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user