singularity-forge/src/resources/extensions/sf/schedule/schedule-auto-dispatch.js

171 lines
5.4 KiB
JavaScript

/**
* Schedule Auto Dispatch — first-class execution for due project schedule entries.
*
* Purpose: let autonomous mode consume repo-owned scheduled work without a
* human approval loop while preserving append-only evidence in the schedule log.
*
* Consumer: auto-dispatch.js and commands-schedule.js.
*/
import { execSync } from "node:child_process";
import { createScheduleStore } from "./schedule-store.js";
const MAX_RESULT_CHARS = 12_000;
/**
* Return true when a schedule entry is allowed to run from autonomous mode.
*
* Purpose: keep auto execution explicit; passive reminders and global schedule
* entries remain visible but do not become repo cron jobs accidentally.
*
* Consumer: auto-dispatch.js schedule rule.
*
* @param {import("./schedule-types.js").ScheduleEntry} entry
* @returns {boolean}
*/
export function isAutoDispatchScheduleEntry(entry) {
return (
entry?.auto_dispatch === true &&
(entry.kind === "command" || entry.kind === "prompt")
);
}
/**
* Mark a project schedule entry done with a bounded result note.
*
* Purpose: make schedule consumption durable so due auto-dispatch entries do
* not repeat forever after a successful autonomous tick.
*
* Consumer: executeProjectScheduleCommand and prompt schedule dispatch.
*
* @param {string} basePath
* @param {import("./schedule-types.js").ScheduleEntry} entry
* @param {Record<string, unknown>} [payloadPatch]
* @returns {import("./schedule-types.js").ScheduleEntry}
*/
export function markProjectScheduleDone(basePath, entry, payloadPatch = {}) {
const updated = {
...entry,
status: "done",
created_at: new Date().toISOString(),
payload: {
...(entry.payload ?? {}),
...payloadPatch,
},
};
createScheduleStore(basePath).appendEntry("project", updated);
return updated;
}
/**
* Mark a project schedule entry cancelled with a bounded failure note.
*
* Purpose: preserve failed cron evidence and prevent an invalid command from
* hot-looping through autonomous dispatch forever.
*
* Consumer: executeProjectScheduleCommand and schedule dispatch failure paths.
*
* @param {string} basePath
* @param {import("./schedule-types.js").ScheduleEntry} entry
* @param {string} reason
* @returns {import("./schedule-types.js").ScheduleEntry}
*/
export function markProjectScheduleCancelled(basePath, entry, reason) {
const updated = {
...entry,
status: "cancelled",
created_at: new Date().toISOString(),
payload: {
...(entry.payload ?? {}),
result_note: _truncate(reason),
},
};
createScheduleStore(basePath).appendEntry("project", updated);
return updated;
}
/**
* Execute one project-scoped command schedule entry from the repo root.
*
* Purpose: make `kind: "command", auto_dispatch: true` behave like a repo cron
* job in autonomous mode, with durable success/failure status in `.sf`.
*
* Consumer: auto-dispatch.js schedule rule and `/sf schedule run`.
*
* @param {string} basePath
* @param {import("./schedule-types.js").ScheduleEntry} entry
* @returns {{ok: true, status: "done", stdout?: string} | {ok: false, status: "cancelled", reason: string}}
*/
export function executeProjectScheduleCommand(basePath, entry) {
const payload = entry.payload ?? {};
const command = payload.command;
if (typeof command !== "string" || command.trim().length === 0) {
const reason = `Command entry ${entry.id} has no payload.command.`;
markProjectScheduleCancelled(basePath, entry, reason);
return { ok: false, status: "cancelled", reason };
}
try {
const stdout = execSync(command, {
cwd: basePath,
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
env: process.env,
maxBuffer: 16 * 1024 * 1024,
});
const captured =
payload.capture === "stdout" ? _truncate(stdout) : undefined;
markProjectScheduleDone(basePath, entry, {
result_note: "command completed",
...(captured !== undefined ? { stdout: captured } : {}),
});
return captured !== undefined
? { ok: true, status: "done", stdout: captured }
: { ok: true, status: "done" };
} catch (err) {
const reason = _errorText(err);
markProjectScheduleCancelled(basePath, entry, reason);
return { ok: false, status: "cancelled", reason };
}
}
/**
* Build the autonomous prompt for a due prompt schedule entry.
*
* Purpose: turn scheduled intent into a normal custom-step unit while keeping
* schedule storage responsible only for when the trigger fires.
*
* Consumer: auto-dispatch.js schedule rule.
*
* @param {import("./schedule-types.js").ScheduleEntry} entry
* @returns {string}
*/
export function buildScheduledPrompt(entry) {
const payload = entry.payload ?? {};
const text = payload.prompt ?? payload.message ?? entry.id;
return [
`Scheduled autonomous prompt ${entry.id} is due.`,
"",
"Treat this as repo-owned scheduled work. Execute it without asking the user unless a safety gate requires a pause.",
"",
"Scheduled work:",
String(text),
].join("\n");
}
function _errorText(err) {
if (err && typeof err === "object") {
const stderr = "stderr" in err ? err.stderr : undefined;
if (stderr) return _truncate(String(stderr));
const stdout = "stdout" in err ? err.stdout : undefined;
const message = "message" in err ? err.message : undefined;
return _truncate(String(message ?? stdout ?? err));
}
return _truncate(String(err));
}
function _truncate(value) {
const text = String(value ?? "");
return text.length > MAX_RESULT_CHARS
? `${text.slice(0, MAX_RESULT_CHARS)}\n[truncated]`
: text;
}