@vendoai/store 0.1.0 → 0.4.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.
Files changed (112) hide show
  1. package/README.md +81 -0
  2. package/dist/blobs.d.ts +5 -0
  3. package/dist/blobs.d.ts.map +1 -0
  4. package/dist/blobs.js +62 -0
  5. package/dist/blobs.js.map +1 -0
  6. package/dist/crypto.d.ts +10 -0
  7. package/dist/crypto.d.ts.map +1 -0
  8. package/dist/crypto.js +44 -0
  9. package/dist/crypto.js.map +1 -0
  10. package/dist/db.d.ts +26 -26
  11. package/dist/db.d.ts.map +1 -0
  12. package/dist/db.js +335 -86
  13. package/dist/db.js.map +1 -0
  14. package/dist/erase.d.ts +33 -0
  15. package/dist/erase.d.ts.map +1 -0
  16. package/dist/erase.js +107 -0
  17. package/dist/erase.js.map +1 -0
  18. package/dist/helpers/approvals.d.ts +12 -0
  19. package/dist/helpers/approvals.d.ts.map +1 -0
  20. package/dist/helpers/approvals.js +37 -0
  21. package/dist/helpers/approvals.js.map +1 -0
  22. package/dist/helpers/apps.d.ts +15 -0
  23. package/dist/helpers/apps.d.ts.map +1 -0
  24. package/dist/helpers/apps.js +42 -0
  25. package/dist/helpers/apps.js.map +1 -0
  26. package/dist/helpers/audit.d.ts +24 -0
  27. package/dist/helpers/audit.d.ts.map +1 -0
  28. package/dist/helpers/audit.js +79 -0
  29. package/dist/helpers/audit.js.map +1 -0
  30. package/dist/helpers/grants.d.ts +14 -0
  31. package/dist/helpers/grants.d.ts.map +1 -0
  32. package/dist/helpers/grants.js +46 -0
  33. package/dist/helpers/grants.js.map +1 -0
  34. package/dist/helpers/rows.d.ts +30 -0
  35. package/dist/helpers/rows.d.ts.map +1 -0
  36. package/dist/helpers/rows.js +191 -0
  37. package/dist/helpers/rows.js.map +1 -0
  38. package/dist/helpers/runs.d.ts +19 -0
  39. package/dist/helpers/runs.d.ts.map +1 -0
  40. package/dist/helpers/runs.js +47 -0
  41. package/dist/helpers/runs.js.map +1 -0
  42. package/dist/helpers/state.d.ts +9 -0
  43. package/dist/helpers/state.d.ts.map +1 -0
  44. package/dist/helpers/state.js +22 -0
  45. package/dist/helpers/state.js.map +1 -0
  46. package/dist/helpers/subjects.d.ts +32 -0
  47. package/dist/helpers/subjects.d.ts.map +1 -0
  48. package/dist/helpers/subjects.js +78 -0
  49. package/dist/helpers/subjects.js.map +1 -0
  50. package/dist/helpers/threads.d.ts +19 -0
  51. package/dist/helpers/threads.d.ts.map +1 -0
  52. package/dist/helpers/threads.js +38 -0
  53. package/dist/helpers/threads.js.map +1 -0
  54. package/dist/helpers/types.d.ts +60 -0
  55. package/dist/helpers/types.d.ts.map +1 -0
  56. package/dist/helpers/types.js +2 -0
  57. package/dist/helpers/types.js.map +1 -0
  58. package/dist/helpers/utils.d.ts +37 -0
  59. package/dist/helpers/utils.d.ts.map +1 -0
  60. package/dist/helpers/utils.js +94 -0
  61. package/dist/helpers/utils.js.map +1 -0
  62. package/dist/index.d.ts +13 -11
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +17 -15
  65. package/dist/index.js.map +1 -0
  66. package/dist/records.d.ts +7 -0
  67. package/dist/records.d.ts.map +1 -0
  68. package/dist/records.js +208 -0
  69. package/dist/records.js.map +1 -0
  70. package/dist/routing.d.ts +7 -0
  71. package/dist/routing.d.ts.map +1 -0
  72. package/dist/routing.js +393 -0
  73. package/dist/routing.js.map +1 -0
  74. package/dist/schema.d.ts +18 -1420
  75. package/dist/schema.d.ts.map +1 -0
  76. package/dist/schema.js +206 -114
  77. package/dist/schema.js.map +1 -0
  78. package/dist/secrets.d.ts +13 -0
  79. package/dist/secrets.d.ts.map +1 -0
  80. package/dist/secrets.js +86 -0
  81. package/dist/secrets.js.map +1 -0
  82. package/dist/sessions.d.ts +53 -0
  83. package/dist/sessions.d.ts.map +1 -0
  84. package/dist/sessions.js +85 -0
  85. package/dist/sessions.js.map +1 -0
  86. package/dist/store.d.ts +24 -0
  87. package/dist/store.d.ts.map +1 -0
  88. package/dist/store.js +49 -0
  89. package/dist/store.js.map +1 -0
  90. package/dist/validate.d.ts +25 -0
  91. package/dist/validate.d.ts.map +1 -0
  92. package/dist/validate.js +144 -0
  93. package/dist/validate.js.map +1 -0
  94. package/package.json +15 -14
  95. package/dist/automation-store.d.ts +0 -62
  96. package/dist/automation-store.js +0 -675
  97. package/dist/connections-store.d.ts +0 -25
  98. package/dist/connections-store.js +0 -102
  99. package/dist/decision-store.d.ts +0 -8
  100. package/dist/decision-store.js +0 -39
  101. package/dist/meta.d.ts +0 -5
  102. package/dist/meta.js +0 -20
  103. package/dist/thread-store.d.ts +0 -5
  104. package/dist/thread-store.js +0 -209
  105. package/dist/vendo-registry.d.ts +0 -5
  106. package/dist/vendo-registry.js +0 -52
  107. package/migrations/.gitkeep +0 -0
  108. package/migrations/0000_living_kate_bishop.sql +0 -110
  109. package/migrations/0001_brainy_tinkerer.sql +0 -13
  110. package/migrations/meta/0000_snapshot.json +0 -757
  111. package/migrations/meta/0001_snapshot.json +0 -859
  112. package/migrations/meta/_journal.json +0 -20
@@ -1,675 +0,0 @@
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
- }