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/jobs.js
ADDED
|
@@ -0,0 +1,1224 @@
|
|
|
1
|
+
// Job CRUD operations
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { Cron } from 'croner';
|
|
4
|
+
import { getDb } from './db.js';
|
|
5
|
+
import { enqueueDispatch } from './dispatch-queue.js';
|
|
6
|
+
|
|
7
|
+
const MAX_CHAIN_DEPTH = 10;
|
|
8
|
+
const VALID_TRIGGERS = new Set(['success', 'failure', 'complete']);
|
|
9
|
+
const VALID_OVERLAP_POLICIES = new Set(['skip', 'allow', 'queue']);
|
|
10
|
+
const VALID_DELIVERY_MODES = new Set(['announce', 'announce-always', 'none']);
|
|
11
|
+
const VALID_PAYLOAD_SCOPES = new Set(['own', 'global']);
|
|
12
|
+
const VALID_DELIVERY_GUARANTEES = new Set(['at-most-once', 'at-least-once']);
|
|
13
|
+
const VALID_JOB_CLASSES = new Set(['standard', 'pre_compaction_flush']);
|
|
14
|
+
const VALID_APPROVAL_AUTO = new Set(['approve', 'reject']);
|
|
15
|
+
const VALID_CONTEXT_RETRIEVAL = new Set(['none', 'recent', 'hybrid']);
|
|
16
|
+
const VALID_JOB_TYPES = new Set(['standard', 'watchdog']);
|
|
17
|
+
const VALID_EXECUTION_INTENTS = new Set(['execute', 'plan']);
|
|
18
|
+
const VALID_SCHEDULE_KINDS = new Set(['cron', 'at']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Valid payload_kind values for each session_target.
|
|
22
|
+
* - main: systemEvent only (inject into the main session)
|
|
23
|
+
* - shell: shellCommand only (run a shell command)
|
|
24
|
+
* - isolated: systemEvent or agentTurn (standalone agent session)
|
|
25
|
+
*/
|
|
26
|
+
const VALID_PAYLOADS_BY_TARGET = {
|
|
27
|
+
main: ['systemEvent'],
|
|
28
|
+
shell: ['shellCommand'],
|
|
29
|
+
isolated: ['systemEvent', 'agentTurn'],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function sqliteNow(offsetMs = 0) {
|
|
33
|
+
return new Date(Date.now() + offsetMs).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const PATCHABLE_COLUMNS = new Set([
|
|
37
|
+
'enabled', 'name', 'schedule_cron', 'schedule_tz', 'schedule_at', 'schedule_kind',
|
|
38
|
+
'next_run_at', 'last_run_at', 'last_status', 'payload_message', 'payload_model',
|
|
39
|
+
'payload_thinking', 'payload_timeout_seconds', 'session_target', 'run_timeout_ms',
|
|
40
|
+
'max_retries', 'consecutive_errors',
|
|
41
|
+
'delivery_mode', 'delivery_channel', 'delivery_to', 'delivery_opt_out_reason',
|
|
42
|
+
'delete_after_run', 'ttl_hours', 'auth_profile', 'origin',
|
|
43
|
+
'output_excerpt_limit_bytes', 'output_summary_limit_bytes',
|
|
44
|
+
'watchdog_check_cmd', 'watchdog_timeout_min', 'watchdog_started_at',
|
|
45
|
+
'watchdog_target_label', 'watchdog_alert_channel', 'watchdog_alert_target',
|
|
46
|
+
'watchdog_self_destruct',
|
|
47
|
+
// v0.2 fields
|
|
48
|
+
'identity_principal', 'identity_run_as', 'identity_attestation', 'identity_ref',
|
|
49
|
+
'identity_subject_kind', 'identity_subject_principal', 'identity_trust_level',
|
|
50
|
+
'identity_delegation_mode', 'identity',
|
|
51
|
+
'authorization_proof_ref', 'authorization_proof',
|
|
52
|
+
'authorization_ref', 'authorization',
|
|
53
|
+
'evidence_ref', 'evidence',
|
|
54
|
+
'contract_required_trust_level', 'contract_trust_enforcement',
|
|
55
|
+
'contract_sandbox', 'contract_allowed_paths', 'contract_network',
|
|
56
|
+
'contract_max_cost_usd', 'contract_audit',
|
|
57
|
+
'child_credential_policy',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
function applyJobPatch(jobId, patch) {
|
|
61
|
+
const entries = Object.entries(patch).filter(([, value]) => value !== undefined);
|
|
62
|
+
if (entries.length === 0) return;
|
|
63
|
+
for (const [key] of entries) {
|
|
64
|
+
if (!PATCHABLE_COLUMNS.has(key)) throw new Error(`applyJobPatch: disallowed column "${key}"`);
|
|
65
|
+
}
|
|
66
|
+
const sets = entries.map(([key]) => `${key} = ?`).join(', ');
|
|
67
|
+
getDb().prepare(`UPDATE jobs SET ${sets} WHERE id = ?`).run(...entries.map(([, value]) => value), jobId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeNullableString(value) {
|
|
71
|
+
if (value == null) return null;
|
|
72
|
+
if (typeof value !== 'string') return value;
|
|
73
|
+
return value.trim() === '' ? null : value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeSqliteUtcDateTime(name, value) {
|
|
77
|
+
if (value == null) return null;
|
|
78
|
+
if (typeof value !== 'string') {
|
|
79
|
+
throw new Error(`${name} must be a string`);
|
|
80
|
+
}
|
|
81
|
+
const trimmed = value.trim();
|
|
82
|
+
if (!trimmed) {
|
|
83
|
+
throw new Error(`${name} cannot be empty`);
|
|
84
|
+
}
|
|
85
|
+
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(trimmed)) {
|
|
86
|
+
return trimmed;
|
|
87
|
+
}
|
|
88
|
+
const parsed = new Date(trimmed);
|
|
89
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
90
|
+
throw new Error(`${name} must be a valid datetime`);
|
|
91
|
+
}
|
|
92
|
+
return parsed.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function assertParentJobExists(parentId) {
|
|
96
|
+
const parent = getDb().prepare('SELECT id FROM jobs WHERE id = ?').get(parentId);
|
|
97
|
+
if (!parent) {
|
|
98
|
+
throw new Error(`parent job does not exist: ${parentId}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function assertSafeString(name, value, opts = {}) {
|
|
103
|
+
if (value == null) return;
|
|
104
|
+
if (typeof value !== 'string') {
|
|
105
|
+
throw new Error(`${name} must be a string`);
|
|
106
|
+
}
|
|
107
|
+
if (!opts.allowEmpty && value.trim().length === 0) {
|
|
108
|
+
throw new Error(`${name} cannot be empty`);
|
|
109
|
+
}
|
|
110
|
+
const hasControlChars = [...value].some((char) => {
|
|
111
|
+
const code = char.charCodeAt(0);
|
|
112
|
+
return (code >= 0 && code <= 8) || (code >= 11 && code <= 12) || (code >= 14 && code <= 31) || code === 127;
|
|
113
|
+
});
|
|
114
|
+
if (hasControlChars) {
|
|
115
|
+
throw new Error(`${name} contains unsupported control characters`);
|
|
116
|
+
}
|
|
117
|
+
if (opts.maxLength && value.length > opts.maxLength) {
|
|
118
|
+
throw new Error(`${name} exceeds max length of ${opts.maxLength}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function assertInt(name, value, min = 0) {
|
|
123
|
+
if (value == null) return;
|
|
124
|
+
if (!Number.isInteger(value) || value < min) {
|
|
125
|
+
throw new Error(`${name} must be an integer >= ${min}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function assertEnum(name, value, allowed, { nullable = false } = {}) {
|
|
130
|
+
if (value == null && nullable) return;
|
|
131
|
+
if (!allowed.has(value)) {
|
|
132
|
+
throw new Error(`${name} must be one of: ${[...allowed].join(', ')}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function assertJsonBlob(name, value, maxBytes = 32768) {
|
|
137
|
+
if (value == null) return;
|
|
138
|
+
if (typeof value !== 'string') throw new Error(`${name} must be a JSON string`);
|
|
139
|
+
if (Buffer.byteLength(value, 'utf8') > maxBytes) throw new Error(`${name} exceeds ${maxBytes} byte limit`);
|
|
140
|
+
try {
|
|
141
|
+
JSON.parse(value);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
throw new Error(`${name} is not valid JSON: ${e.message}`, { cause: e });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validateTriggerConditionSyntax(condition) {
|
|
148
|
+
if (condition == null) return;
|
|
149
|
+
assertSafeString('trigger_condition', condition, { maxLength: 1024 });
|
|
150
|
+
if (condition.startsWith('contains:')) {
|
|
151
|
+
if (!condition.slice('contains:'.length)) {
|
|
152
|
+
throw new Error('trigger_condition contains: pattern cannot be empty');
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (condition.startsWith('regex:')) {
|
|
157
|
+
const pattern = condition.slice('regex:'.length);
|
|
158
|
+
if (!pattern) {
|
|
159
|
+
throw new Error('trigger_condition regex pattern cannot be empty');
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
new RegExp(pattern);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
throw new Error(`Invalid trigger_condition regex: ${err.message}`, { cause: err });
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Unknown prefix -- reject early rather than silently falling back to substring match
|
|
169
|
+
throw new Error('trigger_condition must start with "contains:" or "regex:"');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function validateJobSpec(opts, currentJob = null, mode = 'create') {
|
|
173
|
+
if (!opts || typeof opts !== 'object' || Array.isArray(opts)) {
|
|
174
|
+
throw new Error('Job spec must be an object');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const normalized = { ...opts };
|
|
178
|
+
for (const key of [
|
|
179
|
+
'enabled',
|
|
180
|
+
'execution_read_only',
|
|
181
|
+
'delete_after_run',
|
|
182
|
+
'approval_required',
|
|
183
|
+
'watchdog_self_destruct'
|
|
184
|
+
]) {
|
|
185
|
+
if (typeof normalized[key] === 'boolean') {
|
|
186
|
+
normalized[key] = normalized[key] ? 1 : 0;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const key of [
|
|
190
|
+
'delivery_channel',
|
|
191
|
+
'delivery_to',
|
|
192
|
+
'resource_pool',
|
|
193
|
+
'preferred_session_key',
|
|
194
|
+
'payload_model',
|
|
195
|
+
'payload_thinking',
|
|
196
|
+
'trigger_condition',
|
|
197
|
+
'auth_profile',
|
|
198
|
+
'delivery_opt_out_reason',
|
|
199
|
+
'origin',
|
|
200
|
+
// v0.2 nullable strings
|
|
201
|
+
'identity_principal',
|
|
202
|
+
'identity_run_as',
|
|
203
|
+
'identity_attestation',
|
|
204
|
+
'identity_ref',
|
|
205
|
+
'identity_subject_kind',
|
|
206
|
+
'identity_subject_principal',
|
|
207
|
+
'identity_trust_level',
|
|
208
|
+
'identity_delegation_mode',
|
|
209
|
+
'identity',
|
|
210
|
+
'authorization_proof_ref',
|
|
211
|
+
'authorization_proof',
|
|
212
|
+
'authorization_ref',
|
|
213
|
+
'authorization',
|
|
214
|
+
'evidence_ref',
|
|
215
|
+
'evidence',
|
|
216
|
+
'contract_required_trust_level',
|
|
217
|
+
'contract_trust_enforcement',
|
|
218
|
+
'contract_sandbox',
|
|
219
|
+
'contract_allowed_paths',
|
|
220
|
+
'contract_network',
|
|
221
|
+
'contract_audit',
|
|
222
|
+
'child_credential_policy',
|
|
223
|
+
]) {
|
|
224
|
+
if (key in normalized) normalized[key] = normalizeNullableString(normalized[key]);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const merged = { ...(currentJob || {}), ...normalized };
|
|
228
|
+
const isChild = !!merged.parent_id;
|
|
229
|
+
if (mode === 'create' || 'schedule_kind' in normalized) {
|
|
230
|
+
assertEnum('schedule_kind', merged.schedule_kind || 'cron', VALID_SCHEDULE_KINDS);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (mode === 'create' || 'name' in normalized) {
|
|
234
|
+
assertSafeString('name', merged.name, { maxLength: 200 });
|
|
235
|
+
}
|
|
236
|
+
if (mode === 'create' || 'payload_message' in normalized) {
|
|
237
|
+
assertSafeString('payload_message', merged.payload_message, { maxLength: 100000 });
|
|
238
|
+
}
|
|
239
|
+
if (mode === 'create' || 'agent_id' in normalized) {
|
|
240
|
+
assertSafeString('agent_id', merged.agent_id || 'main', { maxLength: 128 });
|
|
241
|
+
}
|
|
242
|
+
if (mode === 'create' || 'session_target' in normalized || 'payload_kind' in normalized) {
|
|
243
|
+
const finalTarget = merged.session_target || 'isolated';
|
|
244
|
+
const finalKind = merged.payload_kind || (finalTarget === 'main' ? 'systemEvent' : finalTarget === 'shell' ? 'shellCommand' : 'agentTurn');
|
|
245
|
+
validateJobPayload(finalTarget, finalKind);
|
|
246
|
+
}
|
|
247
|
+
const isAtJob = merged.schedule_kind === 'at';
|
|
248
|
+
if (isChild && isAtJob) {
|
|
249
|
+
throw new Error('child jobs cannot use schedule_kind "at" -- use trigger_delay_s for delayed chain execution');
|
|
250
|
+
}
|
|
251
|
+
const triggerFieldsTouched = mode === 'create'
|
|
252
|
+
|| 'parent_id' in normalized
|
|
253
|
+
|| 'trigger_on' in normalized
|
|
254
|
+
|| 'trigger_delay_s' in normalized
|
|
255
|
+
|| 'trigger_condition' in normalized;
|
|
256
|
+
if (isChild && !merged.trigger_on) {
|
|
257
|
+
throw new Error('child jobs require trigger_on ("success", "failure", or "complete")');
|
|
258
|
+
}
|
|
259
|
+
if (!isChild && triggerFieldsTouched) {
|
|
260
|
+
if (merged.trigger_on != null) {
|
|
261
|
+
throw new Error('trigger_on is a child-only field');
|
|
262
|
+
}
|
|
263
|
+
if ((merged.trigger_delay_s || 0) > 0) {
|
|
264
|
+
throw new Error('trigger_delay_s is a child-only field');
|
|
265
|
+
}
|
|
266
|
+
if (merged.trigger_condition != null) {
|
|
267
|
+
throw new Error('trigger_condition is a child-only field');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (!isChild && !isAtJob && !merged.schedule_cron) {
|
|
271
|
+
throw new Error('schedule_cron is required for root cron jobs');
|
|
272
|
+
}
|
|
273
|
+
if (isAtJob && !merged.schedule_at) {
|
|
274
|
+
throw new Error('schedule_at is required for at-jobs (use --at or --in)');
|
|
275
|
+
}
|
|
276
|
+
if (merged.schedule_cron) {
|
|
277
|
+
assertSafeString('schedule_cron', merged.schedule_cron, { maxLength: 128 });
|
|
278
|
+
const sentinelUsedAsRealCron = merged.schedule_cron === AT_JOB_CRON_SENTINEL && !isAtJob && !isChild;
|
|
279
|
+
if (sentinelUsedAsRealCron && (
|
|
280
|
+
mode === 'create'
|
|
281
|
+
|| 'schedule_cron' in normalized
|
|
282
|
+
|| 'schedule_kind' in normalized
|
|
283
|
+
|| 'parent_id' in normalized
|
|
284
|
+
)) {
|
|
285
|
+
throw new Error('schedule_cron cannot use the reserved at-job sentinel for root cron jobs');
|
|
286
|
+
}
|
|
287
|
+
// Skip cron validation for the sentinel (never-fires placeholder for at-jobs/children)
|
|
288
|
+
if (merged.schedule_cron !== AT_JOB_CRON_SENTINEL) {
|
|
289
|
+
nextRunFromCron(merged.schedule_cron, merged.schedule_tz || 'UTC');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (merged.schedule_at != null) {
|
|
293
|
+
assertSafeString('schedule_at', merged.schedule_at, { maxLength: 64 });
|
|
294
|
+
normalized.schedule_at = normalizeSqliteUtcDateTime('schedule_at', merged.schedule_at);
|
|
295
|
+
merged.schedule_at = normalized.schedule_at;
|
|
296
|
+
}
|
|
297
|
+
if (mode === 'create' || 'schedule_tz' in normalized) {
|
|
298
|
+
assertSafeString('schedule_tz', merged.schedule_tz || 'UTC', { maxLength: 128 });
|
|
299
|
+
if (merged.schedule_tz && merged.schedule_tz !== 'UTC') {
|
|
300
|
+
try { Intl.DateTimeFormat(undefined, { timeZone: merged.schedule_tz }); }
|
|
301
|
+
catch { throw new Error(`schedule_tz "${merged.schedule_tz}" is not a valid IANA timezone`); }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
assertEnum('overlap_policy', merged.overlap_policy || 'skip', VALID_OVERLAP_POLICIES);
|
|
306
|
+
assertEnum('delivery_mode', merged.delivery_mode || 'announce', VALID_DELIVERY_MODES);
|
|
307
|
+
|
|
308
|
+
// Enforce: delivery_to is required when delivery_mode is explicitly set to
|
|
309
|
+
// 'announce' or 'announce-always'. Validates on create (when delivery_mode is
|
|
310
|
+
// explicitly provided) and on update (when delivery_mode is being changed or
|
|
311
|
+
// the merged record would end up in announce mode without a delivery_to).
|
|
312
|
+
{
|
|
313
|
+
const modeExplicitlySet = 'delivery_mode' in normalized;
|
|
314
|
+
const deliveryToExplicitlySet = 'delivery_to' in normalized;
|
|
315
|
+
const effectiveMode = merged.delivery_mode || 'announce';
|
|
316
|
+
const isAnnounceMode = ['announce', 'announce-always'].includes(effectiveMode);
|
|
317
|
+
|
|
318
|
+
if (isAnnounceMode && (mode === 'create' || modeExplicitlySet || deliveryToExplicitlySet)) {
|
|
319
|
+
// Re-evaluate: if mode is being set to announce OR delivery_to is being
|
|
320
|
+
// cleared on an announce-mode job, check the merged delivery_to is present.
|
|
321
|
+
if (!merged.delivery_to || (typeof merged.delivery_to === 'string' && merged.delivery_to.trim() === '')) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
'delivery_to is required when delivery_mode is "announce" or "announce-always"'
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
{
|
|
330
|
+
const isAgentTurn =
|
|
331
|
+
!isChild &&
|
|
332
|
+
(merged.payload_kind === 'agentTurn' ||
|
|
333
|
+
((!merged.payload_kind) && (merged.session_target || 'isolated') === 'isolated'));
|
|
334
|
+
const effectiveDeliveryMode = merged.delivery_mode || 'announce';
|
|
335
|
+
if (isAgentTurn && effectiveDeliveryMode === 'none' && !merged.delivery_opt_out_reason) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
'agentTurn jobs with delivery_mode "none" require delivery_opt_out_reason. ' +
|
|
338
|
+
'Set delivery_to + delivery_channel, or pass delivery_opt_out_reason to explicitly skip delivery.'
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
assertEnum('payload_scope', merged.payload_scope || 'own', VALID_PAYLOAD_SCOPES);
|
|
344
|
+
assertEnum('delivery_guarantee', merged.delivery_guarantee || 'at-most-once', VALID_DELIVERY_GUARANTEES);
|
|
345
|
+
assertEnum('job_class', merged.job_class || 'standard', VALID_JOB_CLASSES);
|
|
346
|
+
assertEnum('approval_auto', merged.approval_auto || 'reject', VALID_APPROVAL_AUTO);
|
|
347
|
+
assertEnum('context_retrieval', merged.context_retrieval || 'none', VALID_CONTEXT_RETRIEVAL);
|
|
348
|
+
assertEnum('job_type', merged.job_type || 'standard', VALID_JOB_TYPES);
|
|
349
|
+
assertEnum('execution_intent', merged.execution_intent || 'execute', VALID_EXECUTION_INTENTS);
|
|
350
|
+
|
|
351
|
+
if (merged.trigger_on != null) {
|
|
352
|
+
assertEnum('trigger_on', merged.trigger_on, VALID_TRIGGERS);
|
|
353
|
+
}
|
|
354
|
+
validateTriggerConditionSyntax(merged.trigger_condition);
|
|
355
|
+
|
|
356
|
+
if (mode === 'create' || 'delivery_channel' in normalized) {
|
|
357
|
+
assertSafeString('delivery_channel', merged.delivery_channel, { allowEmpty: false, maxLength: 64 });
|
|
358
|
+
}
|
|
359
|
+
if (mode === 'create' || 'delivery_to' in normalized) {
|
|
360
|
+
assertSafeString('delivery_to', merged.delivery_to, { allowEmpty: false, maxLength: 256 });
|
|
361
|
+
}
|
|
362
|
+
if (mode === 'create' || 'resource_pool' in normalized) {
|
|
363
|
+
assertSafeString('resource_pool', merged.resource_pool, { allowEmpty: false, maxLength: 128 });
|
|
364
|
+
}
|
|
365
|
+
if (mode === 'create' || 'preferred_session_key' in normalized) {
|
|
366
|
+
assertSafeString('preferred_session_key', merged.preferred_session_key, { allowEmpty: false, maxLength: 512 });
|
|
367
|
+
}
|
|
368
|
+
if (mode === 'create' || 'payload_model' in normalized) {
|
|
369
|
+
assertSafeString('payload_model', merged.payload_model, { allowEmpty: false, maxLength: 256 });
|
|
370
|
+
}
|
|
371
|
+
if (mode === 'create' || 'payload_thinking' in normalized) {
|
|
372
|
+
assertSafeString('payload_thinking', merged.payload_thinking, { allowEmpty: false, maxLength: 64 });
|
|
373
|
+
}
|
|
374
|
+
if (mode === 'create' || 'auth_profile' in normalized) {
|
|
375
|
+
if (merged.auth_profile != null) {
|
|
376
|
+
if (typeof merged.auth_profile !== 'string') {
|
|
377
|
+
throw new Error('auth_profile must be a string or null');
|
|
378
|
+
}
|
|
379
|
+
assertSafeString('auth_profile', merged.auth_profile, { allowEmpty: false, maxLength: 256 });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Origin tracking (v20): required on creation for root (non-child) jobs.
|
|
384
|
+
// Format convention: "<channel>:<id>" e.g. "telegram:<your-user-id>", "telegram:<your-group-id>", or "system" for automated jobs.
|
|
385
|
+
// Child jobs inherit origin context from parent and are exempt from this requirement.
|
|
386
|
+
const promotedToRoot = mode === 'update'
|
|
387
|
+
&& !!currentJob?.parent_id
|
|
388
|
+
&& 'parent_id' in normalized
|
|
389
|
+
&& !isChild;
|
|
390
|
+
if ((mode === 'create' || promotedToRoot) && !isChild && !merged.origin) {
|
|
391
|
+
throw new Error(
|
|
392
|
+
'origin is required on job creation -- pass the chat_id or channel identifier where the job was requested from ' +
|
|
393
|
+
'(e.g. "telegram:<your-user-id>", "telegram:<your-group-id>", "system" for automated/cron jobs).'
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (mode === 'create' || 'origin' in normalized) {
|
|
397
|
+
assertSafeString('origin', merged.origin, { allowEmpty: false, maxLength: 256 });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// --- v0.2 Identity ---
|
|
401
|
+
assertSafeString('identity_principal', merged.identity_principal, { maxLength: 512 });
|
|
402
|
+
assertSafeString('identity_run_as', merged.identity_run_as, { maxLength: 256 });
|
|
403
|
+
assertSafeString('identity_attestation', merged.identity_attestation, { maxLength: 256 });
|
|
404
|
+
assertSafeString('identity_ref', merged.identity_ref, { maxLength: 256 });
|
|
405
|
+
assertEnum('identity_subject_kind', merged.identity_subject_kind,
|
|
406
|
+
new Set(['agent', 'service', 'workload', 'user', 'composite', 'delegated-agent', 'unknown']),
|
|
407
|
+
{ nullable: true });
|
|
408
|
+
assertSafeString('identity_subject_principal', merged.identity_subject_principal, { maxLength: 512 });
|
|
409
|
+
assertEnum('identity_trust_level', merged.identity_trust_level,
|
|
410
|
+
new Set(['untrusted', 'restricted', 'supervised', 'autonomous']),
|
|
411
|
+
{ nullable: true });
|
|
412
|
+
assertEnum('identity_delegation_mode', merged.identity_delegation_mode,
|
|
413
|
+
new Set(['none', 'on-behalf-of', 'impersonation']),
|
|
414
|
+
{ nullable: true });
|
|
415
|
+
assertJsonBlob('identity', merged.identity);
|
|
416
|
+
if (merged.identity) {
|
|
417
|
+
const identityBlob = JSON.parse(merged.identity);
|
|
418
|
+
const presentation = identityBlob && typeof identityBlob === 'object' && !Array.isArray(identityBlob)
|
|
419
|
+
? (identityBlob.presentation || identityBlob.credential_handoff || null)
|
|
420
|
+
: null;
|
|
421
|
+
if (presentation && typeof presentation === 'object' && !Array.isArray(presentation)) {
|
|
422
|
+
const finalTarget = merged.session_target || 'isolated';
|
|
423
|
+
if (finalTarget !== 'shell') {
|
|
424
|
+
throw new Error('identity presentation / credential_handoff is only supported for session_target "shell"');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// --- v0.2 Authorization Proof ---
|
|
430
|
+
assertSafeString('authorization_proof_ref', merged.authorization_proof_ref, { maxLength: 256 });
|
|
431
|
+
assertJsonBlob('authorization_proof', merged.authorization_proof);
|
|
432
|
+
|
|
433
|
+
// --- v0.2 Authorization ---
|
|
434
|
+
assertSafeString('authorization_ref', merged.authorization_ref, { maxLength: 256 });
|
|
435
|
+
assertJsonBlob('authorization', merged.authorization);
|
|
436
|
+
|
|
437
|
+
// --- v0.2 Evidence ---
|
|
438
|
+
assertSafeString('evidence_ref', merged.evidence_ref, { maxLength: 256 });
|
|
439
|
+
assertJsonBlob('evidence', merged.evidence);
|
|
440
|
+
|
|
441
|
+
// --- v0.2 Contract ---
|
|
442
|
+
assertEnum('contract_required_trust_level', merged.contract_required_trust_level,
|
|
443
|
+
new Set(['untrusted', 'restricted', 'supervised', 'autonomous']),
|
|
444
|
+
{ nullable: true });
|
|
445
|
+
assertEnum('contract_trust_enforcement', merged.contract_trust_enforcement,
|
|
446
|
+
new Set(['none', 'warn', 'block', 'advisory', 'strict']),
|
|
447
|
+
{ nullable: true });
|
|
448
|
+
assertSafeString('contract_sandbox', merged.contract_sandbox, { maxLength: 128 });
|
|
449
|
+
assertJsonBlob('contract_allowed_paths', merged.contract_allowed_paths);
|
|
450
|
+
assertSafeString('contract_network', merged.contract_network, { maxLength: 128 });
|
|
451
|
+
if (merged.contract_max_cost_usd != null) {
|
|
452
|
+
if (typeof merged.contract_max_cost_usd !== 'number' || !isFinite(merged.contract_max_cost_usd) || merged.contract_max_cost_usd < 0) {
|
|
453
|
+
throw new Error('contract_max_cost_usd must be a non-negative finite number');
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
assertSafeString('contract_audit', merged.contract_audit, { maxLength: 128 });
|
|
457
|
+
|
|
458
|
+
// --- v0.2 Child Credential Policy ---
|
|
459
|
+
assertEnum('child_credential_policy', merged.child_credential_policy,
|
|
460
|
+
new Set(['none', 'inherit', 'downscope', 'independent']),
|
|
461
|
+
{ nullable: true });
|
|
462
|
+
|
|
463
|
+
// Watchdog-specific validations
|
|
464
|
+
if (merged.job_type === 'watchdog') {
|
|
465
|
+
if (!merged.watchdog_check_cmd) {
|
|
466
|
+
throw new Error('watchdog_check_cmd is required for watchdog jobs');
|
|
467
|
+
}
|
|
468
|
+
assertSafeString('watchdog_target_label', merged.watchdog_target_label, { allowEmpty: false, maxLength: 256 });
|
|
469
|
+
assertSafeString('watchdog_check_cmd', merged.watchdog_check_cmd, { allowEmpty: false, maxLength: 4096 });
|
|
470
|
+
assertSafeString('watchdog_alert_channel', merged.watchdog_alert_channel, { allowEmpty: false, maxLength: 64 });
|
|
471
|
+
assertSafeString('watchdog_alert_target', merged.watchdog_alert_target, { allowEmpty: false, maxLength: 256 });
|
|
472
|
+
if (merged.watchdog_timeout_min != null) {
|
|
473
|
+
assertInt('watchdog_timeout_min', merged.watchdog_timeout_min, 1);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
for (const [name, min] of [
|
|
478
|
+
['payload_timeout_seconds', 1],
|
|
479
|
+
['run_timeout_ms', 1],
|
|
480
|
+
['trigger_delay_s', 0],
|
|
481
|
+
['max_retries', 0],
|
|
482
|
+
['approval_timeout_s', 1],
|
|
483
|
+
['context_retrieval_limit', 1],
|
|
484
|
+
['consecutive_errors', 0],
|
|
485
|
+
['max_queued_dispatches', 1],
|
|
486
|
+
['max_pending_approvals', 1],
|
|
487
|
+
['max_trigger_fanout', 1],
|
|
488
|
+
['output_store_limit_bytes', 128],
|
|
489
|
+
['output_excerpt_limit_bytes', 64],
|
|
490
|
+
['output_summary_limit_bytes', 64],
|
|
491
|
+
['output_offload_threshold_bytes', 128],
|
|
492
|
+
['ttl_hours', 1],
|
|
493
|
+
]) {
|
|
494
|
+
if (name in normalized || (mode === 'create' && merged[name] != null)) {
|
|
495
|
+
assertInt(name, merged[name], min);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (merged.output_excerpt_limit_bytes != null && merged.output_store_limit_bytes != null
|
|
500
|
+
&& merged.output_excerpt_limit_bytes > merged.output_store_limit_bytes) {
|
|
501
|
+
throw new Error('output_excerpt_limit_bytes cannot exceed output_store_limit_bytes');
|
|
502
|
+
}
|
|
503
|
+
if (merged.output_summary_limit_bytes != null && merged.output_excerpt_limit_bytes != null
|
|
504
|
+
&& merged.output_summary_limit_bytes < merged.output_excerpt_limit_bytes) {
|
|
505
|
+
throw new Error('output_summary_limit_bytes cannot be smaller than output_excerpt_limit_bytes');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (mode === 'create' && (merged.run_timeout_ms == null || merged.run_timeout_ms === 0)) {
|
|
509
|
+
throw new Error(
|
|
510
|
+
'run_timeout_ms is required and must be > 0 -- this prevents jobs from running indefinitely.'
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return normalized;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Validate that a session_target + payload_kind combination is allowed.
|
|
519
|
+
* Throws a descriptive Error on invalid combos.
|
|
520
|
+
* @param {string} sessionTarget
|
|
521
|
+
* @param {string} payloadKind
|
|
522
|
+
*/
|
|
523
|
+
export function validateJobPayload(sessionTarget, payloadKind) {
|
|
524
|
+
const allowed = VALID_PAYLOADS_BY_TARGET[sessionTarget];
|
|
525
|
+
if (!allowed) {
|
|
526
|
+
throw new Error(
|
|
527
|
+
`Unknown session_target "${sessionTarget}". Valid targets: ${Object.keys(VALID_PAYLOADS_BY_TARGET).join(', ')}`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (!allowed.includes(payloadKind)) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
`Invalid payload_kind "${payloadKind}" for session_target "${sessionTarget}". ` +
|
|
533
|
+
`Allowed: ${allowed.join(', ')}`
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Parse an --in duration string (e.g. '15m', '2h', '30s', '1d') and return
|
|
540
|
+
* an ISO datetime string (SQLite UTC format: 'YYYY-MM-DD HH:MM:SS') for now + duration.
|
|
541
|
+
* Supported units: s (seconds), m (minutes), h (hours), d (days).
|
|
542
|
+
* @param {string} duration - e.g. '15m', '2h', '30s', '1d'
|
|
543
|
+
* @returns {string} schedule_at in SQLite UTC format
|
|
544
|
+
*/
|
|
545
|
+
export function parseInDuration(duration) {
|
|
546
|
+
const match = /^(\d+(?:\.\d+)?)(s|m|h|d)$/i.exec(String(duration).trim());
|
|
547
|
+
if (!match) {
|
|
548
|
+
throw new Error(`Invalid --in duration: "${duration}". Use e.g. 15m, 2h, 30s, 1d`);
|
|
549
|
+
}
|
|
550
|
+
const [, amount, unit] = match;
|
|
551
|
+
const multipliers = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
|
|
552
|
+
const ms = parseFloat(amount) * multipliers[unit.toLowerCase()];
|
|
553
|
+
return new Date(Date.now() + ms).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Sentinel cron expression used for at-jobs on existing DBs where
|
|
558
|
+
* schedule_cron is NOT NULL. Feb 31 never exists, so this never fires.
|
|
559
|
+
*/
|
|
560
|
+
export const AT_JOB_CRON_SENTINEL = '0 0 31 2 *';
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Calculate next run time from a cron expression.
|
|
564
|
+
*/
|
|
565
|
+
export function nextRunFromCron(cronExpr, tz) {
|
|
566
|
+
const cron = new Cron(cronExpr, { timezone: tz });
|
|
567
|
+
const next = cron.nextRun();
|
|
568
|
+
if (!next) return null;
|
|
569
|
+
// Use SQLite-compatible format: 'YYYY-MM-DD HH:MM:SS' (UTC)
|
|
570
|
+
return next.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function deriveNextRunAt(job, { preserveRunNow = false } = {}) {
|
|
574
|
+
if (!job || job.parent_id) return null;
|
|
575
|
+
if (preserveRunNow && job.run_now) {
|
|
576
|
+
return sqliteNow(-1000);
|
|
577
|
+
}
|
|
578
|
+
if (job.schedule_kind === 'at') {
|
|
579
|
+
return job.schedule_at || null;
|
|
580
|
+
}
|
|
581
|
+
if (!job.schedule_cron) return null;
|
|
582
|
+
return nextRunFromCron(job.schedule_cron, job.schedule_tz || 'UTC');
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Create a new job.
|
|
587
|
+
*/
|
|
588
|
+
export function createJob(opts) {
|
|
589
|
+
const normalized = validateJobSpec(opts, null, 'create');
|
|
590
|
+
const db = getDb();
|
|
591
|
+
const id = normalized.id || randomUUID();
|
|
592
|
+
const isChild = !!normalized.parent_id;
|
|
593
|
+
const isAtJob = normalized.schedule_kind === 'at';
|
|
594
|
+
// For at-jobs: use provided cron or sentinel; for children: use sentinel; for cron: require cron
|
|
595
|
+
const cronExpr = normalized.schedule_cron || (isAtJob || isChild ? AT_JOB_CRON_SENTINEL : null);
|
|
596
|
+
|
|
597
|
+
// Cycle detection + depth check for child jobs
|
|
598
|
+
if (isChild) {
|
|
599
|
+
assertParentJobExists(normalized.parent_id);
|
|
600
|
+
detectCycle(id, normalized.parent_id);
|
|
601
|
+
const depth = getChainDepth(normalized.parent_id) + 1; // +1 for the new child
|
|
602
|
+
if (depth > MAX_CHAIN_DEPTH) {
|
|
603
|
+
throw new Error(`Max chain depth (${MAX_CHAIN_DEPTH}) exceeded. Chain would be ${depth} deep.`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Resolve final payload_kind (after defaults) and validate combo
|
|
608
|
+
const finalTarget = normalized.session_target || 'isolated';
|
|
609
|
+
const finalKind = normalized.payload_kind || (finalTarget === 'main' ? 'systemEvent' : finalTarget === 'shell' ? 'shellCommand' : 'agentTurn');
|
|
610
|
+
validateJobPayload(finalTarget, finalKind);
|
|
611
|
+
|
|
612
|
+
const nextRun = normalized.next_run_at || deriveNextRunAt({
|
|
613
|
+
...normalized,
|
|
614
|
+
parent_id: normalized.parent_id || null,
|
|
615
|
+
schedule_kind: normalized.schedule_kind || 'cron',
|
|
616
|
+
schedule_at: normalized.schedule_at || null,
|
|
617
|
+
schedule_cron: cronExpr,
|
|
618
|
+
schedule_tz: normalized.schedule_tz || 'UTC',
|
|
619
|
+
}, { preserveRunNow: true });
|
|
620
|
+
|
|
621
|
+
const stmt = db.prepare(`
|
|
622
|
+
INSERT INTO jobs (
|
|
623
|
+
id, name, enabled, schedule_kind, schedule_at, schedule_cron, schedule_tz,
|
|
624
|
+
session_target, agent_id, payload_kind, payload_message,
|
|
625
|
+
payload_model, payload_thinking, payload_timeout_seconds,
|
|
626
|
+
execution_intent, execution_read_only,
|
|
627
|
+
overlap_policy, run_timeout_ms, max_queued_dispatches, max_pending_approvals, max_trigger_fanout,
|
|
628
|
+
delivery_mode, delivery_channel, delivery_to,
|
|
629
|
+
delete_after_run, next_run_at,
|
|
630
|
+
parent_id, trigger_on, trigger_delay_s,
|
|
631
|
+
max_retries, payload_scope, resource_pool,
|
|
632
|
+
trigger_condition,
|
|
633
|
+
delivery_guarantee, job_class,
|
|
634
|
+
approval_required, approval_timeout_s, approval_auto,
|
|
635
|
+
context_retrieval, context_retrieval_limit,
|
|
636
|
+
output_store_limit_bytes, output_excerpt_limit_bytes, output_summary_limit_bytes, output_offload_threshold_bytes,
|
|
637
|
+
preferred_session_key,
|
|
638
|
+
job_type, watchdog_target_label, watchdog_check_cmd,
|
|
639
|
+
watchdog_timeout_min, watchdog_alert_channel, watchdog_alert_target,
|
|
640
|
+
watchdog_self_destruct, watchdog_started_at,
|
|
641
|
+
ttl_hours,
|
|
642
|
+
auth_profile,
|
|
643
|
+
delivery_opt_out_reason,
|
|
644
|
+
origin,
|
|
645
|
+
identity_principal, identity_run_as, identity_attestation, identity_ref,
|
|
646
|
+
identity_subject_kind, identity_subject_principal, identity_trust_level,
|
|
647
|
+
identity_delegation_mode, identity,
|
|
648
|
+
authorization_proof_ref, authorization_proof,
|
|
649
|
+
authorization_ref, authorization,
|
|
650
|
+
evidence_ref, evidence,
|
|
651
|
+
contract_required_trust_level, contract_trust_enforcement,
|
|
652
|
+
contract_sandbox, contract_allowed_paths, contract_network,
|
|
653
|
+
contract_max_cost_usd, contract_audit,
|
|
654
|
+
child_credential_policy
|
|
655
|
+
) VALUES (
|
|
656
|
+
?, ?, ?, ?, ?, ?, ?,
|
|
657
|
+
?, ?, ?, ?,
|
|
658
|
+
?, ?, ?,
|
|
659
|
+
?, ?,
|
|
660
|
+
?, ?, ?, ?, ?,
|
|
661
|
+
?, ?, ?,
|
|
662
|
+
?, ?,
|
|
663
|
+
?, ?, ?,
|
|
664
|
+
?, ?, ?,
|
|
665
|
+
?, ?, ?,
|
|
666
|
+
?, ?, ?,
|
|
667
|
+
?, ?, ?, ?, ?, ?,
|
|
668
|
+
?,
|
|
669
|
+
?, ?, ?,
|
|
670
|
+
?, ?, ?,
|
|
671
|
+
?, ?,
|
|
672
|
+
?,
|
|
673
|
+
?,
|
|
674
|
+
?,
|
|
675
|
+
?,
|
|
676
|
+
?, ?, ?, ?,
|
|
677
|
+
?, ?, ?,
|
|
678
|
+
?, ?,
|
|
679
|
+
?, ?,
|
|
680
|
+
?, ?,
|
|
681
|
+
?, ?,
|
|
682
|
+
?, ?,
|
|
683
|
+
?, ?, ?,
|
|
684
|
+
?, ?,
|
|
685
|
+
?
|
|
686
|
+
)
|
|
687
|
+
`);
|
|
688
|
+
|
|
689
|
+
stmt.run(
|
|
690
|
+
id,
|
|
691
|
+
normalized.name,
|
|
692
|
+
normalized.enabled == null ? 1 : (normalized.enabled ? 1 : 0),
|
|
693
|
+
normalized.schedule_kind || 'cron',
|
|
694
|
+
normalized.schedule_at || null,
|
|
695
|
+
cronExpr,
|
|
696
|
+
normalized.schedule_tz || 'UTC',
|
|
697
|
+
normalized.session_target || 'isolated',
|
|
698
|
+
normalized.agent_id || 'main',
|
|
699
|
+
finalKind,
|
|
700
|
+
normalized.payload_message,
|
|
701
|
+
normalized.payload_model || null,
|
|
702
|
+
normalized.payload_thinking || null,
|
|
703
|
+
normalized.payload_timeout_seconds ?? 120,
|
|
704
|
+
normalized.execution_intent || 'execute',
|
|
705
|
+
normalized.execution_read_only ? 1 : 0,
|
|
706
|
+
normalized.overlap_policy || 'skip',
|
|
707
|
+
normalized.run_timeout_ms ?? 300000,
|
|
708
|
+
normalized.max_queued_dispatches || 25,
|
|
709
|
+
normalized.max_pending_approvals || 10,
|
|
710
|
+
normalized.max_trigger_fanout || 25,
|
|
711
|
+
normalized.delivery_mode || 'announce',
|
|
712
|
+
normalized.delivery_channel || null,
|
|
713
|
+
normalized.delivery_to || null,
|
|
714
|
+
normalized.delete_after_run ? 1 : 0,
|
|
715
|
+
nextRun,
|
|
716
|
+
normalized.parent_id || null,
|
|
717
|
+
normalized.trigger_on || null,
|
|
718
|
+
normalized.trigger_delay_s || 0,
|
|
719
|
+
normalized.max_retries || 0,
|
|
720
|
+
normalized.payload_scope || 'own',
|
|
721
|
+
normalized.resource_pool || null,
|
|
722
|
+
normalized.trigger_condition || null,
|
|
723
|
+
normalized.delivery_guarantee || 'at-most-once',
|
|
724
|
+
normalized.job_class || 'standard',
|
|
725
|
+
normalized.approval_required ? 1 : 0,
|
|
726
|
+
normalized.approval_timeout_s || 3600,
|
|
727
|
+
normalized.approval_auto || 'reject',
|
|
728
|
+
normalized.context_retrieval || 'none',
|
|
729
|
+
normalized.context_retrieval_limit || 5,
|
|
730
|
+
normalized.output_store_limit_bytes || 65536,
|
|
731
|
+
normalized.output_excerpt_limit_bytes || 2000,
|
|
732
|
+
normalized.output_summary_limit_bytes || 5000,
|
|
733
|
+
normalized.output_offload_threshold_bytes || 65536,
|
|
734
|
+
normalized.preferred_session_key || null,
|
|
735
|
+
normalized.job_type || 'standard',
|
|
736
|
+
normalized.watchdog_target_label || null,
|
|
737
|
+
normalized.watchdog_check_cmd || null,
|
|
738
|
+
normalized.watchdog_timeout_min ?? null,
|
|
739
|
+
normalized.watchdog_alert_channel || null,
|
|
740
|
+
normalized.watchdog_alert_target || null,
|
|
741
|
+
normalized.watchdog_self_destruct != null ? (normalized.watchdog_self_destruct ? 1 : 0) : 1,
|
|
742
|
+
normalized.watchdog_started_at || null,
|
|
743
|
+
normalized.ttl_hours || null,
|
|
744
|
+
normalized.auth_profile || null,
|
|
745
|
+
normalized.delivery_opt_out_reason || null,
|
|
746
|
+
normalized.origin || null,
|
|
747
|
+
normalized.identity_principal || null,
|
|
748
|
+
normalized.identity_run_as || null,
|
|
749
|
+
normalized.identity_attestation || null,
|
|
750
|
+
normalized.identity_ref || null,
|
|
751
|
+
normalized.identity_subject_kind || null,
|
|
752
|
+
normalized.identity_subject_principal || null,
|
|
753
|
+
normalized.identity_trust_level || null,
|
|
754
|
+
normalized.identity_delegation_mode || null,
|
|
755
|
+
normalized.identity || null,
|
|
756
|
+
normalized.authorization_proof_ref || null,
|
|
757
|
+
normalized.authorization_proof || null,
|
|
758
|
+
normalized.authorization_ref || null,
|
|
759
|
+
normalized.authorization || null,
|
|
760
|
+
normalized.evidence_ref || null,
|
|
761
|
+
normalized.evidence || null,
|
|
762
|
+
normalized.contract_required_trust_level || null,
|
|
763
|
+
normalized.contract_trust_enforcement || null,
|
|
764
|
+
normalized.contract_sandbox || null,
|
|
765
|
+
normalized.contract_allowed_paths || null,
|
|
766
|
+
normalized.contract_network || null,
|
|
767
|
+
normalized.contract_max_cost_usd ?? null,
|
|
768
|
+
normalized.contract_audit || null,
|
|
769
|
+
normalized.child_credential_policy || null
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
return getJob(id);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Get a job by ID.
|
|
777
|
+
*/
|
|
778
|
+
export function getJob(id) {
|
|
779
|
+
return getDb().prepare('SELECT * FROM jobs WHERE id = ?').get(id);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* List all jobs, optionally filtered.
|
|
784
|
+
*/
|
|
785
|
+
export function listJobs(opts = {}) {
|
|
786
|
+
const db = getDb();
|
|
787
|
+
if (opts.enabledOnly) {
|
|
788
|
+
return db.prepare('SELECT * FROM jobs WHERE enabled = 1 ORDER BY next_run_at').all();
|
|
789
|
+
}
|
|
790
|
+
return db.prepare('SELECT * FROM jobs ORDER BY name').all();
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Update a job (partial patch).
|
|
795
|
+
*/
|
|
796
|
+
export function updateJob(id, patch) {
|
|
797
|
+
const db = getDb();
|
|
798
|
+
const current = getJob(id);
|
|
799
|
+
if (!current) return null;
|
|
800
|
+
const normalized = validateJobSpec(patch, current, 'update');
|
|
801
|
+
const allowed = [
|
|
802
|
+
'name', 'enabled', 'schedule_kind', 'schedule_at', 'schedule_cron', 'schedule_tz',
|
|
803
|
+
'session_target', 'agent_id', 'payload_kind', 'payload_message',
|
|
804
|
+
'payload_model', 'payload_thinking', 'payload_timeout_seconds',
|
|
805
|
+
'execution_intent', 'execution_read_only',
|
|
806
|
+
'overlap_policy', 'run_timeout_ms', 'max_queued_dispatches', 'max_pending_approvals', 'max_trigger_fanout',
|
|
807
|
+
'delivery_mode', 'delivery_channel', 'delivery_to',
|
|
808
|
+
'delete_after_run', 'next_run_at', 'last_run_at', 'last_status',
|
|
809
|
+
'consecutive_errors', 'parent_id', 'trigger_on', 'trigger_delay_s',
|
|
810
|
+
'max_retries', 'payload_scope', 'resource_pool', 'trigger_condition',
|
|
811
|
+
'delivery_guarantee', 'job_class',
|
|
812
|
+
'approval_required', 'approval_timeout_s', 'approval_auto',
|
|
813
|
+
'context_retrieval', 'context_retrieval_limit',
|
|
814
|
+
'output_store_limit_bytes', 'output_excerpt_limit_bytes', 'output_summary_limit_bytes', 'output_offload_threshold_bytes',
|
|
815
|
+
'preferred_session_key',
|
|
816
|
+
'job_type', 'watchdog_target_label', 'watchdog_check_cmd',
|
|
817
|
+
'watchdog_timeout_min', 'watchdog_alert_channel', 'watchdog_alert_target',
|
|
818
|
+
'watchdog_self_destruct', 'watchdog_started_at',
|
|
819
|
+
'ttl_hours',
|
|
820
|
+
'auth_profile',
|
|
821
|
+
'delivery_opt_out_reason',
|
|
822
|
+
'origin',
|
|
823
|
+
// v0.2 fields
|
|
824
|
+
'identity_principal', 'identity_run_as', 'identity_attestation', 'identity_ref',
|
|
825
|
+
'identity_subject_kind', 'identity_subject_principal', 'identity_trust_level',
|
|
826
|
+
'identity_delegation_mode', 'identity',
|
|
827
|
+
'authorization_proof_ref', 'authorization_proof',
|
|
828
|
+
'authorization_ref', 'authorization',
|
|
829
|
+
'evidence_ref', 'evidence',
|
|
830
|
+
'contract_required_trust_level', 'contract_trust_enforcement',
|
|
831
|
+
'contract_sandbox', 'contract_allowed_paths', 'contract_network',
|
|
832
|
+
'contract_max_cost_usd', 'contract_audit',
|
|
833
|
+
'child_credential_policy',
|
|
834
|
+
];
|
|
835
|
+
|
|
836
|
+
// Cycle detection if parent_id is being changed
|
|
837
|
+
if (normalized.parent_id) {
|
|
838
|
+
assertParentJobExists(normalized.parent_id);
|
|
839
|
+
detectCycle(id, normalized.parent_id);
|
|
840
|
+
const depth = getChainDepth(normalized.parent_id) + 1;
|
|
841
|
+
if (depth > MAX_CHAIN_DEPTH) {
|
|
842
|
+
throw new Error(`Max chain depth (${MAX_CHAIN_DEPTH}) exceeded.`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const sets = [];
|
|
847
|
+
const values = [];
|
|
848
|
+
|
|
849
|
+
for (const key of allowed) {
|
|
850
|
+
if (key in normalized) {
|
|
851
|
+
sets.push(`${key} = ?`);
|
|
852
|
+
values.push(normalized[key]);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (sets.length === 0) return getJob(id);
|
|
857
|
+
|
|
858
|
+
sets.push("updated_at = datetime('now')");
|
|
859
|
+
values.push(id);
|
|
860
|
+
|
|
861
|
+
db.prepare(`UPDATE jobs SET ${sets.join(', ')} WHERE id = ?`).run(...values);
|
|
862
|
+
|
|
863
|
+
const schedulingFieldsChanged = ['schedule_kind', 'schedule_at', 'schedule_cron', 'schedule_tz', 'parent_id']
|
|
864
|
+
.some((key) => key in normalized);
|
|
865
|
+
const reenabledRootJob = 'enabled' in normalized
|
|
866
|
+
&& !!normalized.enabled
|
|
867
|
+
&& !current.enabled;
|
|
868
|
+
if ((schedulingFieldsChanged || reenabledRootJob) && !('next_run_at' in normalized)) {
|
|
869
|
+
const refreshed = getJob(id);
|
|
870
|
+
if (refreshed) {
|
|
871
|
+
const nextRun = deriveNextRunAt(refreshed);
|
|
872
|
+
db.prepare('UPDATE jobs SET next_run_at = ? WHERE id = ?').run(nextRun, id);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return getJob(id);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Delete a job and its runs.
|
|
881
|
+
*/
|
|
882
|
+
export function deleteJob(id) {
|
|
883
|
+
getDb().prepare('DELETE FROM jobs WHERE id = ?').run(id);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Schedule an existing job for immediate execution via a durable manual dispatch.
|
|
888
|
+
* Manual dispatches can run even when the job is disabled, without mutating the
|
|
889
|
+
* stored cron schedule.
|
|
890
|
+
*/
|
|
891
|
+
export function runJobNow(id) {
|
|
892
|
+
const job = getJob(id);
|
|
893
|
+
if (!job) return null;
|
|
894
|
+
if (!canEnqueueDispatch(job.id, job.max_queued_dispatches || 25)) {
|
|
895
|
+
throw new Error(`Dispatch backlog limit reached for ${job.name}`);
|
|
896
|
+
}
|
|
897
|
+
const dispatch = enqueueDispatch(id, {
|
|
898
|
+
kind: 'manual',
|
|
899
|
+
scheduled_for: sqliteNow(-1000),
|
|
900
|
+
});
|
|
901
|
+
return { ...job, dispatch_id: dispatch.id, dispatch_kind: dispatch.dispatch_kind };
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Get cron jobs that are due to run (next_run_at <= now, enabled).
|
|
906
|
+
* At-jobs are excluded -- use getDueAtJobs() for one-shot scheduling.
|
|
907
|
+
*/
|
|
908
|
+
export function getDueJobs() {
|
|
909
|
+
return getDb().prepare(`
|
|
910
|
+
SELECT * FROM jobs
|
|
911
|
+
WHERE enabled = 1
|
|
912
|
+
AND parent_id IS NULL
|
|
913
|
+
AND next_run_at IS NOT NULL
|
|
914
|
+
AND next_run_at <= datetime('now')
|
|
915
|
+
AND (schedule_kind IS NULL OR schedule_kind = 'cron')
|
|
916
|
+
ORDER BY next_run_at ASC
|
|
917
|
+
`).all();
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Get at-jobs (one-shot) that are due to fire.
|
|
922
|
+
* Fires when schedule_at <= now and the job hasn't already run since schedule_at.
|
|
923
|
+
*/
|
|
924
|
+
export function getDueAtJobs() {
|
|
925
|
+
return getDb().prepare(`
|
|
926
|
+
SELECT * FROM jobs
|
|
927
|
+
WHERE schedule_kind = 'at'
|
|
928
|
+
AND enabled = 1
|
|
929
|
+
AND parent_id IS NULL
|
|
930
|
+
AND schedule_at IS NOT NULL
|
|
931
|
+
AND datetime(schedule_at) IS NOT NULL
|
|
932
|
+
AND datetime(schedule_at) <= datetime('now')
|
|
933
|
+
AND (last_run_at IS NULL OR datetime(last_run_at) < datetime(schedule_at))
|
|
934
|
+
ORDER BY datetime(schedule_at) ASC, schedule_at ASC
|
|
935
|
+
`).all();
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Prune expired disabled jobs.
|
|
940
|
+
* This intentionally avoids guessing whether a disabled cron job is "expired":
|
|
941
|
+
* paused recurring jobs may legitimately have a next_run_at far in the future.
|
|
942
|
+
*/
|
|
943
|
+
export function pruneExpiredJobs() {
|
|
944
|
+
const db = getDb();
|
|
945
|
+
// Delete disabled one-shot jobs that have actually run and have been sitting
|
|
946
|
+
// for >24h. Never-run disabled jobs may be intentionally staged for later.
|
|
947
|
+
const aged = db.prepare(`
|
|
948
|
+
DELETE FROM jobs
|
|
949
|
+
WHERE enabled = 0
|
|
950
|
+
AND delete_after_run = 1
|
|
951
|
+
AND last_run_at IS NOT NULL
|
|
952
|
+
AND last_run_at < datetime('now', '-24 hours')
|
|
953
|
+
`).run();
|
|
954
|
+
// Delete orphaned children whose parent no longer exists
|
|
955
|
+
const orphans = db.prepare(`
|
|
956
|
+
DELETE FROM jobs
|
|
957
|
+
WHERE parent_id IS NOT NULL
|
|
958
|
+
AND parent_id NOT IN (SELECT id FROM jobs)
|
|
959
|
+
`).run();
|
|
960
|
+
// TTL pruning: delete disabled jobs that have completed and are past their ttl_hours window
|
|
961
|
+
const ttlExpired = db.prepare(`
|
|
962
|
+
DELETE FROM jobs
|
|
963
|
+
WHERE ttl_hours IS NOT NULL
|
|
964
|
+
AND enabled = 0
|
|
965
|
+
AND last_status IN ('ok', 'error', 'timeout')
|
|
966
|
+
AND last_run_at IS NOT NULL
|
|
967
|
+
AND last_run_at < datetime('now', '-' || ttl_hours || ' hours')
|
|
968
|
+
`).run();
|
|
969
|
+
return aged.changes + orphans.changes + ttlExpired.changes;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Get child jobs triggered by a parent completing with a given status.
|
|
974
|
+
*/
|
|
975
|
+
export function getTriggeredChildren(parentId, status) {
|
|
976
|
+
return getDb().prepare(`
|
|
977
|
+
SELECT * FROM jobs
|
|
978
|
+
WHERE parent_id = ? AND enabled = 1
|
|
979
|
+
AND (trigger_on = 'complete' OR trigger_on = ?)
|
|
980
|
+
`).all(parentId, status === 'ok' ? 'success' : 'failure');
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Get all child jobs of a parent.
|
|
985
|
+
*/
|
|
986
|
+
export function getChildJobs(parentId) {
|
|
987
|
+
return getDb().prepare(`SELECT * FROM jobs WHERE parent_id = ?`).all(parentId);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Evaluate a trigger_condition pattern against parent run output content.
|
|
992
|
+
* Supports:
|
|
993
|
+
* - null / undefined -> always matches (no condition)
|
|
994
|
+
* - "contains:<substr>" -> substring match (case-sensitive)
|
|
995
|
+
* - "regex:<pattern>" -> regex match
|
|
996
|
+
* Returns true if the condition matches (or is absent).
|
|
997
|
+
*/
|
|
998
|
+
export function evalTriggerCondition(condition, content) {
|
|
999
|
+
if (!condition) return true;
|
|
1000
|
+
const str = content || '';
|
|
1001
|
+
if (condition.startsWith('contains:')) {
|
|
1002
|
+
const substr = condition.slice('contains:'.length);
|
|
1003
|
+
return str.includes(substr);
|
|
1004
|
+
}
|
|
1005
|
+
if (condition.startsWith('regex:')) {
|
|
1006
|
+
const pattern = condition.slice('regex:'.length);
|
|
1007
|
+
try {
|
|
1008
|
+
return new RegExp(pattern).test(str);
|
|
1009
|
+
} catch {
|
|
1010
|
+
return false; // Invalid regex never matches
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
// Unknown prefix -- unreachable: validateTriggerConditionSyntax rejects these at write time
|
|
1014
|
+
return false;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Fire triggered children by setting their next_run_at.
|
|
1019
|
+
* @param {string} parentId - parent job id
|
|
1020
|
+
* @param {string} status - 'ok' | 'error'
|
|
1021
|
+
* @param {string} [content] - parent run output (used to evaluate trigger_condition)
|
|
1022
|
+
* Returns array of triggered children.
|
|
1023
|
+
*/
|
|
1024
|
+
export function fireTriggeredChildren(parentId, status, content, parentRunId = null) {
|
|
1025
|
+
const parentJob = getJob(parentId);
|
|
1026
|
+
const candidates = getTriggeredChildren(parentId, status);
|
|
1027
|
+
const triggered = [];
|
|
1028
|
+
for (const child of candidates.slice(0, parentJob?.max_trigger_fanout || 25)) {
|
|
1029
|
+
// Check output-based trigger condition if set
|
|
1030
|
+
if (!evalTriggerCondition(child.trigger_condition, content)) continue;
|
|
1031
|
+
const delay = child.trigger_delay_s || 0;
|
|
1032
|
+
if (!canEnqueueDispatch(child.id, child.max_queued_dispatches || 25)) continue;
|
|
1033
|
+
const dispatch = enqueueDispatch(child.id, {
|
|
1034
|
+
kind: 'chain',
|
|
1035
|
+
scheduled_for: sqliteNow(delay > 0 ? delay * 1000 : -1000),
|
|
1036
|
+
source_run_id: parentRunId || null,
|
|
1037
|
+
});
|
|
1038
|
+
triggered.push({ ...child, dispatch_id: dispatch.id, scheduled_for: dispatch.scheduled_for });
|
|
1039
|
+
}
|
|
1040
|
+
return triggered;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Increment the queued dispatch counter for a job (overlap_policy=queue).
|
|
1045
|
+
*/
|
|
1046
|
+
export function enqueueJob(jobId) {
|
|
1047
|
+
const job = getJob(jobId);
|
|
1048
|
+
if (!job) return { queued: false, queued_count: 0, limited: true };
|
|
1049
|
+
if ((job.queued_count || 0) >= (job.max_queued_dispatches || 25)) {
|
|
1050
|
+
return { queued: false, queued_count: job.queued_count || 0, limited: true };
|
|
1051
|
+
}
|
|
1052
|
+
getDb().prepare('UPDATE jobs SET queued_count = queued_count + 1 WHERE id = ?').run(jobId);
|
|
1053
|
+
return { queued: true, queued_count: (job.queued_count || 0) + 1, limited: false };
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Consume one queued dispatch. Returns true if there was something queued.
|
|
1058
|
+
*/
|
|
1059
|
+
export function dequeueJob(jobId) {
|
|
1060
|
+
const job = getJob(jobId);
|
|
1061
|
+
if (!job || job.queued_count <= 0) return false;
|
|
1062
|
+
const db = getDb();
|
|
1063
|
+
db.prepare('UPDATE jobs SET queued_count = queued_count - 1 WHERE id = ?').run(jobId);
|
|
1064
|
+
// Schedule it to fire on the next tick -- but not for at-jobs, which are one-shot
|
|
1065
|
+
// and will be disabled by updateJobAfterRun. Setting next_run_at here would cause
|
|
1066
|
+
// getDueAtJobs to re-fire an already-completed at-job.
|
|
1067
|
+
if (job.schedule_kind !== 'at') {
|
|
1068
|
+
db.prepare(`UPDATE jobs SET next_run_at = datetime('now', '-1 second') WHERE id = ?`).run(jobId);
|
|
1069
|
+
}
|
|
1070
|
+
return true;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Detect cycles in the parent chain. Throws if adding childId -> parentId would create a loop.
|
|
1075
|
+
*/
|
|
1076
|
+
export function detectCycle(childId, parentId) {
|
|
1077
|
+
const db = getDb();
|
|
1078
|
+
const visited = new Set([childId]);
|
|
1079
|
+
let current = parentId;
|
|
1080
|
+
while (current) {
|
|
1081
|
+
if (visited.has(current)) {
|
|
1082
|
+
throw new Error(`Cycle detected: job ${childId} -> ${parentId} would create a loop`);
|
|
1083
|
+
}
|
|
1084
|
+
visited.add(current);
|
|
1085
|
+
const job = db.prepare('SELECT parent_id FROM jobs WHERE id = ?').get(current);
|
|
1086
|
+
current = job?.parent_id || null;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Get the depth of a job's parent chain (0 = root).
|
|
1092
|
+
*/
|
|
1093
|
+
export function getChainDepth(jobId) {
|
|
1094
|
+
const db = getDb();
|
|
1095
|
+
let depth = 0;
|
|
1096
|
+
let current = jobId;
|
|
1097
|
+
while (current) {
|
|
1098
|
+
const job = db.prepare('SELECT parent_id FROM jobs WHERE id = ?').get(current);
|
|
1099
|
+
if (!job || !job.parent_id) break;
|
|
1100
|
+
depth++;
|
|
1101
|
+
current = job.parent_id;
|
|
1102
|
+
if (depth > MAX_CHAIN_DEPTH) break; // safety valve
|
|
1103
|
+
}
|
|
1104
|
+
return depth;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Check if a failed run should be retried. Returns true if retry was scheduled.
|
|
1109
|
+
*/
|
|
1110
|
+
export function shouldRetry(job, runId) {
|
|
1111
|
+
if (!job.enabled) return false;
|
|
1112
|
+
if (!job.max_retries || job.max_retries <= 0) return false;
|
|
1113
|
+
const db = getDb();
|
|
1114
|
+
// Count retries for this job's most recent failure chain
|
|
1115
|
+
const run = db.prepare('SELECT retry_count FROM runs WHERE id = ?').get(runId);
|
|
1116
|
+
const retryCount = run?.retry_count || 0;
|
|
1117
|
+
if (retryCount >= job.max_retries) return false;
|
|
1118
|
+
return true;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Schedule a retry for a failed run. Returns the new retry run's next_run_at or null.
|
|
1123
|
+
*/
|
|
1124
|
+
export function scheduleRetry(job, failedRunId, opts = {}) {
|
|
1125
|
+
const db = getDb();
|
|
1126
|
+
const failedRun = db.prepare('SELECT retry_count FROM runs WHERE id = ?').get(failedRunId);
|
|
1127
|
+
const retryCount = (failedRun?.retry_count || 0) + 1;
|
|
1128
|
+
// Exponential backoff: 30s, 60s, 120s, etc.
|
|
1129
|
+
const delaySec = 30 * Math.pow(2, retryCount - 1);
|
|
1130
|
+
const retryPatch = {
|
|
1131
|
+
last_run_at: sqliteNow(),
|
|
1132
|
+
last_status: opts.lastStatus || 'error',
|
|
1133
|
+
};
|
|
1134
|
+
if (!job.parent_id && job.schedule_kind !== 'at' && job.schedule_cron) {
|
|
1135
|
+
retryPatch.next_run_at = nextRunFromCron(job.schedule_cron, job.schedule_tz);
|
|
1136
|
+
}
|
|
1137
|
+
if (!canEnqueueDispatch(job.id, job.max_queued_dispatches || 25)) {
|
|
1138
|
+
return { retryCount, delaySec, retryOf: failedRunId, dispatch: null, skipped: true };
|
|
1139
|
+
}
|
|
1140
|
+
const dispatch = enqueueDispatch(job.id, {
|
|
1141
|
+
kind: 'retry',
|
|
1142
|
+
scheduled_for: sqliteNow(delaySec * 1000),
|
|
1143
|
+
source_run_id: failedRunId,
|
|
1144
|
+
retry_of_run_id: failedRunId,
|
|
1145
|
+
});
|
|
1146
|
+
applyJobPatch(job.id, retryPatch);
|
|
1147
|
+
// Store retry metadata for the next run
|
|
1148
|
+
return { retryCount, delaySec, retryOf: failedRunId, dispatch };
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
export function getDispatchBacklogCount(jobId) {
|
|
1152
|
+
const row = getDb().prepare(`
|
|
1153
|
+
SELECT COUNT(*) AS cnt
|
|
1154
|
+
FROM job_dispatch_queue
|
|
1155
|
+
WHERE job_id = ?
|
|
1156
|
+
AND status IN ('pending', 'claimed', 'awaiting_approval')
|
|
1157
|
+
`).get(jobId);
|
|
1158
|
+
return row?.cnt || 0;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
export function canEnqueueDispatch(jobId, maxQueuedDispatches = 25) {
|
|
1162
|
+
return getDispatchBacklogCount(jobId) < maxQueuedDispatches;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Cancel a job and optionally cascade to children.
|
|
1167
|
+
* Sets status to disabled and cancels any running runs.
|
|
1168
|
+
*/
|
|
1169
|
+
export function cancelJob(jobId, opts = {}) {
|
|
1170
|
+
const db = getDb();
|
|
1171
|
+
const cascade = opts.cascade !== false; // default: cascade
|
|
1172
|
+
|
|
1173
|
+
// Disable the job
|
|
1174
|
+
db.prepare('UPDATE jobs SET enabled = 0 WHERE id = ?').run(jobId);
|
|
1175
|
+
|
|
1176
|
+
// Cancel any running runs
|
|
1177
|
+
const runningRuns = db.prepare(`
|
|
1178
|
+
SELECT id FROM runs WHERE job_id = ? AND status = 'running'
|
|
1179
|
+
`).all(jobId);
|
|
1180
|
+
for (const run of runningRuns) {
|
|
1181
|
+
db.prepare(`
|
|
1182
|
+
UPDATE runs SET status = 'cancelled', finished_at = datetime('now')
|
|
1183
|
+
WHERE id = ?
|
|
1184
|
+
`).run(run.id);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const cancelled = [jobId];
|
|
1188
|
+
|
|
1189
|
+
// Cascade to children
|
|
1190
|
+
if (cascade) {
|
|
1191
|
+
const children = getChildJobs(jobId);
|
|
1192
|
+
for (const child of children) {
|
|
1193
|
+
const childCancelled = cancelJob(child.id, { cascade: true });
|
|
1194
|
+
cancelled.push(...childCancelled);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
return cancelled;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Check if ANY job with the given resource_pool has a running run.
|
|
1203
|
+
* Returns true if the pool is busy (at least one running run for any job in the pool).
|
|
1204
|
+
*/
|
|
1205
|
+
export function hasRunningRunForPool(poolName) {
|
|
1206
|
+
if (!poolName) return false;
|
|
1207
|
+
const row = getDb().prepare(`
|
|
1208
|
+
SELECT COUNT(*) as cnt FROM runs r
|
|
1209
|
+
JOIN jobs j ON r.job_id = j.id
|
|
1210
|
+
WHERE r.status = 'running' AND j.resource_pool = ?
|
|
1211
|
+
`).get(poolName);
|
|
1212
|
+
return row.cnt > 0;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Check if a job has a running run (for skip-overlap).
|
|
1217
|
+
*/
|
|
1218
|
+
export function hasRunningRun(jobId) {
|
|
1219
|
+
const row = getDb().prepare(`
|
|
1220
|
+
SELECT COUNT(*) as cnt FROM runs
|
|
1221
|
+
WHERE job_id = ? AND status = 'running'
|
|
1222
|
+
`).get(jobId);
|
|
1223
|
+
return row.cnt > 0;
|
|
1224
|
+
}
|