@dsh-enhanced/assistant-automations 0.1.24 → 0.1.31

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 (51) hide show
  1. package/README.md +18 -5
  2. package/lib/acceptance.d.ts +52 -0
  3. package/lib/acceptance.d.ts.map +1 -0
  4. package/lib/acceptance.js +2 -0
  5. package/lib/acceptance.js.map +1 -0
  6. package/lib/coordinator.d.ts +5 -0
  7. package/lib/coordinator.d.ts.map +1 -1
  8. package/lib/coordinator.js +21 -0
  9. package/lib/coordinator.js.map +1 -1
  10. package/lib/external-event.d.ts +32 -0
  11. package/lib/external-event.d.ts.map +1 -0
  12. package/lib/external-event.js +114 -0
  13. package/lib/external-event.js.map +1 -0
  14. package/lib/growth.d.ts +10 -0
  15. package/lib/growth.d.ts.map +1 -1
  16. package/lib/growth.js +49 -16
  17. package/lib/growth.js.map +1 -1
  18. package/lib/index.d.ts +6 -1
  19. package/lib/index.d.ts.map +1 -1
  20. package/lib/index.js +2 -1
  21. package/lib/index.js.map +1 -1
  22. package/lib/operator.d.ts +68 -2
  23. package/lib/operator.d.ts.map +1 -1
  24. package/lib/operator.js +922 -6
  25. package/lib/operator.js.map +1 -1
  26. package/lib/preparation.d.ts +52 -0
  27. package/lib/preparation.d.ts.map +1 -0
  28. package/lib/preparation.js +191 -0
  29. package/lib/preparation.js.map +1 -0
  30. package/lib/runner.d.ts +29 -0
  31. package/lib/runner.d.ts.map +1 -1
  32. package/lib/runner.js +72 -2
  33. package/lib/runner.js.map +1 -1
  34. package/lib/service.d.ts +37 -0
  35. package/lib/service.d.ts.map +1 -1
  36. package/lib/service.js +269 -60
  37. package/lib/service.js.map +1 -1
  38. package/lib/sqlite.d.ts +1 -1
  39. package/lib/sqlite.d.ts.map +1 -1
  40. package/lib/sqlite.js +77 -3
  41. package/lib/sqlite.js.map +1 -1
  42. package/lib/store.d.ts +55 -0
  43. package/lib/store.d.ts.map +1 -1
  44. package/lib/store.js +233 -14
  45. package/lib/store.js.map +1 -1
  46. package/lib/types.d.ts +3 -0
  47. package/lib/types.d.ts.map +1 -1
  48. package/lib/types.js.map +1 -1
  49. package/lib/version.d.ts +1 -1
  50. package/lib/version.js +1 -1
  51. package/package.json +29 -23
package/lib/store.js CHANGED
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { isAbsolute, resolve } from 'node:path';
3
3
  import { dueOccurrences, nextOccurrence, previousOccurrence, validateSchedule, } from './schedule.js';
4
4
  import { AutomationDatabaseError, openAutomationDatabase } from './sqlite.js';
5
+ import { canonicalExternalEventEnvelope, externalEventDigest, parseExternalEventEnvelope } from './external-event.js';
5
6
  import { isHostAutomationDefinition, legacyAutomationExecutionDiagnostic } from './types.js';
