@inceptionstack/roundhouse 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +321 -9
- package/architecture.md +77 -8
- package/package.json +9 -6
- package/src/agents/pi.ts +433 -26
- package/src/agents/registry.ts +8 -0
- package/src/cli/cli.ts +384 -189
- package/src/cli/cron.ts +296 -0
- package/src/cli/doctor/checks/agent.ts +68 -0
- package/src/cli/doctor/checks/config.ts +88 -0
- package/src/cli/doctor/checks/credentials.ts +62 -0
- package/src/cli/doctor/checks/disk.ts +69 -0
- package/src/cli/doctor/checks/stt.ts +76 -0
- package/src/cli/doctor/checks/system.ts +86 -0
- package/src/cli/doctor/checks/systemd.ts +76 -0
- package/src/cli/doctor/output.ts +58 -0
- package/src/cli/doctor/runner.ts +142 -0
- package/src/cli/doctor/shell.ts +33 -0
- package/src/cli/doctor/types.ts +44 -0
- package/src/cli/doctor.ts +48 -0
- package/src/cli/setup-telegram.ts +148 -0
- package/src/cli/setup.ts +936 -0
- package/src/commands.ts +23 -0
- package/src/config.ts +188 -0
- package/src/cron/constants.ts +54 -0
- package/src/cron/durations.ts +33 -0
- package/src/cron/format.ts +139 -0
- package/src/cron/helpers.ts +30 -0
- package/src/cron/runner.ts +148 -0
- package/src/cron/schedule.ts +101 -0
- package/src/cron/scheduler.ts +295 -0
- package/src/cron/store.ts +125 -0
- package/src/cron/template.ts +89 -0
- package/src/cron/types.ts +76 -0
- package/src/gateway.ts +927 -18
- package/src/index.ts +1 -58
- package/src/memory/bootstrap.ts +98 -0
- package/src/memory/files.ts +100 -0
- package/src/memory/inject.ts +41 -0
- package/src/memory/lifecycle.ts +245 -0
- package/src/memory/policy.ts +122 -0
- package/src/memory/prompts.ts +42 -0
- package/src/memory/state.ts +43 -0
- package/src/memory/types.ts +90 -0
- package/src/notify/telegram.ts +48 -0
- package/src/types.ts +68 -1
- package/src/util.ts +28 -2
- package/src/voice/providers/whisper.ts +339 -0
- package/src/voice/stt-service.ts +284 -0
- package/src/voice/types.ts +63 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cron/schedule.ts — Schedule evaluation using croner
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Cron } from "croner";
|
|
6
|
+
import { parseDuration, isDuration } from "./durations";
|
|
7
|
+
import type { CronSchedule, CronJobConfig, CronJobState } from "./types";
|
|
8
|
+
|
|
9
|
+
/** Validate a schedule config. Throws on invalid. */
|
|
10
|
+
export function validateSchedule(schedule: CronSchedule): void {
|
|
11
|
+
switch (schedule.type) {
|
|
12
|
+
case "cron": {
|
|
13
|
+
try {
|
|
14
|
+
const c = new Cron(schedule.cron, { timezone: schedule.tz, paused: true });
|
|
15
|
+
c.stop();
|
|
16
|
+
} catch (err) {
|
|
17
|
+
throw new Error(`Invalid cron expression "${schedule.cron}": ${(err as Error).message}`);
|
|
18
|
+
}
|
|
19
|
+
// Validate timezone
|
|
20
|
+
try {
|
|
21
|
+
Intl.DateTimeFormat(undefined, { timeZone: schedule.tz });
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error(`Invalid timezone "${schedule.tz}"`);
|
|
24
|
+
}
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
case "interval": {
|
|
28
|
+
parseDuration(schedule.every); // throws if invalid
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
case "once": {
|
|
32
|
+
if (isDuration(schedule.at)) {
|
|
33
|
+
// relative — ok
|
|
34
|
+
} else {
|
|
35
|
+
const d = new Date(schedule.at);
|
|
36
|
+
if (isNaN(d.getTime())) throw new Error(`Invalid date: "${schedule.at}"`);
|
|
37
|
+
}
|
|
38
|
+
if (schedule.tz) {
|
|
39
|
+
try { Intl.DateTimeFormat(undefined, { timeZone: schedule.tz }); }
|
|
40
|
+
catch { throw new Error(`Invalid timezone "${schedule.tz}"`); }
|
|
41
|
+
}
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
default:
|
|
45
|
+
throw new Error(`Unknown schedule type: ${(schedule as any).type}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Compute the next run time after `after` for a given schedule */
|
|
50
|
+
export function computeNextRun(schedule: CronSchedule, after: Date): Date | null {
|
|
51
|
+
switch (schedule.type) {
|
|
52
|
+
case "cron": {
|
|
53
|
+
const c = new Cron(schedule.cron, { timezone: schedule.tz, paused: true });
|
|
54
|
+
const next = c.nextRun(after);
|
|
55
|
+
c.stop();
|
|
56
|
+
return next ?? null;
|
|
57
|
+
}
|
|
58
|
+
case "interval":
|
|
59
|
+
return null; // interval uses lastScheduledAt + every
|
|
60
|
+
case "once":
|
|
61
|
+
return null; // once uses absolute time
|
|
62
|
+
default:
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Check if a job is due to run now */
|
|
68
|
+
export function isDue(job: CronJobConfig, state: CronJobState, now: Date): Date | null {
|
|
69
|
+
if (!job.enabled) return null;
|
|
70
|
+
|
|
71
|
+
switch (job.schedule.type) {
|
|
72
|
+
case "cron": {
|
|
73
|
+
const lastRun = state.lastScheduledAt ? new Date(state.lastScheduledAt) : new Date(job.createdAt);
|
|
74
|
+
const c = new Cron(job.schedule.cron, { timezone: job.schedule.tz, paused: true });
|
|
75
|
+
const next = c.nextRun(lastRun);
|
|
76
|
+
c.stop();
|
|
77
|
+
if (next && next <= now) return next;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
case "interval": {
|
|
81
|
+
const everyMs = parseDuration(job.schedule.every);
|
|
82
|
+
const lastRun = state.lastScheduledAt ? new Date(state.lastScheduledAt) : new Date(job.createdAt);
|
|
83
|
+
const nextTime = new Date(lastRun.getTime() + everyMs);
|
|
84
|
+
if (nextTime <= now) return nextTime;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
case "once": {
|
|
88
|
+
if (state.totalRuns > 0) return null; // already ran
|
|
89
|
+
let targetTime: Date;
|
|
90
|
+
if (isDuration(job.schedule.at)) {
|
|
91
|
+
targetTime = new Date(new Date(job.createdAt).getTime() + parseDuration(job.schedule.at));
|
|
92
|
+
} else {
|
|
93
|
+
targetTime = new Date(job.schedule.at);
|
|
94
|
+
}
|
|
95
|
+
if (targetTime <= now) return targetTime;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
default:
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cron/scheduler.ts — Internal cron scheduler
|
|
3
|
+
*
|
|
4
|
+
* Runs inside the gateway process. Ticks every 60s, checks for due jobs,
|
|
5
|
+
* executes them serially via p-queue (concurrency: 1).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import PQueue from "p-queue";
|
|
9
|
+
import { CronStore } from "./store";
|
|
10
|
+
import { CronRunner } from "./runner";
|
|
11
|
+
import { isDue } from "./schedule";
|
|
12
|
+
import type { CronJobConfig, CronJobState } from "./types";
|
|
13
|
+
import { isBuiltinJob, BUILTIN_HEARTBEAT_JOB_ID, HEARTBEAT_FILE_NAME } from "./helpers";
|
|
14
|
+
import { TICK_MS, SHUTDOWN_TIMEOUT_MS, MAX_CATCHUP_ITERATIONS, HEARTBEAT_INTERVAL_MS, HEARTBEAT_DEFAULT_CONTENT } from "./constants";
|
|
15
|
+
import { emptyState } from "./format";
|
|
16
|
+
import type { GatewayConfig } from "../types";
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { ROUNDHOUSE_DIR } from "../config";
|
|
21
|
+
|
|
22
|
+
export interface CronSchedulerStatus {
|
|
23
|
+
running: boolean;
|
|
24
|
+
jobCount: number;
|
|
25
|
+
enabledCount: number;
|
|
26
|
+
queueSize: number;
|
|
27
|
+
queuePending: number;
|
|
28
|
+
activeJobId: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class CronSchedulerService {
|
|
32
|
+
private store: CronStore;
|
|
33
|
+
private runner: CronRunner;
|
|
34
|
+
private queue: PQueue;
|
|
35
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
36
|
+
private jobs: CronJobConfig[] = [];
|
|
37
|
+
private states: Map<string, CronJobState> = new Map();
|
|
38
|
+
private activeJobId: string | null = null;
|
|
39
|
+
private queuedJobIds = new Set<string>(); // prevent duplicate queueing
|
|
40
|
+
private ticking = false; // prevent concurrent tick invocations
|
|
41
|
+
private lastHeartbeatAt = 0; // 0 = fires on first tick after startup (intentional catch-up)
|
|
42
|
+
private tickMs: number;
|
|
43
|
+
|
|
44
|
+
constructor(private opts?: { tickMs?: number; agentConfig?: GatewayConfig["agent"]; notifyChatIds?: number[] }) {
|
|
45
|
+
this.store = new CronStore();
|
|
46
|
+
this.runner = new CronRunner(this.store, this.opts?.agentConfig);
|
|
47
|
+
this.queue = new PQueue({ concurrency: 1 });
|
|
48
|
+
this.tickMs = this.opts?.tickMs ?? TICK_MS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async start(): Promise<void> {
|
|
52
|
+
await this.store.ensureDirs();
|
|
53
|
+
await this.reload();
|
|
54
|
+
await this.catchUp();
|
|
55
|
+
|
|
56
|
+
this.timer = setInterval(() => void this.tick(), this.tickMs);
|
|
57
|
+
this.timer.unref();
|
|
58
|
+
|
|
59
|
+
console.log(`[cron] scheduler started (${this.jobs.filter((j) => j.enabled).length} enabled jobs, tick every ${this.tickMs / 1000}s)`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async stop(): Promise<void> {
|
|
63
|
+
if (this.timer) {
|
|
64
|
+
clearInterval(this.timer);
|
|
65
|
+
this.timer = null;
|
|
66
|
+
}
|
|
67
|
+
this.queue.clear();
|
|
68
|
+
// Wait for active job with a timeout to prevent hanging shutdown
|
|
69
|
+
const idle = this.queue.onIdle();
|
|
70
|
+
const timeoutPromise = new Promise<void>((r) => {
|
|
71
|
+
const t = setTimeout(r, SHUTDOWN_TIMEOUT_MS);
|
|
72
|
+
if (t.unref) t.unref();
|
|
73
|
+
});
|
|
74
|
+
await Promise.race([idle, timeoutPromise]);
|
|
75
|
+
console.log("[cron] scheduler stopped");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getStatus(): CronSchedulerStatus {
|
|
79
|
+
return {
|
|
80
|
+
running: this.timer !== null,
|
|
81
|
+
jobCount: this.jobs.length,
|
|
82
|
+
enabledCount: this.jobs.filter((j) => j.enabled).length,
|
|
83
|
+
queueSize: this.queue.size,
|
|
84
|
+
queuePending: this.queue.pending,
|
|
85
|
+
activeJobId: this.activeJobId,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async listJobs(): Promise<Array<{ job: CronJobConfig; state: CronJobState }>> {
|
|
90
|
+
await this.reload();
|
|
91
|
+
return this.jobs.map((job) => ({
|
|
92
|
+
job,
|
|
93
|
+
state: this.states.get(job.id) ?? emptyState(job.id),
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async trigger(jobId: string): Promise<void> {
|
|
98
|
+
const job = this.jobs.find((j) => j.id === jobId) ?? await this.store.getJob(jobId);
|
|
99
|
+
if (!job) throw new Error(`Job not found: ${jobId}`);
|
|
100
|
+
this.enqueueJob(job, new Date(), "manual");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async pauseJob(jobId: string): Promise<void> {
|
|
104
|
+
const job = await this.store.getJob(jobId);
|
|
105
|
+
if (!job) throw new Error(`Job not found: ${jobId}`);
|
|
106
|
+
job.enabled = false;
|
|
107
|
+
job.updatedAt = new Date().toISOString();
|
|
108
|
+
await this.store.writeJob(job);
|
|
109
|
+
await this.reload();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async resumeJob(jobId: string): Promise<void> {
|
|
113
|
+
const job = await this.store.getJob(jobId);
|
|
114
|
+
if (!job) throw new Error(`Job not found: ${jobId}`);
|
|
115
|
+
job.enabled = true;
|
|
116
|
+
job.updatedAt = new Date().toISOString();
|
|
117
|
+
await this.store.writeJob(job);
|
|
118
|
+
await this.reload();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Internal ─────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
private enqueueJob(job: CronJobConfig, dueAt: Date, kind: "scheduled" | "manual"): void {
|
|
124
|
+
// Skip if already queued or running (prevent backlog for long-running jobs)
|
|
125
|
+
if (kind === "scheduled" && this.queuedJobIds.has(job.id)) {
|
|
126
|
+
console.log(`[cron] skipping ${job.id} — already queued or running`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.queuedJobIds.add(job.id);
|
|
131
|
+
|
|
132
|
+
this.queue.add(async () => {
|
|
133
|
+
this.activeJobId = job.id;
|
|
134
|
+
try {
|
|
135
|
+
await this.runner.runJob(job, dueAt, kind);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
console.error(`[cron] ${job.id} run failed:`, (err as Error).message);
|
|
138
|
+
} finally {
|
|
139
|
+
this.activeJobId = null;
|
|
140
|
+
if (isBuiltinJob(job.id)) this.lastHeartbeatAt = Date.now();
|
|
141
|
+
this.queuedJobIds.delete(job.id);
|
|
142
|
+
}
|
|
143
|
+
}).catch((err) => {
|
|
144
|
+
console.error(`[cron] ${job.id} queue error:`, (err as Error).message);
|
|
145
|
+
this.queuedJobIds.delete(job.id);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async reload(): Promise<void> {
|
|
150
|
+
this.jobs = await this.store.listJobs();
|
|
151
|
+
// Always refresh state from disk (runner writes updated state after each run)
|
|
152
|
+
for (const job of this.jobs) {
|
|
153
|
+
this.states.set(job.id, await this.store.getState(job.id));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async catchUp(): Promise<void> {
|
|
158
|
+
const now = new Date();
|
|
159
|
+
for (const job of this.jobs) {
|
|
160
|
+
if (!job.enabled) continue;
|
|
161
|
+
const catchUpMode = job.catchUp?.mode ?? "latest";
|
|
162
|
+
if (catchUpMode === "none") continue;
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const state = this.states.get(job.id)!;
|
|
166
|
+
|
|
167
|
+
// Fast-forward to latest due time (not first missed)
|
|
168
|
+
// Cap iterations to prevent blocking on high-frequency schedules after long downtime
|
|
169
|
+
let latestDue: Date | null = null;
|
|
170
|
+
let current = isDue(job, state, now);
|
|
171
|
+
let iterations = 0;
|
|
172
|
+
let prevIso = "";
|
|
173
|
+
while (current && iterations < MAX_CATCHUP_ITERATIONS) {
|
|
174
|
+
latestDue = current;
|
|
175
|
+
const currentIso = current.toISOString();
|
|
176
|
+
// Break if no forward progress (e.g. once jobs always return same time)
|
|
177
|
+
if (currentIso === prevIso) break;
|
|
178
|
+
prevIso = currentIso;
|
|
179
|
+
const tempState = { ...state, lastScheduledAt: currentIso };
|
|
180
|
+
current = isDue(job, tempState, now);
|
|
181
|
+
iterations++;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (latestDue) {
|
|
185
|
+
console.log(`[cron] catch-up: ${job.id} fast-forwarded to ${latestDue.toISOString()}`);
|
|
186
|
+
state.lastScheduledAt = latestDue.toISOString();
|
|
187
|
+
await this.store.writeState(state);
|
|
188
|
+
this.enqueueJob(job, latestDue, "scheduled");
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
console.error(`[cron] catch-up failed for ${job.id}:`, (err as Error).message);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Read HEARTBEAT.md and check if it has real content */
|
|
197
|
+
private async readHeartbeat(): Promise<string | null> {
|
|
198
|
+
try {
|
|
199
|
+
const path = join(ROUNDHOUSE_DIR, HEARTBEAT_FILE_NAME);
|
|
200
|
+
const content = await readFile(path, "utf8");
|
|
201
|
+
const trimmed = content.trim();
|
|
202
|
+
|
|
203
|
+
// Skip if empty
|
|
204
|
+
if (!trimmed) return null;
|
|
205
|
+
|
|
206
|
+
// Skip if content matches the default template exactly
|
|
207
|
+
// Normalize line endings for cross-platform compatibility
|
|
208
|
+
if (trimmed.replace(/\r\n/g, "\n") === HEARTBEAT_DEFAULT_CONTENT) return null;
|
|
209
|
+
|
|
210
|
+
return trimmed;
|
|
211
|
+
} catch (err: any) {
|
|
212
|
+
if (err?.code !== "ENOENT") {
|
|
213
|
+
console.warn("[cron] failed to read HEARTBEAT.md:", err?.message);
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Run heartbeat if due and HEARTBEAT.md has content */
|
|
220
|
+
private async checkHeartbeat(): Promise<void> {
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
if (now - this.lastHeartbeatAt < HEARTBEAT_INTERVAL_MS) return;
|
|
223
|
+
|
|
224
|
+
const heartbeatContent = await this.readHeartbeat();
|
|
225
|
+
if (!heartbeatContent) return;
|
|
226
|
+
|
|
227
|
+
// Only advance timer when we actually have content to run.
|
|
228
|
+
// Also updated in enqueueJob's finally block after completion.
|
|
229
|
+
this.lastHeartbeatAt = now;
|
|
230
|
+
|
|
231
|
+
// Create a synthetic job config for the heartbeat
|
|
232
|
+
const heartbeatJob: CronJobConfig = {
|
|
233
|
+
id: BUILTIN_HEARTBEAT_JOB_ID,
|
|
234
|
+
enabled: true,
|
|
235
|
+
description: "Built-in heartbeat",
|
|
236
|
+
createdAt: new Date().toISOString(),
|
|
237
|
+
updatedAt: new Date().toISOString(),
|
|
238
|
+
schedule: { type: "interval", every: "30m" },
|
|
239
|
+
prompt: heartbeatContent,
|
|
240
|
+
notify: this.getHeartbeatNotifyConfig(),
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
this.enqueueJob(heartbeatJob, new Date(), "scheduled");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Get notify config for heartbeat from gateway config's notifyChatIds */
|
|
247
|
+
private getHeartbeatNotifyConfig(): CronJobConfig["notify"] {
|
|
248
|
+
const chatIds = this.opts?.notifyChatIds;
|
|
249
|
+
if (chatIds?.length) {
|
|
250
|
+
return { telegram: { chatIds, onlyOn: "always" } };
|
|
251
|
+
}
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async tick(): Promise<void> {
|
|
256
|
+
if (this.ticking) return; // prevent concurrent ticks
|
|
257
|
+
this.ticking = true;
|
|
258
|
+
try {
|
|
259
|
+
try {
|
|
260
|
+
await this.reload();
|
|
261
|
+
} catch (err) {
|
|
262
|
+
console.error("[cron] reload failed:", (err as Error).message);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const now = new Date();
|
|
267
|
+
|
|
268
|
+
for (const job of this.jobs) {
|
|
269
|
+
if (!job.enabled) continue;
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const state = this.states.get(job.id)!;
|
|
273
|
+
const dueAt = isDue(job, state, now);
|
|
274
|
+
if (!dueAt) continue;
|
|
275
|
+
|
|
276
|
+
// Skip if already queued — don't write state that could race with the runner
|
|
277
|
+
if (this.queuedJobIds.has(job.id)) continue;
|
|
278
|
+
|
|
279
|
+
// Update lastScheduledAt to prevent re-queueing next tick
|
|
280
|
+
state.lastScheduledAt = dueAt.toISOString();
|
|
281
|
+
await this.store.writeState(state);
|
|
282
|
+
|
|
283
|
+
this.enqueueJob(job, dueAt, "scheduled");
|
|
284
|
+
} catch (err) {
|
|
285
|
+
console.error(`[cron] tick error for ${job.id}:`, (err as Error).message);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Check heartbeat
|
|
290
|
+
await this.checkHeartbeat();
|
|
291
|
+
} finally {
|
|
292
|
+
this.ticking = false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cron/store.ts — Read/write cron job configs, state, and run records
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFile, writeFile, readdir, mkdir, unlink, rename } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { CRON_JOBS_DIR, CRON_STATE_DIR, CRON_RUNS_DIR } from "../config";
|
|
9
|
+
import type { CronJobConfig, CronJobState, CronRunRecord } from "./types";
|
|
10
|
+
|
|
11
|
+
/** Atomic JSON write: write to temp file then rename. Cleans up on failure. */
|
|
12
|
+
async function writeJsonAtomic(path: string, value: unknown, mode = 0o600): Promise<void> {
|
|
13
|
+
const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
|
|
14
|
+
try {
|
|
15
|
+
await writeFile(tmp, JSON.stringify(value, null, 2) + "\n", { mode });
|
|
16
|
+
await rename(tmp, path);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
try { await unlink(tmp); } catch {}
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const JOB_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
24
|
+
|
|
25
|
+
export function validateJobId(id: string): void {
|
|
26
|
+
if (!JOB_ID_RE.test(id)) {
|
|
27
|
+
throw new Error(`Invalid job ID "${id}". Use alphanumeric, dots, dashes, underscores (max 64 chars).`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function generateRunId(): string {
|
|
32
|
+
return `${new Date().toISOString().replace(/[:.]/g, "-")}_${randomBytes(3).toString("hex")}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class CronStore {
|
|
36
|
+
async ensureDirs(): Promise<void> {
|
|
37
|
+
await mkdir(CRON_JOBS_DIR, { recursive: true });
|
|
38
|
+
await mkdir(CRON_STATE_DIR, { recursive: true });
|
|
39
|
+
await mkdir(CRON_RUNS_DIR, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async listJobs(): Promise<CronJobConfig[]> {
|
|
43
|
+
try {
|
|
44
|
+
const files = await readdir(CRON_JOBS_DIR);
|
|
45
|
+
const jobs: CronJobConfig[] = [];
|
|
46
|
+
for (const f of files) {
|
|
47
|
+
if (!f.endsWith(".json")) continue;
|
|
48
|
+
try {
|
|
49
|
+
const raw = await readFile(join(CRON_JOBS_DIR, f), "utf8");
|
|
50
|
+
jobs.push(JSON.parse(raw));
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.warn(`[cron/store] failed to read job ${f}:`, (err as Error).message);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return jobs;
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async getJob(id: string): Promise<CronJobConfig | null> {
|
|
62
|
+
validateJobId(id);
|
|
63
|
+
try {
|
|
64
|
+
const raw = await readFile(join(CRON_JOBS_DIR, `${id}.json`), "utf8");
|
|
65
|
+
return JSON.parse(raw);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async writeJob(job: CronJobConfig): Promise<void> {
|
|
72
|
+
validateJobId(job.id);
|
|
73
|
+
await writeJsonAtomic(join(CRON_JOBS_DIR, `${job.id}.json`), job);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async deleteJob(id: string): Promise<void> {
|
|
77
|
+
validateJobId(id);
|
|
78
|
+
try { await unlink(join(CRON_JOBS_DIR, `${id}.json`)); } catch {}
|
|
79
|
+
try { await unlink(join(CRON_STATE_DIR, `${id}.json`)); } catch {}
|
|
80
|
+
try {
|
|
81
|
+
const { rm } = await import("node:fs/promises");
|
|
82
|
+
await rm(join(CRON_RUNS_DIR, id), { recursive: true });
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async getState(id: string): Promise<CronJobState> {
|
|
87
|
+
validateJobId(id);
|
|
88
|
+
try {
|
|
89
|
+
const raw = await readFile(join(CRON_STATE_DIR, `${id}.json`), "utf8");
|
|
90
|
+
return JSON.parse(raw);
|
|
91
|
+
} catch {
|
|
92
|
+
const { emptyState } = await import("./format");
|
|
93
|
+
return emptyState(id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async writeState(state: CronJobState): Promise<void> {
|
|
98
|
+
validateJobId(state.id);
|
|
99
|
+
await writeJsonAtomic(join(CRON_STATE_DIR, `${state.id}.json`), state);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async appendRun(record: CronRunRecord): Promise<void> {
|
|
103
|
+
validateJobId(record.jobId);
|
|
104
|
+
const dir = join(CRON_RUNS_DIR, record.jobId);
|
|
105
|
+
await mkdir(dir, { recursive: true });
|
|
106
|
+
await writeJsonAtomic(join(dir, `${record.id}.json`), record);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async listRuns(jobId: string, limit = 10): Promise<CronRunRecord[]> {
|
|
110
|
+
validateJobId(jobId);
|
|
111
|
+
const dir = join(CRON_RUNS_DIR, jobId);
|
|
112
|
+
try {
|
|
113
|
+
const files = (await readdir(dir)).filter((f) => f.endsWith(".json")).sort().reverse().slice(0, limit);
|
|
114
|
+
const runs: CronRunRecord[] = [];
|
|
115
|
+
for (const f of files) {
|
|
116
|
+
try {
|
|
117
|
+
runs.push(JSON.parse(await readFile(join(dir, f), "utf8")));
|
|
118
|
+
} catch {}
|
|
119
|
+
}
|
|
120
|
+
return runs;
|
|
121
|
+
} catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cron/template.ts — Simple {{variable}} template renderer
|
|
3
|
+
*
|
|
4
|
+
* No JS eval. Unknown variables throw at validation time.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { hostname } from "node:os";
|
|
8
|
+
|
|
9
|
+
export interface TemplateContext {
|
|
10
|
+
job: { id: string; description?: string };
|
|
11
|
+
run: { id: string; scheduledAt: string; startedAt: string };
|
|
12
|
+
date: { iso: string; local: string; localTime: string };
|
|
13
|
+
timezone?: string;
|
|
14
|
+
hostname: string;
|
|
15
|
+
cwd: string;
|
|
16
|
+
vars: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Extract all {{path.to.value}} references from a template */
|
|
20
|
+
export function extractTemplateVars(text: string): string[] {
|
|
21
|
+
const matches = text.matchAll(/\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/g);
|
|
22
|
+
return [...matches].map((m) => m[1]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Validate that all template variables can be resolved */
|
|
26
|
+
export function validateTemplate(text: string, allowedVars?: Set<string>): string[] {
|
|
27
|
+
const vars = extractTemplateVars(text);
|
|
28
|
+
const builtins = new Set([
|
|
29
|
+
"job.id", "job.description",
|
|
30
|
+
"run.id", "run.scheduledAt", "run.startedAt",
|
|
31
|
+
"date.iso", "date.local", "date.localTime",
|
|
32
|
+
"timezone", "hostname", "cwd",
|
|
33
|
+
]);
|
|
34
|
+
const errors: string[] = [];
|
|
35
|
+
for (const v of vars) {
|
|
36
|
+
if (builtins.has(v)) continue;
|
|
37
|
+
if (v.startsWith("vars.") && allowedVars?.has(v.slice(5))) continue;
|
|
38
|
+
if (v.startsWith("vars.")) {
|
|
39
|
+
errors.push(`Unknown variable: {{${v}}} — define it in job.vars`);
|
|
40
|
+
} else {
|
|
41
|
+
errors.push(`Unknown variable: {{${v}}}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return errors;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Render a template with the given context */
|
|
48
|
+
export function renderTemplate(text: string, ctx: TemplateContext): string {
|
|
49
|
+
return text.replace(/\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/g, (_match, path: string) => {
|
|
50
|
+
const parts = path.split(".");
|
|
51
|
+
let value: unknown = ctx;
|
|
52
|
+
for (const part of parts) {
|
|
53
|
+
if (value == null || typeof value !== "object") return "";
|
|
54
|
+
value = (value as Record<string, unknown>)[part];
|
|
55
|
+
}
|
|
56
|
+
return value != null ? String(value) : "";
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Build a TemplateContext for a cron run */
|
|
61
|
+
export function buildTemplateContext(
|
|
62
|
+
jobId: string,
|
|
63
|
+
jobDescription: string | undefined,
|
|
64
|
+
runId: string,
|
|
65
|
+
scheduledAt: Date,
|
|
66
|
+
startedAt: Date,
|
|
67
|
+
tz: string | undefined,
|
|
68
|
+
cwd: string,
|
|
69
|
+
vars: Record<string, string>,
|
|
70
|
+
): TemplateContext {
|
|
71
|
+
const now = startedAt;
|
|
72
|
+
return {
|
|
73
|
+
job: { id: jobId, description: jobDescription },
|
|
74
|
+
run: {
|
|
75
|
+
id: runId,
|
|
76
|
+
scheduledAt: scheduledAt.toISOString(),
|
|
77
|
+
startedAt: startedAt.toISOString(),
|
|
78
|
+
},
|
|
79
|
+
date: {
|
|
80
|
+
iso: now.toISOString(),
|
|
81
|
+
local: now.toLocaleDateString("en-CA", { timeZone: tz || undefined }), // YYYY-MM-DD in job timezone
|
|
82
|
+
localTime: now.toLocaleTimeString("en-GB", { hour12: false, timeZone: tz || undefined }),
|
|
83
|
+
},
|
|
84
|
+
timezone: tz,
|
|
85
|
+
hostname: hostname(),
|
|
86
|
+
cwd,
|
|
87
|
+
vars,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cron/types.ts — Cron job types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// ── Schedule ─────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export type CronSchedule =
|
|
8
|
+
| { type: "cron"; cron: string; tz: string }
|
|
9
|
+
| { type: "interval"; every: string }
|
|
10
|
+
| { type: "once"; at: string; tz?: string };
|
|
11
|
+
|
|
12
|
+
// ── Job config (persisted as JSON) ───────────────────
|
|
13
|
+
|
|
14
|
+
export interface CronJobConfig {
|
|
15
|
+
id: string;
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
description?: string;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
|
|
21
|
+
schedule: CronSchedule;
|
|
22
|
+
|
|
23
|
+
/** Prompt template with {{variable}} placeholders */
|
|
24
|
+
prompt: string;
|
|
25
|
+
/** Custom variables available in template */
|
|
26
|
+
vars?: Record<string, string>;
|
|
27
|
+
|
|
28
|
+
/** Timeout for agent run (default 30min) */
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
|
|
31
|
+
/** Catch-up behavior after gateway restart */
|
|
32
|
+
catchUp?: { mode: "latest" | "none"; };
|
|
33
|
+
|
|
34
|
+
/** Notification targets */
|
|
35
|
+
notify?: {
|
|
36
|
+
telegram?: {
|
|
37
|
+
chatIds: (string | number)[];
|
|
38
|
+
onlyOn?: "always" | "success" | "failure";
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Job state (mutable, persisted separately) ────────
|
|
44
|
+
|
|
45
|
+
export interface CronJobState {
|
|
46
|
+
id: string;
|
|
47
|
+
lastScheduledAt?: string;
|
|
48
|
+
lastStartedAt?: string;
|
|
49
|
+
lastFinishedAt?: string;
|
|
50
|
+
lastSuccessAt?: string;
|
|
51
|
+
lastFailureAt?: string;
|
|
52
|
+
lastRunId?: string;
|
|
53
|
+
lastError?: string;
|
|
54
|
+
totalRuns: number;
|
|
55
|
+
totalSuccesses: number;
|
|
56
|
+
totalFailures: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Run record ───────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export type CronRunStatus = "completed" | "failed" | "timeout" | "abandoned";
|
|
62
|
+
|
|
63
|
+
export interface CronRunRecord {
|
|
64
|
+
id: string;
|
|
65
|
+
jobId: string;
|
|
66
|
+
kind: "scheduled" | "manual";
|
|
67
|
+
status: CronRunStatus;
|
|
68
|
+
scheduledAt: string;
|
|
69
|
+
startedAt: string;
|
|
70
|
+
finishedAt: string;
|
|
71
|
+
durationMs: number;
|
|
72
|
+
threadId: string;
|
|
73
|
+
prompt: string;
|
|
74
|
+
responseText?: string;
|
|
75
|
+
error?: string;
|
|
76
|
+
}
|