donobu 5.36.0 → 5.36.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 (35) hide show
  1. package/dist/esm/managers/DonobuFlowsManager.d.ts +21 -1
  2. package/dist/esm/managers/DonobuFlowsManager.js +51 -2
  3. package/dist/esm/managers/DonobuStack.js +4 -4
  4. package/dist/esm/managers/FederatedPagination.d.ts +43 -13
  5. package/dist/esm/managers/FederatedPagination.js +122 -39
  6. package/dist/esm/managers/SuitesManager.js +11 -6
  7. package/dist/esm/managers/TestsManager.d.ts +20 -2
  8. package/dist/esm/managers/TestsManager.js +67 -14
  9. package/dist/esm/persistence/flows/FlowsPersistenceDonobuApi.js +10 -1
  10. package/dist/esm/persistence/flows/FlowsPersistenceRegistry.d.ts +25 -1
  11. package/dist/esm/persistence/flows/FlowsPersistenceRegistry.js +17 -5
  12. package/dist/esm/persistence/suites/SuitesPersistenceRegistry.d.ts +18 -1
  13. package/dist/esm/persistence/suites/SuitesPersistenceRegistry.js +17 -5
  14. package/dist/esm/persistence/tests/TestsPersistenceRegistry.d.ts +18 -1
  15. package/dist/esm/persistence/tests/TestsPersistenceRegistry.js +20 -5
  16. package/dist/esm/reporter/render.js +36 -10
  17. package/dist/esm/tools/ScrollPageTool.js +1 -1
  18. package/dist/managers/DonobuFlowsManager.d.ts +21 -1
  19. package/dist/managers/DonobuFlowsManager.js +51 -2
  20. package/dist/managers/DonobuStack.js +4 -4
  21. package/dist/managers/FederatedPagination.d.ts +43 -13
  22. package/dist/managers/FederatedPagination.js +122 -39
  23. package/dist/managers/SuitesManager.js +11 -6
  24. package/dist/managers/TestsManager.d.ts +20 -2
  25. package/dist/managers/TestsManager.js +67 -14
  26. package/dist/persistence/flows/FlowsPersistenceDonobuApi.js +10 -1
  27. package/dist/persistence/flows/FlowsPersistenceRegistry.d.ts +25 -1
  28. package/dist/persistence/flows/FlowsPersistenceRegistry.js +17 -5
  29. package/dist/persistence/suites/SuitesPersistenceRegistry.d.ts +18 -1
  30. package/dist/persistence/suites/SuitesPersistenceRegistry.js +17 -5
  31. package/dist/persistence/tests/TestsPersistenceRegistry.d.ts +18 -1
  32. package/dist/persistence/tests/TestsPersistenceRegistry.js +20 -5
  33. package/dist/reporter/render.js +36 -10
  34. package/dist/tools/ScrollPageTool.js +1 -1
  35. package/package.json +1 -1
@@ -22,6 +22,7 @@ import type { RunConfig } from '../models/RunConfig';
22
22
  import type { RunMode } from '../models/RunMode';
23
23
  import type { ToolCall } from '../models/ToolCall';
24
24
  import type { FlowsPersistenceRegistry } from '../persistence/flows/FlowsPersistenceRegistry';
25
+ import type { TestsPersistenceRegistry } from '../persistence/tests/TestsPersistenceRegistry';
25
26
  import type { TargetRuntimePluginRegistry } from '../targets/TargetRuntimePlugin';
26
27
  import type { Tool } from '../tools/Tool';
27
28
  import type { FlowLogSnapshot } from '../utils/FlowLogBuffer';
