mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 15:12:48 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c67691e1d0 | ||
|
|
345729c588 | ||
|
|
3cdadd362c | ||
|
|
5a836eb170 | ||
|
|
61c36b4e22 | ||
|
|
df0ce60a35 | ||
|
|
f21f861653 | ||
|
|
dbd1645dc9 |
@@ -1,19 +0,0 @@
|
||||
# CI Test Status
|
||||
|
||||
Tracking progress toward all-green CI.
|
||||
|
||||
## CI Jobs
|
||||
|
||||
| Job | Status | Notes |
|
||||
|-----|--------|-------|
|
||||
| format | unknown | `npx biome format .` |
|
||||
| typecheck | unknown | `npm run typecheck` |
|
||||
| server-tests | unknown | unit + integration (vitest) |
|
||||
| app-tests | unknown | unit tests (vitest) |
|
||||
| playwright | unknown | E2E tests (playwright) |
|
||||
| relay-tests | unknown | unit tests (vitest) |
|
||||
| cli-tests | unknown | local tests |
|
||||
|
||||
## Log
|
||||
|
||||
- 2026-04-10: Branch created, automated agents begin iterating
|
||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-sVWb8lj3cgxgVcRvBmS5f0AyPT6uS46KfBEWgG6s8ww=";
|
||||
npmDepsHash = "sha256-AyhJwa2ISs3wxorZIWblYasH35K3YpesPl4+1f5QTJg=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
@@ -36,6 +37,7 @@
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -224,6 +224,51 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
return offer as OfferPayload;
|
||||
}
|
||||
|
||||
function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload {
|
||||
const stdout = execFileSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHomePath,
|
||||
},
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null };
|
||||
if (payload.relayEnabled !== true || typeof payload.url !== "string") {
|
||||
throw new Error(`Unexpected daemon pair response: ${stdout}`);
|
||||
}
|
||||
return decodeOfferFromFragmentUrl(payload.url);
|
||||
}
|
||||
|
||||
async function waitForPairingOfferFromCli(args: {
|
||||
repoRoot: string;
|
||||
paseoHome: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<OfferPayload> {
|
||||
const timeoutMs = args.timeoutMs ?? 15000;
|
||||
const start = Date.now();
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
return loadPairingOfferFromCli(args.repoRoot, args.paseoHome);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${
|
||||
lastError instanceof Error ? lastError.message : String(lastError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
ensureRelayBuildArtifact(repoRoot);
|
||||
@@ -433,12 +478,6 @@ export default async function globalSetup() {
|
||||
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
|
||||
let offerPayload: OfferPayload | null = null;
|
||||
let offerResolve: (() => void) | null = null;
|
||||
const offerPromise = new Promise<void>((resolve) => {
|
||||
offerResolve = resolve;
|
||||
});
|
||||
|
||||
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
@@ -473,26 +512,6 @@ export default async function globalSetup() {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
daemonLineBuffer.add(`[stdout] ${trimmed}`);
|
||||
if (!offerPayload) {
|
||||
const clean = stripAnsi(trimmed);
|
||||
try {
|
||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
||||
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
|
||||
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
||||
offerResolve?.();
|
||||
}
|
||||
} catch {
|
||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
||||
if (match && clean.includes("pairing_offer")) {
|
||||
try {
|
||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||
offerResolve?.();
|
||||
} catch {
|
||||
// ignore parsing failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[daemon] ${trimmed}`);
|
||||
}
|
||||
});
|
||||
@@ -523,17 +542,10 @@ export default async function globalSetup() {
|
||||
}),
|
||||
]);
|
||||
|
||||
// Wait for daemon to emit a pairing offer (includes relay session ID).
|
||||
await Promise.race([
|
||||
offerPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
|
||||
),
|
||||
]);
|
||||
if (!offerPayload) {
|
||||
throw new Error("pairing_offer was not parsed from daemon logs");
|
||||
}
|
||||
const offer = offerPayload as OfferPayload;
|
||||
const offer = await waitForPairingOfferFromCli({
|
||||
repoRoot,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
process.env.E2E_RELAY_PORT = String(relayPort);
|
||||
|
||||
@@ -236,7 +236,14 @@ function WorkspaceStatusIndicator({
|
||||
if (shouldShowSyncedLoader) {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
<SyncedLoader size={11} color={theme.colorScheme === "light" ? theme.colors.palette.amber[700] : theme.colors.palette.amber[500]} />
|
||||
<SyncedLoader
|
||||
size={11}
|
||||
color={
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -340,7 +347,14 @@ function ProjectLeadingVisual({
|
||||
if (shouldShowSyncedLoader) {
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
<SyncedLoader size={11} color={theme.colorScheme === "light" ? theme.colors.palette.amber[700] : theme.colors.palette.amber[500]} />
|
||||
<SyncedLoader
|
||||
size={11}
|
||||
color={
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { summarizeWorkspaceCollection } from "@/utils/workspace-fetch-debug";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -334,24 +333,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
let includeSubscribe = options?.subscribe ?? false;
|
||||
|
||||
while (true) {
|
||||
console.log("[WorkspaceFetch][hydrate] request", {
|
||||
serverId,
|
||||
cursor,
|
||||
includeSubscribe,
|
||||
existingWorkspaces: summarizeWorkspaceCollection(existingWorkspaces?.values()),
|
||||
});
|
||||
const payload = await client.fetchWorkspaces({
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
...(includeSubscribe ? { subscribe: {} } : {}),
|
||||
page: cursor ? { limit: 200, cursor } : { limit: 200 },
|
||||
});
|
||||
console.log("[WorkspaceFetch][hydrate] response", {
|
||||
serverId,
|
||||
cursor,
|
||||
includeSubscribe,
|
||||
pageInfo: payload.pageInfo,
|
||||
payload: summarizeWorkspaceCollection(payload.entries),
|
||||
});
|
||||
if (options?.isCancelled?.()) {
|
||||
return;
|
||||
}
|
||||
@@ -380,11 +366,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
setWorkspaces(serverId, workspaces);
|
||||
setHasHydratedWorkspaces(serverId, true);
|
||||
console.log("[WorkspaceFetch][hydrate] applied", {
|
||||
serverId,
|
||||
subscribed: options?.subscribe ?? false,
|
||||
nextWorkspaces: summarizeWorkspaceCollection(workspaces.values()),
|
||||
});
|
||||
},
|
||||
[client, isConnected, serverId, setHasHydratedWorkspaces, setWorkspaces],
|
||||
);
|
||||
@@ -1172,10 +1153,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update") return;
|
||||
if (message.payload.kind === "remove") {
|
||||
console.log("[WorkspaceFetch][update] remove", {
|
||||
serverId,
|
||||
workspaceId: message.payload.id,
|
||||
});
|
||||
removeWorkspace(serverId, message.payload.id);
|
||||
return;
|
||||
}
|
||||
@@ -1183,11 +1160,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const existingWorkspace = useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.workspaces.get(workspace.id);
|
||||
console.log("[WorkspaceFetch][update] upsert", {
|
||||
serverId,
|
||||
existedInStore: Boolean(existingWorkspace),
|
||||
workspace: summarizeWorkspaceCollection([workspace]),
|
||||
});
|
||||
mergeWorkspaces(serverId, [workspace]);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,10 +9,6 @@ import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||
import {
|
||||
summarizeSidebarProjects,
|
||||
summarizeWorkspaceCollection,
|
||||
} from "@/utils/workspace-fetch-debug";
|
||||
|
||||
const EMPTY_ORDER: string[] = [];
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||
@@ -292,14 +288,6 @@ export function useSidebarWorkspacesList(options?: {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[WorkspaceFetch][sidebar] model", {
|
||||
serverId,
|
||||
connectionStatus,
|
||||
hasHydratedWorkspaces,
|
||||
sessionWorkspaces: summarizeWorkspaceCollection(sessionWorkspaces?.values()),
|
||||
projects: summarizeSidebarProjects(projects),
|
||||
});
|
||||
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -345,21 +333,10 @@ export function useSidebarWorkspacesList(options?: {
|
||||
let cursor: string | null = null;
|
||||
try {
|
||||
while (true) {
|
||||
console.log("[WorkspaceFetch][sidebar-refresh] request", {
|
||||
serverId,
|
||||
cursor,
|
||||
existingWorkspaces: summarizeWorkspaceCollection(existingWorkspaces?.values()),
|
||||
});
|
||||
const payload = await client.fetchWorkspaces({
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
page: cursor ? { limit: 200, cursor } : { limit: 200 },
|
||||
});
|
||||
console.log("[WorkspaceFetch][sidebar-refresh] response", {
|
||||
serverId,
|
||||
cursor,
|
||||
pageInfo: payload.pageInfo,
|
||||
payload: summarizeWorkspaceCollection(payload.entries),
|
||||
});
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = toWorkspaceDescriptor(entry);
|
||||
next.set(
|
||||
@@ -378,10 +355,6 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const store = useSessionStore.getState();
|
||||
store.setWorkspaces(serverId, next);
|
||||
store.setHasHydratedWorkspaces(serverId, true);
|
||||
console.log("[WorkspaceFetch][sidebar-refresh] applied", {
|
||||
serverId,
|
||||
nextWorkspaces: summarizeWorkspaceCollection(next.values()),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
|
||||
serverId,
|
||||
|
||||
@@ -134,7 +134,14 @@ export function WorkspaceTabIcon({
|
||||
if (shouldShowLoader) {
|
||||
return (
|
||||
<View style={[styles.agentIconWrapper, { width: size, height: size }]}>
|
||||
<SyncedLoader size={size - 1} color={theme.colorScheme === "light" ? theme.colors.palette.amber[700] : theme.colors.palette.amber[500]} />
|
||||
<SyncedLoader
|
||||
size={size - 1}
|
||||
color={
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import assert from "node:assert";
|
||||
import {
|
||||
resolveProviderAndModel,
|
||||
resolveStructuredResponseMessage,
|
||||
type StructuredResponseTimelineClient,
|
||||
} from "../src/commands/agent/run.ts";
|
||||
import { resolveProviderAndModel } from "../src/utils/provider-model.ts";
|
||||
|
||||
type TimelineEntry = {
|
||||
item: {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
|
||||
"build:main": "tsc -p tsconfig.json",
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$DesktopDir = (Resolve-Path "$ScriptDir\..").Path
|
||||
$AppDir = (Resolve-Path "$DesktopDir\..\app").Path
|
||||
$RootDir = (Resolve-Path "$DesktopDir\..\..").Path
|
||||
|
||||
# Build the Electron main process
|
||||
npm run build:main
|
||||
|
||||
# Get a random available port for Metro
|
||||
$env:EXPO_PORT = (npx get-port-cli).Trim()
|
||||
|
||||
# Set EXPO_DEV_URL in the environment so Electron inherits it
|
||||
$env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)"
|
||||
|
||||
# Allow any origin in dev so Electron on random ports works.
|
||||
# SECURITY: wildcard CORS is unsafe in production — only acceptable here because
|
||||
# the daemon binds to localhost and this script is never used for production.
|
||||
$env:PASEO_CORS_ORIGINS = "*"
|
||||
|
||||
Write-Host @"
|
||||
======================================================
|
||||
Paseo Desktop Dev (Windows)
|
||||
======================================================
|
||||
Metro: http://localhost:$($env:EXPO_PORT)
|
||||
======================================================
|
||||
"@
|
||||
|
||||
# Launch Metro + Electron together, kill both on exit
|
||||
& "$RootDir\node_modules\.bin\concurrently" `
|
||||
--kill-others `
|
||||
--names "metro,electron" `
|
||||
--prefix-colors "magenta,cyan" `
|
||||
"cd `"$AppDir`" && npx expo start --port $($env:EXPO_PORT)" `
|
||||
"npx wait-on tcp:$($env:EXPO_PORT) && npx electron `"$DesktopDir`""
|
||||
@@ -1,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";
|
||||
|
||||
@@ -150,24 +150,14 @@ 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,
|
||||
@@ -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);
|
||||
|
||||
@@ -107,6 +107,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";
|
||||
@@ -368,6 +369,10 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
|
||||
const terminalManager = createTerminalManager();
|
||||
const workspaceGitService = new WorkspaceGitServiceImpl({
|
||||
logger,
|
||||
paseoHome: config.paseoHome,
|
||||
});
|
||||
|
||||
const detachAgentStoragePersistence = attachAgentStoragePersistence(
|
||||
logger,
|
||||
@@ -381,6 +386,7 @@ export async function createPaseoDaemon(
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
});
|
||||
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
|
||||
@@ -389,6 +395,7 @@ export async function createPaseoDaemon(
|
||||
const checkoutDiffManager = new CheckoutDiffManager({
|
||||
logger,
|
||||
paseoHome: config.paseoHome,
|
||||
workspaceGitService,
|
||||
});
|
||||
const loopService = new LoopService({
|
||||
paseoHome: config.paseoHome,
|
||||
@@ -619,6 +626,7 @@ export async function createPaseoDaemon(
|
||||
loopService,
|
||||
scheduleService,
|
||||
checkoutDiffManager,
|
||||
workspaceGitService,
|
||||
);
|
||||
|
||||
if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") {
|
||||
@@ -638,8 +646,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1404,7 +1404,7 @@ export class Session {
|
||||
private async buildProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> {
|
||||
return buildProjectPlacementForCwd({
|
||||
cwd,
|
||||
paseoHome: this.paseoHome,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3209,6 +3209,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),
|
||||
},
|
||||
@@ -4718,7 +4719,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",
|
||||
@@ -6264,6 +6264,7 @@ export class Session {
|
||||
return handleCreateWorktreeRequest(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
|
||||
emit: (message) => this.emit(message),
|
||||
registerPendingWorktreeWorkspace: (options) =>
|
||||
|
||||
@@ -65,6 +65,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<{
|
||||
@@ -105,6 +107,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(),
|
||||
};
|
||||
|
||||
@@ -328,62 +335,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: () => {},
|
||||
};
|
||||
}
|
||||
@@ -1132,7 +1137,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");
|
||||
@@ -1143,6 +1147,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();
|
||||
|
||||
@@ -66,6 +66,66 @@ type WebSocketServerConfig = {
|
||||
|
||||
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 () => {},
|
||||
@@ -307,6 +367,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
loopService?: LoopService,
|
||||
scheduleService?: ScheduleService,
|
||||
checkoutDiffManager?: CheckoutDiffManager,
|
||||
workspaceGitService?: WorkspaceGitServiceImpl,
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.serverId = serverId;
|
||||
@@ -334,10 +395,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;
|
||||
@@ -515,8 +573,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,160 @@ 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 () => {
|
||||
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();
|
||||
expect(watch).toHaveBeenCalledWith("/tmp/plain", { recursive: true }, 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);
|
||||
|
||||
@@ -6,15 +6,77 @@ 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(() => {
|
||||
@@ -29,6 +91,7 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
path.join(paseoHome, "projects", "workspaces.json"),
|
||||
logger,
|
||||
);
|
||||
workspaceGitService = createNoopWorkspaceGitService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -94,6 +157,7 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -157,6 +221,7 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,10 +17,11 @@ import type {
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
|
||||
import { createAgentWorktree } from "./worktree-bootstrap.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import { getCheckoutStatusLite, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js";
|
||||
import { resolveRepositoryDefaultBranch } from "../utils/checkout-git.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import {
|
||||
computeWorktreePath,
|
||||
@@ -51,6 +52,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;
|
||||
@@ -99,6 +101,7 @@ type CreatePaseoWorktreeInBackgroundDependencies = {
|
||||
|
||||
type HandleCreatePaseoWorktreeRequestDependencies = {
|
||||
paseoHome?: string;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
describeWorkspaceRecord: (
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
) => Promise<WorkspaceDescriptorPayload>;
|
||||
@@ -167,7 +170,8 @@ export async function buildAgentSessionConfig(
|
||||
);
|
||||
|
||||
const baseBranch =
|
||||
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
|
||||
normalized.baseBranch ??
|
||||
(await resolveGitCreateBaseBranch(cwd, dependencies.workspaceGitService));
|
||||
const createdWorktree = await createAgentWorktree({
|
||||
branchName: targetBranch,
|
||||
cwd,
|
||||
@@ -179,7 +183,8 @@ export async function buildAgentSessionConfig(
|
||||
worktreeConfig = createdWorktree;
|
||||
} else if (normalized.createNewBranch) {
|
||||
const baseBranch =
|
||||
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
|
||||
normalized.baseBranch ??
|
||||
(await resolveGitCreateBaseBranch(cwd, dependencies.workspaceGitService));
|
||||
await dependencies.createBranchFromBase({
|
||||
cwd,
|
||||
baseBranch,
|
||||
@@ -264,13 +269,18 @@ 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) {
|
||||
export async function resolveGitCreateBaseBranch(
|
||||
cwd: string,
|
||||
workspaceGitService: WorkspaceGitService,
|
||||
): Promise<string> {
|
||||
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;
|
||||
const repoRoot = snapshot.git.isPaseoOwnedWorktree
|
||||
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? cwd)
|
||||
: (snapshot.git.repoRoot ?? cwd);
|
||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
||||
if (!baseBranch) {
|
||||
throw new Error("Unable to resolve repository default branch");
|
||||
@@ -545,14 +555,14 @@ 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) {
|
||||
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;
|
||||
const repoRoot = snapshot.git.isPaseoOwnedWorktree
|
||||
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? request.cwd)
|
||||
: (snapshot.git.repoRoot ?? request.cwd);
|
||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
||||
if (!baseBranch) {
|
||||
throw new Error("Unable to resolve repository default branch");
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
getCheckoutShortstat,
|
||||
getPullRequestStatus,
|
||||
getCheckoutStatus,
|
||||
getCheckoutStatusLite,
|
||||
listBranchSuggestions,
|
||||
mergeToBase,
|
||||
mergeFromBase,
|
||||
@@ -164,15 +163,6 @@ 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);
|
||||
expect(status.isGit).toBe(true);
|
||||
expect(status.currentBranch).toBe("main");
|
||||
expect(status.worktreeRoot).toBe(repoDir);
|
||||
expect(status.isPaseoOwnedWorktree).toBe(false);
|
||||
expect(status.mainRepoRoot).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes hasRemote when origin is configured", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
execSync(`git init --bare -b main ${remoteDir}`);
|
||||
@@ -380,22 +370,6 @@ const x = 1;
|
||||
expect(message).toBe("worktree update");
|
||||
});
|
||||
|
||||
it("returns lightweight checkout status for .paseo worktrees", async () => {
|
||||
const result = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "lite-alpha",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome });
|
||||
expect(status.isGit).toBe(true);
|
||||
expect(status.worktreeRoot).toBe(result.worktreePath);
|
||||
expect(status.isPaseoOwnedWorktree).toBe(true);
|
||||
expect(status.mainRepoRoot).toBe(repoDir);
|
||||
});
|
||||
|
||||
it("returns mainRepoRoot pointing to first non-bare worktree for bare repos", async () => {
|
||||
const bareRepoDir = join(tempDir, "bare-repo");
|
||||
execSync(`git clone --bare ${repoDir} ${bareRepoDir}`);
|
||||
|
||||
@@ -482,38 +482,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[];
|
||||
@@ -1156,43 +1124,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;
|
||||
|
||||
@@ -21,6 +21,6 @@ export default defineConfig({
|
||||
maxForks: 1,
|
||||
},
|
||||
},
|
||||
exclude: ["**/node_modules/**", "**/dist/**"],
|
||||
exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Ensure node_modules/.bin is in PATH
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$env:PATH = "$ScriptDir\..\node_modules\.bin;$env:PATH"
|
||||
|
||||
# Derive PASEO_HOME: stable name for worktrees, temporary dir otherwise
|
||||
if (-not $env:PASEO_HOME) {
|
||||
$GitDir = git rev-parse --git-dir 2>$null
|
||||
$GitCommonDir = git rev-parse --git-common-dir 2>$null
|
||||
|
||||
if ($GitDir -and $GitCommonDir -and ($GitDir -ne $GitCommonDir)) {
|
||||
# Inside a worktree — derive a stable home from the worktree name
|
||||
$WorktreeRoot = git rev-parse --show-toplevel
|
||||
$WorktreeName = (Split-Path -Leaf $WorktreeRoot).ToLower() -replace '[^a-z0-9-]', '-' -replace '-+', '-' -replace '^-|-$', ''
|
||||
$env:PASEO_HOME = "$env:USERPROFILE\.paseo-$WorktreeName"
|
||||
New-Item -ItemType Directory -Force -Path $env:PASEO_HOME | Out-Null
|
||||
} else {
|
||||
$env:PASEO_HOME = Join-Path ([System.IO.Path]::GetTempPath()) "paseo-dev-$([System.Guid]::NewGuid().ToString('N').Substring(0,6))"
|
||||
New-Item -ItemType Directory -Force -Path $env:PASEO_HOME | Out-Null
|
||||
# Register cleanup on exit
|
||||
$TempPaseoHome = $env:PASEO_HOME
|
||||
Register-EngineEvent PowerShell.Exiting -Action {
|
||||
Remove-Item -Recurse -Force $TempPaseoHome -ErrorAction SilentlyContinue
|
||||
} | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Share speech models with the main install to avoid duplicate downloads
|
||||
if (-not $env:PASEO_LOCAL_MODELS_DIR) {
|
||||
$env:PASEO_LOCAL_MODELS_DIR = "$env:USERPROFILE\.paseo\models\local-speech"
|
||||
New-Item -ItemType Directory -Force -Path $env:PASEO_LOCAL_MODELS_DIR | Out-Null
|
||||
}
|
||||
|
||||
Write-Host @"
|
||||
======================================================
|
||||
Paseo Dev (Windows)
|
||||
======================================================
|
||||
Home: $($env:PASEO_HOME)
|
||||
Models: $($env:PASEO_LOCAL_MODELS_DIR)
|
||||
Daemon: localhost:6767
|
||||
======================================================
|
||||
"@
|
||||
|
||||
# Allow any origin in dev so Electron on random ports all work.
|
||||
# SECURITY: wildcard CORS is unsafe in production — only acceptable here because
|
||||
# the daemon binds to localhost and this script is never used for production.
|
||||
# Build dependencies required by the daemon (they only ship dist/)
|
||||
Write-Host "Building @getpaseo/highlight..."
|
||||
npm run build --workspace=@getpaseo/highlight
|
||||
Write-Host "Building @getpaseo/relay..."
|
||||
npm run build --workspace=@getpaseo/relay
|
||||
|
||||
$env:PASEO_CORS_ORIGINS = "*"
|
||||
|
||||
# Configure the app to auto-connect to this daemon on localhost
|
||||
$env:EXPO_PUBLIC_LOCAL_DAEMON = "localhost:6767"
|
||||
$env:BROWSER = "none"
|
||||
|
||||
# Run both with concurrently
|
||||
concurrently `
|
||||
--names "daemon,metro" `
|
||||
--prefix-colors "cyan,magenta" `
|
||||
"npm run dev:server" `
|
||||
"cd packages/app && npx expo start"
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
exclude: ["**/.claude/**"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user