openclaw-scheduler 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +302 -0
- package/BEST-PRACTICES.md +506 -0
- package/CHANGELOG.md +82 -0
- package/CODE_OF_CONDUCT.md +22 -0
- package/CONTEXT.md +26 -0
- package/CONTRIBUTING.md +73 -0
- package/IMPLEMENTATION_SPEC.md +170 -0
- package/INSTALL-ADDITIONAL-HOST.md +333 -0
- package/INSTALL-LINUX.md +419 -0
- package/INSTALL-WINDOWS.md +305 -0
- package/INSTALL.md +364 -0
- package/JOB-QUICK-REF.md +222 -0
- package/LICENSE +21 -0
- package/QUICK-START.md +256 -0
- package/README.md +2170 -0
- package/SECURITY.md +34 -0
- package/UNINSTALL.md +129 -0
- package/UPGRADING.md +436 -0
- package/agents.js +67 -0
- package/approval.js +107 -0
- package/backup.js +390 -0
- package/bin/openclaw-scheduler.js +138 -0
- package/cli.js +1083 -0
- package/db.js +122 -0
- package/dispatch/529-recovery.mjs +204 -0
- package/dispatch/README.md +372 -0
- package/dispatch/config.example.json +24 -0
- package/dispatch/deliver-watcher.sh +57 -0
- package/dispatch/hooks.mjs +171 -0
- package/dispatch/index.mjs +1836 -0
- package/dispatch/watcher.mjs +1396 -0
- package/dispatch-queue.js +112 -0
- package/dispatcher-approvals.js +96 -0
- package/dispatcher-delivery.js +43 -0
- package/dispatcher-maintenance.js +242 -0
- package/dispatcher-shell.js +29 -0
- package/dispatcher-strategies.js +1280 -0
- package/dispatcher-utils.js +81 -0
- package/dispatcher.js +855 -0
- package/docs/adr-schedule-ownership.md +73 -0
- package/docs/gateway-contract.md +904 -0
- package/docs/plans/2026-03-09-fix-typescript-types.md +91 -0
- package/docs/plans/2026-03-09-test-coverage-gaps.md +83 -0
- package/docs/plans/2026-03-10-dispatcher-refactor.md +801 -0
- package/docs/trust-architecture.md +266 -0
- package/gateway.js +473 -0
- package/idempotency.js +119 -0
- package/index.d.ts +864 -0
- package/index.js +17 -0
- package/jobs.js +1224 -0
- package/messages.js +357 -0
- package/migrate-consolidate.js +694 -0
- package/migrate.js +125 -0
- package/package.json +130 -0
- package/paths.js +79 -0
- package/prompt-context.js +94 -0
- package/retrieval.js +176 -0
- package/runs.js +270 -0
- package/scheduler-schema.js +101 -0
- package/schema.sql +480 -0
- package/scripts/dispatch-cli-utils.mjs +65 -0
- package/scripts/inbox-consumer.mjs +288 -0
- package/scripts/stuck-detector.sh +18 -0
- package/scripts/stuck-run-detector.mjs +333 -0
- package/scripts/telegram-webhook-check.mjs +238 -0
- package/setup.mjs +724 -0
- package/shell-result.js +214 -0
- package/task-tracker.js +300 -0
- package/team-adapter.js +335 -0
- package/v02-runtime.js +599 -0
package/cli.js
ADDED
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Scheduler CLI -- manage jobs, runs, messages, agents
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { initDb, getDb } from './db.js';
|
|
5
|
+
import { createJob, getJob, listJobs, updateJob, deleteJob, cancelJob, runJobNow, validateJobSpec, parseInDuration, AT_JOB_CRON_SENTINEL } from './jobs.js';
|
|
6
|
+
import { getRun, getRunsForJob, getRunningRuns, getStaleRuns, finishRun } from './runs.js';
|
|
7
|
+
import {
|
|
8
|
+
sendMessage, getInbox, getOutbox, getThread, markRead, markAllRead, getUnreadCount, pruneMessages,
|
|
9
|
+
ackMessage, listMessageReceipts, getTeamMessages,
|
|
10
|
+
} from './messages.js';
|
|
11
|
+
import { upsertAgent, getAgent, listAgents } from './agents.js';
|
|
12
|
+
import { SCHEDULER_SCHEMAS } from './scheduler-schema.js';
|
|
13
|
+
|
|
14
|
+
const cliArgs = process.argv.slice(2);
|
|
15
|
+
const jsonFlagIndex = cliArgs.indexOf('--json');
|
|
16
|
+
const jsonMode = jsonFlagIndex >= 0;
|
|
17
|
+
if (jsonFlagIndex >= 0) cliArgs.splice(jsonFlagIndex, 1);
|
|
18
|
+
const [command, sub, ...args] = cliArgs;
|
|
19
|
+
|
|
20
|
+
function usage() {
|
|
21
|
+
console.log(`
|
|
22
|
+
Usage: openclaw-scheduler <command> [subcommand] [options]
|
|
23
|
+
|
|
24
|
+
Global:
|
|
25
|
+
--json Emit machine-readable JSON
|
|
26
|
+
|
|
27
|
+
Jobs:
|
|
28
|
+
jobs list [--type watchdog] List all jobs (optionally filter by type)
|
|
29
|
+
jobs tree Show jobs as parent/child tree
|
|
30
|
+
jobs get <id> Get job details
|
|
31
|
+
jobs add <json> [--watchdog] [--at <datetime>] [--in <duration>] [--profile <id>]
|
|
32
|
+
Add a job (--watchdog sets defaults for watchdog type)
|
|
33
|
+
run_timeout_ms is REQUIRED (no default -- prevents indefinite runs)
|
|
34
|
+
--at: one-shot schedule, e.g. '2026-03-10T16:47:00-04:00'
|
|
35
|
+
--in: one-shot sugar, e.g. '15m', '2h', '30s', '1d'
|
|
36
|
+
--profile: auth profile override (null, 'inherit', or 'provider:label')
|
|
37
|
+
jobs validate <json> Validate a job spec without writing it
|
|
38
|
+
jobs enable <id> Enable a job
|
|
39
|
+
jobs disable <id> Disable a job
|
|
40
|
+
jobs delete <id> Delete a job
|
|
41
|
+
jobs cancel <id> [--no-cascade] Cancel a job (+ children by default)
|
|
42
|
+
jobs update <id> <json> [--profile <id>]
|
|
43
|
+
Update job fields
|
|
44
|
+
--profile: auth profile override (null, 'inherit', or 'provider:label')
|
|
45
|
+
jobs run <id> Trigger immediate run (sets next_run_at to now)
|
|
46
|
+
jobs approve <id> Approve a pending job
|
|
47
|
+
jobs reject <id> [reason] Reject with optional reason
|
|
48
|
+
|
|
49
|
+
Runs:
|
|
50
|
+
runs list <job-id> [limit] List runs for a job
|
|
51
|
+
runs get <run-id> Get a run by id
|
|
52
|
+
runs output <run-id> [stdout|stderr] Print offloaded or stored shell output
|
|
53
|
+
runs running Currently running runs
|
|
54
|
+
runs stale [threshold-s] Stale runs
|
|
55
|
+
|
|
56
|
+
Queue:
|
|
57
|
+
queue list [agent] [limit] Show pending + delivered messages (default: main)
|
|
58
|
+
queue clear [agent] Mark all messages read
|
|
59
|
+
queue prune Prune old messages now
|
|
60
|
+
|
|
61
|
+
Messages:
|
|
62
|
+
messages send --from <label> [--to <agent>] [--kind <kind>] --body "<text>"
|
|
63
|
+
Send a checkpoint/status message (kind defaults to 'status', to defaults to 'main')
|
|
64
|
+
msg send <from> <to> <body> Send a message (positional form)
|
|
65
|
+
msg inbox <agent-id> [limit] Get inbox (unread)
|
|
66
|
+
msg team-inbox <team-id> [limit] [member-id] [task-id] Get team mailbox
|
|
67
|
+
msg outbox <agent-id> [limit] Get outbox
|
|
68
|
+
msg thread <message-id> Get message thread
|
|
69
|
+
msg ack <message-id> [actor] [note] Mark message as acknowledged/read
|
|
70
|
+
msg receipts <message-id> [limit] Show delivery/ack receipt events
|
|
71
|
+
msg read <message-id> Mark message as read
|
|
72
|
+
msg readall <agent-id> Mark all as read
|
|
73
|
+
msg unread <agent-id> Unread count
|
|
74
|
+
|
|
75
|
+
Team Adapter:
|
|
76
|
+
team map [limit] Project unmapped team messages into mailbox/task events
|
|
77
|
+
team tasks <team-id> [limit] List projected team tasks
|
|
78
|
+
team events <team-id> [limit] [task-id] List team mailbox events
|
|
79
|
+
team gate <team-id> <task-id> <members-json> [timeout-s] Open task completion gate
|
|
80
|
+
team check-gates [limit] Evaluate/advance team task gates
|
|
81
|
+
team ack <message-id> [actor] [note] Team-aware ACK (creates team mailbox event)
|
|
82
|
+
|
|
83
|
+
Agents:
|
|
84
|
+
agents list List registered agents
|
|
85
|
+
agents get <id> Get agent details
|
|
86
|
+
agents register <id> [name] Register/update agent
|
|
87
|
+
|
|
88
|
+
Tasks:
|
|
89
|
+
tasks list List active task groups
|
|
90
|
+
tasks status <id> Detailed status of a task group
|
|
91
|
+
tasks create <json> Create a tracked task group
|
|
92
|
+
tasks history [limit] Recent completed task groups
|
|
93
|
+
tasks heartbeat <id> <label> running|completed|failed [msg] Sub-agent reports status
|
|
94
|
+
tasks register-session <id> <label> <key> Orchestrator links session key to agent
|
|
95
|
+
|
|
96
|
+
Approvals:
|
|
97
|
+
approvals list List pending approvals
|
|
98
|
+
approvals pending Alias for list
|
|
99
|
+
|
|
100
|
+
Idempotency:
|
|
101
|
+
idem status <job-id> Show recent idempotency keys for a job
|
|
102
|
+
idem check <key> Check if a key is claimed
|
|
103
|
+
idem release <key> Manually release a claimed key
|
|
104
|
+
idem prune Force prune expired entries
|
|
105
|
+
|
|
106
|
+
Aliases:
|
|
107
|
+
alias list List all delivery aliases
|
|
108
|
+
alias add <name> <ch> <tgt> [desc] Add a delivery alias
|
|
109
|
+
alias remove <name> Remove a delivery alias
|
|
110
|
+
|
|
111
|
+
Status:
|
|
112
|
+
status Overall scheduler status
|
|
113
|
+
|
|
114
|
+
Schema:
|
|
115
|
+
schema [jobs|runs|messages|approvals|dispatches|all]
|
|
116
|
+
|
|
117
|
+
Capabilities:
|
|
118
|
+
capabilities Report v0.2 feature support
|
|
119
|
+
`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await initDb();
|
|
123
|
+
|
|
124
|
+
function fmt(obj) { return JSON.stringify(obj, null, 2); }
|
|
125
|
+
|
|
126
|
+
function emit(data, human = null) {
|
|
127
|
+
if (jsonMode) {
|
|
128
|
+
console.log(fmt(data));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (typeof human === 'function') {
|
|
132
|
+
human();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (typeof human === 'string') {
|
|
136
|
+
console.log(human);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
console.log(typeof data === 'string' ? data : fmt(data));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fail(message, code = 1) {
|
|
143
|
+
if (jsonMode) {
|
|
144
|
+
console.log(fmt({ ok: false, error: message }));
|
|
145
|
+
} else {
|
|
146
|
+
console.error(message);
|
|
147
|
+
}
|
|
148
|
+
process.exit(code);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
switch (command) {
|
|
152
|
+
// -- Jobs ------------------------------------------------
|
|
153
|
+
case 'jobs':
|
|
154
|
+
switch (sub) {
|
|
155
|
+
case 'list': {
|
|
156
|
+
let jobs = listJobs();
|
|
157
|
+
// Filter by --type if provided (e.g. --type watchdog)
|
|
158
|
+
const typeFilterIdx = args.indexOf('--type');
|
|
159
|
+
if (typeFilterIdx >= 0 && args[typeFilterIdx + 1]) {
|
|
160
|
+
const typeFilter = args[typeFilterIdx + 1];
|
|
161
|
+
jobs = jobs.filter(j => (j.job_type || 'standard') === typeFilter);
|
|
162
|
+
}
|
|
163
|
+
const rows = jobs.map(j => ({
|
|
164
|
+
id: j.id.slice(0, 8) + '..',
|
|
165
|
+
name: j.name,
|
|
166
|
+
type: j.job_type || 'standard',
|
|
167
|
+
kind: j.schedule_kind || 'cron',
|
|
168
|
+
enabled: !!j.enabled,
|
|
169
|
+
schedule: j.schedule_kind === 'at' ? `at:${(j.schedule_at || '').slice(0, 16)}` : j.schedule_cron,
|
|
170
|
+
agent: j.agent_id || 'main',
|
|
171
|
+
target: j.session_target,
|
|
172
|
+
guarantee: j.delivery_guarantee || 'at-most-once',
|
|
173
|
+
parent: j.parent_id ? j.parent_id.slice(0, 8) + '..' : '-',
|
|
174
|
+
trigger: j.trigger_on || '-',
|
|
175
|
+
...(j.job_type === 'watchdog' ? { watchdog: j.watchdog_target_label || '-' } : {}),
|
|
176
|
+
nextRun: j.next_run_at,
|
|
177
|
+
lastStatus: j.last_status,
|
|
178
|
+
errors: j.consecutive_errors,
|
|
179
|
+
}));
|
|
180
|
+
emit(jsonMode ? jobs : rows, () => console.table(rows));
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case 'tree': {
|
|
184
|
+
const jobs = listJobs();
|
|
185
|
+
const roots = jobs.filter(j => !j.parent_id);
|
|
186
|
+
const childMap = {};
|
|
187
|
+
for (const j of jobs) {
|
|
188
|
+
if (j.parent_id) {
|
|
189
|
+
if (!childMap[j.parent_id]) childMap[j.parent_id] = [];
|
|
190
|
+
childMap[j.parent_id].push(j);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function treeNode(job) {
|
|
194
|
+
return {
|
|
195
|
+
id: job.id,
|
|
196
|
+
name: job.name,
|
|
197
|
+
enabled: !!job.enabled,
|
|
198
|
+
agent_id: job.agent_id || 'main',
|
|
199
|
+
trigger_on: job.trigger_on || null,
|
|
200
|
+
trigger_delay_s: job.trigger_delay_s || 0,
|
|
201
|
+
children: (childMap[job.id] || []).map(treeNode),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function printTree(job, indent = '') {
|
|
205
|
+
const status = job.enabled ? '[on]' : '[ ]';
|
|
206
|
+
const trigger = job.trigger_on ? ` [on:${job.trigger_on}]` : '';
|
|
207
|
+
const delay = job.trigger_delay_s ? ` (+${job.trigger_delay_s}s)` : '';
|
|
208
|
+
console.log(`${indent}${status} ${job.name} (${job.agent_id || 'main'})${trigger}${delay}`);
|
|
209
|
+
const children = childMap[job.id] || [];
|
|
210
|
+
for (const child of children) {
|
|
211
|
+
printTree(child, indent + ' |- ');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
emit(roots.map(treeNode), () => {
|
|
215
|
+
for (const root of roots) printTree(root);
|
|
216
|
+
});
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case 'get': emit(getJob(args[0])); break;
|
|
220
|
+
case 'add': {
|
|
221
|
+
const dryRun = args.includes('--dry-run');
|
|
222
|
+
const isWatchdog = args.includes('--watchdog');
|
|
223
|
+
const profileIdx = args.indexOf('--profile');
|
|
224
|
+
const profileValue = profileIdx >= 0 ? args[profileIdx + 1] : undefined;
|
|
225
|
+
const atIdx = args.indexOf('--at');
|
|
226
|
+
const atValue = atIdx >= 0 ? args[atIdx + 1] : undefined;
|
|
227
|
+
const inIdx = args.indexOf('--in');
|
|
228
|
+
const inValue = inIdx >= 0 ? args[inIdx + 1] : undefined;
|
|
229
|
+
const skipArgs = new Set(['--dry-run', '--watchdog']);
|
|
230
|
+
if (profileIdx >= 0) { skipArgs.add(args[profileIdx]); skipArgs.add(args[profileIdx + 1]); }
|
|
231
|
+
if (atIdx >= 0) { skipArgs.add(args[atIdx]); skipArgs.add(args[atIdx + 1]); }
|
|
232
|
+
if (inIdx >= 0) { skipArgs.add(args[inIdx]); skipArgs.add(args[inIdx + 1]); }
|
|
233
|
+
const payload = args.find(a => !skipArgs.has(a));
|
|
234
|
+
if (!payload) fail('Usage: jobs add <json> [--dry-run] [--watchdog] [--at <datetime>] [--in <duration>] [--profile <id>]');
|
|
235
|
+
let spec;
|
|
236
|
+
try { spec = JSON.parse(payload); } catch { fail('Invalid JSON. Usage: jobs add \'{"name":"..."}\''); }
|
|
237
|
+
if (profileValue !== undefined) spec.auth_profile = profileValue;
|
|
238
|
+
|
|
239
|
+
// One-shot scheduling via --at or --in
|
|
240
|
+
if (atValue || inValue) {
|
|
241
|
+
let scheduleAt;
|
|
242
|
+
try {
|
|
243
|
+
if (inValue) {
|
|
244
|
+
scheduleAt = parseInDuration(inValue);
|
|
245
|
+
} else {
|
|
246
|
+
const d = new Date(atValue);
|
|
247
|
+
if (isNaN(d.getTime())) throw new Error(`Invalid datetime: "${atValue}"`);
|
|
248
|
+
scheduleAt = d.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
249
|
+
}
|
|
250
|
+
} catch (err) {
|
|
251
|
+
fail(`--at/--in error: ${err.message}`);
|
|
252
|
+
}
|
|
253
|
+
spec.schedule_kind = 'at';
|
|
254
|
+
spec.schedule_at = scheduleAt;
|
|
255
|
+
// Use sentinel cron to satisfy NOT NULL on existing DBs without nullable schedule_cron
|
|
256
|
+
if (!spec.schedule_cron) spec.schedule_cron = AT_JOB_CRON_SENTINEL;
|
|
257
|
+
spec.next_run_at = scheduleAt;
|
|
258
|
+
// Default delete_after_run for at-jobs (user can override in JSON)
|
|
259
|
+
if (spec.delete_after_run === undefined) spec.delete_after_run = 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// If --watchdog flag is set, apply watchdog defaults
|
|
263
|
+
if (isWatchdog) {
|
|
264
|
+
spec.job_type = 'watchdog';
|
|
265
|
+
// Watchdog jobs default to shell target with shellCommand kind
|
|
266
|
+
if (!spec.session_target) spec.session_target = 'shell';
|
|
267
|
+
if (!spec.payload_kind) spec.payload_kind = 'shellCommand';
|
|
268
|
+
if (!spec.payload_message) spec.payload_message = spec.watchdog_check_cmd || 'true';
|
|
269
|
+
if (!spec.delivery_mode) spec.delivery_mode = 'none';
|
|
270
|
+
if (spec.watchdog_self_destruct === undefined) spec.watchdog_self_destruct = 1;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const normalized = validateJobSpec(spec, null, 'create');
|
|
274
|
+
if (dryRun) {
|
|
275
|
+
emit({ ok: true, dry_run: true, valid: true, normalized });
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
const job = createJob(spec);
|
|
279
|
+
emit({ ok: true, job }, `Created: ${fmt(job)}`);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case 'validate': {
|
|
283
|
+
if (!args[0]) fail('Usage: jobs validate <json>');
|
|
284
|
+
let spec;
|
|
285
|
+
try { spec = JSON.parse(args[0]); } catch { fail('Invalid JSON. Usage: jobs validate \'{"name":"..."}\''); }
|
|
286
|
+
const normalized = validateJobSpec(spec, null, 'create');
|
|
287
|
+
emit({ ok: true, valid: true, normalized });
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
case 'enable': updateJob(args[0], { enabled: 1 }); emit({ ok: true, job_id: args[0], enabled: true }, 'Enabled'); break;
|
|
291
|
+
case 'disable': updateJob(args[0], { enabled: 0 }); emit({ ok: true, job_id: args[0], enabled: false }, 'Disabled'); break;
|
|
292
|
+
case 'delete': deleteJob(args[0]); emit({ ok: true, job_id: args[0], deleted: true }, 'Deleted'); break;
|
|
293
|
+
case 'cancel': {
|
|
294
|
+
const noCascade = args.includes('--no-cascade');
|
|
295
|
+
const id = args.find(a => !a.startsWith('--'));
|
|
296
|
+
if (!id) fail('Usage: jobs cancel <id> [--no-cascade]');
|
|
297
|
+
const cancelled = cancelJob(id, { cascade: !noCascade });
|
|
298
|
+
emit({ ok: true, cancelled }, `Cancelled ${cancelled.length} job(s): ${cancelled.map(c => c.slice(0, 8) + '..').join(', ')}`);
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case 'update': {
|
|
302
|
+
const dryRun = args.includes('--dry-run');
|
|
303
|
+
const updateProfileIdx = args.indexOf('--profile');
|
|
304
|
+
const updateProfileValue = updateProfileIdx >= 0 ? args[updateProfileIdx + 1] : undefined;
|
|
305
|
+
const updateFilterArgs = new Set(['--dry-run']);
|
|
306
|
+
if (updateProfileIdx >= 0) { updateFilterArgs.add(args[updateProfileIdx]); updateFilterArgs.add(args[updateProfileIdx + 1]); }
|
|
307
|
+
const updateArgs = args.filter(a => !updateFilterArgs.has(a));
|
|
308
|
+
const current = getJob(updateArgs[0]);
|
|
309
|
+
if (!current) fail(`Job not found: ${updateArgs[0]}`);
|
|
310
|
+
let patch;
|
|
311
|
+
try { patch = JSON.parse(updateArgs[1]); } catch { fail('Invalid JSON. Usage: jobs update <id> \'{"key":"value"}\''); }
|
|
312
|
+
if (updateProfileValue !== undefined) patch.auth_profile = updateProfileValue;
|
|
313
|
+
const normalized = validateJobSpec(patch, current, 'update');
|
|
314
|
+
if (dryRun) {
|
|
315
|
+
emit({ ok: true, dry_run: true, valid: true, normalized });
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
const job = updateJob(updateArgs[0], patch);
|
|
319
|
+
emit({ ok: true, job }, `Updated: ${fmt(job)}`);
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
case 'run': {
|
|
323
|
+
const job = runJobNow(args[0]);
|
|
324
|
+
if (!job) fail(`Job not found: ${args[0]}`);
|
|
325
|
+
emit(
|
|
326
|
+
{ ok: true, job_id: job.id, name: job.name, dispatch_id: job.dispatch_id, dispatch_kind: job.dispatch_kind },
|
|
327
|
+
`Scheduled for immediate run: ${job.name} (dispatch: ${job.dispatch_id})`
|
|
328
|
+
);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
case 'approve': {
|
|
332
|
+
if (!args[0]) fail('Usage: jobs approve <job-id>');
|
|
333
|
+
const { getPendingApproval, resolveApproval } = await import('./approval.js');
|
|
334
|
+
const approval = getPendingApproval(args[0]);
|
|
335
|
+
if (!approval) fail(`No pending approval for job: ${args[0]}`);
|
|
336
|
+
resolveApproval(approval.id, 'approved', 'operator');
|
|
337
|
+
emit({ ok: true, approval_id: approval.id, job_id: approval.job_id, status: 'approved' }, `Approved: ${approval.job_id}`);
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
case 'reject': {
|
|
341
|
+
if (!args[0]) fail('Usage: jobs reject <job-id> [reason]');
|
|
342
|
+
const { getPendingApproval, resolveApproval } = await import('./approval.js');
|
|
343
|
+
const approval = getPendingApproval(args[0]);
|
|
344
|
+
if (!approval) fail(`No pending approval for job: ${args[0]}`);
|
|
345
|
+
const reason = args.slice(1).join(' ') || null;
|
|
346
|
+
resolveApproval(approval.id, 'rejected', 'operator', reason);
|
|
347
|
+
if (approval.run_id) {
|
|
348
|
+
finishRun(approval.run_id, 'cancelled', { error_message: reason || 'Rejected by operator' });
|
|
349
|
+
}
|
|
350
|
+
emit(
|
|
351
|
+
{ ok: true, approval_id: approval.id, job_id: approval.job_id, status: 'rejected', reason },
|
|
352
|
+
`Rejected: ${approval.job_id}${reason ? ' -- ' + reason : ''}`
|
|
353
|
+
);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
default: usage();
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
|
|
360
|
+
// -- Runs ------------------------------------------------
|
|
361
|
+
case 'runs':
|
|
362
|
+
switch (sub) {
|
|
363
|
+
case 'list': {
|
|
364
|
+
if (!args[0]) fail('Usage: runs list <job-id> [limit]');
|
|
365
|
+
const runs = getRunsForJob(args[0], parseInt(args[1] || '20', 10));
|
|
366
|
+
const rows = runs.map(r => ({
|
|
367
|
+
id: r.id.slice(0, 8),
|
|
368
|
+
status: r.status,
|
|
369
|
+
started: r.started_at,
|
|
370
|
+
finished: r.finished_at,
|
|
371
|
+
durationMs: r.duration_ms,
|
|
372
|
+
heartbeat: r.last_heartbeat,
|
|
373
|
+
}));
|
|
374
|
+
emit(jsonMode ? runs : rows, () => console.table(rows));
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
case 'get': {
|
|
378
|
+
const run = getRun(args[0]);
|
|
379
|
+
if (!run) fail(`Run not found: ${args[0]}`);
|
|
380
|
+
emit(run);
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
case 'output': {
|
|
384
|
+
if (!args[0]) fail('Usage: runs output <run-id> [stdout|stderr]');
|
|
385
|
+
const run = getRun(args[0]);
|
|
386
|
+
if (!run) fail(`Run not found: ${args[0]}`);
|
|
387
|
+
const kind = (args[1] || 'stdout').toLowerCase();
|
|
388
|
+
if (kind !== 'stdout' && kind !== 'stderr') fail('Usage: runs output <run-id> [stdout|stderr]');
|
|
389
|
+
const pathField = kind === 'stderr' ? 'shell_stderr_path' : 'shell_stdout_path';
|
|
390
|
+
const textField = kind === 'stderr' ? 'shell_stderr' : 'shell_stdout';
|
|
391
|
+
const filePath = run[pathField];
|
|
392
|
+
let payload;
|
|
393
|
+
try {
|
|
394
|
+
payload = filePath ? readFileSync(filePath, 'utf8') : (run[textField] || '');
|
|
395
|
+
} catch (err) {
|
|
396
|
+
fail(`Cannot read output file ${filePath}: ${err.message}`);
|
|
397
|
+
}
|
|
398
|
+
if (jsonMode) {
|
|
399
|
+
emit({ ok: true, run_id: run.id, kind, file_path: filePath || null, content: payload });
|
|
400
|
+
} else {
|
|
401
|
+
process.stdout.write(payload);
|
|
402
|
+
if (!payload.endsWith('\n')) process.stdout.write('\n');
|
|
403
|
+
}
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
case 'running': {
|
|
407
|
+
const runs = getRunningRuns();
|
|
408
|
+
if (runs.length === 0) { emit([] , 'No running runs'); break; }
|
|
409
|
+
const rows = runs.map(r => ({
|
|
410
|
+
id: r.id.slice(0, 8),
|
|
411
|
+
job: r.job_name,
|
|
412
|
+
started: r.started_at,
|
|
413
|
+
heartbeat: r.last_heartbeat,
|
|
414
|
+
sessionKey: r.session_key,
|
|
415
|
+
}));
|
|
416
|
+
emit(jsonMode ? runs : rows, () => console.table(rows));
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
case 'stale': {
|
|
420
|
+
const stale = getStaleRuns(parseInt(args[0] || '90', 10));
|
|
421
|
+
if (stale.length === 0) { emit([], 'No stale runs'); break; }
|
|
422
|
+
const rows = stale.map(r => ({
|
|
423
|
+
id: r.id.slice(0, 8),
|
|
424
|
+
job: r.job_name,
|
|
425
|
+
heartbeat: r.last_heartbeat,
|
|
426
|
+
}));
|
|
427
|
+
emit(jsonMode ? stale : rows, () => console.table(rows));
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
default: usage();
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
|
|
434
|
+
// -- Messages --------------------------------------------
|
|
435
|
+
case 'msg':
|
|
436
|
+
switch (sub) {
|
|
437
|
+
case 'send': {
|
|
438
|
+
const [from, to, ...bodyParts] = args;
|
|
439
|
+
if (!from || !to || !bodyParts.length) fail('Usage: msg send <from> <to> <body>');
|
|
440
|
+
const msg = sendMessage({ from_agent: from, to_agent: to, body: bodyParts.join(' ') });
|
|
441
|
+
emit({ ok: true, message: msg }, `Sent: ${fmt(msg)}`);
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
case 'inbox': {
|
|
445
|
+
const msgs = getInbox(args[0], { limit: parseInt(args[1] || '20', 10), includeDelivered: true });
|
|
446
|
+
if (msgs.length === 0) { emit([], 'Inbox empty'); break; }
|
|
447
|
+
const rows = msgs.map(m => ({
|
|
448
|
+
id: m.id.slice(0, 8),
|
|
449
|
+
from: m.from_agent,
|
|
450
|
+
kind: m.kind,
|
|
451
|
+
subject: m.subject?.slice(0, 40),
|
|
452
|
+
status: m.status,
|
|
453
|
+
priority: m.priority,
|
|
454
|
+
created: m.created_at,
|
|
455
|
+
}));
|
|
456
|
+
emit(jsonMode ? msgs : rows, () => console.table(rows));
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
case 'team-inbox': {
|
|
460
|
+
const teamId = args[0];
|
|
461
|
+
if (!teamId) fail('Usage: msg team-inbox <team-id> [limit] [member-id] [task-id]');
|
|
462
|
+
const limit = parseInt(args[1] || '20', 10);
|
|
463
|
+
const memberId = args[2] || null;
|
|
464
|
+
const taskId = args[3] || null;
|
|
465
|
+
const msgs = getTeamMessages(teamId, { limit, memberId, taskId, includeRead: true });
|
|
466
|
+
if (msgs.length === 0) { emit([], 'Team inbox empty'); break; }
|
|
467
|
+
const rows = msgs.map(m => ({
|
|
468
|
+
id: m.id.slice(0, 8),
|
|
469
|
+
from: m.from_agent,
|
|
470
|
+
to: m.to_agent,
|
|
471
|
+
member: m.member_id || '-',
|
|
472
|
+
task: m.task_id || '-',
|
|
473
|
+
kind: m.kind,
|
|
474
|
+
status: m.status,
|
|
475
|
+
ackRequired: !!m.ack_required,
|
|
476
|
+
ackAt: m.ack_at || '-',
|
|
477
|
+
attempts: m.delivery_attempts || 0,
|
|
478
|
+
lastError: m.last_error || '-',
|
|
479
|
+
created: m.created_at,
|
|
480
|
+
}));
|
|
481
|
+
emit(jsonMode ? msgs : rows, () => console.table(rows));
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
case 'outbox': {
|
|
485
|
+
const msgs = getOutbox(args[0], parseInt(args[1] || '20', 10));
|
|
486
|
+
if (msgs.length === 0) { emit([], 'Outbox empty'); break; }
|
|
487
|
+
const rows = msgs.map(m => ({
|
|
488
|
+
id: m.id.slice(0, 8),
|
|
489
|
+
to: m.to_agent,
|
|
490
|
+
kind: m.kind,
|
|
491
|
+
subject: m.subject?.slice(0, 40),
|
|
492
|
+
status: m.status,
|
|
493
|
+
created: m.created_at,
|
|
494
|
+
}));
|
|
495
|
+
emit(jsonMode ? msgs : rows, () => console.table(rows));
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
case 'thread': {
|
|
499
|
+
const msgs = getThread(args[0]);
|
|
500
|
+
emit(msgs, () => {
|
|
501
|
+
for (const m of msgs) {
|
|
502
|
+
console.log(`[${m.from_agent} -> ${m.to_agent}] (${m.status}) ${m.created_at}`);
|
|
503
|
+
console.log(` ${(m.body || '').slice(0, 200)}`);
|
|
504
|
+
console.log();
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
case 'ack': {
|
|
510
|
+
if (!args[0]) fail('Usage: msg ack <message-id> [actor] [note]');
|
|
511
|
+
const actor = args[1] || 'operator';
|
|
512
|
+
const detail = args.slice(2).join(' ') || null;
|
|
513
|
+
const msg = ackMessage(args[0], actor, detail);
|
|
514
|
+
if (!msg) fail('Message not found: ' + args[0]);
|
|
515
|
+
emit(
|
|
516
|
+
{ ok: true, id: msg.id, status: msg.status, ack_at: msg.ack_at, read_at: msg.read_at },
|
|
517
|
+
`Acknowledged: ${fmt({ id: msg.id, status: msg.status, ack_at: msg.ack_at, read_at: msg.read_at })}`
|
|
518
|
+
);
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
case 'receipts': {
|
|
522
|
+
if (!args[0]) fail('Usage: msg receipts <message-id> [limit]');
|
|
523
|
+
const receipts = listMessageReceipts(args[0], parseInt(args[1] || '20', 10));
|
|
524
|
+
if (receipts.length === 0) { emit([], 'No receipts for message'); break; }
|
|
525
|
+
const rows = receipts.map(r => ({
|
|
526
|
+
id: r.id.slice(0, 8),
|
|
527
|
+
type: r.event_type,
|
|
528
|
+
attempt: r.attempt ?? '-',
|
|
529
|
+
actor: r.actor || '-',
|
|
530
|
+
detail: (r.detail || '').slice(0, 80),
|
|
531
|
+
at: r.created_at,
|
|
532
|
+
}));
|
|
533
|
+
emit(jsonMode ? receipts : rows, () => console.table(rows));
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
case 'read': { if (!args[0]) fail('Usage: msg read <message-id>'); markRead(args[0]); emit({ ok: true, message_id: args[0], read: true }, 'Marked read'); break; }
|
|
537
|
+
case 'readall': { if (!args[0]) fail('Usage: msg readall <agent-id>'); const r = markAllRead(args[0]); emit({ ok: true, agent: args[0], changes: r.changes }, `Marked ${r.changes} read`); break; }
|
|
538
|
+
case 'unread': { if (!args[0]) fail('Usage: msg unread <agent-id>'); const count = getUnreadCount(args[0]); emit({ agent: args[0], unread: count }, `Unread: ${count}`); break; }
|
|
539
|
+
default: usage();
|
|
540
|
+
}
|
|
541
|
+
break;
|
|
542
|
+
|
|
543
|
+
// -- Messages (named-flag interface) --------------------------
|
|
544
|
+
case 'messages':
|
|
545
|
+
switch (sub) {
|
|
546
|
+
case 'send': {
|
|
547
|
+
// Parse named flags from args: --from, --to, --kind, --body
|
|
548
|
+
const mFlags = {};
|
|
549
|
+
for (let i = 0; i < args.length; i++) {
|
|
550
|
+
if (args[i].startsWith('--')) {
|
|
551
|
+
const key = args[i].slice(2);
|
|
552
|
+
if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
|
|
553
|
+
mFlags[key] = args[i + 1];
|
|
554
|
+
i++;
|
|
555
|
+
} else {
|
|
556
|
+
mFlags[key] = true;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const mFrom = mFlags.from;
|
|
561
|
+
const mTo = mFlags.to || 'main';
|
|
562
|
+
const mKind = mFlags.kind || 'status';
|
|
563
|
+
const mBody = mFlags.body;
|
|
564
|
+
if (!mFrom || !mBody) {
|
|
565
|
+
fail('Usage: messages send --from <label> [--to <agent>] [--kind <kind>] --body "<text>"');
|
|
566
|
+
}
|
|
567
|
+
const msg = sendMessage({ from_agent: mFrom, to_agent: mTo, kind: mKind, body: mBody });
|
|
568
|
+
emit({ ok: true, message: msg }, `Sent: ${fmt(msg)}`);
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
default: usage();
|
|
572
|
+
}
|
|
573
|
+
break;
|
|
574
|
+
|
|
575
|
+
// -- Queue ------------------------------------------------
|
|
576
|
+
case 'queue':
|
|
577
|
+
switch (sub) {
|
|
578
|
+
case 'list':
|
|
579
|
+
case undefined: {
|
|
580
|
+
const agent = args[0] || 'main';
|
|
581
|
+
const limit = parseInt(args[1] || '50', 10);
|
|
582
|
+
const msgs = getInbox(agent, { limit, includeDelivered: true });
|
|
583
|
+
const delivered = msgs.filter(m => m.status === 'delivered');
|
|
584
|
+
const unread = getUnreadCount(agent);
|
|
585
|
+
if (msgs.length === 0) { emit({ agent, pending: unread, delivered: 0, messages: [] }, 'Queue empty'); break; }
|
|
586
|
+
const rows = msgs.map(m => ({
|
|
587
|
+
id: m.id.slice(0, 8),
|
|
588
|
+
from: m.from_agent,
|
|
589
|
+
kind: m.kind,
|
|
590
|
+
subject: m.subject?.slice(0, 45),
|
|
591
|
+
status: m.status,
|
|
592
|
+
priority: m.priority,
|
|
593
|
+
created: m.created_at,
|
|
594
|
+
}));
|
|
595
|
+
emit(jsonMode ? { agent, pending: unread, delivered: delivered.length, messages: msgs } : rows, () => {
|
|
596
|
+
console.log(`\nQueue for agent: ${agent} | ${unread} pending | ${delivered.length} delivered (showing last ${limit})\n`);
|
|
597
|
+
console.table(rows);
|
|
598
|
+
});
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
case 'clear': {
|
|
602
|
+
const r = markAllRead(args[0] || 'main');
|
|
603
|
+
emit({ ok: true, agent: args[0] || 'main', changes: r.changes }, `Cleared ${r.changes} messages`);
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
case 'prune': {
|
|
607
|
+
pruneMessages();
|
|
608
|
+
emit({ ok: true, pruned: true }, 'Pruned old messages (delivered >3d, system/result >3d, read/expired/failed >30d)');
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
default: usage();
|
|
612
|
+
}
|
|
613
|
+
break;
|
|
614
|
+
|
|
615
|
+
// -- Agents ----------------------------------------------
|
|
616
|
+
case 'agents':
|
|
617
|
+
switch (sub) {
|
|
618
|
+
case 'list': {
|
|
619
|
+
const agents = listAgents();
|
|
620
|
+
const rows = agents.map(a => ({
|
|
621
|
+
id: a.id,
|
|
622
|
+
name: a.name,
|
|
623
|
+
status: a.status,
|
|
624
|
+
lastSeen: a.last_seen_at,
|
|
625
|
+
sessionKey: a.session_key,
|
|
626
|
+
}));
|
|
627
|
+
emit(jsonMode ? agents : rows, () => console.table(rows));
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
case 'get': emit(getAgent(args[0])); break;
|
|
631
|
+
case 'register': {
|
|
632
|
+
if (!args[0]) fail('Usage: agents register <agent-id> [name]');
|
|
633
|
+
const a = upsertAgent(args[0], { name: args[1] || args[0] });
|
|
634
|
+
emit({ ok: true, agent: a }, `Registered: ${fmt(a)}`);
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
default: usage();
|
|
638
|
+
}
|
|
639
|
+
break;
|
|
640
|
+
|
|
641
|
+
// -- Tasks ------------------------------------------------
|
|
642
|
+
case 'tasks':
|
|
643
|
+
switch (sub) {
|
|
644
|
+
case 'list': {
|
|
645
|
+
const { listActiveTaskGroups, getTaskGroupStatus } = await import('./task-tracker.js');
|
|
646
|
+
const groups = listActiveTaskGroups();
|
|
647
|
+
if (groups.length === 0) { emit([], 'No active task groups'); break; }
|
|
648
|
+
const rows = groups.map(g => {
|
|
649
|
+
const status = getTaskGroupStatus(g.id);
|
|
650
|
+
if (!status) return null;
|
|
651
|
+
let agents;
|
|
652
|
+
try { agents = JSON.parse(g.expected_agents); } catch (e) {
|
|
653
|
+
process.stderr.write('Warning: failed to parse expected_agents JSON for group ' + g.id + ': ' + e.message + '\n');
|
|
654
|
+
agents = [];
|
|
655
|
+
}
|
|
656
|
+
return {
|
|
657
|
+
id: g.id.slice(0, 8) + '..',
|
|
658
|
+
name: g.name,
|
|
659
|
+
agents: `${status.agents.filter(a => a.status === 'completed').length}/${agents.length}`,
|
|
660
|
+
status: g.status,
|
|
661
|
+
elapsed: `${status.elapsed}s`,
|
|
662
|
+
timeout: `${g.timeout_s}s`,
|
|
663
|
+
};
|
|
664
|
+
}).filter(r => r !== null);
|
|
665
|
+
emit(jsonMode ? groups : rows, () => console.table(rows));
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
case 'status': {
|
|
669
|
+
const { getTaskGroupStatus } = await import('./task-tracker.js');
|
|
670
|
+
const status = getTaskGroupStatus(args[0]);
|
|
671
|
+
if (!status) fail('Task group not found: ' + args[0]);
|
|
672
|
+
emit(status, () => {
|
|
673
|
+
console.log(`\nTask Group: ${status.name}`);
|
|
674
|
+
console.log(`Status: ${status.status}`);
|
|
675
|
+
console.log(`Elapsed: ${status.elapsed}s / ${status.remaining_timeout + status.elapsed}s timeout`);
|
|
676
|
+
console.log(`\nAgents:`);
|
|
677
|
+
for (const a of status.agents) {
|
|
678
|
+
const icon = a.status === 'completed' ? '[ok]' : a.status === 'failed' ? '[FAIL]' : a.status === 'dead' ? '[DEAD]' : a.status === 'running' ? '[..]' : '[ ]';
|
|
679
|
+
const dur = a.duration != null ? ` (${a.duration}s)` : '';
|
|
680
|
+
const detail = a.exit_message || a.error || '';
|
|
681
|
+
console.log(` ${icon} ${a.label}: ${a.status}${dur}${detail ? ' -- ' + detail : ''}`);
|
|
682
|
+
}
|
|
683
|
+
if (status.summary) {
|
|
684
|
+
console.log(`\nSummary:\n${status.summary}`);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
case 'create': {
|
|
690
|
+
const { createTaskGroup } = await import('./task-tracker.js');
|
|
691
|
+
let opts;
|
|
692
|
+
try { opts = JSON.parse(args[0]); } catch { fail('Invalid JSON. Usage: tasks create \'{"name":"..."}\''); }
|
|
693
|
+
const group = createTaskGroup(opts);
|
|
694
|
+
emit({ ok: true, group }, `Created: ${fmt(group)}`);
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
case 'history': {
|
|
698
|
+
const limit = parseInt(args[0] || '10', 10);
|
|
699
|
+
const groups = getDb().prepare(
|
|
700
|
+
"SELECT * FROM task_tracker WHERE status != 'active' ORDER BY completed_at DESC LIMIT ?"
|
|
701
|
+
).all(limit);
|
|
702
|
+
if (groups.length === 0) { emit([], 'No completed task groups'); break; }
|
|
703
|
+
const rows = groups.map(g => ({
|
|
704
|
+
id: g.id.slice(0, 8) + '..',
|
|
705
|
+
name: g.name,
|
|
706
|
+
status: g.status,
|
|
707
|
+
completed: g.completed_at,
|
|
708
|
+
created_by: g.created_by,
|
|
709
|
+
}));
|
|
710
|
+
emit(jsonMode ? groups : rows, () => console.table(rows));
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// -- tasks heartbeat ----------------------------------
|
|
715
|
+
// Called BY sub-agents during execution to report status.
|
|
716
|
+
// Usage: tasks heartbeat <trackerId> <agentLabel> running|completed|failed [message]
|
|
717
|
+
case 'heartbeat': {
|
|
718
|
+
const { agentStarted, agentCompleted, agentFailed } = await import('./task-tracker.js');
|
|
719
|
+
const [trackerId, agentLabel, status, ...msgParts] = args;
|
|
720
|
+
const exitMsg = msgParts.join(' ') || undefined;
|
|
721
|
+
|
|
722
|
+
if (!trackerId || !agentLabel || !status) {
|
|
723
|
+
fail('Usage: tasks heartbeat <trackerId> <agentLabel> running|completed|failed [message]');
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (status === 'running') {
|
|
727
|
+
agentStarted(trackerId, agentLabel);
|
|
728
|
+
emit({ ok: true, tracker_id: trackerId, agent: agentLabel, status: 'running' }, `Heartbeat recorded: ${agentLabel} -> running`);
|
|
729
|
+
} else if (status === 'completed') {
|
|
730
|
+
agentCompleted(trackerId, agentLabel, exitMsg);
|
|
731
|
+
emit({ ok: true, tracker_id: trackerId, agent: agentLabel, status: 'completed', message: exitMsg }, `Heartbeat recorded: ${agentLabel} -> completed${exitMsg ? ` (${exitMsg})` : ''}`);
|
|
732
|
+
} else if (status === 'failed') {
|
|
733
|
+
agentFailed(trackerId, agentLabel, exitMsg || 'failed');
|
|
734
|
+
emit({ ok: true, tracker_id: trackerId, agent: agentLabel, status: 'failed', message: exitMsg }, `Heartbeat recorded: ${agentLabel} -> failed${exitMsg ? ` (${exitMsg})` : ''}`);
|
|
735
|
+
} else {
|
|
736
|
+
fail(`Unknown status: "${status}". Valid values: running | completed | failed`);
|
|
737
|
+
}
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// -- tasks register-session ----------------------------
|
|
742
|
+
// Called BY the orchestrator after spawning a sub-agent.
|
|
743
|
+
// Links the sub-agent's OC session key to the tracker agent so
|
|
744
|
+
// the dispatcher can auto-detect heartbeats without CLI calls.
|
|
745
|
+
// Usage: tasks register-session <trackerId> <agentLabel> <sessionKey>
|
|
746
|
+
case 'register-session': {
|
|
747
|
+
const { registerAgentSession } = await import('./task-tracker.js');
|
|
748
|
+
const [trackerId, agentLabel, sessionKey] = args;
|
|
749
|
+
if (!trackerId || !agentLabel || !sessionKey) {
|
|
750
|
+
fail('Usage: tasks register-session <trackerId> <agentLabel> <sessionKey>');
|
|
751
|
+
}
|
|
752
|
+
registerAgentSession(trackerId, agentLabel, sessionKey);
|
|
753
|
+
emit({ ok: true, tracker_id: trackerId, agent: agentLabel, session_key: sessionKey }, `Session registered: ${agentLabel} -> ${sessionKey}`);
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
default: usage();
|
|
758
|
+
}
|
|
759
|
+
break;
|
|
760
|
+
|
|
761
|
+
// -- Approvals ----------------------------------------------
|
|
762
|
+
case 'approvals':
|
|
763
|
+
switch (sub) {
|
|
764
|
+
case 'list':
|
|
765
|
+
case 'pending': {
|
|
766
|
+
const { listPendingApprovals } = await import('./approval.js');
|
|
767
|
+
const approvals = listPendingApprovals();
|
|
768
|
+
if (approvals.length === 0) { emit([], 'No pending approvals'); break; }
|
|
769
|
+
const rows = approvals.map(a => ({
|
|
770
|
+
id: a.id.slice(0, 8),
|
|
771
|
+
job: a.job_id.slice(0, 8),
|
|
772
|
+
job_name: a.job_name || '-',
|
|
773
|
+
run: a.run_id?.slice(0, 8) || '-',
|
|
774
|
+
status: a.status,
|
|
775
|
+
requested: a.requested_at,
|
|
776
|
+
}));
|
|
777
|
+
emit(jsonMode ? approvals : rows, () => console.table(rows));
|
|
778
|
+
break;
|
|
779
|
+
}
|
|
780
|
+
default: usage();
|
|
781
|
+
}
|
|
782
|
+
break;
|
|
783
|
+
|
|
784
|
+
// -- Idempotency ----------------------------------------
|
|
785
|
+
case 'idem': {
|
|
786
|
+
const { listIdempotencyForJob, getIdempotencyEntry, releaseIdempotencyKey, forcePruneIdempotency } = await import('./idempotency.js');
|
|
787
|
+
switch (sub) {
|
|
788
|
+
case 'status': {
|
|
789
|
+
if (!args[0]) fail('Usage: idem status <job-id>');
|
|
790
|
+
const entries = listIdempotencyForJob(args[0]);
|
|
791
|
+
if (entries.length === 0) { emit([], 'No idempotency entries for this job'); break; }
|
|
792
|
+
const rows = entries.map(e => ({
|
|
793
|
+
key: e.key.slice(0, 12) + '..',
|
|
794
|
+
run: (e.run_id?.slice(0, 8) || '-') + '..',
|
|
795
|
+
status: e.status,
|
|
796
|
+
claimed: e.claimed_at,
|
|
797
|
+
released: e.released_at || '-',
|
|
798
|
+
expires: e.expires_at,
|
|
799
|
+
hash: e.result_hash || '-',
|
|
800
|
+
}));
|
|
801
|
+
emit(jsonMode ? entries : rows, () => console.table(rows));
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
804
|
+
case 'check': {
|
|
805
|
+
if (!args[0]) fail('Usage: idem check <key>');
|
|
806
|
+
const entry = getIdempotencyEntry(args[0]);
|
|
807
|
+
if (!entry) { emit({ found: false, key: args[0] }, 'Key not found in ledger'); break; }
|
|
808
|
+
emit(entry);
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
case 'release': {
|
|
812
|
+
if (!args[0]) fail('Usage: idem release <key>');
|
|
813
|
+
const before = getIdempotencyEntry(args[0]);
|
|
814
|
+
if (!before) fail('Key not found in ledger');
|
|
815
|
+
if (before.status === 'released') { emit({ ok: true, key: args[0], already_released: true }, 'Key already released'); break; }
|
|
816
|
+
releaseIdempotencyKey(args[0]);
|
|
817
|
+
emit({ ok: true, key: args[0], released: true }, `Released idempotency key: ${args[0].slice(0, 12)}..`);
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
case 'prune': {
|
|
821
|
+
const result = forcePruneIdempotency();
|
|
822
|
+
emit({ ok: true, pruned: result }, `Pruned ${result} expired idempotency entries`);
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
default: usage();
|
|
826
|
+
}
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// -- Team Adapter ---------------------------------------
|
|
831
|
+
case 'team': {
|
|
832
|
+
const {
|
|
833
|
+
mapTeamMessages, listTeamTasks, listTeamMailboxEvents,
|
|
834
|
+
createTeamTaskGate, checkTeamTaskGates, ackTeamMessage,
|
|
835
|
+
} = await import('./team-adapter.js');
|
|
836
|
+
|
|
837
|
+
switch (sub) {
|
|
838
|
+
case 'map': {
|
|
839
|
+
const mapped = mapTeamMessages(parseInt(args[0] || '200', 10));
|
|
840
|
+
emit({ ok: true, mapped }, `Mapped ${mapped} team message(s)`);
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
case 'tasks': {
|
|
844
|
+
if (!args[0]) fail('Usage: team tasks <team-id> [limit]');
|
|
845
|
+
const tasks = listTeamTasks(args[0], parseInt(args[1] || '50', 10));
|
|
846
|
+
if (tasks.length === 0) { emit([], 'No team tasks'); break; }
|
|
847
|
+
const rows = tasks.map(t => ({
|
|
848
|
+
team: t.team_id,
|
|
849
|
+
task: t.id,
|
|
850
|
+
member: t.member_id || '-',
|
|
851
|
+
status: t.status,
|
|
852
|
+
gateTracker: t.gate_tracker_id ? t.gate_tracker_id.slice(0, 8) + '..' : '-',
|
|
853
|
+
gateStatus: t.gate_status || '-',
|
|
854
|
+
updated: t.updated_at,
|
|
855
|
+
completed: t.completed_at || '-',
|
|
856
|
+
}));
|
|
857
|
+
emit(jsonMode ? tasks : rows, () => console.table(rows));
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
case 'events': {
|
|
861
|
+
if (!args[0]) fail('Usage: team events <team-id> [limit] [task-id]');
|
|
862
|
+
const teamId = args[0];
|
|
863
|
+
const limit = parseInt(args[1] || '50', 10);
|
|
864
|
+
const taskId = args[2] || null;
|
|
865
|
+
const events = listTeamMailboxEvents(teamId, { limit, taskId });
|
|
866
|
+
if (events.length === 0) { emit([], 'No team events'); break; }
|
|
867
|
+
const rows = events.map(e => ({
|
|
868
|
+
id: e.id.slice(0, 8),
|
|
869
|
+
team: e.team_id,
|
|
870
|
+
member: e.member_id || '-',
|
|
871
|
+
task: e.task_id || '-',
|
|
872
|
+
message: e.message_id ? e.message_id.slice(0, 8) : '-',
|
|
873
|
+
type: e.event_type,
|
|
874
|
+
at: e.created_at,
|
|
875
|
+
}));
|
|
876
|
+
emit(jsonMode ? events : rows, () => console.table(rows));
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
case 'gate': {
|
|
880
|
+
if (!args[0] || !args[1] || !args[2]) {
|
|
881
|
+
fail('Usage: team gate <team-id> <task-id> <members-json> [timeout-s]\nExample: team gate core-team deploy-001 "[\\"writer\\",\\"reviewer\\"]" 900');
|
|
882
|
+
}
|
|
883
|
+
const teamId = args[0];
|
|
884
|
+
const taskId = args[1];
|
|
885
|
+
let members;
|
|
886
|
+
try { members = JSON.parse(args[2]); } catch { fail('Invalid JSON for members list. Example: \'["writer","reviewer"]\''); }
|
|
887
|
+
const timeoutS = parseInt(args[3] || '600', 10);
|
|
888
|
+
const gate = createTeamTaskGate({ teamId, taskId, expectedMembers: members, timeoutS });
|
|
889
|
+
emit({ ok: true, gate }, `Gate opened: ${fmt(gate)}`);
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
case 'check-gates': {
|
|
893
|
+
const result = checkTeamTaskGates(parseInt(args[0] || '100', 10));
|
|
894
|
+
emit(result);
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
case 'ack': {
|
|
898
|
+
if (!args[0]) fail('Usage: team ack <message-id> [actor] [note]');
|
|
899
|
+
const actor = args[1] || 'team-member';
|
|
900
|
+
const detail = args.slice(2).join(' ') || null;
|
|
901
|
+
const msg = ackTeamMessage(args[0], actor, detail);
|
|
902
|
+
if (!msg) fail('Team message not found: ' + args[0]);
|
|
903
|
+
emit(
|
|
904
|
+
{ ok: true, id: msg.id, status: msg.status, ack_at: msg.ack_at },
|
|
905
|
+
`Team ACK: ${fmt({ id: msg.id, status: msg.status, ack_at: msg.ack_at })}`
|
|
906
|
+
);
|
|
907
|
+
break;
|
|
908
|
+
}
|
|
909
|
+
default: usage();
|
|
910
|
+
}
|
|
911
|
+
break;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// -- Aliases ---------------------------------------------
|
|
915
|
+
case 'alias': {
|
|
916
|
+
const db = getDb();
|
|
917
|
+
switch (sub) {
|
|
918
|
+
case 'list': {
|
|
919
|
+
const aliases = db.prepare('SELECT alias, channel, target, description, created_at FROM delivery_aliases ORDER BY alias').all();
|
|
920
|
+
if (aliases.length === 0) { emit([], 'No aliases defined'); break; }
|
|
921
|
+
const rows = aliases.map(a => ({
|
|
922
|
+
alias: a.alias,
|
|
923
|
+
channel: a.channel,
|
|
924
|
+
target: a.target,
|
|
925
|
+
description: a.description || '',
|
|
926
|
+
}));
|
|
927
|
+
emit(jsonMode ? aliases : rows, () => console.table(rows));
|
|
928
|
+
break;
|
|
929
|
+
}
|
|
930
|
+
case 'add': {
|
|
931
|
+
const [name, channel, target, ...descParts] = args;
|
|
932
|
+
if (!name || !channel || !target) {
|
|
933
|
+
fail('Usage: alias add <name> <channel> <target> [description]');
|
|
934
|
+
}
|
|
935
|
+
const description = descParts.length > 0 ? descParts.join(' ') : null;
|
|
936
|
+
db.prepare('INSERT OR REPLACE INTO delivery_aliases (alias, channel, target, description) VALUES (?, ?, ?, ?)')
|
|
937
|
+
.run(name, channel, target, description);
|
|
938
|
+
emit({ ok: true, alias: name, channel, target, description }, `Added alias: ${name} -> ${channel}/${target}`);
|
|
939
|
+
break;
|
|
940
|
+
}
|
|
941
|
+
case 'remove': {
|
|
942
|
+
if (!args[0]) fail('Usage: alias remove <name>');
|
|
943
|
+
const result = db.prepare('DELETE FROM delivery_aliases WHERE alias = ?').run(args[0]);
|
|
944
|
+
if (result.changes > 0) emit({ ok: true, alias: args[0], removed: true }, `Removed alias: ${args[0]}`);
|
|
945
|
+
else fail(`Alias not found: ${args[0]}`);
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
948
|
+
default: usage();
|
|
949
|
+
}
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// -- Status ----------------------------------------------
|
|
954
|
+
case 'status': {
|
|
955
|
+
const db = getDb();
|
|
956
|
+
const jobs = listJobs();
|
|
957
|
+
const runningRuns = getRunningRuns();
|
|
958
|
+
const stale = getStaleRuns();
|
|
959
|
+
const agents = listAgents();
|
|
960
|
+
const budget = db.prepare(`
|
|
961
|
+
SELECT
|
|
962
|
+
(SELECT COUNT(*) FROM job_dispatch_queue WHERE status = 'pending') AS pending_dispatches,
|
|
963
|
+
(SELECT COUNT(*) FROM job_dispatch_queue WHERE status = 'awaiting_approval') AS approval_blocked_dispatches,
|
|
964
|
+
(SELECT COALESCE(SUM(queued_count), 0) FROM jobs) AS overlap_queued_dispatches,
|
|
965
|
+
(SELECT COUNT(*) FROM approvals WHERE status = 'pending') AS pending_approvals,
|
|
966
|
+
(SELECT COALESCE(SUM(shell_stdout_bytes + shell_stderr_bytes), 0) FROM runs) AS shell_output_bytes,
|
|
967
|
+
(SELECT COUNT(*) FROM runs WHERE shell_stdout_path IS NOT NULL OR shell_stderr_path IS NOT NULL) AS offloaded_shell_runs
|
|
968
|
+
`).get();
|
|
969
|
+
const hotJobs = db.prepare(`
|
|
970
|
+
SELECT
|
|
971
|
+
j.id, j.name, j.queued_count, j.max_queued_dispatches, j.max_pending_approvals,
|
|
972
|
+
(SELECT COUNT(*) FROM approvals a WHERE a.job_id = j.id AND a.status = 'pending') AS pending_approval_count,
|
|
973
|
+
(SELECT COUNT(*) FROM job_dispatch_queue q WHERE q.job_id = j.id AND q.status IN ('pending', 'claimed', 'awaiting_approval')) AS dispatch_backlog
|
|
974
|
+
FROM jobs j
|
|
975
|
+
WHERE
|
|
976
|
+
j.queued_count >= j.max_queued_dispatches
|
|
977
|
+
OR (SELECT COUNT(*) FROM approvals a WHERE a.job_id = j.id AND a.status = 'pending') >= j.max_pending_approvals
|
|
978
|
+
ORDER BY j.name
|
|
979
|
+
LIMIT 10
|
|
980
|
+
`).all();
|
|
981
|
+
const nextJob = jobs
|
|
982
|
+
.filter(j => j.enabled && j.next_run_at)
|
|
983
|
+
.sort((a, b) => a.next_run_at.localeCompare(b.next_run_at))[0] || null;
|
|
984
|
+
const payload = {
|
|
985
|
+
jobs_total: jobs.length,
|
|
986
|
+
jobs_enabled: jobs.filter(j => j.enabled).length,
|
|
987
|
+
running_runs: runningRuns.length,
|
|
988
|
+
stale_runs: stale.length,
|
|
989
|
+
budgets: budget,
|
|
990
|
+
budget_hotspots: hotJobs,
|
|
991
|
+
agents: agents.map(a => ({
|
|
992
|
+
id: a.id,
|
|
993
|
+
status: a.status,
|
|
994
|
+
unread: getUnreadCount(a.id),
|
|
995
|
+
})),
|
|
996
|
+
next_job: nextJob ? { id: nextJob.id, name: nextJob.name, next_run_at: nextJob.next_run_at } : null,
|
|
997
|
+
};
|
|
998
|
+
emit(payload, () => {
|
|
999
|
+
console.log('=== OpenClaw Scheduler Status ===');
|
|
1000
|
+
console.log(`Jobs: ${jobs.length} total, ${jobs.filter(j => j.enabled).length} enabled`);
|
|
1001
|
+
console.log(`Running: ${runningRuns.length}`);
|
|
1002
|
+
console.log(`Stale: ${stale.length}`);
|
|
1003
|
+
console.log(`Dispatch: ${budget.pending_dispatches} pending, ${budget.approval_blocked_dispatches} approval-blocked, ${budget.overlap_queued_dispatches} overlap-queued`);
|
|
1004
|
+
console.log(`Approvals:${budget.pending_approvals} pending`);
|
|
1005
|
+
console.log(`Output: ${budget.shell_output_bytes} bytes stored/offloaded across runs (${budget.offloaded_shell_runs} offloaded runs)`);
|
|
1006
|
+
console.log(`Agents: ${agents.length}`);
|
|
1007
|
+
for (const a of agents) {
|
|
1008
|
+
const unread = getUnreadCount(a.id);
|
|
1009
|
+
console.log(` ${a.id}: ${a.status}${unread ? ` (${unread} unread)` : ''}`);
|
|
1010
|
+
}
|
|
1011
|
+
if (hotJobs.length > 0) {
|
|
1012
|
+
console.log('\nBudget hotspots:');
|
|
1013
|
+
console.table(hotJobs.map(job => ({
|
|
1014
|
+
name: job.name,
|
|
1015
|
+
dispatchBacklog: job.dispatch_backlog,
|
|
1016
|
+
queuedCount: job.queued_count,
|
|
1017
|
+
maxQueued: job.max_queued_dispatches,
|
|
1018
|
+
pendingApprovals: job.pending_approval_count,
|
|
1019
|
+
maxApprovals: job.max_pending_approvals,
|
|
1020
|
+
})));
|
|
1021
|
+
}
|
|
1022
|
+
if (nextJob) console.log(`\nNext: ${nextJob.name} at ${nextJob.next_run_at}`);
|
|
1023
|
+
});
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
case 'schema': {
|
|
1028
|
+
const key = (sub || 'all').toLowerCase();
|
|
1029
|
+
if (key === 'all') {
|
|
1030
|
+
emit(SCHEDULER_SCHEMAS);
|
|
1031
|
+
break;
|
|
1032
|
+
}
|
|
1033
|
+
const singularMap = {
|
|
1034
|
+
job: 'jobs',
|
|
1035
|
+
run: 'runs',
|
|
1036
|
+
message: 'messages',
|
|
1037
|
+
approval: 'approvals',
|
|
1038
|
+
dispatch: 'dispatches',
|
|
1039
|
+
};
|
|
1040
|
+
const resolved = singularMap[key] || key;
|
|
1041
|
+
if (!SCHEDULER_SCHEMAS[resolved]) fail(`Unknown schema target: ${sub}`);
|
|
1042
|
+
emit(SCHEDULER_SCHEMAS[resolved]);
|
|
1043
|
+
break;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// -- Capabilities ----------------------------------------
|
|
1047
|
+
case 'capabilities': {
|
|
1048
|
+
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
1049
|
+
const schemaVersion = getDb().prepare('SELECT MAX(version) AS v FROM schema_migrations').get()?.v ?? null;
|
|
1050
|
+
const capabilities = {
|
|
1051
|
+
scheduler_version: pkg.version,
|
|
1052
|
+
schema_version: schemaVersion,
|
|
1053
|
+
handoff_version: '2',
|
|
1054
|
+
features: {
|
|
1055
|
+
approvals: 'runtime',
|
|
1056
|
+
model_policy: 'model+thinking',
|
|
1057
|
+
execution_intent: 'runtime',
|
|
1058
|
+
output_hints: 'runtime',
|
|
1059
|
+
timeout_support: 'runtime',
|
|
1060
|
+
context_retrieval: 'runtime',
|
|
1061
|
+
runtime_execution: true,
|
|
1062
|
+
identity_declaration: true,
|
|
1063
|
+
runtime_identity_resolution: true,
|
|
1064
|
+
trust_evaluation: true,
|
|
1065
|
+
authorization_proof_verification: true,
|
|
1066
|
+
authorization_hook: true,
|
|
1067
|
+
evidence_generation: true,
|
|
1068
|
+
delegation_validation: false,
|
|
1069
|
+
credential_handoff: true,
|
|
1070
|
+
audit_export: true,
|
|
1071
|
+
},
|
|
1072
|
+
};
|
|
1073
|
+
emit(capabilities);
|
|
1074
|
+
break;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
default:
|
|
1078
|
+
if (command) {
|
|
1079
|
+
fail(`Unknown command: ${command}. Run without arguments for usage.`);
|
|
1080
|
+
}
|
|
1081
|
+
usage();
|
|
1082
|
+
process.exit(0);
|
|
1083
|
+
}
|