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,
  };
}