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.
Files changed (46) hide show
  1. package/bin/loopwork +0 -0
  2. package/package.json +48 -4
  3. package/src/backends/github.ts +6 -3
  4. package/src/backends/json.ts +28 -10
  5. package/src/commands/run.ts +2 -2
  6. package/src/contracts/config.ts +3 -75
  7. package/src/contracts/index.ts +0 -6
  8. package/src/core/cli.ts +25 -16
  9. package/src/core/state.ts +10 -4
  10. package/src/core/utils.ts +10 -4
  11. package/src/monitor/index.ts +56 -34
  12. package/src/plugins/index.ts +9 -131
  13. package/examples/README.md +0 -70
  14. package/examples/basic-json-backend/.specs/tasks/TASK-001.md +0 -22
  15. package/examples/basic-json-backend/.specs/tasks/TASK-002.md +0 -23
  16. package/examples/basic-json-backend/.specs/tasks/TASK-003.md +0 -37
  17. package/examples/basic-json-backend/.specs/tasks/tasks.json +0 -19
  18. package/examples/basic-json-backend/README.md +0 -32
  19. package/examples/basic-json-backend/TESTING.md +0 -184
  20. package/examples/basic-json-backend/hello.test.ts +0 -9
  21. package/examples/basic-json-backend/hello.ts +0 -3
  22. package/examples/basic-json-backend/loopwork.config.js +0 -35
  23. package/examples/basic-json-backend/math.test.ts +0 -29
  24. package/examples/basic-json-backend/math.ts +0 -3
  25. package/examples/basic-json-backend/package.json +0 -15
  26. package/examples/basic-json-backend/quick-start.sh +0 -80
  27. package/loopwork.config.ts +0 -164
  28. package/src/plugins/asana.ts +0 -192
  29. package/src/plugins/cost-tracking.ts +0 -402
  30. package/src/plugins/discord.ts +0 -269
  31. package/src/plugins/everhour.ts +0 -335
  32. package/src/plugins/telegram/bot.ts +0 -517
  33. package/src/plugins/telegram/index.ts +0 -6
  34. package/src/plugins/telegram/notifications.ts +0 -198
  35. package/src/plugins/todoist.ts +0 -261
  36. package/test/backends.test.ts +0 -929
  37. package/test/cli.test.ts +0 -145
  38. package/test/config.test.ts +0 -90
  39. package/test/e2e.test.ts +0 -458
  40. package/test/github-tasks.test.ts +0 -191
  41. package/test/loopwork-config-types.test.ts +0 -288
  42. package/test/monitor.test.ts +0 -123
  43. package/test/plugins.test.ts +0 -1175
  44. package/test/state.test.ts +0 -295
  45. package/test/utils.test.ts +0 -60
  46. 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.0",
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
  }
@@ -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: string
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
- return tasks[0] || null
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,body,labels,url --limit 100`.quiet()
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
  })
@@ -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
- return tasks[0] || null
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
- const task = await this.loadFullTask(entry, data)
198
- if (task) {
199
- // Check if task dependencies are met
200
- if (!options?.includeBlocked && task.dependsOn && task.dependsOn.length > 0) {
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
  */
@@ -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 '../plugins/cost-tracking'
10
- import { createTelegramHookPlugin } from '../plugins/telegram/notifications'
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[] {
@@ -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: 'opencode',
46
+ cli: 'claude',
119
47
  maxIterations: 50,
120
48
  timeout: 600,
121
49
  namespace: 'default',
@@ -33,11 +33,5 @@ export type {
33
33
  // Config types
34
34
  export type {
35
35
  LoopworkConfig,
36
- TelegramConfig,
37
- DiscordConfig,
38
- AsanaConfig,
39
- EverhourConfig,
40
- TodoistConfig,
41
- CostTrackingConfig,
42
36
  } from './config'
43
37
  export { DEFAULT_CONFIG } from './config'
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(`[${cliConfig.name}] Executing: ${cmdDisplay}`)
136
- logger.info(`[${cliConfig.name}] Timeout: ${timeoutSecs}s`)
137
- logger.info(`[${cliConfig.name}] Log file: ${outputFile}`)
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
- { env, input: cliConfig.cli === 'claude' ? prompt : undefined },
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 ${cliConfig.name}`)
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 ${cliConfig.name}, waiting 30s...`)
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 ${cliConfig.name}`)
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
- process.stdout.write(data)
222
+ streamLogger.log(data)
216
223
  })
217
224
 
218
225
  child.stderr?.on('data', (data) => {
219
226
  writeStream.write(data)
220
- process.stderr.write(data)
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
- if (fs.existsSync(this.stateFile)) {
148
- fs.unlinkSync(this.stateFile)
149
- if (this.config.debug) {
150
- console.log('State cleared')
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(`${chalk.gray(getTimestamp())} ${line}\n`)
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() {
@@ -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: path.join(this.projectRoot, 'packages/loopwork'),
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
- const dirs = fs.readdirSync(runsDir, { withFileTypes: true })
178
- .filter(d => d.isDirectory())
179
- .map(d => d.name)
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
- if (runDirs.length > 0) {
194
- lastRun = runDirs[0]
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
- const files = fs.readdirSync(logsDir)
224
- .filter(f => f.endsWith('.log'))
225
- .sort()
226
- .reverse()
227
- if (files.length > 0) {
228
- logFile = path.join(logsDir, files[0])
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
- fs.writeFileSync(this.stateFile, JSON.stringify(state, null, 2))
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