@tagma/sdk 0.1.3 → 0.1.5
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 +245 -139
- package/package.json +4 -4
- package/src/adapters/stdin-approval.ts +117 -117
- package/src/adapters/websocket-approval.ts +144 -144
- package/src/completions/exit-code.ts +19 -19
- package/src/completions/file-exists.ts +39 -39
- package/src/completions/output-check.ts +57 -57
- package/src/config-ops.ts +183 -0
- package/src/dag.ts +137 -137
- package/src/drivers/claude-code.ts +207 -207
- package/src/engine.ts +698 -598
- package/src/hooks.ts +138 -138
- package/src/logger.ts +107 -100
- package/src/middlewares/static-context.ts +29 -29
- package/src/pipeline-runner.ts +113 -0
- package/src/registry.ts +1 -3
- package/src/runner.ts +195 -193
- package/src/schema.ts +358 -260
- package/src/sdk.ts +25 -3
- package/src/triggers/file.ts +94 -94
- package/src/triggers/manual.ts +61 -61
- package/src/utils.ts +147 -147
- package/src/validate-raw.ts +199 -0
package/README.md
CHANGED
|
@@ -1,139 +1,245 @@
|
|
|
1
|
-
# @tagma/sdk
|
|
2
|
-
|
|
3
|
-
A local AI task orchestration SDK for [Bun](https://bun.sh). Define multi-track pipelines in YAML, run AI coding agents (Claude Code, Codex, OpenCode) and shell commands in parallel with dependency resolution, approval gates, and lifecycle hooks.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
bun add @tagma/sdk
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Quick Start
|
|
12
|
-
|
|
13
|
-
**1. Define a pipeline** (`pipeline.yaml`)
|
|
14
|
-
|
|
15
|
-
```yaml
|
|
16
|
-
pipeline:
|
|
17
|
-
name: build-and-test
|
|
18
|
-
tracks:
|
|
19
|
-
- id: backend
|
|
20
|
-
name: Backend
|
|
21
|
-
driver: claude-code
|
|
22
|
-
permissions: { read: true, write: true, execute: false }
|
|
23
|
-
tasks:
|
|
24
|
-
- id: implement
|
|
25
|
-
name: Implement feature
|
|
26
|
-
prompt: "Add a /health endpoint to src/server.ts"
|
|
27
|
-
output: ./output/implement.txt
|
|
28
|
-
- id: test
|
|
29
|
-
name: Run tests
|
|
30
|
-
command: "bun test"
|
|
31
|
-
depends_on: [implement]
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
**2. Run it programmatically**
|
|
35
|
-
|
|
36
|
-
```ts
|
|
37
|
-
import {
|
|
38
|
-
bootstrapBuiltins,
|
|
39
|
-
loadPipeline,
|
|
40
|
-
runPipeline,
|
|
41
|
-
InMemoryApprovalGateway,
|
|
42
|
-
} from '@tagma/sdk';
|
|
43
|
-
|
|
44
|
-
// Register built-in drivers, triggers, completions
|
|
45
|
-
bootstrapBuiltins();
|
|
46
|
-
|
|
47
|
-
const yaml = await Bun.file('pipeline.yaml').text();
|
|
48
|
-
const config = await loadPipeline(yaml, process.cwd());
|
|
49
|
-
|
|
50
|
-
const result = await runPipeline(config, process.cwd());
|
|
51
|
-
console.log(result.success ? 'Done' : 'Failed');
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
## Features
|
|
55
|
-
|
|
56
|
-
- **Multi-track DAG execution** -- tasks run in parallel across tracks, respecting `depends_on` ordering
|
|
57
|
-
- **Driver plugins** -- built-in `claude-code` driver; install `@tagma/driver-codex` or `@tagma/driver-opencode` for other agents
|
|
58
|
-
- **Session handoff** -- `continue_from` passes context between tasks (session resume or text injection)
|
|
59
|
-
- **Approval gates** -- trigger-based approval with stdin and WebSocket adapters
|
|
60
|
-
- **Lifecycle hooks** -- `pipeline_start`, `task_start`, `task_success`, `task_failure`, `pipeline_complete`, `pipeline_error`
|
|
61
|
-
- **Middleware** -- enrich prompts before execution (e.g. inject static context)
|
|
62
|
-
- **Completion checks** -- validate task output with `exit_code`, `file_exists`, or `output_check` plugins
|
|
63
|
-
- **Template expansion** -- reusable task templates with parameterized `use` / `with`
|
|
64
|
-
|
|
65
|
-
## Pipeline YAML Reference
|
|
66
|
-
|
|
67
|
-
```yaml
|
|
68
|
-
pipeline:
|
|
69
|
-
name: my-pipeline
|
|
70
|
-
driver: claude-code # default driver for all tasks
|
|
71
|
-
timeout: 30m # pipeline-level timeout
|
|
72
|
-
plugins: # load external driver plugins
|
|
73
|
-
- "@tagma/driver-codex"
|
|
74
|
-
hooks:
|
|
75
|
-
pipeline_start: "echo starting"
|
|
76
|
-
task_failure: "notify-slack.sh"
|
|
77
|
-
tracks:
|
|
78
|
-
- id: track-1
|
|
79
|
-
name: Track One
|
|
80
|
-
model_tier: high # high | medium | low
|
|
81
|
-
permissions:
|
|
82
|
-
read: true
|
|
83
|
-
write: true
|
|
84
|
-
execute: false
|
|
85
|
-
on_failure: skip_downstream # skip_downstream | stop_all | ignore
|
|
86
|
-
tasks:
|
|
87
|
-
- id: task-a
|
|
88
|
-
name: Do something
|
|
89
|
-
prompt: "Your prompt here"
|
|
90
|
-
output: ./output/task-a.txt
|
|
91
|
-
timeout: 10m
|
|
92
|
-
- id: task-b
|
|
93
|
-
name: Follow up
|
|
94
|
-
prompt: "Continue the work"
|
|
95
|
-
continue_from: task-a
|
|
96
|
-
depends_on: [task-a]
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
## API
|
|
100
|
-
|
|
101
|
-
### `bootstrapBuiltins()`
|
|
102
|
-
|
|
103
|
-
Registers all built-in plugins (claude-code driver, file/manual triggers, completion checks, static-context middleware).
|
|
104
|
-
|
|
105
|
-
### `loadPipeline(yaml: string, workDir: string): Promise<PipelineConfig>`
|
|
106
|
-
|
|
107
|
-
Parses YAML, resolves inheritance, expands templates, and validates the configuration.
|
|
108
|
-
|
|
109
|
-
### `runPipeline(config, workDir, options?): Promise<EngineResult>`
|
|
110
|
-
|
|
111
|
-
Executes the pipeline. Returns `{ success, summary, states }`.
|
|
112
|
-
|
|
113
|
-
Options:
|
|
114
|
-
- `approvalGateway` -- custom `ApprovalGateway` instance (defaults to `InMemoryApprovalGateway`)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
1
|
+
# @tagma/sdk
|
|
2
|
+
|
|
3
|
+
A local AI task orchestration SDK for [Bun](https://bun.sh). Define multi-track pipelines in YAML, run AI coding agents (Claude Code, Codex, OpenCode) and shell commands in parallel with dependency resolution, approval gates, and lifecycle hooks.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @tagma/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
**1. Define a pipeline** (`pipeline.yaml`)
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
pipeline:
|
|
17
|
+
name: build-and-test
|
|
18
|
+
tracks:
|
|
19
|
+
- id: backend
|
|
20
|
+
name: Backend
|
|
21
|
+
driver: claude-code
|
|
22
|
+
permissions: { read: true, write: true, execute: false }
|
|
23
|
+
tasks:
|
|
24
|
+
- id: implement
|
|
25
|
+
name: Implement feature
|
|
26
|
+
prompt: "Add a /health endpoint to src/server.ts"
|
|
27
|
+
output: ./output/implement.txt
|
|
28
|
+
- id: test
|
|
29
|
+
name: Run tests
|
|
30
|
+
command: "bun test"
|
|
31
|
+
depends_on: [implement]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**2. Run it programmatically**
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import {
|
|
38
|
+
bootstrapBuiltins,
|
|
39
|
+
loadPipeline,
|
|
40
|
+
runPipeline,
|
|
41
|
+
InMemoryApprovalGateway,
|
|
42
|
+
} from '@tagma/sdk';
|
|
43
|
+
|
|
44
|
+
// Register built-in drivers, triggers, completions
|
|
45
|
+
bootstrapBuiltins();
|
|
46
|
+
|
|
47
|
+
const yaml = await Bun.file('pipeline.yaml').text();
|
|
48
|
+
const config = await loadPipeline(yaml, process.cwd());
|
|
49
|
+
|
|
50
|
+
const result = await runPipeline(config, process.cwd());
|
|
51
|
+
console.log(result.success ? 'Done' : 'Failed');
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Features
|
|
55
|
+
|
|
56
|
+
- **Multi-track DAG execution** -- tasks run in parallel across tracks, respecting `depends_on` ordering
|
|
57
|
+
- **Driver plugins** -- built-in `claude-code` driver; install `@tagma/driver-codex` or `@tagma/driver-opencode` for other agents
|
|
58
|
+
- **Session handoff** -- `continue_from` passes context between tasks (session resume or text injection)
|
|
59
|
+
- **Approval gates** -- trigger-based approval with stdin and WebSocket adapters
|
|
60
|
+
- **Lifecycle hooks** -- `pipeline_start`, `task_start`, `task_success`, `task_failure`, `pipeline_complete`, `pipeline_error`
|
|
61
|
+
- **Middleware** -- enrich prompts before execution (e.g. inject static context)
|
|
62
|
+
- **Completion checks** -- validate task output with `exit_code`, `file_exists`, or `output_check` plugins
|
|
63
|
+
- **Template expansion** -- reusable task templates with parameterized `use` / `with`
|
|
64
|
+
|
|
65
|
+
## Pipeline YAML Reference
|
|
66
|
+
|
|
67
|
+
```yaml
|
|
68
|
+
pipeline:
|
|
69
|
+
name: my-pipeline
|
|
70
|
+
driver: claude-code # default driver for all tasks
|
|
71
|
+
timeout: 30m # pipeline-level timeout
|
|
72
|
+
plugins: # load external driver plugins
|
|
73
|
+
- "@tagma/driver-codex"
|
|
74
|
+
hooks:
|
|
75
|
+
pipeline_start: "echo starting"
|
|
76
|
+
task_failure: "notify-slack.sh"
|
|
77
|
+
tracks:
|
|
78
|
+
- id: track-1
|
|
79
|
+
name: Track One
|
|
80
|
+
model_tier: high # high | medium | low
|
|
81
|
+
permissions:
|
|
82
|
+
read: true
|
|
83
|
+
write: true
|
|
84
|
+
execute: false
|
|
85
|
+
on_failure: skip_downstream # skip_downstream | stop_all | ignore
|
|
86
|
+
tasks:
|
|
87
|
+
- id: task-a
|
|
88
|
+
name: Do something
|
|
89
|
+
prompt: "Your prompt here"
|
|
90
|
+
output: ./output/task-a.txt
|
|
91
|
+
timeout: 10m
|
|
92
|
+
- id: task-b
|
|
93
|
+
name: Follow up
|
|
94
|
+
prompt: "Continue the work"
|
|
95
|
+
continue_from: task-a
|
|
96
|
+
depends_on: [task-a]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## API
|
|
100
|
+
|
|
101
|
+
### `bootstrapBuiltins()`
|
|
102
|
+
|
|
103
|
+
Registers all built-in plugins (claude-code driver, file/manual triggers, completion checks, static-context middleware).
|
|
104
|
+
|
|
105
|
+
### `loadPipeline(yaml: string, workDir: string): Promise<PipelineConfig>`
|
|
106
|
+
|
|
107
|
+
Parses YAML, resolves inheritance, expands templates, and validates the configuration.
|
|
108
|
+
|
|
109
|
+
### `runPipeline(config, workDir, options?): Promise<EngineResult>`
|
|
110
|
+
|
|
111
|
+
Executes the pipeline. Returns `{ success, runId, logPath, summary, states }`.
|
|
112
|
+
|
|
113
|
+
Options:
|
|
114
|
+
- `approvalGateway` -- custom `ApprovalGateway` instance (defaults to `InMemoryApprovalGateway`)
|
|
115
|
+
- `signal` -- `AbortSignal` to cancel the run externally
|
|
116
|
+
- `onEvent` -- callback for real-time `PipelineEvent` updates (task status changes, pipeline start/end)
|
|
117
|
+
- `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/logs/` (default: 20)
|
|
118
|
+
|
|
119
|
+
### `PipelineRunner`
|
|
120
|
+
|
|
121
|
+
Higher-level wrapper for managing multiple concurrent pipeline runs — designed for sidecar / Tauri IPC scenarios where the frontend controls pipeline lifecycle by ID.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
const runner = new PipelineRunner(config, workDir);
|
|
125
|
+
|
|
126
|
+
// Subscribe before start — handler is called for every PipelineEvent
|
|
127
|
+
const unsubscribe = runner.subscribe(event => {
|
|
128
|
+
tauriEmit('pipeline_event', { id: runner.instanceId, event });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
runner.start(); // returns Promise<EngineResult>, idempotent
|
|
132
|
+
|
|
133
|
+
// Cancel from IPC
|
|
134
|
+
runner.abort();
|
|
135
|
+
|
|
136
|
+
// After completion
|
|
137
|
+
const states = runner.getStates(); // ReadonlyMap<taskId, TaskState>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Properties:
|
|
141
|
+
- `instanceId` — stable ID assigned at construction, safe to use as a Map key before `start()`
|
|
142
|
+
- `runId` — engine-assigned run ID, available after the first `pipeline_start` event (`null` until then)
|
|
143
|
+
- `status` — `'idle' | 'running' | 'done' | 'aborted'`
|
|
144
|
+
|
|
145
|
+
### `loadPlugins(names: string[]): Promise<void>`
|
|
146
|
+
|
|
147
|
+
Dynamically loads and registers external plugin packages.
|
|
148
|
+
|
|
149
|
+
### `attachStdinApprovalAdapter(gateway): StdinApprovalAdapter`
|
|
150
|
+
|
|
151
|
+
Attaches an interactive stdin-based approval handler.
|
|
152
|
+
|
|
153
|
+
### `attachWebSocketApprovalAdapter(gateway, options?): WebSocketApprovalAdapter`
|
|
154
|
+
|
|
155
|
+
Starts a WebSocket server for remote approval decisions.
|
|
156
|
+
|
|
157
|
+
### Config CRUD (`config-ops`)
|
|
158
|
+
|
|
159
|
+
Pure, immutable helper functions for building and editing `RawPipelineConfig` in a visual editor. No runtime dependencies — safe to use in renderer processes.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import {
|
|
163
|
+
createEmptyPipeline, setPipelineField,
|
|
164
|
+
upsertTrack, removeTrack, moveTrack, updateTrack,
|
|
165
|
+
upsertTask, removeTask, moveTask, transferTask,
|
|
166
|
+
serializePipeline,
|
|
167
|
+
} from '@tagma/sdk';
|
|
168
|
+
|
|
169
|
+
// Build a config programmatically
|
|
170
|
+
let config = createEmptyPipeline('my-pipeline');
|
|
171
|
+
config = upsertTrack(config, { id: 'backend', name: 'Backend', tasks: [] });
|
|
172
|
+
config = upsertTask(config, 'backend', { id: 'implement', prompt: 'Add /health endpoint' });
|
|
173
|
+
|
|
174
|
+
// Sync back to YAML
|
|
175
|
+
const yaml = serializePipeline(config);
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
| Function | Description |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `createEmptyPipeline(name)` | Create a minimal pipeline config |
|
|
181
|
+
| `setPipelineField(config, fields)` | Update top-level pipeline fields |
|
|
182
|
+
| `upsertTrack(config, track)` | Insert or replace a track by id |
|
|
183
|
+
| `removeTrack(config, trackId)` | Remove a track |
|
|
184
|
+
| `moveTrack(config, trackId, toIndex)` | Reorder a track |
|
|
185
|
+
| `updateTrack(config, trackId, fields)` | Patch track fields (not tasks) |
|
|
186
|
+
| `upsertTask(config, trackId, task)` | Insert or replace a task |
|
|
187
|
+
| `removeTask(config, trackId, taskId)` | Remove a task |
|
|
188
|
+
| `moveTask(config, trackId, taskId, toIndex)` | Reorder a task within its track |
|
|
189
|
+
| `transferTask(config, fromTrackId, taskId, toTrackId)` | Move a task across tracks |
|
|
190
|
+
|
|
191
|
+
### `parseYaml(content: string): RawPipelineConfig`
|
|
192
|
+
|
|
193
|
+
Parses a YAML string and returns the raw (unresolved) pipeline config. Use this when you need to edit and re-save YAML without losing relative paths or user-authored formatting — pass the result to `serializePipeline()` rather than going through `loadPipeline()`.
|
|
194
|
+
|
|
195
|
+
### `deresolvePipeline(config: PipelineConfig, workDir: string): RawPipelineConfig`
|
|
196
|
+
|
|
197
|
+
Converts a resolved `PipelineConfig` back to a `RawPipelineConfig` suitable for serialization. Strips injected defaults and converts absolute `cwd` paths back to relative so the output YAML is portable across machines.
|
|
198
|
+
|
|
199
|
+
Use this when you have a programmatically modified resolved config and need to save it back to YAML:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
// Correct: load → modify resolved config → deresolve → save
|
|
203
|
+
const config = await loadPipeline(yaml, workDir);
|
|
204
|
+
const modified = { ...config, name: 'renamed' };
|
|
205
|
+
const savedYaml = serializePipeline(deresolvePipeline(modified, workDir));
|
|
206
|
+
|
|
207
|
+
// Also correct: work entirely in raw space (preferred for visual editors)
|
|
208
|
+
const raw = parseYaml(yaml);
|
|
209
|
+
const updatedRaw = setPipelineField(raw, { name: 'renamed' });
|
|
210
|
+
const savedYaml = serializePipeline(updatedRaw);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### `validateConfig(config: PipelineConfig): string[]`
|
|
214
|
+
|
|
215
|
+
Validates a resolved pipeline config without executing it. Checks DAG structure (cycles, missing dependencies). Returns an array of error message strings — empty means valid.
|
|
216
|
+
|
|
217
|
+
Use `validateRaw` for editing raw configs in a UI; use `validateConfig` after `resolveConfig` for a final pre-run check.
|
|
218
|
+
|
|
219
|
+
### `validateRaw(config: RawPipelineConfig): ValidationError[]`
|
|
220
|
+
|
|
221
|
+
Validates a raw pipeline config without resolving inheritance or executing anything. Returns a flat list of `{ path, message }` objects — empty array means valid.
|
|
222
|
+
|
|
223
|
+
Checks: required fields, `prompt`/`command` exclusivity, `depends_on`/`continue_from` reference integrity, circular dependency detection.
|
|
224
|
+
|
|
225
|
+
Does **not** check plugin registration (plugins may not be loaded at edit time).
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
const errors = validateRaw(draftConfig);
|
|
229
|
+
if (errors.length > 0) {
|
|
230
|
+
errors.forEach(e => highlightNode(e.path, e.message));
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Related Packages
|
|
235
|
+
|
|
236
|
+
| Package | Description |
|
|
237
|
+
|---|---|
|
|
238
|
+
| [@tagma/types](https://www.npmjs.com/package/@tagma/types) | Shared TypeScript types |
|
|
239
|
+
| [@tagma/driver-codex](https://www.npmjs.com/package/@tagma/driver-codex) | Codex CLI driver plugin |
|
|
240
|
+
| [@tagma/driver-opencode](https://www.npmjs.com/package/@tagma/driver-opencode) | OpenCode CLI driver plugin |
|
|
241
|
+
| [@tagma/cli](https://www.npmjs.com/package/@tagma/cli) | CLI runner |
|
|
242
|
+
|
|
243
|
+
## License
|
|
244
|
+
|
|
245
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tagma/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"workspaces": [
|
|
6
6
|
"plugins/*"
|
|
@@ -20,13 +20,13 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"js-yaml": "^4.1.0",
|
|
22
22
|
"chokidar": "^4.0.0",
|
|
23
|
-
"@tagma/types": "
|
|
23
|
+
"@tagma/types": "workspace:*"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/js-yaml": "^4.0.9",
|
|
27
27
|
"bun-types": "latest",
|
|
28
28
|
"typescript": "^6.0.2",
|
|
29
|
-
"@tagma/driver-codex": "
|
|
30
|
-
"@tagma/driver-opencode": "
|
|
29
|
+
"@tagma/driver-codex": "workspace:*",
|
|
30
|
+
"@tagma/driver-opencode": "workspace:*"
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -1,117 +1,117 @@
|
|
|
1
|
-
import * as readline from 'readline';
|
|
2
|
-
import type { ApprovalGateway, ApprovalRequest } from '../approval';
|
|
3
|
-
|
|
4
|
-
// ═══ CLI Stdin Adapter ═══
|
|
5
|
-
//
|
|
6
|
-
// Subscribes to the gateway's 'requested' events, prompts the user on stdout,
|
|
7
|
-
// reads a line from stdin, and calls gateway.resolve(). Handles at most one
|
|
8
|
-
// prompt at a time — additional requests queue up.
|
|
9
|
-
|
|
10
|
-
export interface StdinApprovalAdapter {
|
|
11
|
-
readonly detach: () => void;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function attachStdinApprovalAdapter(gateway: ApprovalGateway): StdinApprovalAdapter {
|
|
15
|
-
const queue: ApprovalRequest[] = [];
|
|
16
|
-
let processing = false;
|
|
17
|
-
let rl: readline.Interface | null = null;
|
|
18
|
-
|
|
19
|
-
function ensureReadline(): readline.Interface {
|
|
20
|
-
if (!rl) {
|
|
21
|
-
rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
22
|
-
}
|
|
23
|
-
return rl;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function readOneLine(): Promise<string> {
|
|
27
|
-
return new Promise((resolvePromise) => {
|
|
28
|
-
const reader = ensureReadline();
|
|
29
|
-
const handler = (line: string): void => {
|
|
30
|
-
reader.off('line', handler);
|
|
31
|
-
resolvePromise(line);
|
|
32
|
-
};
|
|
33
|
-
reader.on('line', handler);
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function processNext(): Promise<void> {
|
|
38
|
-
if (processing) return;
|
|
39
|
-
processing = true;
|
|
40
|
-
try {
|
|
41
|
-
while (queue.length > 0) {
|
|
42
|
-
const req = queue.shift()!;
|
|
43
|
-
// If the request was already resolved by another path while queued, skip it.
|
|
44
|
-
if (!gateway.pending().some((p) => p.id === req.id)) continue;
|
|
45
|
-
|
|
46
|
-
const optionsStr = req.options.join(' / ');
|
|
47
|
-
process.stdout.write(
|
|
48
|
-
`\n[APPROVAL REQUIRED] ${req.message}\n` +
|
|
49
|
-
` id: ${req.id}\n` +
|
|
50
|
-
` task: ${req.taskId}${req.trackId ? ` (track: ${req.trackId})` : ''}\n` +
|
|
51
|
-
` options: ${optionsStr}\n` +
|
|
52
|
-
` > `,
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
const input = (await readOneLine()).trim().toLowerCase();
|
|
56
|
-
|
|
57
|
-
const approveAliases = new Set(['approve', 'yes', 'y', 'ok', 'true', '1']);
|
|
58
|
-
const rejectAliases = new Set(['reject', 'no', 'n', 'deny', 'false', '0']);
|
|
59
|
-
const matchedOption = req.options.find((o) => o.toLowerCase() === input);
|
|
60
|
-
|
|
61
|
-
if (matchedOption) {
|
|
62
|
-
const isReject = rejectAliases.has(matchedOption.toLowerCase());
|
|
63
|
-
gateway.resolve(req.id, {
|
|
64
|
-
outcome: isReject ? 'rejected' : 'approved',
|
|
65
|
-
choice: matchedOption,
|
|
66
|
-
actor: 'cli',
|
|
67
|
-
});
|
|
68
|
-
} else if (approveAliases.has(input)) {
|
|
69
|
-
gateway.resolve(req.id, { outcome: 'approved', choice: input, actor: 'cli' });
|
|
70
|
-
} else if (rejectAliases.has(input)) {
|
|
71
|
-
gateway.resolve(req.id, {
|
|
72
|
-
outcome: 'rejected',
|
|
73
|
-
choice: input,
|
|
74
|
-
actor: 'cli',
|
|
75
|
-
reason: 'user rejected via CLI',
|
|
76
|
-
});
|
|
77
|
-
} else {
|
|
78
|
-
process.stdout.write(` unrecognized input "${input}" — treating as rejection\n`);
|
|
79
|
-
gateway.resolve(req.id, {
|
|
80
|
-
outcome: 'rejected',
|
|
81
|
-
actor: 'cli',
|
|
82
|
-
reason: `unrecognized CLI input: ${input}`,
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
} finally {
|
|
87
|
-
processing = false;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const unsubscribe = gateway.subscribe((event) => {
|
|
92
|
-
switch (event.type) {
|
|
93
|
-
case 'requested':
|
|
94
|
-
queue.push(event.request);
|
|
95
|
-
void processNext();
|
|
96
|
-
return;
|
|
97
|
-
case 'resolved':
|
|
98
|
-
case 'expired':
|
|
99
|
-
case 'aborted': {
|
|
100
|
-
// Drop from queue if it's still waiting its turn.
|
|
101
|
-
const idx = queue.findIndex((r) => r.id === event.request.id);
|
|
102
|
-
if (idx >= 0) queue.splice(idx, 1);
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
return {
|
|
109
|
-
detach: () => {
|
|
110
|
-
unsubscribe();
|
|
111
|
-
if (rl) {
|
|
112
|
-
rl.close();
|
|
113
|
-
rl = null;
|
|
114
|
-
}
|
|
115
|
-
},
|
|
116
|
-
};
|
|
117
|
-
}
|
|
1
|
+
import * as readline from 'readline';
|
|
2
|
+
import type { ApprovalGateway, ApprovalRequest } from '../approval';
|
|
3
|
+
|
|
4
|
+
// ═══ CLI Stdin Adapter ═══
|
|
5
|
+
//
|
|
6
|
+
// Subscribes to the gateway's 'requested' events, prompts the user on stdout,
|
|
7
|
+
// reads a line from stdin, and calls gateway.resolve(). Handles at most one
|
|
8
|
+
// prompt at a time — additional requests queue up.
|
|
9
|
+
|
|
10
|
+
export interface StdinApprovalAdapter {
|
|
11
|
+
readonly detach: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function attachStdinApprovalAdapter(gateway: ApprovalGateway): StdinApprovalAdapter {
|
|
15
|
+
const queue: ApprovalRequest[] = [];
|
|
16
|
+
let processing = false;
|
|
17
|
+
let rl: readline.Interface | null = null;
|
|
18
|
+
|
|
19
|
+
function ensureReadline(): readline.Interface {
|
|
20
|
+
if (!rl) {
|
|
21
|
+
rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
22
|
+
}
|
|
23
|
+
return rl;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readOneLine(): Promise<string> {
|
|
27
|
+
return new Promise((resolvePromise) => {
|
|
28
|
+
const reader = ensureReadline();
|
|
29
|
+
const handler = (line: string): void => {
|
|
30
|
+
reader.off('line', handler);
|
|
31
|
+
resolvePromise(line);
|
|
32
|
+
};
|
|
33
|
+
reader.on('line', handler);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function processNext(): Promise<void> {
|
|
38
|
+
if (processing) return;
|
|
39
|
+
processing = true;
|
|
40
|
+
try {
|
|
41
|
+
while (queue.length > 0) {
|
|
42
|
+
const req = queue.shift()!;
|
|
43
|
+
// If the request was already resolved by another path while queued, skip it.
|
|
44
|
+
if (!gateway.pending().some((p) => p.id === req.id)) continue;
|
|
45
|
+
|
|
46
|
+
const optionsStr = req.options.join(' / ');
|
|
47
|
+
process.stdout.write(
|
|
48
|
+
`\n[APPROVAL REQUIRED] ${req.message}\n` +
|
|
49
|
+
` id: ${req.id}\n` +
|
|
50
|
+
` task: ${req.taskId}${req.trackId ? ` (track: ${req.trackId})` : ''}\n` +
|
|
51
|
+
` options: ${optionsStr}\n` +
|
|
52
|
+
` > `,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const input = (await readOneLine()).trim().toLowerCase();
|
|
56
|
+
|
|
57
|
+
const approveAliases = new Set(['approve', 'yes', 'y', 'ok', 'true', '1']);
|
|
58
|
+
const rejectAliases = new Set(['reject', 'no', 'n', 'deny', 'false', '0']);
|
|
59
|
+
const matchedOption = req.options.find((o) => o.toLowerCase() === input);
|
|
60
|
+
|
|
61
|
+
if (matchedOption) {
|
|
62
|
+
const isReject = rejectAliases.has(matchedOption.toLowerCase());
|
|
63
|
+
gateway.resolve(req.id, {
|
|
64
|
+
outcome: isReject ? 'rejected' : 'approved',
|
|
65
|
+
choice: matchedOption,
|
|
66
|
+
actor: 'cli',
|
|
67
|
+
});
|
|
68
|
+
} else if (approveAliases.has(input)) {
|
|
69
|
+
gateway.resolve(req.id, { outcome: 'approved', choice: input, actor: 'cli' });
|
|
70
|
+
} else if (rejectAliases.has(input)) {
|
|
71
|
+
gateway.resolve(req.id, {
|
|
72
|
+
outcome: 'rejected',
|
|
73
|
+
choice: input,
|
|
74
|
+
actor: 'cli',
|
|
75
|
+
reason: 'user rejected via CLI',
|
|
76
|
+
});
|
|
77
|
+
} else {
|
|
78
|
+
process.stdout.write(` unrecognized input "${input}" — treating as rejection\n`);
|
|
79
|
+
gateway.resolve(req.id, {
|
|
80
|
+
outcome: 'rejected',
|
|
81
|
+
actor: 'cli',
|
|
82
|
+
reason: `unrecognized CLI input: ${input}`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} finally {
|
|
87
|
+
processing = false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const unsubscribe = gateway.subscribe((event) => {
|
|
92
|
+
switch (event.type) {
|
|
93
|
+
case 'requested':
|
|
94
|
+
queue.push(event.request);
|
|
95
|
+
void processNext();
|
|
96
|
+
return;
|
|
97
|
+
case 'resolved':
|
|
98
|
+
case 'expired':
|
|
99
|
+
case 'aborted': {
|
|
100
|
+
// Drop from queue if it's still waiting its turn.
|
|
101
|
+
const idx = queue.findIndex((r) => r.id === event.request.id);
|
|
102
|
+
if (idx >= 0) queue.splice(idx, 1);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
detach: () => {
|
|
110
|
+
unsubscribe();
|
|
111
|
+
if (rl) {
|
|
112
|
+
rl.close();
|
|
113
|
+
rl = null;
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|