@tagma/sdk 0.7.3 → 0.7.4
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 +26 -5
- package/dist/adapters/stdin-approval.d.ts +1 -5
- package/dist/adapters/stdin-approval.d.ts.map +1 -1
- package/dist/adapters/stdin-approval.js +1 -89
- package/dist/adapters/stdin-approval.js.map +1 -1
- package/dist/adapters/websocket-approval.d.ts +1 -27
- package/dist/adapters/websocket-approval.d.ts.map +1 -1
- package/dist/adapters/websocket-approval.js +1 -146
- package/dist/adapters/websocket-approval.js.map +1 -1
- package/dist/approval.d.ts +2 -12
- package/dist/approval.d.ts.map +1 -1
- package/dist/approval.js +1 -90
- package/dist/approval.js.map +1 -1
- package/dist/bootstrap.d.ts +1 -1
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/core/task-executor.d.ts.map +1 -1
- package/dist/core/task-executor.js +13 -4
- package/dist/core/task-executor.js.map +1 -1
- package/dist/engine.d.ts +5 -56
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +7 -297
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +4 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -4
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +2 -60
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +1 -153
- package/dist/logger.js.map +1 -1
- package/dist/plugins.d.ts +2 -2
- package/dist/plugins.d.ts.map +1 -1
- package/dist/plugins.js +1 -1
- package/dist/plugins.js.map +1 -1
- package/dist/registry.d.ts +2 -66
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1 -292
- package/dist/registry.js.map +1 -1
- package/dist/runner.d.ts +1 -35
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +1 -610
- package/dist/runner.js.map +1 -1
- package/dist/runtime/adapters/stdin-approval.d.ts +2 -0
- package/dist/runtime/adapters/stdin-approval.d.ts.map +1 -0
- package/dist/runtime/adapters/stdin-approval.js +2 -0
- package/dist/runtime/adapters/stdin-approval.js.map +1 -0
- package/dist/runtime/adapters/websocket-approval.d.ts +2 -0
- package/dist/runtime/adapters/websocket-approval.d.ts.map +1 -0
- package/dist/runtime/adapters/websocket-approval.js +2 -0
- package/dist/runtime/adapters/websocket-approval.js.map +1 -0
- package/dist/runtime/bun-process-runner.d.ts +2 -0
- package/dist/runtime/bun-process-runner.d.ts.map +1 -0
- package/dist/runtime/bun-process-runner.js +2 -0
- package/dist/runtime/bun-process-runner.js.map +1 -0
- package/dist/runtime.d.ts +2 -8
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +1 -7
- package/dist/runtime.js.map +1 -1
- package/dist/tagma.d.ts +3 -4
- package/dist/tagma.d.ts.map +1 -1
- package/dist/tagma.js +2 -3
- package/dist/tagma.js.map +1 -1
- package/dist/triggers/file.d.ts.map +1 -1
- package/dist/triggers/file.js +74 -107
- package/dist/triggers/file.js.map +1 -1
- package/package.json +15 -4
- package/src/adapters/stdin-approval.ts +1 -106
- package/src/adapters/websocket-approval.ts +1 -224
- package/src/approval.ts +5 -127
- package/src/bootstrap.ts +1 -1
- package/src/core/run-context.test.ts +35 -0
- package/src/core/task-executor.ts +13 -4
- package/src/engine-ports-mixed.test.ts +70 -44
- package/src/engine-ports.test.ts +77 -33
- package/src/engine.ts +18 -444
- package/src/index.ts +4 -6
- package/src/logger.ts +2 -182
- package/src/package-split.test.ts +15 -0
- package/src/pipeline-runner.test.ts +65 -12
- package/src/plugin-registry.test.ts +69 -3
- package/src/plugins.ts +2 -2
- package/src/registry.ts +7 -353
- package/src/runner.ts +1 -666
- package/src/runtime/adapters/stdin-approval.ts +1 -0
- package/src/runtime/adapters/websocket-approval.ts +1 -0
- package/src/runtime/bun-process-runner.ts +1 -0
- package/src/runtime-adapters.test.ts +10 -0
- package/src/runtime.ts +12 -20
- package/src/tagma.test.ts +162 -0
- package/src/tagma.ts +9 -4
- package/src/triggers/file.test.ts +79 -0
- package/src/triggers/file.ts +85 -118
|
@@ -1,224 +1 @@
|
|
|
1
|
-
|
|
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
|
-
// actor?: string, reason?: string }
|
|
20
|
-
|
|
21
|
-
export interface WebSocketApprovalAdapterOptions {
|
|
22
|
-
port?: number; // default: 3000
|
|
23
|
-
hostname?: string; // default: 'localhost'
|
|
24
|
-
/**
|
|
25
|
-
* M11: shared secret required from the client during the WebSocket
|
|
26
|
-
* upgrade. The token can be supplied either as the `?token=` query
|
|
27
|
-
* parameter or in the `x-tagma-token` request header. When set, any
|
|
28
|
-
* upgrade request that fails the check is rejected with HTTP 401 and
|
|
29
|
-
* never reaches the WebSocket layer (so a misconfigured client cannot
|
|
30
|
-
* exhaust rate-limit slots either). Leave undefined for backward
|
|
31
|
-
* compatibility with localhost-only deployments.
|
|
32
|
-
*/
|
|
33
|
-
token?: string;
|
|
34
|
-
/**
|
|
35
|
-
* M11: opt-out of origin checking. Defaults to false, meaning Origin
|
|
36
|
-
* headers are restricted to loopback hosts (localhost / 127.0.0.1 / ::1).
|
|
37
|
-
* Requests without an Origin header are still allowed so non-browser local
|
|
38
|
-
* clients can connect. Set true only for trusted reverse-proxy setups.
|
|
39
|
-
*/
|
|
40
|
-
allowAnyOrigin?: boolean;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface WebSocketApprovalAdapter {
|
|
44
|
-
readonly port: number;
|
|
45
|
-
readonly detach: () => void;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Maximum allowed message payload (bytes) to prevent DoS via oversized messages.
|
|
49
|
-
const MAX_PAYLOAD_BYTES = 4_096;
|
|
50
|
-
// Per-client rate limit: at most this many messages per window.
|
|
51
|
-
const RATE_LIMIT_MAX = 10;
|
|
52
|
-
const RATE_LIMIT_WINDOW_MS = 1_000;
|
|
53
|
-
|
|
54
|
-
export function attachWebSocketApprovalAdapter(
|
|
55
|
-
gateway: ApprovalGateway,
|
|
56
|
-
options: WebSocketApprovalAdapterOptions = {},
|
|
57
|
-
): WebSocketApprovalAdapter {
|
|
58
|
-
const port = options.port ?? 3000;
|
|
59
|
-
const hostname = options.hostname ?? 'localhost';
|
|
60
|
-
const requiredToken = options.token ?? null;
|
|
61
|
-
const enforceOriginCheck = options.allowAnyOrigin !== true;
|
|
62
|
-
|
|
63
|
-
function isLoopbackOrigin(origin: string): boolean {
|
|
64
|
-
try {
|
|
65
|
-
const host = new URL(origin).hostname.toLowerCase();
|
|
66
|
-
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
|
67
|
-
} catch {
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
type WS = import('bun').ServerWebSocket<unknown>;
|
|
73
|
-
const clients = new Set<WS>();
|
|
74
|
-
const clientRates = new Map<WS, { count: number; resetAt: number }>();
|
|
75
|
-
|
|
76
|
-
function broadcast(msg: unknown): void {
|
|
77
|
-
const text = JSON.stringify(msg);
|
|
78
|
-
for (const ws of clients) {
|
|
79
|
-
ws.send(text);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const unsubscribe = gateway.subscribe((event: ApprovalEvent) => {
|
|
84
|
-
switch (event.type) {
|
|
85
|
-
case 'requested':
|
|
86
|
-
broadcast({ type: 'approval_requested', request: event.request });
|
|
87
|
-
break;
|
|
88
|
-
case 'resolved':
|
|
89
|
-
broadcast({ type: 'approval_resolved', request: event.request, decision: event.decision });
|
|
90
|
-
break;
|
|
91
|
-
case 'expired':
|
|
92
|
-
broadcast({ type: 'approval_expired', request: event.request });
|
|
93
|
-
break;
|
|
94
|
-
case 'aborted':
|
|
95
|
-
broadcast({ type: 'approval_aborted', request: event.request, reason: event.reason });
|
|
96
|
-
break;
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
const server = Bun.serve({
|
|
101
|
-
port,
|
|
102
|
-
hostname,
|
|
103
|
-
|
|
104
|
-
fetch(req, server) {
|
|
105
|
-
if (enforceOriginCheck) {
|
|
106
|
-
const origin = req.headers.get('origin');
|
|
107
|
-
if (origin && !isLoopbackOrigin(origin)) {
|
|
108
|
-
return new Response('forbidden origin', { status: 403 });
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
// M11: enforce token before any upgrade so an unauthenticated client
|
|
112
|
-
// can't even open a socket. Tokens may arrive via header or query.
|
|
113
|
-
if (requiredToken !== null) {
|
|
114
|
-
const headerToken = req.headers.get('x-tagma-token') ?? '';
|
|
115
|
-
let queryToken = '';
|
|
116
|
-
try {
|
|
117
|
-
queryToken = new URL(req.url).searchParams.get('token') ?? '';
|
|
118
|
-
} catch {
|
|
119
|
-
/* malformed URL — leave queryToken empty */
|
|
120
|
-
}
|
|
121
|
-
const presented = headerToken || queryToken;
|
|
122
|
-
if (presented !== requiredToken) {
|
|
123
|
-
return new Response('unauthorized', { status: 401 });
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
if (server.upgrade(req)) return undefined;
|
|
127
|
-
return new Response('tagma-sdk WebSocket approval endpoint', { status: 426 });
|
|
128
|
-
},
|
|
129
|
-
|
|
130
|
-
websocket: {
|
|
131
|
-
open(ws) {
|
|
132
|
-
clients.add(ws);
|
|
133
|
-
// Sync current pending approvals to newly connected client.
|
|
134
|
-
ws.send(JSON.stringify({ type: 'pending', requests: gateway.pending() }));
|
|
135
|
-
},
|
|
136
|
-
|
|
137
|
-
message(ws, raw) {
|
|
138
|
-
const rawStr = typeof raw === 'string' ? raw : raw.toString();
|
|
139
|
-
|
|
140
|
-
// Payload size guard — reject oversized messages before parsing.
|
|
141
|
-
if (rawStr.length > MAX_PAYLOAD_BYTES) {
|
|
142
|
-
ws.send(JSON.stringify({ type: 'error', message: 'message too large' }));
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Per-client rate limit.
|
|
147
|
-
const now = Date.now();
|
|
148
|
-
const rate = clientRates.get(ws) ?? { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS };
|
|
149
|
-
if (now >= rate.resetAt) {
|
|
150
|
-
rate.count = 0;
|
|
151
|
-
rate.resetAt = now + RATE_LIMIT_WINDOW_MS;
|
|
152
|
-
}
|
|
153
|
-
rate.count++;
|
|
154
|
-
clientRates.set(ws, rate);
|
|
155
|
-
if (rate.count > RATE_LIMIT_MAX) {
|
|
156
|
-
ws.send(JSON.stringify({ type: 'error', message: 'rate limit exceeded' }));
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
let msg: unknown;
|
|
161
|
-
try {
|
|
162
|
-
msg = JSON.parse(rawStr);
|
|
163
|
-
} catch {
|
|
164
|
-
ws.send(JSON.stringify({ type: 'error', message: 'invalid JSON' }));
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (!isResolveMessage(msg)) {
|
|
169
|
-
ws.send(JSON.stringify({ type: 'error', message: 'unknown message type' }));
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const ok = gateway.resolve(msg.approvalId, {
|
|
174
|
-
outcome: msg.outcome,
|
|
175
|
-
actor: msg.actor ?? 'websocket',
|
|
176
|
-
reason: msg.reason,
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
if (!ok) {
|
|
180
|
-
ws.send(
|
|
181
|
-
JSON.stringify({
|
|
182
|
-
type: 'error',
|
|
183
|
-
message: `approval ${msg.approvalId} not found or already resolved`,
|
|
184
|
-
}),
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
},
|
|
188
|
-
|
|
189
|
-
close(ws) {
|
|
190
|
-
clients.delete(ws);
|
|
191
|
-
clientRates.delete(ws);
|
|
192
|
-
},
|
|
193
|
-
},
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
return {
|
|
197
|
-
port: server.port!,
|
|
198
|
-
detach() {
|
|
199
|
-
unsubscribe();
|
|
200
|
-
clients.clear();
|
|
201
|
-
server.stop(true);
|
|
202
|
-
},
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// ── Type guard ──
|
|
207
|
-
|
|
208
|
-
interface ResolveMessage {
|
|
209
|
-
type: 'resolve';
|
|
210
|
-
approvalId: string;
|
|
211
|
-
outcome: 'approved' | 'rejected';
|
|
212
|
-
actor?: string;
|
|
213
|
-
reason?: string;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function isResolveMessage(v: unknown): v is ResolveMessage {
|
|
217
|
-
if (typeof v !== 'object' || v === null) return false;
|
|
218
|
-
const m = v as Record<string, unknown>;
|
|
219
|
-
return (
|
|
220
|
-
m['type'] === 'resolve' &&
|
|
221
|
-
typeof m['approvalId'] === 'string' &&
|
|
222
|
-
(m['outcome'] === 'approved' || m['outcome'] === 'rejected')
|
|
223
|
-
);
|
|
224
|
-
}
|
|
1
|
+
export * from '../runtime/adapters/websocket-approval';
|
package/src/approval.ts
CHANGED
|
@@ -1,131 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
import { nowISO } from './utils';
|
|
3
|
-
|
|
4
|
-
// Approval types (ApprovalRequest, ApprovalDecision, ApprovalOutcome,
|
|
5
|
-
// ApprovalEvent, ApprovalListener, ApprovalGateway) live in the shared
|
|
6
|
-
// @tagma/types package so trigger plugins can import them without
|
|
7
|
-
// depending on the engine's runtime implementation. This module keeps
|
|
8
|
-
// only the in-memory implementation. Internal SDK imports go through
|
|
9
|
-
// ./types (the engine-side re-export) for consistency with the rest of
|
|
10
|
-
// the SDK source.
|
|
11
|
-
import type {
|
|
12
|
-
ApprovalRequest,
|
|
13
|
-
ApprovalDecision,
|
|
14
|
-
ApprovalEvent,
|
|
15
|
-
ApprovalListener,
|
|
16
|
-
ApprovalGateway,
|
|
17
|
-
} from './types';
|
|
18
|
-
|
|
19
|
-
// Re-export for existing engine-side consumers that import from this file.
|
|
1
|
+
export { InMemoryApprovalGateway } from '@tagma/core';
|
|
20
2
|
export type {
|
|
21
|
-
ApprovalRequest,
|
|
22
3
|
ApprovalDecision,
|
|
23
|
-
ApprovalOutcome,
|
|
24
4
|
ApprovalEvent,
|
|
25
|
-
ApprovalListener,
|
|
26
5
|
ApprovalGateway,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
interface PendingEntry {
|
|
32
|
-
readonly request: ApprovalRequest;
|
|
33
|
-
readonly settle: (decision: ApprovalDecision) => void;
|
|
34
|
-
readonly timer: ReturnType<typeof setTimeout> | null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export class InMemoryApprovalGateway implements ApprovalGateway {
|
|
38
|
-
private readonly pendingMap = new Map<string, PendingEntry>();
|
|
39
|
-
private readonly listeners = new Set<ApprovalListener>();
|
|
40
|
-
|
|
41
|
-
request(req: Omit<ApprovalRequest, 'id' | 'createdAt'>): Promise<ApprovalDecision> {
|
|
42
|
-
const full: ApprovalRequest = {
|
|
43
|
-
id: randomUUID(),
|
|
44
|
-
createdAt: nowISO(),
|
|
45
|
-
taskId: req.taskId,
|
|
46
|
-
trackId: req.trackId,
|
|
47
|
-
message: req.message,
|
|
48
|
-
timeoutMs: req.timeoutMs,
|
|
49
|
-
metadata: req.metadata,
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
return new Promise<ApprovalDecision>((resolvePromise) => {
|
|
53
|
-
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
54
|
-
if (full.timeoutMs > 0) {
|
|
55
|
-
timer = setTimeout(() => {
|
|
56
|
-
const entry = this.pendingMap.get(full.id);
|
|
57
|
-
if (!entry) return;
|
|
58
|
-
this.pendingMap.delete(full.id);
|
|
59
|
-
const decision: ApprovalDecision = {
|
|
60
|
-
approvalId: full.id,
|
|
61
|
-
outcome: 'timeout',
|
|
62
|
-
reason: `Approval timed out after ${full.timeoutMs}ms`,
|
|
63
|
-
decidedAt: nowISO(),
|
|
64
|
-
};
|
|
65
|
-
this.emit({ type: 'expired', request: full });
|
|
66
|
-
resolvePromise(decision);
|
|
67
|
-
}, full.timeoutMs);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
this.pendingMap.set(full.id, { request: full, settle: resolvePromise, timer });
|
|
71
|
-
this.emit({ type: 'requested', request: full });
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
resolve(
|
|
76
|
-
approvalId: string,
|
|
77
|
-
decision: Omit<ApprovalDecision, 'approvalId' | 'decidedAt'>,
|
|
78
|
-
): boolean {
|
|
79
|
-
const entry = this.pendingMap.get(approvalId);
|
|
80
|
-
if (!entry) return false;
|
|
81
|
-
this.pendingMap.delete(approvalId);
|
|
82
|
-
if (entry.timer) clearTimeout(entry.timer);
|
|
83
|
-
|
|
84
|
-
const full: ApprovalDecision = {
|
|
85
|
-
approvalId,
|
|
86
|
-
outcome: decision.outcome,
|
|
87
|
-
actor: decision.actor,
|
|
88
|
-
reason: decision.reason,
|
|
89
|
-
decidedAt: nowISO(),
|
|
90
|
-
};
|
|
91
|
-
this.emit({ type: 'resolved', request: entry.request, decision: full });
|
|
92
|
-
entry.settle(full);
|
|
93
|
-
return true;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
pending(): readonly ApprovalRequest[] {
|
|
97
|
-
return Array.from(this.pendingMap.values()).map((e) => e.request);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
subscribe(listener: ApprovalListener): () => void {
|
|
101
|
-
this.listeners.add(listener);
|
|
102
|
-
return () => {
|
|
103
|
-
this.listeners.delete(listener);
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
abortAll(reason: string): void {
|
|
108
|
-
const entries = Array.from(this.pendingMap.entries());
|
|
109
|
-
this.pendingMap.clear();
|
|
110
|
-
for (const [id, entry] of entries) {
|
|
111
|
-
if (entry.timer) clearTimeout(entry.timer);
|
|
112
|
-
this.emit({ type: 'aborted', request: entry.request, reason });
|
|
113
|
-
entry.settle({
|
|
114
|
-
approvalId: id,
|
|
115
|
-
outcome: 'aborted',
|
|
116
|
-
reason,
|
|
117
|
-
decidedAt: nowISO(),
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
private emit(event: ApprovalEvent): void {
|
|
123
|
-
for (const listener of this.listeners) {
|
|
124
|
-
try {
|
|
125
|
-
listener(event);
|
|
126
|
-
} catch (err) {
|
|
127
|
-
console.error('[approval gateway] listener error:', err);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
6
|
+
ApprovalListener,
|
|
7
|
+
ApprovalOutcome,
|
|
8
|
+
ApprovalRequest,
|
|
9
|
+
} from '@tagma/core';
|
package/src/bootstrap.ts
CHANGED
|
@@ -12,6 +12,41 @@ const fakeRuntime: TagmaRuntime = {
|
|
|
12
12
|
async runSpawn() {
|
|
13
13
|
throw new Error('fakeRuntime.runSpawn should not be called by RunContext tests');
|
|
14
14
|
},
|
|
15
|
+
async ensureDir() {
|
|
16
|
+
/* no-op */
|
|
17
|
+
},
|
|
18
|
+
async fileExists() {
|
|
19
|
+
return false;
|
|
20
|
+
},
|
|
21
|
+
async *watch() {
|
|
22
|
+
/* no-op */
|
|
23
|
+
},
|
|
24
|
+
logStore: {
|
|
25
|
+
openRunLog() {
|
|
26
|
+
return {
|
|
27
|
+
path: 'mem://pipeline.log',
|
|
28
|
+
dir: 'mem://run',
|
|
29
|
+
append() {
|
|
30
|
+
/* no-op */
|
|
31
|
+
},
|
|
32
|
+
close() {
|
|
33
|
+
/* no-op */
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
taskOutputPath({ taskId, stream }) {
|
|
38
|
+
return `mem://${taskId}.${stream}`;
|
|
39
|
+
},
|
|
40
|
+
logsDir() {
|
|
41
|
+
return 'mem://logs';
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
now() {
|
|
45
|
+
return new Date('2026-04-26T00:00:00.000Z');
|
|
46
|
+
},
|
|
47
|
+
sleep() {
|
|
48
|
+
return Promise.resolve();
|
|
49
|
+
},
|
|
15
50
|
};
|
|
16
51
|
|
|
17
52
|
function makeContext(overrides: Partial<{
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { resolve } from 'path';
|
|
2
1
|
import type {
|
|
3
2
|
CompletionPlugin,
|
|
4
3
|
DriverContext,
|
|
@@ -136,6 +135,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
|
|
|
136
135
|
workDir: task.cwd ?? workDir,
|
|
137
136
|
signal: ctx.abortController.signal,
|
|
138
137
|
approvalGateway,
|
|
138
|
+
runtime: ctx.runtime,
|
|
139
139
|
})
|
|
140
140
|
.then(
|
|
141
141
|
(v) => {
|
|
@@ -395,9 +395,18 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
|
|
|
395
395
|
// and keep only a bounded tail in the returned TaskResult. Filenames
|
|
396
396
|
// mirror the existing `.stderr` naming — dots in task ids are replaced
|
|
397
397
|
// so hierarchical ids (e.g. `track1.task2`) map cleanly to a flat dir.
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
398
|
+
const stdoutPath = ctx.runtime.logStore.taskOutputPath({
|
|
399
|
+
workDir,
|
|
400
|
+
runId: ctx.runId,
|
|
401
|
+
taskId,
|
|
402
|
+
stream: 'stdout',
|
|
403
|
+
});
|
|
404
|
+
const stderrPath = ctx.runtime.logStore.taskOutputPath({
|
|
405
|
+
workDir,
|
|
406
|
+
runId: ctx.runId,
|
|
407
|
+
taskId,
|
|
408
|
+
stream: 'stderr',
|
|
409
|
+
});
|
|
401
410
|
const runOpts = {
|
|
402
411
|
timeoutMs,
|
|
403
412
|
signal: ctx.abortController.signal,
|
|
@@ -5,7 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { bootstrapBuiltins } from './bootstrap';
|
|
6
6
|
import { runPipeline, type RunEventPayload } from './engine';
|
|
7
7
|
import { PluginRegistry } from './registry';
|
|
8
|
-
import type { DriverPlugin, PipelineConfig, TaskConfig } from './types';
|
|
8
|
+
import type { DriverPlugin, PipelineConfig, TagmaRuntime, TaskConfig, TaskResult } from './types';
|
|
9
9
|
|
|
10
10
|
const PERMS = { read: true, write: false, execute: false };
|
|
11
11
|
|
|
@@ -13,40 +13,7 @@ function makeDir(): string {
|
|
|
13
13
|
return mkdtempSync(join(tmpdir(), 'tagma-bindings-mixed-'));
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function
|
|
17
|
-
const path = join(dir, `${name}.js`);
|
|
18
|
-
writeFileSync(
|
|
19
|
-
path,
|
|
20
|
-
`process.stdout.write(${JSON.stringify(JSON.stringify(payload))});\nprocess.stdout.write('\\n');\n`,
|
|
21
|
-
);
|
|
22
|
-
return path;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function writeEchoArgsScript(dir: string): string {
|
|
26
|
-
const path = join(dir, 'echo.js');
|
|
27
|
-
writeFileSync(path, `process.stdout.write(process.argv.slice(2).join('|'));\n`);
|
|
28
|
-
return path;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function writeMockDriverScript(dir: string): string {
|
|
32
|
-
const path = join(dir, 'mock-driver.js');
|
|
33
|
-
writeFileSync(
|
|
34
|
-
path,
|
|
35
|
-
[
|
|
36
|
-
`const fs = require('fs');`,
|
|
37
|
-
`let buf = '';`,
|
|
38
|
-
`process.stdin.setEncoding('utf8');`,
|
|
39
|
-
`process.stdin.on('data', (c) => { buf += c; });`,
|
|
40
|
-
`process.stdin.on('end', () => {`,
|
|
41
|
-
` fs.writeFileSync(process.env.MOCK_RECORD_PATH, buf);`,
|
|
42
|
-
` process.stdout.write(process.env.MOCK_RESPONSE + '\\n');`,
|
|
43
|
-
`});`,
|
|
44
|
-
].join('\n'),
|
|
45
|
-
);
|
|
46
|
-
return path;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function registry(script: string, responses: Record<string, Record<string, unknown>>, records: Record<string, string>) {
|
|
16
|
+
function registry(responses: Record<string, Record<string, unknown>>, records: Record<string, string>) {
|
|
50
17
|
const reg = new PluginRegistry();
|
|
51
18
|
bootstrapBuiltins(reg);
|
|
52
19
|
const driver: DriverPlugin = {
|
|
@@ -54,7 +21,7 @@ function registry(script: string, responses: Record<string, Record<string, unkno
|
|
|
54
21
|
capabilities: { sessionResume: false, systemPrompt: true, outputFormat: true },
|
|
55
22
|
async buildCommand(task) {
|
|
56
23
|
return {
|
|
57
|
-
args: ['
|
|
24
|
+
args: ['mock-driver', task.id],
|
|
58
25
|
stdin: task.prompt ?? '',
|
|
59
26
|
env: {
|
|
60
27
|
MOCK_RESPONSE: JSON.stringify(responses[task.id] ?? {}),
|
|
@@ -85,12 +52,75 @@ async function run(config: PipelineConfig, workDir: string, reg: PluginRegistry)
|
|
|
85
52
|
const events: RunEventPayload[] = [];
|
|
86
53
|
const result = await runPipeline(config, workDir, {
|
|
87
54
|
registry: reg,
|
|
55
|
+
runtime: fakeRuntime(),
|
|
88
56
|
skipPluginLoading: true,
|
|
89
57
|
onEvent: (e) => events.push(e),
|
|
90
58
|
});
|
|
91
59
|
return { events, success: result.success };
|
|
92
60
|
}
|
|
93
61
|
|
|
62
|
+
function taskResult(stdout: string, normalizedOutput: string | null = null): TaskResult {
|
|
63
|
+
return {
|
|
64
|
+
exitCode: 0,
|
|
65
|
+
stdout,
|
|
66
|
+
stderr: '',
|
|
67
|
+
stdoutPath: null,
|
|
68
|
+
stderrPath: null,
|
|
69
|
+
stdoutBytes: stdout.length,
|
|
70
|
+
stderrBytes: 0,
|
|
71
|
+
durationMs: 1,
|
|
72
|
+
sessionId: null,
|
|
73
|
+
normalizedOutput,
|
|
74
|
+
failureKind: null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function fakeRuntime(): TagmaRuntime {
|
|
79
|
+
return {
|
|
80
|
+
async runCommand(command) {
|
|
81
|
+
if (command.startsWith('emit-city')) return taskResult('{"city":"Berlin"}\n');
|
|
82
|
+
return taskResult('ok\n');
|
|
83
|
+
},
|
|
84
|
+
async runSpawn(spec) {
|
|
85
|
+
const response = spec.env?.['MOCK_RESPONSE'] ?? '{}';
|
|
86
|
+
const recordPath = spec.env?.['MOCK_RECORD_PATH'];
|
|
87
|
+
if (recordPath) writeFileSync(recordPath, spec.stdin ?? '');
|
|
88
|
+
return taskResult(response + '\n', response);
|
|
89
|
+
},
|
|
90
|
+
async ensureDir() {
|
|
91
|
+
/* no-op */
|
|
92
|
+
},
|
|
93
|
+
async fileExists() {
|
|
94
|
+
return false;
|
|
95
|
+
},
|
|
96
|
+
async *watch() {
|
|
97
|
+
/* no-op */
|
|
98
|
+
},
|
|
99
|
+
logStore: {
|
|
100
|
+
openRunLog({ runId }) {
|
|
101
|
+
return {
|
|
102
|
+
path: `mem://${runId}/pipeline.log`,
|
|
103
|
+
dir: `mem://${runId}`,
|
|
104
|
+
append() {
|
|
105
|
+
/* memory sink */
|
|
106
|
+
},
|
|
107
|
+
close() {
|
|
108
|
+
/* memory sink */
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
taskOutputPath({ runId, taskId, stream }) {
|
|
113
|
+
return `mem://${runId}/${taskId}.${stream}`;
|
|
114
|
+
},
|
|
115
|
+
logsDir() {
|
|
116
|
+
return 'mem://logs';
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
now: () => new Date('2026-04-26T00:00:00.000Z'),
|
|
120
|
+
sleep: () => Promise.resolve(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
94
124
|
function finalUpdateFor(events: RunEventPayload[], qid: string): RunEventPayload | undefined {
|
|
95
125
|
let last: RunEventPayload | undefined;
|
|
96
126
|
for (const ev of events) {
|
|
@@ -103,17 +133,15 @@ describe('engine — mixed prompt/command unified bindings', () => {
|
|
|
103
133
|
test('prompt outputs are inferred from downstream command inputs', async () => {
|
|
104
134
|
const dir = makeDir();
|
|
105
135
|
try {
|
|
106
|
-
const driverScript = writeMockDriverScript(dir);
|
|
107
|
-
const echo = writeEchoArgsScript(dir);
|
|
108
136
|
const record = join(dir, 'prompt.txt');
|
|
109
|
-
const reg = registry(
|
|
137
|
+
const reg = registry({ plan: { city: 'Paris' } }, { plan: record });
|
|
110
138
|
const config = pipeline([
|
|
111
139
|
task({ id: 'plan', prompt: 'Pick a city' }),
|
|
112
140
|
task({
|
|
113
141
|
id: 'fetch',
|
|
114
142
|
driver: 'opencode',
|
|
115
143
|
depends_on: ['plan'],
|
|
116
|
-
command:
|
|
144
|
+
command: 'echo-city "{{inputs.city}}"',
|
|
117
145
|
inputs: { city: { from: 't.plan.outputs.city', type: 'string', required: true } },
|
|
118
146
|
}),
|
|
119
147
|
]);
|
|
@@ -131,15 +159,13 @@ describe('engine — mixed prompt/command unified bindings', () => {
|
|
|
131
159
|
test('prompt inputs are inferred from upstream command outputs', async () => {
|
|
132
160
|
const dir = makeDir();
|
|
133
161
|
try {
|
|
134
|
-
const emit = writeEmitScript(dir, 'emit', { city: 'Berlin' });
|
|
135
|
-
const driverScript = writeMockDriverScript(dir);
|
|
136
162
|
const record = join(dir, 'prompt.txt');
|
|
137
|
-
const reg = registry(
|
|
163
|
+
const reg = registry({ summarize: {} }, { summarize: record });
|
|
138
164
|
const config = pipeline([
|
|
139
165
|
task({
|
|
140
166
|
id: 'up',
|
|
141
167
|
driver: 'opencode',
|
|
142
|
-
command:
|
|
168
|
+
command: 'emit-city',
|
|
143
169
|
outputs: { city: { type: 'string' } },
|
|
144
170
|
}),
|
|
145
171
|
task({ id: 'summarize', depends_on: ['up'], prompt: 'City is {{inputs.city}}' }),
|