@dsh-enhanced/assistant-automations 0.1.3

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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +135 -0
  3. package/cordis.patch.yml +36 -0
  4. package/lib/artifacts.d.ts +15 -0
  5. package/lib/artifacts.d.ts.map +1 -0
  6. package/lib/artifacts.js +68 -0
  7. package/lib/artifacts.js.map +1 -0
  8. package/lib/coordinator.d.ts +126 -0
  9. package/lib/coordinator.d.ts.map +1 -0
  10. package/lib/coordinator.js +374 -0
  11. package/lib/coordinator.js.map +1 -0
  12. package/lib/index.d.ts +11 -0
  13. package/lib/index.d.ts.map +1 -0
  14. package/lib/index.js +11 -0
  15. package/lib/index.js.map +1 -0
  16. package/lib/operator.d.ts +4 -0
  17. package/lib/operator.d.ts.map +1 -0
  18. package/lib/operator.js +12 -0
  19. package/lib/operator.js.map +1 -0
  20. package/lib/proposals.d.ts +89 -0
  21. package/lib/proposals.d.ts.map +1 -0
  22. package/lib/proposals.js +499 -0
  23. package/lib/proposals.js.map +1 -0
  24. package/lib/runner.d.ts +34 -0
  25. package/lib/runner.d.ts.map +1 -0
  26. package/lib/runner.js +272 -0
  27. package/lib/runner.js.map +1 -0
  28. package/lib/schedule.d.ts +32 -0
  29. package/lib/schedule.d.ts.map +1 -0
  30. package/lib/schedule.js +245 -0
  31. package/lib/schedule.js.map +1 -0
  32. package/lib/service.d.ts +159 -0
  33. package/lib/service.d.ts.map +1 -0
  34. package/lib/service.js +445 -0
  35. package/lib/service.js.map +1 -0
  36. package/lib/sqlite.d.ts +8 -0
  37. package/lib/sqlite.d.ts.map +1 -0
  38. package/lib/sqlite.js +281 -0
  39. package/lib/sqlite.js.map +1 -0
  40. package/lib/store.d.ts +169 -0
  41. package/lib/store.d.ts.map +1 -0
  42. package/lib/store.js +1002 -0
  43. package/lib/store.js.map +1 -0
  44. package/lib/tools.d.ts +4 -0
  45. package/lib/tools.d.ts.map +1 -0
  46. package/lib/tools.js +226 -0
  47. package/lib/tools.js.map +1 -0
  48. package/lib/types.d.ts +182 -0
  49. package/lib/types.d.ts.map +1 -0
  50. package/lib/types.js +2 -0
  51. package/lib/types.js.map +1 -0
  52. package/lib/version.d.ts +2 -0
  53. package/lib/version.d.ts.map +1 -0
  54. package/lib/version.js +2 -0
  55. package/lib/version.js.map +1 -0
  56. package/package.json +106 -0
