@tagma/sdk 0.1.2
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 +1 -0
- package/package.json +32 -0
- package/src/adapters/stdin-approval.ts +117 -0
- package/src/adapters/websocket-approval.ts +144 -0
- package/src/approval.ts +125 -0
- package/src/bootstrap.ts +37 -0
- package/src/completions/exit-code.ts +19 -0
- package/src/completions/file-exists.ts +39 -0
- package/src/completions/output-check.ts +57 -0
- package/src/dag.ts +137 -0
- package/src/drivers/claude-code.ts +207 -0
- package/src/engine.ts +598 -0
- package/src/hooks.ts +138 -0
- package/src/logger.ts +100 -0
- package/src/middlewares/static-context.ts +29 -0
- package/src/registry.ts +56 -0
- package/src/runner.ts +194 -0
- package/src/schema.ts +260 -0
- package/src/sdk.ts +48 -0
- package/src/triggers/file.ts +94 -0
- package/src/triggers/manual.ts +61 -0
- package/src/types.ts +18 -0
- package/src/utils.ts +147 -0
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { HooksConfig, HookCommand } from './types';
|
|
2
|
+
import { shellArgs } from './utils';
|
|
3
|
+
|
|
4
|
+
type HookEvent =
|
|
5
|
+
| 'pipeline_start' | 'task_start' | 'task_success'
|
|
6
|
+
| 'task_failure' | 'pipeline_complete' | 'pipeline_error';
|
|
7
|
+
|
|
8
|
+
const GATE_HOOKS: ReadonlySet<HookEvent> = new Set(['pipeline_start', 'task_start']);
|
|
9
|
+
|
|
10
|
+
export interface HookResult {
|
|
11
|
+
readonly allowed: boolean; // for gate hooks: true = proceed, false = block
|
|
12
|
+
readonly exitCode: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeCommands(cmd: HookCommand | undefined): readonly string[] {
|
|
16
|
+
if (!cmd) return [];
|
|
17
|
+
if (typeof cmd === 'string') return [cmd];
|
|
18
|
+
return cmd;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function runSingleHook(command: string, context: unknown, cwd?: string): Promise<number> {
|
|
22
|
+
const jsonInput = JSON.stringify(context, null, 2);
|
|
23
|
+
|
|
24
|
+
const proc = Bun.spawn(shellArgs(command) as string[], {
|
|
25
|
+
stdin: 'pipe',
|
|
26
|
+
stdout: 'pipe',
|
|
27
|
+
stderr: 'pipe',
|
|
28
|
+
...(cwd ? { cwd } : {}),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (proc.stdin) {
|
|
32
|
+
try {
|
|
33
|
+
proc.stdin.write(jsonInput);
|
|
34
|
+
proc.stdin.end();
|
|
35
|
+
} catch {
|
|
36
|
+
// Process may exit before reading stdin (e.g. `exit 1`), ignore EPIPE
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const exitCode = await proc.exited;
|
|
41
|
+
const stderr = await new Response(proc.stderr).text();
|
|
42
|
+
|
|
43
|
+
if (stderr.trim()) {
|
|
44
|
+
console.error(`[hook: ${command}] stderr: ${stderr.trim()}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return exitCode;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function executeHook(
|
|
51
|
+
hooks: HooksConfig | undefined,
|
|
52
|
+
event: HookEvent,
|
|
53
|
+
context: unknown,
|
|
54
|
+
workDir?: string,
|
|
55
|
+
): Promise<HookResult> {
|
|
56
|
+
if (!hooks) return { allowed: true, exitCode: 0 };
|
|
57
|
+
|
|
58
|
+
const commands = normalizeCommands(hooks[event]);
|
|
59
|
+
if (commands.length === 0) return { allowed: true, exitCode: 0 };
|
|
60
|
+
|
|
61
|
+
const isGate = GATE_HOOKS.has(event);
|
|
62
|
+
|
|
63
|
+
for (const cmd of commands) {
|
|
64
|
+
const exitCode = await runSingleHook(cmd, context, workDir);
|
|
65
|
+
|
|
66
|
+
if (isGate && exitCode === 1) {
|
|
67
|
+
// Only exit code 1 has gate semantics (block execution)
|
|
68
|
+
return { allowed: false, exitCode };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (exitCode !== 0) {
|
|
72
|
+
// Non-zero but not 1: hook itself had an error, log but don't block
|
|
73
|
+
console.warn(`[hook: ${event}] "${cmd}" exited with code ${exitCode}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { allowed: true, exitCode: 0 };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ═══ Context Builders ═══
|
|
81
|
+
|
|
82
|
+
export interface PipelineInfo {
|
|
83
|
+
readonly name: string;
|
|
84
|
+
readonly run_id: string;
|
|
85
|
+
readonly started_at: string;
|
|
86
|
+
readonly finished_at?: string;
|
|
87
|
+
readonly duration_ms?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface TrackInfo {
|
|
91
|
+
readonly id: string;
|
|
92
|
+
readonly name: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface TaskInfo {
|
|
96
|
+
readonly id: string;
|
|
97
|
+
readonly name: string;
|
|
98
|
+
readonly type: 'ai' | 'command';
|
|
99
|
+
readonly status: string;
|
|
100
|
+
readonly exit_code: number | null;
|
|
101
|
+
readonly duration_ms: number | null;
|
|
102
|
+
readonly output_path: string | null;
|
|
103
|
+
readonly stderr_path: string | null;
|
|
104
|
+
readonly session_id: string | null;
|
|
105
|
+
readonly started_at: string | null;
|
|
106
|
+
readonly finished_at: string | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function buildPipelineStartContext(pipeline: PipelineInfo) {
|
|
110
|
+
return { event: 'pipeline_start', pipeline };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function buildTaskContext(
|
|
114
|
+
event: 'task_start' | 'task_success' | 'task_failure',
|
|
115
|
+
pipeline: PipelineInfo,
|
|
116
|
+
track: TrackInfo,
|
|
117
|
+
task: TaskInfo,
|
|
118
|
+
) {
|
|
119
|
+
return { event, pipeline, track, task };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function buildPipelineCompleteContext(
|
|
123
|
+
pipeline: PipelineInfo & { finished_at: string; duration_ms: number },
|
|
124
|
+
summary: {
|
|
125
|
+
total: number; success: number; failed: number;
|
|
126
|
+
skipped: number; timeout: number; blocked: number;
|
|
127
|
+
},
|
|
128
|
+
) {
|
|
129
|
+
return { event: 'pipeline_complete', pipeline, summary };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function buildPipelineErrorContext(
|
|
133
|
+
pipeline: PipelineInfo,
|
|
134
|
+
error: string,
|
|
135
|
+
eventType?: string,
|
|
136
|
+
) {
|
|
137
|
+
return { event: eventType ?? 'pipeline_error', pipeline, error };
|
|
138
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { resolve, dirname } from 'node:path';
|
|
2
|
+
import { mkdirSync, appendFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dual-channel logger.
|
|
6
|
+
*
|
|
7
|
+
* - `info/warn/error` → console AND file (brief, user-visible events)
|
|
8
|
+
* - `debug` → file ONLY (verbose diagnostics)
|
|
9
|
+
* - `section` → file ONLY (visual separators)
|
|
10
|
+
* - `quiet` → file ONLY (bulk payload like full stdout dumps)
|
|
11
|
+
*
|
|
12
|
+
* Log file path: <workDir>/tmp/pipeline.log (one file per pipeline run,
|
|
13
|
+
* truncated on construction).
|
|
14
|
+
*/
|
|
15
|
+
export class Logger {
|
|
16
|
+
private readonly filePath: string;
|
|
17
|
+
|
|
18
|
+
constructor(workDir: string, runId: string) {
|
|
19
|
+
this.filePath = resolve(workDir, 'tmp', 'pipeline.log');
|
|
20
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
21
|
+
writeFileSync(
|
|
22
|
+
this.filePath,
|
|
23
|
+
`# Pipeline run ${runId} @ ${new Date().toISOString()}\n` +
|
|
24
|
+
`# Host: ${process.platform} ${process.arch} Bun: ${process.versions.bun ?? 'n/a'}\n` +
|
|
25
|
+
`# Work dir: ${workDir}\n\n`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
info(prefix: string, message: string): void {
|
|
30
|
+
const line = `${timestamp()} ${prefix} ${message}`;
|
|
31
|
+
console.log(line);
|
|
32
|
+
this.append(line);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
warn(prefix: string, message: string): void {
|
|
36
|
+
const line = `${timestamp()} ${prefix} WARN: ${message}`;
|
|
37
|
+
console.warn(line);
|
|
38
|
+
this.append(line);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
error(prefix: string, message: string): void {
|
|
42
|
+
const line = `${timestamp()} ${prefix} ERROR: ${message}`;
|
|
43
|
+
console.error(line);
|
|
44
|
+
this.append(line);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** File-only diagnostic log line. */
|
|
48
|
+
debug(prefix: string, message: string): void {
|
|
49
|
+
this.append(`${timestamp()} ${prefix} DEBUG: ${message}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** File-only visual separator with title. */
|
|
53
|
+
section(title: string): void {
|
|
54
|
+
this.append(`\n━━━ ${title} ━━━`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** File-only bulk payload (e.g. full stdout / stderr dumps). */
|
|
58
|
+
quiet(message: string): void {
|
|
59
|
+
this.append(message);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private append(line: string): void {
|
|
63
|
+
try {
|
|
64
|
+
appendFileSync(this.filePath, line.endsWith('\n') ? line : line + '\n');
|
|
65
|
+
} catch {
|
|
66
|
+
// Swallow log write failures; engine correctness shouldn't depend on logging.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get path(): string {
|
|
71
|
+
return this.filePath;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function timestamp(): string {
|
|
76
|
+
const d = new Date();
|
|
77
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
78
|
+
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
79
|
+
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
80
|
+
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
|
81
|
+
return `${hh}:${mm}:${ss}.${ms}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Return the last `n` non-empty lines of `text`, joined with newlines. */
|
|
85
|
+
export function tailLines(text: string, n: number): string {
|
|
86
|
+
if (!text) return '';
|
|
87
|
+
const lines = text.split(/\r?\n/).filter(l => l.length > 0);
|
|
88
|
+
return lines.slice(-n).join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Truncate a blob to at most `maxBytes` UTF-8 bytes for log embedding,
|
|
93
|
+
* appending a marker when truncation occurred.
|
|
94
|
+
*/
|
|
95
|
+
export function clip(text: string, maxBytes = 16 * 1024): string {
|
|
96
|
+
if (!text) return '';
|
|
97
|
+
if (text.length <= maxBytes) return text;
|
|
98
|
+
const omitted = text.length - maxBytes;
|
|
99
|
+
return text.slice(0, maxBytes) + `\n…[truncated ${omitted} chars]`;
|
|
100
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { basename } from 'path';
|
|
2
|
+
import type { MiddlewarePlugin, MiddlewareContext } from '../types';
|
|
3
|
+
import { validatePath } from '../utils';
|
|
4
|
+
|
|
5
|
+
export const StaticContextMiddleware: MiddlewarePlugin = {
|
|
6
|
+
name: 'static_context',
|
|
7
|
+
|
|
8
|
+
async enhance(
|
|
9
|
+
prompt: string,
|
|
10
|
+
config: Record<string, unknown>,
|
|
11
|
+
ctx: MiddlewareContext,
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const filePath = config.file as string;
|
|
14
|
+
if (!filePath) throw new Error('static_context middleware: "file" is required');
|
|
15
|
+
|
|
16
|
+
const safePath = validatePath(filePath, ctx.workDir);
|
|
17
|
+
const file = Bun.file(safePath);
|
|
18
|
+
|
|
19
|
+
if (!(await file.exists())) {
|
|
20
|
+
console.warn(`static_context: file ${filePath} not found, skipping`);
|
|
21
|
+
return prompt;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const content = await file.text();
|
|
25
|
+
const label = (config.label as string) ?? `Reference: ${basename(filePath)}`;
|
|
26
|
+
|
|
27
|
+
return `[${label}]\n${content}\n\n[Task]\n${prompt}`;
|
|
28
|
+
},
|
|
29
|
+
};
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PluginCategory, DriverPlugin, TriggerPlugin,
|
|
3
|
+
CompletionPlugin, MiddlewarePlugin,
|
|
4
|
+
} from './types';
|
|
5
|
+
|
|
6
|
+
type PluginType = DriverPlugin | TriggerPlugin | CompletionPlugin | MiddlewarePlugin;
|
|
7
|
+
|
|
8
|
+
const registries = {
|
|
9
|
+
drivers: new Map<string, DriverPlugin>(),
|
|
10
|
+
triggers: new Map<string, TriggerPlugin>(),
|
|
11
|
+
completions: new Map<string, CompletionPlugin>(),
|
|
12
|
+
middlewares: new Map<string, MiddlewarePlugin>(),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function registerPlugin<T extends PluginType>(
|
|
16
|
+
category: PluginCategory, type: string, handler: T,
|
|
17
|
+
): void {
|
|
18
|
+
const registry = registries[category] as Map<string, T>;
|
|
19
|
+
if (registry.has(type)) {
|
|
20
|
+
throw new Error(`${category} type "${type}" is already registered`);
|
|
21
|
+
}
|
|
22
|
+
registry.set(type, handler);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getHandler<T extends PluginType>(
|
|
26
|
+
category: PluginCategory, type: string,
|
|
27
|
+
): T {
|
|
28
|
+
const handler = registries[category].get(type);
|
|
29
|
+
if (!handler) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`${category} type "${type}" not registered.\n` +
|
|
32
|
+
`Install the plugin: bun add @tagma/${category.replace(/s$/, '')}-${type}`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return handler as T;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function hasHandler(category: PluginCategory, type: string): boolean {
|
|
39
|
+
return registries[category].has(type);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function loadPlugins(pluginNames: readonly string[]): Promise<void> {
|
|
43
|
+
for (const name of pluginNames) {
|
|
44
|
+
const mod = await import(name);
|
|
45
|
+
if (!mod.pluginCategory || !mod.pluginType || !mod.default) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Plugin "${name}" must export pluginCategory, pluginType, and default`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
registerPlugin(mod.pluginCategory, mod.pluginType, mod.default);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function listRegistered(category: PluginCategory): string[] {
|
|
55
|
+
return [...registries[category].keys()];
|
|
56
|
+
}
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
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);
|
|
194
|
+
}
|