@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.
- package/README.md +18 -5
- package/lib/acceptance.d.ts +52 -0
- package/lib/acceptance.d.ts.map +1 -0
- package/lib/acceptance.js +2 -0
- package/lib/acceptance.js.map +1 -0
- package/lib/coordinator.d.ts +5 -0
- package/lib/coordinator.d.ts.map +1 -1
- package/lib/coordinator.js +21 -0
- package/lib/coordinator.js.map +1 -1
- package/lib/external-event.d.ts +32 -0
- package/lib/external-event.d.ts.map +1 -0
- package/lib/external-event.js +114 -0
- package/lib/external-event.js.map +1 -0
- package/lib/growth.d.ts +10 -0
- package/lib/growth.d.ts.map +1 -1
- package/lib/growth.js +49 -16
- package/lib/growth.js.map +1 -1
- package/lib/index.d.ts +6 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -1
- package/lib/index.js.map +1 -1
- package/lib/operator.d.ts +68 -2
- package/lib/operator.d.ts.map +1 -1
- package/lib/operator.js +922 -6
- package/lib/operator.js.map +1 -1
- package/lib/preparation.d.ts +52 -0
- package/lib/preparation.d.ts.map +1 -0
- package/lib/preparation.js +191 -0
- package/lib/preparation.js.map +1 -0
- package/lib/runner.d.ts +29 -0
- package/lib/runner.d.ts.map +1 -1
- package/lib/runner.js +72 -2
- package/lib/runner.js.map +1 -1
- package/lib/service.d.ts +37 -0
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +269 -60
- package/lib/service.js.map +1 -1
- package/lib/sqlite.d.ts +1 -1
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +77 -3
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +55 -0
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +233 -14
- package/lib/store.js.map +1 -1
- package/lib/types.d.ts +3 -0
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- 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
|
|
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.
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
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(
|