package/lib/store.js ADDED
@@ -0,0 +1,1002 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isAbsolute } from 'node:path';
3
+ import { dueOccurrences, nextOccurrence, previousOccurrence, validateSchedule, } from './schedule.js';
4
+ import { AutomationDatabaseError, openAutomationDatabase } from './sqlite.js';
5
+ export class AutomationStoreError extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.code = code;
10
+ this.name = 'AutomationStoreError';
11
+ }
12
+ }
13
+ function hash(value) {
14
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
15
+ }
16
+ function text(value, field, maximum = 1_000) {
17
+ if (typeof value !== 'string' || value.trim() === '') {
18
+ throw new AutomationStoreError('invalid-definition', `${field} must be a non-empty string`);
19
+ }
20
+ const normalized = value.normalize('NFC').trim();
21
+ if (Buffer.byteLength(normalized, 'utf8') > maximum) {
22
+ throw new AutomationStoreError('invalid-definition', `${field} exceeds its byte limit`);
23
+ }
24
+ return normalized;
25
+ }
26
+ function safeInteger(value, field, minimum, maximum) {
27
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
28
+ throw new AutomationStoreError('invalid-definition', `${field} must be between ${minimum} and ${maximum}`);
29
+ }
30
+ return value;
31
+ }
32
+ function misfire(value) {
33
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
34
+ throw new AutomationStoreError('invalid-definition', 'misfire must be an object');
35
+ }
36
+ const input = value;
37
+ if (input['kind'] === 'skip' || input['kind'] === 'latest') {
38
+ if (Object.keys(input).some(key => key !== 'kind')) {
39
+ throw new AutomationStoreError('invalid-definition', 'misfire contains an unknown field');
40
+ }
41
+ return Object.freeze({ kind: input['kind'] });
42
+ }
43
+ if (input['kind'] === 'bounded-replay') {
44
+ if (Object.keys(input).some(key => key !== 'kind' && key !== 'limit')) {
45
+ throw new AutomationStoreError('invalid-definition', 'misfire contains an unknown field');
46
+ }
47
+ return Object.freeze({ kind: 'bounded-replay', limit: safeInteger(input['limit'], 'misfire.limit', 1, 100) });
48
+ }
49
+ throw new AutomationStoreError('invalid-definition', 'invalid misfire policy');
50
+ }
51
+ export function normalizeAutomationDefinition(value, maxPromptBytes = 16_384, maxAllowedTools = 100) {
52
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
53
+ throw new AutomationStoreError('invalid-definition', 'automation definition must be an object');
54
+ }
55
+ const input = value;
56
+ const allowed = new Set([
57
+ 'agentPreset', 'allowedTools', 'budgetAmount', 'budgetId', 'deliveryBindingId', 'deliverySuppressExact', 'maxOutputTokens',
58
+ 'maxRetries', 'maxToolCalls', 'misfire', 'model', 'name', 'overlap', 'principal', 'prompt', 'provider',
59
+ 'retrySafety', 'schedule', 'timeoutMs', 'workspace',
60
+ ]);
61
+ if (Object.keys(input).some(key => !allowed.has(key))) {
62
+ throw new AutomationStoreError('invalid-definition', 'automation definition contains an unknown field');
63
+ }
64
+ if (!isAbsolute(input['workspace'])) {
65
+ throw new AutomationStoreError('invalid-definition', 'automation workspace must be absolute');
66
+ }
67
+ if (!Array.isArray(input['allowedTools']) || input['allowedTools'].length > maxAllowedTools) {
68
+ throw new AutomationStoreError('invalid-definition', 'allowedTools must be a bounded array');
69
+ }
70
+ const allowedTools = input['allowedTools'].map(value => text(value, 'allowedTools item', 200));
71
+ if (new Set(allowedTools).size !== allowedTools.length) {
72
+ throw new AutomationStoreError('invalid-definition', 'allowedTools contains a duplicate');
73
+ }
74
+ const overlap = input['overlap'];
75
+ if (overlap !== 'skip' && overlap !== 'queue-one' && overlap !== 'cancel-previous') {
76
+ throw new AutomationStoreError('invalid-definition', 'invalid overlap policy');
77
+ }
78
+ const retrySafety = input['retrySafety'];
79
+ if (retrySafety !== 'never' && retrySafety !== 'idempotent') {
80
+ throw new AutomationStoreError('invalid-definition', 'invalid retrySafety');
81
+ }
82
+ const budgetId = input['budgetId'] === undefined ? undefined : text(input['budgetId'], 'budgetId', 200);
83
+ const budgetAmount = input['budgetAmount'] === undefined
84
+ ? undefined
85
+ : safeInteger(input['budgetAmount'], 'budgetAmount', 1, 10_000_000);
86
+ if ((budgetId === undefined) !== (budgetAmount === undefined)) {
87
+ throw new AutomationStoreError('invalid-definition', 'budgetId and budgetAmount must be supplied together');
88
+ }
89
+ const deliverySuppressExact = input['deliverySuppressExact'] === undefined
90
+ ? undefined
91
+ : (() => {
92
+ if (!Array.isArray(input['deliverySuppressExact']) || input['deliverySuppressExact'].length > 20) {
93
+ throw new AutomationStoreError('invalid-definition', 'deliverySuppressExact must be a bounded array');
94
+ }
95
+ const values = input['deliverySuppressExact'].map(value => text(value, 'deliverySuppressExact item', 1_024));
96
+ if (new Set(values).size !== values.length) {
97
+ throw new AutomationStoreError('invalid-definition', 'deliverySuppressExact contains a duplicate');
98
+ }
99
+ return Object.freeze(values);
100
+ })();
101
+ if (deliverySuppressExact !== undefined && input['deliveryBindingId'] === undefined) {
102
+ throw new AutomationStoreError('invalid-definition', 'deliverySuppressExact requires deliveryBindingId');
103
+ }
104
+ return Object.freeze({
105
+ name: text(input['name'], 'name', 500),
106
+ prompt: text(input['prompt'], 'prompt', maxPromptBytes),
107
+ schedule: validateSchedule(input['schedule']),
108
+ workspace: text(input['workspace'], 'workspace', 4_096),
109
+ agentPreset: text(input['agentPreset'], 'agentPreset', 200),
110
+ provider: text(input['provider'], 'provider', 200),
111
+ model: text(input['model'], 'model', 500),
112
+ allowedTools: Object.freeze(allowedTools),
113
+ timeoutMs: safeInteger(input['timeoutMs'], 'timeoutMs', 1_000, 86_400_000),
114
+ maxOutputTokens: safeInteger(input['maxOutputTokens'], 'maxOutputTokens', 1, 1_000_000),
115
+ maxToolCalls: safeInteger(input['maxToolCalls'], 'maxToolCalls', 0, 10_000),
116
+ misfire: misfire(input['misfire']),
117
+ overlap: overlap,
118
+ retrySafety: retrySafety,
119
+ maxRetries: safeInteger(input['maxRetries'], 'maxRetries', 0, 10),
120
+ principal: text(input['principal'], 'principal', 500),
121
+ ...(budgetId === undefined ? {} : { budgetId, budgetAmount: budgetAmount }),
122
+ ...(input['deliveryBindingId'] === undefined
123
+ ? {}
124
+ : { deliveryBindingId: text(input['deliveryBindingId'], 'deliveryBindingId', 500) }),
125
+ ...(deliverySuppressExact === undefined ? {} : { deliverySuppressExact }),
126
+ });
127
+ }
128
+ function record(row) {
129
+ return Object.freeze({
130
+ id: row.id,
131
+ ...(row.system_owner === null ? {} : { owner: row.system_owner }),
132
+ definition: Object.freeze(JSON.parse(row.definition_json)),
133
+ status: row.status,
134
+ nextRunAt: row.next_run_at ?? undefined,
135
+ createdAt: row.created_at,
136
+ updatedAt: row.updated_at,
137
+ version: row.version,
138
+ });
139
+ }
140
+ function deliveryStatus(value) {
141
+ if (value === null)
142
+ return undefined;
143
+ if (value === 'pending' || value === 'enqueued' || value === 'suppressed')
144
+ return value;
145
+ throw new AutomationStoreError('invalid-state', 'automation run has an invalid delivery status');
146
+ }
147
+ function evidenceStatus(value) {
148
+ if (value === 'pending' || value === 'recorded' || value === 'suppressed')
149
+ return value;
150
+ throw new AutomationStoreError('invalid-state', 'automation run has an invalid evidence status');
151
+ }
152
+ function parseEvidence(row) {
153
+ const status = evidenceStatus(row.evidence_status);
154
+ if (status === 'suppressed') {
155
+ if (row.evidence_json !== null) {
156
+ throw new AutomationStoreError('invalid-state', 'suppressed automation evidence must not carry a payload');
157
+ }
158
+ return undefined;
159
+ }
160
+ if (row.evidence_json === null || Buffer.byteLength(row.evidence_json, 'utf8') > 16_384) {
161
+ throw new AutomationStoreError('invalid-state', 'pending automation evidence must be bounded JSON');
162
+ }
163
+ const value = JSON.parse(row.evidence_json);
164
+ return Object.freeze(value);
165
+ }
166
+ function occurrence(row) {
167
+ return Object.freeze({
168
+ id: row.id,
169
+ automationId: row.automation_id,
170
+ triggerKind: row.trigger_kind,
171
+ triggerKey: row.trigger_key,
172
+ scheduledAt: row.scheduled_at,
173
+ status: row.status,
174
+ ...(row.reason === null ? {} : { reason: row.reason }),
175
+ dryRun: row.dry_run === 1,
176
+ createdAt: row.created_at,
177
+ updatedAt: row.updated_at,
178
+ });
179
+ }
180
+ function task(row) {
181
+ return Object.freeze({
182
+ id: row.id,
183
+ occurrenceId: row.occurrence_id,
184
+ automationId: row.automation_id,
185
+ status: row.status,
186
+ cancelRequested: row.cancel_requested === 1,
187
+ ...(row.claimed_by === null ? {} : { claimedBy: row.claimed_by }),
188
+ ...(row.fencing_token === null ? {} : { fencingToken: row.fencing_token }),
189
+ ...(row.lease_until === null ? {} : { leaseUntil: row.lease_until }),
190
+ attemptCount: row.attempt_count,
191
+ createdAt: row.created_at,
192
+ updatedAt: row.updated_at,
193
+ });
194
+ }
195
+ function run(row) {
196
+ const status = deliveryStatus(row.delivery_status);
197
+ const evidence = parseEvidence(row);
198
+ return Object.freeze({
199
+ id: row.id,
200
+ occurrenceId: row.occurrence_id,
201
+ automationId: row.automation_id,
202
+ taskId: row.task_id,
203
+ attemptId: row.attempt_id,
204
+ status: row.status,
205
+ ...(row.session_id === null ? {} : { sessionId: row.session_id }),
206
+ ...(row.artifact_ref === null ? {} : { artifactRef: row.artifact_ref }),
207
+ outputPreview: row.output_preview,
208
+ usage: Object.freeze(JSON.parse(row.usage_json)),
209
+ ...(status === undefined ? {} : { deliveryStatus: status }),
210
+ ...(row.delivery_ref === null ? {} : { deliveryRef: row.delivery_ref }),
211
+ evidenceStatus: evidenceStatus(row.evidence_status),
212
+ ...(evidence === undefined ? {} : { evidence }),
213
+ createdAt: row.created_at,
214
+ updatedAt: row.updated_at,
215
+ });
216
+ }
217
+ function duty(row, acquired) {
218
+ return Object.freeze({
219
+ acquired,
220
+ ownerId: row.owner_id,
221
+ fencingToken: row.fencing_token,
222
+ leaseUntil: row.lease_until,
223
+ });
224
+ }
225
+ function normalizeEvidenceAttribution(value) {
226
+ if (value === undefined)
227
+ return Object.freeze({});
228
+ const sessionId = value.sessionId === undefined ? undefined : text(value.sessionId, 'evidence.sessionId', 500);
229
+ const ruleId = value.ruleId === undefined ? undefined : text(value.ruleId, 'evidence.ruleId', 200);
230
+ const guidanceVersion = value.guidanceVersion === undefined
231
+ ? undefined
232
+ : safeInteger(value.guidanceVersion, 'evidence.guidanceVersion', 1, 1_000_000_000);
233
+ if ((ruleId === undefined) !== (guidanceVersion === undefined)) {
234
+ throw new AutomationStoreError('invalid-definition', 'evidence ruleId and guidanceVersion must be supplied together');
235
+ }
236
+ return Object.freeze({
237
+ ...(sessionId === undefined ? {} : { sessionId }),
238
+ ...(ruleId === undefined ? {} : { ruleId, guidanceVersion: guidanceVersion }),
239
+ });
240
+ }
241
+ function buildOutcomeEvidence(input) {
242
+ if (input.outcome === 'cancelled' || input.outcome === 'unknown') {
243
+ return { status: 'suppressed', json: null };
244
+ }
245
+ const evidence = {
246
+ situation: `automation:${input.automation.id}`,
247
+ outcome: input.outcome === 'succeeded' ? 'succeeded' : 'failed',
248
+ detail: `automation "${input.automation.definition.name}": run ${input.outcome}`,
249
+ idempotencyKey: `automation-run:${input.runId}`,
250
+ occurredAt: input.occurredAt,
251
+ workspace: input.automation.definition.workspace,
252
+ agentPreset: input.automation.definition.agentPreset,
253
+ automationId: input.automation.id,
254
+ runId: input.runId,
255
+ ...input.attribution,
256
+ };
257
+ const json = JSON.stringify(evidence);
258
+ if (Buffer.byteLength(json, 'utf8') > 16_384) {
259
+ throw new AutomationStoreError('invalid-definition', 'automation evidence exceeds 16384 bytes');
260
+ }
261
+ return { status: 'pending', json };
262
+ }
263
+ export function stableOccurrenceId(automationId, triggerKind, triggerKey) {
264
+ return `occ-${createHash('sha256').update(`${automationId}\0${triggerKind}\0${triggerKey}`).digest('hex')}`;
265
+ }
266
+ function initialNext(schedule, now) {
267
+ if (schedule.kind === 'at')
268
+ return Date.parse(schedule.at);
269
+ return nextOccurrence(schedule, now - 1);
270
+ }
271
+ export class AutomationStore {
272
+ database;
273
+ now;
274
+ maxPromptBytes;
275
+ maxAllowedTools;
276
+ closed = false;
277
+ constructor(options) {
278
+ this.now = options.now ?? Date.now;
279
+ this.maxPromptBytes = options.maxPromptBytes ?? 16_384;
280
+ this.maxAllowedTools = options.maxAllowedTools ?? 100;
281
+ try {
282
+ this.database = openAutomationDatabase(options.path);
283
+ }
284
+ catch (error) {
285
+ if (error instanceof AutomationDatabaseError)
286
+ throw new AutomationStoreError(error.code, error.message);
287
+ throw error;
288
+ }
289
+ }
290
+ close() {
291
+ if (this.closed)
292
+ return;
293
+ this.closed = true;
294
+ this.database.close();
295
+ }
296
+ transaction(operation) {
297
+ this.database.exec('BEGIN IMMEDIATE');
298
+ try {
299
+ const value = operation();
300
+ this.database.exec('COMMIT');
301
+ return value;
302
+ }
303
+ catch (error) {
304
+ this.database.exec('ROLLBACK');
305
+ throw error;
306
+ }
307
+ }
308
+ createApproved(input) {
309
+ const id = text(input.automationId, 'automationId', 500);
310
+ const idempotencyKey = text(input.idempotencyKey, 'idempotencyKey', 500);
311
+ const definition = normalizeAutomationDefinition(input.definition, this.maxPromptBytes, this.maxAllowedTools);
312
+ const definitionHash = hash(definition);
313
+ return this.transaction(() => {
314
+ const byKey = this.database.prepare('SELECT * FROM automation_definitions WHERE create_idempotency_key = ?').get(idempotencyKey);
315
+ if (byKey !== undefined) {
316
+ if (byKey.id !== id || byKey.definition_hash !== definitionHash) {
317
+ throw new AutomationStoreError('idempotency-conflict', 'automation create key was reused with different input');
318
+ }
319
+ return record(byKey);
320
+ }
321
+ if (this.get(id) !== undefined) {
322
+ throw new AutomationStoreError('idempotency-conflict', 'automation id is already in use');
323
+ }
324
+ const now = this.now();
325
+ const next = initialNext(definition.schedule, now);
326
+ this.database.prepare(`
327
+ INSERT INTO automation_definitions(
328
+ id, create_idempotency_key, definition_hash, definition_json, status,
329
+ next_run_at, created_at, updated_at, version
330
+ ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, 1)
331
+ `).run(id, idempotencyKey, definitionHash, JSON.stringify(definition), next ?? null, now, now);
332
+ return this.get(id);
333
+ });
334
+ }
335
+ reconcileSystemOwned(input) {
336
+ const owner = text(input.owner, 'owner', 200);
337
+ const id = text(input.automationId, 'automationId', 500);
338
+ const idempotencyKey = text(input.idempotencyKey, 'idempotencyKey', 500);
339
+ const definition = normalizeAutomationDefinition(input.definition, this.maxPromptBytes, this.maxAllowedTools);
340
+ const desiredStatus = input.desiredStatus ?? 'active';
341
+ const inputHash = hash({ owner, automationId: id, desiredStatus, definition });
342
+ const definitionHash = hash(definition);
343
+ return this.transaction(() => {
344
+ const prior = this.database.prepare(`
345
+ SELECT input_hash, result_json FROM automation_system_reconciles WHERE idempotency_key = ?
346
+ `).get(idempotencyKey);
347
+ if (prior !== undefined) {
348
+ if (prior.input_hash !== inputHash) {
349
+ throw new AutomationStoreError('idempotency-conflict', 'system reconciliation key was reused with different input');
350
+ }
351
+ return Object.freeze(JSON.parse(prior.result_json));
352
+ }
353
+ const current = this.get(id);
354
+ if (current !== undefined && current.owner !== owner) {
355
+ throw new AutomationStoreError('invalid-state', 'automation row is not owned by this system plugin');
356
+ }
357
+ const now = this.now();
358
+ if (current === undefined) {
359
+ this.database.prepare(`
360
+ INSERT INTO automation_definitions(
361
+ id, create_idempotency_key, system_owner, definition_hash, definition_json, status,
362
+ next_run_at, created_at, updated_at, version
363
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
364
+ `).run(id, `system:${owner}:${idempotencyKey}`, owner, definitionHash, JSON.stringify(definition), desiredStatus, desiredStatus === 'active' ? initialNext(definition.schedule, now) ?? null : null, now, now);
365
+ }
366
+ else {
367
+ const currentHash = hash(current.definition);
368
+ if (currentHash !== definitionHash || current.status !== desiredStatus) {
369
+ this.database.prepare(`
370
+ UPDATE automation_definitions
371
+ SET definition_hash = ?, definition_json = ?, status = ?, next_run_at = ?,
372
+ updated_at = ?, version = version + 1
373
+ WHERE id = ? AND system_owner = ? AND version = ?
374
+ `).run(definitionHash, JSON.stringify(definition), desiredStatus, desiredStatus === 'active' ? initialNext(definition.schedule, now) ?? null : null, now, id, owner, current.version);
375
+ }
376
+ }
377
+ const result = this.get(id);
378
+ this.database.prepare(`
379
+ INSERT INTO automation_system_reconciles(
380
+ idempotency_key, system_owner, automation_id, input_hash, result_json, created_at
381
+ ) VALUES (?, ?, ?, ?, ?, ?)
382
+ `).run(idempotencyKey, owner, id, inputHash, JSON.stringify(result), now);
383
+ return result;
384
+ });
385
+ }
386
+ get(id) {
387
+ const row = this.database.prepare('SELECT * FROM automation_definitions WHERE id = ?').get(id);
388
+ return row === undefined ? undefined : record(row);
389
+ }
390
+ list() {
391
+ return this.database.prepare('SELECT * FROM automation_definitions ORDER BY created_at, id').all()
392
+ .map(record);
393
+ }
394
+ health() {
395
+ const scalar = (sql) => this.database.prepare(sql).get().count;
396
+ return {
397
+ activeAutomations: scalar("SELECT COUNT(*) AS count FROM automation_definitions WHERE status = 'active'"),
398
+ pausedAutomations: scalar("SELECT COUNT(*) AS count FROM automation_definitions WHERE status = 'paused'"),
399
+ pendingTasks: scalar("SELECT COUNT(*) AS count FROM automation_tasks WHERE status IN ('scheduled', 'claimed')"),
400
+ runningTasks: scalar("SELECT COUNT(*) AS count FROM automation_tasks WHERE status = 'running'"),
401
+ failedRuns: scalar("SELECT COUNT(*) AS count FROM automation_runs WHERE status IN ('failed', 'timed_out', 'cancelled')"),
402
+ unknownRuns: scalar("SELECT COUNT(*) AS count FROM automation_runs WHERE status = 'unknown'"),
403
+ };
404
+ }
405
+ changeApproved(input) {
406
+ const inputHash = hash(input);
407
+ return this.transaction(() => {
408
+ const prior = this.database.prepare('SELECT input_hash, result_json FROM automation_changes WHERE idempotency_key = ?').get(input.idempotencyKey);
409
+ if (prior !== undefined) {
410
+ if (prior.input_hash !== inputHash) {
411
+ throw new AutomationStoreError('idempotency-conflict', 'automation change key was reused with different input');
412
+ }
413
+ return Object.freeze(JSON.parse(prior.result_json));
414
+ }
415
+ const current = this.get(input.automationId);
416
+ if (current === undefined)
417
+ throw new AutomationStoreError('not-found', 'automation was not found');
418
+ if (current.version !== input.expectedVersion) {
419
+ throw new AutomationStoreError('version-conflict', 'automation version changed');
420
+ }
421
+ if (current.status === 'deleted')
422
+ throw new AutomationStoreError('invalid-state', 'automation is deleted');
423
+ if (input.operation === 'pause' && current.status !== 'active') {
424
+ throw new AutomationStoreError('invalid-state', 'only an active automation can be paused');
425
+ }
426
+ if (input.operation === 'resume' && current.status !== 'paused') {
427
+ throw new AutomationStoreError('invalid-state', 'only a paused automation can be resumed');
428
+ }
429
+ const status = input.operation === 'delete'
430
+ ? 'deleted'
431
+ : input.operation === 'pause' ? 'paused' : 'active';
432
+ const now = this.now();
433
+ const next = status === 'active' ? initialNext(current.definition.schedule, now) : undefined;
434
+ this.database.prepare(`
435
+ UPDATE automation_definitions
436
+ SET status = ?, next_run_at = ?, updated_at = ?, version = version + 1
437
+ WHERE id = ? AND version = ?
438
+ `).run(status, next ?? null, now, current.id, current.version);
439
+ const result = this.get(current.id);
440
+ this.database.prepare(`
441
+ INSERT INTO automation_changes(
442
+ idempotency_key, automation_id, operation, expected_version, input_hash, result_json, created_at
443
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
444
+ `).run(input.idempotencyKey, input.automationId, input.operation, input.expectedVersion, inputHash, JSON.stringify(result), now);
445
+ return result;
446
+ });
447
+ }
448
+ materializeDue(input) {
449
+ if (!Number.isSafeInteger(input.now) || !Number.isSafeInteger(input.misfireGraceMs)
450
+ || input.misfireGraceMs < 0 || !Number.isSafeInteger(input.maxCatchUp)
451
+ || input.maxCatchUp <= 0 || input.maxCatchUp > 1_000) {
452
+ throw new AutomationStoreError('invalid-definition', 'materialization bounds are invalid');
453
+ }
454
+ return this.transaction(() => {
455
+ const due = this.database.prepare(`
456
+ SELECT * FROM automation_definitions
457
+ WHERE status = 'active' AND next_run_at IS NOT NULL AND next_run_at <= ?
458
+ ORDER BY next_run_at, id
459
+ `).all(input.now);
460
+ const created = [];
461
+ for (const row of due) {
462
+ const automation = record(row);
463
+ const firstDue = automation.nextRunAt;
464
+ const cutoff = input.now - input.misfireGraceMs;
465
+ const current = dueOccurrences(automation.definition.schedule, Math.max(firstDue - 1, cutoff), input.now, input.maxCatchUp);
466
+ const latestMissed = previousOccurrence(automation.definition.schedule, cutoff);
467
+ const missed = [];
468
+ if (latestMissed !== undefined && latestMissed >= firstDue) {
469
+ if (automation.definition.misfire.kind === 'latest') {
470
+ missed.push(latestMissed);
471
+ }
472
+ else if (automation.definition.misfire.kind === 'bounded-replay') {
473
+ let cursor = latestMissed;
474
+ while (cursor !== undefined && cursor >= firstDue
475
+ && missed.length < Math.min(automation.definition.misfire.limit, input.maxCatchUp)) {
476
+ missed.push(cursor);
477
+ cursor = previousOccurrence(automation.definition.schedule, cursor - 1);
478
+ }
479
+ missed.reverse();
480
+ }
481
+ }
482
+ const selected = [...new Set([...missed, ...current])].sort((left, right) => left - right)
483
+ .slice(-input.maxCatchUp);
484
+ if (selected.length === 0 && latestMissed !== undefined && latestMissed >= firstDue) {
485
+ const skipped = this.insertOccurrence({
486
+ automationId: automation.id,
487
+ triggerKind: 'scheduled',
488
+ triggerKey: String(latestMissed),
489
+ scheduledAt: latestMissed,
490
+ status: 'skipped',
491
+ reason: 'misfire-skip',
492
+ dryRun: false,
493
+ });
494
+ if (skipped.created)
495
+ created.push(skipped.value);
496
+ }
497
+ for (const scheduledAt of selected) {
498
+ const inserted = this.insertOccurrence({
499
+ automationId: automation.id,
500
+ triggerKind: 'scheduled',
501
+ triggerKey: String(scheduledAt),
502
+ scheduledAt,
503
+ status: 'pending',
504
+ dryRun: false,
505
+ });
506
+ if (inserted.created)
507
+ created.push(inserted.value);
508
+ }
509
+ const next = nextOccurrence(automation.definition.schedule, input.now);
510
+ this.database.prepare(`
511
+ UPDATE automation_definitions SET next_run_at = ?, updated_at = ? WHERE id = ?
512
+ `).run(next ?? null, input.now, automation.id);
513
+ }
514
+ return created;
515
+ });
516
+ }
517
+ ingestExternal(input) {
518
+ const eventId = text(input.externalEventId, 'externalEventId', 1_000);
519
+ if (!Number.isSafeInteger(input.occurredAt)) {
520
+ throw new AutomationStoreError('invalid-definition', 'occurredAt must be a safe integer');
521
+ }
522
+ return this.transaction(() => {
523
+ const automation = this.get(input.automationId);
524
+ if (automation === undefined)
525
+ throw new AutomationStoreError('not-found', 'automation was not found');
526
+ if (automation.status !== 'active')
527
+ throw new AutomationStoreError('invalid-state', 'automation is not active');
528
+ const inserted = this.insertOccurrence({
529
+ automationId: automation.id,
530
+ triggerKind: 'external',
531
+ triggerKey: eventId,
532
+ scheduledAt: input.occurredAt,
533
+ status: 'pending',
534
+ dryRun: false,
535
+ });
536
+ if (!inserted.created && inserted.value.scheduledAt !== input.occurredAt) {
537
+ throw new AutomationStoreError('idempotency-conflict', 'external event id was reused with another timestamp');
538
+ }
539
+ return inserted.value;
540
+ });
541
+ }
542
+ createManual(input) {
543
+ const requestId = text(input.requestId, 'requestId', 1_000);
544
+ return this.transaction(() => {
545
+ const automation = this.get(input.automationId);
546
+ if (automation === undefined)
547
+ throw new AutomationStoreError('not-found', 'automation was not found');
548
+ if (automation.status !== 'active')
549
+ throw new AutomationStoreError('invalid-state', 'automation is not active');
550
+ const occurrenceId = stableOccurrenceId(automation.id, 'manual', requestId);
551
+ const existing = this.database.prepare('SELECT * FROM automation_occurrences WHERE id = ?')
552
+ .get(occurrenceId);
553
+ if (existing !== undefined)
554
+ return occurrence(existing);
555
+ return this.insertOccurrence({
556
+ automationId: automation.id,
557
+ triggerKind: 'manual',
558
+ triggerKey: requestId,
559
+ scheduledAt: this.now(),
560
+ status: 'pending',
561
+ dryRun: input.dryRun,
562
+ }).value;
563
+ });
564
+ }
565
+ listOccurrences(input) {
566
+ if (!Number.isSafeInteger(input.limit) || input.limit <= 0 || input.limit > 1_000) {
567
+ throw new AutomationStoreError('invalid-definition', 'occurrence limit must be between 1 and 1000');
568
+ }
569
+ const rows = input.automationId === undefined
570
+ ? this.database.prepare(`
571
+ SELECT * FROM automation_occurrences ORDER BY scheduled_at, id LIMIT ?
572
+ `).all(input.limit)
573
+ : this.database.prepare(`
574
+ SELECT * FROM automation_occurrences WHERE automation_id = ? ORDER BY scheduled_at, id LIMIT ?
575
+ `).all(input.automationId, input.limit);
576
+ return rows.map(occurrence);
577
+ }
578
+ getOccurrence(id) {
579
+ const row = this.database.prepare('SELECT * FROM automation_occurrences WHERE id = ?').get(id);
580
+ return row === undefined ? undefined : occurrence(row);
581
+ }
582
+ listTasks(input) {
583
+ if (!Number.isSafeInteger(input.limit) || input.limit <= 0 || input.limit > 1_000) {
584
+ throw new AutomationStoreError('invalid-definition', 'task limit must be between 1 and 1000');
585
+ }
586
+ const rows = input.automationId === undefined
587
+ ? this.database.prepare('SELECT * FROM automation_tasks ORDER BY created_at, id LIMIT ?').all(input.limit)
588
+ : this.database.prepare(`
589
+ SELECT * FROM automation_tasks WHERE automation_id = ? ORDER BY created_at, id LIMIT ?
590
+ `).all(input.automationId, input.limit);
591
+ return rows.map(task);
592
+ }
593
+ getTaskRecord(id) {
594
+ return this.getTask(id);
595
+ }
596
+ acquireDuty(input) {
597
+ const ownerId = text(input.ownerId, 'ownerId', 500);
598
+ this.validateLease(input.now, input.leaseMs);
599
+ return this.transaction(() => {
600
+ const current = this.database.prepare('SELECT * FROM duty_lease WHERE singleton = 1').get();
601
+ if (current === undefined) {
602
+ this.database.prepare(`
603
+ INSERT INTO duty_lease(singleton, owner_id, fencing_token, lease_until, updated_at)
604
+ VALUES (1, ?, 1, ?, ?)
605
+ `).run(ownerId, input.now + input.leaseMs, input.now);
606
+ return duty({ owner_id: ownerId, fencing_token: 1, lease_until: input.now + input.leaseMs }, true);
607
+ }
608
+ if (current.owner_id !== ownerId && current.lease_until > input.now)
609
+ return duty(current, false);
610
+ const fencingToken = current.owner_id === ownerId && current.lease_until > input.now
611
+ ? current.fencing_token
612
+ : current.fencing_token + 1;
613
+ const leaseUntil = input.now + input.leaseMs;
614
+ this.database.prepare(`
615
+ UPDATE duty_lease SET owner_id = ?, fencing_token = ?, lease_until = ?, updated_at = ? WHERE singleton = 1
616
+ `).run(ownerId, fencingToken, leaseUntil, input.now);
617
+ return duty({ owner_id: ownerId, fencing_token: fencingToken, lease_until: leaseUntil }, true);
618
+ });
619
+ }
620
+ renewDuty(input) {
621
+ const ownerId = text(input.ownerId, 'ownerId', 500);
622
+ this.validateLease(input.now, input.leaseMs);
623
+ return this.transaction(() => {
624
+ const current = this.requireDuty(ownerId, input.fencingToken, input.now);
625
+ const leaseUntil = input.now + input.leaseMs;
626
+ this.database.prepare('UPDATE duty_lease SET lease_until = ?, updated_at = ? WHERE singleton = 1')
627
+ .run(leaseUntil, input.now);
628
+ return duty({ ...current, lease_until: leaseUntil }, true);
629
+ });
630
+ }
631
+ claimNextTask(input) {
632
+ this.validateLease(input.now, input.leaseMs);
633
+ return this.transaction(() => {
634
+ this.requireDuty(input.ownerId, input.fencingToken, input.now);
635
+ const candidates = this.database.prepare(`
636
+ SELECT task.* FROM automation_tasks task
637
+ JOIN automation_definitions definition ON definition.id = task.automation_id
638
+ WHERE task.status = 'scheduled' AND definition.status = 'active'
639
+ ORDER BY task.created_at, task.id LIMIT 100
640
+ `).all();
641
+ for (const candidate of candidates) {
642
+ const result = this.claimTaskInTransaction(candidate, input);
643
+ if (result !== undefined)
644
+ return result;
645
+ }
646
+ return undefined;
647
+ });
648
+ }
649
+ claimTask(input) {
650
+ this.validateLease(input.now, input.leaseMs);
651
+ return this.transaction(() => {
652
+ this.requireDuty(input.ownerId, input.fencingToken, input.now);
653
+ const row = this.database.prepare('SELECT * FROM automation_tasks WHERE id = ?').get(input.taskId);
654
+ if (row === undefined)
655
+ throw new AutomationStoreError('not-found', 'automation task was not found');
656
+ if (row.status !== 'scheduled')
657
+ throw new AutomationStoreError('invalid-state', 'only scheduled tasks can be claimed');
658
+ return this.claimTaskInTransaction(row, input);
659
+ });
660
+ }
661
+ startTask(input) {
662
+ const sessionId = text(input.sessionId, 'sessionId', 1_000);
663
+ this.validateLease(input.now, input.leaseMs);
664
+ return this.transaction(() => {
665
+ const row = this.requireMutableTask(input, ['claimed']);
666
+ const leaseUntil = input.now + input.leaseMs;
667
+ this.database.prepare(`
668
+ UPDATE automation_tasks SET status = 'running', lease_until = ?, updated_at = ? WHERE id = ?
669
+ `).run(leaseUntil, input.now, row.id);
670
+ this.database.prepare(`
671
+ UPDATE automation_attempts SET status = 'running', session_id = ?, started_at = ?, updated_at = ?
672
+ WHERE task_id = ? AND attempt_number = ? AND fencing_token = ?
673
+ `).run(sessionId, input.now, input.now, row.id, row.attempt_count, input.fencingToken);
674
+ return this.getTask(row.id);
675
+ });
676
+ }
677
+ heartbeatTask(input) {
678
+ this.validateLease(input.now, input.leaseMs);
679
+ return this.transaction(() => {
680
+ const row = this.requireMutableTask(input, ['claimed', 'running']);
681
+ this.database.prepare('UPDATE automation_tasks SET lease_until = ?, updated_at = ? WHERE id = ?')
682
+ .run(input.now + input.leaseMs, input.now, row.id);
683
+ return this.getTask(row.id);
684
+ });
685
+ }
686
+ requestCancellation(input) {
687
+ if (!Number.isSafeInteger(input.now))
688
+ throw new AutomationStoreError('invalid-definition', 'now must be a safe integer');
689
+ return this.transaction(() => {
690
+ const row = this.database.prepare('SELECT * FROM automation_tasks WHERE id = ?').get(input.taskId);
691
+ if (row === undefined)
692
+ throw new AutomationStoreError('not-found', 'automation task was not found');
693
+ if (!['claimed', 'running', 'scheduled'].includes(row.status)) {
694
+ throw new AutomationStoreError('invalid-state', 'terminal task cannot be cancelled');
695
+ }
696
+ if (row.status === 'scheduled') {
697
+ this.database.prepare(`UPDATE automation_tasks SET status = 'cancelled', cancel_requested = 1, updated_at = ? WHERE id = ?`)
698
+ .run(input.now, row.id);
699
+ this.database.prepare(`UPDATE automation_occurrences SET status = 'cancelled', reason = 'cancelled-before-claim', updated_at = ? WHERE id = ?`)
700
+ .run(input.now, row.occurrence_id);
701
+ }
702
+ else {
703
+ this.database.prepare('UPDATE automation_tasks SET cancel_requested = 1, updated_at = ? WHERE id = ?')
704
+ .run(input.now, row.id);
705
+ }
706
+ return this.getTask(row.id);
707
+ });
708
+ }
709
+ completeTask(input) {
710
+ if (!Number.isSafeInteger(input.now))
711
+ throw new AutomationStoreError('invalid-definition', 'now must be a safe integer');
712
+ const preview = typeof input.outputPreview === 'string' && Buffer.byteLength(input.outputPreview, 'utf8') <= 8_192
713
+ ? input.outputPreview
714
+ : (() => { throw new AutomationStoreError('invalid-definition', 'outputPreview exceeds 8192 bytes'); })();
715
+ const usageJson = JSON.stringify(input.usage);
716
+ if (usageJson === undefined || Buffer.byteLength(usageJson, 'utf8') > 16_384) {
717
+ throw new AutomationStoreError('invalid-definition', 'usage must be bounded JSON');
718
+ }
719
+ const evidenceAttribution = normalizeEvidenceAttribution(input.evidenceAttribution);
720
+ return this.transaction(() => {
721
+ const existing = this.database.prepare('SELECT * FROM automation_runs WHERE task_id = ?').get(input.taskId);
722
+ if (existing !== undefined) {
723
+ const taskRow = this.database.prepare('SELECT * FROM automation_tasks WHERE id = ?').get(input.taskId);
724
+ if (taskRow?.claimed_by !== input.ownerId || taskRow.fencing_token !== input.fencingToken) {
725
+ throw new AutomationStoreError('stale-fence', 'task completion belongs to another fence');
726
+ }
727
+ const exact = existing.status === input.outcome
728
+ && existing.session_id === (input.sessionId ?? null)
729
+ && existing.artifact_ref === (input.artifactRef ?? null)
730
+ && existing.output_preview === preview
731
+ && existing.usage_json === usageJson;
732
+ if (!exact)
733
+ throw new AutomationStoreError('idempotency-conflict', 'task completion was replayed with different output');
734
+ return run(existing);
735
+ }
736
+ const row = this.requireMutableTask(input, ['claimed', 'running']);
737
+ const attempt = this.database.prepare(`
738
+ SELECT * FROM automation_attempts WHERE task_id = ? AND attempt_number = ? AND fencing_token = ?
739
+ `).get(row.id, row.attempt_count, input.fencingToken);
740
+ if (attempt === undefined)
741
+ throw new AutomationStoreError('stale-fence', 'winning attempt was not found');
742
+ const runId = `run-${row.id}`;
743
+ const automation = this.get(row.automation_id);
744
+ if (automation === undefined)
745
+ throw new AutomationStoreError('invalid-state', 'automation definition was not found');
746
+ const evidence = buildOutcomeEvidence({
747
+ automation,
748
+ runId,
749
+ outcome: input.outcome,
750
+ occurredAt: input.now,
751
+ attribution: evidenceAttribution,
752
+ });
753
+ const deliveryStatus = input.outcome === 'succeeded'
754
+ && automation.definition.deliveryBindingId !== undefined
755
+ ? 'pending'
756
+ : null;
757
+ this.database.prepare(`
758
+ INSERT INTO automation_runs(
759
+ id, occurrence_id, automation_id, task_id, attempt_id, status, session_id, artifact_ref,
760
+ output_preview, usage_json, delivery_status, delivery_ref, evidence_status, evidence_json,
761
+ created_at, updated_at
762
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?)
763
+ `).run(runId, row.occurrence_id, row.automation_id, row.id, attempt.id, input.outcome, input.sessionId ?? null, input.artifactRef ?? null, preview, usageJson, deliveryStatus, evidence.status, evidence.json, input.now, input.now);
764
+ this.database.prepare(`
765
+ UPDATE automation_attempts
766
+ SET status = ?, failure_code = ?, finished_at = ?, updated_at = ? WHERE id = ?
767
+ `).run(input.outcome, input.outcome === 'succeeded' ? null : input.outcome, input.now, input.now, attempt.id);
768
+ this.database.prepare(`
769
+ UPDATE automation_tasks SET status = ?, lease_until = NULL, updated_at = ? WHERE id = ?
770
+ `).run(input.outcome, input.now, row.id);
771
+ this.database.prepare(`
772
+ UPDATE automation_occurrences SET status = ?, reason = ?, updated_at = ? WHERE id = ?
773
+ `).run(input.outcome, input.outcome === 'succeeded' ? null : input.outcome, input.now, row.occurrence_id);
774
+ return run(this.database.prepare('SELECT * FROM automation_runs WHERE id = ?').get(runId));
775
+ });
776
+ }
777
+ listPendingEvidence(limit) {
778
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1_000) {
779
+ throw new AutomationStoreError('invalid-definition', 'evidence limit must be between 1 and 1000');
780
+ }
781
+ return this.database.prepare(`
782
+ SELECT * FROM automation_runs WHERE evidence_status = 'pending' ORDER BY updated_at, id LIMIT ?
783
+ `).all(limit).map(run);
784
+ }
785
+ deferRunEvidence(input) {
786
+ if (!Number.isSafeInteger(input.now))
787
+ throw new AutomationStoreError('invalid-definition', 'now must be a safe integer');
788
+ const changed = this.database.prepare(`
789
+ UPDATE automation_runs SET updated_at = MAX(updated_at + 1, ?)
790
+ WHERE id = ? AND evidence_status = ?
791
+ `).run(input.now, input.runId, input.expectedStatus);
792
+ if (changed.changes !== 1) {
793
+ const current = this.database.prepare('SELECT * FROM automation_runs WHERE id = ?').get(input.runId);
794
+ if (current?.evidence_status === 'recorded')
795
+ return run(current);
796
+ throw new AutomationStoreError('version-conflict', 'run evidence state changed before deferral');
797
+ }
798
+ return run(this.database.prepare('SELECT * FROM automation_runs WHERE id = ?').get(input.runId));
799
+ }
800
+ completeRunEvidence(input) {
801
+ if (!Number.isSafeInteger(input.now))
802
+ throw new AutomationStoreError('invalid-definition', 'now must be a safe integer');
803
+ const changed = this.database.prepare(`
804
+ UPDATE automation_runs SET evidence_status = 'recorded', updated_at = ?
805
+ WHERE id = ? AND evidence_status = ?
806
+ `).run(input.now, input.runId, input.expectedStatus);
807
+ if (changed.changes !== 1) {
808
+ const current = this.database.prepare('SELECT * FROM automation_runs WHERE id = ?').get(input.runId);
809
+ if (current?.evidence_status === 'recorded')
810
+ return run(current);
811
+ throw new AutomationStoreError('version-conflict', 'run evidence state changed before completion');
812
+ }
813
+ return run(this.database.prepare('SELECT * FROM automation_runs WHERE id = ?').get(input.runId));
814
+ }
815
+ listPendingDeliveries(limit) {
816
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1_000) {
817
+ throw new AutomationStoreError('invalid-definition', 'delivery limit must be between 1 and 1000');
818
+ }
819
+ return this.database.prepare(`
820
+ SELECT * FROM automation_runs WHERE delivery_status = 'pending' ORDER BY created_at, id LIMIT ?
821
+ `).all(limit).map(run);
822
+ }
823
+ completeRunDelivery(input) {
824
+ if (!Number.isSafeInteger(input.now))
825
+ throw new AutomationStoreError('invalid-definition', 'now must be a safe integer');
826
+ const deliveryRef = text(input.deliveryRef, 'deliveryRef', 500);
827
+ const changed = this.database.prepare(`
828
+ UPDATE automation_runs SET delivery_status = 'enqueued', delivery_ref = ?, updated_at = ?
829
+ WHERE id = ? AND delivery_status = ?
830
+ `).run(deliveryRef, input.now, input.runId, input.expectedStatus);
831
+ if (changed.changes !== 1) {
832
+ const current = this.database.prepare('SELECT * FROM automation_runs WHERE id = ?').get(input.runId);
833
+ if (current?.delivery_status === 'enqueued' && current.delivery_ref === deliveryRef)
834
+ return run(current);
835
+ throw new AutomationStoreError('version-conflict', 'run delivery state changed before completion');
836
+ }
837
+ return run(this.database.prepare('SELECT * FROM automation_runs WHERE id = ?').get(input.runId));
838
+ }
839
+ suppressRunDelivery(input) {
840
+ if (!Number.isSafeInteger(input.now))
841
+ throw new AutomationStoreError('invalid-definition', 'now must be a safe integer');
842
+ const changed = this.database.prepare(`
843
+ UPDATE automation_runs SET delivery_status = 'suppressed', delivery_ref = NULL, updated_at = ?
844
+ WHERE id = ? AND delivery_status = ?
845
+ `).run(input.now, input.runId, input.expectedStatus);
846
+ if (changed.changes !== 1) {
847
+ const current = this.database.prepare('SELECT * FROM automation_runs WHERE id = ?').get(input.runId);
848
+ if (current?.delivery_status === 'suppressed' && current.delivery_ref === null)
849
+ return run(current);
850
+ throw new AutomationStoreError('version-conflict', 'run delivery state changed before suppression');
851
+ }
852
+ return run(this.database.prepare('SELECT * FROM automation_runs WHERE id = ?').get(input.runId));
853
+ }
854
+ listRuns(input) {
855
+ if (!Number.isSafeInteger(input.limit) || input.limit <= 0 || input.limit > 1_000) {
856
+ throw new AutomationStoreError('invalid-definition', 'run limit must be between 1 and 1000');
857
+ }
858
+ const rows = input.automationId === undefined
859
+ ? this.database.prepare('SELECT * FROM automation_runs ORDER BY created_at, id LIMIT ?').all(input.limit)
860
+ : this.database.prepare(`SELECT * FROM automation_runs WHERE automation_id = ? ORDER BY created_at, id LIMIT ?`)
861
+ .all(input.automationId, input.limit);
862
+ return rows.map(run);
863
+ }
864
+ recoverExpiredTasks(input) {
865
+ if (!Number.isSafeInteger(input.now))
866
+ throw new AutomationStoreError('invalid-definition', 'now must be a safe integer');
867
+ const limit = input.limit ?? 100;
868
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1_000) {
869
+ throw new AutomationStoreError('invalid-definition', 'recovery limit must be between 1 and 1000');
870
+ }
871
+ return this.transaction(() => {
872
+ const rows = this.database.prepare(`
873
+ SELECT * FROM automation_tasks
874
+ WHERE status IN ('claimed', 'running') AND lease_until IS NOT NULL AND lease_until <= ?
875
+ ORDER BY lease_until, id LIMIT ?
876
+ `).all(input.now, limit);
877
+ const recovered = [];
878
+ for (const row of rows) {
879
+ const definition = this.get(row.automation_id).definition;
880
+ const retry = row.status === 'running'
881
+ && definition.retrySafety === 'idempotent'
882
+ && row.attempt_count <= definition.maxRetries;
883
+ const target = row.status === 'claimed' || retry ? 'scheduled' : 'unknown';
884
+ const attemptStatus = row.status === 'claimed' ? 'lost' : 'unknown';
885
+ this.database.prepare(`
886
+ UPDATE automation_attempts
887
+ SET status = ?, failure_code = ?, finished_at = ?, updated_at = ?
888
+ WHERE task_id = ? AND attempt_number = ? AND fencing_token = ?
889
+ `).run(attemptStatus, row.status === 'claimed' ? 'claim-lease-expired' : 'runner-lease-expired', input.now, input.now, row.id, row.attempt_count, row.fencing_token);
890
+ this.database.prepare(`
891
+ UPDATE automation_tasks
892
+ SET status = ?, claimed_by = NULL, fencing_token = NULL, lease_until = NULL, updated_at = ?
893
+ WHERE id = ?
894
+ `).run(target, input.now, row.id);
895
+ if (target === 'unknown') {
896
+ this.database.prepare(`
897
+ UPDATE automation_occurrences SET status = 'unknown', reason = 'runner-lease-expired', updated_at = ? WHERE id = ?
898
+ `).run(input.now, row.occurrence_id);
899
+ }
900
+ recovered.push(this.getTask(row.id));
901
+ }
902
+ return recovered;
903
+ });
904
+ }
905
+ validateLease(now, leaseMs) {
906
+ if (!Number.isSafeInteger(now) || !Number.isSafeInteger(leaseMs) || leaseMs <= 0 || leaseMs > 86_400_000
907
+ || !Number.isSafeInteger(now + leaseMs)) {
908
+ throw new AutomationStoreError('invalid-definition', 'lease bounds are invalid');
909
+ }
910
+ }
911
+ requireDuty(ownerId, fencingToken, now) {
912
+ const row = this.database.prepare('SELECT * FROM duty_lease WHERE singleton = 1').get();
913
+ if (row === undefined || row.owner_id !== ownerId || row.fencing_token !== fencingToken || row.lease_until <= now) {
914
+ throw new AutomationStoreError('stale-fence', 'duty ownership is missing, expired, or fenced');
915
+ }
916
+ return row;
917
+ }
918
+ getTask(id) {
919
+ const row = this.database.prepare('SELECT * FROM automation_tasks WHERE id = ?').get(id);
920
+ return row === undefined ? undefined : task(row);
921
+ }
922
+ requireMutableTask(input, statuses) {
923
+ this.requireDuty(input.ownerId, input.fencingToken, input.now);
924
+ const row = this.database.prepare('SELECT * FROM automation_tasks WHERE id = ?').get(input.taskId);
925
+ if (row === undefined)
926
+ throw new AutomationStoreError('not-found', 'automation task was not found');
927
+ if (row.claimed_by !== input.ownerId || row.fencing_token !== input.fencingToken || row.lease_until === null
928
+ || row.lease_until <= input.now) {
929
+ throw new AutomationStoreError('stale-fence', 'task ownership is missing, expired, or fenced');
930
+ }
931
+ if (!statuses.includes(row.status))
932
+ throw new AutomationStoreError('invalid-state', 'task state does not allow this transition');
933
+ return row;
934
+ }
935
+ claimTaskInTransaction(row, input) {
936
+ const automation = this.get(row.automation_id);
937
+ if (automation === undefined || automation.status !== 'active')
938
+ return undefined;
939
+ const active = this.database.prepare(`
940
+ SELECT * FROM automation_tasks
941
+ WHERE automation_id = ? AND id != ? AND status IN ('claimed', 'running')
942
+ ORDER BY created_at, id
943
+ `).all(row.automation_id, row.id);
944
+ if (active.length > 0 && automation.definition.overlap === 'queue-one')
945
+ return undefined;
946
+ if (active.length > 0 && automation.definition.overlap === 'skip') {
947
+ this.database.prepare(`
948
+ UPDATE automation_tasks SET status = 'cancelled', cancel_requested = 1, updated_at = ? WHERE id = ?
949
+ `).run(input.now, row.id);
950
+ this.database.prepare(`
951
+ UPDATE automation_occurrences SET status = 'skipped', reason = 'overlap-skip', updated_at = ? WHERE id = ?
952
+ `).run(input.now, row.occurrence_id);
953
+ return this.getTask(row.id);
954
+ }
955
+ if (active.length > 0 && automation.definition.overlap === 'cancel-previous') {
956
+ this.database.prepare(`
957
+ UPDATE automation_tasks SET cancel_requested = 1, updated_at = ?
958
+ WHERE automation_id = ? AND id != ? AND status IN ('claimed', 'running')
959
+ `).run(input.now, row.automation_id, row.id);
960
+ }
961
+ const attemptNumber = row.attempt_count + 1;
962
+ const attemptId = `attempt-${row.id}-${attemptNumber}`;
963
+ const leaseUntil = input.now + input.leaseMs;
964
+ this.database.prepare(`
965
+ UPDATE automation_tasks
966
+ SET status = 'claimed', claimed_by = ?, fencing_token = ?, lease_until = ?,
967
+ attempt_count = ?, updated_at = ? WHERE id = ? AND status = 'scheduled'
968
+ `).run(input.ownerId, input.fencingToken, leaseUntil, attemptNumber, input.now, row.id);
969
+ this.database.prepare(`
970
+ INSERT INTO automation_attempts(
971
+ id, task_id, attempt_number, owner_id, fencing_token, status, session_id,
972
+ failure_code, started_at, finished_at, created_at, updated_at
973
+ ) VALUES (?, ?, ?, ?, ?, 'claimed', NULL, NULL, NULL, NULL, ?, ?)
974
+ `).run(attemptId, row.id, attemptNumber, input.ownerId, input.fencingToken, input.now, input.now);
975
+ return this.getTask(row.id);
976
+ }
977
+ insertOccurrence(input) {
978
+ const id = stableOccurrenceId(input.automationId, input.triggerKind, input.triggerKey);
979
+ const existing = this.database.prepare('SELECT * FROM automation_occurrences WHERE id = ?').get(id);
980
+ if (existing !== undefined)
981
+ return { value: occurrence(existing), created: false };
982
+ const now = this.now();
983
+ this.database.prepare(`
984
+ INSERT INTO automation_occurrences(
985
+ id, automation_id, trigger_kind, trigger_key, scheduled_at, status, reason, dry_run, created_at, updated_at
986
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
987
+ `).run(id, input.automationId, input.triggerKind, input.triggerKey, input.scheduledAt, input.status, input.reason ?? null, input.dryRun ? 1 : 0, now, now);
988
+ if (input.status === 'pending') {
989
+ this.database.prepare(`
990
+ INSERT INTO automation_tasks(
991
+ id, occurrence_id, automation_id, status, cancel_requested, claimed_by,
992
+ fencing_token, lease_until, attempt_count, created_at, updated_at
993
+ ) VALUES (?, ?, ?, 'scheduled', 0, NULL, NULL, NULL, 0, ?, ?)
994
+ `).run(`task-${id}`, id, input.automationId, now, now);
995
+ }
996
+ return {
997
+ value: occurrence(this.database.prepare('SELECT * FROM automation_occurrences WHERE id = ?').get(id)),
998
+ created: true,
999
+ };
1000
+ }
1001
+ }
1002
+ //# sourceMappingURL=store.js.map