feat: add relay reconnect with grace period and branch suggestions
This commit is contained in:
@@ -276,13 +276,11 @@ export function AgentInputArea({
|
|||||||
imageAttachments?: ImageAttachment[],
|
imageAttachments?: ImageAttachment[],
|
||||||
forceSend?: boolean
|
forceSend?: boolean
|
||||||
) {
|
) {
|
||||||
const socketConnected = isConnected;
|
|
||||||
const trimmedMessage = message.trim();
|
const trimmedMessage = message.trim();
|
||||||
if (!trimmedMessage) return;
|
if (!trimmedMessage) return;
|
||||||
// When the parent controls submission (e.g. draft agent creation), let it
|
// When the parent controls submission (e.g. draft agent creation), let it
|
||||||
// decide what to do even if the socket is currently disconnected (so we
|
// decide what to do even if the socket is currently disconnected (so we
|
||||||
// don't no-op and lose deterministic error handling in the UI/tests).
|
// don't no-op and lose deterministic error handling in the UI/tests).
|
||||||
if (!onSubmitMessageRef.current && !socketConnected) return;
|
|
||||||
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
|
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
|
||||||
|
|
||||||
if (agent?.status === "running" && !forceSend) {
|
if (agent?.status === "running" && !forceSend) {
|
||||||
@@ -485,7 +483,7 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
async function handleSendQueuedNow(id: string) {
|
async function handleSendQueuedNow(id: string) {
|
||||||
const item = queuedMessages.find((q) => q.id === id);
|
const item = queuedMessages.find((q) => q.id === id);
|
||||||
if (!item || !isConnected) return;
|
if (!item) return;
|
||||||
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
|
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
|
||||||
|
|
||||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||||
|
|||||||
@@ -731,7 +731,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
||||||
onContentSizeChange={handleContentSizeChange}
|
onContentSizeChange={handleContentSizeChange}
|
||||||
editable={
|
editable={
|
||||||
!isDictating && !isRealtimeVoiceForCurrentAgent && isConnected && !disabled
|
!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled
|
||||||
}
|
}
|
||||||
onKeyPress={
|
onKeyPress={
|
||||||
shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined
|
shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined
|
||||||
@@ -836,24 +836,22 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
)}
|
)}
|
||||||
{shouldShowSendButton && (
|
{shouldShowSendButton && (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={handleSendMessage}
|
onPress={handleSendMessage}
|
||||||
disabled={
|
disabled={
|
||||||
!isConnected ||
|
|
||||||
isSubmitDisabled ||
|
isSubmitDisabled ||
|
||||||
isSubmitLoading ||
|
isSubmitLoading ||
|
||||||
disabled
|
disabled
|
||||||
}
|
}
|
||||||
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
|
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
style={[
|
style={[
|
||||||
styles.sendButton,
|
styles.sendButton,
|
||||||
(!isConnected ||
|
(isSubmitDisabled ||
|
||||||
isSubmitDisabled ||
|
|
||||||
isSubmitLoading ||
|
isSubmitLoading ||
|
||||||
disabled) &&
|
disabled) &&
|
||||||
styles.buttonDisabled,
|
styles.buttonDisabled,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isSubmitLoading ? (
|
{isSubmitLoading ? (
|
||||||
<ActivityIndicator size="small" color="white" />
|
<ActivityIndicator size="small" color="white" />
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionProvider
|
<SessionProvider
|
||||||
key={`${daemon.serverId}:${activeUrl}`}
|
key={daemon.serverId}
|
||||||
serverUrl={activeUrl}
|
serverUrl={activeUrl}
|
||||||
serverId={daemon.serverId}
|
serverId={daemon.serverId}
|
||||||
activeConnection={active?.activeConnection ?? null}
|
activeConnection={active?.activeConnection ?? null}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface ComboboxProps {
|
|||||||
options: ComboboxOption[];
|
options: ComboboxOption[];
|
||||||
value: string;
|
value: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
|
onSearchQueryChange?: (query: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
@@ -156,6 +157,7 @@ export function Combobox({
|
|||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onSearchQueryChange,
|
||||||
placeholder = "Search...",
|
placeholder = "Search...",
|
||||||
searchPlaceholder,
|
searchPlaceholder,
|
||||||
emptyText = "No options match your search.",
|
emptyText = "No options match your search.",
|
||||||
@@ -191,16 +193,24 @@ export function Combobox({
|
|||||||
[isControlled, onOpenChange]
|
[isControlled, onOpenChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setSearchQueryWithCallback = useCallback(
|
||||||
|
(nextQuery: string) => {
|
||||||
|
setSearchQuery(nextQuery);
|
||||||
|
onSearchQueryChange?.(nextQuery);
|
||||||
|
},
|
||||||
|
[onSearchQueryChange]
|
||||||
|
);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setSearchQuery("");
|
setSearchQueryWithCallback("");
|
||||||
}, [setOpen]);
|
}, [setOpen, setSearchQueryWithCallback]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setSearchQuery("");
|
setSearchQueryWithCallback("");
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen, setSearchQueryWithCallback]);
|
||||||
|
|
||||||
const collisionPadding = useMemo(() => {
|
const collisionPadding = useMemo(() => {
|
||||||
const basePadding = 16;
|
const basePadding = 16;
|
||||||
@@ -426,7 +436,7 @@ export function Combobox({
|
|||||||
<SearchInput
|
<SearchInput
|
||||||
placeholder={searchPlaceholder ?? placeholder}
|
placeholder={searchPlaceholder ?? placeholder}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChangeText={setSearchQuery}
|
onChangeText={setSearchQueryWithCallback}
|
||||||
onSubmitEditing={handleSubmitSearch}
|
onSubmitEditing={handleSubmitSearch}
|
||||||
autoFocus={!isMobile}
|
autoFocus={!isMobile}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import {
|
|||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import type { Agent } from "@/contexts/session-context";
|
import type { Agent } from "@/contexts/session-context";
|
||||||
@@ -74,6 +73,8 @@ import {
|
|||||||
|
|
||||||
const DROPDOWN_WIDTH = 220;
|
const DROPDOWN_WIDTH = 220;
|
||||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||||
|
const RECONNECT_NOTICE_DELAY_MS = 10_000;
|
||||||
|
const CONNECTED_NOTICE_DURATION_MS = 2_500;
|
||||||
|
|
||||||
export function AgentReadyScreen({
|
export function AgentReadyScreen({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -101,7 +102,6 @@ export function AgentReadyScreen({
|
|||||||
const isUnknownDaemon = Boolean(connectionServerId && !connection);
|
const isUnknownDaemon = Boolean(connectionServerId && !connection);
|
||||||
const connectionStatus =
|
const connectionStatus =
|
||||||
connection?.status ?? (isUnknownDaemon ? "offline" : "idle");
|
connection?.status ?? (isUnknownDaemon ? "offline" : "idle");
|
||||||
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
|
|
||||||
const lastConnectionError = connection?.lastError ?? null;
|
const lastConnectionError = connection?.lastError ?? null;
|
||||||
|
|
||||||
const handleBackToHome = useCallback(() => {
|
const handleBackToHome = useCallback(() => {
|
||||||
@@ -152,7 +152,6 @@ export function AgentReadyScreen({
|
|||||||
onBack={handleBackToHome}
|
onBack={handleBackToHome}
|
||||||
serverLabel={serverLabel}
|
serverLabel={serverLabel}
|
||||||
connectionStatus={connectionStatus}
|
connectionStatus={connectionStatus}
|
||||||
connectionStatusLabel={connectionStatusLabel}
|
|
||||||
lastError={lastConnectionError}
|
lastError={lastConnectionError}
|
||||||
isUnknownDaemon={isUnknownDaemon}
|
isUnknownDaemon={isUnknownDaemon}
|
||||||
/>
|
/>
|
||||||
@@ -164,6 +163,7 @@ export function AgentReadyScreen({
|
|||||||
<AgentScreenContent
|
<AgentScreenContent
|
||||||
serverId={resolvedServerId}
|
serverId={resolvedServerId}
|
||||||
agentId={resolvedAgentId}
|
agentId={resolvedAgentId}
|
||||||
|
connectionStatus={connectionStatus}
|
||||||
/>
|
/>
|
||||||
</ExplorerSidebarAnimationProvider>
|
</ExplorerSidebarAnimationProvider>
|
||||||
);
|
);
|
||||||
@@ -172,6 +172,7 @@ export function AgentReadyScreen({
|
|||||||
type AgentScreenContentProps = {
|
type AgentScreenContentProps = {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
connectionStatus: ConnectionStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MissingAgentState =
|
type MissingAgentState =
|
||||||
@@ -194,6 +195,7 @@ function isNotFoundErrorMessage(message: string): boolean {
|
|||||||
function AgentScreenContent({
|
function AgentScreenContent({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
|
connectionStatus,
|
||||||
}: AgentScreenContentProps) {
|
}: AgentScreenContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -383,6 +385,11 @@ function AgentScreenContent({
|
|||||||
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
|
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
|
||||||
kind: "idle",
|
kind: "idle",
|
||||||
});
|
});
|
||||||
|
const [showReconnectNotice, setShowReconnectNotice] = useState(false);
|
||||||
|
const [dismissedReconnectNotice, setDismissedReconnectNotice] = useState(false);
|
||||||
|
const [showConnectedNotice, setShowConnectedNotice] = useState(false);
|
||||||
|
const reconnectNoticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const connectedNoticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const initAttemptTokenRef = useRef(0);
|
const initAttemptTokenRef = useRef(0);
|
||||||
const setFocusedAgentId = useCallback(
|
const setFocusedAgentId = useCallback(
|
||||||
(agentId: string | null) => {
|
(agentId: string | null) => {
|
||||||
@@ -410,6 +417,56 @@ function AgentScreenContent({
|
|||||||
const agentModel = extractAgentModel(agent);
|
const agentModel = extractAgentModel(agent);
|
||||||
const modelDisplayValue = agentModel ?? "Unknown";
|
const modelDisplayValue = agentModel ?? "Unknown";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reconnectNoticeTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectNoticeTimeoutRef.current);
|
||||||
|
reconnectNoticeTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectionStatus === "online") {
|
||||||
|
if (showReconnectNotice || dismissedReconnectNotice) {
|
||||||
|
setShowConnectedNotice(true);
|
||||||
|
}
|
||||||
|
setShowReconnectNotice(false);
|
||||||
|
setDismissedReconnectNotice(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowConnectedNotice(false);
|
||||||
|
if (!showReconnectNotice && !dismissedReconnectNotice) {
|
||||||
|
reconnectNoticeTimeoutRef.current = setTimeout(() => {
|
||||||
|
setShowReconnectNotice(true);
|
||||||
|
}, RECONNECT_NOTICE_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (reconnectNoticeTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectNoticeTimeoutRef.current);
|
||||||
|
reconnectNoticeTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [connectionStatus, dismissedReconnectNotice, showReconnectNotice]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showConnectedNotice) {
|
||||||
|
if (connectedNoticeTimeoutRef.current) {
|
||||||
|
clearTimeout(connectedNoticeTimeoutRef.current);
|
||||||
|
connectedNoticeTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectedNoticeTimeoutRef.current = setTimeout(() => {
|
||||||
|
setShowConnectedNotice(false);
|
||||||
|
connectedNoticeTimeoutRef.current = null;
|
||||||
|
}, CONNECTED_NOTICE_DURATION_MS);
|
||||||
|
return () => {
|
||||||
|
if (connectedNoticeTimeoutRef.current) {
|
||||||
|
clearTimeout(connectedNoticeTimeoutRef.current);
|
||||||
|
connectedNoticeTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [showConnectedNotice]);
|
||||||
|
|
||||||
// Checkout status for header subtitle
|
// Checkout status for header subtitle
|
||||||
const checkoutStatusQuery = useCheckoutStatusQuery({
|
const checkoutStatusQuery = useCheckoutStatusQuery({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -902,6 +959,47 @@ function AgentScreenContent({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{(showReconnectNotice || showConnectedNotice) && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.connectionNotice,
|
||||||
|
showReconnectNotice
|
||||||
|
? styles.connectionNoticeReconnecting
|
||||||
|
: styles.connectionNoticeConnected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{showConnectedNotice ? (
|
||||||
|
<CheckCircle2
|
||||||
|
size={14}
|
||||||
|
color={theme.colors.palette.green[600]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.connectionNoticeText,
|
||||||
|
showReconnectNotice
|
||||||
|
? styles.connectionNoticeTextReconnecting
|
||||||
|
: styles.connectionNoticeTextConnected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{showReconnectNotice ? "Reconnecting..." : "Connected"}
|
||||||
|
</Text>
|
||||||
|
{showReconnectNotice ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setShowReconnectNotice(false);
|
||||||
|
setDismissedReconnectNotice(true);
|
||||||
|
}}
|
||||||
|
style={styles.connectionNoticeDismiss}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Dismiss reconnecting notice"
|
||||||
|
>
|
||||||
|
<Text style={styles.connectionNoticeDismissText}>Dismiss</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Content Area with Keyboard Animation */}
|
{/* Content Area with Keyboard Animation */}
|
||||||
<View style={styles.contentContainer}>
|
<View style={styles.contentContainer}>
|
||||||
{shouldBlockForHistorySync ? (
|
{shouldBlockForHistorySync ? (
|
||||||
@@ -967,14 +1065,12 @@ function AgentSessionUnavailableState({
|
|||||||
onBack,
|
onBack,
|
||||||
serverLabel,
|
serverLabel,
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
connectionStatusLabel,
|
|
||||||
lastError,
|
lastError,
|
||||||
isUnknownDaemon = false,
|
isUnknownDaemon = false,
|
||||||
}: {
|
}: {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
serverLabel: string;
|
serverLabel: string;
|
||||||
connectionStatus: ConnectionStatus;
|
connectionStatus: ConnectionStatus;
|
||||||
connectionStatusLabel: string;
|
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
isUnknownDaemon?: boolean;
|
isUnknownDaemon?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -1015,11 +1111,10 @@ function AgentSessionUnavailableState({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.offlineTitle}>
|
<Text style={styles.offlineTitle}>
|
||||||
{serverLabel} is currently {connectionStatusLabel.toLowerCase()}.
|
Reconnecting to {serverLabel}...
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.offlineDescription}>
|
<Text style={styles.offlineDescription}>
|
||||||
We'll reconnect automatically and show this agent as soon as the
|
We will show this agent again as soon as the host is reachable.
|
||||||
host comes back online.
|
|
||||||
</Text>
|
</Text>
|
||||||
{lastError ? (
|
{lastError ? (
|
||||||
<Text style={styles.offlineDetails}>{lastError}</Text>
|
<Text style={styles.offlineDetails}>{lastError}</Text>
|
||||||
@@ -1049,6 +1144,49 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
|
connectionNotice: {
|
||||||
|
marginHorizontal: theme.spacing[4],
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
minHeight: 32,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
connectionNoticeReconnecting: {
|
||||||
|
backgroundColor: `${theme.colors.palette.yellow[400]}22`,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.palette.yellow[400],
|
||||||
|
},
|
||||||
|
connectionNoticeConnected: {
|
||||||
|
backgroundColor: theme.colors.palette.green[100],
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.palette.green[400],
|
||||||
|
},
|
||||||
|
connectionNoticeText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
},
|
||||||
|
connectionNoticeTextReconnecting: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
connectionNoticeTextConnected: {
|
||||||
|
color: theme.colors.palette.green[800],
|
||||||
|
},
|
||||||
|
connectionNoticeDismiss: {
|
||||||
|
marginLeft: "auto",
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
|
connectionNoticeDismissText: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
|
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -249,6 +250,8 @@ export function DraftAgentScreen({
|
|||||||
const [isWorkingDirOpen, setIsWorkingDirOpen] = useState(false);
|
const [isWorkingDirOpen, setIsWorkingDirOpen] = useState(false);
|
||||||
const [isWorktreePickerOpen, setIsWorktreePickerOpen] = useState(false);
|
const [isWorktreePickerOpen, setIsWorktreePickerOpen] = useState(false);
|
||||||
const [isBranchOpen, setIsBranchOpen] = useState(false);
|
const [isBranchOpen, setIsBranchOpen] = useState(false);
|
||||||
|
const [branchSearchQuery, setBranchSearchQuery] = useState("");
|
||||||
|
const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState("");
|
||||||
const hostAnchorRef = useRef<View>(null);
|
const hostAnchorRef = useRef<View>(null);
|
||||||
const workingDirAnchorRef = useRef<View>(null);
|
const workingDirAnchorRef = useRef<View>(null);
|
||||||
const worktreeAnchorRef = useRef<View>(null);
|
const worktreeAnchorRef = useRef<View>(null);
|
||||||
@@ -258,6 +261,12 @@ export function DraftAgentScreen({
|
|||||||
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
|
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
|
||||||
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear);
|
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = branchSearchQuery.trim();
|
||||||
|
const timer = setTimeout(() => setDebouncedBranchSearchQuery(trimmed), 180);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [branchSearchQuery]);
|
||||||
|
|
||||||
type CreateAttempt = {
|
type CreateAttempt = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -489,6 +498,40 @@ export function DraftAgentScreen({
|
|||||||
? "Select a worktree to attach"
|
? "Select a worktree to attach"
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const branchSuggestionsQuery = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"branchSuggestions",
|
||||||
|
selectedServerId,
|
||||||
|
trimmedWorkingDir,
|
||||||
|
debouncedBranchSearchQuery,
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
const client = sessionClient;
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Daemon client unavailable");
|
||||||
|
}
|
||||||
|
const payload = await client.getBranchSuggestions({
|
||||||
|
cwd: trimmedWorkingDir || ".",
|
||||||
|
query: debouncedBranchSearchQuery || undefined,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
return payload.branches ?? [];
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
isCreateWorktree &&
|
||||||
|
isGitDirectory &&
|
||||||
|
!isNonGitDirectory &&
|
||||||
|
Boolean(trimmedWorkingDir) &&
|
||||||
|
!repoAvailabilityError &&
|
||||||
|
Boolean(sessionClient) &&
|
||||||
|
isConnected,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
const validateWorktreeName = useCallback(
|
const validateWorktreeName = useCallback(
|
||||||
(name: string): { valid: boolean; error?: string } => {
|
(name: string): { valid: boolean; error?: string } => {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -658,21 +701,36 @@ export function DraftAgentScreen({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const branchComboOptions = useMemo(() => {
|
const branchComboOptions = useMemo(() => {
|
||||||
const branchSet = new Set<string>();
|
const options = buildBranchComboOptions({
|
||||||
const currentBranch = checkout?.isGit ? checkout.currentBranch?.trim() : null;
|
suggestedBranches: branchSuggestionsQuery.data ?? [],
|
||||||
if (currentBranch && currentBranch !== "HEAD") {
|
currentBranch: checkout?.isGit ? checkout.currentBranch : null,
|
||||||
branchSet.add(currentBranch);
|
baseRef: checkout?.isGit ? checkout.baseRef : null,
|
||||||
|
typedBaseBranch: baseBranch,
|
||||||
|
worktreeBranchLabels: worktreeOptions.map((option) => option.label),
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedQuery = normalizeBranchOptionName(branchSearchQuery)?.toLowerCase() ?? "";
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return options;
|
||||||
}
|
}
|
||||||
if (baseBranch.trim()) {
|
|
||||||
branchSet.add(baseBranch.trim());
|
return options.sort((a, b) => {
|
||||||
}
|
const aLower = a.label.toLowerCase();
|
||||||
for (const option of worktreeOptions) {
|
const bLower = b.label.toLowerCase();
|
||||||
if (option.label) {
|
const aPrefix = aLower.startsWith(normalizedQuery);
|
||||||
branchSet.add(option.label);
|
const bPrefix = bLower.startsWith(normalizedQuery);
|
||||||
|
if (aPrefix !== bPrefix) {
|
||||||
|
return aPrefix ? -1 : 1;
|
||||||
}
|
}
|
||||||
}
|
return aLower.localeCompare(bLower);
|
||||||
return Array.from(branchSet).map((name) => ({ id: name, label: name }));
|
});
|
||||||
}, [baseBranch, checkout, worktreeOptions]);
|
}, [
|
||||||
|
baseBranch,
|
||||||
|
branchSearchQuery,
|
||||||
|
branchSuggestionsQuery.data,
|
||||||
|
checkout,
|
||||||
|
worktreeOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
const createAgentClient = useSessionStore((state) =>
|
const createAgentClient = useSessionStore((state) =>
|
||||||
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
|
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
|
||||||
@@ -1130,13 +1188,19 @@ export function DraftAgentScreen({
|
|||||||
options={branchComboOptions}
|
options={branchComboOptions}
|
||||||
value={baseBranch}
|
value={baseBranch}
|
||||||
onSelect={handleBaseBranchChange}
|
onSelect={handleBaseBranchChange}
|
||||||
|
onSearchQueryChange={setBranchSearchQuery}
|
||||||
searchPlaceholder="Choose a base branch..."
|
searchPlaceholder="Choose a base branch..."
|
||||||
allowCustomValue
|
allowCustomValue
|
||||||
customValuePrefix="Use"
|
customValuePrefix="Use"
|
||||||
customValueDescription="Use this branch name"
|
customValueDescription="Use this branch name"
|
||||||
title="Select base branch"
|
title="Select base branch"
|
||||||
open={isBranchOpen}
|
open={isBranchOpen}
|
||||||
onOpenChange={setIsBranchOpen}
|
onOpenChange={(nextOpen) => {
|
||||||
|
setIsBranchOpen(nextOpen);
|
||||||
|
if (!nextOpen) {
|
||||||
|
setBranchSearchQuery("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
anchorRef={branchAnchorRef}
|
anchorRef={branchAnchorRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildBranchComboOptions, normalizeBranchOptionName } from "./branch-suggestions";
|
||||||
|
|
||||||
|
describe("normalizeBranchOptionName", () => {
|
||||||
|
it("normalizes local and origin-prefixed refs", () => {
|
||||||
|
expect(normalizeBranchOptionName("refs/heads/main")).toBe("main");
|
||||||
|
expect(normalizeBranchOptionName("refs/remotes/origin/main")).toBe("main");
|
||||||
|
expect(normalizeBranchOptionName("origin/feature/test")).toBe("feature/test");
|
||||||
|
expect(normalizeBranchOptionName("feature/test")).toBe("feature/test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out empty values and HEAD", () => {
|
||||||
|
expect(normalizeBranchOptionName("")).toBeNull();
|
||||||
|
expect(normalizeBranchOptionName(" ")).toBeNull();
|
||||||
|
expect(normalizeBranchOptionName("HEAD")).toBeNull();
|
||||||
|
expect(normalizeBranchOptionName("origin/HEAD")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildBranchComboOptions", () => {
|
||||||
|
it("merges branch sources and de-duplicates normalized names", () => {
|
||||||
|
const options = buildBranchComboOptions({
|
||||||
|
suggestedBranches: ["origin/main", "refs/remotes/origin/main", "feature/a"],
|
||||||
|
currentBranch: "refs/heads/feature/a",
|
||||||
|
baseRef: "origin/main",
|
||||||
|
typedBaseBranch: "main",
|
||||||
|
worktreeBranchLabels: ["refs/heads/release/next"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(options).toEqual([
|
||||||
|
{ id: "main", label: "main" },
|
||||||
|
{ id: "feature/a", label: "feature/a" },
|
||||||
|
{ id: "release/next", label: "release/next" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export type BranchComboOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeBranchOptionName(input: string | null | undefined): string | null {
|
||||||
|
const trimmed = input?.trim();
|
||||||
|
if (!trimmed || trimmed === "HEAD") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = trimmed;
|
||||||
|
if (normalized.startsWith("refs/heads/")) {
|
||||||
|
normalized = normalized.slice("refs/heads/".length);
|
||||||
|
} else if (normalized.startsWith("refs/remotes/")) {
|
||||||
|
normalized = normalized.slice("refs/remotes/".length);
|
||||||
|
}
|
||||||
|
if (normalized.startsWith("origin/")) {
|
||||||
|
normalized = normalized.slice("origin/".length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.length > 0 && normalized !== "HEAD" ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBranchComboOptions(input: {
|
||||||
|
suggestedBranches?: string[];
|
||||||
|
currentBranch?: string | null;
|
||||||
|
baseRef?: string | null;
|
||||||
|
typedBaseBranch?: string | null;
|
||||||
|
worktreeBranchLabels?: string[];
|
||||||
|
}): BranchComboOption[] {
|
||||||
|
const branchSet = new Set<string>();
|
||||||
|
const addBranch = (name: string | null | undefined) => {
|
||||||
|
const normalized = normalizeBranchOptionName(name);
|
||||||
|
if (normalized) {
|
||||||
|
branchSet.add(normalized);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const branch of input.suggestedBranches ?? []) {
|
||||||
|
addBranch(branch);
|
||||||
|
}
|
||||||
|
addBranch(input.currentBranch ?? null);
|
||||||
|
addBranch(input.baseRef ?? null);
|
||||||
|
addBranch(input.typedBaseBranch ?? null);
|
||||||
|
for (const label of input.worktreeBranchLabels ?? []) {
|
||||||
|
addBranch(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(branchSet).map((name) => ({ id: name, label: name }));
|
||||||
|
}
|
||||||
@@ -287,6 +287,65 @@ describe("DaemonClient", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("requests branch suggestions via RPC", 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 promise = client.getBranchSuggestions(
|
||||||
|
{ cwd: "/tmp/project", query: "mai", limit: 5 },
|
||||||
|
"req-branches"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mock.sent).toHaveLength(1);
|
||||||
|
const request = JSON.parse(mock.sent[0]) as {
|
||||||
|
type: "session";
|
||||||
|
message: {
|
||||||
|
type: "branch_suggestions_request";
|
||||||
|
cwd: string;
|
||||||
|
query?: string;
|
||||||
|
limit?: number;
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(request.message.type).toBe("branch_suggestions_request");
|
||||||
|
expect(request.message.cwd).toBe("/tmp/project");
|
||||||
|
expect(request.message.query).toBe("mai");
|
||||||
|
expect(request.message.limit).toBe(5);
|
||||||
|
expect(request.message.requestId).toBe("req-branches");
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "branch_suggestions_response",
|
||||||
|
payload: {
|
||||||
|
branches: ["main"],
|
||||||
|
error: null,
|
||||||
|
requestId: "req-branches",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toEqual({
|
||||||
|
branches: ["main"],
|
||||||
|
error: null,
|
||||||
|
requestId: "req-branches",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("resubscribes checkout diff streams after reconnect", async () => {
|
test("resubscribes checkout diff streams after reconnect", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
CheckoutPrCreateResponse,
|
CheckoutPrCreateResponse,
|
||||||
CheckoutPrStatusResponse,
|
CheckoutPrStatusResponse,
|
||||||
ValidateBranchResponse,
|
ValidateBranchResponse,
|
||||||
|
BranchSuggestionsResponse,
|
||||||
PaseoWorktreeListResponse,
|
PaseoWorktreeListResponse,
|
||||||
PaseoWorktreeArchiveResponse,
|
PaseoWorktreeArchiveResponse,
|
||||||
ProjectIconResponse,
|
ProjectIconResponse,
|
||||||
@@ -191,6 +192,7 @@ type CheckoutPushPayload = CheckoutPushResponse["payload"];
|
|||||||
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
||||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||||
type ValidateBranchPayload = ValidateBranchResponse["payload"];
|
type ValidateBranchPayload = ValidateBranchResponse["payload"];
|
||||||
|
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
|
||||||
type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"];
|
type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"];
|
||||||
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"];
|
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"];
|
||||||
type FileExplorerPayload = FileExplorerResponse["payload"];
|
type FileExplorerPayload = FileExplorerResponse["payload"];
|
||||||
@@ -1936,6 +1938,35 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getBranchSuggestions(
|
||||||
|
options: { cwd: string; query?: string; limit?: number },
|
||||||
|
requestId?: string
|
||||||
|
): Promise<BranchSuggestionsPayload> {
|
||||||
|
const resolvedRequestId = this.createRequestId(requestId);
|
||||||
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
type: "branch_suggestions_request",
|
||||||
|
cwd: options.cwd,
|
||||||
|
query: options.query,
|
||||||
|
limit: options.limit,
|
||||||
|
requestId: resolvedRequestId,
|
||||||
|
});
|
||||||
|
return this.sendRequest({
|
||||||
|
requestId: resolvedRequestId,
|
||||||
|
message,
|
||||||
|
timeout: 10000,
|
||||||
|
options: { skipQueue: true },
|
||||||
|
select: (msg) => {
|
||||||
|
if (msg.type !== "branch_suggestions_response") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (msg.payload.requestId !== resolvedRequestId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return msg.payload;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// File Explorer
|
// File Explorer
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -538,11 +538,11 @@ export async function createPaseoDaemon(
|
|||||||
relayTransport?.stop().catch(() => undefined);
|
relayTransport?.stop().catch(() => undefined);
|
||||||
relayTransport = startRelayTransport({
|
relayTransport = startRelayTransport({
|
||||||
logger,
|
logger,
|
||||||
attachSocket: (ws) => {
|
attachSocket: (ws, metadata) => {
|
||||||
if (!wsServer) {
|
if (!wsServer) {
|
||||||
throw new Error("WebSocket server not initialized");
|
throw new Error("WebSocket server not initialized");
|
||||||
}
|
}
|
||||||
return wsServer.attachExternalSocket(ws);
|
return wsServer.attachExternalSocket(ws, metadata);
|
||||||
},
|
},
|
||||||
relayEndpoint,
|
relayEndpoint,
|
||||||
serverId,
|
serverId,
|
||||||
|
|||||||
@@ -188,4 +188,36 @@ describe("relay-transport control lifecycle", () => {
|
|||||||
expect(hasLogMessage(logger.warn, "relay_control_stale_terminating")).toBe(true);
|
expect(hasLogMessage(logger.warn, "relay_control_stale_terminating")).toBe(true);
|
||||||
expect(control.terminateCalls).toBe(1);
|
expect(control.terminateCalls).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("passes stable relay external session metadata when attaching data socket", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const attachSocket = vi.fn(async () => {});
|
||||||
|
const controller = startRelayTransport({
|
||||||
|
logger: logger as any,
|
||||||
|
attachSocket,
|
||||||
|
relayEndpoint: "relay.paseo.sh:443",
|
||||||
|
serverId: "srv_test",
|
||||||
|
});
|
||||||
|
controllers.push(controller);
|
||||||
|
|
||||||
|
const control = MockWebSocket.instances[0];
|
||||||
|
control.open();
|
||||||
|
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
|
||||||
|
control.message(JSON.stringify({ type: "client_connected", clientId: "clt_test" }));
|
||||||
|
|
||||||
|
const dataSocket = MockWebSocket.instances[1];
|
||||||
|
expect(dataSocket).toBeDefined();
|
||||||
|
dataSocket.open();
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(attachSocket).toHaveBeenCalledTimes(1);
|
||||||
|
expect(attachSocket).toHaveBeenCalledWith(
|
||||||
|
dataSocket,
|
||||||
|
{
|
||||||
|
transport: "relay",
|
||||||
|
externalSessionKey: "relay:clt_test",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import {
|
|||||||
type KeyPair,
|
type KeyPair,
|
||||||
} from "@getpaseo/relay/e2ee";
|
} from "@getpaseo/relay/e2ee";
|
||||||
import { buildRelayWebSocketUrl } from "../shared/daemon-endpoints.js";
|
import { buildRelayWebSocketUrl } from "../shared/daemon-endpoints.js";
|
||||||
|
import type { ExternalSocketMetadata } from "./websocket-server.js";
|
||||||
|
|
||||||
type RelayTransportOptions = {
|
type RelayTransportOptions = {
|
||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
attachSocket: (ws: RelaySocketLike) => Promise<void>;
|
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>;
|
||||||
relayEndpoint: string; // "host:port"
|
relayEndpoint: string; // "host:port"
|
||||||
serverId: string;
|
serverId: string;
|
||||||
daemonKeyPair?: KeyPair;
|
daemonKeyPair?: KeyPair;
|
||||||
@@ -321,10 +322,20 @@ export function startRelayTransport({
|
|||||||
relayLogger.info({ url, clientId }, "relay_data_connected");
|
relayLogger.info({ url, clientId }, "relay_data_connected");
|
||||||
if (attached) return;
|
if (attached) return;
|
||||||
attached = true;
|
attached = true;
|
||||||
|
const externalMetadata: ExternalSocketMetadata = {
|
||||||
|
transport: "relay",
|
||||||
|
externalSessionKey: `relay:${clientId}`,
|
||||||
|
};
|
||||||
if (daemonKeyPair) {
|
if (daemonKeyPair) {
|
||||||
void attachEncryptedSocket(socket, daemonKeyPair, relayLogger.child({ clientId }), attachSocket);
|
void attachEncryptedSocket(
|
||||||
|
socket,
|
||||||
|
daemonKeyPair,
|
||||||
|
relayLogger.child({ clientId }),
|
||||||
|
attachSocket,
|
||||||
|
externalMetadata
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
void attachSocket(socket);
|
void attachSocket(socket, externalMetadata);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -353,7 +364,8 @@ async function attachEncryptedSocket(
|
|||||||
socket: WebSocket,
|
socket: WebSocket,
|
||||||
daemonKeyPair: KeyPair,
|
daemonKeyPair: KeyPair,
|
||||||
logger: pino.Logger,
|
logger: pino.Logger,
|
||||||
attachSocket: (ws: RelaySocketLike) => Promise<void>
|
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>,
|
||||||
|
metadata?: ExternalSocketMetadata
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const relayTransport = createRelayTransportAdapter(socket);
|
const relayTransport = createRelayTransportAdapter(socket);
|
||||||
@@ -367,7 +379,7 @@ async function attachEncryptedSocket(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const encryptedSocket = createEncryptedSocket(channel, emitter);
|
const encryptedSocket = createEncryptedSocket(channel, emitter);
|
||||||
await attachSocket(encryptedSocket);
|
await attachSocket(encryptedSocket, metadata);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn({ err: error }, "relay_e2ee_handshake_failed");
|
logger.warn({ err: error }, "relay_e2ee_handshake_failed");
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ import {
|
|||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
getCheckoutStatusLite,
|
getCheckoutStatusLite,
|
||||||
|
listBranchSuggestions,
|
||||||
NotGitRepoError,
|
NotGitRepoError,
|
||||||
MergeConflictError,
|
MergeConflictError,
|
||||||
MergeFromBaseConflictError,
|
MergeFromBaseConflictError,
|
||||||
@@ -1232,6 +1233,10 @@ export class Session {
|
|||||||
await this.handleValidateBranchRequest(msg);
|
await this.handleValidateBranchRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "branch_suggestions_request":
|
||||||
|
await this.handleBranchSuggestionsRequest(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
case "subscribe_checkout_diff_request":
|
case "subscribe_checkout_diff_request":
|
||||||
await this.handleSubscribeCheckoutDiffRequest(msg);
|
await this.handleSubscribeCheckoutDiffRequest(msg);
|
||||||
break;
|
break;
|
||||||
@@ -3589,6 +3594,34 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleBranchSuggestionsRequest(
|
||||||
|
msg: Extract<SessionInboundMessage, { type: "branch_suggestions_request" }>
|
||||||
|
): Promise<void> {
|
||||||
|
const { cwd, query, limit, requestId } = msg;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolvedCwd = expandTilde(cwd);
|
||||||
|
const branches = await listBranchSuggestions(resolvedCwd, { query, limit });
|
||||||
|
this.emit({
|
||||||
|
type: "branch_suggestions_response",
|
||||||
|
payload: {
|
||||||
|
branches,
|
||||||
|
error: null,
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.emit({
|
||||||
|
type: "branch_suggestions_response",
|
||||||
|
payload: {
|
||||||
|
branches: [],
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeCheckoutDiffCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput {
|
private normalizeCheckoutDiffCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput {
|
||||||
if (compare.mode === "uncommitted") {
|
if (compare.mode === "uncommitted") {
|
||||||
return { mode: "uncommitted" };
|
return { mode: "uncommitted" };
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
const wsModuleMock = vi.hoisted(() => {
|
||||||
|
class MockWebSocketServer {
|
||||||
|
static instances: MockWebSocketServer[] = [];
|
||||||
|
readonly handlers = new Map<string, (...args: any[]) => void>();
|
||||||
|
|
||||||
|
constructor(_options: unknown) {
|
||||||
|
MockWebSocketServer.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event: string, handler: (...args: any[]) => void) {
|
||||||
|
this.handlers.set(event, handler);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { MockWebSocketServer };
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionMock = vi.hoisted(() => {
|
||||||
|
const instances: MockSession[] = [];
|
||||||
|
|
||||||
|
class MockSession {
|
||||||
|
cleanup = vi.fn(async () => {});
|
||||||
|
handleMessage = vi.fn(async () => {});
|
||||||
|
getClientActivity = vi.fn(() => null);
|
||||||
|
readonly args: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(args: Record<string, unknown>) {
|
||||||
|
this.args = args;
|
||||||
|
instances.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { MockSession, instances };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("ws", () => ({
|
||||||
|
WebSocketServer: wsModuleMock.MockWebSocketServer,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./session.js", () => ({
|
||||||
|
Session: sessionMock.MockSession,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./push/token-store.js", () => ({
|
||||||
|
PushTokenStore: class {
|
||||||
|
getAllTokens(): string[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./push/push-service.js", () => ({
|
||||||
|
PushService: class {
|
||||||
|
async sendPush(): Promise<void> {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
VoiceAssistantWebSocketServer,
|
||||||
|
type ExternalSocketMetadata,
|
||||||
|
} from "./websocket-server";
|
||||||
|
|
||||||
|
class MockSocket {
|
||||||
|
readyState = 1;
|
||||||
|
sent: string[] = [];
|
||||||
|
private listeners = new Map<string, Array<(...args: any[]) => void>>();
|
||||||
|
|
||||||
|
on(event: "message" | "close" | "error", listener: (...args: any[]) => void): void {
|
||||||
|
const handlers = this.listeners.get(event) ?? [];
|
||||||
|
handlers.push(listener);
|
||||||
|
this.listeners.set(event, handlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
once(event: "close" | "error", listener: (...args: any[]) => void): void {
|
||||||
|
const wrapped = (...args: any[]) => {
|
||||||
|
this.off(event, wrapped);
|
||||||
|
listener(...args);
|
||||||
|
};
|
||||||
|
this.on(event, wrapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: string): void {
|
||||||
|
this.sent.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(code?: number, reason?: string): void {
|
||||||
|
this.readyState = 3;
|
||||||
|
this.emit("close", code ?? 1000, reason ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: "message" | "close" | "error", ...args: any[]): void {
|
||||||
|
const handlers = this.listeners.get(event) ?? [];
|
||||||
|
for (const handler of [...handlers]) {
|
||||||
|
handler(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private off(event: "close" | "error", listener: (...args: any[]) => void): void {
|
||||||
|
const handlers = this.listeners.get(event) ?? [];
|
||||||
|
this.listeners.set(
|
||||||
|
event,
|
||||||
|
handlers.filter((handler) => handler !== listener)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLogger() {
|
||||||
|
const logger = {
|
||||||
|
child: vi.fn(() => logger),
|
||||||
|
trace: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
return logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServer() {
|
||||||
|
return new VoiceAssistantWebSocketServer(
|
||||||
|
{} as any,
|
||||||
|
createLogger() as any,
|
||||||
|
"srv_test",
|
||||||
|
{
|
||||||
|
setAgentAttentionCallback: vi.fn(),
|
||||||
|
getAgent: vi.fn(() => null),
|
||||||
|
} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
"/tmp/paseo-test",
|
||||||
|
async () => ({} as any),
|
||||||
|
{ allowedOrigins: new Set() }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("relay external socket reconnect behavior", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionMock.instances.length = 0;
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the same session when relay reconnects within grace window", async () => {
|
||||||
|
const server = createServer();
|
||||||
|
const metadata: ExternalSocketMetadata = {
|
||||||
|
transport: "relay",
|
||||||
|
externalSessionKey: "relay:client-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const socket1 = new MockSocket();
|
||||||
|
await server.attachExternalSocket(socket1, metadata);
|
||||||
|
expect(sessionMock.instances).toHaveLength(1);
|
||||||
|
const session = sessionMock.instances[0]!;
|
||||||
|
|
||||||
|
socket1.emit("close", 1006, "");
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
expect(session.cleanup).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const socket2 = new MockSocket();
|
||||||
|
await server.attachExternalSocket(socket2, metadata);
|
||||||
|
expect(sessionMock.instances).toHaveLength(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(20_000);
|
||||||
|
expect(session.cleanup).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cleans up relay session when reconnect grace expires", async () => {
|
||||||
|
const server = createServer();
|
||||||
|
const metadata: ExternalSocketMetadata = {
|
||||||
|
transport: "relay",
|
||||||
|
externalSessionKey: "relay:client-2",
|
||||||
|
};
|
||||||
|
|
||||||
|
const socket1 = new MockSocket();
|
||||||
|
await server.attachExternalSocket(socket1, metadata);
|
||||||
|
expect(sessionMock.instances).toHaveLength(1);
|
||||||
|
const session = sessionMock.instances[0]!;
|
||||||
|
|
||||||
|
socket1.emit("close", 1006, "");
|
||||||
|
await vi.advanceTimersByTimeAsync(90_000);
|
||||||
|
expect(session.cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,10 @@ import type {
|
|||||||
} from "./voice-types.js";
|
} from "./voice-types.js";
|
||||||
|
|
||||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||||
|
export type ExternalSocketMetadata = {
|
||||||
|
transport: "relay";
|
||||||
|
externalSessionKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
type WebSocketServerConfig = {
|
type WebSocketServerConfig = {
|
||||||
allowedOrigins: Set<string>;
|
allowedOrigins: Set<string>;
|
||||||
@@ -56,13 +60,25 @@ type WebSocketLike = {
|
|||||||
once: (event: "close" | "error", listener: (...args: any[]) => void) => void;
|
once: (event: "close" | "error", listener: (...args: any[]) => void) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SessionConnection = {
|
||||||
|
session: Session;
|
||||||
|
clientId: string;
|
||||||
|
connectionLogger: pino.Logger;
|
||||||
|
socketRef: { current: WebSocketLike };
|
||||||
|
externalSessionKey: string | null;
|
||||||
|
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket server that only accepts sockets + parses/forwards messages to the session layer.
|
* WebSocket server that only accepts sockets + parses/forwards messages to the session layer.
|
||||||
*/
|
*/
|
||||||
export class VoiceAssistantWebSocketServer {
|
export class VoiceAssistantWebSocketServer {
|
||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
private readonly wss: WebSocketServer;
|
private readonly wss: WebSocketServer;
|
||||||
private readonly sessions: Map<WebSocketLike, Session> = new Map();
|
private readonly sessions: Map<WebSocketLike, SessionConnection> = new Map();
|
||||||
|
private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map();
|
||||||
private clientIdCounter = 0;
|
private clientIdCounter = 0;
|
||||||
private readonly serverId: string;
|
private readonly serverId: string;
|
||||||
private readonly agentManager: AgentManager;
|
private readonly agentManager: AgentManager;
|
||||||
@@ -191,15 +207,27 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async attachExternalSocket(
|
public async attachExternalSocket(
|
||||||
ws: WebSocketLike
|
ws: WebSocketLike,
|
||||||
|
metadata?: ExternalSocketMetadata
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.attachSocket(ws);
|
await this.attachSocket(ws, undefined, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async close(): Promise<void> {
|
public async close(): Promise<void> {
|
||||||
|
const uniqueConnections = new Set<SessionConnection>([
|
||||||
|
...this.sessions.values(),
|
||||||
|
...this.externalSessionsByKey.values(),
|
||||||
|
]);
|
||||||
|
|
||||||
const cleanupPromises: Promise<void>[] = [];
|
const cleanupPromises: Promise<void>[] = [];
|
||||||
for (const [ws, session] of this.sessions) {
|
for (const connection of uniqueConnections) {
|
||||||
cleanupPromises.push(session.cleanup());
|
if (connection.externalDisconnectCleanupTimeout) {
|
||||||
|
clearTimeout(connection.externalDisconnectCleanupTimeout);
|
||||||
|
connection.externalDisconnectCleanupTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = connection.socketRef.current;
|
||||||
|
cleanupPromises.push(connection.session.cleanup());
|
||||||
cleanupPromises.push(
|
cleanupPromises.push(
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
// WebSocket.CLOSED = 3
|
// WebSocket.CLOSED = 3
|
||||||
@@ -214,6 +242,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}
|
}
|
||||||
await Promise.all(cleanupPromises);
|
await Promise.all(cleanupPromises);
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
|
this.externalSessionsByKey.clear();
|
||||||
this.wss.close();
|
this.wss.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,14 +253,53 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async attachSocket(ws: WebSocketLike, _request?: unknown): Promise<void> {
|
private async attachSocket(
|
||||||
|
ws: WebSocketLike,
|
||||||
|
_request?: unknown,
|
||||||
|
metadata?: ExternalSocketMetadata
|
||||||
|
): Promise<void> {
|
||||||
|
const externalSessionKey =
|
||||||
|
metadata?.transport === "relay" && metadata.externalSessionKey.trim().length > 0
|
||||||
|
? metadata.externalSessionKey
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (externalSessionKey) {
|
||||||
|
const existing = this.externalSessionsByKey.get(externalSessionKey);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.externalDisconnectCleanupTimeout) {
|
||||||
|
clearTimeout(existing.externalDisconnectCleanupTimeout);
|
||||||
|
existing.externalDisconnectCleanupTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousSocket = existing.socketRef.current;
|
||||||
|
if (previousSocket !== ws) {
|
||||||
|
this.sessions.delete(previousSocket);
|
||||||
|
existing.socketRef.current = ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessions.set(ws, existing);
|
||||||
|
this.sendServerInfo(ws);
|
||||||
|
existing.connectionLogger.trace(
|
||||||
|
{
|
||||||
|
clientId: existing.clientId,
|
||||||
|
externalSessionKey,
|
||||||
|
totalSessions: this.sessions.size,
|
||||||
|
},
|
||||||
|
"Client reconnected"
|
||||||
|
);
|
||||||
|
this.bindSocketHandlers(ws, existing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const clientId = `client-${++this.clientIdCounter}`;
|
const clientId = `client-${++this.clientIdCounter}`;
|
||||||
const connectionLogger = this.logger.child({ clientId });
|
const connectionLogger = this.logger.child({ clientId });
|
||||||
|
const socketRef = { current: ws };
|
||||||
|
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
clientId,
|
clientId,
|
||||||
onMessage: (msg) => {
|
onMessage: (msg) => {
|
||||||
this.sendToClient(ws, wrapSessionMessage(msg));
|
this.sendToClient(socketRef.current, wrapSessionMessage(msg));
|
||||||
},
|
},
|
||||||
logger: connectionLogger.child({ module: "session" }),
|
logger: connectionLogger.child({ module: "session" }),
|
||||||
downloadTokenStore: this.downloadTokenStore,
|
downloadTokenStore: this.downloadTokenStore,
|
||||||
@@ -264,8 +332,31 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
|
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sessions.set(ws, session);
|
const connection: SessionConnection = {
|
||||||
|
session,
|
||||||
|
clientId,
|
||||||
|
connectionLogger,
|
||||||
|
socketRef,
|
||||||
|
externalSessionKey,
|
||||||
|
externalDisconnectCleanupTimeout: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.sessions.set(ws, connection);
|
||||||
|
if (externalSessionKey) {
|
||||||
|
this.externalSessionsByKey.set(externalSessionKey, connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendServerInfo(ws);
|
||||||
|
|
||||||
|
connectionLogger.trace(
|
||||||
|
{ clientId, externalSessionKey, totalSessions: this.sessions.size },
|
||||||
|
"Client connected"
|
||||||
|
);
|
||||||
|
|
||||||
|
this.bindSocketHandlers(ws, connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendServerInfo(ws: WebSocketLike): void {
|
||||||
// Advertise stable server identity immediately on connect (used for URL/shareable IDs).
|
// Advertise stable server identity immediately on connect (used for URL/shareable IDs).
|
||||||
this.sendToClient(
|
this.sendToClient(
|
||||||
ws,
|
ws,
|
||||||
@@ -278,24 +369,27 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
connectionLogger.trace(
|
private bindSocketHandlers(
|
||||||
{ clientId, totalSessions: this.sessions.size },
|
ws: WebSocketLike,
|
||||||
"Client connected"
|
connection: SessionConnection
|
||||||
);
|
): void {
|
||||||
|
|
||||||
ws.on("message", (data) => {
|
ws.on("message", (data) => {
|
||||||
void this.handleRawMessage(ws, data);
|
void this.handleRawMessage(ws, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("close", async () => {
|
ws.on("close", async (code: number, reason: unknown) => {
|
||||||
await this.detachSocket(ws, connectionLogger, clientId);
|
await this.detachSocket(ws, connection, {
|
||||||
|
code: typeof code === "number" ? code : undefined,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("error", async (error) => {
|
ws.on("error", async (error) => {
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
connectionLogger.error({ err }, "Client error");
|
connection.connectionLogger.error({ err }, "Client error");
|
||||||
await this.detachSocket(ws, connectionLogger, clientId);
|
await this.detachSocket(ws, connection, { error: err });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,19 +407,72 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
|
|
||||||
private async detachSocket(
|
private async detachSocket(
|
||||||
ws: WebSocketLike,
|
ws: WebSocketLike,
|
||||||
connectionLogger: pino.Logger,
|
connection: SessionConnection,
|
||||||
clientId: string
|
details: {
|
||||||
|
code?: number;
|
||||||
|
reason?: unknown;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const session = this.sessions.get(ws);
|
const activeConnection = this.sessions.get(ws);
|
||||||
if (!session) return;
|
if (activeConnection !== connection) return;
|
||||||
|
|
||||||
connectionLogger.trace(
|
|
||||||
{ clientId, totalSessions: this.sessions.size - 1 },
|
|
||||||
"Client disconnected"
|
|
||||||
);
|
|
||||||
|
|
||||||
await session.cleanup();
|
|
||||||
this.sessions.delete(ws);
|
this.sessions.delete(ws);
|
||||||
|
|
||||||
|
if (
|
||||||
|
connection.externalSessionKey &&
|
||||||
|
connection.socketRef.current === ws
|
||||||
|
) {
|
||||||
|
if (connection.externalDisconnectCleanupTimeout) {
|
||||||
|
clearTimeout(connection.externalDisconnectCleanupTimeout);
|
||||||
|
}
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (connection.externalDisconnectCleanupTimeout !== timeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connection.externalDisconnectCleanupTimeout = null;
|
||||||
|
void this.cleanupConnection(connection, "Client disconnected (grace timeout)");
|
||||||
|
}, EXTERNAL_SESSION_DISCONNECT_GRACE_MS);
|
||||||
|
connection.externalDisconnectCleanupTimeout = timeout;
|
||||||
|
|
||||||
|
connection.connectionLogger.trace(
|
||||||
|
{
|
||||||
|
clientId: connection.clientId,
|
||||||
|
externalSessionKey: connection.externalSessionKey,
|
||||||
|
code: details.code,
|
||||||
|
reason: stringifyCloseReason(details.reason),
|
||||||
|
reconnectGraceMs: EXTERNAL_SESSION_DISCONNECT_GRACE_MS,
|
||||||
|
},
|
||||||
|
"Client disconnected; waiting for reconnect"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.cleanupConnection(connection, "Client disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupConnection(
|
||||||
|
connection: SessionConnection,
|
||||||
|
logMessage: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (connection.externalDisconnectCleanupTimeout) {
|
||||||
|
clearTimeout(connection.externalDisconnectCleanupTimeout);
|
||||||
|
connection.externalDisconnectCleanupTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSocket = connection.socketRef.current;
|
||||||
|
this.sessions.delete(currentSocket);
|
||||||
|
if (connection.externalSessionKey) {
|
||||||
|
const existing = this.externalSessionsByKey.get(connection.externalSessionKey);
|
||||||
|
if (existing === connection) {
|
||||||
|
this.externalSessionsByKey.delete(connection.externalSessionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.connectionLogger.trace(
|
||||||
|
{ clientId: connection.clientId, totalSessions: this.sessions.size },
|
||||||
|
logMessage
|
||||||
|
);
|
||||||
|
await connection.session.cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleRawMessage(
|
private async handleRawMessage(
|
||||||
@@ -395,14 +542,14 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = this.sessions.get(ws);
|
const connection = this.sessions.get(ws);
|
||||||
if (!session) {
|
if (!connection) {
|
||||||
this.logger.error("No session found for client");
|
this.logger.error("No session found for client");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === "session") {
|
if (message.type === "session") {
|
||||||
await session.handleMessage(message.message);
|
await connection.session.handleMessage(message.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
@@ -555,10 +702,10 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
};
|
};
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const [ws, session] of this.sessions) {
|
for (const [ws, connection] of this.sessions) {
|
||||||
clientEntries.push({
|
clientEntries.push({
|
||||||
ws,
|
ws,
|
||||||
state: this.getClientActivityState(session),
|
state: this.getClientActivityState(connection.session),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,6 +773,21 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stringifyCloseReason(reason: unknown): string | null {
|
||||||
|
if (typeof reason === "string") {
|
||||||
|
return reason.length > 0 ? reason : null;
|
||||||
|
}
|
||||||
|
if (Buffer.isBuffer(reason)) {
|
||||||
|
const text = reason.toString();
|
||||||
|
return text.length > 0 ? text : null;
|
||||||
|
}
|
||||||
|
if (reason == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const text = String(reason);
|
||||||
|
return text.length > 0 ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
function extractRequestInfoFromUnknownWsInbound(
|
function extractRequestInfoFromUnknownWsInbound(
|
||||||
payload: unknown
|
payload: unknown
|
||||||
): { requestId: string; requestType?: string } | null {
|
): { requestId: string; requestType?: string } | null {
|
||||||
|
|||||||
@@ -791,6 +791,14 @@ export const ValidateBranchRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const BranchSuggestionsRequestSchema = z.object({
|
||||||
|
type: z.literal("branch_suggestions_request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
query: z.string().optional(),
|
||||||
|
limit: z.number().int().min(1).max(200).optional(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const PaseoWorktreeListRequestSchema = z.object({
|
export const PaseoWorktreeListRequestSchema = z.object({
|
||||||
type: z.literal("paseo_worktree_list_request"),
|
type: z.literal("paseo_worktree_list_request"),
|
||||||
cwd: z.string().optional(),
|
cwd: z.string().optional(),
|
||||||
@@ -1015,6 +1023,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPrCreateRequestSchema,
|
CheckoutPrCreateRequestSchema,
|
||||||
CheckoutPrStatusRequestSchema,
|
CheckoutPrStatusRequestSchema,
|
||||||
ValidateBranchRequestSchema,
|
ValidateBranchRequestSchema,
|
||||||
|
BranchSuggestionsRequestSchema,
|
||||||
PaseoWorktreeListRequestSchema,
|
PaseoWorktreeListRequestSchema,
|
||||||
PaseoWorktreeArchiveRequestSchema,
|
PaseoWorktreeArchiveRequestSchema,
|
||||||
FileExplorerRequestSchema,
|
FileExplorerRequestSchema,
|
||||||
@@ -1565,6 +1574,15 @@ export const ValidateBranchResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const BranchSuggestionsResponseSchema = z.object({
|
||||||
|
type: z.literal("branch_suggestions_response"),
|
||||||
|
payload: z.object({
|
||||||
|
branches: z.array(z.string()),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const PaseoWorktreeSchema = z.object({
|
const PaseoWorktreeSchema = z.object({
|
||||||
worktreePath: z.string(),
|
worktreePath: z.string(),
|
||||||
branchName: z.string().nullable().optional(),
|
branchName: z.string().nullable().optional(),
|
||||||
@@ -1835,6 +1853,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPrCreateResponseSchema,
|
CheckoutPrCreateResponseSchema,
|
||||||
CheckoutPrStatusResponseSchema,
|
CheckoutPrStatusResponseSchema,
|
||||||
ValidateBranchResponseSchema,
|
ValidateBranchResponseSchema,
|
||||||
|
BranchSuggestionsResponseSchema,
|
||||||
PaseoWorktreeListResponseSchema,
|
PaseoWorktreeListResponseSchema,
|
||||||
PaseoWorktreeArchiveResponseSchema,
|
PaseoWorktreeArchiveResponseSchema,
|
||||||
FileExplorerResponseSchema,
|
FileExplorerResponseSchema,
|
||||||
@@ -1966,6 +1985,8 @@ export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSche
|
|||||||
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
|
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
|
||||||
export type ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
|
export type ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
|
||||||
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
|
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
|
||||||
|
export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>;
|
||||||
|
export type BranchSuggestionsResponse = z.infer<typeof BranchSuggestionsResponseSchema>;
|
||||||
export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSchema>;
|
export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSchema>;
|
||||||
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
||||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
getCheckoutStatusLite,
|
getCheckoutStatusLite,
|
||||||
|
listBranchSuggestions,
|
||||||
mergeToBase,
|
mergeToBase,
|
||||||
mergeFromBase,
|
mergeFromBase,
|
||||||
MergeConflictError,
|
MergeConflictError,
|
||||||
@@ -402,6 +403,51 @@ describe("checkout git utilities", () => {
|
|||||||
execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`);
|
execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists merged local and remote branch suggestions without duplicates", async () => {
|
||||||
|
const remoteDir = join(tempDir, "remote.git");
|
||||||
|
execSync(`git init --bare -b main ${remoteDir}`);
|
||||||
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
||||||
|
execSync("git push -u origin main", { cwd: repoDir });
|
||||||
|
|
||||||
|
execSync("git checkout -b local-only", { cwd: repoDir });
|
||||||
|
execSync("git checkout main", { cwd: repoDir });
|
||||||
|
|
||||||
|
const otherClone = join(tempDir, "other-clone");
|
||||||
|
execSync(`git clone ${remoteDir} ${otherClone}`);
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
|
||||||
|
execSync("git config user.name 'Test'", { cwd: otherClone });
|
||||||
|
execSync("git checkout -b remote-only", { cwd: otherClone });
|
||||||
|
writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n");
|
||||||
|
execSync("git add remote-only.txt", { cwd: otherClone });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'remote only branch'", { cwd: otherClone });
|
||||||
|
execSync("git push -u origin remote-only", { cwd: otherClone });
|
||||||
|
execSync("git fetch origin", { cwd: repoDir });
|
||||||
|
|
||||||
|
const branches = await listBranchSuggestions(repoDir, { limit: 50 });
|
||||||
|
expect(branches).toContain("main");
|
||||||
|
expect(branches).toContain("local-only");
|
||||||
|
expect(branches).toContain("remote-only");
|
||||||
|
expect(branches.filter((name) => name === "main")).toHaveLength(1);
|
||||||
|
expect(branches).not.toContain("HEAD");
|
||||||
|
expect(branches.some((name) => name.startsWith("origin/"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters branch suggestions by query and enforces result limit", async () => {
|
||||||
|
execSync("git checkout -b feature/alpha", { cwd: repoDir });
|
||||||
|
execSync("git checkout main", { cwd: repoDir });
|
||||||
|
execSync("git checkout -b feature/beta", { cwd: repoDir });
|
||||||
|
execSync("git checkout main", { cwd: repoDir });
|
||||||
|
execSync("git checkout -b chore/docs", { cwd: repoDir });
|
||||||
|
execSync("git checkout main", { cwd: repoDir });
|
||||||
|
|
||||||
|
const branches = await listBranchSuggestions(repoDir, {
|
||||||
|
query: "FEATURE/",
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
expect(branches).toHaveLength(1);
|
||||||
|
expect(branches[0]?.toLowerCase()).toContain("feature/");
|
||||||
|
});
|
||||||
|
|
||||||
it("disables GitHub features when gh is unavailable", async () => {
|
it("disables GitHub features when gh is unavailable", async () => {
|
||||||
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,128 @@ type CheckoutFileChange = {
|
|||||||
isUntracked?: boolean;
|
isUntracked?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BranchSuggestionRefOrigin = "local" | "remote";
|
||||||
|
|
||||||
|
function normalizeBranchSuggestionName(raw: string): string | null {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = trimmed;
|
||||||
|
if (normalized.startsWith("refs/heads/")) {
|
||||||
|
normalized = normalized.slice("refs/heads/".length);
|
||||||
|
} else if (normalized.startsWith("refs/remotes/")) {
|
||||||
|
normalized = normalized.slice("refs/remotes/".length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("origin/")) {
|
||||||
|
normalized = normalized.slice("origin/".length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalized || normalized === "HEAD") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listGitRefs(cwd: string, refPrefix: string): Promise<string[]> {
|
||||||
|
const { stdout } = await execGit(
|
||||||
|
`git for-each-ref --format="%(refname:short)" ${refPrefix}`,
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortBranchSuggestions(
|
||||||
|
branchNames: string[],
|
||||||
|
localBranchNames: Set<string>,
|
||||||
|
query: string
|
||||||
|
): string[] {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const hasQuery = normalizedQuery.length > 0;
|
||||||
|
return branchNames.sort((a, b) => {
|
||||||
|
const aLower = a.toLowerCase();
|
||||||
|
const bLower = b.toLowerCase();
|
||||||
|
|
||||||
|
if (hasQuery) {
|
||||||
|
const aPrefix = aLower.startsWith(normalizedQuery);
|
||||||
|
const bPrefix = bLower.startsWith(normalizedQuery);
|
||||||
|
if (aPrefix !== bPrefix) {
|
||||||
|
return aPrefix ? -1 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const aIsLocal = localBranchNames.has(a);
|
||||||
|
const bIsLocal = localBranchNames.has(b);
|
||||||
|
if (aIsLocal !== bIsLocal) {
|
||||||
|
return aIsLocal ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBranchSuggestions(
|
||||||
|
cwd: string,
|
||||||
|
options?: { query?: string; limit?: number }
|
||||||
|
): Promise<string[]> {
|
||||||
|
await requireGitRepo(cwd);
|
||||||
|
|
||||||
|
const requestedLimit = options?.limit ?? 50;
|
||||||
|
const limit = Math.max(1, Math.min(200, requestedLimit));
|
||||||
|
const query = options?.query?.trim().toLowerCase() ?? "";
|
||||||
|
|
||||||
|
const [localRefs, remoteRefs] = await Promise.all([
|
||||||
|
listGitRefs(cwd, "refs/heads"),
|
||||||
|
listGitRefs(cwd, "refs/remotes/origin"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const merged = new Map<string, Set<BranchSuggestionRefOrigin>>();
|
||||||
|
for (const localRef of localRefs) {
|
||||||
|
const normalized = normalizeBranchSuggestionName(localRef);
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const origins = merged.get(normalized) ?? new Set<BranchSuggestionRefOrigin>();
|
||||||
|
origins.add("local");
|
||||||
|
merged.set(normalized, origins);
|
||||||
|
}
|
||||||
|
for (const remoteRef of remoteRefs) {
|
||||||
|
const normalized = normalizeBranchSuggestionName(remoteRef);
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const origins = merged.get(normalized) ?? new Set<BranchSuggestionRefOrigin>();
|
||||||
|
origins.add("remote");
|
||||||
|
merged.set(normalized, origins);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredNames = Array.from(merged.keys()).filter((name) =>
|
||||||
|
query ? name.toLowerCase().includes(query) : true
|
||||||
|
);
|
||||||
|
if (filteredNames.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const localBranchNames = new Set<string>();
|
||||||
|
for (const [name, origins] of merged) {
|
||||||
|
if (origins.has("local")) {
|
||||||
|
localBranchNames.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = sortBranchSuggestions(filteredNames, localBranchNames, query);
|
||||||
|
return ordered.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
async function listCheckoutFileChanges(cwd: string, ref: string): Promise<CheckoutFileChange[]> {
|
async function listCheckoutFileChanges(cwd: string, ref: string): Promise<CheckoutFileChange[]> {
|
||||||
const changes: CheckoutFileChange[] = [];
|
const changes: CheckoutFileChange[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,8 @@ describe("createWorktree", () => {
|
|||||||
const paseoConfig = {
|
const paseoConfig = {
|
||||||
worktree: {
|
worktree: {
|
||||||
setup: [
|
setup: [
|
||||||
'echo "root=$PASEO_ROOT_PATH" > setup.log',
|
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > setup.log',
|
||||||
|
'echo "root_alias=$PASEO_ROOT_PATH" >> setup.log',
|
||||||
'echo "worktree=$PASEO_WORKTREE_PATH" >> setup.log',
|
'echo "worktree=$PASEO_WORKTREE_PATH" >> setup.log',
|
||||||
'echo "branch=$PASEO_BRANCH_NAME" >> setup.log',
|
'echo "branch=$PASEO_BRANCH_NAME" >> setup.log',
|
||||||
'echo "port=$PASEO_WORKTREE_PORT" >> setup.log',
|
'echo "port=$PASEO_WORKTREE_PORT" >> setup.log',
|
||||||
@@ -193,7 +194,8 @@ describe("createWorktree", () => {
|
|||||||
|
|
||||||
// Verify setup ran and env vars were available
|
// Verify setup ran and env vars were available
|
||||||
const setupLog = readFileSync(join(result.worktreePath, "setup.log"), "utf8");
|
const setupLog = readFileSync(join(result.worktreePath, "setup.log"), "utf8");
|
||||||
expect(setupLog).toContain(`root=${repoDir}`);
|
expect(setupLog).toContain(`source=${repoDir}`);
|
||||||
|
expect(setupLog).toContain(`root_alias=${repoDir}`);
|
||||||
expect(setupLog).toContain(`worktree=${result.worktreePath}`);
|
expect(setupLog).toContain(`worktree=${result.worktreePath}`);
|
||||||
expect(setupLog).toContain("branch=setup-test");
|
expect(setupLog).toContain("branch=setup-test");
|
||||||
const portLine = setupLog
|
const portLine = setupLog
|
||||||
@@ -335,9 +337,10 @@ describe("paseo worktree manager", () => {
|
|||||||
const paseoConfig = {
|
const paseoConfig = {
|
||||||
worktree: {
|
worktree: {
|
||||||
destroy: [
|
destroy: [
|
||||||
'echo "root=$PASEO_ROOT_PATH" > "$PASEO_ROOT_PATH/destroy.log"',
|
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||||
'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_ROOT_PATH/destroy.log"',
|
'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||||
'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_ROOT_PATH/destroy.log"',
|
'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||||
|
'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -359,7 +362,8 @@ describe("paseo worktree manager", () => {
|
|||||||
expect(existsSync(created.worktreePath)).toBe(false);
|
expect(existsSync(created.worktreePath)).toBe(false);
|
||||||
|
|
||||||
const destroyLog = readFileSync(join(repoDir, "destroy.log"), "utf8");
|
const destroyLog = readFileSync(join(repoDir, "destroy.log"), "utf8");
|
||||||
expect(destroyLog).toContain(`root=${repoDir}`);
|
expect(destroyLog).toContain(`source=${repoDir}`);
|
||||||
|
expect(destroyLog).toContain(`root_alias=${repoDir}`);
|
||||||
expect(destroyLog).toContain(`worktree=${created.worktreePath}`);
|
expect(destroyLog).toContain(`worktree=${created.worktreePath}`);
|
||||||
expect(destroyLog).toContain("branch=destroy-branch");
|
expect(destroyLog).toContain("branch=destroy-branch");
|
||||||
});
|
});
|
||||||
@@ -368,7 +372,7 @@ describe("paseo worktree manager", () => {
|
|||||||
const paseoConfig = {
|
const paseoConfig = {
|
||||||
worktree: {
|
worktree: {
|
||||||
destroy: [
|
destroy: [
|
||||||
'echo "started" > "$PASEO_ROOT_PATH/destroy-start.log"',
|
'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy-start.log"',
|
||||||
"echo boom 1>&2; exit 9",
|
"echo boom 1>&2; exit 9",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -204,8 +204,11 @@ export async function runWorktreeSetupCommands(options: {
|
|||||||
|
|
||||||
const setupEnv = {
|
const setupEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
// Root is the original git repo root (shared across worktrees), not the worktree itself.
|
// Source checkout path is the original git repo root (shared across worktrees), not the
|
||||||
// This allows setup scripts to copy uncommitted local files (e.g. .env) from the main checkout.
|
// worktree itself. This allows setup scripts to copy local files (e.g. .env) from the
|
||||||
|
// source checkout.
|
||||||
|
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
|
||||||
|
// Backward-compatible alias.
|
||||||
PASEO_ROOT_PATH: repoRootPath,
|
PASEO_ROOT_PATH: repoRootPath,
|
||||||
PASEO_WORKTREE_PATH: options.worktreePath,
|
PASEO_WORKTREE_PATH: options.worktreePath,
|
||||||
PASEO_BRANCH_NAME: options.branchName,
|
PASEO_BRANCH_NAME: options.branchName,
|
||||||
@@ -275,8 +278,11 @@ export async function runWorktreeDestroyCommands(options: {
|
|||||||
|
|
||||||
const destroyEnv = {
|
const destroyEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
// Root is the original git repo root (shared across worktrees), not the worktree itself.
|
// Source checkout path is the original git repo root (shared across worktrees), not the
|
||||||
// This allows destroy scripts to clean resources using paths from the main checkout.
|
// worktree itself. This allows destroy scripts to clean resources using paths from the
|
||||||
|
// source checkout.
|
||||||
|
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
|
||||||
|
// Backward-compatible alias.
|
||||||
PASEO_ROOT_PATH: repoRootPath,
|
PASEO_ROOT_PATH: repoRootPath,
|
||||||
PASEO_WORKTREE_PATH: options.worktreePath,
|
PASEO_WORKTREE_PATH: options.worktreePath,
|
||||||
PASEO_BRANCH_NAME: branchName,
|
PASEO_BRANCH_NAME: branchName,
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function Worktrees() {
|
|||||||
"worktree": {
|
"worktree": {
|
||||||
"setup": [
|
"setup": [
|
||||||
"npm ci",
|
"npm ci",
|
||||||
"cp \\"$PASEO_ROOT_PATH/.env\\" \\"$PASEO_WORKTREE_PATH/.env\\""
|
"cp \\"$PASEO_SOURCE_CHECKOUT_PATH/.env\\" \\"$PASEO_WORKTREE_PATH/.env\\""
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}`}</pre>
|
}`}</pre>
|
||||||
@@ -120,6 +120,13 @@ function Worktrees() {
|
|||||||
after the worktree is created. Use it to install dependencies, copy local config
|
after the worktree is created. Use it to install dependencies, copy local config
|
||||||
files, or run any other initialization.
|
files, or run any other initialization.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4 text-white/80">
|
||||||
|
<strong>Important:</strong> Setup commands come from{' '}
|
||||||
|
<code className="font-mono">paseo.json</code> in the selected base branch. If you pick{' '}
|
||||||
|
<code className="font-mono">main</code>, Paseo reads the committed file on{' '}
|
||||||
|
<code className="font-mono">main</code>. Local or uncommitted changes in another branch
|
||||||
|
are not used for that worktree.
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Environment variables */}
|
{/* Environment variables */}
|
||||||
@@ -130,7 +137,12 @@ function Worktrees() {
|
|||||||
</p>
|
</p>
|
||||||
<ul className="text-white/60 space-y-2 list-disc list-inside">
|
<ul className="text-white/60 space-y-2 list-disc list-inside">
|
||||||
<li>
|
<li>
|
||||||
<code className="font-mono">$PASEO_ROOT_PATH</code> — your original repository root
|
<code className="font-mono">$PASEO_SOURCE_CHECKOUT_PATH</code> — your source checkout
|
||||||
|
path (original repository root)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="font-mono">$PASEO_ROOT_PATH</code> — legacy alias of{' '}
|
||||||
|
<code className="font-mono">$PASEO_SOURCE_CHECKOUT_PATH</code>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<code className="font-mono">$PASEO_WORKTREE_PATH</code> — the new worktree directory
|
<code className="font-mono">$PASEO_WORKTREE_PATH</code> — the new worktree directory
|
||||||
@@ -143,9 +155,9 @@ function Worktrees() {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-white/60 leading-relaxed">
|
<p className="text-white/60 leading-relaxed">
|
||||||
Use <code className="font-mono">$PASEO_ROOT_PATH</code> to copy files that shouldn't
|
Use <code className="font-mono">$PASEO_SOURCE_CHECKOUT_PATH</code> to copy files that
|
||||||
be in git (like <code className="font-mono">.env</code>) from your main checkout to
|
shouldn't be in git (like <code className="font-mono">.env</code>) from your source
|
||||||
the worktree.
|
checkout to the worktree.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -177,8 +189,8 @@ function Worktrees() {
|
|||||||
"worktree": {
|
"worktree": {
|
||||||
"setup": [
|
"setup": [
|
||||||
"npm ci",
|
"npm ci",
|
||||||
"cp \\"$PASEO_ROOT_PATH/.env\\" \\"$PASEO_WORKTREE_PATH/.env\\"",
|
"cp \\"$PASEO_SOURCE_CHECKOUT_PATH/.env\\" \\"$PASEO_WORKTREE_PATH/.env\\"",
|
||||||
"cp \\"$PASEO_ROOT_PATH/.env.local\\" \\"$PASEO_WORKTREE_PATH/.env.local\\""
|
"cp \\"$PASEO_SOURCE_CHECKOUT_PATH/.env.local\\" \\"$PASEO_WORKTREE_PATH/.env.local\\""
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}`}</pre>
|
}`}</pre>
|
||||||
@@ -190,7 +202,7 @@ function Worktrees() {
|
|||||||
"worktree": {
|
"worktree": {
|
||||||
"setup": [
|
"setup": [
|
||||||
"npm ci",
|
"npm ci",
|
||||||
"cp \\"$PASEO_ROOT_PATH/.env\\" \\"$PASEO_WORKTREE_PATH/.env\\"",
|
"cp \\"$PASEO_SOURCE_CHECKOUT_PATH/.env\\" \\"$PASEO_WORKTREE_PATH/.env\\"",
|
||||||
"npm run db:migrate"
|
"npm run db:migrate"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"worktree": {
|
"worktree": {
|
||||||
"setup": [
|
"setup": [
|
||||||
"npm ci",
|
"npm ci",
|
||||||
"cp \"$PASEO_ROOT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\""
|
"cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\""
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user