File size: 11,929 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 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 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 | import {
getActiveBackend,
getRegisteredBackends,
} from "../backend-registry/active-store";
import type { Backend } from "../backend-registry/types";
import { getStoredConversationMetadata } from "../conversation-metadata-store";
import type {
AppConversation,
AppConversationPage,
AppConversationStartRequest,
AppConversationStartTask,
} from "../conversation-service/agent-server-conversation-service.types";
import { AGENT_CANVAS_CLIENT_HEADERS } from "../client-source";
import { callCloudProxy } from "./proxy";
/**
* The cloud backend does not always echo `selected_repository` /
* `selected_branch` / `git_provider` back from
* `GET /api/v1/app-conversations` until its own background hydration
* completes. We persist the selection to local storage at connect time
* (see `AgentServerConversationService.updateConversationRepository`)
* and overlay it here so the chat-page git control bar reflects the
* connection immediately, instead of snapping back to the empty
* "Connect Repo" state on every refetch.
*
* Server values take precedence whenever they're populated; the
* local-storage fallback only fills in fields the server returned as
* `null`/`undefined`.
*/
function overlayStoredRepoSelection(
conversation: AppConversation | null,
): AppConversation | null {
if (!conversation?.id) return conversation;
const stored = getStoredConversationMetadata(conversation.id);
if (!stored) return conversation;
return {
...conversation,
selected_repository:
conversation.selected_repository ?? stored.selected_repository ?? null,
selected_branch:
conversation.selected_branch ?? stored.selected_branch ?? null,
git_provider: conversation.git_provider ?? stored.git_provider ?? null,
selected_workspace:
conversation.selected_workspace ?? stored.selected_workspace ?? null,
};
}
function getActiveCloudBackend(): Backend {
const active = getActiveBackend().backend;
if (active.kind !== "cloud") {
throw new Error("Cloud conversations call requires a cloud backend.");
}
return active;
}
/**
* Resolve the cloud backend a Cloud conversation should be launched against
* when the active backend may not be a cloud one. Cloud conversations cannot
* register client tools, so the typed launch action always runs from a local
* parent and has to reach a registered-but-inactive cloud backend.
*
* Prefers the active backend when it is already cloud, then the first
* registered cloud backend that carries credentials. Returns `null` when the
* user has no cloud backend connected, so callers can give the agent
* corrective guidance instead of failing opaquely.
*
* Note: `createCloudClient` only sends `X-Org-Id` for the *active* backend, so
* a conversation launched against an inactive backend lands in the API key's
* own organization.
*/
export function pickCloudBackendForLaunch(): Backend | null {
const active = getActiveBackend().backend;
if (active.kind === "cloud") return active;
return (
getRegisteredBackends().find(
(backend) => backend.kind === "cloud" && !!backend.apiKey,
) ?? null
);
}
/**
* Search the cloud app-conversations list. Mirrors the local
* `AgentServerConversationService.searchConversations` interface but calls
* the cloud endpoint `/api/v1/app-conversations/search`.
*/
export async function searchCloudConversations(
limit: number = 20,
pageId?: string,
): Promise<AppConversationPage> {
const backend = getActiveCloudBackend();
const params = new URLSearchParams();
params.set("limit", String(limit));
if (pageId) params.set("page_id", pageId);
params.set("sort_order", "UPDATED_AT_DESC");
const data = await callCloudProxy<{
items: AppConversation[];
next_page_id: string | null;
}>({
backend,
method: "GET",
path: `/api/v1/app-conversations/search?${params.toString()}`,
});
return {
items: (data?.items ?? []).map(
(item) => overlayStoredRepoSelection(item) as AppConversation,
),
next_page_id: data?.next_page_id ?? null,
};
}
/**
* Batch-fetch cloud app-conversations by id. Mirrors the local
* `AgentServerConversationService.batchGetAppConversations` interface.
*/
export async function batchGetCloudConversations(
ids: string[],
): Promise<(AppConversation | null)[]> {
if (ids.length === 0) return [];
const backend = getActiveCloudBackend();
const params = new URLSearchParams();
for (const id of ids) params.append("ids", id);
const data = await callCloudProxy<(AppConversation | null)[]>({
backend,
method: "GET",
path: `/api/v1/app-conversations?${params.toString()}`,
});
return (data ?? []).map(overlayStoredRepoSelection);
}
/**
* Create a v1 app-conversation on the cloud backend.
*
* Mirrors OpenHands' cloud flow: POST /api/v1/app-conversations with the
* `AppConversationStartRequest` payload, returning a
* `AppConversationStartTask`. The task is initially WORKING; the caller
* polls `getCloudAppConversationStartTask` (3s cadence per OpenHands)
* until status is READY (then `app_conversation_id`, `agent_server_url`,
* and `session_api_key` are populated) or ERROR.
*
* This path does NOT use encrypted-settings round-tripping. Secrets stay
* server-side on the cloud backend — the only auth carried is the cloud bearer
* token, and the conversation runtime is
* provisioned with its own ephemeral session_api_key returned in the
* task.
*/
export async function createCloudAppConversation(
request: AppConversationStartRequest,
// Defaults to the active backend. The typed launch action passes an explicit
// backend because it always runs from a *local* parent conversation.
backendOverride?: Backend,
): Promise<AppConversationStartTask> {
const backend = backendOverride ?? getActiveCloudBackend();
const data = await callCloudProxy<AppConversationStartTask>({
backend,
method: "POST",
path: "/api/v1/app-conversations",
body: request as unknown as Record<string, unknown>,
headers: AGENT_CANVAS_CLIENT_HEADERS,
});
return data;
}
/**
* Download a v1 app-conversation as a ZIP from the cloud backend. Mirrors
* the local `AgentServerConversationService.downloadConversation` interface but
* calls
* `GET /api/v1/app-conversations/{id}/download`, which returns
* `application/zip` with `Content-Disposition` set by the cloud backend.
*/
export async function downloadCloudConversation(
conversationId: string,
): Promise<Blob> {
const backend = getActiveCloudBackend();
return callCloudProxy<Blob>({
backend,
method: "GET",
path: `/api/v1/app-conversations/${conversationId}/download`,
responseType: "blob",
});
}
/**
* Delete a v1 app-conversation on the cloud backend. Mirrors the local
* `AgentServerConversationService.deleteConversation` interface but calls
* `DELETE /api/v1/app-conversations/{id}`, which returns a JSON
* `Success` envelope (discarded here — the caller only needs to know
* the request didn't error).
*/
export async function deleteCloudConversation(
conversationId: string,
): Promise<void> {
const backend = getActiveCloudBackend();
await callCloudProxy<unknown>({
backend,
method: "DELETE",
path: `/api/v1/app-conversations/${conversationId}`,
});
}
/**
* Toggle the public-sharing flag on a cloud v1 app-conversation. Mirrors
* OpenHands' `AgentServerConversationService.updateConversationPublicFlag`:
* `PATCH /api/v1/app-conversations/{id}` with `{ public }`, returning
* the updated conversation.
*/
export async function updateCloudConversationPublicFlag(
conversationId: string,
isPublic: boolean,
): Promise<AppConversation> {
const backend = getActiveCloudBackend();
const data = await callCloudProxy<AppConversation>({
backend,
method: "PATCH",
path: `/api/v1/app-conversations/${conversationId}`,
body: { public: isPublic },
});
return data;
}
/**
* Rename a cloud v1 app-conversation. The title belongs to the Cloud
* app-conversation resource, so updating a runtime Agent Server would not
* persist it in the Cloud conversation list.
*/
export async function updateCloudConversationTitle(
conversationId: string,
title: string,
): Promise<AppConversation> {
const backend = getActiveCloudBackend();
return callCloudProxy<AppConversation>({
backend,
method: "PATCH",
path: `/api/v1/app-conversations/${conversationId}`,
body: { title },
});
}
/**
* Pause the cloud sandbox backing a v1 app-conversation. Mirrors
* OpenHands' `SandboxService.pauseSandbox`:
* `POST /api/v1/sandboxes/{sandboxId}/pause` on the cloud backend, which stops
* the runtime owning the conversation.
*/
export async function pauseCloudSandbox(sandboxId: string): Promise<void> {
const backend = getActiveCloudBackend();
await callCloudProxy<unknown>({
backend,
method: "POST",
path: `/api/v1/sandboxes/${sandboxId}/pause`,
});
}
/**
* Resume a paused cloud sandbox. Mirrors OpenHands' `SandboxService.resumeSandbox`
* by calling `POST /api/v1/sandboxes/{sandboxId}/resume` on the SaaS.
*
* This is the correct endpoint for waking a PAUSED sandbox. It is a
* lightweight unpause — NOT the same as creating a new start task via
* `POST /api/v1/app-conversations`, which provisions a fresh conversation
* and is subject to the 120-second sandbox-start timeout.
*/
export async function resumeCloudSandbox(sandboxId: string): Promise<void> {
const backend = getActiveCloudBackend();
await callCloudProxy<unknown>({
backend,
method: "POST",
path: `/api/v1/sandboxes/${sandboxId}/resume`,
});
}
/**
* Read a file from a cloud conversation's sandbox workspace. Mirrors
* OpenHands' `AgentServerConversationService.readConversationFile` — hits
* `GET /api/v1/app-conversations/{id}/file?file_path=...` on the cloud backend
* and returns the file content as a string.
*/
export async function readCloudConversationFile(
conversationId: string,
filePath: string,
): Promise<string> {
const backend = getActiveCloudBackend();
const params = new URLSearchParams();
params.append("file_path", filePath);
const data = await callCloudProxy<string>({
backend,
method: "GET",
path: `/api/v1/app-conversations/${conversationId}/file?${params.toString()}`,
});
return data ?? "";
}
/**
* List every file in a cloud conversation's sandbox workspace. Hits
* `GET /api/v1/app-conversations/{id}/files?path=...` on the cloud backend,
* which resolves the conversation's runtime and runs a bounded `find`
* server-side (see enterprise `list_conversation_files`). Unlike the
* git-changes source, this returns the full tree so the Files tab matches the
* local-backend experience. Paths come back relative to `path`.
*/
export async function listCloudConversationFiles(
conversationId: string,
path: string,
): Promise<string[]> {
const backend = getActiveCloudBackend();
const params = new URLSearchParams();
params.append("path", path);
const data = await callCloudProxy<string[]>({
backend,
method: "GET",
path: `/api/v1/app-conversations/${conversationId}/files?${params.toString()}`,
});
return Array.isArray(data) ? data : [];
}
/**
* Fetch a single v1 app-conversation start task. Mirrors OpenHands'
* `AgentServerConversationService.getStartTask` — uses the batch search endpoint
* with a single id and unwraps the first result.
*/
export async function getCloudAppConversationStartTask(
taskId: string,
backendOverride?: Backend,
): Promise<AppConversationStartTask | null> {
const backend = backendOverride ?? getActiveCloudBackend();
const params = new URLSearchParams();
params.set("ids", taskId);
const data = await callCloudProxy<(AppConversationStartTask | null)[]>({
backend,
method: "GET",
path: `/api/v1/app-conversations/start-tasks?${params.toString()}`,
});
return data?.[0] ?? null;
}
|