donobu 5.52.0 → 5.52.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/cli/donobu-cli.js +58 -0
  2. package/dist/envVars.d.ts +2 -0
  3. package/dist/envVars.js +9 -0
  4. package/dist/esm/cli/donobu-cli.js +58 -0
  5. package/dist/esm/envVars.d.ts +2 -0
  6. package/dist/esm/envVars.js +9 -0
  7. package/dist/esm/lib/page/extendPage.js +55 -26
  8. package/dist/esm/lib/test/testExtension.js +5 -2
  9. package/dist/esm/lib/test/utils/donobuTestStack.js +1 -1
  10. package/dist/esm/main.d.ts +2 -0
  11. package/dist/esm/main.js +1 -1
  12. package/dist/esm/managers/AdminApiController.d.ts +1 -1
  13. package/dist/esm/managers/DonobuStack.d.ts +2 -0
  14. package/dist/esm/managers/DonobuStack.js +1 -1
  15. package/dist/esm/persistence/files/FileUploadCache.d.ts +16 -0
  16. package/dist/esm/persistence/files/FileUploadCache.js +30 -0
  17. package/dist/esm/persistence/files/FileUploadWorker.d.ts +28 -4
  18. package/dist/esm/persistence/files/FileUploadWorker.js +31 -7
  19. package/dist/esm/persistence/files/fileUploadWorkerRegistry.d.ts +9 -0
  20. package/dist/esm/persistence/files/fileUploadWorkerRegistry.js +16 -0
  21. package/dist/esm/persistence/flows/FlowsPersistenceDonobuApi.d.ts +49 -13
  22. package/dist/esm/persistence/flows/FlowsPersistenceDonobuApi.js +106 -39
  23. package/dist/esm/persistence/flows/FlowsPersistenceRegistry.d.ts +17 -1
  24. package/dist/esm/persistence/flows/FlowsPersistenceRegistry.js +33 -10
  25. package/dist/lib/page/extendPage.js +55 -26
  26. package/dist/lib/test/testExtension.js +5 -2
  27. package/dist/lib/test/utils/donobuTestStack.js +1 -1
  28. package/dist/main.d.ts +2 -0
  29. package/dist/main.js +1 -1
  30. package/dist/managers/AdminApiController.d.ts +1 -1
  31. package/dist/managers/DonobuStack.d.ts +2 -0
  32. package/dist/managers/DonobuStack.js +1 -1
  33. package/dist/persistence/files/FileUploadCache.d.ts +16 -0
  34. package/dist/persistence/files/FileUploadCache.js +30 -0
  35. package/dist/persistence/files/FileUploadWorker.d.ts +28 -4
  36. package/dist/persistence/files/FileUploadWorker.js +31 -7
  37. package/dist/persistence/files/fileUploadWorkerRegistry.d.ts +9 -0
  38. package/dist/persistence/files/fileUploadWorkerRegistry.js +16 -0
  39. package/dist/persistence/flows/FlowsPersistenceDonobuApi.d.ts +49 -13
  40. package/dist/persistence/flows/FlowsPersistenceDonobuApi.js +106 -39
  41. package/dist/persistence/flows/FlowsPersistenceRegistry.d.ts +17 -1
  42. package/dist/persistence/flows/FlowsPersistenceRegistry.js +33 -10
  43. package/package.json +1 -1
@@ -30,6 +30,15 @@ export declare function unregisterFileUploadWorker(worker: FileUploadWorker): vo
30
30
  * time.
31
31
  */
32
32
  export declare function getFileUploadAggregateStatus(): Promise<FileUploadAggregateStatus>;
33
+ /**
34
+ * Total uploads still owed by every registered worker — pending files plus
35
+ * each worker's own in-flight claims, excluding claims orphaned by other or
36
+ * crashed processes (which this process can neither upload nor release). This
37
+ * is what a graceful shutdown actually waits on, so use it (not the global
38
+ * {@link getFileUploadAggregateStatus}, which counts foreign claims too) when
39
+ * deciding whether a drain is needed and reporting how much is left.
40
+ */
41
+ export declare function getOwnedActiveUploadCount(): Promise<number>;
33
42
  /**
34
43
  * Drains and stops every registered worker. Used during graceful shutdown.
35
44
  * Returns a summary across all workers so the caller can log how many
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerFileUploadWorker = registerFileUploadWorker;
4
4
  exports.unregisterFileUploadWorker = unregisterFileUploadWorker;
5
5
  exports.getFileUploadAggregateStatus = getFileUploadAggregateStatus;
6
+ exports.getOwnedActiveUploadCount = getOwnedActiveUploadCount;
6
7
  exports.shutdownFileUploadWorkers = shutdownFileUploadWorkers;
7
8
  const Logger_1 = require("../../utils/Logger");
8
9
  const workers = new Set();
@@ -38,6 +39,21 @@ async function getFileUploadAggregateStatus() {
38
39
  }
39
40
  return { totalPending, totalInFlight, totalFailed, perPlatform };
40
41
  }
42
+ /**
43
+ * Total uploads still owed by every registered worker — pending files plus
44
+ * each worker's own in-flight claims, excluding claims orphaned by other or
45
+ * crashed processes (which this process can neither upload nor release). This
46
+ * is what a graceful shutdown actually waits on, so use it (not the global
47
+ * {@link getFileUploadAggregateStatus}, which counts foreign claims too) when
48
+ * deciding whether a drain is needed and reporting how much is left.
49
+ */
50
+ async function getOwnedActiveUploadCount() {
51
+ let total = 0;
52
+ for (const worker of workers) {
53
+ total += await worker.countOwnedActive();
54
+ }
55
+ return total;
56
+ }
41
57
  /**
42
58
  * Drains and stops every registered worker. Used during graceful shutdown.
43
59
  * Returns a summary across all workers so the caller can log how many
@@ -4,6 +4,7 @@ import type { FlowMetadata, FlowsQuery } from '../../models/FlowMetadata';
4
4
  import type { PaginatedResult } from '../../models/PaginatedResult';
5
5
  import type { ToolCall } from '../../models/ToolCall';
6
6
  import type { VideoSegment } from '../../models/VideoSegment';
7
+ import { FileUploadCache } from '../files/FileUploadCache';
7
8
  import type { FlowsPersistence } from './FlowsPersistence';
8
9
  /**
9
10
  * A {@link FlowsPersistence} implementation that persists flow data via the
@@ -24,8 +25,20 @@ export declare class FlowsPersistenceDonobuApi implements FlowsPersistence {
24
25
  private readonly baseUrl;
25
26
  private readonly apiKey;
26
27
  private readonly fileCache;
27
- private readonly fileWorker;
28
- constructor(baseUrl: string, apiKey: string, baseWorkingDirectory?: string);
28
+ private readonly notifyUpload;
29
+ /**
30
+ * Prefer {@link createDonobuFlowsPersistence} over calling this directly — it
31
+ * builds the cache and, when this process owns draining, the background
32
+ * uploader, and wires `notifyUpload` for you.
33
+ *
34
+ * @param fileCache shared local upload cache (bytes + `.pending` markers).
35
+ * @param notifyUpload invoked after each cache write to wake a co-located
36
+ * uploader. A no-op in producer-only mode (Playwright workers under
37
+ * `donobu test`), where a different process owns draining the shared cache;
38
+ * cross-process wake-ups are impossible anyway, so the parent's uploader
39
+ * polls instead.
40
+ */
41
+ constructor(baseUrl: string, apiKey: string, fileCache: FileUploadCache, notifyUpload?: () => void);
29
42
  private request;
