File size: 7,993 Bytes
9b906ea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | 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,
};
}
|