@miphamai/cli 0.31.1 → 0.32.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/daemon.ts +40 -0
- package/bin/mipham.ts +796 -1
- package/package.json +1 -1
- package/src/agent/background-registry.ts +80 -1
- package/src/daemon/agent-manager.ts +110 -0
- package/src/daemon/attach-protocol.ts +74 -0
- package/src/daemon/auth.ts +104 -0
- package/src/daemon/cors.ts +68 -0
- package/src/daemon/database.ts +449 -0
- package/src/daemon/goal-manager.ts +53 -0
- package/src/daemon/index.ts +307 -0
- package/src/daemon/message-bus.ts +84 -0
- package/src/daemon/rate-limiter.ts +70 -0
- package/src/daemon/remote-engine.ts +434 -0
- package/src/daemon/schedule-manager.ts +159 -0
- package/src/daemon/server.ts +632 -0
- package/src/daemon/session-manager.ts +45 -0
- package/src/daemon/session-worker.ts +418 -0
- package/src/daemon/types.ts +98 -0
- package/src/daemon/worker-pool.ts +174 -0
- package/src/index.tsx +44 -4
- package/src/ui/app.tsx +6 -2
- package/src/ui/chat.tsx +39 -33
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// apps/cli/src/daemon/remote-engine.ts — Phase 2 Task 6: Remote Engine
|
|
2
|
+
//
|
|
3
|
+
// RemoteEngine is a WebSocket-based substitute for the local QueryEngine.
|
|
4
|
+
// It exposes the same `process(prompt, signal)` async generator interface
|
|
5
|
+
// so the TUI can consume it identically when running in `mipham attach` mode.
|
|
6
|
+
//
|
|
7
|
+
// Architecture:
|
|
8
|
+
// RemoteEngine
|
|
9
|
+
// owns WebSocket connection to daemon
|
|
10
|
+
// converts ServerMessage stream → StreamChunk stream
|
|
11
|
+
// bridges event-driven WS to async generator via queue + resolver
|
|
12
|
+
//
|
|
13
|
+
// Usage:
|
|
14
|
+
// const engine = new RemoteEngine({ sessionId, port, token })
|
|
15
|
+
// for await (const chunk of engine.process('fix the bug')) {
|
|
16
|
+
// // same StreamChunk as local QueryEngine
|
|
17
|
+
// }
|
|
18
|
+
// engine.close()
|
|
19
|
+
|
|
20
|
+
import type { ClientPromptMessage, ClientInterruptMessage, ServerMessage } from './attach-protocol'
|
|
21
|
+
import type { StreamChunk } from '../shared/types'
|
|
22
|
+
|
|
23
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface RemoteEngineOptions {
|
|
26
|
+
/** Daemon session ID — the session to connect to. */
|
|
27
|
+
sessionId: string
|
|
28
|
+
/** Daemon HTTP/WS port (default 45671). */
|
|
29
|
+
port: number
|
|
30
|
+
/** Auth token (for future non-localhost use; localhost bypasses auth). */
|
|
31
|
+
token: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* WebSocket-based substitute for the local QueryEngine.
|
|
36
|
+
*
|
|
37
|
+
* Connects to a running daemon's WebSocket endpoint and streams prompt
|
|
38
|
+
* responses as StreamChunk objects — the same interface the TUI already
|
|
39
|
+
* consumes from the local engine.
|
|
40
|
+
*/
|
|
41
|
+
export class RemoteEngine {
|
|
42
|
+
private sessionId: string
|
|
43
|
+
private port: number
|
|
44
|
+
private token: string
|
|
45
|
+
private ws: WebSocket | null = null
|
|
46
|
+
|
|
47
|
+
/** Queued chunks not yet consumed by the async generator. */
|
|
48
|
+
private chunkQueue: StreamChunk[] = []
|
|
49
|
+
|
|
50
|
+
/** Resolver for the currently-waiting generator yield. */
|
|
51
|
+
private resolveNext: ((chunk: StreamChunk) => void) | null = null
|
|
52
|
+
private rejectNext: ((reason: Error) => void) | null = null
|
|
53
|
+
|
|
54
|
+
/** Whether this engine has been explicitly closed. */
|
|
55
|
+
private closed = false
|
|
56
|
+
|
|
57
|
+
constructor(options: RemoteEngineOptions) {
|
|
58
|
+
this.sessionId = options.sessionId
|
|
59
|
+
this.port = options.port
|
|
60
|
+
this.token = options.token
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Connection Management ───────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Ensure a WebSocket connection to the daemon exists.
|
|
67
|
+
* Creates one lazily on the first process() call.
|
|
68
|
+
*/
|
|
69
|
+
private ensureConnected(): Promise<void> {
|
|
70
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
71
|
+
return Promise.resolve()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (this.ws) {
|
|
75
|
+
// Stale socket — clean up before reconnecting
|
|
76
|
+
this.ws.onopen = null
|
|
77
|
+
this.ws.onmessage = null
|
|
78
|
+
this.ws.onclose = null
|
|
79
|
+
this.ws.onerror = null
|
|
80
|
+
try {
|
|
81
|
+
this.ws.close()
|
|
82
|
+
} catch {
|
|
83
|
+
// Ignore close errors on stale socket
|
|
84
|
+
}
|
|
85
|
+
this.ws = null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const url = `ws://127.0.0.1:${this.port}/api/v1/sessions/${this.sessionId}/stream`
|
|
89
|
+
|
|
90
|
+
return new Promise<void>((resolve, reject) => {
|
|
91
|
+
const ws = new WebSocket(url)
|
|
92
|
+
this.ws = ws
|
|
93
|
+
|
|
94
|
+
ws.onopen = () => {
|
|
95
|
+
resolve()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
ws.onmessage = (event: MessageEvent<string>) => {
|
|
99
|
+
this.onMessage(event.data)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
ws.onclose = () => {
|
|
103
|
+
// Only signal to the generator if there is an active wait.
|
|
104
|
+
if (this.resolveNext) {
|
|
105
|
+
const doneChunk: StreamChunk = { type: 'stop' }
|
|
106
|
+
this.resolveNext(doneChunk)
|
|
107
|
+
this.resolveNext = null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
ws.onerror = () => {
|
|
112
|
+
reject(new Error(`Failed to connect to daemon at 127.0.0.1:${this.port}`))
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Prompt Processing (async generator) ──────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Process a user prompt through the remote daemon session.
|
|
121
|
+
*
|
|
122
|
+
* Internally sends a `prompt` message over the WebSocket and yields
|
|
123
|
+
* StreamChunk objects as they arrive from the daemon. The async generator
|
|
124
|
+
* stops naturally when a `done` or `error` message is received.
|
|
125
|
+
*
|
|
126
|
+
* @param prompt The user's text prompt to send.
|
|
127
|
+
* @param signal Optional AbortSignal — aborts the prompt (sends interrupt).
|
|
128
|
+
*/
|
|
129
|
+
async *process(prompt: string, signal?: AbortSignal): AsyncGenerator<StreamChunk> {
|
|
130
|
+
if (this.closed) {
|
|
131
|
+
yield { type: 'error', error: 'RemoteEngine is closed' }
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Check signal before connecting
|
|
136
|
+
if (signal?.aborted) {
|
|
137
|
+
yield { type: 'stop' }
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await this.ensureConnected()
|
|
142
|
+
|
|
143
|
+
// Reset per-prompt state
|
|
144
|
+
this.chunkQueue = []
|
|
145
|
+
this.resolveNext = null
|
|
146
|
+
this.rejectNext = null
|
|
147
|
+
|
|
148
|
+
// Send the prompt
|
|
149
|
+
const promptMsg: ClientPromptMessage = {
|
|
150
|
+
type: 'prompt',
|
|
151
|
+
sessionId: this.sessionId,
|
|
152
|
+
prompt,
|
|
153
|
+
}
|
|
154
|
+
this.ws!.send(JSON.stringify(promptMsg))
|
|
155
|
+
|
|
156
|
+
// ── Abort signal → interrupt message ──────────────────────────────
|
|
157
|
+
const onAbort = () => {
|
|
158
|
+
const interruptMsg: ClientInterruptMessage = {
|
|
159
|
+
type: 'interrupt',
|
|
160
|
+
sessionId: this.sessionId,
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
this.ws!.send(JSON.stringify(interruptMsg))
|
|
164
|
+
} catch {
|
|
165
|
+
// WebSocket may already be closing — ignore
|
|
166
|
+
}
|
|
167
|
+
// Reject any pending wait so the generator stops cleanly
|
|
168
|
+
if (this.rejectNext) {
|
|
169
|
+
this.rejectNext(new DOMException('Aborted', 'AbortError'))
|
|
170
|
+
this.rejectNext = null
|
|
171
|
+
this.resolveNext = null
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
let running = true
|
|
178
|
+
|
|
179
|
+
while (running) {
|
|
180
|
+
// Drain any buffered chunks from the queue first
|
|
181
|
+
while (this.chunkQueue.length > 0) {
|
|
182
|
+
const chunk = this.chunkQueue.shift()!
|
|
183
|
+
running = this.yieldAndCheck(chunk)
|
|
184
|
+
yield chunk
|
|
185
|
+
if (!running) return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!running) break
|
|
189
|
+
|
|
190
|
+
// Wait for the next chunk from the WebSocket
|
|
191
|
+
const chunk = await new Promise<StreamChunk>((resolve, reject) => {
|
|
192
|
+
this.resolveNext = resolve
|
|
193
|
+
this.rejectNext = reject
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
running = this.yieldAndCheck(chunk)
|
|
197
|
+
yield chunk
|
|
198
|
+
if (!running) return
|
|
199
|
+
}
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (signal?.aborted) {
|
|
202
|
+
yield { type: 'stop' }
|
|
203
|
+
} else {
|
|
204
|
+
yield {
|
|
205
|
+
type: 'error',
|
|
206
|
+
error: err instanceof Error ? err.message : String(err),
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} finally {
|
|
210
|
+
signal?.removeEventListener('abort', onAbort)
|
|
211
|
+
this.resolveNext = null
|
|
212
|
+
this.rejectNext = null
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Stub methods for TUI compatibility ───────────────────────────────────
|
|
217
|
+
//
|
|
218
|
+
// In remote attach mode, the daemon manages providers, agents, permissions,
|
|
219
|
+
// and context. These stubs satisfy the TUI's engine interface without
|
|
220
|
+
// introducing daemon dependencies into the UI layer.
|
|
221
|
+
//
|
|
222
|
+
// Slash commands may call any of these; we return safe defaults.
|
|
223
|
+
|
|
224
|
+
/** No-op: provider switching is managed by the daemon session. */
|
|
225
|
+
switchProvider(_providerId: string, _modelId?: string): void {}
|
|
226
|
+
|
|
227
|
+
/** Remote mode has no local agent registry. */
|
|
228
|
+
getAgentRegistry(): undefined {
|
|
229
|
+
return undefined
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** No-op: agents run on the daemon. */
|
|
233
|
+
setAgentRegistry(_reg: unknown): void {}
|
|
234
|
+
|
|
235
|
+
/** Returns a stub context object so slash commands and auto-save don't crash. */
|
|
236
|
+
getContext(): {
|
|
237
|
+
saveCheckpoint(_label: string): void
|
|
238
|
+
clear(): void
|
|
239
|
+
getMessages(): Array<{ role: string; content: string }>
|
|
240
|
+
getEstimatedTokens(): number
|
|
241
|
+
} {
|
|
242
|
+
return {
|
|
243
|
+
saveCheckpoint: (_label: string) => {
|
|
244
|
+
// Remote mode: checkpoints are managed by the daemon
|
|
245
|
+
},
|
|
246
|
+
clear: () => {
|
|
247
|
+
// Remote mode: session history lives on the daemon
|
|
248
|
+
},
|
|
249
|
+
getMessages: () => [],
|
|
250
|
+
getEstimatedTokens: () => 0,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Returns a stub permission object so slash commands don't crash. */
|
|
255
|
+
getPermission(): { setMode(_mode: string): void } {
|
|
256
|
+
return {
|
|
257
|
+
setMode: (_mode: string) => {
|
|
258
|
+
// Remote mode: permissions are managed by the daemon
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Returns an empty tool set — tools run on the daemon. */
|
|
264
|
+
getTools(): Map<string, unknown> {
|
|
265
|
+
return new Map()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Remote mode has no local rule engine. */
|
|
269
|
+
getRuleEngine(): undefined {
|
|
270
|
+
return undefined
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Remote mode has no local pattern analyzer. */
|
|
274
|
+
getPatternAnalyzer(): undefined {
|
|
275
|
+
return undefined
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Remote mode has no local effectiveness tracker. */
|
|
279
|
+
getEffectivenessTracker(): undefined {
|
|
280
|
+
return undefined
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Remote mode has no local goal state. */
|
|
284
|
+
getGoalState(): { goal: undefined; decompose: false; subtasks: never[] } {
|
|
285
|
+
return { goal: undefined, decompose: false, subtasks: [] }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** No-op: goals are managed by the daemon session. */
|
|
289
|
+
setGoal(_goal: string, _opts?: unknown): void {}
|
|
290
|
+
|
|
291
|
+
/** Remote mode has no local usage tracker. */
|
|
292
|
+
getUsageTracker(): { getStats(): Record<string, number> } {
|
|
293
|
+
return { getStats: () => ({}) }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Remote mode has no local agent view manager. */
|
|
297
|
+
getAgentViewManager(): undefined {
|
|
298
|
+
return undefined
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Returns a stub registry so slash commands don't crash. */
|
|
302
|
+
getRegistry(): { getProvider(_id: string): unknown; getProviders(): unknown[] } {
|
|
303
|
+
return {
|
|
304
|
+
getProvider: (_id: string) => undefined,
|
|
305
|
+
getProviders: () => [],
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Cleanup ──────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
/** Close the WebSocket connection and release resources. */
|
|
312
|
+
close(): void {
|
|
313
|
+
this.closed = true
|
|
314
|
+
if (this.ws) {
|
|
315
|
+
// Reject any pending wait
|
|
316
|
+
if (this.rejectNext) {
|
|
317
|
+
this.rejectNext(new Error('RemoteEngine closed'))
|
|
318
|
+
this.rejectNext = null
|
|
319
|
+
this.resolveNext = null
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
this.ws.close()
|
|
323
|
+
} catch {
|
|
324
|
+
// Ignore close errors
|
|
325
|
+
}
|
|
326
|
+
this.ws = null
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── Internal Helpers ─────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Process an incoming raw WebSocket message (JSON string).
|
|
334
|
+
* Parses it as a ServerMessage, maps it to a StreamChunk, and either
|
|
335
|
+
* resolves the pending generator wait or enqueues it.
|
|
336
|
+
*/
|
|
337
|
+
private onMessage(raw: string): void {
|
|
338
|
+
let msg: ServerMessage
|
|
339
|
+
try {
|
|
340
|
+
msg = JSON.parse(raw) as ServerMessage
|
|
341
|
+
} catch {
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const chunk = this.mapMessageToChunk(msg)
|
|
346
|
+
if (!chunk) return
|
|
347
|
+
|
|
348
|
+
if (this.resolveNext) {
|
|
349
|
+
this.resolveNext(chunk)
|
|
350
|
+
this.resolveNext = null
|
|
351
|
+
} else {
|
|
352
|
+
this.chunkQueue.push(chunk)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Map a ServerMessage from the daemon to a StreamChunk.
|
|
358
|
+
* Returns null for message types that should be silently consumed
|
|
359
|
+
* (e.g., session_state which is only informative for attach).
|
|
360
|
+
*/
|
|
361
|
+
private mapMessageToChunk(msg: ServerMessage): StreamChunk | null {
|
|
362
|
+
switch (msg.type) {
|
|
363
|
+
case 'text': {
|
|
364
|
+
return { type: 'text', content: msg.content }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
case 'tool_use': {
|
|
368
|
+
return {
|
|
369
|
+
type: 'tool_use',
|
|
370
|
+
toolUse: {
|
|
371
|
+
type: 'tool_use' as const,
|
|
372
|
+
name: msg.toolName,
|
|
373
|
+
input: msg.toolInput,
|
|
374
|
+
id: msg.toolId,
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
case 'tool_result': {
|
|
380
|
+
return {
|
|
381
|
+
type: 'tool_result',
|
|
382
|
+
tool_use_id: msg.toolId,
|
|
383
|
+
content: msg.content,
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
case 'usage': {
|
|
388
|
+
return {
|
|
389
|
+
type: 'usage',
|
|
390
|
+
inputTokens: msg.inputTokens,
|
|
391
|
+
outputTokens: msg.outputTokens,
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
case 'task_notification': {
|
|
396
|
+
return {
|
|
397
|
+
type: 'task_notification',
|
|
398
|
+
taskNotification: {
|
|
399
|
+
taskId: msg.taskId,
|
|
400
|
+
status: msg.status as 'started' | 'completed' | 'failed',
|
|
401
|
+
description: '',
|
|
402
|
+
},
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
case 'done': {
|
|
407
|
+
return { type: 'stop' }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
case 'error': {
|
|
411
|
+
return { type: 'error', error: msg.message }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// session_state is an informational message sent when a client
|
|
415
|
+
// first attaches. It is not part of the prompt stream.
|
|
416
|
+
case 'session_state':
|
|
417
|
+
return null
|
|
418
|
+
|
|
419
|
+
default:
|
|
420
|
+
return null
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Check whether the stream should continue after yielding a chunk.
|
|
426
|
+
* Returns `false` for terminal chunk types (stop, error).
|
|
427
|
+
*/
|
|
428
|
+
private yieldAndCheck(chunk: StreamChunk): boolean {
|
|
429
|
+
if (chunk.type === 'stop' || chunk.type === 'error') {
|
|
430
|
+
return false
|
|
431
|
+
}
|
|
432
|
+
return true
|
|
433
|
+
}
|
|
434
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// apps/cli/src/daemon/schedule-manager.ts — Phase 4: Schedule service layer
|
|
2
|
+
// Manages cron-based scheduled prompts: create, list, delete, and automatic firing.
|
|
3
|
+
// Delegates persistence to DaemonDatabase; uses WorkerPool for prompt execution.
|
|
4
|
+
|
|
5
|
+
import type { DaemonDatabase } from './database'
|
|
6
|
+
import type { WorkerPool } from './worker-pool'
|
|
7
|
+
import type { DaemonSchedule } from './types'
|
|
8
|
+
|
|
9
|
+
// Simple minute-granularity cron expression parser.
|
|
10
|
+
//
|
|
11
|
+
// Supports the standard 5-field cron format:
|
|
12
|
+
// minute hour day-of-month month day-of-week
|
|
13
|
+
//
|
|
14
|
+
// Handles: * (wildcard), star/N (step), N (exact), N-M (range), N,M,O (list).
|
|
15
|
+
// Falls back to +1 minute if the expression is invalid or no match is found
|
|
16
|
+
// within 366 days.
|
|
17
|
+
function computeNextFire(cronExpr: string, from: Date): string {
|
|
18
|
+
const fields = cronExpr.trim().split(/\s+/)
|
|
19
|
+
if (fields.length !== 5) {
|
|
20
|
+
return new Date(from.getTime() + 60_000).toISOString()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const minField = fields[0]!
|
|
24
|
+
const hourField = fields[1]!
|
|
25
|
+
const domField = fields[2]!
|
|
26
|
+
const monthField = fields[3]!
|
|
27
|
+
const dowField = fields[4]!
|
|
28
|
+
|
|
29
|
+
function matches(value: number, field: string): boolean {
|
|
30
|
+
if (field === '*') return true
|
|
31
|
+
// */N step
|
|
32
|
+
const stepMatch = field.match(/^\*\/(\d+)$/)
|
|
33
|
+
if (stepMatch) {
|
|
34
|
+
return value % parseInt(stepMatch[1]!, 10) === 0
|
|
35
|
+
}
|
|
36
|
+
// Comma-separated list
|
|
37
|
+
if (field.includes(',')) {
|
|
38
|
+
return field.split(',').some((f) => matches(value, f))
|
|
39
|
+
}
|
|
40
|
+
// Range
|
|
41
|
+
const rangeMatch = field.match(/^(\d+)-(\d+)$/)
|
|
42
|
+
if (rangeMatch) {
|
|
43
|
+
const low = parseInt(rangeMatch[1]!, 10)
|
|
44
|
+
const high = parseInt(rangeMatch[2]!, 10)
|
|
45
|
+
return value >= low && value <= high
|
|
46
|
+
}
|
|
47
|
+
// Exact value
|
|
48
|
+
return value === parseInt(field, 10)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const date = new Date(from)
|
|
52
|
+
date.setSeconds(0, 0)
|
|
53
|
+
// Start from the next minute to avoid matching the current minute
|
|
54
|
+
date.setMinutes(date.getMinutes() + 1)
|
|
55
|
+
|
|
56
|
+
// Try up to 366 days forward (safety limit — ~527k iterations)
|
|
57
|
+
const maxIterations = 366 * 24 * 60
|
|
58
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
59
|
+
const minute = date.getMinutes()
|
|
60
|
+
const hour = date.getHours()
|
|
61
|
+
const dom = date.getDate()
|
|
62
|
+
const month = date.getMonth() + 1 // JS months are 0-indexed
|
|
63
|
+
const dow = date.getDay() // 0 = Sunday
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
matches(minute, minField) &&
|
|
67
|
+
matches(hour, hourField) &&
|
|
68
|
+
matches(dom, domField) &&
|
|
69
|
+
matches(month, monthField) &&
|
|
70
|
+
matches(dow, dowField)
|
|
71
|
+
) {
|
|
72
|
+
return date.toISOString()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
date.setMinutes(date.getMinutes() + 1)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fallback: 1 minute from now
|
|
79
|
+
return new Date(from.getTime() + 60_000).toISOString()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class ScheduleManager {
|
|
83
|
+
private db: DaemonDatabase
|
|
84
|
+
private pool: WorkerPool
|
|
85
|
+
private intervalId: Timer | null = null
|
|
86
|
+
|
|
87
|
+
constructor(db: DaemonDatabase, pool: WorkerPool) {
|
|
88
|
+
this.db = db
|
|
89
|
+
this.pool = pool
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
createSchedule(sessionId: string, cronExpr: string, prompt: string): number {
|
|
93
|
+
const now = new Date()
|
|
94
|
+
const nextFire = computeNextFire(cronExpr, now)
|
|
95
|
+
return this.db.createSchedule({
|
|
96
|
+
sessionId,
|
|
97
|
+
cronExpr,
|
|
98
|
+
prompt,
|
|
99
|
+
enabled: true,
|
|
100
|
+
lastFired: null,
|
|
101
|
+
nextFire,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
getSchedules(sessionId: string): DaemonSchedule[] {
|
|
106
|
+
return this.db.getSchedules(sessionId)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
deleteSchedule(id: number): void {
|
|
110
|
+
this.db.deleteSchedule(id)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check for due schedules and fire them.
|
|
115
|
+
*
|
|
116
|
+
* For each schedule whose nextFire has passed:
|
|
117
|
+
* 1. Look up the session worker — skip if none is active
|
|
118
|
+
* 2. Send the prompt to the worker (fire-and-forget)
|
|
119
|
+
* 3. Update lastFired and compute the next fire time
|
|
120
|
+
*/
|
|
121
|
+
checkAndFire(): void {
|
|
122
|
+
const due = this.db.getDueSchedules()
|
|
123
|
+
for (const schedule of due) {
|
|
124
|
+
const worker = this.pool.getWorker(schedule.sessionId)
|
|
125
|
+
if (!worker) continue
|
|
126
|
+
|
|
127
|
+
worker.processPrompt(schedule.prompt).catch((err: unknown) => {
|
|
128
|
+
console.error(`ScheduleManager: error firing schedule ${schedule.id}:`, err)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const now = new Date()
|
|
132
|
+
const nextFire = computeNextFire(schedule.cronExpr, now)
|
|
133
|
+
this.db.updateScheduleFireTimes(schedule.id, now.toISOString(), nextFire)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Start the schedule-checking interval.
|
|
139
|
+
* Checks every 60 seconds. Timer is unref'd so it does not prevent
|
|
140
|
+
* the process from exiting when all other work is done.
|
|
141
|
+
*/
|
|
142
|
+
start(): void {
|
|
143
|
+
if (this.intervalId) return
|
|
144
|
+
this.intervalId = setInterval(() => {
|
|
145
|
+
this.checkAndFire()
|
|
146
|
+
}, 60_000)
|
|
147
|
+
this.intervalId.unref()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Stop the schedule-checking interval.
|
|
152
|
+
*/
|
|
153
|
+
stop(): void {
|
|
154
|
+
if (this.intervalId) {
|
|
155
|
+
clearInterval(this.intervalId)
|
|
156
|
+
this.intervalId = null
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|