merge: integrate main into dev
Resolve 9 merge conflicts preserving both sides' intent: - nix hash, bootstrap hostnames rename, websocket-server wiring - session.ts handler dedup, snapshot fallback, worktree registration - worktree-session git service + script/setup features - sidebar-workspace-list setup navigation, session-context cleanup - workspace-registry-bootstrap test kept (code still exists) - checkout-git test additions from both sides Post-merge fixes: isPaneFocused prop rename, server type rebuild.
This commit is contained in:
@@ -47,6 +47,12 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
|||||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||||
|
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
|
||||||
|
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
|
||||||
|
- Never run `npm run test` for an entire workspace unless explicitly asked.
|
||||||
|
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
|
||||||
|
- Never re-run a test suite that another agent already ran and reported green — trust the result.
|
||||||
|
- For full suite verification, push to CI and check GitHub Actions instead.
|
||||||
- **Always run typecheck after every change.**
|
- **Always run typecheck after every change.**
|
||||||
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
|
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
|
||||||
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for
|
|||||||
|
|
||||||
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
||||||
|
|
||||||
Paseo uses a host allowlist to validate the `Host` header on incoming requests. Requests with unrecognized hosts are rejected.
|
Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
|
||||||
|
|
||||||
## Agent authentication
|
## Agent authentication
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const daemon = await createPaseoDaemon(
|
|||||||
listen: "127.0.0.1:0", // OS picks a free port
|
listen: "127.0.0.1:0", // OS picks a free port
|
||||||
paseoHome,
|
paseoHome,
|
||||||
corsAllowedOrigins: [],
|
corsAllowedOrigins: [],
|
||||||
allowedHosts: true,
|
hostnames: true,
|
||||||
mcpEnabled: false,
|
mcpEnabled: false,
|
||||||
staticDir,
|
staticDir,
|
||||||
mcpDebug: false,
|
mcpDebug: false,
|
||||||
|
|||||||
+1
-1
@@ -130,7 +130,7 @@ Single file, validated with `PersistedConfigSchema`.
|
|||||||
version: 1,
|
version: 1,
|
||||||
daemon: {
|
daemon: {
|
||||||
listen: "127.0.0.1:6767",
|
listen: "127.0.0.1:6767",
|
||||||
allowedHosts: true | string[],
|
hostnames: true | string[],
|
||||||
mcp: { enabled: boolean },
|
mcp: { enabled: boolean },
|
||||||
cors: { allowedOrigins: string[] },
|
cors: { allowedOrigins: string[] },
|
||||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
||||||
|
|||||||
+10
-6
@@ -9,6 +9,10 @@ let
|
|||||||
cfg = config.services.paseo;
|
cfg = config.services.paseo;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
imports = [
|
||||||
|
(lib.mkRenamedOptionModule [ "services" "paseo" "allowedHosts" ] [ "services" "paseo" "hostnames" ])
|
||||||
|
];
|
||||||
|
|
||||||
options.services.paseo = {
|
options.services.paseo = {
|
||||||
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
|
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
|
||||||
|
|
||||||
@@ -58,12 +62,12 @@ in
|
|||||||
description = "Whether to open the firewall for the Paseo daemon port.";
|
description = "Whether to open the firewall for the Paseo daemon port.";
|
||||||
};
|
};
|
||||||
|
|
||||||
allowedHosts = lib.mkOption {
|
hostnames = lib.mkOption {
|
||||||
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
||||||
default = [ ];
|
default = [ ];
|
||||||
example = [ ".example.com" "myhost.local" ];
|
example = [ ".example.com" "myhost.local" ];
|
||||||
description = ''
|
description = ''
|
||||||
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
|
Hostnames the Paseo daemon accepts in the Host header (DNS rebinding protection).
|
||||||
Localhost and IP addresses are always allowed by default.
|
Localhost and IP addresses are always allowed by default.
|
||||||
|
|
||||||
Use a leading dot to match a domain and all its subdomains
|
Use a leading dot to match a domain and all its subdomains
|
||||||
@@ -141,10 +145,10 @@ in
|
|||||||
"/run/wrappers/bin"
|
"/run/wrappers/bin"
|
||||||
"/nix/var/nix/profiles/default/bin"
|
"/nix/var/nix/profiles/default/bin"
|
||||||
]);
|
]);
|
||||||
} // lib.optionalAttrs (cfg.allowedHosts == true) {
|
} // lib.optionalAttrs (cfg.hostnames == true) {
|
||||||
PASEO_ALLOWED_HOSTS = "true";
|
PASEO_HOSTNAMES = "true";
|
||||||
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
|
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
||||||
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
|
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
||||||
} // cfg.environment;
|
} // cfg.environment;
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-7AL13lVZq/+K8G7SFHVCtWjK5cBL7XN2kLTULONv/Xs=";
|
npmDepsHash = "sha256-NBB8+DNyodnREhVEiNMLADd+4MMK4lssRuzlwvvPjk8=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+19
-19
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -34854,16 +34854,16 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.55-rc.2",
|
"@getpaseo/expo-two-way-audio": "0.1.56",
|
||||||
"@getpaseo/highlight": "0.1.55-rc.2",
|
"@getpaseo/highlight": "0.1.56",
|
||||||
"@getpaseo/server": "0.1.55-rc.2",
|
"@getpaseo/server": "0.1.56",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -35004,11 +35004,11 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.55-rc.2",
|
"@getpaseo/relay": "0.1.56",
|
||||||
"@getpaseo/server": "0.1.55-rc.2",
|
"@getpaseo/server": "0.1.56",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -35049,11 +35049,11 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.55-rc.2",
|
"@getpaseo/cli": "0.1.56",
|
||||||
"@getpaseo/server": "0.1.55-rc.2",
|
"@getpaseo/server": "0.1.56",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
@@ -35087,7 +35087,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
@@ -35288,7 +35288,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
@@ -35314,7 +35314,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -35330,14 +35330,14 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "0.1.55-rc.2",
|
"@getpaseo/highlight": "0.1.56",
|
||||||
"@getpaseo/relay": "0.1.55-rc.2",
|
"@getpaseo/relay": "0.1.56",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
@@ -35765,7 +35765,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.20.3",
|
"@cloudflare/vite-plugin": "^1.20.3",
|
||||||
"@cloudflare/workers-types": "^4.20260114.0",
|
"@cloudflare/workers-types": "^4.20260114.0",
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "./scripts/dev.sh",
|
"dev": "./scripts/dev.sh",
|
||||||
|
"dev:win": "powershell ./scripts/dev.ps1",
|
||||||
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
||||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||||
"web": "npm run web --workspace=@getpaseo/app",
|
"web": "npm run web --workspace=@getpaseo/app",
|
||||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||||
|
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
|
||||||
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
||||||
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||||
"cli": "npx tsx packages/cli/src/index.js",
|
"cli": "npx tsx packages/cli/src/index.js",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -224,6 +224,51 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
|||||||
return offer as OfferPayload;
|
return offer as OfferPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload {
|
||||||
|
const stdout = execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"],
|
||||||
|
{
|
||||||
|
cwd: repoRoot,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PASEO_HOME: paseoHomePath,
|
||||||
|
},
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null };
|
||||||
|
if (payload.relayEnabled !== true || typeof payload.url !== "string") {
|
||||||
|
throw new Error(`Unexpected daemon pair response: ${stdout}`);
|
||||||
|
}
|
||||||
|
return decodeOfferFromFragmentUrl(payload.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPairingOfferFromCli(args: {
|
||||||
|
repoRoot: string;
|
||||||
|
paseoHome: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<OfferPayload> {
|
||||||
|
const timeoutMs = args.timeoutMs ?? 15000;
|
||||||
|
const start = Date.now();
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
return loadPairingOfferFromCli(args.repoRoot, args.paseoHome);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${
|
||||||
|
lastError instanceof Error ? lastError.message : String(lastError)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function globalSetup() {
|
export default async function globalSetup() {
|
||||||
const repoRoot = path.resolve(__dirname, "../../..");
|
const repoRoot = path.resolve(__dirname, "../../..");
|
||||||
ensureRelayBuildArtifact(repoRoot);
|
ensureRelayBuildArtifact(repoRoot);
|
||||||
@@ -433,12 +478,6 @@ export default async function globalSetup() {
|
|||||||
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||||
const tsxBin = execSync("which tsx").toString().trim();
|
const tsxBin = execSync("which tsx").toString().trim();
|
||||||
|
|
||||||
let offerPayload: OfferPayload | null = null;
|
|
||||||
let offerResolve: (() => void) | null = null;
|
|
||||||
const offerPromise = new Promise<void>((resolve) => {
|
|
||||||
offerResolve = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
|
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
|
||||||
cwd: serverDir,
|
cwd: serverDir,
|
||||||
env: {
|
env: {
|
||||||
@@ -473,26 +512,6 @@ export default async function globalSetup() {
|
|||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
daemonLineBuffer.add(`[stdout] ${trimmed}`);
|
daemonLineBuffer.add(`[stdout] ${trimmed}`);
|
||||||
if (!offerPayload) {
|
|
||||||
const clean = stripAnsi(trimmed);
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
|
||||||
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
|
|
||||||
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
|
||||||
offerResolve?.();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
|
||||||
if (match && clean.includes("pairing_offer")) {
|
|
||||||
try {
|
|
||||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
|
||||||
offerResolve?.();
|
|
||||||
} catch {
|
|
||||||
// ignore parsing failures
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`[daemon] ${trimmed}`);
|
console.log(`[daemon] ${trimmed}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -523,17 +542,10 @@ export default async function globalSetup() {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Wait for daemon to emit a pairing offer (includes relay session ID).
|
const offer = await waitForPairingOfferFromCli({
|
||||||
await Promise.race([
|
repoRoot,
|
||||||
offerPromise,
|
paseoHome,
|
||||||
new Promise((_, reject) =>
|
});
|
||||||
setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
if (!offerPayload) {
|
|
||||||
throw new Error("pairing_offer was not parsed from daemon logs");
|
|
||||||
}
|
|
||||||
const offer = offerPayload as OfferPayload;
|
|
||||||
|
|
||||||
process.env.E2E_DAEMON_PORT = String(port);
|
process.env.E2E_DAEMON_PORT = String(port);
|
||||||
process.env.E2E_RELAY_PORT = String(relayPort);
|
process.env.E2E_RELAY_PORT = String(relayPort);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.55-rc.2",
|
"@getpaseo/expo-two-way-audio": "0.1.56",
|
||||||
"@getpaseo/highlight": "0.1.55-rc.2",
|
"@getpaseo/highlight": "0.1.56",
|
||||||
"@getpaseo/server": "0.1.55-rc.2",
|
"@getpaseo/server": "0.1.56",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
|||||||
@@ -43,12 +43,17 @@ export function BranchSwitcher({
|
|||||||
queryClient,
|
queryClient,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!currentBranchName) {
|
const titleContent = (
|
||||||
return (
|
<>
|
||||||
|
<GitBranch size={14} color={theme.colors.foregroundMuted} />
|
||||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!currentBranchName) {
|
||||||
|
return <View style={styles.branchSwitcherTrigger}>{titleContent}</View>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,10 +68,7 @@ export function BranchSwitcher({
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
||||||
>
|
>
|
||||||
<GitBranch size={14} color={theme.colors.foregroundMuted} />
|
{titleContent}
|
||||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
|
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Combobox
|
<Combobox
|
||||||
@@ -117,7 +119,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
xs: -theme.spacing[2],
|
xs: -theme.spacing[2],
|
||||||
md: 0,
|
md: 0,
|
||||||
},
|
},
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: {
|
||||||
|
xs: 0,
|
||||||
|
md: theme.spacing[1],
|
||||||
|
},
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA
|
|||||||
interface ComposerProps {
|
interface ComposerProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
isInputActive: boolean;
|
isPaneFocused: boolean;
|
||||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||||
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
|
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
|
||||||
hasExternalContent?: boolean;
|
hasExternalContent?: boolean;
|
||||||
@@ -106,7 +106,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
|||||||
export function Composer({
|
export function Composer({
|
||||||
agentId,
|
agentId,
|
||||||
serverId,
|
serverId,
|
||||||
isInputActive,
|
isPaneFocused,
|
||||||
onSubmitMessage,
|
onSubmitMessage,
|
||||||
hasExternalContent = false,
|
hasExternalContent = false,
|
||||||
allowEmptySubmit = false,
|
allowEmptySubmit = false,
|
||||||
@@ -435,7 +435,7 @@ export function Composer({
|
|||||||
|
|
||||||
const handleKeyboardAction = useCallback(
|
const handleKeyboardAction = useCallback(
|
||||||
(action: KeyboardActionDefinition): boolean => {
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
if (!isInputActive) {
|
if (!isPaneFocused) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,7 +479,7 @@ export function Composer({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isInputActive],
|
[isPaneFocused],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
@@ -493,9 +493,9 @@ export function Composer({
|
|||||||
"message-input.voice-toggle",
|
"message-input.voice-toggle",
|
||||||
"message-input.voice-mute-toggle",
|
"message-input.voice-mute-toggle",
|
||||||
],
|
],
|
||||||
enabled: isInputActive,
|
enabled: isPaneFocused,
|
||||||
priority: isMessageInputFocused ? 200 : 100,
|
priority: isMessageInputFocused ? 200 : 100,
|
||||||
isActive: () => isInputActive,
|
isActive: () => isPaneFocused,
|
||||||
handle: handleKeyboardAction,
|
handle: handleKeyboardAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -766,7 +766,7 @@ export function Composer({
|
|||||||
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
||||||
autoFocusKey={`${serverId}:${agentId}`}
|
autoFocusKey={`${serverId}:${agentId}`}
|
||||||
disabled={isSubmitLoading}
|
disabled={isSubmitLoading}
|
||||||
isInputActive={isInputActive}
|
isPaneFocused={isPaneFocused}
|
||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
beforeVoiceContent={beforeVoiceContent}
|
beforeVoiceContent={beforeVoiceContent}
|
||||||
rightContent={rightContent}
|
rightContent={rightContent}
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ export function FilePane({
|
|||||||
return { file: payload.file ?? null, error: payload.error ?? null };
|
return { file: payload.file ?? null, error: payload.error ?? null };
|
||||||
},
|
},
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
|
refetchOnMount: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
|
import { formatShortcut } from "@/utils/format-shortcut";
|
||||||
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||||
import {
|
import {
|
||||||
markScrollInvestigationEvent,
|
markScrollInvestigationEvent,
|
||||||
@@ -81,8 +83,8 @@ export interface MessageInputProps {
|
|||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
autoFocusKey?: string;
|
autoFocusKey?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
|
/** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */
|
||||||
isInputActive?: boolean;
|
isPaneFocused?: boolean;
|
||||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||||
leftContent?: React.ReactNode;
|
leftContent?: React.ReactNode;
|
||||||
/** Content to render on the right side before the voice button (e.g., context window meter) */
|
/** Content to render on the right side before the voice button (e.g., context window meter) */
|
||||||
@@ -219,7 +221,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
autoFocusKey,
|
autoFocusKey,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
isInputActive = true,
|
isPaneFocused = true,
|
||||||
leftContent,
|
leftContent,
|
||||||
beforeVoiceContent,
|
beforeVoiceContent,
|
||||||
rightContent,
|
rightContent,
|
||||||
@@ -247,7 +249,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
|
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
|
||||||
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
|
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
|
||||||
const queueKeys = useShortcutKeys("message-input-queue");
|
const queueKeys = useShortcutKeys("message-input-queue");
|
||||||
|
const focusInputKeys = useShortcutKeys("focus-message-input");
|
||||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||||
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||||
const rootRef = useRef<View | null>(null);
|
const rootRef = useRef<View | null>(null);
|
||||||
const inputWrapperRef = useRef<View | null>(null);
|
const inputWrapperRef = useRef<View | null>(null);
|
||||||
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
|
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
|
||||||
@@ -441,7 +445,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onError: handleDictationError,
|
onError: handleDictationError,
|
||||||
canStart: canStartDictation,
|
canStart: canStartDictation,
|
||||||
canConfirm: canConfirmDictation,
|
canConfirm: canConfirmDictation,
|
||||||
autoStopWhenHidden: { isVisible: isInputActive },
|
autoStopWhenHidden: { isVisible: isPaneFocused },
|
||||||
enableDuration: true,
|
enableDuration: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1025,10 +1029,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
accessibilityLabel="Message agent..."
|
accessibilityLabel="Message agent..."
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
isInputFocusedRef.current = true;
|
isInputFocusedRef.current = true;
|
||||||
|
setIsInputFocused(true);
|
||||||
onFocusChange?.(true);
|
onFocusChange?.(true);
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
isInputFocusedRef.current = false;
|
isInputFocusedRef.current = false;
|
||||||
|
setIsInputFocused(false);
|
||||||
onFocusChange?.(false);
|
onFocusChange?.(false);
|
||||||
}}
|
}}
|
||||||
style={[
|
style={[
|
||||||
@@ -1053,6 +1059,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
autoFocus={isWeb && autoFocus}
|
autoFocus={isWeb && autoFocus}
|
||||||
/>
|
/>
|
||||||
{inputScrollbar}
|
{inputScrollbar}
|
||||||
|
{isWeb && isPaneFocused && !isInputFocused && !value && focusInputKeys ? (
|
||||||
|
<Text style={styles.focusHintText} pointerEvents="none">
|
||||||
|
{formatShortcut(focusInputKeys[0], getShortcutOs())} to focus
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Button row */}
|
{/* Button row */}
|
||||||
@@ -1313,6 +1324,14 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
textInputScrollWrapper: {
|
textInputScrollWrapper: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
},
|
},
|
||||||
|
focusHintText: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
textInput: {
|
textInput: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ export function WorkspaceSetupDialog() {
|
|||||||
<Composer
|
<Composer
|
||||||
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
|
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
isInputActive={true}
|
isPaneFocused={true}
|
||||||
onSubmitMessage={handleCreateChatAgent}
|
onSubmitMessage={handleCreateChatAgent}
|
||||||
isSubmitLoading={pendingAction === "chat"}
|
isSubmitLoading={pendingAction === "chat"}
|
||||||
blurOnSubmit={true}
|
blurOnSubmit={true}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function useAgentAttentionClear({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deferredFocusEntryClearRef.current = false;
|
deferredFocusEntryClearRef.current = false;
|
||||||
client.clearAgentAttention(resolvedAgentId);
|
client.clearAgentAttention(resolvedAgentId).catch(() => {});
|
||||||
},
|
},
|
||||||
[agentId, attentionReason, client, isConnected, requiresAttention],
|
[agentId, attentionReason, client, isConnected, requiresAttention],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ import {
|
|||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||||
import {
|
|
||||||
summarizeSidebarProjects,
|
|
||||||
summarizeWorkspaceCollection,
|
|
||||||
} from "@/utils/workspace-fetch-debug";
|
|
||||||
|
|
||||||
const EMPTY_ORDER: string[] = [];
|
const EMPTY_ORDER: string[] = [];
|
||||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||||
@@ -302,14 +298,6 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[WorkspaceFetch][sidebar] model", {
|
|
||||||
serverId,
|
|
||||||
connectionStatus,
|
|
||||||
hasHydratedWorkspaces,
|
|
||||||
sessionWorkspaces: summarizeWorkspaceCollection(sessionWorkspaces?.values()),
|
|
||||||
projects: summarizeSidebarProjects(projects),
|
|
||||||
});
|
|
||||||
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]);
|
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -355,21 +343,10 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
let cursor: string | null = null;
|
let cursor: string | null = null;
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
console.log("[WorkspaceFetch][sidebar-refresh] request", {
|
|
||||||
serverId,
|
|
||||||
cursor,
|
|
||||||
existingWorkspaces: summarizeWorkspaceCollection(existingWorkspaces?.values()),
|
|
||||||
});
|
|
||||||
const payload = await client.fetchWorkspaces({
|
const payload = await client.fetchWorkspaces({
|
||||||
sort: [{ key: "activity_at", direction: "desc" }],
|
sort: [{ key: "activity_at", direction: "desc" }],
|
||||||
page: cursor ? { limit: 200, cursor } : { limit: 200 },
|
page: cursor ? { limit: 200, cursor } : { limit: 200 },
|
||||||
});
|
});
|
||||||
console.log("[WorkspaceFetch][sidebar-refresh] response", {
|
|
||||||
serverId,
|
|
||||||
cursor,
|
|
||||||
pageInfo: payload.pageInfo,
|
|
||||||
payload: summarizeWorkspaceCollection(payload.entries),
|
|
||||||
});
|
|
||||||
for (const entry of payload.entries) {
|
for (const entry of payload.entries) {
|
||||||
const workspace = toWorkspaceDescriptor(entry);
|
const workspace = toWorkspaceDescriptor(entry);
|
||||||
next.set(
|
next.set(
|
||||||
@@ -388,10 +365,6 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
const store = useSessionStore.getState();
|
const store = useSessionStore.getState();
|
||||||
store.setWorkspaces(serverId, next);
|
store.setWorkspaces(serverId, next);
|
||||||
store.setHasHydratedWorkspaces(serverId, true);
|
store.setHasHydratedWorkspaces(serverId, true);
|
||||||
console.log("[WorkspaceFetch][sidebar-refresh] applied", {
|
|
||||||
serverId,
|
|
||||||
nextWorkspaces: summarizeWorkspaceCollection(next.values()),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
|
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
|
||||||
serverId,
|
serverId,
|
||||||
|
|||||||
@@ -788,6 +788,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
// --- Message input ---
|
// --- Message input ---
|
||||||
|
{
|
||||||
|
id: "message-input-focus-cmd-l-mac",
|
||||||
|
action: "message-input.action",
|
||||||
|
combo: "Cmd+L",
|
||||||
|
when: { mac: true, commandCenter: false },
|
||||||
|
payload: { type: "message-input", kind: "focus" },
|
||||||
|
help: {
|
||||||
|
id: "focus-message-input",
|
||||||
|
section: "agent-input",
|
||||||
|
label: "Focus message input",
|
||||||
|
keys: ["mod", "L"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "message-input-focus-ctrl-l-non-mac",
|
||||||
|
action: "message-input.action",
|
||||||
|
combo: "Ctrl+L",
|
||||||
|
when: { mac: false, commandCenter: false, terminal: false },
|
||||||
|
payload: { type: "message-input", kind: "focus" },
|
||||||
|
help: {
|
||||||
|
id: "focus-message-input",
|
||||||
|
section: "agent-input",
|
||||||
|
label: "Focus message input",
|
||||||
|
keys: ["mod", "L"],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "message-input-voice-toggle-cmd-shift-d-mac",
|
id: "message-input-voice-toggle-cmd-shift-d-mac",
|
||||||
action: "message-input.action",
|
action: "message-input.action",
|
||||||
|
|||||||
@@ -965,7 +965,7 @@ function ChatAgentContent({
|
|||||||
<Composer
|
<Composer
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
isInputActive={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
value={agentInputDraft.text}
|
value={agentInputDraft.text}
|
||||||
onChangeText={agentInputDraft.setText}
|
onChangeText={agentInputDraft.setText}
|
||||||
images={agentInputDraft.images}
|
images={agentInputDraft.images}
|
||||||
|
|||||||
@@ -1195,7 +1195,7 @@ function DraftAgentScreenContent({
|
|||||||
<Composer
|
<Composer
|
||||||
agentId={draftAgentIdRef.current}
|
agentId={draftAgentIdRef.current}
|
||||||
serverId={selectedServerId ?? ""}
|
serverId={selectedServerId ?? ""}
|
||||||
isInputActive={isFocused}
|
isPaneFocused={isFocused}
|
||||||
onSubmitMessage={handleCreateFromInput}
|
onSubmitMessage={handleCreateFromInput}
|
||||||
isSubmitLoading={isSubmitting}
|
isSubmitLoading={isSubmitting}
|
||||||
blurOnSubmit={true}
|
blurOnSubmit={true}
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ export function NewWorkspaceScreen({
|
|||||||
<Composer
|
<Composer
|
||||||
agentId={`new-workspace:${serverId}:${sourceDirectory}`}
|
agentId={`new-workspace:${serverId}:${sourceDirectory}`}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
isInputActive={true}
|
isPaneFocused={true}
|
||||||
onSubmitMessage={handleCreateChatAgent}
|
onSubmitMessage={handleCreateChatAgent}
|
||||||
hasExternalContent={selectedGithubItem !== null}
|
hasExternalContent={selectedGithubItem !== null}
|
||||||
allowEmptySubmit={true}
|
allowEmptySubmit={true}
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
<Composer
|
<Composer
|
||||||
agentId={tabId}
|
agentId={tabId}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
isInputActive={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
onSubmitMessage={handleCreateFromInput}
|
onSubmitMessage={handleCreateFromInput}
|
||||||
isSubmitLoading={isSubmitting}
|
isSubmitLoading={isSubmitting}
|
||||||
blurOnSubmit={true}
|
blurOnSubmit={true}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.55-rc.2",
|
"@getpaseo/relay": "0.1.56",
|
||||||
"@getpaseo/server": "0.1.55-rc.2",
|
"@getpaseo/server": "0.1.56",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
+21
-4
@@ -1,4 +1,4 @@
|
|||||||
import { Command } from "commander";
|
import { Command, Option } from "commander";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { createAgentCommand } from "./commands/agent/index.js";
|
import { createAgentCommand } from "./commands/agent/index.js";
|
||||||
import { createDaemonCommand } from "./commands/daemon/index.js";
|
import { createDaemonCommand } from "./commands/daemon/index.js";
|
||||||
@@ -124,10 +124,27 @@ export function createCli(): Command {
|
|||||||
.option("--no-relay", "Disable relay on restarted daemon")
|
.option("--no-relay", "Disable relay on restarted daemon")
|
||||||
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
|
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
|
||||||
.option(
|
.option(
|
||||||
"--allowed-hosts <hosts>",
|
"--hostnames <hosts>",
|
||||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||||
)
|
)
|
||||||
.action(withOutput(runDaemonRestartCommand));
|
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
|
||||||
|
.action(
|
||||||
|
withOutput((...args) => {
|
||||||
|
const [options, command] = args.slice(-2) as [(typeof args)[number], Command];
|
||||||
|
return runDaemonRestartCommand(
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
hostnames:
|
||||||
|
typeof options.hostnames === "string"
|
||||||
|
? options.hostnames
|
||||||
|
: typeof options.allowedHosts === "string"
|
||||||
|
? options.allowedHosts
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
command,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Advanced agent commands (less common operations)
|
// Advanced agent commands (less common operations)
|
||||||
program.addCommand(createAgentCommand());
|
program.addCommand(createAgentCommand());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Command } from "commander";
|
import { Command, Option } from "commander";
|
||||||
import { startCommand } from "./start.js";
|
import { startCommand } from "./start.js";
|
||||||
import { runStatusCommand } from "./status.js";
|
import { runStatusCommand } from "./status.js";
|
||||||
import { runStopCommand } from "./stop.js";
|
import { runStopCommand } from "./stop.js";
|
||||||
@@ -36,10 +36,27 @@ export function createDaemonCommand(): Command {
|
|||||||
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
|
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
|
||||||
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
|
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
|
||||||
.option(
|
.option(
|
||||||
"--allowed-hosts <hosts>",
|
"--hostnames <hosts>",
|
||||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||||
)
|
)
|
||||||
.action(withOutput(runRestartCommand));
|
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
|
||||||
|
.action(
|
||||||
|
withOutput((...args) => {
|
||||||
|
const [options, command] = args.slice(-2) as [(typeof args)[number], Command];
|
||||||
|
return runRestartCommand(
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
hostnames:
|
||||||
|
typeof options.hostnames === "string"
|
||||||
|
? options.hostnames
|
||||||
|
: typeof options.allowedHosts === "string"
|
||||||
|
? options.allowedHosts
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
command,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return daemon;
|
return daemon;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface DaemonStartOptions {
|
|||||||
relay?: boolean;
|
relay?: boolean;
|
||||||
mcp?: boolean;
|
mcp?: boolean;
|
||||||
injectMcp?: boolean;
|
injectMcp?: boolean;
|
||||||
allowedHosts?: string;
|
hostnames?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalDaemonPidInfo {
|
export interface LocalDaemonPidInfo {
|
||||||
@@ -113,8 +113,8 @@ function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
|
|||||||
} else if (options.port) {
|
} else if (options.port) {
|
||||||
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`;
|
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`;
|
||||||
}
|
}
|
||||||
if (options.allowedHosts) {
|
if (options.hostnames) {
|
||||||
childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts;
|
childEnv.PASEO_HOSTNAMES = options.hostnames;
|
||||||
}
|
}
|
||||||
return childEnv;
|
return childEnv;
|
||||||
}
|
}
|
||||||
@@ -322,6 +322,7 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
|
|||||||
...envWithHome(options.home),
|
...envWithHome(options.home),
|
||||||
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
|
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
|
||||||
PASEO_LISTEN: undefined,
|
PASEO_LISTEN: undefined,
|
||||||
|
PASEO_HOSTNAMES: undefined,
|
||||||
PASEO_ALLOWED_HOSTS: undefined,
|
PASEO_ALLOWED_HOSTS: undefined,
|
||||||
};
|
};
|
||||||
const home = resolvePaseoHome(env);
|
const home = resolvePaseoHome(env);
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function toStartOptions(options: CommandOptions): DaemonStartOptions {
|
|||||||
relay: typeof options.relay === "boolean" ? options.relay : undefined,
|
relay: typeof options.relay === "boolean" ? options.relay : undefined,
|
||||||
mcp: typeof options.mcp === "boolean" ? options.mcp : undefined,
|
mcp: typeof options.mcp === "boolean" ? options.mcp : undefined,
|
||||||
injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined,
|
injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined,
|
||||||
allowedHosts: typeof options.allowedHosts === "string" ? options.allowedHosts : undefined,
|
hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (startOptions.listen && startOptions.port) {
|
if (startOptions.listen && startOptions.port) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Command } from "commander";
|
import { Command, Option } from "commander";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import {
|
import {
|
||||||
startLocalDaemonForeground,
|
startLocalDaemonForeground,
|
||||||
@@ -9,6 +9,10 @@ import { getErrorMessage } from "../../utils/errors.js";
|
|||||||
|
|
||||||
export type { DaemonStartOptions as StartOptions } from "./local-daemon.js";
|
export type { DaemonStartOptions as StartOptions } from "./local-daemon.js";
|
||||||
|
|
||||||
|
type RawStartCommandOptions = StartOptions & {
|
||||||
|
allowedHosts?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function startCommand(): Command {
|
export function startCommand(): Command {
|
||||||
return new Command("start")
|
return new Command("start")
|
||||||
.description("Start the local Paseo daemon")
|
.description("Start the local Paseo daemon")
|
||||||
@@ -20,11 +24,15 @@ export function startCommand(): Command {
|
|||||||
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
||||||
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
|
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
|
||||||
.option(
|
.option(
|
||||||
"--allowed-hosts <hosts>",
|
"--hostnames <hosts>",
|
||||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||||
)
|
)
|
||||||
.action(async (options: StartOptions) => {
|
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
|
||||||
await runStart(options);
|
.action(async (options: RawStartCommandOptions) => {
|
||||||
|
await runStart({
|
||||||
|
...options,
|
||||||
|
hostnames: options.hostnames ?? options.allowedHosts,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts";
|
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts";
|
||||||
import { Command } from "commander";
|
import { Command, Option } from "commander";
|
||||||
import { writeFileSync } from "node:fs";
|
import { writeFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +24,10 @@ interface OnboardOptions extends DaemonStartOptions {
|
|||||||
voice?: "ask" | "enable" | "disable";
|
voice?: "ask" | "enable" | "disable";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RawOnboardOptions = OnboardOptions & {
|
||||||
|
allowedHosts?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type OnboardPersistedConfig = PersistedConfig & {
|
type OnboardPersistedConfig = PersistedConfig & {
|
||||||
features?: PersistedConfig["features"] & {
|
features?: PersistedConfig["features"] & {
|
||||||
dictation?: PersistedConfig["features"] extends { dictation?: infer T }
|
dictation?: PersistedConfig["features"] extends { dictation?: infer T }
|
||||||
@@ -64,7 +68,7 @@ function parseTimeoutMs(raw: string | undefined): number {
|
|||||||
return Math.ceil(seconds * 1000);
|
return Math.ceil(seconds * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
|
function toCliOverrides(options: OnboardOptions): CliConfigOverrides {
|
||||||
const cliOverrides: CliConfigOverrides = {};
|
const cliOverrides: CliConfigOverrides = {};
|
||||||
|
|
||||||
if (options.listen) {
|
if (options.listen) {
|
||||||
@@ -77,9 +81,9 @@ function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
|
|||||||
cliOverrides.relayEnabled = false;
|
cliOverrides.relayEnabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.allowedHosts) {
|
if (options.hostnames) {
|
||||||
const raw = options.allowedHosts.trim();
|
const raw = options.hostnames.trim();
|
||||||
cliOverrides.allowedHosts =
|
cliOverrides.hostnames =
|
||||||
raw.toLowerCase() === "true"
|
raw.toLowerCase() === "true"
|
||||||
? true
|
? true
|
||||||
: raw
|
: raw
|
||||||
@@ -297,13 +301,17 @@ export function onboardCommand(): Command {
|
|||||||
.option("--no-relay", "Disable relay connection")
|
.option("--no-relay", "Disable relay connection")
|
||||||
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
||||||
.option(
|
.option(
|
||||||
"--allowed-hosts <hosts>",
|
"--hostnames <hosts>",
|
||||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||||
)
|
)
|
||||||
|
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
|
||||||
.option("--timeout <seconds>", "Max time to wait for daemon readiness (default: 600)")
|
.option("--timeout <seconds>", "Max time to wait for daemon readiness (default: 600)")
|
||||||
.option("--voice <mode>", "Voice setup mode: ask, enable, disable", "ask")
|
.option("--voice <mode>", "Voice setup mode: ask, enable, disable", "ask")
|
||||||
.action(async (options: OnboardOptions) => {
|
.action(async (options: RawOnboardOptions) => {
|
||||||
await runOnboard(options);
|
await runOnboard({
|
||||||
|
...options,
|
||||||
|
hostnames: options.hostnames ?? options.allowedHosts,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import {
|
import {
|
||||||
resolveProviderAndModel,
|
|
||||||
resolveStructuredResponseMessage,
|
resolveStructuredResponseMessage,
|
||||||
type StructuredResponseTimelineClient,
|
type StructuredResponseTimelineClient,
|
||||||
} from "../src/commands/agent/run.ts";
|
} from "../src/commands/agent/run.ts";
|
||||||
|
import { resolveProviderAndModel } from "../src/utils/provider-model.ts";
|
||||||
|
|
||||||
type TimelineEntry = {
|
type TimelineEntry = {
|
||||||
item: {
|
item: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
@@ -8,12 +8,13 @@
|
|||||||
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
|
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
|
||||||
"build:main": "tsc -p tsconfig.json",
|
"build:main": "tsc -p tsconfig.json",
|
||||||
"dev": "./scripts/dev.sh",
|
"dev": "./scripts/dev.sh",
|
||||||
|
"dev:win": "powershell ./scripts/dev.ps1",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.55-rc.2",
|
"@getpaseo/cli": "0.1.56",
|
||||||
"@getpaseo/server": "0.1.55-rc.2",
|
"@getpaseo/server": "0.1.56",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$DesktopDir = (Resolve-Path "$ScriptDir\..").Path
|
||||||
|
$AppDir = (Resolve-Path "$DesktopDir\..\app").Path
|
||||||
|
$RootDir = (Resolve-Path "$DesktopDir\..\..").Path
|
||||||
|
|
||||||
|
# Build the Electron main process
|
||||||
|
npm run build:main
|
||||||
|
|
||||||
|
# Get a random available port for Metro
|
||||||
|
$env:EXPO_PORT = (npx get-port-cli).Trim()
|
||||||
|
|
||||||
|
# Set EXPO_DEV_URL in the environment so Electron inherits it
|
||||||
|
$env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)"
|
||||||
|
|
||||||
|
# Allow any origin in dev so Electron on random ports works.
|
||||||
|
# SECURITY: wildcard CORS is unsafe in production — only acceptable here because
|
||||||
|
# the daemon binds to localhost and this script is never used for production.
|
||||||
|
$env:PASEO_CORS_ORIGINS = "*"
|
||||||
|
|
||||||
|
Write-Host @"
|
||||||
|
======================================================
|
||||||
|
Paseo Desktop Dev (Windows)
|
||||||
|
======================================================
|
||||||
|
Metro: http://localhost:$($env:EXPO_PORT)
|
||||||
|
======================================================
|
||||||
|
"@
|
||||||
|
|
||||||
|
# Launch Metro + Electron together, kill both on exit
|
||||||
|
& "$RootDir\node_modules\.bin\concurrently" `
|
||||||
|
--kill-others `
|
||||||
|
--names "metro,electron" `
|
||||||
|
--prefix-colors "magenta,cyan" `
|
||||||
|
"cd `"$AppDir`" && npx expo start --port $($env:EXPO_PORT)" `
|
||||||
|
"npx wait-on tcp:$($env:EXPO_PORT) && npx electron `"$DesktopDir`""
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"description": "Native module for two way audio streaming",
|
"description": "Native module for two way audio streaming",
|
||||||
"main": "build/index.js",
|
"main": "build/index.js",
|
||||||
"types": "build/index.d.ts",
|
"types": "build/index.d.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"description": "Paseo relay for bridging daemon and client connections",
|
"description": "Paseo relay for bridging daemon and client connections",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
@@ -60,8 +60,8 @@
|
|||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "0.1.55-rc.2",
|
"@getpaseo/highlight": "0.1.56",
|
||||||
"@getpaseo/relay": "0.1.55-rc.2",
|
"@getpaseo/relay": "0.1.56",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
|
|||||||
@@ -1201,8 +1201,28 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearAgentAttention(agentId: string | string[]): void {
|
async clearAgentAttention(agentId: string | string[]): Promise<void> {
|
||||||
this.sendSessionMessage({ type: "clear_agent_attention", agentId });
|
const requestId = this.createRequestId();
|
||||||
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
type: "clear_agent_attention",
|
||||||
|
agentId,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
await this.sendRequest({
|
||||||
|
requestId,
|
||||||
|
message,
|
||||||
|
timeout: 15000,
|
||||||
|
options: { skipQueue: true },
|
||||||
|
select: (msg) => {
|
||||||
|
if (msg.type !== "clear_agent_attention_response") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (msg.payload.requestId !== requestId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return msg.payload;
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sendHeartbeat(params: {
|
sendHeartbeat(params: {
|
||||||
@@ -1745,7 +1765,27 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async cancelAgent(agentId: string): Promise<void> {
|
async cancelAgent(agentId: string): Promise<void> {
|
||||||
this.sendSessionMessage({ type: "cancel_agent_request", agentId });
|
const requestId = this.createRequestId();
|
||||||
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
type: "cancel_agent_request",
|
||||||
|
agentId,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
await this.sendRequest({
|
||||||
|
requestId,
|
||||||
|
message,
|
||||||
|
timeout: 15000,
|
||||||
|
options: { skipQueue: true },
|
||||||
|
select: (msg) => {
|
||||||
|
if (msg.type !== "cancel_agent_response") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (msg.payload.requestId !== requestId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return msg.payload;
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setAgentMode(agentId: string, modeId: string): Promise<void> {
|
async setAgentMode(agentId: string, modeId: string): Promise<void> {
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
|||||||
listen: `127.0.0.1:${port}`,
|
listen: `127.0.0.1:${port}`,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
corsAllowedOrigins: [],
|
corsAllowedOrigins: [],
|
||||||
allowedHosts: true,
|
hostnames: true,
|
||||||
mcpEnabled: true,
|
mcpEnabled: true,
|
||||||
staticDir,
|
staticDir,
|
||||||
mcpDebug: false,
|
mcpDebug: false,
|
||||||
@@ -192,7 +192,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
|||||||
listen: `127.0.0.1:${port}`,
|
listen: `127.0.0.1:${port}`,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
corsAllowedOrigins: [],
|
corsAllowedOrigins: [],
|
||||||
allowedHosts: true,
|
hostnames: true,
|
||||||
mcpEnabled: true,
|
mcpEnabled: true,
|
||||||
staticDir,
|
staticDir,
|
||||||
mcpDebug: false,
|
mcpDebug: false,
|
||||||
@@ -216,7 +216,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
|||||||
listen: `127.0.0.1:${disabledPort}`,
|
listen: `127.0.0.1:${disabledPort}`,
|
||||||
paseoHome: disabledPaseoHome,
|
paseoHome: disabledPaseoHome,
|
||||||
corsAllowedOrigins: [],
|
corsAllowedOrigins: [],
|
||||||
allowedHosts: true,
|
hostnames: true,
|
||||||
mcpEnabled: true,
|
mcpEnabled: true,
|
||||||
mcpInjectIntoAgents: false,
|
mcpInjectIntoAgents: false,
|
||||||
staticDir: disabledStaticDir,
|
staticDir: disabledStaticDir,
|
||||||
@@ -307,7 +307,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
|||||||
listen: `127.0.0.1:${port}`,
|
listen: `127.0.0.1:${port}`,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
corsAllowedOrigins: [],
|
corsAllowedOrigins: [],
|
||||||
allowedHosts: true,
|
hostnames: true,
|
||||||
mcpEnabled: true,
|
mcpEnabled: true,
|
||||||
staticDir,
|
staticDir,
|
||||||
mcpDebug: false,
|
mcpDebug: false,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
let allowedHosts: string[] | undefined;
|
let mcpAllowedHosts: string[] | undefined;
|
||||||
const agentMcpTransports = new Map<string, StreamableHTTPServerTransport>();
|
const agentMcpTransports = new Map<string, StreamableHTTPServerTransport>();
|
||||||
|
|
||||||
const createAgentMcpTransport = async (callerAgentId?: string) => {
|
const createAgentMcpTransport = async (callerAgentId?: string) => {
|
||||||
@@ -62,7 +62,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
|||||||
agentMcpTransports.delete(sessionId);
|
agentMcpTransports.delete(sessionId);
|
||||||
},
|
},
|
||||||
enableDnsRebindingProtection: true,
|
enableDnsRebindingProtection: true,
|
||||||
...(allowedHosts ? { allowedHosts } : {}),
|
...(mcpAllowedHosts ? { allowedHosts: mcpAllowedHosts } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
@@ -129,7 +129,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
allowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
mcpAllowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
||||||
const url = `http://127.0.0.1:${port}/mcp/agents`;
|
const url = `http://127.0.0.1:${port}/mcp/agents`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@opencode-ai/sdk/v2/client", () => ({
|
||||||
|
createOpencodeClient: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||||
|
|
||||||
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
|
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
|
||||||
|
|
||||||
|
function createDeferred<T>(): {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
} {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (error: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("waits for SSE completion when slash commands hit a header timeout", async () => {
|
||||||
|
const idleEventGate = createDeferred<void>();
|
||||||
|
|
||||||
|
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||||
|
session: {
|
||||||
|
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||||||
|
command: vi.fn().mockRejectedValue(new Error("fetch failed: Headers Timeout Error")),
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
connected: ["openai"],
|
||||||
|
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
subscribe: vi.fn().mockResolvedValue({
|
||||||
|
stream: (async function* () {
|
||||||
|
await idleEventGate.promise;
|
||||||
|
yield {
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: "session-1" },
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
data: [{ name: "help", description: "Show help", hints: [] }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
agents: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||||
|
ensureRunning: vi.fn().mockResolvedValue({ port: 1234, url: "http://127.0.0.1:1234" }),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const client = new OpenCodeAgentClient(createTestLogger());
|
||||||
|
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||||
|
|
||||||
|
const runPromise = session.run("/help");
|
||||||
|
await Promise.resolve();
|
||||||
|
idleEventGate.resolve();
|
||||||
|
|
||||||
|
await expect(runPromise).resolves.toMatchObject({
|
||||||
|
sessionId: "session-1",
|
||||||
|
finalText: "",
|
||||||
|
timeline: [],
|
||||||
|
usage: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -316,6 +316,65 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("OpenCode adapter context-window normalization", () => {
|
describe("OpenCode adapter context-window normalization", () => {
|
||||||
|
test("close reconciliation aborts then archives upstream session", async () => {
|
||||||
|
const abort = vi.fn().mockResolvedValue({ data: true, error: undefined });
|
||||||
|
const update = vi.fn().mockResolvedValue({
|
||||||
|
data: { id: "session-1", time: { archived: Date.now() } },
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await __openCodeInternals.reconcileOpenCodeSessionClose({
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
abort,
|
||||||
|
update,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
sessionId: "session-1",
|
||||||
|
directory: "/tmp/project",
|
||||||
|
logger: createTestLogger(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(abort).toHaveBeenCalledWith({
|
||||||
|
sessionID: "session-1",
|
||||||
|
directory: "/tmp/project",
|
||||||
|
});
|
||||||
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(update).toHaveBeenCalledWith({
|
||||||
|
sessionID: "session-1",
|
||||||
|
directory: "/tmp/project",
|
||||||
|
time: {
|
||||||
|
archived: expect.any(Number),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("close reconciliation still archives when abort returns an error", async () => {
|
||||||
|
const abort = vi.fn().mockResolvedValue({
|
||||||
|
data: undefined,
|
||||||
|
error: { data: {}, errors: [], success: false },
|
||||||
|
});
|
||||||
|
const update = vi.fn().mockResolvedValue({
|
||||||
|
data: { id: "session-1", time: { archived: Date.now() } },
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await __openCodeInternals.reconcileOpenCodeSessionClose({
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
abort,
|
||||||
|
update,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
sessionId: "session-1",
|
||||||
|
directory: "/tmp/project",
|
||||||
|
logger: createTestLogger(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(abort).toHaveBeenCalledTimes(1);
|
||||||
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
test("builds OpenCode file parts for image prompt blocks", () => {
|
test("builds OpenCode file parts for image prompt blocks", () => {
|
||||||
expect(
|
expect(
|
||||||
__openCodeInternals.buildOpenCodePromptParts([
|
__openCodeInternals.buildOpenCodePromptParts([
|
||||||
|
|||||||
@@ -108,6 +108,12 @@ const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [
|
|||||||
"does not exist",
|
"does not exist",
|
||||||
"unsupported model",
|
"unsupported model",
|
||||||
] as const;
|
] as const;
|
||||||
|
const OPENCODE_HEADERS_TIMEOUT_TOKENS = [
|
||||||
|
"headers timeout",
|
||||||
|
"headers timeout error",
|
||||||
|
"headers_timeout",
|
||||||
|
"und_err_headers_timeout",
|
||||||
|
] as const;
|
||||||
|
|
||||||
const OpencodeToolStateSchema = z
|
const OpencodeToolStateSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -232,6 +238,73 @@ function normalizeTurnFailureError(error: unknown): string {
|
|||||||
return normalized.length > 0 ? normalized : "Unknown error";
|
return normalized.length > 0 ? normalized : "Unknown error";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOpenCodeNotFoundError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"name" in error &&
|
||||||
|
(error as { name?: unknown }).name === "NotFoundError"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconcileOpenCodeSessionClose(params: {
|
||||||
|
client: Pick<OpencodeClient, "session">;
|
||||||
|
sessionId: string;
|
||||||
|
directory: string;
|
||||||
|
logger: Logger;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { client, sessionId, directory, logger } = params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.session.abort({
|
||||||
|
sessionID: sessionId,
|
||||||
|
directory,
|
||||||
|
});
|
||||||
|
if (response.error && !isOpenCodeNotFoundError(response.error)) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
sessionId,
|
||||||
|
error: normalizeTurnFailureError(response.error),
|
||||||
|
},
|
||||||
|
"Failed to abort OpenCode session during close",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
sessionId,
|
||||||
|
error: normalizeTurnFailureError(error),
|
||||||
|
},
|
||||||
|
"Failed to abort OpenCode session during close",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.session.update({
|
||||||
|
sessionID: sessionId,
|
||||||
|
directory,
|
||||||
|
time: { archived: Date.now() },
|
||||||
|
});
|
||||||
|
if (response.error && !isOpenCodeNotFoundError(response.error)) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
sessionId,
|
||||||
|
error: normalizeTurnFailureError(response.error),
|
||||||
|
},
|
||||||
|
"Failed to archive OpenCode session during close",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
sessionId,
|
||||||
|
error: normalizeTurnFailureError(error),
|
||||||
|
},
|
||||||
|
"Failed to archive OpenCode session during close",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolean {
|
function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolean {
|
||||||
const normalized = typeof message === "string" ? message.trim().toLowerCase() : "";
|
const normalized = typeof message === "string" ? message.trim().toLowerCase() : "";
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -240,6 +313,49 @@ function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolea
|
|||||||
return OPENCODE_FATAL_RETRY_MESSAGE_TOKENS.some((token) => normalized.includes(token));
|
return OPENCODE_FATAL_RETRY_MESSAGE_TOKENS.some((token) => normalized.includes(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOpenCodeHeadersTimeoutFailure(error: unknown): boolean {
|
||||||
|
const diagnostics = new Set<string>();
|
||||||
|
const queue: unknown[] = [error];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift();
|
||||||
|
if (!current) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = stringifyUnknownError(current).trim().toLowerCase();
|
||||||
|
if (normalized) {
|
||||||
|
diagnostics.add(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof current === "object") {
|
||||||
|
const record = current as {
|
||||||
|
message?: unknown;
|
||||||
|
code?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
cause?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const value of [record.message, record.code, record.name]) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const diagnostic = value.trim().toLowerCase();
|
||||||
|
if (diagnostic) {
|
||||||
|
diagnostics.add(diagnostic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.cause) {
|
||||||
|
queue.push(record.cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...diagnostics].some((diagnostic) =>
|
||||||
|
OPENCODE_HEADERS_TIMEOUT_TOKENS.some((token) => diagnostic.includes(token)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isAlreadyPresentMcpError(error: unknown): boolean {
|
function isAlreadyPresentMcpError(error: unknown): boolean {
|
||||||
const normalized = stringifyUnknownError(error).toLowerCase();
|
const normalized = stringifyUnknownError(error).toLowerCase();
|
||||||
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
|
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
|
||||||
@@ -575,6 +691,7 @@ export const __openCodeInternals = {
|
|||||||
hasNormalizedOpenCodeUsage,
|
hasNormalizedOpenCodeUsage,
|
||||||
mergeOpenCodeStepFinishUsage,
|
mergeOpenCodeStepFinishUsage,
|
||||||
parseOpenCodeModelLookupKey,
|
parseOpenCodeModelLookupKey,
|
||||||
|
reconcileOpenCodeSessionClose,
|
||||||
resolveOpenCodeModelLookupKeyFromAssistantMessage,
|
resolveOpenCodeModelLookupKeyFromAssistantMessage,
|
||||||
resolveOpenCodeSelectedModelContextWindow,
|
resolveOpenCodeSelectedModelContextWindow,
|
||||||
};
|
};
|
||||||
@@ -1535,6 +1652,17 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
|
if (isOpenCodeHeadersTimeoutFailure(response.error)) {
|
||||||
|
this.logger.warn(
|
||||||
|
{
|
||||||
|
err: response.error,
|
||||||
|
commandName: slashCommand.commandName,
|
||||||
|
turnId,
|
||||||
|
},
|
||||||
|
"OpenCode slash command hit a header timeout; waiting for SSE terminal event",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const errorMsg = normalizeTurnFailureError(response.error);
|
const errorMsg = normalizeTurnFailureError(response.error);
|
||||||
this.finishForegroundTurn(
|
this.finishForegroundTurn(
|
||||||
{ type: "turn_failed", provider: "opencode", error: errorMsg },
|
{ type: "turn_failed", provider: "opencode", error: errorMsg },
|
||||||
@@ -1548,6 +1676,17 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
if (isOpenCodeHeadersTimeoutFailure(err)) {
|
||||||
|
this.logger.warn(
|
||||||
|
{
|
||||||
|
err,
|
||||||
|
commandName: slashCommand.commandName,
|
||||||
|
turnId,
|
||||||
|
},
|
||||||
|
"OpenCode slash command hit a header timeout; waiting for SSE terminal event",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.finishForegroundTurn(
|
this.finishForegroundTurn(
|
||||||
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
|
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
|
||||||
turnId,
|
turnId,
|
||||||
@@ -1937,6 +2076,12 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
this.abortController?.abort();
|
this.abortController?.abort();
|
||||||
|
await reconcileOpenCodeSessionClose({
|
||||||
|
client: this.client,
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
directory: this.config.cwd,
|
||||||
|
logger: this.logger,
|
||||||
|
});
|
||||||
this.subscribers.clear();
|
this.subscribers.clear();
|
||||||
this.activeForegroundTurnId = null;
|
this.activeForegroundTurnId = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { isHostAllowed, mergeAllowedHosts, parseAllowedHostsEnv } from "./allowed-hosts.js";
|
|
||||||
|
|
||||||
describe("allowed hosts (vite-style)", () => {
|
|
||||||
it("allows localhost by default", () => {
|
|
||||||
expect(isHostAllowed("localhost:6767", undefined)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows subdomains of .localhost by default", () => {
|
|
||||||
expect(isHostAllowed("foo.localhost:6767", undefined)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows IP addresses by default", () => {
|
|
||||||
expect(isHostAllowed("127.0.0.1:6767", undefined)).toBe(true);
|
|
||||||
expect(isHostAllowed("[::1]:6767", undefined)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects non-default hosts when no allowlist is provided", () => {
|
|
||||||
expect(isHostAllowed("evil.com:6767", undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows any host when set to true", () => {
|
|
||||||
expect(isHostAllowed("evil.com:6767", true)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports leading-dot patterns", () => {
|
|
||||||
const allowed = [".example.com"];
|
|
||||||
expect(isHostAllowed("example.com:6767", allowed)).toBe(true);
|
|
||||||
expect(isHostAllowed("foo.example.com:6767", allowed)).toBe(true);
|
|
||||||
expect(isHostAllowed("foo.bar.example.com:6767", allowed)).toBe(true);
|
|
||||||
expect(isHostAllowed("notexample.com:6767", allowed)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("merges arrays (append + de-dupe) and short-circuits on true", () => {
|
|
||||||
expect(mergeAllowedHosts([["a"], ["a", "b"]])).toEqual(["a", "b"]);
|
|
||||||
expect(mergeAllowedHosts([["a"], true, ["b"]])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses env var values", () => {
|
|
||||||
expect(parseAllowedHostsEnv(undefined)).toBeUndefined();
|
|
||||||
expect(parseAllowedHostsEnv("")).toBeUndefined();
|
|
||||||
expect(parseAllowedHostsEnv("true")).toBe(true);
|
|
||||||
expect(parseAllowedHostsEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
import { Writable } from "node:stream";
|
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
|
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
|
||||||
|
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
||||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ describe("paseo daemon bootstrap", () => {
|
|||||||
listen: "127.0.0.1:0",
|
listen: "127.0.0.1:0",
|
||||||
paseoHome,
|
paseoHome,
|
||||||
corsAllowedOrigins: [],
|
corsAllowedOrigins: [],
|
||||||
allowedHosts: true,
|
hostnames: true,
|
||||||
mcpEnabled: false,
|
mcpEnabled: false,
|
||||||
staticDir,
|
staticDir,
|
||||||
mcpDebug: false,
|
mcpDebug: false,
|
||||||
@@ -150,30 +150,20 @@ describe("paseo daemon bootstrap", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("emits a relay pairing offer for unix socket listeners", async () => {
|
test("generates a relay pairing offer for unix socket listeners", async () => {
|
||||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
|
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
|
||||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||||
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
|
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
|
||||||
await mkdir(path.dirname(socketPath), { recursive: true });
|
await mkdir(path.dirname(socketPath), { recursive: true });
|
||||||
await mkdir(paseoHome, { recursive: true });
|
await mkdir(paseoHome, { recursive: true });
|
||||||
|
const logger = pino({ level: "silent" });
|
||||||
const lines: string[] = [];
|
|
||||||
const logger = pino(
|
|
||||||
{ level: "info" },
|
|
||||||
new Writable({
|
|
||||||
write(chunk, _encoding, callback) {
|
|
||||||
lines.push(chunk.toString("utf8"));
|
|
||||||
callback();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const config: PaseoDaemonConfig = {
|
const config: PaseoDaemonConfig = {
|
||||||
listen: socketPath,
|
listen: socketPath,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
corsAllowedOrigins: [],
|
corsAllowedOrigins: [],
|
||||||
allowedHosts: true,
|
hostnames: true,
|
||||||
mcpEnabled: false,
|
mcpEnabled: false,
|
||||||
staticDir,
|
staticDir,
|
||||||
mcpDebug: false,
|
mcpDebug: false,
|
||||||
@@ -191,7 +181,16 @@ describe("paseo daemon bootstrap", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await daemon.start();
|
await daemon.start();
|
||||||
expect(lines.some((line) => line.includes('"msg":"pairing_offer"'))).toBe(true);
|
const pairing = await generateLocalPairingOffer({
|
||||||
|
paseoHome,
|
||||||
|
relayEnabled: true,
|
||||||
|
relayEndpoint: "127.0.0.1:9",
|
||||||
|
relayPublicEndpoint: "127.0.0.1:9",
|
||||||
|
appBaseUrl: "https://app.paseo.sh",
|
||||||
|
includeQr: false,
|
||||||
|
});
|
||||||
|
expect(pairing.relayEnabled).toBe(true);
|
||||||
|
expect(pairing.url?.startsWith("https://app.paseo.sh/#offer=")).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
await daemon.stop().catch(() => undefined);
|
await daemon.stop().catch(() => undefined);
|
||||||
await daemon.agentManager.flush().catch(() => undefined);
|
await daemon.agentManager.flush().catch(() => undefined);
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
|||||||
import { LoopService } from "./loop-service.js";
|
import { LoopService } from "./loop-service.js";
|
||||||
import { ScheduleService } from "./schedule/service.js";
|
import { ScheduleService } from "./schedule/service.js";
|
||||||
import { DaemonConfigStore } from "./daemon-config-store.js";
|
import { DaemonConfigStore } from "./daemon-config-store.js";
|
||||||
|
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||||
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
|
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
|
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
|
||||||
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
|
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
|
||||||
@@ -119,7 +120,6 @@ import type {
|
|||||||
AgentProviderRuntimeSettingsMap,
|
AgentProviderRuntimeSettingsMap,
|
||||||
ProviderOverride,
|
ProviderOverride,
|
||||||
} from "./agent/provider-launch-config.js";
|
} from "./agent/provider-launch-config.js";
|
||||||
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
|
||||||
import {
|
import {
|
||||||
ScriptRouteStore,
|
ScriptRouteStore,
|
||||||
createScriptProxyMiddleware,
|
createScriptProxyMiddleware,
|
||||||
@@ -128,6 +128,7 @@ import {
|
|||||||
import { ScriptHealthMonitor } from "./script-health-monitor.js";
|
import { ScriptHealthMonitor } from "./script-health-monitor.js";
|
||||||
import { createScriptStatusEmitter } from "./script-status-projection.js";
|
import { createScriptStatusEmitter } from "./script-status-projection.js";
|
||||||
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||||
|
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
|
||||||
|
|
||||||
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
|
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
|
||||||
|
|
||||||
@@ -170,7 +171,8 @@ export type PaseoDaemonConfig = {
|
|||||||
listen: string;
|
listen: string;
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
corsAllowedOrigins: string[];
|
corsAllowedOrigins: string[];
|
||||||
allowedHosts?: AllowedHostsConfig;
|
allowedHosts?: HostnamesConfig;
|
||||||
|
hostnames?: HostnamesConfig;
|
||||||
mcpEnabled?: boolean;
|
mcpEnabled?: boolean;
|
||||||
mcpInjectIntoAgents?: boolean;
|
mcpInjectIntoAgents?: boolean;
|
||||||
staticDir: string;
|
staticDir: string;
|
||||||
@@ -238,6 +240,7 @@ export async function createPaseoDaemon(
|
|||||||
|
|
||||||
const scriptRouteStore = new ScriptRouteStore();
|
const scriptRouteStore = new ScriptRouteStore();
|
||||||
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
|
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
|
||||||
|
const configuredHostnames = config.hostnames ?? config.allowedHosts;
|
||||||
let wsServer: VoiceAssistantWebSocketServer | null = null;
|
let wsServer: VoiceAssistantWebSocketServer | null = null;
|
||||||
const scriptHealthMonitor = new ScriptHealthMonitor({
|
const scriptHealthMonitor = new ScriptHealthMonitor({
|
||||||
routeStore: scriptRouteStore,
|
routeStore: scriptRouteStore,
|
||||||
@@ -264,7 +267,7 @@ export async function createPaseoDaemon(
|
|||||||
if (listenTarget.type === "tcp") {
|
if (listenTarget.type === "tcp") {
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const hostHeader = typeof req.headers.host === "string" ? req.headers.host : undefined;
|
const hostHeader = typeof req.headers.host === "string" ? req.headers.host : undefined;
|
||||||
if (!isHostAllowed(hostHeader, config.allowedHosts)) {
|
if (!isHostnameAllowed(hostHeader, configuredHostnames)) {
|
||||||
res.status(403).json({ error: "Invalid Host header" });
|
res.status(403).json({ error: "Invalid Host header" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -418,6 +421,10 @@ export async function createPaseoDaemon(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const terminalManager = createTerminalManager();
|
const terminalManager = createTerminalManager();
|
||||||
|
const workspaceGitService = new WorkspaceGitServiceImpl({
|
||||||
|
logger,
|
||||||
|
paseoHome: config.paseoHome,
|
||||||
|
});
|
||||||
|
|
||||||
const detachAgentStoragePersistence = attachAgentStoragePersistence(
|
const detachAgentStoragePersistence = attachAgentStoragePersistence(
|
||||||
logger,
|
logger,
|
||||||
@@ -431,6 +438,7 @@ export async function createPaseoDaemon(
|
|||||||
agentStorage,
|
agentStorage,
|
||||||
projectRegistry,
|
projectRegistry,
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
|
workspaceGitService,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
|
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
|
||||||
@@ -439,6 +447,7 @@ export async function createPaseoDaemon(
|
|||||||
const checkoutDiffManager = new CheckoutDiffManager({
|
const checkoutDiffManager = new CheckoutDiffManager({
|
||||||
logger,
|
logger,
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
|
workspaceGitService,
|
||||||
});
|
});
|
||||||
const loopService = new LoopService({
|
const loopService = new LoopService({
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
@@ -647,7 +656,7 @@ export async function createPaseoDaemon(
|
|||||||
config.paseoHome,
|
config.paseoHome,
|
||||||
daemonConfigStore,
|
daemonConfigStore,
|
||||||
mcpBaseUrl,
|
mcpBaseUrl,
|
||||||
{ allowedOrigins, allowedHosts: config.allowedHosts },
|
{ allowedOrigins, hostnames: configuredHostnames },
|
||||||
speechService,
|
speechService,
|
||||||
terminalManager,
|
terminalManager,
|
||||||
{
|
{
|
||||||
@@ -675,6 +684,7 @@ export async function createPaseoDaemon(
|
|||||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
|
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
|
||||||
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
|
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
|
||||||
|
workspaceGitService,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") {
|
if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") {
|
||||||
@@ -694,8 +704,7 @@ export async function createPaseoDaemon(
|
|||||||
relay: { endpoint: relayPublicEndpoint },
|
relay: { endpoint: relayPublicEndpoint },
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
||||||
logger.info({ url }, "pairing_offer");
|
|
||||||
|
|
||||||
relayTransport?.stop().catch(() => undefined);
|
relayTransport?.stop().catch(() => undefined);
|
||||||
relayTransport = startRelayTransport({
|
relayTransport = startRelayTransport({
|
||||||
|
|||||||
@@ -1,137 +1,184 @@
|
|||||||
import path from "node:path";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
const { getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } = vi.hoisted(
|
const { getCheckoutDiffMock, toCheckoutErrorMock } = vi.hoisted(() => ({
|
||||||
() => {
|
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
|
||||||
const hoistedWatchCalls: Array<{ path: string; close: ReturnType<typeof vi.fn> }> = [];
|
toCheckoutErrorMock: vi.fn((error: unknown) => ({
|
||||||
return {
|
message: error instanceof Error ? error.message : String(error),
|
||||||
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
|
|
||||||
resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"),
|
|
||||||
readdirMock: vi.fn(async (directory: string) => {
|
|
||||||
if (directory === "/tmp/repo") {
|
|
||||||
return [
|
|
||||||
{ name: "packages", isDirectory: () => true },
|
|
||||||
{ name: ".git", isDirectory: () => true },
|
|
||||||
{ name: "README.md", isDirectory: () => false },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (directory === path.join("/tmp/repo", "packages")) {
|
|
||||||
return [
|
|
||||||
{ name: "server", isDirectory: () => true },
|
|
||||||
{ name: "app", isDirectory: () => true },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (directory === path.join("/tmp/repo", "packages", "server")) {
|
|
||||||
return [{ name: "src", isDirectory: () => true }];
|
|
||||||
}
|
|
||||||
if (directory === path.join("/tmp/repo", "packages", "server", "src")) {
|
|
||||||
return [{ name: "server", isDirectory: () => true }];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}),
|
|
||||||
watchCalls: hoistedWatchCalls,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.mock("../utils/run-git-command.js", () => ({
|
|
||||||
runGitCommand: vi.fn(async () => ({
|
|
||||||
stdout: "/tmp/repo\n",
|
|
||||||
stderr: "",
|
|
||||||
truncated: false,
|
|
||||||
exitCode: 0,
|
|
||||||
signal: null,
|
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("node:fs/promises", async () => {
|
|
||||||
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
readdir: readdirMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("node:fs", async () => {
|
|
||||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
watch: vi.fn((watchPath: string) => {
|
|
||||||
const close = vi.fn();
|
|
||||||
watchCalls.push({ path: watchPath, close });
|
|
||||||
return {
|
|
||||||
close,
|
|
||||||
on: vi.fn().mockReturnThis(),
|
|
||||||
} as any;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../utils/checkout-git.js", () => ({
|
vi.mock("../utils/checkout-git.js", () => ({
|
||||||
getCheckoutDiff: getCheckoutDiffMock,
|
getCheckoutDiff: getCheckoutDiffMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./checkout-git-utils.js", () => ({
|
vi.mock("./checkout-git-utils.js", () => ({
|
||||||
READ_ONLY_GIT_ENV: {},
|
toCheckoutError: toCheckoutErrorMock,
|
||||||
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
|
|
||||||
toCheckoutError: vi.fn((error: unknown) => ({
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
})),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||||
|
|
||||||
describe("CheckoutDiffManager Linux watchers", () => {
|
describe("CheckoutDiffManager", () => {
|
||||||
const originalPlatform = process.platform;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
watchCalls.length = 0;
|
vi.useFakeTimers();
|
||||||
getCheckoutDiffMock.mockClear();
|
getCheckoutDiffMock.mockReset();
|
||||||
resolveCheckoutGitDirMock.mockClear();
|
getCheckoutDiffMock.mockResolvedValue({ diff: "", structured: [] });
|
||||||
readdirMock.mockClear();
|
toCheckoutErrorMock.mockClear();
|
||||||
Object.defineProperty(process, "platform", {
|
|
||||||
configurable: true,
|
|
||||||
value: "linux",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Object.defineProperty(process, "platform", {
|
vi.useRealTimers();
|
||||||
configurable: true,
|
|
||||||
value: originalPlatform,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("watches nested repository directories on Linux", async () => {
|
function createManager(options?: {
|
||||||
|
repoRoot?: string | null;
|
||||||
|
getCheckoutDiffImplementation?: typeof getCheckoutDiffMock;
|
||||||
|
}) {
|
||||||
|
const unsubscribe = vi.fn();
|
||||||
|
let onChange: (() => void) | null = null;
|
||||||
|
const mockRequestWorkingTreeWatch = vi.fn(async (_cwd: string, listener: () => void) => {
|
||||||
|
onChange = listener;
|
||||||
|
return {
|
||||||
|
repoRoot: options?.repoRoot === undefined ? "/tmp/repo" : options.repoRoot,
|
||||||
|
unsubscribe,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaceGitService = {
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
peekSnapshot: vi.fn(),
|
||||||
|
getSnapshot: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
scheduleRefreshForCwd: vi.fn(),
|
||||||
|
requestWorkingTreeWatch: mockRequestWorkingTreeWatch,
|
||||||
|
dispose: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.getCheckoutDiffImplementation) {
|
||||||
|
getCheckoutDiffMock.mockImplementation(options.getCheckoutDiffImplementation);
|
||||||
|
}
|
||||||
|
|
||||||
const logger = {
|
const logger = {
|
||||||
child: () => logger,
|
child: () => logger,
|
||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const manager = new CheckoutDiffManager({
|
const manager = new CheckoutDiffManager({
|
||||||
logger: logger as any,
|
logger: logger as any,
|
||||||
paseoHome: "/tmp/paseo-test",
|
paseoHome: "/tmp/paseo-test",
|
||||||
|
workspaceGitService: workspaceGitService as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscription = await manager.subscribe(
|
return {
|
||||||
|
manager,
|
||||||
|
workspaceGitService,
|
||||||
|
mockRequestWorkingTreeWatch,
|
||||||
|
unsubscribe,
|
||||||
|
getOnChange: () => onChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("subscribe requests a working tree watch with the correct cwd", async () => {
|
||||||
|
const { manager, mockRequestWorkingTreeWatch } = createManager();
|
||||||
|
|
||||||
|
await manager.subscribe(
|
||||||
{
|
{
|
||||||
cwd: path.join("/tmp/repo", "packages", "server"),
|
cwd: "/tmp/repo/packages/server",
|
||||||
compare: { mode: "uncommitted" },
|
compare: { mode: "uncommitted" },
|
||||||
},
|
},
|
||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(subscription.initial.error).toBeNull();
|
expect(mockRequestWorkingTreeWatch).toHaveBeenCalledWith(
|
||||||
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
|
|
||||||
"/tmp/repo",
|
|
||||||
"/tmp/repo/.git",
|
|
||||||
"/tmp/repo/packages",
|
|
||||||
"/tmp/repo/packages/app",
|
|
||||||
"/tmp/repo/packages/server",
|
"/tmp/repo/packages/server",
|
||||||
"/tmp/repo/packages/server/src",
|
expect.any(Function),
|
||||||
"/tmp/repo/packages/server/src/server",
|
);
|
||||||
]);
|
});
|
||||||
|
|
||||||
|
test("unsubscribe calls the working tree watch unsubscribe", async () => {
|
||||||
|
const { manager, unsubscribe } = createManager();
|
||||||
|
|
||||||
|
const subscription = await manager.subscribe(
|
||||||
|
{
|
||||||
|
cwd: "/tmp/repo/packages/server",
|
||||||
|
compare: { mode: "uncommitted" },
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
manager.dispose();
|
|
||||||
|
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diffCwd uses repoRoot from the working tree watch result", async () => {
|
||||||
|
const { manager } = createManager({ repoRoot: "/tmp/repo" });
|
||||||
|
|
||||||
|
await manager.subscribe(
|
||||||
|
{
|
||||||
|
cwd: "/tmp/repo/packages/server",
|
||||||
|
compare: { mode: "uncommitted" },
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getCheckoutDiffMock).toHaveBeenCalledWith(
|
||||||
|
"/tmp/repo",
|
||||||
|
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
|
||||||
|
{ paseoHome: "/tmp/paseo-test" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diff refresh is triggered when the working tree watch callback fires", async () => {
|
||||||
|
getCheckoutDiffMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
diff: "",
|
||||||
|
structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
diff: "",
|
||||||
|
structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { manager, getOnChange } = createManager();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
await manager.subscribe(
|
||||||
|
{
|
||||||
|
cwd: "/tmp/repo/packages/server",
|
||||||
|
compare: { mode: "uncommitted" },
|
||||||
|
},
|
||||||
|
listener,
|
||||||
|
);
|
||||||
|
|
||||||
|
const onChange = getOnChange();
|
||||||
|
expect(onChange).toBeTypeOf("function");
|
||||||
|
|
||||||
|
onChange?.();
|
||||||
|
await vi.advanceTimersByTimeAsync(150);
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(listener).toHaveBeenCalledWith({
|
||||||
|
cwd: "/tmp/repo/packages/server",
|
||||||
|
files: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to cwd when the working tree watch returns no repo root", async () => {
|
||||||
|
const { manager } = createManager({ repoRoot: null });
|
||||||
|
|
||||||
|
await manager.subscribe(
|
||||||
|
{
|
||||||
|
cwd: "/tmp/plain",
|
||||||
|
compare: { mode: "uncommitted" },
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getCheckoutDiffMock).toHaveBeenCalledWith(
|
||||||
|
"/tmp/plain",
|
||||||
|
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
|
||||||
|
{ paseoHome: "/tmp/paseo-test" },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { watch, type FSWatcher } from "node:fs";
|
|
||||||
import { readdir } from "node:fs/promises";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
|
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
|
||||||
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
import { getCheckoutDiff } from "../utils/checkout-git.js";
|
import { getCheckoutDiff } from "../utils/checkout-git.js";
|
||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
import { runGitCommand } from "../utils/run-git-command.js";
|
import { toCheckoutError } from "./checkout-git-utils.js";
|
||||||
import { READ_ONLY_GIT_ENV, resolveCheckoutGitDir, toCheckoutError } from "./checkout-git-utils.js";
|
|
||||||
|
|
||||||
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
|
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
|
||||||
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
|
|
||||||
|
|
||||||
export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"];
|
export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"];
|
||||||
|
|
||||||
@@ -31,27 +27,26 @@ type CheckoutDiffWatchTarget = {
|
|||||||
diffCwd: string;
|
diffCwd: string;
|
||||||
compare: CheckoutDiffCompareInput;
|
compare: CheckoutDiffCompareInput;
|
||||||
listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>;
|
listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>;
|
||||||
watchers: FSWatcher[];
|
workingTreeWatchUnsubscribe: (() => void) | null;
|
||||||
fallbackRefreshInterval: NodeJS.Timeout | null;
|
|
||||||
debounceTimer: NodeJS.Timeout | null;
|
debounceTimer: NodeJS.Timeout | null;
|
||||||
refreshPromise: Promise<void> | null;
|
refreshPromise: Promise<void> | null;
|
||||||
refreshQueued: boolean;
|
refreshQueued: boolean;
|
||||||
latestPayload: CheckoutDiffSnapshotPayload | null;
|
latestPayload: CheckoutDiffSnapshotPayload | null;
|
||||||
latestFingerprint: string | null;
|
latestFingerprint: string | null;
|
||||||
watchedPaths: Set<string>;
|
|
||||||
repoWatchPath: string | null;
|
|
||||||
linuxTreeRefreshPromise: Promise<void> | null;
|
|
||||||
linuxTreeRefreshQueued: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class CheckoutDiffManager {
|
export class CheckoutDiffManager {
|
||||||
private readonly logger: pino.Logger;
|
|
||||||
private readonly paseoHome: string;
|
private readonly paseoHome: string;
|
||||||
|
private readonly workspaceGitService: WorkspaceGitService;
|
||||||
private readonly targets = new Map<string, CheckoutDiffWatchTarget>();
|
private readonly targets = new Map<string, CheckoutDiffWatchTarget>();
|
||||||
|
|
||||||
constructor(options: { logger: pino.Logger; paseoHome: string }) {
|
constructor(options: {
|
||||||
this.logger = options.logger.child({ module: "checkout-diff-manager" });
|
logger: pino.Logger;
|
||||||
|
paseoHome: string;
|
||||||
|
workspaceGitService: WorkspaceGitService;
|
||||||
|
}) {
|
||||||
this.paseoHome = options.paseoHome;
|
this.paseoHome = options.paseoHome;
|
||||||
|
this.workspaceGitService = options.workspaceGitService;
|
||||||
}
|
}
|
||||||
|
|
||||||
async subscribe(
|
async subscribe(
|
||||||
@@ -93,22 +88,16 @@ export class CheckoutDiffManager {
|
|||||||
|
|
||||||
getMetrics(): CheckoutDiffMetrics {
|
getMetrics(): CheckoutDiffMetrics {
|
||||||
let checkoutDiffSubscriptionCount = 0;
|
let checkoutDiffSubscriptionCount = 0;
|
||||||
let checkoutDiffWatcherCount = 0;
|
|
||||||
let checkoutDiffFallbackRefreshTargetCount = 0;
|
|
||||||
|
|
||||||
for (const target of this.targets.values()) {
|
for (const target of this.targets.values()) {
|
||||||
checkoutDiffSubscriptionCount += target.listeners.size;
|
checkoutDiffSubscriptionCount += target.listeners.size;
|
||||||
checkoutDiffWatcherCount += target.watchers.length;
|
|
||||||
if (target.fallbackRefreshInterval) {
|
|
||||||
checkoutDiffFallbackRefreshTargetCount += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
checkoutDiffTargetCount: this.targets.size,
|
checkoutDiffTargetCount: this.targets.size,
|
||||||
checkoutDiffSubscriptionCount,
|
checkoutDiffSubscriptionCount,
|
||||||
checkoutDiffWatcherCount,
|
checkoutDiffWatcherCount: 0,
|
||||||
checkoutDiffFallbackRefreshTargetCount,
|
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,15 +133,8 @@ export class CheckoutDiffManager {
|
|||||||
clearTimeout(target.debounceTimer);
|
clearTimeout(target.debounceTimer);
|
||||||
target.debounceTimer = null;
|
target.debounceTimer = null;
|
||||||
}
|
}
|
||||||
if (target.fallbackRefreshInterval) {
|
target.workingTreeWatchUnsubscribe?.();
|
||||||
clearInterval(target.fallbackRefreshInterval);
|
target.workingTreeWatchUnsubscribe = null;
|
||||||
target.fallbackRefreshInterval = null;
|
|
||||||
}
|
|
||||||
for (const watcher of target.watchers) {
|
|
||||||
watcher.close();
|
|
||||||
}
|
|
||||||
target.watchers = [];
|
|
||||||
target.watchedPaths.clear();
|
|
||||||
target.listeners.clear();
|
target.listeners.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,22 +154,6 @@ export class CheckoutDiffManager {
|
|||||||
this.targets.delete(targetKey);
|
this.targets.delete(targetKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const { stdout } = await runGitCommand(
|
|
||||||
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
|
||||||
{
|
|
||||||
cwd,
|
|
||||||
env: READ_ONLY_GIT_ENV,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const root = stdout.trim();
|
|
||||||
return root.length > 0 ? root : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private scheduleTargetRefresh(target: CheckoutDiffWatchTarget): void {
|
private scheduleTargetRefresh(target: CheckoutDiffWatchTarget): void {
|
||||||
if (target.debounceTimer) {
|
if (target.debounceTimer) {
|
||||||
clearTimeout(target.debounceTimer);
|
clearTimeout(target.debounceTimer);
|
||||||
@@ -274,212 +240,27 @@ export class CheckoutDiffManager {
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const watchRoot = await this.resolveCheckoutWatchRoot(cwd);
|
|
||||||
const target: CheckoutDiffWatchTarget = {
|
const target: CheckoutDiffWatchTarget = {
|
||||||
key: targetKey,
|
key: targetKey,
|
||||||
cwd,
|
cwd,
|
||||||
diffCwd: watchRoot ?? cwd,
|
diffCwd: cwd,
|
||||||
compare,
|
compare,
|
||||||
listeners: new Set(),
|
listeners: new Set(),
|
||||||
watchers: [],
|
workingTreeWatchUnsubscribe: null,
|
||||||
fallbackRefreshInterval: null,
|
|
||||||
debounceTimer: null,
|
debounceTimer: null,
|
||||||
refreshPromise: null,
|
refreshPromise: null,
|
||||||
refreshQueued: false,
|
refreshQueued: false,
|
||||||
latestPayload: null,
|
latestPayload: null,
|
||||||
latestFingerprint: null,
|
latestFingerprint: null,
|
||||||
watchedPaths: new Set<string>(),
|
|
||||||
repoWatchPath: null,
|
|
||||||
linuxTreeRefreshPromise: null,
|
|
||||||
linuxTreeRefreshQueued: false,
|
|
||||||
};
|
};
|
||||||
|
const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch(
|
||||||
const repoWatchPath = watchRoot ?? cwd;
|
cwd,
|
||||||
target.repoWatchPath = repoWatchPath;
|
() => this.scheduleTargetRefresh(target),
|
||||||
const watchPaths = new Set<string>([repoWatchPath]);
|
);
|
||||||
const gitDir = await resolveCheckoutGitDir(cwd);
|
target.diffCwd = repoRoot ?? cwd;
|
||||||
if (gitDir) {
|
target.workingTreeWatchUnsubscribe = unsubscribe;
|
||||||
watchPaths.add(gitDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasRecursiveRepoCoverage = false;
|
|
||||||
const allowRecursiveRepoWatch = process.platform !== "linux";
|
|
||||||
if (process.platform === "linux") {
|
|
||||||
hasRecursiveRepoCoverage = await this.ensureLinuxRepoTreeWatchers(target, repoWatchPath);
|
|
||||||
}
|
|
||||||
for (const watchPath of watchPaths) {
|
|
||||||
if (process.platform === "linux" && watchPath === repoWatchPath) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch;
|
|
||||||
const watcherIsRecursive = this.addWatcher(target, watchPath, shouldTryRecursive);
|
|
||||||
if (watchPath === repoWatchPath && watcherIsRecursive) {
|
|
||||||
hasRecursiveRepoCoverage = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const missingRepoCoverage = !hasRecursiveRepoCoverage;
|
|
||||||
if (target.watchers.length === 0 || missingRepoCoverage) {
|
|
||||||
target.fallbackRefreshInterval = setInterval(() => {
|
|
||||||
this.scheduleTargetRefresh(target);
|
|
||||||
}, CHECKOUT_DIFF_FALLBACK_REFRESH_MS);
|
|
||||||
this.logger.warn(
|
|
||||||
{
|
|
||||||
cwd,
|
|
||||||
compare,
|
|
||||||
intervalMs: CHECKOUT_DIFF_FALLBACK_REFRESH_MS,
|
|
||||||
reason:
|
|
||||||
target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage",
|
|
||||||
},
|
|
||||||
"Checkout diff watchers unavailable; using timed refresh fallback",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.targets.set(targetKey, target);
|
this.targets.set(targetKey, target);
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
private addWatcher(
|
|
||||||
target: CheckoutDiffWatchTarget,
|
|
||||||
watchPath: string,
|
|
||||||
shouldTryRecursive: boolean,
|
|
||||||
): boolean {
|
|
||||||
if (target.watchedPaths.has(watchPath)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { cwd, compare } = target;
|
|
||||||
const onChange = () => {
|
|
||||||
if (process.platform === "linux" && target.repoWatchPath) {
|
|
||||||
void this.refreshLinuxRepoTreeWatchers(target);
|
|
||||||
}
|
|
||||||
this.scheduleTargetRefresh(target);
|
|
||||||
};
|
|
||||||
const createWatcher = (recursive: boolean): FSWatcher =>
|
|
||||||
watch(watchPath, { recursive }, () => {
|
|
||||||
onChange();
|
|
||||||
});
|
|
||||||
|
|
||||||
let watcher: FSWatcher | null = null;
|
|
||||||
let watcherIsRecursive = false;
|
|
||||||
try {
|
|
||||||
if (shouldTryRecursive) {
|
|
||||||
watcher = createWatcher(true);
|
|
||||||
watcherIsRecursive = true;
|
|
||||||
} else {
|
|
||||||
watcher = createWatcher(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (shouldTryRecursive) {
|
|
||||||
try {
|
|
||||||
watcher = createWatcher(false);
|
|
||||||
this.logger.warn(
|
|
||||||
{ err: error, watchPath, cwd, compare },
|
|
||||||
"Checkout diff recursive watch unavailable; using non-recursive fallback",
|
|
||||||
);
|
|
||||||
} catch (fallbackError) {
|
|
||||||
this.logger.warn(
|
|
||||||
{ err: fallbackError, watchPath, cwd, compare },
|
|
||||||
"Failed to start checkout diff watcher",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.logger.warn(
|
|
||||||
{ err: error, watchPath, cwd, compare },
|
|
||||||
"Failed to start checkout diff watcher",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!watcher) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
watcher.on("error", (error) => {
|
|
||||||
this.logger.warn({ err: error, watchPath, cwd, compare }, "Checkout diff watcher error");
|
|
||||||
});
|
|
||||||
target.watchers.push(watcher);
|
|
||||||
target.watchedPaths.add(watchPath);
|
|
||||||
return watcherIsRecursive;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureLinuxRepoTreeWatchers(
|
|
||||||
target: CheckoutDiffWatchTarget,
|
|
||||||
rootPath: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const directories = await this.listLinuxWatchDirectories(rootPath);
|
|
||||||
let complete = true;
|
|
||||||
for (const directory of directories) {
|
|
||||||
const watcherWasRecursive = this.addWatcher(target, directory, false);
|
|
||||||
if (!watcherWasRecursive && !target.watchedPaths.has(directory)) {
|
|
||||||
complete = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return complete && target.watchedPaths.has(rootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshLinuxRepoTreeWatchers(target: CheckoutDiffWatchTarget): Promise<void> {
|
|
||||||
if (process.platform !== "linux" || !target.repoWatchPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rootPath = target.repoWatchPath;
|
|
||||||
if (target.linuxTreeRefreshPromise) {
|
|
||||||
target.linuxTreeRefreshQueued = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
target.linuxTreeRefreshPromise = (async () => {
|
|
||||||
do {
|
|
||||||
target.linuxTreeRefreshQueued = false;
|
|
||||||
try {
|
|
||||||
await this.ensureLinuxRepoTreeWatchers(target, rootPath);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
{
|
|
||||||
err: error,
|
|
||||||
cwd: target.cwd,
|
|
||||||
compare: target.compare,
|
|
||||||
rootPath,
|
|
||||||
},
|
|
||||||
"Failed to refresh Linux checkout diff tree watchers",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} while (target.linuxTreeRefreshQueued);
|
|
||||||
})();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await target.linuxTreeRefreshPromise;
|
|
||||||
} finally {
|
|
||||||
target.linuxTreeRefreshPromise = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listLinuxWatchDirectories(rootPath: string): Promise<string[]> {
|
|
||||||
const directories: string[] = [];
|
|
||||||
const pending = [rootPath];
|
|
||||||
|
|
||||||
while (pending.length > 0) {
|
|
||||||
const directory = pending.pop();
|
|
||||||
if (!directory) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
directories.push(directory);
|
|
||||||
|
|
||||||
let entries;
|
|
||||||
try {
|
|
||||||
entries = await readdir(directory, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isDirectory() || entry.name === ".git") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
pending.push(join(directory, entry.name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return directories;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import type {
|
|||||||
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
|
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
|
||||||
import { AgentProviderSchema } from "./agent/provider-manifest.js";
|
import { AgentProviderSchema } from "./agent/provider-manifest.js";
|
||||||
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
|
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
|
||||||
import {
|
import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js";
|
||||||
mergeAllowedHosts,
|
|
||||||
parseAllowedHostsEnv,
|
|
||||||
type AllowedHostsConfig,
|
|
||||||
} from "./allowed-hosts.js";
|
|
||||||
|
|
||||||
const DEFAULT_PORT = 6767;
|
const DEFAULT_PORT = 6767;
|
||||||
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||||
@@ -42,7 +38,7 @@ export type CliConfigOverrides = Partial<{
|
|||||||
relayEnabled: boolean;
|
relayEnabled: boolean;
|
||||||
mcpEnabled: boolean;
|
mcpEnabled: boolean;
|
||||||
mcpInjectIntoAgents: boolean;
|
mcpInjectIntoAgents: boolean;
|
||||||
allowedHosts: AllowedHostsConfig;
|
hostnames: HostnamesConfig;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const OptionalVoiceLlmProviderSchema = z
|
const OptionalVoiceLlmProviderSchema = z
|
||||||
@@ -133,10 +129,10 @@ export function loadConfig(
|
|||||||
|
|
||||||
const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? [];
|
const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? [];
|
||||||
|
|
||||||
const allowedHosts = mergeAllowedHosts([
|
const hostnames = mergeHostnames([
|
||||||
persisted.daemon?.allowedHosts,
|
persisted.daemon?.hostnames,
|
||||||
parseAllowedHostsEnv(env.PASEO_ALLOWED_HOSTS),
|
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
|
||||||
options?.cli?.allowedHosts,
|
options?.cli?.hostnames,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
|
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
|
||||||
@@ -181,7 +177,7 @@ export function loadConfig(
|
|||||||
corsAllowedOrigins: Array.from(
|
corsAllowedOrigins: Array.from(
|
||||||
new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)),
|
new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)),
|
||||||
),
|
),
|
||||||
allowedHosts,
|
hostnames,
|
||||||
mcpEnabled,
|
mcpEnabled,
|
||||||
mcpInjectIntoAgents,
|
mcpInjectIntoAgents,
|
||||||
mcpDebug: env.MCP_DEBUG === "1",
|
mcpDebug: env.MCP_DEBUG === "1",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Writable } from "node:stream";
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { generateLocalPairingOffer } from "../pairing-offer.js";
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
|
|
||||||
function createCapturingLogger() {
|
function createCapturingLogger() {
|
||||||
@@ -22,18 +23,25 @@ function createCapturingLogger() {
|
|||||||
return { logger, lines };
|
return { logger, lines };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseOfferUrlFromLogs(lines: string[]): string {
|
async function getPairingOfferUrl(args: {
|
||||||
for (const line of lines) {
|
paseoHome: string;
|
||||||
try {
|
relayEnabled?: boolean;
|
||||||
const obj = JSON.parse(line) as { msg?: string; url?: string };
|
relayEndpoint?: string;
|
||||||
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
|
relayPublicEndpoint?: string;
|
||||||
return obj.url;
|
appBaseUrl?: string;
|
||||||
}
|
}): Promise<string> {
|
||||||
} catch {
|
const pairing = await generateLocalPairingOffer({
|
||||||
// ignore non-JSON lines
|
paseoHome: args.paseoHome,
|
||||||
}
|
relayEnabled: args.relayEnabled,
|
||||||
|
relayEndpoint: args.relayEndpoint,
|
||||||
|
relayPublicEndpoint: args.relayPublicEndpoint,
|
||||||
|
appBaseUrl: args.appBaseUrl,
|
||||||
|
includeQr: false,
|
||||||
|
});
|
||||||
|
if (!pairing.url) {
|
||||||
|
throw new Error("Expected relay pairing URL to be available");
|
||||||
}
|
}
|
||||||
throw new Error(`pairing_offer log not found. saw ${lines.length} lines`);
|
return pairing.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeOfferFromFragmentUrl(url: string): unknown {
|
function decodeOfferFromFragmentUrl(url: string): unknown {
|
||||||
@@ -72,7 +80,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
|||||||
test("emits relay-only offer URL with stable serverId", async () => {
|
test("emits relay-only offer URL with stable serverId", async () => {
|
||||||
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
|
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
|
||||||
|
|
||||||
const { logger, lines } = createCapturingLogger();
|
const { logger } = createCapturingLogger();
|
||||||
|
|
||||||
const daemon = await createTestPaseoDaemon({
|
const daemon = await createTestPaseoDaemon({
|
||||||
listen: "0.0.0.0",
|
listen: "0.0.0.0",
|
||||||
@@ -81,7 +89,13 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const offerUrl = parseOfferUrlFromLogs(lines);
|
const offerUrl = await getPairingOfferUrl({
|
||||||
|
paseoHome: daemon.paseoHome,
|
||||||
|
relayEnabled: daemon.config.relayEnabled,
|
||||||
|
relayEndpoint: daemon.config.relayEndpoint,
|
||||||
|
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
|
||||||
|
appBaseUrl: daemon.config.appBaseUrl,
|
||||||
|
});
|
||||||
expect(offerUrl.startsWith("https://app.paseo.sh/#offer=")).toBe(true);
|
expect(offerUrl.startsWith("https://app.paseo.sh/#offer=")).toBe(true);
|
||||||
|
|
||||||
const offer = decodeOfferFromFragmentUrl(offerUrl) as {
|
const offer = decodeOfferFromFragmentUrl(offerUrl) as {
|
||||||
@@ -111,7 +125,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
|||||||
|
|
||||||
const tempHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-offer-home-"));
|
const tempHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-offer-home-"));
|
||||||
|
|
||||||
const { logger: logger1, lines: lines1 } = createCapturingLogger();
|
const { logger: logger1 } = createCapturingLogger();
|
||||||
const daemon1 = await createTestPaseoDaemon({
|
const daemon1 = await createTestPaseoDaemon({
|
||||||
listen: "0.0.0.0",
|
listen: "0.0.0.0",
|
||||||
logger: logger1,
|
logger: logger1,
|
||||||
@@ -124,7 +138,13 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
|||||||
let staticDir2: string | null = null;
|
let staticDir2: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const offerUrl1 = parseOfferUrlFromLogs(lines1);
|
const offerUrl1 = await getPairingOfferUrl({
|
||||||
|
paseoHome: daemon1.paseoHome,
|
||||||
|
relayEnabled: daemon1.config.relayEnabled,
|
||||||
|
relayEndpoint: daemon1.config.relayEndpoint,
|
||||||
|
relayPublicEndpoint: daemon1.config.relayPublicEndpoint,
|
||||||
|
appBaseUrl: daemon1.config.appBaseUrl,
|
||||||
|
});
|
||||||
const offer1 = decodeOfferFromFragmentUrl(offerUrl1) as {
|
const offer1 = decodeOfferFromFragmentUrl(offerUrl1) as {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
daemonPublicKeyB64: string;
|
daemonPublicKeyB64: string;
|
||||||
@@ -133,7 +153,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
|||||||
|
|
||||||
await daemon1.close();
|
await daemon1.close();
|
||||||
|
|
||||||
const { logger: logger2, lines: lines2 } = createCapturingLogger();
|
const { logger: logger2 } = createCapturingLogger();
|
||||||
const daemon2 = await createTestPaseoDaemon({
|
const daemon2 = await createTestPaseoDaemon({
|
||||||
listen: "0.0.0.0",
|
listen: "0.0.0.0",
|
||||||
logger: logger2,
|
logger: logger2,
|
||||||
@@ -144,7 +164,13 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
|||||||
staticDir2 = daemon2.staticDir;
|
staticDir2 = daemon2.staticDir;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const offerUrl2 = parseOfferUrlFromLogs(lines2);
|
const offerUrl2 = await getPairingOfferUrl({
|
||||||
|
paseoHome: daemon2.paseoHome,
|
||||||
|
relayEnabled: daemon2.config.relayEnabled,
|
||||||
|
relayEndpoint: daemon2.config.relayEndpoint,
|
||||||
|
relayPublicEndpoint: daemon2.config.relayPublicEndpoint,
|
||||||
|
appBaseUrl: daemon2.config.appBaseUrl,
|
||||||
|
});
|
||||||
const offer2 = decodeOfferFromFragmentUrl(offerUrl2) as {
|
const offer2 = decodeOfferFromFragmentUrl(offerUrl2) as {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
daemonPublicKeyB64: string;
|
daemonPublicKeyB64: string;
|
||||||
@@ -207,12 +233,6 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
|||||||
stdoutLines.push(text);
|
stdoutLines.push(text);
|
||||||
for (const line of text.split("\n")) {
|
for (const line of text.split("\n")) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
if (line.includes("pairing_offer")) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
reject(new Error("unexpected pairing_offer log when --no-relay is set"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(line) as { msg?: string };
|
const parsed = JSON.parse(line) as { msg?: string };
|
||||||
if (parsed.msg !== `Server listening on http://0.0.0.0:${port}`) continue;
|
if (parsed.msg !== `Server listening on http://0.0.0.0:${port}`) continue;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import path from "node:path";
|
|||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { Buffer } from "node:buffer";
|
import { Buffer } from "node:buffer";
|
||||||
|
|
||||||
|
import { generateLocalPairingOffer } from "../pairing-offer.js";
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
|
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
|
||||||
import { buildRelayWebSocketUrl } from "../../shared/daemon-endpoints.js";
|
import { buildRelayWebSocketUrl } from "../../shared/daemon-endpoints.js";
|
||||||
@@ -26,19 +27,25 @@ function createCapturingLogger() {
|
|||||||
return { logger, lines };
|
return { logger, lines };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseOfferUrlFromLogs(lines: string[]): string {
|
async function getPairingOfferUrl(args: {
|
||||||
for (const line of lines) {
|
paseoHome: string;
|
||||||
if (!line.includes("pairing_offer")) continue;
|
relayEnabled?: boolean;
|
||||||
try {
|
relayEndpoint?: string;
|
||||||
const obj = JSON.parse(line) as { msg?: string; url?: string };
|
relayPublicEndpoint?: string;
|
||||||
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
|
appBaseUrl?: string;
|
||||||
return obj.url;
|
}): Promise<string> {
|
||||||
}
|
const pairing = await generateLocalPairingOffer({
|
||||||
} catch {
|
paseoHome: args.paseoHome,
|
||||||
// ignore
|
relayEnabled: args.relayEnabled,
|
||||||
}
|
relayEndpoint: args.relayEndpoint,
|
||||||
|
relayPublicEndpoint: args.relayPublicEndpoint,
|
||||||
|
appBaseUrl: args.appBaseUrl,
|
||||||
|
includeQr: false,
|
||||||
|
});
|
||||||
|
if (!pairing.url) {
|
||||||
|
throw new Error("Expected relay pairing URL to be available");
|
||||||
}
|
}
|
||||||
throw new Error(`pairing_offer log not found. saw ${lines.length} lines`);
|
return pairing.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeOfferFromFragmentUrl(url: string): {
|
function decodeOfferFromFragmentUrl(url: string): {
|
||||||
@@ -199,7 +206,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const offerUrl = parseOfferUrlFromLogs(lines);
|
const offerUrl = await getPairingOfferUrl({
|
||||||
|
paseoHome: daemon.paseoHome,
|
||||||
|
relayEnabled: daemon.config.relayEnabled,
|
||||||
|
relayEndpoint: daemon.config.relayEndpoint,
|
||||||
|
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
|
||||||
|
appBaseUrl: daemon.config.appBaseUrl,
|
||||||
|
});
|
||||||
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
|
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
|
||||||
|
|
||||||
const stableClientId = `cid_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
const stableClientId = `cid_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
||||||
@@ -314,7 +327,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const offerUrl = parseOfferUrlFromLogs(lines);
|
const offerUrl = await getPairingOfferUrl({
|
||||||
|
paseoHome: daemon.paseoHome,
|
||||||
|
relayEnabled: daemon.config.relayEnabled,
|
||||||
|
relayEndpoint: daemon.config.relayEndpoint,
|
||||||
|
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
|
||||||
|
appBaseUrl: daemon.config.appBaseUrl,
|
||||||
|
});
|
||||||
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
|
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
|
||||||
|
|
||||||
// Previously, the daemon would time out waiting for `hello` and reconnect every ~10s.
|
// Previously, the daemon would time out waiting for `hello` and reconnect every ~10s.
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { PersistedConfigSchema } from "./persisted-config.js";
|
||||||
|
import { isHostnameAllowed, mergeHostnames, parseHostnamesEnv } from "./hostnames.js";
|
||||||
|
|
||||||
|
describe("hostnames (vite-style)", () => {
|
||||||
|
it("allows localhost by default", () => {
|
||||||
|
expect(isHostnameAllowed("localhost:6767", undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows subdomains of .localhost by default", () => {
|
||||||
|
expect(isHostnameAllowed("foo.localhost:6767", undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows IP addresses by default", () => {
|
||||||
|
expect(isHostnameAllowed("127.0.0.1:6767", undefined)).toBe(true);
|
||||||
|
expect(isHostnameAllowed("[::1]:6767", undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-default hosts when no allowlist is provided", () => {
|
||||||
|
expect(isHostnameAllowed("evil.com:6767", undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows any host when set to true", () => {
|
||||||
|
expect(isHostnameAllowed("evil.com:6767", true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports leading-dot patterns", () => {
|
||||||
|
const hostnames = [".example.com"];
|
||||||
|
expect(isHostnameAllowed("example.com:6767", hostnames)).toBe(true);
|
||||||
|
expect(isHostnameAllowed("foo.example.com:6767", hostnames)).toBe(true);
|
||||||
|
expect(isHostnameAllowed("foo.bar.example.com:6767", hostnames)).toBe(true);
|
||||||
|
expect(isHostnameAllowed("notexample.com:6767", hostnames)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges arrays (append + de-dupe) and short-circuits on true", () => {
|
||||||
|
expect(mergeHostnames([["a"], ["a", "b"]])).toEqual(["a", "b"]);
|
||||||
|
expect(mergeHostnames([["a"], true, ["b"]])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses env var values", () => {
|
||||||
|
expect(parseHostnamesEnv(undefined)).toBeUndefined();
|
||||||
|
expect(parseHostnamesEnv("")).toBeUndefined();
|
||||||
|
expect(parseHostnamesEnv("true")).toBe(true);
|
||||||
|
expect(parseHostnamesEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes persisted allowedHosts into hostnames for backward compatibility", () => {
|
||||||
|
const parsed = PersistedConfigSchema.parse({
|
||||||
|
daemon: {
|
||||||
|
allowedHosts: [".example.com"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.daemon?.hostnames).toEqual([".example.com"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+13
-13
@@ -1,6 +1,6 @@
|
|||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
|
|
||||||
export type AllowedHostsConfig = true | string[] | undefined;
|
export type HostnamesConfig = true | string[] | undefined;
|
||||||
|
|
||||||
function normalizeHostname(hostname: string): string {
|
function normalizeHostname(hostname: string): string {
|
||||||
return hostname.trim().toLowerCase();
|
return hostname.trim().toLowerCase();
|
||||||
@@ -25,7 +25,7 @@ function parseHostnameFromHostHeader(hostHeader: string): string | null {
|
|||||||
return normalizeHostname(trimmed.slice(0, colonIndex));
|
return normalizeHostname(trimmed.slice(0, colonIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesAllowedHostPattern(hostname: string, pattern: string): boolean {
|
function matchesHostnamePattern(hostname: string, pattern: string): boolean {
|
||||||
const normalizedPattern = normalizeHostname(pattern);
|
const normalizedPattern = normalizeHostname(pattern);
|
||||||
if (!normalizedPattern) return false;
|
if (!normalizedPattern) return false;
|
||||||
|
|
||||||
@@ -47,33 +47,33 @@ function isDefaultAllowedHostname(hostname: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vite-style allowed hosts check, adapted to raw Host headers.
|
* Vite-style hostname allowlist check, adapted to raw Host headers.
|
||||||
*
|
*
|
||||||
* Semantics:
|
* Semantics:
|
||||||
* - `allowedHosts === true` => allow any host.
|
* - `hostnames === true` => allow any host.
|
||||||
* - `allowedHosts === []` or `undefined` => allow localhost, *.localhost, and all IPs.
|
* - `hostnames === []` or `undefined` => allow localhost, *.localhost, and all IPs.
|
||||||
* - `allowedHosts === ['.example.com', 'myhost']` => allow those *in addition* to defaults.
|
* - `hostnames === ['.example.com', 'myhost']` => allow those *in addition* to defaults.
|
||||||
*/
|
*/
|
||||||
export function isHostAllowed(
|
export function isHostnameAllowed(
|
||||||
hostHeader: string | undefined,
|
hostHeader: string | undefined,
|
||||||
allowedHosts: AllowedHostsConfig,
|
hostnames: HostnamesConfig,
|
||||||
): boolean {
|
): boolean {
|
||||||
const hostname = hostHeader ? parseHostnameFromHostHeader(hostHeader) : null;
|
const hostname = hostHeader ? parseHostnameFromHostHeader(hostHeader) : null;
|
||||||
if (!hostname) return false;
|
if (!hostname) return false;
|
||||||
|
|
||||||
if (allowedHosts === true) return true;
|
if (hostnames === true) return true;
|
||||||
|
|
||||||
// Defaults are always allowed.
|
// Defaults are always allowed.
|
||||||
if (isDefaultAllowedHostname(hostname)) return true;
|
if (isDefaultAllowedHostname(hostname)) return true;
|
||||||
|
|
||||||
const patterns = allowedHosts ?? [];
|
const patterns = hostnames ?? [];
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
if (matchesAllowedHostPattern(hostname, pattern)) return true;
|
if (matchesHostnamePattern(hostname, pattern)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeAllowedHosts(values: Array<AllowedHostsConfig>): AllowedHostsConfig {
|
export function mergeHostnames(values: Array<HostnamesConfig>): HostnamesConfig {
|
||||||
let merged: string[] = [];
|
let merged: string[] = [];
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (value === true) return true;
|
if (value === true) return true;
|
||||||
@@ -85,7 +85,7 @@ export function mergeAllowedHosts(values: Array<AllowedHostsConfig>): AllowedHos
|
|||||||
return deduped;
|
return deduped;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAllowedHostsEnv(raw: string | undefined): AllowedHostsConfig {
|
export function parseHostnamesEnv(raw: string | undefined): HostnamesConfig {
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return undefined;
|
if (!trimmed) return undefined;
|
||||||
@@ -228,6 +228,7 @@ export const PersistedConfigSchema = z
|
|||||||
daemon: z
|
daemon: z
|
||||||
.object({
|
.object({
|
||||||
listen: z.string().optional(),
|
listen: z.string().optional(),
|
||||||
|
hostnames: z.union([z.literal(true), z.array(z.string())]).optional(),
|
||||||
allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(),
|
allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(),
|
||||||
mcp: z
|
mcp: z
|
||||||
.object({
|
.object({
|
||||||
@@ -252,6 +253,10 @@ export const PersistedConfigSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
.transform(({ allowedHosts, ...daemon }) => {
|
||||||
|
const hostnames = daemon.hostnames ?? allowedHosts;
|
||||||
|
return hostnames === undefined ? daemon : { ...daemon, hostnames };
|
||||||
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
app: z
|
app: z
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ import {
|
|||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
getCachedCheckoutShortstat,
|
getCachedCheckoutShortstat,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
getCheckoutStatusLite,
|
|
||||||
listBranchSuggestions,
|
listBranchSuggestions,
|
||||||
commitChanges,
|
commitChanges,
|
||||||
mergeToBase,
|
mergeToBase,
|
||||||
@@ -191,6 +190,7 @@ import {
|
|||||||
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
|
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
|
||||||
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
|
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
|
||||||
killTerminalsUnderPath as killWorktreeTerminalsUnderPath,
|
killTerminalsUnderPath as killWorktreeTerminalsUnderPath,
|
||||||
|
registerPendingWorktreeWorkspace as registerPendingWorktreeWorkspaceSession,
|
||||||
} from "./worktree-session.js";
|
} from "./worktree-session.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -1465,6 +1465,63 @@ export class Session {
|
|||||||
return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null;
|
return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async buildProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> {
|
||||||
|
return buildProjectPlacementForCwdStandalone({
|
||||||
|
cwd,
|
||||||
|
workspaceGitService: this.workspaceGitService,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPersistedProjectRecord(input: {
|
||||||
|
workspaceId: string;
|
||||||
|
placement: ProjectPlacementPayload;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}): PersistedProjectRecord {
|
||||||
|
return createPersistedProjectRecord({
|
||||||
|
projectId: input.placement.projectKey,
|
||||||
|
rootPath: deriveProjectRootPath({
|
||||||
|
cwd: input.workspaceId,
|
||||||
|
checkout: input.placement.checkout,
|
||||||
|
}),
|
||||||
|
kind: deriveProjectKind(input.placement.checkout),
|
||||||
|
displayName: input.placement.projectName,
|
||||||
|
createdAt: input.createdAt,
|
||||||
|
updatedAt: input.updatedAt,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPersistedWorkspaceRecord(input: {
|
||||||
|
workspaceId: string;
|
||||||
|
placement: ProjectPlacementPayload;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}): PersistedWorkspaceRecord {
|
||||||
|
return createPersistedWorkspaceRecord({
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
projectId: input.placement.projectKey,
|
||||||
|
cwd: input.workspaceId,
|
||||||
|
kind: deriveWorkspaceKind(input.placement.checkout),
|
||||||
|
displayName: deriveWorkspaceDisplayName({
|
||||||
|
cwd: input.workspaceId,
|
||||||
|
checkout: input.placement.checkout,
|
||||||
|
}),
|
||||||
|
createdAt: input.createdAt,
|
||||||
|
updatedAt: input.updatedAt,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async archiveProjectRecordIfEmpty(projectId: string, archivedAt: string): Promise<void> {
|
||||||
|
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||||
|
(workspace) => workspace.projectId === projectId && !workspace.archivedAt,
|
||||||
|
);
|
||||||
|
if (siblingWorkspaces.length === 0) {
|
||||||
|
await this.projectRegistry.archive(projectId, archivedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveWorkspaceByIdOrDirectory(
|
private async resolveWorkspaceByIdOrDirectory(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<PersistedWorkspaceRecord | null> {
|
): Promise<PersistedWorkspaceRecord | null> {
|
||||||
@@ -1477,10 +1534,8 @@ export class Session {
|
|||||||
private async resolveWorkspaceDirectory(cwd: string): Promise<string> {
|
private async resolveWorkspaceDirectory(cwd: string): Promise<string> {
|
||||||
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
||||||
try {
|
try {
|
||||||
const checkout = await getCheckoutStatusLite(normalizedCwd, {
|
const snapshot = await this.workspaceGitService.getSnapshot(normalizedCwd);
|
||||||
paseoHome: this.paseoHome,
|
return normalizePersistedWorkspaceId(snapshot.git.repoRoot ?? normalizedCwd);
|
||||||
});
|
|
||||||
return normalizePersistedWorkspaceId(checkout.worktreeRoot ?? normalizedCwd);
|
|
||||||
} catch {
|
} catch {
|
||||||
return normalizedCwd;
|
return normalizedCwd;
|
||||||
}
|
}
|
||||||
@@ -1823,184 +1878,10 @@ export class Session {
|
|||||||
await this.handleCheckoutPrStatusRequest(msg);
|
await this.handleCheckoutPrStatusRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "paseo_worktree_list_request":
|
|
||||||
await this.handlePaseoWorktreeListRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "paseo_worktree_archive_request":
|
|
||||||
await this.handlePaseoWorktreeArchiveRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "create_paseo_worktree_request":
|
|
||||||
await this.handleCreatePaseoWorktreeRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "list_available_editors_request":
|
|
||||||
await this.handleListAvailableEditorsRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "open_in_editor_request":
|
|
||||||
await this.handleOpenInEditorRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "open_project_request":
|
|
||||||
await this.handleOpenProjectRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "archive_workspace_request":
|
|
||||||
await this.handleArchiveWorkspaceRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "file_explorer_request":
|
|
||||||
await this.handleFileExplorerRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "project_icon_request":
|
|
||||||
await this.handleProjectIconRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "file_download_token_request":
|
|
||||||
await this.handleFileDownloadTokenRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "list_provider_models_request":
|
|
||||||
await this.handleListProviderModelsRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "list_provider_modes_request":
|
|
||||||
await this.handleListProviderModesRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "list_provider_features_request":
|
|
||||||
await this.handleListProviderFeaturesRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "list_available_providers_request":
|
|
||||||
await this.handleListAvailableProvidersRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "get_providers_snapshot_request":
|
|
||||||
await this.handleGetProvidersSnapshotRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "refresh_providers_snapshot_request":
|
|
||||||
await this.handleRefreshProvidersSnapshotRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "provider_diagnostic_request":
|
|
||||||
await this.handleProviderDiagnosticRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "clear_agent_attention":
|
|
||||||
await this.handleClearAgentAttention(msg.agentId, msg.requestId);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "client_heartbeat":
|
|
||||||
this.handleClientHeartbeat(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "ping": {
|
|
||||||
const now = Date.now();
|
|
||||||
this.emit({
|
|
||||||
type: "pong",
|
|
||||||
payload: {
|
|
||||||
requestId: msg.requestId,
|
|
||||||
clientSentAt: msg.clientSentAt,
|
|
||||||
serverReceivedAt: now,
|
|
||||||
serverSentAt: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "list_commands_request":
|
|
||||||
await this.handleListCommandsRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "register_push_token":
|
|
||||||
this.handleRegisterPushToken(msg.token);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "subscribe_terminals_request":
|
|
||||||
this.handleSubscribeTerminalsRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "unsubscribe_terminals_request":
|
|
||||||
this.handleUnsubscribeTerminalsRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "list_terminals_request":
|
|
||||||
await this.handleListTerminalsRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "create_terminal_request":
|
|
||||||
await this.handleCreateTerminalRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "subscribe_terminal_request":
|
|
||||||
await this.handleSubscribeTerminalRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "unsubscribe_terminal_request":
|
|
||||||
this.handleUnsubscribeTerminalRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "terminal_input":
|
|
||||||
this.handleTerminalInput(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "kill_terminal_request":
|
|
||||||
await this.handleKillTerminalRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "capture_terminal_request":
|
|
||||||
await this.handleCaptureTerminalRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "chat/create":
|
|
||||||
await this.handleChatCreateRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "chat/list":
|
|
||||||
await this.handleChatListRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "chat/inspect":
|
|
||||||
await this.handleChatInspectRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "chat/delete":
|
|
||||||
await this.handleChatDeleteRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "chat/post":
|
|
||||||
await this.handleChatPostRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "chat/read":
|
|
||||||
await this.handleChatReadRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "chat/wait":
|
|
||||||
await this.handleChatWaitRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "github_search_request":
|
case "github_search_request":
|
||||||
await this.handleGitHubSearchRequest(msg);
|
await this.handleGitHubSearchRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "directory_suggestions_request":
|
|
||||||
await this.handleDirectorySuggestionsRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "checkout_pr_create_request":
|
|
||||||
await this.handleCheckoutPrCreateRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "checkout_pr_status_request":
|
|
||||||
await this.handleCheckoutPrStatusRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "paseo_worktree_list_request":
|
case "paseo_worktree_list_request":
|
||||||
await this.handlePaseoWorktreeListRequest(msg);
|
await this.handlePaseoWorktreeListRequest(msg);
|
||||||
break;
|
break;
|
||||||
@@ -2074,7 +1955,7 @@ export class Session {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "clear_agent_attention":
|
case "clear_agent_attention":
|
||||||
await this.handleClearAgentAttention(msg.agentId);
|
await this.handleClearAgentAttention(msg.agentId, msg.requestId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "client_heartbeat":
|
case "client_heartbeat":
|
||||||
@@ -3378,6 +3259,7 @@ export class Session {
|
|||||||
{
|
{
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
sessionLogger: this.sessionLogger,
|
sessionLogger: this.sessionLogger,
|
||||||
|
workspaceGitService: this.workspaceGitService,
|
||||||
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
|
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
|
||||||
createBranchFromBase: (params) => this.createBranchFromBase(params),
|
createBranchFromBase: (params) => this.createBranchFromBase(params),
|
||||||
},
|
},
|
||||||
@@ -5030,7 +4912,6 @@ export class Session {
|
|||||||
const { cwd, requestId } = msg;
|
const { cwd, requestId } = msg;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.workspaceGitService.refresh(cwd, { priority: "high" });
|
|
||||||
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
|
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "checkout_pr_status_response",
|
type: "checkout_pr_status_response",
|
||||||
@@ -5788,7 +5669,19 @@ export class Session {
|
|||||||
projectRecord?: PersistedProjectRecord | null,
|
projectRecord?: PersistedProjectRecord | null,
|
||||||
): Promise<WorkspaceDescriptorPayload> {
|
): Promise<WorkspaceDescriptorPayload> {
|
||||||
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
|
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
|
||||||
const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
let snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
||||||
|
if (!snapshot) {
|
||||||
|
try {
|
||||||
|
snapshot = await this.workspaceGitService.getSnapshot(workspace.cwd);
|
||||||
|
} catch (error) {
|
||||||
|
this.sessionLogger.warn(
|
||||||
|
{ err: error, cwd: workspace.cwd },
|
||||||
|
"Failed to load git snapshot for workspace",
|
||||||
|
);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
@@ -6229,7 +6122,7 @@ export class Session {
|
|||||||
|
|
||||||
const placement = await buildProjectPlacementForCwdStandalone({
|
const placement = await buildProjectPlacementForCwdStandalone({
|
||||||
cwd: normalizedCwd,
|
cwd: normalizedCwd,
|
||||||
paseoHome: this.paseoHome,
|
workspaceGitService: this.workspaceGitService,
|
||||||
});
|
});
|
||||||
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
|
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
@@ -6263,56 +6156,21 @@ export class Session {
|
|||||||
branchName: string;
|
branchName: string;
|
||||||
}): Promise<PersistedWorkspaceRecord> {
|
}): Promise<PersistedWorkspaceRecord> {
|
||||||
await this.findOrCreateWorkspaceForDirectory(options.repoRoot);
|
await this.findOrCreateWorkspaceForDirectory(options.repoRoot);
|
||||||
const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath);
|
return registerPendingWorktreeWorkspaceSession(
|
||||||
const basePlacement = await this.buildProjectPlacementForCwd(options.repoRoot);
|
{
|
||||||
if (!basePlacement) {
|
buildPersistedProjectRecord: (input) => this.buildPersistedProjectRecord(input),
|
||||||
throw new Error(`Workspace not found for repo root ${options.repoRoot}`);
|
buildPersistedWorkspaceRecord: (input) => this.buildPersistedWorkspaceRecord(input),
|
||||||
}
|
buildProjectPlacement: (cwd) => this.buildProjectPlacement(cwd),
|
||||||
|
findWorkspaceByDirectory: (directory) => this.findWorkspaceByDirectory(directory),
|
||||||
const projectId = basePlacement.projectKey;
|
projectRegistry: this.projectRegistry,
|
||||||
const now = new Date().toISOString();
|
syncWorkspaceGitWatchTarget: (cwd, syncOptions) =>
|
||||||
const existingWorkspace = await this.findWorkspaceByDirectory(workspaceDirectory);
|
this.syncWorkspaceGitWatchTarget(cwd, syncOptions),
|
||||||
if (!existingWorkspace) {
|
workspaceRegistry: this.workspaceRegistry,
|
||||||
const newRecord = createPersistedWorkspaceRecord({
|
archiveProjectRecordIfEmpty: (projectId, archivedAt) =>
|
||||||
workspaceId: workspaceDirectory,
|
this.archiveProjectRecordIfEmpty(projectId, archivedAt),
|
||||||
projectId,
|
},
|
||||||
cwd: workspaceDirectory,
|
options,
|
||||||
displayName: options.branchName,
|
|
||||||
kind: "worktree",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
|
||||||
await this.workspaceRegistry.upsert(newRecord);
|
|
||||||
await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
|
|
||||||
return newRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.workspaceRegistry.upsert(
|
|
||||||
createPersistedWorkspaceRecord({
|
|
||||||
workspaceId: existingWorkspace.workspaceId,
|
|
||||||
projectId,
|
|
||||||
cwd: workspaceDirectory,
|
|
||||||
displayName: options.branchName,
|
|
||||||
kind: "worktree",
|
|
||||||
createdAt: existingWorkspace.createdAt,
|
|
||||||
updatedAt: now,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
|
|
||||||
|
|
||||||
if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) {
|
|
||||||
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
|
||||||
(workspace) =>
|
|
||||||
workspace.projectId === existingWorkspace.projectId &&
|
|
||||||
workspace.workspaceId !== existingWorkspace.workspaceId &&
|
|
||||||
!workspace.archivedAt,
|
|
||||||
);
|
|
||||||
if (siblingWorkspaces.length === 0) {
|
|
||||||
await this.projectRegistry.archive(existingWorkspace.projectId, now);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (await this.workspaceRegistry.get(existingWorkspace.workspaceId))!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
||||||
@@ -6790,10 +6648,13 @@ export class Session {
|
|||||||
return handleCreateWorktreeRequest(
|
return handleCreateWorktreeRequest(
|
||||||
{
|
{
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
|
workspaceGitService: this.workspaceGitService,
|
||||||
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
|
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
|
||||||
emit: (message) => this.emit(message),
|
emit: (message) => this.emit(message),
|
||||||
registerPendingWorktreeWorkspace: (options) =>
|
registerPendingWorktreeWorkspace: (options) =>
|
||||||
this.registerPendingWorktreeWorkspace(options),
|
this.registerPendingWorktreeWorkspace(options),
|
||||||
|
syncWorkspaceGitWatchTarget: (cwd, syncOptions) =>
|
||||||
|
this.syncWorkspaceGitWatchTarget(cwd, syncOptions),
|
||||||
sessionLogger: this.sessionLogger,
|
sessionLogger: this.sessionLogger,
|
||||||
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options),
|
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ function createSessionForWorkspaceGitWatchTests(): {
|
|||||||
peekSnapshot: ReturnType<typeof vi.fn>;
|
peekSnapshot: ReturnType<typeof vi.fn>;
|
||||||
getSnapshot: ReturnType<typeof vi.fn>;
|
getSnapshot: ReturnType<typeof vi.fn>;
|
||||||
refresh: ReturnType<typeof vi.fn>;
|
refresh: ReturnType<typeof vi.fn>;
|
||||||
|
requestWorkingTreeWatch: ReturnType<typeof vi.fn>;
|
||||||
|
scheduleRefreshForCwd: ReturnType<typeof vi.fn>;
|
||||||
dispose: ReturnType<typeof vi.fn>;
|
dispose: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
subscriptions: Array<{
|
subscriptions: Array<{
|
||||||
@@ -111,6 +113,11 @@ function createSessionForWorkspaceGitWatchTests(): {
|
|||||||
peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
|
peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
|
||||||
getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
|
getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
|
||||||
refresh: vi.fn(async () => {}),
|
refresh: vi.fn(async () => {}),
|
||||||
|
requestWorkingTreeWatch: vi.fn(async (cwd: string) => ({
|
||||||
|
repoRoot: cwd,
|
||||||
|
unsubscribe: vi.fn(),
|
||||||
|
})),
|
||||||
|
scheduleRefreshForCwd: vi.fn(),
|
||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -351,62 +358,17 @@ describe("workspace git watch targets", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("checkout_pr_status_request explicitly refreshes the focused workspace before reading runtime data", async () => {
|
test("checkout_pr_status_request reads cached snapshot without forcing a refresh", async () => {
|
||||||
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
|
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
|
||||||
let refreshed = false;
|
|
||||||
|
|
||||||
workspaceGitService.refresh.mockImplementation(async () => {
|
|
||||||
refreshed = true;
|
|
||||||
});
|
|
||||||
workspaceGitService.getSnapshot.mockImplementation(async (cwd: string) =>
|
|
||||||
createWorkspaceRuntimeSnapshot(cwd, {
|
|
||||||
github: {
|
|
||||||
pullRequest: refreshed
|
|
||||||
? {
|
|
||||||
url: "https://github.com/acme/repo/pull/457",
|
|
||||||
title: "After explicit refresh",
|
|
||||||
state: "merged",
|
|
||||||
baseRefName: "main",
|
|
||||||
headRefName: "workspace-git-service",
|
|
||||||
isMerged: true,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
url: "https://github.com/acme/repo/pull/456",
|
|
||||||
title: "Before explicit refresh",
|
|
||||||
state: "open",
|
|
||||||
baseRefName: "main",
|
|
||||||
headRefName: "workspace-git-service",
|
|
||||||
isMerged: false,
|
|
||||||
},
|
|
||||||
refreshedAt: refreshed ? "2026-04-12T00:10:00.000Z" : "2026-04-12T00:05:00.000Z",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await session.handleMessage({
|
await session.handleMessage({
|
||||||
type: "checkout_pr_status_request",
|
type: "checkout_pr_status_request",
|
||||||
cwd: "/tmp/repo",
|
cwd: "/tmp/repo",
|
||||||
requestId: "req-pr-refresh",
|
requestId: "req-pr-cached",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(workspaceGitService.refresh).toHaveBeenCalledWith("/tmp/repo", {
|
expect(workspaceGitService.refresh).not.toHaveBeenCalled();
|
||||||
priority: "high",
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
|
||||||
});
|
expect(emitted.find((message) => message.type === "checkout_pr_status_response")).toBeDefined();
|
||||||
expect(
|
|
||||||
emitted.find((message) => message.type === "checkout_pr_status_response")?.payload,
|
|
||||||
).toEqual({
|
|
||||||
cwd: "/tmp/repo",
|
|
||||||
status: {
|
|
||||||
url: "https://github.com/acme/repo/pull/457",
|
|
||||||
title: "After explicit refresh",
|
|
||||||
state: "merged",
|
|
||||||
baseRefName: "main",
|
|
||||||
headRefName: "workspace-git-service",
|
|
||||||
isMerged: true,
|
|
||||||
},
|
|
||||||
githubFeaturesEnabled: true,
|
|
||||||
error: null,
|
|
||||||
requestId: "req-pr-refresh",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ function createNoopWorkspaceGitService() {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
refresh: async () => {},
|
refresh: async () => {},
|
||||||
|
requestWorkingTreeWatch: async (cwd: string) => ({
|
||||||
|
repoRoot: cwd,
|
||||||
|
unsubscribe: () => {},
|
||||||
|
}),
|
||||||
|
scheduleRefreshForCwd: () => {},
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1177,7 +1182,6 @@ describe("workspace aggregation", () => {
|
|||||||
|
|
||||||
test("create paseo worktree request returns a registered workspace descriptor", async () => {
|
test("create paseo worktree request returns a registered workspace descriptor", async () => {
|
||||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||||
const session = createSessionForWorkspaceTests() as any;
|
|
||||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-")));
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-")));
|
||||||
const repoDir = path.join(tempDir, "repo");
|
const repoDir = path.join(tempDir, "repo");
|
||||||
const paseoHome = path.join(tempDir, "paseo-home");
|
const paseoHome = path.join(tempDir, "paseo-home");
|
||||||
@@ -1188,6 +1192,45 @@ describe("workspace aggregation", () => {
|
|||||||
writeFileSync(path.join(repoDir, "file.txt"), "hello\n");
|
writeFileSync(path.join(repoDir, "file.txt"), "hello\n");
|
||||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
const workspaceGitService = createNoopWorkspaceGitService();
|
||||||
|
workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => {
|
||||||
|
if (cwd === repoDir) {
|
||||||
|
return createWorkspaceRuntimeSnapshot(cwd, {
|
||||||
|
git: {
|
||||||
|
repoRoot: repoDir,
|
||||||
|
currentBranch: "main",
|
||||||
|
remoteUrl: null,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cwd.includes("worktree-123")) {
|
||||||
|
return createWorkspaceRuntimeSnapshot(cwd, {
|
||||||
|
git: {
|
||||||
|
repoRoot: cwd,
|
||||||
|
currentBranch: "worktree-123",
|
||||||
|
remoteUrl: null,
|
||||||
|
isPaseoOwnedWorktree: true,
|
||||||
|
mainRepoRoot: repoDir,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return createWorkspaceRuntimeSnapshot(cwd, {
|
||||||
|
git: {
|
||||||
|
repoRoot: cwd,
|
||||||
|
currentBranch: "main",
|
||||||
|
remoteUrl: null,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const session = createSessionForWorkspaceTests({
|
||||||
|
workspaceGitService,
|
||||||
|
}) as any;
|
||||||
|
|
||||||
const workspaces = new Map();
|
const workspaces = new Map();
|
||||||
const projects = new Map();
|
const projects = new Map();
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export async function createTestPaseoDaemon(
|
|||||||
listen: `${listenHost}:0`,
|
listen: `${listenHost}:0`,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
|
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
|
||||||
allowedHosts: true,
|
hostnames: true,
|
||||||
mcpEnabled: true,
|
mcpEnabled: true,
|
||||||
staticDir,
|
staticDir,
|
||||||
mcpDebug: false,
|
mcpDebug: false,
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import {
|
|||||||
wrapSessionMessage,
|
wrapSessionMessage,
|
||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
import { asUint8Array, decodeTerminalStreamFrame } from "../shared/terminal-stream-protocol.js";
|
import { asUint8Array, decodeTerminalStreamFrame } from "../shared/terminal-stream-protocol.js";
|
||||||
import type { AllowedHostsConfig } from "./allowed-hosts.js";
|
import type { HostnamesConfig } from "./hostnames.js";
|
||||||
import { isHostAllowed } from "./allowed-hosts.js";
|
import { isHostnameAllowed } from "./hostnames.js";
|
||||||
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
|
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
|
||||||
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -63,11 +63,71 @@ type PendingConnection = {
|
|||||||
|
|
||||||
type WebSocketServerConfig = {
|
type WebSocketServerConfig = {
|
||||||
allowedOrigins: Set<string>;
|
allowedOrigins: Set<string>;
|
||||||
allowedHosts?: AllowedHostsConfig;
|
hostnames?: HostnamesConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
|
type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
|
||||||
|
|
||||||
|
function createFallbackWorkspaceGitService(): WorkspaceGitServiceImpl {
|
||||||
|
return {
|
||||||
|
subscribe: async ({ cwd }: { cwd: string }) => ({
|
||||||
|
initial: {
|
||||||
|
cwd,
|
||||||
|
git: {
|
||||||
|
isGit: false,
|
||||||
|
repoRoot: null,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
currentBranch: null,
|
||||||
|
remoteUrl: null,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
isDirty: null,
|
||||||
|
aheadBehind: null,
|
||||||
|
aheadOfOrigin: null,
|
||||||
|
behindOfOrigin: null,
|
||||||
|
diffStat: null,
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
featuresEnabled: false,
|
||||||
|
pullRequest: null,
|
||||||
|
error: null,
|
||||||
|
refreshedAt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unsubscribe: () => {},
|
||||||
|
}),
|
||||||
|
peekSnapshot: () => null,
|
||||||
|
getSnapshot: async (cwd: string) => ({
|
||||||
|
cwd,
|
||||||
|
git: {
|
||||||
|
isGit: false,
|
||||||
|
repoRoot: null,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
currentBranch: null,
|
||||||
|
remoteUrl: null,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
isDirty: null,
|
||||||
|
aheadBehind: null,
|
||||||
|
aheadOfOrigin: null,
|
||||||
|
behindOfOrigin: null,
|
||||||
|
diffStat: null,
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
featuresEnabled: false,
|
||||||
|
pullRequest: null,
|
||||||
|
error: null,
|
||||||
|
refreshedAt: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
refresh: async () => {},
|
||||||
|
requestWorkingTreeWatch: async (cwd: string) => ({
|
||||||
|
repoRoot: cwd,
|
||||||
|
unsubscribe: () => {},
|
||||||
|
}),
|
||||||
|
scheduleRefreshForCwd: () => {},
|
||||||
|
dispose: () => {},
|
||||||
|
} as unknown as WorkspaceGitServiceImpl;
|
||||||
|
}
|
||||||
|
|
||||||
function createNoopProjectRegistry(): ProjectRegistry {
|
function createNoopProjectRegistry(): ProjectRegistry {
|
||||||
return {
|
return {
|
||||||
initialize: async () => {},
|
initialize: async () => {},
|
||||||
@@ -327,6 +387,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
getDaemonTcpPort?: () => number | null,
|
getDaemonTcpPort?: () => number | null,
|
||||||
getDaemonTcpHost?: () => string | null,
|
getDaemonTcpHost?: () => string | null,
|
||||||
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
|
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
|
||||||
|
workspaceGitService?: WorkspaceGitServiceImpl,
|
||||||
) {
|
) {
|
||||||
this.logger = logger.child({ module: "websocket-server" });
|
this.logger = logger.child({ module: "websocket-server" });
|
||||||
this.serverId = serverId;
|
this.serverId = serverId;
|
||||||
@@ -354,10 +415,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
|
throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
|
||||||
}
|
}
|
||||||
this.checkoutDiffManager = checkoutDiffManager;
|
this.checkoutDiffManager = checkoutDiffManager;
|
||||||
this.workspaceGitService = new WorkspaceGitServiceImpl({
|
this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService();
|
||||||
logger: this.logger,
|
|
||||||
paseoHome,
|
|
||||||
});
|
|
||||||
this.downloadTokenStore = downloadTokenStore;
|
this.downloadTokenStore = downloadTokenStore;
|
||||||
this.paseoHome = paseoHome;
|
this.paseoHome = paseoHome;
|
||||||
this.daemonConfigStore = daemonConfigStore;
|
this.daemonConfigStore = daemonConfigStore;
|
||||||
@@ -403,7 +461,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const { allowedOrigins, allowedHosts } = wsConfig;
|
const { allowedOrigins, hostnames } = wsConfig;
|
||||||
this.wss = new WebSocketServer({
|
this.wss = new WebSocketServer({
|
||||||
server,
|
server,
|
||||||
path: "/ws",
|
path: "/ws",
|
||||||
@@ -411,7 +469,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
const requestMetadata = extractSocketRequestMetadata(req);
|
const requestMetadata = extractSocketRequestMetadata(req);
|
||||||
const origin = requestMetadata.origin;
|
const origin = requestMetadata.origin;
|
||||||
const requestHost = requestMetadata.host ?? null;
|
const requestHost = requestMetadata.host ?? null;
|
||||||
if (requestHost && !isHostAllowed(requestHost, allowedHosts)) {
|
if (requestHost && !isHostnameAllowed(requestHost, hostnames)) {
|
||||||
this.incrementRuntimeCounter("hostRejected");
|
this.incrementRuntimeCounter("hostRejected");
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
{ ...requestMetadata, host: requestHost },
|
{ ...requestMetadata, host: requestHost },
|
||||||
@@ -553,8 +611,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
|
|
||||||
await Promise.all(cleanupPromises);
|
await Promise.all(cleanupPromises);
|
||||||
this.providerSnapshotManager.destroy();
|
this.providerSnapshotManager.destroy();
|
||||||
this.workspaceGitService.dispose();
|
|
||||||
this.checkoutDiffManager.dispose();
|
this.checkoutDiffManager.dispose();
|
||||||
|
this.workspaceGitService.dispose();
|
||||||
this.pendingConnections.clear();
|
this.pendingConnections.clear();
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
this.externalSessionsByKey.clear();
|
this.externalSessionsByKey.clear();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import path from "node:path";
|
||||||
import type { CheckoutStatusGit, PullRequestStatusResult } from "../utils/checkout-git.js";
|
import type { CheckoutStatusGit, PullRequestStatusResult } from "../utils/checkout-git.js";
|
||||||
import {
|
import {
|
||||||
WorkspaceGitServiceImpl,
|
WorkspaceGitServiceImpl,
|
||||||
@@ -116,11 +117,28 @@ function createWatcher() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDirent(name: string, isDirectory: boolean) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
isDirectory: () => isDirectory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function flushPromises(): Promise<void> {
|
async function flushPromises(): Promise<void> {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
function createService(options?: {
|
function createService(options?: {
|
||||||
getCheckoutStatus?: ReturnType<typeof vi.fn>;
|
getCheckoutStatus?: ReturnType<typeof vi.fn>;
|
||||||
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
|
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
|
||||||
@@ -129,6 +147,8 @@ function createService(options?: {
|
|||||||
resolveAbsoluteGitDir?: ReturnType<typeof vi.fn>;
|
resolveAbsoluteGitDir?: ReturnType<typeof vi.fn>;
|
||||||
hasOriginRemote?: ReturnType<typeof vi.fn>;
|
hasOriginRemote?: ReturnType<typeof vi.fn>;
|
||||||
runGitFetch?: ReturnType<typeof vi.fn>;
|
runGitFetch?: ReturnType<typeof vi.fn>;
|
||||||
|
runGitCommand?: ReturnType<typeof vi.fn>;
|
||||||
|
readdir?: ReturnType<typeof vi.fn>;
|
||||||
watch?: ReturnType<typeof vi.fn>;
|
watch?: ReturnType<typeof vi.fn>;
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
}) {
|
}) {
|
||||||
@@ -137,6 +157,7 @@ function createService(options?: {
|
|||||||
paseoHome: "/tmp/paseo-test",
|
paseoHome: "/tmp/paseo-test",
|
||||||
deps: {
|
deps: {
|
||||||
watch: options?.watch ?? ((() => createWatcher()) as unknown as any),
|
watch: options?.watch ?? ((() => createWatcher()) as unknown as any),
|
||||||
|
readdir: options?.readdir ?? vi.fn(async () => []),
|
||||||
getCheckoutStatus:
|
getCheckoutStatus:
|
||||||
options?.getCheckoutStatus ?? vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
|
options?.getCheckoutStatus ?? vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
|
||||||
getCheckoutShortstat:
|
getCheckoutShortstat:
|
||||||
@@ -151,6 +172,15 @@ function createService(options?: {
|
|||||||
resolveAbsoluteGitDir: options?.resolveAbsoluteGitDir ?? vi.fn(async () => "/tmp/repo/.git"),
|
resolveAbsoluteGitDir: options?.resolveAbsoluteGitDir ?? vi.fn(async () => "/tmp/repo/.git"),
|
||||||
hasOriginRemote: options?.hasOriginRemote ?? vi.fn(async () => false),
|
hasOriginRemote: options?.hasOriginRemote ?? vi.fn(async () => false),
|
||||||
runGitFetch: options?.runGitFetch ?? vi.fn(async () => {}),
|
runGitFetch: options?.runGitFetch ?? vi.fn(async () => {}),
|
||||||
|
runGitCommand:
|
||||||
|
options?.runGitCommand ??
|
||||||
|
vi.fn(async () => ({
|
||||||
|
stdout: "/tmp/repo\n",
|
||||||
|
stderr: "",
|
||||||
|
truncated: false,
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
})),
|
||||||
now: options?.now ?? (() => new Date("2026-04-12T00:00:00.000Z")),
|
now: options?.now ?? (() => new Date("2026-04-12T00:00:00.000Z")),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -217,6 +247,48 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("cold getSnapshot calls share one workspace target setup and cache the snapshot", async () => {
|
||||||
|
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
|
||||||
|
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
|
||||||
|
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
|
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
||||||
|
|
||||||
|
const service = createService({
|
||||||
|
getCheckoutStatus,
|
||||||
|
getPullRequestStatus,
|
||||||
|
resolveAbsoluteGitDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstSnapshotPromise = service.getSnapshot("/tmp/repo");
|
||||||
|
const secondSnapshotPromise = service.getSnapshot("/tmp/repo/.");
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
|
||||||
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
|
||||||
|
expect((service as any).workspaceTargets.size).toBe(0);
|
||||||
|
expect((service as any).workspaceTargetSetups.size).toBe(1);
|
||||||
|
|
||||||
|
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
|
||||||
|
|
||||||
|
await expect(Promise.all([firstSnapshotPromise, secondSnapshotPromise])).resolves.toEqual([
|
||||||
|
createSnapshot("/tmp/repo"),
|
||||||
|
createSnapshot("/tmp/repo"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||||
|
expect((service as any).workspaceTargets.size).toBe(1);
|
||||||
|
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
|
||||||
|
|
||||||
|
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
|
||||||
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => {
|
test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => {
|
||||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
||||||
@@ -432,4 +504,172 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("watches nested repository directories on Linux", async () => {
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
Object.defineProperty(process, "platform", {
|
||||||
|
configurable: true,
|
||||||
|
value: "linux",
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchCalls: Array<{ path: string; close: ReturnType<typeof vi.fn> }> = [];
|
||||||
|
const watch = vi.fn((watchPath: string) => {
|
||||||
|
const watcher = createWatcher();
|
||||||
|
watchCalls.push({ path: watchPath, close: watcher.close });
|
||||||
|
return watcher as any;
|
||||||
|
});
|
||||||
|
const readdir = vi.fn(async (directory: string) => {
|
||||||
|
if (directory === "/tmp/repo") {
|
||||||
|
return [
|
||||||
|
createDirent("packages", true),
|
||||||
|
createDirent(".git", true),
|
||||||
|
createDirent("README.md", false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (directory === path.join("/tmp/repo", "packages")) {
|
||||||
|
return [createDirent("server", true), createDirent("app", true)];
|
||||||
|
}
|
||||||
|
if (directory === path.join("/tmp/repo", "packages", "server")) {
|
||||||
|
return [createDirent("src", true)];
|
||||||
|
}
|
||||||
|
if (directory === path.join("/tmp/repo", "packages", "server", "src")) {
|
||||||
|
return [createDirent("server", true)];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = createService({ watch, readdir });
|
||||||
|
const subscription = await service.requestWorkingTreeWatch(
|
||||||
|
path.join("/tmp/repo", "packages", "server"),
|
||||||
|
vi.fn(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(subscription.repoRoot).toBe("/tmp/repo");
|
||||||
|
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
|
||||||
|
"/tmp/repo",
|
||||||
|
"/tmp/repo/.git",
|
||||||
|
"/tmp/repo/packages",
|
||||||
|
"/tmp/repo/packages/app",
|
||||||
|
"/tmp/repo/packages/server",
|
||||||
|
"/tmp/repo/packages/server/src",
|
||||||
|
"/tmp/repo/packages/server/src/server",
|
||||||
|
]);
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
Object.defineProperty(process, "platform", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalPlatform,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requestWorkingTreeWatch reference-counts watchers by cwd", async () => {
|
||||||
|
const watchers = [createWatcher(), createWatcher()];
|
||||||
|
const watch = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(watchers[0] as any)
|
||||||
|
.mockReturnValueOnce(watchers[1] as any);
|
||||||
|
const service = createService({ watch });
|
||||||
|
|
||||||
|
const firstListener = vi.fn();
|
||||||
|
const secondListener = vi.fn();
|
||||||
|
const first = await service.requestWorkingTreeWatch("/tmp/repo", firstListener);
|
||||||
|
const second = await service.requestWorkingTreeWatch("/tmp/repo/.", secondListener);
|
||||||
|
|
||||||
|
expect(first.repoRoot).toBe("/tmp/repo");
|
||||||
|
expect(second.repoRoot).toBe("/tmp/repo");
|
||||||
|
expect(watch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
first.unsubscribe();
|
||||||
|
expect(watchers[0].close).not.toHaveBeenCalled();
|
||||||
|
expect(watchers[1].close).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
second.unsubscribe();
|
||||||
|
expect(watchers[0].close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(watchers[1].close).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets a 5-second fallback polling interval when recursive watch is unavailable", async () => {
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
// On Linux, recursive watch is never attempted — the service uses per-directory
|
||||||
|
// watchers from the start. This scenario only applies to macOS/Windows where
|
||||||
|
// recursive watch is tried first and may fail.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recursiveUnsupported = new Error("recursive unsupported");
|
||||||
|
const watch = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce((_watchPath: string, options: { recursive: boolean }) => {
|
||||||
|
if (options.recursive) {
|
||||||
|
throw recursiveUnsupported;
|
||||||
|
}
|
||||||
|
return createWatcher() as any;
|
||||||
|
})
|
||||||
|
.mockImplementationOnce(() => createWatcher() as any);
|
||||||
|
|
||||||
|
const service = createService({ watch });
|
||||||
|
const subscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn());
|
||||||
|
const target = (service as any).workingTreeWatchTargets.get("/tmp/repo");
|
||||||
|
|
||||||
|
expect(target?.fallbackRefreshInterval).not.toBeNull();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-git directories fall back to watching cwd with polling", async () => {
|
||||||
|
const watch = vi.fn(() => createWatcher() as any);
|
||||||
|
const runGitCommand = vi.fn(async () => {
|
||||||
|
throw new Error("not a git repository");
|
||||||
|
});
|
||||||
|
const resolveAbsoluteGitDir = vi.fn(async () => null);
|
||||||
|
const service = createService({
|
||||||
|
watch,
|
||||||
|
runGitCommand,
|
||||||
|
resolveAbsoluteGitDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscription = await service.requestWorkingTreeWatch("/tmp/plain", vi.fn());
|
||||||
|
const target = (service as any).workingTreeWatchTargets.get("/tmp/plain");
|
||||||
|
|
||||||
|
expect(subscription.repoRoot).toBeNull();
|
||||||
|
const expectedRecursive = process.platform !== "linux";
|
||||||
|
expect(watch).toHaveBeenCalledWith(
|
||||||
|
"/tmp/plain",
|
||||||
|
{ recursive: expectedRecursive },
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(target?.repoWatchPath).toBe("/tmp/plain");
|
||||||
|
expect(target?.fallbackRefreshInterval).not.toBeNull();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("working tree changes notify listeners and schedule workspace refresh", async () => {
|
||||||
|
const watchCallbacks: Array<() => void> = [];
|
||||||
|
const watch = vi.fn(
|
||||||
|
(_watchPath: string, _options: { recursive: boolean }, callback: () => void) => {
|
||||||
|
watchCallbacks.push(callback);
|
||||||
|
return createWatcher() as any;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const service = createService({ watch });
|
||||||
|
const refreshSpy = vi.spyOn(service as any, "scheduleWorkspaceRefresh");
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
const subscription = await service.requestWorkingTreeWatch("/tmp/repo", listener);
|
||||||
|
expect(watchCallbacks).toHaveLength(2);
|
||||||
|
|
||||||
|
watchCallbacks[0]?.();
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(refreshSpy).toHaveBeenCalledWith("/tmp/repo");
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { watch, type FSWatcher } from "node:fs";
|
import { watch, type FSWatcher } from "node:fs";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile, readdir } from "node:fs/promises";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import type { CheckoutContext } from "../utils/checkout-git.js";
|
import type { CheckoutContext } from "../utils/checkout-git.js";
|
||||||
@@ -12,10 +12,12 @@ import {
|
|||||||
resolveAbsoluteGitDir,
|
resolveAbsoluteGitDir,
|
||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
import { runGitCommand } from "../utils/run-git-command.js";
|
import { runGitCommand } from "../utils/run-git-command.js";
|
||||||
|
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||||
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||||
|
|
||||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
||||||
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||||
|
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
|
||||||
|
|
||||||
export type WorkspaceGitRuntimeSnapshot = {
|
export type WorkspaceGitRuntimeSnapshot = {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -59,6 +61,11 @@ export interface WorkspaceGitService {
|
|||||||
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null;
|
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null;
|
||||||
getSnapshot(cwd: string): Promise<WorkspaceGitRuntimeSnapshot>;
|
getSnapshot(cwd: string): Promise<WorkspaceGitRuntimeSnapshot>;
|
||||||
refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise<void>;
|
refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise<void>;
|
||||||
|
requestWorkingTreeWatch(
|
||||||
|
cwd: string,
|
||||||
|
onChange: () => void,
|
||||||
|
): Promise<{ repoRoot: string | null; unsubscribe: () => void }>;
|
||||||
|
scheduleRefreshForCwd(cwd: string): void;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +73,7 @@ export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => vo
|
|||||||
|
|
||||||
interface WorkspaceGitServiceDependencies {
|
interface WorkspaceGitServiceDependencies {
|
||||||
watch: typeof watch;
|
watch: typeof watch;
|
||||||
|
readdir: typeof readdir;
|
||||||
getCheckoutStatus: typeof getCheckoutStatus;
|
getCheckoutStatus: typeof getCheckoutStatus;
|
||||||
getCheckoutShortstat: typeof getCheckoutShortstat;
|
getCheckoutShortstat: typeof getCheckoutShortstat;
|
||||||
getPullRequestStatus: typeof getPullRequestStatus;
|
getPullRequestStatus: typeof getPullRequestStatus;
|
||||||
@@ -73,6 +81,7 @@ interface WorkspaceGitServiceDependencies {
|
|||||||
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
|
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
|
||||||
hasOriginRemote: (cwd: string) => Promise<boolean>;
|
hasOriginRemote: (cwd: string) => Promise<boolean>;
|
||||||
runGitFetch: (cwd: string) => Promise<void>;
|
runGitFetch: (cwd: string) => Promise<void>;
|
||||||
|
runGitCommand: typeof runGitCommand;
|
||||||
now: () => Date;
|
now: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +111,18 @@ interface RepoGitTarget {
|
|||||||
fetchInFlight: boolean;
|
fetchInFlight: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkingTreeWatchTarget {
|
||||||
|
cwd: string;
|
||||||
|
repoRoot: string | null;
|
||||||
|
repoWatchPath: string | null;
|
||||||
|
watchers: FSWatcher[];
|
||||||
|
watchedPaths: Set<string>;
|
||||||
|
fallbackRefreshInterval: NodeJS.Timeout | null;
|
||||||
|
linuxTreeRefreshPromise: Promise<void> | null;
|
||||||
|
linuxTreeRefreshQueued: boolean;
|
||||||
|
listeners: Set<() => void>;
|
||||||
|
}
|
||||||
|
|
||||||
export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
private readonly paseoHome: string;
|
private readonly paseoHome: string;
|
||||||
@@ -109,12 +130,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
|
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
|
||||||
private readonly repoTargets = new Map<string, RepoGitTarget>();
|
private readonly repoTargets = new Map<string, RepoGitTarget>();
|
||||||
private readonly workspaceTargetSetups = new Map<string, Promise<WorkspaceGitTarget>>();
|
private readonly workspaceTargetSetups = new Map<string, Promise<WorkspaceGitTarget>>();
|
||||||
|
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
|
||||||
|
private readonly workingTreeWatchSetups = new Map<string, Promise<WorkingTreeWatchTarget>>();
|
||||||
|
|
||||||
constructor(options: WorkspaceGitServiceOptions) {
|
constructor(options: WorkspaceGitServiceOptions) {
|
||||||
this.logger = options.logger.child({ module: "workspace-git-service" });
|
this.logger = options.logger.child({ module: "workspace-git-service" });
|
||||||
this.paseoHome = options.paseoHome;
|
this.paseoHome = options.paseoHome;
|
||||||
this.deps = {
|
this.deps = {
|
||||||
watch,
|
watch: options.deps?.watch ?? watch,
|
||||||
|
readdir: options.deps?.readdir ?? readdir,
|
||||||
getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus,
|
getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus,
|
||||||
getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat,
|
getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat,
|
||||||
getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus,
|
getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus,
|
||||||
@@ -122,6 +146,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir,
|
resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir,
|
||||||
hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote,
|
hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote,
|
||||||
runGitFetch: options.deps?.runGitFetch ?? runGitFetch,
|
runGitFetch: options.deps?.runGitFetch ?? runGitFetch,
|
||||||
|
runGitCommand: options.deps?.runGitCommand ?? runGitCommand,
|
||||||
now: options.deps?.now ?? (() => new Date()),
|
now: options.deps?.now ?? (() => new Date()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -151,7 +176,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (target?.latestSnapshot) {
|
if (target?.latestSnapshot) {
|
||||||
return target.latestSnapshot;
|
return target.latestSnapshot;
|
||||||
}
|
}
|
||||||
return this.refreshSnapshot(cwd);
|
|
||||||
|
const ensuredTarget = await this.ensureWorkspaceTarget(cwd);
|
||||||
|
return ensuredTarget.latestSnapshot ?? (await this.refreshSnapshot(cwd));
|
||||||
}
|
}
|
||||||
|
|
||||||
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
|
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
|
||||||
@@ -170,17 +197,47 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
await this.ensureWorkspaceTarget(cwd);
|
await this.ensureWorkspaceTarget(cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async requestWorkingTreeWatch(
|
||||||
|
cwd: string,
|
||||||
|
onChange: () => void,
|
||||||
|
): Promise<{ repoRoot: string | null; unsubscribe: () => void }> {
|
||||||
|
cwd = normalizeWorkspaceId(cwd);
|
||||||
|
const target = await this.ensureWorkingTreeWatchTarget(cwd);
|
||||||
|
target.listeners.add(onChange);
|
||||||
|
|
||||||
|
return {
|
||||||
|
repoRoot: target.repoRoot,
|
||||||
|
unsubscribe: () => {
|
||||||
|
this.removeWorkingTreeWatchListener(cwd, onChange);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleRefreshForCwd(cwd: string): void {
|
||||||
|
cwd = normalizeWorkspaceId(cwd);
|
||||||
|
const target = this.workspaceTargets.get(cwd);
|
||||||
|
if (target) {
|
||||||
|
this.scheduleWorkspaceRefresh(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
for (const target of this.workspaceTargets.values()) {
|
for (const target of this.workspaceTargets.values()) {
|
||||||
this.closeWorkspaceTarget(target);
|
this.closeWorkspaceTarget(target);
|
||||||
}
|
}
|
||||||
this.workspaceTargets.clear();
|
this.workspaceTargets.clear();
|
||||||
|
this.workspaceTargetSetups.clear();
|
||||||
|
|
||||||
for (const target of this.repoTargets.values()) {
|
for (const target of this.repoTargets.values()) {
|
||||||
this.closeRepoTarget(target);
|
this.closeRepoTarget(target);
|
||||||
}
|
}
|
||||||
this.repoTargets.clear();
|
this.repoTargets.clear();
|
||||||
this.workspaceTargetSetups.clear();
|
|
||||||
|
for (const target of this.workingTreeWatchTargets.values()) {
|
||||||
|
this.closeWorkingTreeWatchTarget(target);
|
||||||
|
}
|
||||||
|
this.workingTreeWatchTargets.clear();
|
||||||
|
this.workingTreeWatchSetups.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> {
|
private async ensureWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> {
|
||||||
@@ -201,6 +258,24 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return setup;
|
return setup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
|
||||||
|
const existingTarget = this.workingTreeWatchTargets.get(cwd);
|
||||||
|
if (existingTarget) {
|
||||||
|
return existingTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSetup = this.workingTreeWatchSetups.get(cwd);
|
||||||
|
if (existingSetup) {
|
||||||
|
return existingSetup;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setup = this.createWorkingTreeWatchTarget(cwd).finally(() => {
|
||||||
|
this.workingTreeWatchSetups.delete(cwd);
|
||||||
|
});
|
||||||
|
this.workingTreeWatchSetups.set(cwd, setup);
|
||||||
|
return setup;
|
||||||
|
}
|
||||||
|
|
||||||
private async createWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> {
|
private async createWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> {
|
||||||
const target: WorkspaceGitTarget = {
|
const target: WorkspaceGitTarget = {
|
||||||
cwd,
|
cwd,
|
||||||
@@ -230,6 +305,83 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async createWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
|
||||||
|
const repoRoot = await this.resolveCheckoutWatchRoot(cwd);
|
||||||
|
const target: WorkingTreeWatchTarget = {
|
||||||
|
cwd,
|
||||||
|
repoRoot,
|
||||||
|
repoWatchPath: null,
|
||||||
|
watchers: [],
|
||||||
|
watchedPaths: new Set<string>(),
|
||||||
|
fallbackRefreshInterval: null,
|
||||||
|
linuxTreeRefreshPromise: null,
|
||||||
|
linuxTreeRefreshQueued: false,
|
||||||
|
listeners: new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const repoWatchPath = repoRoot ?? cwd;
|
||||||
|
target.repoWatchPath = repoWatchPath;
|
||||||
|
const watchPaths = new Set<string>([repoWatchPath]);
|
||||||
|
const gitDir = await this.deps.resolveAbsoluteGitDir(cwd);
|
||||||
|
if (gitDir) {
|
||||||
|
watchPaths.add(gitDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasRecursiveRepoCoverage = false;
|
||||||
|
const allowRecursiveRepoWatch = process.platform !== "linux";
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
hasRecursiveRepoCoverage = await this.ensureLinuxRepoTreeWatchers(target, repoWatchPath);
|
||||||
|
}
|
||||||
|
for (const watchPath of watchPaths) {
|
||||||
|
if (process.platform === "linux" && watchPath === repoWatchPath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch;
|
||||||
|
const watcherIsRecursive = this.addWorkingTreeWatcher(target, watchPath, shouldTryRecursive);
|
||||||
|
if (watchPath === repoWatchPath && watcherIsRecursive) {
|
||||||
|
hasRecursiveRepoCoverage = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingRepoCoverage = repoRoot === null || !hasRecursiveRepoCoverage;
|
||||||
|
if (target.watchers.length === 0 || missingRepoCoverage) {
|
||||||
|
target.fallbackRefreshInterval = setInterval(() => {
|
||||||
|
this.scheduleWorkspaceRefresh(cwd);
|
||||||
|
for (const listener of target.listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}, WORKING_TREE_WATCH_FALLBACK_REFRESH_MS);
|
||||||
|
this.logger.warn(
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
intervalMs: WORKING_TREE_WATCH_FALLBACK_REFRESH_MS,
|
||||||
|
reason:
|
||||||
|
target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage",
|
||||||
|
},
|
||||||
|
"Working tree watchers unavailable; using timed refresh fallback",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.workingTreeWatchTargets.set(cwd, target);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await this.deps.runGitCommand(
|
||||||
|
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const root = stdout.trim();
|
||||||
|
return root.length > 0 ? root : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveWorkspaceGitRefsRoot(gitDir: string): Promise<string> {
|
private async resolveWorkspaceGitRefsRoot(gitDir: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const commonDir = (await readFile(join(gitDir, "commondir"), "utf8")).trim();
|
const commonDir = (await readFile(join(gitDir, "commondir"), "utf8")).trim();
|
||||||
@@ -308,7 +460,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
void this.runRepoFetch(repoTarget);
|
void this.runRepoFetch(repoTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleWorkspaceRefresh(target: WorkspaceGitTarget): void {
|
private scheduleWorkspaceRefresh(targetOrCwd: WorkspaceGitTarget | string): void {
|
||||||
|
const target =
|
||||||
|
typeof targetOrCwd === "string"
|
||||||
|
? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd))
|
||||||
|
: targetOrCwd;
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (target.debounceTimer) {
|
if (target.debounceTimer) {
|
||||||
clearTimeout(target.debounceTimer);
|
clearTimeout(target.debounceTimer);
|
||||||
}
|
}
|
||||||
@@ -319,6 +479,149 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}, WORKSPACE_GIT_WATCH_DEBOUNCE_MS);
|
}, WORKSPACE_GIT_WATCH_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private addWorkingTreeWatcher(
|
||||||
|
target: WorkingTreeWatchTarget,
|
||||||
|
watchPath: string,
|
||||||
|
shouldTryRecursive: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (target.watchedPaths.has(watchPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cwd } = target;
|
||||||
|
const onChange = () => {
|
||||||
|
if (process.platform === "linux" && target.repoWatchPath) {
|
||||||
|
void this.refreshLinuxRepoTreeWatchers(target);
|
||||||
|
}
|
||||||
|
this.scheduleWorkspaceRefresh(cwd);
|
||||||
|
for (const listener of target.listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const createWatcher = (recursive: boolean): FSWatcher =>
|
||||||
|
this.deps.watch(watchPath, { recursive }, () => {
|
||||||
|
onChange();
|
||||||
|
});
|
||||||
|
|
||||||
|
let watcher: FSWatcher | null = null;
|
||||||
|
let watcherIsRecursive = false;
|
||||||
|
try {
|
||||||
|
if (shouldTryRecursive) {
|
||||||
|
watcher = createWatcher(true);
|
||||||
|
watcherIsRecursive = true;
|
||||||
|
} else {
|
||||||
|
watcher = createWatcher(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (shouldTryRecursive) {
|
||||||
|
try {
|
||||||
|
watcher = createWatcher(false);
|
||||||
|
this.logger.warn(
|
||||||
|
{ err: error, watchPath, cwd },
|
||||||
|
"Working tree recursive watch unavailable; using non-recursive fallback",
|
||||||
|
);
|
||||||
|
} catch (fallbackError) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ err: fallbackError, watchPath, cwd },
|
||||||
|
"Failed to start working tree watcher",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.warn({ err: error, watchPath, cwd }, "Failed to start working tree watcher");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!watcher) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher.on("error", (error) => {
|
||||||
|
this.logger.warn({ err: error, watchPath, cwd }, "Working tree watcher error");
|
||||||
|
});
|
||||||
|
target.watchers.push(watcher);
|
||||||
|
target.watchedPaths.add(watchPath);
|
||||||
|
return watcherIsRecursive;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureLinuxRepoTreeWatchers(
|
||||||
|
target: WorkingTreeWatchTarget,
|
||||||
|
rootPath: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const directories = await this.listLinuxWatchDirectories(rootPath);
|
||||||
|
let complete = true;
|
||||||
|
for (const directory of directories) {
|
||||||
|
const watcherWasRecursive = this.addWorkingTreeWatcher(target, directory, false);
|
||||||
|
if (!watcherWasRecursive && !target.watchedPaths.has(directory)) {
|
||||||
|
complete = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return complete && target.watchedPaths.has(rootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshLinuxRepoTreeWatchers(target: WorkingTreeWatchTarget): Promise<void> {
|
||||||
|
if (process.platform !== "linux" || !target.repoWatchPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rootPath = target.repoWatchPath;
|
||||||
|
if (target.linuxTreeRefreshPromise) {
|
||||||
|
target.linuxTreeRefreshQueued = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.linuxTreeRefreshPromise = (async () => {
|
||||||
|
do {
|
||||||
|
target.linuxTreeRefreshQueued = false;
|
||||||
|
try {
|
||||||
|
await this.ensureLinuxRepoTreeWatchers(target, rootPath);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
{
|
||||||
|
err: error,
|
||||||
|
cwd: target.cwd,
|
||||||
|
rootPath,
|
||||||
|
},
|
||||||
|
"Failed to refresh Linux working tree watchers",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} while (target.linuxTreeRefreshQueued);
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await target.linuxTreeRefreshPromise;
|
||||||
|
} finally {
|
||||||
|
target.linuxTreeRefreshPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listLinuxWatchDirectories(rootPath: string): Promise<string[]> {
|
||||||
|
const directories: string[] = [];
|
||||||
|
const pending = [rootPath];
|
||||||
|
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const directory = pending.pop();
|
||||||
|
if (!directory) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
directories.push(directory);
|
||||||
|
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await this.deps.readdir(directory, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || entry.name === ".git") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pending.push(join(directory, entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return directories;
|
||||||
|
}
|
||||||
|
|
||||||
private async refreshWorkspaceTarget(target: WorkspaceGitTarget): Promise<void> {
|
private async refreshWorkspaceTarget(target: WorkspaceGitTarget): Promise<void> {
|
||||||
if (target.refreshPromise) {
|
if (target.refreshPromise) {
|
||||||
target.refreshQueued = true;
|
target.refreshQueued = true;
|
||||||
@@ -435,6 +738,21 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
this.workspaceTargets.delete(target.cwd);
|
this.workspaceTargets.delete(target.cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void {
|
||||||
|
const target = this.workingTreeWatchTargets.get(cwd);
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.listeners.delete(listener);
|
||||||
|
if (target.listeners.size > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closeWorkingTreeWatchTarget(target);
|
||||||
|
this.workingTreeWatchTargets.delete(cwd);
|
||||||
|
}
|
||||||
|
|
||||||
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
||||||
if (target.debounceTimer) {
|
if (target.debounceTimer) {
|
||||||
clearTimeout(target.debounceTimer);
|
clearTimeout(target.debounceTimer);
|
||||||
@@ -448,6 +766,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target.listeners.clear();
|
target.listeners.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
|
||||||
|
if (target.fallbackRefreshInterval) {
|
||||||
|
clearInterval(target.fallbackRefreshInterval);
|
||||||
|
target.fallbackRefreshInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const watcher of target.watchers) {
|
||||||
|
watcher.close();
|
||||||
|
}
|
||||||
|
target.watchers = [];
|
||||||
|
target.watchedPaths.clear();
|
||||||
|
target.listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
private closeRepoTarget(target: RepoGitTarget): void {
|
private closeRepoTarget(target: RepoGitTarget): void {
|
||||||
if (target.intervalId) {
|
if (target.intervalId) {
|
||||||
clearInterval(target.intervalId);
|
clearInterval(target.intervalId);
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||||
|
import { AgentStorage } from "./agent/agent-storage.js";
|
||||||
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
|
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||||
|
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||||
|
|
||||||
|
function createNoopWorkspaceGitService(): WorkspaceGitService {
|
||||||
|
return {
|
||||||
|
subscribe: async (params) => ({
|
||||||
|
initial: {
|
||||||
|
cwd: params.cwd,
|
||||||
|
git: {
|
||||||
|
isGit: false,
|
||||||
|
repoRoot: null,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
currentBranch: null,
|
||||||
|
remoteUrl: null,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
isDirty: null,
|
||||||
|
aheadBehind: null,
|
||||||
|
aheadOfOrigin: null,
|
||||||
|
behindOfOrigin: null,
|
||||||
|
diffStat: null,
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
featuresEnabled: false,
|
||||||
|
pullRequest: null,
|
||||||
|
error: null,
|
||||||
|
refreshedAt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unsubscribe: () => {},
|
||||||
|
}),
|
||||||
|
peekSnapshot: () => null,
|
||||||
|
getSnapshot: async (cwd) => ({
|
||||||
|
cwd,
|
||||||
|
git: {
|
||||||
|
isGit: false,
|
||||||
|
repoRoot: null,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
currentBranch: null,
|
||||||
|
remoteUrl: null,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
isDirty: null,
|
||||||
|
aheadBehind: null,
|
||||||
|
aheadOfOrigin: null,
|
||||||
|
behindOfOrigin: null,
|
||||||
|
diffStat: null,
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
featuresEnabled: false,
|
||||||
|
pullRequest: null,
|
||||||
|
error: null,
|
||||||
|
refreshedAt: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
refresh: async () => {},
|
||||||
|
requestWorkingTreeWatch: async () => ({
|
||||||
|
repoRoot: null,
|
||||||
|
unsubscribe: () => {},
|
||||||
|
}),
|
||||||
|
scheduleRefreshForCwd: () => {},
|
||||||
|
dispose: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bootstrapWorkspaceRegistries", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let paseoHome: string;
|
||||||
|
let agentStorage: AgentStorage;
|
||||||
|
let projectRegistry: FileBackedProjectRegistry;
|
||||||
|
let workspaceRegistry: FileBackedWorkspaceRegistry;
|
||||||
|
let workspaceGitService: WorkspaceGitService;
|
||||||
|
const logger = createTestLogger();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-bootstrap-"));
|
||||||
|
paseoHome = path.join(tmpDir, ".paseo");
|
||||||
|
agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger);
|
||||||
|
projectRegistry = new FileBackedProjectRegistry(
|
||||||
|
path.join(paseoHome, "projects", "projects.json"),
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||||
|
path.join(paseoHome, "projects", "workspaces.json"),
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
workspaceGitService = createNoopWorkspaceGitService();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("materializes workspace registries from non-archived agent records", async () => {
|
||||||
|
await agentStorage.initialize();
|
||||||
|
await agentStorage.upsert({
|
||||||
|
id: "agent-1",
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/tmp/non-git-project",
|
||||||
|
createdAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||||
|
lastActivityAt: "2026-03-02T00:00:00.000Z",
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
title: null,
|
||||||
|
labels: {},
|
||||||
|
lastStatus: "idle",
|
||||||
|
lastModeId: null,
|
||||||
|
config: null,
|
||||||
|
runtimeInfo: { provider: "codex", sessionId: null },
|
||||||
|
persistence: null,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
await agentStorage.upsert({
|
||||||
|
id: "agent-2",
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/tmp/non-git-project",
|
||||||
|
createdAt: "2026-03-01T01:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
|
lastActivityAt: "2026-03-03T00:00:00.000Z",
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
title: null,
|
||||||
|
labels: {},
|
||||||
|
lastStatus: "running",
|
||||||
|
lastModeId: null,
|
||||||
|
config: null,
|
||||||
|
runtimeInfo: { provider: "codex", sessionId: null },
|
||||||
|
persistence: null,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
await agentStorage.upsert({
|
||||||
|
id: "agent-archived",
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/tmp/archived-project",
|
||||||
|
createdAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
lastActivityAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
title: null,
|
||||||
|
labels: {},
|
||||||
|
lastStatus: "idle",
|
||||||
|
lastModeId: null,
|
||||||
|
config: null,
|
||||||
|
runtimeInfo: { provider: "codex", sessionId: null },
|
||||||
|
persistence: null,
|
||||||
|
archivedAt: "2026-03-02T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
await bootstrapWorkspaceRegistries({
|
||||||
|
paseoHome,
|
||||||
|
agentStorage,
|
||||||
|
projectRegistry,
|
||||||
|
workspaceRegistry,
|
||||||
|
workspaceGitService,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaces = await workspaceRegistry.list();
|
||||||
|
expect(workspaces).toHaveLength(1);
|
||||||
|
expect(workspaces[0]?.workspaceId).toBe("/tmp/non-git-project");
|
||||||
|
expect(workspaces[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
|
||||||
|
expect(workspaces[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
|
||||||
|
|
||||||
|
const projects = await projectRegistry.list();
|
||||||
|
expect(projects).toHaveLength(1);
|
||||||
|
expect(projects[0]?.projectId).toBe("/tmp/non-git-project");
|
||||||
|
expect(projects[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
|
||||||
|
expect(projects[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not rematerialize when registry files already exist", async () => {
|
||||||
|
await projectRegistry.initialize();
|
||||||
|
await workspaceRegistry.initialize();
|
||||||
|
await projectRegistry.upsert({
|
||||||
|
projectId: "/tmp/existing",
|
||||||
|
rootPath: "/tmp/existing",
|
||||||
|
kind: "non_git",
|
||||||
|
displayName: "existing",
|
||||||
|
createdAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
await workspaceRegistry.upsert({
|
||||||
|
workspaceId: "/tmp/existing",
|
||||||
|
projectId: "/tmp/existing",
|
||||||
|
cwd: "/tmp/existing",
|
||||||
|
kind: "directory",
|
||||||
|
displayName: "existing",
|
||||||
|
createdAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await agentStorage.initialize();
|
||||||
|
await agentStorage.upsert({
|
||||||
|
id: "agent-1",
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/tmp/another-project",
|
||||||
|
createdAt: "2026-03-02T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||||
|
lastActivityAt: "2026-03-02T00:00:00.000Z",
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
title: null,
|
||||||
|
labels: {},
|
||||||
|
lastStatus: "idle",
|
||||||
|
lastModeId: null,
|
||||||
|
config: null,
|
||||||
|
runtimeInfo: { provider: "codex", sessionId: null },
|
||||||
|
persistence: null,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await bootstrapWorkspaceRegistries({
|
||||||
|
paseoHome,
|
||||||
|
agentStorage,
|
||||||
|
projectRegistry,
|
||||||
|
workspaceRegistry,
|
||||||
|
workspaceGitService,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await projectRegistry.list()).toHaveLength(1);
|
||||||
|
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||||
|
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("/tmp/existing");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
deriveWorkspaceKind,
|
deriveWorkspaceKind,
|
||||||
normalizeWorkspaceId,
|
normalizeWorkspaceId,
|
||||||
} from "./workspace-registry-model.js";
|
} from "./workspace-registry-model.js";
|
||||||
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
import {
|
import {
|
||||||
createPersistedProjectRecord,
|
createPersistedProjectRecord,
|
||||||
createPersistedWorkspaceRecord,
|
createPersistedWorkspaceRecord,
|
||||||
@@ -53,6 +54,7 @@ export async function bootstrapWorkspaceRegistries(options: {
|
|||||||
agentStorage: AgentStorage;
|
agentStorage: AgentStorage;
|
||||||
projectRegistry: ProjectRegistry;
|
projectRegistry: ProjectRegistry;
|
||||||
workspaceRegistry: WorkspaceRegistry;
|
workspaceRegistry: WorkspaceRegistry;
|
||||||
|
workspaceGitService: WorkspaceGitService;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const [projectsExists, workspacesExists] = await Promise.all([
|
const [projectsExists, workspacesExists] = await Promise.all([
|
||||||
@@ -79,7 +81,7 @@ export async function bootstrapWorkspaceRegistries(options: {
|
|||||||
const normalizedCwd = normalizeWorkspaceId(record.cwd);
|
const normalizedCwd = normalizeWorkspaceId(record.cwd);
|
||||||
const placement = await buildProjectPlacementForCwd({
|
const placement = await buildProjectPlacementForCwd({
|
||||||
cwd: normalizedCwd,
|
cwd: normalizedCwd,
|
||||||
paseoHome: options.paseoHome,
|
workspaceGitService: options.workspaceGitService,
|
||||||
});
|
});
|
||||||
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
|
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
|
||||||
const existing = recordsByWorkspaceId.get(workspaceId) ?? { placement, records: [] };
|
const existing = recordsByWorkspaceId.get(workspaceId) ?? { placement, records: [] };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
import { getCheckoutStatusLite } from "../utils/checkout-git.js";
|
|
||||||
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js";
|
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js";
|
||||||
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||||
|
|
||||||
export type PersistedProjectKind = "git" | "non_git";
|
export type PersistedProjectKind = "git" | "non_git";
|
||||||
@@ -198,45 +198,15 @@ export async function detectStaleWorkspaces(
|
|||||||
|
|
||||||
export async function buildProjectPlacementForCwd(input: {
|
export async function buildProjectPlacementForCwd(input: {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
paseoHome: string;
|
workspaceGitService: WorkspaceGitService;
|
||||||
}): Promise<ProjectPlacementPayload> {
|
}): Promise<ProjectPlacementPayload> {
|
||||||
const normalizedCwd = normalizeWorkspaceId(input.cwd);
|
const normalizedCwd = normalizeWorkspaceId(input.cwd);
|
||||||
const checkout = await getCheckoutStatusLite(normalizedCwd, { paseoHome: input.paseoHome })
|
const checkout = await input.workspaceGitService
|
||||||
.then((status): ProjectCheckoutLitePayload => {
|
.getSnapshot(normalizedCwd)
|
||||||
if (!status.isGit) {
|
.then(
|
||||||
return {
|
(snapshot): ProjectCheckoutLitePayload =>
|
||||||
cwd: normalizedCwd,
|
checkoutLiteFromGitSnapshot(normalizedCwd, snapshot.git),
|
||||||
isGit: false,
|
)
|
||||||
currentBranch: null,
|
|
||||||
remoteUrl: null,
|
|
||||||
worktreeRoot: null,
|
|
||||||
isPaseoOwnedWorktree: false,
|
|
||||||
mainRepoRoot: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.isPaseoOwnedWorktree && status.mainRepoRoot) {
|
|
||||||
return {
|
|
||||||
cwd: normalizedCwd,
|
|
||||||
isGit: true,
|
|
||||||
currentBranch: status.currentBranch,
|
|
||||||
remoteUrl: status.remoteUrl,
|
|
||||||
worktreeRoot: status.worktreeRoot,
|
|
||||||
isPaseoOwnedWorktree: true,
|
|
||||||
mainRepoRoot: status.mainRepoRoot,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
cwd: normalizedCwd,
|
|
||||||
isGit: true,
|
|
||||||
currentBranch: status.currentBranch,
|
|
||||||
remoteUrl: status.remoteUrl,
|
|
||||||
worktreeRoot: status.worktreeRoot,
|
|
||||||
isPaseoOwnedWorktree: false,
|
|
||||||
mainRepoRoot: null,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.catch(
|
.catch(
|
||||||
(): ProjectCheckoutLitePayload => ({
|
(): ProjectCheckoutLitePayload => ({
|
||||||
cwd: normalizedCwd,
|
cwd: normalizedCwd,
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ import {
|
|||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
import { findGitHubPrAttachment } from "./agent/prompt-attachments.js";
|
import { findGitHubPrAttachment } from "./agent/prompt-attachments.js";
|
||||||
import type {
|
import type {
|
||||||
|
PersistedProjectRecord,
|
||||||
PersistedWorkspaceRecord,
|
PersistedWorkspaceRecord,
|
||||||
ProjectRegistry,
|
ProjectRegistry,
|
||||||
WorkspaceRegistry,
|
WorkspaceRegistry,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
|
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
|
||||||
import {
|
import {
|
||||||
applyWorktreeSetupProgressEvent,
|
applyWorktreeSetupProgressEvent,
|
||||||
@@ -33,7 +35,11 @@ import {
|
|||||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||||
import { getCheckoutStatusLite, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js";
|
import {
|
||||||
|
getCheckoutStatus,
|
||||||
|
getCurrentBranch,
|
||||||
|
resolveRepositoryDefaultBranch,
|
||||||
|
} from "../utils/checkout-git.js";
|
||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
import {
|
import {
|
||||||
computeWorktreePath,
|
computeWorktreePath,
|
||||||
@@ -51,8 +57,7 @@ import {
|
|||||||
WorktreeSetupError,
|
WorktreeSetupError,
|
||||||
} from "../utils/worktree.js";
|
} from "../utils/worktree.js";
|
||||||
import { writePaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
import { writePaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||||
import { runGitCommand } from "../utils/run-git-command.js";
|
import { toCheckoutError } from "./checkout-git-utils.js";
|
||||||
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
||||||
@@ -70,6 +75,7 @@ type EmitSessionMessage = (message: SessionOutboundMessage) => void;
|
|||||||
type BuildAgentSessionConfigDependencies = {
|
type BuildAgentSessionConfigDependencies = {
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
sessionLogger: Logger;
|
sessionLogger: Logger;
|
||||||
|
workspaceGitService?: WorkspaceGitService;
|
||||||
checkoutExistingBranch: (cwd: string, branch: string) => Promise<void>;
|
checkoutExistingBranch: (cwd: string, branch: string) => Promise<void>;
|
||||||
createBranchFromBase: (params: {
|
createBranchFromBase: (params: {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -90,6 +96,18 @@ type ArchivePaseoWorktreeDependencies = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type RegisterPendingWorktreeWorkspaceDependencies = {
|
type RegisterPendingWorktreeWorkspaceDependencies = {
|
||||||
|
buildPersistedProjectRecord: (input: {
|
||||||
|
workspaceId: string;
|
||||||
|
placement: ProjectPlacementPayload;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}) => PersistedProjectRecord;
|
||||||
|
buildPersistedWorkspaceRecord: (input: {
|
||||||
|
workspaceId: string;
|
||||||
|
placement: ProjectPlacementPayload;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}) => PersistedWorkspaceRecord;
|
||||||
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
|
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
|
||||||
findWorkspaceByDirectory: (directory: string) => Promise<PersistedWorkspaceRecord | null>;
|
findWorkspaceByDirectory: (directory: string) => Promise<PersistedWorkspaceRecord | null>;
|
||||||
projectRegistry: Pick<ProjectRegistry, "get" | "upsert" | "archive">;
|
projectRegistry: Pick<ProjectRegistry, "get" | "upsert" | "archive">;
|
||||||
@@ -121,6 +139,7 @@ type HandleWorkspaceSetupStatusRequestDependencies = {
|
|||||||
|
|
||||||
type HandleCreatePaseoWorktreeRequestDependencies = {
|
type HandleCreatePaseoWorktreeRequestDependencies = {
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
|
workspaceGitService?: WorkspaceGitService;
|
||||||
describeWorkspaceRecord: (
|
describeWorkspaceRecord: (
|
||||||
workspace: PersistedWorkspaceRecord,
|
workspace: PersistedWorkspaceRecord,
|
||||||
) => Promise<WorkspaceDescriptorPayload>;
|
) => Promise<WorkspaceDescriptorPayload>;
|
||||||
@@ -130,6 +149,7 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
|
|||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
branchName: string;
|
branchName: string;
|
||||||
}) => Promise<PersistedWorkspaceRecord>;
|
}) => Promise<PersistedWorkspaceRecord>;
|
||||||
|
syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise<void>;
|
||||||
sessionLogger: Logger;
|
sessionLogger: Logger;
|
||||||
runWorktreeSetupInBackground: (options: {
|
runWorktreeSetupInBackground: (options: {
|
||||||
requestCwd: string;
|
requestCwd: string;
|
||||||
@@ -178,7 +198,11 @@ export async function buildAgentSessionConfig(
|
|||||||
const baseBranch =
|
const baseBranch =
|
||||||
githubPrAttachment.baseRefName?.trim() ||
|
githubPrAttachment.baseRefName?.trim() ||
|
||||||
normalized.baseBranch ||
|
normalized.baseBranch ||
|
||||||
(await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
|
(await resolveGitCreateBaseBranch(
|
||||||
|
cwd,
|
||||||
|
dependencies.workspaceGitService,
|
||||||
|
dependencies.paseoHome,
|
||||||
|
));
|
||||||
const worktreeSlug =
|
const worktreeSlug =
|
||||||
normalized.worktreeSlug ?? slugify(resolveGitHubPrBranchName(githubPrAttachment));
|
normalized.worktreeSlug ?? slugify(resolveGitHubPrBranchName(githubPrAttachment));
|
||||||
const createdWorktree = await createGitHubPrWorktree({
|
const createdWorktree = await createGitHubPrWorktree({
|
||||||
@@ -208,11 +232,7 @@ export async function buildAgentSessionConfig(
|
|||||||
if (normalized.createNewBranch) {
|
if (normalized.createNewBranch) {
|
||||||
targetBranch = normalized.newBranchName!;
|
targetBranch = normalized.newBranchName!;
|
||||||
} else {
|
} else {
|
||||||
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
|
targetBranch = (await getCurrentBranch(cwd)) ?? "";
|
||||||
cwd,
|
|
||||||
env: READ_ONLY_GIT_ENV,
|
|
||||||
});
|
|
||||||
targetBranch = stdout.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetBranch) {
|
if (!targetBranch) {
|
||||||
@@ -225,7 +245,12 @@ export async function buildAgentSessionConfig(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const baseBranch =
|
const baseBranch =
|
||||||
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
|
normalized.baseBranch ??
|
||||||
|
(await resolveGitCreateBaseBranch(
|
||||||
|
cwd,
|
||||||
|
dependencies.workspaceGitService,
|
||||||
|
dependencies.paseoHome,
|
||||||
|
));
|
||||||
const createdWorktree = await createAgentWorktree({
|
const createdWorktree = await createAgentWorktree({
|
||||||
branchName: targetBranch,
|
branchName: targetBranch,
|
||||||
cwd,
|
cwd,
|
||||||
@@ -237,7 +262,12 @@ export async function buildAgentSessionConfig(
|
|||||||
worktreeBootstrap = createdWorktree;
|
worktreeBootstrap = createdWorktree;
|
||||||
} else if (normalized.createNewBranch) {
|
} else if (normalized.createNewBranch) {
|
||||||
const baseBranch =
|
const baseBranch =
|
||||||
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
|
normalized.baseBranch ??
|
||||||
|
(await resolveGitCreateBaseBranch(
|
||||||
|
cwd,
|
||||||
|
dependencies.workspaceGitService,
|
||||||
|
dependencies.paseoHome,
|
||||||
|
));
|
||||||
await dependencies.createBranchFromBase({
|
await dependencies.createBranchFromBase({
|
||||||
cwd,
|
cwd,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
@@ -322,13 +352,31 @@ export function assertSafeGitRef(ref: string, label: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveGitCreateBaseBranch(cwd: string, paseoHome?: string): Promise<string> {
|
export async function resolveGitCreateBaseBranch(
|
||||||
const checkout = await getCheckoutStatusLite(cwd, { paseoHome });
|
cwd: string,
|
||||||
if (!checkout.isGit) {
|
workspaceGitService?: WorkspaceGitService,
|
||||||
throw new Error("Cannot create a worktree outside a git repository");
|
paseoHome?: string,
|
||||||
}
|
): Promise<string> {
|
||||||
|
let repoRoot = cwd;
|
||||||
|
if (workspaceGitService) {
|
||||||
|
const snapshot = await workspaceGitService.getSnapshot(cwd);
|
||||||
|
if (!snapshot.git.isGit) {
|
||||||
|
throw new Error("Cannot create a worktree outside a git repository");
|
||||||
|
}
|
||||||
|
|
||||||
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd;
|
repoRoot = snapshot.git.isPaseoOwnedWorktree
|
||||||
|
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? cwd)
|
||||||
|
: (snapshot.git.repoRoot ?? cwd);
|
||||||
|
} else {
|
||||||
|
const checkout = await getCheckoutStatus(cwd, paseoHome ? { paseoHome } : undefined);
|
||||||
|
if (!checkout.isGit) {
|
||||||
|
throw new Error("Cannot create a worktree outside a git repository");
|
||||||
|
}
|
||||||
|
|
||||||
|
repoRoot = checkout.isPaseoOwnedWorktree
|
||||||
|
? (checkout.mainRepoRoot ?? checkout.repoRoot ?? cwd)
|
||||||
|
: (checkout.repoRoot ?? cwd);
|
||||||
|
}
|
||||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
||||||
if (!baseBranch) {
|
if (!baseBranch) {
|
||||||
throw new Error("Unable to resolve repository default branch");
|
throw new Error("Unable to resolve repository default branch");
|
||||||
@@ -603,46 +651,47 @@ export async function registerPendingWorktreeWorkspace(
|
|||||||
): Promise<PersistedWorkspaceRecord> {
|
): Promise<PersistedWorkspaceRecord> {
|
||||||
const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath);
|
const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath);
|
||||||
const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot);
|
const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot);
|
||||||
const projectId = basePlacement.projectKey;
|
const placement: ProjectPlacementPayload = {
|
||||||
|
...basePlacement,
|
||||||
|
checkout: {
|
||||||
|
cwd: workspaceDirectory,
|
||||||
|
isGit: true,
|
||||||
|
currentBranch: options.branchName,
|
||||||
|
remoteUrl: basePlacement.checkout.remoteUrl,
|
||||||
|
worktreeRoot: options.worktreePath,
|
||||||
|
isPaseoOwnedWorktree: true,
|
||||||
|
mainRepoRoot: options.repoRoot,
|
||||||
|
},
|
||||||
|
};
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const existingWorkspace = await dependencies.findWorkspaceByDirectory(workspaceDirectory);
|
const existingWorkspace = await dependencies.findWorkspaceByDirectory(workspaceDirectory);
|
||||||
if (!existingWorkspace) {
|
const existingProject = await dependencies.projectRegistry.get(placement.projectKey);
|
||||||
const newRecord: import("./workspace-registry.js").PersistedWorkspaceRecord = {
|
const nextProjectRecord = dependencies.buildPersistedProjectRecord({
|
||||||
workspaceId: workspaceDirectory,
|
workspaceId: workspaceDirectory,
|
||||||
projectId,
|
placement,
|
||||||
cwd: workspaceDirectory,
|
createdAt: existingProject?.createdAt ?? now,
|
||||||
displayName: options.branchName,
|
|
||||||
kind: "worktree",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
archivedAt: null,
|
|
||||||
};
|
|
||||||
await dependencies.workspaceRegistry.upsert(newRecord);
|
|
||||||
const workspace = await dependencies.workspaceRegistry.get(workspaceDirectory);
|
|
||||||
if (!workspace) {
|
|
||||||
throw new Error(`Workspace not found after upsert: ${workspaceDirectory}`);
|
|
||||||
}
|
|
||||||
await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
|
|
||||||
return workspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
await dependencies.workspaceRegistry.upsert({
|
|
||||||
workspaceId: existingWorkspace.workspaceId,
|
|
||||||
projectId,
|
|
||||||
cwd: workspaceDirectory,
|
|
||||||
displayName: options.branchName,
|
|
||||||
kind: "worktree",
|
|
||||||
createdAt: existingWorkspace.createdAt,
|
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
archivedAt: null,
|
|
||||||
});
|
});
|
||||||
|
const nextWorkspaceRecord = dependencies.buildPersistedWorkspaceRecord({
|
||||||
|
workspaceId: workspaceDirectory,
|
||||||
|
placement,
|
||||||
|
createdAt: existingWorkspace?.createdAt ?? now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await dependencies.projectRegistry.upsert(nextProjectRecord);
|
||||||
|
await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord);
|
||||||
await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
|
await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
|
||||||
|
|
||||||
if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) {
|
if (
|
||||||
|
existingWorkspace &&
|
||||||
|
!existingWorkspace.archivedAt &&
|
||||||
|
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
|
||||||
|
) {
|
||||||
await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
|
await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await dependencies.workspaceRegistry.get(existingWorkspace.workspaceId))!;
|
return (await dependencies.workspaceRegistry.get(nextWorkspaceRecord.workspaceId))!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleCreatePaseoWorktreeRequest(
|
export async function handleCreatePaseoWorktreeRequest(
|
||||||
@@ -650,14 +699,29 @@ export async function handleCreatePaseoWorktreeRequest(
|
|||||||
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const checkout = await getCheckoutStatusLite(request.cwd, {
|
let repoRoot = request.cwd;
|
||||||
paseoHome: dependencies.paseoHome,
|
if (dependencies.workspaceGitService) {
|
||||||
});
|
const snapshot = await dependencies.workspaceGitService.getSnapshot(request.cwd);
|
||||||
if (!checkout.isGit) {
|
if (!snapshot.git.isGit) {
|
||||||
throw new Error("Create worktree requires a git repository");
|
throw new Error("Create worktree requires a git repository");
|
||||||
}
|
}
|
||||||
|
|
||||||
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd;
|
repoRoot = snapshot.git.isPaseoOwnedWorktree
|
||||||
|
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? request.cwd)
|
||||||
|
: (snapshot.git.repoRoot ?? request.cwd);
|
||||||
|
} else {
|
||||||
|
const checkout = await getCheckoutStatus(
|
||||||
|
request.cwd,
|
||||||
|
dependencies.paseoHome ? { paseoHome: dependencies.paseoHome } : undefined,
|
||||||
|
);
|
||||||
|
if (!checkout.isGit) {
|
||||||
|
throw new Error("Create worktree requires a git repository");
|
||||||
|
}
|
||||||
|
|
||||||
|
repoRoot = checkout.isPaseoOwnedWorktree
|
||||||
|
? (checkout.mainRepoRoot ?? checkout.repoRoot ?? request.cwd)
|
||||||
|
: (checkout.repoRoot ?? request.cwd);
|
||||||
|
}
|
||||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
||||||
if (!baseBranch) {
|
if (!baseBranch) {
|
||||||
throw new Error("Unable to resolve repository default branch");
|
throw new Error("Unable to resolve repository default branch");
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ import {
|
|||||||
__setPullRequestStatusCacheTtlForTests,
|
__setPullRequestStatusCacheTtlForTests,
|
||||||
commitAll,
|
commitAll,
|
||||||
getCachedCheckoutShortstat,
|
getCachedCheckoutShortstat,
|
||||||
|
getCurrentBranch,
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
getCheckoutShortstat,
|
getCheckoutShortstat,
|
||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
getCheckoutStatusLite,
|
|
||||||
listBranchSuggestions,
|
listBranchSuggestions,
|
||||||
mergeToBase,
|
mergeToBase,
|
||||||
mergeFromBase,
|
mergeFromBase,
|
||||||
@@ -91,6 +91,15 @@ describe("checkout git utilities", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns null for getCurrentBranch in a repo with no commits", async () => {
|
||||||
|
const emptyRepo = join(tempDir, "empty-repo");
|
||||||
|
execSync(`mkdir -p ${emptyRepo}`);
|
||||||
|
execSync("git init -b main", { cwd: emptyRepo });
|
||||||
|
|
||||||
|
const branch = await getCurrentBranch(emptyRepo);
|
||||||
|
expect(branch).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("handles status/diff/commit in a normal repo", async () => {
|
it("handles status/diff/commit in a normal repo", async () => {
|
||||||
writeFileSync(join(repoDir, "file.txt"), "updated\n");
|
writeFileSync(join(repoDir, "file.txt"), "updated\n");
|
||||||
|
|
||||||
@@ -170,13 +179,16 @@ const x = 1;
|
|||||||
expect(removedLine?.tokens).toEqual([{ text: "old comment line", style: "comment" }]);
|
expect(removedLine?.tokens).toEqual([{ text: "old comment line", style: "comment" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns lightweight checkout status for normal repos", async () => {
|
it("returns checkout root metadata for normal repos", async () => {
|
||||||
const status = await getCheckoutStatusLite(repoDir);
|
const status = await getCheckoutStatus(repoDir);
|
||||||
expect(status.isGit).toBe(true);
|
expect(status.isGit).toBe(true);
|
||||||
|
if (!status.isGit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
expect(status.currentBranch).toBe("main");
|
expect(status.currentBranch).toBe("main");
|
||||||
expect(status.worktreeRoot).toBe(repoDir);
|
expect(status.repoRoot).toBe(repoDir);
|
||||||
expect(status.isPaseoOwnedWorktree).toBe(false);
|
expect(status.isPaseoOwnedWorktree).toBe(false);
|
||||||
expect(status.mainRepoRoot).toBeNull();
|
expect(status.mainRepoRoot ?? null).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes hasRemote when origin is configured", async () => {
|
it("exposes hasRemote when origin is configured", async () => {
|
||||||
@@ -404,7 +416,7 @@ const x = 1;
|
|||||||
expect(message).toBe("worktree update");
|
expect(message).toBe("worktree update");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns lightweight checkout status for .paseo worktrees", async () => {
|
it("returns checkout root metadata for .paseo worktrees", async () => {
|
||||||
const result = await createWorktree({
|
const result = await createWorktree({
|
||||||
branchName: "main",
|
branchName: "main",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
@@ -413,9 +425,12 @@ const x = 1;
|
|||||||
paseoHome,
|
paseoHome,
|
||||||
});
|
});
|
||||||
|
|
||||||
const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome });
|
const status = await getCheckoutStatus(result.worktreePath, { paseoHome });
|
||||||
expect(status.isGit).toBe(true);
|
expect(status.isGit).toBe(true);
|
||||||
expect(status.worktreeRoot).toBe(result.worktreePath);
|
if (!status.isGit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(status.repoRoot).toBe(result.worktreePath);
|
||||||
expect(status.isPaseoOwnedWorktree).toBe(true);
|
expect(status.isPaseoOwnedWorktree).toBe(true);
|
||||||
expect(status.mainRepoRoot).toBe(repoDir);
|
expect(status.mainRepoRoot).toBe(repoDir);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -516,38 +516,6 @@ export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPas
|
|||||||
|
|
||||||
export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit;
|
export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit;
|
||||||
|
|
||||||
export type CheckoutStatusLiteNotGit = {
|
|
||||||
isGit: false;
|
|
||||||
currentBranch: null;
|
|
||||||
remoteUrl: null;
|
|
||||||
worktreeRoot: null;
|
|
||||||
isPaseoOwnedWorktree: false;
|
|
||||||
mainRepoRoot: null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CheckoutStatusLiteGitNonPaseo = {
|
|
||||||
isGit: true;
|
|
||||||
currentBranch: string | null;
|
|
||||||
remoteUrl: string | null;
|
|
||||||
worktreeRoot: string;
|
|
||||||
isPaseoOwnedWorktree: false;
|
|
||||||
mainRepoRoot: null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CheckoutStatusLiteGitPaseo = {
|
|
||||||
isGit: true;
|
|
||||||
currentBranch: string | null;
|
|
||||||
remoteUrl: string | null;
|
|
||||||
worktreeRoot: string;
|
|
||||||
isPaseoOwnedWorktree: true;
|
|
||||||
mainRepoRoot: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CheckoutStatusLiteResult =
|
|
||||||
| CheckoutStatusLiteNotGit
|
|
||||||
| CheckoutStatusLiteGitNonPaseo
|
|
||||||
| CheckoutStatusLiteGitPaseo;
|
|
||||||
|
|
||||||
export interface CheckoutDiffResult {
|
export interface CheckoutDiffResult {
|
||||||
diff: string;
|
diff: string;
|
||||||
structured?: ParsedDiffFile[];
|
structured?: ParsedDiffFile[];
|
||||||
@@ -591,12 +559,16 @@ async function requireGitRepo(cwd: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentBranch(cwd: string): Promise<string | null> {
|
export async function getCurrentBranch(cwd: string): Promise<string | null> {
|
||||||
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
|
try {
|
||||||
cwd,
|
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||||
env: READ_ONLY_GIT_ENV,
|
cwd,
|
||||||
});
|
env: READ_ONLY_GIT_ENV,
|
||||||
const branch = stdout.trim();
|
});
|
||||||
return branch.length > 0 ? branch : null;
|
const branch = stdout.trim();
|
||||||
|
return branch.length > 0 ? branch : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getWorktreeRoot(cwd: string): Promise<string | null> {
|
async function getWorktreeRoot(cwd: string): Promise<string | null> {
|
||||||
@@ -1190,43 +1162,6 @@ export async function getCheckoutStatus(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCheckoutStatusLite(
|
|
||||||
cwd: string,
|
|
||||||
context?: CheckoutContext,
|
|
||||||
): Promise<CheckoutStatusLiteResult> {
|
|
||||||
const inspected = await inspectCheckoutContext(cwd, context);
|
|
||||||
if (!inspected) {
|
|
||||||
return {
|
|
||||||
isGit: false,
|
|
||||||
currentBranch: null,
|
|
||||||
remoteUrl: null,
|
|
||||||
worktreeRoot: null,
|
|
||||||
isPaseoOwnedWorktree: false,
|
|
||||||
mainRepoRoot: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inspected.configured.isPaseoOwnedWorktree) {
|
|
||||||
return {
|
|
||||||
isGit: true,
|
|
||||||
currentBranch: inspected.currentBranch,
|
|
||||||
remoteUrl: inspected.remoteUrl,
|
|
||||||
worktreeRoot: inspected.worktreeRoot,
|
|
||||||
isPaseoOwnedWorktree: true,
|
|
||||||
mainRepoRoot: await getMainRepoRoot(cwd),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isGit: true,
|
|
||||||
currentBranch: inspected.currentBranch,
|
|
||||||
remoteUrl: inspected.remoteUrl,
|
|
||||||
worktreeRoot: inspected.worktreeRoot,
|
|
||||||
isPaseoOwnedWorktree: false,
|
|
||||||
mainRepoRoot: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckoutShortstat {
|
export interface CheckoutShortstat {
|
||||||
additions: number;
|
additions: number;
|
||||||
deletions: number;
|
deletions: number;
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ export default defineConfig({
|
|||||||
maxForks: 1,
|
maxForks: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
exclude: ["**/node_modules/**", "**/dist/**"],
|
exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.55-rc.2",
|
"version": "0.1.56",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -14,7 +14,22 @@
|
|||||||
"listen": {
|
"listen": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"hostnames": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "boolean",
|
||||||
|
"const": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"allowedHosts": {
|
"allowedHosts": {
|
||||||
|
"description": "Deprecated: use hostnames instead",
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
|
|||||||
@@ -51,8 +51,7 @@ function Configuration() {
|
|||||||
<li>CLI flags</li>
|
<li>CLI flags</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p className="text-white/60 leading-relaxed">
|
<p className="text-white/60 leading-relaxed">
|
||||||
Lists append across sources (for example, <code className="font-mono">allowedHosts</code>{" "}
|
Lists append across sources (for example, <code className="font-mono">hostnames</code> and
|
||||||
and
|
|
||||||
<code className="font-mono">cors.allowedOrigins</code>).
|
<code className="font-mono">cors.allowedOrigins</code>).
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -60,7 +59,7 @@ function Configuration() {
|
|||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-xl font-medium">Example</h2>
|
<h2 className="text-xl font-medium">Example</h2>
|
||||||
<p className="text-white/60 leading-relaxed">
|
<p className="text-white/60 leading-relaxed">
|
||||||
Minimal example that configures listening address, host allowlist, provider keys, and MCP:
|
Minimal example that configures listening address, hostnames, provider keys, and MCP:
|
||||||
</p>
|
</p>
|
||||||
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
|
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
|
||||||
{`{
|
{`{
|
||||||
@@ -71,11 +70,16 @@ function Configuration() {
|
|||||||
},
|
},
|
||||||
"daemon": {
|
"daemon": {
|
||||||
"listen": "127.0.0.1:6767",
|
"listen": "127.0.0.1:6767",
|
||||||
"allowedHosts": ["localhost", ".localhost"],
|
"hostnames": ["localhost", ".localhost"],
|
||||||
"mcp": { "enabled": true }
|
"mcp": { "enabled": true }
|
||||||
}
|
}
|
||||||
}`}
|
}`}
|
||||||
</pre>
|
</pre>
|
||||||
|
<p className="text-white/60 leading-relaxed">
|
||||||
|
<code className="font-mono">daemon.hostnames</code> is the primary field. The old{" "}
|
||||||
|
<code className="font-mono">daemon.allowedHosts</code> name still works as a deprecated
|
||||||
|
alias for backward compatibility.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
@@ -229,8 +233,12 @@ docker run --rm -i \\
|
|||||||
<code className="font-mono">daemon.listen</code>
|
<code className="font-mono">daemon.listen</code>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<code className="font-mono">PASEO_ALLOWED_HOSTS</code> — override/extend{" "}
|
<code className="font-mono">PASEO_HOSTNAMES</code> — override/extend{" "}
|
||||||
<code className="font-mono">daemon.allowedHosts</code>
|
<code className="font-mono">daemon.hostnames</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="font-mono">PASEO_ALLOWED_HOSTS</code> — deprecated alias for{" "}
|
||||||
|
<code className="font-mono">PASEO_HOSTNAMES</code>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<code className="font-mono">PASEO_LOG_CONSOLE_LEVEL</code> — override{" "}
|
<code className="font-mono">PASEO_LOG_CONSOLE_LEVEL</code> — override{" "}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ function Security() {
|
|||||||
<code className="font-mono">100.x.y.z:6767</code>)
|
<code className="font-mono">100.x.y.z:6767</code>)
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Add your Tailscale hostname to <code className="font-mono">allowedHosts</code> and{" "}
|
Add your Tailscale hostname to <code className="font-mono">hostnames</code> and{" "}
|
||||||
<code className="font-mono">cors.allowedOrigins</code>
|
<code className="font-mono">cors.allowedOrigins</code>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -199,7 +199,7 @@ function Security() {
|
|||||||
daemon reachable on all network interfaces, including public Wi-Fi and local networks.
|
daemon reachable on all network interfaces, including public Wi-Fi and local networks.
|
||||||
This can expose your daemon to unauthorized access. If you must bind to all interfaces,
|
This can expose your daemon to unauthorized access. If you must bind to all interfaces,
|
||||||
ensure you have proper firewall rules and review your{" "}
|
ensure you have proper firewall rules and review your{" "}
|
||||||
<code className="font-mono">allowedHosts</code> configuration.
|
<code className="font-mono">hostnames</code> configuration.
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ function Security() {
|
|||||||
on incoming requests. Requests with unrecognized hosts are rejected.
|
on incoming requests. Requests with unrecognized hosts are rejected.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-white/60 leading-relaxed">
|
<p className="text-white/60 leading-relaxed">
|
||||||
Configure via <code className="font-mono">daemon.allowedHosts</code> in{" "}
|
Configure via <code className="font-mono">daemon.hostnames</code> in{" "}
|
||||||
<code className="font-mono">config.json</code>:
|
<code className="font-mono">config.json</code>:
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-white/60 space-y-2 list-disc list-inside">
|
<ul className="text-white/60 space-y-2 list-disc list-inside">
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Ensure node_modules/.bin is in PATH
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$env:PATH = "$ScriptDir\..\node_modules\.bin;$env:PATH"
|
||||||
|
|
||||||
|
# Derive PASEO_HOME: stable name for worktrees, temporary dir otherwise
|
||||||
|
if (-not $env:PASEO_HOME) {
|
||||||
|
$GitDir = git rev-parse --git-dir 2>$null
|
||||||
|
$GitCommonDir = git rev-parse --git-common-dir 2>$null
|
||||||
|
|
||||||
|
if ($GitDir -and $GitCommonDir -and ($GitDir -ne $GitCommonDir)) {
|
||||||
|
# Inside a worktree — derive a stable home from the worktree name
|
||||||
|
$WorktreeRoot = git rev-parse --show-toplevel
|
||||||
|
$WorktreeName = (Split-Path -Leaf $WorktreeRoot).ToLower() -replace '[^a-z0-9-]', '-' -replace '-+', '-' -replace '^-|-$', ''
|
||||||
|
$env:PASEO_HOME = "$env:USERPROFILE\.paseo-$WorktreeName"
|
||||||
|
New-Item -ItemType Directory -Force -Path $env:PASEO_HOME | Out-Null
|
||||||
|
} else {
|
||||||
|
$env:PASEO_HOME = Join-Path ([System.IO.Path]::GetTempPath()) "paseo-dev-$([System.Guid]::NewGuid().ToString('N').Substring(0,6))"
|
||||||
|
New-Item -ItemType Directory -Force -Path $env:PASEO_HOME | Out-Null
|
||||||
|
# Register cleanup on exit
|
||||||
|
$TempPaseoHome = $env:PASEO_HOME
|
||||||
|
Register-EngineEvent PowerShell.Exiting -Action {
|
||||||
|
Remove-Item -Recurse -Force $TempPaseoHome -ErrorAction SilentlyContinue
|
||||||
|
} | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Share speech models with the main install to avoid duplicate downloads
|
||||||
|
if (-not $env:PASEO_LOCAL_MODELS_DIR) {
|
||||||
|
$env:PASEO_LOCAL_MODELS_DIR = "$env:USERPROFILE\.paseo\models\local-speech"
|
||||||
|
New-Item -ItemType Directory -Force -Path $env:PASEO_LOCAL_MODELS_DIR | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host @"
|
||||||
|
======================================================
|
||||||
|
Paseo Dev (Windows)
|
||||||
|
======================================================
|
||||||
|
Home: $($env:PASEO_HOME)
|
||||||
|
Models: $($env:PASEO_LOCAL_MODELS_DIR)
|
||||||
|
Daemon: localhost:6767
|
||||||
|
======================================================
|
||||||
|
"@
|
||||||
|
|
||||||
|
# Allow any origin in dev so Electron on random ports all work.
|
||||||
|
# SECURITY: wildcard CORS is unsafe in production — only acceptable here because
|
||||||
|
# the daemon binds to localhost and this script is never used for production.
|
||||||
|
# Build dependencies required by the daemon (they only ship dist/)
|
||||||
|
Write-Host "Building @getpaseo/highlight..."
|
||||||
|
npm run build --workspace=@getpaseo/highlight
|
||||||
|
Write-Host "Building @getpaseo/relay..."
|
||||||
|
npm run build --workspace=@getpaseo/relay
|
||||||
|
|
||||||
|
$env:PASEO_CORS_ORIGINS = "*"
|
||||||
|
|
||||||
|
# Configure the app to auto-connect to this daemon on localhost
|
||||||
|
$env:EXPO_PUBLIC_LOCAL_DAEMON = "localhost:6767"
|
||||||
|
$env:BROWSER = "none"
|
||||||
|
|
||||||
|
# Run both with concurrently
|
||||||
|
concurrently `
|
||||||
|
--names "daemon,metro" `
|
||||||
|
--prefix-colors "cyan,magenta" `
|
||||||
|
"npm run dev:server" `
|
||||||
|
"cd packages/app && npx expo start"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
exclude: ["**/.claude/**"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user