@invisiblecities/sidequest-cqo
Version:
Configuration-agnostic TypeScript and ESLint orchestrator with real-time watch mode, SQLite persistence, and intelligent terminal detection
301 lines • 9.53 kB
JavaScript
/**
* Watch State Manager
* Manages watch mode state transitions and coordinates between analysis and display
* Prevents race conditions by enforcing explicit state machine
*/
import { EventEmitter } from "node:events";
import { debugLog } from "../utils/debug-logger.js";
/**
* State machine for watch mode lifecycle
* Enforces valid transitions and prevents race conditions
*/
// eslint-disable-next-line unicorn/prefer-event-target
export class WatchStateManager extends EventEmitter {
state;
transitionHistory = [];
// Valid state transitions
validTransitions = new Map([
["initializing", ["analyzing", "error", "shutdown"]],
["analyzing", ["ready", "error", "shutdown"]],
["ready", ["running", "analyzing", "paused", "error", "shutdown"]],
["running", ["analyzing", "paused", "error", "shutdown"]],
["paused", ["running", "analyzing", "error", "shutdown"]],
["error", ["running", "analyzing", "shutdown"]],
["shutdown", []], // Terminal state
]);
constructor(sessionId, metadata = {}) {
super();
this.state = {
phase: "initializing",
checksCount: 0,
sessionId,
sessionStart: Date.now(),
lastAnalysisTime: 0,
lastError: undefined,
analysisInProgress: false,
metadata: {
cwd: process.cwd(),
nodeVersion: process.version,
platform: process.platform,
flags: {},
...metadata,
},
};
}
/**
* Transition to a new phase with validation
*/
transition(toPhase, reason) {
const fromPhase = this.state.phase;
// Check if transition is valid
const validNextPhases = this.validTransitions.get(fromPhase) || [];
if (!validNextPhases.includes(toPhase)) {
this.emit("invalidTransition", { from: fromPhase, to: toPhase, reason });
return false;
}
// Record transition
const transition = {
from: fromPhase,
to: toPhase,
timestamp: Date.now(),
reason,
};
// Update state
this.state.phase = toPhase;
this.transitionHistory.push(transition);
// Handle phase-specific logic
this.handlePhaseTransition(transition);
// Emit events
this.emit("stateChange", transition);
this.emit(`enter:${toPhase}`, this.state);
if (fromPhase !== toPhase) {
this.emit(`exit:${fromPhase}`, this.state);
}
return true;
}
/**
* Handle phase-specific logic
*/
handlePhaseTransition(transition) {
const { to } = transition;
switch (to) {
case "analyzing": {
this.state.analysisInProgress = true;
this.state.lastAnalysisTime = Date.now();
break;
}
case "ready":
case "running": {
this.state.analysisInProgress = false;
break;
}
case "error": {
this.state.analysisInProgress = false;
break;
}
case "shutdown": {
this.state.analysisInProgress = false;
break;
}
}
}
/**
* Start analysis cycle (only if allowed)
*/
startAnalysis() {
debugLog("WatchStateManager", "startAnalysis called", {
canStart: this.canStartAnalysis(),
currentPhase: this.state.phase,
analysisInProgress: this.state.analysisInProgress,
});
if (!this.canStartAnalysis()) {
debugLog("WatchStateManager", "Cannot start analysis in current state");
return false;
}
const result = this.transition("analyzing", "analysis_cycle_start");
debugLog("WatchStateManager", "Analysis started", {
transitionResult: result,
});
return result;
}
/**
* Complete analysis cycle
*/
completeAnalysis() {
debugLog("WatchStateManager", "completeAnalysis called", {
currentPhase: this.state.phase,
checksCount: this.state.checksCount,
});
if (this.state.phase !== "analyzing") {
debugLog("WatchStateManager", "Cannot complete analysis - not in analyzing phase");
return false;
}
this.state.checksCount++;
// Transition to ready if this is first analysis, otherwise back to running
const nextPhase = this.state.checksCount === 1 ? "ready" : "running";
debugLog("WatchStateManager", "Transitioning after analysis completion", {
nextPhase,
checksCount: this.state.checksCount,
isFirstAnalysis: this.state.checksCount === 1,
});
const result = this.transition(nextPhase, "analysis_cycle_complete");
debugLog("WatchStateManager", "Analysis completed", {
transitionResult: result,
newPhase: this.state.phase,
});
return result;
}
/**
* Handle analysis error
*/
handleAnalysisError(error) {
this.state.lastError = error;
return this.transition("error", `analysis_error: ${error.message}`);
}
/**
* Recover from error state
*/
recover() {
if (this.state.phase !== "error") {
return false;
}
return this.transition("running", "error_recovery");
}
/**
* Pause watch mode
*/
pause() {
if (!["running", "ready"].includes(this.state.phase)) {
return false;
}
return this.transition("paused", "user_pause");
}
/**
* Resume from pause
*/
resume() {
if (this.state.phase !== "paused") {
return false;
}
return this.transition("running", "user_resume");
}
/**
* Shutdown (terminal state)
*/
shutdown(reason) {
return this.transition("shutdown", reason || "user_shutdown");
}
/**
* Check if analysis can be started
*/
canStartAnalysis() {
const canStart = ["initializing", "ready", "running"].includes(this.state.phase) &&
!this.state.analysisInProgress;
debugLog("WatchStateManager", "canStartAnalysis check", {
canStart,
phase: this.state.phase,
analysisInProgress: this.state.analysisInProgress,
phaseAllowed: ["initializing", "ready", "running"].includes(this.state.phase),
});
return canStart;
}
/**
* Check if display updates are allowed
*/
canUpdateDisplay() {
const canUpdate = ["ready", "running"].includes(this.state.phase) &&
!this.state.analysisInProgress;
debugLog("WatchStateManager", "canUpdateDisplay check", {
canUpdate,
phase: this.state.phase,
analysisInProgress: this.state.analysisInProgress,
phaseAllowed: ["ready", "running"].includes(this.state.phase),
});
return canUpdate;
}
/**
* Check if watch mode is active
*/
isActive() {
return !["shutdown", "error"].includes(this.state.phase);
}
/**
* Check if currently analyzing
*/
isAnalyzing() {
return this.state.analysisInProgress;
}
/**
* Get current state (read-only)
*/
getState() {
return { ...this.state };
}
/**
* Get current phase
*/
getPhase() {
return this.state.phase;
}
/**
* Get checks count
*/
getChecksCount() {
return this.state.checksCount;
}
/**
* Update session ID
*/
setSessionId(sessionId) {
this.state.sessionId = sessionId;
}
/**
* Get transition history
*/
getTransitionHistory() {
return [...this.transitionHistory];
}
/**
* Get state summary for debugging
*/
getStateSummary() {
const { phase, checksCount, analysisInProgress, lastError } = this.state;
const uptime = Date.now() - this.state.sessionStart;
return [
`Phase: ${phase}`,
`Checks: ${checksCount}`,
`Analyzing: ${analysisInProgress}`,
`Uptime: ${Math.floor(uptime / 1000)}s`,
lastError ? `Last Error: ${lastError.message}` : undefined,
]
.filter(Boolean)
.join(" | ");
}
/**
* Validate current state integrity
*/
validateState() {
const issues = [];
const { phase, analysisInProgress, checksCount } = this.state;
// Check analysis flag consistency
if (phase === "analyzing" && !analysisInProgress) {
issues.push("Phase is analyzing but analysisInProgress is false");
}
if (phase !== "analyzing" && analysisInProgress) {
issues.push("analysisInProgress is true but phase is not analyzing");
}
// Check checks count
if (checksCount < 0) {
issues.push("checksCount cannot be negative");
}
// Check session timing
if (this.state.lastAnalysisTime > Date.now()) {
issues.push("lastAnalysisTime is in the future");
}
return {
valid: issues.length === 0,
issues,
};
}
}
//# sourceMappingURL=watch-state-manager.js.map