Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
178 lines
5.5 KiB
TypeScript
178 lines
5.5 KiB
TypeScript
/**
|
|
* SignalManager — Centralized signal.json operations with atomic file handling.
|
|
*
|
|
* Provides robust signal.json management with proper error handling and atomic
|
|
* operations. Replaces scattered signal detection logic throughout the codebase.
|
|
*/
|
|
|
|
import { readFile, unlink, stat } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { createModuleLogger } from '../../logger/index.js';
|
|
|
|
const log = createModuleLogger('signal-manager');
|
|
|
|
export interface SignalData {
|
|
status: 'done' | 'questions' | 'error';
|
|
questions?: Array<{
|
|
id: string;
|
|
question: string;
|
|
options?: string[];
|
|
}>;
|
|
error?: string;
|
|
}
|
|
|
|
export interface SignalManager {
|
|
clearSignal(agentWorkdir: string): Promise<void>;
|
|
checkSignalExists(agentWorkdir: string): Promise<boolean>;
|
|
readSignal(agentWorkdir: string): Promise<SignalData | null>;
|
|
waitForSignal(agentWorkdir: string, timeoutMs: number): Promise<SignalData | null>;
|
|
validateSignalFile(signalPath: string): Promise<boolean>;
|
|
}
|
|
|
|
export class FileSystemSignalManager implements SignalManager {
|
|
/**
|
|
* Clear signal.json file atomically. Always called before spawn/resume.
|
|
* This prevents race conditions in completion detection.
|
|
*/
|
|
async clearSignal(agentWorkdir: string): Promise<void> {
|
|
const signalPath = join(agentWorkdir, '.cw/output/signal.json');
|
|
try {
|
|
await unlink(signalPath);
|
|
log.debug({ agentWorkdir, signalPath }, 'signal.json cleared successfully');
|
|
} catch (error: any) {
|
|
if (error.code !== 'ENOENT') {
|
|
log.warn({ agentWorkdir, signalPath, error: error.message }, 'failed to clear signal.json');
|
|
throw error;
|
|
}
|
|
// File doesn't exist - that's fine, it's already "cleared"
|
|
log.debug({ agentWorkdir, signalPath }, 'signal.json already absent (nothing to clear)');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if signal.json file exists synchronously.
|
|
*/
|
|
async checkSignalExists(agentWorkdir: string): Promise<boolean> {
|
|
const signalPath = join(agentWorkdir, '.cw/output/signal.json');
|
|
return existsSync(signalPath);
|
|
}
|
|
|
|
/**
|
|
* Read and parse signal.json file with robust error handling.
|
|
* Returns null if file doesn't exist or is invalid.
|
|
*/
|
|
async readSignal(agentWorkdir: string): Promise<SignalData | null> {
|
|
const signalPath = join(agentWorkdir, '.cw/output/signal.json');
|
|
|
|
try {
|
|
if (!existsSync(signalPath)) {
|
|
return null;
|
|
}
|
|
|
|
const content = await readFile(signalPath, 'utf-8');
|
|
const trimmed = content.trim();
|
|
|
|
if (!trimmed) {
|
|
log.debug({ agentWorkdir, signalPath }, 'signal.json is empty');
|
|
return null;
|
|
}
|
|
|
|
const signal = JSON.parse(trimmed) as SignalData;
|
|
|
|
// Basic validation
|
|
if (!signal.status || !['done', 'questions', 'error'].includes(signal.status)) {
|
|
log.warn({ agentWorkdir, signalPath, signal }, 'signal.json has invalid status');
|
|
return null;
|
|
}
|
|
|
|
log.debug({ agentWorkdir, signalPath, status: signal.status }, 'signal.json read successfully');
|
|
return signal;
|
|
|
|
} catch (error) {
|
|
log.warn({
|
|
agentWorkdir,
|
|
signalPath,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
}, 'failed to read or parse signal.json');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for signal.json to appear and be valid, with exponential backoff polling.
|
|
* Returns null if timeout is reached or signal is never valid.
|
|
*/
|
|
async waitForSignal(agentWorkdir: string, timeoutMs: number): Promise<SignalData | null> {
|
|
const startTime = Date.now();
|
|
const signalPath = join(agentWorkdir, '.cw/output/signal.json');
|
|
let attempt = 0;
|
|
|
|
log.debug({ agentWorkdir, timeoutMs }, 'waiting for signal.json to appear');
|
|
|
|
while (Date.now() - startTime < timeoutMs) {
|
|
const signal = await this.readSignal(agentWorkdir);
|
|
if (signal) {
|
|
log.debug({
|
|
agentWorkdir,
|
|
signalPath,
|
|
status: signal.status,
|
|
waitTime: Date.now() - startTime
|
|
}, 'signal.json found and valid');
|
|
return signal;
|
|
}
|
|
|
|
// Exponential backoff: 100ms, 200ms, 400ms, 800ms, then 1s max
|
|
const delay = Math.min(100 * Math.pow(2, attempt), 1000);
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
attempt++;
|
|
}
|
|
|
|
log.debug({
|
|
agentWorkdir,
|
|
signalPath,
|
|
timeoutMs,
|
|
totalWaitTime: Date.now() - startTime
|
|
}, 'timeout waiting for signal.json');
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Validate that a signal file is complete and properly formatted.
|
|
* Used to detect if file is still being written vs. truly missing/incomplete.
|
|
*/
|
|
async validateSignalFile(signalPath: string): Promise<boolean> {
|
|
try {
|
|
if (!existsSync(signalPath)) {
|
|
return false;
|
|
}
|
|
|
|
// Check file is not empty and appears complete
|
|
const stats = await stat(signalPath);
|
|
if (stats.size === 0) {
|
|
return false;
|
|
}
|
|
|
|
const content = await readFile(signalPath, 'utf-8');
|
|
const trimmed = content.trim();
|
|
|
|
if (!trimmed) {
|
|
return false;
|
|
}
|
|
|
|
// Check if JSON structure appears complete
|
|
const endsCorrectly = trimmed.endsWith('}') || trimmed.endsWith(']');
|
|
if (!endsCorrectly) {
|
|
return false;
|
|
}
|
|
|
|
// Try to parse as JSON to ensure it's valid
|
|
JSON.parse(trimmed);
|
|
return true;
|
|
|
|
} catch (error) {
|
|
log.debug({ signalPath, error: error instanceof Error ? error.message : String(error) }, 'signal file validation failed');
|
|
return false;
|
|
}
|
|
}
|
|
} |