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:
Mohamed Boudra
2026-04-15 16:07:39 +07:00
73 changed files with 2288 additions and 1126 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.55-rc.2",
"version": "0.1.56",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -60,8 +60,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.55-rc.2",
"@getpaseo/relay": "0.1.55-rc.2",
"@getpaseo/highlight": "0.1.56",
"@getpaseo/relay": "0.1.56",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
+43 -3
View File
@@ -1201,8 +1201,28 @@ export class DaemonClient {
}
}
clearAgentAttention(agentId: string | string[]): void {
this.sendSessionMessage({ type: "clear_agent_attention", agentId });
async clearAgentAttention(agentId: string | string[]): Promise<void> {
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: {
@@ -1745,7 +1765,27 @@ export class DaemonClient {
}
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> {
@@ -119,7 +119,7 @@ describe("agent MCP end-to-end (offline)", () => {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
@@ -192,7 +192,7 @@ describe("agent MCP end-to-end (offline)", () => {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
@@ -216,7 +216,7 @@ describe("agent MCP end-to-end (offline)", () => {
listen: `127.0.0.1:${disabledPort}`,
paseoHome: disabledPaseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
hostnames: true,
mcpEnabled: true,
mcpInjectIntoAgents: false,
staticDir: disabledStaticDir,
@@ -307,7 +307,7 @@ describe("agent MCP end-to-end (offline)", () => {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
@@ -42,7 +42,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
logger,
});
let allowedHosts: string[] | undefined;
let mcpAllowedHosts: string[] | undefined;
const agentMcpTransports = new Map<string, StreamableHTTPServerTransport>();
const createAgentMcpTransport = async (callerAgentId?: string) => {
@@ -62,7 +62,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
agentMcpTransports.delete(sessionId);
},
enableDnsRebindingProtection: true,
...(allowedHosts ? { allowedHosts } : {}),
...(mcpAllowedHosts ? { allowedHosts: mcpAllowedHosts } : {}),
});
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`;
return {
@@ -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", () => {
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", () => {
expect(
__openCodeInternals.buildOpenCodePromptParts([
@@ -108,6 +108,12 @@ const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [
"does not exist",
"unsupported model",
] as const;
const OPENCODE_HEADERS_TIMEOUT_TOKENS = [
"headers timeout",
"headers timeout error",
"headers_timeout",
"und_err_headers_timeout",
] as const;
const OpencodeToolStateSchema = z
.object({
@@ -232,6 +238,73 @@ function normalizeTurnFailureError(error: unknown): string {
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 {
const normalized = typeof message === "string" ? message.trim().toLowerCase() : "";
if (!normalized) {
@@ -240,6 +313,49 @@ function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolea
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 {
const normalized = stringifyUnknownError(error).toLowerCase();
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
@@ -575,6 +691,7 @@ export const __openCodeInternals = {
hasNormalizedOpenCodeUsage,
mergeOpenCodeStepFinishUsage,
parseOpenCodeModelLookupKey,
reconcileOpenCodeSessionClose,
resolveOpenCodeModelLookupKeyFromAssistantMessage,
resolveOpenCodeSelectedModelContextWindow,
};
@@ -1535,6 +1652,17 @@ class OpenCodeAgentSession implements AgentSession {
})
.then((response) => {
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);
this.finishForegroundTurn(
{ type: "turn_failed", provider: "opencode", error: errorMsg },
@@ -1548,6 +1676,17 @@ class OpenCodeAgentSession implements AgentSession {
}
})
.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(
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
turnId,
@@ -1937,6 +2076,12 @@ class OpenCodeAgentSession implements AgentSession {
async close(): Promise<void> {
this.abortController?.abort();
await reconcileOpenCodeSessionClose({
client: this.client,
sessionId: this.sessionId,
directory: this.config.cwd,
logger: this.logger,
});
this.subscribers.clear();
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 path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { Writable } from "node:stream";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
@@ -50,7 +50,7 @@ describe("paseo daemon bootstrap", () => {
listen: "127.0.0.1:0",
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
hostnames: true,
mcpEnabled: false,
staticDir,
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 paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
await mkdir(path.dirname(socketPath), { recursive: true });
await mkdir(paseoHome, { recursive: true });
const lines: string[] = [];
const logger = pino(
{ level: "info" },
new Writable({
write(chunk, _encoding, callback) {
lines.push(chunk.toString("utf8"));
callback();
},
}),
);
const logger = pino({ level: "silent" });
const config: PaseoDaemonConfig = {
listen: socketPath,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
@@ -191,7 +181,16 @@ describe("paseo daemon bootstrap", () => {
try {
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 {
await daemon.stop().catch(() => undefined);
await daemon.agentManager.flush().catch(() => undefined);
+15 -6
View File
@@ -108,6 +108,7 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.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 { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
@@ -119,7 +120,6 @@ import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
import {
ScriptRouteStore,
createScriptProxyMiddleware,
@@ -128,6 +128,7 @@ import {
import { ScriptHealthMonitor } from "./script-health-monitor.js";
import { createScriptStatusEmitter } from "./script-status-projection.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
@@ -170,7 +171,8 @@ export type PaseoDaemonConfig = {
listen: string;
paseoHome: string;
corsAllowedOrigins: string[];
allowedHosts?: AllowedHostsConfig;
allowedHosts?: HostnamesConfig;
hostnames?: HostnamesConfig;
mcpEnabled?: boolean;
mcpInjectIntoAgents?: boolean;
staticDir: string;
@@ -238,6 +240,7 @@ export async function createPaseoDaemon(
const scriptRouteStore = new ScriptRouteStore();
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
const configuredHostnames = config.hostnames ?? config.allowedHosts;
let wsServer: VoiceAssistantWebSocketServer | null = null;
const scriptHealthMonitor = new ScriptHealthMonitor({
routeStore: scriptRouteStore,
@@ -264,7 +267,7 @@ export async function createPaseoDaemon(
if (listenTarget.type === "tcp") {
app.use((req, res, next) => {
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" });
return;
}
@@ -418,6 +421,10 @@ export async function createPaseoDaemon(
});
const terminalManager = createTerminalManager();
const workspaceGitService = new WorkspaceGitServiceImpl({
logger,
paseoHome: config.paseoHome,
});
const detachAgentStoragePersistence = attachAgentStoragePersistence(
logger,
@@ -431,6 +438,7 @@ export async function createPaseoDaemon(
agentStorage,
projectRegistry,
workspaceRegistry,
workspaceGitService,
logger,
});
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
@@ -439,6 +447,7 @@ export async function createPaseoDaemon(
const checkoutDiffManager = new CheckoutDiffManager({
logger,
paseoHome: config.paseoHome,
workspaceGitService,
});
const loopService = new LoopService({
paseoHome: config.paseoHome,
@@ -647,7 +656,7 @@ export async function createPaseoDaemon(
config.paseoHome,
daemonConfigStore,
mcpBaseUrl,
{ allowedOrigins, allowedHosts: config.allowedHosts },
{ allowedOrigins, hostnames: configuredHostnames },
speechService,
terminalManager,
{
@@ -675,6 +684,7 @@ export async function createPaseoDaemon(
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
workspaceGitService,
);
if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") {
@@ -694,8 +704,7 @@ export async function createPaseoDaemon(
relay: { endpoint: relayPublicEndpoint },
});
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
logger.info({ url }, "pairing_offer");
encodeOfferToFragmentUrl({ offer, appBaseUrl });
relayTransport?.stop().catch(() => undefined);
relayTransport = startRelayTransport({
@@ -1,137 +1,184 @@
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const { getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } = vi.hoisted(
() => {
const hoistedWatchCalls: Array<{ path: string; close: ReturnType<typeof vi.fn> }> = [];
return {
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,
const { getCheckoutDiffMock, toCheckoutErrorMock } = vi.hoisted(() => ({
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
toCheckoutErrorMock: vi.fn((error: unknown) => ({
message: error instanceof Error ? error.message : String(error),
})),
}));
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", () => ({
getCheckoutDiff: getCheckoutDiffMock,
}));
vi.mock("./checkout-git-utils.js", () => ({
READ_ONLY_GIT_ENV: {},
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
toCheckoutError: vi.fn((error: unknown) => ({
message: error instanceof Error ? error.message : String(error),
})),
toCheckoutError: toCheckoutErrorMock,
}));
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
describe("CheckoutDiffManager Linux watchers", () => {
const originalPlatform = process.platform;
describe("CheckoutDiffManager", () => {
beforeEach(() => {
watchCalls.length = 0;
getCheckoutDiffMock.mockClear();
resolveCheckoutGitDirMock.mockClear();
readdirMock.mockClear();
Object.defineProperty(process, "platform", {
configurable: true,
value: "linux",
});
vi.useFakeTimers();
getCheckoutDiffMock.mockReset();
getCheckoutDiffMock.mockResolvedValue({ diff: "", structured: [] });
toCheckoutErrorMock.mockClear();
});
afterEach(() => {
Object.defineProperty(process, "platform", {
configurable: true,
value: originalPlatform,
});
vi.useRealTimers();
});
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 = {
child: () => logger,
warn: vi.fn(),
};
const manager = new CheckoutDiffManager({
logger: logger as any,
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" },
},
() => {},
);
expect(subscription.initial.error).toBeNull();
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
"/tmp/repo",
"/tmp/repo/.git",
"/tmp/repo/packages",
"/tmp/repo/packages/app",
expect(mockRequestWorkingTreeWatch).toHaveBeenCalledWith(
"/tmp/repo/packages/server",
"/tmp/repo/packages/server/src",
"/tmp/repo/packages/server/src/server",
]);
expect.any(Function),
);
});
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();
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 { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { getCheckoutDiff } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { READ_ONLY_GIT_ENV, resolveCheckoutGitDir, toCheckoutError } from "./checkout-git-utils.js";
import { toCheckoutError } from "./checkout-git-utils.js";
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"];
@@ -31,27 +27,26 @@ type CheckoutDiffWatchTarget = {
diffCwd: string;
compare: CheckoutDiffCompareInput;
listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>;
watchers: FSWatcher[];
fallbackRefreshInterval: NodeJS.Timeout | null;
workingTreeWatchUnsubscribe: (() => void) | null;
debounceTimer: NodeJS.Timeout | null;
refreshPromise: Promise<void> | null;
refreshQueued: boolean;
latestPayload: CheckoutDiffSnapshotPayload | null;
latestFingerprint: string | null;
watchedPaths: Set<string>;
repoWatchPath: string | null;
linuxTreeRefreshPromise: Promise<void> | null;
linuxTreeRefreshQueued: boolean;
};
export class CheckoutDiffManager {
private readonly logger: pino.Logger;
private readonly paseoHome: string;
private readonly workspaceGitService: WorkspaceGitService;
private readonly targets = new Map<string, CheckoutDiffWatchTarget>();
constructor(options: { logger: pino.Logger; paseoHome: string }) {
this.logger = options.logger.child({ module: "checkout-diff-manager" });
constructor(options: {
logger: pino.Logger;
paseoHome: string;
workspaceGitService: WorkspaceGitService;
}) {
this.paseoHome = options.paseoHome;
this.workspaceGitService = options.workspaceGitService;
}
async subscribe(
@@ -93,22 +88,16 @@ export class CheckoutDiffManager {
getMetrics(): CheckoutDiffMetrics {
let checkoutDiffSubscriptionCount = 0;
let checkoutDiffWatcherCount = 0;
let checkoutDiffFallbackRefreshTargetCount = 0;
for (const target of this.targets.values()) {
checkoutDiffSubscriptionCount += target.listeners.size;
checkoutDiffWatcherCount += target.watchers.length;
if (target.fallbackRefreshInterval) {
checkoutDiffFallbackRefreshTargetCount += 1;
}
}
return {
checkoutDiffTargetCount: this.targets.size,
checkoutDiffSubscriptionCount,
checkoutDiffWatcherCount,
checkoutDiffFallbackRefreshTargetCount,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
};
}
@@ -144,15 +133,8 @@ export class CheckoutDiffManager {
clearTimeout(target.debounceTimer);
target.debounceTimer = null;
}
if (target.fallbackRefreshInterval) {
clearInterval(target.fallbackRefreshInterval);
target.fallbackRefreshInterval = null;
}
for (const watcher of target.watchers) {
watcher.close();
}
target.watchers = [];
target.watchedPaths.clear();
target.workingTreeWatchUnsubscribe?.();
target.workingTreeWatchUnsubscribe = null;
target.listeners.clear();
}
@@ -172,22 +154,6 @@ export class CheckoutDiffManager {
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 {
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
@@ -274,212 +240,27 @@ export class CheckoutDiffManager {
return existing;
}
const watchRoot = await this.resolveCheckoutWatchRoot(cwd);
const target: CheckoutDiffWatchTarget = {
key: targetKey,
cwd,
diffCwd: watchRoot ?? cwd,
diffCwd: cwd,
compare,
listeners: new Set(),
watchers: [],
fallbackRefreshInterval: null,
workingTreeWatchUnsubscribe: null,
debounceTimer: null,
refreshPromise: null,
refreshQueued: false,
latestPayload: null,
latestFingerprint: null,
watchedPaths: new Set<string>(),
repoWatchPath: null,
linuxTreeRefreshPromise: null,
linuxTreeRefreshQueued: false,
};
const repoWatchPath = watchRoot ?? cwd;
target.repoWatchPath = repoWatchPath;
const watchPaths = new Set<string>([repoWatchPath]);
const gitDir = await resolveCheckoutGitDir(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.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",
);
}
const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch(
cwd,
() => this.scheduleTargetRefresh(target),
);
target.diffCwd = repoRoot ?? cwd;
target.workingTreeWatchUnsubscribe = unsubscribe;
this.targets.set(targetKey, 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;
}
}
+7 -11
View File
@@ -11,11 +11,7 @@ import type {
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
import { AgentProviderSchema } from "./agent/provider-manifest.js";
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
import {
mergeAllowedHosts,
parseAllowedHostsEnv,
type AllowedHostsConfig,
} from "./allowed-hosts.js";
import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js";
const DEFAULT_PORT = 6767;
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
@@ -42,7 +38,7 @@ export type CliConfigOverrides = Partial<{
relayEnabled: boolean;
mcpEnabled: boolean;
mcpInjectIntoAgents: boolean;
allowedHosts: AllowedHostsConfig;
hostnames: HostnamesConfig;
}>;
const OptionalVoiceLlmProviderSchema = z
@@ -133,10 +129,10 @@ export function loadConfig(
const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? [];
const allowedHosts = mergeAllowedHosts([
persisted.daemon?.allowedHosts,
parseAllowedHostsEnv(env.PASEO_ALLOWED_HOSTS),
options?.cli?.allowedHosts,
const hostnames = mergeHostnames([
persisted.daemon?.hostnames,
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
options?.cli?.hostnames,
]);
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
@@ -181,7 +177,7 @@ export function loadConfig(
corsAllowedOrigins: Array.from(
new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)),
),
allowedHosts,
hostnames,
mcpEnabled,
mcpInjectIntoAgents,
mcpDebug: env.MCP_DEBUG === "1",
@@ -8,6 +8,7 @@ import { Writable } from "node:stream";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
function createCapturingLogger() {
@@ -22,18 +23,25 @@ function createCapturingLogger() {
return { logger, lines };
}
function parseOfferUrlFromLogs(lines: string[]): string {
for (const line of lines) {
try {
const obj = JSON.parse(line) as { msg?: string; url?: string };
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
return obj.url;
}
} catch {
// ignore non-JSON lines
}
async function getPairingOfferUrl(args: {
paseoHome: string;
relayEnabled?: boolean;
relayEndpoint?: string;
relayPublicEndpoint?: string;
appBaseUrl?: string;
}): Promise<string> {
const pairing = await generateLocalPairingOffer({
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 {
@@ -72,7 +80,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
test("emits relay-only offer URL with stable serverId", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
const { logger, lines } = createCapturingLogger();
const { logger } = createCapturingLogger();
const daemon = await createTestPaseoDaemon({
listen: "0.0.0.0",
@@ -81,7 +89,13 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
});
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);
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 { logger: logger1, lines: lines1 } = createCapturingLogger();
const { logger: logger1 } = createCapturingLogger();
const daemon1 = await createTestPaseoDaemon({
listen: "0.0.0.0",
logger: logger1,
@@ -124,7 +138,13 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
let staticDir2: string | null = null;
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 {
serverId: string;
daemonPublicKeyB64: string;
@@ -133,7 +153,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
await daemon1.close();
const { logger: logger2, lines: lines2 } = createCapturingLogger();
const { logger: logger2 } = createCapturingLogger();
const daemon2 = await createTestPaseoDaemon({
listen: "0.0.0.0",
logger: logger2,
@@ -144,7 +164,13 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
staticDir2 = daemon2.staticDir;
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 {
serverId: string;
daemonPublicKeyB64: string;
@@ -207,12 +233,6 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
stdoutLines.push(text);
for (const line of text.split("\n")) {
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 {
const parsed = JSON.parse(line) as { msg?: string };
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 { Buffer } from "node:buffer";
import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
import { buildRelayWebSocketUrl } from "../../shared/daemon-endpoints.js";
@@ -26,19 +27,25 @@ function createCapturingLogger() {
return { logger, lines };
}
function parseOfferUrlFromLogs(lines: string[]): string {
for (const line of lines) {
if (!line.includes("pairing_offer")) continue;
try {
const obj = JSON.parse(line) as { msg?: string; url?: string };
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
return obj.url;
}
} catch {
// ignore
}
async function getPairingOfferUrl(args: {
paseoHome: string;
relayEnabled?: boolean;
relayEndpoint?: string;
relayPublicEndpoint?: string;
appBaseUrl?: string;
}): Promise<string> {
const pairing = await generateLocalPairingOffer({
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): {
@@ -199,7 +206,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
});
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 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 {
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);
// 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"]);
});
});
@@ -1,6 +1,6 @@
import net from "node:net";
export type AllowedHostsConfig = true | string[] | undefined;
export type HostnamesConfig = true | string[] | undefined;
function normalizeHostname(hostname: string): string {
return hostname.trim().toLowerCase();
@@ -25,7 +25,7 @@ function parseHostnameFromHostHeader(hostHeader: string): string | null {
return normalizeHostname(trimmed.slice(0, colonIndex));
}
function matchesAllowedHostPattern(hostname: string, pattern: string): boolean {
function matchesHostnamePattern(hostname: string, pattern: string): boolean {
const normalizedPattern = normalizeHostname(pattern);
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:
* - `allowedHosts === true` => allow any host.
* - `allowedHosts === []` or `undefined` => allow localhost, *.localhost, and all IPs.
* - `allowedHosts === ['.example.com', 'myhost']` => allow those *in addition* to defaults.
* - `hostnames === true` => allow any host.
* - `hostnames === []` or `undefined` => allow localhost, *.localhost, and all IPs.
* - `hostnames === ['.example.com', 'myhost']` => allow those *in addition* to defaults.
*/
export function isHostAllowed(
export function isHostnameAllowed(
hostHeader: string | undefined,
allowedHosts: AllowedHostsConfig,
hostnames: HostnamesConfig,
): boolean {
const hostname = hostHeader ? parseHostnameFromHostHeader(hostHeader) : null;
if (!hostname) return false;
if (allowedHosts === true) return true;
if (hostnames === true) return true;
// Defaults are always allowed.
if (isDefaultAllowedHostname(hostname)) return true;
const patterns = allowedHosts ?? [];
const patterns = hostnames ?? [];
for (const pattern of patterns) {
if (matchesAllowedHostPattern(hostname, pattern)) return true;
if (matchesHostnamePattern(hostname, pattern)) return true;
}
return false;
}
export function mergeAllowedHosts(values: Array<AllowedHostsConfig>): AllowedHostsConfig {
export function mergeHostnames(values: Array<HostnamesConfig>): HostnamesConfig {
let merged: string[] = [];
for (const value of values) {
if (value === true) return true;
@@ -85,7 +85,7 @@ export function mergeAllowedHosts(values: Array<AllowedHostsConfig>): AllowedHos
return deduped;
}
export function parseAllowedHostsEnv(raw: string | undefined): AllowedHostsConfig {
export function parseHostnamesEnv(raw: string | undefined): HostnamesConfig {
if (!raw) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
@@ -228,6 +228,7 @@ export const PersistedConfigSchema = z
daemon: z
.object({
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(),
mcp: z
.object({
@@ -252,6 +253,10 @@ export const PersistedConfigSchema = z
.optional(),
})
.strict()
.transform(({ allowedHosts, ...daemon }) => {
const hostnames = daemon.hostnames ?? allowedHosts;
return hostnames === undefined ? daemon : { ...daemon, hostnames };
})
.optional(),
app: z
+93 -232
View File
@@ -156,7 +156,6 @@ import {
getCheckoutDiff,
getCachedCheckoutShortstat,
getCheckoutStatus,
getCheckoutStatusLite,
listBranchSuggestions,
commitChanges,
mergeToBase,
@@ -191,6 +190,7 @@ import {
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
killTerminalsUnderPath as killWorktreeTerminalsUnderPath,
registerPendingWorktreeWorkspace as registerPendingWorktreeWorkspaceSession,
} from "./worktree-session.js";
const execAsync = promisify(exec);
@@ -1465,6 +1465,63 @@ export class Session {
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(
workspaceId: string,
): Promise<PersistedWorkspaceRecord | null> {
@@ -1477,10 +1534,8 @@ export class Session {
private async resolveWorkspaceDirectory(cwd: string): Promise<string> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
try {
const checkout = await getCheckoutStatusLite(normalizedCwd, {
paseoHome: this.paseoHome,
});
return normalizePersistedWorkspaceId(checkout.worktreeRoot ?? normalizedCwd);
const snapshot = await this.workspaceGitService.getSnapshot(normalizedCwd);
return normalizePersistedWorkspaceId(snapshot.git.repoRoot ?? normalizedCwd);
} catch {
return normalizedCwd;
}
@@ -1823,184 +1878,10 @@ export class Session {
await this.handleCheckoutPrStatusRequest(msg);
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":
await this.handleGitHubSearchRequest(msg);
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":
await this.handlePaseoWorktreeListRequest(msg);
break;
@@ -2074,7 +1955,7 @@ export class Session {
break;
case "clear_agent_attention":
await this.handleClearAgentAttention(msg.agentId);
await this.handleClearAgentAttention(msg.agentId, msg.requestId);
break;
case "client_heartbeat":
@@ -3378,6 +3259,7 @@ export class Session {
{
paseoHome: this.paseoHome,
sessionLogger: this.sessionLogger,
workspaceGitService: this.workspaceGitService,
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
createBranchFromBase: (params) => this.createBranchFromBase(params),
},
@@ -5030,7 +4912,6 @@ export class Session {
const { cwd, requestId } = msg;
try {
await this.workspaceGitService.refresh(cwd, { priority: "high" });
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
this.emit({
type: "checkout_pr_status_response",
@@ -5788,7 +5669,19 @@ export class Session {
projectRecord?: PersistedProjectRecord | null,
): Promise<WorkspaceDescriptorPayload> {
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) {
return base;
}
@@ -6229,7 +6122,7 @@ export class Session {
const placement = await buildProjectPlacementForCwdStandalone({
cwd: normalizedCwd,
paseoHome: this.paseoHome,
workspaceGitService: this.workspaceGitService,
});
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
const timestamp = new Date().toISOString();
@@ -6263,56 +6156,21 @@ export class Session {
branchName: string;
}): Promise<PersistedWorkspaceRecord> {
await this.findOrCreateWorkspaceForDirectory(options.repoRoot);
const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath);
const basePlacement = await this.buildProjectPlacementForCwd(options.repoRoot);
if (!basePlacement) {
throw new Error(`Workspace not found for repo root ${options.repoRoot}`);
}
const projectId = basePlacement.projectKey;
const now = new Date().toISOString();
const existingWorkspace = await this.findWorkspaceByDirectory(workspaceDirectory);
if (!existingWorkspace) {
const newRecord = createPersistedWorkspaceRecord({
workspaceId: workspaceDirectory,
projectId,
cwd: workspaceDirectory,
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,
}),
return registerPendingWorktreeWorkspaceSession(
{
buildPersistedProjectRecord: (input) => this.buildPersistedProjectRecord(input),
buildPersistedWorkspaceRecord: (input) => this.buildPersistedWorkspaceRecord(input),
buildProjectPlacement: (cwd) => this.buildProjectPlacement(cwd),
findWorkspaceByDirectory: (directory) => this.findWorkspaceByDirectory(directory),
projectRegistry: this.projectRegistry,
syncWorkspaceGitWatchTarget: (cwd, syncOptions) =>
this.syncWorkspaceGitWatchTarget(cwd, syncOptions),
workspaceRegistry: this.workspaceRegistry,
archiveProjectRecordIfEmpty: (projectId, archivedAt) =>
this.archiveProjectRecordIfEmpty(projectId, archivedAt),
},
options,
);
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> {
@@ -6790,10 +6648,13 @@ export class Session {
return handleCreateWorktreeRequest(
{
paseoHome: this.paseoHome,
workspaceGitService: this.workspaceGitService,
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
emit: (message) => this.emit(message),
registerPendingWorktreeWorkspace: (options) =>
this.registerPendingWorktreeWorkspace(options),
syncWorkspaceGitWatchTarget: (cwd, syncOptions) =>
this.syncWorkspaceGitWatchTarget(cwd, syncOptions),
sessionLogger: this.sessionLogger,
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options),
},
@@ -71,6 +71,8 @@ function createSessionForWorkspaceGitWatchTests(): {
peekSnapshot: ReturnType<typeof vi.fn>;
getSnapshot: ReturnType<typeof vi.fn>;
refresh: ReturnType<typeof vi.fn>;
requestWorkingTreeWatch: ReturnType<typeof vi.fn>;
scheduleRefreshForCwd: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
};
subscriptions: Array<{
@@ -111,6 +113,11 @@ function createSessionForWorkspaceGitWatchTests(): {
peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
refresh: vi.fn(async () => {}),
requestWorkingTreeWatch: vi.fn(async (cwd: string) => ({
repoRoot: cwd,
unsubscribe: vi.fn(),
})),
scheduleRefreshForCwd: 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();
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({
type: "checkout_pr_status_request",
cwd: "/tmp/repo",
requestId: "req-pr-refresh",
requestId: "req-pr-cached",
});
expect(workspaceGitService.refresh).toHaveBeenCalledWith("/tmp/repo", {
priority: "high",
});
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",
});
expect(workspaceGitService.refresh).not.toHaveBeenCalled();
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
expect(emitted.find((message) => message.type === "checkout_pr_status_response")).toBeDefined();
});
});
@@ -113,6 +113,11 @@ function createNoopWorkspaceGitService() {
},
}),
refresh: async () => {},
requestWorkingTreeWatch: async (cwd: string) => ({
repoRoot: cwd,
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
dispose: () => {},
};
}
@@ -1177,7 +1182,6 @@ describe("workspace aggregation", () => {
test("create paseo worktree request returns a registered workspace descriptor", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any;
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-")));
const repoDir = path.join(tempDir, "repo");
const paseoHome = path.join(tempDir, "paseo-home");
@@ -1188,6 +1192,45 @@ describe("workspace aggregation", () => {
writeFileSync(path.join(repoDir, "file.txt"), "hello\n");
execSync("git add .", { 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 projects = new Map();
@@ -85,7 +85,7 @@ export async function createTestPaseoDaemon(
listen: `${listenHost}:0`,
paseoHome,
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
allowedHosts: true,
hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
+68 -10
View File
@@ -23,8 +23,8 @@ import {
wrapSessionMessage,
} from "./messages.js";
import { asUint8Array, decodeTerminalStreamFrame } from "../shared/terminal-stream-protocol.js";
import type { AllowedHostsConfig } from "./allowed-hosts.js";
import { isHostAllowed } from "./allowed-hosts.js";
import type { HostnamesConfig } from "./hostnames.js";
import { isHostnameAllowed } from "./hostnames.js";
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import type {
@@ -63,11 +63,71 @@ type PendingConnection = {
type WebSocketServerConfig = {
allowedOrigins: Set<string>;
allowedHosts?: AllowedHostsConfig;
hostnames?: HostnamesConfig;
};
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 {
return {
initialize: async () => {},
@@ -327,6 +387,7 @@ export class VoiceAssistantWebSocketServer {
getDaemonTcpPort?: () => number | null,
getDaemonTcpHost?: () => string | null,
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
workspaceGitService?: WorkspaceGitServiceImpl,
) {
this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId;
@@ -354,10 +415,7 @@ export class VoiceAssistantWebSocketServer {
throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
}
this.checkoutDiffManager = checkoutDiffManager;
this.workspaceGitService = new WorkspaceGitServiceImpl({
logger: this.logger,
paseoHome,
});
this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService();
this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome;
this.daemonConfigStore = daemonConfigStore;
@@ -403,7 +461,7 @@ export class VoiceAssistantWebSocketServer {
});
});
const { allowedOrigins, allowedHosts } = wsConfig;
const { allowedOrigins, hostnames } = wsConfig;
this.wss = new WebSocketServer({
server,
path: "/ws",
@@ -411,7 +469,7 @@ export class VoiceAssistantWebSocketServer {
const requestMetadata = extractSocketRequestMetadata(req);
const origin = requestMetadata.origin;
const requestHost = requestMetadata.host ?? null;
if (requestHost && !isHostAllowed(requestHost, allowedHosts)) {
if (requestHost && !isHostnameAllowed(requestHost, hostnames)) {
this.incrementRuntimeCounter("hostRejected");
this.logger.warn(
{ ...requestMetadata, host: requestHost },
@@ -553,8 +611,8 @@ export class VoiceAssistantWebSocketServer {
await Promise.all(cleanupPromises);
this.providerSnapshotManager.destroy();
this.workspaceGitService.dispose();
this.checkoutDiffManager.dispose();
this.workspaceGitService.dispose();
this.pendingConnections.clear();
this.sessions.clear();
this.externalSessionsByKey.clear();
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import path from "node:path";
import type { CheckoutStatusGit, PullRequestStatusResult } from "../utils/checkout-git.js";
import {
WorkspaceGitServiceImpl,
@@ -116,11 +117,28 @@ function createWatcher() {
};
}
function createDirent(name: string, isDirectory: boolean) {
return {
name,
isDirectory: () => isDirectory,
};
}
async function flushPromises(): Promise<void> {
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?: {
getCheckoutStatus?: ReturnType<typeof vi.fn>;
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
@@ -129,6 +147,8 @@ function createService(options?: {
resolveAbsoluteGitDir?: ReturnType<typeof vi.fn>;
hasOriginRemote?: ReturnType<typeof vi.fn>;
runGitFetch?: ReturnType<typeof vi.fn>;
runGitCommand?: ReturnType<typeof vi.fn>;
readdir?: ReturnType<typeof vi.fn>;
watch?: ReturnType<typeof vi.fn>;
now?: () => Date;
}) {
@@ -137,6 +157,7 @@ function createService(options?: {
paseoHome: "/tmp/paseo-test",
deps: {
watch: options?.watch ?? ((() => createWatcher()) as unknown as any),
readdir: options?.readdir ?? vi.fn(async () => []),
getCheckoutStatus:
options?.getCheckoutStatus ?? vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
getCheckoutShortstat:
@@ -151,6 +172,15 @@ function createService(options?: {
resolveAbsoluteGitDir: options?.resolveAbsoluteGitDir ?? vi.fn(async () => "/tmp/repo/.git"),
hasOriginRemote: options?.hasOriginRemote ?? vi.fn(async () => false),
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")),
},
});
@@ -217,6 +247,48 @@ describe("WorkspaceGitServiceImpl", () => {
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 () => {
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
@@ -432,4 +504,172 @@ describe("WorkspaceGitServiceImpl", () => {
subscription.unsubscribe();
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 { readFile } from "node:fs/promises";
import { readFile, readdir } from "node:fs/promises";
import { join, resolve } from "node:path";
import type pino from "pino";
import type { CheckoutContext } from "../utils/checkout-git.js";
@@ -12,10 +12,12 @@ import {
resolveAbsoluteGitDir,
} from "../utils/checkout-git.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";
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
export type WorkspaceGitRuntimeSnapshot = {
cwd: string;
@@ -59,6 +61,11 @@ export interface WorkspaceGitService {
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null;
getSnapshot(cwd: string): Promise<WorkspaceGitRuntimeSnapshot>;
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;
}
@@ -66,6 +73,7 @@ export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => vo
interface WorkspaceGitServiceDependencies {
watch: typeof watch;
readdir: typeof readdir;
getCheckoutStatus: typeof getCheckoutStatus;
getCheckoutShortstat: typeof getCheckoutShortstat;
getPullRequestStatus: typeof getPullRequestStatus;
@@ -73,6 +81,7 @@ interface WorkspaceGitServiceDependencies {
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
hasOriginRemote: (cwd: string) => Promise<boolean>;
runGitFetch: (cwd: string) => Promise<void>;
runGitCommand: typeof runGitCommand;
now: () => Date;
}
@@ -102,6 +111,18 @@ interface RepoGitTarget {
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 {
private readonly logger: pino.Logger;
private readonly paseoHome: string;
@@ -109,12 +130,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
private readonly repoTargets = new Map<string, RepoGitTarget>();
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) {
this.logger = options.logger.child({ module: "workspace-git-service" });
this.paseoHome = options.paseoHome;
this.deps = {
watch,
watch: options.deps?.watch ?? watch,
readdir: options.deps?.readdir ?? readdir,
getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus,
getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat,
getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus,
@@ -122,6 +146,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir,
hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote,
runGitFetch: options.deps?.runGitFetch ?? runGitFetch,
runGitCommand: options.deps?.runGitCommand ?? runGitCommand,
now: options.deps?.now ?? (() => new Date()),
};
}
@@ -151,7 +176,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (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 {
@@ -170,17 +197,47 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
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 {
for (const target of this.workspaceTargets.values()) {
this.closeWorkspaceTarget(target);
}
this.workspaceTargets.clear();
this.workspaceTargetSetups.clear();
for (const target of this.repoTargets.values()) {
this.closeRepoTarget(target);
}
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> {
@@ -201,6 +258,24 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
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> {
const target: WorkspaceGitTarget = {
cwd,
@@ -230,6 +305,83 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
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> {
try {
const commonDir = (await readFile(join(gitDir, "commondir"), "utf8")).trim();
@@ -308,7 +460,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
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) {
clearTimeout(target.debounceTimer);
}
@@ -319,6 +479,149 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}, 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> {
if (target.refreshPromise) {
target.refreshQueued = true;
@@ -435,6 +738,21 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
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 {
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
@@ -448,6 +766,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
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 {
if (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,
normalizeWorkspaceId,
} from "./workspace-registry-model.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
@@ -53,6 +54,7 @@ export async function bootstrapWorkspaceRegistries(options: {
agentStorage: AgentStorage;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
workspaceGitService: WorkspaceGitService;
logger: Logger;
}): Promise<void> {
const [projectsExists, workspacesExists] = await Promise.all([
@@ -79,7 +81,7 @@ export async function bootstrapWorkspaceRegistries(options: {
const normalizedCwd = normalizeWorkspaceId(record.cwd);
const placement = await buildProjectPlacementForCwd({
cwd: normalizedCwd,
paseoHome: options.paseoHome,
workspaceGitService: options.workspaceGitService,
});
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
const existing = recordsByWorkspaceId.get(workspaceId) ?? { placement, records: [] };
@@ -1,7 +1,7 @@
import { resolve } from "node:path";
import { getCheckoutStatusLite } from "../utils/checkout-git.js";
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
export type PersistedProjectKind = "git" | "non_git";
@@ -198,45 +198,15 @@ export async function detectStaleWorkspaces(
export async function buildProjectPlacementForCwd(input: {
cwd: string;
paseoHome: string;
workspaceGitService: WorkspaceGitService;
}): Promise<ProjectPlacementPayload> {
const normalizedCwd = normalizeWorkspaceId(input.cwd);
const checkout = await getCheckoutStatusLite(normalizedCwd, { paseoHome: input.paseoHome })
.then((status): ProjectCheckoutLitePayload => {
if (!status.isGit) {
return {
cwd: normalizedCwd,
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,
};
})
const checkout = await input.workspaceGitService
.getSnapshot(normalizedCwd)
.then(
(snapshot): ProjectCheckoutLitePayload =>
checkoutLiteFromGitSnapshot(normalizedCwd, snapshot.git),
)
.catch(
(): ProjectCheckoutLitePayload => ({
cwd: normalizedCwd,
+119 -55
View File
@@ -17,10 +17,12 @@ import {
} from "./messages.js";
import { findGitHubPrAttachment } from "./agent/prompt-attachments.js";
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
import {
applyWorktreeSetupProgressEvent,
@@ -33,7 +35,11 @@ import {
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { ScriptRouteStore } from "./script-proxy.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 {
computeWorktreePath,
@@ -51,8 +57,7 @@ import {
WorktreeSetupError,
} from "../utils/worktree.js";
import { writePaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
import { toCheckoutError } from "./checkout-git-utils.js";
const execFileAsync = promisify(execFile);
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
@@ -70,6 +75,7 @@ type EmitSessionMessage = (message: SessionOutboundMessage) => void;
type BuildAgentSessionConfigDependencies = {
paseoHome?: string;
sessionLogger: Logger;
workspaceGitService?: WorkspaceGitService;
checkoutExistingBranch: (cwd: string, branch: string) => Promise<void>;
createBranchFromBase: (params: {
cwd: string;
@@ -90,6 +96,18 @@ type ArchivePaseoWorktreeDependencies = {
};
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>;
findWorkspaceByDirectory: (directory: string) => Promise<PersistedWorkspaceRecord | null>;
projectRegistry: Pick<ProjectRegistry, "get" | "upsert" | "archive">;
@@ -121,6 +139,7 @@ type HandleWorkspaceSetupStatusRequestDependencies = {
type HandleCreatePaseoWorktreeRequestDependencies = {
paseoHome?: string;
workspaceGitService?: WorkspaceGitService;
describeWorkspaceRecord: (
workspace: PersistedWorkspaceRecord,
) => Promise<WorkspaceDescriptorPayload>;
@@ -130,6 +149,7 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
worktreePath: string;
branchName: string;
}) => Promise<PersistedWorkspaceRecord>;
syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise<void>;
sessionLogger: Logger;
runWorktreeSetupInBackground: (options: {
requestCwd: string;
@@ -178,7 +198,11 @@ export async function buildAgentSessionConfig(
const baseBranch =
githubPrAttachment.baseRefName?.trim() ||
normalized.baseBranch ||
(await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
(await resolveGitCreateBaseBranch(
cwd,
dependencies.workspaceGitService,
dependencies.paseoHome,
));
const worktreeSlug =
normalized.worktreeSlug ?? slugify(resolveGitHubPrBranchName(githubPrAttachment));
const createdWorktree = await createGitHubPrWorktree({
@@ -208,11 +232,7 @@ export async function buildAgentSessionConfig(
if (normalized.createNewBranch) {
targetBranch = normalized.newBranchName!;
} else {
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
targetBranch = stdout.trim();
targetBranch = (await getCurrentBranch(cwd)) ?? "";
}
if (!targetBranch) {
@@ -225,7 +245,12 @@ export async function buildAgentSessionConfig(
);
const baseBranch =
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
normalized.baseBranch ??
(await resolveGitCreateBaseBranch(
cwd,
dependencies.workspaceGitService,
dependencies.paseoHome,
));
const createdWorktree = await createAgentWorktree({
branchName: targetBranch,
cwd,
@@ -237,7 +262,12 @@ export async function buildAgentSessionConfig(
worktreeBootstrap = createdWorktree;
} else if (normalized.createNewBranch) {
const baseBranch =
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
normalized.baseBranch ??
(await resolveGitCreateBaseBranch(
cwd,
dependencies.workspaceGitService,
dependencies.paseoHome,
));
await dependencies.createBranchFromBase({
cwd,
baseBranch,
@@ -322,13 +352,31 @@ export function assertSafeGitRef(ref: string, label: string): void {
}
}
export async function resolveGitCreateBaseBranch(cwd: string, paseoHome?: string): Promise<string> {
const checkout = await getCheckoutStatusLite(cwd, { paseoHome });
if (!checkout.isGit) {
throw new Error("Cannot create a worktree outside a git repository");
}
export async function resolveGitCreateBaseBranch(
cwd: string,
workspaceGitService?: WorkspaceGitService,
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);
if (!baseBranch) {
throw new Error("Unable to resolve repository default branch");
@@ -603,46 +651,47 @@ export async function registerPendingWorktreeWorkspace(
): Promise<PersistedWorkspaceRecord> {
const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath);
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 existingWorkspace = await dependencies.findWorkspaceByDirectory(workspaceDirectory);
if (!existingWorkspace) {
const newRecord: import("./workspace-registry.js").PersistedWorkspaceRecord = {
workspaceId: workspaceDirectory,
projectId,
cwd: workspaceDirectory,
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,
const existingProject = await dependencies.projectRegistry.get(placement.projectKey);
const nextProjectRecord = dependencies.buildPersistedProjectRecord({
workspaceId: workspaceDirectory,
placement,
createdAt: existingProject?.createdAt ?? 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 });
if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) {
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return (await dependencies.workspaceRegistry.get(existingWorkspace.workspaceId))!;
return (await dependencies.workspaceRegistry.get(nextWorkspaceRecord.workspaceId))!;
}
export async function handleCreatePaseoWorktreeRequest(
@@ -650,14 +699,29 @@ export async function handleCreatePaseoWorktreeRequest(
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
try {
const checkout = await getCheckoutStatusLite(request.cwd, {
paseoHome: dependencies.paseoHome,
});
if (!checkout.isGit) {
throw new Error("Create worktree requires a git repository");
}
let repoRoot = request.cwd;
if (dependencies.workspaceGitService) {
const snapshot = await dependencies.workspaceGitService.getSnapshot(request.cwd);
if (!snapshot.git.isGit) {
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);
if (!baseBranch) {
throw new Error("Unable to resolve repository default branch");
+23 -8
View File
@@ -20,11 +20,11 @@ import {
__setPullRequestStatusCacheTtlForTests,
commitAll,
getCachedCheckoutShortstat,
getCurrentBranch,
getCheckoutDiff,
getCheckoutShortstat,
getPullRequestStatus,
getCheckoutStatus,
getCheckoutStatusLite,
listBranchSuggestions,
mergeToBase,
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 () => {
writeFileSync(join(repoDir, "file.txt"), "updated\n");
@@ -170,13 +179,16 @@ const x = 1;
expect(removedLine?.tokens).toEqual([{ text: "old comment line", style: "comment" }]);
});
it("returns lightweight checkout status for normal repos", async () => {
const status = await getCheckoutStatusLite(repoDir);
it("returns checkout root metadata for normal repos", async () => {
const status = await getCheckoutStatus(repoDir);
expect(status.isGit).toBe(true);
if (!status.isGit) {
return;
}
expect(status.currentBranch).toBe("main");
expect(status.worktreeRoot).toBe(repoDir);
expect(status.repoRoot).toBe(repoDir);
expect(status.isPaseoOwnedWorktree).toBe(false);
expect(status.mainRepoRoot).toBeNull();
expect(status.mainRepoRoot ?? null).toBeNull();
});
it("exposes hasRemote when origin is configured", async () => {
@@ -404,7 +416,7 @@ const x = 1;
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({
branchName: "main",
cwd: repoDir,
@@ -413,9 +425,12 @@ const x = 1;
paseoHome,
});
const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome });
const status = await getCheckoutStatus(result.worktreePath, { paseoHome });
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.mainRepoRoot).toBe(repoDir);
});
+10 -75
View File
@@ -516,38 +516,6 @@ export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPas
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 {
diff: string;
structured?: ParsedDiffFile[];
@@ -591,12 +559,16 @@ async function requireGitRepo(cwd: string): Promise<void> {
}
export async function getCurrentBranch(cwd: string): Promise<string | null> {
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
const branch = stdout.trim();
return branch.length > 0 ? branch : null;
try {
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
const branch = stdout.trim();
return branch.length > 0 ? branch : null;
} catch {
return 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 {
additions: number;
deletions: number;
+1 -1
View File
@@ -21,6 +21,6 @@ export default defineConfig({
maxForks: 1,
},
},
exclude: ["**/node_modules/**", "**/dist/**"],
exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**"],
},
});