mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
fix: improve branch naming logic for workflow commands (#1491)
This commit is contained in:
13
packages/tm-core/src/common/schemas/index.ts
Normal file
13
packages/tm-core/src/common/schemas/index.ts
Normal 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';
|
||||
201
packages/tm-core/src/common/schemas/task-id.schema.spec.ts
Normal file
201
packages/tm-core/src/common/schemas/task-id.schema.spec.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
217
packages/tm-core/src/common/schemas/task-id.schema.ts
Normal file
217
packages/tm-core/src/common/schemas/task-id.schema.ts
Normal 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>;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface WorkflowContext {
|
||||
currentSubtaskIndex: number;
|
||||
currentTDDPhase?: TDDPhase;
|
||||
branchName?: string;
|
||||
tag?: string;
|
||||
errors: WorkflowError[];
|
||||
metadata: Record<string, unknown>;
|
||||
lastTestResults?: TestResult;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user