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.
Files changed (70) hide show
  1. package/AGENTS.md +302 -0
  2. package/BEST-PRACTICES.md +506 -0
  3. package/CHANGELOG.md +82 -0
  4. package/CODE_OF_CONDUCT.md +22 -0
  5. package/CONTEXT.md +26 -0
  6. package/CONTRIBUTING.md +73 -0
  7. package/IMPLEMENTATION_SPEC.md +170 -0
  8. package/INSTALL-ADDITIONAL-HOST.md +333 -0
  9. package/INSTALL-LINUX.md +419 -0
  10. package/INSTALL-WINDOWS.md +305 -0
  11. package/INSTALL.md +364 -0
  12. package/JOB-QUICK-REF.md +222 -0
  13. package/LICENSE +21 -0
  14. package/QUICK-START.md +256 -0
  15. package/README.md +2170 -0
  16. package/SECURITY.md +34 -0
  17. package/UNINSTALL.md +129 -0
  18. package/UPGRADING.md +436 -0
  19. package/agents.js +67 -0
  20. package/approval.js +107 -0
  21. package/backup.js +390 -0
  22. package/bin/openclaw-scheduler.js +138 -0
  23. package/cli.js +1083 -0
  24. package/db.js +122 -0
  25. package/dispatch/529-recovery.mjs +204 -0
  26. package/dispatch/README.md +372 -0
  27. package/dispatch/config.example.json +24 -0
  28. package/dispatch/deliver-watcher.sh +57 -0
  29. package/dispatch/hooks.mjs +171 -0
  30. package/dispatch/index.mjs +1836 -0
  31. package/dispatch/watcher.mjs +1396 -0
  32. package/dispatch-queue.js +112 -0
  33. package/dispatcher-approvals.js +96 -0
  34. package/dispatcher-delivery.js +43 -0
  35. package/dispatcher-maintenance.js +242 -0
  36. package/dispatcher-shell.js +29 -0
  37. package/dispatcher-strategies.js +1280 -0
  38. package/dispatcher-utils.js +81 -0
  39. package/dispatcher.js +855 -0
  40. package/docs/adr-schedule-ownership.md +73 -0
  41. package/docs/gateway-contract.md +904 -0
  42. package/docs/plans/2026-03-09-fix-typescript-types.md +91 -0
  43. package/docs/plans/2026-03-09-test-coverage-gaps.md +83 -0
  44. package/docs/plans/2026-03-10-dispatcher-refactor.md +801 -0
  45. package/docs/trust-architecture.md +266 -0
  46. package/gateway.js +473 -0
  47. package/idempotency.js +119 -0
  48. package/index.d.ts +864 -0
  49. package/index.js +17 -0
  50. package/jobs.js +1224 -0
  51. package/messages.js +357 -0
  52. package/migrate-consolidate.js +694 -0
  53. package/migrate.js +125 -0
  54. package/package.json +130 -0
  55. package/paths.js +79 -0
  56. package/prompt-context.js +94 -0
  57. package/retrieval.js +176 -0
  58. package/runs.js +270 -0
  59. package/scheduler-schema.js +101 -0
  60. package/schema.sql +480 -0
  61. package/scripts/dispatch-cli-utils.mjs +65 -0
  62. package/scripts/inbox-consumer.mjs +288 -0
  63. package/scripts/stuck-detector.sh +18 -0
  64. package/scripts/stuck-run-detector.mjs +333 -0
  65. package/scripts/telegram-webhook-check.mjs +238 -0
  66. package/setup.mjs +724 -0
  67. package/shell-result.js +214 -0
  68. package/task-tracker.js +300 -0
  69. package/team-adapter.js +335 -0
  70. 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
+ }