feat: cleanup autopilot commands and fix workflow state manager

This commit is contained in:
Ralph Khreish
2025-10-31 15:27:16 +01:00
parent 2db6b39409
commit c6f19bb773
5 changed files with 87 additions and 105 deletions

View File

@@ -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 <taskId>'
});
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);

14
package-lock.json generated
View File

@@ -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": {

View File

@@ -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"
},

View File

@@ -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": {

View File

@@ -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<void> | null = null;
private writer: Writer | null = null;
private writerInitPromise: Promise<void> | 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<void> {
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<void> {
// 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<void> {
// 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}`);
}
}