keystone-cli 1.0.3 → 1.1.1
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 +276 -32
- package/package.json +8 -4
- package/src/cli.ts +350 -416
- package/src/commands/doc.ts +31 -0
- package/src/commands/event.ts +29 -0
- package/src/commands/graph.ts +37 -0
- package/src/commands/index.ts +14 -0
- package/src/commands/init.ts +185 -0
- package/src/commands/run.ts +124 -0
- package/src/commands/schema.ts +40 -0
- package/src/commands/utils.ts +78 -0
- package/src/commands/validate.ts +111 -0
- package/src/db/workflow-db.test.ts +314 -0
- package/src/db/workflow-db.ts +810 -210
- package/src/expression/evaluator-audit.test.ts +4 -2
- package/src/expression/evaluator.test.ts +14 -1
- package/src/expression/evaluator.ts +166 -19
- package/src/parser/config-schema.ts +18 -0
- package/src/parser/schema.ts +153 -22
- package/src/parser/test-schema.ts +6 -6
- package/src/parser/workflow-parser.test.ts +24 -0
- package/src/parser/workflow-parser.ts +65 -3
- package/src/runner/auto-heal.test.ts +5 -6
- package/src/runner/blueprint-executor.test.ts +2 -2
- package/src/runner/debug-repl.test.ts +5 -8
- package/src/runner/debug-repl.ts +59 -16
- package/src/runner/durable-timers.test.ts +11 -2
- package/src/runner/engine-executor.test.ts +1 -1
- package/src/runner/events.ts +57 -0
- package/src/runner/executors/artifact-executor.ts +166 -0
- package/src/runner/{blueprint-executor.ts → executors/blueprint-executor.ts} +15 -7
- package/src/runner/{engine-executor.ts → executors/engine-executor.ts} +55 -7
- package/src/runner/executors/file-executor.test.ts +48 -0
- package/src/runner/executors/file-executor.ts +324 -0
- package/src/runner/{foreach-executor.ts → executors/foreach-executor.ts} +168 -80
- package/src/runner/executors/human-executor.ts +144 -0
- package/src/runner/executors/join-executor.ts +75 -0
- package/src/runner/executors/llm-executor.ts +1266 -0
- package/src/runner/executors/memory-executor.ts +71 -0
- package/src/runner/executors/plan-executor.ts +104 -0
- package/src/runner/executors/request-executor.ts +265 -0
- package/src/runner/executors/script-executor.ts +43 -0
- package/src/runner/executors/shell-executor.ts +403 -0
- package/src/runner/executors/subworkflow-executor.ts +114 -0
- package/src/runner/executors/types.ts +69 -0
- package/src/runner/executors/wait-executor.ts +59 -0
- package/src/runner/join-scheduling.test.ts +197 -0
- package/src/runner/llm-adapter-runtime.test.ts +209 -0
- package/src/runner/llm-adapter.test.ts +419 -24
- package/src/runner/llm-adapter.ts +130 -26
- package/src/runner/llm-clarification.test.ts +2 -1
- package/src/runner/llm-executor.test.ts +532 -17
- package/src/runner/mcp-client-audit.test.ts +1 -2
- package/src/runner/mcp-client.ts +136 -46
- package/src/runner/mcp-manager.test.ts +4 -0
- package/src/runner/mcp-server.test.ts +58 -0
- package/src/runner/mcp-server.ts +26 -0
- package/src/runner/memoization.test.ts +190 -0
- package/src/runner/optimization-runner.ts +4 -9
- package/src/runner/quality-gate.test.ts +69 -0
- package/src/runner/reflexion.test.ts +6 -17
- package/src/runner/resource-pool.ts +102 -14
- package/src/runner/services/context-builder.ts +144 -0
- package/src/runner/services/secret-manager.ts +105 -0
- package/src/runner/services/workflow-validator.ts +131 -0
- package/src/runner/shell-executor.test.ts +28 -4
- package/src/runner/standard-tools-ast.test.ts +196 -0
- package/src/runner/standard-tools-execution.test.ts +27 -0
- package/src/runner/standard-tools-integration.test.ts +6 -10
- package/src/runner/standard-tools.ts +339 -102
- package/src/runner/step-executor.test.ts +216 -4
- package/src/runner/step-executor.ts +69 -941
- package/src/runner/stream-utils.ts +7 -3
- package/src/runner/test-harness.ts +20 -1
- package/src/runner/timeout.test.ts +10 -0
- package/src/runner/timeout.ts +11 -2
- package/src/runner/tool-integration.test.ts +1 -1
- package/src/runner/wait-step.test.ts +102 -0
- package/src/runner/workflow-runner.test.ts +208 -15
- package/src/runner/workflow-runner.ts +890 -818
- package/src/runner/workflow-scheduler.ts +75 -0
- package/src/runner/workflow-state.ts +269 -0
- package/src/runner/workflow-subflows.test.ts +13 -12
- package/src/scripts/generate-schemas.ts +16 -0
- package/src/templates/agents/explore.md +1 -0
- package/src/templates/agents/general.md +1 -0
- package/src/templates/agents/handoff-router.md +14 -0
- package/src/templates/agents/handoff-specialist.md +15 -0
- package/src/templates/agents/keystone-architect.md +13 -44
- package/src/templates/agents/my-agent.md +1 -0
- package/src/templates/agents/software-engineer.md +1 -0
- package/src/templates/agents/summarizer.md +1 -0
- package/src/templates/agents/test-agent.md +1 -0
- package/src/templates/agents/tester.md +1 -0
- package/src/templates/{basic-inputs.yaml → basics/basic-inputs.yaml} +2 -0
- package/src/templates/{basic-shell.yaml → basics/basic-shell.yaml} +4 -1
- package/src/templates/{full-feature-demo.yaml → basics/full-feature-demo.yaml} +2 -0
- package/src/templates/{stop-watch.yaml → basics/stop-watch.yaml} +1 -0
- package/src/templates/{child-rollback.yaml → control-flow/child-rollback.yaml} +1 -0
- package/src/templates/{cleanup-finally.yaml → control-flow/cleanup-finally.yaml} +1 -0
- package/src/templates/{fan-out-fan-in.yaml → control-flow/fan-out-fan-in.yaml} +3 -0
- package/src/templates/control-flow/idempotency-example.yaml +30 -0
- package/src/templates/{loop-parallel.yaml → control-flow/loop-parallel.yaml} +3 -0
- package/src/templates/{parent-rollback.yaml → control-flow/parent-rollback.yaml} +1 -0
- package/src/templates/{retry-policy.yaml → control-flow/retry-policy.yaml} +3 -0
- package/src/templates/features/artifact-example.yaml +40 -0
- package/src/templates/{engine-example.yaml → features/engine-example.yaml} +1 -0
- package/src/templates/{human-interaction.yaml → features/human-interaction.yaml} +1 -0
- package/src/templates/{llm-agent.yaml → features/llm-agent.yaml} +1 -0
- package/src/templates/{memory-service.yaml → features/memory-service.yaml} +2 -0
- package/src/templates/{robust-automation.yaml → features/robust-automation.yaml} +3 -0
- package/src/templates/features/script-example.yaml +28 -0
- package/src/templates/patterns/agent-handoff.yaml +53 -0
- package/src/templates/{approval-process.yaml → patterns/approval-process.yaml} +1 -0
- package/src/templates/{batch-processor.yaml → patterns/batch-processor.yaml} +2 -0
- package/src/templates/{composition-child.yaml → patterns/composition-child.yaml} +2 -1
- package/src/templates/patterns/composition-parent.yaml +18 -0
- package/src/templates/{data-pipeline.yaml → patterns/data-pipeline.yaml} +2 -0
- package/src/templates/{decompose-implement.yaml → scaffolding/decompose-implement.yaml} +1 -0
- package/src/templates/{decompose-problem.yaml → scaffolding/decompose-problem.yaml} +1 -0
- package/src/templates/{decompose-research.yaml → scaffolding/decompose-research.yaml} +1 -0
- package/src/templates/{decompose-review.yaml → scaffolding/decompose-review.yaml} +1 -0
- package/src/templates/{dev.yaml → scaffolding/dev.yaml} +1 -0
- package/src/templates/scaffolding/review-loop.yaml +97 -0
- package/src/templates/{scaffold-feature.yaml → scaffolding/scaffold-feature.yaml} +2 -0
- package/src/templates/{scaffold-generate.yaml → scaffolding/scaffold-generate.yaml} +1 -0
- package/src/templates/{scaffold-plan.yaml → scaffolding/scaffold-plan.yaml} +1 -0
- package/src/templates/testing/invalid.yaml +6 -0
- package/src/ui/dashboard.tsx +191 -33
- package/src/utils/auth-manager.test.ts +337 -0
- package/src/utils/auth-manager.ts +157 -61
- package/src/utils/blueprint-utils.ts +4 -6
- package/src/utils/config-loader.test.ts +2 -0
- package/src/utils/config-loader.ts +12 -3
- package/src/utils/constants.ts +76 -0
- package/src/utils/container.ts +63 -0
- package/src/utils/context-injector.test.ts +200 -0
- package/src/utils/context-injector.ts +244 -0
- package/src/utils/doc-generator.ts +85 -0
- package/src/utils/env-filter.ts +45 -0
- package/src/utils/json-parser.test.ts +12 -0
- package/src/utils/json-parser.ts +30 -5
- package/src/utils/logger.ts +12 -1
- package/src/utils/mermaid.ts +4 -0
- package/src/utils/paths.ts +52 -1
- package/src/utils/process-sandbox-worker.test.ts +46 -0
- package/src/utils/process-sandbox.ts +227 -14
- package/src/utils/redactor.test.ts +11 -6
- package/src/utils/redactor.ts +25 -9
- package/src/utils/sandbox.ts +3 -0
- package/src/runner/llm-executor.ts +0 -638
- package/src/runner/shell-executor.ts +0 -366
- package/src/templates/composition-parent.yaml +0 -14
- package/src/templates/invalid.yaml +0 -5
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import type { Step, Workflow } from '../parser/schema';
|
|
3
|
+
import { WorkflowRunner } from './workflow-runner';
|
|
4
|
+
|
|
5
|
+
describe('WorkflowRunner qualityGate', () => {
|
|
6
|
+
test('should refine output until the quality gate approves', async () => {
|
|
7
|
+
const workflow: Workflow = {
|
|
8
|
+
name: 'quality-gate-test',
|
|
9
|
+
outputs: {
|
|
10
|
+
final: '${{ steps.generate.output }}',
|
|
11
|
+
},
|
|
12
|
+
steps: [
|
|
13
|
+
{
|
|
14
|
+
id: 'generate',
|
|
15
|
+
type: 'llm',
|
|
16
|
+
agent: 'test-agent',
|
|
17
|
+
prompt: 'draft the content',
|
|
18
|
+
qualityGate: {
|
|
19
|
+
agent: 'reviewer',
|
|
20
|
+
maxAttempts: 1,
|
|
21
|
+
},
|
|
22
|
+
} as Step,
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let draftAttempt = 0;
|
|
27
|
+
let reviewAttempt = 0;
|
|
28
|
+
|
|
29
|
+
const executeStepMock = mock(async (step: Step) => {
|
|
30
|
+
if (step.id === 'generate') {
|
|
31
|
+
draftAttempt += 1;
|
|
32
|
+
return {
|
|
33
|
+
status: 'success',
|
|
34
|
+
output: draftAttempt === 1 ? 'draft v1' : 'draft v2',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (step.id === 'generate-quality-review') {
|
|
39
|
+
reviewAttempt += 1;
|
|
40
|
+
return {
|
|
41
|
+
status: 'success',
|
|
42
|
+
output: {
|
|
43
|
+
approved: reviewAttempt > 1,
|
|
44
|
+
issues: reviewAttempt > 1 ? [] : ['Needs more detail'],
|
|
45
|
+
suggestions: reviewAttempt > 1 ? [] : ['Expand the draft'],
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { status: 'failed', output: null, error: 'Unexpected step' };
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const runner = new WorkflowRunner(workflow, {
|
|
54
|
+
dbPath: ':memory:',
|
|
55
|
+
executeStep: executeStepMock as unknown as typeof import('./step-executor').executeStep,
|
|
56
|
+
logger: {
|
|
57
|
+
log: () => {},
|
|
58
|
+
error: () => {},
|
|
59
|
+
warn: () => {},
|
|
60
|
+
info: () => {},
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const outputs = await runner.run();
|
|
65
|
+
|
|
66
|
+
expect(outputs.final).toBe('draft v2');
|
|
67
|
+
expect(executeStepMock).toHaveBeenCalledTimes(4);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -3,8 +3,6 @@ import type { Step, Workflow } from '../parser/schema';
|
|
|
3
3
|
import * as StepExecutor from './step-executor';
|
|
4
4
|
import { WorkflowRunner } from './workflow-runner';
|
|
5
5
|
|
|
6
|
-
// Mock the LLM Adapter
|
|
7
|
-
|
|
8
6
|
describe('WorkflowRunner Reflexion', () => {
|
|
9
7
|
beforeEach(() => {
|
|
10
8
|
jest.restoreAllMocks();
|
|
@@ -33,42 +31,36 @@ describe('WorkflowRunner Reflexion', () => {
|
|
|
33
31
|
content: JSON.stringify({ run: 'echo "fixed"' }),
|
|
34
32
|
},
|
|
35
33
|
}),
|
|
36
|
-
// biome-ignore lint/suspicious/noExplicitAny: mock adapter
|
|
37
34
|
} as any,
|
|
38
35
|
resolvedModel: 'mock-model',
|
|
39
36
|
});
|
|
40
37
|
|
|
38
|
+
const spy = jest.fn();
|
|
39
|
+
|
|
41
40
|
const runner = new WorkflowRunner(workflow, {
|
|
42
41
|
logger: { log: () => {}, error: () => {}, warn: () => {} },
|
|
43
42
|
dbPath: ':memory:',
|
|
44
43
|
getAdapter: mockGetAdapter,
|
|
44
|
+
executeStep: spy as any,
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
// biome-ignore lint/suspicious/noExplicitAny: Accessing private property for testing
|
|
48
47
|
const db = (runner as any).db;
|
|
49
48
|
await db.createRun(runner.runId, workflow.name, {});
|
|
50
49
|
|
|
51
|
-
const spy = jest.spyOn(StepExecutor, 'executeStep');
|
|
52
|
-
|
|
53
50
|
// First call fails, Reflexion logic kicks in (calling mocked getAdapter),
|
|
54
51
|
// then it retries with corrected command.
|
|
55
|
-
spy.mockImplementation(async (step
|
|
56
|
-
|
|
57
|
-
// biome-ignore lint/suspicious/noExplicitAny: Accessing run property dynamically
|
|
58
|
-
if ((step as any).run === 'exit 1') {
|
|
52
|
+
spy.mockImplementation(async (step: any) => {
|
|
53
|
+
if (step.run === 'exit 1') {
|
|
59
54
|
return { status: 'failed', output: null, error: 'Command failed' };
|
|
60
55
|
}
|
|
61
56
|
|
|
62
|
-
|
|
63
|
-
// biome-ignore lint/suspicious/noExplicitAny: Accessing run property dynamically
|
|
64
|
-
if ((step as any).run === 'echo "fixed"') {
|
|
57
|
+
if (step.run === 'echo "fixed"') {
|
|
65
58
|
return { status: 'success', output: 'fixed' };
|
|
66
59
|
}
|
|
67
60
|
|
|
68
61
|
return { status: 'failed', output: null, error: 'Unknown step' };
|
|
69
62
|
});
|
|
70
63
|
|
|
71
|
-
// biome-ignore lint/suspicious/noExplicitAny: Accessing private property for testing
|
|
72
64
|
await (runner as any).executeStepWithForeach(workflow.steps[0]);
|
|
73
65
|
|
|
74
66
|
// Expectations:
|
|
@@ -78,10 +70,7 @@ describe('WorkflowRunner Reflexion', () => {
|
|
|
78
70
|
expect(spy).toHaveBeenCalledTimes(2);
|
|
79
71
|
|
|
80
72
|
// Verify the second call had the corrected command
|
|
81
|
-
// biome-ignore lint/suspicious/noExplicitAny: mock call args typing
|
|
82
73
|
const secondCallArg = spy.mock.calls[1][0] as any;
|
|
83
74
|
expect(secondCallArg.run).toBe('echo "fixed"');
|
|
84
|
-
|
|
85
|
-
spy.mockRestore();
|
|
86
75
|
});
|
|
87
76
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LIMITS } from '../utils/constants.ts';
|
|
1
2
|
import type { Logger } from '../utils/logger.ts';
|
|
2
3
|
|
|
3
4
|
export type ReleaseFunction = () => void;
|
|
@@ -19,6 +20,12 @@ interface QueuedRequest {
|
|
|
19
20
|
timestamp: number;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/** Default maximum queue size to prevent unbounded memory growth */
|
|
24
|
+
const DEFAULT_MAX_QUEUE_SIZE = 1000;
|
|
25
|
+
|
|
26
|
+
/** Default resource pool limit */
|
|
27
|
+
const DEFAULT_POOL_LIMIT = 10;
|
|
28
|
+
|
|
22
29
|
export class ResourcePoolManager {
|
|
23
30
|
private pools = new Map<
|
|
24
31
|
string,
|
|
@@ -31,12 +38,14 @@ export class ResourcePoolManager {
|
|
|
31
38
|
}
|
|
32
39
|
>();
|
|
33
40
|
private globalLimit: number;
|
|
41
|
+
private maxQueueSize: number;
|
|
34
42
|
|
|
35
43
|
constructor(
|
|
36
44
|
private logger: Logger,
|
|
37
|
-
options: { defaultLimit?: number; pools?: Record<string, number> } = {}
|
|
45
|
+
options: { defaultLimit?: number; maxQueueSize?: number; pools?: Record<string, number> } = {}
|
|
38
46
|
) {
|
|
39
|
-
this.globalLimit = options.defaultLimit ||
|
|
47
|
+
this.globalLimit = options.defaultLimit || DEFAULT_POOL_LIMIT;
|
|
48
|
+
this.maxQueueSize = options.maxQueueSize || DEFAULT_MAX_QUEUE_SIZE;
|
|
40
49
|
if (options.pools) {
|
|
41
50
|
for (const [name, limit] of Object.entries(options.pools)) {
|
|
42
51
|
this.pools.set(name, { limit, active: 0, queue: [], totalAcquired: 0, totalWaitTimeMs: 0 });
|
|
@@ -47,11 +56,30 @@ export class ResourcePoolManager {
|
|
|
47
56
|
/**
|
|
48
57
|
* Acquire a resource from a pool.
|
|
49
58
|
* If the pool doesn't exist, it uses the global limit.
|
|
59
|
+
*
|
|
60
|
+
* @param poolName - Name of the pool to acquire from
|
|
61
|
+
* @param options.priority - Higher priority requests are processed first (default: 0)
|
|
62
|
+
* @param options.signal - AbortSignal for cancellation
|
|
63
|
+
* @param options.timeout - Maximum time to wait for a slot (ms), rejects with timeout error if exceeded
|
|
50
64
|
*/
|
|
51
65
|
async acquire(
|
|
52
66
|
poolName: string,
|
|
53
|
-
options: { priority?: number; signal?: AbortSignal } = {}
|
|
67
|
+
options: { priority?: number; signal?: AbortSignal; timeout?: number } = {}
|
|
54
68
|
): Promise<ReleaseFunction> {
|
|
69
|
+
// Validate timeout parameter - must be positive, finite, and within max bounds
|
|
70
|
+
if (options.timeout !== undefined) {
|
|
71
|
+
if (
|
|
72
|
+
typeof options.timeout !== 'number' ||
|
|
73
|
+
!Number.isFinite(options.timeout) ||
|
|
74
|
+
options.timeout <= 0 ||
|
|
75
|
+
options.timeout > LIMITS.MAX_RESOURCE_POOL_TIMEOUT_MS
|
|
76
|
+
) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Invalid timeout value: ${options.timeout}. Timeout must be a positive number <= ${LIMITS.MAX_RESOURCE_POOL_TIMEOUT_MS}ms.`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
55
83
|
let pool = this.pools.get(poolName);
|
|
56
84
|
if (!pool) {
|
|
57
85
|
// Create a pool for this name if it doesn't exist, using global limit
|
|
@@ -71,6 +99,13 @@ export class ResourcePoolManager {
|
|
|
71
99
|
return this.createReleaseFn(poolName);
|
|
72
100
|
}
|
|
73
101
|
|
|
102
|
+
// Check queue size limit
|
|
103
|
+
if (pool.queue.length >= this.maxQueueSize) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Resource pool "${poolName}" queue is full (${this.maxQueueSize}). Consider increasing concurrency limits or reducing parallel work.`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
74
109
|
// Queue the request
|
|
75
110
|
const timestamp = Date.now();
|
|
76
111
|
const poolRef = pool;
|
|
@@ -90,19 +125,63 @@ export class ResourcePoolManager {
|
|
|
90
125
|
return a.timestamp - b.timestamp;
|
|
91
126
|
});
|
|
92
127
|
|
|
128
|
+
// Handle timeout
|
|
129
|
+
let timeoutId: Timer | undefined;
|
|
130
|
+
if (options.timeout && options.timeout > 0) {
|
|
131
|
+
timeoutId = setTimeout(() => {
|
|
132
|
+
const index = poolRef.queue.indexOf(request);
|
|
133
|
+
if (index !== -1) {
|
|
134
|
+
poolRef.queue.splice(index, 1);
|
|
135
|
+
reject(
|
|
136
|
+
new Error(`Resource pool "${poolName}" acquire timeout after ${options.timeout}ms`)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}, options.timeout);
|
|
140
|
+
}
|
|
141
|
+
|
|
93
142
|
// Handle abort signal
|
|
143
|
+
const cleanup = () => {
|
|
144
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
145
|
+
};
|
|
146
|
+
|
|
94
147
|
if (options.signal) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
()
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
148
|
+
const onAbort = () => {
|
|
149
|
+
cleanup();
|
|
150
|
+
const index = poolRef.queue.indexOf(request);
|
|
151
|
+
if (index !== -1) {
|
|
152
|
+
poolRef.queue.splice(index, 1);
|
|
153
|
+
reject(new Error('Acquisition aborted'));
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
options.signal.addEventListener('abort', onAbort, { once: true });
|
|
157
|
+
|
|
158
|
+
// Wrap accessors to remove listener
|
|
159
|
+
const originalResolve = resolve;
|
|
160
|
+
const originalReject = reject;
|
|
161
|
+
|
|
162
|
+
request.resolve = (release) => {
|
|
163
|
+
cleanup();
|
|
164
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
165
|
+
originalResolve(release);
|
|
166
|
+
};
|
|
167
|
+
request.reject = (err) => {
|
|
168
|
+
cleanup();
|
|
169
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
170
|
+
originalReject(err);
|
|
171
|
+
};
|
|
172
|
+
} else {
|
|
173
|
+
// Still need to wrap for timeout cleanup
|
|
174
|
+
const originalResolve = resolve;
|
|
175
|
+
const originalReject = reject;
|
|
176
|
+
|
|
177
|
+
request.resolve = (release) => {
|
|
178
|
+
cleanup();
|
|
179
|
+
originalResolve(release);
|
|
180
|
+
};
|
|
181
|
+
request.reject = (err) => {
|
|
182
|
+
cleanup();
|
|
183
|
+
originalReject(err);
|
|
184
|
+
};
|
|
106
185
|
}
|
|
107
186
|
});
|
|
108
187
|
}
|
|
@@ -161,4 +240,13 @@ export class ResourcePoolManager {
|
|
|
161
240
|
totalWaitTimeMs: pool.totalWaitTimeMs,
|
|
162
241
|
}));
|
|
163
242
|
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Check if a pool has capacity for another task.
|
|
246
|
+
*/
|
|
247
|
+
hasCapacity(poolName: string): boolean {
|
|
248
|
+
const pool = this.pools.get(poolName);
|
|
249
|
+
if (!pool) return true; // Global limit will be checked on acquire
|
|
250
|
+
return pool.active < pool.limit;
|
|
251
|
+
}
|
|
164
252
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { ExpressionContext } from '../../expression/evaluator.ts';
|
|
2
|
+
import { ExpressionEvaluator } from '../../expression/evaluator.ts';
|
|
3
|
+
import type { Workflow } from '../../parser/schema.ts';
|
|
4
|
+
import type { Logger } from '../../utils/logger.ts';
|
|
5
|
+
import type { WorkflowState } from '../workflow-state.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Service for building the expression context for workflow steps.
|
|
9
|
+
*/
|
|
10
|
+
export class ContextBuilder {
|
|
11
|
+
constructor(
|
|
12
|
+
private workflow: Workflow,
|
|
13
|
+
private inputs: Record<string, unknown>,
|
|
14
|
+
private secretValues: string[],
|
|
15
|
+
private state: WorkflowState,
|
|
16
|
+
private logger: Logger
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Builds the context object used for expression evaluation.
|
|
21
|
+
*/
|
|
22
|
+
public buildContext(
|
|
23
|
+
secrets: Record<string, string>,
|
|
24
|
+
envOverrides: Record<string, string> = {},
|
|
25
|
+
memory: Record<string, unknown> = {},
|
|
26
|
+
lastFailedStep?: string,
|
|
27
|
+
item?: unknown,
|
|
28
|
+
index?: number
|
|
29
|
+
): ExpressionContext {
|
|
30
|
+
const stepsContext: Record<
|
|
31
|
+
string,
|
|
32
|
+
{
|
|
33
|
+
output?: unknown;
|
|
34
|
+
outputs?: Record<string, unknown>;
|
|
35
|
+
status?: string;
|
|
36
|
+
error?: string;
|
|
37
|
+
items?: any[];
|
|
38
|
+
}
|
|
39
|
+
> = {};
|
|
40
|
+
|
|
41
|
+
for (const [stepId, ctx] of this.state.entries()) {
|
|
42
|
+
stepsContext[stepId] = {
|
|
43
|
+
output: ctx.output,
|
|
44
|
+
outputs: ctx.outputs,
|
|
45
|
+
status: ctx.status,
|
|
46
|
+
error: ctx.error,
|
|
47
|
+
...(ctx && 'items' in ctx ? { items: (ctx as any).items } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const baseContext: ExpressionContext = {
|
|
52
|
+
inputs: this.inputs,
|
|
53
|
+
secrets: secrets,
|
|
54
|
+
secretValues: this.secretValues,
|
|
55
|
+
steps: stepsContext,
|
|
56
|
+
item,
|
|
57
|
+
index,
|
|
58
|
+
env: {},
|
|
59
|
+
envOverrides: envOverrides,
|
|
60
|
+
memory: memory,
|
|
61
|
+
output: item
|
|
62
|
+
? undefined
|
|
63
|
+
: this.state.get(this.workflow.steps.find((s) => !s.foreach)?.id || '')?.output,
|
|
64
|
+
last_failed_step: lastFailedStep ? { id: lastFailedStep, error: '' } : undefined,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const resolvedEnv: Record<string, string> = {};
|
|
68
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
69
|
+
if (value !== undefined) {
|
|
70
|
+
resolvedEnv[key] = value;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (this.workflow.env) {
|
|
75
|
+
for (const [key, value] of Object.entries(this.workflow.env)) {
|
|
76
|
+
try {
|
|
77
|
+
resolvedEnv[key] = ExpressionEvaluator.evaluateString(value, {
|
|
78
|
+
...baseContext,
|
|
79
|
+
env: resolvedEnv,
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
this.logger.warn(
|
|
83
|
+
`Warning: Failed to evaluate workflow env "${key}": ${error instanceof Error ? error.message : String(error)}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
baseContext.env = { ...resolvedEnv, ...envOverrides };
|
|
90
|
+
return baseContext;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Builds input object for a specific step.
|
|
94
|
+
*/
|
|
95
|
+
public buildStepInputs(step: any, context: ExpressionContext): Record<string, unknown> {
|
|
96
|
+
const stripUndefined = (value: Record<string, unknown>) => {
|
|
97
|
+
const result: Record<string, unknown> = {};
|
|
98
|
+
for (const [key, val] of Object.entries(value)) {
|
|
99
|
+
if (val !== undefined) {
|
|
100
|
+
result[key] = val;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
switch (step.type) {
|
|
107
|
+
case 'shell': {
|
|
108
|
+
let env: Record<string, string> | undefined;
|
|
109
|
+
if (step.env) {
|
|
110
|
+
env = {};
|
|
111
|
+
for (const [key, value] of Object.entries(step.env)) {
|
|
112
|
+
env[key] = ExpressionEvaluator.evaluateString(value as string, context);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return stripUndefined({
|
|
116
|
+
run: ExpressionEvaluator.evaluateString((step as any).run, context),
|
|
117
|
+
env,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
case 'file': {
|
|
121
|
+
return stripUndefined({
|
|
122
|
+
path: ExpressionEvaluator.evaluateString((step as any).path, context),
|
|
123
|
+
content: (step as any).content
|
|
124
|
+
? ExpressionEvaluator.evaluateString((step as any).content, context)
|
|
125
|
+
: undefined,
|
|
126
|
+
op: (step as any).op,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
default: {
|
|
130
|
+
// For most steps, we just pass through properties which might contain expressions
|
|
131
|
+
const inputs: Record<string, unknown> = {};
|
|
132
|
+
for (const [key, value] of Object.entries(step)) {
|
|
133
|
+
if (key === 'id' || key === 'type' || key === 'if' || key === 'foreach') continue;
|
|
134
|
+
if (typeof value === 'string' && value.includes('{{')) {
|
|
135
|
+
inputs[key] = ExpressionEvaluator.evaluateString(value, context);
|
|
136
|
+
} else {
|
|
137
|
+
inputs[key] = value;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return stripUndefined(inputs);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { RedactionBuffer, Redactor } from '../../utils/redactor';
|
|
2
|
+
|
|
3
|
+
export class SecretManager {
|
|
4
|
+
private secretValues: string[] = [];
|
|
5
|
+
private redactor: Redactor;
|
|
6
|
+
|
|
7
|
+
constructor(private initialSecrets: Record<string, string> = {}) {
|
|
8
|
+
this.redactor = new Redactor(initialSecrets, { forcedSecrets: [] });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public static collectSecretValues(
|
|
12
|
+
value: unknown,
|
|
13
|
+
sink: Set<string>,
|
|
14
|
+
seen: WeakSet<object> = new WeakSet()
|
|
15
|
+
): void {
|
|
16
|
+
if (value === null || value === undefined) return;
|
|
17
|
+
|
|
18
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
19
|
+
sink.add(String(value));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (typeof value !== 'object') return;
|
|
24
|
+
|
|
25
|
+
if (seen.has(value)) return;
|
|
26
|
+
seen.add(value);
|
|
27
|
+
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
for (const item of value) {
|
|
30
|
+
SecretManager.collectSecretValues(item, sink, seen);
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const item of Object.values(value as Record<string, unknown>)) {
|
|
36
|
+
SecretManager.collectSecretValues(item, sink, seen);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public loadSecrets(optionsSecrets: Record<string, string> = {}): Record<string, string> {
|
|
41
|
+
const secrets: Record<string, string> = { ...this.initialSecrets, ...optionsSecrets };
|
|
42
|
+
|
|
43
|
+
// Strict allowlist for safe system environment variables
|
|
44
|
+
const safeSystemVars = ['PATH', 'HOME', 'PWD', 'SHELL', 'LANG', 'TZ'];
|
|
45
|
+
for (const key of safeSystemVars) {
|
|
46
|
+
const value = process.env[key];
|
|
47
|
+
if (value) {
|
|
48
|
+
secrets[key] = value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Include pattern-matched secrets from Bun.env (safe-ish way to get common secrets)
|
|
53
|
+
const secretPatterns = [/token/i, /key/i, /secret/i, /password/i, /auth/i, /api/i];
|
|
54
|
+
for (const [key, value] of Object.entries(Bun.env)) {
|
|
55
|
+
if (value && secretPatterns.some((p) => p.test(key))) {
|
|
56
|
+
// Skip common system non-secret variables that might match patterns
|
|
57
|
+
if (safeSystemVars.includes(key)) continue;
|
|
58
|
+
secrets[key] = value;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return secrets;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public getSecrets(optionsSecrets: Record<string, string> = {}): Record<string, string> {
|
|
66
|
+
return this.loadSecrets(optionsSecrets);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public getRedactor(): Redactor {
|
|
70
|
+
return this.redactor;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public setSecretValues(values: string[]): void {
|
|
74
|
+
this.secretValues = values;
|
|
75
|
+
this.redactor = new Redactor(this.initialSecrets, { forcedSecrets: this.secretValues });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
public getSecretValues(): string[] {
|
|
79
|
+
return this.secretValues;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public redact(content: string): string {
|
|
83
|
+
return this.redactor.redact(content);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public createRedactionBuffer(): RedactionBuffer {
|
|
87
|
+
return new RedactionBuffer(this.redactor);
|
|
88
|
+
}
|
|
89
|
+
public redactAtRest = true;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Redact secrets from a value for storage (if enabled)
|
|
93
|
+
*/
|
|
94
|
+
public redactForStorage<T>(value: T): T {
|
|
95
|
+
if (!this.redactAtRest) return value;
|
|
96
|
+
return this.redactor.redactValue(value) as T;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Redact secrets from a value
|
|
101
|
+
*/
|
|
102
|
+
public redactValue<T>(value: T): T {
|
|
103
|
+
return this.redactor.redactValue(value) as T;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { Workflow, WorkflowInput } from '../../parser/schema.ts';
|
|
2
|
+
import { validateJsonSchema } from '../../utils/schema-validator.ts';
|
|
3
|
+
import { SecretManager } from './secret-manager.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Service for validating workflow inputs and applying defaults.
|
|
7
|
+
*/
|
|
8
|
+
export class WorkflowValidator {
|
|
9
|
+
public static readonly REDACTED_PLACEHOLDER = '[REDACTED]';
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
private workflow: Workflow,
|
|
13
|
+
private inputs: Record<string, unknown>
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Apply workflow defaults to inputs and validate types.
|
|
18
|
+
* Returns the set of secret values found in the inputs.
|
|
19
|
+
*/
|
|
20
|
+
public applyDefaultsAndValidate(): { secretValues: string[] } {
|
|
21
|
+
if (!this.workflow.inputs) return { secretValues: [] };
|
|
22
|
+
|
|
23
|
+
const secretValues = new Set<string>();
|
|
24
|
+
|
|
25
|
+
for (const [key, config] of Object.entries(this.workflow.inputs)) {
|
|
26
|
+
const inputConfig = config as WorkflowInput;
|
|
27
|
+
|
|
28
|
+
// Apply default if missing
|
|
29
|
+
if (this.inputs[key] === undefined && inputConfig.default !== undefined) {
|
|
30
|
+
this.inputs[key] = inputConfig.default;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (inputConfig.secret) {
|
|
34
|
+
if (this.inputs[key] === WorkflowValidator.REDACTED_PLACEHOLDER) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Secret input "${key}" was redacted at rest. Please provide it again to resume this run.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Validate required inputs
|
|
42
|
+
if (this.inputs[key] === undefined) {
|
|
43
|
+
throw new Error(`Missing required input: ${key}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Basic type validation
|
|
47
|
+
const value = this.inputs[key];
|
|
48
|
+
const type = inputConfig.type.toLowerCase();
|
|
49
|
+
|
|
50
|
+
if (type === 'string' && typeof value !== 'string') {
|
|
51
|
+
throw new Error(`Input "${key}" must be a string, got ${typeof value}`);
|
|
52
|
+
}
|
|
53
|
+
if (type === 'number' && typeof value !== 'number') {
|
|
54
|
+
throw new Error(`Input "${key}" must be a number, got ${typeof value}`);
|
|
55
|
+
}
|
|
56
|
+
if (type === 'boolean' && typeof value !== 'boolean') {
|
|
57
|
+
throw new Error(`Input "${key}" must be a boolean, got ${typeof value}`);
|
|
58
|
+
}
|
|
59
|
+
if (type === 'array' && !Array.isArray(value)) {
|
|
60
|
+
throw new Error(`Input "${key}" must be an array, got ${typeof value}`);
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
type === 'object' &&
|
|
64
|
+
(typeof value !== 'object' || value === null || Array.isArray(value))
|
|
65
|
+
) {
|
|
66
|
+
throw new Error(`Input "${key}" must be an object, got ${typeof value}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (inputConfig.values) {
|
|
70
|
+
if (type !== 'string' && type !== 'number' && type !== 'boolean') {
|
|
71
|
+
throw new Error(`Input "${key}" cannot use enum values with type "${type}"`);
|
|
72
|
+
}
|
|
73
|
+
for (const allowed of inputConfig.values) {
|
|
74
|
+
const matchesType =
|
|
75
|
+
(type === 'string' && typeof allowed === 'string') ||
|
|
76
|
+
(type === 'number' && typeof allowed === 'number') ||
|
|
77
|
+
(type === 'boolean' && typeof allowed === 'boolean');
|
|
78
|
+
if (!matchesType) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Input "${key}" enum value ${JSON.stringify(allowed)} must be a ${type}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!inputConfig.values.includes(value as string | number | boolean)) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Input "${key}" must be one of: ${inputConfig.values.map((v) => JSON.stringify(v)).join(', ')}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (
|
|
92
|
+
inputConfig.secret &&
|
|
93
|
+
value !== undefined &&
|
|
94
|
+
value !== WorkflowValidator.REDACTED_PLACEHOLDER
|
|
95
|
+
) {
|
|
96
|
+
SecretManager.collectSecretValues(value, secretValues, new WeakSet());
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { secretValues: Array.from(secretValues) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Validate data against a JSON schema.
|
|
105
|
+
*/
|
|
106
|
+
public validateSchema(
|
|
107
|
+
kind: 'input' | 'output',
|
|
108
|
+
schema: unknown,
|
|
109
|
+
data: unknown,
|
|
110
|
+
stepId: string
|
|
111
|
+
): void {
|
|
112
|
+
try {
|
|
113
|
+
const result = validateJsonSchema(schema, data);
|
|
114
|
+
if (result.valid) return;
|
|
115
|
+
const details = result.errors.map((line: string) => ` - ${line}`).join('\n');
|
|
116
|
+
throw new Error(
|
|
117
|
+
`${kind === 'input' ? 'Input' : 'Output'} schema validation failed for step "${stepId}":\n${details}`
|
|
118
|
+
);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error instanceof Error) {
|
|
121
|
+
if (error.message.includes('schema validation failed for step')) {
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
throw new Error(
|
|
125
|
+
`${kind === 'input' ? 'Input' : 'Output'} schema error for step "${stepId}": ${error.message}`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|