File size: 5,633 Bytes
63522a5 | 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 | import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
import { getActiveBackend } from "#/api/backend-registry/active-store";
import { batchGetCloudConversations } from "#/api/cloud/conversation-service.api";
import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
import type { FileUploadSuccessResponse } from "#/api/open-hands.types";
import {
buildWorkspaceUploadPath,
getSafeUploadFileName,
resolveConversationUploadWorkingDir,
} from "#/api/workspace-upload-path";
const FILE_UPLOAD_CONCURRENCY = 5;
export interface ConversationRuntimeContext {
conversationUrl: string | null;
sessionApiKey: string | null;
}
/**
* Resolve the sandbox runtime URL + session key needed for file upload and
* send-event calls. Cloud conversations only exist on the provisioned runtime,
* not on the bundled local agent-server.
*/
export async function resolveConversationRuntime(
conversationId: string,
currentConversation?: AppConversation | null,
): Promise<ConversationRuntimeContext> {
if (
currentConversation?.id === conversationId &&
currentConversation.conversation_url?.trim() &&
currentConversation.session_api_key?.trim()
) {
return {
conversationUrl: currentConversation.conversation_url.trim(),
sessionApiKey: currentConversation.session_api_key.trim(),
};
}
if (getActiveBackend().backend.kind === "cloud") {
const [conversation] = await batchGetCloudConversations([conversationId]);
return {
conversationUrl: conversation?.conversation_url?.trim() ?? null,
sessionApiKey: conversation?.session_api_key?.trim() ?? null,
};
}
return { conversationUrl: null, sessionApiKey: null };
}
function requireCloudRuntime(
runtime: ConversationRuntimeContext,
): ConversationRuntimeContext & {
conversationUrl: string;
sessionApiKey: string;
} {
if (!runtime.conversationUrl || !runtime.sessionApiKey) {
throw new Error(
"Conversation sandbox is still starting. Wait for it to finish, then try again.",
);
}
return {
conversationUrl: runtime.conversationUrl,
sessionApiKey: runtime.sessionApiKey,
};
}
/**
* Upload attachments into the conversation workspace. Local conversations use
* the bundled agent-server; cloud conversations target the provisioned runtime.
*/
export async function uploadFilesToConversation(
conversationId: string,
files: File[],
currentConversation?: AppConversation | null,
): Promise<FileUploadSuccessResponse> {
const workingDir = await resolveConversationUploadWorkingDir(
conversationId,
currentConversation,
);
const runtime = await resolveConversationRuntime(
conversationId,
currentConversation,
);
const isCloud = getActiveBackend().backend.kind === "cloud";
const sessionApiKey =
currentConversation?.id === conversationId
? (currentConversation.session_api_key ?? runtime.sessionApiKey)
: runtime.sessionApiKey;
const conversationUrl =
currentConversation?.id === conversationId
? (currentConversation.conversation_url ?? runtime.conversationUrl)
: runtime.conversationUrl;
if (isCloud) {
const cloudRuntime = requireCloudRuntime({
conversationUrl,
sessionApiKey,
});
return uploadFilesToRuntime({
files,
workingDir,
conversationUrl: cloudRuntime.conversationUrl,
sessionApiKey: cloudRuntime.sessionApiKey,
});
}
return uploadFilesToRuntime({
files,
workingDir,
conversationUrl,
sessionApiKey,
});
}
async function uploadFilesToRuntime(options: {
files: File[];
workingDir: string;
conversationUrl: string | null;
sessionApiKey: string | null;
}): Promise<FileUploadSuccessResponse> {
const { files, workingDir, conversationUrl, sessionApiKey } = options;
const workspace = new RemoteWorkspace(
getAgentServerClientOptions({
conversationUrl,
sessionApiKey,
workingDir,
}),
);
const uploadFile = async (file: File) => {
try {
const safeName = getSafeUploadFileName(file.name);
// @spec WUP-001 — Build an absolute upload path that's anchored against
// the agent-server's home dir (when `workingDir` is relative) instead
// of the filesystem root. Without this, default conversations whose
// working_dir is `workspace/project/<hex>` (relative) land at
// `/workspace/project/<hex>/...` on the agent-server, which on macOS
// and fresh containers is a read-only mount.
const uploadPath = await buildWorkspaceUploadPath(file.name, workingDir, {
conversationUrl,
sessionApiKey,
});
await workspace.fileUpload(file, uploadPath);
return { uploadedFile: safeName, skippedFile: null };
} catch (error) {
return {
uploadedFile: null,
skippedFile: {
name: file.name,
reason: error instanceof Error ? error.message : "Upload failed",
},
};
}
};
const results: Awaited<ReturnType<typeof uploadFile>>[] = [];
for (let index = 0; index < files.length; index += FILE_UPLOAD_CONCURRENCY) {
const batch = files.slice(index, index + FILE_UPLOAD_CONCURRENCY);
results.push(...(await Promise.all(batch.map(uploadFile))));
}
return {
uploaded_files: results.flatMap((result) =>
result.uploadedFile ? [result.uploadedFile] : [],
),
skipped_files: results.flatMap((result) =>
result.skippedFile ? [result.skippedFile] : [],
),
};
}
|