sinapse-ai 1.19.0 → 1.19.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/.sinapse-ai/core/execution/build-orchestrator.js +31 -0
- package/.sinapse-ai/core/orchestration/bob-orchestrator.js +2 -4
- package/.sinapse-ai/core/orchestration/executors/epic-4-executor.js +57 -40
- package/.sinapse-ai/core/orchestration/gate-evaluator.js +124 -3
- package/.sinapse-ai/core/orchestration/greenfield-handler.js +14 -46
- package/.sinapse-ai/core/orchestration/index.js +0 -12
- package/.sinapse-ai/core/orchestration/master-orchestrator.js +60 -4
- package/.sinapse-ai/core/orchestration/workflow-executor.js +8 -150
- package/.sinapse-ai/data/entity-registry.yaml +315 -527
- package/.sinapse-ai/development/agents/project-lead/MEMORY.md +1 -1
- package/.sinapse-ai/development/agents/project-lead.md +9 -13
- package/.sinapse-ai/development/tasks/push-and-pr.md +1 -1
- package/.sinapse-ai/development/workflows/development-cycle.yaml +1 -12
- package/.sinapse-ai/install-manifest.yaml +26 -34
- package/CHANGELOG.md +41 -26
- package/package.json +1 -1
- package/.sinapse-ai/core/orchestration/terminal-spawner.js +0 -1067
- package/.sinapse-ai/scripts/pm.sh +0 -466
|
@@ -1,1067 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Terminal Spawner - Node.js wrapper for pm.sh
|
|
3
|
-
*
|
|
4
|
-
* Provides async API for spawning SINAPSE agents in separate terminals
|
|
5
|
-
* to maintain clean context isolation during orchestration.
|
|
6
|
-
*
|
|
7
|
-
* Story 11.2: Bob Terminal Spawning
|
|
8
|
-
*
|
|
9
|
-
* @module core/orchestration/terminal-spawner
|
|
10
|
-
* @version 1.0.0
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
'use strict';
|
|
14
|
-
|
|
15
|
-
const { spawn, execSync, execFileSync } = require('child_process');
|
|
16
|
-
const fs = require('fs').promises;
|
|
17
|
-
const fsSync = require('fs');
|
|
18
|
-
const path = require('path');
|
|
19
|
-
const os = require('os');
|
|
20
|
-
|
|
21
|
-
// Constants
|
|
22
|
-
const POLL_INTERVAL_MS = 500;
|
|
23
|
-
const DEFAULT_TIMEOUT_MS = 300000; // 5 minutes
|
|
24
|
-
const MAX_RETRIES = 3;
|
|
25
|
-
const RETRY_DELAY_MS = 1000;
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Environment types for spawn strategy selection
|
|
29
|
-
* @readonly
|
|
30
|
-
* @enum {string}
|
|
31
|
-
*/
|
|
32
|
-
const ENVIRONMENT_TYPE = {
|
|
33
|
-
NATIVE_TERMINAL: 'NATIVE_TERMINAL',
|
|
34
|
-
VSCODE: 'VSCODE',
|
|
35
|
-
SSH: 'SSH',
|
|
36
|
-
DOCKER: 'DOCKER',
|
|
37
|
-
CI: 'CI',
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Environment detection result
|
|
42
|
-
* @typedef {Object} EnvironmentInfo
|
|
43
|
-
* @property {string} type - Environment type (ENVIRONMENT_TYPE enum value)
|
|
44
|
-
* @property {boolean} supportsVisualTerminal - Whether visual terminal spawn is supported
|
|
45
|
-
* @property {string} reason - Human-readable reason for detection
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Detects the current execution environment (Story 12.10 - Task 1)
|
|
50
|
-
*
|
|
51
|
-
* Detection priority:
|
|
52
|
-
* 1. CI/CD (GitHub Actions, GitLab CI, etc.)
|
|
53
|
-
* 2. Docker container
|
|
54
|
-
* 3. SSH session
|
|
55
|
-
* 4. VS Code integrated terminal
|
|
56
|
-
* 5. Native terminal (default)
|
|
57
|
-
*
|
|
58
|
-
* @returns {EnvironmentInfo} Environment detection result
|
|
59
|
-
*/
|
|
60
|
-
function detectEnvironment() {
|
|
61
|
-
// 1. CI/CD detection (Task 1.5)
|
|
62
|
-
// Check common CI environment variables
|
|
63
|
-
if (
|
|
64
|
-
process.env.CI === 'true' ||
|
|
65
|
-
process.env.GITHUB_ACTIONS === 'true' ||
|
|
66
|
-
process.env.GITLAB_CI === 'true' ||
|
|
67
|
-
process.env.JENKINS_URL ||
|
|
68
|
-
process.env.TRAVIS === 'true' ||
|
|
69
|
-
process.env.CIRCLECI === 'true' ||
|
|
70
|
-
process.env.BUILDKITE === 'true' ||
|
|
71
|
-
process.env.AZURE_PIPELINES === 'true' ||
|
|
72
|
-
process.env.TF_BUILD === 'True'
|
|
73
|
-
) {
|
|
74
|
-
return {
|
|
75
|
-
type: ENVIRONMENT_TYPE.CI,
|
|
76
|
-
supportsVisualTerminal: false,
|
|
77
|
-
reason: 'CI/CD environment detected (headless)',
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// 2. Docker container detection (Task 1.4)
|
|
82
|
-
// Check for /.dockerenv file or cgroup indicators
|
|
83
|
-
try {
|
|
84
|
-
if (fsSync.existsSync('/.dockerenv')) {
|
|
85
|
-
return {
|
|
86
|
-
type: ENVIRONMENT_TYPE.DOCKER,
|
|
87
|
-
supportsVisualTerminal: false,
|
|
88
|
-
reason: 'Docker container detected (/.dockerenv exists)',
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Check cgroup for docker/containerd/kubepods
|
|
93
|
-
if (fsSync.existsSync('/proc/1/cgroup')) {
|
|
94
|
-
const cgroup = fsSync.readFileSync('/proc/1/cgroup', 'utf8');
|
|
95
|
-
if (cgroup.includes('docker') || cgroup.includes('containerd') || cgroup.includes('kubepods')) {
|
|
96
|
-
return {
|
|
97
|
-
type: ENVIRONMENT_TYPE.DOCKER,
|
|
98
|
-
supportsVisualTerminal: false,
|
|
99
|
-
reason: 'Container detected via cgroup',
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
} catch {
|
|
104
|
-
// Ignore file read errors (e.g., on Windows)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// 3. SSH session detection (Task 1.3)
|
|
108
|
-
if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) {
|
|
109
|
-
return {
|
|
110
|
-
type: ENVIRONMENT_TYPE.SSH,
|
|
111
|
-
supportsVisualTerminal: false,
|
|
112
|
-
reason: 'SSH session detected (no display available)',
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// 4. VS Code integrated terminal detection (Task 1.2)
|
|
117
|
-
if (
|
|
118
|
-
process.env.TERM_PROGRAM === 'vscode' ||
|
|
119
|
-
process.env.VSCODE_PID ||
|
|
120
|
-
process.env.VSCODE_CWD ||
|
|
121
|
-
process.env.VSCODE_GIT_IPC_HANDLE
|
|
122
|
-
) {
|
|
123
|
-
return {
|
|
124
|
-
type: ENVIRONMENT_TYPE.VSCODE,
|
|
125
|
-
supportsVisualTerminal: false,
|
|
126
|
-
reason: 'VS Code integrated terminal detected',
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// 5. Native terminal (default) - supports visual spawn
|
|
131
|
-
return {
|
|
132
|
-
type: ENVIRONMENT_TYPE.NATIVE_TERMINAL,
|
|
133
|
-
supportsVisualTerminal: true,
|
|
134
|
-
reason: 'Native terminal environment',
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Default spawn options
|
|
140
|
-
* @type {Object}
|
|
141
|
-
*/
|
|
142
|
-
const DEFAULT_OPTIONS = {
|
|
143
|
-
params: '',
|
|
144
|
-
context: null,
|
|
145
|
-
timeout: DEFAULT_TIMEOUT_MS,
|
|
146
|
-
outputDir: os.tmpdir(),
|
|
147
|
-
retries: MAX_RETRIES,
|
|
148
|
-
debug: false,
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Context schema for agent execution
|
|
153
|
-
* @typedef {Object} AgentContext
|
|
154
|
-
* @property {string} story - Story file path or content
|
|
155
|
-
* @property {string[]} files - Array of relevant file paths
|
|
156
|
-
* @property {string} instructions - Additional instructions for the agent
|
|
157
|
-
* @property {Object} metadata - Additional metadata
|
|
158
|
-
*/
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Spawn result object
|
|
162
|
-
* @typedef {Object} SpawnResult
|
|
163
|
-
* @property {boolean} success - Whether spawn was successful
|
|
164
|
-
* @property {string} output - Agent output content
|
|
165
|
-
* @property {string} outputFile - Path to output file
|
|
166
|
-
* @property {number} duration - Execution duration in ms
|
|
167
|
-
* @property {string} [error] - Error message if failed
|
|
168
|
-
*/
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Gets the path to the pm.sh script
|
|
172
|
-
* @returns {string} Absolute path to pm.sh
|
|
173
|
-
*/
|
|
174
|
-
function getScriptPath() {
|
|
175
|
-
return path.join(__dirname, '../../scripts/pm.sh');
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Validates spawn arguments
|
|
180
|
-
* @param {string} agent - Agent ID
|
|
181
|
-
* @param {string} task - Task to execute
|
|
182
|
-
* @throws {Error} If arguments are invalid
|
|
183
|
-
*/
|
|
184
|
-
function validateArgs(agent, task) {
|
|
185
|
-
if (!agent || typeof agent !== 'string') {
|
|
186
|
-
throw new Error('Agent ID is required and must be a string');
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
if (!task || typeof task !== 'string') {
|
|
190
|
-
throw new Error('Task is required and must be a string');
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Validate agent format (should be alphanumeric with optional hyphen)
|
|
194
|
-
const agentPattern = /^[a-zA-Z][a-zA-Z0-9-]*$/;
|
|
195
|
-
if (!agentPattern.test(agent)) {
|
|
196
|
-
throw new Error(`Invalid agent ID format: ${agent}`);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Validate task format
|
|
200
|
-
const taskPattern = /^[a-zA-Z][a-zA-Z0-9-]*$/;
|
|
201
|
-
if (!taskPattern.test(task)) {
|
|
202
|
-
throw new Error(`Invalid task format: ${task}`);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Creates a temporary context file for the agent (Task 2.2)
|
|
208
|
-
*
|
|
209
|
-
* @param {AgentContext} context - Context data to pass to agent
|
|
210
|
-
* @param {string} outputDir - Directory for temp files
|
|
211
|
-
* @returns {Promise<string>} Path to created context file
|
|
212
|
-
*/
|
|
213
|
-
async function createContextFile(context, outputDir = os.tmpdir()) {
|
|
214
|
-
if (!context) {
|
|
215
|
-
return '';
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Validate context structure
|
|
219
|
-
const validatedContext = {
|
|
220
|
-
story: context.story || '',
|
|
221
|
-
files: Array.isArray(context.files) ? context.files : [],
|
|
222
|
-
instructions: context.instructions || '',
|
|
223
|
-
metadata: context.metadata || {},
|
|
224
|
-
createdAt: new Date().toISOString(),
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
const contextPath = path.join(outputDir, `sinapse-context-${Date.now()}.json`);
|
|
228
|
-
await fs.writeFile(contextPath, JSON.stringify(validatedContext, null, 2));
|
|
229
|
-
|
|
230
|
-
return contextPath;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Polls for agent output completion (Task 3.2, 3.3)
|
|
235
|
-
*
|
|
236
|
-
* @param {string} outputFile - Path to output file
|
|
237
|
-
* @param {number} timeout - Timeout in milliseconds
|
|
238
|
-
* @param {boolean} debug - Enable debug logging
|
|
239
|
-
* @returns {Promise<string>} Output content
|
|
240
|
-
* @throws {Error} If timeout exceeded
|
|
241
|
-
*/
|
|
242
|
-
async function pollForOutput(outputFile, timeout = DEFAULT_TIMEOUT_MS, debug = false) {
|
|
243
|
-
const startTime = Date.now();
|
|
244
|
-
const lockFile = outputFile.replace('output', 'lock');
|
|
245
|
-
|
|
246
|
-
if (debug) {
|
|
247
|
-
console.log(`[TerminalSpawner] Polling for output: ${outputFile}`);
|
|
248
|
-
console.log(`[TerminalSpawner] Lock file: ${lockFile}`);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
while (Date.now() - startTime < timeout) {
|
|
252
|
-
// Check if lock file is gone (agent finished)
|
|
253
|
-
try {
|
|
254
|
-
await fs.access(lockFile);
|
|
255
|
-
// Lock still exists, wait and retry
|
|
256
|
-
if (debug) {
|
|
257
|
-
console.log(`[TerminalSpawner] Lock exists, waiting ${POLL_INTERVAL_MS}ms...`);
|
|
258
|
-
}
|
|
259
|
-
await sleep(POLL_INTERVAL_MS);
|
|
260
|
-
} catch {
|
|
261
|
-
// Lock gone, agent finished - read output
|
|
262
|
-
if (debug) {
|
|
263
|
-
console.log('[TerminalSpawner] Lock removed, reading output...');
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
try {
|
|
267
|
-
const output = await fs.readFile(outputFile, 'utf8');
|
|
268
|
-
return output;
|
|
269
|
-
} catch (readError) {
|
|
270
|
-
if (debug) {
|
|
271
|
-
console.log(`[TerminalSpawner] Output file not found: ${readError.message}`);
|
|
272
|
-
}
|
|
273
|
-
return 'No output captured';
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Timeout - cleanup lock file if still exists
|
|
279
|
-
try {
|
|
280
|
-
await fs.unlink(lockFile);
|
|
281
|
-
} catch {
|
|
282
|
-
// Ignore cleanup errors
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
throw new Error(`Timeout waiting for agent output after ${timeout}ms`);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Sleep utility
|
|
290
|
-
* @param {number} ms - Milliseconds to sleep
|
|
291
|
-
* @returns {Promise<void>}
|
|
292
|
-
*/
|
|
293
|
-
function sleep(ms) {
|
|
294
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Spawns an agent inline using child_process.spawn (Story 12.10 - Task 2)
|
|
299
|
-
*
|
|
300
|
-
* This is used when visual terminal spawning is not available (VS Code, SSH, Docker, CI).
|
|
301
|
-
* Output is piped directly instead of using a separate terminal window.
|
|
302
|
-
*
|
|
303
|
-
* @param {string} agent - Agent ID (e.g., 'dev', 'architect', 'qa')
|
|
304
|
-
* @param {string} task - Task to execute
|
|
305
|
-
* @param {Object} options - Spawn options
|
|
306
|
-
* @param {string} [options.params=''] - Additional parameters
|
|
307
|
-
* @param {AgentContext} [options.context=null] - Context data for agent
|
|
308
|
-
* @param {number} [options.timeout=300000] - Timeout in ms
|
|
309
|
-
* @param {string} [options.outputDir] - Directory for output files
|
|
310
|
-
* @param {boolean} [options.debug=false] - Enable debug logging
|
|
311
|
-
* @returns {Promise<SpawnResult>} Result with output and status
|
|
312
|
-
*/
|
|
313
|
-
async function spawnInline(agent, task, options = {}) {
|
|
314
|
-
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
315
|
-
const startTime = Date.now();
|
|
316
|
-
|
|
317
|
-
if (opts.debug) {
|
|
318
|
-
console.log('[TerminalSpawner] Using inline spawn (no visual terminal)');
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Create context file if needed
|
|
322
|
-
let contextPath = '';
|
|
323
|
-
if (opts.context) {
|
|
324
|
-
contextPath = await createContextFile(opts.context, opts.outputDir);
|
|
325
|
-
if (opts.debug) {
|
|
326
|
-
console.log(`[TerminalSpawner] Created context file: ${contextPath}`);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Build command arguments for pm.sh
|
|
331
|
-
const args = [agent, task];
|
|
332
|
-
if (opts.params) {
|
|
333
|
-
args.push(opts.params);
|
|
334
|
-
}
|
|
335
|
-
if (contextPath) {
|
|
336
|
-
args.push('--context', contextPath);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
const scriptPath = getScriptPath();
|
|
340
|
-
|
|
341
|
-
// Verify script exists
|
|
342
|
-
if (!fsSync.existsSync(scriptPath)) {
|
|
343
|
-
throw new Error(`pm.sh script not found at: ${scriptPath}`);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
return new Promise((resolve) => {
|
|
347
|
-
const outputChunks = [];
|
|
348
|
-
const errorChunks = [];
|
|
349
|
-
|
|
350
|
-
// Spawn the process inline with piped stdout/stderr (Task 2.2)
|
|
351
|
-
const child = spawn('bash', [scriptPath, ...args], {
|
|
352
|
-
env: {
|
|
353
|
-
...process.env,
|
|
354
|
-
SINAPSE_DEBUG: opts.debug ? 'true' : 'false',
|
|
355
|
-
SINAPSE_OUTPUT_DIR: opts.outputDir,
|
|
356
|
-
SINAPSE_INLINE_MODE: 'true', // Signal to pm.sh that we're running inline
|
|
357
|
-
},
|
|
358
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
// Register child process for cleanup on parent exit (Task 3.4)
|
|
362
|
-
registerChildProcess(child);
|
|
363
|
-
|
|
364
|
-
// Capture stdout
|
|
365
|
-
child.stdout.on('data', (data) => {
|
|
366
|
-
outputChunks.push(data);
|
|
367
|
-
if (opts.debug) {
|
|
368
|
-
process.stdout.write(data);
|
|
369
|
-
}
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
// Capture stderr
|
|
373
|
-
child.stderr.on('data', (data) => {
|
|
374
|
-
errorChunks.push(data);
|
|
375
|
-
if (opts.debug) {
|
|
376
|
-
process.stderr.write(data);
|
|
377
|
-
}
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
// Set timeout
|
|
381
|
-
const timeoutId = setTimeout(() => {
|
|
382
|
-
child.kill('SIGTERM');
|
|
383
|
-
const duration = Date.now() - startTime;
|
|
384
|
-
|
|
385
|
-
// Cleanup context file
|
|
386
|
-
if (contextPath) {
|
|
387
|
-
fs.unlink(contextPath).catch(() => {});
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
resolve({
|
|
391
|
-
success: false,
|
|
392
|
-
output: Buffer.concat(outputChunks).toString('utf8'),
|
|
393
|
-
outputFile: '',
|
|
394
|
-
duration,
|
|
395
|
-
error: `Timeout after ${opts.timeout}ms`,
|
|
396
|
-
});
|
|
397
|
-
}, opts.timeout);
|
|
398
|
-
|
|
399
|
-
// Handle spawn failure (e.g. bash absent on Windows without Git Bash/WSL).
|
|
400
|
-
// Without an 'error' listener Node throws ENOENT as an uncaughtException and
|
|
401
|
-
// this Promise would never resolve (hang). pm.sh requires bash by design.
|
|
402
|
-
child.on('error', (err) => {
|
|
403
|
-
clearTimeout(timeoutId);
|
|
404
|
-
if (contextPath) {
|
|
405
|
-
fs.unlink(contextPath).catch(() => {});
|
|
406
|
-
}
|
|
407
|
-
resolve({
|
|
408
|
-
success: false,
|
|
409
|
-
output: '',
|
|
410
|
-
outputFile: '',
|
|
411
|
-
duration: Date.now() - startTime,
|
|
412
|
-
error: err.code === 'ENOENT'
|
|
413
|
-
? 'bash nao encontrado (instale Git Bash ou WSL no Windows)'
|
|
414
|
-
: err.message,
|
|
415
|
-
});
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
// Handle process completion
|
|
419
|
-
child.on('close', async (code) => {
|
|
420
|
-
clearTimeout(timeoutId);
|
|
421
|
-
const duration = Date.now() - startTime;
|
|
422
|
-
|
|
423
|
-
// Cleanup context file
|
|
424
|
-
if (contextPath) {
|
|
425
|
-
fs.unlink(contextPath).catch(() => {});
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
const stdout = Buffer.concat(outputChunks).toString('utf8').trim();
|
|
429
|
-
const stderr = Buffer.concat(errorChunks).toString('utf8');
|
|
430
|
-
|
|
431
|
-
if (code === 0) {
|
|
432
|
-
// pm.sh returns the output file path in stdout
|
|
433
|
-
// Read the actual output from that file
|
|
434
|
-
let output = stdout;
|
|
435
|
-
const outputFilePath = stdout.split('\n').pop()?.trim();
|
|
436
|
-
|
|
437
|
-
if (outputFilePath && outputFilePath.endsWith('.md')) {
|
|
438
|
-
try {
|
|
439
|
-
output = await fs.readFile(outputFilePath, 'utf8');
|
|
440
|
-
// Cleanup the output file after reading
|
|
441
|
-
await fs.unlink(outputFilePath).catch(() => {});
|
|
442
|
-
} catch {
|
|
443
|
-
// If we can't read the file, use stdout as fallback
|
|
444
|
-
output = stdout;
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
resolve({
|
|
449
|
-
success: true,
|
|
450
|
-
output,
|
|
451
|
-
outputFile: outputFilePath || '',
|
|
452
|
-
duration,
|
|
453
|
-
});
|
|
454
|
-
} else {
|
|
455
|
-
resolve({
|
|
456
|
-
success: false,
|
|
457
|
-
output: stdout,
|
|
458
|
-
outputFile: '',
|
|
459
|
-
duration,
|
|
460
|
-
error: stderr || `Process exited with code ${code}`,
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
// Handle spawn errors
|
|
466
|
-
child.on('error', (error) => {
|
|
467
|
-
clearTimeout(timeoutId);
|
|
468
|
-
const duration = Date.now() - startTime;
|
|
469
|
-
|
|
470
|
-
// Cleanup context file
|
|
471
|
-
if (contextPath) {
|
|
472
|
-
fs.unlink(contextPath).catch(() => {});
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
resolve({
|
|
476
|
-
success: false,
|
|
477
|
-
output: Buffer.concat(outputChunks).toString('utf8'),
|
|
478
|
-
outputFile: '',
|
|
479
|
-
duration,
|
|
480
|
-
error: error.message,
|
|
481
|
-
});
|
|
482
|
-
});
|
|
483
|
-
});
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* Spawns an agent in a separate terminal (Task 4.1)
|
|
488
|
-
*
|
|
489
|
-
* Opens a new terminal window with the specified agent and task,
|
|
490
|
-
* passing context if provided. Returns the agent's output after completion.
|
|
491
|
-
*
|
|
492
|
-
* @param {string} agent - Agent ID (e.g., 'dev', 'architect', 'qa')
|
|
493
|
-
* @param {string} task - Task to execute (e.g., 'develop', 'review')
|
|
494
|
-
* @param {Object} options - Spawn options
|
|
495
|
-
* @param {string} [options.params=''] - Additional parameters
|
|
496
|
-
* @param {AgentContext} [options.context=null] - Context data for agent
|
|
497
|
-
* @param {number} [options.timeout=300000] - Timeout in ms (default: 5 min)
|
|
498
|
-
* @param {string} [options.outputDir] - Directory for output files
|
|
499
|
-
* @param {number} [options.retries=3] - Number of retry attempts
|
|
500
|
-
* @param {boolean} [options.debug=false] - Enable debug logging
|
|
501
|
-
* @returns {Promise<SpawnResult>} Result with output and status
|
|
502
|
-
*
|
|
503
|
-
* @example
|
|
504
|
-
* const result = await spawnAgent('dev', 'develop', {
|
|
505
|
-
* params: 'story-11.2',
|
|
506
|
-
* context: {
|
|
507
|
-
* story: 'docs/stories/active/11.2.story.md',
|
|
508
|
-
* files: ['src/index.js'],
|
|
509
|
-
* instructions: 'Focus on terminal spawning'
|
|
510
|
-
* },
|
|
511
|
-
* timeout: 600000 // 10 minutes
|
|
512
|
-
* });
|
|
513
|
-
*/
|
|
514
|
-
async function spawnAgent(agent, task, options = {}) {
|
|
515
|
-
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
516
|
-
const startTime = Date.now();
|
|
517
|
-
|
|
518
|
-
// Validate arguments
|
|
519
|
-
validateArgs(agent, task);
|
|
520
|
-
|
|
521
|
-
// Detect environment to determine spawn strategy (Story 12.10 - Task 2.5)
|
|
522
|
-
const environment = detectEnvironment();
|
|
523
|
-
|
|
524
|
-
if (opts.debug) {
|
|
525
|
-
console.log(`[TerminalSpawner] Environment: ${environment.type} (${environment.reason})`);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// If environment doesn't support visual terminal, use inline spawn directly
|
|
529
|
-
if (!environment.supportsVisualTerminal) {
|
|
530
|
-
console.log(`⚠️ Terminal visual indisponível. Executando inline. [${environment.reason}]`);
|
|
531
|
-
return spawnInline(agent, task, opts);
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// Create context file if needed (Task 2.2)
|
|
535
|
-
let contextPath = '';
|
|
536
|
-
if (opts.context) {
|
|
537
|
-
contextPath = await createContextFile(opts.context, opts.outputDir);
|
|
538
|
-
if (opts.debug) {
|
|
539
|
-
console.log(`[TerminalSpawner] Created context file: ${contextPath}`);
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// Build command arguments
|
|
544
|
-
const args = [agent, task];
|
|
545
|
-
if (opts.params) {
|
|
546
|
-
args.push(opts.params);
|
|
547
|
-
}
|
|
548
|
-
if (contextPath) {
|
|
549
|
-
args.push('--context', contextPath);
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
// Get script path
|
|
553
|
-
const scriptPath = getScriptPath();
|
|
554
|
-
|
|
555
|
-
// Verify script exists
|
|
556
|
-
if (!fsSync.existsSync(scriptPath)) {
|
|
557
|
-
throw new Error(`pm.sh script not found at: ${scriptPath}`);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// Execute with retry logic (Task 4.2)
|
|
561
|
-
let lastError;
|
|
562
|
-
for (let attempt = 1; attempt <= opts.retries; attempt++) {
|
|
563
|
-
try {
|
|
564
|
-
if (opts.debug) {
|
|
565
|
-
console.log(`[TerminalSpawner] Attempt ${attempt}/${opts.retries}`);
|
|
566
|
-
console.log(`[TerminalSpawner] Executing: bash ${scriptPath} ${args.join(' ')}`);
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// Execute pm.sh
|
|
570
|
-
const env = {
|
|
571
|
-
...process.env,
|
|
572
|
-
SINAPSE_DEBUG: opts.debug ? 'true' : 'false',
|
|
573
|
-
SINAPSE_OUTPUT_DIR: opts.outputDir,
|
|
574
|
-
};
|
|
575
|
-
|
|
576
|
-
const result = execFileSync('bash', [scriptPath, ...args], {
|
|
577
|
-
encoding: 'utf8',
|
|
578
|
-
timeout: opts.timeout,
|
|
579
|
-
env,
|
|
580
|
-
});
|
|
581
|
-
|
|
582
|
-
// Get output file path from script output
|
|
583
|
-
const outputFile = result.trim();
|
|
584
|
-
|
|
585
|
-
if (opts.debug) {
|
|
586
|
-
console.log(`[TerminalSpawner] Output file: ${outputFile}`);
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// Poll for completion (Task 3.2, 3.3)
|
|
590
|
-
const output = await pollForOutput(outputFile, opts.timeout, opts.debug);
|
|
591
|
-
|
|
592
|
-
// Cleanup context file (Task 2.4)
|
|
593
|
-
if (contextPath) {
|
|
594
|
-
await fs.unlink(contextPath).catch(() => {});
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
const duration = Date.now() - startTime;
|
|
598
|
-
|
|
599
|
-
return {
|
|
600
|
-
success: true,
|
|
601
|
-
output,
|
|
602
|
-
outputFile,
|
|
603
|
-
duration,
|
|
604
|
-
};
|
|
605
|
-
} catch (error) {
|
|
606
|
-
lastError = error;
|
|
607
|
-
if (opts.debug) {
|
|
608
|
-
console.log(`[TerminalSpawner] Attempt ${attempt} failed: ${error.message}`);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
if (attempt < opts.retries) {
|
|
612
|
-
await sleep(RETRY_DELAY_MS * attempt);
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// Fallback to inline spawn if visual terminal fails (Story 12.10 - Task 2.3)
|
|
618
|
-
console.log('⚠️ Terminal visual falhou. Tentando execução inline como fallback...');
|
|
619
|
-
|
|
620
|
-
// Cleanup context file before retry with inline
|
|
621
|
-
if (contextPath) {
|
|
622
|
-
await fs.unlink(contextPath).catch(() => {});
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// Try inline spawn as fallback
|
|
626
|
-
const inlineResult = await spawnInline(agent, task, opts);
|
|
627
|
-
|
|
628
|
-
if (inlineResult.success) {
|
|
629
|
-
console.log('✅ Execução inline bem-sucedida.');
|
|
630
|
-
return inlineResult;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
// Both methods failed
|
|
634
|
-
const duration = Date.now() - startTime;
|
|
635
|
-
|
|
636
|
-
return {
|
|
637
|
-
success: false,
|
|
638
|
-
output: inlineResult.output || '',
|
|
639
|
-
outputFile: '',
|
|
640
|
-
duration,
|
|
641
|
-
error: `Visual spawn failed: ${lastError?.message || 'Unknown'}. Inline fallback also failed: ${inlineResult.error || 'Unknown'}`,
|
|
642
|
-
};
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/**
|
|
646
|
-
* Checks if the terminal spawner is available on this platform
|
|
647
|
-
* @returns {boolean} True if spawning is supported
|
|
648
|
-
*/
|
|
649
|
-
function isSpawnerAvailable() {
|
|
650
|
-
const platform = process.platform;
|
|
651
|
-
return ['darwin', 'linux', 'win32'].includes(platform);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/**
|
|
655
|
-
* Gets the current platform name
|
|
656
|
-
* @returns {string} Platform name (macos, linux, windows, or unknown)
|
|
657
|
-
*/
|
|
658
|
-
function getPlatform() {
|
|
659
|
-
switch (process.platform) {
|
|
660
|
-
case 'darwin':
|
|
661
|
-
return 'macos';
|
|
662
|
-
case 'linux':
|
|
663
|
-
return 'linux';
|
|
664
|
-
case 'win32':
|
|
665
|
-
return 'windows';
|
|
666
|
-
default:
|
|
667
|
-
return 'unknown';
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
/**
|
|
672
|
-
* Cleans up old output and lock files (Task 2.4)
|
|
673
|
-
*
|
|
674
|
-
* @param {string} outputDir - Directory to clean
|
|
675
|
-
* @param {number} maxAgeMs - Maximum age in milliseconds (default: 1 hour)
|
|
676
|
-
* @returns {Promise<number>} Number of files cleaned
|
|
677
|
-
*/
|
|
678
|
-
async function cleanupOldFiles(outputDir = os.tmpdir(), maxAgeMs = 3600000) {
|
|
679
|
-
const now = Date.now();
|
|
680
|
-
let cleaned = 0;
|
|
681
|
-
|
|
682
|
-
try {
|
|
683
|
-
const files = await fs.readdir(outputDir);
|
|
684
|
-
const sinapseFiles = files.filter(
|
|
685
|
-
(f) => f.startsWith('sinapse-output-') || f.startsWith('sinapse-lock-') || f.startsWith('sinapse-context-'),
|
|
686
|
-
);
|
|
687
|
-
|
|
688
|
-
for (const file of sinapseFiles) {
|
|
689
|
-
const filePath = path.join(outputDir, file);
|
|
690
|
-
try {
|
|
691
|
-
const stats = await fs.stat(filePath);
|
|
692
|
-
if (now - stats.mtimeMs > maxAgeMs) {
|
|
693
|
-
await fs.unlink(filePath);
|
|
694
|
-
cleaned++;
|
|
695
|
-
}
|
|
696
|
-
} catch {
|
|
697
|
-
// Ignore errors for individual files
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
} catch {
|
|
701
|
-
// Ignore directory read errors
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
return cleaned;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// ============================================
|
|
708
|
-
// OS Compatibility Matrix (Story 12.10 - Task 7)
|
|
709
|
-
// ============================================
|
|
710
|
-
|
|
711
|
-
/**
|
|
712
|
-
* OS Compatibility Matrix definition (PRD §9.6 — D19)
|
|
713
|
-
* @type {Object}
|
|
714
|
-
*/
|
|
715
|
-
const OS_COMPATIBILITY_MATRIX = {
|
|
716
|
-
must_pass: [
|
|
717
|
-
{ os: 'macOS Sonoma', arch: 'arm64', docker: 'Docker Desktop', description: 'Apple Silicon' },
|
|
718
|
-
{ os: 'macOS Sonoma', arch: 'x64', docker: 'Docker Desktop', description: 'Intel' },
|
|
719
|
-
{ os: 'Windows 11', arch: 'x64', docker: 'Docker Desktop', wsl: 'Ubuntu 22.04', description: 'WSL2' },
|
|
720
|
-
{ os: 'Ubuntu 22.04', arch: 'x64', docker: 'Docker Engine', description: 'Native Linux' },
|
|
721
|
-
],
|
|
722
|
-
should_pass: [
|
|
723
|
-
{ os: 'Windows 10', arch: 'x64', wsl: 'Ubuntu', description: 'WSL2' },
|
|
724
|
-
{ os: 'macOS Ventura', arch: 'arm64', docker: 'Docker Desktop', description: 'Previous macOS' },
|
|
725
|
-
{ os: 'macOS Ventura', arch: 'x64', docker: 'Docker Desktop', description: 'Previous macOS Intel' },
|
|
726
|
-
{ os: 'Ubuntu 24.04', arch: 'x64', docker: 'Docker Engine', description: 'Latest Ubuntu' },
|
|
727
|
-
],
|
|
728
|
-
};
|
|
729
|
-
|
|
730
|
-
/**
|
|
731
|
-
* Compatibility test result
|
|
732
|
-
* @typedef {Object} CompatibilityTestResult
|
|
733
|
-
* @property {string} testName - Name of the test
|
|
734
|
-
* @property {string} result - 'pass' | 'fail' | 'skip'
|
|
735
|
-
* @property {string} [failureReason] - Reason for failure if applicable
|
|
736
|
-
* @property {number} duration - Test duration in ms
|
|
737
|
-
*/
|
|
738
|
-
|
|
739
|
-
/**
|
|
740
|
-
* Compatibility report structure
|
|
741
|
-
* @typedef {Object} CompatibilityReport
|
|
742
|
-
* @property {string} generatedAt - ISO timestamp
|
|
743
|
-
* @property {Object} system - System information
|
|
744
|
-
* @property {string} system.os_name - Operating system name
|
|
745
|
-
* @property {string} system.os_version - OS version
|
|
746
|
-
* @property {string} system.architecture - CPU architecture (x64, arm64)
|
|
747
|
-
* @property {string} system.shell - Default shell
|
|
748
|
-
* @property {string} system.docker_version - Docker version or 'not installed'
|
|
749
|
-
* @property {string} system.node_version - Node.js version
|
|
750
|
-
* @property {Object} environment - Detected environment
|
|
751
|
-
* @property {CompatibilityTestResult[]} tests - Test results
|
|
752
|
-
* @property {Object} summary - Summary statistics
|
|
753
|
-
*/
|
|
754
|
-
|
|
755
|
-
/**
|
|
756
|
-
* Gets current system information for compatibility reporting (Task 7.4)
|
|
757
|
-
* @returns {Object} System information
|
|
758
|
-
*/
|
|
759
|
-
function getSystemInfo() {
|
|
760
|
-
const { execSync } = require('child_process');
|
|
761
|
-
|
|
762
|
-
// Get OS info
|
|
763
|
-
const platform = os.platform();
|
|
764
|
-
const release = os.release();
|
|
765
|
-
const arch = os.arch();
|
|
766
|
-
|
|
767
|
-
// Get OS name
|
|
768
|
-
let osName = 'Unknown';
|
|
769
|
-
if (platform === 'darwin') {
|
|
770
|
-
try {
|
|
771
|
-
const swVers = execSync('sw_vers -productName 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
772
|
-
const swVersion = execSync('sw_vers -productVersion 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
773
|
-
osName = `${swVers} ${swVersion}`;
|
|
774
|
-
} catch {
|
|
775
|
-
osName = `macOS ${release}`;
|
|
776
|
-
}
|
|
777
|
-
} else if (platform === 'linux') {
|
|
778
|
-
try {
|
|
779
|
-
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();
|
|
780
|
-
osName = lsbRelease.replace('Description:\t', '') || `Linux ${release}`;
|
|
781
|
-
} catch {
|
|
782
|
-
osName = `Linux ${release}`;
|
|
783
|
-
}
|
|
784
|
-
} else if (platform === 'win32') {
|
|
785
|
-
osName = `Windows ${release}`;
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
// Get shell
|
|
789
|
-
const shell = process.env.SHELL || process.env.ComSpec || 'unknown';
|
|
790
|
-
|
|
791
|
-
// Get Docker version
|
|
792
|
-
let dockerVersion = 'not installed';
|
|
793
|
-
try {
|
|
794
|
-
dockerVersion = execSync('docker --version', {
|
|
795
|
-
encoding: 'utf8',
|
|
796
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
797
|
-
windowsHide: true,
|
|
798
|
-
}).trim();
|
|
799
|
-
} catch {
|
|
800
|
-
// Docker not available
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
// Get Node version
|
|
804
|
-
const nodeVersion = process.version;
|
|
805
|
-
|
|
806
|
-
return {
|
|
807
|
-
os_name: osName,
|
|
808
|
-
os_version: release,
|
|
809
|
-
architecture: arch,
|
|
810
|
-
shell: path.basename(shell),
|
|
811
|
-
docker_version: dockerVersion,
|
|
812
|
-
node_version: nodeVersion,
|
|
813
|
-
};
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
/**
|
|
817
|
-
* Generates a compatibility report after test run (Task 7.4, 7.5)
|
|
818
|
-
*
|
|
819
|
-
* @param {CompatibilityTestResult[]} testResults - Array of test results
|
|
820
|
-
* @returns {CompatibilityReport} Generated compatibility report
|
|
821
|
-
*/
|
|
822
|
-
function generateCompatibilityReport(testResults = []) {
|
|
823
|
-
const systemInfo = getSystemInfo();
|
|
824
|
-
const environment = detectEnvironment();
|
|
825
|
-
|
|
826
|
-
// Calculate summary
|
|
827
|
-
const passed = testResults.filter((t) => t.result === 'pass').length;
|
|
828
|
-
const failed = testResults.filter((t) => t.result === 'fail').length;
|
|
829
|
-
const skipped = testResults.filter((t) => t.result === 'skip').length;
|
|
830
|
-
const total = testResults.length;
|
|
831
|
-
|
|
832
|
-
// Determine matrix classification
|
|
833
|
-
const matchesMustPass = OS_COMPATIBILITY_MATRIX.must_pass.some(
|
|
834
|
-
(m) => systemInfo.os_name.toLowerCase().includes(m.os.toLowerCase().split(' ')[0]),
|
|
835
|
-
);
|
|
836
|
-
const matchesShouldPass = OS_COMPATIBILITY_MATRIX.should_pass.some(
|
|
837
|
-
(m) => systemInfo.os_name.toLowerCase().includes(m.os.toLowerCase().split(' ')[0]),
|
|
838
|
-
);
|
|
839
|
-
|
|
840
|
-
return {
|
|
841
|
-
generatedAt: new Date().toISOString(),
|
|
842
|
-
system: systemInfo,
|
|
843
|
-
environment: {
|
|
844
|
-
type: environment.type,
|
|
845
|
-
supportsVisualTerminal: environment.supportsVisualTerminal,
|
|
846
|
-
reason: environment.reason,
|
|
847
|
-
},
|
|
848
|
-
matrixClassification: matchesMustPass ? 'must_pass' : matchesShouldPass ? 'should_pass' : 'not_in_matrix',
|
|
849
|
-
tests: testResults,
|
|
850
|
-
summary: {
|
|
851
|
-
total,
|
|
852
|
-
passed,
|
|
853
|
-
failed,
|
|
854
|
-
skipped,
|
|
855
|
-
passRate: total > 0 ? Math.round((passed / total) * 100) : 0,
|
|
856
|
-
},
|
|
857
|
-
};
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
/**
|
|
861
|
-
* Formats compatibility report for console output (Task 7.5)
|
|
862
|
-
* @param {CompatibilityReport} report - Compatibility report
|
|
863
|
-
* @returns {string} Formatted report string
|
|
864
|
-
*/
|
|
865
|
-
function formatCompatibilityReport(report) {
|
|
866
|
-
const lines = [
|
|
867
|
-
'═══════════════════════════════════════════════════════════════',
|
|
868
|
-
' SINAPSE Terminal Spawner Compatibility Report ',
|
|
869
|
-
'═══════════════════════════════════════════════════════════════',
|
|
870
|
-
'',
|
|
871
|
-
`Generated: ${report.generatedAt}`,
|
|
872
|
-
'',
|
|
873
|
-
'── System Information ──────────────────────────────────────────',
|
|
874
|
-
` OS: ${report.system.os_name}`,
|
|
875
|
-
` Version: ${report.system.os_version}`,
|
|
876
|
-
` Architecture: ${report.system.architecture}`,
|
|
877
|
-
` Shell: ${report.system.shell}`,
|
|
878
|
-
` Docker: ${report.system.docker_version}`,
|
|
879
|
-
` Node.js: ${report.system.node_version}`,
|
|
880
|
-
'',
|
|
881
|
-
'── Environment Detection ───────────────────────────────────────',
|
|
882
|
-
` Type: ${report.environment.type}`,
|
|
883
|
-
` Visual Term: ${report.environment.supportsVisualTerminal ? 'Yes' : 'No'}`,
|
|
884
|
-
` Reason: ${report.environment.reason}`,
|
|
885
|
-
'',
|
|
886
|
-
` Matrix Class: ${report.matrixClassification.toUpperCase()}`,
|
|
887
|
-
'',
|
|
888
|
-
];
|
|
889
|
-
|
|
890
|
-
if (report.tests.length > 0) {
|
|
891
|
-
lines.push('── Test Results ────────────────────────────────────────────────');
|
|
892
|
-
for (const test of report.tests) {
|
|
893
|
-
const icon = test.result === 'pass' ? '✅' : test.result === 'fail' ? '❌' : '⏭️';
|
|
894
|
-
const duration = test.duration ? ` (${test.duration}ms)` : '';
|
|
895
|
-
lines.push(` ${icon} ${test.testName}${duration}`);
|
|
896
|
-
if (test.failureReason) {
|
|
897
|
-
lines.push(` └─ ${test.failureReason}`);
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
lines.push('');
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
lines.push('── Summary ─────────────────────────────────────────────────────');
|
|
904
|
-
lines.push(` Total: ${report.summary.total}`);
|
|
905
|
-
lines.push(` Passed: ${report.summary.passed}`);
|
|
906
|
-
lines.push(` Failed: ${report.summary.failed}`);
|
|
907
|
-
lines.push(` Skipped: ${report.summary.skipped}`);
|
|
908
|
-
lines.push(` Rate: ${report.summary.passRate}%`);
|
|
909
|
-
lines.push('');
|
|
910
|
-
lines.push('═══════════════════════════════════════════════════════════════');
|
|
911
|
-
|
|
912
|
-
return lines.join('\n');
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
// ============================================
|
|
916
|
-
// Lock File Cleanup and Signal Handling (Story 12.10 - Task 3)
|
|
917
|
-
// ============================================
|
|
918
|
-
|
|
919
|
-
/**
|
|
920
|
-
* Active lock files being tracked for cleanup
|
|
921
|
-
* @type {Set<string>}
|
|
922
|
-
*/
|
|
923
|
-
const activeLockFiles = new Set();
|
|
924
|
-
|
|
925
|
-
/**
|
|
926
|
-
* Active child processes being tracked for cleanup
|
|
927
|
-
* @type {Set<ChildProcess>}
|
|
928
|
-
*/
|
|
929
|
-
const activeChildProcesses = new Set();
|
|
930
|
-
|
|
931
|
-
/**
|
|
932
|
-
* Registers a lock file for cleanup on process exit
|
|
933
|
-
* @param {string} lockPath - Path to lock file
|
|
934
|
-
*/
|
|
935
|
-
function registerLockFile(lockPath) {
|
|
936
|
-
activeLockFiles.add(lockPath);
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
/**
|
|
940
|
-
* Unregisters a lock file (called when process completes normally)
|
|
941
|
-
* @param {string} lockPath - Path to lock file
|
|
942
|
-
*/
|
|
943
|
-
function unregisterLockFile(lockPath) {
|
|
944
|
-
activeLockFiles.delete(lockPath);
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
/**
|
|
948
|
-
* Registers a child process for cleanup on parent exit
|
|
949
|
-
* @param {ChildProcess} child - Child process to track
|
|
950
|
-
*/
|
|
951
|
-
function registerChildProcess(child) {
|
|
952
|
-
activeChildProcesses.add(child);
|
|
953
|
-
child.on('close', () => {
|
|
954
|
-
activeChildProcesses.delete(child);
|
|
955
|
-
});
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
/**
|
|
959
|
-
* Cleans up all registered lock files (Task 3.3)
|
|
960
|
-
* Called on process exit or signal
|
|
961
|
-
*/
|
|
962
|
-
function cleanupLocks() {
|
|
963
|
-
for (const lockPath of activeLockFiles) {
|
|
964
|
-
try {
|
|
965
|
-
if (fsSync.existsSync(lockPath)) {
|
|
966
|
-
fsSync.unlinkSync(lockPath);
|
|
967
|
-
}
|
|
968
|
-
} catch {
|
|
969
|
-
// Ignore cleanup errors
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
activeLockFiles.clear();
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
/**
|
|
976
|
-
* Terminates all active child processes (Task 3.4)
|
|
977
|
-
*/
|
|
978
|
-
function terminateChildProcesses() {
|
|
979
|
-
for (const child of activeChildProcesses) {
|
|
980
|
-
try {
|
|
981
|
-
child.kill('SIGTERM');
|
|
982
|
-
} catch {
|
|
983
|
-
// Ignore if already dead
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
activeChildProcesses.clear();
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
/**
|
|
990
|
-
* Cleanup handler for process exit/signals (Task 3.4)
|
|
991
|
-
* @param {string} signal - Signal name or 'exit'
|
|
992
|
-
*/
|
|
993
|
-
function cleanupHandler(signal) {
|
|
994
|
-
console.log(`[TerminalSpawner] Cleanup triggered by ${signal}`);
|
|
995
|
-
cleanupLocks();
|
|
996
|
-
terminateChildProcesses();
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
// Register cleanup handlers (Task 3.4)
|
|
1000
|
-
// Only register once, even if module is required multiple times
|
|
1001
|
-
if (!process._terminalSpawnerCleanupRegistered) {
|
|
1002
|
-
process._terminalSpawnerCleanupRegistered = true;
|
|
1003
|
-
|
|
1004
|
-
// Normal exit
|
|
1005
|
-
process.on('exit', () => cleanupHandler('exit'));
|
|
1006
|
-
|
|
1007
|
-
// Ctrl+C
|
|
1008
|
-
process.on('SIGINT', () => {
|
|
1009
|
-
cleanupHandler('SIGINT');
|
|
1010
|
-
process.exit(1);
|
|
1011
|
-
});
|
|
1012
|
-
|
|
1013
|
-
// Termination signal (kill command)
|
|
1014
|
-
process.on('SIGTERM', () => {
|
|
1015
|
-
cleanupHandler('SIGTERM');
|
|
1016
|
-
process.exit(1);
|
|
1017
|
-
});
|
|
1018
|
-
|
|
1019
|
-
// Uncaught exception (try to cleanup before crash)
|
|
1020
|
-
process.on('uncaughtException', (error) => {
|
|
1021
|
-
console.error('[TerminalSpawner] Uncaught exception:', error);
|
|
1022
|
-
cleanupHandler('uncaughtException');
|
|
1023
|
-
process.exit(1);
|
|
1024
|
-
});
|
|
1025
|
-
|
|
1026
|
-
// Unhandled promise rejection
|
|
1027
|
-
process.on('unhandledRejection', (reason) => {
|
|
1028
|
-
console.error('[TerminalSpawner] Unhandled rejection:', reason);
|
|
1029
|
-
cleanupHandler('unhandledRejection');
|
|
1030
|
-
process.exit(1);
|
|
1031
|
-
});
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
module.exports = {
|
|
1035
|
-
// Main API
|
|
1036
|
-
spawnAgent,
|
|
1037
|
-
spawnInline,
|
|
1038
|
-
createContextFile,
|
|
1039
|
-
pollForOutput,
|
|
1040
|
-
|
|
1041
|
-
// Environment Detection (Story 12.10)
|
|
1042
|
-
detectEnvironment,
|
|
1043
|
-
ENVIRONMENT_TYPE,
|
|
1044
|
-
|
|
1045
|
-
// OS Compatibility Matrix (Story 12.10 - Task 7)
|
|
1046
|
-
OS_COMPATIBILITY_MATRIX,
|
|
1047
|
-
getSystemInfo,
|
|
1048
|
-
generateCompatibilityReport,
|
|
1049
|
-
formatCompatibilityReport,
|
|
1050
|
-
|
|
1051
|
-
// Utilities
|
|
1052
|
-
isSpawnerAvailable,
|
|
1053
|
-
getPlatform,
|
|
1054
|
-
cleanupOldFiles,
|
|
1055
|
-
getScriptPath,
|
|
1056
|
-
|
|
1057
|
-
// Lock Management (Story 12.10)
|
|
1058
|
-
registerLockFile,
|
|
1059
|
-
unregisterLockFile,
|
|
1060
|
-
cleanupLocks,
|
|
1061
|
-
|
|
1062
|
-
// Constants
|
|
1063
|
-
DEFAULT_TIMEOUT_MS,
|
|
1064
|
-
POLL_INTERVAL_MS,
|
|
1065
|
-
MAX_RETRIES,
|
|
1066
|
-
};
|
|
1067
|
-
|