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/bin/loopwork
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,16 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopwork",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Loopwork - AI task runner with pluggable backends",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./contracts": "./src/contracts/index.ts"
|
|
9
|
+
},
|
|
6
10
|
"bin": {
|
|
7
11
|
"loopwork": "bin/loopwork"
|
|
8
12
|
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin/",
|
|
15
|
+
"src/",
|
|
16
|
+
"README.md",
|
|
17
|
+
"CHANGELOG.md",
|
|
18
|
+
"SECURITY.md",
|
|
19
|
+
".env.example",
|
|
20
|
+
"examples/"
|
|
21
|
+
],
|
|
9
22
|
"scripts": {
|
|
10
23
|
"start": "bun run src/index.ts",
|
|
11
|
-
"build": "bun build ./src/index.ts --compile --outfile bin/loopwork",
|
|
24
|
+
"build": "bun build ./src/index.ts --compile --outfile bin/loopwork && bun build ./src/index.ts --outdir dist --target node --external bun",
|
|
12
25
|
"test": "bun test",
|
|
13
|
-
"setup-labels": "bun run src/setup-labels.ts"
|
|
26
|
+
"setup-labels": "bun run src/setup-labels.ts",
|
|
27
|
+
"posts:generate": "bun run scripts/generate-posts.ts",
|
|
28
|
+
"posts:schedule": "bun run scripts/schedule-linkedin-posts.ts",
|
|
29
|
+
"posts:preview": "bun run scripts/generate-posts.ts markdown",
|
|
30
|
+
"prepack": "npm run build",
|
|
31
|
+
"pack:dry": "npm pack --dry-run",
|
|
32
|
+
"pack:check": "npm pack && tar -tzf *.tgz && rm *.tgz",
|
|
33
|
+
"prepublishOnly": "npm run build && npm run test",
|
|
34
|
+
"security:audit": "npm audit --audit-level=moderate",
|
|
35
|
+
"security:check": "npm outdated && npm audit",
|
|
36
|
+
"security:fix": "npm audit fix",
|
|
37
|
+
"security:trivy": "trivy fs . --severity CRITICAL,HIGH,MEDIUM || echo 'Trivy not installed. Run: brew install aquasecurity/trivy/trivy'"
|
|
14
38
|
},
|
|
15
39
|
"dependencies": {
|
|
16
40
|
"chalk": "^5.3.0",
|
|
@@ -22,5 +46,25 @@
|
|
|
22
46
|
"@types/bun": "latest",
|
|
23
47
|
"@types/node": "^20.0.0",
|
|
24
48
|
"@types/react": "^18.2.0"
|
|
25
|
-
}
|
|
49
|
+
},
|
|
50
|
+
"keywords": [
|
|
51
|
+
"ai",
|
|
52
|
+
"automation",
|
|
53
|
+
"task-runner",
|
|
54
|
+
"github",
|
|
55
|
+
"workflow",
|
|
56
|
+
"cli",
|
|
57
|
+
"loopwork",
|
|
58
|
+
"agent"
|
|
59
|
+
],
|
|
60
|
+
"repository": {
|
|
61
|
+
"type": "git",
|
|
62
|
+
"url": "https://github.com/nadimtuhin/loopwork.git"
|
|
63
|
+
},
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/nadimtuhin/loopwork/issues"
|
|
66
|
+
},
|
|
67
|
+
"homepage": "https://github.com/nadimtuhin/loopwork#readme",
|
|
68
|
+
"author": "Nadim Tuhin",
|
|
69
|
+
"license": "MIT"
|
|
26
70
|
}
|
package/src/backends/github.ts
CHANGED
|
@@ -11,7 +11,7 @@ const DEPENDS_PATTERN = /(?:^|\n)\s*(?:Depends on|depends on|Dependencies|depend
|
|
|
11
11
|
interface GitHubIssue {
|
|
12
12
|
number: number
|
|
13
13
|
title: string
|
|
14
|
-
body
|
|
14
|
+
body?: string
|
|
15
15
|
state: 'open' | 'closed'
|
|
16
16
|
labels: { name: string }[]
|
|
17
17
|
url: string
|
|
@@ -75,7 +75,10 @@ export class GitHubTaskAdapter implements TaskBackend {
|
|
|
75
75
|
|
|
76
76
|
async findNextTask(options?: FindTaskOptions): Promise<Task | null> {
|
|
77
77
|
const tasks = await this.listPendingTasks(options)
|
|
78
|
-
|
|
78
|
+
const task = tasks[0] || null
|
|
79
|
+
if (!task) return null
|
|
80
|
+
|
|
81
|
+
return this.getTask(task.id)
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
async getTask(taskId: string): Promise<Task | null> {
|
|
@@ -101,7 +104,7 @@ export class GitHubTaskAdapter implements TaskBackend {
|
|
|
101
104
|
try {
|
|
102
105
|
return await this.withRetry(async () => {
|
|
103
106
|
const labelArg = labels.join(',')
|
|
104
|
-
const result = await $`gh issue list ${this.repoFlag()} --label "${labelArg}" --state open --json number,title,
|
|
107
|
+
const result = await $`gh issue list ${this.repoFlag()} --label "${labelArg}" --state open --json number,title,labels,url --limit 100`.quiet()
|
|
105
108
|
const issues: GitHubIssue[] = JSON.parse(result.stdout.toString())
|
|
106
109
|
return issues.map(issue => this.adaptIssue(issue))
|
|
107
110
|
})
|
package/src/backends/json.ts
CHANGED
|
@@ -143,11 +143,12 @@ export class JsonTaskAdapter implements TaskBackend {
|
|
|
143
143
|
if (options?.startFrom) {
|
|
144
144
|
const startIdx = tasks.findIndex(t => t.id === options.startFrom)
|
|
145
145
|
if (startIdx >= 0) {
|
|
146
|
-
return tasks[startIdx]
|
|
146
|
+
return this.getTask(tasks[startIdx].id)
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
|
|
150
|
+
if (!tasks[0]) return null
|
|
151
|
+
return this.getTask(tasks[0].id)
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
async getTask(taskId: string): Promise<Task | null> {
|
|
@@ -194,20 +195,37 @@ export class JsonTaskAdapter implements TaskBackend {
|
|
|
194
195
|
|
|
195
196
|
const tasks: Task[] = []
|
|
196
197
|
for (const entry of entries) {
|
|
197
|
-
|
|
198
|
-
if (
|
|
199
|
-
|
|
200
|
-
if (!
|
|
201
|
-
const depsMet = await this.areDependenciesMetInternal(task.dependsOn, data)
|
|
202
|
-
if (!depsMet) continue // Skip blocked tasks
|
|
203
|
-
}
|
|
204
|
-
tasks.push(task)
|
|
198
|
+
// Check if task dependencies are met
|
|
199
|
+
if (!options?.includeBlocked && entry.dependsOn && entry.dependsOn.length > 0) {
|
|
200
|
+
const depsMet = await this.areDependenciesMetInternal(entry.dependsOn, data)
|
|
201
|
+
if (!depsMet) continue // Skip blocked tasks
|
|
205
202
|
}
|
|
203
|
+
|
|
204
|
+
const task = this.loadTaskSummary(entry, data)
|
|
205
|
+
tasks.push(task)
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
return tasks
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
private loadTaskSummary(entry: JsonTaskEntry, data: JsonTasksFile): Task {
|
|
212
|
+
const featureInfo = entry.feature && data.features?.[entry.feature]
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
id: entry.id,
|
|
216
|
+
title: entry.id,
|
|
217
|
+
description: '',
|
|
218
|
+
status: entry.status,
|
|
219
|
+
priority: entry.priority || 'medium',
|
|
220
|
+
feature: entry.feature,
|
|
221
|
+
parentId: entry.parentId,
|
|
222
|
+
dependsOn: entry.dependsOn,
|
|
223
|
+
metadata: {
|
|
224
|
+
featureName: featureInfo?.name,
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
211
229
|
/**
|
|
212
230
|
* Internal method to check if dependencies are met
|
|
213
231
|
*/
|
package/src/commands/run.ts
CHANGED
|
@@ -6,8 +6,8 @@ import { createBackend, type TaskBackend, type Task } from '../backends'
|
|
|
6
6
|
import { CliExecutor } from '../core/cli'
|
|
7
7
|
import { logger } from '../core/utils'
|
|
8
8
|
import { plugins } from '../plugins'
|
|
9
|
-
import { createCostTrackingPlugin } from '
|
|
10
|
-
import { createTelegramHookPlugin } from '
|
|
9
|
+
import { createCostTrackingPlugin } from '../../../cost-tracking/src/index'
|
|
10
|
+
import { createTelegramHookPlugin } from '../../../telegram/src/notifications'
|
|
11
11
|
import type { TaskContext } from '../contracts/plugin'
|
|
12
12
|
|
|
13
13
|
function generateSuccessCriteria(task: Task): string[] {
|
package/src/contracts/config.ts
CHANGED
|
@@ -5,72 +5,6 @@
|
|
|
5
5
|
import type { LoopworkPlugin } from './plugin'
|
|
6
6
|
import type { BackendConfig } from './backend'
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
* Telegram plugin configuration
|
|
10
|
-
*/
|
|
11
|
-
export interface TelegramConfig {
|
|
12
|
-
botToken?: string
|
|
13
|
-
chatId?: string
|
|
14
|
-
notifications?: boolean
|
|
15
|
-
silent?: boolean
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Discord plugin configuration
|
|
20
|
-
*/
|
|
21
|
-
export interface DiscordConfig {
|
|
22
|
-
webhookUrl?: string
|
|
23
|
-
username?: string
|
|
24
|
-
avatarUrl?: string
|
|
25
|
-
notifyOnStart?: boolean
|
|
26
|
-
notifyOnComplete?: boolean
|
|
27
|
-
notifyOnFail?: boolean
|
|
28
|
-
notifyOnLoopEnd?: boolean
|
|
29
|
-
mentionOnFail?: string
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Asana plugin configuration
|
|
34
|
-
*/
|
|
35
|
-
export interface AsanaConfig {
|
|
36
|
-
accessToken?: string
|
|
37
|
-
projectId?: string
|
|
38
|
-
workspaceId?: string
|
|
39
|
-
autoCreate?: boolean
|
|
40
|
-
syncStatus?: boolean
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Everhour plugin configuration
|
|
45
|
-
*/
|
|
46
|
-
export interface EverhourConfig {
|
|
47
|
-
apiKey?: string
|
|
48
|
-
autoStartTimer?: boolean
|
|
49
|
-
autoStopTimer?: boolean
|
|
50
|
-
projectId?: string
|
|
51
|
-
dailyLimit?: number
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Todoist plugin configuration
|
|
56
|
-
*/
|
|
57
|
-
export interface TodoistConfig {
|
|
58
|
-
apiToken?: string
|
|
59
|
-
projectId?: string
|
|
60
|
-
syncStatus?: boolean
|
|
61
|
-
addComments?: boolean
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Cost tracking configuration
|
|
66
|
-
*/
|
|
67
|
-
export interface CostTrackingConfig {
|
|
68
|
-
enabled?: boolean
|
|
69
|
-
defaultModel?: string
|
|
70
|
-
dailyBudget?: number
|
|
71
|
-
alertThreshold?: number
|
|
72
|
-
}
|
|
73
|
-
|
|
74
8
|
/**
|
|
75
9
|
* Main Loopwork configuration
|
|
76
10
|
*/
|
|
@@ -99,23 +33,17 @@ export interface LoopworkConfig {
|
|
|
99
33
|
taskDelay?: number
|
|
100
34
|
retryDelay?: number
|
|
101
35
|
|
|
102
|
-
// Plugin configs
|
|
103
|
-
telegram?: TelegramConfig
|
|
104
|
-
discord?: DiscordConfig
|
|
105
|
-
asana?: AsanaConfig
|
|
106
|
-
everhour?: EverhourConfig
|
|
107
|
-
todoist?: TodoistConfig
|
|
108
|
-
costTracking?: CostTrackingConfig
|
|
109
|
-
|
|
110
36
|
// Registered plugins
|
|
111
37
|
plugins?: LoopworkPlugin[]
|
|
38
|
+
|
|
39
|
+
[key: string]: any
|
|
112
40
|
}
|
|
113
41
|
|
|
114
42
|
/**
|
|
115
43
|
* Default configuration values
|
|
116
44
|
*/
|
|
117
45
|
export const DEFAULT_CONFIG: Partial<LoopworkConfig> = {
|
|
118
|
-
cli: '
|
|
46
|
+
cli: 'claude',
|
|
119
47
|
maxIterations: 50,
|
|
120
48
|
timeout: 600,
|
|
121
49
|
namespace: 'default',
|
package/src/contracts/index.ts
CHANGED
package/src/core/cli.ts
CHANGED
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
import { spawn, spawnSync, ChildProcess } from 'child_process'
|
|
2
2
|
import fs from 'fs'
|
|
3
3
|
import path from 'path'
|
|
4
|
-
import { logger } from './utils'
|
|
4
|
+
import { logger, StreamLogger } from './utils'
|
|
5
5
|
import type { Config } from './config'
|
|
6
6
|
|
|
7
7
|
export interface CliConfig {
|
|
8
8
|
name: string
|
|
9
|
+
displayName?: string
|
|
9
10
|
cli: 'opencode' | 'claude'
|
|
10
11
|
model: string
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
// Default model pools
|
|
14
15
|
export const EXEC_MODELS: CliConfig[] = [
|
|
15
|
-
{ name: 'sonnet-claude', cli: 'claude', model: 'sonnet' },
|
|
16
|
-
{ name: 'sonnet-opencode', cli: 'opencode', model: 'google/antigravity-claude-sonnet-4-5' },
|
|
17
|
-
{ name: 'gemini-3-flash', cli: 'opencode', model: 'google/antigravity-gemini-3-flash' },
|
|
16
|
+
{ name: 'sonnet-claude', displayName: 'claude', cli: 'claude', model: 'sonnet' },
|
|
17
|
+
{ name: 'sonnet-opencode', displayName: 'sonnet', cli: 'opencode', model: 'google/antigravity-claude-sonnet-4-5' },
|
|
18
|
+
{ name: 'gemini-3-flash', displayName: 'gemini-flash', cli: 'opencode', model: 'google/antigravity-gemini-3-flash' },
|
|
18
19
|
]
|
|
19
20
|
|
|
20
21
|
export const FALLBACK_MODELS: CliConfig[] = [
|
|
21
|
-
{ name: 'opus-claude', cli: 'claude', model: 'opus' },
|
|
22
|
-
{ name: 'gemini-3-pro', cli: 'opencode', model: 'google/antigravity-gemini-3-pro' },
|
|
22
|
+
{ name: 'opus-claude', displayName: 'opus', cli: 'claude', model: 'opus' },
|
|
23
|
+
{ name: 'gemini-3-pro', displayName: 'gemini-pro', cli: 'opencode', model: 'google/antigravity-gemini-3-pro' },
|
|
23
24
|
]
|
|
24
25
|
|
|
25
26
|
export class CliExecutor {
|
|
@@ -110,6 +111,7 @@ export class CliExecutor {
|
|
|
110
111
|
|
|
111
112
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
112
113
|
const cliConfig = this.getNextCliConfig()
|
|
114
|
+
const displayName = cliConfig.displayName || cliConfig.name
|
|
113
115
|
const cliPath = this.cliPaths.get(cliConfig.cli)
|
|
114
116
|
|
|
115
117
|
if (!cliPath) {
|
|
@@ -132,9 +134,9 @@ export class CliExecutor {
|
|
|
132
134
|
? `opencode run --model ${cliConfig.model} "<prompt>"`
|
|
133
135
|
: `claude -p --dangerously-skip-permissions --model ${cliConfig.model}`
|
|
134
136
|
|
|
135
|
-
logger.info(`[${
|
|
136
|
-
logger.info(`[${
|
|
137
|
-
logger.info(`[${
|
|
137
|
+
logger.info(`[${displayName}] Executing: ${cmdDisplay}`)
|
|
138
|
+
logger.info(`[${displayName}] Timeout: ${timeoutSecs}s`)
|
|
139
|
+
logger.info(`[${displayName}] Log file: ${outputFile}`)
|
|
138
140
|
logger.info('─────────────────────────────────────')
|
|
139
141
|
logger.info('📝 Streaming CLI output below...')
|
|
140
142
|
logger.info('─────────────────────────────────────')
|
|
@@ -142,13 +144,17 @@ export class CliExecutor {
|
|
|
142
144
|
const result = await this.spawnWithTimeout(
|
|
143
145
|
cliPath,
|
|
144
146
|
args,
|
|
145
|
-
{
|
|
147
|
+
{
|
|
148
|
+
env,
|
|
149
|
+
input: cliConfig.cli === 'claude' ? prompt : undefined,
|
|
150
|
+
prefix: displayName,
|
|
151
|
+
},
|
|
146
152
|
outputFile,
|
|
147
153
|
timeoutSecs
|
|
148
154
|
)
|
|
149
155
|
|
|
150
156
|
if (result.timedOut) {
|
|
151
|
-
logger.error(`Timed out with ${
|
|
157
|
+
logger.error(`Timed out with ${displayName}`)
|
|
152
158
|
continue
|
|
153
159
|
}
|
|
154
160
|
|
|
@@ -158,13 +164,13 @@ export class CliExecutor {
|
|
|
158
164
|
: ''
|
|
159
165
|
|
|
160
166
|
if (/rate.*limit|too.*many.*request|429|RESOURCE_EXHAUSTED/i.test(output)) {
|
|
161
|
-
logger.warn(`Rate limited on ${
|
|
167
|
+
logger.warn(`Rate limited on ${displayName}, waiting 30s...`)
|
|
162
168
|
await new Promise(r => setTimeout(r, 30000))
|
|
163
169
|
continue
|
|
164
170
|
}
|
|
165
171
|
|
|
166
172
|
if (/quota.*exceed|billing.*limit/i.test(output)) {
|
|
167
|
-
logger.warn(`Quota exhausted for ${
|
|
173
|
+
logger.warn(`Quota exhausted for ${displayName}`)
|
|
168
174
|
this.switchToFallback()
|
|
169
175
|
continue
|
|
170
176
|
}
|
|
@@ -186,7 +192,7 @@ export class CliExecutor {
|
|
|
186
192
|
private spawnWithTimeout(
|
|
187
193
|
command: string,
|
|
188
194
|
args: string[],
|
|
189
|
-
options: { env?: NodeJS.ProcessEnv; input?: string },
|
|
195
|
+
options: { env?: NodeJS.ProcessEnv; input?: string; prefix?: string },
|
|
190
196
|
outputFile: string,
|
|
191
197
|
timeoutSecs: number
|
|
192
198
|
): Promise<{ exitCode: number; timedOut: boolean }> {
|
|
@@ -194,6 +200,7 @@ export class CliExecutor {
|
|
|
194
200
|
const writeStream = fs.createWriteStream(outputFile)
|
|
195
201
|
let timedOut = false
|
|
196
202
|
const startTime = Date.now()
|
|
203
|
+
const streamLogger = new StreamLogger(options.prefix)
|
|
197
204
|
|
|
198
205
|
const child = spawn(command, args, {
|
|
199
206
|
env: options.env,
|
|
@@ -212,12 +219,12 @@ export class CliExecutor {
|
|
|
212
219
|
|
|
213
220
|
child.stdout?.on('data', (data) => {
|
|
214
221
|
writeStream.write(data)
|
|
215
|
-
|
|
222
|
+
streamLogger.log(data)
|
|
216
223
|
})
|
|
217
224
|
|
|
218
225
|
child.stderr?.on('data', (data) => {
|
|
219
226
|
writeStream.write(data)
|
|
220
|
-
|
|
227
|
+
streamLogger.log(data)
|
|
221
228
|
})
|
|
222
229
|
|
|
223
230
|
if (child.stdin) {
|
|
@@ -236,6 +243,7 @@ export class CliExecutor {
|
|
|
236
243
|
child.on('close', (code) => {
|
|
237
244
|
clearInterval(progressInterval)
|
|
238
245
|
clearTimeout(timer)
|
|
246
|
+
streamLogger.flush()
|
|
239
247
|
writeStream.end()
|
|
240
248
|
this.currentSubprocess = null
|
|
241
249
|
const totalTime = Math.floor((Date.now() - startTime) / 1000)
|
|
@@ -265,6 +273,7 @@ export class CliExecutor {
|
|
|
265
273
|
child.on('error', (err) => {
|
|
266
274
|
clearInterval(progressInterval)
|
|
267
275
|
clearTimeout(timer)
|
|
276
|
+
streamLogger.flush()
|
|
268
277
|
writeStream.end()
|
|
269
278
|
this.currentSubprocess = null
|
|
270
279
|
logger.error(`Spawn error: ${err.message}`)
|
package/src/core/state.ts
CHANGED
|
@@ -144,10 +144,16 @@ export class StateManager {
|
|
|
144
144
|
* Clear saved state
|
|
145
145
|
*/
|
|
146
146
|
clearState(): void {
|
|
147
|
-
|
|
148
|
-
fs.
|
|
149
|
-
|
|
150
|
-
|
|
147
|
+
try {
|
|
148
|
+
if (fs.existsSync(this.stateFile)) {
|
|
149
|
+
fs.unlinkSync(this.stateFile)
|
|
150
|
+
if (this.config.debug) {
|
|
151
|
+
console.log('State cleared')
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch (e: any) {
|
|
155
|
+
if (e.code !== 'ENOENT') {
|
|
156
|
+
console.error('Failed to clear state')
|
|
151
157
|
}
|
|
152
158
|
}
|
|
153
159
|
}
|
package/src/core/utils.ts
CHANGED
|
@@ -98,13 +98,15 @@ export async function promptUser(
|
|
|
98
98
|
|
|
99
99
|
export class StreamLogger {
|
|
100
100
|
private buffer: string = ''
|
|
101
|
+
private prefix: string = ''
|
|
102
|
+
|
|
103
|
+
constructor(prefix?: string) {
|
|
104
|
+
this.prefix = prefix || ''
|
|
105
|
+
}
|
|
101
106
|
|
|
102
107
|
log(chunk: string | Buffer) {
|
|
103
108
|
this.buffer += chunk.toString('utf8')
|
|
104
109
|
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
110
|
this.buffer = lines.pop() || ''
|
|
109
111
|
|
|
110
112
|
for (const line of lines) {
|
|
@@ -113,7 +115,11 @@ export class StreamLogger {
|
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
private printLine(line: string) {
|
|
116
|
-
process.stdout.write(
|
|
118
|
+
process.stdout.write('\r\x1b[K')
|
|
119
|
+
const timestamp = chalk.gray(getTimestamp())
|
|
120
|
+
const separator = chalk.gray(' │')
|
|
121
|
+
const prefixStr = this.prefix ? ` ${chalk.magenta(`[${this.prefix}]`)}` : ''
|
|
122
|
+
process.stdout.write(`${timestamp}${separator}${prefixStr} ${chalk.dim(line)}\n`)
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
flush() {
|
package/src/monitor/index.ts
CHANGED
|
@@ -59,13 +59,19 @@ export class LoopworkMonitor {
|
|
|
59
59
|
// Spawn background process
|
|
60
60
|
const logStream = fs.openSync(logFile, 'a')
|
|
61
61
|
|
|
62
|
+
// Find the real package root - either we are in it, or it's in packages/loopwork
|
|
63
|
+
const currentPkgDir = path.join(this.projectRoot, 'packages/loopwork')
|
|
64
|
+
const spawnCwd = fs.existsSync(currentPkgDir) ? currentPkgDir : this.projectRoot
|
|
65
|
+
|
|
62
66
|
const child: ChildProcess = spawn('bun', ['run', 'src/index.ts', ...fullArgs], {
|
|
63
|
-
cwd:
|
|
67
|
+
cwd: spawnCwd,
|
|
64
68
|
detached: true,
|
|
65
69
|
stdio: ['ignore', logStream, logStream],
|
|
66
70
|
})
|
|
67
71
|
|
|
68
|
-
child.unref
|
|
72
|
+
if (child.unref) {
|
|
73
|
+
child.unref()
|
|
74
|
+
}
|
|
69
75
|
|
|
70
76
|
if (!child.pid) {
|
|
71
77
|
return { success: false, error: 'Failed to spawn process' }
|
|
@@ -174,32 +180,36 @@ export class LoopworkMonitor {
|
|
|
174
180
|
const namespaces: { name: string; status: 'running' | 'stopped'; lastRun?: string }[] = []
|
|
175
181
|
|
|
176
182
|
if (fs.existsSync(runsDir)) {
|
|
177
|
-
|
|
178
|
-
.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
for (const name of dirs) {
|
|
182
|
-
const isRunning = running.some(p => p.namespace === name)
|
|
183
|
-
const nsDir = path.join(runsDir, name)
|
|
184
|
-
|
|
185
|
-
// Find last run timestamp
|
|
186
|
-
let lastRun: string | undefined
|
|
187
|
-
const runDirs = fs.readdirSync(nsDir, { withFileTypes: true })
|
|
188
|
-
.filter(d => d.isDirectory() && d.name !== 'monitor-logs')
|
|
183
|
+
try {
|
|
184
|
+
const dirs = fs.readdirSync(runsDir, { withFileTypes: true })
|
|
185
|
+
.filter(d => d.isDirectory())
|
|
189
186
|
.map(d => d.name)
|
|
190
|
-
.sort()
|
|
191
|
-
.reverse()
|
|
192
187
|
|
|
193
|
-
|
|
194
|
-
|
|
188
|
+
for (const name of dirs) {
|
|
189
|
+
const isRunning = running.some(p => p.namespace === name)
|
|
190
|
+
const nsDir = path.join(runsDir, name)
|
|
191
|
+
|
|
192
|
+
// Find last run timestamp
|
|
193
|
+
let lastRun: string | undefined
|
|
194
|
+
try {
|
|
195
|
+
const runDirs = fs.readdirSync(nsDir, { withFileTypes: true })
|
|
196
|
+
.filter(d => d.isDirectory() && d.name !== 'monitor-logs')
|
|
197
|
+
.map(d => d.name)
|
|
198
|
+
.sort()
|
|
199
|
+
.reverse()
|
|
200
|
+
|
|
201
|
+
if (runDirs.length > 0) {
|
|
202
|
+
lastRun = runDirs[0]
|
|
203
|
+
}
|
|
204
|
+
} catch (e) {}
|
|
205
|
+
|
|
206
|
+
namespaces.push({
|
|
207
|
+
name,
|
|
208
|
+
status: isRunning ? 'running' : 'stopped',
|
|
209
|
+
lastRun,
|
|
210
|
+
})
|
|
195
211
|
}
|
|
196
|
-
|
|
197
|
-
namespaces.push({
|
|
198
|
-
name,
|
|
199
|
-
status: isRunning ? 'running' : 'stopped',
|
|
200
|
-
lastRun,
|
|
201
|
-
})
|
|
202
|
-
}
|
|
212
|
+
} catch (e) {}
|
|
203
213
|
}
|
|
204
214
|
|
|
205
215
|
return { running, namespaces }
|
|
@@ -220,13 +230,15 @@ export class LoopworkMonitor {
|
|
|
220
230
|
// Find most recent log file
|
|
221
231
|
const logsDir = path.join(this.projectRoot, 'loopwork-runs', namespace, 'monitor-logs')
|
|
222
232
|
if (fs.existsSync(logsDir)) {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
233
|
+
try {
|
|
234
|
+
const files = fs.readdirSync(logsDir)
|
|
235
|
+
.filter(f => f.endsWith('.log'))
|
|
236
|
+
.sort()
|
|
237
|
+
.reverse()
|
|
238
|
+
if (files.length > 0) {
|
|
239
|
+
logFile = path.join(logsDir, files[0])
|
|
240
|
+
}
|
|
241
|
+
} catch (e) {}
|
|
230
242
|
}
|
|
231
243
|
}
|
|
232
244
|
|
|
@@ -255,14 +267,24 @@ export class LoopworkMonitor {
|
|
|
255
267
|
try {
|
|
256
268
|
if (fs.existsSync(this.stateFile)) {
|
|
257
269
|
const content = fs.readFileSync(this.stateFile, 'utf-8')
|
|
270
|
+
if (!content || content.trim() === 'undefined') {
|
|
271
|
+
return { processes: [] }
|
|
272
|
+
}
|
|
258
273
|
return JSON.parse(content)
|
|
259
274
|
}
|
|
260
|
-
} catch {
|
|
275
|
+
} catch (e) {
|
|
276
|
+
console.error(`Failed to load monitor state: ${e}`)
|
|
277
|
+
}
|
|
261
278
|
return { processes: [] }
|
|
262
279
|
}
|
|
263
280
|
|
|
264
281
|
private saveState(state: MonitorState): void {
|
|
265
|
-
|
|
282
|
+
try {
|
|
283
|
+
if (!state) state = { processes: [] }
|
|
284
|
+
fs.writeFileSync(this.stateFile, JSON.stringify(state, null, 2))
|
|
285
|
+
} catch (e) {
|
|
286
|
+
console.error(`Failed to save monitor state: ${e}`)
|
|
287
|
+
}
|
|
266
288
|
}
|
|
267
289
|
}
|
|
268
290
|
|