@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/src/runner.ts CHANGED
@@ -1,194 +1,196 @@
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
- // If we killed the process but it had already exited with a real code
155
- // before our signal landed, don't treat it as a timeout.
156
- if (killedByUs && exitCode !== 0) {
157
- return {
158
- exitCode: -1,
159
- stdout,
160
- stderr,
161
- outputPath: null,
162
- stderrPath: null,
163
- durationMs,
164
- sessionId: null,
165
- normalizedOutput: null,
166
- };
167
- }
168
-
169
- // ── 6. Let driver extract metadata ─────────────────────────────────────
170
- const meta = driver?.parseResult?.(stdout, stderr) ?? {};
171
-
172
- return {
173
- exitCode,
174
- stdout,
175
- stderr,
176
- outputPath: null,
177
- stderrPath: null,
178
- durationMs,
179
- sessionId: meta.sessionId ?? null,
180
- normalizedOutput: meta.normalizedOutput ?? null,
181
- };
182
- }
183
-
184
- export async function runCommand(
185
- command: string,
186
- cwd: string,
187
- opts: RunOptions = {},
188
- ): Promise<TaskResult> {
189
- const spec: SpawnSpec = {
190
- args: shellArgs(command),
191
- cwd,
192
- };
193
- 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
+ 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);
194
196
  }