feat(exec-events): bead-1 - exec event bus and helpers
This commit is contained in:
parent
9688454a30
commit
c9c2045419
488
src/infra/exec-events.ts
Normal file
488
src/infra/exec-events.ts
Normal file
@ -0,0 +1,488 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
|
|
||||||
|
export type ExecEventName = "exec.started" | "exec.output" | "exec.completed";
|
||||||
|
|
||||||
|
export type ExecEventContext = {
|
||||||
|
runId?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
sessionKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExecEventBase = {
|
||||||
|
event: ExecEventName;
|
||||||
|
ts: number;
|
||||||
|
seq: number;
|
||||||
|
sessionId: string;
|
||||||
|
pid?: number;
|
||||||
|
command: string;
|
||||||
|
commandName?: string;
|
||||||
|
context?: ExecEventContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecStartedEvent = ExecEventBase & {
|
||||||
|
event: "exec.started";
|
||||||
|
startedAt: number;
|
||||||
|
cwd?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecOutputEvent = ExecEventBase & {
|
||||||
|
event: "exec.output";
|
||||||
|
stream: "stdout" | "stderr";
|
||||||
|
output: string;
|
||||||
|
truncated?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecCompletedEvent = ExecEventBase & {
|
||||||
|
event: "exec.completed";
|
||||||
|
startedAt: number;
|
||||||
|
completedAt: number;
|
||||||
|
durationMs: number;
|
||||||
|
status: "completed" | "failed";
|
||||||
|
exitCode: number | null;
|
||||||
|
exitSignal: NodeJS.Signals | number | null;
|
||||||
|
timedOut?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecEventPayload = ExecStartedEvent | ExecOutputEvent | ExecCompletedEvent;
|
||||||
|
|
||||||
|
type ExecEventInputMap = {
|
||||||
|
"exec.started": Omit<ExecStartedEvent, "event" | "ts" | "seq">;
|
||||||
|
"exec.output": Omit<ExecOutputEvent, "event" | "ts" | "seq">;
|
||||||
|
"exec.completed": Omit<ExecCompletedEvent, "event" | "ts" | "seq">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecEventInput<T extends ExecEventName = ExecEventName> = ExecEventInputMap[T];
|
||||||
|
|
||||||
|
const listeners = new Set<(evt: ExecEventPayload) => void>();
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
export function emitExecEvent<T extends ExecEventName>(
|
||||||
|
event: T,
|
||||||
|
payload: ExecEventInput<T>,
|
||||||
|
): ExecEventPayload {
|
||||||
|
const enriched = {
|
||||||
|
...payload,
|
||||||
|
event,
|
||||||
|
ts: Date.now(),
|
||||||
|
seq: (seq += 1),
|
||||||
|
} as ExecEventPayload;
|
||||||
|
for (const listener of listeners) {
|
||||||
|
try {
|
||||||
|
listener(enriched);
|
||||||
|
} catch {
|
||||||
|
// Ignore listener failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return enriched;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onExecEvent(listener: (evt: ExecEventPayload) => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetExecEventsForTest(): void {
|
||||||
|
seq = 0;
|
||||||
|
listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_COMMAND_WHITELIST = [
|
||||||
|
"codex",
|
||||||
|
"claude",
|
||||||
|
"opencode",
|
||||||
|
"pi",
|
||||||
|
"gog",
|
||||||
|
"himalaya",
|
||||||
|
"playwright",
|
||||||
|
"puppeteer",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const DEFAULT_OUTPUT_THROTTLE_MS = 150;
|
||||||
|
const DEFAULT_OUTPUT_MAX_CHUNK_BYTES = 4096;
|
||||||
|
const DEFAULT_OUTPUT_BUFFER_MULTIPLIER = 8;
|
||||||
|
const MIN_OUTPUT_MAX_CHUNK_BYTES = 256;
|
||||||
|
const MAX_OUTPUT_BUFFER_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
const WRAPPER_COMMANDS = new Set([
|
||||||
|
"npx",
|
||||||
|
"pnpm",
|
||||||
|
"pnpmx",
|
||||||
|
"bunx",
|
||||||
|
"npm",
|
||||||
|
"yarn",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const WRAPPER_SUBCOMMANDS = new Set(["exec", "run", "run-script", "dlx", "x"]);
|
||||||
|
|
||||||
|
type WhitelistMatchResult = {
|
||||||
|
matched: boolean;
|
||||||
|
commandName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecEventsConfigResolved = {
|
||||||
|
emitEvents: boolean;
|
||||||
|
commandWhitelist: string[];
|
||||||
|
commandWhitelistSet: Set<string>;
|
||||||
|
commandWhitelistTokens: string[];
|
||||||
|
outputThrottleMs: number;
|
||||||
|
outputMaxChunkBytes: number;
|
||||||
|
outputBufferMaxBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeCommandName(value: string): string {
|
||||||
|
return path.basename(value.trim()).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCommandWhitelist(entries?: unknown): string[] {
|
||||||
|
const list = Array.isArray(entries) ? entries : DEFAULT_COMMAND_WHITELIST;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const normalized: string[] = [];
|
||||||
|
for (const entry of list) {
|
||||||
|
if (typeof entry !== "string") continue;
|
||||||
|
const name = normalizeCommandName(entry);
|
||||||
|
if (!name || seen.has(name)) continue;
|
||||||
|
seen.add(name);
|
||||||
|
normalized.push(name);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePositiveInt(value: unknown, fallback: number, min?: number): number {
|
||||||
|
const raw = typeof value === "number" ? value : Number.NaN;
|
||||||
|
if (!Number.isFinite(raw) || raw <= 0) return fallback;
|
||||||
|
const floored = Math.floor(raw);
|
||||||
|
if (min && floored < min) return min;
|
||||||
|
return floored;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveExecEventsConfig(cfg?: MoltbotConfig): ExecEventsConfigResolved {
|
||||||
|
const execCfg = cfg?.hooks?.exec;
|
||||||
|
const commandWhitelist = normalizeCommandWhitelist(execCfg?.commandWhitelist);
|
||||||
|
const commandWhitelistSet = new Set(commandWhitelist);
|
||||||
|
const outputThrottleMs = resolvePositiveInt(execCfg?.outputThrottleMs, DEFAULT_OUTPUT_THROTTLE_MS);
|
||||||
|
const outputMaxChunkBytes = resolvePositiveInt(
|
||||||
|
execCfg?.outputMaxChunkBytes,
|
||||||
|
DEFAULT_OUTPUT_MAX_CHUNK_BYTES,
|
||||||
|
MIN_OUTPUT_MAX_CHUNK_BYTES,
|
||||||
|
);
|
||||||
|
const outputBufferMaxBytes = Math.min(
|
||||||
|
Math.max(outputMaxChunkBytes * DEFAULT_OUTPUT_BUFFER_MULTIPLIER, outputMaxChunkBytes),
|
||||||
|
MAX_OUTPUT_BUFFER_BYTES,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
emitEvents: execCfg?.emitEvents !== false,
|
||||||
|
commandWhitelist,
|
||||||
|
commandWhitelistSet,
|
||||||
|
commandWhitelistTokens: commandWhitelist,
|
||||||
|
outputThrottleMs,
|
||||||
|
outputMaxChunkBytes,
|
||||||
|
outputBufferMaxBytes,
|
||||||
|
} satisfies ExecEventsConfigResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function includesWhitelistToken(command: string, tokens: string[]): boolean {
|
||||||
|
if (!command || tokens.length === 0) return false;
|
||||||
|
const lower = command.toLowerCase();
|
||||||
|
return tokens.some((token) => lower.includes(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnvAssignment(token: string): boolean {
|
||||||
|
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellTokenize(command: string): string[] {
|
||||||
|
const tokens: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let quote: "'" | '"' | null = null;
|
||||||
|
let escaped = false;
|
||||||
|
|
||||||
|
const pushCurrent = () => {
|
||||||
|
const trimmed = current.trim();
|
||||||
|
if (trimmed) tokens.push(trimmed);
|
||||||
|
current = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < command.length; i += 1) {
|
||||||
|
const ch = command[i];
|
||||||
|
if (escaped) {
|
||||||
|
current += ch;
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quote) {
|
||||||
|
if (ch === quote) {
|
||||||
|
quote = null;
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "'" || ch === '"') {
|
||||||
|
quote = ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/\s/.test(ch)) {
|
||||||
|
pushCurrent();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
pushCurrent();
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNextNonFlagToken(tokens: string[], startIndex: number): number | null {
|
||||||
|
for (let i = startIndex; i < tokens.length; i += 1) {
|
||||||
|
const token = tokens[i];
|
||||||
|
if (!token) continue;
|
||||||
|
if (token === "--") continue;
|
||||||
|
if (token.startsWith("-")) continue;
|
||||||
|
if (isEnvAssignment(token)) continue;
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCommandTokenIndex(tokens: string[]): number | null {
|
||||||
|
if (tokens.length === 0) return null;
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while (index < tokens.length && isEnvAssignment(tokens[index])) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
if (index >= tokens.length) return null;
|
||||||
|
|
||||||
|
const token = tokens[index]?.toLowerCase();
|
||||||
|
if (token !== "env") return index;
|
||||||
|
|
||||||
|
index += 1;
|
||||||
|
while (index < tokens.length) {
|
||||||
|
const entry = tokens[index];
|
||||||
|
if (!entry) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry === "--") {
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (entry.startsWith("-") || isEnvAssignment(entry)) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return index < tokens.length ? index : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWrapperCommandName(tokens: string[], wrapperIndex: number): string | undefined {
|
||||||
|
const wrapper = normalizeCommandName(tokens[wrapperIndex]);
|
||||||
|
const nextIndex = findNextNonFlagToken(tokens, wrapperIndex + 1);
|
||||||
|
if (nextIndex == null) return undefined;
|
||||||
|
const subcommand = normalizeCommandName(tokens[nextIndex]);
|
||||||
|
|
||||||
|
if (wrapper === "npm" || wrapper === "yarn" || wrapper === "pnpm") {
|
||||||
|
if (WRAPPER_SUBCOMMANDS.has(subcommand)) {
|
||||||
|
const deeperIndex = findNextNonFlagToken(tokens, nextIndex + 1);
|
||||||
|
if (deeperIndex != null) {
|
||||||
|
return normalizeCommandName(tokens[deeperIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return subcommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchExecCommandAgainstWhitelist(command: string, cfg: ExecEventsConfigResolved): WhitelistMatchResult {
|
||||||
|
if (!cfg.emitEvents) return { matched: false };
|
||||||
|
if (cfg.commandWhitelistSet.size === 0) return { matched: false };
|
||||||
|
if (!includesWhitelistToken(command, cfg.commandWhitelistTokens)) {
|
||||||
|
return { matched: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = shellTokenize(command);
|
||||||
|
const commandIndex = resolveCommandTokenIndex(tokens);
|
||||||
|
if (commandIndex == null) return { matched: false };
|
||||||
|
|
||||||
|
const root = normalizeCommandName(tokens[commandIndex]);
|
||||||
|
if (cfg.commandWhitelistSet.has(root)) {
|
||||||
|
return { matched: true, commandName: root };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!WRAPPER_COMMANDS.has(root)) {
|
||||||
|
return { matched: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapped = resolveWrapperCommandName(tokens, commandIndex);
|
||||||
|
if (wrapped && cfg.commandWhitelistSet.has(wrapped)) {
|
||||||
|
return { matched: true, commandName: wrapped };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { matched: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutputStream = "stdout" | "stderr";
|
||||||
|
|
||||||
|
type OutputStreamState = {
|
||||||
|
buffer: string;
|
||||||
|
truncated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecOutputChunk = {
|
||||||
|
stream: OutputStream;
|
||||||
|
output: string;
|
||||||
|
truncated?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecOutputBuffer = {
|
||||||
|
append: (stream: OutputStream, chunk: string) => void;
|
||||||
|
flush: () => void;
|
||||||
|
flushAll: () => void;
|
||||||
|
dispose: () => void;
|
||||||
|
hasBufferedOutput: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OutputBufferOptions = {
|
||||||
|
throttleMs: number;
|
||||||
|
maxChunkBytes: number;
|
||||||
|
maxBufferBytes: number;
|
||||||
|
onFlush: (chunks: ExecOutputChunk[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function byteLength(text: string): number {
|
||||||
|
return Buffer.byteLength(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sliceByMaxBytes(text: string, maxBytes: number): string {
|
||||||
|
if (!text) return "";
|
||||||
|
if (maxBytes <= 0) return text[0] ?? "";
|
||||||
|
if (byteLength(text) <= maxBytes) return text;
|
||||||
|
|
||||||
|
let low = 0;
|
||||||
|
let high = text.length;
|
||||||
|
while (low < high) {
|
||||||
|
const mid = Math.ceil((low + high) / 2);
|
||||||
|
const bytes = byteLength(text.slice(0, mid));
|
||||||
|
if (bytes <= maxBytes) {
|
||||||
|
low = mid;
|
||||||
|
} else {
|
||||||
|
high = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (low <= 0) return text[0] ?? "";
|
||||||
|
return text.slice(0, low);
|
||||||
|
}
|
||||||
|
|
||||||
|
function capBuffer(state: OutputStreamState, maxBufferBytes: number): void {
|
||||||
|
const totalBytes = byteLength(state.buffer);
|
||||||
|
if (totalBytes <= maxBufferBytes) return;
|
||||||
|
|
||||||
|
state.truncated = true;
|
||||||
|
// Drop oldest data until we're within the cap.
|
||||||
|
let remaining = state.buffer;
|
||||||
|
while (remaining && byteLength(remaining) > maxBufferBytes) {
|
||||||
|
const overflowBytes = byteLength(remaining) - maxBufferBytes;
|
||||||
|
const dropChars = Math.min(remaining.length, Math.max(1, Math.floor(overflowBytes / 2)));
|
||||||
|
remaining = remaining.slice(dropChars);
|
||||||
|
}
|
||||||
|
state.buffer = remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createThrottledExecOutputBuffer(options: OutputBufferOptions): ExecOutputBuffer {
|
||||||
|
const throttleMs = Math.max(0, Math.floor(options.throttleMs));
|
||||||
|
const maxChunkBytes = Math.max(1, Math.floor(options.maxChunkBytes));
|
||||||
|
const maxBufferBytes = Math.max(maxChunkBytes, Math.floor(options.maxBufferBytes));
|
||||||
|
|
||||||
|
const stdout: OutputStreamState = { buffer: "", truncated: false };
|
||||||
|
const stderr: OutputStreamState = { buffer: "", truncated: false };
|
||||||
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
const getState = (stream: OutputStream) => (stream === "stdout" ? stdout : stderr);
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (disposed || timer) return;
|
||||||
|
if (throttleMs === 0) {
|
||||||
|
flush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
flush();
|
||||||
|
}, throttleMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drainOnce = (state: OutputStreamState, stream: OutputStream): ExecOutputChunk | null => {
|
||||||
|
if (!state.buffer) return null;
|
||||||
|
const chunk = sliceByMaxBytes(state.buffer, maxChunkBytes);
|
||||||
|
if (!chunk) return null;
|
||||||
|
state.buffer = state.buffer.slice(chunk.length);
|
||||||
|
const truncated = state.truncated || undefined;
|
||||||
|
state.truncated = false;
|
||||||
|
return { stream, output: chunk, ...(truncated ? { truncated } : {}) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
const chunks: ExecOutputChunk[] = [];
|
||||||
|
const stdoutChunk = drainOnce(stdout, "stdout");
|
||||||
|
if (stdoutChunk) chunks.push(stdoutChunk);
|
||||||
|
const stderrChunk = drainOnce(stderr, "stderr");
|
||||||
|
if (stderrChunk) chunks.push(stderrChunk);
|
||||||
|
if (chunks.length > 0) {
|
||||||
|
options.onFlush(chunks);
|
||||||
|
}
|
||||||
|
if (!disposed && throttleMs > 0 && (stdout.buffer || stderr.buffer)) {
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushAll = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
while (stdout.buffer || stderr.buffer) {
|
||||||
|
flush();
|
||||||
|
if (throttleMs > 0 && (stdout.buffer || stderr.buffer)) {
|
||||||
|
// Prevent tight loops in extremely large output bursts by yielding back to the event loop.
|
||||||
|
// The next scheduled flush will continue draining.
|
||||||
|
schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const append = (stream: OutputStream, chunk: string) => {
|
||||||
|
if (disposed || !chunk) return;
|
||||||
|
const state = getState(stream);
|
||||||
|
state.buffer += chunk;
|
||||||
|
capBuffer(state, maxBufferBytes);
|
||||||
|
schedule();
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispose = () => {
|
||||||
|
disposed = true;
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
append,
|
||||||
|
flush,
|
||||||
|
flushAll,
|
||||||
|
dispose,
|
||||||
|
hasBufferedOutput: () => stdout.buffer.length > 0 || stderr.buffer.length > 0,
|
||||||
|
} satisfies ExecOutputBuffer;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user