loopwork 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/loopwork +0 -0
- package/package.json +48 -4
- package/src/backends/github.ts +6 -3
- package/src/backends/json.ts +28 -10
- package/src/commands/run.ts +2 -2
- package/src/contracts/config.ts +3 -75
- package/src/contracts/index.ts +0 -6
- package/src/core/cli.ts +25 -16
- package/src/core/state.ts +10 -4
- package/src/core/utils.ts +10 -4
- package/src/monitor/index.ts +56 -34
- package/src/plugins/index.ts +9 -131
- package/examples/README.md +0 -70
- package/examples/basic-json-backend/.specs/tasks/TASK-001.md +0 -22
- package/examples/basic-json-backend/.specs/tasks/TASK-002.md +0 -23
- package/examples/basic-json-backend/.specs/tasks/TASK-003.md +0 -37
- package/examples/basic-json-backend/.specs/tasks/tasks.json +0 -19
- package/examples/basic-json-backend/README.md +0 -32
- package/examples/basic-json-backend/TESTING.md +0 -184
- package/examples/basic-json-backend/hello.test.ts +0 -9
- package/examples/basic-json-backend/hello.ts +0 -3
- package/examples/basic-json-backend/loopwork.config.js +0 -35
- package/examples/basic-json-backend/math.test.ts +0 -29
- package/examples/basic-json-backend/math.ts +0 -3
- package/examples/basic-json-backend/package.json +0 -15
- package/examples/basic-json-backend/quick-start.sh +0 -80
- package/loopwork.config.ts +0 -164
- package/src/plugins/asana.ts +0 -192
- package/src/plugins/cost-tracking.ts +0 -402
- package/src/plugins/discord.ts +0 -269
- package/src/plugins/everhour.ts +0 -335
- package/src/plugins/telegram/bot.ts +0 -517
- package/src/plugins/telegram/index.ts +0 -6
- package/src/plugins/telegram/notifications.ts +0 -198
- package/src/plugins/todoist.ts +0 -261
- package/test/backends.test.ts +0 -929
- package/test/cli.test.ts +0 -145
- package/test/config.test.ts +0 -90
- package/test/e2e.test.ts +0 -458
- package/test/github-tasks.test.ts +0 -191
- package/test/loopwork-config-types.test.ts +0 -288
- package/test/monitor.test.ts +0 -123
- package/test/plugins.test.ts +0 -1175
- package/test/state.test.ts +0 -295
- package/test/utils.test.ts +0 -60
- package/tsconfig.json +0 -20
package/test/cli.test.ts
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from 'bun:test'
|
|
2
|
-
import { EXEC_MODELS, FALLBACK_MODELS, CliConfig } from '../src/core/cli'
|
|
3
|
-
|
|
4
|
-
describe('CLI Model Pools', () => {
|
|
5
|
-
describe('EXEC_MODELS', () => {
|
|
6
|
-
test('has at least one model', () => {
|
|
7
|
-
expect(EXEC_MODELS.length).toBeGreaterThan(0)
|
|
8
|
-
})
|
|
9
|
-
|
|
10
|
-
test('all models have required fields', () => {
|
|
11
|
-
for (const model of EXEC_MODELS) {
|
|
12
|
-
expect(model.name).toBeDefined()
|
|
13
|
-
expect(model.cli).toBeDefined()
|
|
14
|
-
expect(model.model).toBeDefined()
|
|
15
|
-
expect(['opencode', 'claude']).toContain(model.cli)
|
|
16
|
-
}
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
test('includes claude sonnet', () => {
|
|
20
|
-
const claudeSonnet = EXEC_MODELS.find(m => m.cli === 'claude' && m.model === 'sonnet')
|
|
21
|
-
expect(claudeSonnet).toBeDefined()
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
test('includes opencode models', () => {
|
|
25
|
-
const opencodeModels = EXEC_MODELS.filter(m => m.cli === 'opencode')
|
|
26
|
-
expect(opencodeModels.length).toBeGreaterThan(0)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
test('model names are unique', () => {
|
|
30
|
-
const names = EXEC_MODELS.map(m => m.name)
|
|
31
|
-
const uniqueNames = new Set(names)
|
|
32
|
-
expect(uniqueNames.size).toBe(names.length)
|
|
33
|
-
})
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
describe('FALLBACK_MODELS', () => {
|
|
37
|
-
test('has at least one model', () => {
|
|
38
|
-
expect(FALLBACK_MODELS.length).toBeGreaterThan(0)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
test('all models have required fields', () => {
|
|
42
|
-
for (const model of FALLBACK_MODELS) {
|
|
43
|
-
expect(model.name).toBeDefined()
|
|
44
|
-
expect(model.cli).toBeDefined()
|
|
45
|
-
expect(model.model).toBeDefined()
|
|
46
|
-
expect(['opencode', 'claude']).toContain(model.cli)
|
|
47
|
-
}
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
test('includes opus as fallback', () => {
|
|
51
|
-
const opus = FALLBACK_MODELS.find(m => m.model === 'opus')
|
|
52
|
-
expect(opus).toBeDefined()
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
test('model names are unique', () => {
|
|
56
|
-
const names = FALLBACK_MODELS.map(m => m.name)
|
|
57
|
-
const uniqueNames = new Set(names)
|
|
58
|
-
expect(uniqueNames.size).toBe(names.length)
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
test('fallback models are different from exec models', () => {
|
|
62
|
-
const execNames = new Set(EXEC_MODELS.map(m => m.name))
|
|
63
|
-
for (const fallback of FALLBACK_MODELS) {
|
|
64
|
-
expect(execNames.has(fallback.name)).toBe(false)
|
|
65
|
-
}
|
|
66
|
-
})
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
describe('CliConfig interface', () => {
|
|
70
|
-
test('can create valid CliConfig', () => {
|
|
71
|
-
const config: CliConfig = {
|
|
72
|
-
name: 'test-model',
|
|
73
|
-
cli: 'claude',
|
|
74
|
-
model: 'sonnet',
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
expect(config.name).toBe('test-model')
|
|
78
|
-
expect(config.cli).toBe('claude')
|
|
79
|
-
expect(config.model).toBe('sonnet')
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
test('cli must be opencode or claude', () => {
|
|
83
|
-
const validConfigs: CliConfig[] = [
|
|
84
|
-
{ name: 'a', cli: 'opencode', model: 'x' },
|
|
85
|
-
{ name: 'b', cli: 'claude', model: 'y' },
|
|
86
|
-
]
|
|
87
|
-
|
|
88
|
-
for (const config of validConfigs) {
|
|
89
|
-
expect(['opencode', 'claude']).toContain(config.cli)
|
|
90
|
-
}
|
|
91
|
-
})
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
describe('Model rotation logic', () => {
|
|
95
|
-
test('EXEC_MODELS rotation cycles through all models', () => {
|
|
96
|
-
const visited = new Set<string>()
|
|
97
|
-
for (let i = 0; i < EXEC_MODELS.length; i++) {
|
|
98
|
-
const model = EXEC_MODELS[i % EXEC_MODELS.length]
|
|
99
|
-
visited.add(model.name)
|
|
100
|
-
}
|
|
101
|
-
expect(visited.size).toBe(EXEC_MODELS.length)
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
test('FALLBACK_MODELS rotation cycles through all models', () => {
|
|
105
|
-
const visited = new Set<string>()
|
|
106
|
-
for (let i = 0; i < FALLBACK_MODELS.length; i++) {
|
|
107
|
-
const model = FALLBACK_MODELS[i % FALLBACK_MODELS.length]
|
|
108
|
-
visited.add(model.name)
|
|
109
|
-
}
|
|
110
|
-
expect(visited.size).toBe(FALLBACK_MODELS.length)
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
test('total attempts cover all models', () => {
|
|
114
|
-
const maxAttempts = EXEC_MODELS.length + FALLBACK_MODELS.length
|
|
115
|
-
expect(maxAttempts).toBe(EXEC_MODELS.length + FALLBACK_MODELS.length)
|
|
116
|
-
})
|
|
117
|
-
})
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
describe('Rate limit detection patterns', () => {
|
|
121
|
-
const rateLimitPatterns = /rate.*limit|too.*many.*request|429|RESOURCE_EXHAUSTED/i
|
|
122
|
-
const quotaPatterns = /quota.*exceed|billing.*limit/i
|
|
123
|
-
|
|
124
|
-
test('detects rate limit errors', () => {
|
|
125
|
-
expect(rateLimitPatterns.test('Error: rate limit exceeded')).toBe(true)
|
|
126
|
-
expect(rateLimitPatterns.test('Too many requests')).toBe(true)
|
|
127
|
-
expect(rateLimitPatterns.test('HTTP 429')).toBe(true)
|
|
128
|
-
expect(rateLimitPatterns.test('RESOURCE_EXHAUSTED')).toBe(true)
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
test('does not false positive on normal output', () => {
|
|
132
|
-
expect(rateLimitPatterns.test('Task completed successfully')).toBe(false)
|
|
133
|
-
expect(rateLimitPatterns.test('Writing to file')).toBe(false)
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
test('detects quota errors', () => {
|
|
137
|
-
expect(quotaPatterns.test('quota exceeded')).toBe(true)
|
|
138
|
-
expect(quotaPatterns.test('billing limit reached')).toBe(true)
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
test('does not false positive quota on normal output', () => {
|
|
142
|
-
expect(quotaPatterns.test('Task completed successfully')).toBe(false)
|
|
143
|
-
expect(quotaPatterns.test('Checking bill status')).toBe(false)
|
|
144
|
-
})
|
|
145
|
-
})
|
package/test/config.test.ts
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from 'bun:test'
|
|
2
|
-
import type { Config } from '../src/core/config'
|
|
3
|
-
import { DEFAULT_CONFIG } from '../src/contracts'
|
|
4
|
-
|
|
5
|
-
describe('Config', () => {
|
|
6
|
-
describe('DEFAULT_CONFIG', () => {
|
|
7
|
-
test('has expected default values', () => {
|
|
8
|
-
expect(DEFAULT_CONFIG.maxIterations).toBe(50)
|
|
9
|
-
expect(DEFAULT_CONFIG.timeout).toBe(600)
|
|
10
|
-
expect(DEFAULT_CONFIG.cli).toBe('opencode')
|
|
11
|
-
expect(DEFAULT_CONFIG.autoConfirm).toBe(false)
|
|
12
|
-
expect(DEFAULT_CONFIG.dryRun).toBe(false)
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
test('cli default is a valid option', () => {
|
|
16
|
-
const validClis = ['opencode', 'claude', 'gemini']
|
|
17
|
-
expect(validClis).toContain(DEFAULT_CONFIG.cli)
|
|
18
|
-
})
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
describe('Config interface', () => {
|
|
22
|
-
test('can create a valid config object', () => {
|
|
23
|
-
const config: Config = {
|
|
24
|
-
...DEFAULT_CONFIG,
|
|
25
|
-
projectRoot: '/test/project',
|
|
26
|
-
outputDir: '/test/project/loopwork-runs/2026-01-17',
|
|
27
|
-
sessionId: 'loopwork-2026-01-17-123',
|
|
28
|
-
debug: false,
|
|
29
|
-
resume: false,
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
expect(config.projectRoot).toBe('/test/project')
|
|
33
|
-
expect(config.sessionId).toMatch(/^loopwork-/)
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
test('supports optional fields', () => {
|
|
37
|
-
const config: Config = {
|
|
38
|
-
...DEFAULT_CONFIG,
|
|
39
|
-
projectRoot: '/test',
|
|
40
|
-
outputDir: '/test/output',
|
|
41
|
-
sessionId: 'test',
|
|
42
|
-
debug: false,
|
|
43
|
-
resume: false,
|
|
44
|
-
repo: 'owner/repo',
|
|
45
|
-
feature: 'profile-health',
|
|
46
|
-
startTask: 123,
|
|
47
|
-
model: 'claude-sonnet',
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
expect(config.repo).toBe('owner/repo')
|
|
51
|
-
expect(config.feature).toBe('profile-health')
|
|
52
|
-
expect(config.startTask).toBe(123)
|
|
53
|
-
expect(config.model).toBe('claude-sonnet')
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
test('config without optional fields is valid', () => {
|
|
57
|
-
const config: Config = {
|
|
58
|
-
...DEFAULT_CONFIG,
|
|
59
|
-
projectRoot: '/test',
|
|
60
|
-
outputDir: '/test/output',
|
|
61
|
-
sessionId: 'test',
|
|
62
|
-
debug: false,
|
|
63
|
-
resume: false,
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
expect(config.repo).toBeUndefined()
|
|
67
|
-
expect(config.feature).toBeUndefined()
|
|
68
|
-
expect(config.startTask).toBeUndefined()
|
|
69
|
-
})
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
describe('sessionId format', () => {
|
|
73
|
-
test('sessionId follows expected pattern', () => {
|
|
74
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
75
|
-
const sessionId = `loopwork-${timestamp}-${process.pid}`
|
|
76
|
-
|
|
77
|
-
expect(sessionId).toMatch(/^loopwork-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d+$/)
|
|
78
|
-
})
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
describe('outputDir format', () => {
|
|
82
|
-
test('outputDir includes timestamp', () => {
|
|
83
|
-
const timestamp = '2026-01-17T10-30-00'
|
|
84
|
-
const projectRoot = '/home/user/project'
|
|
85
|
-
const outputDir = `${projectRoot}/loopwork-runs/${timestamp}`
|
|
86
|
-
|
|
87
|
-
expect(outputDir).toBe('/home/user/project/loopwork-runs/2026-01-17T10-30-00')
|
|
88
|
-
})
|
|
89
|
-
})
|
|
90
|
-
})
|
package/test/e2e.test.ts
DELETED
|
@@ -1,458 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
|
|
2
|
-
import fs from 'fs'
|
|
3
|
-
import path from 'path'
|
|
4
|
-
import os from 'os'
|
|
5
|
-
import { JsonTaskAdapter } from '../src/backends/json'
|
|
6
|
-
import { StateManager } from '../src/core/state'
|
|
7
|
-
import { CliExecutor } from '../src/core/cli'
|
|
8
|
-
import type { Config } from '../src/core/config'
|
|
9
|
-
import type { Task } from '../src/backends/types'
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* End-to-End Test for Loopwork with JSON Backend
|
|
13
|
-
*
|
|
14
|
-
* This test simulates the complete loopwork workflow:
|
|
15
|
-
* 1. Setting up tasks in JSON format
|
|
16
|
-
* 2. Running the task loop
|
|
17
|
-
* 3. Executing tasks (mocked CLI)
|
|
18
|
-
* 4. Verifying task status changes
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
describe('Loopwork E2E with JSON Backend', () => {
|
|
22
|
-
let tempDir: string
|
|
23
|
-
let tasksFile: string
|
|
24
|
-
let config: Config
|
|
25
|
-
let backend: JsonTaskAdapter
|
|
26
|
-
let stateManager: StateManager
|
|
27
|
-
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
// Create temp directory for test
|
|
30
|
-
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loopwork-e2e-'))
|
|
31
|
-
tasksFile = path.join(tempDir, 'tasks.json')
|
|
32
|
-
|
|
33
|
-
// Create config
|
|
34
|
-
config = {
|
|
35
|
-
projectRoot: tempDir,
|
|
36
|
-
backend: {
|
|
37
|
-
type: 'json',
|
|
38
|
-
tasksFile,
|
|
39
|
-
tasksDir: tempDir,
|
|
40
|
-
},
|
|
41
|
-
cli: 'claude',
|
|
42
|
-
maxIterations: 10,
|
|
43
|
-
timeout: 30,
|
|
44
|
-
namespace: 'test',
|
|
45
|
-
sessionId: 'test-session-123',
|
|
46
|
-
outputDir: path.join(tempDir, 'output'),
|
|
47
|
-
dryRun: false,
|
|
48
|
-
debug: false,
|
|
49
|
-
autoConfirm: true,
|
|
50
|
-
maxRetries: 2,
|
|
51
|
-
circuitBreakerThreshold: 3,
|
|
52
|
-
taskDelay: 0,
|
|
53
|
-
retryDelay: 0,
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Initialize backend and state manager
|
|
57
|
-
backend = new JsonTaskAdapter(config.backend)
|
|
58
|
-
stateManager = new StateManager(config)
|
|
59
|
-
|
|
60
|
-
// Create output directory
|
|
61
|
-
fs.mkdirSync(config.outputDir, { recursive: true })
|
|
62
|
-
fs.mkdirSync(path.join(config.outputDir, 'logs'), { recursive: true })
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
afterEach(() => {
|
|
66
|
-
// Cleanup
|
|
67
|
-
stateManager.releaseLock()
|
|
68
|
-
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
test('completes simple task workflow', async () => {
|
|
72
|
-
// Setup: Create tasks
|
|
73
|
-
const tasksData = {
|
|
74
|
-
tasks: [
|
|
75
|
-
{ id: 'TASK-001-01', status: 'pending' as const, priority: 'high' as const },
|
|
76
|
-
{ id: 'TASK-001-02', status: 'pending' as const, priority: 'medium' as const },
|
|
77
|
-
],
|
|
78
|
-
}
|
|
79
|
-
fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
|
|
80
|
-
|
|
81
|
-
// Create PRD files
|
|
82
|
-
fs.writeFileSync(
|
|
83
|
-
path.join(tempDir, 'TASK-001-01.md'),
|
|
84
|
-
'# TASK-001-01: Implement login\n\n## Goal\nAdd login functionality with username and password'
|
|
85
|
-
)
|
|
86
|
-
fs.writeFileSync(
|
|
87
|
-
path.join(tempDir, 'TASK-001-02.md'),
|
|
88
|
-
'# TASK-001-02: Add logout button\n\n## Goal\nAdd a logout button to the navbar'
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
// Verify initial state
|
|
92
|
-
const initialPending = await backend.countPending()
|
|
93
|
-
expect(initialPending).toBe(2)
|
|
94
|
-
|
|
95
|
-
// Execute first task
|
|
96
|
-
const task1 = await backend.findNextTask()
|
|
97
|
-
expect(task1).not.toBeNull()
|
|
98
|
-
expect(task1!.id).toBe('TASK-001-01') // High priority first
|
|
99
|
-
|
|
100
|
-
await backend.markInProgress(task1!.id)
|
|
101
|
-
let loadedTask = await backend.getTask(task1!.id)
|
|
102
|
-
expect(loadedTask!.status).toBe('in-progress')
|
|
103
|
-
|
|
104
|
-
// Complete first task
|
|
105
|
-
await backend.markCompleted(task1!.id)
|
|
106
|
-
loadedTask = await backend.getTask(task1!.id)
|
|
107
|
-
expect(loadedTask!.status).toBe('completed')
|
|
108
|
-
|
|
109
|
-
// Verify only one pending remains
|
|
110
|
-
const midPending = await backend.countPending()
|
|
111
|
-
expect(midPending).toBe(1)
|
|
112
|
-
|
|
113
|
-
// Execute second task
|
|
114
|
-
const task2 = await backend.findNextTask()
|
|
115
|
-
expect(task2).not.toBeNull()
|
|
116
|
-
expect(task2!.id).toBe('TASK-001-02')
|
|
117
|
-
|
|
118
|
-
await backend.markInProgress(task2!.id)
|
|
119
|
-
await backend.markCompleted(task2!.id)
|
|
120
|
-
|
|
121
|
-
// Verify all tasks completed
|
|
122
|
-
const finalPending = await backend.countPending()
|
|
123
|
-
expect(finalPending).toBe(0)
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
test('handles task failure and retry', async () => {
|
|
127
|
-
// Setup: Create task
|
|
128
|
-
const tasksData = {
|
|
129
|
-
tasks: [{ id: 'TASK-002-01', status: 'pending' as const, priority: 'high' as const }],
|
|
130
|
-
}
|
|
131
|
-
fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
|
|
132
|
-
fs.writeFileSync(
|
|
133
|
-
path.join(tempDir, 'TASK-002-01.md'),
|
|
134
|
-
'# TASK-002-01: Complex task\n\n## Goal\nImplement complex feature'
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
const task = await backend.findNextTask()
|
|
138
|
-
expect(task).not.toBeNull()
|
|
139
|
-
|
|
140
|
-
// Mark as in progress
|
|
141
|
-
await backend.markInProgress(task!.id)
|
|
142
|
-
|
|
143
|
-
// Simulate failure
|
|
144
|
-
await backend.markFailed(task!.id, 'Test error: Something went wrong')
|
|
145
|
-
let loadedTask = await backend.getTask(task!.id)
|
|
146
|
-
expect(loadedTask!.status).toBe('failed')
|
|
147
|
-
|
|
148
|
-
// Check error log
|
|
149
|
-
const logFile = path.join(tempDir, `${task!.id}.log`)
|
|
150
|
-
expect(fs.existsSync(logFile)).toBe(true)
|
|
151
|
-
const logContent = fs.readFileSync(logFile, 'utf-8')
|
|
152
|
-
expect(logContent).toContain('Something went wrong')
|
|
153
|
-
|
|
154
|
-
// Reset to pending for retry
|
|
155
|
-
await backend.resetToPending(task!.id)
|
|
156
|
-
loadedTask = await backend.getTask(task!.id)
|
|
157
|
-
expect(loadedTask!.status).toBe('pending')
|
|
158
|
-
|
|
159
|
-
// Second attempt succeeds
|
|
160
|
-
await backend.markInProgress(task!.id)
|
|
161
|
-
await backend.markCompleted(task!.id)
|
|
162
|
-
loadedTask = await backend.getTask(task!.id)
|
|
163
|
-
expect(loadedTask!.status).toBe('completed')
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
test('respects task priority ordering', async () => {
|
|
167
|
-
// Setup: Create tasks with different priorities
|
|
168
|
-
const tasksData = {
|
|
169
|
-
tasks: [
|
|
170
|
-
{ id: 'TASK-003-01', status: 'pending' as const, priority: 'low' as const },
|
|
171
|
-
{ id: 'TASK-003-02', status: 'pending' as const, priority: 'high' as const },
|
|
172
|
-
{ id: 'TASK-003-03', status: 'pending' as const, priority: 'medium' as const },
|
|
173
|
-
],
|
|
174
|
-
}
|
|
175
|
-
fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
|
|
176
|
-
|
|
177
|
-
// Create PRD files
|
|
178
|
-
for (const task of tasksData.tasks) {
|
|
179
|
-
fs.writeFileSync(path.join(tempDir, `${task.id}.md`), `# ${task.id}\n\nTask content`)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Get tasks in priority order
|
|
183
|
-
const tasks = await backend.listPendingTasks()
|
|
184
|
-
expect(tasks.length).toBe(3)
|
|
185
|
-
expect(tasks[0].id).toBe('TASK-003-02') // High priority first
|
|
186
|
-
expect(tasks[1].id).toBe('TASK-003-03') // Medium priority second
|
|
187
|
-
expect(tasks[2].id).toBe('TASK-003-01') // Low priority last
|
|
188
|
-
|
|
189
|
-
// Find next task should return highest priority
|
|
190
|
-
const nextTask = await backend.findNextTask()
|
|
191
|
-
expect(nextTask!.id).toBe('TASK-003-02')
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
test('handles task dependencies', async () => {
|
|
195
|
-
// Setup: Create tasks with dependencies
|
|
196
|
-
const tasksData = {
|
|
197
|
-
tasks: [
|
|
198
|
-
{ id: 'TASK-004-01', status: 'pending' as const, priority: 'high' as const },
|
|
199
|
-
{
|
|
200
|
-
id: 'TASK-004-02',
|
|
201
|
-
status: 'pending' as const,
|
|
202
|
-
priority: 'high' as const,
|
|
203
|
-
dependsOn: ['TASK-004-01'],
|
|
204
|
-
},
|
|
205
|
-
],
|
|
206
|
-
}
|
|
207
|
-
fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
|
|
208
|
-
|
|
209
|
-
// Create PRD files
|
|
210
|
-
for (const task of tasksData.tasks) {
|
|
211
|
-
fs.writeFileSync(path.join(tempDir, `${task.id}.md`), `# ${task.id}\n\nTask content`)
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Task 2 should be blocked initially
|
|
215
|
-
const pendingTasks = await backend.listPendingTasks()
|
|
216
|
-
expect(pendingTasks.length).toBe(1)
|
|
217
|
-
expect(pendingTasks[0].id).toBe('TASK-004-01') // Only unblocked task
|
|
218
|
-
|
|
219
|
-
// Check dependencies
|
|
220
|
-
const depsMet = await backend.areDependenciesMet('TASK-004-02')
|
|
221
|
-
expect(depsMet).toBe(false)
|
|
222
|
-
|
|
223
|
-
// Complete Task 1
|
|
224
|
-
await backend.markInProgress('TASK-004-01')
|
|
225
|
-
await backend.markCompleted('TASK-004-01')
|
|
226
|
-
|
|
227
|
-
// Task 2 should now be unblocked
|
|
228
|
-
const newPendingTasks = await backend.listPendingTasks()
|
|
229
|
-
expect(newPendingTasks.length).toBe(1)
|
|
230
|
-
expect(newPendingTasks[0].id).toBe('TASK-004-02')
|
|
231
|
-
|
|
232
|
-
const depsMetNow = await backend.areDependenciesMet('TASK-004-02')
|
|
233
|
-
expect(depsMetNow).toBe(true)
|
|
234
|
-
})
|
|
235
|
-
|
|
236
|
-
test('creates and manages sub-tasks', async () => {
|
|
237
|
-
// Setup: Create parent task
|
|
238
|
-
const tasksData = {
|
|
239
|
-
tasks: [{ id: 'TASK-005-01', status: 'pending' as const, priority: 'high' as const }],
|
|
240
|
-
}
|
|
241
|
-
fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
|
|
242
|
-
fs.writeFileSync(
|
|
243
|
-
path.join(tempDir, 'TASK-005-01.md'),
|
|
244
|
-
'# TASK-005-01: Parent task\n\nParent task content'
|
|
245
|
-
)
|
|
246
|
-
|
|
247
|
-
// Create sub-task
|
|
248
|
-
const subtask = await backend.createSubTask('TASK-005-01', {
|
|
249
|
-
title: 'TASK-005-01a: Sub-task 1',
|
|
250
|
-
description: 'First sub-task',
|
|
251
|
-
priority: 'medium',
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
expect(subtask.id).toBe('TASK-005-01a')
|
|
255
|
-
expect(subtask.parentId).toBe('TASK-005-01')
|
|
256
|
-
expect(subtask.status).toBe('pending')
|
|
257
|
-
|
|
258
|
-
// Verify PRD file was created
|
|
259
|
-
const prdPath = path.join(tempDir, 'TASK-005-01a.md')
|
|
260
|
-
expect(fs.existsSync(prdPath)).toBe(true)
|
|
261
|
-
|
|
262
|
-
// Get sub-tasks
|
|
263
|
-
const subtasks = await backend.getSubTasks('TASK-005-01')
|
|
264
|
-
expect(subtasks.length).toBe(1)
|
|
265
|
-
expect(subtasks[0].id).toBe('TASK-005-01a')
|
|
266
|
-
|
|
267
|
-
// List pending can filter by parent
|
|
268
|
-
const pendingSubtasks = await backend.listPendingTasks({ parentId: 'TASK-005-01' })
|
|
269
|
-
expect(pendingSubtasks.length).toBe(1)
|
|
270
|
-
expect(pendingSubtasks[0].id).toBe('TASK-005-01a')
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
test('filters tasks by feature', async () => {
|
|
274
|
-
// Setup: Create tasks with features
|
|
275
|
-
const tasksData = {
|
|
276
|
-
tasks: [
|
|
277
|
-
{ id: 'TASK-006-01', status: 'pending' as const, priority: 'high' as const, feature: 'auth' },
|
|
278
|
-
{ id: 'TASK-006-02', status: 'pending' as const, priority: 'high' as const, feature: 'billing' },
|
|
279
|
-
{ id: 'TASK-006-03', status: 'pending' as const, priority: 'high' as const, feature: 'auth' },
|
|
280
|
-
],
|
|
281
|
-
features: {
|
|
282
|
-
auth: { name: 'Authentication' },
|
|
283
|
-
billing: { name: 'Billing System' },
|
|
284
|
-
},
|
|
285
|
-
}
|
|
286
|
-
fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
|
|
287
|
-
|
|
288
|
-
// Create PRD files
|
|
289
|
-
for (const task of tasksData.tasks) {
|
|
290
|
-
fs.writeFileSync(path.join(tempDir, `${task.id}.md`), `# ${task.id}\n\nTask content`)
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Filter by auth feature
|
|
294
|
-
const authTasks = await backend.listPendingTasks({ feature: 'auth' })
|
|
295
|
-
expect(authTasks.length).toBe(2)
|
|
296
|
-
expect(authTasks.every(t => t.feature === 'auth')).toBe(true)
|
|
297
|
-
|
|
298
|
-
// Filter by billing feature
|
|
299
|
-
const billingTasks = await backend.listPendingTasks({ feature: 'billing' })
|
|
300
|
-
expect(billingTasks.length).toBe(1)
|
|
301
|
-
expect(billingTasks[0].feature).toBe('billing')
|
|
302
|
-
|
|
303
|
-
// Count by feature
|
|
304
|
-
const authCount = await backend.countPending({ feature: 'auth' })
|
|
305
|
-
expect(authCount).toBe(2)
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
test('state manager handles locking', async () => {
|
|
309
|
-
// Acquire lock
|
|
310
|
-
const acquired = stateManager.acquireLock()
|
|
311
|
-
expect(acquired).toBe(true)
|
|
312
|
-
|
|
313
|
-
// Lock file should exist
|
|
314
|
-
const lockFile = stateManager.getLockFile()
|
|
315
|
-
expect(fs.existsSync(lockFile)).toBe(true)
|
|
316
|
-
|
|
317
|
-
// Second acquire should fail
|
|
318
|
-
const stateManager2 = new StateManager(config)
|
|
319
|
-
const acquired2 = stateManager2.acquireLock()
|
|
320
|
-
expect(acquired2).toBe(false)
|
|
321
|
-
|
|
322
|
-
// Release lock
|
|
323
|
-
stateManager.releaseLock()
|
|
324
|
-
expect(fs.existsSync(lockFile)).toBe(false)
|
|
325
|
-
|
|
326
|
-
// Now second manager can acquire
|
|
327
|
-
const acquired3 = stateManager2.acquireLock()
|
|
328
|
-
expect(acquired3).toBe(true)
|
|
329
|
-
stateManager2.releaseLock()
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
test('state manager saves and loads state', async () => {
|
|
333
|
-
// Save state
|
|
334
|
-
stateManager.saveState(42, 5)
|
|
335
|
-
|
|
336
|
-
// State file should exist
|
|
337
|
-
const stateFile = stateManager.getStateFile()
|
|
338
|
-
expect(fs.existsSync(stateFile)).toBe(true)
|
|
339
|
-
|
|
340
|
-
// Load state
|
|
341
|
-
const loadedState = stateManager.loadState()
|
|
342
|
-
expect(loadedState).not.toBeNull()
|
|
343
|
-
expect(loadedState!.lastIssue).toBe(42)
|
|
344
|
-
expect(loadedState!.lastIteration).toBe(5)
|
|
345
|
-
|
|
346
|
-
// Clear state
|
|
347
|
-
stateManager.clearState()
|
|
348
|
-
expect(fs.existsSync(stateFile)).toBe(false)
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
test('adds comments to task log', async () => {
|
|
352
|
-
// Setup: Create task
|
|
353
|
-
const tasksData = {
|
|
354
|
-
tasks: [{ id: 'TASK-007-01', status: 'pending' as const, priority: 'high' as const }],
|
|
355
|
-
}
|
|
356
|
-
fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
|
|
357
|
-
fs.writeFileSync(path.join(tempDir, 'TASK-007-01.md'), '# TASK-007-01\n\nTask content')
|
|
358
|
-
|
|
359
|
-
// Add comments
|
|
360
|
-
await backend.addComment('TASK-007-01', 'Starting implementation')
|
|
361
|
-
await backend.addComment('TASK-007-01', 'Tests passing')
|
|
362
|
-
|
|
363
|
-
// Check log file
|
|
364
|
-
const logFile = path.join(tempDir, 'TASK-007-01.log')
|
|
365
|
-
expect(fs.existsSync(logFile)).toBe(true)
|
|
366
|
-
const logContent = fs.readFileSync(logFile, 'utf-8')
|
|
367
|
-
expect(logContent).toContain('Starting implementation')
|
|
368
|
-
expect(logContent).toContain('Tests passing')
|
|
369
|
-
})
|
|
370
|
-
|
|
371
|
-
test('backend ping checks health', async () => {
|
|
372
|
-
// With valid tasks file
|
|
373
|
-
const tasksData = { tasks: [] }
|
|
374
|
-
fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
|
|
375
|
-
|
|
376
|
-
const result = await backend.ping()
|
|
377
|
-
expect(result.ok).toBe(true)
|
|
378
|
-
expect(result.latencyMs).toBeGreaterThanOrEqual(0)
|
|
379
|
-
|
|
380
|
-
// With missing file
|
|
381
|
-
fs.unlinkSync(tasksFile)
|
|
382
|
-
const result2 = await backend.ping()
|
|
383
|
-
expect(result2.ok).toBe(false)
|
|
384
|
-
expect(result2.error).toContain('not found')
|
|
385
|
-
})
|
|
386
|
-
|
|
387
|
-
test('complete workflow simulation', async () => {
|
|
388
|
-
// Setup: Create realistic task set
|
|
389
|
-
const tasksData = {
|
|
390
|
-
tasks: [
|
|
391
|
-
{ id: 'TASK-100-01', status: 'pending' as const, priority: 'high' as const, feature: 'auth' },
|
|
392
|
-
{ id: 'TASK-100-02', status: 'pending' as const, priority: 'medium' as const, feature: 'auth' },
|
|
393
|
-
{ id: 'TASK-100-03', status: 'pending' as const, priority: 'low' as const },
|
|
394
|
-
],
|
|
395
|
-
features: {
|
|
396
|
-
auth: { name: 'Authentication System' },
|
|
397
|
-
},
|
|
398
|
-
}
|
|
399
|
-
fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
|
|
400
|
-
|
|
401
|
-
// Create PRD files
|
|
402
|
-
const prds = [
|
|
403
|
-
{ id: 'TASK-100-01', title: 'Implement login API', goal: 'Create login endpoint' },
|
|
404
|
-
{ id: 'TASK-100-02', title: 'Add JWT tokens', goal: 'Implement JWT authentication' },
|
|
405
|
-
{ id: 'TASK-100-03', title: 'Update README', goal: 'Document the changes' },
|
|
406
|
-
]
|
|
407
|
-
|
|
408
|
-
for (const prd of prds) {
|
|
409
|
-
fs.writeFileSync(
|
|
410
|
-
path.join(tempDir, `${prd.id}.md`),
|
|
411
|
-
`# ${prd.id}: ${prd.title}\n\n## Goal\n${prd.goal}\n\n## Requirements\n- Requirement 1\n- Requirement 2`
|
|
412
|
-
)
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// Simulate workflow loop
|
|
416
|
-
let iteration = 0
|
|
417
|
-
const maxIterations = 10
|
|
418
|
-
let tasksCompleted = 0
|
|
419
|
-
|
|
420
|
-
while (iteration < maxIterations) {
|
|
421
|
-
iteration++
|
|
422
|
-
|
|
423
|
-
// Find next task
|
|
424
|
-
const task = await backend.findNextTask()
|
|
425
|
-
if (!task) break
|
|
426
|
-
|
|
427
|
-
// Mark in progress
|
|
428
|
-
await backend.markInProgress(task.id)
|
|
429
|
-
|
|
430
|
-
// Simulate execution (would normally call CLI)
|
|
431
|
-
await backend.addComment(task.id, `Iteration ${iteration}: Executing task`)
|
|
432
|
-
|
|
433
|
-
// Simulate success
|
|
434
|
-
await backend.markCompleted(task.id, `Completed in iteration ${iteration}`)
|
|
435
|
-
tasksCompleted++
|
|
436
|
-
|
|
437
|
-
// Small delay to simulate work
|
|
438
|
-
await new Promise(r => setTimeout(r, 10))
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// Verify all tasks completed
|
|
442
|
-
expect(tasksCompleted).toBe(3)
|
|
443
|
-
const finalPending = await backend.countPending()
|
|
444
|
-
expect(finalPending).toBe(0)
|
|
445
|
-
|
|
446
|
-
// Verify all tasks are marked completed
|
|
447
|
-
for (const task of tasksData.tasks) {
|
|
448
|
-
const loadedTask = await backend.getTask(task.id)
|
|
449
|
-
expect(loadedTask!.status).toBe('completed')
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// Verify log files exist
|
|
453
|
-
for (const task of tasksData.tasks) {
|
|
454
|
-
const logFile = path.join(tempDir, `${task.id}.log`)
|
|
455
|
-
expect(fs.existsSync(logFile)).toBe(true)
|
|
456
|
-
}
|
|
457
|
-
})
|
|
458
|
-
})
|