@agentmessier/restwalker 1.0.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/node/app.ts ADDED
@@ -0,0 +1,767 @@
1
+ import Fastify from 'fastify'
2
+ import fastifyStatic from '@fastify/static'
3
+ import fastifySwagger from '@fastify/swagger'
4
+ import fastifySwaggerUi from '@fastify/swagger-ui'
5
+ import chokidar from 'chokidar'
6
+ import { join, dirname } from 'path'
7
+ import { fileURLToPath } from 'url'
8
+ import { homedir } from 'os'
9
+ import { createWriteStream, readFileSync, existsSync } from 'fs'
10
+
11
+ import * as db from './db.js'
12
+ import * as scheduler from './scheduler.js'
13
+ import { startQueue, setQueue, enqueueTask, forceRunTask } from './runner.js'
14
+ import type { Settings } from './db.js'
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url))
17
+ const PORT = parseInt(process.env.PORT ?? '47290')
18
+ const LOG_FILE = process.env.RESTWALKER_LOG ?? join(homedir(), '.restwalker', 'restwalker.log')
19
+
20
+ const app = Fastify({
21
+ logger: {
22
+ level: 'info',
23
+ stream: createWriteStream(LOG_FILE, { flags: 'a' }),
24
+ },
25
+ disableRequestLogging: true,
26
+ })
27
+
28
+ // ── OpenAPI ────────────────────────────────────────────────────────────────────
29
+
30
+ await app.register(fastifySwagger, {
31
+ openapi: {
32
+ openapi: '3.0.3',
33
+ info: {
34
+ title: 'Restwalker API',
35
+ description: 'Background Claude task queue with usage-gate scheduling',
36
+ version: '1.0.0',
37
+ },
38
+ tags: [
39
+ { name: 'health', description: 'Health and status' },
40
+ { name: 'usage', description: 'Claude usage monitoring and sync' },
41
+ { name: 'settings', description: 'Daemon configuration' },
42
+ { name: 'providers', description: 'Agent provider management' },
43
+ { name: 'queue', description: 'Task queue' },
44
+ { name: 'discovery', description: 'Models and projects' },
45
+ ],
46
+ },
47
+ })
48
+
49
+ await app.register(fastifySwaggerUi, {
50
+ routePrefix: '/docs',
51
+ uiConfig: { docExpansion: 'list', deepLinking: true },
52
+ })
53
+
54
+ // ── Static ─────────────────────────────────────────────────────────────────────
55
+
56
+ await app.register(fastifyStatic, {
57
+ root: join(__dirname, '..'),
58
+ serve: false,
59
+ })
60
+
61
+ // ── Reusable schemas ───────────────────────────────────────────────────────────
62
+
63
+ const S = {
64
+ provider: {
65
+ type: 'object',
66
+ properties: {
67
+ id: { type: 'integer' },
68
+ name: { type: 'string' },
69
+ command: { type: 'string' },
70
+ args_template:{ type: 'string' },
71
+ is_default: { type: 'integer' },
72
+ created_at: { type: 'string', format: 'date-time' },
73
+ },
74
+ },
75
+ task: {
76
+ type: 'object',
77
+ properties: {
78
+ id: { type: 'integer' },
79
+ description: { type: 'string' },
80
+ cwd: { type: 'string' },
81
+ model: { type: 'string' },
82
+ provider_id: { type: 'integer', nullable: true },
83
+ schedule: { type: 'string', enum: ['once','hourly','daily','weekly','monthly'] },
84
+ next_run_at: { type: 'string', nullable: true },
85
+ status: { type: 'string', enum: ['scheduled','pending','running','done','failed','cancelled'] },
86
+ result: { type: 'string', nullable: true },
87
+ session_id: { type: 'string', nullable: true },
88
+ session_path: { type: 'string', nullable: true },
89
+ tool_calls: { type: 'integer' },
90
+ tokens_used: { type: 'integer' },
91
+ created_at: { type: 'string', format: 'date-time' },
92
+ started_at: { type: 'string', nullable: true },
93
+ finished_at: { type: 'string', nullable: true },
94
+ },
95
+ },
96
+ ok: {
97
+ type: 'object',
98
+ properties: { ok: { type: 'boolean' } },
99
+ },
100
+ error: {
101
+ type: 'object',
102
+ properties: { error: { type: 'string' } },
103
+ },
104
+ } as const
105
+
106
+ // ── Sync helper ────────────────────────────────────────────────────────────────
107
+
108
+ async function doSync({ forceRefresh = false } = {}): Promise<void> {
109
+ const cfg = db.getSettings()
110
+ const staleS = parseFloat(cfg.CACHE_STALE_MIN) * 60
111
+ const usage = await scheduler.readUsage({ cacheStaleS: staleS, forceRefresh })
112
+ if (usage && !usage.stale) {
113
+ db.recordSnapshot(usage.five_hour_pct, usage.weekly_pct, usage.weekly_resets_at)
114
+ app.log.info(`[sync] 5h=${usage.five_hour_pct.toFixed(1)}% weekly=${usage.weekly_pct.toFixed(1)}% source=${usage.source}`)
115
+ }
116
+ }
117
+
118
+ // ── File watcher ───────────────────────────────────────────────────────────────
119
+
120
+ const watcher = chokidar.watch(scheduler.USAGE_CACHE, { persistent: true, ignoreInitial: true })
121
+ watcher.on('change', () => {
122
+ app.log.info('[watcher] cache file changed — syncing')
123
+ doSync().catch((e: Error) => app.log.warn('[watcher] sync error: ' + e.message))
124
+ })
125
+
126
+ // ── Background poller ──────────────────────────────────────────────────────────
127
+
128
+ function startPoller(): void {
129
+ const cfg = db.getSettings()
130
+ const intervalMs = parseFloat(cfg.POLL_INTERVAL_MIN) * 60_000
131
+ setTimeout(async () => {
132
+ await doSync({ forceRefresh: true }).catch((e: Error) => app.log.warn('[poller] ' + e.message))
133
+ startPoller()
134
+ }, intervalMs)
135
+ }
136
+
137
+ // ── Health ─────────────────────────────────────────────────────────────────────
138
+
139
+ app.get('/healthz', {
140
+ schema: {
141
+ tags: ['health'],
142
+ summary: 'Health check',
143
+ response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } } },
144
+ },
145
+ }, async () => ({ ok: true }))
146
+
147
+ app.get('/', async (_req, reply) => reply.sendFile('index.html'))
148
+
149
+ // ── Usage ──────────────────────────────────────────────────────────────────────
150
+
151
+ app.post('/sync', {
152
+ schema: {
153
+ tags: ['usage'],
154
+ summary: 'Force a usage cache refresh',
155
+ response: {
156
+ 200: { type: 'object', properties: { ok: { type: 'boolean' }, stale: { type: 'boolean' } } },
157
+ },
158
+ },
159
+ }, async () => {
160
+ const cfg = db.getSettings()
161
+ const staleS = parseFloat(cfg.CACHE_STALE_MIN) * 60
162
+ const usage = await scheduler.readUsage({ cacheStaleS: staleS, forceRefresh: true })
163
+ if (usage && !usage.stale) {
164
+ db.recordSnapshot(usage.five_hour_pct, usage.weekly_pct, usage.weekly_resets_at)
165
+ app.log.info(`[sync] 5h=${usage.five_hour_pct.toFixed(1)}% weekly=${usage.weekly_pct.toFixed(1)}% source=${usage.source}`)
166
+ }
167
+ return { ok: true, stale: usage?.stale ?? true }
168
+ })
169
+
170
+ app.get('/can-run', {
171
+ schema: {
172
+ tags: ['usage'],
173
+ summary: 'Check whether the gate is open for a project',
174
+ querystring: {
175
+ type: 'object',
176
+ properties: { project: { type: 'string', default: 'default' } },
177
+ },
178
+ response: {
179
+ 200: {
180
+ type: 'object',
181
+ properties: { ok: { type: 'boolean' }, reason: { type: 'string' }, provider: { type: 'string', nullable: true } },
182
+ },
183
+ },
184
+ },
185
+ }, async (req) => {
186
+ const project = (req.query as Record<string, string>).project ?? 'default'
187
+ const cfg = db.getSettings()
188
+ const staleS = parseFloat(cfg.CACHE_STALE_MIN) * 60
189
+ const usage = await scheduler.readUsage({ cacheStaleS: staleS })
190
+ const result = await scheduler.canRun(usage, cfg)
191
+ app.log.info(`[can-run] project=${project} ok=${result.ok} reason=${result.reason}`)
192
+ return result
193
+ })
194
+
195
+ app.get('/status', {
196
+ schema: {
197
+ tags: ['usage'],
198
+ summary: 'Full daemon status including usage, window, and thresholds',
199
+ response: {
200
+ 200: {
201
+ type: 'object',
202
+ properties: {
203
+ window: { type: 'string', enum: ['coding', 'idle'] },
204
+ next_idle_in_s: { type: 'number', nullable: true },
205
+ ok: { type: 'boolean' },
206
+ provider: { type: 'string', nullable: true },
207
+ reason: { type: 'string' },
208
+ usage: {
209
+ type: 'object',
210
+ properties: {
211
+ five_hour_pct: { type: 'number', nullable: true },
212
+ weekly_pct: { type: 'number', nullable: true },
213
+ weekly_resets_at: { type: 'string', nullable: true },
214
+ cache_age_s: { type: 'number', nullable: true },
215
+ stale: { type: 'boolean' },
216
+ source: { type: 'string', nullable: true },
217
+ },
218
+ },
219
+ thresholds: {
220
+ type: 'object',
221
+ properties: {
222
+ coding_start_h: { type: 'number' },
223
+ coding_end_h: { type: 'number' },
224
+ timezone: { type: 'string' },
225
+ five_hour_pause_pct: { type: 'number' },
226
+ weekly_reserve_pct: { type: 'number' },
227
+ weekly_hard_stop_pct: { type: 'number' },
228
+ cache_stale_min: { type: 'number' },
229
+ poll_interval_min: { type: 'number' },
230
+ },
231
+ },
232
+ },
233
+ },
234
+ },
235
+ },
236
+ }, async () => {
237
+ const cfg = db.getSettings()
238
+ const staleS = parseFloat(cfg.CACHE_STALE_MIN) * 60
239
+ const now = new Date()
240
+ const usage = await scheduler.readUsage({ cacheStaleS: staleS })
241
+ const decision = await scheduler.canRun(usage, cfg)
242
+ const snap = db.latestSnapshot()
243
+
244
+ const startH = parseInt(cfg.CODING_START_H)
245
+ const endH = parseInt(cfg.CODING_END_H)
246
+ const tz = cfg.TIMEZONE
247
+
248
+ return {
249
+ window: scheduler.isCodingWindow(now, startH, endH, tz) ? 'coding' : 'idle',
250
+ next_idle_in_s: scheduler.nextIdleInS(now, startH, endH, tz),
251
+ ok: decision.ok,
252
+ provider: decision.provider,
253
+ reason: decision.reason,
254
+ usage: {
255
+ five_hour_pct: usage?.five_hour_pct ?? null,
256
+ weekly_pct: usage?.weekly_pct ?? null,
257
+ weekly_resets_at: usage?.weekly_resets_at ?? null,
258
+ cache_age_s: usage?.age_s != null ? Math.round(usage.age_s * 10) / 10 : null,
259
+ stale: usage?.stale ?? true,
260
+ source: usage?.source ?? null,
261
+ },
262
+ last_db_snapshot: snap,
263
+ thresholds: {
264
+ coding_start_h: startH,
265
+ coding_end_h: endH,
266
+ timezone: tz,
267
+ five_hour_pause_pct: parseFloat(cfg.FIVE_HOUR_PAUSE_PCT),
268
+ weekly_reserve_pct: parseFloat(cfg.WEEKLY_RESERVE_PCT),
269
+ weekly_hard_stop_pct: parseFloat(cfg.WEEKLY_HARD_STOP_PCT),
270
+ cache_stale_min: parseFloat(cfg.CACHE_STALE_MIN),
271
+ poll_interval_min: parseFloat(cfg.POLL_INTERVAL_MIN),
272
+ },
273
+ }
274
+ })
275
+
276
+ app.get('/history', {
277
+ schema: {
278
+ tags: ['usage'],
279
+ summary: 'Usage history bucketed into 15-minute intervals',
280
+ querystring: {
281
+ type: 'object',
282
+ properties: { hours: { type: 'integer', default: 48, minimum: 1, maximum: 720 } },
283
+ },
284
+ response: {
285
+ 200: {
286
+ type: 'object',
287
+ properties: {
288
+ history: {
289
+ type: 'array',
290
+ items: {
291
+ type: 'object',
292
+ properties: {
293
+ bucket: { type: 'string', format: 'date-time' },
294
+ five_hour_pct: { type: 'number' },
295
+ weekly_pct: { type: 'number' },
296
+ samples: { type: 'integer' },
297
+ },
298
+ },
299
+ },
300
+ },
301
+ },
302
+ },
303
+ },
304
+ }, async (req) => {
305
+ const hours = parseInt((req.query as Record<string, string>).hours ?? '48')
306
+ return { history: db.usageHistory(hours) }
307
+ })
308
+
309
+ // ── Settings ───────────────────────────────────────────────────────────────────
310
+
311
+ app.get('/settings', {
312
+ schema: {
313
+ tags: ['settings'],
314
+ summary: 'Get all settings',
315
+ response: {
316
+ 200: {
317
+ type: 'object',
318
+ additionalProperties: { type: 'string' },
319
+ },
320
+ },
321
+ },
322
+ }, async () => db.getSettings())
323
+
324
+ app.post('/settings', {
325
+ schema: {
326
+ tags: ['settings'],
327
+ summary: 'Update one or more settings',
328
+ body: {
329
+ type: 'object',
330
+ additionalProperties: { type: 'string' },
331
+ },
332
+ response: {
333
+ 200: { type: 'object', properties: { ok: { type: 'boolean' }, settings: { type: 'object', additionalProperties: { type: 'string' } } } },
334
+ 422: S.error,
335
+ },
336
+ },
337
+ }, async (req, reply) => {
338
+ try {
339
+ db.updateSettings(req.body as Partial<Settings>)
340
+ return { ok: true, settings: db.getSettings() }
341
+ } catch (e) {
342
+ return reply.code(422).send({ error: (e as Error).message })
343
+ }
344
+ })
345
+
346
+ // ── Providers ──────────────────────────────────────────────────────────────────
347
+
348
+ app.get('/providers', {
349
+ schema: {
350
+ tags: ['providers'],
351
+ summary: 'List all agent providers',
352
+ response: { 200: { type: 'object', properties: { providers: { type: 'array', items: S.provider } } } },
353
+ },
354
+ }, async () => ({ providers: db.getProviders() }))
355
+
356
+ app.post('/providers', {
357
+ schema: {
358
+ tags: ['providers'],
359
+ summary: 'Add an agent provider',
360
+ body: {
361
+ type: 'object',
362
+ required: ['name', 'command'],
363
+ properties: {
364
+ name: { type: 'string' },
365
+ command: { type: 'string' },
366
+ args_template:{ type: 'string', description: 'JSON string array with {{task}}, {{model}}, {{cwd}} placeholders' },
367
+ },
368
+ },
369
+ response: {
370
+ 200: { type: 'object', properties: { ok: { type: 'boolean' }, provider: S.provider } },
371
+ 400: S.error,
372
+ },
373
+ },
374
+ }, async (req, reply) => {
375
+ const { name, command, args_template } = req.body as { name?: string; command?: string; args_template?: string }
376
+ if (!name?.trim() || !command?.trim()) return reply.code(400).send({ error: 'name and command required' })
377
+ const provider = db.addProvider(name.trim(), command.trim(), args_template?.trim() || '["{{task}}"]')
378
+ return { ok: true, provider }
379
+ })
380
+
381
+ app.put('/providers/:id', {
382
+ schema: {
383
+ tags: ['providers'],
384
+ summary: 'Update an agent provider',
385
+ params: { type: 'object', required: ['id'], properties: { id: { type: 'integer' } } },
386
+ body: {
387
+ type: 'object',
388
+ properties: {
389
+ name: { type: 'string' },
390
+ command: { type: 'string' },
391
+ args_template:{ type: 'string' },
392
+ },
393
+ },
394
+ response: { 200: S.ok, 404: S.error },
395
+ },
396
+ }, async (req, reply) => {
397
+ const id = parseInt((req.params as { id: string }).id)
398
+ if (!db.getProvider(id)) return reply.code(404).send({ error: 'not found' })
399
+ const { name, command, args_template } = req.body as Partial<db.Provider>
400
+ db.updateProvider(id, { name, command, args_template })
401
+ return { ok: true }
402
+ })
403
+
404
+ app.post('/providers/:id/default', {
405
+ schema: {
406
+ tags: ['providers'],
407
+ summary: 'Set a provider as the default',
408
+ params: { type: 'object', required: ['id'], properties: { id: { type: 'integer' } } },
409
+ response: { 200: S.ok, 404: S.error },
410
+ },
411
+ }, async (req, reply) => {
412
+ const id = parseInt((req.params as { id: string }).id)
413
+ if (!db.getProvider(id)) return reply.code(404).send({ error: 'not found' })
414
+ db.setDefaultProvider(id)
415
+ return { ok: true }
416
+ })
417
+
418
+ app.delete('/providers/:id', {
419
+ schema: {
420
+ tags: ['providers'],
421
+ summary: 'Delete an agent provider',
422
+ params: { type: 'object', required: ['id'], properties: { id: { type: 'integer' } } },
423
+ response: { 200: S.ok, 404: S.error, 409: S.error },
424
+ },
425
+ }, async (req, reply) => {
426
+ const id = parseInt((req.params as { id: string }).id)
427
+ if (!db.getProvider(id)) return reply.code(404).send({ error: 'not found' })
428
+ if (db.getProviders().length <= 1) return reply.code(409).send({ error: 'cannot delete last provider' })
429
+ db.deleteProvider(id)
430
+ return { ok: true }
431
+ })
432
+
433
+ // ── Discovery ──────────────────────────────────────────────────────────────────
434
+
435
+ app.get('/projects', {
436
+ schema: {
437
+ tags: ['discovery'],
438
+ summary: 'List Claude Code projects from history, sorted by recency',
439
+ response: {
440
+ 200: {
441
+ type: 'object',
442
+ properties: {
443
+ projects: {
444
+ type: 'array',
445
+ items: {
446
+ type: 'object',
447
+ properties: {
448
+ cwd: { type: 'string' },
449
+ last_active: { type: 'string', format: 'date-time' },
450
+ },
451
+ },
452
+ },
453
+ },
454
+ },
455
+ },
456
+ },
457
+ }, async () => {
458
+ const historyFile = join(homedir(), '.claude', 'history.jsonl')
459
+ if (!existsSync(historyFile)) return { projects: [] }
460
+
461
+ const seen = new Map<string, number>()
462
+ try {
463
+ const lines = readFileSync(historyFile, 'utf8').split('\n').filter(Boolean)
464
+ for (const line of lines) {
465
+ const d = JSON.parse(line) as { project?: string; timestamp?: number }
466
+ const p = d.project?.trim()
467
+ if (!p || !existsSync(p)) continue
468
+ const ts = d.timestamp ?? 0
469
+ if (ts > (seen.get(p) ?? 0)) seen.set(p, ts)
470
+ }
471
+ } catch { return { projects: [] } }
472
+
473
+ return {
474
+ projects: [...seen.entries()]
475
+ .sort((a, b) => b[1] - a[1])
476
+ .map(([cwd, ts]) => ({ cwd, last_active: new Date(ts).toISOString() }))
477
+ }
478
+ })
479
+
480
+ app.get('/models', {
481
+ schema: {
482
+ tags: ['discovery'],
483
+ summary: 'List available Anthropic models from the live API',
484
+ response: {
485
+ 200: {
486
+ type: 'object',
487
+ properties: {
488
+ models: {
489
+ type: 'array',
490
+ items: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' } } },
491
+ },
492
+ },
493
+ },
494
+ 502: S.error,
495
+ 503: S.error,
496
+ },
497
+ },
498
+ }, async (_req, reply) => {
499
+ const token = scheduler.readKeychainToken()
500
+ if (!token) return reply.code(503).send({ error: 'no auth token' })
501
+ try {
502
+ const res = await fetch('https://api.anthropic.com/v1/models', {
503
+ headers: { 'Authorization': `Bearer ${token}`, 'anthropic-version': '2023-06-01' },
504
+ signal: AbortSignal.timeout(8000),
505
+ })
506
+ const data = await res.json() as { data: { id: string; display_name: string }[] }
507
+ return { models: data.data.map(m => ({ id: m.id, name: m.display_name })) }
508
+ } catch (e) {
509
+ return reply.code(502).send({ error: (e as Error).message })
510
+ }
511
+ })
512
+
513
+ // ── Queue ──────────────────────────────────────────────────────────────────────
514
+
515
+ app.get('/queue/stats', {
516
+ schema: {
517
+ tags: ['queue'],
518
+ summary: 'Task counts by status',
519
+ response: {
520
+ 200: {
521
+ type: 'object',
522
+ properties: {
523
+ scheduled: { type: 'integer' },
524
+ pending: { type: 'integer' },
525
+ running: { type: 'integer' },
526
+ done: { type: 'integer' },
527
+ failed: { type: 'integer' },
528
+ total: { type: 'integer' },
529
+ },
530
+ },
531
+ },
532
+ },
533
+ }, async () => db.queueStats())
534
+
535
+ app.get('/queue', {
536
+ schema: {
537
+ tags: ['queue'],
538
+ summary: 'List tasks with pagination',
539
+ querystring: {
540
+ type: 'object',
541
+ properties: {
542
+ limit: { type: 'integer', default: 25, minimum: 1, maximum: 100 },
543
+ offset: { type: 'integer', default: 0, minimum: 0 },
544
+ },
545
+ },
546
+ response: {
547
+ 200: {
548
+ type: 'object',
549
+ properties: {
550
+ tasks: { type: 'array', items: S.task },
551
+ total: { type: 'integer' },
552
+ },
553
+ },
554
+ },
555
+ },
556
+ }, async (req) => {
557
+ const q = req.query as Record<string, string>
558
+ const limit = Math.min(parseInt(q.limit ?? '25'), 100)
559
+ const offset = Math.max(parseInt(q.offset ?? '0'), 0)
560
+ return { tasks: db.getTasks(limit, offset), total: db.getTaskCount() }
561
+ })
562
+
563
+ app.get('/queue/:id', {
564
+ schema: {
565
+ tags: ['queue'],
566
+ summary: 'Get a single task',
567
+ params: { type: 'object', required: ['id'], properties: { id: { type: 'integer' } } },
568
+ response: { 200: S.task, 404: S.error },
569
+ },
570
+ }, async (req, reply) => {
571
+ const task = db.getTask(parseInt((req.params as { id: string }).id))
572
+ if (!task) return reply.code(404).send({ error: 'not found' })
573
+ return task
574
+ })
575
+
576
+ app.post('/queue', {
577
+ schema: {
578
+ tags: ['queue'],
579
+ summary: 'Enqueue a new task',
580
+ body: {
581
+ type: 'object',
582
+ required: ['description'],
583
+ properties: {
584
+ description: { type: 'string' },
585
+ cwd: { type: 'string' },
586
+ model: { type: 'string' },
587
+ provider_id: { type: 'integer' },
588
+ schedule: { type: 'string', enum: ['once','hourly','daily','weekly','monthly'], default: 'once' },
589
+ },
590
+ },
591
+ response: {
592
+ 200: { type: 'object', properties: { ok: { type: 'boolean' }, task: S.task } },
593
+ 400: S.error,
594
+ },
595
+ },
596
+ }, async (req, reply) => {
597
+ const { description, cwd, model, provider_id, schedule } =
598
+ req.body as { description?: string; cwd?: string; model?: string; provider_id?: number; schedule?: db.TaskSchedule }
599
+ if (!description?.trim()) return reply.code(400).send({ error: 'description required' })
600
+ const task = db.addTask(description.trim(), cwd?.trim(), model?.trim(), provider_id, schedule || 'once')
601
+ enqueueTask(task)
602
+ app.log.info(`[queue] added #${task.id} (${task.schedule}): ${description.slice(0, 80)}`)
603
+ return { ok: true, task }
604
+ })
605
+
606
+ app.delete('/queue/:id', {
607
+ schema: {
608
+ tags: ['queue'],
609
+ summary: 'Delete a task (any status except running)',
610
+ params: { type: 'object', required: ['id'], properties: { id: { type: 'integer' } } },
611
+ response: { 200: S.ok, 404: S.error, 409: S.error },
612
+ },
613
+ }, async (req, reply) => {
614
+ const id = parseInt((req.params as { id: string }).id)
615
+ const task = db.getTask(id)
616
+ if (!task) return reply.code(404).send({ error: 'not found' })
617
+ if (task.status === 'running') return reply.code(409).send({ error: 'cannot delete a running task' })
618
+ const deleted = db.deleteTask(id)
619
+ if (!deleted) return reply.code(404).send({ error: 'not found' })
620
+ return { ok: true }
621
+ })
622
+
623
+ app.post('/queue/:id/force-run', {
624
+ schema: {
625
+ tags: ['queue'],
626
+ summary: 'Force-run a pending task immediately, bypassing the usage gate',
627
+ params: { type: 'object', required: ['id'], properties: { id: { type: 'integer' } } },
628
+ response: {
629
+ 200: S.ok,
630
+ 404: S.error,
631
+ 409: { type: 'object', properties: { ok: { type: 'boolean' }, error: { type: 'string' } } },
632
+ },
633
+ },
634
+ }, async (req, reply) => {
635
+ const id = parseInt((req.params as { id: string }).id)
636
+ const task = db.getTask(id)
637
+ if (!task) return reply.code(404).send({ error: 'not found' })
638
+ return forceRunTask(id, msg => app.log.info(msg))
639
+ })
640
+
641
+ app.get('/queue/:id/session', {
642
+ schema: {
643
+ tags: ['queue'],
644
+ summary: 'Get the parsed Claude Code session transcript for a task',
645
+ params: { type: 'object', required: ['id'], properties: { id: { type: 'integer' } } },
646
+ response: {
647
+ 200: {
648
+ type: 'object',
649
+ properties: {
650
+ turns: {
651
+ type: 'array',
652
+ items: {
653
+ type: 'object',
654
+ properties: {
655
+ role: { type: 'string', enum: ['user', 'assistant'] },
656
+ text: { type: 'string', nullable: true },
657
+ thinking: { type: 'string', nullable: true },
658
+ tool_calls: {
659
+ type: 'array',
660
+ items: {
661
+ type: 'object',
662
+ properties: {
663
+ name: { type: 'string' },
664
+ input: { type: 'object', additionalProperties: true },
665
+ result: { type: 'string', nullable: true },
666
+ },
667
+ },
668
+ },
669
+ },
670
+ },
671
+ },
672
+ },
673
+ },
674
+ 404: S.error,
675
+ 500: S.error,
676
+ },
677
+ },
678
+ }, async (req, reply) => {
679
+ const task = db.getTask(parseInt((req.params as { id: string }).id))
680
+ if (!task) return reply.code(404).send({ error: 'not found' })
681
+ if (!task.session_path) return reply.code(404).send({ error: 'no session recorded' })
682
+ if (!existsSync(task.session_path)) return reply.code(404).send({ error: 'session file missing' })
683
+
684
+ try {
685
+ const lines = readFileSync(task.session_path, 'utf8').split('\n').filter(Boolean)
686
+
687
+ const toolResults = new Map<string, string>()
688
+ for (const line of lines) {
689
+ const e = JSON.parse(line)
690
+ if (e.type === 'user' && Array.isArray(e.message?.content)) {
691
+ for (const b of e.message.content) {
692
+ if (b.type !== 'tool_result') continue
693
+ const raw = Array.isArray(b.content)
694
+ ? b.content.map((c: {text?: string}) => c.text ?? '').join('')
695
+ : String(b.content ?? '')
696
+ toolResults.set(b.tool_use_id, raw.slice(0, 3000))
697
+ }
698
+ }
699
+ }
700
+
701
+ interface Turn {
702
+ role: 'user' | 'assistant'
703
+ text: string | null
704
+ thinking: string | null
705
+ tool_calls: { name: string; input: Record<string, unknown>; result: string | null }[]
706
+ }
707
+ const turns: Turn[] = []
708
+ let firstUser = true
709
+
710
+ for (const line of lines) {
711
+ const e = JSON.parse(line)
712
+
713
+ if (e.type === 'user') {
714
+ const content = e.message?.content
715
+ let text: string | null = null
716
+ if (typeof content === 'string') text = content.trim() || null
717
+ else if (Array.isArray(content)) {
718
+ const parts = content.filter((b: {type:string}) => b.type === 'text').map((b: {text:string}) => b.text)
719
+ text = parts.join('\n').trim() || null
720
+ }
721
+ if (firstUser) { firstUser = false; if (text) turns.push({ role: 'user', text, thinking: null, tool_calls: [] }) }
722
+ else if (text) turns.push({ role: 'user', text, thinking: null, tool_calls: [] })
723
+
724
+ } else if (e.type === 'assistant') {
725
+ const content = e.message?.content
726
+ if (!Array.isArray(content)) continue
727
+ const thinking = content.filter((b: {type:string}) => b.type === 'thinking')
728
+ .map((b: {thinking:string}) => b.thinking).join('\n').slice(0, 8000) || null
729
+ const text = content.filter((b: {type:string}) => b.type === 'text')
730
+ .map((b: {text:string}) => b.text).join('\n').trim().slice(0, 4000) || null
731
+ const tool_calls = content.filter((b: {type:string}) => b.type === 'tool_use')
732
+ .map((b: {id:string;name:string;input:Record<string,unknown>}) => ({
733
+ name: b.name,
734
+ input: b.input,
735
+ result: toolResults.get(b.id) ?? null,
736
+ }))
737
+ if (thinking || text || tool_calls.length)
738
+ turns.push({ role: 'assistant', text, thinking, tool_calls })
739
+ }
740
+ }
741
+
742
+ return { turns }
743
+ } catch (e) {
744
+ return reply.code(500).send({ error: (e as Error).message })
745
+ }
746
+ })
747
+
748
+ // ── Start ──────────────────────────────────────────────────────────────────────
749
+
750
+ function startScheduleChecker(): void {
751
+ setInterval(() => {
752
+ const due = db.getScheduledDueTasks()
753
+ for (const task of due) {
754
+ db.setTaskPending(task.id)
755
+ enqueueTask(task)
756
+ app.log.info(`[schedule] enqueued #${task.id} (${task.schedule})`)
757
+ }
758
+ }, 60_000)
759
+ }
760
+
761
+ db.migrate()
762
+ await app.listen({ host: '0.0.0.0', port: PORT })
763
+ app.log.info(`[restwalker] running on http://localhost:${PORT}`)
764
+ app.log.info(`[restwalker] watching ${scheduler.USAGE_CACHE}`)
765
+ startPoller()
766
+ startScheduleChecker()
767
+ setQueue(startQueue(msg => app.log.info(msg)))