30
43
  private jsonRequest;
31
44
  setFlowMetadata(flowMetadata: FlowMetadata): Promise<void>;
@@ -50,20 +63,43 @@ export declare class FlowsPersistenceDonobuApi implements FlowsPersistence {
50
63
  */
51
64
  getFlowFile(flowId: string, fileId: string): Promise<Buffer | null>;
52
65
  /**
53
- * Writes bytes to the local file cache and returns immediately. The
54
- * actual HTTP upload to the Donobu API runs asynchronously in the
55
- * {@link FileUploadWorker} with retry + backoff. Same-machine reads
56
- * during the upload window hit the local cache (no 404).
66
+ * Writes bytes to the local file cache and returns immediately. The actual
67
+ * HTTP upload to the Donobu API runs asynchronously in a
68
+ * {@link FileUploadWorker} (owned by this process or, under `donobu test`, by
69
+ * the CLI parent) with retry + backoff. Same-machine reads during the upload
70
+ * window hit the local cache (no 404).
57
71
  */
58
72
  setFlowFile(flowId: string, fileId: string, fileBytes: Buffer): Promise<void>;
59
- /**
60
- * Performs the actual HTTP PUT against the Donobu API. Called by the
61
- * {@link FileUploadWorker} when draining the upload queue. Not part of
62
- * the public {@link FlowsPersistence} surface — callers go through
63
- * {@link setFlowFile}.
64
- */
65
- private uploadFlowFileViaHttp;
66
73
  setBrowserState(flowId: string, browserState: BrowserStorageState): Promise<void>;
67
74
  getBrowserState(flowId: string): Promise<BrowserStorageState | null>;
68
75
  }
76
+ export interface CreateDonobuFlowsPersistenceOptions {
77
+ readonly baseUrl: string;
78
+ readonly apiKey: string;
79
+ readonly baseWorkingDirectory?: string;
80
+ /**
81
+ * Whether THIS process owns draining uploads to the cloud. True for a
82
+ * standalone process (Studio, or a raw `playwright test` worker with no CLI
83
+ * parent): start the background uploader here. False for a Playwright worker
84
+ * under `donobu test`: producer-only — write bytes to the shared cache and
85
+ * let the CLI parent drain them.
86
+ */
87
+ readonly ownsUploadDraining: boolean;
88
+ /** Idle re-scan interval for the uploader, used by the cross-process parent
89
+ * daemon whose producers live in other processes (no in-process notify). */
90
+ readonly pollIntervalMs?: number;
91
+ }
92
+ /**
93
+ * Composition root for the Donobu persistence client. Builds the shared upload
94
+ * cache and, when this process owns draining, the background uploader; returns
95
+ * a {@link FlowsPersistenceDonobuApi} wired to notify that uploader on writes.
96
+ */
97
+ export declare function createDonobuFlowsPersistence(options: CreateDonobuFlowsPersistenceOptions): FlowsPersistenceDonobuApi;
98
+ /**
99
+ * Starts ONLY the Donobu cloud uploader (no read/write client) for a process
100
+ * that owns draining but never reads or writes flows itself — the long-lived
101
+ * `donobu` CLI parent. The worker registers globally; drain it later via
102
+ * `shutdownFileUploadWorkers()`.
103
+ */
104
+ export declare function startDonobuParentUploader(baseUrl: string, apiKey: string, pollIntervalMs: number, baseWorkingDirectory?: string): void;
69
105
  //# sourceMappingURL=FlowsPersistenceDonobuApi.d.ts.map
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.FlowsPersistenceDonobuApi = void 0;
7
+ exports.createDonobuFlowsPersistence = createDonobuFlowsPersistence;
8
+ exports.startDonobuParentUploader = startDonobuParentUploader;
7
9
  const path_1 = __importDefault(require("path"));
8
10
  const FlowNotFoundException_1 = require("../../exceptions/FlowNotFoundException");
9
11
  const BrowserStorageState_1 = require("../../models/BrowserStorageState");
@@ -29,29 +31,27 @@ const VIDEO_FILE_ID = 'video.webm';
29
31
  * remain synchronous — they're small JSON, no bandwidth concern.
30
32
  */
