donobu
Version:
Create browser automations with an LLM agent and replay them as Playwright scripts.
315 lines • 14.4 kB
JavaScript
;
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