| import { useQuery } from "@tanstack/react-query"; | |
| import axios from "axios"; | |
| import { isSdkHttpError } from "#/api/agent-server-compatibility"; | |
| import BashService from "#/api/bash-service/bash-service.api"; | |
| import { useActiveBackend } from "#/contexts/active-backend-context"; | |
| import type { SandboxStatus } from "#/api/conversation-service/agent-server-conversation-service.types"; | |
| import { useUserConversation } from "./use-user-conversation"; | |
| export const BASH_COMMAND_LOGS_QUERY_KEY = ["bash-command-logs"] as const; | |
| /** | |
| * Reasons the modal can't fetch logs from a cloud sandbox, in priority | |
| * order. The hook surfaces at most one of these so the UI can render a | |
| * targeted message instead of a raw error. | |
| */ | |
| export type SandboxIssue = | |
| | "missing" // sandbox has been deleted (or conversation has no runtime URL) | |
| | "paused" // sandbox is paused — needs resuming | |
| | "starting" // sandbox is still booting | |
| | "errored" // sandbox is in a terminal error state | |
| | "unreachable"; // bash query attempted and failed at the network layer | |
| interface UseBashCommandLogsOptions { | |
| /** | |
| * The agent-server conversation that hosts the bash command. Used to | |
| * resolve `conversation_url` and `session_api_key` for cloud | |
| * backends, and to gate the query on `sandbox_status` so we don't | |
| * fire requests at known-unreachable sandboxes. | |
| */ | |
| conversationId: string | null | undefined; | |
| bashCommandId: string | null | undefined; | |
| enabled?: boolean; | |
| } | |
| /** | |
| * Map a cloud sandbox status to a stable issue code (or null when the | |
| * sandbox is healthy enough to attempt the fetch). | |
| */ | |
| function sandboxIssueFromStatus( | |
| status: SandboxStatus | null | undefined, | |
| ): SandboxIssue | null { | |
| switch (status) { | |
| case "MISSING": | |
| return "missing"; | |
| case "PAUSED": | |
| return "paused"; | |
| case "STARTING": | |
| return "starting"; | |
| case "ERROR": | |
| return "errored"; | |
| case "RUNNING": | |
| case null: | |
| case undefined: | |
| default: | |
| return null; | |
| } | |
| } | |
| /** | |
| * Detect "the runtime is unreachable" errors from the cloud proxy. The | |
| * proxy itself returns 5xx when the upstream sandbox is gone; runtimes | |
| * return 4xx/5xx for various ephemeral states. We classify 5xx and | |
| * network errors as "unreachable" so the modal can render the | |
| * sandbox-gone state instead of dumping a raw error. Cloud calls go | |
| * through the shared TypeScript client and throw its `HttpError`; | |
| * axios-shaped errors are still recognized as well. | |
| */ | |
| function classifyFetchError(error: unknown): SandboxIssue | null { | |
| const status = axios.isAxiosError(error) | |
| ? error.response?.status | |
| : isSdkHttpError(error) | |
| ? (error as { status: number }).status | |
| : undefined; | |
| if (status !== undefined) { | |
| // Treat 502/503/504 (proxy can't reach upstream) and 404 (sandbox or | |
| // resource no longer exists) as the sandbox being gone. We do not | |
| // collapse 401/403 here — those are auth bugs we want to surface. | |
| return status === 404 || status >= 500 ? "unreachable" : null; | |
| } | |
| // No status → the request never got a response. Axios reports these as | |
| // response-less errors; fetch (the shared client) throws `TypeError` | |
| // for network failures and `AbortError`/`TimeoutError` for timeouts, | |
| // sometimes wrapped in a plain `Error` with the original as `cause`. | |
| if (axios.isAxiosError(error) || error instanceof TypeError) { | |
| return "unreachable"; | |
| } | |
| if (error instanceof Error) { | |
| const causeName = error.cause instanceof Error ? error.cause.name : null; | |
| if ( | |
| error.name === "AbortError" || | |
| error.name === "TimeoutError" || | |
| causeName === "AbortError" || | |
| causeName === "TimeoutError" | |
| ) { | |
| return "unreachable"; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Search `BashOutput` events for an automation run's bash command. | |
| * | |
| * - **Local backend**: the query fires as soon as the modal opens and | |
| * we have a `bash_command_id`. The conversation lookup runs in | |
| * parallel; if it resolves with `session_api_key`/`conversation_url` | |
| * those are passed through, but a missing/stale conversation does not | |
| * block the bash query (the local agent-server hosts events under a | |
| * single root). | |
| * - **Cloud backend**: pre-checks `sandbox_status` and the existence of | |
| * a `conversation_url` before firing — paused, starting, errored, or | |
| * missing sandboxes report a `sandboxIssue` and skip the request | |
| * entirely (saves a doomed round-trip and gives the UI a targeted | |
| * empty state). If the request does fire and fails with a 5xx / | |
| * network error / 404 we re-classify it as `unreachable`. | |
| */ | |
| export function useBashCommandLogs(options: UseBashCommandLogsOptions) { | |
| const { conversationId, bashCommandId, enabled = true } = options; | |
| const active = useActiveBackend(); | |
| // Only resolve the conversation when the modal is open. RunLogsModal mounts | |
| // (closed) for every activity-log row, so an unconditional lookup would fire | |
| // one /api/conversations request per row on page load. Passing null when | |
| // disabled trips useUserConversation's own `!!cid` gate. | |
| const conversationQuery = useUserConversation( | |
| enabled ? (conversationId ?? null) : null, | |
| ); | |
| const conversation = conversationQuery.data; | |
| const conversationUrl = conversation?.conversation_url ?? null; | |
| const sessionApiKey = conversation?.session_api_key ?? null; | |
| const isCloud = active.backend.kind === "cloud"; | |
| const conversationFetched = conversationQuery.isFetched; | |
| // Resolve a single "sandbox issue" only for cloud backends. Local | |
| // backends don't carry sandbox_status, and the agent-server hosts | |
| // events under a single root so there's nothing to gate on. | |
| let preflightIssue: SandboxIssue | null = null; | |
| let conversationMissing = false; | |
| if (isCloud && conversationFetched) { | |
| if (!conversation) { | |
| conversationMissing = true; | |
| } else { | |
| preflightIssue = | |
| sandboxIssueFromStatus(conversation.sandbox_status) ?? | |
| (!conversation.conversation_url ? "missing" : null); | |
| } | |
| } | |
| // Cloud needs the conversation URL before it can talk to the | |
| // runtime; local does not. | |
| const hasRequiredAuth = isCloud ? !!conversationUrl : true; | |
| const canFire = | |
| enabled && | |
| !!bashCommandId && | |
| hasRequiredAuth && | |
| !preflightIssue && | |
| !conversationMissing; | |
| const query = useQuery({ | |
| queryKey: [ | |
| ...BASH_COMMAND_LOGS_QUERY_KEY, | |
| bashCommandId, | |
| conversationUrl, | |
| sessionApiKey, | |
| active.backend.id, | |
| active.orgId, | |
| ], | |
| queryFn: () => | |
| BashService.listOutputs( | |
| conversationUrl, | |
| sessionApiKey, | |
| bashCommandId as string, | |
| ), | |
| enabled: canFire, | |
| // Completed-run logs don't change — cache long enough that reopening | |
| // the modal is instant but not forever. | |
| staleTime: 60 * 1000, | |
| gcTime: 5 * 60 * 1000, | |
| retry: false, | |
| }); | |
| // If the request fired and failed in a way that suggests the | |
| // sandbox is gone/unreachable, surface it as a sandbox issue so the | |
| // modal can render the matching empty state instead of a raw error. | |
| const fetchIssue = isCloud ? classifyFetchError(query.error) : null; | |
| const sandboxIssue: SandboxIssue | null = preflightIssue ?? fetchIssue; | |
| return { | |
| data: query.data, | |
| /** | |
| * Set only when the request actually fired and failed AND the | |
| * failure isn't already classified as a sandbox issue. The modal | |
| * should render `sandboxIssue` first and only fall back to this. | |
| */ | |
| error: fetchIssue ? null : query.error, | |
| isFetching: query.isFetching, | |
| isPending: query.isPending, | |
| /** True while we're still resolving the conversation runtime URL. */ | |
| isResolvingConversation: isCloud && conversationQuery.isPending, | |
| /** Cloud-only: conversation lookup failed (deleted or no access). */ | |
| conversationMissing, | |
| /** | |
| * Reason the bash query couldn't / didn't usefully complete. Always | |
| * null for healthy cloud sandboxes and for local backends. | |
| */ | |
| sandboxIssue, | |
| }; | |
| } | |