singularity-forge/src/resources/extensions/sf/commands-schedule.js
2026-05-08 01:34:07 +02:00

582 lines
14 KiB
JavaScript

/**
* SF Command — /schedule
*
* Schedule management: add, list, done, cancel, snooze, run.
* Entries are stored in SQLite (`schedule_entries`). Legacy schedule JSONL is
* imported on first read when the DB has no schedule rows.
*/
import {
executeProjectScheduleCommand,
markProjectScheduleDone,
} from "./schedule/schedule-autonomous-dispatch.js";
import { createScheduleStore } from "./schedule/schedule-store.js";
import { ALL_SCHEDULE_KINDS, isValidKind } from "./schedule/schedule-types.js";
import { generateULID } from "./schedule/schedule-ulid.js";
// ─── Duration parser ────────────────────────────────────────────────────────
/**
* Parse a duration string into milliseconds.
*
* Purpose: convert human-friendly durations like "2w" or "30m" into
* epoch milliseconds for computing due_at timestamps.
*
* Consumer: schedule add and snooze commands.
*
* @param {string} str
* @returns {number} milliseconds
*/
export function parseDuration(str) {
const match = str.trim().match(/^(\d+)([wdhm])$/i);
if (!match) {
throw new Error(
`Invalid duration: "${str}". Expected format: <number><unit> where unit is w(weeks), d(days), h(hours), m(minutes).`,
);
}
const value = parseInt(match[1], 10);
const unit = match[2].toLowerCase();
switch (unit) {
case "w":
return value * 7 * 24 * 60 * 60 * 1000;
case "d":
return value * 24 * 60 * 60 * 1000;
case "h":
return value * 60 * 60 * 1000;
case "m":
return value * 60 * 1000;
default:
throw new Error(`Unknown duration unit: ${unit}`);
}
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function _basePath() {
return process.cwd();
}
function _isoRelative(dueAt) {
const now = Date.now();
const due = new Date(dueAt).getTime();
const diff = due - now;
const abs = Math.abs(diff);
const days = Math.floor(abs / (24 * 60 * 60 * 1000));
const hours = Math.floor((abs % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
if (diff < 0) {
if (days > 0) return `overdue ${days}d`;
if (hours > 0) return `overdue ${hours}h`;
return "overdue";
}
if (days > 0) return `${days}d`;
if (hours > 0) return `${hours}h`;
return "soon";
}
function _formatTable(rows) {
if (rows.length === 0) return "";
const colCount = rows[0].length;
const widths = new Array(colCount).fill(0);
for (const row of rows) {
for (let i = 0; i < colCount; i++) {
widths[i] = Math.max(widths[i], String(row[i]).length);
}
}
return rows
.map((row) =>
row.map((cell, i) => String(cell).padEnd(widths[i])).join(" "),
)
.join("\n");
}
function _findEntry(store, scope, idPrefix) {
const entries = store.readEntries(scope);
return {
entry: entries.find((e) => e.id.startsWith(idPrefix)) ?? null,
entries,
};
}
function _splitArgs(args) {
if (Array.isArray(args)) {
return args.map((part) => String(part)).filter(Boolean);
}
const input = String(args ?? "");
const tokens = [];
let current = "";
let quote = null;
let escaped = false;
for (const char of input) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (quote) {
if (char === quote) {
quote = null;
} else {
current += char;
}
continue;
}
if (char === "'" || char === '"') {
quote = char;
continue;
}
if (/\s/.test(char)) {
if (current) {
tokens.push(current);
current = "";
}
continue;
}
current += char;
}
if (escaped) current += "\\";
if (current) tokens.push(current);
return tokens;
}
function _joinPlain(parts) {
return parts.join(" ").trim();
}
function _shellQuote(part) {
if (/^[A-Za-z0-9_./:=@%+-]+$/.test(part)) return part;
return `'${part.replace(/'/g, "'\\''")}'`;
}
function _commandFromParts(parts) {
return parts
.map((part) => _shellQuote(String(part)))
.join(" ")
.trim();
}
// ─── Subcommands ────────────────────────────────────────────────────────────
async function addItem(args, ctx) {
const parts = _splitArgs(args);
let kind = "reminder";
let scope = "project";
let dueAt = null;
let autonomousDispatch = false;
let capture = null;
const titleParts = [];
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (p === "--") {
titleParts.push(...parts.slice(i + 1));
break;
}
if (p === "--kind" || p === "-k") {
kind = parts[++i];
continue;
}
if (p === "--scope" || p === "-s") {
scope = parts[++i];
continue;
}
if (p === "--in") {
const dur = parts[++i];
try {
dueAt = new Date(Date.now() + parseDuration(dur)).toISOString();
} catch (err) {
ctx.ui.notify(err.message, "warning");
return;
}
continue;
}
if (p === "--at") {
const iso = parts[++i];
const parsed = Date.parse(iso);
if (Number.isNaN(parsed)) {
ctx.ui.notify(`Invalid ISO date: ${iso}`, "warning");
return;
}
dueAt = new Date(parsed).toISOString();
continue;
}
if (p === "--autonomous-dispatch") {
autonomousDispatch = true;
continue;
}
if (p === "--auto-dispatch" || p === "--auto") {
ctx.ui.notify(
"Unsupported schedule argument: use --autonomous-dispatch.",
"warning",
);
return;
}
if (p === "--capture") {
capture = parts[++i];
continue;
}
titleParts.push(p);
}
if (!isValidKind(kind)) {
ctx.ui.notify(
`Unknown kind: ${kind}. Valid: ${ALL_SCHEDULE_KINDS.join(", ")}`,
"warning",
);
return;
}
if (capture && capture !== "stdout") {
ctx.ui.notify(`Unknown capture mode: ${capture}. Valid: stdout`, "warning");
return;
}
if (scope !== "project" && scope !== "global") {
ctx.ui.notify(`Unknown scope: ${scope}. Valid: project, global`, "warning");
return;
}
if (!dueAt) {
ctx.ui.notify(
"Usage: /schedule add --in <duration> <title>\n /schedule add --at <ISO-date> <title>",
"warning",
);
return;
}
const title =
kind === "command" ? _commandFromParts(titleParts) : _joinPlain(titleParts);
if (!title) {
ctx.ui.notify(
"Missing title. Example: /schedule add --in 2w 'Review adoption metrics'",
"warning",
);
return;
}
const store = createScheduleStore(_basePath());
const entry = {
id: generateULID(),
kind,
status: "pending",
due_at: dueAt,
created_at: new Date().toISOString(),
payload: _payloadForKind(kind, title, capture),
created_by: "user",
...(autonomousDispatch ? { autonomous_dispatch: true } : {}),
};
store.appendEntry(scope, entry);
ctx.ui.notify(`Scheduled: ${entry.id}\nDue: ${entry.due_at}`, "success");
}
function _payloadForKind(kind, title, capture) {
if (kind === "command") {
return {
command: title,
...(capture === "stdout" ? { capture } : {}),
};
}
if (kind === "prompt") {
return { prompt: title, message: title };
}
return { message: title };
}
async function listItems(args, ctx) {
const parts = _splitArgs(args);
let scope = "project";
let showDueOnly = false;
let showAll = false;
let json = false;
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (p === "--scope" || p === "-s") {
scope = parts[++i];
continue;
}
if (p === "--due" || p === "-d") {
showDueOnly = true;
continue;
}
if (p === "--all" || p === "-a") {
showAll = true;
continue;
}
if (p === "--json" || p === "-j") {
json = true;
}
}
if (scope !== "project" && scope !== "global") {
ctx.ui.notify(`Unknown scope: ${scope}. Valid: project, global`, "warning");
return;
}
const store = createScheduleStore(_basePath());
const now = new Date().toISOString();
let entries;
if (showDueOnly) {
entries = store.findDue(scope, now);
} else if (showAll) {
entries = store.readEntries(scope);
} else {
entries = store
.readEntries(scope)
.filter((e) => e.status === "pending" || e.status === "snoozed");
}
entries.sort((a, b) => new Date(a.due_at) - new Date(b.due_at));
if (json) {
ctx.ui.notify(JSON.stringify(entries, null, 2), "info");
return;
}
if (entries.length === 0) {
ctx.ui.notify("No scheduled items.", "info");
return;
}
const rows = [["ID", "Title", "Due", "Status", "Kind"]];
for (const e of entries) {
const title = e.payload?.message || e.payload?.command || e.id;
rows.push([
e.id.slice(0, 8),
title.slice(0, 40),
_isoRelative(e.due_at),
e.status,
e.kind,
]);
}
ctx.ui.notify(_formatTable(rows), "info");
}
async function markDone(args, ctx) {
const idPrefix = _joinPlain(_splitArgs(args));
if (!idPrefix) {
ctx.ui.notify("Usage: /schedule done \u003cid\u003e", "warning");
return;
}
const store = createScheduleStore(_basePath());
const { entry } = _findEntry(store, "project", idPrefix);
if (!entry) {
ctx.ui.notify(`Item ${idPrefix} not found in project scope.`, "warning");
return;
}
const updated = {
...entry,
status: "done",
created_at: new Date().toISOString(),
};
store.appendEntry("project", updated);
ctx.ui.notify(`Marked done: ${entry.id}`, "success");
}
async function markCancel(args, ctx) {
const idPrefix = _joinPlain(_splitArgs(args));
if (!idPrefix) {
ctx.ui.notify("Usage: /schedule cancel \u003cid\u003e", "warning");
return;
}
const store = createScheduleStore(_basePath());
const { entry } = _findEntry(store, "project", idPrefix);
if (!entry) {
ctx.ui.notify(`Item ${idPrefix} not found in project scope.`, "warning");
return;
}
const updated = {
...entry,
status: "cancelled",
created_at: new Date().toISOString(),
};
store.appendEntry("project", updated);
ctx.ui.notify(`Cancelled: ${entry.id}`, "success");
}
async function snoozeItem(args, ctx) {
const parts = _splitArgs(args);
let idPrefix = "";
let by = "";
for (let i = 0; i < parts.length; i++) {
if (parts[i] === "--by" || parts[i] === "-b") {
by = parts[++i];
} else if (!idPrefix) {
idPrefix = parts[i];
}
}
if (!idPrefix || !by) {
ctx.ui.notify(
"Usage: /schedule snooze \u003cid\u003e --by \u003cduration\u003e",
"warning",
);
return;
}
const store = createScheduleStore(_basePath());
const { entry } = _findEntry(store, "project", idPrefix);
if (!entry) {
ctx.ui.notify(`Item ${idPrefix} not found in project scope.`, "warning");
return;
}
let offsetMs;
try {
offsetMs = parseDuration(by);
} catch (err) {
ctx.ui.notify(err.message, "warning");
return;
}
const now = new Date().toISOString();
const newDue = new Date(
new Date(entry.due_at).getTime() + offsetMs,
).toISOString();
const updated = {
...entry,
status: "pending",
due_at: newDue,
created_at: now,
snoozed_at: now,
};
store.appendEntry("project", updated);
ctx.ui.notify(`Snoozed: ${entry.id}\nNew due: ${newDue}`, "success");
}
async function runItem(args, ctx) {
const parts = _splitArgs(args);
let idPrefix = "";
let dryRun = false;
for (const part of parts) {
if (part === "--dry-run" || part === "--dry") {
dryRun = true;
continue;
}
if (!idPrefix) idPrefix = part;
}
if (!idPrefix) {
ctx.ui.notify("Usage: /schedule run [--dry-run] \u003cid\u003e", "warning");
return;
}
const store = createScheduleStore(_basePath());
const { entry } = _findEntry(store, "project", idPrefix);
if (!entry) {
ctx.ui.notify(`Item ${idPrefix} not found in project scope.`, "warning");
return;
}
const kind = entry.kind;
const payload = entry.payload || {};
switch (kind) {
case "reminder":
case "milestone_check":
case "review_due": {
const title = payload.message || entry.id;
ctx.ui.notify(`Reminder: ${title}`, "info");
break;
}
case "command": {
const command = payload.command;
if (dryRun) {
ctx.ui.notify(
JSON.stringify(
{
id: entry.id,
kind: entry.kind,
status: entry.status,
cwd: _basePath(),
command,
autonomous_dispatch: entry.autonomous_dispatch === true,
would_execute: typeof command === "string" && command.length > 0,
},
null,
2,
),
"info",
);
return;
}
const result = executeProjectScheduleCommand(_basePath(), entry);
if (!result.ok) {
ctx.ui.notify(`Command failed: ${result.reason}`, "error");
return;
}
if (result.stdout) ctx.ui.notify(result.stdout, "info");
ctx.ui.notify(`Completed: ${entry.id}`, "success");
return;
}
case "prompt": {
const title = payload.prompt || payload.message || entry.id;
if (dryRun) {
ctx.ui.notify(`Dry run prompt: ${title}`, "info");
return;
}
ctx.ui.notify(`Prompt: ${title}`, "info");
break;
}
default: {
ctx.ui.notify(`Unknown kind "${kind}" for item ${entry.id}.`, "warning");
return;
}
}
// Mark done on success
markProjectScheduleDone(_basePath(), entry);
ctx.ui.notify(`Completed: ${entry.id}`, "success");
}
// ─── Public handler ─────────────────────────────────────────────────────────
/**
* Handle /schedule subcommands.
*
* Purpose: route schedule CLI input to the appropriate subcommand.
*
* Consumer: commands dispatcher (dispatcher.js).
*
* @param {string|string[]} args
* @param {import("@singularity-forge/pi-coding-agent").ExtensionContext} ctx
*/
export async function handleSchedule(args, ctx) {
const parts = _splitArgs(args);
const sub = parts[0] ?? "";
const rest = Array.isArray(args) ? parts.slice(1) : parts.slice(1).join(" ");
switch (sub) {
case "add":
return addItem(rest, ctx);
case "list":
return listItems(rest, ctx);
case "done":
return markDone(rest, ctx);
case "cancel":
return markCancel(rest, ctx);
case "snooze":
return snoozeItem(rest, ctx);
case "run":
return runItem(rest, ctx);
case "":
ctx.ui.notify(
"Usage: /schedule add|list|done|cancel|snooze|run\n" +
" add --in \u003cduration\u003e [--kind \u003ckind\u003e] [--scope \u003cscope\u003e] [--autonomous-dispatch] \u003ctitle-or-command\u003e\n" +
" list [--due] [--all] [--json] [--scope \u003cscope\u003e]\n" +
" done \u003cid\u003e\n" +
" cancel \u003cid\u003e\n" +
" snooze \u003cid\u003e --by \u003cduration\u003e\n" +
" run [--dry-run] \u003cid\u003e",
"info",
);
return;
default:
ctx.ui.notify(
`Unknown schedule subcommand: ${sub}. Use /schedule for usage.`,
"warning",
);
}
}