fix: improve branch naming logic for workflow commands (#1491)

This commit is contained in:
Ralph Khreish
2025-12-07 19:04:40 +01:00
committed by GitHub
parent bf4515b6de
commit 0e908be43a
39 changed files with 2164 additions and 1356 deletions

View File

@@ -0,0 +1,13 @@
/**
* @fileoverview Zod schemas for validation
*/
export {
TaskIdSchema,
MainTaskIdSchema,
TaskIdSchemaForMcp,
MainTaskIdSchemaForMcp,
normalizeDisplayId,
type TaskId,
type MainTaskId
} from './task-id.schema.js';

View File

@@ -0,0 +1,201 @@
/**
* @fileoverview Unit tests for task ID schemas
*/
import { describe, expect, it } from 'vitest';
import {
MainTaskIdSchema,
TaskIdSchema,
normalizeDisplayId
} from './task-id.schema.js';
describe('normalizeDisplayId', () => {
describe('file storage IDs (numeric)', () => {
it('should return numeric main task IDs unchanged', () => {
expect(normalizeDisplayId('1')).toBe('1');
expect(normalizeDisplayId('123')).toBe('123');
});
it('should return numeric subtask IDs unchanged', () => {
expect(normalizeDisplayId('1.1')).toBe('1.1');
expect(normalizeDisplayId('123.45')).toBe('123.45');
});
it('should trim whitespace', () => {
expect(normalizeDisplayId(' 1 ')).toBe('1');
expect(normalizeDisplayId(' 1.2 ')).toBe('1.2');
});
});
describe('API storage IDs (prefixed)', () => {
it('should normalize lowercase without hyphen', () => {
expect(normalizeDisplayId('ham1')).toBe('HAM-1');
expect(normalizeDisplayId('ham123')).toBe('HAM-123');
});
it('should normalize uppercase without hyphen', () => {
expect(normalizeDisplayId('HAM1')).toBe('HAM-1');
expect(normalizeDisplayId('HAM123')).toBe('HAM-123');
});
it('should normalize lowercase with hyphen', () => {
expect(normalizeDisplayId('ham-1')).toBe('HAM-1');
expect(normalizeDisplayId('ham-123')).toBe('HAM-123');
});
it('should keep uppercase with hyphen unchanged', () => {
expect(normalizeDisplayId('HAM-1')).toBe('HAM-1');
expect(normalizeDisplayId('HAM-123')).toBe('HAM-123');
});
it('should normalize mixed case', () => {
expect(normalizeDisplayId('Ham-1')).toBe('HAM-1');
expect(normalizeDisplayId('hAm1')).toBe('HAM-1');
});
it('should trim whitespace', () => {
expect(normalizeDisplayId(' ham1 ')).toBe('HAM-1');
expect(normalizeDisplayId(' HAM-1 ')).toBe('HAM-1');
});
});
describe('edge cases', () => {
it('should return empty string for empty input', () => {
expect(normalizeDisplayId('')).toBe('');
});
it('should return null/undefined as-is', () => {
expect(normalizeDisplayId(null as any)).toBe(null);
expect(normalizeDisplayId(undefined as any)).toBe(undefined);
});
it('should return unmatched patterns as-is', () => {
expect(normalizeDisplayId('abc')).toBe('abc');
expect(normalizeDisplayId('HAMSTER-1')).toBe('HAMSTER-1'); // 7 letters, not 3
expect(normalizeDisplayId('AB-1')).toBe('AB-1'); // 2 letters, not 3
});
});
});
describe('TaskIdSchema', () => {
describe('file storage IDs', () => {
it('should accept numeric main task IDs', () => {
expect(TaskIdSchema.safeParse('1').success).toBe(true);
expect(TaskIdSchema.safeParse('123').success).toBe(true);
});
it('should accept numeric subtask IDs (one level)', () => {
expect(TaskIdSchema.safeParse('1.1').success).toBe(true);
expect(TaskIdSchema.safeParse('123.45').success).toBe(true);
});
it('should reject deeply nested IDs', () => {
expect(TaskIdSchema.safeParse('1.2.3').success).toBe(false);
expect(TaskIdSchema.safeParse('1.2.3.4').success).toBe(false);
});
it('should return normalized value', () => {
const result = TaskIdSchema.safeParse(' 1 ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('1');
}
});
});
describe('API storage IDs', () => {
it('should accept prefixed IDs with hyphen', () => {
expect(TaskIdSchema.safeParse('HAM-1').success).toBe(true);
expect(TaskIdSchema.safeParse('ham-1').success).toBe(true);
});
it('should accept prefixed IDs without hyphen', () => {
expect(TaskIdSchema.safeParse('HAM1').success).toBe(true);
expect(TaskIdSchema.safeParse('ham1').success).toBe(true);
});
it('should reject prefixed subtask IDs', () => {
expect(TaskIdSchema.safeParse('HAM-1.2').success).toBe(false);
expect(TaskIdSchema.safeParse('ham1.2').success).toBe(false);
});
it('should normalize to uppercase with hyphen', () => {
const result = TaskIdSchema.safeParse('ham1');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('HAM-1');
}
});
});
describe('invalid inputs', () => {
it('should reject empty string', () => {
expect(TaskIdSchema.safeParse('').success).toBe(false);
});
it('should reject whitespace only', () => {
expect(TaskIdSchema.safeParse(' ').success).toBe(false);
});
it('should reject invalid formats', () => {
expect(TaskIdSchema.safeParse('abc').success).toBe(false);
expect(TaskIdSchema.safeParse('HAMSTER-1').success).toBe(false);
expect(TaskIdSchema.safeParse('AB-1').success).toBe(false);
});
});
});
describe('MainTaskIdSchema', () => {
describe('valid main tasks', () => {
it('should accept numeric main task IDs', () => {
expect(MainTaskIdSchema.safeParse('1').success).toBe(true);
expect(MainTaskIdSchema.safeParse('123').success).toBe(true);
});
it('should accept prefixed main task IDs', () => {
expect(MainTaskIdSchema.safeParse('HAM-1').success).toBe(true);
expect(MainTaskIdSchema.safeParse('ham-1').success).toBe(true);
expect(MainTaskIdSchema.safeParse('ham1').success).toBe(true);
});
it('should normalize prefixed IDs', () => {
const result = MainTaskIdSchema.safeParse('ham1');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('HAM-1');
}
});
});
describe('invalid subtasks', () => {
it('should reject numeric subtask IDs', () => {
const result = MainTaskIdSchema.safeParse('1.2');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('Subtask');
}
});
it('should reject prefixed subtask IDs', () => {
expect(MainTaskIdSchema.safeParse('HAM-1.2').success).toBe(false);
});
});
describe('error messages', () => {
it('should provide helpful error for invalid format', () => {
const result = MainTaskIdSchema.safeParse('invalid');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('Invalid task ID');
}
});
it('should provide helpful error for subtask', () => {
const result = MainTaskIdSchema.safeParse('1.2');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('Subtask');
}
});
});
});

View File

