@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
package/src/commands.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands.ts — Shared Telegram bot command definitions
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for bot commands registered with Telegram.
|
|
5
|
+
* Used by both setup (at install time) and gateway (on startup).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface BotCommand {
|
|
9
|
+
command: string;
|
|
10
|
+
description: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const BOT_COMMANDS: BotCommand[] = [
|
|
14
|
+
{ command: "new", description: "Start a fresh conversation" },
|
|
15
|
+
{ command: "compact", description: "Compact context window" },
|
|
16
|
+
{ command: "verbose", description: "Toggle verbose tool output" },
|
|
17
|
+
{ command: "stop", description: "Stop the current agent run" },
|
|
18
|
+
{ command: "restart", description: "Restart agent process" },
|
|
19
|
+
{ command: "status", description: "Show system status" },
|
|
20
|
+
{ command: "doctor", description: "Run diagnostics" },
|
|
21
|
+
{ command: "crons", description: "List scheduled cron jobs" },
|
|
22
|
+
{ command: "jobs", description: "Show running jobs" },
|
|
23
|
+
];
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.ts — Shared configuration for roundhouse
|
|
3
|
+
*
|
|
4
|
+
* Canonical config directory: ~/.roundhouse/
|
|
5
|
+
* Legacy fallback: ~/.config/roundhouse/ (deprecated, will warn)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { readFile, access } from "node:fs/promises";
|
|
11
|
+
import type { GatewayConfig } from "./types";
|
|
12
|
+
|
|
13
|
+
// ── Path constants ───────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** New canonical config root */
|
|
16
|
+
export const ROUNDHOUSE_DIR = resolve(homedir(), ".roundhouse");
|
|
17
|
+
|
|
18
|
+
/** Legacy config root (deprecated) */
|
|
19
|
+
export const LEGACY_CONFIG_DIR = resolve(homedir(), ".config", "roundhouse");
|
|
20
|
+
|
|
21
|
+
/** Active config directory — use ROUNDHOUSE_DIR */
|
|
22
|
+
export const CONFIG_DIR = ROUNDHOUSE_DIR;
|
|
23
|
+
export const CONFIG_PATH = resolve(ROUNDHOUSE_DIR, "gateway.config.json");
|
|
24
|
+
export const ENV_FILE_PATH = resolve(ROUNDHOUSE_DIR, "env");
|
|
25
|
+
|
|
26
|
+
/** Cron directories */
|
|
27
|
+
export const CRON_JOBS_DIR = resolve(ROUNDHOUSE_DIR, "crons");
|
|
28
|
+
export const CRON_STATE_DIR = resolve(ROUNDHOUSE_DIR, "cron-state");
|
|
29
|
+
export const CRON_RUNS_DIR = resolve(ROUNDHOUSE_DIR, "cron-runs");
|
|
30
|
+
|
|
31
|
+
export const SERVICE_NAME = "roundhouse";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Default config written to disk by `roundhouse install`.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_CONFIG: GatewayConfig = {
|
|
37
|
+
agent: {
|
|
38
|
+
type: "pi",
|
|
39
|
+
cwd: homedir(),
|
|
40
|
+
},
|
|
41
|
+
chat: {
|
|
42
|
+
botUsername: "roundhouse_bot",
|
|
43
|
+
allowedUsers: [],
|
|
44
|
+
adapters: {
|
|
45
|
+
telegram: { mode: "polling" },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a runtime config by overlaying environment variables onto a base config.
|
|
52
|
+
*/
|
|
53
|
+
export function applyEnvOverrides(config: GatewayConfig): GatewayConfig {
|
|
54
|
+
return {
|
|
55
|
+
...config,
|
|
56
|
+
agent: {
|
|
57
|
+
...config.agent,
|
|
58
|
+
cwd: (typeof config.agent.cwd === "string" && config.agent.cwd) ? config.agent.cwd : process.cwd(),
|
|
59
|
+
},
|
|
60
|
+
chat: {
|
|
61
|
+
...config.chat,
|
|
62
|
+
botUsername: process.env.BOT_USERNAME ?? config.chat.botUsername,
|
|
63
|
+
allowedUsers: process.env.ALLOWED_USERS
|
|
64
|
+
? process.env.ALLOWED_USERS.split(",").map((u) => u.trim())
|
|
65
|
+
: config.chat.allowedUsers,
|
|
66
|
+
notifyChatIds: process.env.NOTIFY_CHAT_IDS
|
|
67
|
+
? process.env.NOTIFY_CHAT_IDS.split(",").map((id) => Number(id.trim())).filter((n) => !isNaN(n))
|
|
68
|
+
: config.chat.notifyChatIds,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function fileExists(path: string): Promise<boolean> {
|
|
74
|
+
try {
|
|
75
|
+
await access(path);
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve config path with legacy fallback.
|
|
84
|
+
* Returns the path that actually has a config file, or the new canonical path.
|
|
85
|
+
*/
|
|
86
|
+
let configPathWarned = false;
|
|
87
|
+
|
|
88
|
+
export async function resolveConfigPath(): Promise<{ path: string; legacy: boolean }> {
|
|
89
|
+
// New path takes priority
|
|
90
|
+
if (await fileExists(CONFIG_PATH)) {
|
|
91
|
+
return { path: CONFIG_PATH, legacy: false };
|
|
92
|
+
}
|
|
93
|
+
// Legacy fallback
|
|
94
|
+
const legacyPath = resolve(LEGACY_CONFIG_DIR, "gateway.config.json");
|
|
95
|
+
if (await fileExists(legacyPath)) {
|
|
96
|
+
if (!configPathWarned) {
|
|
97
|
+
configPathWarned = true;
|
|
98
|
+
console.warn(`[roundhouse] ⚠️ Config found at legacy path: ${legacyPath}`);
|
|
99
|
+
console.warn(`[roundhouse] Move it to ${CONFIG_PATH} — legacy path will be removed in a future version.`);
|
|
100
|
+
}
|
|
101
|
+
return { path: legacyPath, legacy: true };
|
|
102
|
+
}
|
|
103
|
+
return { path: CONFIG_PATH, legacy: false };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve env file path with legacy fallback.
|
|
108
|
+
*/
|
|
109
|
+
let envFileWarned = false;
|
|
110
|
+
|
|
111
|
+
export async function resolveEnvFilePath(): Promise<string> {
|
|
112
|
+
if (await fileExists(ENV_FILE_PATH)) return ENV_FILE_PATH;
|
|
113
|
+
const legacyEnv = resolve(LEGACY_CONFIG_DIR, "env");
|
|
114
|
+
if (await fileExists(legacyEnv)) {
|
|
115
|
+
if (!envFileWarned) {
|
|
116
|
+
envFileWarned = true;
|
|
117
|
+
console.warn(`[roundhouse] \u26a0\ufe0f Env file found at legacy path: ${legacyEnv}`);
|
|
118
|
+
console.warn(`[roundhouse] Move it to ${ENV_FILE_PATH} \u2014 legacy path will be removed in a future version.`);
|
|
119
|
+
}
|
|
120
|
+
return legacyEnv;
|
|
121
|
+
}
|
|
122
|
+
return ENV_FILE_PATH;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function loadConfig(): Promise<GatewayConfig> {
|
|
126
|
+
let config: GatewayConfig | undefined;
|
|
127
|
+
|
|
128
|
+
// Check for ROUNDHOUSE_CONFIG env var (set by CLI/daemon — must be valid)
|
|
129
|
+
const envConfig = process.env.ROUNDHOUSE_CONFIG;
|
|
130
|
+
if (envConfig) {
|
|
131
|
+
try {
|
|
132
|
+
const raw = await readFile(resolve(envConfig), "utf8");
|
|
133
|
+
console.log(`[roundhouse] loaded config from ${envConfig}`);
|
|
134
|
+
config = JSON.parse(raw) as GatewayConfig;
|
|
135
|
+
} catch (err: any) {
|
|
136
|
+
console.error(`[roundhouse] failed to load config from ROUNDHOUSE_CONFIG=${envConfig}: ${err.message}`);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check for --config flag
|
|
142
|
+
if (!config) {
|
|
143
|
+
const configIdx = process.argv.indexOf("--config");
|
|
144
|
+
if (configIdx !== -1 && process.argv[configIdx + 1]) {
|
|
145
|
+
const configPath = resolve(process.argv[configIdx + 1]);
|
|
146
|
+
try {
|
|
147
|
+
const raw = await readFile(configPath, "utf8");
|
|
148
|
+
console.log(`[roundhouse] loaded config from ${configPath}`);
|
|
149
|
+
config = JSON.parse(raw) as GatewayConfig;
|
|
150
|
+
} catch (err: any) {
|
|
151
|
+
console.error(`[roundhouse] failed to load config from ${configPath}: ${err.message}`);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Try canonical path, then legacy, then cwd
|
|
158
|
+
if (!config) {
|
|
159
|
+
const resolved = await resolveConfigPath();
|
|
160
|
+
try {
|
|
161
|
+
const raw = await readFile(resolved.path, "utf8");
|
|
162
|
+
console.log(`[roundhouse] loaded config from ${resolved.path}`);
|
|
163
|
+
config = JSON.parse(raw) as GatewayConfig;
|
|
164
|
+
} catch (err: any) {
|
|
165
|
+
// File not found → try cwd. Parse error on existing file → fail fast.
|
|
166
|
+
if (err.code !== "ENOENT") {
|
|
167
|
+
console.error(`[roundhouse] failed to parse config at ${resolved.path}: ${err.message}`);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
// Try cwd
|
|
171
|
+
try {
|
|
172
|
+
const cwdPath = resolve(process.cwd(), "gateway.config.json");
|
|
173
|
+
const raw = await readFile(cwdPath, "utf8");
|
|
174
|
+
console.log("[roundhouse] loaded gateway.config.json from cwd");
|
|
175
|
+
config = JSON.parse(raw) as GatewayConfig;
|
|
176
|
+
} catch (cwdErr: any) {
|
|
177
|
+
if (cwdErr.code !== "ENOENT") {
|
|
178
|
+
console.error(`[roundhouse] failed to parse config at ./gateway.config.json: ${cwdErr.message}`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
console.log("[roundhouse] using default config + env vars");
|
|
182
|
+
config = DEFAULT_CONFIG;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return applyEnvOverrides(config);
|
|
188
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cron/constants.ts — Shared constants for cron system
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Default job timeout: 30 minutes */
|
|
6
|
+
export const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
/** Default timezone when none specified */
|
|
9
|
+
export const DEFAULT_TIMEZONE = "UTC";
|
|
10
|
+
|
|
11
|
+
/** Scheduler tick interval: 60 seconds */
|
|
12
|
+
export const TICK_MS = 60_000;
|
|
13
|
+
|
|
14
|
+
/** Shutdown grace period: 30 seconds */
|
|
15
|
+
export const SHUTDOWN_TIMEOUT_MS = 30_000;
|
|
16
|
+
|
|
17
|
+
/** Max catch-up iterations to prevent blocking on high-frequency schedules */
|
|
18
|
+
export const MAX_CATCHUP_ITERATIONS = 10_000;
|
|
19
|
+
|
|
20
|
+
/** Default number of recent runs to show */
|
|
21
|
+
export const DEFAULT_RUNS_LIMIT = 10;
|
|
22
|
+
|
|
23
|
+
/** Telegram notification: max response text chars */
|
|
24
|
+
export const NOTIFY_MAX_RESPONSE_CHARS = 3500;
|
|
25
|
+
|
|
26
|
+
/** Telegram notification: max error text chars */
|
|
27
|
+
export const NOTIFY_MAX_ERROR_CHARS = 500;
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
/** Valid notify-on values */
|
|
31
|
+
export const VALID_NOTIFY_ON = ["always", "success", "failure"] as const;
|
|
32
|
+
export type NotifyOn = (typeof VALID_NOTIFY_ON)[number];
|
|
33
|
+
|
|
34
|
+
/** Heartbeat interval: 30 minutes */
|
|
35
|
+
export const HEARTBEAT_INTERVAL_MS = 30 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
/** Default HEARTBEAT.md content — if file matches this exactly, heartbeat is skipped */
|
|
38
|
+
export const HEARTBEAT_DEFAULT_CONTENT = `# Heartbeat Instructions
|
|
39
|
+
|
|
40
|
+
# Add your recurring tasks below. The agent will check these every 30 minutes.
|
|
41
|
+
# If this file is empty or contains only this default text, no action is taken.
|
|
42
|
+
#
|
|
43
|
+
# Example:
|
|
44
|
+
# ## Every heartbeat:
|
|
45
|
+
# - Check disk usage and warn if above 80%
|
|
46
|
+
# - Check if roundhouse gateway is healthy
|
|
47
|
+
#
|
|
48
|
+
# ## Every morning:
|
|
49
|
+
# - Summarize overnight system events`;
|
|
50
|
+
|
|
51
|
+
/** Suffix appended to every cron prompt to constrain agent output */
|
|
52
|
+
export const CRON_PROMPT_SUFFIX = `
|
|
53
|
+
|
|
54
|
+
IMPORTANT: You are running as an automated cron job. Your entire text output will be sent as a notification message. Output ONLY the requested content — no preamble, no explanation of what you are, no offers to help with other things. Do not repeat the request. Be concise and direct.`;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cron/durations.ts — Parse human-friendly duration strings
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const UNITS: Record<string, number> = {
|
|
6
|
+
s: 1000,
|
|
7
|
+
m: 60_000,
|
|
8
|
+
h: 3_600_000,
|
|
9
|
+
d: 86_400_000,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Parse "30s", "5m", "6h", "2d" → milliseconds. Throws on invalid input. */
|
|
13
|
+
export function parseDuration(input: string): number {
|
|
14
|
+
const match = input.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)$/i);
|
|
15
|
+
if (!match) throw new Error(`Invalid duration: "${input}". Use format like 30s, 5m, 6h, 2d`);
|
|
16
|
+
const value = parseFloat(match[1]);
|
|
17
|
+
const unit = match[2].toLowerCase();
|
|
18
|
+
if (value <= 0) throw new Error(`Duration must be positive: "${input}"`);
|
|
19
|
+
return Math.round(value * UNITS[unit]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Format milliseconds as human-friendly string */
|
|
23
|
+
export function formatDuration(ms: number): string {
|
|
24
|
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
|
25
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
|
|
26
|
+
if (ms < 86_400_000) return `${(ms / 3_600_000).toFixed(1)}h`;
|
|
27
|
+
return `${(ms / 86_400_000).toFixed(1)}d`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Check if a string looks like a duration */
|
|
31
|
+
export function isDuration(input: string): boolean {
|
|
32
|
+
return /^\d+(?:\.\d+)?\s*[smhd]$/i.test(input.trim());
|
|
33
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cron/format.ts — Shared cron formatting utilities
|
|
3
|
+
*
|
|
4
|
+
* Used by CLI, gateway /crons, and notifications.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CronSchedule, CronJobConfig, CronJobState, CronRunRecord, CronRunStatus } from "./types";
|
|
8
|
+
import { DEFAULT_TIMEOUT_MS } from "./constants";
|
|
9
|
+
import { formatDuration } from "./durations";
|
|
10
|
+
|
|
11
|
+
/** Format a schedule for human-readable display */
|
|
12
|
+
export function formatSchedule(schedule: CronSchedule): string {
|
|
13
|
+
switch (schedule.type) {
|
|
14
|
+
case "cron": return `${cronToHuman(schedule.cron)} (${schedule.tz})`;
|
|
15
|
+
case "interval": return `every ${schedule.every}`;
|
|
16
|
+
case "once": return `once at ${schedule.at}${schedule.tz ? ` (${schedule.tz})` : ""}`;
|
|
17
|
+
default: return "unknown";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Convert a 5-field cron expression to human-readable text */
|
|
22
|
+
function cronToHuman(expr: string): string {
|
|
23
|
+
const parts = expr.trim().split(/\s+/);
|
|
24
|
+
if (parts.length !== 5) return expr;
|
|
25
|
+
const [min, hour, dom, month, dow] = parts;
|
|
26
|
+
|
|
27
|
+
const time = formatCronTime(hour, min);
|
|
28
|
+
if (!time) return expr; // complex expression — show raw
|
|
29
|
+
const isRepeating = time.startsWith("every");
|
|
30
|
+
const dayPart = formatCronDays(dom, month, dow);
|
|
31
|
+
|
|
32
|
+
if (isRepeating && (!dayPart || dayPart === "daily")) return time;
|
|
33
|
+
if (isRepeating && dayPart) return `${dayPart}, ${time}`;
|
|
34
|
+
if (dayPart) return `${dayPart} at ${time}`;
|
|
35
|
+
return expr; // complex — show raw
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function formatCronTime(hour: string, min: string): string | null {
|
|
39
|
+
if (hour === "*" && min === "*") return "every minute";
|
|
40
|
+
if (hour.startsWith("*/")) {
|
|
41
|
+
if (min !== "0" && min !== "*") return null; // complex: specific min + repeating hour
|
|
42
|
+
const n = hour.slice(2); return n === "1" ? "every hour" : `every ${n} hours`;
|
|
43
|
+
}
|
|
44
|
+
if (min.startsWith("*/") && hour === "*") { const n = min.slice(2); return n === "1" ? "every minute" : `every ${n} minutes`; }
|
|
45
|
+
if (min.startsWith("*/")) return null; // complex: min repeat + specific hour
|
|
46
|
+
if (hour === "*" && /^\d+$/.test(min)) return `every hour at :${min.padStart(2, "0")}`;
|
|
47
|
+
if (hour === "*") return null; // complex min field
|
|
48
|
+
|
|
49
|
+
// Only parse simple numeric values — reject ranges/lists
|
|
50
|
+
if (!/^\d+$/.test(hour) || !/^\d+$/.test(min)) return null;
|
|
51
|
+
const h = parseInt(hour, 10);
|
|
52
|
+
const m = parseInt(min, 10);
|
|
53
|
+
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
|
|
54
|
+
const ampm = h >= 12 ? "pm" : "am";
|
|
55
|
+
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
|
56
|
+
return m === 0 ? `${h12}${ampm}` : `${h12}:${String(m).padStart(2, "0")}${ampm}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function formatCronDays(dom: string, month: string, dow: string): string {
|
|
60
|
+
const DOW_NAMES: Record<string, string> = { "0": "Sun", "1": "Mon", "2": "Tue", "3": "Wed", "4": "Thu", "5": "Fri", "6": "Sat", "7": "Sun" };
|
|
61
|
+
const DOW_RANGE: Record<string, string> = { "1-5": "weekdays", "0,6": "weekends", "6,0": "weekends" };
|
|
62
|
+
|
|
63
|
+
// Non-* month makes it complex — return empty to trigger raw fallback
|
|
64
|
+
if (month !== "*") return "";
|
|
65
|
+
|
|
66
|
+
if (dom === "*" && dow === "*") return "daily";
|
|
67
|
+
|
|
68
|
+
if (dom === "*" && dow !== "*") {
|
|
69
|
+
if (DOW_RANGE[dow]) return DOW_RANGE[dow];
|
|
70
|
+
// Handle ranges like 1-3 and lists like 1,3,5
|
|
71
|
+
const days = dow.split(",").map((d) => {
|
|
72
|
+
if (d.includes("-")) {
|
|
73
|
+
const [s, e] = d.split("-");
|
|
74
|
+
return `${DOW_NAMES[s] ?? s}-${DOW_NAMES[e] ?? e}`;
|
|
75
|
+
}
|
|
76
|
+
return DOW_NAMES[d] ?? d;
|
|
77
|
+
}).join(", ");
|
|
78
|
+
return `every ${days}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (dom !== "*" && dow === "*") {
|
|
82
|
+
return `on day ${dom} of each month`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return ""; // complex — both dom + dow set
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Icon for job enabled/paused state */
|
|
89
|
+
export function jobEnabledIcon(enabled: boolean): string {
|
|
90
|
+
return enabled ? "✅" : "⏸️";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Icon for run status */
|
|
94
|
+
export function runStatusIcon(status: CronRunStatus): string {
|
|
95
|
+
return status === "completed" ? "✅" : "❌";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Format run count summary */
|
|
99
|
+
export function formatRunCounts(state: CronJobState): string {
|
|
100
|
+
return `${state.totalRuns} runs (${state.totalSuccesses}✓ ${state.totalFailures}✗)`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Format a single run line */
|
|
104
|
+
export function formatRunLine(run: CronRunRecord): string {
|
|
105
|
+
return `${runStatusIcon(run.status)} ${run.id} ${run.status} ${run.durationMs}ms`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Format a job summary line */
|
|
109
|
+
export function formatJobSummary(job: CronJobConfig, state: CronJobState): string {
|
|
110
|
+
return `${jobEnabledIcon(job.enabled)} ${job.id}: ${formatSchedule(job.schedule)} — ${formatRunCounts(state)}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Format job detail (for show command) */
|
|
114
|
+
export function formatJobDetail(job: CronJobConfig, state: CronJobState, runs: CronRunRecord[]): string {
|
|
115
|
+
const lines: string[] = [];
|
|
116
|
+
lines.push(`Job: ${job.id}`);
|
|
117
|
+
lines.push(`Enabled: ${job.enabled}`);
|
|
118
|
+
lines.push(`Schedule: ${formatSchedule(job.schedule)}`);
|
|
119
|
+
if (job.description) lines.push(`Description: ${job.description}`);
|
|
120
|
+
lines.push(`Prompt: ${job.prompt.slice(0, 200)}`);
|
|
121
|
+
lines.push(`Timeout: ${formatDuration(job.timeoutMs ?? DEFAULT_TIMEOUT_MS)}`);
|
|
122
|
+
lines.push(``);
|
|
123
|
+
lines.push(`Runs: ${formatRunCounts(state)}`);
|
|
124
|
+
if (state.lastRunId) lines.push(`Last run: ${state.lastRunId} at ${state.lastFinishedAt}`);
|
|
125
|
+
if (state.lastError) lines.push(`Last error: ${state.lastError}`);
|
|
126
|
+
if (runs.length) {
|
|
127
|
+
lines.push(``);
|
|
128
|
+
lines.push(`Recent runs:`);
|
|
129
|
+
for (const r of runs) {
|
|
130
|
+
lines.push(` ${formatRunLine(r)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return lines.join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Default empty state for a job */
|
|
137
|
+
export function emptyState(id: string): CronJobState {
|
|
138
|
+
return { id, totalRuns: 0, totalSuccesses: 0, totalFailures: 0 };
|
|
139
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cron/helpers.ts — Shared cron helpers to eliminate cross-module duplication
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Built-in job ID prefix */
|
|
6
|
+
export const BUILTIN_JOB_PREFIX = "builtin-";
|
|
7
|
+
|
|
8
|
+
/** Built-in heartbeat job ID */
|
|
9
|
+
export const BUILTIN_HEARTBEAT_JOB_ID = `${BUILTIN_JOB_PREFIX}heartbeat`;
|
|
10
|
+
|
|
11
|
+
/** Heartbeat file name */
|
|
12
|
+
export const HEARTBEAT_FILE_NAME = "HEARTBEAT.md";
|
|
13
|
+
|
|
14
|
+
/** Check if a job ID is a built-in job */
|
|
15
|
+
export function isBuiltinJob(id: string): boolean {
|
|
16
|
+
return id.startsWith(BUILTIN_JOB_PREFIX);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Build a cron thread ID */
|
|
20
|
+
export function buildCronThreadId(jobId: string, runId: string): string {
|
|
21
|
+
return `cron:${jobId}:${runId}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Check if a notify policy should send for a given status */
|
|
25
|
+
export function shouldNotify(policy: string | undefined, status: string): boolean {
|
|
26
|
+
if (!policy || policy === "always") return true;
|
|
27
|
+
if (policy === "success" && status === "completed") return true;
|
|
28
|
+
if (policy === "failure" && status !== "completed") return true;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cron/runner.ts — Execute a single cron job
|
|
3
|
+
*
|
|
4
|
+
* Creates a fresh agent per run, renders prompt, runs with timeout,
|
|
5
|
+
* saves results, notifies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getAgentFactory } from "../agents/registry";
|
|
9
|
+
import { sendTelegramToMany } from "../notify/telegram";
|
|
10
|
+
import { CronStore, generateRunId } from "./store";
|
|
11
|
+
import { buildTemplateContext, renderTemplate } from "./template";
|
|
12
|
+
import type { CronJobConfig, CronRunRecord } from "./types";
|
|
13
|
+
import { isBuiltinJob, buildCronThreadId, shouldNotify } from "./helpers";
|
|
14
|
+
import { DEFAULT_TIMEOUT_MS, NOTIFY_MAX_RESPONSE_CHARS, NOTIFY_MAX_ERROR_CHARS, CRON_PROMPT_SUFFIX } from "./constants";
|
|
15
|
+
import { runStatusIcon } from "./format";
|
|
16
|
+
import type { GatewayConfig } from "../types";
|
|
17
|
+
|
|
18
|
+
export class CronRunner {
|
|
19
|
+
constructor(
|
|
20
|
+
private store: CronStore,
|
|
21
|
+
private agentConfig?: GatewayConfig["agent"],
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
async runJob(
|
|
25
|
+
job: CronJobConfig,
|
|
26
|
+
scheduledAt: Date,
|
|
27
|
+
kind: "scheduled" | "manual" = "scheduled",
|
|
28
|
+
): Promise<CronRunRecord> {
|
|
29
|
+
const runId = generateRunId();
|
|
30
|
+
const startedAt = new Date();
|
|
31
|
+
const threadId = buildCronThreadId(job.id, runId);
|
|
32
|
+
const timeoutMs = job.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
33
|
+
|
|
34
|
+
// Render prompt
|
|
35
|
+
const tz = job.schedule.type === "cron" ? job.schedule.tz : job.schedule.type === "once" ? job.schedule.tz : undefined;
|
|
36
|
+
const ctx = buildTemplateContext(job.id, job.description, runId, scheduledAt, startedAt, tz, process.cwd(), job.vars ?? {});
|
|
37
|
+
const prompt = renderTemplate(job.prompt, ctx) + CRON_PROMPT_SUFFIX;
|
|
38
|
+
|
|
39
|
+
console.log(`[cron] starting ${job.id} [${runId}] kind=${kind}`);
|
|
40
|
+
|
|
41
|
+
// Create fresh agent — use provided config or load dynamically for CLI trigger
|
|
42
|
+
let agentCfg = this.agentConfig;
|
|
43
|
+
if (!agentCfg) {
|
|
44
|
+
const { loadConfig } = await import("../config");
|
|
45
|
+
agentCfg = (await loadConfig()).agent;
|
|
46
|
+
}
|
|
47
|
+
const { type, ...rest } = agentCfg;
|
|
48
|
+
const factory = getAgentFactory(type);
|
|
49
|
+
const agent = factory({ ...rest, sessionDir: undefined });
|
|
50
|
+
|
|
51
|
+
let responseText = "";
|
|
52
|
+
let error: string | undefined;
|
|
53
|
+
let status: CronRunRecord["status"] = "completed";
|
|
54
|
+
let timedOut = false;
|
|
55
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
// Race agent.prompt against timeout
|
|
59
|
+
const result = await Promise.race([
|
|
60
|
+
agent.prompt(threadId, { text: prompt }),
|
|
61
|
+
new Promise<never>((_, reject) => {
|
|
62
|
+
timer = setTimeout(async () => {
|
|
63
|
+
timedOut = true;
|
|
64
|
+
try { await agent.abort?.(threadId); } catch {}
|
|
65
|
+
reject(new Error(`Cron job timed out after ${timeoutMs}ms`));
|
|
66
|
+
}, timeoutMs);
|
|
67
|
+
}),
|
|
68
|
+
]);
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
responseText = result.text;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
error = (err as Error).message;
|
|
74
|
+
status = timedOut ? "timeout" : "failed";
|
|
75
|
+
console.error(`[cron] ${job.id} [${runId}] ${status}:`, error);
|
|
76
|
+
} finally {
|
|
77
|
+
try { await agent.dispose(); } catch {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const finishedAt = new Date();
|
|
81
|
+
const record: CronRunRecord = {
|
|
82
|
+
id: runId, jobId: job.id, kind, status,
|
|
83
|
+
scheduledAt: scheduledAt.toISOString(),
|
|
84
|
+
startedAt: startedAt.toISOString(),
|
|
85
|
+
finishedAt: finishedAt.toISOString(),
|
|
86
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
87
|
+
threadId, prompt,
|
|
88
|
+
responseText: responseText || undefined,
|
|
89
|
+
error,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Save run + update state (skip entirely for built-in jobs)
|
|
93
|
+
const isBuiltin = isBuiltinJob(job.id);
|
|
94
|
+
|
|
95
|
+
if (!isBuiltin) {
|
|
96
|
+
try {
|
|
97
|
+
await this.store.appendRun(record);
|
|
98
|
+
const state = await this.store.getState(job.id);
|
|
99
|
+
state.lastRunId = runId;
|
|
100
|
+
state.lastStartedAt = startedAt.toISOString();
|
|
101
|
+
state.lastFinishedAt = finishedAt.toISOString();
|
|
102
|
+
state.totalRuns++;
|
|
103
|
+
if (status === "completed") {
|
|
104
|
+
state.lastSuccessAt = finishedAt.toISOString();
|
|
105
|
+
state.totalSuccesses++;
|
|
106
|
+
state.lastError = undefined;
|
|
107
|
+
} else {
|
|
108
|
+
state.lastFailureAt = finishedAt.toISOString();
|
|
109
|
+
state.totalFailures++;
|
|
110
|
+
state.lastError = error ?? status;
|
|
111
|
+
}
|
|
112
|
+
await this.store.writeState(state);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error(`[cron] ${job.id} [${runId}] failed to persist run:`, (err as Error).message);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Notify (catch errors)
|
|
119
|
+
try { await this.notify(job, record); } catch (err) {
|
|
120
|
+
console.error(`[cron] ${job.id} [${runId}] notification failed:`, (err as Error).message);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(`[cron] finished ${job.id} [${runId}] status=${status} duration=${record.durationMs}ms`);
|
|
124
|
+
return record;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async notify(job: CronJobConfig, record: CronRunRecord): Promise<void> {
|
|
128
|
+
const tg = job.notify?.telegram;
|
|
129
|
+
if (!tg?.chatIds?.length) return;
|
|
130
|
+
|
|
131
|
+
if (!shouldNotify(tg.onlyOn, record.status)) return;
|
|
132
|
+
|
|
133
|
+
const icon = runStatusIcon(record.status);
|
|
134
|
+
const dur = `${(record.durationMs / 1000).toFixed(1)}s`;
|
|
135
|
+
const header = `${icon} Cron: ${job.id}\nStatus: ${record.status} (${dur})`;
|
|
136
|
+
|
|
137
|
+
let body = header;
|
|
138
|
+
if (record.responseText) {
|
|
139
|
+
const trimmed = record.responseText.slice(0, NOTIFY_MAX_RESPONSE_CHARS);
|
|
140
|
+
body = `${header}\n\n${trimmed}`;
|
|
141
|
+
if (record.responseText.length > NOTIFY_MAX_RESPONSE_CHARS) body += "\n\n(truncated)";
|
|
142
|
+
} else if (record.error) {
|
|
143
|
+
body = `${header}\nError: ${record.error.slice(0, NOTIFY_MAX_ERROR_CHARS)}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await sendTelegramToMany(tg.chatIds, body);
|
|
147
|
+
}
|
|
148
|
+
}
|