openhands / src /hooks /query /use-bash-command-logs.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
9b906ea verified
Raw
History Blame Contribute Delete
7.99 kB
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,
};
}