keystone-cli 0.1.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 +136 -0
- package/logo.png +0 -0
- package/package.json +45 -0
- package/src/cli.ts +775 -0
- package/src/db/workflow-db.test.ts +99 -0
- package/src/db/workflow-db.ts +265 -0
- package/src/expression/evaluator.test.ts +247 -0
- package/src/expression/evaluator.ts +517 -0
- package/src/parser/agent-parser.test.ts +123 -0
- package/src/parser/agent-parser.ts +59 -0
- package/src/parser/config-schema.ts +54 -0
- package/src/parser/schema.ts +157 -0
- package/src/parser/workflow-parser.test.ts +212 -0
- package/src/parser/workflow-parser.ts +228 -0
- package/src/runner/llm-adapter.test.ts +329 -0
- package/src/runner/llm-adapter.ts +306 -0
- package/src/runner/llm-executor.test.ts +537 -0
- package/src/runner/llm-executor.ts +256 -0
- package/src/runner/mcp-client.test.ts +122 -0
- package/src/runner/mcp-client.ts +123 -0
- package/src/runner/mcp-manager.test.ts +143 -0
- package/src/runner/mcp-manager.ts +85 -0
- package/src/runner/mcp-server.test.ts +242 -0
- package/src/runner/mcp-server.ts +436 -0
- package/src/runner/retry.test.ts +52 -0
- package/src/runner/retry.ts +58 -0
- package/src/runner/shell-executor.test.ts +123 -0
- package/src/runner/shell-executor.ts +166 -0
- package/src/runner/step-executor.test.ts +465 -0
- package/src/runner/step-executor.ts +354 -0
- package/src/runner/timeout.test.ts +20 -0
- package/src/runner/timeout.ts +30 -0
- package/src/runner/tool-integration.test.ts +198 -0
- package/src/runner/workflow-runner.test.ts +358 -0
- package/src/runner/workflow-runner.ts +955 -0
- package/src/ui/dashboard.tsx +165 -0
- package/src/utils/auth-manager.test.ts +152 -0
- package/src/utils/auth-manager.ts +88 -0
- package/src/utils/config-loader.test.ts +52 -0
- package/src/utils/config-loader.ts +85 -0
- package/src/utils/mermaid.test.ts +51 -0
- package/src/utils/mermaid.ts +87 -0
- package/src/utils/redactor.test.ts +66 -0
- package/src/utils/redactor.ts +60 -0
- package/src/utils/workflow-registry.test.ts +108 -0
- package/src/utils/workflow-registry.ts +121 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import * as readline from 'node:readline';
|
|
2
|
+
import { WorkflowDb } from '../db/workflow-db';
|
|
3
|
+
import { WorkflowParser } from '../parser/workflow-parser';
|
|
4
|
+
import { generateMermaidGraph } from '../utils/mermaid';
|
|
5
|
+
import { WorkflowRegistry } from '../utils/workflow-registry';
|
|
6
|
+
import { WorkflowSuspendedError } from './step-executor';
|
|
7
|
+
import { WorkflowRunner } from './workflow-runner';
|
|
8
|
+
|
|
9
|
+
interface MCPMessage {
|
|
10
|
+
jsonrpc: '2.0';
|
|
11
|
+
method: string;
|
|
12
|
+
params?: unknown;
|
|
13
|
+
id?: string | number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class MCPServer {
|
|
17
|
+
private db: WorkflowDb;
|
|
18
|
+
|
|
19
|
+
constructor(db?: WorkflowDb) {
|
|
20
|
+
this.db = db || new WorkflowDb();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async start() {
|
|
24
|
+
const rl = readline.createInterface({
|
|
25
|
+
input: process.stdin,
|
|
26
|
+
terminal: false,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
rl.on('line', async (line) => {
|
|
30
|
+
if (!line.trim()) return;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const message = JSON.parse(line) as MCPMessage;
|
|
34
|
+
const response = await this.handleMessage(message);
|
|
35
|
+
if (response) {
|
|
36
|
+
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error('Error handling MCP message:', error);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private async handleMessage(message: MCPMessage) {
|
|
45
|
+
const { method, params, id } = message;
|
|
46
|
+
|
|
47
|
+
switch (method) {
|
|
48
|
+
case 'initialize':
|
|
49
|
+
return {
|
|
50
|
+
jsonrpc: '2.0',
|
|
51
|
+
id,
|
|
52
|
+
result: {
|
|
53
|
+
protocolVersion: '2024-11-05',
|
|
54
|
+
capabilities: {
|
|
55
|
+
tools: {},
|
|
56
|
+
},
|
|
57
|
+
serverInfo: {
|
|
58
|
+
name: 'keystone-mcp',
|
|
59
|
+
version: '0.1.0',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
case 'tools/list':
|
|
65
|
+
return {
|
|
66
|
+
jsonrpc: '2.0',
|
|
67
|
+
id,
|
|
68
|
+
result: {
|
|
69
|
+
tools: [
|
|
70
|
+
{
|
|
71
|
+
name: 'list_workflows',
|
|
72
|
+
description: 'List all available workflows and their required inputs.',
|
|
73
|
+
inputSchema: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: {},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'run_workflow',
|
|
80
|
+
description: 'Execute a workflow by name.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
workflow_name: {
|
|
85
|
+
type: 'string',
|
|
86
|
+
description: 'The name of the workflow to run (e.g., "deploy", "cleanup")',
|
|
87
|
+
},
|
|
88
|
+
inputs: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
description: 'Key-value pairs for workflow inputs',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
required: ['workflow_name'],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'get_run_logs',
|
|
98
|
+
description: 'Get the logs and status of a specific workflow run.',
|
|
99
|
+
inputSchema: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
run_id: { type: 'string' },
|
|
103
|
+
},
|
|
104
|
+
required: ['run_id'],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'get_workflow_graph',
|
|
109
|
+
description: 'Get a visual diagram (Mermaid.js) of the workflow structure.',
|
|
110
|
+
inputSchema: {
|
|
111
|
+
type: 'object',
|
|
112
|
+
properties: {
|
|
113
|
+
workflow_name: { type: 'string' },
|
|
114
|
+
},
|
|
115
|
+
required: ['workflow_name'],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'answer_human_input',
|
|
120
|
+
description:
|
|
121
|
+
'Provide input to a workflow that is paused waiting for human interaction.',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
run_id: { type: 'string', description: 'The ID of the paused run' },
|
|
126
|
+
input: {
|
|
127
|
+
type: 'string',
|
|
128
|
+
description: 'The text input or "confirm" for confirmation steps',
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
required: ['run_id', 'input'],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
case 'tools/call': {
|
|
139
|
+
const toolParams = params as { name: string; arguments: Record<string, unknown> };
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
// --- Tool: list_workflows ---
|
|
143
|
+
if (toolParams.name === 'list_workflows') {
|
|
144
|
+
const workflows = WorkflowRegistry.listWorkflows();
|
|
145
|
+
return {
|
|
146
|
+
jsonrpc: '2.0',
|
|
147
|
+
id,
|
|
148
|
+
result: {
|
|
149
|
+
content: [{ type: 'text', text: JSON.stringify(workflows, null, 2) }],
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- Tool: run_workflow ---
|
|
155
|
+
if (toolParams.name === 'run_workflow') {
|
|
156
|
+
const { workflow_name, inputs } = toolParams.arguments as {
|
|
157
|
+
workflow_name: string;
|
|
158
|
+
inputs: Record<string, unknown>;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const path = WorkflowRegistry.resolvePath(workflow_name);
|
|
162
|
+
const workflow = WorkflowParser.loadWorkflow(path);
|
|
163
|
+
|
|
164
|
+
// Use a custom logger that captures logs for the MCP response
|
|
165
|
+
const logs: string[] = [];
|
|
166
|
+
const logger = {
|
|
167
|
+
log: (msg: string) => logs.push(msg),
|
|
168
|
+
error: (msg: string) => logs.push(`ERROR: ${msg}`),
|
|
169
|
+
warn: (msg: string) => logs.push(`WARN: ${msg}`),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const runner = new WorkflowRunner(workflow, {
|
|
173
|
+
inputs,
|
|
174
|
+
logger,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Note: This waits for completion. For long workflows, we might want to
|
|
178
|
+
// return the run_id immediately and let the agent poll via get_run_logs.
|
|
179
|
+
// For now, synchronous is easier for the agent to reason about.
|
|
180
|
+
let outputs: Record<string, unknown> | undefined;
|
|
181
|
+
try {
|
|
182
|
+
outputs = await runner.run();
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof WorkflowSuspendedError) {
|
|
185
|
+
return {
|
|
186
|
+
jsonrpc: '2.0',
|
|
187
|
+
id,
|
|
188
|
+
result: {
|
|
189
|
+
content: [
|
|
190
|
+
{
|
|
191
|
+
type: 'text',
|
|
192
|
+
text: JSON.stringify(
|
|
193
|
+
{
|
|
194
|
+
status: 'paused',
|
|
195
|
+
run_id: runner.getRunId(),
|
|
196
|
+
message: error.message,
|
|
197
|
+
step_id: error.stepId,
|
|
198
|
+
input_type: error.inputType,
|
|
199
|
+
instructions:
|
|
200
|
+
error.inputType === 'confirm'
|
|
201
|
+
? 'Use answer_human_input with input="confirm" to proceed.'
|
|
202
|
+
: 'Use answer_human_input with the required text input.',
|
|
203
|
+
},
|
|
204
|
+
null,
|
|
205
|
+
2
|
|
206
|
+
),
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
// Even if it fails, we return the logs so the agent knows why
|
|
213
|
+
return {
|
|
214
|
+
jsonrpc: '2.0',
|
|
215
|
+
id,
|
|
216
|
+
result: {
|
|
217
|
+
isError: true,
|
|
218
|
+
content: [
|
|
219
|
+
{
|
|
220
|
+
type: 'text',
|
|
221
|
+
text: `Workflow failed.\n\nLogs:\n${logs.join('\n')}`,
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
jsonrpc: '2.0',
|
|
230
|
+
id,
|
|
231
|
+
result: {
|
|
232
|
+
content: [
|
|
233
|
+
{
|
|
234
|
+
type: 'text',
|
|
235
|
+
text: JSON.stringify(
|
|
236
|
+
{
|
|
237
|
+
status: 'success',
|
|
238
|
+
outputs,
|
|
239
|
+
logs: logs.slice(-20), // Return last 20 lines to avoid token limits
|
|
240
|
+
},
|
|
241
|
+
null,
|
|
242
|
+
2
|
|
243
|
+
),
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// --- Tool: get_run_logs ---
|
|
251
|
+
if (toolParams.name === 'get_run_logs') {
|
|
252
|
+
const { run_id } = toolParams.arguments as { run_id: string };
|
|
253
|
+
const run = this.db.getRun(run_id);
|
|
254
|
+
|
|
255
|
+
if (!run) {
|
|
256
|
+
throw new Error(`Run ID ${run_id} not found`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const steps = this.db.getStepsByRun(run_id);
|
|
260
|
+
const summary = {
|
|
261
|
+
workflow: run.workflow_name,
|
|
262
|
+
status: run.status,
|
|
263
|
+
error: run.error,
|
|
264
|
+
steps: steps.map((s) => ({
|
|
265
|
+
step: s.step_id,
|
|
266
|
+
status: s.status,
|
|
267
|
+
error: s.error,
|
|
268
|
+
output: s.output ? JSON.parse(s.output) : null,
|
|
269
|
+
})),
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
jsonrpc: '2.0',
|
|
274
|
+
id,
|
|
275
|
+
result: {
|
|
276
|
+
content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }],
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- Tool: get_workflow_graph ---
|
|
282
|
+
if (toolParams.name === 'get_workflow_graph') {
|
|
283
|
+
const { workflow_name } = toolParams.arguments as { workflow_name: string };
|
|
284
|
+
const path = WorkflowRegistry.resolvePath(workflow_name);
|
|
285
|
+
const workflow = WorkflowParser.loadWorkflow(path);
|
|
286
|
+
|
|
287
|
+
const mermaid = generateMermaidGraph(workflow);
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
jsonrpc: '2.0',
|
|
291
|
+
id,
|
|
292
|
+
result: {
|
|
293
|
+
content: [
|
|
294
|
+
{
|
|
295
|
+
type: 'text',
|
|
296
|
+
text: `Here is the graph for **${workflow_name}**:\n\n\`\`\`mermaid\n${mermaid}\n\`\`\``,
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --- Tool: answer_human_input ---
|
|
304
|
+
if (toolParams.name === 'answer_human_input') {
|
|
305
|
+
const { run_id, input } = toolParams.arguments as { run_id: string; input: string };
|
|
306
|
+
const run = this.db.getRun(run_id);
|
|
307
|
+
if (!run) {
|
|
308
|
+
throw new Error(`Run ID ${run_id} not found`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (run.status !== 'paused') {
|
|
312
|
+
throw new Error(`Run ${run_id} is not paused (status: ${run.status})`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Find the pending human step
|
|
316
|
+
const steps = this.db.getStepsByRun(run_id);
|
|
317
|
+
const pendingStep = steps.find((s) => s.status === 'pending');
|
|
318
|
+
if (!pendingStep) {
|
|
319
|
+
throw new Error(`No pending step found for run ${run_id}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Fulfill the step in the DB
|
|
323
|
+
const output = input === 'confirm' ? true : input;
|
|
324
|
+
await this.db.completeStep(pendingStep.id, 'success', output);
|
|
325
|
+
|
|
326
|
+
// Resume the workflow
|
|
327
|
+
const path = WorkflowRegistry.resolvePath(run.workflow_name);
|
|
328
|
+
const workflow = WorkflowParser.loadWorkflow(path);
|
|
329
|
+
|
|
330
|
+
const logs: string[] = [];
|
|
331
|
+
const logger = {
|
|
332
|
+
log: (msg: string) => logs.push(msg),
|
|
333
|
+
error: (msg: string) => logs.push(`ERROR: ${msg}`),
|
|
334
|
+
warn: (msg: string) => logs.push(`WARN: ${msg}`),
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const runner = new WorkflowRunner(workflow, {
|
|
338
|
+
resumeRunId: run_id,
|
|
339
|
+
logger,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
let outputs: Record<string, unknown> | undefined;
|
|
343
|
+
try {
|
|
344
|
+
outputs = await runner.run();
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (error instanceof WorkflowSuspendedError) {
|
|
347
|
+
return {
|
|
348
|
+
jsonrpc: '2.0',
|
|
349
|
+
id,
|
|
350
|
+
result: {
|
|
351
|
+
content: [
|
|
352
|
+
{
|
|
353
|
+
type: 'text',
|
|
354
|
+
text: JSON.stringify(
|
|
355
|
+
{
|
|
356
|
+
status: 'paused',
|
|
357
|
+
run_id: runner.getRunId(),
|
|
358
|
+
message: error.message,
|
|
359
|
+
step_id: error.stepId,
|
|
360
|
+
input_type: error.inputType,
|
|
361
|
+
instructions:
|
|
362
|
+
error.inputType === 'confirm'
|
|
363
|
+
? 'Use answer_human_input with input="confirm" to proceed.'
|
|
364
|
+
: 'Use answer_human_input with the required text input.',
|
|
365
|
+
},
|
|
366
|
+
null,
|
|
367
|
+
2
|
|
368
|
+
),
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
jsonrpc: '2.0',
|
|
377
|
+
id,
|
|
378
|
+
result: {
|
|
379
|
+
isError: true,
|
|
380
|
+
content: [
|
|
381
|
+
{
|
|
382
|
+
type: 'text',
|
|
383
|
+
text: `Workflow failed after resume.\n\nLogs:\n${logs.join('\n')}`,
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
jsonrpc: '2.0',
|
|
392
|
+
id,
|
|
393
|
+
result: {
|
|
394
|
+
content: [
|
|
395
|
+
{
|
|
396
|
+
type: 'text',
|
|
397
|
+
text: JSON.stringify(
|
|
398
|
+
{
|
|
399
|
+
status: 'success',
|
|
400
|
+
outputs,
|
|
401
|
+
logs: logs.slice(-20),
|
|
402
|
+
},
|
|
403
|
+
null,
|
|
404
|
+
2
|
|
405
|
+
),
|
|
406
|
+
},
|
|
407
|
+
],
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
throw new Error(`Unknown tool: ${toolParams.name}`);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
return {
|
|
415
|
+
jsonrpc: '2.0',
|
|
416
|
+
id,
|
|
417
|
+
error: {
|
|
418
|
+
code: -32000,
|
|
419
|
+
message: error instanceof Error ? error.message : String(error),
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
default:
|
|
426
|
+
return {
|
|
427
|
+
jsonrpc: '2.0',
|
|
428
|
+
id,
|
|
429
|
+
error: {
|
|
430
|
+
code: -32601,
|
|
431
|
+
message: `Method not found: ${method}`,
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from 'bun:test';
|
|
2
|
+
import { withRetry } from './retry';
|
|
3
|
+
|
|
4
|
+
describe('withRetry', () => {
|
|
5
|
+
test('should return result if fn succeeds on first try', async () => {
|
|
6
|
+
const fn = async () => 'success';
|
|
7
|
+
const result = await withRetry(fn, { count: 3, backoff: 'linear' });
|
|
8
|
+
expect(result).toBe('success');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('should retry and succeed', async () => {
|
|
12
|
+
let attempts = 0;
|
|
13
|
+
const fn = async () => {
|
|
14
|
+
attempts++;
|
|
15
|
+
if (attempts < 3) throw new Error('fail');
|
|
16
|
+
return 'success';
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Use a small delay for tests if possible, but retry.ts has hardcoded 1000ms base delay.
|
|
20
|
+
// This test might take a few seconds.
|
|
21
|
+
const result = await withRetry(fn, { count: 3, backoff: 'linear' });
|
|
22
|
+
expect(result).toBe('success');
|
|
23
|
+
expect(attempts).toBe(3);
|
|
24
|
+
}, 10000); // 10s timeout
|
|
25
|
+
|
|
26
|
+
test('should throw after exhausting retries', async () => {
|
|
27
|
+
let attempts = 0;
|
|
28
|
+
const fn = async () => {
|
|
29
|
+
attempts++;
|
|
30
|
+
throw new Error('fail');
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
await expect(withRetry(fn, { count: 2, backoff: 'linear' })).rejects.toThrow('fail');
|
|
34
|
+
expect(attempts).toBe(3); // 1 original + 2 retries
|
|
35
|
+
}, 10000);
|
|
36
|
+
|
|
37
|
+
test('should call onRetry callback', async () => {
|
|
38
|
+
let attempts = 0;
|
|
39
|
+
const onRetry = (attempt: number, error: Error) => {
|
|
40
|
+
expect(attempt).toBeGreaterThan(0);
|
|
41
|
+
expect(error.message).toBe('fail');
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const fn = async () => {
|
|
45
|
+
attempts++;
|
|
46
|
+
if (attempts < 2) throw new Error('fail');
|
|
47
|
+
return 'success';
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
await withRetry(fn, { count: 1, backoff: 'linear' }, onRetry);
|
|
51
|
+
}, 5000);
|
|
52
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { RetryConfig } from '../parser/schema.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calculate backoff delay in milliseconds
|
|
5
|
+
*/
|
|
6
|
+
function calculateBackoff(
|
|
7
|
+
attempt: number,
|
|
8
|
+
backoff: 'linear' | 'exponential',
|
|
9
|
+
baseDelay = 1000
|
|
10
|
+
): number {
|
|
11
|
+
if (backoff === 'exponential') {
|
|
12
|
+
return baseDelay * 2 ** attempt;
|
|
13
|
+
}
|
|
14
|
+
// Linear backoff
|
|
15
|
+
return baseDelay * (attempt + 1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sleep for a given duration
|
|
20
|
+
*/
|
|
21
|
+
function sleep(ms: number): Promise<void> {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Execute a function with retry logic
|
|
27
|
+
*/
|
|
28
|
+
export async function withRetry<T>(
|
|
29
|
+
fn: () => Promise<T>,
|
|
30
|
+
retry?: RetryConfig,
|
|
31
|
+
onRetry?: (attempt: number, error: Error) => void
|
|
32
|
+
): Promise<T> {
|
|
33
|
+
const maxRetries = retry?.count || 0;
|
|
34
|
+
const backoffType = retry?.backoff || 'linear';
|
|
35
|
+
const baseDelay = retry?.baseDelay ?? 1000;
|
|
36
|
+
|
|
37
|
+
let lastError: Error | undefined;
|
|
38
|
+
|
|
39
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
40
|
+
try {
|
|
41
|
+
return await fn();
|
|
42
|
+
} catch (error) {
|
|
43
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
44
|
+
|
|
45
|
+
// Don't retry if we've exhausted attempts
|
|
46
|
+
if (attempt >= maxRetries) {
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Calculate delay and wait before retry
|
|
51
|
+
const delay = calculateBackoff(attempt, backoffType, baseDelay);
|
|
52
|
+
onRetry?.(attempt + 1, lastError);
|
|
53
|
+
await sleep(delay);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
throw lastError || new Error('Operation failed with no error details');
|
|
58
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { ExpressionContext } from '../expression/evaluator';
|
|
3
|
+
import type { ShellStep } from '../parser/schema';
|
|
4
|
+
import { escapeShellArg, executeShell } from './shell-executor';
|
|
5
|
+
|
|
6
|
+
describe('shell-executor', () => {
|
|
7
|
+
describe('escapeShellArg', () => {
|
|
8
|
+
it('should wrap in single quotes', () => {
|
|
9
|
+
expect(escapeShellArg('hello')).toBe("'hello'");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should escape single quotes', () => {
|
|
13
|
+
expect(escapeShellArg("don't")).toBe("'don'\\''t'");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('executeShell', () => {
|
|
18
|
+
const context: ExpressionContext = {
|
|
19
|
+
inputs: {},
|
|
20
|
+
steps: {},
|
|
21
|
+
env: {},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
it('should execute a simple command', async () => {
|
|
25
|
+
const step: ShellStep = {
|
|
26
|
+
id: 'test',
|
|
27
|
+
type: 'shell',
|
|
28
|
+
run: 'echo "hello world"',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const result = await executeShell(step, context);
|
|
32
|
+
expect(result.stdout.trim()).toBe('hello world');
|
|
33
|
+
expect(result.exitCode).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should evaluate expressions in the command', async () => {
|
|
37
|
+
const step: ShellStep = {
|
|
38
|
+
id: 'test',
|
|
39
|
+
type: 'shell',
|
|
40
|
+
run: 'echo "${{ inputs.name }}"',
|
|
41
|
+
};
|
|
42
|
+
const customContext: ExpressionContext = {
|
|
43
|
+
...context,
|
|
44
|
+
inputs: { name: 'world' },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const result = await executeShell(step, customContext);
|
|
48
|
+
expect(result.stdout.trim()).toBe('world');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should handle environment variables', async () => {
|
|
52
|
+
const step: ShellStep = {
|
|
53
|
+
id: 'test',
|
|
54
|
+
type: 'shell',
|
|
55
|
+
run: 'echo $TEST_VAR',
|
|
56
|
+
env: {
|
|
57
|
+
TEST_VAR: 'env-value',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const result = await executeShell(step, context);
|
|
62
|
+
expect(result.stdout.trim()).toBe('env-value');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should handle working directory', async () => {
|
|
66
|
+
const step: ShellStep = {
|
|
67
|
+
id: 'test',
|
|
68
|
+
type: 'shell',
|
|
69
|
+
run: 'pwd',
|
|
70
|
+
dir: '/tmp',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const result = await executeShell(step, context);
|
|
74
|
+
expect(result.stdout.trim()).toMatch(/\/tmp$/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should capture stderr', async () => {
|
|
78
|
+
const step: ShellStep = {
|
|
79
|
+
id: 'test',
|
|
80
|
+
type: 'shell',
|
|
81
|
+
run: 'echo "error" >&2',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const result = await executeShell(step, context);
|
|
85
|
+
expect(result.stderr.trim()).toBe('error');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should handle non-zero exit codes', async () => {
|
|
89
|
+
const step: ShellStep = {
|
|
90
|
+
id: 'test',
|
|
91
|
+
type: 'shell',
|
|
92
|
+
run: 'exit 1',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const result = await executeShell(step, context);
|
|
96
|
+
expect(result.exitCode).toBe(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should warn about shell injection risk', async () => {
|
|
100
|
+
const spy = console.warn;
|
|
101
|
+
let warned = false;
|
|
102
|
+
console.warn = (...args: unknown[]) => {
|
|
103
|
+
const msg = args[0];
|
|
104
|
+
if (
|
|
105
|
+
typeof msg === 'string' &&
|
|
106
|
+
msg.includes('WARNING: Command contains shell metacharacters')
|
|
107
|
+
) {
|
|
108
|
+
warned = true;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const step: ShellStep = {
|
|
113
|
+
id: 'test',
|
|
114
|
+
type: 'shell',
|
|
115
|
+
run: 'echo "hello" ; rm -rf /tmp/foo',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
await executeShell(step, context);
|
|
119
|
+
expect(warned).toBe(true);
|
|
120
|
+
console.warn = spy; // Restore
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|