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.
@@ -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
+ }
@@ -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
- delivery_to: { type: 'string', nullable: true },
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: 2000 },
42
- output_summary_limit_bytes: { type: 'integer', min: 64, default: 5000 },
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 2000,
85
- output_summary_limit_bytes INTEGER NOT NULL DEFAULT 5000,
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}`.slice(0, 4000);
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 IN ('pending', 'delivered')
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 = 2000;
8
- export const DEFAULT_SUMMARY_LIMIT = 5000;
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 stdoutText = toText(stdout);
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: {