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,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopwork Dashboard
|
|
3
|
+
*
|
|
4
|
+
* Live TUI dashboard showing loop progress using ink (React for CLI).
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import { createDashboardPlugin, renderDashboard } from './dashboard'
|
|
8
|
+
*
|
|
9
|
+
* // As a plugin
|
|
10
|
+
* withPlugin(createDashboardPlugin())
|
|
11
|
+
*
|
|
12
|
+
* // Standalone
|
|
13
|
+
* renderDashboard()
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { useState, useEffect } from 'react'
|
|
17
|
+
import { render, Box, Text, useApp, useInput } from 'ink'
|
|
18
|
+
import type { LoopworkPlugin, PluginTask } from './loopwork-config-types'
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Types
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
interface TaskEvent {
|
|
25
|
+
id: string
|
|
26
|
+
title: string
|
|
27
|
+
status: 'started' | 'completed' | 'failed'
|
|
28
|
+
duration?: number
|
|
29
|
+
error?: string
|
|
30
|
+
timestamp: Date
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DashboardState {
|
|
34
|
+
namespace: string
|
|
35
|
+
currentTask: PluginTask | null
|
|
36
|
+
taskStartTime: number | null
|
|
37
|
+
completed: number
|
|
38
|
+
failed: number
|
|
39
|
+
total: number
|
|
40
|
+
loopStartTime: number | null
|
|
41
|
+
recentEvents: TaskEvent[]
|
|
42
|
+
isRunning: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Global state for plugin communication
|
|
46
|
+
let dashboardState: DashboardState = {
|
|
47
|
+
namespace: 'default',
|
|
48
|
+
currentTask: null,
|
|
49
|
+
taskStartTime: null,
|
|
50
|
+
completed: 0,
|
|
51
|
+
failed: 0,
|
|
52
|
+
total: 0,
|
|
53
|
+
loopStartTime: null,
|
|
54
|
+
recentEvents: [],
|
|
55
|
+
isRunning: false,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let stateListeners: Array<(state: DashboardState) => void> = []
|
|
59
|
+
|
|
60
|
+
function updateState(updates: Partial<DashboardState>) {
|
|
61
|
+
dashboardState = { ...dashboardState, ...updates }
|
|
62
|
+
stateListeners.forEach((fn) => fn(dashboardState))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function subscribe(fn: (state: DashboardState) => void) {
|
|
66
|
+
stateListeners.push(fn)
|
|
67
|
+
return () => {
|
|
68
|
+
stateListeners = stateListeners.filter((f) => f !== fn)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ============================================================================
|
|
73
|
+
// Components
|
|
74
|
+
// ============================================================================
|
|
75
|
+
|
|
76
|
+
function Header({ namespace }: { namespace: string }) {
|
|
77
|
+
return (
|
|
78
|
+
<Box borderStyle="round" borderColor="cyan" paddingX={1}>
|
|
79
|
+
<Text bold color="cyan">
|
|
80
|
+
{'🤖 Loopwork Dashboard'}
|
|
81
|
+
</Text>
|
|
82
|
+
<Text color="gray"> | </Text>
|
|
83
|
+
<Text color="yellow">namespace: {namespace}</Text>
|
|
84
|
+
</Box>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ProgressBar({ percent, width = 30 }: { percent: number; width?: number }) {
|
|
89
|
+
const filled = Math.round((percent / 100) * width)
|
|
90
|
+
const empty = width - filled
|
|
91
|
+
const bar = '█'.repeat(filled) + '░'.repeat(empty)
|
|
92
|
+
const color = percent === 100 ? 'green' : percent > 50 ? 'yellow' : 'cyan'
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Text color={color}>
|
|
96
|
+
[{bar}] {percent.toFixed(0)}%
|
|
97
|
+
</Text>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function CurrentTask({ task, startTime }: { task: PluginTask | null; startTime: number | null }) {
|
|
102
|
+
const [elapsed, setElapsed] = useState(0)
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!startTime) {
|
|
106
|
+
setElapsed(0)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const interval = setInterval(() => {
|
|
111
|
+
setElapsed(Math.floor((Date.now() - startTime) / 1000))
|
|
112
|
+
}, 1000)
|
|
113
|
+
|
|
114
|
+
return () => clearInterval(interval)
|
|
115
|
+
}, [startTime])
|
|
116
|
+
|
|
117
|
+
if (!task) {
|
|
118
|
+
return (
|
|
119
|
+
<Box marginY={1}>
|
|
120
|
+
<Text color="gray">⏳ Waiting for next task...</Text>
|
|
121
|
+
</Box>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<Box flexDirection="column" marginY={1}>
|
|
127
|
+
<Box>
|
|
128
|
+
<Text color="cyan">{'▶ '}</Text>
|
|
129
|
+
<Text bold>{task.id}</Text>
|
|
130
|
+
<Text color="gray">: {task.title.slice(0, 50)}</Text>
|
|
131
|
+
</Box>
|
|
132
|
+
<Box>
|
|
133
|
+
<Text color="yellow"> ⏱ {formatTime(elapsed)}</Text>
|
|
134
|
+
</Box>
|
|
135
|
+
</Box>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function Stats({ completed, failed, total }: { completed: number; failed: number; total: number }) {
|
|
140
|
+
const percent = total > 0 ? ((completed + failed) / total) * 100 : 0
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<Box flexDirection="column" marginY={1}>
|
|
144
|
+
<Box>
|
|
145
|
+
<Text color="green">✓ {completed}</Text>
|
|
146
|
+
<Text color="gray"> | </Text>
|
|
147
|
+
<Text color="red">✗ {failed}</Text>
|
|
148
|
+
<Text color="gray"> | </Text>
|
|
149
|
+
<Text>Total: {total}</Text>
|
|
150
|
+
</Box>
|
|
151
|
+
<Box marginTop={1}>
|
|
152
|
+
<ProgressBar percent={percent} />
|
|
153
|
+
</Box>
|
|
154
|
+
</Box>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function RecentEvents({ events }: { events: TaskEvent[] }) {
|
|
159
|
+
return (
|
|
160
|
+
<Box flexDirection="column" marginY={1}>
|
|
161
|
+
<Text bold color="white">Recent:</Text>
|
|
162
|
+
{events.length === 0 ? (
|
|
163
|
+
<Text color="gray"> No events yet</Text>
|
|
164
|
+
) : (
|
|
165
|
+
events.slice(-5).map((event, i) => (
|
|
166
|
+
<Box key={i}>
|
|
167
|
+
<Text color="gray">{formatTimestamp(event.timestamp)} </Text>
|
|
168
|
+
<Text color={event.status === 'completed' ? 'green' : event.status === 'failed' ? 'red' : 'cyan'}>
|
|
169
|
+
{event.status === 'completed' ? '✓' : event.status === 'failed' ? '✗' : '▶'}
|
|
170
|
+
</Text>
|
|
171
|
+
<Text> {event.id}</Text>
|
|
172
|
+
{event.duration && <Text color="gray"> ({event.duration}s)</Text>}
|
|
173
|
+
</Box>
|
|
174
|
+
))
|
|
175
|
+
)}
|
|
176
|
+
</Box>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function ElapsedTime({ startTime }: { startTime: number | null }) {
|
|
181
|
+
const [elapsed, setElapsed] = useState(0)
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!startTime) return
|
|
185
|
+
|
|
186
|
+
const interval = setInterval(() => {
|
|
187
|
+
setElapsed(Math.floor((Date.now() - startTime) / 1000))
|
|
188
|
+
}, 1000)
|
|
189
|
+
|
|
190
|
+
return () => clearInterval(interval)
|
|
191
|
+
}, [startTime])
|
|
192
|
+
|
|
193
|
+
if (!startTime) return null
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<Box>
|
|
197
|
+
<Text color="gray">Loop time: </Text>
|
|
198
|
+
<Text color="white">{formatTime(elapsed)}</Text>
|
|
199
|
+
</Box>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function Footer() {
|
|
204
|
+
return (
|
|
205
|
+
<Box marginTop={1}>
|
|
206
|
+
<Text color="gray">Press </Text>
|
|
207
|
+
<Text color="yellow">q</Text>
|
|
208
|
+
<Text color="gray"> to quit</Text>
|
|
209
|
+
</Box>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function Dashboard() {
|
|
214
|
+
const { exit } = useApp()
|
|
215
|
+
const [state, setState] = useState<DashboardState>(dashboardState)
|
|
216
|
+
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
return subscribe(setState)
|
|
219
|
+
}, [])
|
|
220
|
+
|
|
221
|
+
useInput((input) => {
|
|
222
|
+
if (input === 'q') {
|
|
223
|
+
exit()
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<Box flexDirection="column" padding={1}>
|
|
229
|
+
<Header namespace={state.namespace} />
|
|
230
|
+
|
|
231
|
+
<Box marginTop={1}>
|
|
232
|
+
<ElapsedTime startTime={state.loopStartTime} />
|
|
233
|
+
</Box>
|
|
234
|
+
|
|
235
|
+
<CurrentTask task={state.currentTask} startTime={state.taskStartTime} />
|
|
236
|
+
|
|
237
|
+
<Stats completed={state.completed} failed={state.failed} total={state.total} />
|
|
238
|
+
|
|
239
|
+
<RecentEvents events={state.recentEvents} />
|
|
240
|
+
|
|
241
|
+
<Footer />
|
|
242
|
+
</Box>
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ============================================================================
|
|
247
|
+
// Helpers
|
|
248
|
+
// ============================================================================
|
|
249
|
+
|
|
250
|
+
function formatTime(seconds: number): string {
|
|
251
|
+
const h = Math.floor(seconds / 3600)
|
|
252
|
+
const m = Math.floor((seconds % 3600) / 60)
|
|
253
|
+
const s = seconds % 60
|
|
254
|
+
|
|
255
|
+
if (h > 0) return `${h}h ${m}m ${s}s`
|
|
256
|
+
if (m > 0) return `${m}m ${s}s`
|
|
257
|
+
return `${s}s`
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function formatTimestamp(date: Date): string {
|
|
261
|
+
return date.toLocaleTimeString('en-US', { hour12: false })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ============================================================================
|
|
265
|
+
// Public API
|
|
266
|
+
// ============================================================================
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Render the dashboard (standalone mode)
|
|
270
|
+
*/
|
|
271
|
+
export function renderDashboard() {
|
|
272
|
+
const { unmount } = render(<Dashboard />)
|
|
273
|
+
return { unmount }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Create dashboard plugin
|
|
278
|
+
*/
|
|
279
|
+
export function createDashboardPlugin(options: { totalTasks?: number } = {}): LoopworkPlugin {
|
|
280
|
+
return {
|
|
281
|
+
name: 'dashboard',
|
|
282
|
+
|
|
283
|
+
onLoopStart(namespace) {
|
|
284
|
+
updateState({
|
|
285
|
+
namespace,
|
|
286
|
+
loopStartTime: Date.now(),
|
|
287
|
+
isRunning: true,
|
|
288
|
+
completed: 0,
|
|
289
|
+
failed: 0,
|
|
290
|
+
total: options.totalTasks || 0,
|
|
291
|
+
recentEvents: [],
|
|
292
|
+
})
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
onTaskStart(task) {
|
|
296
|
+
updateState({
|
|
297
|
+
currentTask: task,
|
|
298
|
+
taskStartTime: Date.now(),
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
const events = [...dashboardState.recentEvents]
|
|
302
|
+
events.push({
|
|
303
|
+
id: task.id,
|
|
304
|
+
title: task.title,
|
|
305
|
+
status: 'started',
|
|
306
|
+
timestamp: new Date(),
|
|
307
|
+
})
|
|
308
|
+
updateState({ recentEvents: events.slice(-10) })
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
onTaskComplete(task, result) {
|
|
312
|
+
const events = [...dashboardState.recentEvents]
|
|
313
|
+
// Update the started event to completed
|
|
314
|
+
const idx = events.findIndex((e) => e.id === task.id && e.status === 'started')
|
|
315
|
+
if (idx >= 0) {
|
|
316
|
+
events[idx] = {
|
|
317
|
+
...events[idx],
|
|
318
|
+
status: 'completed',
|
|
319
|
+
duration: Math.round(result.duration),
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
updateState({
|
|
324
|
+
currentTask: null,
|
|
325
|
+
taskStartTime: null,
|
|
326
|
+
completed: dashboardState.completed + 1,
|
|
327
|
+
recentEvents: events.slice(-10),
|
|
328
|
+
})
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
onTaskFailed(task, error) {
|
|
332
|
+
const events = [...dashboardState.recentEvents]
|
|
333
|
+
const idx = events.findIndex((e) => e.id === task.id && e.status === 'started')
|
|
334
|
+
if (idx >= 0) {
|
|
335
|
+
events[idx] = {
|
|
336
|
+
...events[idx],
|
|
337
|
+
status: 'failed',
|
|
338
|
+
error,
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
updateState({
|
|
343
|
+
currentTask: null,
|
|
344
|
+
taskStartTime: null,
|
|
345
|
+
failed: dashboardState.failed + 1,
|
|
346
|
+
recentEvents: events.slice(-10),
|
|
347
|
+
})
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
onLoopEnd() {
|
|
351
|
+
updateState({
|
|
352
|
+
isRunning: false,
|
|
353
|
+
currentTask: null,
|
|
354
|
+
taskStartTime: null,
|
|
355
|
+
})
|
|
356
|
+
},
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Set total task count (call before loop starts)
|
|
362
|
+
*/
|
|
363
|
+
export function setTotalTasks(count: number) {
|
|
364
|
+
updateState({ total: count })
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Get current dashboard state
|
|
369
|
+
*/
|
|
370
|
+
export function getDashboardState(): DashboardState {
|
|
371
|
+
return { ...dashboardState }
|
|
372
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { logger } from './core/utils'
|
|
2
|
+
|
|
3
|
+
async function main() {
|
|
4
|
+
const args = process.argv.slice(2)
|
|
5
|
+
const command = args[0]
|
|
6
|
+
|
|
7
|
+
if (command === 'init') {
|
|
8
|
+
const { init } = await import('./commands/init')
|
|
9
|
+
await init()
|
|
10
|
+
} else {
|
|
11
|
+
const { run } = await import('./commands/run')
|
|
12
|
+
await run()
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
main().catch((err) => {
|
|
17
|
+
logger.error(`Unhandled error: ${err.message}`)
|
|
18
|
+
process.exit(1)
|
|
19
|
+
})
|