Files
makelore/electron/services/cloud-agents.ts
brother7 2e710db0fb
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat(agents): 支持个人微信发布与渠道会话
2026-09-13 16:22:36 +08:00

628 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentConfiguration, type CloudAgentDraft, type CloudAgentPage, type CreateCloudAgent, type SaveCloudAgentDraft, type CloudUpload } from '../../shared/cloud-agents';
import { operationPlan } from './cloud-agent-operations';
import { CloudAgentJournal } from './cloud-agent-journal';
import type { CloudRecent } from '../../shared/cloud-agents';
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
import { proxyAwareFetch, runWithDeadline } from '../utils/proxy-fetch';
import {
getValidWorksSquareAccessToken,
getWorksSquareAccountBinding,
isCurrentWorksSquareAccountBinding,
subscribeWorksSquareSession,
type WorksSquareAccountBinding,
} from './works-square-session';
const MESSAGES: Record<string, string> = {
session_expired: '登录已过期,请重新登录',
account_changed: '账号已切换,请重新打开智能体',
account_locked: '账号已锁定,请稍后重试',
module_unavailable: 'AI 智能体模块暂不可用',
agent_not_found: '智能体不存在',
draft_revision_conflict: '草稿已在其他设备更新,你的输入已保留',
operation_conflict: '创建操作的内容发生冲突,请刷新列表确认',
cloud_service_unavailable: '智能体服务暂时不可用,请重试',
invalid_input: '请检查名称、用途和配置内容',
model_required: '请先选择模型并保存',
model_unavailable: '所选模型当前不可用,请刷新模型目录',
resource_unavailable: '所选资源已不可用,请刷新配置',
publish_required: '请先发布智能体',
access_revoked: '该智能体的使用授权已撤销',
application_disabled: '应用已停用',
key_revoked: '此凭据已撤销,请新建凭据',
insufficient_balance: '创建者的词元点数不足',
request_conflict: '该请求已用于其他输入,请开始新的请求',
thread_archived: '请先恢复已归档的对话再提交任务',
thread_active: '请先处理待回答或审批的任务,并停止运行及排队任务,再归档对话',
knowledge_processing: '知识库有文档正在处理,请完成后再维护',
agent_archived: '请先从归档恢复智能体,再启用',
attachment_too_large: '附件最大支持 5 MB',
download_failed: '文件保存失败,请重试',
channel_not_found: '微信渠道不存在或已解绑',
channel_operation_conflict: '微信渠道正在处理其他操作,请稍后查看状态',
channel_request_invalid: '微信渠道请求无效,请刷新后重试',
channel_rate_limited: '微信渠道操作过于频繁,请稍后重试',
channel_conversation_not_found: '微信会话不存在或已关闭',
channel_session_control_failed: '微信会话操作失败,请刷新后重试',
channel_delivery_not_found: '渠道交付记录不存在',
channel_delivery_retry_failed: '文件补发失败,请刷新交付记录后重试',
pairing_expired: '配对码已过期,请重新生成',
verification_required: '请先完成微信验证',
artifact_unavailable: '文件已不可用,请刷新文件列表',
operation_unavailable: '操作状态暂时无法确认,请稍后查询',
channel_scope_changed: '微信渠道版本已变化,请刷新后重试',
binding_revoked: '微信渠道已暂停、切换或撤销',
binding_paused: '微信渠道已暂停,请先启用后重试',
caller_not_found: '渠道调用者不存在',
delivery_not_found: '交付记录不存在',
delivery_part_not_found: '交付文件分片不存在',
artifact_snapshot_missing: '文件快照尚未准备好,请稍后重试',
channel_control_failed: '微信会话控制失败,请刷新后重试',
run_active: '当前会话仍在执行,请稍后重试',
run_not_active: '当前会话没有可操作的运行',
channel_session_unavailable: '新的微信会话暂时无法建立',
pairing_invalid: '配对请求无效,请刷新后重试',
invitation_invalid: '配对码无效或已撤销',
invitation_consumed: '配对码已经使用',
invites_disabled: '当前渠道仅允许创建者本人配对',
agentbus_unavailable: '渠道服务暂时不可用,请稍后重试',
};
export class CloudAgentsError extends Error {
constructor(readonly status: number, readonly code: string) {
super(MESSAGES[code] ?? MESSAGES.cloud_service_unavailable);
}
}
function record(value: unknown): Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown> : {};
}
function textField(value: unknown, max: number, nonempty = true): string {
if (typeof value !== 'string' || value.length > max || (nonempty && !value.trim())) {
throw new CloudAgentsError(422, 'invalid_input');
}
return value;
}
function revision(value: unknown): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
throw new CloudAgentsError(422, 'invalid_input');
}
return value;
}
function slug(value: string): string {
if (!/^ml-[a-f0-9]{32}$/.test(value)) throw new CloudAgentsError(422, 'invalid_input');
return value;
}
function projectDraft(value: unknown): CloudAgentDraft {
const item = record(value);
try { return {
slug: slug(textField(item.slug, 80)),
name: textField(item.name, 100),
purpose: textField(item.purpose, 2000),
system_prompt: textField(item.system_prompt, 32000, false),
draft_revision: revision(item.draft_revision),
updated_at: textField(item.updated_at, 40),
configuration: configuration(item.configuration),
published_version: item.published_version == null ? null : revision(item.published_version),
enabled: item.enabled !== false,
archived: item.archived === true,
}; } catch { throw new CloudAgentsError(502, 'cloud_service_unavailable'); }
}
function configuration(value: unknown): CloudAgentConfiguration {
const input = record(value);
const result = { ...EMPTY_CLOUD_CONFIGURATION, ...Object.fromEntries(
Object.keys(EMPTY_CLOUD_CONFIGURATION).filter(key => input[key] !== undefined).map(key => [key, input[key]]),
) };
textField(result.model, 200, false);
for (const key of ['tools', 'knowledges', 'mcps', 'skills', 'preload_skills', 'subagents'] as const) {
if (!Array.isArray(result[key]) || result[key].length > 100 || result[key].some(v => typeof v !== 'string' || v.length > 200)) {
throw new CloudAgentsError(422, 'invalid_input');
}
}
if (!['default', 'always_trust'].includes(result.tool_approval_mode)
|| !Number.isInteger(result.max_execution_steps) || result.max_execution_steps < 1 || result.max_execution_steps > 300
|| !Number.isInteger(result.max_output_tokens) || result.max_output_tokens < 1 || result.max_output_tokens > 32768
|| !Number.isInteger(result.max_run_seconds) || result.max_run_seconds < 10 || result.max_run_seconds > 3600) {
throw new CloudAgentsError(422, 'invalid_input');
}
return result;
}
type Session = {
binding: WorksSquareAccountBinding;
accessToken: string;
apiBaseUrl: string;
expiresAt: number;
};
/** Main owns cloud credentials and transport; Renderer receives product data only. */
export class CloudAgentsModule {
private cached: Session | null = null;
private readonly unsubscribe: () => void;
private readonly streams = new Map<AbortController, WorksSquareAccountBinding>();
private activityTimer: ReturnType<typeof setInterval>;
private activityPolling = false;
private activitySeen: Map<string, string> | null = null;
constructor(private readonly fetchImpl: (input: string, init?: RequestInit) => Promise<Response> = proxyAwareFetch,
private readonly journal = new CloudAgentJournal()) {
this.unsubscribe = subscribeWorksSquareSession(() => {
if (this.cached && !isCurrentWorksSquareAccountBinding(this.cached.binding)) {
this.cached = null;
this.activitySeen = null;
}
for (const [controller, binding] of this.streams) {
if (!isCurrentWorksSquareAccountBinding(binding)) controller.abort();
}
});
this.activityTimer = setInterval(() => { void this.pollActivity(); }, 30000);
this.activityTimer.unref?.();
}
dispose(): void {
this.cached = null;
clearInterval(this.activityTimer);
for (const controller of this.streams.keys()) controller.abort();
this.unsubscribe();
}
async list(cursor: string | null = null, archived = false): Promise<CloudAgentPage> {
if (cursor !== null && !/^\d{1,16}$/.test(cursor)) throw new CloudAgentsError(422, 'invalid_input');
const params = new URLSearchParams();
if (cursor) params.set('cursor', cursor);
if (archived) params.set('archived', 'true');
const body = record(await this.request('/api/makelore/agents' + (params.size ? '?' + params : ''), 'GET'));
if (!Array.isArray(body.agents) || body.agents.length > 100
|| (body.next_cursor !== null && (typeof body.next_cursor !== 'string' || !/^\d{1,16}$/.test(body.next_cursor)))) {
throw new CloudAgentsError(502, 'cloud_service_unavailable');
}
return { agents: body.agents.map(projectDraft), next_cursor: body.next_cursor as string | null };
}
async get(agentSlug: string): Promise<CloudAgentDraft> {
return projectDraft(await this.request(`/api/makelore/agents/${slug(agentSlug)}`, 'GET'));
}
async create(value: unknown): Promise<CloudAgentDraft> {
const input = record(value);
const operationId = textField(input.operation_id, 36);
if (!/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(operationId)) {
throw new CloudAgentsError(422, 'invalid_input');
}
const body: CreateCloudAgent = {
operation_id: operationId,
name: textField(input.name, 100).trim(),
purpose: textField(input.purpose, 2000).trim(),
};
return this.withRecovery('createAgent', { ...body }, () => this.request('/api/makelore/agents', 'POST', body).then(projectDraft));
}
private async withRecovery<T>(operation: string, input: Record<string, unknown>, execute: () => Promise<T>): Promise<T> {
const binding = getWorksSquareAccountBinding();
if (!binding) throw new CloudAgentsError(401, 'session_expired');
const operationKey = operation === 'retryChannelDeliveryPart'
? [input.channel_account_id, input.logical_message_id, input.part_id]
.map(value => encodeURIComponent(typeof value === 'string' ? value : '')).join(':')
: input.request_id ?? input.operation_id;
const id = [operation, input.slug ?? input.application_id ?? input.run_id ?? '', operationKey].join(':');
await this.journal.put(binding.accountKey, { id, operation, input, created_at: new Date().toISOString() }, () => this.requireCurrent(binding));
this.requireCurrent(binding);
const result = await execute();
this.requireCurrent(binding);
await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding));
return result;
}
async recovery() {
const binding = getWorksSquareAccountBinding();
if (!binding) throw new CloudAgentsError(401, 'session_expired');
const state = await this.journal.read(binding.accountKey);
this.requireCurrent(binding);
return state;
}
async rememberRecent(value: unknown) {
const binding = getWorksSquareAccountBinding();
if (!binding) throw new CloudAgentsError(401, 'session_expired');
const input = record(value);
const recent: CloudRecent | null = value === null ? null : {
slug: slug(textField(input.slug, 64)),
mode: input.mode === 'preview' ? 'preview' : 'published',
...(input.thread_id === undefined ? {} : { thread_id: textField(input.thread_id, 128) }),
...(input.draft_revision === undefined ? {} : { draft_revision: revision(input.draft_revision) }),
};
await this.journal.remember(binding.accountKey, recent, () => this.requireCurrent(binding));
return { saved: true };
}
async resolvePending(value: unknown) {
const input = record(value);
const id = textField(input.id, 300);
const binding = getWorksSquareAccountBinding();
if (!binding) throw new CloudAgentsError(401, 'session_expired');
const state = await this.recovery();
const pending = state.pending.find(item => item.id === id);
if (!pending) throw new CloudAgentsError(404, 'invalid_input');
if (input.discard === true) {
await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding));
return { discarded: true };
}
if (pending.operation === 'wechatBindVerification') {
// The verification code is intentionally never persisted. First recover the
// durable provider receipt: the code may already have been consumed even if
// the response was lost. Only an absent receipt requires new input.
const pendingInput = record(pending.input);
const pendingSlug = typeof pendingInput.slug === 'string' ? pendingInput.slug : undefined;
const operationId = typeof pendingInput.operation_id === 'string' ? pendingInput.operation_id : undefined;
if (pendingSlug && operationId) {
try {
const receipt = await this.execute({ operation: 'channelOperation', input: { slug: pendingSlug, operation_id: operationId } });
const status = typeof record(receipt).status === 'string' ? String(record(receipt).status).toLowerCase() : '';
const terminal = ['completed', 'complete', 'succeeded', 'success', 'failed', 'rejected', 'cancelled', 'canceled', 'expired', 'error'].includes(status);
if (terminal) await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding));
return { operation: pending.operation, input: pending.input, result: receipt, ...(terminal ? {} : { pending: true }) };
} catch (error) {
if (!(error instanceof CloudAgentsError) || error.status !== 404) throw error;
}
}
// No receipt exists (or this is an old record without enough metadata), so
// the Renderer must collect a fresh verification code.
return { operation: pending.operation, input: pending.input, requires_input: true };
}
const result = pending.operation === 'createAgent' ? await this.create(pending.input)
: await this.execute({ operation: pending.operation, input: pending.input });
return { operation: pending.operation, input: pending.input, result };
}
async save(agentSlug: string, value: unknown): Promise<CloudAgentDraft> {
const input = record(value);
const body: SaveCloudAgentDraft = {
expected_revision: revision(input.expected_revision),
name: textField(input.name, 100).trim(),
purpose: textField(input.purpose, 2000).trim(),
system_prompt: textField(input.system_prompt, 32000, false),
...(input.configuration === undefined ? {} : { configuration: configuration(input.configuration) }),
};
return projectDraft(await this.request(`/api/makelore/agents/${slug(agentSlug)}/draft`, 'PATCH', body));
}
private async pollActivity(): Promise<void> {
if (!this.cached || this.activityPolling) return;
this.activityPolling = true;
try {
const binding = this.cached.binding;
const result = record(await this.request('/api/makelore/threads', 'GET'));
const rows = Array.isArray(result.threads) ? result.threads.map(record) : [];
const next = new Map(rows.filter(row => typeof row.run_id === 'string').map(row => [String(row.run_id), String(row.status)]));
if (this.activitySeen) {
const changed = rows.filter(row => row.unread === true && typeof row.run_id === 'string'
&& ['completed', 'failed', 'interrupted'].includes(String(row.status))
&& this.activitySeen?.get(row.run_id) !== row.status);
if (changed.length) {
const { Notification, BrowserWindow } = await import('electron');
this.requireCurrent(binding);
if (Notification.isSupported()) {
const notice = new Notification({ title: '智能体有新的任务结果', body: changed.some(row => row.status === 'interrupted')
? '有任务等待你的确认,打开活动查看。' : '打开活动查看完成结果或失败原因。' });
notice.on('click', () => {
if (!isCurrentWorksSquareAccountBinding(binding)) return;
const window = BrowserWindow.getAllWindows().find(w => !w.isDestroyed());
window?.show(); window?.focus(); window?.webContents.send('navigate', '/cloud-agents?view=activity');
});
notice.show();
}
}
}
this.activitySeen = next;
} catch(error) {
if (error instanceof CloudAgentsError && [401, 403, 423].includes(error.status)) this.cached = null;
// The durable activity list remains the source of truth when notifications or transport are unavailable.
} finally { this.activityPolling = false; }
}
async execute(value: unknown): Promise<unknown> {
let plan;
try { plan = operationPlan(value); }
catch { throw new CloudAgentsError(422, 'invalid_input'); }
const invoke = async () => plan.project(await this.request(plan.path, plan.method, plan.body, plan.cloud === 'ws'));
const recoverable = ['submit', 'preview', 'resume', 'publish', 'createApplication', 'createKey', 'createKnowledge', 'processKnowledge', 'createSchedule', 'runSchedule', 'importKnowledgeAttachment', 'createChild',
'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding', 'disconnectChannelBinding',
'channelPolicy', 'createChannelPairing', 'wechatBindStart', 'wechatBindVerification', 'wechatUnbind', 'revokeChannelCaller',
'channelConversationControl', 'retryChannelDeliveryPart'];
let result: unknown;
if (recoverable.includes(plan.operation)) {
const journalInput = plan.operation === 'wechatBindVerification'
? Object.fromEntries(Object.entries(plan.input).filter(([key]) => key !== 'verify_code'))
: plan.input;
result = await this.withRecovery(plan.operation, journalInput, invoke);
} else result = await invoke();
try {
const projected = plan.project(result);
if (record(value).operation === 'access') {
// The address is public; both exchanged tokens stay in Main.
const binding = getWorksSquareAccountBinding();
if (!binding) throw new CloudAgentsError(401, 'session_expired');
const session = await runWithDeadline(signal => this.session(binding, signal), 30000);
this.requireCurrent(binding);
return { ...projected, api_url: session.apiBaseUrl + '/api/makelore/api/requests' };
}
return projected;
}
catch (error) {
if (error instanceof CloudAgentsError) throw error;
throw new CloudAgentsError(502, 'cloud_service_unavailable');
}
}
async upload(): Promise<CloudUpload | null> {
const body = await this.pickUpload();
if (!body) return null;
const result = record(await this.request('/api/makelore/attachments/tmp', 'POST', body));
return {
object_name: textField(result.object_name, 2048), file_name: textField(result.file_name, 255),
file_type: typeof result.file_type === 'string' ? result.file_type : 'application/octet-stream',
parse_supported: result.parse_supported === true,
parse_methods: Array.isArray(result.parse_methods) ? result.parse_methods.filter((v): v is string => typeof v === 'string') : [],
};
}
async uploadKnowledge(value: unknown): Promise<unknown> {
const input = record(value);
const slug = textField(input.slug, 128), kbId = textField(input.kb_id, 128);
const operationId = textField(input.operation_id, 64);
const replacement = input.replaces_file_id === undefined ? '' : textField(input.replaces_file_id, 64);
if (replacement && !/^[a-zA-Z0-9_-]+$/.test(replacement)) throw new CloudAgentsError(422, 'invalid_input');
if (!/^ml-[a-f0-9]{32}$/.test(slug) || !/^kb_[a-z0-9]+$/.test(kbId)
|| !/^[a-f0-9-]{36}$/.test(operationId)) throw new CloudAgentsError(422, 'invalid_input');
const body = await this.pickUpload();
if (!body) return null;
const result = record(await this.request(`/api/makelore/agents/${slug}/knowledge/${kbId}/files?operation_id=${operationId}` + (replacement ? '&replaces_file_id=' + replacement : ''), 'POST', body));
return Object.fromEntries(['file_id', 'name', 'size', 'status', 'error', 'chunk_count'].map(key => [key, result[key]]));
}
async uploadSkill(): Promise<unknown> {
const body = await this.pickUpload();
if (!body) return null;
const result = record(await this.request('/api/makelore/resources/skills/upload', 'POST', body));
return { draft_id: result.draft_id, items: result.items };
}
private async pickUpload(): Promise<FormData | null> {
const binding = getWorksSquareAccountBinding();
if (!binding) throw new CloudAgentsError(401, 'session_expired');
const { dialog } = await import('electron');
const { readFile, stat } = await import('node:fs/promises');
const { basename } = await import('node:path');
const picked = await dialog.showOpenDialog({ title: '添加智能体附件(最大 5 MB', properties: ['openFile'] });
this.requireCurrent(binding);
if (picked.canceled || !picked.filePaths[0]) return null;
const path = picked.filePaths[0];
if ((await stat(path)).size > 5 * 1024 * 1024) throw new CloudAgentsError(422, 'attachment_too_large');
const bytes = await readFile(path);
this.requireCurrent(binding);
if (bytes.length > 5 * 1024 * 1024) throw new CloudAgentsError(422, 'attachment_too_large');
const body = new FormData();
body.append('file', new Blob([new Uint8Array(bytes)]), basename(path));
return body;
}
async download(value: unknown): Promise<{ saved: boolean }> {
const input = record(value);
const threadId = textField(input.thread_id, 128);
const path = textField(input.path, 2048);
if (!/^[a-zA-Z0-9_-]+$/.test(threadId)) throw new CloudAgentsError(422, 'invalid_input');
const binding = getWorksSquareAccountBinding();
if (!binding) throw new CloudAgentsError(401, 'session_expired');
const { dialog } = await import('electron');
const fs = await import('node:fs/promises');
const { basename, dirname, join } = await import('node:path');
const picked = await dialog.showSaveDialog({ title: '保存智能体文件', defaultPath: basename(path),
properties: ['createDirectory', 'showOverwriteConfirmation'] });
this.requireCurrent(binding);
if (picked.canceled || !picked.filePath) return { saved: false };
const temporary = join(dirname(picked.filePath), '.makelore-download-' + crypto.randomUUID());
try {
await runWithDeadline(async signal => {
const session = await this.session(binding, signal);
this.requireCurrent(binding);
const url = session.apiBaseUrl + '/api/makelore/threads/' + encodeURIComponent(threadId) + '/artifacts/'
+ path.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/') + '?download=true';
const response = await this.fetchImpl(url, { redirect: 'error', signal, headers: { Authorization: 'Bearer ' + session.accessToken } });
if (!response.ok || !response.body) throw new CloudAgentsError(response.status || 502, 'download_failed');
const output = await fs.open(temporary, 'wx');
const reader = response.body.getReader();
let size = 0;
try {
while (true) {
const { value: chunk, done } = await reader.read();
this.requireCurrent(binding);
if (done) break;
size += chunk.byteLength;
if (size > 1024 * 1024 * 1024) throw new CloudAgentsError(413, 'download_failed');
let offset = 0;
while (offset < chunk.byteLength) offset += (await output.write(chunk, offset, chunk.byteLength - offset)).bytesWritten;
}
} finally { await reader.cancel().catch(() => undefined); await output.close(); }
}, 300000);
this.requireCurrent(binding);
await fs.rename(temporary, picked.filePath);
return { saved: true };
} catch(error) {
if (error instanceof CloudAgentsError) throw error;
throw new CloudAgentsError(502, 'download_failed');
} finally { await fs.unlink(temporary).catch(() => undefined); }
}
/** Save a personal-channel artifact through the session-scoped bridge. */
async downloadChannelArtifact(value: unknown): Promise<{ saved: boolean }> {
const input = record(value);
const sessionId = textField(input.session_id, 256);
const path = textField(input.path, 2048);
if (!/^[a-zA-Z0-9._:#-]{1,256}$/.test(sessionId)
|| path.includes('\\') || path.includes('\0') || /:\/\//.test(path)
|| path.replace(/^\/+/, '').split('/').some(part => !part || part === '..' || part === '.')) {
throw new CloudAgentsError(422, 'invalid_input');
}
const binding = getWorksSquareAccountBinding();
if (!binding) throw new CloudAgentsError(401, 'session_expired');
const { dialog } = await import('electron');
const fs = await import('node:fs/promises');
const { basename, dirname, join } = await import('node:path');
const picked = await dialog.showSaveDialog({ title: '保存微信渠道文件', defaultPath: basename(path),
properties: ['createDirectory', 'showOverwriteConfirmation'] });
this.requireCurrent(binding);
if (picked.canceled || !picked.filePath) return { saved: false };
const temporary = join(dirname(picked.filePath), '.makelore-channel-download-' + crypto.randomUUID());
try {
await runWithDeadline(async signal => {
const session = await this.session(binding, signal);
this.requireCurrent(binding);
const encodedPath = path.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
const url = session.apiBaseUrl + '/api/makelore/channel-conversations/' + encodeURIComponent(sessionId)
+ '/artifacts/' + encodedPath + '?download=true';
const response = await this.fetchImpl(url, { redirect: 'error', signal, headers: { Authorization: 'Bearer ' + session.accessToken } });
if (!response.ok || !response.body) throw new CloudAgentsError(response.status || 502, 'download_failed');
const output = await fs.open(temporary, 'wx');
const reader = response.body.getReader();
let size = 0;
try {
while (true) {
const { value: chunk, done } = await reader.read();
this.requireCurrent(binding);
if (done) break;
size += chunk.byteLength;
if (size > 1024 * 1024 * 1024) throw new CloudAgentsError(413, 'download_failed');
let offset = 0;
while (offset < chunk.byteLength) offset += (await output.write(chunk, offset, chunk.byteLength - offset)).bytesWritten;
}
} finally { await reader.cancel().catch(() => undefined); await output.close(); }
}, 300000);
this.requireCurrent(binding);
await fs.rename(temporary, picked.filePath);
return { saved: true };
} catch(error) {
if (error instanceof CloudAgentsError) throw error;
throw new CloudAgentsError(502, 'download_failed');
} finally { await fs.unlink(temporary).catch(() => undefined); }
}
async *events(runId: string, after: string, signal: AbortSignal): AsyncGenerator<Uint8Array> {
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(runId) || !/^\d+-\d+$/.test(after)) {
throw new CloudAgentsError(422, 'invalid_input');
}
const binding = getWorksSquareAccountBinding();
if (!binding) throw new CloudAgentsError(401, 'session_expired');
const controller = new AbortController();
const abort = () => controller.abort();
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort();
this.streams.set(controller, binding);
let cancelReader: (() => Promise<void>) | undefined;
try {
const session = await runWithDeadline(s => this.session(binding, s), 30000, controller.signal);
this.requireCurrent(binding);
const response = await this.fetchImpl(session.apiBaseUrl + '/api/makelore/runs/' + runId + '/events?after_seq=' + after, {
method: 'GET', redirect: 'error', signal: controller.signal,
headers: { Authorization: 'Bearer ' + session.accessToken, Accept: 'text/event-stream' },
});
this.requireCurrent(binding);
if (!response.ok || !response.body) throw new CloudAgentsError(response.status || 502, 'cloud_service_unavailable');
const reader = response.body.getReader();
cancelReader = () => reader.cancel();
while (true) {
const chunk = await reader.read();
this.requireCurrent(binding);
if (chunk.done) break;
yield chunk.value;
}
} finally {
await cancelReader?.().catch(() => undefined);
controller.abort();
signal.removeEventListener('abort', abort);
this.streams.delete(controller);
}
}
private async request(path: string, method: string, body?: unknown, worksSquare = false): Promise<unknown> {
const binding = getWorksSquareAccountBinding();
if (!binding) throw new CloudAgentsError(401, 'session_expired');
try {
return await runWithDeadline(async (signal) => {
const session = worksSquare ? {
apiBaseUrl: WORKS_SQUARE_CONFIG.apiBaseUrl,
accessToken: await getValidWorksSquareAccessToken(),
} : await this.session(binding, signal);
this.requireCurrent(binding);
if (!session.accessToken) throw new CloudAgentsError(401, 'session_expired');
const result = await this.fetchJson(session.apiBaseUrl + path, {
method, signal, redirect: 'error',
headers: { Authorization: `Bearer ${session.accessToken}`, ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) },
...(body === undefined ? {} : { body: body instanceof FormData ? body : JSON.stringify(body) }),
});
this.requireCurrent(binding);
return result;
}, 30000);
} catch (error) {
this.requireCurrent(binding);
if (error instanceof CloudAgentsError) {
if (error.status === 401) this.cached = null;
throw error;
}
throw new CloudAgentsError(502, 'cloud_service_unavailable');
}
}
private requireCurrent(binding: WorksSquareAccountBinding): void {
if (!isCurrentWorksSquareAccountBinding(binding)) throw new CloudAgentsError(409, 'account_changed');
}
private async session(binding: WorksSquareAccountBinding, signal: AbortSignal): Promise<Session> {
if (this.cached && this.cached.expiresAt > Date.now() + 5000
&& this.cached.binding.accountKey === binding.accountKey && this.cached.binding.epoch === binding.epoch) {
return this.cached;
}
const token = await getValidWorksSquareAccessToken();
this.requireCurrent(binding);
if (!token) throw new CloudAgentsError(401, 'session_expired');
const value = record(await this.fetchJson(`${WORKS_SQUARE_CONFIG.apiBaseUrl}/api/cloud-agents/session`, {
method: 'POST', signal, redirect: 'error',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
}));
this.requireCurrent(binding);
const url = new URL(textField(value.api_base_url, 2048));
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)))
|| url.username || url.password || url.search || url.hash
|| value.scope !== 'makelore-agents' || value.token_type !== 'bearer'
|| typeof value.expires_at !== 'number' || value.expires_at * 1000 <= Date.now()
|| value.expires_at * 1000 > Date.now() + 300000) {
throw new CloudAgentsError(502, 'cloud_service_unavailable');
}
this.cached = {
binding, accessToken: textField(value.access_token, 8192),
apiBaseUrl: url.toString().replace(/\/+$/, ''), expiresAt: value.expires_at * 1000,
};
return this.cached;
}
private async fetchJson(url: string, init: RequestInit): Promise<unknown> {
const response = await this.fetchImpl(url, init);
const raw = await response.text();
if (raw.length > 4_000_000) throw new CloudAgentsError(502, 'cloud_service_unavailable');
const body: unknown = JSON.parse(raw);
if (!response.ok) {
const code = record(record(body).detail).code;
throw new CloudAgentsError(
response.status, typeof code === 'string' && Object.hasOwn(MESSAGES, code) ? code
: response.status === 422 ? 'invalid_input' : response.status === 401 ? 'session_expired'
: response.status === 403 ? 'access_revoked' : 'cloud_service_unavailable',
);
}
return body;
}
}