Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 15 additions & 16 deletions coverage.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
ℹ start of coverage report
ℹ -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ℹ file | line % | branch % | funcs % | uncovered lines
ℹ -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ℹ src | | | |
ℹ agent | | | |
ℹ agentRegistry.js | 92.23 | 84.00 | 100.00 | 72-73 78-79 82-83 86-87
Expand All @@ -27,7 +27,7 @@
ℹ gc.js | 99.30 | 96.00 | 100.00 | 53
ℹ profile.js | 98.87 | 96.23 | 100.00 | 76-77
ℹ prompts.js | 100.00 | 100.00 | 100.00 |
ℹ reader.js | 96.15 | 81.25 | 100.00 | 22-23
ℹ reader.js | 96.36 | 81.25 | 100.00 | 22-23
ℹ provider | | | |
ℹ openai.js | 100.00 | 100.00 | 100.00 |
ℹ sandbox | | | |
Expand All @@ -38,31 +38,30 @@
ℹ timeoutHandler.js | 100.00 | 100.00 | 100.00 |
ℹ urlFilter.js | 100.00 | 93.75 | 100.00 |
ℹ scheduler | | | |
ℹ autoSchedule.js | 91.15 | 73.33 | 100.00 | 53-55 69-71 108-111
ℹ cron.js | 48.16 | 30.00 | 52.94 | 33-37 49 61-63 104-106 108-110 114-115 131-132 137-154 156-169 181-182 189-194 202-205 210-212 224-267 283-285 287-289 300-302 304-306 324-326 328-330 332-339 350-351 363-382 391-414 430-515
ℹ cron.js | 30.19 | 100.00 | 5.88 | 29-30 52-53 65 73-79 87-96 103-105 114-134 145-185 196-230 240-283 291-326 334-370 379-398 408-432 441-464 480-571
ℹ index.js | 100.00 | 100.00 | 100.00 |
ℹ scheduler.js | 88.55 | 89.66 | 81.82 | 87-99 129-130
ℹ scheduler.js | 99.09 | 95.00 | 85.00 | 217-218
ℹ session | | | |
ℹ checkpointer.js | 82.22 | 87.50 | 50.00 | 22 24 39-43 45
ℹ factory.js | 100.00 | 100.00 | 100.00 |
ℹ index.js | 100.00 | 100.00 | 100.00 |
ℹ loader.js | 22.22 | 100.00 | 0.00 | 16-46 48-72
ℹ loader.js | 20.78 | 100.00 | 0.00 | 16-51 53-77
ℹ onboarding.js | 95.83 | 89.58 | 100.00 | 162-168 195-196
ℹ saver.js | 98.18 | 75.00 | 100.00 | 47
ℹ shutdown.js | 74.00 | 100.00 | 50.00 | 38-50
ℹ shutdown.js | 73.47 | 100.00 | 50.00 | 37-49
ℹ stateManager.js | 100.00 | 100.00 | 100.00 |
ℹ window.js | 100.00 | 91.67 | 100.00 |
ℹ skills | | | |
ℹ discoverer.js | 96.33 | 87.93 | 100.00 | 61-66 173-174
ℹ registry.js | 75.72 | 57.89 | 44.44 | 46-49 52-54 108-109 126-127 135-144 155-157 160-162 188-194 202-206 214-218 225-226 240-247 256-263 271-275
ℹ discoverer.js | 96.35 | 87.93 | 100.00 | 61-66 173-174
ℹ registry.js | 76.69 | 46.15 | 47.37 | 46-49 52-54 106-107 128-129 146-147 155-164 175-177 180-182 208-214 222-226 234-238 245-246 260-267 276-283 291-295
ℹ types.js | 100.00 | 100.00 | 100.00 |
ℹ validator.js | 89.78 | 76.32 | 80.00 | 19-20 27-28 68 70 72-73 105-107 119-121
ℹ tools | | | |
ℹ clarify.js | 100.00 | 94.12 | 100.00 |
ℹ code.js | 100.00 | 81.25 | 100.00 |
ℹ common.js | 100.00 | 92.86 | 83.33 |
ℹ compact_context.js | 23.40 | 100.00 | 14.29 | 18-29 37-39 47-50 58-65 84-288 307-385
ℹ cron.js | 94.74 | 90.10 | 73.68 | 93-94 106-107 228-229 231-242 246-252
ℹ cron.js | 94.41 | 88.46 | 73.68 | 93-94 106-107 228-229 231-242 246-252 325-326
ℹ date.js | 100.00 | 100.00 | 100.00 |
ℹ image.js | 97.50 | 91.67 | 50.00 | 95-97
ℹ index.js | 100.00 | 94.29 | 100.00 |
Expand All @@ -71,8 +70,8 @@
ℹ sampling.js | 94.97 | 81.82 | 80.00 | 27 180-188
ℹ scanAgents.js | 100.00 | 80.00 | 100.00 |
ℹ session_search.js | 97.06 | 71.19 | 94.12 | 71-72 118-119 128 181-182
ℹ shell.js | 92.52 | 76.47 | 86.67 | 41-44 80 108-109 195-196 202-204 210-211 218-219 226-227 229
ℹ skills.js | 77.41 | 85.48 | 60.00 | 40-60 81-114 167-168 195-196 223-231 242-249 269-276 292-294 309-310
ℹ shell.js | 92.58 | 76.47 | 86.67 | 41-44 81 110-111 197-198 204-206 212-213 220-221 228-229 231
ℹ skills.js | 77.47 | 85.48 | 60.00 | 40-60 81-114 168-169 196-197 224-232 243-250 270-277 293-295 310-311
ℹ tts.js | 100.00 | 88.00 | 50.00 |
ℹ vision.js | 100.00 | 84.21 | 80.00 |
ℹ web.js | 95.14 | 69.88 | 62.50 | 27-28 42-43 46-48 89-91 126-128 192-194 325-326
Expand All @@ -90,7 +89,7 @@
ℹ statusBar.js | 91.89 | 84.21 | 100.00 | 36-37 48-54
ℹ workspace | | | |
ℹ loadAgents.js | 100.00 | 87.50 | 100.00 |
ℹ -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ℹ all files | 87.40 | 82.04 | 81.13 |
ℹ -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ℹ all files | 86.32 | 83.32 | 79.44 |
ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ℹ end of coverage report
3 changes: 2 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ const { flush: flushLogger } = await import("./src/logger.js");

