keystone-cli 0.7.2 → 1.0.0
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/README.md +486 -54
- package/package.json +8 -2
- package/src/__fixtures__/index.ts +100 -0
- package/src/cli.ts +841 -91
- package/src/db/memory-db.ts +35 -1
- package/src/db/workflow-db.test.ts +24 -0
- package/src/db/workflow-db.ts +484 -14
- package/src/expression/evaluator.ts +68 -4
- package/src/parser/agent-parser.ts +6 -3
- package/src/parser/config-schema.ts +38 -2
- package/src/parser/schema.ts +192 -7
- package/src/parser/test-schema.ts +29 -0
- package/src/parser/workflow-parser.test.ts +54 -0
- package/src/parser/workflow-parser.ts +153 -7
- package/src/runner/aggregate-error.test.ts +57 -0
- package/src/runner/aggregate-error.ts +46 -0
- package/src/runner/audit-verification.test.ts +2 -2
- package/src/runner/auto-heal.test.ts +1 -1
- package/src/runner/blueprint-executor.test.ts +63 -0
- package/src/runner/blueprint-executor.ts +157 -0
- package/src/runner/concurrency-limit.test.ts +82 -0
- package/src/runner/debug-repl.ts +18 -3
- package/src/runner/durable-timers.test.ts +200 -0
- package/src/runner/engine-executor.test.ts +464 -0
- package/src/runner/engine-executor.ts +491 -0
- package/src/runner/foreach-executor.ts +30 -12
- package/src/runner/llm-adapter.test.ts +282 -5
- package/src/runner/llm-adapter.ts +581 -8
- package/src/runner/llm-clarification.test.ts +79 -21
- package/src/runner/llm-errors.ts +83 -0
- package/src/runner/llm-executor.test.ts +258 -219
- package/src/runner/llm-executor.ts +226 -29
- package/src/runner/mcp-client.ts +70 -3
- package/src/runner/mcp-manager.test.ts +52 -52
- package/src/runner/mcp-manager.ts +12 -5
- package/src/runner/mcp-server.test.ts +117 -78
- package/src/runner/mcp-server.ts +13 -4
- package/src/runner/optimization-runner.ts +48 -31
- package/src/runner/reflexion.test.ts +1 -1
- package/src/runner/resource-pool.test.ts +113 -0
- package/src/runner/resource-pool.ts +164 -0
- package/src/runner/shell-executor.ts +130 -32
- package/src/runner/standard-tools-execution.test.ts +39 -0
- package/src/runner/standard-tools-integration.test.ts +36 -36
- package/src/runner/standard-tools.test.ts +18 -0
- package/src/runner/standard-tools.ts +174 -93
- package/src/runner/step-executor.test.ts +176 -16
- package/src/runner/step-executor.ts +534 -83
- package/src/runner/stream-utils.test.ts +14 -0
- package/src/runner/subflow-outputs.test.ts +103 -0
- package/src/runner/test-harness.ts +161 -0
- package/src/runner/tool-integration.test.ts +73 -79
- package/src/runner/workflow-runner.test.ts +549 -15
- package/src/runner/workflow-runner.ts +1448 -79
- package/src/runner/workflow-subflows.test.ts +255 -0
- package/src/templates/agents/keystone-architect.md +17 -12
- package/src/templates/agents/tester.md +21 -0
- package/src/templates/child-rollback.yaml +11 -0
- package/src/templates/decompose-implement.yaml +53 -0
- package/src/templates/decompose-problem.yaml +159 -0
- package/src/templates/decompose-research.yaml +52 -0
- package/src/templates/decompose-review.yaml +51 -0
- package/src/templates/dev.yaml +134 -0
- package/src/templates/engine-example.yaml +33 -0
- package/src/templates/fan-out-fan-in.yaml +61 -0
- package/src/templates/memory-service.yaml +1 -1
- package/src/templates/parent-rollback.yaml +16 -0
- package/src/templates/robust-automation.yaml +1 -1
- package/src/templates/scaffold-feature.yaml +29 -27
- package/src/templates/scaffold-generate.yaml +41 -0
- package/src/templates/scaffold-plan.yaml +53 -0
- package/src/types/status.ts +3 -0
- package/src/ui/dashboard.tsx +4 -3
- package/src/utils/assets.macro.ts +36 -0
- package/src/utils/auth-manager.ts +585 -8
- package/src/utils/blueprint-utils.test.ts +49 -0
- package/src/utils/blueprint-utils.ts +80 -0
- package/src/utils/circuit-breaker.test.ts +177 -0
- package/src/utils/circuit-breaker.ts +160 -0
- package/src/utils/config-loader.test.ts +100 -13
- package/src/utils/config-loader.ts +44 -17
- package/src/utils/constants.ts +62 -0
- package/src/utils/error-renderer.test.ts +267 -0
- package/src/utils/error-renderer.ts +320 -0
- package/src/utils/json-parser.test.ts +4 -0
- package/src/utils/json-parser.ts +18 -1
- package/src/utils/mermaid.ts +4 -0
- package/src/utils/paths.test.ts +46 -0
- package/src/utils/paths.ts +70 -0
- package/src/utils/process-sandbox.test.ts +128 -0
- package/src/utils/process-sandbox.ts +293 -0
- package/src/utils/rate-limiter.test.ts +143 -0
- package/src/utils/rate-limiter.ts +221 -0
- package/src/utils/redactor.test.ts +23 -15
- package/src/utils/redactor.ts +65 -25
- package/src/utils/resource-loader.test.ts +54 -0
- package/src/utils/resource-loader.ts +158 -0
- package/src/utils/sandbox.test.ts +69 -4
- package/src/utils/sandbox.ts +69 -6
- package/src/utils/schema-validator.ts +65 -0
- package/src/utils/workflow-registry.test.ts +57 -0
- package/src/utils/workflow-registry.ts +45 -25
- /package/src/expression/{evaluator.audit.test.ts → evaluator-audit.test.ts} +0 -0
- /package/src/runner/{mcp-client.audit.test.ts → mcp-client-audit.test.ts} +0 -0
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
1
|
import { dirname } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import { parseAgent, resolveAgentPath } from '../parser/agent-parser';
|
|
2
|
+
import { resolveAgentPath } from '../parser/agent-parser';
|
|
5
3
|
import type { LlmStep, Step, Workflow } from '../parser/schema';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
4
|
+
import { TIMEOUTS } from '../utils/constants';
|
|
5
|
+
import { ConsoleLogger, type Logger } from '../utils/logger';
|
|
8
6
|
import { executeLlmStep } from './llm-executor';
|
|
9
7
|
import { WorkflowRunner } from './workflow-runner';
|
|
10
8
|
|
|
@@ -13,6 +11,7 @@ export interface OptimizationOptions {
|
|
|
13
11
|
targetStepId: string;
|
|
14
12
|
inputs?: Record<string, unknown>;
|
|
15
13
|
iterations?: number;
|
|
14
|
+
logger?: Logger;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
17
|
export class OptimizationRunner {
|
|
@@ -21,6 +20,8 @@ export class OptimizationRunner {
|
|
|
21
20
|
private targetStepId: string;
|
|
22
21
|
private iterations: number;
|
|
23
22
|
private inputs: Record<string, unknown>;
|
|
23
|
+
private logger: Logger;
|
|
24
|
+
private secrets: Record<string, string>;
|
|
24
25
|
|
|
25
26
|
constructor(workflow: Workflow, options: OptimizationOptions) {
|
|
26
27
|
this.workflow = workflow;
|
|
@@ -28,6 +29,18 @@ export class OptimizationRunner {
|
|
|
28
29
|
this.targetStepId = options.targetStepId;
|
|
29
30
|
this.iterations = options.iterations || 5;
|
|
30
31
|
this.inputs = options.inputs || {};
|
|
32
|
+
this.logger = options.logger || new ConsoleLogger();
|
|
33
|
+
this.secrets = OptimizationRunner.extractSecrets(Bun.env);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private static extractSecrets(env: Record<string, string | undefined>): Record<string, string> {
|
|
37
|
+
const secrets: Record<string, string> = {};
|
|
38
|
+
for (const [key, value] of Object.entries(env)) {
|
|
39
|
+
if (typeof value === 'string') {
|
|
40
|
+
secrets[key] = value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return secrets;
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
public async optimize(): Promise<{ bestPrompt: string; bestScore: number }> {
|
|
@@ -40,8 +53,8 @@ export class OptimizationRunner {
|
|
|
40
53
|
throw new Error(`Target step "${this.targetStepId}" not found or is not an LLM/Shell step`);
|
|
41
54
|
}
|
|
42
55
|
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
this.logger.log(`\n🚀 Optimizing step: ${this.targetStepId} (${targetStep.type})`);
|
|
57
|
+
this.logger.log(`📊 Iterations: ${this.iterations}`);
|
|
45
58
|
|
|
46
59
|
let bestPrompt =
|
|
47
60
|
targetStep.type === 'llm'
|
|
@@ -52,8 +65,8 @@ export class OptimizationRunner {
|
|
|
52
65
|
let currentPrompt = bestPrompt;
|
|
53
66
|
|
|
54
67
|
for (let i = 1; i <= this.iterations; i++) {
|
|
55
|
-
|
|
56
|
-
|
|
68
|
+
this.logger.log(`\n--- Iteration ${i}/${this.iterations} ---`);
|
|
69
|
+
this.logger.log(
|
|
57
70
|
`Current Prompt: ${currentPrompt.substring(0, 100)}${currentPrompt.length > 100 ? '...' : ''}`
|
|
58
71
|
);
|
|
59
72
|
|
|
@@ -74,18 +87,19 @@ export class OptimizationRunner {
|
|
|
74
87
|
const runner = new WorkflowRunner(modifiedWorkflow, {
|
|
75
88
|
inputs: this.inputs,
|
|
76
89
|
workflowDir: dirname(this.workflowPath),
|
|
90
|
+
logger: this.logger,
|
|
77
91
|
});
|
|
78
92
|
|
|
79
93
|
const outputs = await runner.run();
|
|
80
94
|
|
|
81
95
|
// 2. Evaluate the result
|
|
82
96
|
const score = await this.evaluate(outputs);
|
|
83
|
-
|
|
97
|
+
this.logger.log(`Score: ${score}/100`);
|
|
84
98
|
|
|
85
99
|
if (score > bestScore) {
|
|
86
100
|
bestScore = score;
|
|
87
101
|
bestPrompt = currentPrompt;
|
|
88
|
-
|
|
102
|
+
this.logger.log(`✨ New best score: ${bestScore}`);
|
|
89
103
|
}
|
|
90
104
|
|
|
91
105
|
// 3. Suggest next prompt (if not last iteration)
|
|
@@ -103,15 +117,18 @@ export class OptimizationRunner {
|
|
|
103
117
|
if (!evalConfig) return 0;
|
|
104
118
|
|
|
105
119
|
if (evalConfig.scorer === 'script') {
|
|
106
|
-
|
|
107
|
-
|
|
120
|
+
if (!evalConfig.allowInsecure) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
'Eval script execution is disabled by default. Set eval.allowInsecure: true to enable.'
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const allowSecrets = evalConfig.allowSecrets === true;
|
|
108
126
|
|
|
109
127
|
// Create a context with outputs available
|
|
110
128
|
const context = {
|
|
111
129
|
inputs: this.inputs,
|
|
112
130
|
steps: {},
|
|
113
|
-
|
|
114
|
-
secrets: Bun.env as any,
|
|
131
|
+
secrets: allowSecrets ? this.secrets : {},
|
|
115
132
|
env: this.workflow.env,
|
|
116
133
|
outputs, // Direct access
|
|
117
134
|
output: outputs, // For convenience
|
|
@@ -136,7 +153,9 @@ export class OptimizationRunner {
|
|
|
136
153
|
// Note: OptimizationRunner should probably import executeStep
|
|
137
154
|
const { SafeSandbox } = await import('../utils/sandbox');
|
|
138
155
|
try {
|
|
139
|
-
const result = await SafeSandbox.execute(scriptStep.run, context, {
|
|
156
|
+
const result = await SafeSandbox.execute(scriptStep.run, context, {
|
|
157
|
+
timeout: TIMEOUTS.DEFAULT_SCRIPT_TIMEOUT_MS,
|
|
158
|
+
});
|
|
140
159
|
if (typeof result === 'object' && result !== null && 'stdout' in result) {
|
|
141
160
|
// biome-ignore lint/suspicious/noExplicitAny: result typing
|
|
142
161
|
const match = (result as any).stdout.match(/\d+/);
|
|
@@ -150,14 +169,14 @@ export class OptimizationRunner {
|
|
|
150
169
|
if (match) return Number.parseInt(match[0], 10);
|
|
151
170
|
}
|
|
152
171
|
} catch (e) {
|
|
153
|
-
|
|
172
|
+
this.logger.error(`Eval script failed: ${String(e)}`);
|
|
154
173
|
}
|
|
155
174
|
return 0;
|
|
156
175
|
}
|
|
157
176
|
|
|
158
177
|
// LLM Scorer
|
|
159
178
|
if (!evalConfig.agent || !evalConfig.prompt) {
|
|
160
|
-
|
|
179
|
+
this.logger.warn('Skipping LLM evaluation: agent or prompt missing');
|
|
161
180
|
return 0;
|
|
162
181
|
}
|
|
163
182
|
|
|
@@ -168,7 +187,7 @@ export class OptimizationRunner {
|
|
|
168
187
|
prompt: `${evalConfig.prompt}\n\nOutputs to evaluate:\n${JSON.stringify(outputs, null, 2)}`,
|
|
169
188
|
needs: [],
|
|
170
189
|
maxIterations: 10,
|
|
171
|
-
|
|
190
|
+
outputSchema: {
|
|
172
191
|
type: 'object',
|
|
173
192
|
properties: {
|
|
174
193
|
score: { type: 'number', minimum: 0, maximum: 100 },
|
|
@@ -182,8 +201,7 @@ export class OptimizationRunner {
|
|
|
182
201
|
const context = {
|
|
183
202
|
inputs: this.inputs,
|
|
184
203
|
steps: {},
|
|
185
|
-
|
|
186
|
-
secrets: Bun.env as any,
|
|
204
|
+
secrets: this.secrets,
|
|
187
205
|
env: this.workflow.env,
|
|
188
206
|
};
|
|
189
207
|
|
|
@@ -194,7 +212,7 @@ export class OptimizationRunner {
|
|
|
194
212
|
async () => {
|
|
195
213
|
throw new Error('Tools not supported in eval');
|
|
196
214
|
},
|
|
197
|
-
|
|
215
|
+
this.logger
|
|
198
216
|
);
|
|
199
217
|
|
|
200
218
|
if (result.status === 'success' && result.output && typeof result.output === 'object') {
|
|
@@ -244,8 +262,7 @@ Return ONLY the new prompt text.`,
|
|
|
244
262
|
const context = {
|
|
245
263
|
inputs: this.inputs,
|
|
246
264
|
steps: {},
|
|
247
|
-
|
|
248
|
-
secrets: Bun.env as any,
|
|
265
|
+
secrets: this.secrets,
|
|
249
266
|
env: this.workflow.env,
|
|
250
267
|
};
|
|
251
268
|
|
|
@@ -257,7 +274,7 @@ Return ONLY the new prompt text.`,
|
|
|
257
274
|
async () => {
|
|
258
275
|
throw new Error('Tools not supported in meta-opt');
|
|
259
276
|
},
|
|
260
|
-
|
|
277
|
+
this.logger,
|
|
261
278
|
undefined,
|
|
262
279
|
dirname(this.workflowPath) // Pass workflowDir to resolve agent
|
|
263
280
|
);
|
|
@@ -265,7 +282,7 @@ Return ONLY the new prompt text.`,
|
|
|
265
282
|
return result.output.trim();
|
|
266
283
|
}
|
|
267
284
|
} catch (e) {
|
|
268
|
-
|
|
285
|
+
this.logger.warn(` ⚠️ Meta-optimizer failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
269
286
|
// Adding a dummy mutation for testing purposes if env var is set
|
|
270
287
|
if (Bun.env.TEST_OPTIMIZER) {
|
|
271
288
|
return `${currentPrompt}!`;
|
|
@@ -276,28 +293,28 @@ Return ONLY the new prompt text.`,
|
|
|
276
293
|
}
|
|
277
294
|
|
|
278
295
|
private async saveBestPrompt(prompt: string): Promise<void> {
|
|
279
|
-
|
|
296
|
+
this.logger.log(`\n💾 Saving best prompt to ${this.workflowPath}`);
|
|
280
297
|
|
|
281
298
|
// We need to be careful here. The prompt might be in the workflow YAML directly,
|
|
282
299
|
// or it might be in an agent file.
|
|
283
300
|
|
|
284
301
|
const targetStep = this.workflow.steps.find((s) => s.id === this.targetStepId);
|
|
285
302
|
|
|
286
|
-
|
|
303
|
+
this.logger.log(`--- BEST PROMPT/RUN ---\n${prompt}\n-----------------------`);
|
|
287
304
|
|
|
288
305
|
if (targetStep?.type === 'llm') {
|
|
289
306
|
const agentPath = resolveAgentPath((targetStep as LlmStep).agent, dirname(this.workflowPath));
|
|
290
307
|
try {
|
|
291
308
|
// For MVP, we just logged it. Automatic replacement in arbitrary files is risky without robust parsing.
|
|
292
309
|
// But we can try to warn/notify.
|
|
293
|
-
|
|
310
|
+
this.logger.log(
|
|
294
311
|
`To apply this optimization, update the 'systemPrompt' or instruction in: ${agentPath}`
|
|
295
312
|
);
|
|
296
313
|
} catch (e) {
|
|
297
|
-
|
|
314
|
+
this.logger.warn(`Could not locate agent file: ${String(e)}`);
|
|
298
315
|
}
|
|
299
316
|
} else {
|
|
300
|
-
|
|
317
|
+
this.logger.log(
|
|
301
318
|
`To apply this optimization, update the 'run' command for step '${this.targetStepId}' in ${this.workflowPath}`
|
|
302
319
|
);
|
|
303
320
|
}
|
|
@@ -46,7 +46,7 @@ describe('WorkflowRunner Reflexion', () => {
|
|
|
46
46
|
|
|
47
47
|
// biome-ignore lint/suspicious/noExplicitAny: Accessing private property for testing
|
|
48
48
|
const db = (runner as any).db;
|
|
49
|
-
await db.createRun(runner.
|
|
49
|
+
await db.createRun(runner.runId, workflow.name, {});
|
|
50
50
|
|
|
51
51
|
const spy = jest.spyOn(StepExecutor, 'executeStep');
|
|
52
52
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from 'bun:test';
|
|
2
|
+
import { ConsoleLogger } from '../utils/logger';
|
|
3
|
+
import { ResourcePoolManager } from './resource-pool';
|
|
4
|
+
|
|
5
|
+
describe('ResourcePoolManager', () => {
|
|
6
|
+
const logger = new ConsoleLogger();
|
|
7
|
+
|
|
8
|
+
it('should respect pool limits', async () => {
|
|
9
|
+
const manager = new ResourcePoolManager(logger, {
|
|
10
|
+
pools: { test: 2 },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
let activeCount = 0;
|
|
14
|
+
const run = async () => {
|
|
15
|
+
const release = await manager.acquire('test');
|
|
16
|
+
activeCount++;
|
|
17
|
+
expect(activeCount).toBeLessThanOrEqual(2);
|
|
18
|
+
await Bun.sleep(50);
|
|
19
|
+
activeCount--;
|
|
20
|
+
release();
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
await Promise.all([run(), run(), run(), run()]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should use default limit for unknown pools', async () => {
|
|
27
|
+
const manager = new ResourcePoolManager(logger, {
|
|
28
|
+
defaultLimit: 3,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let activeCount = 0;
|
|
32
|
+
const run = async () => {
|
|
33
|
+
const release = await manager.acquire('unknown');
|
|
34
|
+
activeCount++;
|
|
35
|
+
expect(activeCount).toBeLessThanOrEqual(3);
|
|
36
|
+
await Bun.sleep(50);
|
|
37
|
+
activeCount--;
|
|
38
|
+
release();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
await Promise.all([run(), run(), run(), run(), run()]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should handle cancellation via AbortSignal', async () => {
|
|
45
|
+
const manager = new ResourcePoolManager(logger, {
|
|
46
|
+
pools: { test: 1 },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Acquire the only slot
|
|
50
|
+
const release1 = await manager.acquire('test');
|
|
51
|
+
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const pendingAcquisition = manager.acquire('test', { signal: controller.signal });
|
|
54
|
+
|
|
55
|
+
// Cancel after a bit
|
|
56
|
+
setTimeout(() => controller.abort(), 10);
|
|
57
|
+
|
|
58
|
+
await expect(pendingAcquisition).rejects.toThrow('Acquisition aborted');
|
|
59
|
+
release1();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should respect priority in queue', async () => {
|
|
63
|
+
const manager = new ResourcePoolManager(logger, {
|
|
64
|
+
pools: { test: 1 },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const results: number[] = [];
|
|
68
|
+
const release1 = await manager.acquire('test');
|
|
69
|
+
|
|
70
|
+
const p1 = manager.acquire('test', { priority: 1 }).then((r) => {
|
|
71
|
+
results.push(1);
|
|
72
|
+
r();
|
|
73
|
+
});
|
|
74
|
+
const p2 = manager.acquire('test', { priority: 10 }).then((r) => {
|
|
75
|
+
results.push(10);
|
|
76
|
+
r();
|
|
77
|
+
});
|
|
78
|
+
const p3 = manager.acquire('test', { priority: 5 }).then((r) => {
|
|
79
|
+
results.push(5);
|
|
80
|
+
r();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
release1();
|
|
84
|
+
await Promise.all([p1, p2, p3]);
|
|
85
|
+
|
|
86
|
+
// Priority 10 should run first, then 5, then 1
|
|
87
|
+
expect(results).toEqual([10, 5, 1]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should provide metrics', async () => {
|
|
91
|
+
const manager = new ResourcePoolManager(logger, {
|
|
92
|
+
pools: { test: 2 },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const release1 = await manager.acquire('test');
|
|
96
|
+
const release2 = await manager.acquire('test');
|
|
97
|
+
|
|
98
|
+
const metrics = manager.getMetrics('test');
|
|
99
|
+
expect(metrics?.active).toBe(2);
|
|
100
|
+
expect(metrics?.queued).toBe(0);
|
|
101
|
+
|
|
102
|
+
const p3 = manager.acquire('test');
|
|
103
|
+
const metrics2 = manager.getMetrics('test');
|
|
104
|
+
expect(metrics2?.queued).toBe(1);
|
|
105
|
+
|
|
106
|
+
release1();
|
|
107
|
+
release2();
|
|
108
|
+
await p3.then((r) => r());
|
|
109
|
+
|
|
110
|
+
const metrics3 = manager.getMetrics('test');
|
|
111
|
+
expect(metrics3?.totalAcquired).toBe(3);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Logger } from '../utils/logger.ts';
|
|
2
|
+
|
|
3
|
+
export type ReleaseFunction = () => void;
|
|
4
|
+
|
|
5
|
+
export interface PoolMetrics {
|
|
6
|
+
name: string;
|
|
7
|
+
limit: number;
|
|
8
|
+
active: number;
|
|
9
|
+
queued: number;
|
|
10
|
+
totalAcquired: number;
|
|
11
|
+
totalWaitTimeMs: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface QueuedRequest {
|
|
15
|
+
resolve: (release: ReleaseFunction) => void;
|
|
16
|
+
reject: (error: Error) => void;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
priority: number;
|
|
19
|
+
timestamp: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ResourcePoolManager {
|
|
23
|
+
private pools = new Map<
|
|
24
|
+
string,
|
|
25
|
+
{
|
|
26
|
+
limit: number;
|
|
27
|
+
active: number;
|
|
28
|
+
queue: QueuedRequest[];
|
|
29
|
+
totalAcquired: number;
|
|
30
|
+
totalWaitTimeMs: number;
|
|
31
|
+
}
|
|
32
|
+
>();
|
|
33
|
+
private globalLimit: number;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private logger: Logger,
|
|
37
|
+
options: { defaultLimit?: number; pools?: Record<string, number> } = {}
|
|
38
|
+
) {
|
|
39
|
+
this.globalLimit = options.defaultLimit || 10;
|
|
40
|
+
if (options.pools) {
|
|
41
|
+
for (const [name, limit] of Object.entries(options.pools)) {
|
|
42
|
+
this.pools.set(name, { limit, active: 0, queue: [], totalAcquired: 0, totalWaitTimeMs: 0 });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Acquire a resource from a pool.
|
|
49
|
+
* If the pool doesn't exist, it uses the global limit.
|
|
50
|
+
*/
|
|
51
|
+
async acquire(
|
|
52
|
+
poolName: string,
|
|
53
|
+
options: { priority?: number; signal?: AbortSignal } = {}
|
|
54
|
+
): Promise<ReleaseFunction> {
|
|
55
|
+
let pool = this.pools.get(poolName);
|
|
56
|
+
if (!pool) {
|
|
57
|
+
// Create a pool for this name if it doesn't exist, using global limit
|
|
58
|
+
pool = {
|
|
59
|
+
limit: this.globalLimit,
|
|
60
|
+
active: 0,
|
|
61
|
+
queue: [],
|
|
62
|
+
totalAcquired: 0,
|
|
63
|
+
totalWaitTimeMs: 0,
|
|
64
|
+
};
|
|
65
|
+
this.pools.set(poolName, pool);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (pool.active < pool.limit && pool.queue.length === 0) {
|
|
69
|
+
pool.active++;
|
|
70
|
+
pool.totalAcquired++;
|
|
71
|
+
return this.createReleaseFn(poolName);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Queue the request
|
|
75
|
+
const timestamp = Date.now();
|
|
76
|
+
const poolRef = pool;
|
|
77
|
+
return new Promise<ReleaseFunction>((resolve, reject) => {
|
|
78
|
+
const request: QueuedRequest = {
|
|
79
|
+
resolve,
|
|
80
|
+
reject,
|
|
81
|
+
signal: options.signal,
|
|
82
|
+
priority: options.priority || 0,
|
|
83
|
+
timestamp,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Add to queue and sort by priority (desc) then timestamp (asc)
|
|
87
|
+
poolRef.queue.push(request);
|
|
88
|
+
poolRef.queue.sort((a, b) => {
|
|
89
|
+
if (b.priority !== a.priority) return b.priority - a.priority;
|
|
90
|
+
return a.timestamp - b.timestamp;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Handle abort signal
|
|
94
|
+
if (options.signal) {
|
|
95
|
+
options.signal.addEventListener(
|
|
96
|
+
'abort',
|
|
97
|
+
() => {
|
|
98
|
+
const index = poolRef.queue.indexOf(request);
|
|
99
|
+
if (index !== -1) {
|
|
100
|
+
poolRef.queue.splice(index, 1);
|
|
101
|
+
reject(new Error('Acquisition aborted'));
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
{ once: true }
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private createReleaseFn(poolName: string): ReleaseFunction {
|
|
111
|
+
let released = false;
|
|
112
|
+
return () => {
|
|
113
|
+
if (released) return;
|
|
114
|
+
released = true;
|
|
115
|
+
this.release(poolName);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private release(poolName: string) {
|
|
120
|
+
const pool = this.pools.get(poolName);
|
|
121
|
+
if (!pool) return;
|
|
122
|
+
|
|
123
|
+
pool.active--;
|
|
124
|
+
|
|
125
|
+
// Process queue
|
|
126
|
+
while (pool.active < pool.limit && pool.queue.length > 0) {
|
|
127
|
+
const request = pool.queue.shift();
|
|
128
|
+
if (!request) break;
|
|
129
|
+
|
|
130
|
+
// Skip if signal already aborted
|
|
131
|
+
if (request.signal?.aborted) continue;
|
|
132
|
+
|
|
133
|
+
pool.active++;
|
|
134
|
+
pool.totalAcquired++;
|
|
135
|
+
pool.totalWaitTimeMs += Date.now() - request.timestamp;
|
|
136
|
+
request.resolve(this.createReleaseFn(poolName));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
getMetrics(poolName: string): PoolMetrics | undefined {
|
|
141
|
+
const pool = this.pools.get(poolName);
|
|
142
|
+
if (!pool) return undefined;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
name: poolName,
|
|
146
|
+
limit: pool.limit,
|
|
147
|
+
active: pool.active,
|
|
148
|
+
queued: pool.queue.length,
|
|
149
|
+
totalAcquired: pool.totalAcquired,
|
|
150
|
+
totalWaitTimeMs: pool.totalWaitTimeMs,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
getAllMetrics(): PoolMetrics[] {
|
|
155
|
+
return Array.from(this.pools.entries()).map(([name, pool]) => ({
|
|
156
|
+
name,
|
|
157
|
+
limit: pool.limit,
|
|
158
|
+
active: pool.active,
|
|
159
|
+
queued: pool.queue.length,
|
|
160
|
+
totalAcquired: pool.totalAcquired,
|
|
161
|
+
totalWaitTimeMs: pool.totalWaitTimeMs,
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
}
|