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.
- package/dist/esm/managers/DonobuFlowsManager.d.ts +21 -1
- package/dist/esm/managers/DonobuFlowsManager.js +51 -2
- package/dist/esm/managers/DonobuStack.js +4 -4
- package/dist/esm/managers/FederatedPagination.d.ts +43 -13
- package/dist/esm/managers/FederatedPagination.js +122 -39
- package/dist/esm/managers/SuitesManager.js +11 -6
- package/dist/esm/managers/TestsManager.d.ts +20 -2
- package/dist/esm/managers/TestsManager.js +67 -14
- package/dist/esm/persistence/flows/FlowsPersistenceDonobuApi.js +10 -1
- package/dist/esm/persistence/flows/FlowsPersistenceRegistry.d.ts +25 -1
- package/dist/esm/persistence/flows/FlowsPersistenceRegistry.js +17 -5
- package/dist/esm/persistence/suites/SuitesPersistenceRegistry.d.ts +18 -1
- package/dist/esm/persistence/suites/SuitesPersistenceRegistry.js +17 -5
- package/dist/esm/persistence/tests/TestsPersistenceRegistry.d.ts +18 -1
- package/dist/esm/persistence/tests/TestsPersistenceRegistry.js +20 -5
- package/dist/esm/reporter/render.js +36 -10
- package/dist/esm/tools/ScrollPageTool.js +1 -1
- package/dist/managers/DonobuFlowsManager.d.ts +21 -1
- package/dist/managers/DonobuFlowsManager.js +51 -2
- package/dist/managers/DonobuStack.js +4 -4
- package/dist/managers/FederatedPagination.d.ts +43 -13
- package/dist/managers/FederatedPagination.js +122 -39
- package/dist/managers/SuitesManager.js +11 -6
- package/dist/managers/TestsManager.d.ts +20 -2
- package/dist/managers/TestsManager.js +67 -14
- package/dist/persistence/flows/FlowsPersistenceDonobuApi.js +10 -1
- package/dist/persistence/flows/FlowsPersistenceRegistry.d.ts +25 -1
- package/dist/persistence/flows/FlowsPersistenceRegistry.js +17 -5
- package/dist/persistence/suites/SuitesPersistenceRegistry.d.ts +18 -1
- package/dist/persistence/suites/SuitesPersistenceRegistry.js +17 -5
- package/dist/persistence/tests/TestsPersistenceRegistry.d.ts +18 -1
- package/dist/persistence/tests/TestsPersistenceRegistry.js +20 -5
- package/dist/reporter/render.js +36 -10
- package/dist/tools/ScrollPageTool.js +1 -1
- 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.
|
|
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
|
-
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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 {
|
|
11
|
+
return { sources: [] };
|
|
12
12
|
}
|
|
13
13
|
try {
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
141
|
+
newSources.push({
|
|
142
|
+
cursor: lastKept.fetchCursor,
|
|
143
|
+
resumeAfterId: lastKept.item.id,
|
|
144
|
+
exhausted: false,
|
|
145
|
+
});
|
|
57
146
|
}
|
|
58
147
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
152
|
+
items: page.map((w) => w.item),
|
|
70
153
|
nextPageToken: hasMore
|
|
71
|
-
? createCompositePageToken(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
}
|