@tagma/sdk 0.1.8 → 0.1.9

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/src/runner.ts CHANGED
@@ -1,196 +1,214 @@
1
- import { existsSync } from 'node:fs';
2
- import { isAbsolute, join } from 'node:path';
3
- import type { SpawnSpec, DriverPlugin, TaskResult, TaskConfig } from './types';
4
- import { shellArgs } from './utils';
5
-
6
- export interface RunOptions {
7
- readonly timeoutMs?: number;
8
- readonly signal?: AbortSignal; // pipeline-level abort
9
- }
10
-
11
- /**
12
- * On Windows, Bun.spawn does NOT auto-append PATHEXT extensions like
13
- * CreateProcess does. A bare command like `claude` fails with ENOENT if the
14
- * actual file on disk is `claude.cmd` / `claude.bat` / `claude.ps1`. We
15
- * manually resolve the command against PATH + PATHEXT here so Drivers can
16
- * keep using short names (`claude`, `npx`, etc.) cross-platform.
17
- *
18
- * Returns the original name if resolution fails; Bun will raise the same
19
- * ENOENT it would have otherwise.
20
- */
21
- function resolveWindowsExe(
22
- args: readonly string[],
23
- envPath: string,
24
- ): readonly string[] {
25
- if (process.platform !== 'win32' || args.length === 0) return args;
26
- const cmd = args[0]!;
27
- // Already a full path or has an extension → trust caller.
28
- if (isAbsolute(cmd) || /\.[a-z0-9]+$/i.test(cmd)) return args;
29
-
30
- const exts = (
31
- process.env.PATHEXT ??
32
- '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC'
33
- )
34
- .split(';')
35
- .filter(Boolean);
36
- const dirs = envPath.split(';').filter(Boolean);
37
-
38
- for (const dir of dirs) {
39
- for (const ext of exts) {
40
- const candidate = join(dir, cmd + ext);
41
- if (existsSync(candidate)) {
42
- return [candidate, ...args.slice(1)];
43
- }
44
- }
45
- }
46
- return args;
47
- }
48
-
49
- /** Build a "failed before spawn" result. */
50
- function failResult(stderr: string, durationMs: number): TaskResult {
51
- return {
52
- exitCode: -1,
53
- stdout: '',
54
- stderr,
55
- outputPath: null,
56
- stderrPath: null,
57
- durationMs,
58
- sessionId: null,
59
- normalizedOutput: null,
60
- };
61
- }
62
-
63
- export async function runSpawn(
64
- spec: SpawnSpec,
65
- driver: DriverPlugin | null,
66
- opts: RunOptions = {},
67
- ): Promise<TaskResult> {
68
- const { timeoutMs, signal } = opts;
69
- const start = performance.now();
70
- const elapsed = () => Math.round(performance.now() - start);
71
-
72
- const mergedEnv = { ...process.env, ...(spec.env ?? {}) };
73
- const resolvedArgs = resolveWindowsExe(
74
- spec.args,
75
- mergedEnv.PATH ?? process.env.PATH ?? '',
76
- );
77
-
78
- // ── 1. Spawn (catch ENOENT / bad-cwd up front) ────────────────────────
79
- let proc: ReturnType<typeof Bun.spawn>;
80
- try {
81
- proc = Bun.spawn(resolvedArgs as string[], {
82
- cwd: spec.cwd,
83
- env: mergedEnv,
84
- stdout: 'pipe',
85
- stderr: 'pipe',
86
- stdin: spec.stdin ? 'pipe' : undefined,
87
- });
88
- } catch (err) {
89
- return failResult(String(err), elapsed());
90
- }
91
-
92
- // ── 2. Write stdin ─────────────────────────────────────────────────────
93
- // Child may exit before reading (e.g. quick-fail commands that don't
94
- // touch stdin) → swallow EPIPE rather than surfacing it as an
95
- // engine-level error.
96
- if (spec.stdin && proc.stdin && typeof proc.stdin !== 'number') {
97
- try {
98
- proc.stdin.write(spec.stdin);
99
- proc.stdin.end();
100
- } catch {
101
- /* ignore EPIPE / closed-pipe errors */
102
- }
103
- }
104
-
105
- // ── 3. Timeout & abort handling ────────────────────────────────────────
106
- let killedByUs = false;
107
- let timer: ReturnType<typeof setTimeout> | null = null;
108
- let forceTimer: ReturnType<typeof setTimeout> | null = null;
109
-
110
- const killGracefully = () => {
111
- if (killedByUs) return;
112
- killedByUs = true;
113
- proc.kill('SIGTERM');
114
- // If the child ignores SIGTERM, escalate to SIGKILL after 3 s.
115
- forceTimer = setTimeout(() => {
116
- try {
117
- proc.kill('SIGKILL');
118
- } catch {
119
- /* already exited */
120
- }
121
- }, 3_000);
122
- };
123
-
124
- if (timeoutMs && timeoutMs > 0) {
125
- timer = setTimeout(killGracefully, timeoutMs);
126
- }
127
-
128
- const onAbort = () => killGracefully();
129
- if (signal) {
130
- if (signal.aborted) {
131
- killGracefully();
132
- } else {
133
- signal.addEventListener('abort', onAbort, { once: true });
134
- }
135
- }
136
-
137
- // ── 4. Collect output & wait (parallel to avoid pipe-buffer deadlock) ─
138
- const stdoutStream = typeof proc.stdout === 'object' ? proc.stdout : undefined;
139
- const stderrStream = typeof proc.stderr === 'object' ? proc.stderr : undefined;
140
-
141
- const [exitCode, stdout, stderr] = await Promise.all([
142
- proc.exited,
143
- stdoutStream ? new Response(stdoutStream).text() : Promise.resolve(''),
144
- stderrStream ? new Response(stderrStream).text() : Promise.resolve(''),
145
- ]);
146
-
147
- // ── 5. Cleanup timers & listeners ──────────────────────────────────────
148
- if (timer) clearTimeout(timer);
149
- if (forceTimer) clearTimeout(forceTimer);
150
- if (signal) signal.removeEventListener('abort', onAbort);
151
-
152
- const durationMs = elapsed();
153
-
154
- // We initiated the kill (timeout or abort) — always treat as non-success
155
- // regardless of exit code. A process that catches SIGTERM and exits 0 still
156
- // hit the timeout; letting it pass as success would unblock downstream tasks
157
- // incorrectly.
158
- if (killedByUs) {
159
- return {
160
- exitCode: -1,
161
- stdout,
162
- stderr,
163
- outputPath: null,
164
- stderrPath: null,
165
- durationMs,
166
- sessionId: null,
167
- normalizedOutput: null,
168
- };
169
- }
170
-
171
- // ── 6. Let driver extract metadata ─────────────────────────────────────
172
- const meta = driver?.parseResult?.(stdout, stderr) ?? {};
173
-
174
- return {
175
- exitCode,
176
- stdout,
177
- stderr,
178
- outputPath: null,
179
- stderrPath: null,
180
- durationMs,
181
- sessionId: meta.sessionId ?? null,
182
- normalizedOutput: meta.normalizedOutput ?? null,
183
- };
184
- }
185
-
186
- export async function runCommand(
187
- command: string,
188
- cwd: string,
189
- opts: RunOptions = {},
190
- ): Promise<TaskResult> {
191
- const spec: SpawnSpec = {
192
- args: shellArgs(command),
193
- cwd,
194
- };
195
- return runSpawn(spec, null, opts);
1
+ import { existsSync } from 'node:fs';
2
+ import { isAbsolute, join } from 'node:path';
3
+ import type { SpawnSpec, DriverPlugin, TaskResult, TaskConfig } from './types';
4
+ import { shellArgs } from './utils';
5
+
6
+ // Delay before escalating SIGTERM to SIGKILL when killing a timed-out process.
7
+ const SIGKILL_DELAY_MS = 3_000;
8
+
9
+ export interface RunOptions {
10
+ readonly timeoutMs?: number;
11
+ readonly signal?: AbortSignal; // pipeline-level abort
12
+ }
13
+
14
+ /**
15
+ * On Windows, Bun.spawn does NOT auto-append PATHEXT extensions like
16
+ * CreateProcess does. A bare command like `claude` fails with ENOENT if the
17
+ * actual file on disk is `claude.cmd` / `claude.bat` / `claude.ps1`. We
18
+ * manually resolve the command against PATH + PATHEXT here so Drivers can
19
+ * keep using short names (`claude`, `npx`, etc.) cross-platform.
20
+ *
21
+ * Results are cached by (cmd, envPath) key so repeated spawns of the same
22
+ * command don't block the event loop with synchronous PATH scans.
23
+ *
24
+ * Returns the original name if resolution fails; Bun will raise the same
25
+ * ENOENT it would have otherwise.
26
+ */
27
+ const resolvedExeCache = new Map<string, string | null>();
28
+
29
+ function resolveWindowsExe(
30
+ args: readonly string[],
31
+ envPath: string,
32
+ ): readonly string[] {
33
+ if (process.platform !== 'win32' || args.length === 0) return args;
34
+ const cmd = args[0]!;
35
+ // Already a full path or has an extension → trust caller.
36
+ if (isAbsolute(cmd) || /\.[a-z0-9]+$/i.test(cmd)) return args;
37
+
38
+ const cacheKey = `${cmd}\x00${envPath}`;
39
+ if (resolvedExeCache.has(cacheKey)) {
40
+ // ?? null coerces undefined→null so cached is string|null and the !== null
41
+ // check narrows it to string without a spurious 'undefined' arm.
42
+ const cached = resolvedExeCache.get(cacheKey) ?? null;
43
+ return cached !== null ? [cached, ...args.slice(1)] : args;
44
+ }
45
+
46
+ const exts = (
47
+ process.env.PATHEXT ??
48
+ '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC'
49
+ )
50
+ .split(';')
51
+ .filter(Boolean);
52
+ const dirs = envPath.split(';').filter(Boolean);
53
+
54
+ for (const dir of dirs) {
55
+ for (const ext of exts) {
56
+ const candidate = join(dir, cmd + ext);
57
+ if (existsSync(candidate)) {
58
+ resolvedExeCache.set(cacheKey, candidate);
59
+ return [candidate, ...args.slice(1)];
60
+ }
61
+ }
62
+ }
63
+ resolvedExeCache.set(cacheKey, null);
64
+ return args;
65
+ }
66
+
67
+ /** Build a "failed before spawn" result. */
68
+ function failResult(stderr: string, durationMs: number): TaskResult {
69
+ return {
70
+ exitCode: -1,
71
+ stdout: '',
72
+ stderr,
73
+ outputPath: null,
74
+ stderrPath: null,
75
+ durationMs,
76
+ sessionId: null,
77
+ normalizedOutput: null,
78
+ };
79
+ }
80
+
81
+ export async function runSpawn(
82
+ spec: SpawnSpec,
83
+ driver: DriverPlugin | null,
84
+ opts: RunOptions = {},
85
+ ): Promise<TaskResult> {
86
+ const { timeoutMs, signal } = opts;
87
+ const start = performance.now();
88
+ const elapsed = () => Math.round(performance.now() - start);
89
+
90
+ const mergedEnv = { ...process.env, ...(spec.env ?? {}) };
91
+ const resolvedArgs = resolveWindowsExe(
92
+ spec.args,
93
+ mergedEnv.PATH ?? process.env.PATH ?? '',
94
+ );
95
+
96
+ // ── 1. Spawn (catch ENOENT / bad-cwd up front) ────────────────────────
97
+ let proc: ReturnType<typeof Bun.spawn>;
98
+ try {
99
+ proc = Bun.spawn(resolvedArgs as string[], {
100
+ cwd: spec.cwd,
101
+ env: mergedEnv,
102
+ stdout: 'pipe',
103
+ stderr: 'pipe',
104
+ stdin: spec.stdin ? 'pipe' : undefined,
105
+ });
106
+ } catch (err) {
107
+ return failResult(String(err), elapsed());
108
+ }
109
+
110
+ // ── 2. Write stdin ─────────────────────────────────────────────────────
111
+ // Child may exit before reading (e.g. quick-fail commands that don't
112
+ // touch stdin) → swallow EPIPE rather than surfacing it as an
113
+ // engine-level error.
114
+ if (spec.stdin && proc.stdin && typeof proc.stdin !== 'number') {
115
+ try {
116
+ proc.stdin.write(spec.stdin);
117
+ proc.stdin.end();
118
+ } catch {
119
+ /* ignore EPIPE / closed-pipe errors */
120
+ }
121
+ }
122
+
123
+ // ── 3. Timeout & abort handling ────────────────────────────────────────
124
+ let killedByUs = false;
125
+ let timer: ReturnType<typeof setTimeout> | null = null;
126
+ let forceTimer: ReturnType<typeof setTimeout> | null = null;
127
+
128
+ const killGracefully = () => {
129
+ if (killedByUs) return;
130
+ killedByUs = true;
131
+ proc.kill('SIGTERM');
132
+ // If the child ignores SIGTERM, escalate to SIGKILL after 3 s.
133
+ forceTimer = setTimeout(() => {
134
+ try {
135
+ proc.kill('SIGKILL');
136
+ } catch {
137
+ /* already exited */
138
+ }
139
+ }, SIGKILL_DELAY_MS);
140
+ };
141
+
142
+ if (timeoutMs && timeoutMs > 0) {
143
+ timer = setTimeout(killGracefully, timeoutMs);
144
+ }
145
+
146
+ const onAbort = () => killGracefully();
147
+ if (signal) {
148
+ if (signal.aborted) {
149
+ killGracefully();
150
+ } else {
151
+ signal.addEventListener('abort', onAbort, { once: true });
152
+ }
153
+ }
154
+
155
+ // ── 4. Collect output & wait (parallel to avoid pipe-buffer deadlock)
156
+ const stdoutStream = typeof proc.stdout === 'object' ? proc.stdout : undefined;
157
+ const stderrStream = typeof proc.stderr === 'object' ? proc.stderr : undefined;
158
+
159
+ const [exitCode, stdout, stderr] = await Promise.all([
160
+ proc.exited,
161
+ stdoutStream ? new Response(stdoutStream).text() : Promise.resolve(''),
162
+ stderrStream ? new Response(stderrStream).text() : Promise.resolve(''),
163
+ ]);
164
+
165
+ // ── 5. Cleanup timers & listeners ──────────────────────────────────────
166
+ if (timer) clearTimeout(timer);
167
+ if (forceTimer) clearTimeout(forceTimer);
168
+ if (signal) signal.removeEventListener('abort', onAbort);
169
+
170
+ const durationMs = elapsed();
171
+
172
+ // We initiated the kill (timeout or abort) always treat as non-success
173
+ // regardless of exit code. A process that catches SIGTERM and exits 0 still
174
+ // hit the timeout; letting it pass as success would unblock downstream tasks
175
+ // incorrectly.
176
+ if (killedByUs) {
177
+ return {
178
+ exitCode: -1,
179
+ stdout,
180
+ stderr,
181
+ outputPath: null,
182
+ stderrPath: null,
183
+ durationMs,
184
+ sessionId: null,
185
+ normalizedOutput: null,
186
+ };
187
+ }
188
+
189
+ // ── 6. Let driver extract metadata ─────────────────────────────────────
190
+ const meta = driver?.parseResult?.(stdout, stderr) ?? {};
191
+
192
+ return {
193
+ exitCode,
194
+ stdout,
195
+ stderr,
196
+ outputPath: null,
197
+ stderrPath: null,
198
+ durationMs,
199
+ sessionId: meta.sessionId ?? null,
200
+ normalizedOutput: meta.normalizedOutput ?? null,
201
+ };
202
+ }
203
+
204
+ export async function runCommand(
205
+ command: string,
206
+ cwd: string,
207
+ opts: RunOptions = {},
208
+ ): Promise<TaskResult> {
209
+ const spec: SpawnSpec = {
210
+ args: shellArgs(command),
211
+ cwd,
212
+ };
213
+ return runSpawn(spec, null, opts);
196
214
  }