@@ -0,0 +1,217 @@
/**
* @fileoverview Zod schemas for task ID validation
* Provides type-safe validation and normalization of task IDs
*
* Task ID Formats:
*
* FILE STORAGE (local):
* - Main tasks: "1", "2", "3" (numeric only)
* - Subtasks: "1.1", "1.2" (one level only, no "1.2.3")
*
* API STORAGE (Hamster):
* - Main tasks only: "HAM-1", "HAM-2" (3 letters + hyphen + number)
* - Input accepts: "ham-1", "HAM-1", "ham1", "HAM1" (permissive input)
* - Output always: "HAM-1" format (uppercase with hyphen)
* - No subtasks: Never "HAM-1.2"
*/
import { z } from 'zod';
/**
* Normalizes a display ID to the standard format
*
* API Storage IDs: Always uppercase with hyphen (HAM-1)
* - "ham1" → "HAM-1"
* - "HAM1" → "HAM-1"
* - "ham-1" → "HAM-1"
* - "HAM-1" → "HAM-1"
*
* File Storage IDs: Unchanged
* - "1" → "1"
* - "1.1" → "1.1"
*
* @param id - The display ID to normalize
* @returns The normalized display ID
*/
export function normalizeDisplayId(id: string): string {
if (!id) return id;
const trimmed = id.trim();
// File storage: numeric (main or subtask) - return as-is
if (/^\d+(\.\d+)?$/.test(trimmed)) {
return trimmed;
}
// API storage: 3 letters + optional hyphen + number
// e.g., "ham1", "HAM1", "ham-1", "HAM-1"
const apiPattern = /^([a-zA-Z]{3})-?(\d+)$/;
const apiMatch = trimmed.match(apiPattern);
if (apiMatch) {
const prefix = apiMatch[1].toUpperCase();
const number = apiMatch[2];
return `${prefix}-${number}`;
}
// No pattern matched, return as-is
return trimmed;
}
/**
* Pattern for file storage main task: "1", "2", "123"
*/
const FILE_MAIN_PATTERN = /^\d+$/;
/**
* Pattern for file storage subtask: "1.1", "2.3" (exactly one dot, one level)
*/
const FILE_SUBTASK_PATTERN = /^\d+\.\d+$/;
/**
* Pattern for API storage main task: "HAM-1", "ham-1", "HAM1", "ham1"
* Accepts with or without hyphen, normalizes to "HAM-1" format
*/
const API_MAIN_PATTERN = /^[a-zA-Z]{3}-?\d+$/;
/**
* Check if a task ID format is valid
* Accepts: "1", "1.1", "HAM-1", "ham-1", "HAM1", "ham1"
* Rejects: "1.2.3", "HAM-1.2"
* Note: All API IDs normalize to "HAM-1" format (uppercase with hyphen)
*/
function isValidTaskIdFormat(id: string): boolean {
if (!id) return false;
const trimmed = id.trim();
// File storage: numeric main or subtask (one level only)
if (FILE_MAIN_PATTERN.test(trimmed) || FILE_SUBTASK_PATTERN.test(trimmed)) {
return true;
}
// API storage: prefixed main task only (no subtasks in API storage)
if (API_MAIN_PATTERN.test(trimmed)) {
return true;
}
return false;
}
/**
* Check if a task ID is a main task (not a subtask)
* Main tasks: "1", "2", "HAM-1"
* Subtasks: "1.1", "2.3" (file storage only)
*/
function isMainTask(taskId: string): boolean {
if (!taskId) return false;
const trimmed = taskId.trim();
// File storage main task
if (FILE_MAIN_PATTERN.test(trimmed)) {
return true;
}
// API storage main task (always main, no subtasks in API)
if (API_MAIN_PATTERN.test(trimmed)) {
return true;
}
return false;
}
/**
* Base schema for any task ID (main task or subtask) - validation only
* Use this for MCP tool schemas (JSON Schema can't represent transforms)
* Call normalizeDisplayId() manually after validation if needed
*/
const taskIdBaseSchema = z.string().trim().refine(isValidTaskIdFormat, {
message:
'Invalid task ID format. Expected: numeric ("1", "1.2") or prefixed with hyphen ("HAM-1")'
});
/**
* Base schema for main task IDs only - validation only
* Use this for MCP tool schemas (JSON Schema can't represent transforms)
* Call normalizeDisplayId() manually after validation if needed
*/
const mainTaskIdBaseSchema = z
.string()
.trim()
.refine(isValidTaskIdFormat, {
message:
'Invalid task ID format. Expected: numeric ("1") or prefixed with hyphen ("HAM-1")'
})
.refine(isMainTask, {
message:
'Subtask IDs are not allowed. Please provide a main task ID (e.g., "1", "HAM-1")'
});
/**
* Zod schema for any task ID (main task or subtask)
* Validates format and transforms to normalized form
*
* NOTE: For MCP tools, use TaskIdSchemaForMcp instead (no transform)
*
* @example
* ```typescript
* // File storage
* TaskIdSchema.safeParse('1'); // { success: true, data: '1' }
* TaskIdSchema.safeParse('1.2'); // { success: true, data: '1.2' }
*
* // API storage
* TaskIdSchema.safeParse('ham-1'); // { success: true, data: 'HAM-1' }
* TaskIdSchema.safeParse('HAM-1'); // { success: true, data: 'HAM-1' }
*
* // Permissive input, normalized output
* TaskIdSchema.safeParse('ham1'); // { success: true, data: 'HAM-1' }
* TaskIdSchema.safeParse('HAM1'); // { success: true, data: 'HAM-1' }
*
* // Invalid
* TaskIdSchema.safeParse('1.2.3'); // { success: false } - too deep
* TaskIdSchema.safeParse('HAM-1.2'); // { success: false } - no API subtasks
* ```
*/
export const TaskIdSchema = taskIdBaseSchema.transform(normalizeDisplayId);
/**
* Zod schema for main task IDs only (no subtasks)
* Validates format, ensures no subtask part, and transforms to normalized form
*
* NOTE: For MCP tools, use MainTaskIdSchemaForMcp instead (no transform)
*
* @example
* ```typescript
* // Valid main tasks
* MainTaskIdSchema.safeParse('1'); // { success: true, data: '1' }
* MainTaskIdSchema.safeParse('ham-1'); // { success: true, data: 'HAM-1' }
* MainTaskIdSchema.safeParse('ham1'); // { success: true, data: 'HAM-1' }
*
* // Invalid (subtasks)
* MainTaskIdSchema.safeParse('1.2'); // { success: false }
* ```
*/
export const MainTaskIdSchema =
mainTaskIdBaseSchema.transform(normalizeDisplayId);
/**
* Zod schema for any task ID - validation only, no transform
* Use this for MCP tool parameter schemas (JSON Schema can't represent transforms)
* Call normalizeDisplayId() manually after validation
*/
export const TaskIdSchemaForMcp = taskIdBaseSchema;
/**
* Zod schema for main task IDs - validation only, no transform
* Use this for MCP tool parameter schemas (JSON Schema can't represent transforms)
* Call normalizeDisplayId() manually after validation
*/
export const MainTaskIdSchemaForMcp = mainTaskIdBaseSchema;
/**
* Type for a validated and normalized task ID
*/
export type TaskId = z.output<typeof TaskIdSchema>;
/**
* Type for a validated and normalized main task ID
*/
export type MainTaskId = z.output<typeof MainTaskIdSchema>;

View File

@@ -4,14 +4,14 @@
*/
// Export ID generation utilities
// Note: normalizeDisplayId is now exported from common/schemas/task-id.schema.ts
export {
generateTaskId as generateId, // Alias for backward compatibility
generateTaskId,
generateSubtaskId,
isValidTaskId,
isValidSubtaskId,
getParentTaskId,
normalizeDisplayId
getParentTaskId
} from './id-generator.js';
// Export git utilities

View File

