UNPKG

donobu

Version:

Create browser automations with an LLM agent and replay them as Playwright scripts.

315 lines 14.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.FlowsPersistenceDonobuApi = void 0; const path_1 = __importDefault(require("path")); const FlowNotFoundException_1 = require("../../exceptions/FlowNotFoundException"); const BrowserStorageState_1 = require("../../models/BrowserStorageState"); const MiscUtils_1 = require("../../utils/MiscUtils"); const FileUploadCache_1 = require("../files/FileUploadCache"); const FileUploadWorker_1 = require("../files/FileUploadWorker"); const fileUploadWorkerRegistry_1 = require("../files/fileUploadWorkerRegistry"); const PLATFORM_LABEL = 'donobu'; const VIDEO_FILE_ID = 'video.webm'; /** * A {@link FlowsPersistence} implementation that persists flow data via the * Donobu API. The API stores the SDK's data models as opaque JSON blobs, * meaning the SDK owns the shape of the data and can evolve independently * of the API schema. * * File uploads (`setFlowFile`, `setVideo`, `saveScreenShot`) are async with * a local-first cache: bytes are written synchronously to * `<baseWorkingDirectory>/uploads/donobu/<flowId>/<fileId>` and a * {@link FileUploadWorker} drains the upload to the API in the background * with retry + backoff. Reads (`getFlowFile`, `getVideoSegment`) check the * cache first and only hit the network on cache miss (cross-machine reads). * Other write methods (metadata, tool calls, ai queries, browser state) * remain synchronous — they're small JSON, no bandwidth concern. */ class FlowsPersistenceDonobuApi { constructor(baseUrl, apiKey, baseWorkingDirectory = MiscUtils_1.MiscUtils.baseWorkingDirectory()) { this.baseUrl = baseUrl; this.apiKey = apiKey; this.fileCache = new FileUploadCache_1.FileUploadCache(path_1.default.join(baseWorkingDirectory, 'uploads', PLATFORM_LABEL)); this.fileWorker = new FileUploadWorker_1.FileUploadWorker({ cache: this.fileCache, platformLabel: PLATFORM_LABEL, upload: (flowId, fileId, bytes) => this.uploadFlowFileViaHttp(flowId, fileId, bytes), }); (0, fileUploadWorkerRegistry_1.registerFileUploadWorker)(this.fileWorker); this.fileWorker.start(); } // -- helpers -------------------------------------------------------- async request(path, init) { const url = `${this.baseUrl}${path}`; const response = await fetch(url, { ...init, headers: { Authorization: `Bearer ${this.apiKey}`, ...init.headers, }, }); return response; } async jsonRequest(path, method, body) { return this.request(path, { method, headers: { 'Content-Type': 'application/json' }, body: body !== undefined ? JSON.stringify(body) : undefined, }); } // -- Flow metadata ------------------------------------------------- async setFlowMetadata(flowMetadata) { const response = await this.jsonRequest(`/v1/flows/${encodeURIComponent(flowMetadata.id)}/metadata`, 'PUT', { id: flowMetadata.id, name: flowMetadata.name, runMode: flowMetadata.runMode, state: flowMetadata.state, startedAt: flowMetadata.startedAt, testId: flowMetadata.testId ?? null, record: flowMetadata, }); if (!response.ok) { throw new Error(`Failed to set flow metadata: ${response.status} ${response.statusText}`); } } async getFlowMetadataById(flowId) { const response = await this.jsonRequest(`/v1/flows/${encodeURIComponent(flowId)}`, 'GET'); if (response.status === 404) { throw FlowNotFoundException_1.FlowNotFoundException.forId(flowId); } if (!response.ok) { throw new Error(`Failed to get flow metadata: ${response.status} ${response.statusText}`); } const body = (await response.json()); return body.record; } async getFlowMetadataByName(flowName) { const params = new URLSearchParams({ name: flowName, limit: '1' }); const response = await this.jsonRequest(`/v1/flows?${params.toString()}`, 'GET'); if (!response.ok) { throw new Error(`Failed to get flow metadata by name: ${response.status} ${response.statusText}`); } const body = (await response.json()); if (body.items.length === 0) { throw FlowNotFoundException_1.FlowNotFoundException.forName(flowName); } return body.items[0]; } async getFlowsMetadata(query) { const params = new URLSearchParams(); if (query.name) { params.set('name', query.name); } if (query.partialName) { params.set('partial_name', query.partialName); } if (query.runMode) { params.set('runMode', query.runMode); } if (query.state) { params.set('state', query.state); } if (query.startedAfter !== undefined) { params.set('startedAfter', query.startedAfter.toString()); } if (query.startedBefore !== undefined) { params.set('startedBefore', query.startedBefore.toString()); } if (query.testId) { params.set('testId', query.testId); } if (query.orphaned !== undefined) { params.set('orphaned', query.orphaned ? 'true' : 'false'); } if (query.sortBy) { switch (query.sortBy) { case 'created_at': // `donobu-api` only supports `started_at`, but that's essentially // equivalent to `created_at` params.set('sort_by', 'started_at'); break; default: params.set('sort_by', query.sortBy); break; } } if (query.sortOrder) { params.set('sort_order', query.sortOrder); } if (query.limit !== undefined) { params.set('limit', query.limit.toString()); } if (query.pageToken) { params.set('pageToken', query.pageToken); } const response = await this.jsonRequest(`/v1/flows?${params.toString()}`, 'GET'); if (!response.ok) { throw new Error(`Failed to list flows: ${response.status} ${response.statusText}`); } return (await response.json()); } async deleteFlow(flowId) { const response = await this.jsonRequest(`/v1/flows/${encodeURIComponent(flowId)}`, 'DELETE'); if (!response.ok) { throw new Error(`Failed to delete flow: ${response.status} ${response.statusText}`); } // Tear down any local cache + pending uploads for this flow. If the // worker had a claim mid-upload, the bytes vanishing causes the next // worker iteration to release the claim cleanly (see processOne). await this.fileCache.deleteFlow(flowId); } // -- Tool calls ---------------------------------------------------- async setToolCall(flowId, toolCall) { const response = await this.jsonRequest(`/v1/flows/${encodeURIComponent(flowId)}/tool-calls/${encodeURIComponent(toolCall.id)}`, 'PUT', { record: toolCall }); if (!response.ok) { throw new Error(`Failed to set tool call: ${response.status} ${response.statusText}`); } } async getToolCalls(flowId) { const response = await this.jsonRequest(`/v1/flows/${encodeURIComponent(flowId)}/tool-calls`, 'GET'); if (response.status === 404) { throw FlowNotFoundException_1.FlowNotFoundException.forId(flowId); } if (!response.ok) { throw new Error(`Failed to get tool calls: ${response.status} ${response.statusText}`); } const body = (await response.json()); return body.items; } async deleteToolCall(flowId, toolCallId) { const response = await this.jsonRequest(`/v1/flows/${encodeURIComponent(flowId)}/tool-calls/${encodeURIComponent(toolCallId)}`, 'DELETE'); if (!response.ok) { throw new Error(`Failed to delete tool call: ${response.status} ${response.statusText}`); } } // -- AI Queries ---------------------------------------------------- async setAiQuery(flowId, aiQuery) { const response = await this.jsonRequest(`/v1/flows/${encodeURIComponent(flowId)}/ai-queries/${encodeURIComponent(aiQuery.id)}`, 'PUT', { record: aiQuery }); if (!response.ok) { throw new Error(`Failed to set AI query: ${response.status} ${response.statusText}`); } } async getAiQueries(flowId) { const response = await this.jsonRequest(`/v1/flows/${encodeURIComponent(flowId)}/ai-queries`, 'GET'); if (response.status === 404) { throw FlowNotFoundException_1.FlowNotFoundException.forId(flowId); } if (!response.ok) { throw new Error(`Failed to get AI queries: ${response.status} ${response.statusText}`); } const body = (await response.json()); return body.items; } // -- Screenshots --------------------------------------------------- async saveScreenShot(flowId, bytes) { const imageType = MiscUtils_1.MiscUtils.detectImageType(bytes); const fileId = `${new Date().toISOString()}.screenshot.${imageType}`; await this.setFlowFile(flowId, fileId, bytes); return fileId; } async getScreenShot(flowId, screenShotId) { return this.getFlowFile(flowId, screenShotId); } // -- Video -------------------------------------------------------- async setVideo(flowId, bytes) { await this.setFlowFile(flowId, VIDEO_FILE_ID, bytes); } async getVideoSegment(flowId, startOffset, length) { const videoBuffer = await this.getFlowFile(flowId, VIDEO_FILE_ID); if (!videoBuffer) { return null; } const totalLength = videoBuffer.length; if (startOffset >= totalLength) { return null; } const adjustedLength = Math.min(length, totalLength - startOffset); const segmentBuffer = videoBuffer.subarray(startOffset, startOffset + adjustedLength); return { bytes: Buffer.from(segmentBuffer), totalLength, startOffset, }; } // -- Flow files ---------------------------------------------------- /** * Returns bytes for a flow file. Tries the local cache first (instant * playback for files this machine recently uploaded; warm cache for * cross-machine reads after the first download). On cache miss, fetches * from the API and populates the cache so subsequent reads are local. */ async getFlowFile(flowId, fileId) { const cached = await this.fileCache.readBytes(flowId, fileId); if (cached) { return cached; } const response = await this.request(`/v1/flows/${encodeURIComponent(flowId)}/files/${encodeURIComponent(fileId)}`, { method: 'GET' }); if (response.status === 404) { return null; } if (!response.ok) { throw new Error(`Failed to get flow file: ${response.status} ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); // Populate cache without a `.pending` marker — this came FROM the cloud, // there is nothing to upload. Best-effort; a write failure here just // means the next read pays the network cost again. await this.fileCache .writeCached(flowId, fileId, buffer) .catch(() => undefined); return buffer; } /** * Writes bytes to the local file cache and returns immediately. The * actual HTTP upload to the Donobu API runs asynchronously in the * {@link FileUploadWorker} with retry + backoff. Same-machine reads * during the upload window hit the local cache (no 404). */ async setFlowFile(flowId, fileId, fileBytes) { await this.fileCache.writePending(flowId, fileId, fileBytes); this.fileWorker.notify(); } /** * Performs the actual HTTP PUT against the Donobu API. Called by the * {@link FileUploadWorker} when draining the upload queue. Not part of * the public {@link FlowsPersistence} surface — callers go through * {@link setFlowFile}. */ async uploadFlowFileViaHttp(flowId, fileId, fileBytes) { const response = await this.request(`/v1/flows/${encodeURIComponent(flowId)}/files/${encodeURIComponent(fileId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream' }, body: new Uint8Array(fileBytes), }); if (!response.ok) { throw new Error(`Failed to set flow file: ${response.status} ${response.statusText}`); } } // -- Browser state ------------------------------------------------- async setBrowserState(flowId, browserState) { const response = await this.jsonRequest(`/v1/flows/${encodeURIComponent(flowId)}/browser-state`, 'PUT', { record: browserState }); if (!response.ok) { throw new Error(`Failed to set browser state: ${response.status} ${response.statusText}`); } } async getBrowserState(flowId) { const response = await this.jsonRequest(`/v1/flows/${encodeURIComponent(flowId)}/browser-state`, 'GET'); if (response.status === 404) { throw FlowNotFoundException_1.FlowNotFoundException.forId(flowId); } if (!response.ok) { throw new Error(`Failed to get browser state: ${response.status} ${response.statusText}`); } const body = (await response.json()); if (!body.record) { return null; } return BrowserStorageState_1.BrowserStorageStateSchema.parse(body.record); } } exports.FlowsPersistenceDonobuApi = FlowsPersistenceDonobuApi; //# sourceMappingURL=FlowsPersistenceDonobuApi.js.map