File size: 4,347 Bytes
3d700dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { BashClient } from "@openhands/typescript-client/clients";
import type {
  BashEvent,
  BashEventPage,
  BashOutput,
} from "@openhands/typescript-client";
import { buildHttpBaseUrl } from "#/utils/websocket-url";
import { getActiveBackend } from "../backend-registry/active-store";
import { callCloudProxy } from "../cloud/proxy";
import { getAgentServerClientOptions } from "../agent-server-client-options";

interface SearchOptions {
  kind__eq?: "BashCommand" | "BashOutput";
  command_id__eq?: string;
  sort_order?: "TIMESTAMP" | "TIMESTAMP_DESC";
  page_id?: string;
  limit?: number;
}

const MAX_OUTPUT_PAGES = 20; // safety cap; >2000 output events is unlikely.

function isBashOutput(event: BashEvent): event is BashOutput {
  return event.kind === "BashOutput";
}

/**
 * Cloud-aware bash event reads.
 *
 * Bash events live on the agent-server runtime that owns the
 * conversation. In **local** mode we talk to the active backend's
 * agent-server directly with the SDK's `BashClient` (a per-conversation
 * URL is honoured when known, otherwise we fall back to the backend
 * host — a single local agent-server hosts all conversations). In
 * **cloud** mode we tunnel through `callCloudProxy` with the runtime URL
 * as `hostOverride`: direct browser calls to `*.prod-runtime.all-hands.dev`
 * are blocked by CORS, and runtime endpoints authenticate with the
 * conversation's `X-Session-API-Key`.
 *
 * Note on the search filter name: the agent-server API uses
 * `command_id__eq` (not `bash_command_id__eq`) — that's the parameter the
 * `BashService.search_bash_events` Python implementation declares and
 * what the typescript-client's `BashEventSearchOptions` exposes.
 */
class BashService {
  /**
   * Fetch all `BashOutput` events for a bash command, paginated and
   * sorted by timestamp. Returns events in command-emission order so
   * callers can concatenate `stdout` / `stderr` values directly.
   */
  static async listOutputs(
    conversationUrl: string | null,
    sessionApiKey: string | null | undefined,
    bashCommandId: string,
  ): Promise<BashOutput[]> {
    const outputs: BashOutput[] = [];
    let pageId: string | undefined;
    for (let i = 0; i < MAX_OUTPUT_PAGES; i += 1) {
      const page = await BashService.searchEvents(
        conversationUrl,
        sessionApiKey,
        {
          kind__eq: "BashOutput",
          command_id__eq: bashCommandId,
          sort_order: "TIMESTAMP",
          ...(pageId ? { page_id: pageId } : {}),
        },
      );
      page.items.forEach((event) => {
        if (isBashOutput(event)) outputs.push(event);
      });
      if (!page.next_page_id) break;
      pageId = page.next_page_id;
    }
    return outputs;
  }

  private static async searchEvents(
    conversationUrl: string | null,
    sessionApiKey: string | null | undefined,
    options: SearchOptions,
  ): Promise<BashEventPage> {
    const active = getActiveBackend().backend;

    if (active.kind === "cloud") {
      // Cloud requires the per-conversation runtime URL — there is no
      // shared cloud host that owns bash events. Callers must wait for
      // the conversation to be hydrated before invoking this method on
      // a cloud backend.
      if (!conversationUrl) {
        throw new Error(
          "BashService.listOutputs requires a conversation URL on cloud backends",
        );
      }
      const params = new URLSearchParams();
      Object.entries(options).forEach(([k, v]) => {
        if (v !== undefined && v !== null) params.set(k, String(v));
      });
      return callCloudProxy<BashEventPage>({
        backend: active,
        method: "GET",
        hostOverride: buildHttpBaseUrl(conversationUrl),
        path: `/api/bash/bash_events/search?${params.toString()}`,
        authMode: "session-api-key",
        sessionApiKey,
      });
    }

    // Local mode: the active backend's agent-server hosts the bash
    // events. The optional `conversationUrl` is used when present (lets
    // us target a per-conversation sub-host), otherwise we fall through
    // to `backend.host` via `getAgentServerClientOptions`.
    return new BashClient(
      getAgentServerClientOptions({
        ...(conversationUrl ? { conversationUrl } : {}),
        sessionApiKey,
      }),
    ).searchEvents(options);
  }
}

export default BashService;