From c6f19bb7738730287a5aa8a03c212051857a658c Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:27:16 +0100 Subject: [PATCH] feat: cleanup autopilot commands and fix workflow state manager --- .../src/commands/autopilot/commit.command.ts | 102 +++++++----------- package-lock.json | 14 +++ package.json | 1 + packages/tm-core/package.json | 1 + .../managers/workflow-state-manager.ts | 74 ++++++------- 5 files changed, 87 insertions(+), 105 deletions(-) diff --git a/apps/cli/src/commands/autopilot/commit.command.ts b/apps/cli/src/commands/autopilot/commit.command.ts index 3a56f66b..f4662bd6 100644 --- a/apps/cli/src/commands/autopilot/commit.command.ts +++ b/apps/cli/src/commands/autopilot/commit.command.ts @@ -3,16 +3,8 @@ */ import { Command } from 'commander'; -import { WorkflowOrchestrator } from '@tm/core'; -import { - AutopilotBaseOptions, - hasWorkflowState, - loadWorkflowState, - createGitAdapter, - createCommitMessageGenerator, - OutputFormatter, - saveWorkflowState -} from './shared.js'; +import { WorkflowService, GitAdapter, CommitMessageGenerator } from '@tm/core'; +import { AutopilotBaseOptions, OutputFormatter } from './shared.js'; type CommitOptions = AutopilotBaseOptions; @@ -43,47 +35,41 @@ export class CommitCommand extends Command { const formatter = new OutputFormatter(mergedOptions.json || false); try { - // Check for workflow state - const hasState = await hasWorkflowState(mergedOptions.projectRoot!); - if (!hasState) { + const projectRoot = mergedOptions.projectRoot!; + + // Create workflow service (manages WorkflowStateManager internally) + const workflowService = new WorkflowService(projectRoot); + + // Check if workflow exists + if (!(await workflowService.hasWorkflow())) { formatter.error('No active workflow', { suggestion: 'Start a workflow with: autopilot start ' }); process.exit(1); } - // Load state - const state = await loadWorkflowState(mergedOptions.projectRoot!); - if (!state) { - formatter.error('Failed to load workflow state'); - process.exit(1); - } - - const orchestrator = new WorkflowOrchestrator(state.context); - orchestrator.restoreState(state); - orchestrator.enableAutoPersist(async (newState) => { - await saveWorkflowState(mergedOptions.projectRoot!, newState); - }); + // Resume workflow (loads state with single WorkflowStateManager instance) + await workflowService.resumeWorkflow(); + const status = workflowService.getStatus(); + const workflowContext = workflowService.getContext(); // Verify in COMMIT phase - const tddPhase = orchestrator.getCurrentTDDPhase(); - if (tddPhase !== 'COMMIT') { + if (status.tddPhase !== 'COMMIT') { formatter.error('Not in COMMIT phase', { - currentPhase: tddPhase || orchestrator.getCurrentPhase(), + currentPhase: status.tddPhase || status.phase, suggestion: 'Complete RED and GREEN phases first' }); process.exit(1); } - // Get current subtask - const currentSubtask = orchestrator.getCurrentSubtask(); - if (!currentSubtask) { + // Verify there's an active subtask + if (!status.currentSubtask) { formatter.error('No current subtask'); process.exit(1); } // Initialize git adapter - const gitAdapter = createGitAdapter(mergedOptions.projectRoot!); + const gitAdapter = new GitAdapter(projectRoot); await gitAdapter.ensureGitRepository(); // Check for staged changes @@ -95,20 +81,20 @@ export class CommitCommand extends Command { } // Get changed files for scope detection - const status = await gitAdapter.getStatus(); - const changedFiles = [...status.staged, ...status.modified]; + const gitStatus = await gitAdapter.getStatus(); + const changedFiles = [...gitStatus.staged, ...gitStatus.modified]; // Generate commit message - const messageGenerator = createCommitMessageGenerator(); - const testResults = state.context.lastTestResults; + const messageGenerator = new CommitMessageGenerator(); + const testResults = workflowContext.lastTestResults; const commitMessage = messageGenerator.generateMessage({ type: 'feat', - description: currentSubtask.title, + description: status.currentSubtask.title, changedFiles, - taskId: state.context.taskId, - phase: 'TDD', - tag: (state.context.metadata.tag as string) || undefined, + taskId: status.taskId, + phase: status.tddPhase, + tag: (workflowContext.metadata.tag as string) || undefined, testsPassing: testResults?.passed, testsFailing: testResults?.failed, coveragePercent: undefined // Could be added if available @@ -117,8 +103,8 @@ export class CommitCommand extends Command { // Create commit with metadata await gitAdapter.createCommit(commitMessage, { metadata: { - taskId: state.context.taskId, - subtaskId: currentSubtask.id, + taskId: status.taskId, + subtaskId: status.currentSubtask.id, phase: 'COMMIT', tddCycle: 'complete' } @@ -127,36 +113,24 @@ export class CommitCommand extends Command { // Get commit info const lastCommit = await gitAdapter.getLastCommit(); - // Complete COMMIT phase (this marks subtask as completed) - orchestrator.transition({ type: 'COMMIT_COMPLETE' }); + // Complete COMMIT phase and advance workflow + // This handles all transitions internally with a single WorkflowStateManager + const newStatus = await workflowService.commit(); - // Check if should advance to next subtask - const progress = orchestrator.getProgress(); - if (progress.current < progress.total) { - orchestrator.transition({ type: 'SUBTASK_COMPLETE' }); - } else { - // All subtasks complete - orchestrator.transition({ type: 'ALL_SUBTASKS_COMPLETE' }); - } + const isComplete = newStatus.phase === 'COMPLETE'; // Output success formatter.success('Commit created', { commitHash: lastCommit.hash.substring(0, 7), message: commitMessage.split('\n')[0], // First line only subtask: { - id: currentSubtask.id, - title: currentSubtask.title, - status: currentSubtask.status + id: status.currentSubtask.id, + title: status.currentSubtask.title }, - progress: { - completed: progress.completed, - total: progress.total, - percentage: progress.percentage - }, - nextAction: - progress.completed < progress.total - ? 'Start next subtask with RED phase' - : 'All subtasks complete. Run: autopilot status' + progress: newStatus.progress, + nextAction: isComplete + ? 'All subtasks complete. Run: autopilot status' + : 'Start next subtask with RED phase' }); } catch (error) { formatter.error((error as Error).message); diff --git a/package-lock.json b/package-lock.json index 50ff834f..9e713e36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "open": "^10.2.0", "ora": "^8.2.0", "simple-git": "^3.28.0", + "steno": "^4.0.2", "uuid": "^11.1.0", "zod": "^4.1.11" }, @@ -25049,6 +25050,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/steno": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/steno/-/steno-4.0.2.tgz", + "integrity": "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "dev": true, @@ -28500,6 +28513,7 @@ "@supabase/supabase-js": "^2.57.4", "fs-extra": "^11.3.2", "simple-git": "^3.28.0", + "steno": "^4.0.2", "zod": "^4.1.11" }, "devDependencies": { diff --git a/package.json b/package.json index 3469af22..aa482576 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "open": "^10.2.0", "ora": "^8.2.0", "simple-git": "^3.28.0", + "steno": "^4.0.2", "uuid": "^11.1.0", "zod": "^4.1.11" }, diff --git a/packages/tm-core/package.json b/packages/tm-core/package.json index 4750d96c..8be869bc 100644 --- a/packages/tm-core/package.json +++ b/packages/tm-core/package.json @@ -33,6 +33,7 @@ "@supabase/supabase-js": "^2.57.4", "fs-extra": "^11.3.2", "simple-git": "^3.28.0", + "steno": "^4.0.2", "zod": "^4.1.11" }, "devDependencies": { diff --git a/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.ts b/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.ts index cdffba1b..716adf10 100644 --- a/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.ts +++ b/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.ts @@ -9,6 +9,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; +import { Writer } from 'steno'; import type { WorkflowState } from '../types.js'; import { getLogger } from '../../../common/logger/index.js'; @@ -28,7 +29,8 @@ export class WorkflowStateManager { private readonly sessionDir: string; private maxBackups: number; private readonly logger = getLogger('WorkflowStateManager'); - private savePromise: Promise | null = null; + private writer: Writer | null = null; + private writerInitPromise: Promise | null = null; constructor(projectRoot: string, maxBackups = 5) { this.projectRoot = path.resolve(projectRoot); @@ -70,6 +72,31 @@ export class WorkflowStateManager { return sanitized; } + /** + * Ensure the steno Writer is initialized + * This ensures the session directory exists before creating the writer + */ + private async ensureWriter(): Promise { + if (this.writer) { + return; + } + + // If another call is already initializing, wait for it + if (this.writerInitPromise) { + await this.writerInitPromise; + return; + } + + this.writerInitPromise = (async () => { + // Ensure session directory exists before creating writer + await fs.mkdir(this.sessionDir, { recursive: true }); + this.writer = new Writer(this.statePath); + })(); + + await this.writerInitPromise; + this.writerInitPromise = null; + } + /** * Check if workflow state exists */ @@ -99,40 +126,12 @@ export class WorkflowStateManager { /** * Save workflow state to disk - * Uses a mutex to prevent concurrent saves from corrupting the file + * Uses steno for atomic writes and automatic queueing of concurrent saves */ async save(state: WorkflowState): Promise { - // Chain this save after any pending operation atomically - const previousSave = this.savePromise; - const currentSave = (async () => { - if (previousSave) { - await previousSave; - } - await this.performSave(state); - })(); - - this.savePromise = currentSave; try { - await currentSave; - } finally { - // Only clear if we're still the active save - if (this.savePromise === currentSave) { - this.savePromise = null; - } - } - } - - /** - * Internal method that performs the actual save operation - */ - private async performSave(state: WorkflowState): Promise { - // Use unique temp path to avoid conflicts - const timestamp = Date.now(); - const tempPath = `${this.statePath}.${timestamp}.tmp`; - - try { - // Ensure session directory exists - await fs.mkdir(this.sessionDir, { recursive: true }); + // Ensure writer is initialized (creates directory if needed) + await this.ensureWriter(); // Serialize and validate JSON const jsonContent = JSON.stringify(state, null, 2); @@ -145,18 +144,11 @@ export class WorkflowStateManager { throw new Error('Failed to generate valid JSON from workflow state'); } - // Write state atomically with newline at end - await fs.writeFile(tempPath, jsonContent + '\n', 'utf-8'); - await fs.rename(tempPath, this.statePath); + // Write using steno (handles queuing and atomic writes automatically) + await this.writer!.write(jsonContent + '\n'); this.logger.debug(`Saved workflow state (${jsonContent.length} bytes)`); } catch (error: any) { - // Clean up temp file if it exists - try { - await fs.unlink(tempPath); - } catch { - // Ignore cleanup errors - } throw new Error(`Failed to save workflow state: ${error.message}`); } }