31
33
  class FlowsPersistenceDonobuApi {
32
- constructor(baseUrl, apiKey, baseWorkingDirectory = MiscUtils_1.MiscUtils.baseWorkingDirectory()) {
34
+ /**
35
+ * Prefer {@link createDonobuFlowsPersistence} over calling this directly — it
36
+ * builds the cache and, when this process owns draining, the background
37
+ * uploader, and wires `notifyUpload` for you.
38
+ *
39
+ * @param fileCache shared local upload cache (bytes + `.pending` markers).
40
+ * @param notifyUpload invoked after each cache write to wake a co-located
41
+ * uploader. A no-op in producer-only mode (Playwright workers under
42
+ * `donobu test`), where a different process owns draining the shared cache;
43
+ * cross-process wake-ups are impossible anyway, so the parent's uploader
44
+ * polls instead.
45
+ */
46
+ constructor(baseUrl, apiKey, fileCache, notifyUpload = () => { }) {
33
47
  this.baseUrl = baseUrl;
34
48
  this.apiKey = apiKey;
35
- this.fileCache = new FileUploadCache_1.FileUploadCache(path_1.default.join(baseWorkingDirectory, 'uploads', PLATFORM_LABEL));
36
- this.fileWorker = new FileUploadWorker_1.FileUploadWorker({
37
- cache: this.fileCache,
38
- platformLabel: PLATFORM_LABEL,
39
- upload: (flowId, fileId, bytes) => this.uploadFlowFileViaHttp(flowId, fileId, bytes),
40
- });
41
- (0, fileUploadWorkerRegistry_1.registerFileUploadWorker)(this.fileWorker);
42
- this.fileWorker.start();
49
+ this.fileCache = fileCache;
50
+ this.notifyUpload = notifyUpload;
43
51
  }
44
52
  // -- helpers --------------------------------------------------------
45
53
  async request(path, init) {
46
- const url = `${this.baseUrl}${path}`;
47
- const response = await fetch(url, {
48
- ...init,
49
- headers: {
50
- Authorization: `Bearer ${this.apiKey}`,
51
- ...init.headers,
52
- },
53
- });
54
- return response;
54
+ return donobuApiFetch(this.baseUrl, this.apiKey, path, init);
55
55
  }
56
56
  async jsonRequest(path, method, body) {
57
57
  return this.request(path, {
@@ -264,30 +264,15 @@ class FlowsPersistenceDonobuApi {
264
264
  return buffer;
265
265
  }
266
266
  /**
267
- * Writes bytes to the local file cache and returns immediately. The
268
- * actual HTTP upload to the Donobu API runs asynchronously in the
269
- * {@link FileUploadWorker} with retry + backoff. Same-machine reads
270
- * during the upload window hit the local cache (no 404).
267
+ * Writes bytes to the local file cache and returns immediately. The actual
268
+ * HTTP upload to the Donobu API runs asynchronously in a
269
+ * {@link FileUploadWorker} (owned by this process or, under `donobu test`, by
270
+ * the CLI parent) with retry + backoff. Same-machine reads during the upload
271
+ * window hit the local cache (no 404).
271
272
  */
272
273
  async setFlowFile(flowId, fileId, fileBytes) {
273
274
  await this.fileCache.writePending(flowId, fileId, fileBytes);
274
- this.fileWorker.notify();
275
- }
276
- /**
277
- * Performs the actual HTTP PUT against the Donobu API. Called by the
278
- * {@link FileUploadWorker} when draining the upload queue. Not part of
279
- * the public {@link FlowsPersistence} surface — callers go through
280
- * {@link setFlowFile}.
281
- */
282
- async uploadFlowFileViaHttp(flowId, fileId, fileBytes) {
283
- const response = await this.request(`/v1/flows/${encodeURIComponent(flowId)}/files/${encodeURIComponent(fileId)}`, {
284
- method: 'PUT',
285
- headers: { 'Content-Type': 'application/octet-stream' },
286
- body: new Uint8Array(fileBytes),
287
- });
288
- if (!response.ok) {
289
- throw new Error(`Failed to set flow file: ${response.status} ${response.statusText}`);
290
- }
275
+ this.notifyUpload();
291
276
  }
292
277
  // -- Browser state -------------------------------------------------
293
278
  async setBrowserState(flowId, browserState) {
@@ -312,4 +297,86 @@ class FlowsPersistenceDonobuApi {
312
297
  }
313
298
  }
314
299
  exports.FlowsPersistenceDonobuApi = FlowsPersistenceDonobuApi;
300
+ // ---------------------------------------------------------------------------
301
+ // Donobu API transport + upload subsystem (composition root)
302
+ // ---------------------------------------------------------------------------
303
+ // These free functions own all construction of the Donobu cloud upload
304
+ // subsystem (cache + background uploader) and of the persistence client
305
+ // itself. Keeping them out of the class means the persistence is a plain
306
+ // read/write client over an injected cache + notify hook, with no knowledge of
307
+ // ownership policy, env, or worker lifecycle.
308
+ /** Authenticated fetch against the Donobu API. Shared by the persistence
309
+ * client (reads/writes) and the standalone upload transport below. */
310
+ async function donobuApiFetch(baseUrl, apiKey, path, init) {
311
+ return fetch(`${baseUrl}${path}`, {
312
+ ...init,
313
+ headers: {
314
+ Authorization: `Bearer ${apiKey}`,
315
+ ...init.headers,
316
+ },
317
+ });
318
+ }
319
+ /**
320
+ * PUTs file bytes to the Donobu API. This is the upload transport handed to the
321
+ * {@link FileUploadWorker}; it's standalone (takes creds, not a persistence
322
+ * instance) precisely so a worker can be built without a
323
+ * {@link FlowsPersistenceDonobuApi} — which is what lets the CLI parent own an
324
+ * uploader without constructing a read/write client it never uses.
325
+ */
326
+ async function uploadDonobuFlowFile(baseUrl, apiKey, flowId, fileId, fileBytes) {
327
+ const response = await donobuApiFetch(baseUrl, apiKey, `/v1/flows/${encodeURIComponent(flowId)}/files/${encodeURIComponent(fileId)}`, {
328
+ method: 'PUT',
329
+ headers: { 'Content-Type': 'application/octet-stream' },
330
+ body: new Uint8Array(fileBytes),
331
+ });
332
+ if (!response.ok) {
333
+ throw new Error(`Failed to set flow file: ${response.status} ${response.statusText}`);
334
+ }
335
+ }
336
+ /** The local upload cache for the Donobu platform, rooted under the shared
337
+ * working directory so every process (CLI parent + Playwright workers) agrees
338
+ * on one location. */
339
+ function donobuUploadCache(baseWorkingDirectory = MiscUtils_1.MiscUtils.baseWorkingDirectory()) {
340
+ return new FileUploadCache_1.FileUploadCache(path_1.default.join(baseWorkingDirectory, 'uploads', PLATFORM_LABEL));
341
+ }
342
+ /**
343
+ * Builds, registers, and starts the background uploader that drains `cache` to
344
+ * the Donobu API. Whatever process is responsible for pushing artifacts to the
345
+ * cloud calls this exactly once; drain + stop it later via
346
+ * `shutdownFileUploadWorkers()`.
347
+ */
348
+ function startDonobuCloudUploader(cache, baseUrl, apiKey, pollIntervalMs) {
349
+ const worker = new FileUploadWorker_1.FileUploadWorker({
350
+ cache,
351
+ platformLabel: PLATFORM_LABEL,
352
+ upload: (flowId, fileId, bytes) => uploadDonobuFlowFile(baseUrl, apiKey, flowId, fileId, bytes),
353
+ pollIntervalMs,
354
+ });
355
+ (0, fileUploadWorkerRegistry_1.registerFileUploadWorker)(worker);
356
+ worker.start();
357
+ return worker;
358
+ }
359
+ /**
360
+ * Composition root for the Donobu persistence client. Builds the shared upload
361
+ * cache and, when this process owns draining, the background uploader; returns
362
+ * a {@link FlowsPersistenceDonobuApi} wired to notify that uploader on writes.
363
+ */
364
+ function createDonobuFlowsPersistence(options) {
365
+ const cache = donobuUploadCache(options.baseWorkingDirectory);
366
+ let notifyUpload = () => { };
367
+ if (options.ownsUploadDraining) {
368
+ const worker = startDonobuCloudUploader(cache, options.baseUrl, options.apiKey, options.pollIntervalMs);
369
+ notifyUpload = () => worker.notify();
370
+ }
371
+ return new FlowsPersistenceDonobuApi(options.baseUrl, options.apiKey, cache, notifyUpload);
372
+ }
373
+ /**
374
+ * Starts ONLY the Donobu cloud uploader (no read/write client) for a process
375
+ * that owns draining but never reads or writes flows itself — the long-lived
376
+ * `donobu` CLI parent. The worker registers globally; drain it later via
377
+ * `shutdownFileUploadWorkers()`.
378
+ */
379
+ function startDonobuParentUploader(baseUrl, apiKey, pollIntervalMs, baseWorkingDirectory) {
380
+ startDonobuCloudUploader(donobuUploadCache(baseWorkingDirectory), baseUrl, apiKey, pollIntervalMs);
381
+ }
315
382
  //# sourceMappingURL=FlowsPersistenceDonobuApi.js.map
@@ -2,6 +2,22 @@ import type { EnvPick } from 'env-struct';
2
2
  import type { env } from '../../envVars';
3
3
  import { PersistencePluginRegistry } from '../PersistencePlugin';
4
4
  import type { FlowsPersistence } from './FlowsPersistence';
5
+ /** Credentials for the DONOBU cloud layer, resolved from the environment. */
6
+ export interface DonobuCloudConfig {
7
+ readonly baseUrl: string;
8
+ readonly apiKey: string;
9
+ }
10
+ /**
11
+ * Resolves the DONOBU cloud persistence config, or null if it isn't active for
12
+ * this environment (missing credentials, or `DONOBU` not in
13
+ * `PERSISTENCE_PRIORITY`). Single source of truth for "is DONOBU cloud on?",
14
+ * shared by {@link FlowsPersistenceRegistryImpl.fromEnvironment} (which builds
15
+ * the read/write client) and the `donobu` CLI (which starts the parent-owned
16
+ * uploader). The credential precedence — primary `DONOBU_API_KEY`, then the
17
+ * persistence-only `DONOBU_PERSISTENCE_API_KEY` — matches the inference
18
+ * fallback chain in DonobuFlowsManager.createGptClient.
19
+ */
20
+ export declare function resolveDonobuCloudConfig(environ: EnvPick<typeof env, 'DONOBU_API_BASE_URL' | 'DONOBU_API_KEY' | 'DONOBU_PERSISTENCE_API_KEY' | 'PERSISTENCE_PRIORITY'>): DonobuCloudConfig | null;
5
21
  /**
6
22
  * A persistence layer paired with the `PERSISTENCE_PRIORITY` key it was
7
23
  * created from. The key is shared across the flows/tests/suites registries:
@@ -46,7 +62,7 @@ export declare class FlowsPersistenceRegistryImpl implements FlowsPersistenceReg
46
62
  * Creates an instance by reading environment variables and eagerly constructing
47
63
  * all applicable persistence layers.
48
64
  */
49
- static fromEnvironment(environ: EnvPick<typeof env, 'DONOBU_API_BASE_URL' | 'DONOBU_API_KEY' | 'DONOBU_PERSISTENCE_API_KEY' | 'PERSISTENCE_PRIORITY'>, persistencePlugins?: PersistencePluginRegistry): Promise<FlowsPersistenceRegistryImpl>;
65
+ static fromEnvironment(environ: EnvPick<typeof env, 'DONOBU_API_BASE_URL' | 'DONOBU_API_KEY' | 'DONOBU_PERSISTENCE_API_KEY' | 'DONOBU_UPLOADS_OWNED_BY_PARENT' | 'PERSISTENCE_PRIORITY'>, persistencePlugins?: PersistencePluginRegistry): Promise<FlowsPersistenceRegistryImpl>;
50
66
  /**
51
67
  * Returns the primary persistence layer.
52
68
  */
@@ -1,11 +1,32 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FlowsPersistenceRegistryImpl = void 0;
4
+ exports.resolveDonobuCloudConfig = resolveDonobuCloudConfig;
4
5
  const DonobuSqliteDb_1 = require("../DonobuSqliteDb");
5
6
  const PersistencePlugin_1 = require("../PersistencePlugin");
6
7
  const FlowsPersistenceDonobuApi_1 = require("./FlowsPersistenceDonobuApi");
7
8
  const FlowsPersistenceSqlite_1 = require("./FlowsPersistenceSqlite");
8
9
  const FlowsPersistenceVolatile_1 = require("./FlowsPersistenceVolatile");
10
+ /**
11
+ * Resolves the DONOBU cloud persistence config, or null if it isn't active for
12
+ * this environment (missing credentials, or `DONOBU` not in
13
+ * `PERSISTENCE_PRIORITY`). Single source of truth for "is DONOBU cloud on?",
14
+ * shared by {@link FlowsPersistenceRegistryImpl.fromEnvironment} (which builds
15
+ * the read/write client) and the `donobu` CLI (which starts the parent-owned
16
+ * uploader). The credential precedence — primary `DONOBU_API_KEY`, then the
17
+ * persistence-only `DONOBU_PERSISTENCE_API_KEY` — matches the inference
18
+ * fallback chain in DonobuFlowsManager.createGptClient.
19
+ */
20
+ function resolveDonobuCloudConfig(environ) {
21
+ const baseUrl = environ.data.DONOBU_API_BASE_URL;
22
+ const apiKey = environ.data.DONOBU_API_KEY ?? environ.data.DONOBU_PERSISTENCE_API_KEY;
23
+ if (!baseUrl ||
24
+ !apiKey ||
25
+ !environ.data.PERSISTENCE_PRIORITY.includes('DONOBU')) {
26
+ return null;
27
+ }
28
+ return { baseUrl, apiKey };
29
+ }
9
30
  /**
10
31
  * A factory class for creating FlowsPersistence instances. Persistence layers are constructed
11
32
  * eagerly at creation time and reused across all subsequent calls.
@@ -25,22 +46,24 @@ class FlowsPersistenceRegistryImpl {
25
46
  * all applicable persistence layers.
26
47
  */
27
48
  static async fromEnvironment(environ, persistencePlugins = new PersistencePlugin_1.PersistencePluginRegistry()) {
28
- const donobuApiBaseUrl = environ.data.DONOBU_API_BASE_URL;
29
- // Persistence credential resolution: prefer the primary DONOBU_API_KEY
30
- // (which also drives the inference fallback chain in
31
- // DonobuFlowsManager.createGptClient), then fall back to the
32
- // persistence-only DONOBU_PERSISTENCE_API_KEY for hosts that route
33
- // inference through an explicit gpt-config and don't want their
34
- // persistence credential to short-circuit the agent lookup.
35
- const donobuApiKey = environ.data.DONOBU_API_KEY ?? environ.data.DONOBU_PERSISTENCE_API_KEY;
49
+ const donobuCloud = resolveDonobuCloudConfig(environ);
36
50
  const layers = [];
37
51
  for (const key of environ.data.PERSISTENCE_PRIORITY) {
38
52
  switch (key) {
39
53
  case 'DONOBU':
40
- if (donobuApiKey && donobuApiBaseUrl) {
54
+ if (donobuCloud) {
41
55
  layers.push({
42
56
  key,
43
- persistence: new FlowsPersistenceDonobuApi_1.FlowsPersistenceDonobuApi(donobuApiBaseUrl, donobuApiKey),
57
+ // Read the internal coordination flag here, at the env boundary,
58
+ // rather than inside the persistence client. When `donobu test`
59
+ // set it on this (worker) process, the worker runs producer-only
60
+ // and the CLI parent owns draining; otherwise this process owns
61
+ // its own uploader (Studio, or raw `playwright test`).
62
+ persistence: (0, FlowsPersistenceDonobuApi_1.createDonobuFlowsPersistence)({
63
+ baseUrl: donobuCloud.baseUrl,
64
+ apiKey: donobuCloud.apiKey,
65
+ ownsUploadDraining: !environ.data.DONOBU_UPLOADS_OWNED_BY_PARENT,
66
+ }),
44
67
  });
45
68
  }
46
69
  break;
@@ -602,33 +602,13 @@ Use this information to return an appropriate JSON object.`,
602
602
  }
603
603
  }
604
604
  }
605
+ const originalGoto = (0, originalGotoRegistry_1.getOriginalGoto)(page);
606
+ // The navigation itself is the only outcome the caller cares about. A
607
+ // throw here is a genuine navigation error (bad URL, timeout, net::ERR_*),
608
+ // so we record a best-effort failed tool call and re-throw to the caller.
609
+ let resp;
605
610
  try {
606
- const originalGoto = (0, originalGotoRegistry_1.getOriginalGoto)(page);
607
- const resp = await originalGoto.call(page, url, options);
608
- const pageTitle = await page.title();
609
- const postCallImage = await PlaywrightUtils_1.PlaywrightUtils.takeViewportScreenshot(page);
610
- const postCallImageId = await sharedState.persistence.saveScreenShot(flowId, postCallImage);
611
- const completedAt = new Date().getTime();
612
- await sharedState.persistence.setToolCall(flowId, {
613
- id: MiscUtils_1.MiscUtils.createAdHocToolCallId(),
614
- toolName: GoToWebpageTool_1.GoToWebpageTool.NAME,
615
- parameters: {
616
- url: effectiveUrl,
617
- },
618
- outcome: {
619
- isSuccessful: true,
620
- forLlm: `Successfully navigated to ${effectiveUrl}`,
621
- metadata: {
622
- pageTitle: pageTitle,
623
- resolvedUrl: page.url(),
624
- },
625
- },
626
- postCallImageId: postCallImageId,
627
- page: effectiveUrl,
628
- startedAt: startedAt,
629
- completedAt: completedAt,
630
- });
631
- return resp;
611
+ resp = await originalGoto.call(page, url, options);
632
612
  }
633
613
  catch (error) {
634
614
  // Best-effort screenshot and tool-call recording - if the page is gone
@@ -659,6 +639,55 @@ Use this information to return an appropriate JSON object.`,
659
639
  }
660
640
  throw error;
661
641
  }
642
+ // Navigation succeeded. Everything below is best-effort Donobu telemetry
643
+ // (page title, screenshot, persisted tool call) and must never fail the
644
+ // caller's page.goto(). A SPA that self-reloads right after the `load`
645
+ // event - e.g. a feature-flag bootstrap - tears down the execution
646
+ // context, so post-navigation calls like page.title() can throw
647
+ // "Execution context was destroyed, most likely because of a navigation".
648
+ // That is normal app behavior, not a navigation failure.
649
+ try {
650
+ // page.title() evaluates in the page context, so it is the most likely
651
+ // casualty of a mid-flight reload. Capture it defensively (the CDP-based
652
+ // screenshot can still succeed via the compositor) so we can record a
653
+ // useful tool call even when the title is momentarily unavailable.
654
+ let pageTitle = '';
655
+ try {
656
+ pageTitle = await page.title();
657
+ }
658
+ catch (titleError) {
659
+ if (!PlaywrightUtils_1.PlaywrightUtils.isPageClosedError(titleError)) {
660
+ throw titleError;
661
+ }
662
+ Logger_1.appLogger.debug('page.title() failed after navigation (execution context destroyed by an in-flight reload); recording goto with an empty title.');
663
+ }
664
+ const postCallImage = await PlaywrightUtils_1.PlaywrightUtils.takeViewportScreenshot(page);
665
+ const postCallImageId = await sharedState.persistence.saveScreenShot(flowId, postCallImage);
666
+ const completedAt = new Date().getTime();
667
+ await sharedState.persistence.setToolCall(flowId, {
668
+ id: MiscUtils_1.MiscUtils.createAdHocToolCallId(),
669
+ toolName: GoToWebpageTool_1.GoToWebpageTool.NAME,
670
+ parameters: {
671
+ url: effectiveUrl,
672
+ },
673
+ outcome: {
674
+ isSuccessful: true,
675
+ forLlm: `Successfully navigated to ${effectiveUrl}`,
676
+ metadata: {
677
+ pageTitle: pageTitle,
678
+ resolvedUrl: page.url(),
679
+ },
680
+ },
681
+ postCallImageId: postCallImageId,
682
+ page: effectiveUrl,
683
+ startedAt: startedAt,
684
+ completedAt: completedAt,
685
+ });
686
+ }
687
+ catch (telemetryError) {
688
+ Logger_1.appLogger.warn('Navigation succeeded but Donobu post-goto telemetry failed; this does not affect the navigation:', telemetryError);
689
+ }
690
+ return resp;
662
691
  };
663
692
  page.run = async (toolName, toolParams, options) => {
664
693
  // Thin wrapper to run a Donobu tool and throw on failure so tests can await it directly.
@@ -333,8 +333,11 @@ exports.test = test_1.test.extend({
333
333
  }
334
334
  let initialActive;
335
335
  try {
336
- const status = await (0, fileUploadWorkerRegistry_1.getFileUploadAggregateStatus)();
337
- initialActive = status.totalPending + status.totalInFlight;
336
+ // Owned count only: pending + this process's own in-flight claims.
337
+ // Claims orphaned by other/crashed processes are excluded — this
338
+ // process can neither upload nor release them, so waiting on them
339
+ // would wedge worker teardown until the grace timeout force-kills it.
340
+ initialActive = await (0, fileUploadWorkerRegistry_1.getOwnedActiveUploadCount)();
338
341
  }
339
342
  catch (err) {
340
343
  Logger_1.appLogger.warn(`donobuFileUploadDrainGuard: failed to read upload status; ` +
@@ -20,7 +20,7 @@ function getEnvSnapshot() {
20
20
  }
21
21
  async function getOrCreateDonobuStack() {
22
22
  if (!donobuStack) {
23
- donobuStack = await (0, DonobuStack_1.setupDonobuStack)('LOCAL', ControlPanel_1.NoOpControlPanelFactory, new EnvPersistenceVolatile_1.EnvPersistenceVolatile(getEnvSnapshot()), envVars_1.env.pick('ANTHROPIC_API_KEY', 'ANTHROPIC_MODEL_NAME', 'AWS_ACCESS_KEY_ID', 'AWS_BEDROCK_MODEL_NAME', 'AWS_SECRET_ACCESS_KEY', 'BASE64_GPT_CONFIG', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'DONOBU_API_BASE_URL', 'DONOBU_API_KEY', 'DONOBU_PERSISTENCE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY', 'GOOGLE_GENERATIVE_AI_MODEL_NAME', 'OLLAMA_API_URL', 'OLLAMA_MODEL_NAME', 'OPENAI_API_KEY', 'OPENAI_API_MODEL_NAME', 'PERSISTENCE_PRIORITY'));
23
+ donobuStack = await (0, DonobuStack_1.setupDonobuStack)('LOCAL', ControlPanel_1.NoOpControlPanelFactory, new EnvPersistenceVolatile_1.EnvPersistenceVolatile(getEnvSnapshot()), envVars_1.env.pick('ANTHROPIC_API_KEY', 'ANTHROPIC_MODEL_NAME', 'AWS_ACCESS_KEY_ID', 'AWS_BEDROCK_MODEL_NAME', 'AWS_SECRET_ACCESS_KEY', 'BASE64_GPT_CONFIG', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'DONOBU_API_BASE_URL', 'DONOBU_API_KEY', 'DONOBU_PERSISTENCE_API_KEY', 'DONOBU_UPLOADS_OWNED_BY_PARENT', 'GOOGLE_GENERATIVE_AI_API_KEY', 'GOOGLE_GENERATIVE_AI_MODEL_NAME', 'OLLAMA_API_URL', 'OLLAMA_MODEL_NAME', 'OPENAI_API_KEY', 'OPENAI_API_MODEL_NAME', 'PERSISTENCE_PRIORITY'));
24
24
  return donobuStack;
25
25
  }
26
26
  else {
package/dist/main.d.ts CHANGED
@@ -105,6 +105,7 @@ export declare function startDonobuServer({ port, controlPanelHost, environ, }?:
105
105
  AWS_SECRET_ACCESS_KEY: z.ZodOptional<z.ZodString>;
106
106
  DONOBU_API_KEY: z.ZodOptional<z.ZodString>;
107
107
  DONOBU_PERSISTENCE_API_KEY: z.ZodOptional<z.ZodString>;
108
+ DONOBU_UPLOADS_OWNED_BY_PARENT: z.ZodOptional<z.ZodCodec<z.ZodString, z.ZodBoolean>>;
108
109
  }, {
109
110
  BASE64_GPT_CONFIG?: string | undefined;
110
111
  BROWSERBASE_API_KEY?: string | undefined;
@@ -125,6 +126,7 @@ export declare function startDonobuServer({ port, controlPanelHost, environ, }?:
125
126
  AWS_SECRET_ACCESS_KEY?: string | undefined;
126
127
  DONOBU_API_KEY?: string | undefined;
127
128
  DONOBU_PERSISTENCE_API_KEY?: string | undefined;
129
+ DONOBU_UPLOADS_OWNED_BY_PARENT?: boolean | undefined;
128
130
  }> | undefined;
129
131
  }): Promise<void>;
130
132
  //# sourceMappingURL=main.d.ts.map
package/dist/main.js CHANGED
@@ -111,7 +111,7 @@ const DEFAULT_PORT = 31000;
111
111
  * Starts a Donobu API server at the given port. The server assumes that the
112
112
  * Playwright browsers have been installed.
113
113
  */
114
- async function startDonobuServer({ port = DEFAULT_PORT, controlPanelHost = ControlPanel_1.NoOpControlPanelFactory, environ = envVars_1.env.pick('ANTHROPIC_API_KEY', 'ANTHROPIC_MODEL_NAME', 'AWS_ACCESS_KEY_ID', 'AWS_BEDROCK_MODEL_NAME', 'AWS_SECRET_ACCESS_KEY', 'BASE64_GPT_CONFIG', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'DONOBU_DEPLOYMENT_ENVIRONMENT', 'DONOBU_API_BASE_URL', 'DONOBU_API_KEY', 'DONOBU_PERSISTENCE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY', 'GOOGLE_GENERATIVE_AI_MODEL_NAME', 'OLLAMA_API_URL', 'OLLAMA_MODEL_NAME', 'OPENAI_API_KEY', 'OPENAI_API_MODEL_NAME', 'PERSISTENCE_PRIORITY'), } = {}) {
114
+ async function startDonobuServer({ port = DEFAULT_PORT, controlPanelHost = ControlPanel_1.NoOpControlPanelFactory, environ = envVars_1.env.pick('ANTHROPIC_API_KEY', 'ANTHROPIC_MODEL_NAME', 'AWS_ACCESS_KEY_ID', 'AWS_BEDROCK_MODEL_NAME', 'AWS_SECRET_ACCESS_KEY', 'BASE64_GPT_CONFIG', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'DONOBU_DEPLOYMENT_ENVIRONMENT', 'DONOBU_API_BASE_URL', 'DONOBU_API_KEY', 'DONOBU_PERSISTENCE_API_KEY', 'DONOBU_UPLOADS_OWNED_BY_PARENT', 'GOOGLE_GENERATIVE_AI_API_KEY', 'GOOGLE_GENERATIVE_AI_MODEL_NAME', 'OLLAMA_API_URL', 'OLLAMA_MODEL_NAME', 'OPENAI_API_KEY', 'OPENAI_API_MODEL_NAME', 'PERSISTENCE_PRIORITY'), } = {}) {
115
115
  try {
116
116
  const adminController = await AdminApiController_1.AdminApiController.create(environ.data.DONOBU_DEPLOYMENT_ENVIRONMENT ?? 'LOCAL', controlPanelHost, environ);
117
117
  await adminController.start(port);
@@ -59,7 +59,7 @@ export declare class AdminApiController {
59
59
  * - no checking for flow ownership is performed, as all flows are considered owned by the
60
60
  * local environment.
61
61
  */
62
- static create(donobuDeploymentEnvironment: DonobuDeploymentEnvironment, controlPanelFactory: ControlPanelFactory, environ: EnvPick<typeof env, 'ANTHROPIC_API_KEY' | 'ANTHROPIC_MODEL_NAME' | 'AWS_ACCESS_KEY_ID' | 'AWS_BEDROCK_MODEL_NAME' | 'AWS_SECRET_ACCESS_KEY' | 'BASE64_GPT_CONFIG' | 'BROWSERBASE_API_KEY' | 'BROWSERBASE_PROJECT_ID' | 'DONOBU_API_BASE_URL' | 'DONOBU_API_KEY' | 'DONOBU_PERSISTENCE_API_KEY' | 'GOOGLE_GENERATIVE_AI_API_KEY' | 'GOOGLE_GENERATIVE_AI_MODEL_NAME' | 'OLLAMA_API_URL' | 'OLLAMA_MODEL_NAME' | 'OPENAI_API_KEY' | 'OPENAI_API_MODEL_NAME' | 'PERSISTENCE_PRIORITY'>): Promise<AdminApiController>;
62
+ static create(donobuDeploymentEnvironment: DonobuDeploymentEnvironment, controlPanelFactory: ControlPanelFactory, environ: EnvPick<typeof env, 'ANTHROPIC_API_KEY' | 'ANTHROPIC_MODEL_NAME' | 'AWS_ACCESS_KEY_ID' | 'AWS_BEDROCK_MODEL_NAME' | 'AWS_SECRET_ACCESS_KEY' | 'BASE64_GPT_CONFIG' | 'BROWSERBASE_API_KEY' | 'BROWSERBASE_PROJECT_ID' | 'DONOBU_API_BASE_URL' | 'DONOBU_API_KEY' | 'DONOBU_PERSISTENCE_API_KEY' | 'DONOBU_UPLOADS_OWNED_BY_PARENT' | 'GOOGLE_GENERATIVE_AI_API_KEY' | 'GOOGLE_GENERATIVE_AI_MODEL_NAME' | 'OLLAMA_API_URL' | 'OLLAMA_MODEL_NAME' | 'OPENAI_API_KEY' | 'OPENAI_API_MODEL_NAME' | 'PERSISTENCE_PRIORITY'>): Promise<AdminApiController>;
63
63
  private constructor();
64
64
  /**
65
65
  * Binds the API/web-asset server to `port` and resolves once the socket is
@@ -61,6 +61,7 @@ export declare function setupDonobuStack(donobuDeploymentEnvironment: DonobuDepl
61
61
  AWS_SECRET_ACCESS_KEY: import("zod/v4").ZodOptional<import("zod/v4").ZodString>;
62
62
  DONOBU_API_KEY: import("zod/v4").ZodOptional<import("zod/v4").ZodString>;
63
63
  DONOBU_PERSISTENCE_API_KEY: import("zod/v4").ZodOptional<import("zod/v4").ZodString>;
64
+ DONOBU_UPLOADS_OWNED_BY_PARENT: import("zod/v4").ZodOptional<import("zod/v4").ZodCodec<import("zod/v4").ZodString, import("zod/v4").ZodBoolean>>;
64
65
  }, {
65
66
  BASE64_GPT_CONFIG?: string | undefined;
66
67
  BROWSERBASE_API_KEY?: string | undefined;
@@ -80,5 +81,6 @@ export declare function setupDonobuStack(donobuDeploymentEnvironment: DonobuDepl
80
81
  AWS_SECRET_ACCESS_KEY?: string | undefined;
81
82
  DONOBU_API_KEY?: string | undefined;
82
83
  DONOBU_PERSISTENCE_API_KEY?: string | undefined;
84
+ DONOBU_UPLOADS_OWNED_BY_PARENT?: boolean | undefined;
83
85
  }>): Promise<DonobuStack>;
84
86
  //# sourceMappingURL=DonobuStack.d.ts.map
@@ -33,7 +33,7 @@ const ToolRegistry_1 = require("./ToolRegistry");
33
33
  * then having this snapshot is relevant so that tests can use normal
34
34
  * environment variables.
35
35
  */
36
- async function setupDonobuStack(donobuDeploymentEnvironment, controlPanelFactory, envPersistenceVolatile, environ = envVars_1.env.pick('ANTHROPIC_API_KEY', 'ANTHROPIC_MODEL_NAME', 'AWS_ACCESS_KEY_ID', 'AWS_BEDROCK_MODEL_NAME', 'AWS_SECRET_ACCESS_KEY', 'BASE64_GPT_CONFIG', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'DONOBU_API_BASE_URL', 'DONOBU_API_KEY', 'DONOBU_PERSISTENCE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY', 'GOOGLE_GENERATIVE_AI_MODEL_NAME', 'OLLAMA_API_URL', 'OLLAMA_MODEL_NAME', 'OPENAI_API_KEY', 'OPENAI_API_MODEL_NAME', 'PERSISTENCE_PRIORITY')) {
36
+ async function setupDonobuStack(donobuDeploymentEnvironment, controlPanelFactory, envPersistenceVolatile, environ = envVars_1.env.pick('ANTHROPIC_API_KEY', 'ANTHROPIC_MODEL_NAME', 'AWS_ACCESS_KEY_ID', 'AWS_BEDROCK_MODEL_NAME', 'AWS_SECRET_ACCESS_KEY', 'BASE64_GPT_CONFIG', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'DONOBU_API_BASE_URL', 'DONOBU_API_KEY', 'DONOBU_PERSISTENCE_API_KEY', 'DONOBU_UPLOADS_OWNED_BY_PARENT', 'GOOGLE_GENERATIVE_AI_API_KEY', 'GOOGLE_GENERATIVE_AI_MODEL_NAME', 'OLLAMA_API_URL', 'OLLAMA_MODEL_NAME', 'OPENAI_API_KEY', 'OPENAI_API_MODEL_NAME', 'PERSISTENCE_PRIORITY')) {
37
37
  const loadedPlugins = await loadDefaultPlugins();
38
38
  const resolvedToolRegistry = await (0, ToolRegistry_1.createDefaultToolRegistry)(loadedPlugins.tools);
39
39
  const persistencePlugins = new PersistencePlugin_1.PersistencePluginRegistry(loadedPlugins.persistencePlugins);
@@ -94,6 +94,22 @@ export declare class FileUploadCache {
94
94
  * active work even though `listPending` excludes it.
95
95
  */
96
96
  countActive(): Promise<number>;
97
+ /**
98
+ * Like {@link countActive}, but scoped to the work THIS worker is
99
+ * responsible for finishing before its process exits: files still
100
+ * `.pending` (which this worker will claim and upload) plus this worker's
101
+ * own `.uploading.<ownToken>` claims.
102
+ *
103
+ * Foreign `.uploading.<otherToken>` markers — in-flight claims owned by
104
+ * OTHER (possibly long-dead) processes — are deliberately EXCLUDED. A live
105
+ * peer is responsible for finishing its own claims, and claims orphaned by
106
+ * a crashed peer are recovered by {@link reclaimStaleClaims} on the next
107
+ * long-lived process. Counting them here is exactly what made an
108
+ * end-of-worker drain block for the full timeout on a crashed peer's
109
+ * orphaned claim (which this process can neither upload nor release),
110
+ * wedging Playwright worker teardown until it was force-killed.
111
+ */
112
+ countOwnedActive(ownToken: string): Promise<number>;
97
113
  /**
98
114
  * Returns a breakdown of marker counts on disk. Used by the admin
99
115
  * "uploads status" endpoint so the desktop app can show the user how