171 lines
5.4 KiB
JavaScript
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;
|
|
}
|