Files
nanoclaw/src/cli/format-tasks.ts
T
Omri Maya b0c76ce4c5 feat(tasks): ncl tasks control plane — isolated per-series sessions, strict verb contract, script gate
Scheduled tasks move from MCP tools to a first-class ncl resource:
list / get / create / update / cancel / pause / resume / run / append-log,
with args declared on every verb (strict validation, fix-carrying errors)
and deep per-verb help with executable examples.

A series is CronJob-like: the live (pending/paused) row is the next
occurrence, completed rows are its run history. tasks list reads as a
compact run-history table, server-rendered so the container agent prints
the same aligned table. Short named ids (<slug>-<hex>) are
filesystem/thread-safe and copy-pasteable. Agents keep a per-series work
journal: the run log at tasks/<series>.md (append-log + auto-logged
final text).

Each series runs in its own isolated system session
(system:tasks:<series>); the live-task cap is dropped — isolation
replaces throttling. Spent task sessions are GC'd by the sweep.

--script on a task runs a bash gate BEFORE the agent wakes. It prints a
JSON verdict as its last stdout line: {"wakeAgent": bool, "data": {...}}.
wakeAgent:false handles the occurrence without waking the agent; data is
threaded into the fire's prompt, so a gate can fetch/inspect and hand the
agent exactly what changed. Failing scripts back off exponentially
(2·2^(n-1) min, cap 60) and auto-pause the series after 8 consecutive
failures with a host-written run-log note; manual runs are excluded from
the streak.

BREAKING: the schedule_task / list_tasks / update_task / cancel_task /
pause_task / resume_task MCP tools are removed. Agents schedule via
ncl tasks (available under cli_scope=group). Existing task rows keep
firing — storage (messages_in kind='task') is unchanged; only the
management surface moved. Migration: docs/ncl-tasks-migration.md.
Review round (gavrielc):
- Recurrence frequency guard: create/update refuse a recurrence more
  frequent than 4 fires/day with a warning steering to gate scripts;
  --dangerously-override-recurrence-limit bypasses after explicit user
  confirmation. Counted over the next 24h in the instance TZ. Guarded on
  update too, so create-slow-then-update cannot sneak past.
- ncl tasks help scripts: resource help topics (new helpTopics seam on
  registerResource) carry the full gate-script guide — contract, examples,
  state, failure/backoff semantics, testing directions.
- Agent-facing scheduling instructions slimmed: no recurring-frequency
  prose in the fragment; one pointer to the help topic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:04:45 +03:00

85 lines
2.7 KiB
TypeScript

/**
* Compact aligned-table renderer for `ncl tasks list` (human mode only).
*
* A task series is a recurring job; each fire is a run. This surfaces the run history
* the raw row hides — run count, last/next fire, schedule — as an aligned table.
* Driven by the enriched rows from listTasks (tasks.ts); the --json path is
* untouched. `now` is injectable so the relative times are testable.
*/
interface TaskListRow {
series_id: string;
schedule?: string | null;
runs?: number;
failed_runs?: number;
last_run?: string | null;
next_run?: string | null;
status?: string;
log?: string | null;
created_at?: string | null;
prompt?: string | null;
}
const COLS = ['SERIES', 'SCHEDULE', 'RUNS', 'FAILED', 'LAST RUN', 'NEXT RUN', 'STATUS', 'AGE', 'PROMPT'] as const;
function parseMs(iso: string): number {
return Date.parse(/[Z+]|[+-]\d\d:\d\d$/.test(iso) ? iso : iso + 'Z');
}
/** "1m ago" / "in 30s" — coarse, human relative time. */
function duration(ms: number): string {
const s = Math.abs(ms) / 1000;
if (s < 60) return `${Math.round(s)}s`;
if (s < 3600) return `${Math.round(s / 60)}m`;
if (s < 86400) return `${Math.round(s / 3600)}h`;
return `${Math.round(s / 86400)}d`;
}
function lastRun(iso: string | null | undefined, now: number): string {
if (!iso) return '-';
const t = parseMs(iso);
if (Number.isNaN(t)) return iso;
return `${duration(now - t)} ago`;
}
function nextRun(iso: string | null | undefined, now: number): string {
if (!iso) return '-';
const t = parseMs(iso);
if (Number.isNaN(t)) return iso;
return t <= now ? 'due' : `in ${duration(t - now)}`;
}
/** AGE — how long since the series was created. */
function age(iso: string | null | undefined, now: number): string {
if (!iso) return '-';
const t = parseMs(iso);
return Number.isNaN(t) ? '-' : duration(now - t);
}
function clip(s: string | null | undefined, n: number): string {
const v = (s ?? '').replace(/\s+/g, ' ').trim();
return v.length > n ? v.slice(0, n - 1) + '…' : v;
}
export function formatTasksTable(rows: TaskListRow[], now: number = Date.now()): string {
if (!rows.length) return 'No tasks.';
const body = rows.map((r) => [
r.series_id, // full id, copy-pasteable into `ncl tasks get --id <…>`
r.schedule || 'once',
String(r.runs ?? 0),
String(r.failed_runs ?? 0),
lastRun(r.last_run, now),
nextRun(r.next_run, now),
r.status ?? '-',
age(r.created_at, now),
clip(r.prompt, 40),
]);
const widths = COLS.map((c, i) => Math.max(c.length, ...body.map((row) => row[i].length)));
const line = (cells: string[]) =>
cells
.map((c, i) => c.padEnd(widths[i]))
.join(' ')
.trimEnd();
return [line([...COLS]), ...body.map(line)].join('\n');
}