@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
|
@@ -1,144 +1,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
|
-
export function attachWebSocketApprovalAdapter(
|
|
32
|
-
gateway: ApprovalGateway,
|
|
33
|
-
options: WebSocketApprovalAdapterOptions = {},
|
|
34
|
-
): WebSocketApprovalAdapter {
|
|
35
|
-
const port = options.port ?? 3000;
|
|
36
|
-
const hostname = options.hostname ?? 'localhost';
|
|
37
|
-
|
|
38
|
-
const clients = new Set<import('bun').ServerWebSocket<unknown>>();
|
|
39
|
-
|
|
40
|
-
function broadcast(msg: unknown): void {
|
|
41
|
-
const text = JSON.stringify(msg);
|
|
42
|
-
for (const ws of clients) {
|
|
43
|
-
ws.send(text);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const unsubscribe = gateway.subscribe((event: ApprovalEvent) => {
|
|
48
|
-
switch (event.type) {
|
|
49
|
-
case 'requested':
|
|
50
|
-
broadcast({ type: 'approval_requested', request: event.request });
|
|
51
|
-
break;
|
|
52
|
-
case 'resolved':
|
|
53
|
-
broadcast({ type: 'approval_resolved', request: event.request, decision: event.decision });
|
|
54
|
-
break;
|
|
55
|
-
case 'expired':
|
|
56
|
-
broadcast({ type: 'approval_expired', request: event.request });
|
|
57
|
-
break;
|
|
58
|
-
case 'aborted':
|
|
59
|
-
broadcast({ type: 'approval_aborted', request: event.request, reason: event.reason });
|
|
60
|
-
break;
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
const server = Bun.serve({
|
|
65
|
-
port,
|
|
66
|
-
hostname,
|
|
67
|
-
|
|
68
|
-
fetch(req, server) {
|
|
69
|
-
if (server.upgrade(req)) return undefined;
|
|
70
|
-
return new Response('tagma-sdk WebSocket approval endpoint', { status: 426 });
|
|
71
|
-
},
|
|
72
|
-
|
|
73
|
-
websocket: {
|
|
74
|
-
open(ws) {
|
|
75
|
-
clients.add(ws);
|
|
76
|
-
// Sync current pending approvals to newly connected client.
|
|
77
|
-
ws.send(JSON.stringify({ type: 'pending', requests: gateway.pending() }));
|
|
78
|
-
},
|
|
79
|
-
|
|
80
|
-
message(ws, raw) {
|
|
81
|
-
let msg: unknown;
|
|
82
|
-
try {
|
|
83
|
-
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
|
84
|
-
} catch {
|
|
85
|
-
ws.send(JSON.stringify({ type: 'error', message: 'invalid JSON' }));
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (!isResolveMessage(msg)) {
|
|
90
|
-
ws.send(JSON.stringify({ type: 'error', message: 'unknown message type' }));
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const ok = gateway.resolve(msg.approvalId, {
|
|
95
|
-
outcome: msg.outcome,
|
|
96
|
-
choice: msg.choice,
|
|
97
|
-
actor: msg.actor ?? 'websocket',
|
|
98
|
-
reason: msg.reason,
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
if (!ok) {
|
|
102
|
-
ws.send(JSON.stringify({
|
|
103
|
-
type: 'error',
|
|
104
|
-
message: `approval ${msg.approvalId} not found or already resolved`,
|
|
105
|
-
}));
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
|
|
109
|
-
close(ws) {
|
|
110
|
-
clients.delete(ws);
|
|
111
|
-
},
|
|
112
|
-
},
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
return {
|
|
116
|
-
port: server.port
|
|
117
|
-
detach() {
|
|
118
|
-
unsubscribe();
|
|
119
|
-
clients.clear();
|
|
120
|
-
server.stop(true);
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ── Type guard ──
|
|
126
|
-
|
|
127
|
-
interface ResolveMessage {
|
|
128
|
-
type: 'resolve';
|
|
129
|
-
approvalId: string;
|
|
130
|
-
outcome: 'approved' | 'rejected';
|
|
131
|
-
choice?: string;
|
|
132
|
-
actor?: string;
|
|
133
|
-
reason?: string;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function isResolveMessage(v: unknown): v is ResolveMessage {
|
|
137
|
-
if (typeof v !== 'object' || v === null) return false;
|
|
138
|
-
const m = v as Record<string, unknown>;
|
|
139
|
-
return (
|
|
140
|
-
m['type'] === 'resolve' &&
|
|
141
|
-
typeof m['approvalId'] === 'string' &&
|
|
142
|
-
(m['outcome'] === 'approved' || m['outcome'] === 'rejected')
|
|
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
|
+
export function attachWebSocketApprovalAdapter(
|
|
32
|
+
gateway: ApprovalGateway,
|
|
33
|
+
options: WebSocketApprovalAdapterOptions = {},
|
|
34
|
+
): WebSocketApprovalAdapter {
|
|
35
|
+
const port = options.port ?? 3000;
|
|
36
|
+
const hostname = options.hostname ?? 'localhost';
|
|
37
|
+
|
|
38
|
+
const clients = new Set<import('bun').ServerWebSocket<unknown>>();
|
|
39
|
+
|
|
40
|
+
function broadcast(msg: unknown): void {
|
|
41
|
+
const text = JSON.stringify(msg);
|
|
42
|
+
for (const ws of clients) {
|
|
43
|
+
ws.send(text);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const unsubscribe = gateway.subscribe((event: ApprovalEvent) => {
|
|
48
|
+
switch (event.type) {
|
|
49
|
+
case 'requested':
|
|
50
|
+
broadcast({ type: 'approval_requested', request: event.request });
|
|
51
|
+
break;
|
|
52
|
+
case 'resolved':
|
|
53
|
+
broadcast({ type: 'approval_resolved', request: event.request, decision: event.decision });
|
|
54
|
+
break;
|
|
55
|
+
case 'expired':
|
|
56
|
+
broadcast({ type: 'approval_expired', request: event.request });
|
|
57
|
+
break;
|
|
58
|
+
case 'aborted':
|
|
59
|
+
broadcast({ type: 'approval_aborted', request: event.request, reason: event.reason });
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const server = Bun.serve({
|
|
65
|
+
port,
|
|
66
|
+
hostname,
|
|
67
|
+
|
|
68
|
+
fetch(req, server) {
|
|
69
|
+
if (server.upgrade(req)) return undefined;
|
|
70
|
+
return new Response('tagma-sdk WebSocket approval endpoint', { status: 426 });
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
websocket: {
|
|
74
|
+
open(ws) {
|
|
75
|
+
clients.add(ws);
|
|
76
|
+
// Sync current pending approvals to newly connected client.
|
|
77
|
+
ws.send(JSON.stringify({ type: 'pending', requests: gateway.pending() }));
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
message(ws, raw) {
|
|
81
|
+
let msg: unknown;
|
|
82
|
+
try {
|
|
83
|
+
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
|
84
|
+
} catch {
|
|
85
|
+
ws.send(JSON.stringify({ type: 'error', message: 'invalid JSON' }));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!isResolveMessage(msg)) {
|
|
90
|
+
ws.send(JSON.stringify({ type: 'error', message: 'unknown message type' }));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ok = gateway.resolve(msg.approvalId, {
|
|
95
|
+
outcome: msg.outcome,
|
|
96
|
+
choice: msg.choice,
|
|
97
|
+
actor: msg.actor ?? 'websocket',
|
|
98
|
+
reason: msg.reason,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!ok) {
|
|
102
|
+
ws.send(JSON.stringify({
|
|
103
|
+
type: 'error',
|
|
104
|
+
message: `approval ${msg.approvalId} not found or already resolved`,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
close(ws) {
|
|
110
|
+
clients.delete(ws);
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
port: server.port!,
|
|
117
|
+
detach() {
|
|
118
|
+
unsubscribe();
|
|
119
|
+
clients.clear();
|
|
120
|
+
server.stop(true);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Type guard ──
|
|
126
|
+
|
|
127
|
+
interface ResolveMessage {
|
|
128
|
+
type: 'resolve';
|
|
129
|
+
approvalId: string;
|
|
130
|
+
outcome: 'approved' | 'rejected';
|
|
131
|
+
choice?: string;
|
|
132
|
+
actor?: string;
|
|
133
|
+
reason?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isResolveMessage(v: unknown): v is ResolveMessage {
|
|
137
|
+
if (typeof v !== 'object' || v === null) return false;
|
|
138
|
+
const m = v as Record<string, unknown>;
|
|
139
|
+
return (
|
|
140
|
+
m['type'] === 'resolve' &&
|
|
141
|
+
typeof m['approvalId'] === 'string' &&
|
|
142
|
+
(m['outcome'] === 'approved' || m['outcome'] === 'rejected')
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -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
|
+
};
|
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
import { stat } from 'node:fs/promises';
|
|
2
|
-
import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
|
|
3
|
-
import { validatePath } from '../utils';
|
|
4
|
-
|
|
5
|
-
type Kind = 'file' | 'dir' | 'any';
|
|
6
|
-
|
|
7
|
-
export const FileExistsCompletion: CompletionPlugin = {
|
|
8
|
-
name: 'file_exists',
|
|
9
|
-
|
|
10
|
-
async check(config: Record<string, unknown>, _result: TaskResult, ctx: CompletionContext): Promise<boolean> {
|
|
11
|
-
const filePath = config.path as string;
|
|
12
|
-
if (!filePath) throw new Error('file_exists completion: "path" is required');
|
|
13
|
-
|
|
14
|
-
const safePath = validatePath(filePath, ctx.workDir);
|
|
15
|
-
|
|
16
|
-
const kind = (config.kind as Kind | undefined) ?? 'any';
|
|
17
|
-
if (kind !== 'file' && kind !== 'dir' && kind !== 'any') {
|
|
18
|
-
throw new Error(`file_exists completion: "kind" must be "file" | "dir" | "any", got "${kind}"`);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const minSize = config.min_size;
|
|
22
|
-
if (minSize != null && (typeof minSize !== 'number' || minSize < 0)) {
|
|
23
|
-
throw new Error(`file_exists completion: "min_size" must be a non-negative number`);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
try {
|
|
27
|
-
const st = await stat(safePath);
|
|
28
|
-
if (kind === 'file' && !st.isFile()) return false;
|
|
29
|
-
if (kind === 'dir' && !st.isDirectory()) return false;
|
|
30
|
-
if (typeof minSize === 'number' && st.isFile() && st.size < minSize) return false;
|
|
31
|
-
return true;
|
|
32
|
-
} catch (err: unknown) {
|
|
33
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
34
|
-
if (code === 'ENOENT' || code === 'ENOTDIR') return false;
|
|
35
|
-
// Permission / IO errors should surface, not silently mean "missing"
|
|
36
|
-
throw err;
|
|
37
|
-
}
|
|
38
|
-
},
|
|
39
|
-
};
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
|
|
3
|
+
import { validatePath } from '../utils';
|
|
4
|
+
|
|
5
|
+
type Kind = 'file' | 'dir' | 'any';
|
|
6
|
+
|
|
7
|
+
export const FileExistsCompletion: CompletionPlugin = {
|
|
8
|
+
name: 'file_exists',
|
|
9
|
+
|
|
10
|
+
async check(config: Record<string, unknown>, _result: TaskResult, ctx: CompletionContext): Promise<boolean> {
|
|
11
|
+
const filePath = config.path as string;
|
|
12
|
+
if (!filePath) throw new Error('file_exists completion: "path" is required');
|
|
13
|
+
|
|
14
|
+
const safePath = validatePath(filePath, ctx.workDir);
|
|
15
|
+
|
|
16
|
+
const kind = (config.kind as Kind | undefined) ?? 'any';
|
|
17
|
+
if (kind !== 'file' && kind !== 'dir' && kind !== 'any') {
|
|
18
|
+
throw new Error(`file_exists completion: "kind" must be "file" | "dir" | "any", got "${kind}"`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const minSize = config.min_size;
|
|
22
|
+
if (minSize != null && (typeof minSize !== 'number' || minSize < 0)) {
|
|
23
|
+
throw new Error(`file_exists completion: "min_size" must be a non-negative number`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const st = await stat(safePath);
|
|
28
|
+
if (kind === 'file' && !st.isFile()) return false;
|
|
29
|
+
if (kind === 'dir' && !st.isDirectory()) return false;
|
|
30
|
+
if (typeof minSize === 'number' && st.isFile() && st.size < minSize) return false;
|
|
31
|
+
return true;
|
|
32
|
+
} catch (err: unknown) {
|
|
33
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
34
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return false;
|
|
35
|
+
// Permission / IO errors should surface, not silently mean "missing"
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -1,57 +1,57 @@
|
|
|
1
|
-
import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
|
|
2
|
-
import { shellArgs, parseDuration } from '../utils';
|
|
3
|
-
|
|
4
|
-
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
|
-
|
|
6
|
-
export const OutputCheckCompletion: CompletionPlugin = {
|
|
7
|
-
name: 'output_check',
|
|
8
|
-
|
|
9
|
-
async check(config: Record<string, unknown>, result: TaskResult, ctx: CompletionContext): Promise<boolean> {
|
|
10
|
-
const checkCmd = config.check as string;
|
|
11
|
-
if (!checkCmd) throw new Error('output_check completion: "check" is required');
|
|
12
|
-
|
|
13
|
-
const timeoutMs = config.timeout != null
|
|
14
|
-
? parseDuration(String(config.timeout))
|
|
15
|
-
: DEFAULT_TIMEOUT_MS;
|
|
16
|
-
|
|
17
|
-
const controller = new AbortController();
|
|
18
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
19
|
-
|
|
20
|
-
const proc = Bun.spawn(shellArgs(checkCmd) as string[], {
|
|
21
|
-
cwd: ctx.workDir,
|
|
22
|
-
stdin: 'pipe',
|
|
23
|
-
stdout: 'pipe',
|
|
24
|
-
stderr: 'pipe',
|
|
25
|
-
signal: controller.signal,
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
try {
|
|
29
|
-
if (proc.stdin) {
|
|
30
|
-
try {
|
|
31
|
-
proc.stdin.write(result.stdout);
|
|
32
|
-
await proc.stdin.end();
|
|
33
|
-
} catch (err: unknown) {
|
|
34
|
-
// EPIPE is expected when the check process exits before reading all of stdin
|
|
35
|
-
// (e.g. `grep -q` exits on first match). Anything else is a real failure.
|
|
36
|
-
const code = (err as NodeJS.ErrnoException)?.code;
|
|
37
|
-
if (code !== 'EPIPE') throw err;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const exitCode = await proc.exited;
|
|
42
|
-
|
|
43
|
-
if (exitCode !== 0) {
|
|
44
|
-
try {
|
|
45
|
-
const stderr = await new Response(proc.stderr).text();
|
|
46
|
-
if (stderr.trim()) {
|
|
47
|
-
console.warn(`[output_check] "${checkCmd}" exit=${exitCode}: ${stderr.trim()}`);
|
|
48
|
-
}
|
|
49
|
-
} catch { /* ignore stderr read failures */ }
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return exitCode === 0;
|
|
53
|
-
} finally {
|
|
54
|
-
clearTimeout(timer);
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
|
-
};
|
|
1
|
+
import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
|
|
2
|
+
import { shellArgs, parseDuration } from '../utils';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
|
+
|
|
6
|
+
export const OutputCheckCompletion: CompletionPlugin = {
|
|
7
|
+
name: 'output_check',
|
|
8
|
+
|
|
9
|
+
async check(config: Record<string, unknown>, result: TaskResult, ctx: CompletionContext): Promise<boolean> {
|
|
10
|
+
const checkCmd = config.check as string;
|
|
11
|
+
if (!checkCmd) throw new Error('output_check completion: "check" is required');
|
|
12
|
+
|
|
13
|
+
const timeoutMs = config.timeout != null
|
|
14
|
+
? parseDuration(String(config.timeout))
|
|
15
|
+
: DEFAULT_TIMEOUT_MS;
|
|
16
|
+
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
19
|
+
|
|
20
|
+
const proc = Bun.spawn(shellArgs(checkCmd) as string[], {
|
|
21
|
+
cwd: ctx.workDir,
|
|
22
|
+
stdin: 'pipe',
|
|
23
|
+
stdout: 'pipe',
|
|
24
|
+
stderr: 'pipe',
|
|
25
|
+
signal: controller.signal,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
if (proc.stdin) {
|
|
30
|
+
try {
|
|
31
|
+
proc.stdin.write(result.stdout);
|
|
32
|
+
await proc.stdin.end();
|
|
33
|
+
} catch (err: unknown) {
|
|
34
|
+
// EPIPE is expected when the check process exits before reading all of stdin
|
|
35
|
+
// (e.g. `grep -q` exits on first match). Anything else is a real failure.
|
|
36
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
37
|
+
if (code !== 'EPIPE') throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const exitCode = await proc.exited;
|
|
42
|
+
|
|
43
|
+
if (exitCode !== 0) {
|
|
44
|
+
try {
|
|
45
|
+
const stderr = await new Response(proc.stderr).text();
|
|
46
|
+
if (stderr.trim()) {
|
|
47
|
+
console.warn(`[output_check] "${checkCmd}" exit=${exitCode}: ${stderr.trim()}`);
|
|
48
|
+
}
|
|
49
|
+
} catch { /* ignore stderr read failures */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return exitCode === 0;
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|