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/gateway.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// Gateway API client -- independent dispatch via chat completions + system events
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { getDb } from './db.js';
|
|
7
|
+
|
|
8
|
+
const GATEWAY_URL = process.env.OPENCLAW_GATEWAY_URL || 'http://127.0.0.1:18789';
|
|
9
|
+
const HOME_DIR = process.env.HOME || homedir();
|
|
10
|
+
export const TELEGRAM_MAX_MESSAGE_LENGTH = 4096;
|
|
11
|
+
|
|
12
|
+
let _cachedToken;
|
|
13
|
+
let _tokenLoaded = false;
|
|
14
|
+
|
|
15
|
+
function getGatewayToken() {
|
|
16
|
+
if (!_tokenLoaded) {
|
|
17
|
+
_tokenLoaded = true;
|
|
18
|
+
if (process.env.OPENCLAW_GATEWAY_TOKEN) {
|
|
19
|
+
_cachedToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
20
|
+
} else {
|
|
21
|
+
try {
|
|
22
|
+
const tokenPath = process.env.OPENCLAW_GATEWAY_TOKEN_PATH
|
|
23
|
+
|| join(HOME_DIR, '.openclaw/credentials/.gateway-token');
|
|
24
|
+
_cachedToken = readFileSync(tokenPath, 'utf-8').trim();
|
|
25
|
+
} catch { _cachedToken = null; }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return _cachedToken;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function authHeaders(scopes = null) {
|
|
32
|
+
const token = getGatewayToken();
|
|
33
|
+
return token
|
|
34
|
+
? {
|
|
35
|
+
'Authorization': `Bearer ${token}`,
|
|
36
|
+
...(scopes ? { 'x-openclaw-scopes': scopes } : {}),
|
|
37
|
+
}
|
|
38
|
+
: {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// -- Chat Completions (independent dispatch) -----------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Run an agent turn via the OpenAI-compatible chat completions endpoint.
|
|
45
|
+
* Returns the full response including the assistant message.
|
|
46
|
+
*
|
|
47
|
+
* This is the primary dispatch mechanism for isolated jobs.
|
|
48
|
+
* Each call gets its own session (or use sessionKey for continuity).
|
|
49
|
+
*
|
|
50
|
+
* @param {object} opts
|
|
51
|
+
* @param {string} opts.message - The user message to send.
|
|
52
|
+
* @param {string} [opts.agentId='main'] - Agent ID.
|
|
53
|
+
* @param {string} [opts.sessionKey] - Session key for continuity.
|
|
54
|
+
* @param {string} [opts.model] - Model override.
|
|
55
|
+
* @param {string|null} [opts.authProfile] - Auth profile header value.
|
|
56
|
+
* @param {number} [opts.timeoutMs=300000] - Request timeout in milliseconds.
|
|
57
|
+
*/
|
|
58
|
+
export async function runAgentTurn(opts) {
|
|
59
|
+
const {
|
|
60
|
+
message,
|
|
61
|
+
agentId = 'main',
|
|
62
|
+
sessionKey,
|
|
63
|
+
model,
|
|
64
|
+
authProfile,
|
|
65
|
+
timeoutMs = 300000,
|
|
66
|
+
} = opts;
|
|
67
|
+
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const resp = await fetch(`${GATEWAY_URL}/v1/chat/completions`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
...authHeaders('operator.write'),
|
|
77
|
+
...(agentId ? { 'x-openclaw-agent-id': agentId } : {}),
|
|
78
|
+
...(sessionKey ? { 'x-openclaw-session-key': sessionKey } : {}),
|
|
79
|
+
...(authProfile ? { 'x-openclaw-auth-profile': authProfile } : {}),
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
model: model || `openclaw:${agentId}`,
|
|
83
|
+
messages: [{ role: 'user', content: message }],
|
|
84
|
+
stream: false,
|
|
85
|
+
}),
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (!resp.ok) {
|
|
90
|
+
const text = await resp.text();
|
|
91
|
+
throw new Error(`Chat completions failed (${resp.status}): ${text.slice(0, 500)}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const data = await resp.json();
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
content: data.choices?.[0]?.message?.content || '',
|
|
98
|
+
usage: data.usage,
|
|
99
|
+
sessionKey: resp.headers.get('x-openclaw-session-key') || sessionKey,
|
|
100
|
+
raw: data,
|
|
101
|
+
};
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err.name === 'AbortError' || err.name === 'TimeoutError') {
|
|
104
|
+
throw new Error(`Agent turn timed out after ${Math.round(timeoutMs / 1000)}s`, { cause: err });
|
|
105
|
+
}
|
|
106
|
+
throw err;
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Activity-aware wrapper around runAgentTurn.
|
|
114
|
+
*
|
|
115
|
+
* Instead of a hard wall-clock abort, this polls the session's `updatedAt`
|
|
116
|
+
* timestamp and only aborts when the session has been idle for 2x the idle
|
|
117
|
+
* threshold (default: 2 x 120s = 240s of no activity).
|
|
118
|
+
*
|
|
119
|
+
* The absolute ceiling (`absoluteTimeoutMs`, default 5 min) is always enforced
|
|
120
|
+
* as a safety net regardless of activity.
|
|
121
|
+
*
|
|
122
|
+
* @param {Object} opts
|
|
123
|
+
* @param {string} opts.message - Prompt to send
|
|
124
|
+
* @param {string} opts.agentId - Agent ID (default: 'main')
|
|
125
|
+
* @param {string} opts.sessionKey - Session key for matching activity
|
|
126
|
+
* @param {string} opts.model - Model override
|
|
127
|
+
* @param {number} opts.idleTimeoutMs - Per-check idle threshold; session aborts after 2x this value of continuous idle time
|
|
128
|
+
* @param {number} opts.pollIntervalMs - How often to poll session activity (default: 60000)
|
|
129
|
+
* @param {number} opts.absoluteTimeoutMs - Hard ceiling regardless of activity (default: 300000)
|
|
130
|
+
* @param {string} opts.authProfile - Auth profile override (null, 'inherit', or 'provider:label')
|
|
131
|
+
*/
|
|
132
|
+
export async function runAgentTurnWithActivityTimeout(opts) {
|
|
133
|
+
const {
|
|
134
|
+
message,
|
|
135
|
+
agentId = 'main',
|
|
136
|
+
sessionKey,
|
|
137
|
+
model,
|
|
138
|
+
authProfile,
|
|
139
|
+
idleTimeoutMs = 120000, // per-check idle threshold (from payload_timeout_seconds)
|
|
140
|
+
pollIntervalMs = 60000, // check activity every 60s
|
|
141
|
+
absoluteTimeoutMs = 300000, // hard ceiling (run_timeout_ms)
|
|
142
|
+
} = opts;
|
|
143
|
+
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
let abortReason = null;
|
|
146
|
+
|
|
147
|
+
// Hard absolute ceiling -- always fires regardless of activity
|
|
148
|
+
const absoluteTimer = setTimeout(() => {
|
|
149
|
+
abortReason = 'absolute_timeout';
|
|
150
|
+
controller.abort();
|
|
151
|
+
}, absoluteTimeoutMs);
|
|
152
|
+
|
|
153
|
+
// Track last known activity time (initialised to now -- grace period for startup)
|
|
154
|
+
let lastSeenActivity = Date.now();
|
|
155
|
+
|
|
156
|
+
const checkActivity = async () => {
|
|
157
|
+
try {
|
|
158
|
+
const result = await listSessions({ kinds: ['subagent', 'isolated'], activeMinutes: 60 });
|
|
159
|
+
// Normalise: gateway wraps result in several layers
|
|
160
|
+
const sessions =
|
|
161
|
+
result?.result?.details?.sessions ||
|
|
162
|
+
result?.result?.sessions ||
|
|
163
|
+
result?.sessions ||
|
|
164
|
+
result || [];
|
|
165
|
+
if (!Array.isArray(sessions)) return;
|
|
166
|
+
|
|
167
|
+
const matched = sessions.find(
|
|
168
|
+
s => (s.key || s.sessionKey) === sessionKey
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
if (matched && matched.updatedAt) {
|
|
172
|
+
const ts = typeof matched.updatedAt === 'number'
|
|
173
|
+
? matched.updatedAt
|
|
174
|
+
: new Date(matched.updatedAt).getTime();
|
|
175
|
+
if (ts > lastSeenActivity) {
|
|
176
|
+
lastSeenActivity = ts; // activity advanced -> reset
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check total continuous idle time
|
|
181
|
+
const idleDuration = Date.now() - lastSeenActivity;
|
|
182
|
+
if (idleDuration >= idleTimeoutMs * 2) {
|
|
183
|
+
// Two full idle windows elapsed -- session is truly idle
|
|
184
|
+
abortReason = 'idle_timeout';
|
|
185
|
+
controller.abort();
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
// Monitoring failure -- don't abort on transient errors
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Start polling after the first interval (gives session time to initialise)
|
|
193
|
+
const pollTimer = setInterval(checkActivity, pollIntervalMs);
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const resp = await fetch(`${GATEWAY_URL}/v1/chat/completions`, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: {
|
|
199
|
+
'Content-Type': 'application/json',
|
|
200
|
+
...authHeaders('operator.write'),
|
|
201
|
+
...(agentId ? { 'x-openclaw-agent-id': agentId } : {}),
|
|
202
|
+
...(sessionKey ? { 'x-openclaw-session-key': sessionKey } : {}),
|
|
203
|
+
...(authProfile ? { 'x-openclaw-auth-profile': authProfile } : {}),
|
|
204
|
+
},
|
|
205
|
+
body: JSON.stringify({
|
|
206
|
+
model: model || `openclaw:${agentId}`,
|
|
207
|
+
messages: [{ role: 'user', content: message }],
|
|
208
|
+
stream: false,
|
|
209
|
+
}),
|
|
210
|
+
signal: controller.signal,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (!resp.ok) {
|
|
214
|
+
const text = await resp.text();
|
|
215
|
+
throw new Error(`Chat completions failed (${resp.status}): ${text.slice(0, 500)}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const data = await resp.json();
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
221
|
+
content: data.choices?.[0]?.message?.content || '',
|
|
222
|
+
usage: data.usage,
|
|
223
|
+
sessionKey: resp.headers.get('x-openclaw-session-key') || sessionKey,
|
|
224
|
+
raw: data,
|
|
225
|
+
};
|
|
226
|
+
} catch (err) {
|
|
227
|
+
// Translate AbortError into descriptive messages
|
|
228
|
+
if (err.name === 'AbortError' || err.name === 'TimeoutError') {
|
|
229
|
+
if (abortReason === 'idle_timeout') {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Session idle for ${Math.round((idleTimeoutMs * 2) / 1000)}s -- aborted (activity-based timeout)`,
|
|
232
|
+
{ cause: err }
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (abortReason === 'absolute_timeout') {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Exceeded absolute timeout of ${Math.round(absoluteTimeoutMs / 1000)}s`,
|
|
238
|
+
{ cause: err }
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
throw err;
|
|
243
|
+
} finally {
|
|
244
|
+
clearTimeout(absoluteTimer);
|
|
245
|
+
clearInterval(pollTimer);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// -- System Events (main session) ----------------------------
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Send a system event to the main session.
|
|
253
|
+
*/
|
|
254
|
+
const VALID_MODES = new Set(['now', 'queue']);
|
|
255
|
+
|
|
256
|
+
export async function sendSystemEvent(text, mode = 'now') {
|
|
257
|
+
if (!VALID_MODES.has(mode)) {
|
|
258
|
+
throw new Error(`Invalid mode '${mode}': must be one of ${[...VALID_MODES].join(', ')}`);
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const result = execFileSync(
|
|
262
|
+
'openclaw', ['system', 'event', '--text', text, '--mode', mode, '--json'],
|
|
263
|
+
{ encoding: 'utf8', timeout: 30000 }
|
|
264
|
+
);
|
|
265
|
+
// Strip any non-JSON prefix (e.g. openclaw doctor output) before parsing
|
|
266
|
+
const jsonStart = result.indexOf('{');
|
|
267
|
+
const clean = jsonStart >= 0 ? result.slice(jsonStart) : result;
|
|
268
|
+
return JSON.parse(clean);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
throw new Error(`system event failed: ${err.message}`, { cause: err });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// -- Tools Invoke (for session listing, messages) ------------
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Invoke a tool via the Gateway's /tools/invoke endpoint.
|
|
278
|
+
*/
|
|
279
|
+
export async function invokeGatewayTool(tool, args, sessionKey = 'main') {
|
|
280
|
+
const resp = await fetch(`${GATEWAY_URL}/tools/invoke`, {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
headers: {
|
|
283
|
+
'Content-Type': 'application/json',
|
|
284
|
+
...authHeaders(),
|
|
285
|
+
},
|
|
286
|
+
body: JSON.stringify({ tool, args, sessionKey }),
|
|
287
|
+
signal: AbortSignal.timeout(30_000),
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (!resp.ok) {
|
|
291
|
+
const text = await resp.text();
|
|
292
|
+
throw new Error(`Gateway ${tool} failed (${resp.status}): ${text.slice(0, 500)}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return resp.json();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* List active sessions (for task tracker auto-correlation).
|
|
300
|
+
* opts.kinds: filter by session kind, e.g. ['subagent']
|
|
301
|
+
* opts.activeMinutes: only sessions active within N minutes
|
|
302
|
+
* opts.limit: max results
|
|
303
|
+
*/
|
|
304
|
+
export async function listSessions(opts = {}) {
|
|
305
|
+
return invokeGatewayTool('sessions_list', {
|
|
306
|
+
...(opts.activeMinutes ? { activeMinutes: opts.activeMinutes } : {}),
|
|
307
|
+
...(opts.limit ? { limit: opts.limit } : {}),
|
|
308
|
+
...(opts.kinds ? { kinds: opts.kinds } : {}),
|
|
309
|
+
messageLimit: 0, // don't fetch message history -- we only need session metadata
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Fetch ALL active sub-agent sessions across every requester.
|
|
315
|
+
* Uses the gateway token's admin view -- not scoped to a single session.
|
|
316
|
+
* Returns an array of session objects (keys like "agent:*:subagent:*").
|
|
317
|
+
*/
|
|
318
|
+
export async function getAllSubAgentSessions(activeMinutes = 10) {
|
|
319
|
+
try {
|
|
320
|
+
const result = await listSessions({ kinds: ['subagent'], activeMinutes, limit: 200 });
|
|
321
|
+
// Gateway returns { sessions: [...] } or similar -- normalise to array
|
|
322
|
+
const raw = result?.sessions || result?.result?.sessions || result || [];
|
|
323
|
+
return Array.isArray(raw) ? raw : [];
|
|
324
|
+
} catch {
|
|
325
|
+
return [];
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Resolve a delivery alias. Returns { channel, target } or null.
|
|
331
|
+
* Accepts '@name' or bare 'name'. Falls through to null if not found.
|
|
332
|
+
*/
|
|
333
|
+
export function resolveDeliveryAlias(rawTarget) {
|
|
334
|
+
if (!rawTarget) return null;
|
|
335
|
+
try {
|
|
336
|
+
const db = getDb();
|
|
337
|
+
const name = rawTarget.startsWith('@') ? rawTarget.slice(1) : rawTarget;
|
|
338
|
+
const row = db.prepare('SELECT channel, target FROM delivery_aliases WHERE alias = ?').get(name);
|
|
339
|
+
return row || null;
|
|
340
|
+
} catch {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function chunkPlainText(message, maxBytes) {
|
|
346
|
+
const text = String(message ?? '');
|
|
347
|
+
if (Buffer.byteLength(text, 'utf8') <= maxBytes) return [text];
|
|
348
|
+
|
|
349
|
+
const chunks = [];
|
|
350
|
+
let rest = text;
|
|
351
|
+
const hardLimit = Math.max(256, maxBytes - 12);
|
|
352
|
+
|
|
353
|
+
while (rest.length > 0) {
|
|
354
|
+
if (Buffer.byteLength(rest, 'utf8') <= hardLimit) {
|
|
355
|
+
chunks.push(rest);
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Walk forward tracking byte count to find the character index at the byte limit
|
|
360
|
+
let byteCount = 0;
|
|
361
|
+
let charLimit = 0;
|
|
362
|
+
for (let i = 0; i < rest.length; i++) {
|
|
363
|
+
const code = rest.codePointAt(i);
|
|
364
|
+
const charBytes = code > 0xFFFF ? 4 : code > 0x7FF ? 3 : code > 0x7F ? 2 : 1;
|
|
365
|
+
if (byteCount + charBytes > hardLimit) break;
|
|
366
|
+
byteCount += charBytes;
|
|
367
|
+
charLimit = i + 1;
|
|
368
|
+
// Skip surrogate pair trailing unit
|
|
369
|
+
if (code > 0xFFFF) i++;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let splitAt = rest.lastIndexOf('\n', charLimit);
|
|
373
|
+
if (splitAt < charLimit * 0.5) splitAt = rest.lastIndexOf(' ', charLimit);
|
|
374
|
+
if (splitAt < charLimit * 0.5) splitAt = charLimit;
|
|
375
|
+
|
|
376
|
+
const part = rest.slice(0, splitAt).trimEnd();
|
|
377
|
+
chunks.push(part);
|
|
378
|
+
rest = rest.slice(splitAt).trimStart();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return chunks.map((chunk, index) => `[${index + 1}/${chunks.length}] ${chunk}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function splitMessageForChannel(channel, message) {
|
|
385
|
+
if (channel === 'telegram') {
|
|
386
|
+
return chunkPlainText(message, TELEGRAM_MAX_MESSAGE_LENGTH);
|
|
387
|
+
}
|
|
388
|
+
return [String(message ?? '')];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Send a message to a Telegram/channel target via message tool.
|
|
393
|
+
* Automatically resolves delivery aliases (e.g. '@team_room', 'owner_dm').
|
|
394
|
+
*/
|
|
395
|
+
export async function deliverMessage(channel, target, message) {
|
|
396
|
+
let resolvedChannel = channel;
|
|
397
|
+
let resolvedTarget = target;
|
|
398
|
+
|
|
399
|
+
// Strip channel prefix from target if present (e.g., "telegram/123456789" -> "123456789")
|
|
400
|
+
// Some jobs store the channel in the delivery_to field as "channel/id".
|
|
401
|
+
if (resolvedTarget && resolvedChannel && resolvedTarget.startsWith(resolvedChannel + '/')) {
|
|
402
|
+
resolvedTarget = resolvedTarget.slice(resolvedChannel.length + 1);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Resolve alias: try '@name' strip and bare name lookup
|
|
406
|
+
if (resolvedTarget) {
|
|
407
|
+
const alias = resolveDeliveryAlias(resolvedTarget);
|
|
408
|
+
if (alias) {
|
|
409
|
+
resolvedChannel = alias.channel;
|
|
410
|
+
resolvedTarget = alias.target;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const parts = splitMessageForChannel(resolvedChannel, message);
|
|
415
|
+
let lastResponse = null;
|
|
416
|
+
for (const part of parts) {
|
|
417
|
+
lastResponse = await invokeGatewayTool('message', {
|
|
418
|
+
action: 'send',
|
|
419
|
+
message: part,
|
|
420
|
+
...(resolvedChannel ? { channel: resolvedChannel } : {}),
|
|
421
|
+
...(resolvedTarget ? { target: resolvedTarget } : {}),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
ok: true,
|
|
426
|
+
parts: parts.length,
|
|
427
|
+
lastResponse,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Check gateway health.
|
|
433
|
+
*/
|
|
434
|
+
export async function checkGatewayHealth() {
|
|
435
|
+
try {
|
|
436
|
+
const resp = await fetch(`${GATEWAY_URL}/health`, {
|
|
437
|
+
headers: authHeaders(),
|
|
438
|
+
signal: AbortSignal.timeout(5000),
|
|
439
|
+
});
|
|
440
|
+
return resp.ok;
|
|
441
|
+
} catch {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Wait for the gateway to become reachable, polling at intervals.
|
|
448
|
+
* Returns true if the gateway responded within the timeout, false otherwise.
|
|
449
|
+
* Any HTTP response (even non-200) counts as "up" -- we just need TCP connectivity.
|
|
450
|
+
*
|
|
451
|
+
* @param {number} timeoutMs - Maximum time to wait (default 30s)
|
|
452
|
+
* @param {number} intervalMs - Polling interval (default 2s)
|
|
453
|
+
* @returns {Promise<boolean>}
|
|
454
|
+
*/
|
|
455
|
+
export async function waitForGateway(timeoutMs = 30000, intervalMs = 2000) {
|
|
456
|
+
const deadline = Date.now() + timeoutMs;
|
|
457
|
+
while (Date.now() < deadline) {
|
|
458
|
+
try {
|
|
459
|
+
const resp = await fetch(`${GATEWAY_URL}/health`, {
|
|
460
|
+
headers: authHeaders(),
|
|
461
|
+
signal: AbortSignal.timeout(Math.min(intervalMs, 5000)),
|
|
462
|
+
});
|
|
463
|
+
try { await resp.body?.cancel(); } catch {}
|
|
464
|
+
return true; // Any response means gateway is up
|
|
465
|
+
} catch {
|
|
466
|
+
// Not up yet -- wait and retry
|
|
467
|
+
const remaining = deadline - Date.now();
|
|
468
|
+
if (remaining <= 0) break;
|
|
469
|
+
await new Promise(r => setTimeout(r, Math.min(intervalMs, remaining)));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return false;
|
|
473
|
+
}
|
package/idempotency.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Idempotency key generation and ledger operations
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { getDb } from './db.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate an idempotency key for a scheduled job execution.
|
|
7
|
+
* Deterministic: same job + same scheduled time = same key.
|
|
8
|
+
*/
|
|
9
|
+
export function generateIdempotencyKey(jobId, scheduledTime) {
|
|
10
|
+
if (!scheduledTime) throw new Error('scheduledTime is required for deterministic idempotency key');
|
|
11
|
+
const raw = `${jobId}:${scheduledTime}`;
|
|
12
|
+
return createHash('sha256').update(raw).digest('hex').slice(0, 32);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generate an idempotency key for a chain-triggered child job.
|
|
17
|
+
* Based on the parent run ID + child job ID.
|
|
18
|
+
*/
|
|
19
|
+
export function generateChainIdempotencyKey(parentRunId, childJobId) {
|
|
20
|
+
const raw = `chain:${parentRunId}:${childJobId}`;
|
|
21
|
+
return createHash('sha256').update(raw).digest('hex').slice(0, 32);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generate an idempotency key for a manual run-now trigger.
|
|
26
|
+
* Unique per call (timestamp-based).
|
|
27
|
+
*/
|
|
28
|
+
export function generateRunNowIdempotencyKey(jobId) {
|
|
29
|
+
const raw = `run_now:${jobId}:${Date.now()}`;
|
|
30
|
+
return createHash('sha256').update(raw).digest('hex').slice(0, 32);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if an idempotency key is currently claimed in the ledger.
|
|
35
|
+
* Returns the ledger entry if claimed, null otherwise.
|
|
36
|
+
*/
|
|
37
|
+
export function checkIdempotencyKey(key) {
|
|
38
|
+
return getDb().prepare("SELECT * FROM idempotency_ledger WHERE key = ? AND status = 'claimed'").get(key) || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get a ledger entry by key (any status).
|
|
43
|
+
*/
|
|
44
|
+
export function getIdempotencyEntry(key) {
|
|
45
|
+
return getDb().prepare('SELECT * FROM idempotency_ledger WHERE key = ?').get(key) || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Claim an idempotency key in the ledger.
|
|
50
|
+
* Returns true if successfully claimed, false if already claimed (race condition).
|
|
51
|
+
*/
|
|
52
|
+
export function claimIdempotencyKey(key, jobId, runId, expiresAt) {
|
|
53
|
+
if (!key) return true;
|
|
54
|
+
const db = getDb();
|
|
55
|
+
const tx = db.transaction(() => {
|
|
56
|
+
const existing = db.prepare('SELECT status FROM idempotency_ledger WHERE key = ?').get(key);
|
|
57
|
+
if (!existing) {
|
|
58
|
+
db.prepare(
|
|
59
|
+
"INSERT INTO idempotency_ledger (key, job_id, run_id, claimed_at, expires_at) VALUES (?, ?, ?, datetime('now'), ?)"
|
|
60
|
+
).run(key, jobId, runId, expiresAt);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (existing.status === 'released') {
|
|
65
|
+
db.prepare(`
|
|
66
|
+
UPDATE idempotency_ledger
|
|
67
|
+
SET status = 'claimed',
|
|
68
|
+
job_id = ?,
|
|
69
|
+
run_id = ?,
|
|
70
|
+
claimed_at = datetime('now'),
|
|
71
|
+
released_at = NULL,
|
|
72
|
+
result_hash = NULL,
|
|
73
|
+
expires_at = ?
|
|
74
|
+
WHERE key = ?
|
|
75
|
+
`).run(jobId, runId, expiresAt, key);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return false;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return tx();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Release an idempotency key (on failure) so retries/replays can reclaim it.
|
|
87
|
+
*/
|
|
88
|
+
export function releaseIdempotencyKey(key) {
|
|
89
|
+
if (!key) return;
|
|
90
|
+
getDb().prepare(
|
|
91
|
+
"UPDATE idempotency_ledger SET status = 'released', released_at = datetime('now') WHERE key = ? AND status = 'claimed'"
|
|
92
|
+
).run(key);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Store a result hash on the ledger entry (for debugging/verification).
|
|
97
|
+
*/
|
|
98
|
+
export function updateIdempotencyResultHash(key, content) {
|
|
99
|
+
if (!key || !content) return;
|
|
100
|
+
const resultHash = createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
101
|
+
getDb().prepare('UPDATE idempotency_ledger SET result_hash = ? WHERE key = ?').run(resultHash, key);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* List recent idempotency entries for a job.
|
|
106
|
+
*/
|
|
107
|
+
export function listIdempotencyForJob(jobId, limit = 20) {
|
|
108
|
+
return getDb().prepare(
|
|
109
|
+
'SELECT * FROM idempotency_ledger WHERE job_id = ? ORDER BY claimed_at DESC LIMIT ?'
|
|
110
|
+
).all(jobId, limit);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Force prune all expired entries. Returns deletion count.
|
|
115
|
+
*/
|
|
116
|
+
export function forcePruneIdempotency() {
|
|
117
|
+
const result = getDb().prepare("DELETE FROM idempotency_ledger WHERE expires_at < datetime('now')").run();
|
|
118
|
+
return result.changes;
|
|
119
|
+
}
|