@vendoai/store 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/dist/automation-store.d.ts +62 -0
- package/dist/automation-store.js +675 -0
- package/dist/connections-store.d.ts +25 -0
- package/dist/connections-store.js +102 -0
- package/dist/db.d.ts +28 -0
- package/dist/db.js +105 -0
- package/dist/decision-store.d.ts +8 -0
- package/dist/decision-store.js +39 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +15 -0
- package/dist/meta.d.ts +5 -0
- package/dist/meta.js +20 -0
- package/dist/schema.d.ts +1420 -0
- package/dist/schema.js +114 -0
- package/dist/thread-store.d.ts +5 -0
- package/dist/thread-store.js +209 -0
- package/dist/vendo-registry.d.ts +5 -0
- package/dist/vendo-registry.js +52 -0
- package/migrations/.gitkeep +0 -0
- package/migrations/0000_living_kate_bishop.sql +110 -0
- package/migrations/0001_brainy_tinkerer.sql +13 -0
- package/migrations/meta/0000_snapshot.json +757 -0
- package/migrations/meta/0001_snapshot.json +859 -0
- package/migrations/meta/_journal.json +20 -0
- package/package.json +58 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DrizzleAutomationStore — Postgres-dialect (PGlite default, DATABASE_URL
|
|
3
|
+
* override) port of `InMemoryAutomationStore` (packages/vendo-runtime/src/
|
|
4
|
+
* automations/store.ts). That class is the BEHAVIORAL SPEC: every method here
|
|
5
|
+
* mirrors its semantics exactly — truncation caps, coarse-status mapping, and
|
|
6
|
+
* the trigger index are reused verbatim via the runtime's exported helpers
|
|
7
|
+
* rather than re-implemented, so the two stores can never silently diverge.
|
|
8
|
+
*
|
|
9
|
+
* Two things can't be ported as plain JS logic because they need the database:
|
|
10
|
+
* - `claimPendingApproval` — a single capture-then-clear SQL statement (a
|
|
11
|
+
* plain `UPDATE … RETURNING pending_approval` would return the just-set
|
|
12
|
+
* NULL, not the value it cleared) using `FOR UPDATE SKIP LOCKED` so exactly
|
|
13
|
+
* one concurrent caller wins.
|
|
14
|
+
* - `finalizeRun` — the run write and the automation counters update commit
|
|
15
|
+
* together in one `db.transaction`.
|
|
16
|
+
*
|
|
17
|
+
* Timestamp round-trip: drizzle's `timestamp(..., { mode: "string" })` gives
|
|
18
|
+
* back Postgres's own text rendering ("2026-07-01 08:00:00+00"), not the ISO
|
|
19
|
+
* string the runtime writes ("2026-07-01T08:00:00.000Z"). Contract tests
|
|
20
|
+
* assert exact ISO equality, so every timestamp column is normalized through
|
|
21
|
+
* `toIso()` at the read boundary. Freshly-created/updated records are
|
|
22
|
+
* returned from the JS values just written (already canonical ISO from the
|
|
23
|
+
* clock) rather than re-read from the DB, so `toIso` only applies where rows
|
|
24
|
+
* come back from a `select`.
|
|
25
|
+
*/
|
|
26
|
+
import { randomUUID } from "node:crypto";
|
|
27
|
+
import { and, eq, isNull, sql } from "drizzle-orm";
|
|
28
|
+
import { DuplicateRunError, PARKED_ACTION_TTL_MS, automationSpecSchema, capEnvelope, capParkedInput, capStep, coarseStatus, firingRunId, triggerIndex, } from "@vendoai/runtime";
|
|
29
|
+
import { automationRuns, automations, automationVersions, parkedActions } from "./schema.js";
|
|
30
|
+
/** Postgres text timestamp -> the ISO 8601 string the runtime writes/expects. */
|
|
31
|
+
export function toIso(value) {
|
|
32
|
+
return new Date(value).toISOString();
|
|
33
|
+
}
|
|
34
|
+
function rowToAutomation(row) {
|
|
35
|
+
const record = {
|
|
36
|
+
id: row.id,
|
|
37
|
+
name: row.name,
|
|
38
|
+
status: row.status,
|
|
39
|
+
spec: row.spec,
|
|
40
|
+
tenantId: row.tenantId,
|
|
41
|
+
subject: row.subject,
|
|
42
|
+
currentVersion: row.currentVersion,
|
|
43
|
+
triggerKind: row.triggerKind,
|
|
44
|
+
triggerKey: row.triggerKey,
|
|
45
|
+
counters: row.counters,
|
|
46
|
+
createdFromThreadId: row.createdFromThreadId,
|
|
47
|
+
createdAt: toIso(row.createdAt),
|
|
48
|
+
updatedAt: toIso(row.updatedAt),
|
|
49
|
+
};
|
|
50
|
+
if (row.disabledReason != null) {
|
|
51
|
+
record.disabledReason = row.disabledReason;
|
|
52
|
+
}
|
|
53
|
+
return record;
|
|
54
|
+
}
|
|
55
|
+
function rowToVersion(row) {
|
|
56
|
+
return {
|
|
57
|
+
automationId: row.automationId,
|
|
58
|
+
version: row.version,
|
|
59
|
+
spec: row.spec,
|
|
60
|
+
dslVersion: row.dslVersion,
|
|
61
|
+
manifestHash: row.manifestHash,
|
|
62
|
+
grants: row.grants,
|
|
63
|
+
createdBy: row.createdBy,
|
|
64
|
+
createdAt: toIso(row.createdAt),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function rowToRun(row) {
|
|
68
|
+
const run = {
|
|
69
|
+
id: row.id,
|
|
70
|
+
automationId: row.automationId,
|
|
71
|
+
version: row.version,
|
|
72
|
+
manifestHash: row.manifestHash,
|
|
73
|
+
tenantId: row.tenantId,
|
|
74
|
+
subject: row.subject,
|
|
75
|
+
status: row.status,
|
|
76
|
+
trigger: row.trigger,
|
|
77
|
+
steps: row.steps,
|
|
78
|
+
isTest: row.isTest,
|
|
79
|
+
parkedCount: row.parkedCount,
|
|
80
|
+
startedAt: toIso(row.startedAt),
|
|
81
|
+
};
|
|
82
|
+
if (row.outcome != null)
|
|
83
|
+
run.outcome = row.outcome;
|
|
84
|
+
if (row.pendingApproval != null)
|
|
85
|
+
run.pendingApproval = row.pendingApproval;
|
|
86
|
+
if (row.error != null)
|
|
87
|
+
run.error = row.error;
|
|
88
|
+
if (row.finishedAt != null)
|
|
89
|
+
run.finishedAt = toIso(row.finishedAt);
|
|
90
|
+
return run;
|
|
91
|
+
}
|
|
92
|
+
/** PG unique-violation code; PGlite's thrown shape doesn't always match
|
|
93
|
+
* node-postgres's (code may be nested under `cause`, or absent — fall back
|
|
94
|
+
* to matching the driver-agnostic Postgres error text). */
|
|
95
|
+
function isUniqueViolation(err) {
|
|
96
|
+
const code = err?.code ??
|
|
97
|
+
err?.cause?.code;
|
|
98
|
+
if (code === "23505")
|
|
99
|
+
return true;
|
|
100
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
101
|
+
return /duplicate key value violates unique constraint/i.test(message);
|
|
102
|
+
}
|
|
103
|
+
/** Durable port of `InMemoryAutomationStore`; see the module doc for the two
|
|
104
|
+
* methods that genuinely need database semantics. */
|
|
105
|
+
export class DrizzleAutomationStore {
|
|
106
|
+
handle;
|
|
107
|
+
clock;
|
|
108
|
+
constructor(handle, opts = {}) {
|
|
109
|
+
this.handle = handle;
|
|
110
|
+
this.clock = opts.now ?? (() => new Date().toISOString());
|
|
111
|
+
}
|
|
112
|
+
now() {
|
|
113
|
+
return this.clock();
|
|
114
|
+
}
|
|
115
|
+
get db() {
|
|
116
|
+
return this.handle.db;
|
|
117
|
+
}
|
|
118
|
+
async withTransaction(fn) {
|
|
119
|
+
if (this.handle.kind === "pglite") {
|
|
120
|
+
const handle = this.handle;
|
|
121
|
+
return handle.db.transaction((tx) => fn(tx));
|
|
122
|
+
}
|
|
123
|
+
const handle = this.handle;
|
|
124
|
+
return handle.db.transaction((tx) => fn(tx));
|
|
125
|
+
}
|
|
126
|
+
// ---- frozen core surface -------------------------------------------------
|
|
127
|
+
async save(scope, automation) {
|
|
128
|
+
const spec = automationSpecSchema.parse(automation.spec);
|
|
129
|
+
const { automation: record } = await this.create(scope, {
|
|
130
|
+
spec,
|
|
131
|
+
name: automation.name,
|
|
132
|
+
grants: [],
|
|
133
|
+
});
|
|
134
|
+
if (automation.status === "paused")
|
|
135
|
+
await this.setStatus(scope, record.id, "paused");
|
|
136
|
+
return (await this.get(scope, record.id));
|
|
137
|
+
}
|
|
138
|
+
async get(scope, id) {
|
|
139
|
+
const rows = await this.db
|
|
140
|
+
.select()
|
|
141
|
+
.from(automations)
|
|
142
|
+
.where(and(eq(automations.id, id), eq(automations.tenantId, scope.tenantId), eq(automations.subject, scope.subject)));
|
|
143
|
+
return rows[0] ? rowToAutomation(rows[0]) : undefined;
|
|
144
|
+
}
|
|
145
|
+
async list(scope) {
|
|
146
|
+
const rows = await this.db
|
|
147
|
+
.select()
|
|
148
|
+
.from(automations)
|
|
149
|
+
.where(and(eq(automations.tenantId, scope.tenantId), eq(automations.subject, scope.subject)));
|
|
150
|
+
return rows.map(rowToAutomation);
|
|
151
|
+
}
|
|
152
|
+
async recordRun(scope, run) {
|
|
153
|
+
// Core-shaped upsert: merge onto the engine row when it exists AND the
|
|
154
|
+
// caller owns it (mirrors InMemoryAutomationStore.recordRun's
|
|
155
|
+
// `{ ...existing, ...run }` preserve-merge). Run ids are globally unique
|
|
156
|
+
// (the PK), so an existing id owned by ANOTHER principal can neither be
|
|
157
|
+
// merged (cross-tenant write) nor re-inserted (PK collision): it's a
|
|
158
|
+
// no-op, same as the in-memory reference.
|
|
159
|
+
const existingRows = await this.db.select().from(automationRuns).where(eq(automationRuns.id, run.id));
|
|
160
|
+
const existing = existingRows[0];
|
|
161
|
+
if (existing) {
|
|
162
|
+
if (existing.tenantId !== scope.tenantId || existing.subject !== scope.subject)
|
|
163
|
+
return;
|
|
164
|
+
// Preserve-merge: absent optional fields keep their stored values,
|
|
165
|
+
// matching the in-memory spread.
|
|
166
|
+
const set = {
|
|
167
|
+
automationId: run.automationId,
|
|
168
|
+
status: run.status,
|
|
169
|
+
startedAt: run.startedAt,
|
|
170
|
+
};
|
|
171
|
+
if (run.error !== undefined)
|
|
172
|
+
set.error = run.error;
|
|
173
|
+
if (run.finishedAt !== undefined)
|
|
174
|
+
set.finishedAt = run.finishedAt;
|
|
175
|
+
await this.db
|
|
176
|
+
.update(automationRuns)
|
|
177
|
+
.set(set)
|
|
178
|
+
.where(and(eq(automationRuns.id, run.id), eq(automationRuns.tenantId, scope.tenantId), eq(automationRuns.subject, scope.subject)));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
await this.db.insert(automationRuns).values({
|
|
182
|
+
id: run.id,
|
|
183
|
+
automationId: run.automationId,
|
|
184
|
+
tenantId: scope.tenantId,
|
|
185
|
+
subject: scope.subject,
|
|
186
|
+
version: 0,
|
|
187
|
+
manifestHash: null,
|
|
188
|
+
status: run.status,
|
|
189
|
+
outcome: null,
|
|
190
|
+
trigger: {
|
|
191
|
+
source: "external",
|
|
192
|
+
eventId: run.id,
|
|
193
|
+
subject: scope.subject,
|
|
194
|
+
occurredAt: run.startedAt,
|
|
195
|
+
payload: undefined,
|
|
196
|
+
},
|
|
197
|
+
steps: [],
|
|
198
|
+
pendingApproval: null,
|
|
199
|
+
error: run.error ?? null,
|
|
200
|
+
isTest: false,
|
|
201
|
+
startedAt: run.startedAt,
|
|
202
|
+
finishedAt: run.finishedAt ?? null,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
async listRuns(scope, automationId) {
|
|
206
|
+
const rows = await this.db
|
|
207
|
+
.select()
|
|
208
|
+
.from(automationRuns)
|
|
209
|
+
.where(and(eq(automationRuns.automationId, automationId), eq(automationRuns.tenantId, scope.tenantId), eq(automationRuns.subject, scope.subject)));
|
|
210
|
+
return rows.map(rowToRun);
|
|
211
|
+
}
|
|
212
|
+
// ---- engine surface ------------------------------------------------------
|
|
213
|
+
async create(scope, input) {
|
|
214
|
+
const id = `auto-${randomUUID()}`;
|
|
215
|
+
const now = this.now();
|
|
216
|
+
const { kind, key } = triggerIndex(input.spec);
|
|
217
|
+
const counters = {
|
|
218
|
+
totalRuns: 0,
|
|
219
|
+
totalFailures: 0,
|
|
220
|
+
consecutiveFailures: 0,
|
|
221
|
+
lastRunAt: null,
|
|
222
|
+
lastStatus: null,
|
|
223
|
+
};
|
|
224
|
+
const automation = {
|
|
225
|
+
id,
|
|
226
|
+
name: input.name ?? input.spec.name,
|
|
227
|
+
status: "enabled",
|
|
228
|
+
spec: input.spec,
|
|
229
|
+
tenantId: scope.tenantId,
|
|
230
|
+
subject: scope.subject,
|
|
231
|
+
currentVersion: 1,
|
|
232
|
+
triggerKind: kind,
|
|
233
|
+
triggerKey: key,
|
|
234
|
+
counters,
|
|
235
|
+
createdFromThreadId: input.createdFromThreadId ?? null,
|
|
236
|
+
createdAt: now,
|
|
237
|
+
updatedAt: now,
|
|
238
|
+
};
|
|
239
|
+
const version = {
|
|
240
|
+
automationId: id,
|
|
241
|
+
version: 1,
|
|
242
|
+
spec: input.spec,
|
|
243
|
+
dslVersion: input.spec.dslVersion,
|
|
244
|
+
manifestHash: input.manifestHash ?? null,
|
|
245
|
+
grants: input.grants,
|
|
246
|
+
createdBy: input.createdBy ?? "compiler",
|
|
247
|
+
createdAt: now,
|
|
248
|
+
};
|
|
249
|
+
// Record + version row commit together: a crash between them must never
|
|
250
|
+
// leave an automation without its version 1.
|
|
251
|
+
await this.withTransaction(async (tx) => {
|
|
252
|
+
await tx.insert(automations).values({
|
|
253
|
+
id,
|
|
254
|
+
tenantId: scope.tenantId,
|
|
255
|
+
subject: scope.subject,
|
|
256
|
+
name: automation.name,
|
|
257
|
+
status: "enabled",
|
|
258
|
+
spec: input.spec,
|
|
259
|
+
currentVersion: 1,
|
|
260
|
+
triggerKind: kind,
|
|
261
|
+
triggerKey: key,
|
|
262
|
+
counters,
|
|
263
|
+
createdFromThreadId: automation.createdFromThreadId ?? null,
|
|
264
|
+
createdAt: now,
|
|
265
|
+
updatedAt: now,
|
|
266
|
+
});
|
|
267
|
+
await tx.insert(automationVersions).values({
|
|
268
|
+
automationId: id,
|
|
269
|
+
version: 1,
|
|
270
|
+
spec: input.spec,
|
|
271
|
+
dslVersion: input.spec.dslVersion,
|
|
272
|
+
manifestHash: version.manifestHash,
|
|
273
|
+
grants: input.grants,
|
|
274
|
+
createdBy: version.createdBy,
|
|
275
|
+
createdAt: now,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
return { automation, version };
|
|
279
|
+
}
|
|
280
|
+
async update(scope, id, input) {
|
|
281
|
+
const now = this.now();
|
|
282
|
+
const { kind, key } = triggerIndex(input.spec);
|
|
283
|
+
// currentVersion is read and bumped in ONE transaction with the version
|
|
284
|
+
// insert: a crash between the insert and the pointer bump would otherwise
|
|
285
|
+
// wedge every later update on the (automation_id, version) PK forever.
|
|
286
|
+
return this.withTransaction(async (tx) => {
|
|
287
|
+
// `FOR UPDATE` row-locks this automation for the rest of the
|
|
288
|
+
// transaction: two concurrent update() calls both reading
|
|
289
|
+
// currentVersion before either commits would otherwise compute the
|
|
290
|
+
// SAME nextVersion and race on the (automation_id, version) PK — one
|
|
291
|
+
// throws a 23505 instead of correctly serializing to versions N+1 and
|
|
292
|
+
// N+2. The second concurrent caller now blocks here until the first
|
|
293
|
+
// commits, then reads the POST-update currentVersion. PGlite runs every
|
|
294
|
+
// `.transaction()` under an internal exclusive lock (one connection),
|
|
295
|
+
// so this is a no-op there — real concurrent Postgres is what the lock
|
|
296
|
+
// is for; a true concurrent-PG test isn't runnable in this test
|
|
297
|
+
// environment (see thread-store.ts's analogous note).
|
|
298
|
+
const rows = await tx
|
|
299
|
+
.select()
|
|
300
|
+
.from(automations)
|
|
301
|
+
.where(and(eq(automations.id, id), eq(automations.tenantId, scope.tenantId), eq(automations.subject, scope.subject)))
|
|
302
|
+
.for("update");
|
|
303
|
+
if (!rows[0])
|
|
304
|
+
throw new Error(`automation "${id}" not found`);
|
|
305
|
+
const automation = rowToAutomation(rows[0]);
|
|
306
|
+
const nextVersion = automation.currentVersion + 1;
|
|
307
|
+
const version = {
|
|
308
|
+
automationId: id,
|
|
309
|
+
version: nextVersion,
|
|
310
|
+
spec: input.spec,
|
|
311
|
+
dslVersion: input.spec.dslVersion,
|
|
312
|
+
manifestHash: input.manifestHash ?? null,
|
|
313
|
+
grants: input.grants,
|
|
314
|
+
createdBy: input.createdBy,
|
|
315
|
+
createdAt: now,
|
|
316
|
+
};
|
|
317
|
+
const updated = {
|
|
318
|
+
...automation,
|
|
319
|
+
name: input.spec.name,
|
|
320
|
+
spec: input.spec,
|
|
321
|
+
currentVersion: nextVersion,
|
|
322
|
+
triggerKind: kind,
|
|
323
|
+
triggerKey: key,
|
|
324
|
+
updatedAt: now,
|
|
325
|
+
};
|
|
326
|
+
await tx.insert(automationVersions).values({
|
|
327
|
+
automationId: id,
|
|
328
|
+
version: nextVersion,
|
|
329
|
+
spec: input.spec,
|
|
330
|
+
dslVersion: input.spec.dslVersion,
|
|
331
|
+
manifestHash: version.manifestHash,
|
|
332
|
+
grants: input.grants,
|
|
333
|
+
createdBy: input.createdBy,
|
|
334
|
+
createdAt: now,
|
|
335
|
+
});
|
|
336
|
+
await tx
|
|
337
|
+
.update(automations)
|
|
338
|
+
.set({
|
|
339
|
+
name: updated.name,
|
|
340
|
+
spec: input.spec,
|
|
341
|
+
currentVersion: nextVersion,
|
|
342
|
+
triggerKind: kind,
|
|
343
|
+
triggerKey: key,
|
|
344
|
+
updatedAt: now,
|
|
345
|
+
})
|
|
346
|
+
.where(and(eq(automations.id, id), eq(automations.tenantId, scope.tenantId), eq(automations.subject, scope.subject)));
|
|
347
|
+
return { automation: updated, version };
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
async getVersion(scope, id, version) {
|
|
351
|
+
const owned = await this.get(scope, id);
|
|
352
|
+
if (!owned)
|
|
353
|
+
return undefined;
|
|
354
|
+
const rows = await this.db
|
|
355
|
+
.select()
|
|
356
|
+
.from(automationVersions)
|
|
357
|
+
.where(and(eq(automationVersions.automationId, id), eq(automationVersions.version, version)));
|
|
358
|
+
return rows[0] ? rowToVersion(rows[0]) : undefined;
|
|
359
|
+
}
|
|
360
|
+
async setStatus(scope, id, status, opts) {
|
|
361
|
+
await this.mustGet(scope, id);
|
|
362
|
+
const now = this.now();
|
|
363
|
+
await this.db
|
|
364
|
+
.update(automations)
|
|
365
|
+
.set({
|
|
366
|
+
status,
|
|
367
|
+
disabledReason: opts?.disabledReason !== undefined ? opts.disabledReason : null,
|
|
368
|
+
updatedAt: now,
|
|
369
|
+
})
|
|
370
|
+
.where(and(eq(automations.id, id), eq(automations.tenantId, scope.tenantId), eq(automations.subject, scope.subject)));
|
|
371
|
+
}
|
|
372
|
+
async delete(scope, id) {
|
|
373
|
+
const owned = await this.get(scope, id);
|
|
374
|
+
if (!owned)
|
|
375
|
+
return;
|
|
376
|
+
await this.db
|
|
377
|
+
.delete(automations)
|
|
378
|
+
.where(and(eq(automations.id, id), eq(automations.tenantId, scope.tenantId), eq(automations.subject, scope.subject)));
|
|
379
|
+
}
|
|
380
|
+
async findEnabledByTrigger(scope, lookup) {
|
|
381
|
+
const rows = await this.db
|
|
382
|
+
.select()
|
|
383
|
+
.from(automations)
|
|
384
|
+
.where(and(eq(automations.tenantId, scope.tenantId), eq(automations.subject, scope.subject), eq(automations.status, "enabled"), eq(automations.triggerKind, lookup.kind), lookup.key === null ? isNull(automations.triggerKey) : eq(automations.triggerKey, lookup.key)));
|
|
385
|
+
return rows.map(rowToAutomation);
|
|
386
|
+
}
|
|
387
|
+
async createRun(scope, input) {
|
|
388
|
+
const id = firingRunId(input.automation.id, input.envelope.source, input.envelope.eventId);
|
|
389
|
+
const versionRow = await this.getVersion(scope, input.automation.id, input.version);
|
|
390
|
+
const now = this.now();
|
|
391
|
+
const trigger = capEnvelope(input.envelope);
|
|
392
|
+
const run = {
|
|
393
|
+
id,
|
|
394
|
+
automationId: input.automation.id,
|
|
395
|
+
version: input.version,
|
|
396
|
+
manifestHash: versionRow?.manifestHash ?? null,
|
|
397
|
+
tenantId: scope.tenantId,
|
|
398
|
+
subject: scope.subject,
|
|
399
|
+
status: "running",
|
|
400
|
+
trigger,
|
|
401
|
+
steps: [],
|
|
402
|
+
isTest: input.isTest,
|
|
403
|
+
parkedCount: 0,
|
|
404
|
+
startedAt: now,
|
|
405
|
+
};
|
|
406
|
+
try {
|
|
407
|
+
await this.db.insert(automationRuns).values({
|
|
408
|
+
id: run.id,
|
|
409
|
+
automationId: run.automationId,
|
|
410
|
+
tenantId: run.tenantId,
|
|
411
|
+
subject: run.subject,
|
|
412
|
+
version: run.version,
|
|
413
|
+
manifestHash: run.manifestHash,
|
|
414
|
+
status: run.status,
|
|
415
|
+
outcome: null,
|
|
416
|
+
trigger: run.trigger,
|
|
417
|
+
steps: run.steps,
|
|
418
|
+
pendingApproval: null,
|
|
419
|
+
error: null,
|
|
420
|
+
isTest: run.isTest,
|
|
421
|
+
parkedCount: 0,
|
|
422
|
+
startedAt: now,
|
|
423
|
+
finishedAt: null,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
if (isUniqueViolation(err))
|
|
428
|
+
throw new DuplicateRunError(id);
|
|
429
|
+
throw err;
|
|
430
|
+
}
|
|
431
|
+
return run;
|
|
432
|
+
}
|
|
433
|
+
async updateRun(scope, id, patch) {
|
|
434
|
+
const run = await this.mustGetRun(scope, id);
|
|
435
|
+
const steps = patch.steps ? patch.steps.map(capStep) : run.steps;
|
|
436
|
+
const status = patch.outcome === "waiting_approval" ? "running" : run.status;
|
|
437
|
+
const next = { ...run, ...patch, status, steps };
|
|
438
|
+
await this.db
|
|
439
|
+
.update(automationRuns)
|
|
440
|
+
.set({
|
|
441
|
+
status: next.status,
|
|
442
|
+
outcome: next.outcome ?? null,
|
|
443
|
+
steps: next.steps,
|
|
444
|
+
pendingApproval: next.pendingApproval ?? null,
|
|
445
|
+
error: next.error ?? null,
|
|
446
|
+
parkedCount: next.parkedCount,
|
|
447
|
+
})
|
|
448
|
+
.where(and(eq(automationRuns.id, id), eq(automationRuns.tenantId, scope.tenantId), eq(automationRuns.subject, scope.subject)));
|
|
449
|
+
return next;
|
|
450
|
+
}
|
|
451
|
+
async finalizeRun(scope, id, input) {
|
|
452
|
+
const now = this.now();
|
|
453
|
+
return this.withTransaction(async (tx) => {
|
|
454
|
+
const runRows = await tx
|
|
455
|
+
.select()
|
|
456
|
+
.from(automationRuns)
|
|
457
|
+
.where(and(eq(automationRuns.id, id), eq(automationRuns.tenantId, scope.tenantId), eq(automationRuns.subject, scope.subject)));
|
|
458
|
+
const existing = runRows[0];
|
|
459
|
+
if (!existing)
|
|
460
|
+
throw new Error(`run "${id}" not found`);
|
|
461
|
+
const run = rowToRun(existing);
|
|
462
|
+
const status = coarseStatus(input);
|
|
463
|
+
// Skipped runs stay compact: no steps array is stored for them.
|
|
464
|
+
const steps = input.outcome === "skipped" ? [] : (input.steps ?? run.steps).map(capStep);
|
|
465
|
+
const finalized = {
|
|
466
|
+
...run,
|
|
467
|
+
status,
|
|
468
|
+
outcome: input.outcome,
|
|
469
|
+
steps,
|
|
470
|
+
error: input.error,
|
|
471
|
+
finishedAt: now,
|
|
472
|
+
parkedCount: input.parkedCount ?? run.parkedCount,
|
|
473
|
+
};
|
|
474
|
+
delete finalized.pendingApproval;
|
|
475
|
+
await tx
|
|
476
|
+
.update(automationRuns)
|
|
477
|
+
.set({
|
|
478
|
+
status,
|
|
479
|
+
outcome: input.outcome ?? null,
|
|
480
|
+
steps,
|
|
481
|
+
error: input.error ?? null,
|
|
482
|
+
pendingApproval: null,
|
|
483
|
+
finishedAt: now,
|
|
484
|
+
parkedCount: finalized.parkedCount,
|
|
485
|
+
})
|
|
486
|
+
.where(eq(automationRuns.id, id));
|
|
487
|
+
// Counters update commits in the SAME transaction as the run write.
|
|
488
|
+
const automationRows = await tx
|
|
489
|
+
.select()
|
|
490
|
+
.from(automations)
|
|
491
|
+
.where(and(eq(automations.id, run.automationId), eq(automations.tenantId, scope.tenantId), eq(automations.subject, scope.subject)));
|
|
492
|
+
const automationRow = automationRows[0];
|
|
493
|
+
if (automationRow) {
|
|
494
|
+
const automation = rowToAutomation(automationRow);
|
|
495
|
+
// Only clean successes and real failures move the streak; refined
|
|
496
|
+
// outcomes (skipped/cancelled) never count as failures.
|
|
497
|
+
const failed = status === "failed" && input.outcome === undefined;
|
|
498
|
+
const succeeded = status === "succeeded" && input.outcome === undefined;
|
|
499
|
+
const counters = {
|
|
500
|
+
totalRuns: automation.counters.totalRuns + 1,
|
|
501
|
+
totalFailures: automation.counters.totalFailures + (failed ? 1 : 0),
|
|
502
|
+
consecutiveFailures: failed
|
|
503
|
+
? automation.counters.consecutiveFailures + 1
|
|
504
|
+
: succeeded
|
|
505
|
+
? 0
|
|
506
|
+
: automation.counters.consecutiveFailures,
|
|
507
|
+
lastRunAt: now,
|
|
508
|
+
lastStatus: input.outcome ?? status,
|
|
509
|
+
};
|
|
510
|
+
await tx.update(automations).set({ counters, updatedAt: now }).where(eq(automations.id, automation.id));
|
|
511
|
+
}
|
|
512
|
+
return finalized;
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
async getRun(scope, id) {
|
|
516
|
+
const rows = await this.db
|
|
517
|
+
.select()
|
|
518
|
+
.from(automationRuns)
|
|
519
|
+
.where(and(eq(automationRuns.id, id), eq(automationRuns.tenantId, scope.tenantId), eq(automationRuns.subject, scope.subject)));
|
|
520
|
+
return rows[0] ? rowToRun(rows[0]) : undefined;
|
|
521
|
+
}
|
|
522
|
+
/** Capture-then-clear in ONE statement (a plain `RETURNING pending_approval`
|
|
523
|
+
* after an `UPDATE … SET pending_approval = NULL` returns the new NULL,
|
|
524
|
+
* not the value it cleared) — `FOR UPDATE SKIP LOCKED` makes exactly one
|
|
525
|
+
* concurrent caller win. */
|
|
526
|
+
async claimPendingApproval(scope, runId) {
|
|
527
|
+
const result = await this.db.execute(sql `
|
|
528
|
+
WITH claimed AS (
|
|
529
|
+
SELECT id, pending_approval FROM vendo.automation_runs
|
|
530
|
+
WHERE id = ${runId} AND tenant_id = ${scope.tenantId} AND subject = ${scope.subject}
|
|
531
|
+
AND pending_approval IS NOT NULL
|
|
532
|
+
FOR UPDATE SKIP LOCKED
|
|
533
|
+
)
|
|
534
|
+
UPDATE vendo.automation_runs r
|
|
535
|
+
SET pending_approval = NULL
|
|
536
|
+
FROM claimed
|
|
537
|
+
WHERE r.id = claimed.id
|
|
538
|
+
RETURNING claimed.pending_approval AS claimed_approval
|
|
539
|
+
`);
|
|
540
|
+
const rows = result.rows;
|
|
541
|
+
const row = rows[0];
|
|
542
|
+
if (!row || row.claimed_approval == null)
|
|
543
|
+
return undefined;
|
|
544
|
+
return row.claimed_approval;
|
|
545
|
+
}
|
|
546
|
+
async cancelPendingRuns(scope, automationId) {
|
|
547
|
+
const now = this.now();
|
|
548
|
+
await this.db
|
|
549
|
+
.update(automationRuns)
|
|
550
|
+
.set({
|
|
551
|
+
status: "failed",
|
|
552
|
+
outcome: "cancelled",
|
|
553
|
+
pendingApproval: null,
|
|
554
|
+
finishedAt: now,
|
|
555
|
+
})
|
|
556
|
+
.where(and(eq(automationRuns.automationId, automationId), eq(automationRuns.tenantId, scope.tenantId), eq(automationRuns.subject, scope.subject), eq(automationRuns.outcome, "waiting_approval")));
|
|
557
|
+
}
|
|
558
|
+
async listEnabledSchedules() {
|
|
559
|
+
const rows = await this.db
|
|
560
|
+
.select()
|
|
561
|
+
.from(automations)
|
|
562
|
+
.where(and(eq(automations.status, "enabled"), eq(automations.triggerKind, "schedule")));
|
|
563
|
+
return rows.map((row) => {
|
|
564
|
+
const automation = rowToAutomation(row);
|
|
565
|
+
return {
|
|
566
|
+
automationId: automation.id,
|
|
567
|
+
trigger: automation.spec.trigger,
|
|
568
|
+
principal: { tenantId: automation.tenantId, subject: automation.subject },
|
|
569
|
+
};
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
// -------------------------------------------------------------------------
|
|
573
|
+
// Parked actions (ENG-193 §4.6) — durable port of the in-memory semantics:
|
|
574
|
+
// frozen capped input, 7-day TTL swept lazily by list (every surface reads
|
|
575
|
+
// through list), single-resolution invariant enforced at resolve time.
|
|
576
|
+
// -------------------------------------------------------------------------
|
|
577
|
+
async createParkedAction(scope, input) {
|
|
578
|
+
const id = `parked-${randomUUID()}`;
|
|
579
|
+
const { input: cappedInput, truncated, bytes } = capParkedInput(input.input);
|
|
580
|
+
const action = {
|
|
581
|
+
id,
|
|
582
|
+
tenantId: scope.tenantId,
|
|
583
|
+
subject: scope.subject,
|
|
584
|
+
automationId: input.automationId,
|
|
585
|
+
runId: input.runId,
|
|
586
|
+
stepId: input.stepId,
|
|
587
|
+
tool: input.tool,
|
|
588
|
+
input: cappedInput,
|
|
589
|
+
...(truncated ? { inputTruncated: true, inputBytes: bytes } : {}),
|
|
590
|
+
...(input.guardExpr !== undefined ? { guardExpr: input.guardExpr } : {}),
|
|
591
|
+
...(input.guardBindings !== undefined ? { guardBindings: input.guardBindings } : {}),
|
|
592
|
+
reason: input.reason,
|
|
593
|
+
tier: input.tier,
|
|
594
|
+
descriptorHash: input.descriptorHash,
|
|
595
|
+
requestedAt: input.requestedAt,
|
|
596
|
+
};
|
|
597
|
+
await this.db.insert(parkedActions).values({
|
|
598
|
+
id,
|
|
599
|
+
tenantId: scope.tenantId,
|
|
600
|
+
subject: scope.subject,
|
|
601
|
+
automationId: input.automationId,
|
|
602
|
+
runId: input.runId,
|
|
603
|
+
resolution: null,
|
|
604
|
+
requestedAt: input.requestedAt,
|
|
605
|
+
record: action,
|
|
606
|
+
});
|
|
607
|
+
return action;
|
|
608
|
+
}
|
|
609
|
+
async listParkedActions(scope, filter) {
|
|
610
|
+
// Lazy expiry sweep (mirrors InMemoryAutomationStore): stale parked
|
|
611
|
+
// intent must never surface as approvable — 7-day TTL matching
|
|
612
|
+
// PendingApproval's. The sweep is a single UPDATE over this scope's
|
|
613
|
+
// expired-but-unresolved rows.
|
|
614
|
+
const now = this.now();
|
|
615
|
+
const cutoff = new Date(Date.parse(now) - PARKED_ACTION_TTL_MS).toISOString();
|
|
616
|
+
const expiredRows = await this.db
|
|
617
|
+
.select()
|
|
618
|
+
.from(parkedActions)
|
|
619
|
+
.where(and(eq(parkedActions.tenantId, scope.tenantId), eq(parkedActions.subject, scope.subject), isNull(parkedActions.resolution), sql `${parkedActions.requestedAt} <= ${cutoff}`));
|
|
620
|
+
for (const row of expiredRows) {
|
|
621
|
+
const expired = { ...row.record, resolution: "expired", resolvedAt: now };
|
|
622
|
+
await this.db
|
|
623
|
+
.update(parkedActions)
|
|
624
|
+
.set({ resolution: "expired", record: expired })
|
|
625
|
+
.where(and(eq(parkedActions.id, row.id), isNull(parkedActions.resolution)));
|
|
626
|
+
}
|
|
627
|
+
const rows = await this.db
|
|
628
|
+
.select()
|
|
629
|
+
.from(parkedActions)
|
|
630
|
+
.where(and(eq(parkedActions.tenantId, scope.tenantId), eq(parkedActions.subject, scope.subject), ...(filter.automationId !== undefined ? [eq(parkedActions.automationId, filter.automationId)] : []), ...(filter.runId !== undefined ? [eq(parkedActions.runId, filter.runId)] : []), ...(filter.unresolvedOnly === true ? [isNull(parkedActions.resolution)] : [])));
|
|
631
|
+
return rows.map((row) => row.record);
|
|
632
|
+
}
|
|
633
|
+
async getParkedAction(scope, id) {
|
|
634
|
+
const rows = await this.db
|
|
635
|
+
.select()
|
|
636
|
+
.from(parkedActions)
|
|
637
|
+
.where(and(eq(parkedActions.id, id), eq(parkedActions.tenantId, scope.tenantId), eq(parkedActions.subject, scope.subject)));
|
|
638
|
+
const row = rows[0];
|
|
639
|
+
return row ? row.record : undefined;
|
|
640
|
+
}
|
|
641
|
+
async resolveParkedAction(scope, id, resolution, resolvedAt) {
|
|
642
|
+
return this.withTransaction(async (tx) => {
|
|
643
|
+
const rows = await tx
|
|
644
|
+
.select()
|
|
645
|
+
.from(parkedActions)
|
|
646
|
+
.where(and(eq(parkedActions.id, id), eq(parkedActions.tenantId, scope.tenantId), eq(parkedActions.subject, scope.subject)))
|
|
647
|
+
.for("update");
|
|
648
|
+
const row = rows[0];
|
|
649
|
+
if (!row)
|
|
650
|
+
throw new Error(`parked action "${id}" not found`);
|
|
651
|
+
const action = row.record;
|
|
652
|
+
if (action.resolution !== undefined) {
|
|
653
|
+
throw new Error(`parked action "${id}" is already resolved (${action.resolution})`);
|
|
654
|
+
}
|
|
655
|
+
const next = { ...action, resolution, resolvedAt };
|
|
656
|
+
await tx
|
|
657
|
+
.update(parkedActions)
|
|
658
|
+
.set({ resolution, record: next })
|
|
659
|
+
.where(eq(parkedActions.id, id));
|
|
660
|
+
return next;
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
async mustGet(scope, id) {
|
|
664
|
+
const automation = await this.get(scope, id);
|
|
665
|
+
if (!automation)
|
|
666
|
+
throw new Error(`automation "${id}" not found`);
|
|
667
|
+
return automation;
|
|
668
|
+
}
|
|
669
|
+
async mustGetRun(scope, id) {
|
|
670
|
+
const run = await this.getRun(scope, id);
|
|
671
|
+
if (!run)
|
|
672
|
+
throw new Error(`run "${id}" not found`);
|
|
673
|
+
return run;
|
|
674
|
+
}
|
|
675
|
+
}
|