@@ -45,17 +46,22 @@ export declare class DonobuFlowsManager {
45
46
  private readonly environ;
46
47
  private readonly toolRegistry;
47
48
  private readonly targetRuntimePlugins;
49
+ private readonly testsPersistenceRegistry;
48
50
  static readonly DEFAULT_MESSAGE_DURATION = 2247;
49
51
  static readonly DEFAULT_MAX_TOOL_CALLS = 50;
50
52
  static readonly DEFAULT_BROWSER_STATE_FILENAME = "browserstate.json";
51
53
  static readonly FLOW_NAME_MAX_LENGTH = 255;
52
54
  private readonly flowRuntime;
53
55
  private readonly flowCatalog;
54
- constructor(deploymentEnvironment: DonobuDeploymentEnvironment, gptClientFactory: GptClientFactory, gptConfigsManager: GptConfigsManager, agentsManager: AgentsManager, flowsPersistenceRegistry: FlowsPersistenceRegistry, envDataManager: EnvDataManager, 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_KEY' | 'GOOGLE_GENERATIVE_AI_API_KEY' | 'GOOGLE_GENERATIVE_AI_MODEL_NAME' | 'OLLAMA_API_URL' | 'OLLAMA_MODEL_NAME' | 'OPENAI_API_KEY' | 'OPENAI_API_MODEL_NAME'>, toolRegistry: ToolRegistry, targetRuntimePlugins: TargetRuntimePluginRegistry);
56
+ constructor(deploymentEnvironment: DonobuDeploymentEnvironment, gptClientFactory: GptClientFactory, gptConfigsManager: GptConfigsManager, agentsManager: AgentsManager, flowsPersistenceRegistry: FlowsPersistenceRegistry, envDataManager: EnvDataManager, 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_KEY' | 'GOOGLE_GENERATIVE_AI_API_KEY' | 'GOOGLE_GENERATIVE_AI_MODEL_NAME' | 'OLLAMA_API_URL' | 'OLLAMA_MODEL_NAME' | 'OPENAI_API_KEY' | 'OPENAI_API_MODEL_NAME'>, toolRegistry: ToolRegistry, targetRuntimePlugins: TargetRuntimePluginRegistry, testsPersistenceRegistry: TestsPersistenceRegistry);
55
57
  /**
56
58
  * Create a flow with the given parameters and invoke its `DonobuFlow#run`
57
59
  * method, adding it to list of active flows.
58
60
  *
61
+ * If `flowParams.testId` is set, the new flow is persisted to the same
62
+ * layer as the referenced test so the `flow_metadata.test_id` foreign
63
+ * key holds. Otherwise the primary layer is used.
64
+ *
59
65
  * @param gptClient If present, will use this as the associated GPT client for
60
66
  * this flow instead of instantiating a new one. If so, the
61
67
  * gptConfigNameOverride field will be ignored.
@@ -168,6 +174,20 @@ export declare class DonobuFlowsManager {
168
174
  * {@link BrowserStateNotFoundException} if it is not found.
169
175
  */
170
176
  getBrowserStorageState(browserStateRef: BrowserStateReference): Promise<BrowserStorageState>;
177
+ /**
178
+ * Picks the flows persistence layer to use when creating a new flow.
179
+ *
180
+ * - If `testId` is null/undefined: use the primary flows layer.
181
+ * - If `testId` is set: look up the test's layer key and use the matching
182
+ * flows layer. If no flows layer matches the test's key (rare —
183
+ * asymmetric registry config), fall back to the primary layer; the FK
184
+ * won't hold but the flow is at least persisted.
185
+ * - If `testId` is set but no test exists with that ID anywhere: fall
186
+ * back to the primary layer (the SQLite FK will reject if applicable;
187
+ * non-DB layers will accept the dangling reference).
188
+ */
189
+ private resolveLayerForFlowCreate;
190
+ private findTestLayerKey;
171
191
  private isLocallyRunning;
172
192
  }
173
193
  /**
@@ -47,6 +47,7 @@ const BrowserStateNotFoundException_1 = require("../exceptions/BrowserStateNotFo
47
47
  const CannotDeleteRunningFlowException_1 = require("../exceptions/CannotDeleteRunningFlowException");
48
48
  const FlowNotFoundException_1 = require("../exceptions/FlowNotFoundException");
49
49
  const InvalidParamValueException_1 = require("../exceptions/InvalidParamValueException");
50
+ const TestNotFoundException_1 = require("../exceptions/TestNotFoundException");
50
51
  const ToolRequiresGptException_1 = require("../exceptions/ToolRequiresGptException");
51
52
  const UnknownToolException_1 = require("../exceptions/UnknownToolException");
52
53
  const GptConfig_1 = require("../models/GptConfig");
@@ -64,7 +65,7 @@ const FlowRuntime_1 = require("./FlowRuntime");
64
65
  const InteractionVisualizer_1 = require("./InteractionVisualizer");
65
66
  const ToolManager_1 = require("./ToolManager");
66
67
  class DonobuFlowsManager {
67
- constructor(deploymentEnvironment, gptClientFactory, gptConfigsManager, agentsManager, flowsPersistenceRegistry, envDataManager, controlPanelFactory, environ, toolRegistry, targetRuntimePlugins) {
68
+ constructor(deploymentEnvironment, gptClientFactory, gptConfigsManager, agentsManager, flowsPersistenceRegistry, envDataManager, controlPanelFactory, environ, toolRegistry, targetRuntimePlugins, testsPersistenceRegistry) {
68
69
  this.deploymentEnvironment = deploymentEnvironment;
69
70
  this.gptClientFactory = gptClientFactory;
70
71
  this.gptConfigsManager = gptConfigsManager;
@@ -75,6 +76,7 @@ class DonobuFlowsManager {
75
76
  this.environ = environ;
76
77
  this.toolRegistry = toolRegistry;
77
78
  this.targetRuntimePlugins = targetRuntimePlugins;
79
+ this.testsPersistenceRegistry = testsPersistenceRegistry;
78
80
  this.flowRuntime = new FlowRuntime_1.FlowRuntime();
79
81
  this.flowCatalog = new FlowCatalog_1.FlowCatalog(this.flowsPersistenceRegistry, this.flowRuntime, this.deploymentEnvironment);
80
82
  }
@@ -82,6 +84,10 @@ class DonobuFlowsManager {
82
84
  * Create a flow with the given parameters and invoke its `DonobuFlow#run`
83
85
  * method, adding it to list of active flows.
84
86
  *
87
+ * If `flowParams.testId` is set, the new flow is persisted to the same
88
+ * layer as the referenced test so the `flow_metadata.test_id` foreign
89
+ * key holds. Otherwise the primary layer is used.
90
+ *
85
91
  * @param gptClient If present, will use this as the associated GPT client for
86
92
  * this flow instead of instantiating a new one. If so, the
87
93
  * gptConfigNameOverride field will be ignored.
@@ -179,7 +185,7 @@ class DonobuFlowsManager {
179
185
  ...targetRuntime.getMetadataFields(),
180
186
  provenance: (0, buildProvenance_1.buildProvenance)('DONOBU_STUDIO'),
181
187
  };
182
- const flowsPersistence = await this.flowsPersistenceRegistry.get();
188
+ const flowsPersistence = await this.resolveLayerForFlowCreate(flowParams.testId ?? null);
183
189
  const envData = await this.envDataManager.getByNames(flowMetadata.envVars ?? []);
184
190
  const donobuFlow = new DonobuFlow_1.DonobuFlow(this, envData, flowsPersistence, gptClientData.gptClient, toolManager, interactionVisualizer, toolCallsOnStart, [], [], targetRuntime.inspector, flowMetadata, targetRuntime.controlPanel);
185
191
  await flowsPersistence.setFlowMetadata(flowMetadata);
@@ -652,6 +658,49 @@ class DonobuFlowsManager {
652
658
  }
653
659
  throw new BrowserStateNotFoundException_1.BrowserStateNotFoundException(flowMetadata.id);
654
660
  }
661
+ /**
662
+ * Picks the flows persistence layer to use when creating a new flow.
663
+ *
664
+ * - If `testId` is null/undefined: use the primary flows layer.
665
+ * - If `testId` is set: look up the test's layer key and use the matching
666
+ * flows layer. If no flows layer matches the test's key (rare —
667
+ * asymmetric registry config), fall back to the primary layer; the FK
668
+ * won't hold but the flow is at least persisted.
669
+ * - If `testId` is set but no test exists with that ID anywhere: fall
670
+ * back to the primary layer (the SQLite FK will reject if applicable;
671
+ * non-DB layers will accept the dangling reference).
672
+ */
673
+ async resolveLayerForFlowCreate(testId) {
674
+ if (!testId) {
675
+ return this.flowsPersistenceRegistry.get();
676
+ }
677
+ let testLayerKey;
678
+ try {
679
+ testLayerKey = await this.findTestLayerKey(testId);
680
+ }
681
+ catch (error) {
682
+ if (!(error instanceof TestNotFoundException_1.TestNotFoundException)) {
683
+ throw error;
684
+ }
685
+ return this.flowsPersistenceRegistry.get();
686
+ }
687
+ const matched = await this.flowsPersistenceRegistry.getByKey(testLayerKey);
688
+ return matched ?? (await this.flowsPersistenceRegistry.get());
689
+ }
690
+ async findTestLayerKey(testId) {
691
+ for (const { key, persistence, } of await this.testsPersistenceRegistry.getEntries()) {
692
+ try {
693
+ await persistence.getTestById(testId);
694
+ return key;
695
+ }
696
+ catch (error) {
697
+ if (!(error instanceof TestNotFoundException_1.TestNotFoundException)) {
698
+ throw error;
699
+ }
700
+ }
701
+ }
702
+ throw TestNotFoundException_1.TestNotFoundException.forId(testId);
703
+ }
655
704
  isLocallyRunning() {
656
705
  return this.deploymentEnvironment === 'LOCAL';
657
706
  }
@@ -46,12 +46,12 @@ async function setupDonobuStack(donobuDeploymentEnvironment, controlPanelFactory
46
46
  const agentsPersistence = await AgentsPersistenceSqlite_1.AgentsPersistenceSqlite.create(sqliteDb);
47
47
  const agentsManager = await AgentsManager_1.AgentsManager.create(agentsPersistence, gptConfigsManager);
48
48
  const flowsPersistenceRegistry = await FlowsPersistenceRegistry_1.FlowsPersistenceRegistryImpl.fromEnvironment(environ, persistencePlugins);
49
- const envPersistenceFactory = await EnvPersistenceRegistry_1.EnvPersistenceRegistryImpl.fromEnvironment(envPersistenceVolatile ?? null, environ, persistencePlugins);
50
- const envDataManager = new EnvDataManager_1.EnvDataManager(envPersistenceFactory);
51
- const flowsManager = new DonobuFlowsManager_1.DonobuFlowsManager(donobuDeploymentEnvironment, gptClientFactory, gptConfigsManager, agentsManager, flowsPersistenceRegistry, envDataManager, controlPanelFactory, environ, resolvedToolRegistry, targetRuntimePlugins);
52
49
  const testsPersistenceRegistry = await TestsPersistenceRegistry_1.TestsPersistenceRegistryImpl.fromEnvironment(environ, flowsPersistenceRegistry);
53
- const testsManager = new TestsManager_1.TestsManager(testsPersistenceRegistry, flowsManager);
54
50
  const suitesPersistenceRegistry = await SuitesPersistenceRegistry_1.SuitesPersistenceRegistryImpl.fromEnvironment(environ);
51
+ const envPersistenceFactory = await EnvPersistenceRegistry_1.EnvPersistenceRegistryImpl.fromEnvironment(envPersistenceVolatile ?? null, environ, persistencePlugins);
52
+ const envDataManager = new EnvDataManager_1.EnvDataManager(envPersistenceFactory);
53
+ const flowsManager = new DonobuFlowsManager_1.DonobuFlowsManager(donobuDeploymentEnvironment, gptClientFactory, gptConfigsManager, agentsManager, flowsPersistenceRegistry, envDataManager, controlPanelFactory, environ, resolvedToolRegistry, targetRuntimePlugins, testsPersistenceRegistry);
54
+ const testsManager = new TestsManager_1.TestsManager(testsPersistenceRegistry, suitesPersistenceRegistry, flowsManager);
55
55
  const suitesManager = new SuitesManager_1.SuitesManager(suitesPersistenceRegistry, testsPersistenceRegistry);
56
56
  return {
57
57
  toolRegistry: resolvedToolRegistry,
@@ -1,31 +1,61 @@
1
1
  import type { PaginatedResult } from '../models/PaginatedResult';
2
+ /**
3
+ * Per-layer pagination state, carried across pages inside the composite
4
+ * page token. `cursor` is the layer's own opaque page token; `resumeAfterId`
5
+ * is the id of the last item from this layer kept on the previous page (in
6
+ * the layer's own order). `exhausted` becomes true once a layer has nothing
7
+ * more to give and we've already returned everything it contributed.
8
+ */
9
+ interface SourceState {
10
+ cursor?: string;
11
+ resumeAfterId?: string;
12
+ exhausted: boolean;
13
+ }
2
14
  /**
3
15
  * Internal state tracked across pages when federating results from multiple
4
- * persistence layers.
16
+ * persistence layers. Serialized as base64(JSON(...)) into the composite
17
+ * page token.
5
18
  */
6
19
  interface FederatedPaginationState {
7
- sourceTokens: Record<number, string>;
8
- exhaustedSources: number[];
9
- cursorTimestamp: number | null;
20
+ sources: SourceState[];
10
21
  }
11
22
  export declare function createCompositePageToken(state: FederatedPaginationState): string;
12
23
  export declare function parseCompositePageToken(token?: string): FederatedPaginationState;
13
24
  /**
14
- * Federate a paginated listing across multiple persistence layers.
25
+ * Federate a paginated listing across multiple persistence layers
26
+ * (resume-by-id).
27
+ *
28
+ * Each layer holds an opaque cursor and an optional `resumeAfterId`. On
29
+ * each page request, federation fetches from each non-exhausted layer
30
+ * (advancing through batches as needed until it has at least `limit`
31
+ * candidates per layer), stable-sorts the merged contributions with the
32
+ * caller's `comparator`, and trims to `limit`. For each layer, the
33
+ * **last kept item in that layer's order** becomes the new
34
+ * `resumeAfterId`, with `cursor` set to the layer-token used to fetch
35
+ * that item's batch. Layers whose contribution is wholly dropped by the
36
+ * trim retain their pre-call state so their items remain reachable on
37
+ * later pages.
15
38
  *
16
- * Each layer is queried with up to the requested limit, results are merged,
17
- * sorted by the provided comparator, and trimmed to the requested limit.
18
- * A composite page token tracks per-source pagination state.
39
+ * **Critical contract: the comparator must NOT tiebreak by id.** Ties
40
+ * on the sort key must return 0 from `comparator`. `Array.sort` is
41
+ * stable since ES2019, and federation feeds the merge in
42
+ * `[layer0..., layer1..., ...]` insertion order, so ties resolve by
43
+ * `(layerIndex, layer-internal position)` — exactly the property that
44
+ * keeps each layer's kept items as a *prefix* of its own order, which
45
+ * is what makes a single `resumeAfterId` representable. An id
46
+ * tiebreaker would silently break this and lose items.
19
47
  *
20
- * @param layers The persistence layers to query.
21
- * @param query The query object (must have `limit`, `pageToken`).
22
- * @param fetcher Function that queries a single layer.
23
- * @param comparator Sort comparator for merging results across layers.
48
+ * **Layer contract.** Layers must return the same items in the same
49
+ * order for the same `(query, pageToken)` pair across calls. Their
50
+ * internal ordering does not need to match federation's tiebreaker —
51
+ * just be deterministic.
24
52
  */
25
53
  export declare function federatedList<TQuery extends {
26
54
  limit?: number;
27
55
  pageToken?: string;
28
- }, TItem>(layers: {
56
+ }, TItem extends {
57
+ id: string;
58
+ }>(layers: {
29
59
  getItems: (query: TQuery) => Promise<PaginatedResult<TItem>>;
30
60
  }[], query: TQuery, comparator: (a: TItem, b: TItem) => number): Promise<PaginatedResult<TItem>>;
31
61
  export {};
@@ -8,67 +8,150 @@ function createCompositePageToken(state) {
8
8
  }
9
9
  function parseCompositePageToken(token) {
10
10
  if (!token) {
11
- return { sourceTokens: {}, exhaustedSources: [], cursorTimestamp: null };
11
+ return { sources: [] };
12
12
  }
13
13
  try {
14
- return JSON.parse(Buffer.from(token, 'base64').toString());
14
+ const decoded = JSON.parse(Buffer.from(token, 'base64').toString());
15
+ if (decoded && Array.isArray(decoded.sources)) {
16
+ return decoded;
17
+ }
15
18
  }
16
19
  catch {
17
- return { sourceTokens: {}, exhaustedSources: [], cursorTimestamp: null };
20
+ // fall through
18
21
  }
22
+ return { sources: [] };
19
23
  }
20
24
  /**
21
- * Federate a paginated listing across multiple persistence layers.
25
+ * Federate a paginated listing across multiple persistence layers
26
+ * (resume-by-id).
27
+ *
28
+ * Each layer holds an opaque cursor and an optional `resumeAfterId`. On
29
+ * each page request, federation fetches from each non-exhausted layer
30
+ * (advancing through batches as needed until it has at least `limit`
31
+ * candidates per layer), stable-sorts the merged contributions with the
32
+ * caller's `comparator`, and trims to `limit`. For each layer, the
33
+ * **last kept item in that layer's order** becomes the new
34
+ * `resumeAfterId`, with `cursor` set to the layer-token used to fetch
35
+ * that item's batch. Layers whose contribution is wholly dropped by the
36
+ * trim retain their pre-call state so their items remain reachable on
37
+ * later pages.
22
38
  *
23
- * Each layer is queried with up to the requested limit, results are merged,
24
- * sorted by the provided comparator, and trimmed to the requested limit.
25
- * A composite page token tracks per-source pagination state.
39
+ * **Critical contract: the comparator must NOT tiebreak by id.** Ties
40
+ * on the sort key must return 0 from `comparator`. `Array.sort` is
41
+ * stable since ES2019, and federation feeds the merge in
42
+ * `[layer0..., layer1..., ...]` insertion order, so ties resolve by
43
+ * `(layerIndex, layer-internal position)` — exactly the property that
44
+ * keeps each layer's kept items as a *prefix* of its own order, which
45
+ * is what makes a single `resumeAfterId` representable. An id
46
+ * tiebreaker would silently break this and lose items.
26
47
  *
27
- * @param layers The persistence layers to query.
28
- * @param query The query object (must have `limit`, `pageToken`).
29
- * @param fetcher Function that queries a single layer.
30
- * @param comparator Sort comparator for merging results across layers.
48
+ * **Layer contract.** Layers must return the same items in the same
49
+ * order for the same `(query, pageToken)` pair across calls. Their
50
+ * internal ordering does not need to match federation's tiebreaker —
51
+ * just be deterministic.
31
52
  */
32
53
  async function federatedList(layers, query, comparator) {
33
- const paginationState = parseCompositePageToken(query.pageToken);
34
54
  const requestedLimit = Math.min(Math.max(1, query.limit ?? 100), 100);
35
- const combinedResults = [];
36
- let layersThatReturnedResults = 0;
55
+ const incomingState = parseCompositePageToken(query.pageToken);
56
+ const initialFor = (i) => {
57
+ const s = incomingState.sources[i];
58
+ return s ? { ...s } : { exhausted: false };
59
+ };
60
+ const work = [];
37
61
  for (let i = 0; i < layers.length; i++) {
38
- if (paginationState.exhaustedSources.includes(i)) {
62
+ const init = initialFor(i);
63
+ const w = { contribution: [], layerExhausted: false };
64
+ work.push(w);
65
+ if (init.exhausted) {
66
+ w.layerExhausted = true;
39
67
  continue;
40
68
  }
41
- const sourceLimit = Math.min(requestedLimit * 2, 100);
42
- const sourceQuery = {
43
- ...query,
44
- limit: sourceLimit,
45
- pageToken: paginationState.sourceTokens[i],
46
- };
47
- const sourceResult = await layers[i].getItems(sourceQuery);
48
- if (sourceResult.items.length > 0) {
49
- layersThatReturnedResults++;
69
+ let cursor = init.cursor;
70
+ let resume = init.resumeAfterId;
71
+ while (w.contribution.length < requestedLimit) {
72
+ const layerQuery = {
73
+ ...query,
74
+ limit: requestedLimit,
75
+ pageToken: cursor,
76
+ };
77
+ const batch = await layers[i].getItems(layerQuery);
78
+ let items = batch.items;
79
+ if (resume !== undefined) {
80
+ const idx = items.findIndex((it) => it.id === resume);
81
+ if (idx >= 0) {
82
+ items = items.slice(idx + 1);
83
+ }
84
+ // The skip is one-shot: clear `resume` regardless of whether we
85
+ // found it. If found, we've passed it; if not, it was in a
86
+ // previously-fetched batch and won't reappear in subsequent
87
+ // batches we fetch in this loop.
88
+ resume = undefined;
89
+ }
90
+ for (const it of items) {
91
+ w.contribution.push({
92
+ item: it,
93
+ layerIndex: i,
94
+ fetchCursor: cursor,
95
+ });
96
+ }
97
+ if (!batch.nextPageToken) {
98
+ w.layerExhausted = true;
99
+ break;
100
+ }
101
+ cursor = batch.nextPageToken;
50
102
  }
51
- combinedResults.push(...sourceResult.items);
52
- if (sourceResult.nextPageToken) {
53
- paginationState.sourceTokens[i] = sourceResult.nextPageToken;
103
+ }
104
+ // Stable sort: ties resolve by insertion order = (layerIndex, layer
105
+ // position). Do NOT add an id tiebreaker (see header comment).
106
+ const merged = [];
107
+ for (const w of work) {
108
+ merged.push(...w.contribution);
109
+ }
110
+ merged.sort((a, b) => comparator(a.item, b.item));
111
+ const page = merged.slice(0, requestedLimit);
112
+ const newSources = [];
113
+ for (let i = 0; i < layers.length; i++) {
114
+ const init = initialFor(i);
115
+ if (init.exhausted) {
116
+ newSources.push({ exhausted: true });
117
+ continue;
118
+ }
119
+ const keptFromLayer = page.filter((w) => w.layerIndex === i);
120
+ if (keptFromLayer.length === 0) {
121
+ if (work[i].contribution.length === 0 && work[i].layerExhausted) {
122
+ // Layer truly has nothing — empty stream. Mark exhausted so
123
+ // we don't re-query it forever.
124
+ newSources.push({ exhausted: true });
125
+ }
126
+ else {
127
+ // Layer's items were all outranked by other layers. Roll back
128
+ // to the incoming state so they remain reachable on later
129
+ // pages, when the outranking layers have moved on.
130
+ newSources.push(init);
131
+ }
132
+ continue;
133
+ }
134
+ const lastKept = keptFromLayer[keptFromLayer.length - 1];
135
+ const allFromLayer = work[i].contribution;
136
+ const allKept = allFromLayer.length === keptFromLayer.length;
137
+ if (work[i].layerExhausted && allKept) {
138
+ newSources.push({ exhausted: true });
54
139
  }
55
140
  else {
56
- paginationState.exhaustedSources.push(i);
141
+ newSources.push({
142
+ cursor: lastKept.fetchCursor,
143
+ resumeAfterId: lastKept.item.id,
144
+ exhausted: false,
145
+ });
57
146
  }
58
147
  }
59
- // Only re-sort when results came from multiple layers; a single layer's
60
- // results are already in the order it returned them, and re-sorting can
61
- // disturb fields the comparator can't fully account for.
62
- if (layersThatReturnedResults > 1) {
63
- combinedResults.sort(comparator);
64
- }
65
- const limitedResults = combinedResults.slice(0, requestedLimit);
66
- const hasMore = combinedResults.length > requestedLimit ||
67
- paginationState.exhaustedSources.length < layers.length;
148
+ const someUnconsumed = merged.length > requestedLimit;
149
+ const someNotExhausted = newSources.some((s) => !s.exhausted);
150
+ const hasMore = someUnconsumed || someNotExhausted;
68
151
  return {
69
- items: limitedResults,
152
+ items: page.map((w) => w.item),
70
153
  nextPageToken: hasMore
71
- ? createCompositePageToken(paginationState)
154
+ ? createCompositePageToken({ sources: newSources })
72
155
  : undefined,
73
156
  };
74
157
  }
@@ -108,16 +108,21 @@ class SuitesManager {
108
108
  * behavior for non-DB persistence layers (Volatile, S3, GCS).
109
109
  */
110
110
  async deleteSuite(suiteId) {
111
- const suiteLayers = await this.suitesPersistenceRegistry.getAll();
112
- const testLayers = await this.testsPersistenceRegistry.getAll();
113
- for (let i = 0; i < suiteLayers.length; i++) {
111
+ for (const { key, persistence, } of await this.suitesPersistenceRegistry.getEntries()) {
114
112
  try {
115
- await suiteLayers[i].deleteSuite(suiteId);
113
+ await persistence.deleteSuite(suiteId);
116
114
  // Orphan tests in this layer after successfully deleting the suite.
117
115
  // This mirrors the DB-level ON DELETE SET NULL for non-DB layers.
118
- const testsResult = await testLayers[i].getTests({ suiteId });
116
+ // Pair by key the suites and tests registries can have different
117
+ // sets of layers (e.g. plugin-only suites persistence) so positional
118
+ // indexing isn't safe.
119
+ const testsPersistence = await this.testsPersistenceRegistry.getByKey(key);
120
+ if (!testsPersistence) {
121
+ continue;
122
+ }
123
+ const testsResult = await testsPersistence.getTests({ suiteId });
119
124
  for (const test of testsResult.items) {
120
- await testLayers[i].updateTest({ ...test, suiteId: null });
125
+ await testsPersistence.updateTest({ ...test, suiteId: null });
121
126
  }
122
127
  }
123
128
  catch {
@@ -2,14 +2,30 @@ import type { CreateDonobuFlow } from '../models/CreateDonobuFlow';
2
2
  import type { CreateTest } from '../models/CreateTest';
3
3
  import type { PaginatedResult } from '../models/PaginatedResult';
4
4
  import type { TestListItem, TestMetadata, TestsQuery } from '../models/TestMetadata';
5
+ import type { SuitesPersistenceRegistry } from '../persistence/suites/SuitesPersistenceRegistry';
5
6
  import type { TestsPersistenceRegistry } from '../persistence/tests/TestsPersistenceRegistry';
6
7
  import type { DonobuFlowsManager } from './DonobuFlowsManager';
7
8
  export declare class TestsManager {
8
9
  private readonly testsPersistenceRegistry;
10
+ private readonly suitesPersistenceRegistry;
9
11
  private readonly flowsManager;
10
- constructor(testsPersistenceRegistry: TestsPersistenceRegistry, flowsManager: DonobuFlowsManager);
12
+ constructor(testsPersistenceRegistry: TestsPersistenceRegistry, suitesPersistenceRegistry: SuitesPersistenceRegistry, flowsManager: DonobuFlowsManager);
11
13
  createTest(params: CreateTest): Promise<TestMetadata>;
12
14
  getTestById(testId: string): Promise<TestMetadata>;
15
+ /**
16
+ * Picks the tests persistence layer to use when creating a new test.
17
+ *
18
+ * - If `suiteId` is null/undefined: use the primary tests layer.
19
+ * - If `suiteId` is set: look up the suite's layer key and use the matching
20
+ * tests layer. If no tests layer matches the suite's key (rare — would
21
+ * require asymmetric registry config), fall back to the primary tests
22
+ * layer; the FK won't hold but at least the test is persisted somewhere.
23
+ * - If `suiteId` is set but the suite doesn't exist anywhere: fall back
24
+ * to the primary tests layer (the SQLite FK will reject if applicable;
25
+ * non-DB layers will accept the dangling reference).
26
+ */
27
+ private resolveLayerForTestCreate;
28
+ private findSuiteLayerKey;
13
29
  getTests(query: TestsQuery): Promise<PaginatedResult<TestListItem>>;
14
30
  /**
15
31
  * Update a test in the persistence layer where it exists.
@@ -38,7 +54,9 @@ export declare class TestsManager {
38
54
  private getTestToolCalls;
39
55
  /**
40
56
  * Creates a new flow (config) for the given test, which should be passed to
41
- * `flowsManager.createFlow` to execute the test.
57
+ * `flowsManager.createFlow` to execute the test. The returned config's
58
+ * `testId` is set, which `createFlow` uses to route the flow to the same
59
+ * persistence layer as the test.
42
60
  *
43
61
  * @param testId - The ID of the test to create a new flow for
44
62
  *
@@ -3,13 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TestsManager = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const CannotDeleteRunningFlowException_1 = require("../exceptions/CannotDeleteRunningFlowException");
6
+ const SuiteNotFoundException_1 = require("../exceptions/SuiteNotFoundException");
6
7
  const TestNotFoundException_1 = require("../exceptions/TestNotFoundException");
7
8
  const buildProvenance_1 = require("../utils/buildProvenance");
8
9
  const displayName_1 = require("../utils/displayName");
9
10
  const FederatedPagination_1 = require("./FederatedPagination");
10
11
  class TestsManager {
11
- constructor(testsPersistenceRegistry, flowsManager) {
12
+ constructor(testsPersistenceRegistry, suitesPersistenceRegistry, flowsManager) {
12
13
  this.testsPersistenceRegistry = testsPersistenceRegistry;
14
+ this.suitesPersistenceRegistry = suitesPersistenceRegistry;
13
15
  this.flowsManager = flowsManager;
14
16
  }
15
17
  async createTest(params) {
@@ -38,9 +40,12 @@ class TestsManager {
38
40
  videoDisabled: params.videoDisabled,
39
41
  provenance: (0, buildProvenance_1.buildProvenance)('DONOBU_STUDIO'),
40
42
  };
41
- // Create in the primary persistence layer only.
42
- const primary = await this.testsPersistenceRegistry.get();
43
- await primary.createTest(testMetadata);
43
+ // If the test is part of a suite, write it to the same persistence layer
44
+ // as the suite — otherwise the suite_id foreign key fails (in SQLite)
45
+ // or leaves a dangling reference (in non-DB layers). When standalone,
46
+ // fall back to the primary layer.
47
+ const persistence = await this.resolveLayerForTestCreate(testMetadata.suiteId);
48
+ await persistence.createTest(testMetadata);
44
49
  return testMetadata;
45
50
  }
46
51
  async getTestById(testId) {
@@ -56,6 +61,49 @@ class TestsManager {
56
61
  }
57
62
  throw TestNotFoundException_1.TestNotFoundException.forId(testId);
58
63
  }
64
+ /**
65
+ * Picks the tests persistence layer to use when creating a new test.
66
+ *
67
+ * - If `suiteId` is null/undefined: use the primary tests layer.
68
+ * - If `suiteId` is set: look up the suite's layer key and use the matching
69
+ * tests layer. If no tests layer matches the suite's key (rare — would
70
+ * require asymmetric registry config), fall back to the primary tests
71
+ * layer; the FK won't hold but at least the test is persisted somewhere.
72
+ * - If `suiteId` is set but the suite doesn't exist anywhere: fall back
73
+ * to the primary tests layer (the SQLite FK will reject if applicable;
74
+ * non-DB layers will accept the dangling reference).
75
+ */
76
+ async resolveLayerForTestCreate(suiteId) {
77
+ if (!suiteId) {
78
+ return this.testsPersistenceRegistry.get();
79
+ }
80
+ let suiteLayerKey;
81
+ try {
82
+ suiteLayerKey = await this.findSuiteLayerKey(suiteId);
83
+ }
84
+ catch (error) {
85
+ if (!(error instanceof SuiteNotFoundException_1.SuiteNotFoundException)) {
86
+ throw error;
87
+ }
88
+ return this.testsPersistenceRegistry.get();
89
+ }
90
+ const matched = await this.testsPersistenceRegistry.getByKey(suiteLayerKey);
91
+ return matched ?? (await this.testsPersistenceRegistry.get());
92
+ }
93
+ async findSuiteLayerKey(suiteId) {
94
+ for (const { key, persistence, } of await this.suitesPersistenceRegistry.getEntries()) {
95
+ try {
96
+ await persistence.getSuiteById(suiteId);
97
+ return key;
98
+ }
99
+ catch (error) {
100
+ if (!(error instanceof SuiteNotFoundException_1.SuiteNotFoundException)) {
101
+ throw error;
102
+ }
103
+ }
104
+ }
105
+ throw SuiteNotFoundException_1.SuiteNotFoundException.forId(suiteId);
106
+ }
59
107
  async getTests(query) {
60
108
  const layers = (await this.testsPersistenceRegistry.getAll()).map((persistence) => ({
61
109
  getItems: (q) => persistence.getTests(q),
@@ -65,13 +113,6 @@ class TestsManager {
65
113
  // TestMetadata has no `created_at` field — fall back to "now" so newly
66
114
  // returned items sort to the most-recent end. For other sort columns,
67
115
  // map the snake_case API name to the camelCase JS field.
68
- //
69
- // `flow_count` and `latest_flow_created_at` are only known at the
70
- // persistence layer (SQLite computes them via JOIN; the cloud handles
71
- // them server-side). Returning 0 here means each layer's pre-sorted
72
- // results stay in their own order during the federated merge, but
73
- // ordering across layers degrades to "first seen wins" — fine for the
74
- // common single-primary-layer case.
75
116
  const fieldFor = (test) => {
76
117
  switch (sortBy) {
77
118
  case 'created_at':
@@ -83,8 +124,9 @@ class TestsManager {
83
124
  case 'next_run_mode':
84
125
  return test.nextRunMode;
85
126
  case 'flow_count':
127
+ return test.flowCount ?? 0;
86
128
  case 'latest_flow_created_at':
87
- return 0;
129
+ return test.latestFlow?.startedAt ?? Date.now();
88
130
  default:
89
131
  return '';
90
132
  }
@@ -196,7 +238,9 @@ class TestsManager {
196
238
  }
197
239
  /**
198
240
  * Creates a new flow (config) for the given test, which should be passed to
199
- * `flowsManager.createFlow` to execute the test.
241
+ * `flowsManager.createFlow` to execute the test. The returned config's
242
+ * `testId` is set, which `createFlow` uses to route the flow to the same
243
+ * persistence layer as the test.
200
244
  *
201
245
  * @param testId - The ID of the test to create a new flow for
202
246
  *
@@ -210,7 +254,16 @@ class TestsManager {
210
254
  try {
211
255
  toolCallsOnStart = await this.getTestToolCalls(test);
212
256
  }
213
- catch {
257
+ catch (error) {
258
+ // Fallback to AUTONOMOUS is only viable when the test has an
259
+ // overallObjective for the agent to work toward. For tests without
260
+ // one (e.g. Playwright-imported tests, or AI tests where the user
261
+ // cleared the objective), AUTONOMOUS would just fail downstream
262
+ // with a misleading "overallObjective is required" — propagate the
263
+ // original error instead so the user sees the real cause.
264
+ if ((test.overallObjective?.trim().length ?? 0) === 0) {
265
+ throw error;
266
+ }
214
267
  runMode = 'AUTONOMOUS';
215
268
  }
216
269
  }