582 lines
14 KiB
JavaScript
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",
|
|
);
|
|
}
|
|
}
|