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,154 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import type { Config } from './config'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* State Manager for loopwork
|
|
7
|
+
*
|
|
8
|
+
* Note: Primary state (task status) lives in GitHub Issues.
|
|
9
|
+
* This manages local session state and locking only.
|
|
10
|
+
*/
|
|
11
|
+
export class StateManager {
|
|
12
|
+
private stateFile: string
|
|
13
|
+
private lockFile: string
|
|
14
|
+
private namespace: string
|
|
15
|
+
|
|
16
|
+
constructor(private config: Config) {
|
|
17
|
+
this.namespace = config.namespace || 'default'
|
|
18
|
+
// Use namespace in file paths to allow concurrent loops
|
|
19
|
+
const suffix = this.namespace === 'default' ? '' : `-${this.namespace}`
|
|
20
|
+
this.stateFile = path.join(config.projectRoot, `.loopwork-state${suffix}`)
|
|
21
|
+
this.lockFile = path.join(config.projectRoot, `.loopwork${suffix}.lock`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
getNamespace(): string {
|
|
25
|
+
return this.namespace
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getLockFile(): string {
|
|
29
|
+
return this.lockFile
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getStateFile(): string {
|
|
33
|
+
return this.stateFile
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Acquire exclusive lock to prevent multiple instances
|
|
38
|
+
*/
|
|
39
|
+
acquireLock(retryCount = 0): boolean {
|
|
40
|
+
if (retryCount > 3) {
|
|
41
|
+
console.error('Failed to acquire lock after multiple attempts')
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
fs.mkdirSync(this.lockFile)
|
|
47
|
+
fs.writeFileSync(path.join(this.lockFile, 'pid'), process.pid.toString())
|
|
48
|
+
return true
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// Check if lock is stale
|
|
51
|
+
try {
|
|
52
|
+
const pidFile = path.join(this.lockFile, 'pid')
|
|
53
|
+
if (fs.existsSync(pidFile)) {
|
|
54
|
+
const pid = fs.readFileSync(pidFile, 'utf-8')
|
|
55
|
+
try {
|
|
56
|
+
process.kill(parseInt(pid, 10), 0)
|
|
57
|
+
console.error(`Another loopwork is running (PID: ${pid})`)
|
|
58
|
+
return false
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.warn(`Stale lock found (process ${pid} not running), removing...`)
|
|
61
|
+
fs.rmSync(this.lockFile, { recursive: true, force: true })
|
|
62
|
+
return this.acquireLock(retryCount + 1)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.error('Failed to acquire lock (unknown reason)')
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Release the lock
|
|
75
|
+
*/
|
|
76
|
+
releaseLock(): void {
|
|
77
|
+
try {
|
|
78
|
+
if (fs.existsSync(this.lockFile)) {
|
|
79
|
+
fs.rmSync(this.lockFile, { recursive: true, force: true })
|
|
80
|
+
}
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.error('Failed to release lock')
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Save current session state
|
|
88
|
+
*/
|
|
89
|
+
saveState(currentIssue: number, iteration: number): void {
|
|
90
|
+
const content = [
|
|
91
|
+
`NAMESPACE=${this.namespace}`,
|
|
92
|
+
`LAST_ISSUE=${currentIssue}`,
|
|
93
|
+
`LAST_ITERATION=${iteration}`,
|
|
94
|
+
`LAST_OUTPUT_DIR=${this.config.outputDir}`,
|
|
95
|
+
`SESSION_ID=${this.config.sessionId}`,
|
|
96
|
+
`SAVED_AT=${new Date().toISOString()}`,
|
|
97
|
+
].join('\n')
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
fs.writeFileSync(this.stateFile, content, { mode: 0o600 })
|
|
101
|
+
if (this.config.debug) {
|
|
102
|
+
console.log(`State saved: issue=#${currentIssue}, iteration=${iteration}`)
|
|
103
|
+
}
|
|
104
|
+
} catch (e) {
|
|
105
|
+
console.error('Failed to save state')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Load previous session state
|
|
111
|
+
*/
|
|
112
|
+
loadState(): { lastIssue: number; lastIteration: number; lastOutputDir: string } | null {
|
|
113
|
+
if (!fs.existsSync(this.stateFile)) {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const content = fs.readFileSync(this.stateFile, 'utf-8')
|
|
119
|
+
const state: Record<string, string> = {}
|
|
120
|
+
|
|
121
|
+
content.split('\n').forEach((line) => {
|
|
122
|
+
const idx = line.indexOf('=')
|
|
123
|
+
if (idx !== -1) {
|
|
124
|
+
const key = line.substring(0, idx)
|
|
125
|
+
const value = line.substring(idx + 1)
|
|
126
|
+
if (key && value) state[key] = value
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
if (!state.LAST_ISSUE) return null
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
lastIssue: parseInt(state.LAST_ISSUE, 10),
|
|
134
|
+
lastIteration: parseInt(state.LAST_ITERATION || '0', 10),
|
|
135
|
+
lastOutputDir: state.LAST_OUTPUT_DIR || '',
|
|
136
|
+
}
|
|
137
|
+
} catch (e) {
|
|
138
|
+
console.error('Failed to load state')
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Clear saved state
|
|
145
|
+
*/
|
|
146
|
+
clearState(): void {
|
|
147
|
+
if (fs.existsSync(this.stateFile)) {
|
|
148
|
+
fs.unlinkSync(this.stateFile)
|
|
149
|
+
if (this.config.debug) {
|
|
150
|
+
console.log('State cleared')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
|
|
3
|
+
export function getTimestamp(): string {
|
|
4
|
+
return new Date().toLocaleTimeString('en-US', {
|
|
5
|
+
hour: 'numeric',
|
|
6
|
+
minute: '2-digit',
|
|
7
|
+
second: '2-digit',
|
|
8
|
+
hour12: true,
|
|
9
|
+
})
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const logger = {
|
|
13
|
+
info: (msg: string) => {
|
|
14
|
+
process.stdout.write('\r\x1b[K')
|
|
15
|
+
console.log(chalk.gray(getTimestamp()), chalk.blue('[INFO]'), msg)
|
|
16
|
+
},
|
|
17
|
+
success: (msg: string) => {
|
|
18
|
+
process.stdout.write('\r\x1b[K')
|
|
19
|
+
console.log(chalk.gray(getTimestamp()), chalk.green('[SUCCESS]'), msg)
|
|
20
|
+
},
|
|
21
|
+
warn: (msg: string) => {
|
|
22
|
+
process.stdout.write('\r\x1b[K')
|
|
23
|
+
console.log(chalk.gray(getTimestamp()), chalk.yellow('[WARN]'), msg)
|
|
24
|
+
},
|
|
25
|
+
error: (msg: string) => {
|
|
26
|
+
process.stdout.write('\r\x1b[K')
|
|
27
|
+
console.log(chalk.gray(getTimestamp()), chalk.red('[ERROR]'), msg)
|
|
28
|
+
},
|
|
29
|
+
debug: (msg: string) => {
|
|
30
|
+
if (process.env.LOOPWORK_DEBUG === 'true') {
|
|
31
|
+
process.stdout.write('\r\x1b[K')
|
|
32
|
+
console.log(chalk.gray(getTimestamp()), chalk.cyan('[DEBUG]'), msg)
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
update: (msg: string) => {
|
|
36
|
+
process.stdout.write(
|
|
37
|
+
`\r\x1b[K${chalk.gray(getTimestamp())} ${chalk.blue('[INFO]')} ${msg}`
|
|
38
|
+
)
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function promptUser(
|
|
43
|
+
question: string,
|
|
44
|
+
defaultValue: string = 'n',
|
|
45
|
+
nonInteractive: boolean = false
|
|
46
|
+
): Promise<string> {
|
|
47
|
+
if (nonInteractive || !process.stdin.isTTY) {
|
|
48
|
+
logger.debug(`Non-interactive mode, using default: ${defaultValue}`)
|
|
49
|
+
return defaultValue
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
process.stdout.write(question)
|
|
53
|
+
process.stdin.setRawMode(true)
|
|
54
|
+
|
|
55
|
+
return new Promise<string>((resolve) => {
|
|
56
|
+
process.stdin.resume()
|
|
57
|
+
|
|
58
|
+
const cleanup = () => {
|
|
59
|
+
try {
|
|
60
|
+
process.stdin.setRawMode(false)
|
|
61
|
+
} catch {
|
|
62
|
+
// stdin may already be closed
|
|
63
|
+
}
|
|
64
|
+
process.stdin.pause()
|
|
65
|
+
process.stdin.removeListener('data', onData)
|
|
66
|
+
process.stdin.removeListener('error', onError)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const onData = (data: Buffer) => {
|
|
70
|
+
const char = data.toString('utf8')
|
|
71
|
+
|
|
72
|
+
// Handle Ctrl+C
|
|
73
|
+
if (char === '\u0003') {
|
|
74
|
+
cleanup()
|
|
75
|
+
process.stdout.write('\n')
|
|
76
|
+
logger.info('Interrupted by user (Ctrl+C)')
|
|
77
|
+
process.exit(130)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
cleanup()
|
|
81
|
+
process.stdout.write('\n')
|
|
82
|
+
if (char === '\r' || char === '\n') {
|
|
83
|
+
resolve(defaultValue)
|
|
84
|
+
} else {
|
|
85
|
+
resolve(char.trim())
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const onError = (err: Error) => {
|
|
90
|
+
cleanup()
|
|
91
|
+
logger.debug(`stdin error: ${err.message}`)
|
|
92
|
+
resolve(defaultValue)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
process.stdin.once('error', onError)
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class StreamLogger {
|
|
100
|
+
private buffer: string = ''
|
|
101
|
+
|
|
102
|
+
log(chunk: string | Buffer) {
|
|
103
|
+
this.buffer += chunk.toString('utf8')
|
|
104
|
+
const lines = this.buffer.split('\n')
|
|
105
|
+
|
|
106
|
+
// The last element is either an empty string (if buffer ended with \n)
|
|
107
|
+
// or a partial line. Keep it in the buffer.
|
|
108
|
+
this.buffer = lines.pop() || ''
|
|
109
|
+
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
this.printLine(line)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private printLine(line: string) {
|
|
116
|
+
process.stdout.write(`${chalk.gray(getTimestamp())} ${line}\n`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
flush() {
|
|
120
|
+
if (this.buffer) {
|
|
121
|
+
this.printLine(this.buffer)
|
|
122
|
+
this.buffer = ''
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|