@tagma/sdk 0.1.8 → 0.2.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 +5 -5
- package/package.json +1 -1
- package/src/adapters/stdin-approval.ts +117 -117
- package/src/adapters/websocket-approval.ts +175 -144
- package/src/approval.ts +4 -1
- 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 +249 -220
- package/src/dag.ts +222 -222
- package/src/drivers/claude-code.ts +207 -207
- package/src/engine.ts +762 -714
- package/src/hooks.ts +147 -138
- package/src/logger.ts +112 -107
- package/src/middlewares/static-context.ts +29 -29
- package/src/pipeline-runner.ts +141 -125
- package/src/runner.ts +213 -195
- package/src/schema.ts +386 -358
- package/src/triggers/file.ts +105 -94
- package/src/triggers/manual.ts +61 -61
- package/src/utils.ts +154 -147
- package/src/validate-raw.ts +223 -203
package/README.md
CHANGED
|
@@ -115,9 +115,9 @@ Options:
|
|
|
115
115
|
- `signal` -- `AbortSignal` to cancel the run externally
|
|
116
116
|
- `onEvent` -- callback for real-time `PipelineEvent` updates:
|
|
117
117
|
- `pipeline_start` — pipeline began; includes `states: ReadonlyMap<taskId, TaskState>` (initial snapshot of all tasks at `waiting`)
|
|
118
|
-
- `task_status_change` — a task changed status; includes `state: TaskState` (complete snapshot at the time of change
|
|
118
|
+
- `task_status_change` — a task changed status; includes `state: TaskState` (complete snapshot at the time of change: `startedAt` is populated before the `running` event; `result` and `finishedAt` are populated before any terminal-status event)
|
|
119
119
|
- `pipeline_end` — pipeline finished; includes `success: boolean`
|
|
120
|
-
- `maxLogRuns` -- number of per-run log directories to keep under `<workDir
|
|
120
|
+
- `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/.tagma/logs/` (default: 20)
|
|
121
121
|
|
|
122
122
|
### `PipelineRunner`
|
|
123
123
|
|
|
@@ -188,7 +188,7 @@ const yaml = serializePipeline(config);
|
|
|
188
188
|
| `moveTrack(config, trackId, toIndex)` | Reorder a track |
|
|
189
189
|
| `updateTrack(config, trackId, fields)` | Patch track fields (not tasks) |
|
|
190
190
|
| `upsertTask(config, trackId, task)` | Insert or replace a task |
|
|
191
|
-
| `removeTask(config, trackId, taskId, cleanRefs?)` | Remove a task; pass `cleanRefs: true` to also strip dangling `depends_on` / `continue_from` references
|
|
191
|
+
| `removeTask(config, trackId, taskId, cleanRefs?)` | Remove a task; pass `cleanRefs: true` to also strip dangling `depends_on` / `continue_from` references. Only refs that resolve to the deleted task are removed — same-named tasks in other tracks are unaffected |
|
|
192
192
|
| `moveTask(config, trackId, taskId, toIndex)` | Reorder a task within its track |
|
|
193
193
|
| `transferTask(config, fromTrackId, taskId, toTrackId)` | Move a task across tracks |
|
|
194
194
|
|
|
@@ -224,7 +224,7 @@ Use `validateRaw` for editing raw configs in a UI; use `validateConfig` after `r
|
|
|
224
224
|
|
|
225
225
|
Validates a raw pipeline config without resolving inheritance or executing anything. Returns a flat list of `{ path, message }` objects — empty array means valid.
|
|
226
226
|
|
|
227
|
-
Checks: required fields, `prompt`/`command` exclusivity, `depends_on`/`continue_from` reference integrity, circular dependency detection.
|
|
227
|
+
Checks: required fields, `prompt`/`command` exclusivity, `depends_on`/`continue_from` reference integrity (including ambiguous bare refs that exist in multiple tracks — use `trackId.taskId` to disambiguate), circular dependency detection.
|
|
228
228
|
|
|
229
229
|
Does **not** check plugin registration (plugins may not be loaded at edit time).
|
|
230
230
|
|
|
@@ -260,4 +260,4 @@ Use `buildDag` instead when you have a fully resolved `PipelineConfig` and need
|
|
|
260
260
|
|
|
261
261
|
## License
|
|
262
262
|
|
|
263
|
-
MIT
|
|
263
|
+
MIT
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -1,144 +1,175 @@
|
|
|
1
|
-
import type { ApprovalGateway, ApprovalEvent } from '../approval';
|
|
2
|
-
|
|
3
|
-
// ═══ WebSocket Approval Adapter ═══
|
|
4
|
-
//
|
|
5
|
-
// Bridges the ApprovalGateway to WebSocket clients (e.g. a frontend UI).
|
|
6
|
-
// Mirrors the stdin-approval adapter pattern: subscribe to gateway events,
|
|
7
|
-
// forward them as JSON to all connected clients, and call gateway.resolve()
|
|
8
|
-
// when a client sends a resolution message.
|
|
9
|
-
//
|
|
10
|
-
// Protocol — server → client:
|
|
11
|
-
// { type: 'pending', requests: ApprovalRequest[] } ← sent on connect
|
|
12
|
-
// { type: 'approval_requested', request: ApprovalRequest }
|
|
13
|
-
// { type: 'approval_resolved', request: ApprovalRequest, decision: ApprovalDecision }
|
|
14
|
-
// { type: 'approval_expired', request: ApprovalRequest }
|
|
15
|
-
// { type: 'approval_aborted', request: ApprovalRequest, reason: string }
|
|
16
|
-
//
|
|
17
|
-
// Protocol — client → server:
|
|
18
|
-
// { type: 'resolve', approvalId: string, outcome: 'approved'|'rejected',
|
|
19
|
-
// choice?: string, actor?: string, reason?: string }
|
|
20
|
-
|
|
21
|
-
export interface WebSocketApprovalAdapterOptions {
|
|
22
|
-
port?: number; // default: 3000
|
|
23
|
-
hostname?: string; // default: 'localhost'
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface WebSocketApprovalAdapter {
|
|
27
|
-
readonly port: number;
|
|
28
|
-
readonly detach: () => void;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
1
|
+
import type { ApprovalGateway, ApprovalEvent } from '../approval';
|
|
2
|
+
|
|
3
|
+
// ═══ WebSocket Approval Adapter ═══
|
|
4
|
+
//
|
|
5
|
+
// Bridges the ApprovalGateway to WebSocket clients (e.g. a frontend UI).
|
|
6
|
+
// Mirrors the stdin-approval adapter pattern: subscribe to gateway events,
|
|
7
|
+
// forward them as JSON to all connected clients, and call gateway.resolve()
|
|
8
|
+
// when a client sends a resolution message.
|
|
9
|
+
//
|
|
10
|
+
// Protocol — server → client:
|
|
11
|
+
// { type: 'pending', requests: ApprovalRequest[] } ← sent on connect
|
|
12
|
+
// { type: 'approval_requested', request: ApprovalRequest }
|
|
13
|
+
// { type: 'approval_resolved', request: ApprovalRequest, decision: ApprovalDecision }
|
|
14
|
+
// { type: 'approval_expired', request: ApprovalRequest }
|
|
15
|
+
// { type: 'approval_aborted', request: ApprovalRequest, reason: string }
|
|
16
|
+
//
|
|
17
|
+
// Protocol — client → server:
|
|
18
|
+
// { type: 'resolve', approvalId: string, outcome: 'approved'|'rejected',
|
|
19
|
+
// choice?: string, actor?: string, reason?: string }
|
|
20
|
+
|
|
21
|
+
export interface WebSocketApprovalAdapterOptions {
|
|
22
|
+
port?: number; // default: 3000
|
|
23
|
+
hostname?: string; // default: 'localhost'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WebSocketApprovalAdapter {
|
|
27
|
+
readonly port: number;
|
|
28
|
+
readonly detach: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Maximum allowed message payload (bytes) to prevent DoS via oversized messages.
|
|
32
|
+
const MAX_PAYLOAD_BYTES = 4_096;
|
|
33
|
+
// Per-client rate limit: at most this many messages per window.
|
|
34
|
+
const RATE_LIMIT_MAX = 10;
|
|
35
|
+
const RATE_LIMIT_WINDOW_MS = 1_000;
|
|
36
|
+
|
|
37
|
+
export function attachWebSocketApprovalAdapter(
|
|
38
|
+
gateway: ApprovalGateway,
|
|
39
|
+
options: WebSocketApprovalAdapterOptions = {},
|
|
40
|
+
): WebSocketApprovalAdapter {
|
|
41
|
+
const port = options.port ?? 3000;
|
|
42
|
+
const hostname = options.hostname ?? 'localhost';
|
|
43
|
+
|
|
44
|
+
type WS = import('bun').ServerWebSocket<unknown>;
|
|
45
|
+
const clients = new Set<WS>();
|
|
46
|
+
const clientRates = new Map<WS, { count: number; resetAt: number }>();
|
|
47
|
+
|
|
48
|
+
function broadcast(msg: unknown): void {
|
|
49
|
+
const text = JSON.stringify(msg);
|
|
50
|
+
for (const ws of clients) {
|
|
51
|
+
ws.send(text);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const unsubscribe = gateway.subscribe((event: ApprovalEvent) => {
|
|
56
|
+
switch (event.type) {
|
|
57
|
+
case 'requested':
|
|
58
|
+
broadcast({ type: 'approval_requested', request: event.request });
|
|
59
|
+
break;
|
|
60
|
+
case 'resolved':
|
|
61
|
+
broadcast({ type: 'approval_resolved', request: event.request, decision: event.decision });
|
|
62
|
+
break;
|
|
63
|
+
case 'expired':
|
|
64
|
+
broadcast({ type: 'approval_expired', request: event.request });
|
|
65
|
+
break;
|
|
66
|
+
case 'aborted':
|
|
67
|
+
broadcast({ type: 'approval_aborted', request: event.request, reason: event.reason });
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const server = Bun.serve({
|
|
73
|
+
port,
|
|
74
|
+
hostname,
|
|
75
|
+
|
|
76
|
+
fetch(req, server) {
|
|
77
|
+
if (server.upgrade(req)) return undefined;
|
|
78
|
+
return new Response('tagma-sdk WebSocket approval endpoint', { status: 426 });
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
websocket: {
|
|
82
|
+
open(ws) {
|
|
83
|
+
clients.add(ws);
|
|
84
|
+
// Sync current pending approvals to newly connected client.
|
|
85
|
+
ws.send(JSON.stringify({ type: 'pending', requests: gateway.pending() }));
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
message(ws, raw) {
|
|
89
|
+
const rawStr = typeof raw === 'string' ? raw : raw.toString();
|
|
90
|
+
|
|
91
|
+
// Payload size guard — reject oversized messages before parsing.
|
|
92
|
+
if (rawStr.length > MAX_PAYLOAD_BYTES) {
|
|
93
|
+
ws.send(JSON.stringify({ type: 'error', message: 'message too large' }));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Per-client rate limit.
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
const rate = clientRates.get(ws) ?? { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS };
|
|
100
|
+
if (now >= rate.resetAt) {
|
|
101
|
+
rate.count = 0;
|
|
102
|
+
rate.resetAt = now + RATE_LIMIT_WINDOW_MS;
|
|
103
|
+
}
|
|
104
|
+
rate.count++;
|
|
105
|
+
clientRates.set(ws, rate);
|
|
106
|
+
if (rate.count > RATE_LIMIT_MAX) {
|
|
107
|
+
ws.send(JSON.stringify({ type: 'error', message: 'rate limit exceeded' }));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let msg: unknown;
|
|
112
|
+
try {
|
|
113
|
+
msg = JSON.parse(rawStr);
|
|
114
|
+
} catch {
|
|
115
|
+
ws.send(JSON.stringify({ type: 'error', message: 'invalid JSON' }));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!isResolveMessage(msg)) {
|
|
120
|
+
ws.send(JSON.stringify({ type: 'error', message: 'unknown message type' }));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const ok = gateway.resolve(msg.approvalId, {
|
|
125
|
+
outcome: msg.outcome,
|
|
126
|
+
choice: msg.choice,
|
|
127
|
+
actor: msg.actor ?? 'websocket',
|
|
128
|
+
reason: msg.reason,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (!ok) {
|
|
132
|
+
ws.send(JSON.stringify({
|
|
133
|
+
type: 'error',
|
|
134
|
+
message: `approval ${msg.approvalId} not found or already resolved`,
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
close(ws) {
|
|
140
|
+
clients.delete(ws);
|
|
141
|
+
clientRates.delete(ws);
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
port: server.port!,
|
|
148
|
+
detach() {
|
|
149
|
+
unsubscribe();
|
|
150
|
+
clients.clear();
|
|
151
|
+
server.stop(true);
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Type guard ──
|
|
157
|
+
|
|
158
|
+
interface ResolveMessage {
|
|
159
|
+
type: 'resolve';
|
|
160
|
+
approvalId: string;
|
|
161
|
+
outcome: 'approved' | 'rejected';
|
|
162
|
+
choice?: string;
|
|
163
|
+
actor?: string;
|
|
164
|
+
reason?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isResolveMessage(v: unknown): v is ResolveMessage {
|
|
168
|
+
if (typeof v !== 'object' || v === null) return false;
|
|
169
|
+
const m = v as Record<string, unknown>;
|
|
170
|
+
return (
|
|
171
|
+
m['type'] === 'resolve' &&
|
|
172
|
+
typeof m['approvalId'] === 'string' &&
|
|
173
|
+
(m['outcome'] === 'approved' || m['outcome'] === 'rejected')
|
|
174
|
+
);
|
|
175
|
+
}
|
package/src/approval.ts
CHANGED
|
@@ -16,6 +16,9 @@ export type {
|
|
|
16
16
|
ApprovalListener, ApprovalGateway,
|
|
17
17
|
} from '@tagma/types';
|
|
18
18
|
|
|
19
|
+
// Default options presented to the approver when the caller does not specify any.
|
|
20
|
+
const DEFAULT_APPROVAL_OPTIONS = ['approve', 'reject'] as const;
|
|
21
|
+
|
|
19
22
|
// ═══ Default In-Memory Implementation ═══
|
|
20
23
|
|
|
21
24
|
interface PendingEntry {
|
|
@@ -37,7 +40,7 @@ export class InMemoryApprovalGateway implements ApprovalGateway {
|
|
|
37
40
|
taskId: req.taskId,
|
|
38
41
|
trackId: req.trackId,
|
|
39
42
|
message: req.message,
|
|
40
|
-
options: req.options && req.options.length > 0 ? req.options :
|
|
43
|
+
options: req.options && req.options.length > 0 ? req.options : DEFAULT_APPROVAL_OPTIONS,
|
|
41
44
|
timeoutMs: req.timeoutMs,
|
|
42
45
|
metadata: req.metadata,
|
|
43
46
|
};
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
|
|
2
|
-
|
|
3
|
-
export const ExitCodeCompletion: CompletionPlugin = {
|
|
4
|
-
name: 'exit_code',
|
|
5
|
-
|
|
6
|
-
async check(config: Record<string, unknown>, result: TaskResult, _ctx: CompletionContext): Promise<boolean> {
|
|
7
|
-
const expected = config.expect ?? 0;
|
|
8
|
-
|
|
9
|
-
if (typeof expected === 'number') {
|
|
10
|
-
return result.exitCode === expected;
|
|
11
|
-
}
|
|
12
|
-
if (Array.isArray(expected) && expected.every((v) => typeof v === 'number')) {
|
|
13
|
-
return expected.includes(result.exitCode);
|
|
14
|
-
}
|
|
15
|
-
throw new Error(
|
|
16
|
-
`exit_code completion: "expect" must be a number or number[], got ${typeof expected}`
|
|
17
|
-
);
|
|
18
|
-
},
|
|
19
|
-
};
|
|
1
|
+
import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
|
|
2
|
+
|
|
3
|
+
export const ExitCodeCompletion: CompletionPlugin = {
|
|
4
|
+
name: 'exit_code',
|
|
5
|
+
|
|
6
|
+
async check(config: Record<string, unknown>, result: TaskResult, _ctx: CompletionContext): Promise<boolean> {
|
|
7
|
+
const expected = config.expect ?? 0;
|
|
8
|
+
|
|
9
|
+
if (typeof expected === 'number') {
|
|
10
|
+
return result.exitCode === expected;
|
|
11
|
+
}
|
|
12
|
+
if (Array.isArray(expected) && expected.every((v) => typeof v === 'number')) {
|
|
13
|
+
return expected.includes(result.exitCode);
|
|
14
|
+
}
|
|
15
|
+
throw new Error(
|
|
16
|
+
`exit_code completion: "expect" must be a number or number[], got ${typeof expected}`
|
|
17
|
+
);
|
|
18
|
+
},
|
|
19
|
+
};
|