mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat: stream worktree setup progress and terminal subscriptions
This commit is contained in:
@@ -11,7 +11,11 @@ interface BackHeaderProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
|
||||
export function BackHeader({
|
||||
title,
|
||||
rightContent,
|
||||
onBack,
|
||||
}: BackHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Menu, PanelLeft } from "lucide-react-native";
|
||||
import { PanelLeft } from "lucide-react-native";
|
||||
import { ScreenHeader } from "./screen-header";
|
||||
import { HeaderToggleButton } from "./header-toggle-button";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
@@ -12,7 +12,39 @@ interface MenuHeaderProps {
|
||||
rightContent?: ReactNode;
|
||||
}
|
||||
|
||||
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||
interface SidebarMenuToggleProps {
|
||||
style?: StyleProp<ViewStyle>;
|
||||
tooltipSide?: "left" | "right" | "top" | "bottom";
|
||||
testID?: string;
|
||||
nativeID?: string;
|
||||
}
|
||||
|
||||
const MOBILE_MENU_LINE_WIDTH = 16;
|
||||
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
|
||||
const MOBILE_MENU_LINE_HEIGHT = 2;
|
||||
|
||||
function MobileMenuIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<View style={styles.mobileMenuIcon} pointerEvents="none">
|
||||
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
|
||||
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
|
||||
<View
|
||||
style={[
|
||||
styles.mobileMenuLine,
|
||||
styles.mobileMenuLineShort,
|
||||
{ backgroundColor: color },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarMenuToggle({
|
||||
style,
|
||||
tooltipSide = "right",
|
||||
testID = "menu-button",
|
||||
nativeID = "menu-button",
|
||||
}: SidebarMenuToggleProps = {}) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
@@ -22,29 +54,42 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
||||
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const MenuIcon = isMobile ? Menu : PanelLeft;
|
||||
const menuIconColor = !isMobile && isOpen
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted;
|
||||
|
||||
return (
|
||||
<HeaderToggleButton
|
||||
onPress={toggleAgentList}
|
||||
tooltipLabel="Toggle sidebar"
|
||||
tooltipKeys={toggleShortcutKeys}
|
||||
tooltipSide={tooltipSide}
|
||||
testID={testID}
|
||||
nativeID={nativeID}
|
||||
style={style}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
|
||||
accessibilityState={{ expanded: isOpen }}
|
||||
>
|
||||
{isMobile ? (
|
||||
<MobileMenuIcon color={menuIconColor} />
|
||||
) : (
|
||||
<PanelLeft size={16} color={menuIconColor} />
|
||||
)}
|
||||
</HeaderToggleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuHeader({
|
||||
title,
|
||||
rightContent,
|
||||
}: MenuHeaderProps) {
|
||||
return (
|
||||
<ScreenHeader
|
||||
left={
|
||||
<>
|
||||
<HeaderToggleButton
|
||||
onPress={toggleAgentList}
|
||||
tooltipLabel="Toggle sidebar"
|
||||
tooltipKeys={toggleShortcutKeys}
|
||||
tooltipSide="right"
|
||||
testID="menu-button"
|
||||
nativeID="menu-button"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
|
||||
accessibilityState={{ expanded: isOpen }}
|
||||
>
|
||||
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
|
||||
</HeaderToggleButton>
|
||||
<SidebarMenuToggle />
|
||||
{title && (
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
@@ -71,4 +116,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
mobileMenuIcon: {
|
||||
width: MOBILE_MENU_LINE_WIDTH,
|
||||
height: 12,
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
mobileMenuLine: {
|
||||
width: MOBILE_MENU_LINE_WIDTH,
|
||||
height: MOBILE_MENU_LINE_HEIGHT,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
mobileMenuLineShort: {
|
||||
width: MOBILE_MENU_LINE_SHORT_WIDTH,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -16,7 +16,12 @@ interface ScreenHeaderProps {
|
||||
* Shared frame for the home/back headers so we only maintain padding, border,
|
||||
* and safe-area logic in one place.
|
||||
*/
|
||||
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
|
||||
export function ScreenHeader({
|
||||
left,
|
||||
right,
|
||||
leftStyle,
|
||||
rightStyle,
|
||||
}: ScreenHeaderProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||
|
||||
@@ -1089,8 +1089,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.78,
|
||||
fontSize: theme.fontSize.xs,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
flexShrink: 0,
|
||||
},
|
||||
agentMetaBranchBadge: {
|
||||
borderRadius: theme.borderRadius.full,
|
||||
@@ -1098,7 +1097,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: 1,
|
||||
flexShrink: 0,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
agentMetaBranchBadgeSelected: {
|
||||
borderColor: theme.colors.borderAccent,
|
||||
@@ -1107,6 +1107,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.76,
|
||||
fontSize: theme.fontSize.xs,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
selectionBar: {
|
||||
position: "absolute",
|
||||
|
||||
@@ -290,6 +290,35 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
});
|
||||
}, [client, cwd, isConnected, queryClient, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !cwd.startsWith("/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = client.on("terminals_changed", (message) => {
|
||||
if (message.type !== "terminals_changed") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.cwd !== cwd) {
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
});
|
||||
void queryClient.refetchQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
type: "active",
|
||||
});
|
||||
});
|
||||
|
||||
client.subscribeTerminals({ cwd });
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
client.unsubscribeTerminals({ cwd });
|
||||
};
|
||||
}, [client, cwd, isConnected, queryClient, serverId]);
|
||||
|
||||
const createTerminalMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client) {
|
||||
|
||||
@@ -89,6 +89,43 @@ export function ToolCallDetailsContent({
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
} else if (detail?.type === "worktree_setup") {
|
||||
const setupLog = detail.log.replace(/^\n+/, "");
|
||||
const hasLog = setupLog.length > 0;
|
||||
sections.push(
|
||||
<View
|
||||
key="worktree-setup"
|
||||
style={[styles.section, shouldFill && styles.fillHeight]}
|
||||
>
|
||||
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
|
||||
<ScrollView
|
||||
style={[
|
||||
styles.codeVerticalScroll,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
]}
|
||||
contentContainerStyle={styles.codeVerticalContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{hasLog
|
||||
? setupLog
|
||||
: `Preparing worktree ${detail.branchName} at ${detail.worktreePath}`}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
} else if (detail?.type === "edit") {
|
||||
sections.push(
|
||||
<View
|
||||
|
||||
@@ -14,8 +14,8 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { Folder, GitBranch, Menu, PanelLeft } from "lucide-react-native";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { Folder, GitBranch } from "lucide-react-native";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { AgentConfigRow, FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
|
||||
@@ -31,7 +31,6 @@ import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
@@ -125,9 +124,6 @@ export function DraftAgentScreen({
|
||||
const insets = useSafeAreaInsets();
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
const params = useLocalSearchParams<DraftAgentParams>();
|
||||
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
@@ -226,11 +222,6 @@ export function DraftAgentScreen({
|
||||
: undefined;
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isSidebarOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const SidebarIcon = isMobile ? Menu : PanelLeft;
|
||||
const sidebarIconColor = !isMobile && isSidebarOpen
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted;
|
||||
|
||||
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none");
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
@@ -977,20 +968,7 @@ export function DraftAgentScreen({
|
||||
isMobile ? { paddingTop: insets.top + theme.spacing[2] } : null,
|
||||
]}
|
||||
>
|
||||
<HeaderToggleButton
|
||||
onPress={toggleAgentList}
|
||||
tooltipLabel="Toggle sidebar"
|
||||
tooltipKeys={["mod", "B"]}
|
||||
tooltipSide="right"
|
||||
testID="menu-button"
|
||||
nativeID="menu-button"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isSidebarOpen ? "Close menu" : "Open menu"}
|
||||
accessibilityState={{ expanded: isSidebarOpen }}
|
||||
>
|
||||
<SidebarIcon size={isMobile ? 20 : 16} color={sidebarIconColor} />
|
||||
</HeaderToggleButton>
|
||||
<SidebarMenuToggle />
|
||||
</View>
|
||||
|
||||
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>
|
||||
|
||||
@@ -76,6 +76,34 @@ describe("tool-call-display", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("builds display model from worktree setup detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "paseo_worktree_setup",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||
branchName: "feature-branch",
|
||||
log: "==> [1/1] Running: npm install\n",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Worktree Setup",
|
||||
summary: "feature-branch",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not derive command summary from unknown raw detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "exec_command",
|
||||
|
||||
@@ -19,6 +19,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
|
||||
edit: Pencil,
|
||||
write: Pencil,
|
||||
search: Search,
|
||||
worktree_setup: SquareTerminal,
|
||||
unknown: Wrench,
|
||||
};
|
||||
|
||||
|
||||
@@ -1236,4 +1236,89 @@ describe("DaemonClient", () => {
|
||||
expect(received).toHaveLength(0);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("sends subscribe/unsubscribe terminals messages", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
client.subscribeTerminals({ cwd: "/tmp/project" });
|
||||
client.unsubscribeTerminals({ cwd: "/tmp/project" });
|
||||
|
||||
expect(mock.sent).toHaveLength(2);
|
||||
expect(JSON.parse(String(mock.sent[0]))).toEqual({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "subscribe_terminals_request",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(String(mock.sent[1]))).toEqual({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "unsubscribe_terminals_request",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatches terminals_changed events to typed listeners", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const received: Array<{ cwd: string; names: string[] }> = [];
|
||||
const unsubscribe = client.on("terminals_changed", (message) => {
|
||||
received.push({
|
||||
cwd: message.payload.cwd,
|
||||
names: message.payload.terminals.map((terminal) => terminal.name),
|
||||
});
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "terminals_changed",
|
||||
payload: {
|
||||
cwd: "/tmp/project",
|
||||
terminals: [
|
||||
{
|
||||
id: "term-1",
|
||||
name: "Dev Server",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
expect(received).toEqual([
|
||||
{
|
||||
cwd: "/tmp/project",
|
||||
names: ["Dev Server"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -356,6 +356,7 @@ export class DaemonClient {
|
||||
string,
|
||||
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
|
||||
>();
|
||||
private terminalDirectorySubscriptions = new Set<string>();
|
||||
private logger: Logger;
|
||||
private pendingSendQueue: PendingSend[] = [];
|
||||
private relayClientId: string | null = null;
|
||||
@@ -474,6 +475,7 @@ export class DaemonClient {
|
||||
this.updateConnectionState({ status: "connected" });
|
||||
this.resubscribeAgentUpdates();
|
||||
this.resubscribeCheckoutDiffSubscriptions();
|
||||
this.resubscribeTerminalDirectorySubscriptions();
|
||||
this.flushPendingSendQueue();
|
||||
this.resolveConnect();
|
||||
}),
|
||||
@@ -1093,6 +1095,18 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
private resubscribeTerminalDirectorySubscriptions(): void {
|
||||
if (this.terminalDirectorySubscriptions.size === 0) {
|
||||
return;
|
||||
}
|
||||
for (const cwd of this.terminalDirectorySubscriptions) {
|
||||
this.sendSessionMessage({
|
||||
type: "subscribe_terminals_request",
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent Lifecycle
|
||||
// ============================================================================
|
||||
@@ -2314,6 +2328,28 @@ export class DaemonClient {
|
||||
// Terminals
|
||||
// ============================================================================
|
||||
|
||||
subscribeTerminals(input: { cwd: string }): void {
|
||||
this.terminalDirectorySubscriptions.add(input.cwd);
|
||||
if (!this.transport || this.connectionState.status !== "connected") {
|
||||
return;
|
||||
}
|
||||
this.sendSessionMessage({
|
||||
type: "subscribe_terminals_request",
|
||||
cwd: input.cwd,
|
||||
});
|
||||
}
|
||||
|
||||
unsubscribeTerminals(input: { cwd: string }): void {
|
||||
this.terminalDirectorySubscriptions.delete(input.cwd);
|
||||
if (!this.transport || this.connectionState.status !== "connected") {
|
||||
return;
|
||||
}
|
||||
this.sendSessionMessage({
|
||||
type: "unsubscribe_terminals_request",
|
||||
cwd: input.cwd,
|
||||
});
|
||||
}
|
||||
|
||||
async listTerminals(
|
||||
cwd: string,
|
||||
requestId?: string
|
||||
|
||||
@@ -46,7 +46,10 @@ import { toAgentPayload } from "./agent-projections.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { appendTimelineItemIfAgentKnown } from "./timeline-append.js";
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
} from "./timeline-append.js";
|
||||
import { type WorktreeConfig } from "../../utils/worktree.js";
|
||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
@@ -357,6 +360,12 @@ export async function createAgentManagementMcpServer(
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
emitLiveTimelineItem: (item) =>
|
||||
emitLiveTimelineItemIfAgentKnown({
|
||||
agentManager,
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
logger: childLogger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -405,6 +405,66 @@ describe("AgentManager", () => {
|
||||
expect(result.rows[result.rows.length - 1]?.seq).toBe(3);
|
||||
});
|
||||
|
||||
test("emits live timeline updates without recording canonical timeline rows", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-timeline-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000120",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
const streamEvents: Array<{
|
||||
seq?: number;
|
||||
epoch?: string;
|
||||
eventType?: string;
|
||||
itemType?: string;
|
||||
}> = [];
|
||||
manager.subscribe(
|
||||
(event) => {
|
||||
if (event.type !== "agent_stream") {
|
||||
return;
|
||||
}
|
||||
streamEvents.push({
|
||||
seq: event.seq,
|
||||
epoch: event.epoch,
|
||||
eventType: event.event.type,
|
||||
itemType: event.event.type === "timeline" ? event.event.item.type : undefined,
|
||||
});
|
||||
},
|
||||
{ agentId: snapshot.id, replayState: false }
|
||||
);
|
||||
|
||||
await manager.emitLiveTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
text: "live-only update",
|
||||
});
|
||||
|
||||
expect(streamEvents).toHaveLength(1);
|
||||
expect(streamEvents[0]).toMatchObject({
|
||||
eventType: "timeline",
|
||||
itemType: "assistant_message",
|
||||
});
|
||||
expect(streamEvents[0]?.seq).toBeUndefined();
|
||||
expect(streamEvents[0]?.epoch).toBeUndefined();
|
||||
|
||||
expect(manager.getTimeline(snapshot.id)).toEqual([]);
|
||||
const fetched = manager.fetchTimeline(snapshot.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
});
|
||||
expect(fetched.rows).toEqual([]);
|
||||
});
|
||||
|
||||
test("fetchTimeline returns full timeline with reset when cursor seq falls behind retention window", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-gap-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -895,6 +895,19 @@ export class AgentManager {
|
||||
await this.persistSnapshot(agent);
|
||||
}
|
||||
|
||||
async emitLiveTimelineItem(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem
|
||||
): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.touchUpdatedAt(agent);
|
||||
this.dispatchStream(agentId, {
|
||||
type: "timeline",
|
||||
item,
|
||||
provider: agent.provider,
|
||||
});
|
||||
}
|
||||
|
||||
streamAgent(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
|
||||
@@ -134,6 +134,21 @@ export type ToolCallDetail =
|
||||
type: "search";
|
||||
query: string;
|
||||
}
|
||||
| {
|
||||
type: "worktree_setup";
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
log: string;
|
||||
commands: Array<{
|
||||
index: number;
|
||||
command: string;
|
||||
cwd: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
exitCode: number | null;
|
||||
durationMs?: number;
|
||||
}>;
|
||||
truncated?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "unknown";
|
||||
input: unknown | null;
|
||||
|
||||
@@ -28,7 +28,10 @@ import { toAgentPayload } from "./agent-projections.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { appendTimelineItemIfAgentKnown } from "./timeline-append.js";
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
} from "./timeline-append.js";
|
||||
import { type WorktreeConfig } from "../../utils/worktree.js";
|
||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
@@ -563,6 +566,12 @@ export async function createAgentMcpServer(
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
emitLiveTimelineItem: (item) =>
|
||||
emitLiveTimelineItemIfAgentKnown({
|
||||
agentManager,
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
logger: childLogger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,3 +21,18 @@ export async function appendTimelineItemIfAgentKnown(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function emitLiveTimelineItemIfAgentKnown(
|
||||
options: AppendTimelineItemIfAgentKnownOptions
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await options.agentManager.emitLiveTimelineItem(options.agentId, options.item);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("Unknown agent")) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,9 +397,10 @@ describe("daemon E2E", () => {
|
||||
);
|
||||
|
||||
expect(completed.callId).toBeTruthy();
|
||||
expect(completed.detail.type).toBe("unknown");
|
||||
if (completed.detail.type === "unknown") {
|
||||
expect(completed.detail.output).toBeTruthy();
|
||||
expect(completed.detail.type).toBe("worktree_setup");
|
||||
if (completed.detail.type === "worktree_setup") {
|
||||
expect(completed.detail.commands.length).toBeGreaterThan(0);
|
||||
expect(completed.detail.log.length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(true);
|
||||
|
||||
@@ -595,10 +596,12 @@ describe("daemon E2E", () => {
|
||||
expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true);
|
||||
expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false);
|
||||
|
||||
const output = failed.detail.type === "unknown" ? failed.detail.output as any : undefined;
|
||||
const commands = output?.commands as any[] | undefined;
|
||||
expect(Array.isArray(commands)).toBe(true);
|
||||
expect(commands?.[0]?.exitCode).toBe(7);
|
||||
expect(failed.detail.type).toBe("worktree_setup");
|
||||
if (failed.detail.type === "worktree_setup") {
|
||||
expect(Array.isArray(failed.detail.commands)).toBe(true);
|
||||
expect(failed.detail.commands[0]?.exitCode).toBe(7);
|
||||
expect(failed.detail.log).toContain("Exit 7");
|
||||
}
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(repoRoot, { recursive: true, force: true });
|
||||
|
||||
@@ -97,6 +97,42 @@ const shouldRun = !process.env.CI;
|
||||
30000
|
||||
);
|
||||
|
||||
test(
|
||||
"emits terminals_changed for subscribed cwd when terminals are created",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
await ctx.client.listTerminals(cwd);
|
||||
|
||||
const snapshots: Array<{ cwd: string; names: string[] }> = [];
|
||||
const unsubscribe = ctx.client.on("terminals_changed", (message) => {
|
||||
if (message.type !== "terminals_changed") {
|
||||
return;
|
||||
}
|
||||
snapshots.push({
|
||||
cwd: message.payload.cwd,
|
||||
names: message.payload.terminals.map((terminal) => terminal.name),
|
||||
});
|
||||
});
|
||||
|
||||
ctx.client.subscribeTerminals({ cwd });
|
||||
await ctx.client.createTerminal(cwd, "Dev Server");
|
||||
|
||||
await waitForCondition(
|
||||
() =>
|
||||
snapshots.some(
|
||||
(snapshot) =>
|
||||
snapshot.cwd === cwd && snapshot.names.includes("Dev Server")
|
||||
),
|
||||
10000
|
||||
);
|
||||
|
||||
ctx.client.unsubscribeTerminals({ cwd });
|
||||
unsubscribe();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
30000
|
||||
);
|
||||
|
||||
test(
|
||||
"subscribes to terminal and receives state",
|
||||
async () => {
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
type FileDownloadTokenRequest,
|
||||
type GitSetupOptions,
|
||||
type ListTerminalsRequest,
|
||||
type SubscribeTerminalsRequest,
|
||||
type UnsubscribeTerminalsRequest,
|
||||
type CreateTerminalRequest,
|
||||
type SubscribeTerminalRequest,
|
||||
type UnsubscribeTerminalRequest,
|
||||
@@ -27,7 +29,10 @@ import {
|
||||
type ProjectCheckoutLitePayload,
|
||||
type ProjectPlacementPayload,
|
||||
} from "./messages.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type {
|
||||
TerminalManager,
|
||||
TerminalsChangedEvent,
|
||||
} from "../terminal/terminal-manager.js";
|
||||
import type { TerminalSession } from "../terminal/terminal.js";
|
||||
import {
|
||||
BinaryMuxChannel,
|
||||
@@ -68,7 +73,10 @@ import type {
|
||||
} from "./agent/agent-manager.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
|
||||
import { toAgentPayload } from "./agent/agent-projections.js";
|
||||
import { appendTimelineItemIfAgentKnown } from "./agent/timeline-append.js";
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
} from "./agent/timeline-append.js";
|
||||
import { projectTimelineRows, type TimelineProjectionMode } from "./agent/timeline-projection.js";
|
||||
import {
|
||||
StructuredAgentResponseError,
|
||||
@@ -559,6 +567,8 @@ export class Session {
|
||||
} | null = null;
|
||||
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
private readonly subscribedTerminalDirectories = new Set<string>();
|
||||
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
||||
private terminalSubscriptions: Map<string, () => void> = new Map();
|
||||
private terminalExitSubscriptions: Map<string, () => void> = new Map();
|
||||
private readonly terminalStreams = new Map<
|
||||
@@ -627,6 +637,11 @@ export class Session {
|
||||
this.agentStorage = agentStorage;
|
||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||
this.terminalManager = terminalManager;
|
||||
if (this.terminalManager) {
|
||||
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged(
|
||||
(event) => this.handleTerminalsChanged(event)
|
||||
);
|
||||
}
|
||||
this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null;
|
||||
const configuredModelsDir = dictation?.localModels?.modelsDir?.trim();
|
||||
this.localSpeechModelsDir =
|
||||
@@ -1439,6 +1454,14 @@ export class Session {
|
||||
this.handleRegisterPushToken(msg.token);
|
||||
break;
|
||||
|
||||
case "subscribe_terminals_request":
|
||||
this.handleSubscribeTerminalsRequest(msg);
|
||||
break;
|
||||
|
||||
case "unsubscribe_terminals_request":
|
||||
this.handleUnsubscribeTerminalsRequest(msg);
|
||||
break;
|
||||
|
||||
case "list_terminals_request":
|
||||
await this.handleListTerminalsRequest(msg);
|
||||
break;
|
||||
@@ -2417,6 +2440,12 @@ export class Session {
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
emitLiveTimelineItem: (item) =>
|
||||
emitLiveTimelineItemIfAgentKnown({
|
||||
agentManager: this.agentManager,
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
}
|
||||
@@ -6097,6 +6126,12 @@ export class Session {
|
||||
this.isVoiceMode = false;
|
||||
|
||||
// Unsubscribe from all terminals
|
||||
if (this.unsubscribeTerminalsChanged) {
|
||||
this.unsubscribeTerminalsChanged();
|
||||
this.unsubscribeTerminalsChanged = null;
|
||||
}
|
||||
this.subscribedTerminalDirectories.clear();
|
||||
|
||||
for (const unsubscribe of this.terminalSubscriptions.values()) {
|
||||
unsubscribe();
|
||||
}
|
||||
@@ -6155,6 +6190,81 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private emitTerminalsChangedSnapshot(input: {
|
||||
cwd: string;
|
||||
terminals: Array<{ id: string; name: string }>;
|
||||
}): void {
|
||||
this.emit({
|
||||
type: "terminals_changed",
|
||||
payload: {
|
||||
cwd: input.cwd,
|
||||
terminals: input.terminals,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private handleTerminalsChanged(event: TerminalsChangedEvent): void {
|
||||
if (!this.subscribedTerminalDirectories.has(event.cwd)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emitTerminalsChangedSnapshot({
|
||||
cwd: event.cwd,
|
||||
terminals: event.terminals.map((terminal) => ({
|
||||
id: terminal.id,
|
||||
name: terminal.name,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void {
|
||||
this.subscribedTerminalDirectories.add(msg.cwd);
|
||||
void this.emitInitialTerminalsChangedSnapshot(msg.cwd);
|
||||
}
|
||||
|
||||
private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void {
|
||||
this.subscribedTerminalDirectories.delete(msg.cwd);
|
||||
}
|
||||
|
||||
private async emitInitialTerminalsChangedSnapshot(cwd: string): Promise<void> {
|
||||
if (!this.terminalManager || !this.subscribedTerminalDirectories.has(cwd)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hadDirectoryBeforeSubscribe = this.terminalManager
|
||||
.listDirectories()
|
||||
.includes(cwd);
|
||||
|
||||
try {
|
||||
const terminals = await this.terminalManager.getTerminals(cwd);
|
||||
for (const terminal of terminals) {
|
||||
this.ensureTerminalExitSubscription(terminal);
|
||||
}
|
||||
|
||||
// New directories auto-create Terminal 1, which already emits through
|
||||
// terminal-manager change listeners.
|
||||
if (!hadDirectoryBeforeSubscribe) {
|
||||
return;
|
||||
}
|
||||
if (!this.subscribedTerminalDirectories.has(cwd)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emitTerminalsChangedSnapshot({
|
||||
cwd,
|
||||
terminals: terminals.map((terminal) => ({
|
||||
id: terminal.id,
|
||||
name: terminal.name,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, cwd },
|
||||
"Failed to emit initial terminal snapshot"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleListTerminalsRequest(msg: ListTerminalsRequest): Promise<void> {
|
||||
if (!this.terminalManager) {
|
||||
this.emit({
|
||||
|
||||
239
packages/server/src/server/worktree-bootstrap.test.ts
Normal file
239
packages/server/src/server/worktree-bootstrap.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
|
||||
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
||||
import {
|
||||
createAgentWorktree,
|
||||
runAsyncWorktreeBootstrap,
|
||||
} from "./worktree-bootstrap.js";
|
||||
|
||||
describe("runAsyncWorktreeBootstrap", () => {
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
let paseoHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-")));
|
||||
repoDir = join(tempDir, "repo");
|
||||
paseoHome = join(tempDir, "paseo-home");
|
||||
|
||||
execSync(`mkdir -p ${repoDir}`);
|
||||
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("echo 'hello' > file.txt", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("streams running setup updates live and persists only a final setup timeline row", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: ['echo "line-one"; echo "line-two" 1>&2', 'echo "line-three"'],
|
||||
},
|
||||
})
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add setup'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-streaming-setup",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-streaming-setup",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
const live: AgentTimelineItem[] = [];
|
||||
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-test",
|
||||
worktree,
|
||||
terminalManager: null,
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
return true;
|
||||
},
|
||||
emitLiveTimelineItem: async (item: AgentTimelineItem) => {
|
||||
live.push(item);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const liveSetupItems = live.filter(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
item.name === "paseo_worktree_setup" &&
|
||||
item.status === "running"
|
||||
);
|
||||
expect(liveSetupItems.length).toBeGreaterThan(0);
|
||||
|
||||
const persistedSetupItems = persisted.filter(
|
||||
(item) => item.type === "tool_call" && item.name === "paseo_worktree_setup"
|
||||
);
|
||||
expect(persistedSetupItems).toHaveLength(1);
|
||||
expect(persistedSetupItems[0]?.type).toBe("tool_call");
|
||||
if (persistedSetupItems[0]?.type === "tool_call") {
|
||||
expect(persistedSetupItems[0].status).toBe("completed");
|
||||
expect(persistedSetupItems[0].detail.type).toBe("worktree_setup");
|
||||
|
||||
if (persistedSetupItems[0].detail.type === "worktree_setup") {
|
||||
expect(persistedSetupItems[0].detail.log).toContain(
|
||||
"==> [1/2] Running: echo \"line-one\"; echo \"line-two\" 1>&2"
|
||||
);
|
||||
expect(persistedSetupItems[0].detail.log).toContain("line-one");
|
||||
expect(persistedSetupItems[0].detail.log).toContain("line-two");
|
||||
expect(persistedSetupItems[0].detail.log).toContain(
|
||||
"==> [2/2] Running: echo \"line-three\""
|
||||
);
|
||||
expect(persistedSetupItems[0].detail.log).toContain("line-three");
|
||||
expect(persistedSetupItems[0].detail.log).toMatch(/<== \[1\/2\] Exit 0 in \d+\.\d{2}s/);
|
||||
expect(persistedSetupItems[0].detail.log).toMatch(/<== \[2\/2\] Exit 0 in \d+\.\d{2}s/);
|
||||
|
||||
expect(persistedSetupItems[0].detail.commands).toHaveLength(2);
|
||||
expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({
|
||||
index: 1,
|
||||
command: 'echo "line-one"; echo "line-two" 1>&2',
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({
|
||||
index: 2,
|
||||
command: 'echo "line-three"',
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(
|
||||
typeof persistedSetupItems[0].detail.commands[0]?.durationMs === "number"
|
||||
).toBe(true);
|
||||
expect(
|
||||
typeof persistedSetupItems[0].detail.commands[1]?.durationMs === "number"
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
const liveCallIds = new Set(
|
||||
liveSetupItems
|
||||
.filter((item): item is Extract<AgentTimelineItem, { type: "tool_call" }> => item.type === "tool_call")
|
||||
.map((item) => item.callId)
|
||||
);
|
||||
expect(liveCallIds.size).toBe(1);
|
||||
if (persistedSetupItems[0]?.type === "tool_call") {
|
||||
expect(liveCallIds.has(persistedSetupItems[0].callId)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not fail setup when live timeline emission throws", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: ['echo "ok"'],
|
||||
},
|
||||
})
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add setup'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-live-failure",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-live-failure",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await expect(
|
||||
runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-live-failure",
|
||||
worktree,
|
||||
terminalManager: null,
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
return true;
|
||||
},
|
||||
emitLiveTimelineItem: async () => {
|
||||
throw new Error("live emit failed");
|
||||
},
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const persistedSetupItems = persisted.filter(
|
||||
(item) => item.type === "tool_call" && item.name === "paseo_worktree_setup"
|
||||
);
|
||||
expect(persistedSetupItems).toHaveLength(1);
|
||||
if (persistedSetupItems[0]?.type === "tool_call") {
|
||||
expect(persistedSetupItems[0].status).toBe("completed");
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates each command output to 64kb in the middle", async () => {
|
||||
const largeOutputCommand =
|
||||
"node -e \"process.stdout.write('prefix-'); process.stdout.write('x'.repeat(70000)); process.stdout.write('-suffix')\"";
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [largeOutputCommand],
|
||||
},
|
||||
})
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add large output setup'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-large-output",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-large-output",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-large-output",
|
||||
worktree,
|
||||
terminalManager: null,
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
return true;
|
||||
},
|
||||
emitLiveTimelineItem: async () => true,
|
||||
});
|
||||
|
||||
const persistedSetupItem = persisted.find(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||
item.type === "tool_call" && item.name === "paseo_worktree_setup"
|
||||
);
|
||||
expect(persistedSetupItem).toBeDefined();
|
||||
expect(persistedSetupItem?.detail.type).toBe("worktree_setup");
|
||||
if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") {
|
||||
throw new Error("Expected worktree_setup tool detail");
|
||||
}
|
||||
|
||||
expect(persistedSetupItem.detail.truncated).toBe(true);
|
||||
expect(persistedSetupItem.detail.log).toContain("prefix-");
|
||||
expect(persistedSetupItem.detail.log).toContain("-suffix");
|
||||
expect(persistedSetupItem.detail.log).toContain("...<output truncated in the middle>...");
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ export interface RunAsyncWorktreeBootstrapOptions {
|
||||
worktree: WorktreeConfig;
|
||||
terminalManager: TerminalManager | null;
|
||||
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
|
||||
emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
@@ -35,6 +36,116 @@ export interface CreateAgentWorktreeOptions {
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
||||
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
|
||||
|
||||
type MiddleTruncationAccumulator = {
|
||||
totalBytes: number;
|
||||
head: string;
|
||||
tail: string;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
function byteLength(text: string): number {
|
||||
return Buffer.byteLength(text, "utf8");
|
||||
}
|
||||
|
||||
function sliceFirstBytes(text: string, maxBytes: number): string {
|
||||
if (maxBytes <= 0 || text.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const bytes = Buffer.from(text, "utf8");
|
||||
if (bytes.length <= maxBytes) {
|
||||
return text;
|
||||
}
|
||||
return bytes.subarray(0, maxBytes).toString("utf8");
|
||||
}
|
||||
|
||||
function sliceLastBytes(text: string, maxBytes: number): string {
|
||||
if (maxBytes <= 0 || text.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const bytes = Buffer.from(text, "utf8");
|
||||
if (bytes.length <= maxBytes) {
|
||||
return text;
|
||||
}
|
||||
return bytes.subarray(bytes.length - maxBytes).toString("utf8");
|
||||
}
|
||||
|
||||
function createMiddleTruncationAccumulator(): MiddleTruncationAccumulator {
|
||||
return {
|
||||
totalBytes: 0,
|
||||
head: "",
|
||||
tail: "",
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getHeadTailBudgets(maxBytes: number): { headBytes: number; tailBytes: number } {
|
||||
const markerBytes = byteLength(WORKTREE_SETUP_TRUNCATION_MARKER);
|
||||
const availableBytes = Math.max(0, maxBytes - markerBytes);
|
||||
const headBytes = Math.floor(availableBytes / 2);
|
||||
const tailBytes = availableBytes - headBytes;
|
||||
return { headBytes, tailBytes };
|
||||
}
|
||||
|
||||
function appendToMiddleTruncationAccumulator(
|
||||
accumulator: MiddleTruncationAccumulator,
|
||||
chunk: string
|
||||
): void {
|
||||
if (!chunk) {
|
||||
return;
|
||||
}
|
||||
accumulator.totalBytes += byteLength(chunk);
|
||||
|
||||
if (!accumulator.truncated) {
|
||||
const combined = `${accumulator.head}${chunk}`;
|
||||
if (byteLength(combined) <= MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES) {
|
||||
accumulator.head = combined;
|
||||
return;
|
||||
}
|
||||
const { headBytes, tailBytes } = getHeadTailBudgets(
|
||||
MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES
|
||||
);
|
||||
accumulator.head = sliceFirstBytes(combined, headBytes);
|
||||
accumulator.tail = sliceLastBytes(combined, tailBytes);
|
||||
accumulator.truncated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const { tailBytes } = getHeadTailBudgets(MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES);
|
||||
accumulator.tail = sliceLastBytes(`${accumulator.tail}${chunk}`, tailBytes);
|
||||
}
|
||||
|
||||
function truncateTextInMiddle(
|
||||
text: string,
|
||||
maxBytes: number
|
||||
): { text: string; truncated: boolean } {
|
||||
if (maxBytes <= 0 || !text) {
|
||||
return { text: "", truncated: text.length > 0 };
|
||||
}
|
||||
if (byteLength(text) <= maxBytes) {
|
||||
return { text, truncated: false };
|
||||
}
|
||||
const { headBytes, tailBytes } = getHeadTailBudgets(maxBytes);
|
||||
return {
|
||||
text: `${sliceFirstBytes(text, headBytes)}${WORKTREE_SETUP_TRUNCATION_MARKER}${sliceLastBytes(text, tailBytes)}`,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMiddleTruncationAccumulator(
|
||||
accumulator: MiddleTruncationAccumulator
|
||||
): { text: string; truncated: boolean } {
|
||||
if (!accumulator.truncated) {
|
||||
return { text: accumulator.head, truncated: false };
|
||||
}
|
||||
return {
|
||||
text: `${accumulator.head}${WORKTREE_SETUP_TRUNCATION_MARKER}${accumulator.tail}`,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAgentWorktree(
|
||||
options: CreateAgentWorktreeOptions
|
||||
): Promise<WorktreeConfig> {
|
||||
@@ -48,25 +159,88 @@ export async function createAgentWorktree(
|
||||
});
|
||||
}
|
||||
|
||||
function formatDurationMs(durationMs: number): string {
|
||||
return `${(durationMs / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function commandStatusFromResult(
|
||||
result: WorktreeSetupCommandResult
|
||||
): "running" | "completed" | "failed" {
|
||||
if (result.exitCode === null) {
|
||||
return "running";
|
||||
}
|
||||
return result.exitCode === 0 ? "completed" : "failed";
|
||||
}
|
||||
|
||||
function buildWorktreeSetupLog(input: {
|
||||
results: WorktreeSetupCommandResult[];
|
||||
outputAccumulatorsByIndex?: Map<number, MiddleTruncationAccumulator>;
|
||||
}): { log: string; truncated: boolean } {
|
||||
const { results, outputAccumulatorsByIndex } = input;
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
log: "",
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
let anyTruncated = false;
|
||||
const total = results.length;
|
||||
for (const [index, result] of results.entries()) {
|
||||
lines.push(`==> [${index + 1}/${total}] Running: ${result.command}`);
|
||||
const accumulator = outputAccumulatorsByIndex?.get(index + 1);
|
||||
const output = accumulator
|
||||
? renderMiddleTruncationAccumulator(accumulator)
|
||||
: truncateTextInMiddle(
|
||||
`${result.stdout ?? ""}${result.stderr ?? ""}`,
|
||||
MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES
|
||||
);
|
||||
if (output.text.length > 0) {
|
||||
lines.push(output.text.replace(/\n$/, ""));
|
||||
}
|
||||
if (output.truncated) {
|
||||
anyTruncated = true;
|
||||
}
|
||||
if (result.exitCode !== null) {
|
||||
lines.push(
|
||||
`<== [${index + 1}/${total}] Exit ${result.exitCode} in ${formatDurationMs(result.durationMs)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
log: lines.join("\n"),
|
||||
truncated: anyTruncated,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSetupTimelineItem(input: {
|
||||
callId: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
worktree: WorktreeConfig;
|
||||
results: WorktreeSetupCommandResult[];
|
||||
outputAccumulatorsByIndex?: Map<number, MiddleTruncationAccumulator>;
|
||||
errorMessage: string | null;
|
||||
}): AgentTimelineItem {
|
||||
const detailInput = {
|
||||
const commands = input.results.map((result, index) => ({
|
||||
index: index + 1,
|
||||
command: result.command,
|
||||
cwd: result.cwd,
|
||||
status: commandStatusFromResult(result),
|
||||
exitCode: result.exitCode,
|
||||
...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}),
|
||||
}));
|
||||
const renderedLog = buildWorktreeSetupLog({
|
||||
results: input.results,
|
||||
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
|
||||
});
|
||||
const detail = {
|
||||
type: "worktree_setup" as const,
|
||||
worktreePath: input.worktree.worktreePath,
|
||||
branchName: input.worktree.branchName,
|
||||
};
|
||||
const detailOutput = {
|
||||
worktreePath: input.worktree.worktreePath,
|
||||
commands: input.results.map((result) => ({
|
||||
command: result.command,
|
||||
cwd: result.cwd,
|
||||
exitCode: result.exitCode,
|
||||
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
|
||||
})),
|
||||
log: renderedLog.log,
|
||||
commands,
|
||||
...(renderedLog.truncated ? { truncated: true } : {}),
|
||||
};
|
||||
|
||||
if (input.status === "running") {
|
||||
@@ -75,11 +249,7 @@ function buildSetupTimelineItem(input: {
|
||||
name: "paseo_worktree_setup",
|
||||
callId: input.callId,
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: detailInput,
|
||||
output: null,
|
||||
},
|
||||
detail,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
@@ -90,11 +260,7 @@ function buildSetupTimelineItem(input: {
|
||||
name: "paseo_worktree_setup",
|
||||
callId: input.callId,
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: detailInput,
|
||||
output: detailOutput,
|
||||
},
|
||||
detail,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
@@ -104,11 +270,7 @@ function buildSetupTimelineItem(input: {
|
||||
name: "paseo_worktree_setup",
|
||||
callId: input.callId,
|
||||
status: "failed",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: detailInput,
|
||||
output: detailOutput,
|
||||
},
|
||||
detail,
|
||||
error: { message: input.errorMessage ?? "Worktree setup failed" },
|
||||
};
|
||||
}
|
||||
@@ -258,40 +420,105 @@ export async function runAsyncWorktreeBootstrap(
|
||||
): Promise<void> {
|
||||
const setupCallId = uuidv4();
|
||||
let setupResults: WorktreeSetupCommandResult[] = [];
|
||||
const emitLiveTimelineItem = options.emitLiveTimelineItem;
|
||||
const runningResultsByIndex = new Map<number, WorktreeSetupCommandResult>();
|
||||
const outputAccumulatorsByIndex = new Map<number, MiddleTruncationAccumulator>();
|
||||
let liveEmitQueue = Promise.resolve();
|
||||
|
||||
try {
|
||||
const started = await options.appendTimelineItem(
|
||||
buildSetupTimelineItem({
|
||||
callId: setupCallId,
|
||||
status: "running",
|
||||
worktree: options.worktree,
|
||||
results: [],
|
||||
errorMessage: null,
|
||||
})
|
||||
);
|
||||
if (!started) {
|
||||
const queueLiveRunningEmit = () => {
|
||||
if (!emitLiveTimelineItem) {
|
||||
return;
|
||||
}
|
||||
const runningResults = Array.from(runningResultsByIndex.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, result]) => result);
|
||||
liveEmitQueue = liveEmitQueue.then(async () => {
|
||||
try {
|
||||
await emitLiveTimelineItem(
|
||||
buildSetupTimelineItem({
|
||||
callId: setupCallId,
|
||||
status: "running",
|
||||
worktree: options.worktree,
|
||||
results: runningResults,
|
||||
outputAccumulatorsByIndex,
|
||||
errorMessage: null,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
options.logger?.warn(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Failed to emit live worktree setup timeline update"
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
setupResults = await runWorktreeSetupCommands({
|
||||
worktreePath: options.worktree.worktreePath,
|
||||
branchName: options.worktree.branchName,
|
||||
cleanupOnFailure: false,
|
||||
onEvent: (event) => {
|
||||
const existing = runningResultsByIndex.get(event.index);
|
||||
const baseResult: WorktreeSetupCommandResult = existing ?? {
|
||||
command: event.command,
|
||||
cwd: event.cwd,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: null,
|
||||
durationMs: 0,
|
||||
};
|
||||
if (event.type === "output") {
|
||||
const outputAccumulator =
|
||||
outputAccumulatorsByIndex.get(event.index) ??
|
||||
createMiddleTruncationAccumulator();
|
||||
appendToMiddleTruncationAccumulator(outputAccumulator, event.chunk);
|
||||
outputAccumulatorsByIndex.set(event.index, outputAccumulator);
|
||||
runningResultsByIndex.set(event.index, {
|
||||
...baseResult,
|
||||
// Keep the timeline command model lightweight; output is carried in
|
||||
// outputAccumulatorsByIndex.
|
||||
stdout: baseResult.stdout,
|
||||
stderr: baseResult.stderr,
|
||||
});
|
||||
queueLiveRunningEmit();
|
||||
return;
|
||||
}
|
||||
if (event.type === "command_completed") {
|
||||
runningResultsByIndex.set(event.index, {
|
||||
...baseResult,
|
||||
stdout: event.stdout,
|
||||
stderr: event.stderr,
|
||||
exitCode: event.exitCode,
|
||||
durationMs: event.durationMs,
|
||||
});
|
||||
queueLiveRunningEmit();
|
||||
return;
|
||||
}
|
||||
runningResultsByIndex.set(event.index, baseResult);
|
||||
queueLiveRunningEmit();
|
||||
},
|
||||
});
|
||||
await liveEmitQueue;
|
||||
|
||||
await options.appendTimelineItem(
|
||||
const completed = await options.appendTimelineItem(
|
||||
buildSetupTimelineItem({
|
||||
callId: setupCallId,
|
||||
status: "completed",
|
||||
worktree: options.worktree,
|
||||
results: setupResults,
|
||||
outputAccumulatorsByIndex,
|
||||
errorMessage: null,
|
||||
})
|
||||
);
|
||||
if (!completed) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WorktreeSetupError) {
|
||||
setupResults = error.results;
|
||||
}
|
||||
await liveEmitQueue;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await options.appendTimelineItem(
|
||||
buildSetupTimelineItem({
|
||||
@@ -299,6 +526,7 @@ export async function runAsyncWorktreeBootstrap(
|
||||
status: "failed",
|
||||
worktree: options.worktree,
|
||||
results: setupResults,
|
||||
outputAccumulatorsByIndex,
|
||||
errorMessage: message,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -188,6 +188,23 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
|
||||
type: z.literal("search"),
|
||||
query: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("worktree_setup"),
|
||||
worktreePath: z.string(),
|
||||
branchName: z.string(),
|
||||
log: z.string(),
|
||||
commands: z.array(
|
||||
z.object({
|
||||
index: z.number().int().positive(),
|
||||
command: z.string(),
|
||||
cwd: z.string(),
|
||||
status: z.enum(["running", "completed", "failed"]),
|
||||
exitCode: z.number().nullable(),
|
||||
durationMs: z.number().nonnegative().optional(),
|
||||
})
|
||||
),
|
||||
truncated: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("unknown"),
|
||||
input: UnknownValueSchema,
|
||||
@@ -948,6 +965,16 @@ export const ListTerminalsRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const SubscribeTerminalsRequestSchema = z.object({
|
||||
type: z.literal("subscribe_terminals_request"),
|
||||
cwd: z.string(),
|
||||
});
|
||||
|
||||
export const UnsubscribeTerminalsRequestSchema = z.object({
|
||||
type: z.literal("unsubscribe_terminals_request"),
|
||||
cwd: z.string(),
|
||||
});
|
||||
|
||||
export const CreateTerminalRequestSchema = z.object({
|
||||
type: z.literal("create_terminal_request"),
|
||||
cwd: z.string(),
|
||||
@@ -1060,6 +1087,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ExecuteCommandRequestSchema,
|
||||
RegisterPushTokenMessageSchema,
|
||||
ListTerminalsRequestSchema,
|
||||
SubscribeTerminalsRequestSchema,
|
||||
UnsubscribeTerminalsRequestSchema,
|
||||
CreateTerminalRequestSchema,
|
||||
SubscribeTerminalRequestSchema,
|
||||
UnsubscribeTerminalRequestSchema,
|
||||
@@ -1885,6 +1914,14 @@ export const ListTerminalsResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const TerminalsChangedSchema = z.object({
|
||||
type: z.literal("terminals_changed"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
terminals: z.array(TerminalInfoSchema.omit({ cwd: true })),
|
||||
}),
|
||||
});
|
||||
|
||||
export const CreateTerminalResponseSchema = z.object({
|
||||
type: z.literal("create_terminal_response"),
|
||||
payload: z.object({
|
||||
@@ -2006,6 +2043,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ListCommandsResponseSchema,
|
||||
ExecuteCommandResponseSchema,
|
||||
ListTerminalsResponseSchema,
|
||||
TerminalsChangedSchema,
|
||||
CreateTerminalResponseSchema,
|
||||
SubscribeTerminalResponseSchema,
|
||||
TerminalOutputSchema,
|
||||
@@ -2149,6 +2187,9 @@ export type RegisterPushTokenMessage = z.infer<typeof RegisterPushTokenMessageSc
|
||||
// Terminal message types
|
||||
export type ListTerminalsRequest = z.infer<typeof ListTerminalsRequestSchema>;
|
||||
export type ListTerminalsResponse = z.infer<typeof ListTerminalsResponseSchema>;
|
||||
export type SubscribeTerminalsRequest = z.infer<typeof SubscribeTerminalsRequestSchema>;
|
||||
export type UnsubscribeTerminalsRequest = z.infer<typeof UnsubscribeTerminalsRequestSchema>;
|
||||
export type TerminalsChanged = z.infer<typeof TerminalsChangedSchema>;
|
||||
export type CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>;
|
||||
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
|
||||
export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSchema>;
|
||||
|
||||
@@ -59,6 +59,34 @@ describe("shared tool-call display mapping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("builds display model for worktree setup detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "paseo_worktree_setup",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||
branchName: "feature-branch",
|
||||
log: "==> [1/1] Running: npm install\n",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Worktree Setup",
|
||||
summary: "feature-branch",
|
||||
});
|
||||
});
|
||||
|
||||
it("provides errorText for failed calls", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "shell",
|
||||
|
||||
@@ -83,6 +83,10 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall
|
||||
displayName = "Search";
|
||||
summary = input.detail.query;
|
||||
break;
|
||||
case "worktree_setup":
|
||||
displayName = "Worktree Setup";
|
||||
summary = input.detail.branchName;
|
||||
break;
|
||||
case "unknown":
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -201,4 +201,52 @@ describe("TerminalManager", () => {
|
||||
expect(manager.getTerminal(homeId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeTerminalsChanged", () => {
|
||||
it("emits cwd snapshots when terminals are created", async () => {
|
||||
manager = createTerminalManager();
|
||||
const snapshots: Array<{ cwd: string; terminalNames: string[] }> = [];
|
||||
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
||||
snapshots.push({
|
||||
cwd: input.cwd,
|
||||
terminalNames: input.terminals.map((terminal) => terminal.name),
|
||||
});
|
||||
});
|
||||
|
||||
await manager.getTerminals("/tmp");
|
||||
await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
|
||||
|
||||
expect(snapshots).toContainEqual({
|
||||
cwd: "/tmp",
|
||||
terminalNames: ["Terminal 1"],
|
||||
});
|
||||
expect(snapshots).toContainEqual({
|
||||
cwd: "/tmp",
|
||||
terminalNames: ["Terminal 1", "Dev Server"],
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("emits empty snapshot when last terminal is removed", async () => {
|
||||
manager = createTerminalManager();
|
||||
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
|
||||
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
||||
snapshots.push({
|
||||
cwd: input.cwd,
|
||||
terminalCount: input.terminals.length,
|
||||
});
|
||||
});
|
||||
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
manager.killTerminal(terminals[0].id);
|
||||
|
||||
expect(snapshots).toContainEqual({
|
||||
cwd: "/tmp",
|
||||
terminalCount: 0,
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { createTerminal, type TerminalSession } from "./terminal.js";
|
||||
|
||||
export interface TerminalListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface TerminalsChangedEvent {
|
||||
cwd: string;
|
||||
terminals: TerminalListItem[];
|
||||
}
|
||||
|
||||
export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void;
|
||||
|
||||
export interface TerminalManager {
|
||||
getTerminals(cwd: string): Promise<TerminalSession[]>;
|
||||
createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession>;
|
||||
@@ -7,12 +20,14 @@ export interface TerminalManager {
|
||||
killTerminal(id: string): void;
|
||||
listDirectories(): string[];
|
||||
killAll(): void;
|
||||
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
|
||||
}
|
||||
|
||||
export function createTerminalManager(): TerminalManager {
|
||||
const terminalsByCwd = new Map<string, TerminalSession[]>();
|
||||
const terminalsById = new Map<string, TerminalSession>();
|
||||
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
||||
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
|
||||
|
||||
function assertAbsolutePath(cwd: string): void {
|
||||
if (!cwd.startsWith("/")) {
|
||||
@@ -48,6 +63,8 @@ export function createTerminalManager(): TerminalManager {
|
||||
if (options.kill) {
|
||||
session.kill();
|
||||
}
|
||||
|
||||
emitTerminalsChanged({ cwd: session.cwd });
|
||||
}
|
||||
|
||||
function registerSession(session: TerminalSession): TerminalSession {
|
||||
@@ -59,6 +76,36 @@ export function createTerminalManager(): TerminalManager {
|
||||
return session;
|
||||
}
|
||||
|
||||
function toTerminalListItem(input: { session: TerminalSession }): TerminalListItem {
|
||||
return {
|
||||
id: input.session.id,
|
||||
name: input.session.name,
|
||||
cwd: input.session.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
function emitTerminalsChanged(input: { cwd: string }): void {
|
||||
if (terminalsChangedListeners.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const terminals = (terminalsByCwd.get(input.cwd) ?? []).map((session) =>
|
||||
toTerminalListItem({ session })
|
||||
);
|
||||
const event: TerminalsChangedEvent = {
|
||||
cwd: input.cwd,
|
||||
terminals,
|
||||
};
|
||||
|
||||
for (const listener of terminalsChangedListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async getTerminals(cwd: string): Promise<TerminalSession[]> {
|
||||
assertAbsolutePath(cwd);
|
||||
@@ -70,6 +117,7 @@ export function createTerminalManager(): TerminalManager {
|
||||
);
|
||||
terminals = [session];
|
||||
terminalsByCwd.set(cwd, terminals);
|
||||
emitTerminalsChanged({ cwd });
|
||||
}
|
||||
return terminals;
|
||||
},
|
||||
@@ -88,6 +136,7 @@ export function createTerminalManager(): TerminalManager {
|
||||
|
||||
terminals.push(session);
|
||||
terminalsByCwd.set(options.cwd, terminals);
|
||||
emitTerminalsChanged({ cwd: options.cwd });
|
||||
|
||||
return session;
|
||||
},
|
||||
@@ -109,5 +158,12 @@ export function createTerminalManager(): TerminalManager {
|
||||
removeSessionById(id, { kill: true });
|
||||
}
|
||||
},
|
||||
|
||||
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void {
|
||||
terminalsChangedListeners.add(listener);
|
||||
return () => {
|
||||
terminalsChangedListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
getWorktreeTerminalSpecs,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
listPaseoWorktrees,
|
||||
type WorktreeSetupCommandProgressEvent,
|
||||
runWorktreeSetupCommands,
|
||||
slugify,
|
||||
} from "./worktree";
|
||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||
@@ -233,6 +235,36 @@ describe("createWorktree", () => {
|
||||
expect(existsSync(join(result.worktreePath, "setup.log"))).toBe(false);
|
||||
});
|
||||
|
||||
it("streams setup command progress events while commands are executing", async () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
setup: [
|
||||
'echo "first line"; echo "second line" 1>&2',
|
||||
],
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync(
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add streaming setup'",
|
||||
{ cwd: repoDir }
|
||||
);
|
||||
|
||||
const progressEvents: WorktreeSetupCommandProgressEvent[] = [];
|
||||
const results = await runWorktreeSetupCommands({
|
||||
worktreePath: repoDir,
|
||||
branchName: "main",
|
||||
cleanupOnFailure: false,
|
||||
onEvent: (event) => {
|
||||
progressEvents.push(event);
|
||||
},
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(progressEvents.some((event) => event.type === "command_started")).toBe(true);
|
||||
expect(progressEvents.some((event) => event.type === "output")).toBe(true);
|
||||
expect(progressEvents.some((event) => event.type === "command_completed")).toBe(true);
|
||||
});
|
||||
|
||||
it("cleans up worktree if setup command fails", async () => {
|
||||
// Create paseo.json with failing setup command
|
||||
const paseoConfig = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { exec } from "child_process";
|
||||
import { exec, spawn } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs";
|
||||
import { join, basename, dirname, resolve, sep } from "path";
|
||||
@@ -32,8 +32,38 @@ export type WorktreeSetupCommandResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type WorktreeSetupCommandProgressEvent =
|
||||
| {
|
||||
type: "command_started";
|
||||
index: number;
|
||||
total: number;
|
||||
command: string;
|
||||
cwd: string;
|
||||
}
|
||||
| {
|
||||
type: "output";
|
||||
index: number;
|
||||
total: number;
|
||||
command: string;
|
||||
cwd: string;
|
||||
stream: "stdout" | "stderr";
|
||||
chunk: string;
|
||||
}
|
||||
| {
|
||||
type: "command_completed";
|
||||
index: number;
|
||||
total: number;
|
||||
command: string;
|
||||
cwd: string;
|
||||
exitCode: number | null;
|
||||
durationMs: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export interface WorktreeTerminalConfig {
|
||||
name?: string;
|
||||
command: string;
|
||||
@@ -153,6 +183,7 @@ async function execSetupCommand(
|
||||
command: string,
|
||||
options: { cwd: string; env: NodeJS.ProcessEnv }
|
||||
): Promise<WorktreeSetupCommandResult> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
cwd: options.cwd,
|
||||
@@ -165,6 +196,7 @@ async function execSetupCommand(
|
||||
stdout: stdout ?? "",
|
||||
stderr: stderr ?? "",
|
||||
exitCode: 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
@@ -175,10 +207,105 @@ async function execSetupCommand(
|
||||
error?.stderr ??
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
exitCode: typeof error?.code === "number" ? error.code : null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function execSetupCommandStreamed(options: {
|
||||
command: string;
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
index: number;
|
||||
total: number;
|
||||
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
|
||||
}): Promise<WorktreeSetupCommandResult> {
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = Date.now();
|
||||
const stdoutChunks: string[] = [];
|
||||
const stderrChunks: string[] = [];
|
||||
let settled = false;
|
||||
|
||||
const finish = (exitCode: number | null) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
const result: WorktreeSetupCommandResult = {
|
||||
command: options.command,
|
||||
cwd: options.cwd,
|
||||
stdout: stdoutChunks.join(""),
|
||||
stderr: stderrChunks.join(""),
|
||||
exitCode,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
options.onEvent?.({
|
||||
type: "command_completed",
|
||||
index: options.index,
|
||||
total: options.total,
|
||||
command: options.command,
|
||||
cwd: options.cwd,
|
||||
exitCode: result.exitCode,
|
||||
durationMs: result.durationMs,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
});
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
options.onEvent?.({
|
||||
type: "command_started",
|
||||
index: options.index,
|
||||
total: options.total,
|
||||
command: options.command,
|
||||
cwd: options.cwd,
|
||||
});
|
||||
|
||||
const child = spawn("/bin/bash", ["-lc", options.command], {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
const text = chunk.toString();
|
||||
stdoutChunks.push(text);
|
||||
options.onEvent?.({
|
||||
type: "output",
|
||||
index: options.index,
|
||||
total: options.total,
|
||||
command: options.command,
|
||||
cwd: options.cwd,
|
||||
stream: "stdout",
|
||||
chunk: text,
|
||||
});
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
const text = chunk.toString();
|
||||
stderrChunks.push(text);
|
||||
options.onEvent?.({
|
||||
type: "output",
|
||||
index: options.index,
|
||||
total: options.total,
|
||||
command: options.command,
|
||||
cwd: options.cwd,
|
||||
stream: "stderr",
|
||||
chunk: text,
|
||||
});
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
stderrChunks.push(error instanceof Error ? error.message : String(error));
|
||||
finish(null);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
finish(typeof code === "number" ? code : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -233,6 +360,7 @@ export async function runWorktreeSetupCommands(options: {
|
||||
branchName: string;
|
||||
cleanupOnFailure: boolean;
|
||||
repoRootPath?: string;
|
||||
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
|
||||
}): Promise<WorktreeSetupCommandResult[]> {
|
||||
// Read paseo.json from the worktree (it will have the same content as the source repo)
|
||||
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
|
||||
@@ -258,11 +386,20 @@ export async function runWorktreeSetupCommands(options: {
|
||||
};
|
||||
|
||||
const results: WorktreeSetupCommandResult[] = [];
|
||||
for (const cmd of setupCommands) {
|
||||
const result = await execSetupCommand(cmd, {
|
||||
cwd: options.worktreePath,
|
||||
env: setupEnv,
|
||||
});
|
||||
for (const [index, cmd] of setupCommands.entries()) {
|
||||
const result = options.onEvent
|
||||
? await execSetupCommandStreamed({
|
||||
command: cmd,
|
||||
cwd: options.worktreePath,
|
||||
env: setupEnv,
|
||||
index: index + 1,
|
||||
total: setupCommands.length,
|
||||
onEvent: options.onEvent,
|
||||
})
|
||||
: await execSetupCommand(cmd, {
|
||||
cwd: options.worktreePath,
|
||||
env: setupEnv,
|
||||
});
|
||||
results.push(result);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
|
||||
Reference in New Issue
Block a user