6
7
  export class AutomationStoreError extends Error {
7
8
  code;
@@ -552,6 +553,25 @@ function parseEvidence(row) {
552
553
  return Object.freeze(value);
553
554
  }
554
555
  function occurrence(row) {
556
+ let externalEvent;
557
+ if ((row.external_event_json === null) !== (row.external_event_digest === null)) {
558
+ throw new AutomationStoreError('invalid-state', 'external event provenance columns must be paired');
559
+ }
560
+ if (row.external_event_json !== null && row.external_event_digest !== null) {
561
+ try {
562
+ externalEvent = parseExternalEventEnvelope(JSON.parse(row.external_event_json));
563
+ }
564
+ catch {
565
+ throw new AutomationStoreError('invalid-state', 'external event provenance is invalid');
566
+ }
567
+ if (externalEventDigest(externalEvent) !== row.external_event_digest) {
568
+ throw new AutomationStoreError('invalid-state', 'external event provenance digest does not match');
569
+ }
570
+ if (row.trigger_kind !== 'external' || externalEvent.deduplicationKey !== row.trigger_key
571
+ || externalEvent.target.automationId !== row.automation_id || externalEvent.event.occurredAt !== row.scheduled_at) {
572
+ throw new AutomationStoreError('invalid-state', 'external event provenance does not bind its occurrence');
573
+ }
574
+ }
555
575
  return Object.freeze({
556
576
  id: row.id,
557
577
  automationId: row.automation_id,
@@ -563,6 +583,7 @@ function occurrence(row) {
563
583
  dryRun: row.dry_run === 1,
564
584
  createdAt: row.created_at,
565
585
  updatedAt: row.updated_at,
586
+ ...(externalEvent === undefined ? {} : { externalEvent, externalEventDigest: row.external_event_digest }),
566
587
  });
567
588
  }
568
589
  function task(row) {
@@ -1826,9 +1847,15 @@ export class AutomationStore {
1826
1847
  }
1827
1848
  ingestExternal(input) {
1828
1849
  const eventId = text(input.externalEventId, 'externalEventId', 1_000);
1829
- if (!Number.isSafeInteger(input.occurredAt)) {
1830
- throw new AutomationStoreError('invalid-definition', 'occurredAt must be a safe integer');
1850
+ if (!Number.isSafeInteger(input.occurredAt) || input.occurredAt < 0) {
1851
+ throw new AutomationStoreError('invalid-definition', 'occurredAt must be a non-negative safe integer');
1852
+ }
1853
+ const envelope = input.envelope === undefined ? undefined : parseExternalEventEnvelope(input.envelope);
1854
+ if (envelope !== undefined && (envelope.deduplicationKey !== eventId
1855
+ || envelope.target.automationId !== input.automationId || envelope.event.occurredAt !== input.occurredAt)) {
1856
+ throw new AutomationStoreError('invalid-definition', 'external event envelope does not bind this ingest input');
1831
1857
  }
1858
+ const envelopeDigest = envelope === undefined ? undefined : externalEventDigest(envelope);
1832
1859
  return this.transaction(() => {
1833
1860
  const automation = this.get(input.automationId);
1834
1861
  if (automation === undefined)
@@ -1842,8 +1869,10 @@ export class AutomationStore {
1842
1869
  scheduledAt: input.occurredAt,
1843
1870
  status: 'pending',
1844
1871
  dryRun: false,
1872
+ ...(envelope === undefined ? {} : { externalEvent: envelope, externalEventDigest: envelopeDigest }),
1845
1873
  });
1846
- if (!inserted.created && inserted.value.scheduledAt !== input.occurredAt) {
1874
+ if (!inserted.created && (inserted.value.scheduledAt !== input.occurredAt
1875
+ || inserted.value.externalEventDigest !== envelopeDigest)) {
1847
1876
  throw new AutomationStoreError('idempotency-conflict', 'external event id was reused with another timestamp');
1848
1877
  }
1849
1878
  return inserted.value;
@@ -1934,6 +1963,72 @@ export class AutomationStore {
1934
1963
  }
1935
1964
  throw new AutomationStoreError('invalid-state', 'automation run has an invalid execution mode');
1936
1965
  }
1966
+ /**
1967
+ * Durable, pre-prompt binding to an immutable task attempt. A recorded
1968
+ * binding means the prompt boundary is treated as crossed even if a process
1969
+ * dies immediately afterwards; recovery must never replay its side effects.
1970
+ */
1971
+ bindTaskAcceptance(input) {
1972
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u.test(input.contractId)
1973
+ || !/^[a-f0-9]{64}$/u.test(input.contractDigest)
1974
+ || !Number.isSafeInteger(input.dispatchedAt) || input.dispatchedAt < 0
1975
+ || !Number.isSafeInteger(input.owner.principalVersion) || input.owner.principalVersion < 1
1976
+ || !Number.isSafeInteger(input.bindingVersion) || input.bindingVersion < 1
1977
+ || !Number.isSafeInteger(input.bindingGeneration) || input.bindingGeneration < 1) {
1978
+ throw new AutomationStoreError('invalid-definition', 'acceptance binding is invalid');
1979
+ }
1980
+ const required = [input.scope.workspace, input.scope.preset, input.owner.principalRecordId, input.bindingId];
1981
+ if (required.some(value => typeof value !== 'string' || value.trim() === '' || Buffer.byteLength(value, 'utf8') > 4_096)) {
1982
+ throw new AutomationStoreError('invalid-definition', 'acceptance binding text is invalid');
1983
+ }
1984
+ this.transaction(() => {
1985
+ const taskRow = this.database.prepare('SELECT * FROM automation_tasks WHERE id = ?').get(input.taskId);
1986
+ if (taskRow === undefined || !['claimed', 'running'].includes(taskRow.status) || taskRow.attempt_count < 1) {
1987
+ throw new AutomationStoreError('invalid-state', 'acceptance task is not an active attempt');
1988
+ }
1989
+ const snapshot = this.getTaskExecutionSnapshot(input.taskId);
1990
+ const occurrenceRow = this.database.prepare('SELECT * FROM automation_occurrences WHERE id = ?').get(taskRow.occurrence_id);
1991
+ if (snapshot === undefined || isHostAutomationDefinition(snapshot.definition) || occurrenceRow?.dry_run !== 0
1992
+ || snapshot.definition.workspace !== input.scope.workspace || snapshot.definition.agentPreset !== input.scope.preset) {
1993
+ throw new AutomationStoreError('invalid-state', 'acceptance does not match immutable production task');
1994
+ }
1995
+ const existing = this.database.prepare('SELECT * FROM automation_task_acceptance WHERE task_id = ?').get(input.taskId);
1996
+ if (existing !== undefined) {
1997
+ throw new AutomationStoreError('invalid-state', existing.submitted === 1
1998
+ ? 'accepted task prompt may not be submitted again'
1999
+ : 'acceptance binding already exists');
2000
+ }
2001
+ this.database.prepare(`INSERT INTO automation_task_acceptance(
2002
+ task_id, contract_id, contract_digest, workspace, preset, principal_record_id,
2003
+ principal_version, binding_id, binding_version, binding_generation, dispatched_at, submitted
2004
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`).run(input.taskId, input.contractId, input.contractDigest, input.scope.workspace, input.scope.preset, input.owner.principalRecordId, input.owner.principalVersion, input.bindingId, input.bindingVersion, input.bindingGeneration, input.dispatchedAt);
2005
+ });
2006
+ }
2007
+ inspectAcceptedExecution(contract) {
2008
+ const row = this.database.prepare(`SELECT acceptance.*, run.* FROM automation_task_acceptance acceptance
2009
+ LEFT JOIN automation_runs run ON run.task_id = acceptance.task_id
2010
+ WHERE acceptance.contract_id = ?`).get(contract.id);
2011
+ if (row === undefined || row.contract_digest !== contract.digest || row.workspace !== contract.scope.workspace
2012
+ || row.preset !== contract.scope.preset || row.principal_record_id !== contract.owner.principalRecordId
2013
+ || row.principal_version !== contract.owner.principalVersion || contract.task.kind !== 'automation-run'
2014
+ || contract.task.ref !== `run-${row.task_id}`)
2015
+ return null;
2016
+ if (row.id === undefined || row.status === undefined || row.created_at === undefined || row.diagnostic_json === undefined)
2017
+ return null;
2018
+ const snapshot = this.getRunExecutionSnapshot(row.id);
2019
+ if (snapshot === undefined || isHostAutomationDefinition(snapshot.definition)
2020
+ || snapshot.definition.workspace !== row.workspace || snapshot.definition.agentPreset !== row.preset
2021
+ || this.getRunExecutionMode(row.id) !== 'production')
2022
+ return null;
2023
+ const status = row.status === 'timed_out' ? 'timed-out' : row.status;
2024
+ const quiescent = row.quiescent === 1;
2025
+ return Object.freeze({ contractId: row.contract_id, contractDigest: row.contract_digest,
2026
+ dispatchedAt: row.dispatched_at, status, quiescent, completedAt: row.created_at, executionRef: row.id });
2027
+ }
2028
+ markAcceptedTaskQuiescent(taskId, quiescent) {
2029
+ this.database.prepare(`UPDATE automation_task_acceptance SET quiescent = ? WHERE task_id = ?`)
2030
+ .run(quiescent ? 1 : 0, taskId);
2031
+ }
1937
2032
  /**
1938
2033
  * Re-prove the exact production tuple immediately before an effect sink.
1939
2034
  * Legacy rows, mutable current definitions, and a mode flag alone are never
@@ -2160,6 +2255,7 @@ export class AutomationStore {
2160
2255
  const candidates = this.database.prepare(`
2161
2256
  SELECT task.* FROM automation_tasks task
2162
2257
  JOIN automation_definitions definition ON definition.id = task.automation_id
2258
+ JOIN automation_occurrences occurrence ON occurrence.id = task.occurrence_id
2163
2259
  WHERE task.status = 'scheduled' AND (
2164
2260
  definition.status = 'active'
2165
2261
  OR (
@@ -2168,7 +2264,7 @@ export class AutomationStore {
2168
2264
  AND ${pausedGrowthTaskAdmission('task', 'definition')}
2169
2265
  )
2170
2266
  )
2171
- ORDER BY task.created_at, task.id LIMIT 100
2267
+ ORDER BY occurrence.scheduled_at ASC, occurrence.trigger_key ASC, task.id ASC LIMIT 100
2172
2268
  `).all(growthAutomationOwner);
2173
2269
  for (const candidate of candidates) {
2174
2270
  const result = this.claimTaskInTransaction(candidate, input, input.hostAvailability);
@@ -2178,6 +2274,50 @@ export class AutomationStore {
2178
2274
  return undefined;
2179
2275
  });
2180
2276
  }
2277
+ /**
2278
+ * Make cancel-previous visible even when the coordinator has no free local
2279
+ * runner slot. The cancellation request is fenced by the duty lease and is
2280
+ * committed with the still-scheduled successor as its durable cause; a
2281
+ * later tick claims that successor only after the prior lease is released.
2282
+ */
2283
+ requestCancellationForSupersededTasks(input) {
2284
+ return this.transaction(() => {
2285
+ this.requireDuty(input.ownerId, input.fencingToken, input.now);
2286
+ const successors = this.database.prepare(`
2287
+ SELECT task.*
2288
+ FROM automation_tasks task
2289
+ JOIN automation_definitions definition ON definition.id = task.automation_id
2290
+ WHERE task.status = 'scheduled'
2291
+ AND task.attempt_count = 0
2292
+ AND (
2293
+ definition.status = 'active'
2294
+ OR (
2295
+ definition.status = 'paused'
2296
+ AND definition.system_owner = ?
2297
+ AND ${pausedGrowthTaskAdmission('task', 'definition')}
2298
+ )
2299
+ )
2300
+ AND json_extract(definition.definition_json, '$.overlap') = 'cancel-previous'
2301
+ `).all(growthAutomationOwner);
2302
+ return [...new Set(successors.flatMap(task => this.requestCancellationForNewerOccurrenceInTransaction(task, input.now)))];
2303
+ });
2304
+ }
2305
+ /** Bound queue-one backlog while its active predecessor still owns a slot. */
2306
+ coalesceQueuedTasks(input) {
2307
+ return this.transaction(() => {
2308
+ this.requireDuty(input.ownerId, input.fencingToken, input.now);
2309
+ const automations = this.database.prepare(`
2310
+ SELECT DISTINCT task.automation_id, occurrence.dry_run, task.occurrence_id
2311
+ FROM automation_tasks task
2312
+ JOIN automation_definitions definition ON definition.id = task.automation_id
2313
+ JOIN automation_occurrences occurrence ON occurrence.id = task.occurrence_id
2314
+ WHERE task.status IN ('claimed', 'running')
2315
+ AND definition.status = 'active'
2316
+ AND json_extract(definition.definition_json, '$.overlap') = 'queue-one'
2317
+ `).all();
2318
+ return automations.flatMap(row => this.coalesceQueueOneInTransaction(row.automation_id, row.occurrence_id, input.now));
2319
+ });
2320
+ }
2181
2321
  claimTask(input) {
2182
2322
  this.validateLease(input.now, input.leaseMs);
2183
2323
  return this.transaction(() => {
@@ -2854,9 +2994,14 @@ export class AutomationStore {
2854
2994
  throw new AutomationStoreError('invalid-state', 'expired task has no matching execution attempt');
2855
2995
  }
2856
2996
  const snapshot = automationSnapshot(attempt, row.automation_id);
2997
+ const accepted = this.database.prepare('SELECT submitted FROM automation_task_acceptance WHERE task_id = ?')
2998
+ .get(row.id);
2857
2999
  const retry = row.status === 'running'
2858
3000
  && snapshot?.definition.retrySafety === 'idempotent'
2859
- && row.attempt_count <= snapshot.definition.maxRetries;
3001
+ && row.attempt_count <= snapshot.definition.maxRetries
3002
+ // Accepted task prompts are side-effect capable. A lease/restart
3003
+ // can recover their terminal unknown receipt but cannot replay them.
3004
+ && accepted?.submitted !== 1;
2860
3005
  const target = row.status === 'claimed' || retry ? 'scheduled' : 'unknown';
2861
3006
  const attemptStatus = row.status === 'claimed' ? 'lost' : 'unknown';
2862
3007
  this.database.prepare(`
@@ -3019,10 +3164,16 @@ export class AutomationStore {
3019
3164
  const active = this.database.prepare(`
3020
3165
  SELECT * FROM automation_tasks
3021
3166
  WHERE automation_id = ? AND id != ? AND status IN ('claimed', 'running')
3167
+ AND (SELECT dry_run FROM automation_occurrences WHERE id = automation_tasks.occurrence_id)
3168
+ = (SELECT dry_run FROM automation_occurrences WHERE id = ?)
3022
3169
  ORDER BY created_at, id
3023
- `).all(row.automation_id, row.id);
3024
- if (active.length > 0 && automation.definition.overlap === 'queue-one')
3170
+ `).all(row.automation_id, row.id, row.occurrence_id);
3171
+ if (active.length > 0 && automation.definition.overlap === 'queue-one') {
3172
+ const superseded = this.coalesceQueueOneInTransaction(row.automation_id, row.occurrence_id, input.now);
3173
+ if (superseded.includes(row.id))
3174
+ return this.getTask(row.id);
3025
3175
  return undefined;
3176
+ }
3026
3177
  if (active.length > 0 && automation.definition.overlap === 'skip') {
3027
3178
  this.database.prepare(`
3028
3179
  UPDATE automation_tasks SET status = 'cancelled', cancel_requested = 1, updated_at = ? WHERE id = ?
@@ -3033,10 +3184,12 @@ export class AutomationStore {
3033
3184
  return this.getTask(row.id);
3034
3185
  }
3035
3186
  if (active.length > 0 && automation.definition.overlap === 'cancel-previous') {
3036
- this.database.prepare(`
3037
- UPDATE automation_tasks SET cancel_requested = 1, updated_at = ?
3038
- WHERE automation_id = ? AND id != ? AND status IN ('claimed', 'running')
3039
- `).run(input.now, row.automation_id, row.id);
3187
+ this.requestCancellationForNewerOccurrenceInTransaction(row, input.now);
3188
+ // A cancellation is cooperative and fenced. Do not claim the
3189
+ // successor until every active predecessor has actually released its
3190
+ // lease/slot; otherwise maxConcurrency > 1 and direct claims can run
3191
+ // both sides of a purported replacement concurrently.
3192
+ return undefined;
3040
3193
  }
3041
3194
  const attemptNumber = row.attempt_count + 1;
3042
3195
  const attemptId = `attempt-${row.id}-${attemptNumber}`;
@@ -3056,6 +3209,72 @@ export class AutomationStore {
3056
3209
  `).run(attemptId, row.id, attemptNumber, input.ownerId, input.fencingToken, snapshot.hash, snapshot.json, input.now, input.now);
3057
3210
  return this.getTask(row.id);
3058
3211
  }
3212
+ /**
3213
+ * Request cancellation only when a fresh successor is strictly later than
3214
+ * an active occurrence. Equal timestamps have no causal order, and retry
3215
+ * rows retain their existing occurrence rather than superseding it.
3216
+ */
3217
+ requestCancellationForNewerOccurrenceInTransaction(successor, now) {
3218
+ if (successor.attempt_count !== 0)
3219
+ return [];
3220
+ const successorOccurrence = this.database.prepare(`
3221
+ SELECT scheduled_at, dry_run FROM automation_occurrences WHERE id = ?
3222
+ `).get(successor.occurrence_id);
3223
+ if (successorOccurrence === undefined) {
3224
+ throw new AutomationStoreError('invalid-state', 'scheduled successor has no occurrence');
3225
+ }
3226
+ const active = this.database.prepare(`
3227
+ SELECT task.id
3228
+ FROM automation_tasks task
3229
+ JOIN automation_occurrences occurrence ON occurrence.id = task.occurrence_id
3230
+ WHERE task.automation_id = ?
3231
+ AND task.id != ?
3232
+ AND task.status IN ('claimed', 'running')
3233
+ AND occurrence.dry_run = ?
3234
+ AND occurrence.scheduled_at < ?
3235
+ ORDER BY occurrence.scheduled_at ASC, task.id ASC
3236
+ `).all(successor.automation_id, successor.id, successorOccurrence.dry_run, successorOccurrence.scheduled_at);
3237
+ for (const task of active) {
3238
+ this.database.prepare(`
3239
+ UPDATE automation_tasks SET cancel_requested = 1, updated_at = ?
3240
+ WHERE id = ? AND status IN ('claimed', 'running')
3241
+ `).run(now, task.id);
3242
+ }
3243
+ return active.map(task => task.id);
3244
+ }
3245
+ /**
3246
+ * Keep the latest not-yet-attempted occurrence only. A scheduled retry
3247
+ * already has attempt history and must remain auditable/recoverable, so
3248
+ * occurrence coalescing never changes it.
3249
+ */
3250
+ coalesceQueueOneInTransaction(automationId, occurrenceId, now) {
3251
+ const occurrence = this.database.prepare(`SELECT dry_run FROM automation_occurrences WHERE id = ?`)
3252
+ .get(occurrenceId);
3253
+ if (occurrence === undefined)
3254
+ throw new AutomationStoreError('invalid-state', 'queue-one task has no occurrence');
3255
+ const pending = this.database.prepare(`
3256
+ SELECT task.*
3257
+ FROM automation_tasks task
3258
+ JOIN automation_occurrences occurrence ON occurrence.id = task.occurrence_id
3259
+ WHERE task.automation_id = ? AND task.status = 'scheduled' AND task.attempt_count = 0
3260
+ AND occurrence.dry_run = ?
3261
+ ORDER BY occurrence.scheduled_at DESC, occurrence.trigger_key DESC, task.id DESC
3262
+ `).all(automationId, occurrence.dry_run);
3263
+ const superseded = pending.slice(1);
3264
+ for (const task of superseded) {
3265
+ this.database.prepare(`
3266
+ UPDATE automation_tasks
3267
+ SET status = 'cancelled', cancel_requested = 1, updated_at = ?
3268
+ WHERE id = ? AND status = 'scheduled' AND attempt_count = 0
3269
+ `).run(now, task.id);
3270
+ this.database.prepare(`
3271
+ UPDATE automation_occurrences
3272
+ SET status = 'skipped', reason = 'overlap-queue-one-coalesced', updated_at = ?
3273
+ WHERE id = ? AND status = 'pending'
3274
+ `).run(now, task.occurrence_id);
3275
+ }
3276
+ return superseded.map(task => task.id);
3277
+ }
3059
3278
  exactHostAvailability(decisions, automationId, definitionHash, stage) {
3060
3279
  const exact = decisions?.filter(decision => decision.automationId === automationId
3061
3280
  && decision.definitionHash === definitionHash && decision.stage === stage) ?? [];
@@ -3184,9 +3403,9 @@ export class AutomationStore {
3184
3403
  const now = this.now();
3185
3404
  this.database.prepare(`
3186
3405
  INSERT INTO automation_occurrences(
3187
- id, automation_id, trigger_kind, trigger_key, scheduled_at, status, reason, dry_run, created_at, updated_at
3188
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3189
- `).run(id, input.automationId, input.triggerKind, input.triggerKey, input.scheduledAt, input.status, input.reason ?? null, input.dryRun ? 1 : 0, now, now);
3406
+ id, automation_id, trigger_kind, trigger_key, scheduled_at, status, reason, dry_run, external_event_json, external_event_digest, created_at, updated_at
3407
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3408
+ `).run(id, input.automationId, input.triggerKind, input.triggerKey, input.scheduledAt, input.status, input.reason ?? null, input.dryRun ? 1 : 0, input.externalEvent === undefined ? null : canonicalExternalEventEnvelope(input.externalEvent), input.externalEventDigest ?? null, now, now);
3190
3409
  if (input.status === 'pending') {
3191
3410
  this.database.prepare(`
3192
3411
  INSERT INTO automation_tasks(