// Initialize scheduler
const { ScheduleManager } = await import("./src/scheduler/index.js");
const scheduleManager = new ScheduleManager();
const schedulesDir = config.memory?.schedulesDir || "memory/schedules/";
const scheduleManager = await ScheduleManager.loadFromDisk(config.cwd + "/" + schedulesDir);

// Create or restore session
const providerName = Object.keys(config.providers)[0] || "openai";
Expand Down
25 changes: 22 additions & 3 deletions src/scheduler/cron.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { join } from "node:path";

Expand All @@ -18,8 +17,28 @@ const REFLECTION_JOB = {
/** @type {string|undefined} */
let _logPath = undefined;

/**
* Execute a shell command with optional stdin input.
* @param {string} cmd - Shell command to execute
* @param {object} [opts={}] - Options
* @param {string} [opts.input] - Data to write to stdin
* @returns {Promise<{stdout: string, stderr: string}>} Command output
*/
function runExec(cmd, opts = {}) {
return new Promise((resolve, reject) => {
const child = exec(cmd, opts, (err, stdout, stderr) => {
if (err) reject(err);
else resolve({ stdout, stderr });
});
if (opts.input) {
child.stdin.write(opts.input);
}
child.stdin.end();
});
}

/** @type {typeof import("node:child_process").exec|undefined} */
let _execOverride = promisify(exec);
let _execOverride = runExec;

/**
* Set a custom exec function for testing.
Expand Down Expand Up @@ -562,7 +581,7 @@ export const Cron = {
}

try {
this._writeCrontab(outsideLines.join("\n"));
await this._writeCrontab(outsideLines.join("\n"));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { added, removed, updated, skipped, error: `Failed to write crontab: ${msg}` };
Expand Down
128 changes: 108 additions & 20 deletions src/scheduler/scheduler.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";

const DEFAULT_TIMEOUT_MS = 60000;

/**
Expand All @@ -10,8 +13,65 @@ export class ScheduleManager {

/**
* @param {number} [_maxConcurrent=1] - Deprecated, kept for API compat
* @param {Array} [entries] - Optional initial schedule entries
*/
constructor(_maxConcurrent = 1, entries = []) {
for (const entry of entries) {
this.#scheduleEntry.set(entry.name, { ...entry, paused: false, lastRun: null });
}
}

/**
* Load schedule entries from JSON files in the schedules directory.
* Reads *.json files, skips entries with enabled: false or malformed data.
* For entries with a 'skill' field, the command is set to the reflection-style shell command.
* For entries with only a 'command' field, the command is used directly.
* @param {string} schedulesDir - Path to the schedules directory
* @param {object} [deps] - Dependency injection for testing ({ readFile, readdir })
* @returns {Promise<ScheduleManager>} New instance populated with disk entries
*/
constructor(_maxConcurrent = 1) {}
static async loadFromDisk(schedulesDir, deps = {}) {
const readFileFn = deps.readFile || readFile;
const readdirFn = deps.readdir || readdir;
const entries = [];

try {
const files = await readdirFn(schedulesDir);
for (const file of files) {
if (!file.endsWith(".json")) continue;
try {
const content = await readFileFn(join(schedulesDir, file), "utf-8");
const job = JSON.parse(content);
if (job.enabled === false) continue;
if (!job.name || !job.cron || (!job.skill && !job.command)) continue;

const entry = {
name: job.name,
cron: job.cron,
input: job.input || {},
contextFile: "",
paused: false,
lastRun: null,
};

if (job.skill) {
entry.skill = job.skill;
entry.command = `cd ${process.cwd()} && node index.js --message "Run the ${job.skill} skill"`;
} else if (job.command) {
entry.command = job.command;
}

entries.push(entry);
} catch {
// Skip malformed JSON files
}
}
} catch {
// Directory doesn't exist — return empty manager
}

return new ScheduleManager(undefined, entries);
}

/**
* Register schedule entries from config-style objects.
Expand All @@ -21,11 +81,20 @@ export class ScheduleManager {
register(entries = []) {
const results = [];
for (const entry of entries) {
if (!entry.name || !entry.cron || !entry.skill) {
results.push({ name: entry.name, error: "Missing required fields (name, cron, skill)" });
if (!entry.name || !entry.cron || (!entry.skill && !entry.command)) {
results.push({
name: entry.name,
error: "Missing required fields (name, cron, skill or command)",
});
continue;
}
this.#scheduleEntry.set(entry.name, { ...entry, paused: false, lastRun: null });
this.#scheduleEntry.set(entry.name, {
...entry,
paused: false,
lastRun: null,
input: entry.input || {},
contextFile: entry.contextFile || "",
});
}
return results;
}
Expand Down Expand Up @@ -67,7 +136,9 @@ export class ScheduleManager {
}

/**
* Run a schedule immediately via the sandbox.
* Run a schedule immediately via the sandbox or command.
* If the entry has a 'skill', uses the sandbox. If it has only a 'command',
* spawns the command directly via shell.
* @param {string} name - Schedule name
* @param {Object} scheduler - The full scheduler instance for sandbox access
* @returns {Promise<Object>} Execution result
Expand All @@ -80,24 +151,41 @@ export class ScheduleManager {
return { error: `Schedule "${name}" is paused` };
}

const contextDir = scheduler.state?.contextDir || "memory/context/";
const timeoutMs = scheduler.state?.timeoutMs || DEFAULT_TIMEOUT_MS;
let contextPrefix = "";
if (entry.contextFile) {
try {
const { readFile, access, constants } = await import("node:fs/promises");
const { loadContext } = await import("../memory/context.js");
try {
await access(entry.contextFile, constants.F_OK);
contextPrefix = await readFile(entry.contextFile, "utf-8");
} catch {
contextPrefix = loadContext(contextDir);
}
} catch {
// Context load failed — continue with empty context
}

if (entry.command && !entry.skill) {
const { spawn } = await import("node:child_process");
return new Promise((resolve) => {
const child = spawn("/bin/sh", ["-c", entry.command], {
stdio: ["pipe", "pipe", "pipe"],
});

const chunks = { stdout: [], stderr: [] };
let settled = false;
const settle = (exitCode) => {
if (settled) return;
settled = true;
entry.lastRun = new Date().toISOString();
resolve({
stdout: Buffer.concat(chunks.stdout).toString(),
stderr: Buffer.concat(chunks.stderr).toString(),
exitCode,
});
};

child.stdout.on("data", (chunk) => chunks.stdout.push(chunk));
child.stderr.on("data", (chunk) => chunks.stderr.push(chunk));
child.on("exit", (code) => settle(code ?? 0));
child.on("error", () => settle(-1));

setTimeout(() => {
child.kill("SIGTERM");
setTimeout(() => settle(-1), 3000);
}, timeoutMs);
});
}

const contextPrefix = "";
const sandbox = scheduler.sandbox || (() => ({ stdout: "", stderr: "", exitCode: 1 }));
const result = await Promise.race([
sandbox({
Expand Down
Loading