keystone-cli 0.7.1 → 0.8.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 +25 -18
- package/package.json +1 -1
- package/src/cli.ts +33 -2
- package/src/db/workflow-db.ts +15 -0
- package/src/runner/standard-tools-execution.test.ts +39 -0
- package/src/runner/standard-tools.ts +77 -69
- package/src/runner/step-executor.ts +7 -0
- package/src/runner/workflow-runner.test.ts +57 -0
- package/src/runner/workflow-runner.ts +13 -3
package/README.md
CHANGED
|
@@ -372,6 +372,16 @@ You are a technical communications expert. Your goal is to take technical output
|
|
|
372
372
|
|
|
373
373
|
Agents can be equipped with tools, which are essentially workflow steps they can choose to execute. You can define tools in the agent definition, or directly in an LLM step within a workflow.
|
|
374
374
|
|
|
375
|
+
Keystone comes with a set of **Standard Tools** that can be enabled for any agent by setting `useStandardTools: true` in the step definition:
|
|
376
|
+
|
|
377
|
+
- `read_file`: Read the contents of a file (arguments: `path`)
|
|
378
|
+
- `read_file_lines`: Read a specific range of lines from a file (arguments: `path`, `start`, `count`)
|
|
379
|
+
- `write_file`: Write or overwrite a file (arguments: `path`, `content`)
|
|
380
|
+
- `list_files`: List files in a directory (arguments: `path`)
|
|
381
|
+
- `search_files`: Search for files by glob pattern (arguments: `pattern`, `dir`)
|
|
382
|
+
- `search_content`: Search for string or regex within files (arguments: `query`, `dir`, `pattern`)
|
|
383
|
+
- `run_command`: Run a shell command (arguments: `command`, `dir`). Requires `allowInsecure: true` on the step unless whitelisted.
|
|
384
|
+
|
|
375
385
|
Tool arguments are passed to the tool's execution step via the `args` variable.
|
|
376
386
|
|
|
377
387
|
**`.keystone/workflows/agents/developer.md`**
|
|
@@ -379,28 +389,25 @@ Tool arguments are passed to the tool's execution step via the `args` variable.
|
|
|
379
389
|
---
|
|
380
390
|
name: developer
|
|
381
391
|
tools:
|
|
382
|
-
- name:
|
|
383
|
-
description:
|
|
392
|
+
- name: custom_tool
|
|
393
|
+
description: A custom tool definition
|
|
384
394
|
execution:
|
|
385
|
-
id: list-files-tool
|
|
386
395
|
type: shell
|
|
387
|
-
run:
|
|
388
|
-
- name: read_file
|
|
389
|
-
description: Read a specific file
|
|
390
|
-
parameters:
|
|
391
|
-
type: object
|
|
392
|
-
properties:
|
|
393
|
-
path: { type: string }
|
|
394
|
-
required: [path]
|
|
395
|
-
execution:
|
|
396
|
-
id: read-file-tool
|
|
397
|
-
type: file
|
|
398
|
-
op: read
|
|
399
|
-
path: ${{ args.path }}
|
|
396
|
+
run: echo "custom"
|
|
400
397
|
---
|
|
401
398
|
You are a software developer. You can use tools to explore the codebase.
|
|
402
399
|
```
|
|
403
400
|
|
|
401
|
+
To enable standard tools in a workflow step:
|
|
402
|
+
|
|
403
|
+
```yaml
|
|
404
|
+
- id: explore
|
|
405
|
+
type: llm
|
|
406
|
+
agent: developer
|
|
407
|
+
useStandardTools: true
|
|
408
|
+
prompt: "Explore the src directory"
|
|
409
|
+
```
|
|
410
|
+
|
|
404
411
|
### Keystone as an MCP Server
|
|
405
412
|
|
|
406
413
|
Keystone can itself act as an MCP server, allowing other agents (like Claude Desktop or GitHub Copilot) to discover and run your workflows as tools.
|
|
@@ -480,9 +487,9 @@ In these examples, the agent will have access to all tools provided by the MCP s
|
|
|
480
487
|
| Command | Description |
|
|
481
488
|
| :--- | :--- |
|
|
482
489
|
| `init` | Initialize a new Keystone project |
|
|
483
|
-
| `run <workflow>` | Execute a workflow (use `-i key=val`
|
|
490
|
+
| `run <workflow>` | Execute a workflow (use `-i key=val`, `--resume` to auto-resume, `--dry-run`, `--debug`) |
|
|
484
491
|
| `optimize <workflow>` | Optimize a specific step in a workflow (requires --target) |
|
|
485
|
-
| `resume <run_id>` | Resume a failed
|
|
492
|
+
| `resume <run_id>` | Resume a failed/paused/crashed workflow by ID |
|
|
486
493
|
| `validate [path]` | Check workflow files for errors |
|
|
487
494
|
| `workflows` | List available workflows |
|
|
488
495
|
| `history` | Show recent workflow runs |
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -228,7 +228,8 @@ program
|
|
|
228
228
|
.option('-i, --input <key=value...>', 'Input values')
|
|
229
229
|
.option('--dry-run', 'Show what would be executed without actually running it')
|
|
230
230
|
.option('--debug', 'Enable interactive debug mode on failure')
|
|
231
|
-
.
|
|
231
|
+
.option('--resume', 'Resume the last run of this workflow if it failed or was paused')
|
|
232
|
+
.action(async (workflowPathArg, options) => {
|
|
232
233
|
// Parse inputs
|
|
233
234
|
const inputs: Record<string, unknown> = {};
|
|
234
235
|
if (options.input) {
|
|
@@ -249,17 +250,47 @@ program
|
|
|
249
250
|
|
|
250
251
|
// Load and validate workflow
|
|
251
252
|
try {
|
|
252
|
-
const resolvedPath = WorkflowRegistry.resolvePath(
|
|
253
|
+
const resolvedPath = WorkflowRegistry.resolvePath(workflowPathArg);
|
|
253
254
|
const workflow = WorkflowParser.loadWorkflow(resolvedPath);
|
|
254
255
|
|
|
255
256
|
// Import WorkflowRunner dynamically
|
|
256
257
|
const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
|
|
257
258
|
const logger = new ConsoleLogger();
|
|
259
|
+
|
|
260
|
+
let resumeRunId: string | undefined;
|
|
261
|
+
|
|
262
|
+
// Handle auto-resume
|
|
263
|
+
if (options.resume) {
|
|
264
|
+
const db = new WorkflowDb();
|
|
265
|
+
const lastRun = await db.getLastRun(workflow.name);
|
|
266
|
+
db.close();
|
|
267
|
+
|
|
268
|
+
if (lastRun) {
|
|
269
|
+
if (
|
|
270
|
+
lastRun.status === 'failed' ||
|
|
271
|
+
lastRun.status === 'paused' ||
|
|
272
|
+
lastRun.status === 'running'
|
|
273
|
+
) {
|
|
274
|
+
resumeRunId = lastRun.id;
|
|
275
|
+
console.log(
|
|
276
|
+
`Resuming run ${lastRun.id} (status: ${lastRun.status}) from ${new Date(
|
|
277
|
+
lastRun.started_at
|
|
278
|
+
).toLocaleString()}`
|
|
279
|
+
);
|
|
280
|
+
} else {
|
|
281
|
+
console.log(`Last run ${lastRun.id} completed successfully. Starting new run.`);
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
console.log('No previous run found. Starting new run.');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
258
288
|
const runner = new WorkflowRunner(workflow, {
|
|
259
289
|
inputs,
|
|
260
290
|
workflowDir: dirname(resolvedPath),
|
|
261
291
|
dryRun: !!options.dryRun,
|
|
262
292
|
debug: !!options.debug,
|
|
293
|
+
resumeRunId,
|
|
263
294
|
logger,
|
|
264
295
|
});
|
|
265
296
|
|
package/src/db/workflow-db.ts
CHANGED
|
@@ -342,6 +342,21 @@ export class WorkflowDb {
|
|
|
342
342
|
});
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Get the most recent run for a specific workflow
|
|
347
|
+
*/
|
|
348
|
+
async getLastRun(workflowName: string): Promise<WorkflowRun | null> {
|
|
349
|
+
return this.withRetry(() => {
|
|
350
|
+
const stmt = this.db.prepare(`
|
|
351
|
+
SELECT * FROM workflow_runs
|
|
352
|
+
WHERE workflow_name = ?
|
|
353
|
+
ORDER BY started_at DESC
|
|
354
|
+
LIMIT 1
|
|
355
|
+
`);
|
|
356
|
+
return stmt.get(workflowName) as WorkflowRun | null;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
345
360
|
close(): void {
|
|
346
361
|
this.db.close();
|
|
347
362
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import * as vm from 'node:vm';
|
|
3
|
+
import { STANDARD_TOOLS } from './standard-tools';
|
|
4
|
+
|
|
5
|
+
describe('Standard Tools Execution Verification', () => {
|
|
6
|
+
const scriptTools = STANDARD_TOOLS.filter(
|
|
7
|
+
(t) => t.execution && t.execution.type === 'script' && typeof t.execution.run === 'string'
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
for (const tool of scriptTools) {
|
|
11
|
+
it(`should compile and execute ${tool.name} without SyntaxError`, () => {
|
|
12
|
+
const script = tool.execution.run as string;
|
|
13
|
+
const sandbox = {
|
|
14
|
+
args: { path: '.', pattern: '*', query: 'test' },
|
|
15
|
+
require: (mod: string) => {
|
|
16
|
+
if (mod === 'node:fs' || mod === 'fs') {
|
|
17
|
+
return {
|
|
18
|
+
existsSync: () => true,
|
|
19
|
+
readdirSync: () => [],
|
|
20
|
+
statSync: () => ({ size: 0 }),
|
|
21
|
+
readFileSync: () => '',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (mod === 'node:path' || mod === 'path') {
|
|
25
|
+
return { join: (...args: string[]) => args.join('/') };
|
|
26
|
+
}
|
|
27
|
+
if (mod === 'glob') {
|
|
28
|
+
return { globSync: () => [] };
|
|
29
|
+
}
|
|
30
|
+
return {};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
expect(() => {
|
|
35
|
+
vm.runInNewContext(script, sandbox);
|
|
36
|
+
}).not.toThrow();
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
});
|
|
@@ -38,19 +38,21 @@ export const STANDARD_TOOLS: AgentTool[] = [
|
|
|
38
38
|
id: 'std_read_file_lines',
|
|
39
39
|
type: 'script',
|
|
40
40
|
run: `
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
41
|
+
(function() {
|
|
42
|
+
const fs = require('node:fs');
|
|
43
|
+
const path = require('node:path');
|
|
44
|
+
const filePath = args.path;
|
|
45
|
+
const start = args.start || 1;
|
|
46
|
+
const count = args.count || 100;
|
|
47
|
+
|
|
48
|
+
if (!fs.existsSync(filePath)) {
|
|
49
|
+
throw new Error('File not found: ' + filePath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
53
|
+
const lines = content.split('\\n');
|
|
54
|
+
return lines.slice(start - 1, start - 1 + count).join('\\n');
|
|
55
|
+
})();
|
|
54
56
|
`,
|
|
55
57
|
allowInsecure: true,
|
|
56
58
|
},
|
|
@@ -91,18 +93,20 @@ export const STANDARD_TOOLS: AgentTool[] = [
|
|
|
91
93
|
id: 'std_list_files',
|
|
92
94
|
type: 'script',
|
|
93
95
|
run: `
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
96
|
+
(function() {
|
|
97
|
+
const fs = require('node:fs');
|
|
98
|
+
const path = require('node:path');
|
|
99
|
+
const dir = args.path || '.';
|
|
100
|
+
if (fs.existsSync(dir)) {
|
|
101
|
+
const files = fs.readdirSync(dir, { withFileTypes: true });
|
|
102
|
+
return files.map(f => ({
|
|
103
|
+
name: f.name,
|
|
104
|
+
type: f.isDirectory() ? 'directory' : 'file',
|
|
105
|
+
size: f.isFile() ? fs.statSync(path.join(dir, f.name)).size : undefined
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
throw new Error('Directory not found: ' + dir);
|
|
109
|
+
})();
|
|
106
110
|
`,
|
|
107
111
|
allowInsecure: true,
|
|
108
112
|
},
|
|
@@ -122,16 +126,18 @@ export const STANDARD_TOOLS: AgentTool[] = [
|
|
|
122
126
|
id: 'std_search_files',
|
|
123
127
|
type: 'script',
|
|
124
128
|
run: `
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
129
|
+
(function() {
|
|
130
|
+
const fs = require('node:fs');
|
|
131
|
+
const path = require('node:path');
|
|
132
|
+
const { globSync } = require('glob');
|
|
133
|
+
const dir = args.dir || '.';
|
|
134
|
+
const pattern = args.pattern;
|
|
135
|
+
try {
|
|
136
|
+
return globSync(pattern, { cwd: dir, nodir: true });
|
|
137
|
+
} catch (e) {
|
|
138
|
+
throw new Error('Search failed: ' + e.message);
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
135
141
|
`,
|
|
136
142
|
allowInsecure: true,
|
|
137
143
|
},
|
|
@@ -156,42 +162,44 @@ export const STANDARD_TOOLS: AgentTool[] = [
|
|
|
156
162
|
id: 'std_search_content',
|
|
157
163
|
type: 'script',
|
|
158
164
|
run: `
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
165
|
+
(function() {
|
|
166
|
+
const fs = require('node:fs');
|
|
167
|
+
const path = require('node:path');
|
|
168
|
+
const { globSync } = require('glob');
|
|
169
|
+
const dir = args.dir || '.';
|
|
170
|
+
const pattern = args.pattern || '**/*';
|
|
171
|
+
const query = args.query;
|
|
172
|
+
if (query.length > 500) {
|
|
173
|
+
throw new Error('Search query exceeds maximum length of 500 characters');
|
|
174
|
+
}
|
|
175
|
+
const isRegex = query.startsWith('/') && query.endsWith('/');
|
|
176
|
+
let regex;
|
|
177
|
+
try {
|
|
178
|
+
regex = isRegex ? new RegExp(query.slice(1, -1)) : new RegExp(query.replace(/[.*+?^$\\{}()|[\\]\\\\]/g, '\\\\$&'), 'i');
|
|
179
|
+
} catch (e) {
|
|
180
|
+
throw new Error('Invalid regular expression: ' + e.message);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const files = globSync(pattern, { cwd: dir, nodir: true });
|
|
184
|
+
const results = [];
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
const fullPath = path.join(dir, file);
|
|
187
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
188
|
+
const lines = content.split('\\n');
|
|
189
|
+
for (let i = 0; i < lines.length; i++) {
|
|
190
|
+
if (regex.test(lines[i])) {
|
|
191
|
+
results.push({
|
|
192
|
+
file,
|
|
193
|
+
line: i + 1,
|
|
194
|
+
content: lines[i].trim()
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (results.length > 100) break; // Limit results
|
|
189
198
|
}
|
|
190
|
-
if (results.length > 100) break;
|
|
199
|
+
if (results.length > 100) break;
|
|
191
200
|
}
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
return results;
|
|
201
|
+
return results;
|
|
202
|
+
})();
|
|
195
203
|
`,
|
|
196
204
|
allowInsecure: true,
|
|
197
205
|
},
|
|
@@ -18,6 +18,7 @@ import { getAdapter } from './llm-adapter.ts';
|
|
|
18
18
|
import { detectShellInjectionRisk, executeShell } from './shell-executor.ts';
|
|
19
19
|
|
|
20
20
|
import * as fs from 'node:fs';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
21
22
|
import * as os from 'node:os';
|
|
22
23
|
import * as path from 'node:path';
|
|
23
24
|
import * as readline from 'node:readline/promises';
|
|
@@ -543,6 +544,8 @@ async function executeScriptStep(
|
|
|
543
544
|
);
|
|
544
545
|
}
|
|
545
546
|
|
|
547
|
+
const requireFn = createRequire(import.meta.url);
|
|
548
|
+
|
|
546
549
|
const result = await sandbox.execute(
|
|
547
550
|
step.run,
|
|
548
551
|
{
|
|
@@ -550,6 +553,10 @@ async function executeScriptStep(
|
|
|
550
553
|
secrets: context.secrets,
|
|
551
554
|
steps: context.steps,
|
|
552
555
|
env: context.env,
|
|
556
|
+
// biome-ignore lint/suspicious/noExplicitAny: args is dynamic
|
|
557
|
+
args: (context as any).args,
|
|
558
|
+
require: requireFn,
|
|
559
|
+
console,
|
|
553
560
|
},
|
|
554
561
|
{
|
|
555
562
|
timeout: step.timeout,
|
|
@@ -115,6 +115,8 @@ describe('WorkflowRunner', () => {
|
|
|
115
115
|
},
|
|
116
116
|
error: (msg: string) => console.error(msg),
|
|
117
117
|
warn: (msg: string) => console.warn(msg),
|
|
118
|
+
info: (msg: string) => {},
|
|
119
|
+
debug: (msg: string) => {},
|
|
118
120
|
};
|
|
119
121
|
|
|
120
122
|
const finallyWorkflow: Workflow = {
|
|
@@ -487,4 +489,59 @@ describe('WorkflowRunner', () => {
|
|
|
487
489
|
|
|
488
490
|
if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
|
|
489
491
|
});
|
|
492
|
+
|
|
493
|
+
it('should resume a workflow marked as running (crashed process)', async () => {
|
|
494
|
+
const resumeDbPath = 'test-running-resume.db';
|
|
495
|
+
if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
|
|
496
|
+
|
|
497
|
+
const workflow: Workflow = {
|
|
498
|
+
name: 'running-wf',
|
|
499
|
+
steps: [
|
|
500
|
+
{ id: 's1', type: 'shell', run: 'echo "one"', needs: [] },
|
|
501
|
+
{ id: 's2', type: 'shell', run: 'echo "two"', needs: ['s1'] },
|
|
502
|
+
],
|
|
503
|
+
outputs: {
|
|
504
|
+
out: '${{ steps.s1.output.stdout.trim() }}-${{ steps.s2.output.stdout.trim() }}',
|
|
505
|
+
},
|
|
506
|
+
} as unknown as Workflow;
|
|
507
|
+
|
|
508
|
+
// Manually create a "running" state in the DB
|
|
509
|
+
const db = new WorkflowDb(resumeDbPath);
|
|
510
|
+
const runId = crypto.randomUUID();
|
|
511
|
+
await db.createRun(runId, workflow.name, {});
|
|
512
|
+
await db.updateRunStatus(runId, 'running');
|
|
513
|
+
|
|
514
|
+
// Create a completed step 1
|
|
515
|
+
const step1Id = crypto.randomUUID();
|
|
516
|
+
await db.createStep(step1Id, runId, 's1');
|
|
517
|
+
await db.completeStep(step1Id, 'success', { stdout: 'one\n', stderr: '', exitCode: 0 });
|
|
518
|
+
db.close();
|
|
519
|
+
|
|
520
|
+
// Verify warnings
|
|
521
|
+
let warningLogged = false;
|
|
522
|
+
const logger = {
|
|
523
|
+
log: () => {},
|
|
524
|
+
error: () => {},
|
|
525
|
+
warn: (msg: string) => {
|
|
526
|
+
if (msg.includes("Resuming a run marked as 'running'")) {
|
|
527
|
+
warningLogged = true;
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
info: () => {},
|
|
531
|
+
debug: () => {},
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const runner = new WorkflowRunner(workflow, {
|
|
535
|
+
dbPath: resumeDbPath,
|
|
536
|
+
resumeRunId: runId,
|
|
537
|
+
// @ts-ignore
|
|
538
|
+
logger: logger,
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const outputs = await runner.run();
|
|
542
|
+
expect(outputs.out).toBe('one-two');
|
|
543
|
+
expect(warningLogged).toBe(true);
|
|
544
|
+
|
|
545
|
+
if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
|
|
546
|
+
});
|
|
490
547
|
});
|
|
@@ -161,10 +161,20 @@ export class WorkflowRunner {
|
|
|
161
161
|
throw new Error(`Run ${this.runId} not found`);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
// Only allow resuming failed or
|
|
165
|
-
if (
|
|
164
|
+
// Only allow resuming failed, paused, or running (crash recovery) runs
|
|
165
|
+
if (
|
|
166
|
+
run.status !== WorkflowStatus.FAILED &&
|
|
167
|
+
run.status !== WorkflowStatus.PAUSED &&
|
|
168
|
+
run.status !== WorkflowStatus.RUNNING
|
|
169
|
+
) {
|
|
166
170
|
throw new Error(
|
|
167
|
-
`Cannot resume run with status '${run.status}'. Only 'failed' or '
|
|
171
|
+
`Cannot resume run with status '${run.status}'. Only 'failed', 'paused', or 'running' runs can be resumed.`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (run.status === WorkflowStatus.RUNNING) {
|
|
176
|
+
this.logger.warn(
|
|
177
|
+
`⚠️ Resuming a run marked as 'running'. This usually means the previous process crashed or was killed forcefully. Ensure no other instances are running.`
|
|
168
178
|
);
|
|
169
179
|
}
|
|
170
180
|
|