loopwork 0.3.0
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/CHANGELOG.md +52 -0
- package/README.md +528 -0
- package/bin/loopwork +0 -0
- package/examples/README.md +70 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-001.md +22 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-002.md +23 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-003.md +37 -0
- package/examples/basic-json-backend/.specs/tasks/tasks.json +19 -0
- package/examples/basic-json-backend/README.md +32 -0
- package/examples/basic-json-backend/TESTING.md +184 -0
- package/examples/basic-json-backend/hello.test.ts +9 -0
- package/examples/basic-json-backend/hello.ts +3 -0
- package/examples/basic-json-backend/loopwork.config.js +35 -0
- package/examples/basic-json-backend/math.test.ts +29 -0
- package/examples/basic-json-backend/math.ts +3 -0
- package/examples/basic-json-backend/package.json +15 -0
- package/examples/basic-json-backend/quick-start.sh +80 -0
- package/loopwork.config.ts +164 -0
- package/package.json +26 -0
- package/src/backends/github.ts +426 -0
- package/src/backends/index.ts +86 -0
- package/src/backends/json.ts +598 -0
- package/src/backends/plugin.ts +317 -0
- package/src/backends/types.ts +19 -0
- package/src/commands/init.ts +100 -0
- package/src/commands/run.ts +365 -0
- package/src/contracts/backend.ts +127 -0
- package/src/contracts/config.ts +129 -0
- package/src/contracts/index.ts +43 -0
- package/src/contracts/plugin.ts +82 -0
- package/src/contracts/task.ts +78 -0
- package/src/core/cli.ts +275 -0
- package/src/core/config.ts +165 -0
- package/src/core/state.ts +154 -0
- package/src/core/utils.ts +125 -0
- package/src/dashboard/cli.ts +449 -0
- package/src/dashboard/index.ts +6 -0
- package/src/dashboard/kanban.tsx +226 -0
- package/src/dashboard/tui.tsx +372 -0
- package/src/index.ts +19 -0
- package/src/mcp/server.ts +451 -0
- package/src/monitor/index.ts +420 -0
- package/src/plugins/asana.ts +192 -0
- package/src/plugins/cost-tracking.ts +402 -0
- package/src/plugins/discord.ts +269 -0
- package/src/plugins/everhour.ts +335 -0
- package/src/plugins/index.ts +253 -0
- package/src/plugins/telegram/bot.ts +517 -0
- package/src/plugins/telegram/index.ts +6 -0
- package/src/plugins/telegram/notifications.ts +198 -0
- package/src/plugins/todoist.ts +261 -0
- package/test/backends.test.ts +929 -0
- package/test/cli.test.ts +145 -0
- package/test/config.test.ts +90 -0
- package/test/e2e.test.ts +458 -0
- package/test/github-tasks.test.ts +191 -0
- package/test/loopwork-config-types.test.ts +288 -0
- package/test/monitor.test.ts +123 -0
- package/test/plugins.test.ts +1175 -0
- package/test/state.test.ts +295 -0
- package/test/utils.test.ts +60 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import os from 'os'
|
|
5
|
+
import { StateManager } from '../src/core/state'
|
|
6
|
+
import type { Config } from '../src/core/config'
|
|
7
|
+
|
|
8
|
+
describe('StateManager', () => {
|
|
9
|
+
let tempDir: string
|
|
10
|
+
let config: Config
|
|
11
|
+
let stateManager: StateManager
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loopwork-test-'))
|
|
15
|
+
config = {
|
|
16
|
+
projectRoot: tempDir,
|
|
17
|
+
outputDir: path.join(tempDir, 'output'),
|
|
18
|
+
sessionId: 'test-session-123',
|
|
19
|
+
maxIterations: 50,
|
|
20
|
+
timeout: 600,
|
|
21
|
+
cli: 'opencode',
|
|
22
|
+
autoConfirm: false,
|
|
23
|
+
dryRun: false,
|
|
24
|
+
debug: false,
|
|
25
|
+
} as Config
|
|
26
|
+
stateManager = new StateManager(config)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
// Clean up temp directory
|
|
31
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('acquireLock', () => {
|
|
35
|
+
test('acquires lock successfully when no lock exists', () => {
|
|
36
|
+
const result = stateManager.acquireLock()
|
|
37
|
+
expect(result).toBe(true)
|
|
38
|
+
|
|
39
|
+
const lockDir = path.join(tempDir, '.loopwork.lock')
|
|
40
|
+
expect(fs.existsSync(lockDir)).toBe(true)
|
|
41
|
+
|
|
42
|
+
const pidFile = path.join(lockDir, 'pid')
|
|
43
|
+
expect(fs.existsSync(pidFile)).toBe(true)
|
|
44
|
+
expect(fs.readFileSync(pidFile, 'utf-8')).toBe(process.pid.toString())
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('fails to acquire lock when another process holds it', () => {
|
|
48
|
+
// Create a lock with current process PID (simulating another instance)
|
|
49
|
+
const lockDir = path.join(tempDir, '.loopwork.lock')
|
|
50
|
+
fs.mkdirSync(lockDir)
|
|
51
|
+
fs.writeFileSync(path.join(lockDir, 'pid'), process.pid.toString())
|
|
52
|
+
|
|
53
|
+
const result = stateManager.acquireLock()
|
|
54
|
+
expect(result).toBe(false)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('removes stale lock and acquires new one', () => {
|
|
58
|
+
// Create a lock with a non-existent PID
|
|
59
|
+
const lockDir = path.join(tempDir, '.loopwork.lock')
|
|
60
|
+
fs.mkdirSync(lockDir)
|
|
61
|
+
fs.writeFileSync(path.join(lockDir, 'pid'), '999999999') // Non-existent PID
|
|
62
|
+
|
|
63
|
+
const result = stateManager.acquireLock()
|
|
64
|
+
expect(result).toBe(true)
|
|
65
|
+
|
|
66
|
+
// Verify new lock has current PID
|
|
67
|
+
const pidFile = path.join(lockDir, 'pid')
|
|
68
|
+
expect(fs.readFileSync(pidFile, 'utf-8')).toBe(process.pid.toString())
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('fails after max retry attempts', () => {
|
|
72
|
+
const result = stateManager.acquireLock(4) // Already exceeded max retries
|
|
73
|
+
expect(result).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('releaseLock', () => {
|
|
78
|
+
test('releases existing lock', () => {
|
|
79
|
+
stateManager.acquireLock()
|
|
80
|
+
const lockDir = path.join(tempDir, '.loopwork.lock')
|
|
81
|
+
expect(fs.existsSync(lockDir)).toBe(true)
|
|
82
|
+
|
|
83
|
+
stateManager.releaseLock()
|
|
84
|
+
expect(fs.existsSync(lockDir)).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('does nothing when no lock exists', () => {
|
|
88
|
+
// Should not throw
|
|
89
|
+
stateManager.releaseLock()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('saveState', () => {
|
|
94
|
+
test('saves state to file', () => {
|
|
95
|
+
stateManager.saveState(123, 5)
|
|
96
|
+
|
|
97
|
+
const stateFile = path.join(tempDir, '.loopwork-state')
|
|
98
|
+
expect(fs.existsSync(stateFile)).toBe(true)
|
|
99
|
+
|
|
100
|
+
const content = fs.readFileSync(stateFile, 'utf-8')
|
|
101
|
+
expect(content).toContain('LAST_ISSUE=123')
|
|
102
|
+
expect(content).toContain('LAST_ITERATION=5')
|
|
103
|
+
expect(content).toContain('SESSION_ID=test-session-123')
|
|
104
|
+
expect(content).toContain('SAVED_AT=')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('overwrites previous state', () => {
|
|
108
|
+
stateManager.saveState(100, 1)
|
|
109
|
+
stateManager.saveState(200, 10)
|
|
110
|
+
|
|
111
|
+
const stateFile = path.join(tempDir, '.loopwork-state')
|
|
112
|
+
const content = fs.readFileSync(stateFile, 'utf-8')
|
|
113
|
+
expect(content).toContain('LAST_ISSUE=200')
|
|
114
|
+
expect(content).toContain('LAST_ITERATION=10')
|
|
115
|
+
expect(content).not.toContain('LAST_ISSUE=100')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('loadState', () => {
|
|
120
|
+
test('returns null when no state file exists', () => {
|
|
121
|
+
const result = stateManager.loadState()
|
|
122
|
+
expect(result).toBeNull()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('loads state from file', () => {
|
|
126
|
+
stateManager.saveState(456, 20)
|
|
127
|
+
|
|
128
|
+
const result = stateManager.loadState()
|
|
129
|
+
expect(result).not.toBeNull()
|
|
130
|
+
expect(result!.lastIssue).toBe(456)
|
|
131
|
+
expect(result!.lastIteration).toBe(20)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('returns null when state file is malformed', () => {
|
|
135
|
+
const stateFile = path.join(tempDir, '.loopwork-state')
|
|
136
|
+
fs.writeFileSync(stateFile, 'invalid content without equals sign')
|
|
137
|
+
|
|
138
|
+
const result = stateManager.loadState()
|
|
139
|
+
expect(result).toBeNull()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('handles missing optional fields', () => {
|
|
143
|
+
const stateFile = path.join(tempDir, '.loopwork-state')
|
|
144
|
+
fs.writeFileSync(stateFile, 'LAST_ISSUE=789')
|
|
145
|
+
|
|
146
|
+
const result = stateManager.loadState()
|
|
147
|
+
expect(result).not.toBeNull()
|
|
148
|
+
expect(result!.lastIssue).toBe(789)
|
|
149
|
+
expect(result!.lastIteration).toBe(0)
|
|
150
|
+
expect(result!.lastOutputDir).toBe('')
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('clearState', () => {
|
|
155
|
+
test('removes state file', () => {
|
|
156
|
+
stateManager.saveState(123, 5)
|
|
157
|
+
const stateFile = path.join(tempDir, '.loopwork-state')
|
|
158
|
+
expect(fs.existsSync(stateFile)).toBe(true)
|
|
159
|
+
|
|
160
|
+
stateManager.clearState()
|
|
161
|
+
expect(fs.existsSync(stateFile)).toBe(false)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('does nothing when no state file exists', () => {
|
|
165
|
+
// Should not throw
|
|
166
|
+
stateManager.clearState()
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('StateManager with namespace', () => {
|
|
172
|
+
let tempDir: string
|
|
173
|
+
|
|
174
|
+
beforeEach(() => {
|
|
175
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loopwork-ns-test-'))
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
afterEach(() => {
|
|
179
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('uses default namespace by default', () => {
|
|
183
|
+
const config = {
|
|
184
|
+
projectRoot: tempDir,
|
|
185
|
+
outputDir: path.join(tempDir, 'output'),
|
|
186
|
+
sessionId: 'test-session',
|
|
187
|
+
namespace: 'default',
|
|
188
|
+
} as Config
|
|
189
|
+
|
|
190
|
+
const stateManager = new StateManager(config)
|
|
191
|
+
expect(stateManager.getNamespace()).toBe('default')
|
|
192
|
+
expect(stateManager.getStateFile()).toBe(path.join(tempDir, '.loopwork-state'))
|
|
193
|
+
expect(stateManager.getLockFile()).toBe(path.join(tempDir, '.loopwork.lock'))
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('uses custom namespace in file paths', () => {
|
|
197
|
+
const config = {
|
|
198
|
+
projectRoot: tempDir,
|
|
199
|
+
outputDir: path.join(tempDir, 'output'),
|
|
200
|
+
sessionId: 'test-session',
|
|
201
|
+
namespace: 'feature-a',
|
|
202
|
+
} as Config
|
|
203
|
+
|
|
204
|
+
const stateManager = new StateManager(config)
|
|
205
|
+
expect(stateManager.getNamespace()).toBe('feature-a')
|
|
206
|
+
expect(stateManager.getStateFile()).toBe(path.join(tempDir, '.loopwork-state-feature-a'))
|
|
207
|
+
expect(stateManager.getLockFile()).toBe(path.join(tempDir, '.loopwork-feature-a.lock'))
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('multiple namespaces can coexist', () => {
|
|
211
|
+
const configA = {
|
|
212
|
+
projectRoot: tempDir,
|
|
213
|
+
outputDir: path.join(tempDir, 'output-a'),
|
|
214
|
+
sessionId: 'session-a',
|
|
215
|
+
namespace: 'feature-a',
|
|
216
|
+
} as Config
|
|
217
|
+
|
|
218
|
+
const configB = {
|
|
219
|
+
projectRoot: tempDir,
|
|
220
|
+
outputDir: path.join(tempDir, 'output-b'),
|
|
221
|
+
sessionId: 'session-b',
|
|
222
|
+
namespace: 'feature-b',
|
|
223
|
+
} as Config
|
|
224
|
+
|
|
225
|
+
const managerA = new StateManager(configA)
|
|
226
|
+
const managerB = new StateManager(configB)
|
|
227
|
+
|
|
228
|
+
// Both should acquire locks independently
|
|
229
|
+
expect(managerA.acquireLock()).toBe(true)
|
|
230
|
+
expect(managerB.acquireLock()).toBe(true)
|
|
231
|
+
|
|
232
|
+
// Save state independently
|
|
233
|
+
managerA.saveState(100, 1)
|
|
234
|
+
managerB.saveState(200, 2)
|
|
235
|
+
|
|
236
|
+
// Load state independently
|
|
237
|
+
const stateA = managerA.loadState()
|
|
238
|
+
const stateB = managerB.loadState()
|
|
239
|
+
|
|
240
|
+
expect(stateA!.lastIssue).toBe(100)
|
|
241
|
+
expect(stateB!.lastIssue).toBe(200)
|
|
242
|
+
|
|
243
|
+
// Clean up
|
|
244
|
+
managerA.releaseLock()
|
|
245
|
+
managerB.releaseLock()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('saves namespace in state file', () => {
|
|
249
|
+
const config = {
|
|
250
|
+
projectRoot: tempDir,
|
|
251
|
+
outputDir: path.join(tempDir, 'output'),
|
|
252
|
+
sessionId: 'test-session',
|
|
253
|
+
namespace: 'my-namespace',
|
|
254
|
+
} as Config
|
|
255
|
+
|
|
256
|
+
const stateManager = new StateManager(config)
|
|
257
|
+
stateManager.saveState(123, 5)
|
|
258
|
+
|
|
259
|
+
const stateFile = stateManager.getStateFile()
|
|
260
|
+
const content = fs.readFileSync(stateFile, 'utf-8')
|
|
261
|
+
expect(content).toContain('NAMESPACE=my-namespace')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('namespace does not affect lock acquisition for different namespaces', () => {
|
|
265
|
+
const defaultConfig = {
|
|
266
|
+
projectRoot: tempDir,
|
|
267
|
+
outputDir: path.join(tempDir, 'output'),
|
|
268
|
+
sessionId: 'default-session',
|
|
269
|
+
namespace: 'default',
|
|
270
|
+
} as Config
|
|
271
|
+
|
|
272
|
+
const customConfig = {
|
|
273
|
+
projectRoot: tempDir,
|
|
274
|
+
outputDir: path.join(tempDir, 'output-custom'),
|
|
275
|
+
sessionId: 'custom-session',
|
|
276
|
+
namespace: 'custom',
|
|
277
|
+
} as Config
|
|
278
|
+
|
|
279
|
+
const defaultManager = new StateManager(defaultConfig)
|
|
280
|
+
const customManager = new StateManager(customConfig)
|
|
281
|
+
|
|
282
|
+
// Acquire lock for default namespace
|
|
283
|
+
expect(defaultManager.acquireLock()).toBe(true)
|
|
284
|
+
|
|
285
|
+
// Custom namespace should still be able to acquire its own lock
|
|
286
|
+
expect(customManager.acquireLock()).toBe(true)
|
|
287
|
+
|
|
288
|
+
// Both locks should exist
|
|
289
|
+
expect(fs.existsSync(defaultManager.getLockFile())).toBe(true)
|
|
290
|
+
expect(fs.existsSync(customManager.getLockFile())).toBe(true)
|
|
291
|
+
|
|
292
|
+
defaultManager.releaseLock()
|
|
293
|
+
customManager.releaseLock()
|
|
294
|
+
})
|
|
295
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach, spyOn } from 'bun:test'
|
|
2
|
+
import { logger } from '../src/core/utils'
|
|
3
|
+
|
|
4
|
+
describe('utils', () => {
|
|
5
|
+
describe('logger', () => {
|
|
6
|
+
let consoleSpy: ReturnType<typeof spyOn>
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
consoleSpy.mockRestore()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('info logs message', () => {
|
|
17
|
+
logger.info('test message')
|
|
18
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
19
|
+
const args = consoleSpy.mock.calls[0]
|
|
20
|
+
expect(args.some((a: string) => a.includes('[INFO]') || a.includes('INFO'))).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('success logs message', () => {
|
|
24
|
+
logger.success('test message')
|
|
25
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
26
|
+
const args = consoleSpy.mock.calls[0]
|
|
27
|
+
expect(args.some((a: string) => a.includes('[SUCCESS]') || a.includes('SUCCESS'))).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('warn logs message', () => {
|
|
31
|
+
logger.warn('test message')
|
|
32
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
33
|
+
const args = consoleSpy.mock.calls[0]
|
|
34
|
+
expect(args.some((a: string) => a.includes('[WARN]') || a.includes('WARN'))).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('error logs message', () => {
|
|
38
|
+
logger.error('test message')
|
|
39
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
40
|
+
const args = consoleSpy.mock.calls[0]
|
|
41
|
+
expect(args.some((a: string) => a.includes('[ERROR]') || a.includes('ERROR'))).toBe(true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('debug logs only when LOOPWORK_DEBUG is true', () => {
|
|
45
|
+
const originalDebug = process.env.LOOPWORK_DEBUG
|
|
46
|
+
|
|
47
|
+
// Should not log when LOOPWORK_DEBUG is not set
|
|
48
|
+
process.env.LOOPWORK_DEBUG = ''
|
|
49
|
+
logger.debug('test message')
|
|
50
|
+
expect(consoleSpy).not.toHaveBeenCalled()
|
|
51
|
+
|
|
52
|
+
// Should log when LOOPWORK_DEBUG is true
|
|
53
|
+
process.env.LOOPWORK_DEBUG = 'true'
|
|
54
|
+
logger.debug('test message')
|
|
55
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
56
|
+
|
|
57
|
+
process.env.LOOPWORK_DEBUG = originalDebug
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["ESNext"],
|
|
4
|
+
"module": "esnext",
|
|
5
|
+
"target": "esnext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"composite": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"downlevelIteration": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"jsx": "react-jsx",
|
|
15
|
+
"allowSyntheticDefaultImports": true,
|
|
16
|
+
"forceConsistentCasingInFileNames": true,
|
|
17
|
+
"allowJs": true,
|
|
18
|
+
"types": ["bun-types"]
|
|
19
|
+
}
|
|
20
|
+
}
|