Files
nanoclaw/src/modules/scheduling/db.ts
gavrielc 473f766585 refactor(modules): extract scheduling as registry-based module
Moves the scheduling surface — 5 delivery actions (schedule_task,
cancel_task, pause_task, resume_task, update_task), handleRecurrence,
applyPreTaskScripts, and task DB helpers — out of core and into
src/modules/scheduling/ (host) and container/agent-runner/src/scheduling/
(container).

First PR to fill the MODULE-HOOK markers introduced in PR #2:
  - src/host-sweep.ts MODULE-HOOK:scheduling-recurrence now dynamically
    imports handleRecurrence from the module each sweep tick.
  - container/agent-runner/src/poll-loop.ts MODULE-HOOK:scheduling-pre-task
    dynamically imports applyPreTaskScripts before the provider call.
    When the marker block is empty (scheduling uninstalled), `keep`
    falls back to `normalMessages` so non-task messages still flow.

The 5 task cases are removed from delivery.ts's handleSystemAction
switch — the registry now routes them. Task DB helpers moved out of
src/db/session-db.ts (which kept `nextEvenSeq` as a named export so
the module can uphold the host-writes-even-seq invariant). Test suite
split to match: scheduling-specific tests live in the module.

No migration — tasks are messages_in rows with kind='task'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:17:47 +03:00

154 lines
5.1 KiB
TypeScript

/**
* Task DB helpers used by the scheduling module.
*
* Tasks are `messages_in` rows with `kind='task'`. This module doesn't own
* its own table — it piggybacks on the core schema. That's why there's no
* `module-scheduling-*.ts` migration file.
*
* cancel/pause/resume match any live row in the series, not just the exact id.
* Recurring tasks get a new row per occurrence (see handleRecurrence), all
* sharing series_id. Matching by id alone would only hit the completed row
* the agent remembers, missing the live next occurrence.
*/
import type Database from 'better-sqlite3';
import { nextEvenSeq } from '../../db/session-db.js';
export function insertTask(
db: Database.Database,
task: {
id: string;
processAfter: string;
recurrence: string | null;
platformId: string | null;
channelType: string | null;
threadId: string | null;
content: string;
},
): void {
db.prepare(
`INSERT INTO messages_in (id, seq, timestamp, status, tries, process_after, recurrence, kind, platform_id, channel_type, thread_id, content, series_id)
VALUES (@id, @seq, datetime('now'), 'pending', 0, @processAfter, @recurrence, 'task', @platformId, @channelType, @threadId, @content, @id)`,
).run({
...task,
seq: nextEvenSeq(db),
});
}
export function cancelTask(db: Database.Database, taskId: string): void {
db.prepare(
"UPDATE messages_in SET status = 'completed', recurrence = NULL WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status IN ('pending', 'paused')",
).run(taskId, taskId);
}
export function pauseTask(db: Database.Database, taskId: string): void {
db.prepare(
"UPDATE messages_in SET status = 'paused' WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status = 'pending'",
).run(taskId, taskId);
}
export function resumeTask(db: Database.Database, taskId: string): void {
db.prepare(
"UPDATE messages_in SET status = 'pending' WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status = 'paused'",
).run(taskId, taskId);
}
export interface TaskUpdate {
prompt?: string;
script?: string | null;
recurrence?: string | null;
processAfter?: string;
}
// Merges content JSON in-place so callers can update prompt/script without
// clobbering other fields. Matches by id OR series_id so the live next
// occurrence of a recurring task is updated, not just the completed row the
// agent last saw. Returns the number of rows touched.
export function updateTask(db: Database.Database, taskId: string, update: TaskUpdate): number {
const rows = db
.prepare(
"SELECT id, content FROM messages_in WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status IN ('pending', 'paused')",
)
.all(taskId, taskId) as Array<{ id: string; content: string }>;
if (rows.length === 0) return 0;
const setProcessAfter = update.processAfter !== undefined;
const setRecurrence = update.recurrence !== undefined;
const mergeContent = update.prompt !== undefined || update.script !== undefined;
const tx = db.transaction(() => {
for (const row of rows) {
let content = row.content;
if (mergeContent) {
const parsed = JSON.parse(row.content) as Record<string, unknown>;
if (update.prompt !== undefined) parsed.prompt = update.prompt;
if (update.script !== undefined) parsed.script = update.script;
content = JSON.stringify(parsed);
}
// Build SET clause dynamically so callers can update fields independently.
const sets: string[] = ['content = ?'];
const params: unknown[] = [content];
if (setProcessAfter) {
sets.push('process_after = ?');
params.push(update.processAfter);
}
if (setRecurrence) {
sets.push('recurrence = ?');
params.push(update.recurrence);
}
params.push(row.id);
db.prepare(`UPDATE messages_in SET ${sets.join(', ')} WHERE id = ?`).run(...params);
}
});
tx();
return rows.length;
}
export interface RecurringMessage {
id: string;
kind: string;
content: string;
recurrence: string;
process_after: string | null;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
series_id: string;
}
export function getCompletedRecurring(db: Database.Database): RecurringMessage[] {
return db
.prepare("SELECT * FROM messages_in WHERE status = 'completed' AND recurrence IS NOT NULL")
.all() as RecurringMessage[];
}
export function insertRecurrence(
db: Database.Database,
msg: RecurringMessage,
newId: string,
nextRun: string | null,
): void {
db.prepare(
`INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content, series_id)
VALUES (?, ?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?, ?)`,
).run(
newId,
nextEvenSeq(db),
msg.kind,
nextRun,
msg.recurrence,
msg.platform_id,
msg.channel_type,
msg.thread_id,
msg.content,
msg.series_id,
);
}
export function clearRecurrence(db: Database.Database, messageId: string): void {
db.prepare('UPDATE messages_in SET recurrence = NULL WHERE id = ?').run(messageId);
}