@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/README.md +137 -0
- package/bin/restwalker.js +81 -0
- package/index.html +1161 -0
- package/install.sh +176 -0
- package/node/app.ts +767 -0
- package/node/db.ts +392 -0
- package/node/mcp.ts +217 -0
- package/node/package-lock.json +4552 -0
- package/node/package.json +32 -0
- package/node/runner.ts +174 -0
- package/node/scheduler.ts +221 -0
- package/node/schema.ts +46 -0
- package/node/session.ts +119 -0
- package/node/tsconfig.json +14 -0
- package/package.json +39 -0
- package/uninstall.sh +36 -0
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)))
|