mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c67691e1d0 | ||
|
|
345729c588 | ||
|
|
3cdadd362c | ||
|
|
5a836eb170 | ||
|
|
61c36b4e22 | ||
|
|
df0ce60a35 | ||
|
|
f21f861653 | ||
|
|
dbd1645dc9 | ||
|
|
dd99144587 | ||
|
|
dd6a396dbf | ||
|
|
202fa3e8f3 | ||
|
|
8d854a6342 | ||
|
|
5329a12222 | ||
|
|
9c0b5616fe | ||
|
|
c59a979446 | ||
|
|
6ed166641f | ||
|
|
be3d5ed78d |
@@ -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).
|
||||
|
||||
Generated
+19
-19
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -34906,16 +34906,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
||||
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.55-rc.2",
|
||||
"@getpaseo/highlight": "0.1.55-rc.2",
|
||||
"@getpaseo/server": "0.1.55-rc.2",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -35056,11 +35056,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"@getpaseo/relay": "0.1.55-rc.2",
|
||||
"@getpaseo/server": "0.1.55-rc.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35101,11 +35101,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"@getpaseo/cli": "0.1.55-rc.2",
|
||||
"@getpaseo/server": "0.1.55-rc.2",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -35139,7 +35139,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35340,7 +35340,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35366,7 +35366,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35382,14 +35382,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||
"@getpaseo/relay": "0.1.55-rc.1",
|
||||
"@getpaseo/highlight": "0.1.55-rc.2",
|
||||
"@getpaseo/relay": "0.1.55-rc.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -35817,7 +35817,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
||||
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.55-rc.2",
|
||||
"@getpaseo/highlight": "0.1.55-rc.2",
|
||||
"@getpaseo/server": "0.1.55-rc.2",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -1225,6 +1225,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
prefsButton: {
|
||||
height: 28,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, GitBranch } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
||||
@@ -24,6 +25,7 @@ export function BranchSwitcher({
|
||||
isGitCheckout,
|
||||
}: BranchSwitcherProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -65,7 +67,7 @@ export function BranchSwitcher({
|
||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={branchOptions}
|
||||
@@ -111,6 +113,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
marginLeft: {
|
||||
xs: -theme.spacing[2],
|
||||
md: 0,
|
||||
},
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
|
||||
@@ -634,7 +634,9 @@ export function CombinedModelSelector({
|
||||
) : (
|
||||
<>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.triggerText}>{triggerLabel}</Text>
|
||||
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
||||
{triggerLabel}
|
||||
</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</>
|
||||
)}
|
||||
@@ -704,6 +706,8 @@ export function CombinedModelSelector({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
height: 28,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "transparent",
|
||||
@@ -721,6 +725,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: 0.5,
|
||||
},
|
||||
triggerText: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
|
||||
@@ -1290,11 +1290,15 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
leftButtonGroup: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexGrow: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
rightButtonGroup: {
|
||||
flexShrink: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: isWeb ? theme.spacing[2] : theme.spacing[1],
|
||||
|
||||
@@ -236,7 +236,14 @@ function WorkspaceStatusIndicator({
|
||||
if (shouldShowSyncedLoader) {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
<SyncedLoader size={11} color={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.colors.palette.amber[500]} />
|
||||
<SyncedLoader
|
||||
size={11}
|
||||
color={
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const DOT_COUNT = DOT_SEQUENCE.length;
|
||||
const GRID_ROWS = 3;
|
||||
const GRID_COLUMNS = 2;
|
||||
const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const;
|
||||
const SNAKE_OPACITIES = [1, 0.72, 0.46, 0.22, 0] as const;
|
||||
const SNAKE_OPACITIES = [1, 0.78, 0.56, 0.34, 0] as const;
|
||||
const sharedStepProgress = makeMutable(0);
|
||||
let sharedLoopStarted = false;
|
||||
|
||||
|
||||
@@ -1156,7 +1156,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
removeWorkspace(serverId, message.payload.id);
|
||||
return;
|
||||
}
|
||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
||||
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
||||
const existingWorkspace = useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.workspaces.get(workspace.id);
|
||||
mergeWorkspaces(serverId, [workspace]);
|
||||
});
|
||||
|
||||
const unsubStatus = client.on("status", (message) => {
|
||||
|
||||
@@ -284,6 +284,12 @@ export function useSidebarWorkspacesList(options?: {
|
||||
});
|
||||
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || projects.length === 0) {
|
||||
return;
|
||||
@@ -349,7 +355,12 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const store = useSessionStore.getState();
|
||||
store.setWorkspaces(serverId, next);
|
||||
store.setHasHydratedWorkspaces(serverId, true);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
|
||||
serverId,
|
||||
cursor,
|
||||
error,
|
||||
});
|
||||
// ignore explicit refresh failures; hook keeps existing data
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -141,6 +141,18 @@ function decodeSegment(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function areHeaderLabelsEquivalent(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
): boolean {
|
||||
const normalizedA = trimNonEmpty(a)?.toLocaleLowerCase();
|
||||
const normalizedB = trimNonEmpty(b)?.toLocaleLowerCase();
|
||||
if (!normalizedA || !normalizedB) {
|
||||
return false;
|
||||
}
|
||||
return normalizedA === normalizedB;
|
||||
}
|
||||
|
||||
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
@@ -747,6 +759,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
|
||||
: null;
|
||||
const isWorkspaceHeaderLoading = workspaceHeader === null;
|
||||
const workspaceHeaderTitle = workspaceHeader?.title ?? "";
|
||||
const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? "";
|
||||
const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent(
|
||||
workspaceHeaderTitle,
|
||||
workspaceHeaderSubtitle,
|
||||
);
|
||||
|
||||
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
||||
const currentBranchName =
|
||||
@@ -1949,27 +1967,29 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
<SidebarMenuToggle />
|
||||
<View style={styles.headerTitleContainer}>
|
||||
{isWorkspaceHeaderLoading ? (
|
||||
<>
|
||||
<View style={styles.headerTitleTextGroup}>
|
||||
<View style={styles.headerTitleSkeleton} />
|
||||
<View style={styles.headerProjectTitleSkeleton} />
|
||||
</>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.headerTitleTextGroup}>
|
||||
<BranchSwitcher
|
||||
currentBranchName={currentBranchName}
|
||||
title={workspaceHeader.title}
|
||||
title={workspaceHeaderTitle}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
isGitCheckout={isGitCheckout}
|
||||
/>
|
||||
<Text
|
||||
testID="workspace-header-subtitle"
|
||||
style={styles.headerProjectTitle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{workspaceHeader.subtitle}
|
||||
</Text>
|
||||
</>
|
||||
{shouldShowWorkspaceHeaderSubtitle ? (
|
||||
<Text
|
||||
testID="workspace-header-subtitle"
|
||||
style={styles.headerProjectTitle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{workspaceHeaderSubtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -2291,15 +2311,40 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
},
|
||||
headerTitleContainer: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
headerTitleTextGroup: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexGrow: {
|
||||
xs: 1,
|
||||
md: 0,
|
||||
},
|
||||
flexDirection: {
|
||||
xs: "column",
|
||||
md: "row",
|
||||
},
|
||||
alignItems: {
|
||||
xs: "flex-start",
|
||||
md: "center",
|
||||
},
|
||||
justifyContent: "flex-start",
|
||||
gap: {
|
||||
xs: 0,
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
},
|
||||
headerProjectTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: {
|
||||
xs: theme.fontSize.sm,
|
||||
md: theme.fontSize.base,
|
||||
},
|
||||
flexShrink: 1,
|
||||
},
|
||||
headerTitleSkeleton: {
|
||||
|
||||
@@ -134,7 +134,14 @@ export function WorkspaceTabIcon({
|
||||
if (shouldShowLoader) {
|
||||
return (
|
||||
<View style={[styles.agentIconWrapper, { width: size, height: size }]}>
|
||||
<SyncedLoader size={size - 1} color={theme.colors.palette.amber[500]} />
|
||||
<SyncedLoader
|
||||
size={size - 1}
|
||||
color={
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ export const baseColors = {
|
||||
// Amber scale
|
||||
amber: {
|
||||
500: "#f59e0b",
|
||||
700: "#b45309",
|
||||
},
|
||||
|
||||
// Yellow scale
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
type WorkspaceLike = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName?: string;
|
||||
name: string;
|
||||
status: string;
|
||||
workspaceKind: string;
|
||||
};
|
||||
|
||||
type SidebarWorkspaceLike = {
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
statusBucket: string;
|
||||
workspaceKind: string;
|
||||
};
|
||||
|
||||
type SidebarProjectLike = {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
statusBucket: string;
|
||||
activeCount: number;
|
||||
totalWorkspaces: number;
|
||||
workspaces: SidebarWorkspaceLike[];
|
||||
};
|
||||
|
||||
function countByValue(values: Iterable<string>): Record<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const value of values) {
|
||||
counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
}
|
||||
return Object.fromEntries(counts);
|
||||
}
|
||||
|
||||
export function summarizeWorkspaceCollection(
|
||||
workspaces: Iterable<WorkspaceLike> | null | undefined,
|
||||
): {
|
||||
count: number;
|
||||
projectIds: string[];
|
||||
statusCounts: Record<string, number>;
|
||||
workspaces: Array<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string | null;
|
||||
name: string;
|
||||
status: string;
|
||||
workspaceKind: string;
|
||||
}>;
|
||||
} {
|
||||
const entries = Array.from(workspaces ?? [], (workspace) => ({
|
||||
id: workspace.id,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName?.trim() || null,
|
||||
name: workspace.name,
|
||||
status: workspace.status,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
}));
|
||||
|
||||
return {
|
||||
count: entries.length,
|
||||
projectIds: [...new Set(entries.map((workspace) => workspace.projectId))],
|
||||
statusCounts: countByValue(entries.map((workspace) => workspace.status)),
|
||||
workspaces: entries,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeSidebarProjects(
|
||||
projects: Iterable<SidebarProjectLike> | null | undefined,
|
||||
): {
|
||||
count: number;
|
||||
projectKeys: string[];
|
||||
projects: Array<{
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
statusBucket: string;
|
||||
activeCount: number;
|
||||
totalWorkspaces: number;
|
||||
workspaces: Array<{
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
statusBucket: string;
|
||||
workspaceKind: string;
|
||||
}>;
|
||||
}>;
|
||||
} {
|
||||
const entries = Array.from(projects ?? [], (project) => ({
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
statusBucket: project.statusBucket,
|
||||
activeCount: project.activeCount,
|
||||
totalWorkspaces: project.totalWorkspaces,
|
||||
workspaces: project.workspaces.map((workspace) => ({
|
||||
workspaceId: workspace.workspaceId,
|
||||
name: workspace.name,
|
||||
statusBucket: workspace.statusBucket,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
count: entries.length,
|
||||
projectKeys: entries.map((project) => project.projectKey),
|
||||
projects: entries,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"@getpaseo/relay": "0.1.55-rc.2",
|
||||
"@getpaseo/server": "0.1.55-rc.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { resolve } from "node:path";
|
||||
import { lookup } from "mime-types";
|
||||
import { parseDuration } from "../../utils/duration.js";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import { resolveProviderAndModel } from "../../utils/provider-model.js";
|
||||
|
||||
export function addRunOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
@@ -98,11 +99,6 @@ export interface AgentRunOptions extends CommandOptions {
|
||||
outputSchema?: string;
|
||||
}
|
||||
|
||||
interface ResolvedProviderModel {
|
||||
provider: string;
|
||||
model: string | undefined;
|
||||
}
|
||||
|
||||
function toRunResult(
|
||||
agent: AgentSnapshotPayload,
|
||||
statusOverride?: AgentRunResult["status"],
|
||||
@@ -222,54 +218,6 @@ function structuredRunSchema(output: Record<string, unknown>): OutputSchema<Agen
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProviderAndModel(
|
||||
options: Pick<AgentRunOptions, "provider" | "model">,
|
||||
): ResolvedProviderModel {
|
||||
const providerInput = options.provider?.trim() || "claude";
|
||||
const modelInput = options.model?.trim();
|
||||
|
||||
if (options.model !== undefined && !modelInput) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_MODEL",
|
||||
message: "--model cannot be empty",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const slashIndex = providerInput.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return {
|
||||
provider: providerInput,
|
||||
model: modelInput,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = providerInput.slice(0, slashIndex).trim();
|
||||
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
|
||||
if (!provider || !modelFromProvider) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_PROVIDER",
|
||||
message: "Invalid --provider value",
|
||||
details: "Use --provider <provider> or --provider <provider>/<model>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (modelInput && modelInput !== modelFromProvider) {
|
||||
const error: CommandError = {
|
||||
code: "CONFLICTING_MODEL_OPTIONS",
|
||||
message: "Conflicting model values provided",
|
||||
details: `--provider specifies model ${modelFromProvider}, but --model specifies ${modelInput}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
model: modelInput ?? modelFromProvider,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runRunCommand(
|
||||
prompt: string,
|
||||
options: AgentRunOptions,
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ScheduleCreateOptions extends ScheduleCommandOptions {
|
||||
cron?: string;
|
||||
name?: string;
|
||||
target?: string;
|
||||
provider?: string;
|
||||
maxRuns?: string;
|
||||
expiresIn?: string;
|
||||
}
|
||||
@@ -30,6 +31,7 @@ export async function runCreateCommand(
|
||||
cron: options.cron,
|
||||
name: options.name,
|
||||
target: options.target,
|
||||
provider: options.provider,
|
||||
maxRuns: options.maxRuns,
|
||||
expiresIn: options.expiresIn,
|
||||
});
|
||||
|
||||
@@ -21,6 +21,10 @@ export function createScheduleCommand(): Command {
|
||||
.option("--cron <expr>", "Cron cadence expression")
|
||||
.option("--name <name>", "Optional schedule name")
|
||||
.option("--target <self|new-agent|agent-id>", "Run target")
|
||||
.option(
|
||||
"--provider <provider>",
|
||||
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
||||
)
|
||||
.option("--max-runs <n>", "Maximum number of runs")
|
||||
.option("--expires-in <duration>", "Time to live for the schedule"),
|
||||
).action(withOutput(runCreateCommand));
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ScheduleTarget,
|
||||
} from "./types.js";
|
||||
import { parseDuration } from "../../utils/duration.js";
|
||||
import { resolveProviderAndModel } from "../../utils/provider-model.js";
|
||||
|
||||
export interface ScheduleCommandOptions extends CommandOptions {
|
||||
host?: string;
|
||||
@@ -58,7 +59,8 @@ export function formatTarget(target: ScheduleTarget | ScheduleListItem["target"]
|
||||
if (target.type === "agent") {
|
||||
return `agent:${target.agentId.slice(0, 7)}`;
|
||||
}
|
||||
return `new-agent:${target.config.provider}`;
|
||||
const modelSuffix = target.config.model ? `/${target.config.model}` : "";
|
||||
return `new-agent:${target.config.provider}${modelSuffix}`;
|
||||
}
|
||||
|
||||
export function formatDurationMs(durationMs: number): string {
|
||||
@@ -87,6 +89,7 @@ export function parseScheduleCreateInput(options: {
|
||||
cron?: string;
|
||||
name?: string;
|
||||
target?: string;
|
||||
provider?: string;
|
||||
maxRuns?: string;
|
||||
expiresIn?: string;
|
||||
}): CreateScheduleInput {
|
||||
@@ -111,29 +114,53 @@ export function parseScheduleCreateInput(options: {
|
||||
: { type: "cron", expression: options.cron!.trim() };
|
||||
|
||||
const targetValue = options.target?.trim();
|
||||
const hasExplicitProviderSelection = options.provider !== undefined;
|
||||
const resolvedProviderModel = resolveProviderAndModel({
|
||||
provider: options.provider,
|
||||
defaultProvider: "claude",
|
||||
});
|
||||
const newAgentTarget: ScheduleTarget = {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: process.cwd(),
|
||||
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
|
||||
},
|
||||
};
|
||||
let target: ScheduleTarget;
|
||||
if (!targetValue || targetValue === "self") {
|
||||
if (!targetValue) {
|
||||
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
|
||||
if (currentAgentId) {
|
||||
if (currentAgentId && !hasExplicitProviderSelection) {
|
||||
target = { type: "self", agentId: currentAgentId };
|
||||
} else {
|
||||
target = {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
};
|
||||
target = newAgentTarget;
|
||||
}
|
||||
} else if (targetValue === "self") {
|
||||
if (hasExplicitProviderSelection) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--provider can only be used with a new-agent target",
|
||||
details: "Use --target new-agent or omit --target to create a new agent schedule",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
|
||||
if (!currentAgentId) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--target self requires running inside a Paseo agent",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
target = { type: "self", agentId: currentAgentId };
|
||||
} else if (targetValue === "new-agent") {
|
||||
target = {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
};
|
||||
target = newAgentTarget;
|
||||
} else {
|
||||
if (hasExplicitProviderSelection) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--provider can only be used with a new-agent target",
|
||||
details: "Use --target new-agent or omit --target to create a new agent schedule",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
target = {
|
||||
type: "agent",
|
||||
agentId: targetValue,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { CommandError } from "../output/index.js";
|
||||
|
||||
export interface ResolveProviderAndModelOptions {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
defaultProvider?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedProviderModel {
|
||||
provider: string;
|
||||
model: string | undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderAndModel(
|
||||
options: ResolveProviderAndModelOptions,
|
||||
): ResolvedProviderModel {
|
||||
const providerInput = options.provider?.trim() || options.defaultProvider || "claude";
|
||||
const modelInput = options.model?.trim();
|
||||
|
||||
if (options.model !== undefined && !modelInput) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_MODEL",
|
||||
message: "--model cannot be empty",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const slashIndex = providerInput.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return {
|
||||
provider: providerInput,
|
||||
model: modelInput,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = providerInput.slice(0, slashIndex).trim();
|
||||
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
|
||||
if (!provider || !modelFromProvider) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_PROVIDER",
|
||||
message: "Invalid --provider value",
|
||||
details: "Use --provider <provider> or --provider <provider>/<model>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (modelInput && modelInput !== modelFromProvider) {
|
||||
const error: CommandError = {
|
||||
code: "CONFLICTING_MODEL_OPTIONS",
|
||||
message: "Conflicting model values provided",
|
||||
details: `--provider specifies model ${modelFromProvider}, but --model specifies ${modelInput}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
model: modelInput ?? modelFromProvider,
|
||||
};
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -23,6 +23,11 @@ try {
|
||||
const createdJson = JSON.parse(created.stdout);
|
||||
assert.strictEqual(createdJson.name, "review-prs");
|
||||
assert.strictEqual(createdJson.cadence, "every:5m");
|
||||
assert(
|
||||
typeof createdJson.target === "string" &&
|
||||
(createdJson.target.startsWith("agent:") || createdJson.target === "new-agent:claude"),
|
||||
created.stdout,
|
||||
);
|
||||
|
||||
const listed = await ctx.paseo(["schedule", "ls", "--json"]);
|
||||
assert.strictEqual(listed.exitCode, 0, listed.stderr);
|
||||
@@ -53,6 +58,61 @@ try {
|
||||
console.log("schedule commands work\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 1b: schedule create accepts provider/model syntax for new-agent runs");
|
||||
const created = await ctx.paseo(
|
||||
[
|
||||
"schedule",
|
||||
"create",
|
||||
"Refactor the API layer",
|
||||
"--every",
|
||||
"10m",
|
||||
"--provider",
|
||||
"codex/gpt-5.4",
|
||||
"--json",
|
||||
],
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
assert.strictEqual(created.exitCode, 0, created.stderr);
|
||||
const createdJson = JSON.parse(created.stdout);
|
||||
assert.strictEqual(createdJson.target, "new-agent:codex/gpt-5.4");
|
||||
|
||||
const inspected = await ctx.paseo(["schedule", "inspect", createdJson.id, "--json"]);
|
||||
assert.strictEqual(inspected.exitCode, 0, inspected.stderr);
|
||||
const inspectedJson = JSON.parse(inspected.stdout);
|
||||
assert.strictEqual(inspectedJson.target.config.provider, "codex");
|
||||
assert.strictEqual(inspectedJson.target.config.model, "gpt-5.4");
|
||||
|
||||
const deleted = await ctx.paseo(["schedule", "delete", createdJson.id, "--json"]);
|
||||
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);
|
||||
console.log("schedule provider/model syntax works\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 1c: schedule create rejects provider with self target");
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
"schedule",
|
||||
"create",
|
||||
"Conflicting schedule",
|
||||
"--every",
|
||||
"5m",
|
||||
"--target",
|
||||
"self",
|
||||
"--provider",
|
||||
"codex/gpt-5.4",
|
||||
],
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail for self target with provider");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(
|
||||
output.includes("--provider can only be used with a new-agent target"),
|
||||
"should explain provider target mismatch",
|
||||
);
|
||||
console.log("schedule rejects provider with self target\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: loop run/ls/inspect/logs/stop work");
|
||||
const run = await ctx.paseo(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -8,12 +8,13 @@
|
||||
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
|
||||
"build:main": "tsc -p tsconfig.json",
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"@getpaseo/cli": "0.1.55-rc.2",
|
||||
"@getpaseo/server": "0.1.55-rc.2",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$DesktopDir = (Resolve-Path "$ScriptDir\..").Path
|
||||
$AppDir = (Resolve-Path "$DesktopDir\..\app").Path
|
||||
$RootDir = (Resolve-Path "$DesktopDir\..\..").Path
|
||||
|
||||
# Build the Electron main process
|
||||
npm run build:main
|
||||
|
||||
# Get a random available port for Metro
|
||||
$env:EXPO_PORT = (npx get-port-cli).Trim()
|
||||
|
||||
# Set EXPO_DEV_URL in the environment so Electron inherits it
|
||||
$env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)"
|
||||
|
||||
# Allow any origin in dev so Electron on random ports works.
|
||||
# SECURITY: wildcard CORS is unsafe in production — only acceptable here because
|
||||
# the daemon binds to localhost and this script is never used for production.
|
||||
$env:PASEO_CORS_ORIGINS = "*"
|
||||
|
||||
Write-Host @"
|
||||
======================================================
|
||||
Paseo Desktop Dev (Windows)
|
||||
======================================================
|
||||
Metro: http://localhost:$($env:EXPO_PORT)
|
||||
======================================================
|
||||
"@
|
||||
|
||||
# Launch Metro + Electron together, kill both on exit
|
||||
& "$RootDir\node_modules\.bin\concurrently" `
|
||||
--kill-others `
|
||||
--names "metro,electron" `
|
||||
--prefix-colors "magenta,cyan" `
|
||||
"cd `"$AppDir`" && npx expo start --port $($env:EXPO_PORT)" `
|
||||
"npx wait-on tcp:$($env:EXPO_PORT) && npx electron `"$DesktopDir`""
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -64,8 +64,8 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||
"@getpaseo/relay": "0.1.55-rc.1",
|
||||
"@getpaseo/highlight": "0.1.55-rc.2",
|
||||
"@getpaseo/relay": "0.1.55-rc.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
AgentStatusEnum,
|
||||
ProviderSummarySchema,
|
||||
parseDurationString,
|
||||
resolveProviderAndModel,
|
||||
sanitizePermissionRequest,
|
||||
serializeSnapshotWithMetadata,
|
||||
startAgentRun,
|
||||
@@ -81,13 +82,20 @@ export async function createAgentManagementMcpServer(
|
||||
component: "agent-management-mcp",
|
||||
});
|
||||
const waitTracker = new WaitForAgentTracker(logger);
|
||||
const resolveNewAgentScheduleTarget = (params?: { provider?: AgentProvider; cwd?: string }) => ({
|
||||
type: "new-agent" as const,
|
||||
config: {
|
||||
provider: params?.provider ?? ("claude" as AgentProvider),
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
|
||||
},
|
||||
});
|
||||
const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => {
|
||||
const resolvedProviderModel = resolveProviderAndModel({
|
||||
provider: params?.provider,
|
||||
defaultProvider: "claude",
|
||||
});
|
||||
return {
|
||||
type: "new-agent" as const,
|
||||
config: {
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
|
||||
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const server = new McpServer({
|
||||
name: "paseo-agent-management",
|
||||
@@ -656,7 +664,9 @@ export async function createAgentManagementMcpServer(
|
||||
cron: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
target: z.enum(["self", "new-agent"]).optional(),
|
||||
provider: AgentProviderEnum.optional(),
|
||||
provider: AgentProviderEnum.optional().describe(
|
||||
"Provider, or provider/model (for example: codex or codex/gpt-5.4).",
|
||||
),
|
||||
cwd: z.string().optional(),
|
||||
maxRuns: z.number().int().positive().optional(),
|
||||
expiresIn: z.string().optional(),
|
||||
|
||||
@@ -478,6 +478,28 @@ describe("MCP parity end-to-end", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("create_schedule accepts provider/model syntax", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Parity provider schedule",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
scheduleId = created.id as string;
|
||||
expect(created.target).toMatchObject({
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await deleteScheduleIfPresent(scheduleId);
|
||||
}
|
||||
});
|
||||
|
||||
test("inspect_schedule returns details", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
@@ -568,6 +590,27 @@ describe("MCP parity end-to-end", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("create_schedule on agent MCP accepts provider/model override for new-agent", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(agentScopedClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
scheduleId = created.id as string;
|
||||
expect(created.target).toMatchObject({
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await deleteScheduleIfPresent(scheduleId);
|
||||
}
|
||||
});
|
||||
|
||||
test("create_schedule target self without callerAgentId throws", async () => {
|
||||
await expectToolError(
|
||||
topLevelClient,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
AgentStatusEnum,
|
||||
ProviderSummarySchema,
|
||||
parseDurationString,
|
||||
resolveProviderAndModel,
|
||||
sanitizePermissionRequest,
|
||||
setupFinishNotification,
|
||||
serializeSnapshotWithMetadata,
|
||||
@@ -230,16 +231,36 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
return expandUserPath(trimmedCwd);
|
||||
};
|
||||
|
||||
const resolveNewAgentScheduleTarget = () => {
|
||||
const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => {
|
||||
const callerAgent = resolveCallerAgent();
|
||||
if (callerAgent) {
|
||||
const hasProviderOverride = params?.provider !== undefined;
|
||||
const resolvedProviderModel = hasProviderOverride
|
||||
? resolveProviderAndModel({
|
||||
provider: params?.provider,
|
||||
defaultProvider: callerAgent.provider,
|
||||
})
|
||||
: null;
|
||||
const resolvedProvider = resolvedProviderModel?.provider ?? callerAgent.provider;
|
||||
return {
|
||||
type: "new-agent" as const,
|
||||
config: {
|
||||
provider: callerAgent.provider,
|
||||
cwd: callerAgent.cwd,
|
||||
...(callerAgent.currentModeId ? { modeId: callerAgent.currentModeId } : {}),
|
||||
...(callerAgent.config.model ? { model: callerAgent.config.model } : {}),
|
||||
provider: resolvedProvider,
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : callerAgent.cwd,
|
||||
...(callerAgent.currentModeId
|
||||
? {
|
||||
modeId: mapModeAcrossProviders(
|
||||
callerAgent.currentModeId,
|
||||
callerAgent.provider,
|
||||
resolvedProvider,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(resolvedProviderModel?.model
|
||||
? { model: resolvedProviderModel.model }
|
||||
: !hasProviderOverride && callerAgent.config.model
|
||||
? { model: callerAgent.config.model }
|
||||
: {}),
|
||||
...(callerAgent.config.thinkingOptionId
|
||||
? { thinkingOptionId: callerAgent.config.thinkingOptionId }
|
||||
: {}),
|
||||
@@ -267,10 +288,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
|
||||
return {
|
||||
type: "new-agent" as const,
|
||||
config: {
|
||||
provider: "claude" as AgentProvider,
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
config: (() => {
|
||||
const resolvedProviderModel = resolveProviderAndModel({
|
||||
provider: params?.provider,
|
||||
defaultProvider: "claude",
|
||||
});
|
||||
return {
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
|
||||
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
|
||||
};
|
||||
})(),
|
||||
};
|
||||
};
|
||||
const agentToAgentInputSchema = {
|
||||
@@ -1181,12 +1209,16 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
cron: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
target: z.enum(["self", "new-agent"]).optional(),
|
||||
provider: AgentProviderEnum.optional().describe(
|
||||
"Provider, or provider/model (for example: codex or codex/gpt-5.4).",
|
||||
),
|
||||
cwd: z.string().optional(),
|
||||
maxRuns: z.number().int().positive().optional(),
|
||||
expiresIn: z.string().optional(),
|
||||
},
|
||||
outputSchema: ScheduleSummarySchema.shape,
|
||||
},
|
||||
async ({ prompt, every, cron, name, target, maxRuns, expiresIn }) => {
|
||||
async ({ prompt, every, cron, name, target, provider, cwd, maxRuns, expiresIn }) => {
|
||||
if (!scheduleService) {
|
||||
throw new Error("Schedule service is not configured");
|
||||
}
|
||||
@@ -1202,9 +1234,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
if (!callerAgentId) {
|
||||
throw new Error("target=self requires a caller agent");
|
||||
}
|
||||
if (provider !== undefined || cwd !== undefined) {
|
||||
throw new Error("provider and cwd can only be used with target=new-agent");
|
||||
}
|
||||
return { type: "agent" as const, agentId: callerAgentId };
|
||||
})()
|
||||
: resolveNewAgentScheduleTarget();
|
||||
: resolveNewAgentScheduleTarget({ provider, cwd });
|
||||
|
||||
const schedule = await scheduleService.create({
|
||||
prompt: prompt.trim(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { curateAgentActivity } from "./activity-curator.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { serializeAgentSnapshot } from "../messages.js";
|
||||
import { StoredScheduleSchema } from "../schedule/types.js";
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
|
||||
export const AgentProviderEnum = z.string();
|
||||
|
||||
@@ -48,6 +49,49 @@ export const AgentModelSchema = z.object({
|
||||
// 30 seconds - surface friendly message before SDK tool timeout (~60s)
|
||||
export const AGENT_WAIT_TIMEOUT_MS = 30000;
|
||||
|
||||
export interface ResolvedProviderModel {
|
||||
provider: AgentProvider;
|
||||
model: string | undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderAndModel(params: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
defaultProvider: AgentProvider;
|
||||
}): ResolvedProviderModel {
|
||||
const providerInput = params.provider?.trim() || params.defaultProvider;
|
||||
const modelInput = params.model?.trim();
|
||||
|
||||
if (params.model !== undefined && !modelInput) {
|
||||
throw new Error("model cannot be empty");
|
||||
}
|
||||
|
||||
const slashIndex = providerInput.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return {
|
||||
provider: providerInput as AgentProvider,
|
||||
model: modelInput,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = providerInput.slice(0, slashIndex).trim();
|
||||
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
|
||||
if (!provider || !modelFromProvider) {
|
||||
throw new Error("provider must be <provider> or <provider>/<model>");
|
||||
}
|
||||
|
||||
if (modelInput && modelInput !== modelFromProvider) {
|
||||
throw new Error(
|
||||
`Conflicting model values provided: provider specifies ${modelFromProvider}, but model specifies ${modelInput}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: provider as AgentProvider,
|
||||
model: modelInput ?? modelFromProvider,
|
||||
};
|
||||
}
|
||||
|
||||
export type StartAgentRunOptions = {
|
||||
replaceRunning?: boolean;
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -114,6 +114,7 @@ import type {
|
||||
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import {
|
||||
buildProjectPlacementForCwd,
|
||||
checkoutLiteFromGitSnapshot,
|
||||
detectStaleWorkspaces,
|
||||
deriveProjectKind,
|
||||
deriveProjectRootPath,
|
||||
@@ -334,6 +335,42 @@ type FetchWorkspacesCursor = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
function summarizeFetchWorkspacesEntries(entries: Iterable<FetchWorkspacesResponseEntry>): {
|
||||
count: number;
|
||||
projectIds: string[];
|
||||
statusCounts: Record<string, number>;
|
||||
workspaces: Array<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
name: string;
|
||||
status: FetchWorkspacesResponseEntry["status"];
|
||||
workspaceKind: FetchWorkspacesResponseEntry["workspaceKind"];
|
||||
activityAt: string | null;
|
||||
}>;
|
||||
} {
|
||||
const workspaces = Array.from(entries, (entry) => ({
|
||||
id: entry.id,
|
||||
projectId: entry.projectId,
|
||||
projectDisplayName: entry.projectDisplayName,
|
||||
name: entry.name,
|
||||
status: entry.status,
|
||||
workspaceKind: entry.workspaceKind,
|
||||
activityAt: entry.activityAt,
|
||||
}));
|
||||
const statusCounts = new Map<string, number>();
|
||||
for (const workspace of workspaces) {
|
||||
statusCounts.set(workspace.status, (statusCounts.get(workspace.status) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
count: workspaces.length,
|
||||
projectIds: [...new Set(workspaces.map((workspace) => workspace.projectId))],
|
||||
statusCounts: Object.fromEntries(statusCounts),
|
||||
workspaces,
|
||||
};
|
||||
}
|
||||
|
||||
class SessionRequestError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
@@ -1367,7 +1404,7 @@ export class Session {
|
||||
private async buildProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> {
|
||||
return buildProjectPlacementForCwd({
|
||||
cwd,
|
||||
paseoHome: this.paseoHome,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3172,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),
|
||||
},
|
||||
@@ -4681,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",
|
||||
@@ -5406,26 +5443,17 @@ export class Session {
|
||||
projectRecord?: PersistedProjectRecord | null,
|
||||
): Promise<WorkspaceDescriptorPayload> {
|
||||
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
|
||||
let displayName = workspace.displayName;
|
||||
try {
|
||||
const placement = await this.buildProjectPlacement(workspace.cwd);
|
||||
displayName = deriveWorkspaceDisplayName({
|
||||
cwd: workspace.cwd,
|
||||
checkout: placement.checkout,
|
||||
});
|
||||
} catch {
|
||||
// Fall back to the persisted label if checkout metadata is unavailable.
|
||||
}
|
||||
const snapshot = await this.workspaceGitService.getSnapshot(workspace.cwd);
|
||||
|
||||
let snapshot: WorkspaceGitRuntimeSnapshot | null = null;
|
||||
snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
||||
const checkout = checkoutLiteFromGitSnapshot(workspace.cwd, snapshot.git);
|
||||
const displayName = deriveWorkspaceDisplayName({ cwd: workspace.cwd, checkout });
|
||||
|
||||
return {
|
||||
...base,
|
||||
name: displayName,
|
||||
diffStat: snapshot?.git.diffStat ?? null,
|
||||
gitRuntime: snapshot ? this.buildWorkspaceGitRuntimePayload(snapshot) : undefined,
|
||||
githubRuntime: snapshot ? this.buildWorkspaceGitHubRuntimePayload(snapshot) : undefined,
|
||||
diffStat: snapshot.git.diffStat ?? null,
|
||||
gitRuntime: this.buildWorkspaceGitRuntimePayload(snapshot),
|
||||
githubRuntime: this.buildWorkspaceGitHubRuntimePayload(snapshot),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5738,7 +5766,9 @@ export class Session {
|
||||
const filter = request.filter;
|
||||
const sort = this.normalizeFetchWorkspacesSort(request.sort);
|
||||
let entries = await this.listWorkspaceDescriptors();
|
||||
const listedCount = entries.length;
|
||||
entries = entries.filter((workspace) => this.matchesWorkspaceFilter({ workspace, filter }));
|
||||
const filteredCount = entries.length;
|
||||
entries.sort((left, right) => this.compareFetchWorkspacesEntries(left, right, sort));
|
||||
|
||||
const cursorToken = request.page?.cursor;
|
||||
@@ -5757,6 +5787,21 @@ export class Session {
|
||||
? this.encodeFetchWorkspacesCursor(pagedEntries[pagedEntries.length - 1], sort)
|
||||
: null;
|
||||
|
||||
this.sessionLogger.debug(
|
||||
{
|
||||
requestId: request.requestId,
|
||||
filter: request.filter ?? null,
|
||||
sort,
|
||||
page: request.page ?? null,
|
||||
listedCount,
|
||||
filteredCount,
|
||||
returnedCount: pagedEntries.length,
|
||||
hasMore,
|
||||
nextCursor,
|
||||
},
|
||||
"fetch_workspaces_entries_listed",
|
||||
);
|
||||
|
||||
return {
|
||||
entries: pagedEntries,
|
||||
pageInfo: {
|
||||
@@ -6039,6 +6084,16 @@ export class Session {
|
||||
: null;
|
||||
|
||||
try {
|
||||
this.sessionLogger.debug(
|
||||
{
|
||||
requestId: request.requestId,
|
||||
subscribeRequested: Boolean(request.subscribe),
|
||||
filter: request.filter ?? null,
|
||||
sort: request.sort ?? null,
|
||||
page: request.page ?? null,
|
||||
},
|
||||
"fetch_workspaces_request_received",
|
||||
);
|
||||
if (subscriptionId) {
|
||||
this.workspaceUpdatesSubscription = {
|
||||
subscriptionId,
|
||||
@@ -6050,6 +6105,15 @@ export class Session {
|
||||
}
|
||||
|
||||
const payload = await this.listFetchWorkspacesEntries(request);
|
||||
this.sessionLogger.debug(
|
||||
{
|
||||
requestId: request.requestId,
|
||||
subscriptionId,
|
||||
pageInfo: payload.pageInfo,
|
||||
payload: summarizeFetchWorkspacesEntries(payload.entries),
|
||||
},
|
||||
"fetch_workspaces_response_ready",
|
||||
);
|
||||
const snapshotLatestActivityByWorkspaceId = new Map<string, number>();
|
||||
for (const entry of payload.entries) {
|
||||
const parsedLatestActivity = entry.activityAt
|
||||
@@ -6200,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();
|
||||
@@ -1845,9 +1888,7 @@ describe("workspace aggregation", () => {
|
||||
});
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot);
|
||||
workspaceGitService.getSnapshot = vi.fn(async () => {
|
||||
throw new Error("fetch_workspaces should not trigger per-workspace refreshes");
|
||||
});
|
||||
workspaceGitService.getSnapshot = vi.fn(async () => runtimeSnapshot);
|
||||
workspaceGitService.subscribe = vi.fn(async () => ({
|
||||
initial: runtimeSnapshot,
|
||||
unsubscribe: () => {},
|
||||
@@ -1901,8 +1942,7 @@ describe("workspace aggregation", () => {
|
||||
| { type: "fetch_workspaces_response"; payload: any }
|
||||
| undefined;
|
||||
|
||||
expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(response?.payload.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "/tmp/repo",
|
||||
@@ -1953,9 +1993,7 @@ describe("workspace aggregation", () => {
|
||||
});
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot);
|
||||
workspaceGitService.getSnapshot = vi.fn(async () => {
|
||||
throw new Error("workspace updates should use passive workspace git snapshots");
|
||||
});
|
||||
workspaceGitService.getSnapshot = vi.fn(async () => runtimeSnapshot);
|
||||
|
||||
const session = createSessionForWorkspaceTests({
|
||||
workspaceGitService,
|
||||
@@ -2008,8 +2046,7 @@ describe("workspace aggregation", () => {
|
||||
skipReconcile: true,
|
||||
});
|
||||
|
||||
expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(emitted).toContainEqual({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
|
||||
@@ -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";
|
||||
@@ -137,6 +137,50 @@ export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): Persi
|
||||
return checkout.isPaseoOwnedWorktree ? "worktree" : "local_checkout";
|
||||
}
|
||||
|
||||
export function checkoutLiteFromGitSnapshot(
|
||||
cwd: string,
|
||||
git: {
|
||||
isGit: boolean;
|
||||
currentBranch: string | null;
|
||||
remoteUrl: string | null;
|
||||
repoRoot: string | null;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
mainRepoRoot: string | null;
|
||||
},
|
||||
): ProjectCheckoutLitePayload {
|
||||
if (!git.isGit) {
|
||||
return {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
if (git.isPaseoOwnedWorktree && git.mainRepoRoot) {
|
||||
return {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: git.currentBranch,
|
||||
remoteUrl: git.remoteUrl,
|
||||
worktreeRoot: git.repoRoot ?? cwd,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: git.mainRepoRoot,
|
||||
};
|
||||
}
|
||||
return {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: git.currentBranch,
|
||||
remoteUrl: git.remoteUrl,
|
||||
worktreeRoot: git.repoRoot ?? cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectStaleWorkspaces(
|
||||
input: DetectStaleWorkspacesInput,
|
||||
): Promise<Set<string>> {
|
||||
@@ -154,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/**"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.55-rc.1",
|
||||
"version": "0.1.55-rc.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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"
|
||||
@@ -41,14 +41,20 @@ Read user preferences:
|
||||
cat ~/.paseo/orchestrate.json 2>/dev/null || echo '{}'
|
||||
```
|
||||
|
||||
See [preferences.md](references/preferences.md) for schema, defaults, and mode resolution. Merge with defaults for any missing fields.
|
||||
Merge with defaults for any missing fields. The file maps role categories to `<agent-type>/<model>` strings:
|
||||
|
||||
If the user asks to store a preference at any point, update the file per the preferences reference.
|
||||
- The part before `/` is the `agentType` (e.g., `codex`, `claude`, `opencode`)
|
||||
- The part after `/` is the `model` (e.g., `gpt-5.4`, `opus`)
|
||||
|
||||
Example models:
|
||||
| Category | Roles covered | Default |
|
||||
|----------|--------------|---------|
|
||||
| `impl` | impl, tester, refactorer | `codex/gpt-5.4` |
|
||||
| `ui` | impl agents doing UI/styling work | `claude/opus` |
|
||||
| `research` | researcher | `codex/gpt-5.4` |
|
||||
| `planning` | planner, plan-reviewer | `codex/gpt-5.4` |
|
||||
| `audit` | auditor, qa | `codex/gpt-5.4` |
|
||||
|
||||
- claude/opus
|
||||
- codex/gpt-5.4
|
||||
The file also has a `preferences` array of freeform natural language strings. Read these at startup and weave them into your behavior contextually. When the user says "store my preference: X", update the file.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
@@ -62,6 +68,8 @@ Example models:
|
||||
- **Never trust implementation agents at face value.** Always verify with separate auditor agents.
|
||||
- **Never classify failures as "pre-existing."** If a test is failing, fix it or delete it.
|
||||
- **The plan file on disk is the source of truth.** Re-read `~/.paseo/plans/<task-slug>.md` before every verification and QA phase. It survives compaction.
|
||||
- **Never micromanage agents.** Describe the **problem** (what's broken, how it fails, the error output), not the **solution** (which line to change, what to change it to). Agents are smart — give them context and let them figure out the fix. If you find yourself writing specific line numbers or code snippets in an agent prompt, you're doing it wrong. Say "this test fails with this error" not "change line 47 to use X instead of Y."
|
||||
- **Any task that touches tests MUST run those tests.** This is non-negotiable. If an agent modifies, fixes, or writes a test file, the prompt MUST explicitly say "run the test(s) and confirm they pass." Typecheck alone is never sufficient for test changes. An agent that changes a test without running it has not completed its task.
|
||||
|
||||
## Launching Agents
|
||||
|
||||
@@ -79,6 +87,24 @@ All agents are launched via the Paseo **create agent** tool. The standard patter
|
||||
To send follow-up instructions: Paseo **send agent prompt**.
|
||||
To archive: Paseo **archive agent**.
|
||||
|
||||
### How to Write Agent Prompts
|
||||
|
||||
**Describe the problem, not the solution.** Your prompt should tell the agent:
|
||||
- What's wrong or what needs to be built (the goal)
|
||||
- How it currently fails (error output, test output, user-visible behavior)
|
||||
- The acceptance criteria (what "done" looks like)
|
||||
|
||||
**Do NOT tell the agent:**
|
||||
- Which specific lines to change
|
||||
- What code to write
|
||||
- Which functions to call or which patterns to use
|
||||
|
||||
The agent reads the plan and the code. It will figure out the implementation. If you're writing specific line numbers or code snippets in the prompt, you're micromanaging and it will backfire — the agent takes you literally and skips its own judgment.
|
||||
|
||||
Bad: "In `new-workspace.spec.ts` at line 164, change the tab assertion from `getByText('New Agent')` to `getByTestId(/workspace-tab-agent_/)`"
|
||||
|
||||
Good: "The new-workspace E2E test is failing. The test creates a workspace via empty submit, but then the tab assertion fails because it looks for text 'New Agent' which doesn't match the actual tab label. Here's the error output: [paste error]. Fix the test and run it to confirm it passes."
|
||||
|
||||
---
|
||||
|
||||
## Worktree Mode
|
||||
@@ -97,51 +123,223 @@ If `--worktree` is NOT set, skip this — work in the current directory as norma
|
||||
default mode only default mode only
|
||||
```
|
||||
|
||||
### Phase 1: Triage
|
||||
---
|
||||
|
||||
See [triage.md](references/triage.md).
|
||||
## Phase 1: Triage
|
||||
|
||||
Assess complexity order (1-4) yourself. This is fast — grep relevant files, read the task, determine how many packages/modules are involved.
|
||||
Triage is fast and cheap. You do it yourself — no agents. The goal is to assess complexity order, which determines how many agents to deploy at each phase.
|
||||
|
||||
State the order and why: "Order 3 — touches server session management and the app's git status display."
|
||||
1. Read the task description
|
||||
2. Grep the codebase for relevant files, types, and functions
|
||||
3. Identify how many packages/modules are touched
|
||||
4. Identify whether it's a new feature, refactor, bug fix, or architectural change
|
||||
5. Assign a complexity order
|
||||
|
||||
The order determines how many agents to deploy at each subsequent phase.
|
||||
State the order and briefly why: "Order 3 — touches server session management and the app's git status display across two packages."
|
||||
|
||||
### Phase 2: Grill (default mode only)
|
||||
### Complexity Orders
|
||||
|
||||
See [grill.md](references/grill.md).
|
||||
**Order 1 — Single file, single concern.** A contained change: fix a bug in one function, add a field to one type, update one component.
|
||||
|
||||
| Phase | Agents |
|
||||
|-------|--------|
|
||||
| Research | 1 researcher |
|
||||
| Planning | 0 — orchestrator plans inline |
|
||||
| Implement | 1 impl |
|
||||
| Verify | 1-2 auditors |
|
||||
| Cleanup | 0-1 refactorer |
|
||||
|
||||
**Order 2 — Single module, few files.** A feature or fix within one package that touches 3-8 files.
|
||||
|
||||
| Phase | Agents |
|
||||
|-------|--------|
|
||||
| Research | 2 researchers |
|
||||
| Planning | 1 planner |
|
||||
| Implement | 1 impl per phase |
|
||||
| Verify | 2-3 auditors |
|
||||
| Cleanup | 1 refactorer |
|
||||
|
||||
**Order 3 — Cross-module, multiple packages.** A feature that spans packages.
|
||||
|
||||
| Phase | Agents |
|
||||
|-------|--------|
|
||||
| Research | 3-4 researchers |
|
||||
| Planning | 2 planners + 1 plan-reviewer |
|
||||
| Implement | 1-2 impl agents per phase |
|
||||
| Verify | 3-4 auditors |
|
||||
| Cleanup | 1-2 refactorers |
|
||||
|
||||
**Order 4 — Architectural, system-wide.** A new subsystem, major refactor, or system-wide change.
|
||||
|
||||
| Phase | Agents |
|
||||
|-------|--------|
|
||||
| Research | 5+ researchers |
|
||||
| Planning | 2+ planners + 2 plan-reviewers |
|
||||
| Implement | 2+ impl agents per phase |
|
||||
| Verify | Full auditor suite per phase |
|
||||
| Cleanup | 2+ refactorers |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Grill (default mode only)
|
||||
|
||||
Skipped in `--auto` mode.
|
||||
|
||||
Research the codebase first to avoid asking questions the code can answer. Then question the user depth-first through the decision tree until all branches are resolved.
|
||||
### Protocol: Research First, Grill Second
|
||||
|
||||
Conclude with a summary of resolved decisions. This feeds the research and planning phases.
|
||||
Before asking the user anything:
|
||||
|
||||
### Phase 3: Research
|
||||
1. Read the task description
|
||||
2. Grep relevant files, types, functions
|
||||
3. Read key files to understand the current state
|
||||
4. Form your own understanding of the problem space
|
||||
|
||||
See [research-phase.md](references/research-phase.md).
|
||||
Then ask the user ONLY about things the code cannot answer: intent, scope boundaries, UX preferences, tradeoffs, priorities, acceptance criteria. Never ask a question the codebase could answer.
|
||||
|
||||
Deploy researchers in parallel based on complexity order. Each gets a narrow mandate — one area of the codebase, one external doc source, one reference project.
|
||||
### Questioning Approach
|
||||
|
||||
Wait for all researchers to complete (you'll be notified). Check their activity with Paseo **get agent activity** to read findings. If findings raise new questions (default mode), go back and ask the user.
|
||||
Treat the task as a decision tree. Each design choice branches into sub-decisions, constraints, and consequences.
|
||||
|
||||
- Ask one question at a time
|
||||
- Wait for the answer before moving on
|
||||
- Drill depth-first into each branch until it's resolved or explicitly deferred
|
||||
- For each question, state your recommended answer based on what you've learned from the code — the user can confirm or override
|
||||
- Cycle through question types: feasibility, dependency, edge case, alternative, scope, ordering, failure mode
|
||||
|
||||
Every 3-4 questions, summarize: resolved decisions, open branches, current focus.
|
||||
|
||||
Stop grilling when all branches are resolved, the user signals they're done, or no meaningful questions remain. Conclude with a final summary of all resolved decisions.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Research
|
||||
|
||||
Deploy researchers to gather information before planning. Each researcher gets a narrow mandate — one area of the codebase, one external doc source, one reference project.
|
||||
|
||||
### Launching Researchers
|
||||
|
||||
```
|
||||
title: "researcher-<scope>"
|
||||
agentType: <resolved from providers.research>
|
||||
model: <resolved from providers.research>
|
||||
background: true
|
||||
notifyOnFinish: true
|
||||
initialPrompt: "You are a researcher.
|
||||
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md for the objective.
|
||||
|
||||
<specific research mandate>
|
||||
|
||||
Include in your findings: relevant files, types, interfaces, patterns, gotchas, and anything surprising. Do NOT suggest solutions or edit files."
|
||||
```
|
||||
|
||||
Wait for all researchers to complete (you'll be notified). Use Paseo **get agent activity** to read their findings. Synthesize into a research summary that feeds the planning phase.
|
||||
|
||||
If findings raise new questions (default mode), go back and ask the user.
|
||||
|
||||
Archive all researchers when done.
|
||||
|
||||
### Phase 4: Plan
|
||||
---
|
||||
|
||||
See [planning-phase.md](references/planning-phase.md).
|
||||
## Phase 4: Plan
|
||||
|
||||
Deploy planners informed by research findings. For Order 3+, deploy multiple planners and plan-reviewers. Iterate until the plan is solid.
|
||||
Deploy planners to create an implementation plan informed by research findings.
|
||||
|
||||
Persist the final plan to `~/.paseo/plans/<task-slug>.md`.
|
||||
### Refactor-First Thinking
|
||||
|
||||
### Phase 5: Approve (default mode only)
|
||||
Every planner prompt must emphasize this: the default agent instinct is to bolt new code on top of existing code. Resist this.
|
||||
|
||||
The right approach:
|
||||
1. Study the existing code — understand why it's shaped the way it is
|
||||
2. Design the target shape — what would the code look like if this feature had always existed?
|
||||
3. Identify the refactoring gap — what needs to change so the new feature slots in cleanly?
|
||||
4. Plan refactor phases before feature phases
|
||||
|
||||
If the plan has a phase called "wire up" or "connect" or "integrate," a refactor phase could probably eliminate the need for it.
|
||||
|
||||
### Launching Planners
|
||||
|
||||
```
|
||||
title: "planner-<scope>"
|
||||
agentType: <resolved from providers.planning>
|
||||
model: <resolved from providers.planning>
|
||||
background: true
|
||||
notifyOnFinish: true
|
||||
initialPrompt: "You are a planner.
|
||||
|
||||
Read the research findings provided below and the objective.
|
||||
|
||||
<paste synthesized research findings and objective>
|
||||
|
||||
Draft a phased implementation plan. Think refactor-first: before planning the feature, identify what existing code needs to be reshaped so the feature slots in naturally.
|
||||
|
||||
For each phase, specify:
|
||||
- What changes and why
|
||||
- Files involved
|
||||
- Types and interfaces affected
|
||||
- Tests to write (failing test first — TDD)
|
||||
- Acceptance criteria for the phase
|
||||
|
||||
Write the plan to ~/.paseo/plans/<task-slug>.md"
|
||||
```
|
||||
|
||||
### Launching Plan-Reviewers
|
||||
|
||||
```
|
||||
title: "plan-reviewer-<scope>"
|
||||
agentType: <resolved from providers.planning>
|
||||
model: <resolved from providers.planning>
|
||||
background: true
|
||||
notifyOnFinish: true
|
||||
initialPrompt: "You are a plan-reviewer.
|
||||
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md.
|
||||
|
||||
Challenge the plan:
|
||||
- Is it bolting new code on top, or reshaping existing code first?
|
||||
- Are there coordination/glue/bridge layers that a better refactor would eliminate?
|
||||
- What edge cases are missing? What will break?
|
||||
- What's over-engineered? What's under-specified?
|
||||
- Is the phase ordering correct? Are there hidden dependencies?"
|
||||
```
|
||||
|
||||
For Order 3+, deploy multiple planners (one per area) + plan-reviewers. Iterate until the plan-reviewer's only feedback is minor.
|
||||
|
||||
### Plan Structure
|
||||
|
||||
The final plan must follow:
|
||||
|
||||
```
|
||||
# <Task Title>
|
||||
|
||||
## Objective
|
||||
<one-paragraph summary>
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] <criterion 1>
|
||||
- [ ] <criterion 2>
|
||||
|
||||
## Plan
|
||||
### Phase 1: <name>
|
||||
<description, files, types, tests, acceptance criteria>
|
||||
|
||||
### Phase 2: <name>
|
||||
...
|
||||
```
|
||||
|
||||
Persist to `~/.paseo/plans/<task-slug>.md`. Archive all planners and plan-reviewers.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Approve (default mode only)
|
||||
|
||||
Skipped in `--auto` mode.
|
||||
|
||||
Present the plan to the user. Wait for explicit confirmation before proceeding.
|
||||
|
||||
### Phase 6: Set Up
|
||||
---
|
||||
|
||||
## Phase 6: Set Up
|
||||
|
||||
Persist the plan to disk and set up the heartbeat:
|
||||
|
||||
@@ -152,7 +350,7 @@ Use the Paseo **create schedule** tool with:
|
||||
- `expiresIn`: `"4h"`
|
||||
- `prompt`: (see heartbeat prompt below)
|
||||
|
||||
#### Heartbeat prompt
|
||||
### Heartbeat prompt
|
||||
|
||||
```
|
||||
HEARTBEAT — periodic self-check.
|
||||
@@ -191,9 +389,9 @@ Do the following steps in order:
|
||||
- Do NOT delete this schedule yet — if the user requests a PR, the heartbeat transitions to CI monitoring mode. Only delete it once CI is fully green (or if the user declines a PR).
|
||||
```
|
||||
|
||||
### Phase 7: Implement
|
||||
---
|
||||
|
||||
See [impl-phase.md](references/impl-phase.md).
|
||||
## Phase 7: Implement
|
||||
|
||||
Execute phases from the plan sequentially. For each phase:
|
||||
1. Launch impl agent(s) with `background: true, notifyOnFinish: true`
|
||||
@@ -205,52 +403,401 @@ Execute phases from the plan sequentially. For each phase:
|
||||
|
||||
UI passes use `providers.ui` from preferences. All other impl work uses `providers.impl`.
|
||||
|
||||
### Phase 8: Verify
|
||||
### TDD — Not Optional
|
||||
|
||||
See [verification.md](references/verification.md).
|
||||
Every impl agent works TDD:
|
||||
1. Write a failing test that defines the expected behavior
|
||||
2. Make it pass
|
||||
3. Refactor if needed
|
||||
4. All tests green — not just new ones, the full relevant suite
|
||||
|
||||
After each implementation phase, deploy auditors in parallel. Match auditors to the type of work (refactor, feature, UI). Each auditor checks exactly one thing.
|
||||
If an impl agent finds a broken test, it fixes it. No "pre-existing failures." No exceptions.
|
||||
|
||||
If auditors find issues, direct the impl agent to fix or launch a new one. Re-verify after fixes.
|
||||
### Impl Agent Prompt Template
|
||||
|
||||
Archive all auditors when done.
|
||||
```
|
||||
title: "impl-<scope>-<phase>"
|
||||
agentType: <resolved from providers.impl>
|
||||
model: <resolved from providers.impl>
|
||||
cwd: <worktree-path if worktree mode, omit otherwise>
|
||||
background: true
|
||||
notifyOnFinish: true
|
||||
initialPrompt: "You are an implementation engineer. [Load the e2e-playwright skill if frontend/E2E work.]
|
||||
|
||||
### Phase 9: Cleanup
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md to understand the objective and your specific phase.
|
||||
|
||||
See [cleanup.md](references/cleanup.md).
|
||||
Do not bolt new code on top of existing code. If the existing code isn't shaped to accommodate your work, reshape it first. The goal is code that looks like this feature always existed.
|
||||
|
||||
After all phases are implemented and verified, deploy refactorers for a final sweep: DRY, dead code, naming. Run a regression auditor after cleanup to confirm nothing broke.
|
||||
Work TDD: write a failing test first, then make it pass. All tests must be green when done — not just your new ones, the full relevant suite. If you find a broken test, fix it.
|
||||
|
||||
Archive all refactorers when done.
|
||||
<describe the problem and acceptance criteria — NOT the solution>
|
||||
|
||||
### Phase 10: Final QA
|
||||
When done: run typecheck AND run any tests you modified or that cover your changes. Both must pass. Do NOT commit."
|
||||
```
|
||||
|
||||
See [final-qa.md](references/final-qa.md).
|
||||
### UI Agent Prompt Template
|
||||
|
||||
Re-read the plan from disk. Run typecheck and tests yourself. Deploy final review and quality auditors. Fix any issues found. Do not deliver until everything passes.
|
||||
```
|
||||
title: "impl-<scope>-ui"
|
||||
agentType: <resolved from providers.ui>
|
||||
model: <resolved from providers.ui>
|
||||
cwd: <worktree-path if worktree mode, omit otherwise>
|
||||
background: true
|
||||
notifyOnFinish: true
|
||||
initialPrompt: "You are a UI engineer. [Load the e2e-playwright skill.]
|
||||
|
||||
Archive all QA agents when done.
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
|
||||
|
||||
### Phase 11: Deliver
|
||||
The functionality is implemented. Your job is the styling pass:
|
||||
- Study existing components and styles in nearby screens
|
||||
- Follow existing conventions exactly — no new patterns
|
||||
- Keep design minimal and consistent with the rest of the app
|
||||
- Think carefully about spacing, alignment, and visual hierarchy
|
||||
|
||||
<describe the specific UI work>
|
||||
|
||||
Run typecheck when done. Do NOT commit."
|
||||
```
|
||||
|
||||
### Handling Blockers
|
||||
|
||||
If an impl agent reports a blocker:
|
||||
- Do NOT ask the user (in either mode)
|
||||
- Spin up a researcher to investigate
|
||||
- Spin up an impl agent to fix it
|
||||
- The scope of work is unlimited — touching other files, packages, or systems is fine
|
||||
|
||||
Archive every impl agent as soon as its phase is verified.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Verify
|
||||
|
||||
After every implementation phase, deploy auditors to verify the work. Auditors are read-only — they check, they don't fix. Each auditor has a single specialization.
|
||||
|
||||
### Which Auditors to Deploy
|
||||
|
||||
| Phase type | Auditors |
|
||||
|-----------|----------|
|
||||
| Refactor | `parity`, `regression`, `types` |
|
||||
| Feature (backend) | `overeng`, `tests`, `regression`, `types` |
|
||||
| Feature (frontend) | `overeng`, `tests`, `types`, `browser` (if applicable) |
|
||||
| UI pass | `overeng`, `browser` (if applicable) |
|
||||
| Test-only | `regression` |
|
||||
|
||||
Deploy all relevant auditors in parallel — they're read-only so they don't conflict.
|
||||
|
||||
### Auditor Prompts
|
||||
|
||||
All auditors are launched via the Paseo **create agent** tool with `background: true` and `notifyOnFinish: true`.
|
||||
|
||||
#### overeng (anti-over-engineering)
|
||||
|
||||
```
|
||||
title: "auditor-<scope>-overeng"
|
||||
initialPrompt: "You are an anti-over-engineering auditor.
|
||||
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
|
||||
|
||||
Check the recent changes (use git diff) for:
|
||||
- Unnecessary abstractions, helpers, or utility functions
|
||||
- Defensive code for scenarios that can't happen
|
||||
- Event emitters, observers, or pub/sub where a direct call would do
|
||||
- Coordination/glue/bridge layers between old and new code
|
||||
- Flag parameters or special-case branches
|
||||
- Weird or overly literal naming
|
||||
|
||||
For each issue: file, line, what's wrong, what it should be instead.
|
||||
|
||||
Do NOT edit files."
|
||||
```
|
||||
|
||||
#### dry (DRY violations)
|
||||
|
||||
```
|
||||
title: "auditor-<scope>-dry"
|
||||
initialPrompt: "You are a DRY auditor.
|
||||
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
|
||||
|
||||
Check the recent changes (use git diff) for:
|
||||
- Duplicated logic across files
|
||||
- Copy-pasted code with minor variations
|
||||
- Types that repeat fields from other types instead of deriving
|
||||
- Constants or strings repeated instead of extracted
|
||||
|
||||
For each issue: the duplicated code locations and a brief note on how to consolidate.
|
||||
|
||||
Do NOT edit files."
|
||||
```
|
||||
|
||||
#### tests (test coverage)
|
||||
|
||||
```
|
||||
title: "auditor-<scope>-tests"
|
||||
initialPrompt: "You are a test coverage auditor. [Load the e2e-playwright skill if E2E tests are in scope.]
|
||||
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
|
||||
|
||||
Check:
|
||||
- Does every new behavior have a test?
|
||||
- Do tests verify behavior, not implementation details?
|
||||
- Are tests asserting real outcomes or just mocks?
|
||||
- Are there edge cases without test coverage?
|
||||
- Do E2E tests follow DSL-style helpers and ARIA role selectors (if applicable)?
|
||||
|
||||
Run the full relevant test suite and report output.
|
||||
|
||||
Do NOT edit files."
|
||||
```
|
||||
|
||||
#### regression
|
||||
|
||||
```
|
||||
title: "auditor-<scope>-regression"
|
||||
initialPrompt: "You are a regression auditor.
|
||||
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
|
||||
|
||||
Run the full test suite. Report:
|
||||
- Total tests, passed, failed, skipped
|
||||
- Any failures with full error output
|
||||
- Whether failures are in new tests or existing tests
|
||||
|
||||
If ANY test fails, this phase is not done.
|
||||
|
||||
Do NOT edit files."
|
||||
```
|
||||
|
||||
#### types
|
||||
|
||||
```
|
||||
title: "auditor-<scope>-types"
|
||||
initialPrompt: "You are a type auditor.
|
||||
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
|
||||
|
||||
Run typecheck (npm run typecheck). Report:
|
||||
- Pass/fail
|
||||
- All type errors with file, line, and error message
|
||||
- Any use of 'any', type assertions, or @ts-ignore in the changes
|
||||
|
||||
Do NOT edit files."
|
||||
```
|
||||
|
||||
#### browser
|
||||
|
||||
```
|
||||
title: "auditor-<scope>-browser"
|
||||
initialPrompt: "You are a browser QA auditor. Load the e2e-playwright skill.
|
||||
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
|
||||
|
||||
Test the affected user flows in a browser:
|
||||
- Navigate to the relevant screens
|
||||
- Exercise the new/changed functionality
|
||||
- Check for visual regressions, broken layouts, missing states
|
||||
- Take screenshots of results
|
||||
|
||||
Report what works and what doesn't with evidence. Do NOT edit files."
|
||||
```
|
||||
|
||||
#### parity (for refactors)
|
||||
|
||||
```
|
||||
title: "auditor-<scope>-parity"
|
||||
initialPrompt: "You are a parity auditor.
|
||||
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
|
||||
|
||||
This was a refactoring phase — behavior must be identical before and after. Check:
|
||||
- All existing tests still pass (run them)
|
||||
- No behavioral changes were introduced
|
||||
- Public APIs and interfaces are unchanged
|
||||
- No removed functionality unless explicitly planned
|
||||
|
||||
Do NOT edit files."
|
||||
```
|
||||
|
||||
### Interpreting Findings
|
||||
|
||||
If any auditor reports issues:
|
||||
1. Check the auditor's activity with Paseo **get agent activity** for details
|
||||
2. Direct the impl agent to fix them via Paseo **send agent prompt**, or launch a new impl agent if the old one is stale
|
||||
3. Re-deploy the same auditor after fixes
|
||||
4. Do not proceed to the next phase until all auditors pass
|
||||
|
||||
Archive every auditor as soon as its report is reviewed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Cleanup
|
||||
|
||||
After all implementation phases are verified, deploy refactorer agents for targeted cleanup. Each refactorer has a single specialization.
|
||||
|
||||
### Refactorer Prompts
|
||||
|
||||
All refactorers launched via the Paseo **create agent** tool with `background: true` and `notifyOnFinish: true`.
|
||||
|
||||
#### dry (consolidate duplication)
|
||||
|
||||
```
|
||||
title: "refactorer-<scope>-dry"
|
||||
initialPrompt: "You are a cleanup engineer specializing in DRY.
|
||||
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
|
||||
|
||||
Look at the full diff of changes in this task (use git diff). Consolidate:
|
||||
- Duplicated logic — extract shared functions or reuse existing ones
|
||||
- Repeated types — derive with Pick, Omit, or extend instead of redefining
|
||||
- Repeated constants or strings — extract to a single source
|
||||
|
||||
Only fix genuine duplication. Three similar lines is fine — don't create premature abstractions. Run typecheck and any tests you touch when done.
|
||||
|
||||
Do NOT commit."
|
||||
```
|
||||
|
||||
#### dead-code (remove unused code)
|
||||
|
||||
```
|
||||
title: "refactorer-<scope>-dead-code"
|
||||
initialPrompt: "You are a cleanup engineer specializing in dead code.
|
||||
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
|
||||
|
||||
Look at the full diff of changes (use git diff). Remove:
|
||||
- Unused imports
|
||||
- Unused variables, functions, or types introduced by this task
|
||||
- Commented-out code
|
||||
- Backwards-compatibility shims or renamed _vars that serve no purpose
|
||||
|
||||
Do NOT remove code that predates this task unless it was made dead by this task's changes. Run typecheck and any tests you touch when done.
|
||||
|
||||
Do NOT commit."
|
||||
```
|
||||
|
||||
#### naming (fix unclear names)
|
||||
|
||||
```
|
||||
title: "refactorer-<scope>-naming"
|
||||
initialPrompt: "You are a cleanup engineer specializing in naming.
|
||||
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
|
||||
|
||||
Look at all new names introduced by this task (functions, variables, types, files). Fix:
|
||||
- Overly literal or verbose names
|
||||
- Inconsistent naming relative to surrounding code conventions
|
||||
- Unclear abbreviations
|
||||
- Names that describe implementation instead of intent
|
||||
|
||||
Only rename things introduced or modified by this task. Run typecheck and any tests you touch when done.
|
||||
|
||||
Do NOT commit."
|
||||
```
|
||||
|
||||
Deploy refactorers in parallel. After cleanup, run a regression auditor to confirm nothing broke.
|
||||
|
||||
Archive every refactorer as soon as verified.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Final QA
|
||||
|
||||
After all phases are implemented, verified, and cleaned up, run one final pass.
|
||||
|
||||
### 1. Re-read the plan
|
||||
|
||||
```bash
|
||||
cat ~/.paseo/plans/<task-slug>.md
|
||||
```
|
||||
|
||||
### 2. Run typecheck yourself
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
Must pass. No exceptions.
|
||||
|
||||
### 3. Run the full test suite yourself
|
||||
|
||||
Run all relevant tests. Must be 100% green. No skipped tests, no "known failures."
|
||||
|
||||
### 4. Final review agent
|
||||
|
||||
```
|
||||
title: "qa-<scope>-review"
|
||||
initialPrompt: "You are a final reviewer.
|
||||
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md for the objective and acceptance criteria.
|
||||
|
||||
Review the entire git diff for this task. For each acceptance criterion, report:
|
||||
- YES — met, with evidence (file, line, test that proves it)
|
||||
- NO — not met, with explanation of what's missing
|
||||
|
||||
Do NOT edit files."
|
||||
```
|
||||
|
||||
### 5. Final anti-over-engineering agent
|
||||
|
||||
```
|
||||
title: "qa-<scope>-overeng"
|
||||
initialPrompt: "You are a final quality auditor.
|
||||
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
|
||||
|
||||
Audit the entire git diff for this task:
|
||||
- Unnecessary abstractions or helpers
|
||||
- Code that's clever instead of clear
|
||||
- Missing error handling at system boundaries
|
||||
- Excessive error handling for internal code
|
||||
- Any code that doesn't serve the acceptance criteria
|
||||
|
||||
Do NOT edit files."
|
||||
```
|
||||
|
||||
### 6. Browser QA (if applicable)
|
||||
|
||||
If the task involves UI changes:
|
||||
|
||||
```
|
||||
title: "qa-<scope>-browser"
|
||||
initialPrompt: "You are a QA engineer. Load the e2e-playwright skill.
|
||||
|
||||
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
|
||||
|
||||
Test all affected user flows end-to-end in the browser. For each flow:
|
||||
- What you tested
|
||||
- What you expected
|
||||
- What actually happened
|
||||
- Screenshot evidence
|
||||
|
||||
Do NOT edit files."
|
||||
```
|
||||
|
||||
If any final QA agent reports issues, launch an impl or refactorer to fix, then re-run the specific check. Do not deliver with any failing checks.
|
||||
|
||||
Archive all QA agents once reports are reviewed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: Deliver
|
||||
|
||||
1. Archive any remaining implementation/QA agents
|
||||
2. **If in worktree mode:**
|
||||
- Report the worktree path and branch name
|
||||
- Ask: "The work is in worktree `<worktree-path>` on branch `orchestrate/<task-slug>`. Should I merge it into your current branch, create a PR, or leave the worktree for you to review?"
|
||||
- Do NOT remove the worktree automatically — the user decides what to do with it
|
||||
- Do NOT remove the worktree automatically
|
||||
3. **If NOT in worktree mode:**
|
||||
- Report to the user:
|
||||
- What was done (high-level)
|
||||
- What files changed
|
||||
- Verification results (typecheck, tests, auditor verdicts)
|
||||
- Ask: "Should I commit this? Create a PR? Or leave it uncommitted for you to review?"
|
||||
- Report: what was done (high-level), what files changed, verification results
|
||||
- Ask: "Should I commit this? Create a PR? Or leave it uncommitted for you to review?"
|
||||
|
||||
Wait for the user's instruction.
|
||||
|
||||
**When the user asks for a PR, the job is NOT done when the PR is created.** The objective is: PR created AND all CI checks passing. After creating the PR:
|
||||
|
||||
1. Keep the heartbeat schedule running — do NOT delete it yet.
|
||||
2. Update the heartbeat prompt to include CI monitoring (see below).
|
||||
2. Update the heartbeat prompt to CI monitoring mode (below).
|
||||
3. Monitor CI status via `gh pr checks <pr-number> --watch` or `gh pr checks <pr-number>`.
|
||||
4. If any check fails:
|
||||
- Read the failure logs (`gh run view <run-id> --log-failed`).
|
||||
@@ -259,11 +806,9 @@ Wait for the user's instruction.
|
||||
- Continue monitoring.
|
||||
5. Only when ALL checks are green:
|
||||
- Delete the heartbeat schedule.
|
||||
- Report to the user with the full PR URL (e.g. `https://github.com/owner/repo/pull/N`), not just a number.
|
||||
- Report to the user with the full PR URL.
|
||||
|
||||
#### Post-PR heartbeat prompt
|
||||
|
||||
After creating the PR, update the heartbeat schedule prompt to:
|
||||
### Post-PR heartbeat prompt
|
||||
|
||||
```
|
||||
HEARTBEAT — CI monitoring for PR #<pr-number>.
|
||||
@@ -290,22 +835,25 @@ Do the following steps in order:
|
||||
|
||||
---
|
||||
|
||||
## Role Reference
|
||||
## Roles Reference
|
||||
|
||||
See [roles.md](references/roles.md) for the complete role definitions, naming convention, and what each role can and cannot do.
|
||||
Every agent has exactly one role. The role determines what the agent does, whether it can edit files, and how it's named.
|
||||
|
||||
| Role | Job | Edits? |
|
||||
|------|-----|--------|
|
||||
| `researcher` | Gathers info: codebase, docs, web, scripts | No |
|
||||
| `planner` | Creates implementation plan from research | No |
|
||||
| `plan-reviewer` | Adversarially challenges a plan | No |
|
||||
| `impl` | Writes code, TDD | Yes |
|
||||
| `tester` | Writes/runs tests | Yes |
|
||||
| `auditor` | Read-only verification (sub-specializations) | No |
|
||||
| `refactorer` | Targeted cleanup (sub-specializations) | Yes |
|
||||
| `qa` | End-to-end QA, browser testing | No |
|
||||
**Naming:** `<role>-<scope>[-<specialization>]` in kebab-case.
|
||||
|
||||
Naming: `<role>-<scope>[-<specialization>]`
|
||||
| Role | Job | Edits? | Prompt emphasis |
|
||||
|------|-----|--------|----------------|
|
||||
| `researcher` | Gathers info: codebase, docs, web, scripts | No | "Report what you find. Do not suggest solutions. Do not edit files." |
|
||||
| `planner` | Creates implementation plan from research | No | "Think refactor-first. Design the target shape, not the steps." |
|
||||
| `plan-reviewer` | Adversarially challenges a plan | No | "Challenge the plan. Find what's wrong, missing, or over-engineered." |
|
||||
| `impl` | Writes code, works TDD | Yes | "Work TDD. Reshape existing code. Run typecheck AND run any tests you modified. Both must pass. Do NOT commit." |
|
||||
| `tester` | Writes/fixes tests | Yes | "Verify behavior, not implementation. Run every test you modified and confirm it passes. A test change without running the test is not done." |
|
||||
| `auditor` | Read-only verification | No | "Check [specialization]. Report YES/NO with evidence. Do NOT edit files." |
|
||||
| `refactorer` | Targeted cleanup | Yes | "Fix [specialization] only. Run typecheck and any tests you touch. Do NOT commit." |
|
||||
| `qa` | End-to-end QA, browser testing | No | "Test the actual user experience. Report with evidence." |
|
||||
|
||||
Auditor specializations: `overeng`, `dry`, `tests`, `regression`, `types`, `browser`, `parity`
|
||||
Refactorer specializations: `dry`, `dead-code`, `naming`
|
||||
|
||||
---
|
||||
|
||||
@@ -319,3 +867,4 @@ Naming: `<role>-<scope>[-<specialization>]`
|
||||
- **The plan file is the shared context.** Every agent reads the plan from disk.
|
||||
- **Archive aggressively.** Done agents clutter the UI.
|
||||
- **Trust but verify.** Always verify with separate agents. Never take an impl agent's word for it.
|
||||
- **Describe problems, not solutions.** Tell agents what's wrong, not what to type.
|
||||
|
||||
@@ -26,6 +26,26 @@ After each phase:
|
||||
|
||||
Use the Paseo **create agent** tool with `background: true` and `notifyOnFinish: true`. **If in worktree mode, always set `cwd` to the worktree path.**
|
||||
|
||||
### How to describe the work
|
||||
|
||||
**Describe the problem, not the solution.** Your prompt should tell the agent:
|
||||
- What's wrong or what needs to be built (the goal)
|
||||
- How it currently fails (error output, test output, user-visible behavior)
|
||||
- The acceptance criteria (what "done" looks like)
|
||||
|
||||
**Do NOT tell the agent:**
|
||||
- Which specific lines to change
|
||||
- What code to write
|
||||
- Which functions to call or which patterns to use
|
||||
|
||||
The agent reads the plan and the code. It will figure out the implementation. If you're writing specific line numbers or code snippets in the prompt, you're micromanaging and it will backfire — the agent takes you literally and skips its own judgment.
|
||||
|
||||
Bad: "In `new-workspace.spec.ts` at line 164, change the tab assertion from `getByText('New Agent')` to `getByTestId(/workspace-tab-agent_/)`"
|
||||
|
||||
Good: "The new-workspace E2E test is failing. The test creates a workspace via empty submit, but then the tab assertion fails because it looks for text 'New Agent' which doesn't match the actual tab label. Here's the error output: [paste error]. Fix the test and run it to confirm it passes."
|
||||
|
||||
### Prompt template
|
||||
|
||||
```
|
||||
title: "impl-<scope>-<phase>"
|
||||
agentType: <resolved from providers.impl>
|
||||
@@ -41,9 +61,9 @@ Do not bolt new code on top of existing code. If the existing code isn't shaped
|
||||
|
||||
Work TDD: write a failing test first, then make it pass. All tests must be green when done — not just your new ones, the full relevant suite. If you find a broken test, fix it.
|
||||
|
||||
<describe the specific phase work and acceptance criteria>
|
||||
<describe the problem and acceptance criteria — NOT the solution>
|
||||
|
||||
Run typecheck and tests when done. Do NOT commit."
|
||||
When done: run typecheck AND run any tests you modified or that cover your changes. Both must pass. Do NOT commit."
|
||||
```
|
||||
|
||||
## UI Passes
|
||||
|
||||
@@ -37,17 +37,17 @@ Adversarially challenges a plan. Looks for: bolted-on code vs natural fit, missi
|
||||
|
||||
### impl
|
||||
|
||||
Writes code. Works TDD: failing test first, then make it pass. Runs typecheck and tests when done.
|
||||
Writes code. Works TDD: failing test first, then make it pass. Runs typecheck and all modified/related tests when done.
|
||||
|
||||
- **Edits files:** Yes
|
||||
- **Prompt emphasis:** "Work TDD. Do not bolt new code on top — reshape existing code so the feature slots in naturally. Run typecheck and tests when done. Do NOT commit."
|
||||
- **Prompt emphasis:** "Work TDD. Do not bolt new code on top — reshape existing code so the feature slots in naturally. Run typecheck AND run any tests you modified or that cover your changes when done. Both must pass. Do NOT commit."
|
||||
|
||||
### tester
|
||||
|
||||
Writes or fixes tests specifically. Used when test work is substantial enough to warrant a dedicated agent separate from impl.
|
||||
|
||||
- **Edits files:** Yes
|
||||
- **Prompt emphasis:** "Write tests that verify behavior, not implementation details. Run the full relevant suite when done."
|
||||
- **Prompt emphasis:** "Write tests that verify behavior, not implementation details. Run every test you modified and confirm it passes. A test change without running the test is not done."
|
||||
|
||||
### auditor
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
exclude: ["**/.claude/**"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user