openclaw-scheduler 0.2.0 → 0.2.2
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 +21 -0
- package/BEST-PRACTICES.md +20 -3
- package/CHANGELOG.md +38 -1
- package/INSTALL.md +23 -0
- package/README.md +24 -12
- package/UPGRADING.md +31 -0
- package/dispatch/index.mjs +73 -28
- package/dispatch/watcher.mjs +101 -38
- package/dispatcher-delivery.js +11 -4
- package/dispatcher-strategies.js +45 -4
- package/dispatcher.js +19 -4
- package/gateway.js +117 -1
- package/jobs.js +30 -1
- package/package.json +2 -1
- package/provider-registry.js +94 -0
- package/scheduler-schema.js +5 -3
- package/schema.sql +2 -2
- package/scripts/inbox-consumer.mjs +29 -2
- package/shell-result.js +21 -3
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readdir, stat as fsStat } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const identityProviders = new Map();
|
|
6
|
+
const authorizationProviders = new Map();
|
|
7
|
+
const proofVerifiers = new Map();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Load provider plugins from a directory. Every *.js file is imported and
|
|
11
|
+
* its default export registered by type (identity / authorization / proof-verifier).
|
|
12
|
+
*
|
|
13
|
+
* TRUST BOUNDARY: This directory is dynamically imported at startup. Only
|
|
14
|
+
* point SCHEDULER_PROVIDER_PATH at operator-controlled directories. The loader
|
|
15
|
+
* refuses world-writable directories as a minimal safety net, but the
|
|
16
|
+
* primary defense is correct deployment configuration.
|
|
17
|
+
*/
|
|
18
|
+
export async function loadProviders(dirPath) {
|
|
19
|
+
if (!dirPath) return;
|
|
20
|
+
const absPath = resolve(dirPath);
|
|
21
|
+
|
|
22
|
+
// Trust boundary: provider plugins run arbitrary code in the scheduler process.
|
|
23
|
+
// Refuse to load from world-writable directories to prevent code injection.
|
|
24
|
+
try {
|
|
25
|
+
const dirStat = await fsStat(absPath);
|
|
26
|
+
if ((dirStat.mode & 0o002) !== 0) {
|
|
27
|
+
console.error(`[provider-registry] REFUSING to load providers: ${absPath} is world-writable (mode 0${(dirStat.mode & 0o777).toString(8)}). Fix permissions or use a trusted directory.`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.error(`[provider-registry] Cannot stat provider directory ${absPath}: ${err.message}`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const files = await readdir(absPath);
|
|
36
|
+
const jsFiles = files.filter(f => f.endsWith('.js'));
|
|
37
|
+
|
|
38
|
+
for (const file of jsFiles) {
|
|
39
|
+
const filePath = join(absPath, file);
|
|
40
|
+
try {
|
|
41
|
+
const mod = await import(pathToFileURL(filePath).href);
|
|
42
|
+
const provider = mod.default;
|
|
43
|
+
if (!provider || !provider.name || !provider.type) {
|
|
44
|
+
console.warn(`[provider-registry] Skipping ${file}: missing name or type`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (provider.type === 'identity') {
|
|
48
|
+
identityProviders.set(provider.name, provider);
|
|
49
|
+
} else if (provider.type === 'authorization') {
|
|
50
|
+
authorizationProviders.set(provider.name, provider);
|
|
51
|
+
} else if (provider.type === 'proof-verifier') {
|
|
52
|
+
proofVerifiers.set(provider.name, provider);
|
|
53
|
+
} else {
|
|
54
|
+
console.warn(`[provider-registry] Skipping ${file}: unknown type "${provider.type}"`);
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error(`[provider-registry] Failed to load ${file}: ${err.message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const total = identityProviders.size + authorizationProviders.size + proofVerifiers.size;
|
|
62
|
+
console.log(`[provider-registry] Loaded ${total} provider(s) from ${absPath}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getIdentityProvider(name) {
|
|
66
|
+
return identityProviders.get(name) || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getAuthorizationProvider(name) {
|
|
70
|
+
return authorizationProviders.get(name) || null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getProofVerifier(name) {
|
|
74
|
+
return proofVerifiers.get(name) || null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function hasProvider(name) {
|
|
78
|
+
return identityProviders.has(name) || authorizationProviders.has(name) || proofVerifiers.has(name);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function listProviders() {
|
|
82
|
+
const result = [];
|
|
83
|
+
for (const [name, p] of identityProviders) result.push({ name, type: p.type });
|
|
84
|
+
for (const [name, p] of authorizationProviders) result.push({ name, type: p.type });
|
|
85
|
+
for (const [name, p] of proofVerifiers) result.push({ name, type: p.type });
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// For testing: reset all registries
|
|
90
|
+
export function _resetForTesting() {
|
|
91
|
+
identityProviders.clear();
|
|
92
|
+
authorizationProviders.clear();
|
|
93
|
+
proofVerifiers.clear();
|
|
94
|
+
}
|
package/scheduler-schema.js
CHANGED
|
@@ -22,7 +22,9 @@ export const SCHEDULER_SCHEMAS = {
|
|
|
22
22
|
max_trigger_fanout: { type: 'integer', min: 1, default: 25 },
|
|
23
23
|
delivery_mode: { type: 'string', enum: ['announce', 'announce-always', 'none'], default: 'announce' },
|
|
24
24
|
delivery_channel: { type: 'string', nullable: true },
|
|
25
|
-
|
|
25
|
+
// REQUIRED on insert for non-exempt jobs. Exempt: job_type='watchdog', name starts with 'watchdog:',
|
|
26
|
+
// session_target='main', or delivery_mode='none'. Set to the origin chat_id so results reach the right chat.
|
|
27
|
+
delivery_to: { type: 'string', nullable: true, required: 'non-system jobs', description: 'Target chat/user id for delivery (e.g. telegram chat_id). Required on insert for non-system jobs.' },
|
|
26
28
|
parent_id: { type: 'string', nullable: true },
|
|
27
29
|
trigger_on: { type: 'string', enum: ['success', 'failure', 'complete'], nullable: true },
|
|
28
30
|
trigger_delay_s: { type: 'integer', min: 0, default: 0 },
|
|
@@ -38,8 +40,8 @@ export const SCHEDULER_SCHEMAS = {
|
|
|
38
40
|
context_retrieval: { type: 'string', enum: ['none', 'recent', 'hybrid'], default: 'none' },
|
|
39
41
|
context_retrieval_limit: { type: 'integer', min: 1, default: 5 },
|
|
40
42
|
output_store_limit_bytes: { type: 'integer', min: 128, default: 65536 },
|
|
41
|
-
output_excerpt_limit_bytes: { type: 'integer', min: 64, default:
|
|
42
|
-
output_summary_limit_bytes: { type: 'integer', min: 64, default:
|
|
43
|
+
output_excerpt_limit_bytes: { type: 'integer', min: 64, default: 65536 },
|
|
44
|
+
output_summary_limit_bytes: { type: 'integer', min: 64, default: 65536 },
|
|
43
45
|
output_offload_threshold_bytes: { type: 'integer', min: 128, default: 65536 },
|
|
44
46
|
preferred_session_key: { type: 'string', nullable: true },
|
|
45
47
|
auth_profile: { type: 'string', nullable: true, description: 'Auth profile override: null=default, "inherit"=main session profile, or "provider:label"' },
|
package/schema.sql
CHANGED
|
@@ -81,8 +81,8 @@ CREATE TABLE IF NOT EXISTS jobs (
|
|
|
81
81
|
|
|
82
82
|
-- Output handling (v14)
|
|
83
83
|
output_store_limit_bytes INTEGER NOT NULL DEFAULT 65536,
|
|
84
|
-
output_excerpt_limit_bytes INTEGER NOT NULL DEFAULT
|
|
85
|
-
output_summary_limit_bytes INTEGER NOT NULL DEFAULT
|
|
84
|
+
output_excerpt_limit_bytes INTEGER NOT NULL DEFAULT 65536,
|
|
85
|
+
output_summary_limit_bytes INTEGER NOT NULL DEFAULT 65536,
|
|
86
86
|
output_offload_threshold_bytes INTEGER NOT NULL DEFAULT 65536,
|
|
87
87
|
|
|
88
88
|
-- Session continuity (v9)
|
|
@@ -118,7 +118,7 @@ function formatMessageForDelivery(msg, { brand = 'Scheduler' } = {}) {
|
|
|
118
118
|
const subject = msg.subject || 'Notification';
|
|
119
119
|
const header = `${brand} | ${subject} | ${age}`;
|
|
120
120
|
|
|
121
|
-
return `${header}\n\n${body}
|
|
121
|
+
return `${header}\n\n${body}`;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/**
|
|
@@ -137,12 +137,17 @@ function _formatMessagesDebug(msgs, agentId) {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
function selectPendingMessages(db, agentId, limit) {
|
|
140
|
+
// Only fetch 'pending' messages for user-facing delivery.
|
|
141
|
+
// Messages with status='delivered' have already been injected into an AI
|
|
142
|
+
// agent's context prompt (by buildJobPrompt/markDelivered in dispatcher.js)
|
|
143
|
+
// and must NOT be re-delivered to the user via Telegram — doing so causes
|
|
144
|
+
// duplicate notifications on every inbox-consumer run.
|
|
140
145
|
return db.prepare(`
|
|
141
146
|
SELECT id, from_agent, to_agent, subject, body, kind, created_at, priority,
|
|
142
147
|
delivery_to, channel
|
|
143
148
|
FROM messages
|
|
144
149
|
WHERE (to_agent = ? OR to_agent = 'broadcast')
|
|
145
|
-
AND status
|
|
150
|
+
AND status = 'pending'
|
|
146
151
|
ORDER BY
|
|
147
152
|
CASE kind
|
|
148
153
|
WHEN 'constraint' THEN 0
|
|
@@ -273,8 +278,30 @@ try {
|
|
|
273
278
|
}, 250);
|
|
274
279
|
});
|
|
275
280
|
|
|
281
|
+
// Periodic poll fallback — catches messages that slip through WAL checkpoints.
|
|
282
|
+
// When SQLite checkpoints the WAL (merges it back into the main DB), the WAL
|
|
283
|
+
// file is reset and the watcher may miss a subsequent write. This belt-and-
|
|
284
|
+
// suspenders poll ensures delivery within at most INBOX_POLL_INTERVAL_MS.
|
|
285
|
+
const pollIntervalMs = parsePositiveInt(process.env.INBOX_POLL_INTERVAL_MS, 60000);
|
|
286
|
+
const pollInterval = setInterval(async () => {
|
|
287
|
+
if (draining) return;
|
|
288
|
+
draining = true;
|
|
289
|
+
try {
|
|
290
|
+
const n = await drainOnce(db, { to: deliveryTo, channel, agentId, limit, brand });
|
|
291
|
+
if (n > 0) {
|
|
292
|
+
process.stdout.write(`[inbox-consumer] poll fallback delivered ${n} pending message(s)\n`);
|
|
293
|
+
}
|
|
294
|
+
} catch (err) {
|
|
295
|
+
process.stderr.write(`[inbox-consumer] poll fallback error: ${err.message}\n`);
|
|
296
|
+
} finally {
|
|
297
|
+
draining = false;
|
|
298
|
+
}
|
|
299
|
+
}, pollIntervalMs);
|
|
300
|
+
process.stdout.write(`[inbox-consumer] poll fallback enabled (interval=${pollIntervalMs}ms)\n`);
|
|
301
|
+
|
|
276
302
|
const shutdown = (signal) => {
|
|
277
303
|
if (timer) clearTimeout(timer);
|
|
304
|
+
clearInterval(pollInterval);
|
|
278
305
|
watcher.close();
|
|
279
306
|
process.stdout.write(`[inbox-consumer] ${signal}; exiting\n`);
|
|
280
307
|
process.exit(0);
|
package/shell-result.js
CHANGED
|
@@ -4,8 +4,8 @@ import { getResolvedDbPath } from './db.js';
|
|
|
4
4
|
import { ensureArtifactsDir, resolveArtifactsDir } from './paths.js';
|
|
5
5
|
|
|
6
6
|
export const DEFAULT_STORE_LIMIT = 64 * 1024;
|
|
7
|
-
export const DEFAULT_EXCERPT_LIMIT =
|
|
8
|
-
export const DEFAULT_SUMMARY_LIMIT =
|
|
7
|
+
export const DEFAULT_EXCERPT_LIMIT = 64 * 1024;
|
|
8
|
+
export const DEFAULT_SUMMARY_LIMIT = 64 * 1024;
|
|
9
9
|
export const DEFAULT_OFFLOAD_THRESHOLD = 64 * 1024;
|
|
10
10
|
|
|
11
11
|
function toText(value) {
|
|
@@ -92,7 +92,24 @@ export function normalizeShellResult(
|
|
|
92
92
|
artifactsDir = resolveArtifactsDir({ dbPath: getResolvedDbPath() }),
|
|
93
93
|
} = {}
|
|
94
94
|
) {
|
|
95
|
-
const
|
|
95
|
+
const rawStdout = toText(stdout);
|
|
96
|
+
|
|
97
|
+
// Extract [IMAGE:path] markers from stdout before processing.
|
|
98
|
+
// Scripts can signal image attachments by writing lines like:
|
|
99
|
+
// [IMAGE:/tmp/chart.png]
|
|
100
|
+
// [IMAGE:/tmp/report.pdf]
|
|
101
|
+
// Extracted paths are passed through to the delivery layer.
|
|
102
|
+
const IMAGE_MARKER_RE = /^\[IMAGE:(\/[^\]\n]+)\]$/gm;
|
|
103
|
+
const imageAttachments = [];
|
|
104
|
+
let match;
|
|
105
|
+
while ((match = IMAGE_MARKER_RE.exec(rawStdout)) !== null) {
|
|
106
|
+
imageAttachments.push(match[1]);
|
|
107
|
+
}
|
|
108
|
+
// Strip markers from stdout so they don't appear in delivery text
|
|
109
|
+
const stdoutText = imageAttachments.length > 0
|
|
110
|
+
? rawStdout.replace(IMAGE_MARKER_RE, '').replace(/\n{3,}/g, '\n\n').trim()
|
|
111
|
+
: rawStdout;
|
|
112
|
+
|
|
96
113
|
const stderrText = toText(stderr);
|
|
97
114
|
const stdoutBytes = textBytes(stdoutText);
|
|
98
115
|
const stderrBytes = textBytes(stderrText);
|
|
@@ -144,6 +161,7 @@ export function normalizeShellResult(
|
|
|
144
161
|
stderrTruncated: stderrStored.truncated,
|
|
145
162
|
summary: truncateText(previewText, summaryLimit).text,
|
|
146
163
|
deliveryText: previewText,
|
|
164
|
+
imageAttachments,
|
|
147
165
|
errorMessage,
|
|
148
166
|
contextSummary: {
|
|
149
167
|
shell_result: {
|