aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
1,044 lines (919 loc) • 31.5 kB
JavaScript
/**
* Terminal Spawner - Node.js wrapper for pm.sh
*
* Provides async API for spawning AIOS agents in separate terminals
* to maintain clean context isolation during orchestration.
*
* Story 11.2: Bob Terminal Spawning
*
* @module core/orchestration/terminal-spawner
* @version 1.0.0
*/
;
const { spawn, execSync } = require('child_process');
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const os = require('os');
// Constants
const POLL_INTERVAL_MS = 500;
const DEFAULT_TIMEOUT_MS = 300000; // 5 minutes
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 1000;
/**
* Environment types for spawn strategy selection
* @readonly
* @enum {string}
*/
const ENVIRONMENT_TYPE = {
NATIVE_TERMINAL: 'NATIVE_TERMINAL',
VSCODE: 'VSCODE',
SSH: 'SSH',
DOCKER: 'DOCKER',
CI: 'CI',
};
/**
* Environment detection result
* @typedef {Object} EnvironmentInfo
* @property {string} type - Environment type (ENVIRONMENT_TYPE enum value)
* @property {boolean} supportsVisualTerminal - Whether visual terminal spawn is supported
* @property {string} reason - Human-readable reason for detection
*/
/**
* Detects the current execution environment (Story 12.10 - Task 1)
*
* Detection priority:
* 1. CI/CD (GitHub Actions, GitLab CI, etc.)
* 2. Docker container
* 3. SSH session
* 4. VS Code integrated terminal
* 5. Native terminal (default)
*
* @returns {EnvironmentInfo} Environment detection result
*/
function detectEnvironment() {
// 1. CI/CD detection (Task 1.5)
// Check common CI environment variables
if (
process.env.CI === 'true' ||
process.env.GITHUB_ACTIONS === 'true' ||
process.env.GITLAB_CI === 'true' ||
process.env.JENKINS_URL ||
process.env.TRAVIS === 'true' ||
process.env.CIRCLECI === 'true' ||
process.env.BUILDKITE === 'true' ||
process.env.AZURE_PIPELINES === 'true' ||
process.env.TF_BUILD === 'True'
) {
return {
type: ENVIRONMENT_TYPE.CI,
supportsVisualTerminal: false,
reason: 'CI/CD environment detected (headless)',
};
}
// 2. Docker container detection (Task 1.4)
// Check for /.dockerenv file or cgroup indicators
try {
if (fsSync.existsSync('/.dockerenv')) {
return {
type: ENVIRONMENT_TYPE.DOCKER,
supportsVisualTerminal: false,
reason: 'Docker container detected (/.dockerenv exists)',
};
}
// Check cgroup for docker/containerd/kubepods
if (fsSync.existsSync('/proc/1/cgroup')) {
const cgroup = fsSync.readFileSync('/proc/1/cgroup', 'utf8');
if (cgroup.includes('docker') || cgroup.includes('containerd') || cgroup.includes('kubepods')) {
return {
type: ENVIRONMENT_TYPE.DOCKER,
supportsVisualTerminal: false,
reason: 'Container detected via cgroup',
};
}
}
} catch {
// Ignore file read errors (e.g., on Windows)
}
// 3. SSH session detection (Task 1.3)
if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) {
return {
type: ENVIRONMENT_TYPE.SSH,
supportsVisualTerminal: false,
reason: 'SSH session detected (no display available)',
};
}
// 4. VS Code integrated terminal detection (Task 1.2)
if (
process.env.TERM_PROGRAM === 'vscode' ||
process.env.VSCODE_PID ||
process.env.VSCODE_CWD ||
process.env.VSCODE_GIT_IPC_HANDLE
) {
return {
type: ENVIRONMENT_TYPE.VSCODE,
supportsVisualTerminal: false,
reason: 'VS Code integrated terminal detected',
};
}
// 5. Native terminal (default) - supports visual spawn
return {
type: ENVIRONMENT_TYPE.NATIVE_TERMINAL,
supportsVisualTerminal: true,
reason: 'Native terminal environment',
};
}
/**
* Default spawn options
* @type {Object}
*/
const DEFAULT_OPTIONS = {
params: '',
context: null,
timeout: DEFAULT_TIMEOUT_MS,
outputDir: os.tmpdir(),
retries: MAX_RETRIES,
debug: false,
};
/**
* Context schema for agent execution
* @typedef {Object} AgentContext
* @property {string} story - Story file path or content
* @property {string[]} files - Array of relevant file paths
* @property {string} instructions - Additional instructions for the agent
* @property {Object} metadata - Additional metadata
*/
/**
* Spawn result object
* @typedef {Object} SpawnResult
* @property {boolean} success - Whether spawn was successful
* @property {string} output - Agent output content
* @property {string} outputFile - Path to output file
* @property {number} duration - Execution duration in ms
* @property {string} [error] - Error message if failed
*/
/**
* Gets the path to the pm.sh script
* @returns {string} Absolute path to pm.sh
*/
function getScriptPath() {
return path.join(__dirname, '../../scripts/pm.sh');
}
/**
* Validates spawn arguments
* @param {string} agent - Agent ID
* @param {string} task - Task to execute
* @throws {Error} If arguments are invalid
*/
function validateArgs(agent, task) {
if (!agent || typeof agent !== 'string') {
throw new Error('Agent ID is required and must be a string');
}
if (!task || typeof task !== 'string') {
throw new Error('Task is required and must be a string');
}
// Validate agent format (should be alphanumeric with optional hyphen)
const agentPattern = /^[a-zA-Z][a-zA-Z0-9-]*$/;
if (!agentPattern.test(agent)) {
throw new Error(`Invalid agent ID format: ${agent}`);
}
// Validate task format
const taskPattern = /^[a-zA-Z][a-zA-Z0-9-]*$/;
if (!taskPattern.test(task)) {
throw new Error(`Invalid task format: ${task}`);
}
}
/**
* Creates a temporary context file for the agent (Task 2.2)
*
* @param {AgentContext} context - Context data to pass to agent
* @param {string} outputDir - Directory for temp files
* @returns {Promise<string>} Path to created context file
*/
async function createContextFile(context, outputDir = os.tmpdir()) {
if (!context) {
return '';
}
// Validate context structure
const validatedContext = {
story: context.story || '',
files: Array.isArray(context.files) ? context.files : [],
instructions: context.instructions || '',
metadata: context.metadata || {},
createdAt: new Date().toISOString(),
};
const contextPath = path.join(outputDir, `aios-context-${Date.now()}.json`);
await fs.writeFile(contextPath, JSON.stringify(validatedContext, null, 2));
return contextPath;
}
/**
* Polls for agent output completion (Task 3.2, 3.3)
*
* @param {string} outputFile - Path to output file
* @param {number} timeout - Timeout in milliseconds
* @param {boolean} debug - Enable debug logging
* @returns {Promise<string>} Output content
* @throws {Error} If timeout exceeded
*/
async function pollForOutput(outputFile, timeout = DEFAULT_TIMEOUT_MS, debug = false) {
const startTime = Date.now();
const lockFile = outputFile.replace('output', 'lock');
if (debug) {
console.log(`[TerminalSpawner] Polling for output: ${outputFile}`);
console.log(`[TerminalSpawner] Lock file: ${lockFile}`);
}
while (Date.now() - startTime < timeout) {
// Check if lock file is gone (agent finished)
try {
await fs.access(lockFile);
// Lock still exists, wait and retry
if (debug) {
console.log(`[TerminalSpawner] Lock exists, waiting ${POLL_INTERVAL_MS}ms...`);
}
await sleep(POLL_INTERVAL_MS);
} catch {
// Lock gone, agent finished - read output
if (debug) {
console.log('[TerminalSpawner] Lock removed, reading output...');
}
try {
const output = await fs.readFile(outputFile, 'utf8');
return output;
} catch (readError) {
if (debug) {
console.log(`[TerminalSpawner] Output file not found: ${readError.message}`);
}
return 'No output captured';
}
}
}
// Timeout - cleanup lock file if still exists
try {
await fs.unlink(lockFile);
} catch {
// Ignore cleanup errors
}
throw new Error(`Timeout waiting for agent output after ${timeout}ms`);
}
/**
* Sleep utility
* @param {number} ms - Milliseconds to sleep
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Spawns an agent inline using child_process.spawn (Story 12.10 - Task 2)
*
* This is used when visual terminal spawning is not available (VS Code, SSH, Docker, CI).
* Output is piped directly instead of using a separate terminal window.
*
* @param {string} agent - Agent ID (e.g., 'dev', 'architect', 'qa')
* @param {string} task - Task to execute
* @param {Object} options - Spawn options
* @param {string} [options.params=''] - Additional parameters
* @param {AgentContext} [options.context=null] - Context data for agent
* @param {number} [options.timeout=300000] - Timeout in ms
* @param {string} [options.outputDir] - Directory for output files
* @param {boolean} [options.debug=false] - Enable debug logging
* @returns {Promise<SpawnResult>} Result with output and status
*/
async function spawnInline(agent, task, options = {}) {
const opts = { ...DEFAULT_OPTIONS, ...options };
const startTime = Date.now();
if (opts.debug) {
console.log('[TerminalSpawner] Using inline spawn (no visual terminal)');
}
// Create context file if needed
let contextPath = '';
if (opts.context) {
contextPath = await createContextFile(opts.context, opts.outputDir);
if (opts.debug) {
console.log(`[TerminalSpawner] Created context file: ${contextPath}`);
}
}
// Build command arguments for pm.sh
const args = [agent, task];
if (opts.params) {
args.push(opts.params);
}
if (contextPath) {
args.push('--context', contextPath);
}
const scriptPath = getScriptPath();
// Verify script exists
if (!fsSync.existsSync(scriptPath)) {
throw new Error(`pm.sh script not found at: ${scriptPath}`);
}
return new Promise((resolve) => {
const outputChunks = [];
const errorChunks = [];
// Spawn the process inline with piped stdout/stderr (Task 2.2)
const child = spawn('bash', [scriptPath, ...args], {
env: {
...process.env,
AIOS_DEBUG: opts.debug ? 'true' : 'false',
AIOS_OUTPUT_DIR: opts.outputDir,
AIOS_INLINE_MODE: 'true', // Signal to pm.sh that we're running inline
},
stdio: ['ignore', 'pipe', 'pipe'],
});
// Register child process for cleanup on parent exit (Task 3.4)
registerChildProcess(child);
// Capture stdout
child.stdout.on('data', (data) => {
outputChunks.push(data);
if (opts.debug) {
process.stdout.write(data);
}
});
// Capture stderr
child.stderr.on('data', (data) => {
errorChunks.push(data);
if (opts.debug) {
process.stderr.write(data);
}
});
// Set timeout
const timeoutId = setTimeout(() => {
child.kill('SIGTERM');
const duration = Date.now() - startTime;
// Cleanup context file
if (contextPath) {
fs.unlink(contextPath).catch(() => {});
}
resolve({
success: false,
output: Buffer.concat(outputChunks).toString('utf8'),
outputFile: '',
duration,
error: `Timeout after ${opts.timeout}ms`,
});
}, opts.timeout);
// Handle process completion
child.on('close', async (code) => {
clearTimeout(timeoutId);
const duration = Date.now() - startTime;
// Cleanup context file
if (contextPath) {
fs.unlink(contextPath).catch(() => {});
}
const stdout = Buffer.concat(outputChunks).toString('utf8').trim();
const stderr = Buffer.concat(errorChunks).toString('utf8');
if (code === 0) {
// pm.sh returns the output file path in stdout
// Read the actual output from that file
let output = stdout;
const outputFilePath = stdout.split('\n').pop()?.trim();
if (outputFilePath && outputFilePath.endsWith('.md')) {
try {
output = await fs.readFile(outputFilePath, 'utf8');
// Cleanup the output file after reading
await fs.unlink(outputFilePath).catch(() => {});
} catch {
// If we can't read the file, use stdout as fallback
output = stdout;
}
}
resolve({
success: true,
output,
outputFile: outputFilePath || '',
duration,
});
} else {
resolve({
success: false,
output: stdout,
outputFile: '',
duration,
error: stderr || `Process exited with code ${code}`,
});
}
});
// Handle spawn errors
child.on('error', (error) => {
clearTimeout(timeoutId);
const duration = Date.now() - startTime;
// Cleanup context file
if (contextPath) {
fs.unlink(contextPath).catch(() => {});
}
resolve({
success: false,
output: Buffer.concat(outputChunks).toString('utf8'),
outputFile: '',
duration,
error: error.message,
});
});
});
}
/**
* Spawns an agent in a separate terminal (Task 4.1)
*
* Opens a new terminal window with the specified agent and task,
* passing context if provided. Returns the agent's output after completion.
*
* @param {string} agent - Agent ID (e.g., 'dev', 'architect', 'qa')
* @param {string} task - Task to execute (e.g., 'develop', 'review')
* @param {Object} options - Spawn options
* @param {string} [options.params=''] - Additional parameters
* @param {AgentContext} [options.context=null] - Context data for agent
* @param {number} [options.timeout=300000] - Timeout in ms (default: 5 min)
* @param {string} [options.outputDir] - Directory for output files
* @param {number} [options.retries=3] - Number of retry attempts
* @param {boolean} [options.debug=false] - Enable debug logging
* @returns {Promise<SpawnResult>} Result with output and status
*
* @example
* const result = await spawnAgent('dev', 'develop', {
* params: 'story-11.2',
* context: {
* story: 'docs/stories/active/11.2.story.md',
* files: ['src/index.js'],
* instructions: 'Focus on terminal spawning'
* },
* timeout: 600000 // 10 minutes
* });
*/
async function spawnAgent(agent, task, options = {}) {
const opts = { ...DEFAULT_OPTIONS, ...options };
const startTime = Date.now();
// Validate arguments
validateArgs(agent, task);
// Detect environment to determine spawn strategy (Story 12.10 - Task 2.5)
const environment = detectEnvironment();
if (opts.debug) {
console.log(`[TerminalSpawner] Environment: ${environment.type} (${environment.reason})`);
}
// If environment doesn't support visual terminal, use inline spawn directly
if (!environment.supportsVisualTerminal) {
console.log(`⚠️ Terminal visual indisponível. Executando inline. [${environment.reason}]`);
return spawnInline(agent, task, opts);
}
// Create context file if needed (Task 2.2)
let contextPath = '';
if (opts.context) {
contextPath = await createContextFile(opts.context, opts.outputDir);
if (opts.debug) {
console.log(`[TerminalSpawner] Created context file: ${contextPath}`);
}
}
// Build command arguments
const args = [agent, task];
if (opts.params) {
args.push(opts.params);
}
if (contextPath) {
args.push('--context', contextPath);
}
// Get script path
const scriptPath = getScriptPath();
// Verify script exists
if (!fsSync.existsSync(scriptPath)) {
throw new Error(`pm.sh script not found at: ${scriptPath}`);
}
// Execute with retry logic (Task 4.2)
let lastError;
for (let attempt = 1; attempt <= opts.retries; attempt++) {
try {
if (opts.debug) {
console.log(`[TerminalSpawner] Attempt ${attempt}/${opts.retries}`);
console.log(`[TerminalSpawner] Executing: bash ${scriptPath} ${args.join(' ')}`);
}
// Execute pm.sh
const env = {
...process.env,
AIOS_DEBUG: opts.debug ? 'true' : 'false',
AIOS_OUTPUT_DIR: opts.outputDir,
};
const result = execSync(`bash "${scriptPath}" ${args.join(' ')}`, {
encoding: 'utf8',
timeout: opts.timeout,
env,
});
// Get output file path from script output
const outputFile = result.trim();
if (opts.debug) {
console.log(`[TerminalSpawner] Output file: ${outputFile}`);
}
// Poll for completion (Task 3.2, 3.3)
const output = await pollForOutput(outputFile, opts.timeout, opts.debug);
// Cleanup context file (Task 2.4)
if (contextPath) {
await fs.unlink(contextPath).catch(() => {});
}
const duration = Date.now() - startTime;
return {
success: true,
output,
outputFile,
duration,
};
} catch (error) {
lastError = error;
if (opts.debug) {
console.log(`[TerminalSpawner] Attempt ${attempt} failed: ${error.message}`);
}
if (attempt < opts.retries) {
await sleep(RETRY_DELAY_MS * attempt);
}
}
}
// Fallback to inline spawn if visual terminal fails (Story 12.10 - Task 2.3)
console.log('⚠️ Terminal visual falhou. Tentando execução inline como fallback...');
// Cleanup context file before retry with inline
if (contextPath) {
await fs.unlink(contextPath).catch(() => {});
}
// Try inline spawn as fallback
const inlineResult = await spawnInline(agent, task, opts);
if (inlineResult.success) {
console.log('✅ Execução inline bem-sucedida.');
return inlineResult;
}
// Both methods failed
const duration = Date.now() - startTime;
return {
success: false,
output: inlineResult.output || '',
outputFile: '',
duration,
error: `Visual spawn failed: ${lastError?.message || 'Unknown'}. Inline fallback also failed: ${inlineResult.error || 'Unknown'}`,
};
}
/**
* Checks if the terminal spawner is available on this platform
* @returns {boolean} True if spawning is supported
*/
function isSpawnerAvailable() {
const platform = process.platform;
return ['darwin', 'linux', 'win32'].includes(platform);
}
/**
* Gets the current platform name
* @returns {string} Platform name (macos, linux, windows, or unknown)
*/
function getPlatform() {
switch (process.platform) {
case 'darwin':
return 'macos';
case 'linux':
return 'linux';
case 'win32':
return 'windows';
default:
return 'unknown';
}
}
/**
* Cleans up old output and lock files (Task 2.4)
*
* @param {string} outputDir - Directory to clean
* @param {number} maxAgeMs - Maximum age in milliseconds (default: 1 hour)
* @returns {Promise<number>} Number of files cleaned
*/
async function cleanupOldFiles(outputDir = os.tmpdir(), maxAgeMs = 3600000) {
const now = Date.now();
let cleaned = 0;
try {
const files = await fs.readdir(outputDir);
const aiosFiles = files.filter(
(f) => f.startsWith('aios-output-') || f.startsWith('aios-lock-') || f.startsWith('aios-context-'),
);
for (const file of aiosFiles) {
const filePath = path.join(outputDir, file);
try {
const stats = await fs.stat(filePath);
if (now - stats.mtimeMs > maxAgeMs) {
await fs.unlink(filePath);
cleaned++;
}
} catch {
// Ignore errors for individual files
}
}
} catch {
// Ignore directory read errors
}
return cleaned;
}
// ============================================
// OS Compatibility Matrix (Story 12.10 - Task 7)
// ============================================
/**
* OS Compatibility Matrix definition (PRD §9.6 — D19)
* @type {Object}
*/
const OS_COMPATIBILITY_MATRIX = {
must_pass: [
{ os: 'macOS Sonoma', arch: 'arm64', docker: 'Docker Desktop', description: 'Apple Silicon' },
{ os: 'macOS Sonoma', arch: 'x64', docker: 'Docker Desktop', description: 'Intel' },
{ os: 'Windows 11', arch: 'x64', docker: 'Docker Desktop', wsl: 'Ubuntu 22.04', description: 'WSL2' },
{ os: 'Ubuntu 22.04', arch: 'x64', docker: 'Docker Engine', description: 'Native Linux' },
],
should_pass: [
{ os: 'Windows 10', arch: 'x64', wsl: 'Ubuntu', description: 'WSL2' },
{ os: 'macOS Ventura', arch: 'arm64', docker: 'Docker Desktop', description: 'Previous macOS' },
{ os: 'macOS Ventura', arch: 'x64', docker: 'Docker Desktop', description: 'Previous macOS Intel' },
{ os: 'Ubuntu 24.04', arch: 'x64', docker: 'Docker Engine', description: 'Latest Ubuntu' },
],
};
/**
* Compatibility test result
* @typedef {Object} CompatibilityTestResult
* @property {string} testName - Name of the test
* @property {string} result - 'pass' | 'fail' | 'skip'
* @property {string} [failureReason] - Reason for failure if applicable
* @property {number} duration - Test duration in ms
*/
/**
* Compatibility report structure
* @typedef {Object} CompatibilityReport
* @property {string} generatedAt - ISO timestamp
* @property {Object} system - System information
* @property {string} system.os_name - Operating system name
* @property {string} system.os_version - OS version
* @property {string} system.architecture - CPU architecture (x64, arm64)
* @property {string} system.shell - Default shell
* @property {string} system.docker_version - Docker version or 'not installed'
* @property {string} system.node_version - Node.js version
* @property {Object} environment - Detected environment
* @property {CompatibilityTestResult[]} tests - Test results
* @property {Object} summary - Summary statistics
*/
/**
* Gets current system information for compatibility reporting (Task 7.4)
* @returns {Object} System information
*/
function getSystemInfo() {
const { execSync } = require('child_process');
// Get OS info
const platform = os.platform();
const release = os.release();
const arch = os.arch();
// Get OS name
let osName = 'Unknown';
if (platform === 'darwin') {
try {
const swVers = execSync('sw_vers -productName 2>/dev/null', { encoding: 'utf8' }).trim();
const swVersion = execSync('sw_vers -productVersion 2>/dev/null', { encoding: 'utf8' }).trim();
osName = `${swVers} ${swVersion}`;
} catch {
osName = `macOS ${release}`;
}
} else if (platform === 'linux') {
try {
const lsbRelease = execSync('lsb_release -d 2>/dev/null || cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d= -f2 | tr -d \'"\'', { encoding: 'utf8' }).trim();
osName = lsbRelease.replace('Description:\t', '') || `Linux ${release}`;
} catch {
osName = `Linux ${release}`;
}
} else if (platform === 'win32') {
osName = `Windows ${release}`;
}
// Get shell
const shell = process.env.SHELL || process.env.ComSpec || 'unknown';
// Get Docker version
let dockerVersion = 'not installed';
try {
dockerVersion = execSync('docker --version 2>/dev/null', { encoding: 'utf8' }).trim();
} catch {
// Docker not available
}
// Get Node version
const nodeVersion = process.version;
return {
os_name: osName,
os_version: release,
architecture: arch,
shell: path.basename(shell),
docker_version: dockerVersion,
node_version: nodeVersion,
};
}
/**
* Generates a compatibility report after test run (Task 7.4, 7.5)
*
* @param {CompatibilityTestResult[]} testResults - Array of test results
* @returns {CompatibilityReport} Generated compatibility report
*/
function generateCompatibilityReport(testResults = []) {
const systemInfo = getSystemInfo();
const environment = detectEnvironment();
// Calculate summary
const passed = testResults.filter((t) => t.result === 'pass').length;
const failed = testResults.filter((t) => t.result === 'fail').length;
const skipped = testResults.filter((t) => t.result === 'skip').length;
const total = testResults.length;
// Determine matrix classification
const matchesMustPass = OS_COMPATIBILITY_MATRIX.must_pass.some(
(m) => systemInfo.os_name.toLowerCase().includes(m.os.toLowerCase().split(' ')[0]),
);
const matchesShouldPass = OS_COMPATIBILITY_MATRIX.should_pass.some(
(m) => systemInfo.os_name.toLowerCase().includes(m.os.toLowerCase().split(' ')[0]),
);
return {
generatedAt: new Date().toISOString(),
system: systemInfo,
environment: {
type: environment.type,
supportsVisualTerminal: environment.supportsVisualTerminal,
reason: environment.reason,
},
matrixClassification: matchesMustPass ? 'must_pass' : matchesShouldPass ? 'should_pass' : 'not_in_matrix',
tests: testResults,
summary: {
total,
passed,
failed,
skipped,
passRate: total > 0 ? Math.round((passed / total) * 100) : 0,
},
};
}
/**
* Formats compatibility report for console output (Task 7.5)
* @param {CompatibilityReport} report - Compatibility report
* @returns {string} Formatted report string
*/
function formatCompatibilityReport(report) {
const lines = [
'═══════════════════════════════════════════════════════════════',
' AIOS Terminal Spawner Compatibility Report ',
'═══════════════════════════════════════════════════════════════',
'',
`Generated: ${report.generatedAt}`,
'',
'── System Information ──────────────────────────────────────────',
` OS: ${report.system.os_name}`,
` Version: ${report.system.os_version}`,
` Architecture: ${report.system.architecture}`,
` Shell: ${report.system.shell}`,
` Docker: ${report.system.docker_version}`,
` Node.js: ${report.system.node_version}`,
'',
'── Environment Detection ───────────────────────────────────────',
` Type: ${report.environment.type}`,
` Visual Term: ${report.environment.supportsVisualTerminal ? 'Yes' : 'No'}`,
` Reason: ${report.environment.reason}`,
'',
` Matrix Class: ${report.matrixClassification.toUpperCase()}`,
'',
];
if (report.tests.length > 0) {
lines.push('── Test Results ────────────────────────────────────────────────');
for (const test of report.tests) {
const icon = test.result === 'pass' ? '✅' : test.result === 'fail' ? '❌' : '⏭️';
const duration = test.duration ? ` (${test.duration}ms)` : '';
lines.push(` ${icon} ${test.testName}${duration}`);
if (test.failureReason) {
lines.push(` └─ ${test.failureReason}`);
}
}
lines.push('');
}
lines.push('── Summary ─────────────────────────────────────────────────────');
lines.push(` Total: ${report.summary.total}`);
lines.push(` Passed: ${report.summary.passed}`);
lines.push(` Failed: ${report.summary.failed}`);
lines.push(` Skipped: ${report.summary.skipped}`);
lines.push(` Rate: ${report.summary.passRate}%`);
lines.push('');
lines.push('═══════════════════════════════════════════════════════════════');
return lines.join('\n');
}
// ============================================
// Lock File Cleanup and Signal Handling (Story 12.10 - Task 3)
// ============================================
/**
* Active lock files being tracked for cleanup
* @type {Set<string>}
*/
const activeLockFiles = new Set();
/**
* Active child processes being tracked for cleanup
* @type {Set<ChildProcess>}
*/
const activeChildProcesses = new Set();
/**
* Registers a lock file for cleanup on process exit
* @param {string} lockPath - Path to lock file
*/
function registerLockFile(lockPath) {
activeLockFiles.add(lockPath);
}
/**
* Unregisters a lock file (called when process completes normally)
* @param {string} lockPath - Path to lock file
*/
function unregisterLockFile(lockPath) {
activeLockFiles.delete(lockPath);
}
/**
* Registers a child process for cleanup on parent exit
* @param {ChildProcess} child - Child process to track
*/
function registerChildProcess(child) {
activeChildProcesses.add(child);
child.on('close', () => {
activeChildProcesses.delete(child);
});
}
/**
* Cleans up all registered lock files (Task 3.3)
* Called on process exit or signal
*/
function cleanupLocks() {
for (const lockPath of activeLockFiles) {
try {
if (fsSync.existsSync(lockPath)) {
fsSync.unlinkSync(lockPath);
}
} catch {
// Ignore cleanup errors
}
}
activeLockFiles.clear();
}
/**
* Terminates all active child processes (Task 3.4)
*/
function terminateChildProcesses() {
for (const child of activeChildProcesses) {
try {
child.kill('SIGTERM');
} catch {
// Ignore if already dead
}
}
activeChildProcesses.clear();
}
/**
* Cleanup handler for process exit/signals (Task 3.4)
* @param {string} signal - Signal name or 'exit'
*/
function cleanupHandler(signal) {
console.log(`[TerminalSpawner] Cleanup triggered by ${signal}`);
cleanupLocks();
terminateChildProcesses();
}
// Register cleanup handlers (Task 3.4)
// Only register once, even if module is required multiple times
if (!process._terminalSpawnerCleanupRegistered) {
process._terminalSpawnerCleanupRegistered = true;
// Normal exit
process.on('exit', () => cleanupHandler('exit'));
// Ctrl+C
process.on('SIGINT', () => {
cleanupHandler('SIGINT');
process.exit(1);
});
// Termination signal (kill command)
process.on('SIGTERM', () => {
cleanupHandler('SIGTERM');
process.exit(1);
});
// Uncaught exception (try to cleanup before crash)
process.on('uncaughtException', (error) => {
console.error('[TerminalSpawner] Uncaught exception:', error);
cleanupHandler('uncaughtException');
process.exit(1);
});
// Unhandled promise rejection
process.on('unhandledRejection', (reason) => {
console.error('[TerminalSpawner] Unhandled rejection:', reason);
cleanupHandler('unhandledRejection');
process.exit(1);
});
}
module.exports = {
// Main API
spawnAgent,
spawnInline,
createContextFile,
pollForOutput,
// Environment Detection (Story 12.10)
detectEnvironment,
ENVIRONMENT_TYPE,
// OS Compatibility Matrix (Story 12.10 - Task 7)
OS_COMPATIBILITY_MATRIX,
getSystemInfo,
generateCompatibilityReport,
formatCompatibilityReport,
// Utilities
isSpawnerAvailable,
getPlatform,
cleanupOldFiles,
getScriptPath,
// Lock Management (Story 12.10)
registerLockFile,
unregisterLockFile,
cleanupLocks,
// Constants
DEFAULT_TIMEOUT_MS,
POLL_INTERVAL_MS,
MAX_RETRIES,
};