@@ -58,6 +58,9 @@ export * from './utils/time.utils.js';
// Task validation schemas
export * from './modules/tasks/validation/index.js';
// Zod schemas for validation
export * from './common/schemas/index.js';
// ========== Domain-Specific Type Exports ==========
// Task types
@@ -168,7 +171,11 @@ export { BriefService } from './modules/briefs/services/brief-service.js';
// Workflow - Advanced
export { WorkflowOrchestrator } from './modules/workflow/orchestrators/workflow-orchestrator.js';
export { WorkflowStateManager } from './modules/workflow/managers/workflow-state-manager.js';
export { WorkflowService } from './modules/workflow/services/workflow.service.js';
export {
WorkflowService,
type TaskStatusUpdater,
type WorkflowServiceOptions
} from './modules/workflow/services/workflow.service.js';
export type { SubtaskInfo } from './modules/workflow/types.js';
// Git - Advanced

View File

@@ -51,10 +51,24 @@ describe('TemplateEngine', () => {
});
it('should handle missing variables by leaving placeholder', () => {
const engineWithPreserve = new TemplateEngine({
preservePlaceholders: true
});
const template = 'Hello {{name}} from {{location}}';
const result = engineWithPreserve.render(
'test',
{ name: 'Alice' },
template
);
expect(result).toBe('Hello Alice from {{location}}');
});
it('should replace missing variables with empty string by default', () => {
const template = 'Hello {{name}} from {{location}}';
const result = templateEngine.render('test', { name: 'Alice' }, template);
expect(result).toBe('Hello Alice from {{location}}');
expect(result).toBe('Hello Alice from ');
});
it('should handle empty variable values', () => {
@@ -215,11 +229,21 @@ describe('TemplateEngine', () => {
expect(result).toBe('Static text');
});
it('should handle empty variables object with preservePlaceholders', () => {
const engineWithPreserve = new TemplateEngine({
preservePlaceholders: true
});
const template = 'Hello {{name}}';
const result = engineWithPreserve.render('test', {}, template);
expect(result).toBe('Hello {{name}}');
});
it('should handle empty variables object', () => {
const template = 'Hello {{name}}';
const result = templateEngine.render('test', {}, template);
expect(result).toBe('Hello {{name}}');
expect(result).toBe('Hello ');
});
it('should handle special characters in values', () => {

View File

@@ -3,27 +3,31 @@
* Provides validation for task IDs used in MCP tools and CLI
*
* Supported formats:
* - Simple numeric: "1", "2", "15" (local file storage)
* - Numeric subtask: "1.2", "15.3" (local file storage, dot notation)
* - Numeric sub-subtask: "1.2.3", "15.3.1" (local file storage, dot notation)
* - Alphanumeric display IDs: "HAM-123", "PROJ-456" (remote API storage)
* Note: In remote mode, subtasks also use alphanumeric IDs (HAM-2, HAM-3),
* they don't use dot notation like local storage.
*
* FILE STORAGE (local):
* - Main tasks: "1", "2", "15"
* - Subtasks: "1.2", "15.3" (one level only)
*
* API STORAGE (Hamster):
* - Main tasks: "HAM-1", "ham-1", "HAM1", "ham1" (all normalized to "HAM-1")
* - No subtasks (API doesn't use dot notation)
*
* NOT supported:
* - Alphanumeric with dot notation: "HAM-123.2" (doesn't exist in any mode)
* - Deep nesting: "1.2.3" (file storage only has one subtask level)
* - API subtasks: "HAM-1.2" (doesn't exist)
*/
import { z } from 'zod';
import { normalizeDisplayId } from '../../../common/schemas/task-id.schema.js';
/**
* Pattern for validating a single task ID
* Supports:
* Permissive input - accepts with or without hyphen for API IDs
* - Numeric: "1", "15", "999"
* - Numeric subtasks: "1.2", "15.3.1"
* - Alphanumeric display IDs: "HAM-123", "PROJ-456" (main tasks only, no subtask notation)
* - Numeric subtasks: "1.2" (one level only)
* - API display IDs: "HAM-1", "ham-1", "HAM1", "ham1"
*/
export const TASK_ID_PATTERN = /^(\d+(\.\d+)*|[A-Za-z]+-\d+)$/;
export const TASK_ID_PATTERN = /^(\d+(\.\d+)?|[A-Za-z]{3}-?\d+)$/;
/**
* Validates a single task ID string
@@ -34,12 +38,12 @@ export const TASK_ID_PATTERN = /^(\d+(\.\d+)*|[A-Za-z]+-\d+)$/;
* @example
* ```typescript
* isValidTaskIdFormat("1"); // true
* isValidTaskIdFormat("15.2"); // true
* isValidTaskIdFormat("1.2.3"); // true
* isValidTaskIdFormat("HAM-123"); // true
* isValidTaskIdFormat("HAM-123.2"); // false (alphanumeric subtasks not supported)
* isValidTaskIdFormat("1.2"); // true
* isValidTaskIdFormat("HAM-1"); // true
* isValidTaskIdFormat("ham1"); // true (permissive input)
* isValidTaskIdFormat("1.2.3"); // false (too deep)
* isValidTaskIdFormat("HAM-1.2"); // false (no API subtasks)
* isValidTaskIdFormat("abc"); // false
* isValidTaskIdFormat(""); // false
* ```
*/
export function isValidTaskIdFormat(id: string): boolean {
@@ -49,6 +53,8 @@ export function isValidTaskIdFormat(id: string): boolean {
/**
* Zod schema for a single task ID
* Validates format: numeric, alphanumeric display ID, or numeric subtask
* Note: Use parseTaskIds() for normalization (e.g., "ham1" → "HAM-1")
* This schema is used in MCP tool definitions which can't have transforms.
*/
export const taskIdSchema = z
.string()
@@ -61,6 +67,7 @@ export const taskIdSchema = z
/**
* Zod schema for comma-separated task IDs
* Validates that each ID in the comma-separated list is valid
* Permissive input - accepts "ham1", "HAM1", "ham-1" etc.
*
* @example
* ```typescript
@@ -68,8 +75,9 @@ export const taskIdSchema = z
* taskIdsSchema.parse("1,2,3"); // valid
* taskIdsSchema.parse("1.2, 3.4"); // valid (spaces trimmed)
* taskIdsSchema.parse("HAM-123"); // valid
* taskIdsSchema.parse("ham1"); // valid (permissive input)
* taskIdsSchema.parse("abc"); // throws
* taskIdsSchema.parse("HAM-123.2"); // throws (alphanumeric subtasks not supported)
* taskIdsSchema.parse("HAM-1.2"); // throws (API subtasks not supported)
* ```
*/
export const taskIdsSchema = z
@@ -91,9 +99,10 @@ export const taskIdsSchema = z
/**
* Parse and validate comma-separated task IDs
* Returns normalized IDs (e.g., "ham1" → "HAM-1")
*
* @param input - Comma-separated task ID string
* @returns Array of validated task IDs
* @returns Array of validated and normalized task IDs
* @throws Error if any ID is invalid
*
* @example
@@ -101,8 +110,9 @@ export const taskIdsSchema = z
* parseTaskIds("1, 2, 3"); // ["1", "2", "3"]
* parseTaskIds("1.2,3.4"); // ["1.2", "3.4"]
* parseTaskIds("HAM-123"); // ["HAM-123"]
* parseTaskIds("ham1,ham2"); // ["HAM-1", "HAM-2"] (normalized)
* parseTaskIds("invalid"); // throws Error
* parseTaskIds("HAM-123.2"); // throws Error (alphanumeric subtasks not supported)
* parseTaskIds("HAM-1.2"); // throws Error (API subtasks not supported)
* ```
*/
export function parseTaskIds(input: string): string[] {
@@ -122,7 +132,8 @@ export function parseTaskIds(input: string): string[] {
);
}
return ids;
// Normalize all IDs (e.g., "ham1" → "HAM-1")
return ids.map(normalizeDisplayId);
}
/**

View File

@@ -89,10 +89,10 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
expect(orchestrator.getCurrentPhase()).toBe('COMPLETE');
});
it('should reject invalid transitions', () => {
expect(() => {
orchestrator.transition({ type: 'FINALIZE_COMPLETE' });
}).toThrow('Invalid transition');
it('should reject invalid transitions', async () => {
await expect(
orchestrator.transition({ type: 'FINALIZE_COMPLETE' })
).rejects.toThrow('Invalid transition');
});
});
@@ -433,7 +433,7 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
});
describe('Phase Transition Guards and Validation', () => {
it('should enforce guard conditions on transitions', () => {
it('should enforce guard conditions on transitions', async () => {
// Create orchestrator with guard condition that should fail
const guardedContext: WorkflowContext = {
taskId: 'task-1',
@@ -450,14 +450,14 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
return context.subtasks.length > 0;
});
guardedOrchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
await guardedOrchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
expect(() => {
await expect(
guardedOrchestrator.transition({
type: 'BRANCH_CREATED',
branchName: 'feature/test'
});
}).toThrow('Guard condition failed');
})
).rejects.toThrow('Guard condition failed');
});
it('should allow transition when guard condition passes', () => {
@@ -486,28 +486,31 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
expect(guardedOrchestrator.getCurrentPhase()).toBe('SUBTASK_LOOP');
});
it('should validate test results before GREEN phase transition', () => {
orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
orchestrator.transition({
it('should validate test results before GREEN phase transition', async () => {
await orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
await orchestrator.transition({
type: 'BRANCH_CREATED',
branchName: 'feature/test'
});
// Attempt to transition to GREEN without test results
expect(() => {
orchestrator.transition({ type: 'RED_PHASE_COMPLETE' });
}).toThrow('Test results required');
await expect(
orchestrator.transition({ type: 'RED_PHASE_COMPLETE' })
).rejects.toThrow('Test results required');
});
it('should validate RED phase test results have failures', () => {
orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
orchestrator.transition({
// Note: When all tests pass in RED phase, the orchestrator auto-completes
// the subtask (feature already implemented) instead of throwing.
// This test is skipped as the behavior has changed.
it.skip('should validate RED phase test results have failures', async () => {
await orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
await orchestrator.transition({
type: 'BRANCH_CREATED',
branchName: 'feature/test'
});
// Provide passing test results (should fail RED phase validation)
expect(() => {
await expect(
orchestrator.transition({
type: 'RED_PHASE_COMPLETE',
testResults: {
@@ -517,8 +520,8 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
skipped: 0,
phase: 'RED'
}
});
}).toThrow('RED phase must have at least one failing test');
})
).rejects.toThrow('RED phase must have at least one failing test');
});
it('should allow RED to GREEN transition with valid failing tests', () => {
@@ -542,14 +545,14 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
expect(orchestrator.getCurrentTDDPhase()).toBe('GREEN');
});
it('should validate GREEN phase test results have no failures', () => {
orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
orchestrator.transition({
it('should validate GREEN phase test results have no failures', async () => {
await orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
await orchestrator.transition({
type: 'BRANCH_CREATED',
branchName: 'feature/test'
});
orchestrator.transition({
await orchestrator.transition({
type: 'RED_PHASE_COMPLETE',
testResults: {
total: 5,
@@ -561,7 +564,7 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
});
// Provide test results with failures (should fail GREEN phase validation)
expect(() => {
await expect(
orchestrator.transition({
type: 'GREEN_PHASE_COMPLETE',
testResults: {
@@ -571,8 +574,8 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
skipped: 0,
phase: 'GREEN'
}
});
}).toThrow('GREEN phase must have zero failures');
})
).rejects.toThrow('GREEN phase must have zero failures');
});
it('should allow GREEN to COMMIT transition with all tests passing', () => {
@@ -631,7 +634,7 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
expect(context.lastTestResults).toEqual(redResults);
});
it('should validate git repository state before BRANCH_SETUP', () => {
it('should validate git repository state before BRANCH_SETUP', async () => {
// Set up orchestrator with git validation enabled
const gitContext: WorkflowContext = {
taskId: 'task-1',
@@ -650,9 +653,9 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
return context.metadata.requireGit === true;
});
expect(() => {
gitOrchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
}).toThrow('Guard condition failed');
await expect(
gitOrchestrator.transition({ type: 'PREFLIGHT_COMPLETE' })
).rejects.toThrow('Guard condition failed');
});
});
@@ -1067,10 +1070,10 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
expect(orchestrator.isAborted()).toBe(true);
});
it('should prevent transitions after abort', () => {
orchestrator.transition({ type: 'ABORT' });
it('should prevent transitions after abort', async () => {
await orchestrator.transition({ type: 'ABORT' });
expect(() => {
await expect(
orchestrator.transition({
type: 'RED_PHASE_COMPLETE',
testResults: {
@@ -1080,8 +1083,8 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
skipped: 0,
phase: 'RED'
}
});
}).toThrow('Workflow has been aborted');
})
).rejects.toThrow('Workflow has been aborted');
});
it('should allow retry after recoverable error', () => {
@@ -1395,9 +1398,10 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
expect(orchestrator.hasTestResultValidator()).toBe(true);
});
it('should use TestResultValidator to validate RED phase', () => {
orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
orchestrator.transition({
// Skip: Behavior changed - RED phase with 0 failures now auto-completes subtask instead of throwing
it.skip('should use TestResultValidator to validate RED phase', async () => {
await orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
await orchestrator.transition({
type: 'BRANCH_CREATED',
branchName: 'feature/test'
});
@@ -1405,7 +1409,7 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
orchestrator.setTestResultValidator(testValidator);
// Should reject passing tests in RED phase
expect(() => {
await expect(
orchestrator.transition({
type: 'RED_PHASE_COMPLETE',
testResults: {
@@ -1415,20 +1419,20 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
skipped: 0,
phase: 'RED'
}
});
}).toThrow('RED phase must have at least one failing test');
})
).rejects.toThrow('RED phase must have at least one failing test');
});
it('should use TestResultValidator to validate GREEN phase', () => {
orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
orchestrator.transition({
it('should use TestResultValidator to validate GREEN phase', async () => {
await orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
await orchestrator.transition({
type: 'BRANCH_CREATED',
branchName: 'feature/test'
});
orchestrator.setTestResultValidator(testValidator);
orchestrator.transition({
await orchestrator.transition({
type: 'RED_PHASE_COMPLETE',
testResults: {
total: 5,
@@ -1440,7 +1444,7 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
});
// Should reject failing tests in GREEN phase
expect(() => {
await expect(
orchestrator.transition({
type: 'GREEN_PHASE_COMPLETE',
testResults: {
@@ -1450,8 +1454,8 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
skipped: 0,
phase: 'GREEN'
}
});
}).toThrow('GREEN phase must have zero failures');
})
).rejects.toThrow('GREEN phase must have zero failures');
});
it('should support git adapter hooks', () => {

View File

@@ -3,6 +3,8 @@
* Provides a simplified API for MCP tools while delegating to WorkflowOrchestrator
*/
import { getLogger } from '../../../common/logger/index.js';
import type { TaskStatus } from '../../../common/types/index.js';
import { GitAdapter } from '../../git/adapters/git-adapter.js';
import { WorkflowStateManager } from '../managers/workflow-state-manager.js';
import { WorkflowOrchestrator } from '../orchestrators/workflow-orchestrator.js';
@@ -30,7 +32,8 @@ export interface StartWorkflowOptions {
}>;
maxAttempts?: number;
force?: boolean;
tag?: string; // Optional tag for branch naming
tag?: string; // Optional tag for branch naming (local storage)
orgSlug?: string; // Optional org slug for branch naming (API storage, takes precedence over tag)
}
/**
@@ -70,6 +73,23 @@ export interface NextAction {
};
}
/**
* Interface for updating task statuses
* Allows WorkflowService to update task statuses without direct dependency on TasksDomain
*/
export interface TaskStatusUpdater {
updateStatus(taskId: string, status: TaskStatus, tag?: string): Promise<void>;
}
/**
* Options for WorkflowService constructor
*/
export interface WorkflowServiceOptions {
projectRoot: string;
taskStatusUpdater?: TaskStatusUpdater;
tag?: string;
}
/**
* WorkflowService - Facade for workflow operations
* Manages WorkflowOrchestrator lifecycle and state persistence
@@ -77,12 +97,52 @@ export interface NextAction {
export class WorkflowService {
private readonly projectRoot: string;
private readonly stateManager: WorkflowStateManager;
private readonly taskStatusUpdater?: TaskStatusUpdater;
private readonly tag?: string;
private readonly logger = getLogger('WorkflowService');
private orchestrator?: WorkflowOrchestrator;
private activityLogger?: WorkflowActivityLogger;
constructor(projectRoot: string) {
this.projectRoot = projectRoot;
this.stateManager = new WorkflowStateManager(projectRoot);
constructor(projectRootOrOptions: string | WorkflowServiceOptions) {
if (typeof projectRootOrOptions === 'string') {
// Legacy constructor: just projectRoot
this.projectRoot = projectRootOrOptions;
} else {
// New constructor with options
this.projectRoot = projectRootOrOptions.projectRoot;
this.taskStatusUpdater = projectRootOrOptions.taskStatusUpdater;
this.tag = projectRootOrOptions.tag;
}
this.stateManager = new WorkflowStateManager(this.projectRoot);
}
/**
* Update task status if updater is available
* Logs warning but doesn't throw if update fails
* @param taskId - Task ID to update
* @param status - New status
* @param tag - Optional tag override (uses constructor tag if not provided)
*/
private async updateTaskStatus(
taskId: string,
status: TaskStatus,
tag?: string
): Promise<void> {
if (!this.taskStatusUpdater) {
return;
}
try {
await this.taskStatusUpdater.updateStatus(
taskId,
status,
tag ?? this.tag
);
} catch (error: unknown) {
// Log but don't fail the workflow operation
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to update task ${taskId} status: ${message}`);
}
}
/**
@@ -102,7 +162,8 @@ export class WorkflowService {
subtasks,
maxAttempts = 3,
force,
tag
tag,
orgSlug
} = options;
// Check for existing workflow
@@ -143,6 +204,7 @@ export class WorkflowService {
taskId,
subtasks: workflowSubtasks,
currentSubtaskIndex: firstIncompleteIndex,
tag,
errors: [],
metadata: {
startedAt: new Date().toISOString(),
@@ -171,7 +233,7 @@ export class WorkflowService {
await this.orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
// Create git branch with descriptive name
const branchName = this.generateBranchName(taskId, taskTitle, tag);
const branchName = this.generateBranchName(taskId, taskTitle, tag, orgSlug);
// Check if we're already on the target branch
const currentBranch = await gitAdapter.getCurrentBranch();
@@ -186,6 +248,9 @@ export class WorkflowService {
branchName
});
// Set main task status to in-progress
await this.updateTaskStatus(taskId, 'in-progress', tag);
return this.getStatus();
}
@@ -401,6 +466,10 @@ export class WorkflowService {
);
}
// Capture current subtask before transitioning
const currentSubtask = this.orchestrator.getCurrentSubtask();
const completedSubtaskId = currentSubtask?.id;
// Transition COMMIT phase complete
await this.orchestrator.transition({
type: 'COMMIT_COMPLETE'
@@ -415,12 +484,19 @@ export class WorkflowService {
await this.orchestrator.transition({ type: 'ALL_SUBTASKS_COMPLETE' });
}
// Mark completed subtask as done (use workflow's tag from context)
if (completedSubtaskId) {
const context = this.orchestrator.getContext();
await this.updateTaskStatus(completedSubtaskId, 'done', context.tag);
}
return this.getStatus();
}
/**
* Finalize and complete the workflow
* Validates working tree is clean before marking complete
* Cleans up workflow state file after successful completion
*/
async finalizeWorkflow(): Promise<WorkflowStatus> {
if (!this.orchestrator) {
@@ -447,10 +523,24 @@ export class WorkflowService {
);
}
// Capture task ID before transitioning
const context = this.orchestrator.getContext();
const taskId = context.taskId;
// Transition to COMPLETE
await this.orchestrator.transition({ type: 'FINALIZE_COMPLETE' });
return this.getStatus();
// Get final status before cleanup
const finalStatus = this.getStatus();
// Mark main task as done (use workflow's tag from context)
await this.updateTaskStatus(taskId, 'done', context.tag);
// Clean up workflow state file so new workflows can start without force
await this.stateManager.delete();
this.orchestrator = undefined;
return finalStatus;
}
/**
@@ -469,12 +559,14 @@ export class WorkflowService {
/**
* Generate a descriptive git branch name
* Format: tag-name/task-id-task-title or task-id-task-title
* Format: tm/<namespace>/task-<id>-<title> where namespace is orgSlug (API) or tag (local)
* All branches are prefixed with 'tm/' to avoid conflicts with existing branches
*/
private generateBranchName(
taskId: string,
taskTitle: string,
tag?: string
tag?: string,
orgSlug?: string
): string {
// Sanitize task title for branch name
const sanitizedTitle = taskTitle
@@ -486,9 +578,10 @@ export class WorkflowService {
// Format task ID for branch name
const formattedTaskId = taskId.replace(/\./g, '-');
// Add tag prefix if tag is provided
const tagPrefix = tag ? `${tag}/` : '';
// Priority: orgSlug (API storage) > tag (local storage) > none
const namespace = orgSlug || tag;
const prefix = namespace ? `tm/${namespace}` : 'tm';
return `${tagPrefix}task-${formattedTaskId}-${sanitizedTitle}`;
return `${prefix}/task-${formattedTaskId}-${sanitizedTitle}`;
}
}

View File

@@ -22,6 +22,7 @@ export interface WorkflowContext {
currentSubtaskIndex: number;
currentTDDPhase?: TDDPhase;
branchName?: string;
tag?: string;
errors: WorkflowError[];
metadata: Record<string, unknown>;
lastTestResults?: TestResult;

View File

@@ -4,7 +4,11 @@
*/
import type { ConfigManager } from '../config/managers/config-manager.js';
import { WorkflowService } from './services/workflow.service.js';
import type { TasksDomain } from '../tasks/tasks-domain.js';
import {
type TaskStatusUpdater,
WorkflowService
} from './services/workflow.service.js';
import type {
NextAction,
StartWorkflowOptions,
@@ -14,12 +18,57 @@ import type { TestResult, WorkflowContext } from './types.js';
/**
* Workflow Domain - Unified API for TDD workflow operations
* Automatically handles task status updates through dependency injection
*/
export class WorkflowDomain {
private workflowService: WorkflowService;
private workflowService: WorkflowService | null = null;
private readonly projectRoot: string;
private readonly configManager: ConfigManager;
private tasksDomain: TasksDomain | null = null;
constructor(configManager: ConfigManager) {
this.workflowService = new WorkflowService(configManager.getProjectRoot());
this.configManager = configManager;
this.projectRoot = configManager.getProjectRoot();
}
/**
* Set the TasksDomain for status updates
* Called by TmCore after TasksDomain is initialized
*/
setTasksDomain(tasksDomain: TasksDomain): void {
this.tasksDomain = tasksDomain;
}
/**
* Create or get WorkflowService instance with proper DI
*/
private getWorkflowService(): WorkflowService {
if (!this.workflowService) {
const currentTag = this.configManager.getActiveTag();
// Create task status updater if TasksDomain is available
const taskStatusUpdater: TaskStatusUpdater | undefined = this.tasksDomain
? {
updateStatus: async (taskId, status, tag) => {
await this.tasksDomain!.updateStatus(taskId, status, tag);
}
}
: undefined;
this.workflowService = new WorkflowService({
projectRoot: this.projectRoot,
taskStatusUpdater,
tag: currentTag
});
}
return this.workflowService;
}
/**
* Reset workflow service (for when workflow completes or aborts)
*/
private resetWorkflowService(): void {
this.workflowService = null;
}
// ========== Workflow Lifecycle ==========
@@ -28,63 +77,72 @@ export class WorkflowDomain {
* Start a new TDD workflow for a task
*/
async start(options: StartWorkflowOptions): Promise<WorkflowStatus> {
return this.workflowService.startWorkflow(options);
// Reset to get fresh service with current tag
this.resetWorkflowService();
return this.getWorkflowService().startWorkflow(options);
}
/**
* Resume an existing workflow
*/
async resume(): Promise<WorkflowStatus> {
return this.workflowService.resumeWorkflow();
// Reset to get fresh service with current tag
this.resetWorkflowService();
return this.getWorkflowService().resumeWorkflow();
}
/**
* Get current workflow status
*/
getStatus(): WorkflowStatus {
return this.workflowService.getStatus();
return this.getWorkflowService().getStatus();
}
/**
* Get workflow context
*/
getContext(): WorkflowContext {
return this.workflowService.getContext();
return this.getWorkflowService().getContext();
}
/**
* Get next action to perform in workflow
*/
getNextAction(): NextAction {
return this.workflowService.getNextAction();
return this.getWorkflowService().getNextAction();
}
/**
* Complete current phase with test results
*/
async completePhase(testResults: TestResult): Promise<WorkflowStatus> {
return this.workflowService.completePhase(testResults);
return this.getWorkflowService().completePhase(testResults);
}
/**
* Commit changes with auto-generated message
*/
async commit(): Promise<WorkflowStatus> {
return this.workflowService.commit();
return this.getWorkflowService().commit();
}
/**
* Finalize and complete the workflow
* Resets workflow service after completion
*/
async finalize(): Promise<WorkflowStatus> {
return this.workflowService.finalizeWorkflow();
const result = await this.getWorkflowService().finalizeWorkflow();
this.resetWorkflowService();
return result;
}
/**
* Abort the current workflow
* Resets workflow service after abort
*/
async abort(): Promise<void> {
return this.workflowService.abortWorkflow();
await this.getWorkflowService().abortWorkflow();
this.resetWorkflowService();
}
// ========== Workflow Information ==========
@@ -93,6 +151,6 @@ export class WorkflowDomain {
* Check if a workflow currently exists
*/
async hasWorkflow(): Promise<boolean> {
return this.workflowService.hasWorkflow();
return this.getWorkflowService().hasWorkflow();
}
}

View File

@@ -180,6 +180,10 @@ export class TmCore {
// Initialize domains that need async setup
await this._tasks.initialize();
// Wire up cross-domain dependencies
// WorkflowDomain needs TasksDomain for status updates
this._workflow.setTasksDomain(this._tasks);
// Log successful initialization
this._logger.info('TmCore initialized successfully');
} catch (error) {

View File

@@ -0,0 +1,866 @@
/**
* @fileoverview Integration tests for autopilot workflow state machine
*
* Tests the full workflow lifecycle through WorkflowService:
* - Start workflow and verify state file creation
* - TDD phase transitions (RED → GREEN → COMMIT)
* - State persistence and resume
* - Auto-complete subtask when RED phase has 0 failures
* - Workflow finalization and abort
*
* These tests create temporary project directories and verify the workflow
* state machine operates correctly with actual file I/O.
*
* NOTE: Workflow state is stored in ~/.taskmaster/{project-id}/sessions/
* based on the project path. Tests clean up their state files in afterEach.
*
* @integration
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the logger to reduce noise in tests
vi.mock('../../../src/common/logger/index.js', () => ({
getLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn().mockReturnThis()
})
}));
import { WorkflowStateManager } from '../../../src/modules/workflow/managers/workflow-state-manager.js';
import { WorkflowService } from '../../../src/modules/workflow/services/workflow.service.js';
import type { WorkflowState } from '../../../src/modules/workflow/types.js';
// Store original HOME to restore after tests
const originalHome = process.env.HOME;
describe('Autopilot Workflow Integration', () => {
let testProjectDir: string;
let testHomeDir: string;
let stateManager: WorkflowStateManager;
let workflowService: WorkflowService;
/**
* Read the workflow state file directly from disk
*/
const readWorkflowState = (): WorkflowState | null => {
const statePath = stateManager.getStatePath();
try {
const content = fs.readFileSync(statePath, 'utf-8');
return JSON.parse(content);
} catch {
return null;
}
};
/**
* Check if workflow state file exists
*/
const workflowStateExists = (): boolean => {
return fs.existsSync(stateManager.getStatePath());
};
/**
* Get the expected state file path
*/
const getExpectedStatePath = (): string => {
return stateManager.getStatePath();
};
beforeEach(() => {
// Create temp directories for isolation
testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-workflow-home-'));
testProjectDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'tm-workflow-project-')
);
// Override HOME so os.homedir() returns our temp directory
// This prevents tests from polluting the real ~/.taskmaster/
process.env.HOME = testHomeDir;
// Create state manager AFTER setting HOME (uses os.homedir() internally)
stateManager = new WorkflowStateManager(testProjectDir);
// Initialize git in the project directory (required for workflow)
execSync('git init', { cwd: testProjectDir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', {
cwd: testProjectDir,
stdio: 'pipe'
});
execSync('git config user.name "Test User"', {
cwd: testProjectDir,
stdio: 'pipe'
});
// Disable GPG/SSH signing to avoid 1Password and other signing tool interference
execSync('git config commit.gpgsign false', {
cwd: testProjectDir,
stdio: 'pipe'
});
// Create an initial commit (git needs at least one commit)
fs.writeFileSync(
path.join(testProjectDir, 'README.md'),
'# Test Project\n'
);
execSync('git add .', { cwd: testProjectDir, stdio: 'pipe' });
execSync('git commit -m "Initial commit"', {
cwd: testProjectDir,
stdio: 'pipe'
});
// Create workflow service
workflowService = new WorkflowService(testProjectDir);
});
afterEach(() => {
// Restore original HOME
process.env.HOME = originalHome;
// Clean up temp directories
if (testProjectDir && fs.existsSync(testProjectDir)) {
fs.rmSync(testProjectDir, { recursive: true, force: true });
}
if (testHomeDir && fs.existsSync(testHomeDir)) {
fs.rmSync(testHomeDir, { recursive: true, force: true });
}
});
describe('Workflow State File Location', () => {
it('should store workflow state in isolated temp home directory', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Test Task',
subtasks: [
{ id: '1.1', title: 'Subtask 1', status: 'pending' },
{ id: '1.2', title: 'Subtask 2', status: 'pending' }
]
});
const statePath = getExpectedStatePath();
// State file should be in temp home directory (not real ~/.taskmaster/)
expect(statePath).toContain(testHomeDir);
expect(statePath).toContain('.taskmaster');
expect(statePath).toContain('sessions');
expect(statePath).toContain('workflow-state.json');
// State file should exist
expect(workflowStateExists()).toBe(true);
});
it('should create project-specific directory based on project path', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Test Task',
subtasks: [{ id: '1.1', title: 'Subtask 1', status: 'pending' }]
});
const statePath = getExpectedStatePath();
// Should contain sanitized project path as identifier
// The path should be like: ~/.taskmaster/-tmp-...-tm-workflow-project-.../sessions/workflow-state.json
expect(statePath).toMatch(
/\.taskmaster\/-[^/]+\/sessions\/workflow-state\.json$/
);
});
});
describe('Start Workflow', () => {
it('should initialize workflow and create state file', async () => {
const status = await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Implement Feature',
subtasks: [
{ id: '1.1', title: 'Write Tests', status: 'pending' },
{ id: '1.2', title: 'Implement Code', status: 'pending' }
]
});
expect(status.taskId).toBe('1');
expect(status.phase).toBe('SUBTASK_LOOP');
expect(status.tddPhase).toBe('RED');
expect(status.currentSubtask?.id).toBe('1.1');
expect(status.progress.total).toBe(2);
expect(status.progress.completed).toBe(0);
// Verify state file
const state = readWorkflowState();
expect(state).not.toBeNull();
expect(state?.phase).toBe('SUBTASK_LOOP');
expect(state?.context.taskId).toBe('1');
expect(state?.context.currentTDDPhase).toBe('RED');
});
it('should create git branch with proper naming', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Add User Authentication',
subtasks: [{ id: '1.1', title: 'Setup auth', status: 'pending' }]
});
const currentBranch = execSync('git branch --show-current', {
cwd: testProjectDir,
encoding: 'utf-8'
}).trim();
expect(currentBranch).toBe('tm/task-1-add-user-authentication');
});
it('should include tag in branch name when provided', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Feature X',
subtasks: [{ id: '1.1', title: 'Do thing', status: 'pending' }],
tag: 'sprint-1'
});
const currentBranch = execSync('git branch --show-current', {
cwd: testProjectDir,
encoding: 'utf-8'
}).trim();
expect(currentBranch).toBe('tm/sprint-1/task-1-feature-x');
});
it('should skip already completed subtasks', async () => {
const status = await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Resume Task',
subtasks: [
{ id: '1.1', title: 'Already Done', status: 'done' },
{ id: '1.2', title: 'Next Up', status: 'pending' }
]
});
// Should start at subtask 1.2 since 1.1 is done
expect(status.currentSubtask?.id).toBe('1.2');
expect(status.progress.completed).toBe(1);
expect(status.progress.current).toBe(2);
});
it('should reject when no subtasks to work on', async () => {
await expect(
workflowService.startWorkflow({
taskId: '1',
taskTitle: 'All Done',
subtasks: [
{ id: '1.1', title: 'Done 1', status: 'done' },
{ id: '1.2', title: 'Done 2', status: 'done' }
]
})
).rejects.toThrow('All subtasks for task 1 are already completed');
});
it('should reject when workflow already exists without force', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'First Workflow',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }]
});
// Create new service instance (simulating new command invocation)
const newService = new WorkflowService(testProjectDir);
await expect(
newService.startWorkflow({
taskId: '2',
taskTitle: 'Second Workflow',
subtasks: [{ id: '2.1', title: 'Task', status: 'pending' }]
})
).rejects.toThrow('Workflow already exists');
});
it('should allow force restart when workflow exists', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'First Workflow',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }]
});
// Create new service instance and force restart
const newService = new WorkflowService(testProjectDir);
const status = await newService.startWorkflow({
taskId: '2',
taskTitle: 'Second Workflow',
subtasks: [{ id: '2.1', title: 'New Task', status: 'pending' }],
force: true
});
expect(status.taskId).toBe('2');
expect(status.currentSubtask?.id).toBe('2.1');
});
});
describe('TDD Phase Transitions', () => {
beforeEach(async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'TDD Test',
subtasks: [
{ id: '1.1', title: 'First Subtask', status: 'pending' },
{ id: '1.2', title: 'Second Subtask', status: 'pending' }
]
});
});
it('should transition from RED to GREEN phase', async () => {
// Initial state should be RED
let status = workflowService.getStatus();
expect(status.tddPhase).toBe('RED');
// Complete RED phase with failing tests
status = await workflowService.completePhase({
total: 5,
passed: 2,
failed: 3,
skipped: 0,
phase: 'RED'
});
expect(status.tddPhase).toBe('GREEN');
// Verify state file updated
const state = readWorkflowState();
expect(state?.context.currentTDDPhase).toBe('GREEN');
});
it('should transition from GREEN to COMMIT phase', async () => {
// Complete RED phase
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
// Complete GREEN phase with all tests passing
const status = await workflowService.completePhase({
total: 5,
passed: 5,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
expect(status.tddPhase).toBe('COMMIT');
});
it('should reject GREEN phase with failing tests', async () => {
// Complete RED phase
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
// Try to complete GREEN with failures
await expect(
workflowService.completePhase({
total: 5,
passed: 3,
failed: 2,
skipped: 0,
phase: 'GREEN'
})
).rejects.toThrow('GREEN phase must have zero failures');
});
it('should advance to next subtask after COMMIT', async () => {
// Complete full TDD cycle for first subtask
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
await workflowService.completePhase({
total: 5,
passed: 5,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
// Complete COMMIT phase
const status = await workflowService.commit();
// Should be on second subtask in RED phase
expect(status.currentSubtask?.id).toBe('1.2');
expect(status.tddPhase).toBe('RED');
expect(status.progress.completed).toBe(1);
expect(status.progress.current).toBe(2);
});
it('should store test results in state', async () => {
const testResults = {
total: 10,
passed: 3,
failed: 7,
skipped: 0,
phase: 'RED' as const
};
await workflowService.completePhase(testResults);
const state = readWorkflowState();
expect(state?.context.lastTestResults).toEqual(testResults);
});
});
describe('Auto-Complete Subtask (RED with 0 failures)', () => {
it('should auto-complete subtask when RED phase has no failures', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Auto-Complete Test',
subtasks: [
{ id: '1.1', title: 'Already Implemented', status: 'pending' },
{ id: '1.2', title: 'Needs Work', status: 'pending' }
]
});
// Complete RED phase with all tests passing (feature already implemented)
const status = await workflowService.completePhase({
total: 5,
passed: 5,
failed: 0,
skipped: 0,
phase: 'RED'
});
// Should have auto-advanced to next subtask
expect(status.currentSubtask?.id).toBe('1.2');
expect(status.tddPhase).toBe('RED');
expect(status.progress.completed).toBe(1);
});
});
describe('Resume Workflow', () => {
it('should resume workflow from saved state', async () => {
// Start workflow and progress through RED phase
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Resume Test',
subtasks: [
{ id: '1.1', title: 'First', status: 'pending' },
{ id: '1.2', title: 'Second', status: 'pending' }
]
});
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
// Verify we're in GREEN phase
expect(workflowService.getStatus().tddPhase).toBe('GREEN');
// Create new service instance (simulating new session)
const newService = new WorkflowService(testProjectDir);
// Resume workflow
const status = await newService.resumeWorkflow();
expect(status.taskId).toBe('1');
expect(status.phase).toBe('SUBTASK_LOOP');
expect(status.tddPhase).toBe('GREEN'); // Should resume in GREEN phase
expect(status.currentSubtask?.id).toBe('1.1');
});
it('should preserve progress when resuming', async () => {
// Start workflow and complete first subtask
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Progress Test',
subtasks: [
{ id: '1.1', title: 'First', status: 'pending' },
{ id: '1.2', title: 'Second', status: 'pending' }
]
});
// Complete first subtask
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
await workflowService.completePhase({
total: 5,
passed: 5,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
await workflowService.commit();
// Resume in new session
const newService = new WorkflowService(testProjectDir);
const status = await newService.resumeWorkflow();
expect(status.progress.completed).toBe(1);
expect(status.progress.current).toBe(2);
expect(status.currentSubtask?.id).toBe('1.2');
});
it('should error when no workflow exists to resume', async () => {
await expect(workflowService.resumeWorkflow()).rejects.toThrow(
'Workflow state file not found'
);
});
});
describe('Finalize Workflow', () => {
it('should finalize when all subtasks are complete', async () => {
// Start workflow with single subtask
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Finalize Test',
subtasks: [{ id: '1.1', title: 'Only Task', status: 'pending' }]
});
// Complete the subtask
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
await workflowService.completePhase({
total: 5,
passed: 5,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
// Make a commit in git to clean working tree
fs.writeFileSync(
path.join(testProjectDir, 'feature.ts'),
'export const x = 1;\n'
);
execSync('git add .', { cwd: testProjectDir, stdio: 'pipe' });
execSync('git commit -m "Implement feature"', {
cwd: testProjectDir,
stdio: 'pipe'
});
// Complete commit phase
await workflowService.commit();
// Should now be in FINALIZE phase
let status = workflowService.getStatus();
expect(status.phase).toBe('FINALIZE');
// Finalize workflow
status = await workflowService.finalizeWorkflow();
expect(status.phase).toBe('COMPLETE');
expect(status.progress.percentage).toBe(100);
});
it('should reject finalize with uncommitted changes', async () => {
// Start and complete all subtasks
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Dirty Tree Test',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }]
});
await workflowService.completePhase({
total: 1,
passed: 0,
failed: 1,
skipped: 0,
phase: 'RED'
});
await workflowService.completePhase({
total: 1,
passed: 1,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
await workflowService.commit();
// Create uncommitted changes
fs.writeFileSync(
path.join(testProjectDir, 'uncommitted.ts'),
'const x = 1;\n'
);
// Should fail to finalize
await expect(workflowService.finalizeWorkflow()).rejects.toThrow(
'working tree has uncommitted changes'
);
});
});
describe('Abort Workflow', () => {
it('should abort workflow and delete state file', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Abort Test',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }]
});
expect(workflowStateExists()).toBe(true);
await workflowService.abortWorkflow();
expect(workflowStateExists()).toBe(false);
});
it('should not error when aborting non-existent workflow', async () => {
// Should not throw
await expect(workflowService.abortWorkflow()).resolves.not.toThrow();
});
});
describe('Next Action Recommendations', () => {
it('should recommend correct action for RED phase', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Next Action Test',
subtasks: [{ id: '1.1', title: 'Write auth tests', status: 'pending' }]
});
const nextAction = workflowService.getNextAction();
expect(nextAction.action).toBe('generate_test');
expect(nextAction.tddPhase).toBe('RED');
expect(nextAction.subtask?.id).toBe('1.1');
expect(nextAction.nextSteps).toContain('Write failing tests');
});
it('should recommend correct action for GREEN phase', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Next Action Test',
subtasks: [{ id: '1.1', title: 'Implement feature', status: 'pending' }]
});
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
const nextAction = workflowService.getNextAction();
expect(nextAction.action).toBe('implement_code');
expect(nextAction.tddPhase).toBe('GREEN');
expect(nextAction.nextSteps).toContain('Implement code');
});
it('should recommend correct action for COMMIT phase', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Next Action Test',
subtasks: [{ id: '1.1', title: 'Commit changes', status: 'pending' }]
});
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
await workflowService.completePhase({
total: 5,
passed: 5,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
const nextAction = workflowService.getNextAction();
expect(nextAction.action).toBe('commit_changes');
expect(nextAction.tddPhase).toBe('COMMIT');
expect(nextAction.nextSteps).toContain('commit');
});
it('should recommend finalize for FINALIZE phase', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Finalize Test',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }]
});
// Make git commit to have clean tree
fs.writeFileSync(
path.join(testProjectDir, 'feature.ts'),
'export const x = 1;\n'
);
execSync('git add .', { cwd: testProjectDir, stdio: 'pipe' });
execSync('git commit -m "Feature"', {
cwd: testProjectDir,
stdio: 'pipe'
});
// Complete the workflow to FINALIZE phase
await workflowService.completePhase({
total: 1,
passed: 0,
failed: 1,
skipped: 0,
phase: 'RED'
});
await workflowService.completePhase({
total: 1,
passed: 1,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
await workflowService.commit();
const nextAction = workflowService.getNextAction();
expect(nextAction.action).toBe('finalize_workflow');
expect(nextAction.phase).toBe('FINALIZE');
});
});
describe('State File Evolution', () => {
it('should track full workflow state evolution', async () => {
// Start workflow
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Evolution Test',
subtasks: [
{ id: '1.1', title: 'First', status: 'pending' },
{ id: '1.2', title: 'Second', status: 'pending' }
]
});
// Verify initial state
let state = readWorkflowState();
expect(state?.phase).toBe('SUBTASK_LOOP');
expect(state?.context.currentSubtaskIndex).toBe(0);
expect(state?.context.currentTDDPhase).toBe('RED');
// Complete RED phase
await workflowService.completePhase({
total: 3,
passed: 0,
failed: 3,
skipped: 0,
phase: 'RED'
});
state = readWorkflowState();
expect(state?.context.currentTDDPhase).toBe('GREEN');
expect(state?.context.lastTestResults?.failed).toBe(3);
// Complete GREEN phase
await workflowService.completePhase({
total: 3,
passed: 3,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
state = readWorkflowState();
expect(state?.context.currentTDDPhase).toBe('COMMIT');
// Complete commit and advance to next subtask
await workflowService.commit();
state = readWorkflowState();
expect(state?.context.currentSubtaskIndex).toBe(1);
expect(state?.context.currentTDDPhase).toBe('RED');
expect(state?.context.subtasks[0].status).toBe('completed');
});
});
describe('hasWorkflow', () => {
it('should return false when no workflow exists', async () => {
const exists = await workflowService.hasWorkflow();
expect(exists).toBe(false);
});
it('should return true when workflow exists', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Exists Test',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }]
});
const exists = await workflowService.hasWorkflow();
expect(exists).toBe(true);
});
it('should return false after workflow is aborted', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Abort Test',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }]
});
await workflowService.abortWorkflow();
const exists = await workflowService.hasWorkflow();
expect(exists).toBe(false);
});
});
describe('Team/API Storage', () => {
it('should use orgSlug for branch naming when provided (API storage mode)', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Team Feature',
subtasks: [{ id: 'HAM-2', title: 'Implement', status: 'pending' }],
orgSlug: 'acme-corp'
});
const currentBranch = execSync('git branch --show-current', {
cwd: testProjectDir,
encoding: 'utf-8'
}).trim();
expect(currentBranch).toBe('tm/acme-corp/task-1-team-feature');
});
it('should prioritize orgSlug over tag for branch naming', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Priority Test',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }],
tag: 'local-tag',
orgSlug: 'team-slug'
});
const currentBranch = execSync('git branch --show-current', {
cwd: testProjectDir,
encoding: 'utf-8'
}).trim();
// orgSlug should take precedence over tag
expect(currentBranch).toBe('tm/team-slug/task-1-priority-test');
expect(currentBranch).not.toContain('local-tag');
});
});
});