workflows-status.test.js
1 /** 2 * Tests for workflow status and creation functions 3 * 4 * getBugFixWorkflowStatus, getFeatureWorkflowStatus, getRefactorWorkflowStatus, 5 * createBugFixWorkflow, createBulkBugFixWorkflows, createFeatureWorkflow, 6 * createRefactorWorkflow — all use a mocked task-manager and a temp SQLite DB. 7 */ 8 9 import { test, describe, before, after } from 'node:test'; 10 import assert from 'node:assert/strict'; 11 import { join } from 'path'; 12 import { tmpdir } from 'os'; 13 import { unlinkSync, existsSync } from 'fs'; 14 import Database from 'better-sqlite3'; 15 16 // ─── Setup temp DB ──────────────────────────────────────────────────────────── 17 18 const TEST_DB = join(tmpdir(), `test-workflows-status-${Date.now()}.db`); 19 process.env.DATABASE_PATH = TEST_DB; 20 21 // Create minimal schema: agent_tasks only 22 const db = new Database(TEST_DB); 23 db.exec(` 24 CREATE TABLE IF NOT EXISTS agent_tasks ( 25 id INTEGER PRIMARY KEY AUTOINCREMENT, 26 task_type TEXT NOT NULL, 27 assigned_to TEXT NOT NULL, 28 created_by TEXT DEFAULT 'system', 29 parent_task_id INTEGER REFERENCES agent_tasks(id) ON DELETE CASCADE, 30 priority INTEGER DEFAULT 5, 31 status TEXT DEFAULT 'pending', 32 context_json TEXT, 33 result_json TEXT, 34 error_message TEXT, 35 retry_count INTEGER DEFAULT 0, 36 reviewed_by TEXT, 37 approval_json TEXT, 38 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 39 started_at TIMESTAMP, 40 completed_at TIMESTAMP 41 ); 42 `); 43 db.close(); 44 45 // Mock task-manager to avoid it querying additional tables on import 46 import { mock } from 'node:test'; 47 48 let nextTaskId = 1000; 49 const createAgentTaskMock = mock.fn(async _opts => nextTaskId++); 50 51 mock.module('../../src/agents/utils/task-manager.js', { 52 namedExports: { 53 createAgentTask: createAgentTaskMock, 54 getAgentTask: async _id => null, 55 }, 56 }); 57 58 mock.module('dotenv', { 59 defaultExport: { config: () => {} }, 60 namedExports: { config: () => {} }, 61 }); 62 63 const { getBugFixWorkflowStatus, createBugFixWorkflow, createBulkBugFixWorkflows } = 64 await import('../../src/agents/workflows/bug-fix.js'); 65 const { getFeatureWorkflowStatus, createFeatureWorkflow } = 66 await import('../../src/agents/workflows/feature.js'); 67 const { getRefactorWorkflowStatus, createRefactorWorkflow, createBulkRefactorWorkflows } = 68 await import('../../src/agents/workflows/refactor.js'); 69 70 // ─── Helpers ───────────────────────────────────────────────────────────────── 71 72 function openDb() { 73 return new Database(TEST_DB); 74 } 75 76 function insertTask( 77 db2, 78 { taskType = 'classify_issue', assignedTo = 'triage', status = 'pending', parentId = null } = {} 79 ) { 80 const result = db2 81 .prepare( 82 ` 83 INSERT INTO agent_tasks (task_type, assigned_to, status, parent_task_id) 84 VALUES (?, ?, ?, ?) 85 ` 86 ) 87 .run(taskType, assignedTo, status, parentId); 88 return result.lastInsertRowid; 89 } 90 91 // ─── Tests ─────────────────────────────────────────────────────────────────── 92 93 describe('getBugFixWorkflowStatus', () => { 94 let db2; 95 before(() => { 96 db2 = openDb(); 97 }); 98 after(() => { 99 db2.close(); 100 }); 101 102 test('returns empty stages for nonexistent workflow ID', async () => { 103 const status = await getBugFixWorkflowStatus(999999); 104 assert.strictEqual(status.workflow_id, 999999); 105 assert.ok(Array.isArray(status.stages)); 106 assert.strictEqual(status.stages.length, 0); 107 assert.strictEqual(status.is_complete, true); // every() on empty = true 108 assert.strictEqual(status.has_failures, false); 109 assert.strictEqual(status.is_blocked, false); 110 }); 111 112 test('returns correct status for single pending task', async () => { 113 const taskId = insertTask(db2, { 114 taskType: 'classify_issue', 115 assignedTo: 'triage', 116 status: 'pending', 117 }); 118 119 const status = await getBugFixWorkflowStatus(taskId); 120 assert.strictEqual(status.workflow_id, taskId); 121 assert.strictEqual(status.stages.length, 1); 122 assert.strictEqual(status.stages[0].status, 'pending'); 123 assert.strictEqual(status.stages[0].agent, 'triage'); 124 assert.strictEqual(status.is_complete, false); 125 assert.strictEqual(status.has_failures, false); 126 assert.ok(status.current_stage !== undefined); 127 }); 128 129 test('reports is_complete=true when all tasks are completed', async () => { 130 const parentId = insertTask(db2, { 131 taskType: 'classify_issue', 132 assignedTo: 'triage', 133 status: 'completed', 134 }); 135 insertTask(db2, { 136 taskType: 'fix_bug', 137 assignedTo: 'developer', 138 status: 'completed', 139 parentId, 140 }); 141 142 const status = await getBugFixWorkflowStatus(parentId); 143 assert.strictEqual(status.is_complete, true); 144 assert.strictEqual(status.has_failures, false); 145 }); 146 147 test('reports has_failures=true when any task is failed', async () => { 148 const parentId = insertTask(db2, { 149 taskType: 'classify_issue', 150 assignedTo: 'triage', 151 status: 'failed', 152 }); 153 154 const status = await getBugFixWorkflowStatus(parentId); 155 assert.strictEqual(status.has_failures, true); 156 assert.strictEqual(status.is_complete, false); 157 }); 158 159 test('reports is_blocked=true when any task is blocked', async () => { 160 const parentId = insertTask(db2, { 161 taskType: 'classify_issue', 162 assignedTo: 'triage', 163 status: 'blocked', 164 }); 165 166 const status = await getBugFixWorkflowStatus(parentId); 167 assert.strictEqual(status.is_blocked, true); 168 }); 169 }); 170 171 describe('getFeatureWorkflowStatus', () => { 172 test('returns status for feature workflow', async () => { 173 const db2 = openDb(); 174 const taskId = insertTask(db2, { 175 taskType: 'review_design', 176 assignedTo: 'architect', 177 status: 'running', 178 }); 179 db2.close(); 180 181 const status = await getFeatureWorkflowStatus(taskId); 182 assert.strictEqual(status.workflow_id, taskId); 183 assert.strictEqual(status.stages.length, 1); 184 assert.strictEqual(status.stages[0].task_type, 'review_design'); 185 assert.ok(status.current_stage); // running task = current stage 186 assert.strictEqual(status.is_complete, false); 187 }); 188 }); 189 190 describe('getRefactorWorkflowStatus', () => { 191 test('returns status for refactor workflow', async () => { 192 const db2 = openDb(); 193 const taskId = insertTask(db2, { 194 taskType: 'review_refactor', 195 assignedTo: 'architect', 196 status: 'completed', 197 }); 198 db2.close(); 199 200 const status = await getRefactorWorkflowStatus(taskId); 201 assert.strictEqual(status.workflow_id, taskId); 202 assert.strictEqual(status.stages.length, 1); 203 assert.strictEqual(status.is_complete, true); 204 assert.strictEqual(status.current_stage, undefined); // no pending/running 205 }); 206 }); 207 208 // ─── Create workflow tests ───────────────────────────────────────────────────── 209 210 describe('createBugFixWorkflow', () => { 211 test('returns a task ID (number) from createAgentTask', async () => { 212 createAgentTaskMock.mock.resetCalls(); 213 const id = await createBugFixWorkflow('TypeError: cannot read x', 'stack trace', 'scoring', 3); 214 assert.strictEqual(typeof id, 'number'); 215 assert.strictEqual(createAgentTaskMock.mock.calls.length, 1); 216 const callArgs = createAgentTaskMock.mock.calls[0].arguments[0]; 217 assert.strictEqual(callArgs.task_type, 'classify_error'); 218 assert.strictEqual(callArgs.assigned_to, 'triage'); 219 assert.strictEqual(callArgs.context.stage, 'scoring'); 220 assert.strictEqual(callArgs.context.frequency, 3); 221 }); 222 223 test('accepts options.priority and options.created_by', async () => { 224 createAgentTaskMock.mock.resetCalls(); 225 await createBugFixWorkflow('Error', '', 'enrich', 1, { priority: 8, created_by: 'monitor' }); 226 const callArgs = createAgentTaskMock.mock.calls[0].arguments[0]; 227 assert.strictEqual(callArgs.priority, 8); 228 assert.strictEqual(callArgs.created_by, 'monitor'); 229 }); 230 }); 231 232 describe('createBulkBugFixWorkflows', () => { 233 test('returns array of workflow IDs for multiple errors', async () => { 234 createAgentTaskMock.mock.resetCalls(); 235 const errors = [ 236 { message: 'Error A', stage: 'scoring', frequency: 2 }, 237 { message: 'Error B', stage: 'enrich', frequency: 1 }, 238 ]; 239 const ids = await createBulkBugFixWorkflows(errors); 240 assert.ok(Array.isArray(ids)); 241 assert.strictEqual(ids.length, 2); 242 assert.strictEqual(createAgentTaskMock.mock.calls.length, 2); 243 }); 244 245 test('skips erroring items and continues', async () => { 246 createAgentTaskMock.mock.resetCalls(); 247 createAgentTaskMock.mock.mockImplementationOnce(async () => { 248 throw new Error('task-manager down'); 249 }); 250 const errors = [{ message: 'Will fail' }, { message: 'Will succeed', stage: 'scoring' }]; 251 const ids = await createBulkBugFixWorkflows(errors); 252 // First item failed, second succeeded — should have 1 ID 253 assert.strictEqual(ids.length, 1); 254 }); 255 }); 256 257 describe('createFeatureWorkflow', () => { 258 test('creates architect review task by default', async () => { 259 createAgentTaskMock.mock.resetCalls(); 260 const id = await createFeatureWorkflow('Add OAuth login', ['must support Google']); 261 assert.strictEqual(typeof id, 'number'); 262 const callArgs = createAgentTaskMock.mock.calls[0].arguments[0]; 263 assert.strictEqual(callArgs.task_type, 'review_design'); 264 assert.strictEqual(callArgs.assigned_to, 'architect'); 265 }); 266 267 test('skips architect review when skipArchitectReview=true', async () => { 268 createAgentTaskMock.mock.resetCalls(); 269 const id = await createFeatureWorkflow('Quick fix', [], { skipArchitectReview: true }); 270 assert.strictEqual(typeof id, 'number'); 271 const callArgs = createAgentTaskMock.mock.calls[0].arguments[0]; 272 assert.strictEqual(callArgs.task_type, 'implement_feature'); 273 assert.strictEqual(callArgs.assigned_to, 'developer'); 274 }); 275 }); 276 277 describe('createRefactorWorkflow', () => { 278 test('creates refactor workflow and returns task ID', async () => { 279 createAgentTaskMock.mock.resetCalls(); 280 const id = await createRefactorWorkflow('Extract utils into separate module', ['src/utils.js']); 281 assert.strictEqual(typeof id, 'number'); 282 assert.strictEqual(createAgentTaskMock.mock.calls.length, 1); 283 const callArgs = createAgentTaskMock.mock.calls[0].arguments[0]; 284 assert.strictEqual(callArgs.task_type, 'suggest_refactor'); 285 assert.strictEqual(callArgs.assigned_to, 'architect'); 286 }); 287 }); 288 289 describe('createBulkRefactorWorkflows', () => { 290 test('creates workflow for files exceeding lines threshold', async () => { 291 createAgentTaskMock.mock.resetCalls(); 292 const ids = await createBulkRefactorWorkflows([ 293 { path: 'src/big.js', lines: 200, complexity: 5, depth: 2 }, 294 ]); 295 assert.strictEqual(ids.length, 1); 296 assert.strictEqual(createAgentTaskMock.mock.calls.length, 1); 297 }); 298 299 test('creates workflow for files exceeding complexity threshold', async () => { 300 createAgentTaskMock.mock.resetCalls(); 301 const ids = await createBulkRefactorWorkflows([ 302 { path: 'src/complex.js', lines: 50, complexity: 20, depth: 2 }, 303 ]); 304 assert.strictEqual(ids.length, 1); 305 }); 306 307 test('creates workflow for files exceeding depth threshold', async () => { 308 createAgentTaskMock.mock.resetCalls(); 309 const ids = await createBulkRefactorWorkflows([ 310 { path: 'src/deep.js', lines: 50, complexity: 5, depth: 6 }, 311 ]); 312 assert.strictEqual(ids.length, 1); 313 }); 314 315 test('skips files within all thresholds', async () => { 316 createAgentTaskMock.mock.resetCalls(); 317 const ids = await createBulkRefactorWorkflows([ 318 { path: 'src/small.js', lines: 100, complexity: 10, depth: 3 }, 319 ]); 320 assert.strictEqual(ids.length, 0); 321 assert.strictEqual(createAgentTaskMock.mock.calls.length, 0); 322 }); 323 324 test('handles createAgentTask errors gracefully', async () => { 325 createAgentTaskMock.mock.resetCalls(); 326 createAgentTaskMock.mock.mockImplementationOnce(async () => { 327 throw new Error('DB connection lost'); 328 }); 329 const ids = await createBulkRefactorWorkflows([ 330 { path: 'src/heavy.js', lines: 300, complexity: 25, depth: 8 }, 331 ]); 332 // Should swallow error and return empty array 333 assert.strictEqual(ids.length, 0); 334 }); 335 336 after(() => { 337 if (existsSync(TEST_DB)) { 338 try { 339 unlinkSync(TEST_DB); 340 } catch {} 341 } 342 }); 343 });