keystone-cli 1.2.0 → 1.3.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 +65 -14
- package/package.json +1 -1
- package/src/commands/init.ts +6 -0
- package/src/db/dynamic-state-manager.test.ts +319 -0
- package/src/db/dynamic-state-manager.ts +411 -0
- package/src/db/workflow-db.ts +64 -0
- package/src/parser/schema.ts +34 -1
- package/src/parser/workflow-parser.test.ts +3 -4
- package/src/parser/workflow-parser.ts +3 -62
- package/src/runner/executors/dynamic-executor.test.ts +613 -0
- package/src/runner/executors/dynamic-executor.ts +718 -0
- package/src/runner/executors/dynamic-types.ts +69 -0
- package/src/runner/step-executor.ts +20 -0
- package/src/templates/dynamic-demo.yaml +31 -0
- package/src/templates/scaffolding/decompose-problem.yaml +1 -1
- package/src/templates/scaffolding/dynamic-decompose.yaml +39 -0
- package/src/utils/topo-sort.ts +47 -0
package/README.md
CHANGED
|
@@ -34,11 +34,12 @@ Keystone allows you to define complex automation workflows using a simple YAML s
|
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
37
|
-
##
|
|
37
|
+
## <a id="features"></a>✨ Features
|
|
38
38
|
|
|
39
39
|
- ⚡ **Local-First:** Built on Bun with a local SQLite database for state management.
|
|
40
40
|
- 🧩 **Declarative:** Define workflows in YAML with automatic dependency tracking (DAG).
|
|
41
41
|
- 🤖 **Agentic:** First-class support for LLM agents defined in Markdown with YAML frontmatter.
|
|
42
|
+
- 🎯 **Dynamic Workflows:** LLM-driven orchestration where a supervisor generates and executes steps at runtime.
|
|
42
43
|
- 🧑💻 **Human-in-the-Loop:** Support for manual approval and text input steps.
|
|
43
44
|
- 🔄 **Resilient:** Built-in retries, timeouts, and state persistence. Resume failed or paused runs exactly where they left off.
|
|
44
45
|
- 📊 **TUI Dashboard:** Built-in interactive dashboard for monitoring and managing runs.
|
|
@@ -51,7 +52,7 @@ Keystone allows you to define complex automation workflows using a simple YAML s
|
|
|
51
52
|
|
|
52
53
|
---
|
|
53
54
|
|
|
54
|
-
##
|
|
55
|
+
## <a id="installation"></a>🚀 Installation
|
|
55
56
|
|
|
56
57
|
Ensure you have [Bun](https://bun.sh) installed.
|
|
57
58
|
|
|
@@ -89,7 +90,7 @@ source <(keystone completion bash)
|
|
|
89
90
|
|
|
90
91
|
---
|
|
91
92
|
|
|
92
|
-
##
|
|
93
|
+
## <a id="quick-start"></a>🚦 Quick Start
|
|
93
94
|
|
|
94
95
|
### 1. Initialize a Project
|
|
95
96
|
```bash
|
|
@@ -130,7 +131,7 @@ keystone ui
|
|
|
130
131
|
|
|
131
132
|
---
|
|
132
133
|
|
|
133
|
-
##
|
|
134
|
+
## <a id="bundled-workflows"></a>🧰 Bundled Workflows
|
|
134
135
|
|
|
135
136
|
`keystone init` seeds these workflows under `.keystone/workflows/` (and the agents they rely on under `.keystone/workflows/agents/`):
|
|
136
137
|
|
|
@@ -143,6 +144,7 @@ Top-level workflows (seeded in `.keystone/workflows/`):
|
|
|
143
144
|
- `script-example.yaml`: Demonstrates sandboxed JavaScript execution.
|
|
144
145
|
- `artifact-example.yaml`: Demonstrates artifact upload and download between steps.
|
|
145
146
|
- `idempotency-example.yaml`: Demonstrates safe retries for side-effecting steps.
|
|
147
|
+
- `dynamic-demo.yaml`: Demonstrates LLM-driven dynamic workflow orchestration where steps are generated at runtime.
|
|
146
148
|
|
|
147
149
|
Sub-workflows (seeded in `.keystone/workflows/`):
|
|
148
150
|
- `scaffold-plan.yaml`: Generates a file plan from `requirements` input.
|
|
@@ -157,13 +159,14 @@ Example runs:
|
|
|
157
159
|
keystone run scaffold-feature
|
|
158
160
|
keystone run decompose-problem -i problem="Add caching to the API" -i context="Node/Bun service"
|
|
159
161
|
keystone run agent-handoff -i topic="billing" -i user="Ada"
|
|
162
|
+
keystone run dynamic-demo -i task="Set up a Node.js project with TypeScript"
|
|
160
163
|
```
|
|
161
164
|
|
|
162
165
|
Sub-workflows are used by the top-level workflows, but can be run directly if you want just one phase.
|
|
163
166
|
|
|
164
167
|
---
|
|
165
168
|
|
|
166
|
-
##
|
|
169
|
+
## <a id="configuration"></a>⚙️ Configuration
|
|
167
170
|
|
|
168
171
|
Keystone loads configuration from project `.keystone/config.yaml` (and user-level config; see `keystone config show` for search order) to manage model providers and model mappings.
|
|
169
172
|
|
|
@@ -358,7 +361,7 @@ Or use the `keystone auth login` command to securely store them in your local ma
|
|
|
358
361
|
|
|
359
362
|
---
|
|
360
363
|
|
|
361
|
-
##
|
|
364
|
+
## <a id="workflow-example"></a>📝 Workflow Example
|
|
362
365
|
|
|
363
366
|
Workflows are defined in YAML. Dependencies are automatically resolved based on the `needs` field, and **Keystone also automatically detects implicit dependencies** from your `${{ }}` expressions.
|
|
364
367
|
|
|
@@ -441,7 +444,7 @@ expression:
|
|
|
441
444
|
|
|
442
445
|
---
|
|
443
446
|
|
|
444
|
-
##
|
|
447
|
+
## <a id="step-types"></a>🏗️ Step Types
|
|
445
448
|
|
|
446
449
|
Keystone supports several specialized step types:
|
|
447
450
|
|
|
@@ -521,6 +524,54 @@ Keystone supports several specialized step types:
|
|
|
521
524
|
- `env` and `cwd` are required and must be explicit.
|
|
522
525
|
- `input` is sent to stdin (objects/arrays are JSON-encoded).
|
|
523
526
|
- Summary is parsed from stdout or a file at `KEYSTONE_ENGINE_SUMMARY_PATH` and stored as an artifact.
|
|
527
|
+
- `git`: Execute git operations with automatic worktree management.
|
|
528
|
+
- Operations: `clone`, `checkout`, `pull`, `push`, `commit`, `worktree_add`, `worktree_remove`.
|
|
529
|
+
- `cleanup: true` automatically removes worktrees at workflow end.
|
|
530
|
+
```yaml
|
|
531
|
+
- id: clone_repo
|
|
532
|
+
type: git
|
|
533
|
+
op: clone
|
|
534
|
+
url: https://github.com/example/repo.git
|
|
535
|
+
path: ./repo
|
|
536
|
+
branch: main
|
|
537
|
+
cleanup: true
|
|
538
|
+
```
|
|
539
|
+
- `dynamic`: LLM-driven workflow orchestration where a supervisor agent generates steps at runtime.
|
|
540
|
+
- The supervisor LLM creates a plan of steps that are then executed dynamically.
|
|
541
|
+
- Supports resumability - state is persisted after each generated step.
|
|
542
|
+
- Generated steps can be: `llm`, `shell`, `workflow`, `file`, or `request`.
|
|
543
|
+
- `goal`: High-level goal for the supervisor to accomplish (required).
|
|
544
|
+
- `context`: Additional context for planning.
|
|
545
|
+
- `prompt`: Custom supervisor prompt (overrides default).
|
|
546
|
+
- `supervisor`: Agent for planning (defaults to `keystone-architect`).
|
|
547
|
+
- `agent`: Default agent for generated LLM steps.
|
|
548
|
+
- `templates`: Role-to-agent mapping for specialized tasks.
|
|
549
|
+
- `maxSteps`: Maximum number of steps to generate.
|
|
550
|
+
- `concurrency`: Maximum number of steps to run in parallel (default: `1`).
|
|
551
|
+
- `confirmPlan`: Review and approve/modify the plan before execution (default: `false`).
|
|
552
|
+
- `maxReplans`: Number of automatic recovery attempts if the plan fails (default: `3`).
|
|
553
|
+
- `allowStepFailure`: Continue execution even if individual generated steps fail.
|
|
554
|
+
- `library`: A list of pre-defined step patterns available to the supervisor.
|
|
555
|
+
```yaml
|
|
556
|
+
- id: implement_feature
|
|
557
|
+
type: dynamic
|
|
558
|
+
goal: "Implement user authentication with JWT"
|
|
559
|
+
context: "This is a Node.js Express application"
|
|
560
|
+
agent: keystone-architect
|
|
561
|
+
templates:
|
|
562
|
+
planner: "keystone-architect"
|
|
563
|
+
developer: "software-engineer"
|
|
564
|
+
maxSteps: 10
|
|
565
|
+
allowStepFailure: false
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
#### Dynamic Orchestration vs. Rigid Pipelines
|
|
569
|
+
Traditional workflows often require complex multi-file decomposition (e.g., `decompose-problem.yaml` calling separate research, implementation, and review workflows). The `dynamic` step type replaces these rigid patterns with **Agentic Orchestration**:
|
|
570
|
+
- **Simplified Structure**: A single `dynamic` step can replace multiple nested pipelines.
|
|
571
|
+
- **Adaptive Execution**: The agent adjusts its plan based on real-time feedback and results from previous steps.
|
|
572
|
+
- **Improved Resumability**: Each sub-step generated by the agent is persisted, allowing seamless resumption even inside long-running dynamic tasks.
|
|
573
|
+
|
|
574
|
+
Use **Deterministic Workflows** (standard steps) for predictable, repeatable processes. Use **Dynamic Orchestration** for open-ended tasks where the specific steps cannot be known in advance.
|
|
524
575
|
|
|
525
576
|
### Human Steps in Non-Interactive Mode
|
|
526
577
|
If stdin is not a TTY (CI, piped input), `human` steps suspend. Resume by providing an answer via inputs using the step id and `__answer`:
|
|
@@ -726,7 +777,7 @@ Until `strategy.matrix` is wired end-to-end, use explicit `foreach` with an arra
|
|
|
726
777
|
|
|
727
778
|
---
|
|
728
779
|
|
|
729
|
-
##
|
|
780
|
+
## <a id="advanced-features"></a>🔧 Advanced Features
|
|
730
781
|
|
|
731
782
|
### Idempotency Keys
|
|
732
783
|
|
|
@@ -939,7 +990,7 @@ You can also define a workflow-level `compensate` step to handle overall cleanup
|
|
|
939
990
|
|
|
940
991
|
---
|
|
941
992
|
|
|
942
|
-
##
|
|
993
|
+
## <a id="agent-definitions"></a>🤖 Agent Definitions
|
|
943
994
|
|
|
944
995
|
Agents are defined in Markdown files with YAML frontmatter, making them easy to read and version control.
|
|
945
996
|
|
|
@@ -1123,7 +1174,7 @@ In these examples, the agent will have access to all tools provided by the MCP s
|
|
|
1123
1174
|
|
|
1124
1175
|
---
|
|
1125
1176
|
|
|
1126
|
-
##
|
|
1177
|
+
## <a id="cli-commands"></a>🛠️ CLI Commands
|
|
1127
1178
|
|
|
1128
1179
|
| Command | Description |
|
|
1129
1180
|
| :--- | :--- |
|
|
@@ -1187,7 +1238,7 @@ Input keys passed via `-i key=val` must be alphanumeric/underscore and cannot be
|
|
|
1187
1238
|
### Dry Run
|
|
1188
1239
|
`keystone run --dry-run` prints shell commands without executing them and skips non-shell steps (including human prompts). Outputs from skipped steps are empty, so conditional branches may differ from a real run.
|
|
1189
1240
|
|
|
1190
|
-
##
|
|
1241
|
+
## <a id="security"></a>🛡️ Security
|
|
1191
1242
|
|
|
1192
1243
|
### Shell Execution
|
|
1193
1244
|
Keystone blocks shell commands that match common injection/destructive patterns (like `rm -rf /` or pipes to shells). To run them, set `allowInsecure: true` on the step. Prefer `${{ escape(...) }}` when interpolating user input.
|
|
@@ -1215,7 +1266,7 @@ Request steps enforce SSRF protections and require HTTPS by default. Cross-origi
|
|
|
1215
1266
|
|
|
1216
1267
|
---
|
|
1217
1268
|
|
|
1218
|
-
##
|
|
1269
|
+
## <a id="architecture"></a>🏗️ Architecture
|
|
1219
1270
|
|
|
1220
1271
|
```mermaid
|
|
1221
1272
|
graph TD
|
|
@@ -1256,7 +1307,7 @@ graph TD
|
|
|
1256
1307
|
LLM --> MCPClient[MCP Client]
|
|
1257
1308
|
```
|
|
1258
1309
|
|
|
1259
|
-
##
|
|
1310
|
+
## <a id="project-structure"></a>📂 Project Structure
|
|
1260
1311
|
|
|
1261
1312
|
- `src/cli.ts`: CLI entry point.
|
|
1262
1313
|
- `src/db/`: SQLite persistence layer.
|
|
@@ -1271,6 +1322,6 @@ graph TD
|
|
|
1271
1322
|
|
|
1272
1323
|
---
|
|
1273
1324
|
|
|
1274
|
-
##
|
|
1325
|
+
## <a id="license"></a>📄 License
|
|
1275
1326
|
|
|
1276
1327
|
MIT
|
package/package.json
CHANGED
package/src/commands/init.ts
CHANGED
|
@@ -21,6 +21,7 @@ import fullFeatureDemo from '../templates/basics/full-feature-demo.yaml' with {
|
|
|
21
21
|
import idempotencyExample from '../templates/control-flow/idempotency-example.yaml' with {
|
|
22
22
|
type: 'text',
|
|
23
23
|
};
|
|
24
|
+
import dynamicDemo from '../templates/dynamic-demo.yaml' with { type: 'text' };
|
|
24
25
|
import artifactExample from '../templates/features/artifact-example.yaml' with { type: 'text' };
|
|
25
26
|
import scriptExample from '../templates/features/script-example.yaml' with { type: 'text' };
|
|
26
27
|
// Import templates
|
|
@@ -38,6 +39,9 @@ import decomposeReviewWorkflow from '../templates/scaffolding/decompose-review.y
|
|
|
38
39
|
type: 'text',
|
|
39
40
|
};
|
|
40
41
|
import devWorkflow from '../templates/scaffolding/dev.yaml' with { type: 'text' };
|
|
42
|
+
import dynamicDecomposeWorkflow from '../templates/scaffolding/dynamic-decompose.yaml' with {
|
|
43
|
+
type: 'text',
|
|
44
|
+
};
|
|
41
45
|
import reviewLoopWorkflow from '../templates/scaffolding/review-loop.yaml' with { type: 'text' };
|
|
42
46
|
import scaffoldWorkflow from '../templates/scaffolding/scaffold-feature.yaml' with { type: 'text' };
|
|
43
47
|
import scaffoldGenerateWorkflow from '../templates/scaffolding/scaffold-generate.yaml' with {
|
|
@@ -102,6 +106,7 @@ const SEEDS = [
|
|
|
102
106
|
{ path: '.keystone/workflows/scaffold-plan.yaml', content: scaffoldPlanWorkflow },
|
|
103
107
|
{ path: '.keystone/workflows/scaffold-generate.yaml', content: scaffoldGenerateWorkflow },
|
|
104
108
|
{ path: '.keystone/workflows/decompose-problem.yaml', content: decomposeWorkflow },
|
|
109
|
+
{ path: '.keystone/workflows/dynamic-decompose.yaml', content: dynamicDecomposeWorkflow },
|
|
105
110
|
{ path: '.keystone/workflows/decompose-research.yaml', content: decomposeResearchWorkflow },
|
|
106
111
|
{ path: '.keystone/workflows/decompose-implement.yaml', content: decomposeImplementWorkflow },
|
|
107
112
|
{ path: '.keystone/workflows/decompose-review.yaml', content: decomposeReviewWorkflow },
|
|
@@ -120,6 +125,7 @@ const SEEDS = [
|
|
|
120
125
|
{ path: '.keystone/workflows/artifact-example.yaml', content: artifactExample },
|
|
121
126
|
{ path: '.keystone/workflows/idempotency-example.yaml', content: idempotencyExample },
|
|
122
127
|
{ path: '.keystone/workflows/full-feature-demo.yaml', content: fullFeatureDemo },
|
|
128
|
+
{ path: '.keystone/workflows/dynamic-demo.yaml', content: dynamicDemo },
|
|
123
129
|
];
|
|
124
130
|
|
|
125
131
|
export function registerInitCommand(program: Command): void {
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for DynamicStateManager
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
5
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
type DynamicPlan,
|
|
9
|
+
DynamicStateManager,
|
|
10
|
+
type DynamicStepState,
|
|
11
|
+
} from './dynamic-state-manager.ts';
|
|
12
|
+
import { WorkflowDb } from './workflow-db.ts';
|
|
13
|
+
|
|
14
|
+
describe('DynamicStateManager', () => {
|
|
15
|
+
let db: WorkflowDb;
|
|
16
|
+
let stateManager: DynamicStateManager;
|
|
17
|
+
const testDir = join(import.meta.dir, '.test-dynamic-state');
|
|
18
|
+
const testDbPath = join(testDir, 'test.db');
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
// Clean up any existing test db
|
|
22
|
+
if (existsSync(testDir)) {
|
|
23
|
+
rmSync(testDir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
mkdirSync(testDir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
db = new WorkflowDb(testDbPath);
|
|
28
|
+
stateManager = new DynamicStateManager(db);
|
|
29
|
+
|
|
30
|
+
// Create a workflow run for foreign key constraint
|
|
31
|
+
await db.createRun('test-run-1', 'test-workflow', { input: 'value' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
db.close();
|
|
36
|
+
if (existsSync(testDir)) {
|
|
37
|
+
rmSync(testDir, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('create', () => {
|
|
42
|
+
it('should create a new dynamic state', async () => {
|
|
43
|
+
const state = await stateManager.create({
|
|
44
|
+
runId: 'test-run-1',
|
|
45
|
+
stepId: 'dynamic-step-1',
|
|
46
|
+
workflowId: 'wf-123',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(state.id).toBeDefined();
|
|
50
|
+
expect(state.runId).toBe('test-run-1');
|
|
51
|
+
expect(state.stepId).toBe('dynamic-step-1');
|
|
52
|
+
expect(state.workflowId).toBe('wf-123');
|
|
53
|
+
expect(state.status).toBe('planning');
|
|
54
|
+
expect(state.generatedPlan.steps).toEqual([]);
|
|
55
|
+
expect(state.currentStepIndex).toBe(0);
|
|
56
|
+
expect(state.startedAt).toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should create state defaulting workflowId to runId', async () => {
|
|
60
|
+
const state = await stateManager.create({
|
|
61
|
+
runId: 'test-run-1',
|
|
62
|
+
stepId: 'dynamic-step-2',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(state.workflowId).toBe('test-run-1');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('load', () => {
|
|
70
|
+
it('should load existing state', async () => {
|
|
71
|
+
const created = await stateManager.create({
|
|
72
|
+
runId: 'test-run-1',
|
|
73
|
+
stepId: 'dynamic-step-1',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const loaded = await stateManager.load('test-run-1', 'dynamic-step-1');
|
|
77
|
+
|
|
78
|
+
expect(loaded).not.toBeNull();
|
|
79
|
+
expect(loaded?.id).toBe(created.id);
|
|
80
|
+
expect(loaded?.status).toBe('planning');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should return null for non-existent state', async () => {
|
|
84
|
+
const loaded = await stateManager.load('test-run-1', 'non-existent');
|
|
85
|
+
expect(loaded).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('loadById', () => {
|
|
90
|
+
it('should load state by ID', async () => {
|
|
91
|
+
const created = await stateManager.create({
|
|
92
|
+
runId: 'test-run-1',
|
|
93
|
+
stepId: 'dynamic-step-1',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!created.id) throw new Error('ID missing');
|
|
97
|
+
const loaded = await stateManager.loadById(created.id);
|
|
98
|
+
|
|
99
|
+
expect(loaded).not.toBeNull();
|
|
100
|
+
expect(loaded?.id).toBe(created.id);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('setPlan', () => {
|
|
105
|
+
it('should set the plan and create step executions', async () => {
|
|
106
|
+
const state = await stateManager.create({
|
|
107
|
+
runId: 'test-run-1',
|
|
108
|
+
stepId: 'dynamic-step-1',
|
|
109
|
+
});
|
|
110
|
+
if (!state.id) throw new Error('State ID missing');
|
|
111
|
+
|
|
112
|
+
const plan: DynamicPlan = {
|
|
113
|
+
steps: [
|
|
114
|
+
{ id: 'step1', name: 'First step', type: 'shell', run: 'echo hello' },
|
|
115
|
+
{
|
|
116
|
+
id: 'step2',
|
|
117
|
+
name: 'Second step',
|
|
118
|
+
type: 'llm',
|
|
119
|
+
agent: 'test',
|
|
120
|
+
prompt: 'do something',
|
|
121
|
+
needs: ['step1'],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
notes: 'Test plan',
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
await stateManager.setPlan(state.id, plan);
|
|
128
|
+
|
|
129
|
+
// Verify state was updated
|
|
130
|
+
const loaded = await stateManager.loadById(state.id);
|
|
131
|
+
expect(loaded?.status).toBe('executing');
|
|
132
|
+
expect(loaded?.generatedPlan.steps.length).toBe(2);
|
|
133
|
+
expect(loaded?.generatedPlan.notes).toBe('Test plan');
|
|
134
|
+
|
|
135
|
+
// Verify step executions were created
|
|
136
|
+
const executions = await stateManager.getStepExecutions(state.id);
|
|
137
|
+
expect(executions.length).toBe(2);
|
|
138
|
+
expect(executions[0].stepId).toBe('step1');
|
|
139
|
+
expect(executions[0].status).toBe('pending');
|
|
140
|
+
expect(executions[0].executionOrder).toBe(0);
|
|
141
|
+
expect(executions[1].stepId).toBe('step2');
|
|
142
|
+
expect(executions[1].executionOrder).toBe(1);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('updateProgress', () => {
|
|
147
|
+
it('should update the current step index', async () => {
|
|
148
|
+
const state = await stateManager.create({
|
|
149
|
+
runId: 'test-run-1',
|
|
150
|
+
stepId: 'dynamic-step-1',
|
|
151
|
+
});
|
|
152
|
+
if (!state.id) throw new Error('State ID missing');
|
|
153
|
+
|
|
154
|
+
await stateManager.updateProgress(state.id, 3);
|
|
155
|
+
|
|
156
|
+
const loaded = await stateManager.loadById(state.id);
|
|
157
|
+
expect(loaded?.currentStepIndex).toBe(3);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('startStep and completeStep', () => {
|
|
162
|
+
it('should track step execution lifecycle', async () => {
|
|
163
|
+
const state = await stateManager.create({
|
|
164
|
+
runId: 'test-run-1',
|
|
165
|
+
stepId: 'dynamic-step-1',
|
|
166
|
+
});
|
|
167
|
+
if (!state.id) throw new Error('State ID missing');
|
|
168
|
+
|
|
169
|
+
const plan: DynamicPlan = {
|
|
170
|
+
steps: [{ id: 'step1', name: 'First step', type: 'shell', run: 'echo hello' }],
|
|
171
|
+
};
|
|
172
|
+
await stateManager.setPlan(state.id, plan);
|
|
173
|
+
|
|
174
|
+
// Start the step
|
|
175
|
+
await stateManager.startStep(state.id, 'step1');
|
|
176
|
+
|
|
177
|
+
let executions = await stateManager.getStepExecutions(state.id);
|
|
178
|
+
expect(executions[0].status).toBe('running');
|
|
179
|
+
expect(executions[0].startedAt).toBeDefined();
|
|
180
|
+
|
|
181
|
+
// Complete the step
|
|
182
|
+
await stateManager.completeStep(state.id, 'step1', {
|
|
183
|
+
status: 'success',
|
|
184
|
+
output: { result: 'hello' },
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
executions = await stateManager.getStepExecutions(state.id);
|
|
188
|
+
expect(executions[0].status).toBe('success');
|
|
189
|
+
expect(executions[0].output).toEqual({ result: 'hello' });
|
|
190
|
+
expect(executions[0].completedAt).toBeDefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should handle failed steps', async () => {
|
|
194
|
+
const state = await stateManager.create({
|
|
195
|
+
runId: 'test-run-1',
|
|
196
|
+
stepId: 'dynamic-step-1',
|
|
197
|
+
});
|
|
198
|
+
if (!state.id) throw new Error('State ID missing');
|
|
199
|
+
|
|
200
|
+
const plan: DynamicPlan = {
|
|
201
|
+
steps: [{ id: 'step1', name: 'First step', type: 'shell', run: 'exit 1' }],
|
|
202
|
+
};
|
|
203
|
+
await stateManager.setPlan(state.id, plan);
|
|
204
|
+
await stateManager.startStep(state.id, 'step1');
|
|
205
|
+
|
|
206
|
+
await stateManager.completeStep(state.id, 'step1', {
|
|
207
|
+
status: 'failed',
|
|
208
|
+
error: 'Command exited with code 1',
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const executions = await stateManager.getStepExecutions(state.id);
|
|
212
|
+
expect(executions[0].status).toBe('failed');
|
|
213
|
+
expect(executions[0].error).toBe('Command exited with code 1');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('finish', () => {
|
|
218
|
+
it('should mark state as completed', async () => {
|
|
219
|
+
const state = await stateManager.create({
|
|
220
|
+
runId: 'test-run-1',
|
|
221
|
+
stepId: 'dynamic-step-1',
|
|
222
|
+
});
|
|
223
|
+
if (!state.id) throw new Error('State ID missing');
|
|
224
|
+
|
|
225
|
+
await stateManager.finish(state.id, 'completed');
|
|
226
|
+
|
|
227
|
+
const loaded = await stateManager.loadById(state.id);
|
|
228
|
+
expect(loaded?.status).toBe('completed');
|
|
229
|
+
expect(loaded?.completedAt).toBeDefined();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should mark state as failed with error', async () => {
|
|
233
|
+
const state = await stateManager.create({
|
|
234
|
+
runId: 'test-run-1',
|
|
235
|
+
stepId: 'dynamic-step-1',
|
|
236
|
+
});
|
|
237
|
+
if (!state.id) throw new Error('State ID missing');
|
|
238
|
+
|
|
239
|
+
await stateManager.finish(state.id, 'failed', 'Something went wrong');
|
|
240
|
+
|
|
241
|
+
const loaded = await stateManager.loadById(state.id);
|
|
242
|
+
expect(loaded?.status).toBe('failed');
|
|
243
|
+
expect(loaded?.error).toBe('Something went wrong');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('getStepResultsMap', () => {
|
|
248
|
+
it('should return completed steps as a map', async () => {
|
|
249
|
+
const state = await stateManager.create({
|
|
250
|
+
runId: 'test-run-1',
|
|
251
|
+
stepId: 'dynamic-step-1',
|
|
252
|
+
});
|
|
253
|
+
if (!state.id) throw new Error('State ID missing');
|
|
254
|
+
|
|
255
|
+
const plan: DynamicPlan = {
|
|
256
|
+
steps: [
|
|
257
|
+
{ id: 'step1', name: 'First', type: 'shell', run: 'echo 1' },
|
|
258
|
+
{ id: 'step2', name: 'Second', type: 'shell', run: 'echo 2' },
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
await stateManager.setPlan(state.id, plan);
|
|
262
|
+
|
|
263
|
+
// Complete first step
|
|
264
|
+
await stateManager.startStep(state.id, 'step1');
|
|
265
|
+
await stateManager.completeStep(state.id, 'step1', {
|
|
266
|
+
status: 'success',
|
|
267
|
+
output: { value: 1 },
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const resultsMap = await stateManager.getStepResultsMap(state.id);
|
|
271
|
+
|
|
272
|
+
expect(resultsMap.size).toBe(1); // Only completed steps
|
|
273
|
+
expect(resultsMap.get('step1')).toEqual({
|
|
274
|
+
output: { value: 1 },
|
|
275
|
+
status: 'success',
|
|
276
|
+
error: undefined,
|
|
277
|
+
});
|
|
278
|
+
expect(resultsMap.has('step2')).toBe(false); // Still pending
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('listActive', () => {
|
|
283
|
+
it('should list active states', async () => {
|
|
284
|
+
await stateManager.create({
|
|
285
|
+
runId: 'test-run-1',
|
|
286
|
+
stepId: 'step-1',
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const state2 = await stateManager.create({
|
|
290
|
+
runId: 'test-run-1',
|
|
291
|
+
stepId: 'step-2',
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Complete one
|
|
295
|
+
if (!state2.id) throw new Error('State ID missing');
|
|
296
|
+
await stateManager.finish(state2.id, 'completed');
|
|
297
|
+
|
|
298
|
+
const active = await stateManager.listActive();
|
|
299
|
+
expect(active.length).toBe(1);
|
|
300
|
+
expect(active[0].stepId).toBe('step-1');
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('listByRun', () => {
|
|
305
|
+
it('should list states for a run', async () => {
|
|
306
|
+
await stateManager.create({
|
|
307
|
+
runId: 'test-run-1',
|
|
308
|
+
stepId: 'step-1',
|
|
309
|
+
});
|
|
310
|
+
await stateManager.create({
|
|
311
|
+
runId: 'test-run-1',
|
|
312
|
+
stepId: 'step-2',
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const states = await stateManager.listByRun('test-run-1');
|
|
316
|
+
expect(states.length).toBe(2);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
});
|