@vendoai/automations 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.
- package/LICENSE +202 -0
- package/README.md +16 -0
- package/dist/engine.d.ts +3 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +1314 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
package/dist/engine.js
ADDED
|
@@ -0,0 +1,1314 @@
|
|
|
1
|
+
import { VendoError, approvalRequestSchema, appDocumentSchema, descriptorHash, permissionGrantSchema, triggerSchema, webhookSubject, } from "@vendoai/core";
|
|
2
|
+
import { Cron } from "croner";
|
|
3
|
+
import jsonata from "jsonata";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
const APPS = "vendo_apps";
|
|
6
|
+
const RUNS = "vendo_runs";
|
|
7
|
+
/** runs.list page size — the store's own default (100) is its escape hatch, not a UX. */
|
|
8
|
+
const RUNS_PAGE_LIMIT = 50;
|
|
9
|
+
const GRANTS = "vendo_grants";
|
|
10
|
+
const APPROVALS = "vendo_approvals";
|
|
11
|
+
const CAPTURES = "automations:captures";
|
|
12
|
+
const PARKED = "automations:parked";
|
|
13
|
+
const RESUME_CLAIMS = "automations:resume-claims";
|
|
14
|
+
const SCHEDULE = "automations:schedule";
|
|
15
|
+
const WEBHOOK = "automations:webhook";
|
|
16
|
+
const DELIVERIES = "automations:deliveries";
|
|
17
|
+
const WEBHOOK_MAX_BYTES = 1024 * 1024;
|
|
18
|
+
const RESUME_MAX_BYTES = 512 * 1024;
|
|
19
|
+
const FOREACH_MAX_ITEMS = 1000;
|
|
20
|
+
const appRowSchema = z.object({
|
|
21
|
+
subject: z.string(),
|
|
22
|
+
enabled: z.boolean(),
|
|
23
|
+
doc: appDocumentSchema,
|
|
24
|
+
});
|
|
25
|
+
const approvalRowSchema = z.object({
|
|
26
|
+
request: approvalRequestSchema,
|
|
27
|
+
status: z.enum(["pending", "approved", "denied"]),
|
|
28
|
+
sessionId: z.string().optional(),
|
|
29
|
+
decidedAt: z.string().optional(),
|
|
30
|
+
consumedAt: z.string().optional(),
|
|
31
|
+
});
|
|
32
|
+
const captureSchema = z.object({
|
|
33
|
+
appId: z.string(),
|
|
34
|
+
subject: z.string(),
|
|
35
|
+
tool: z.string(),
|
|
36
|
+
descriptorHash: z.string(),
|
|
37
|
+
});
|
|
38
|
+
const parkedSchema = z.object({ runId: z.string() });
|
|
39
|
+
const scheduleSchema = z.object({ lastFiredAt: z.string(), firedAt: z.string().optional() });
|
|
40
|
+
const webhookSchema = z.object({ secret: z.string() });
|
|
41
|
+
const resumeSchema = z.object({
|
|
42
|
+
stepIndex: z.number().int().nonnegative(),
|
|
43
|
+
forEachIndex: z.number().int().nonnegative().optional(),
|
|
44
|
+
event: z.unknown(),
|
|
45
|
+
stepOutputs: z.record(z.unknown()),
|
|
46
|
+
call: z.object({ id: z.string(), tool: z.string(), args: z.unknown() }),
|
|
47
|
+
approvalId: z.string(),
|
|
48
|
+
iterationItems: z.array(z.unknown()).optional(),
|
|
49
|
+
iterationOutputs: z.array(z.unknown()).optional(),
|
|
50
|
+
claimedBy: z.string().optional(),
|
|
51
|
+
});
|
|
52
|
+
const runStatusSchema = z.enum(["running", "ok", "error", "stopped", "pending-approval"]);
|
|
53
|
+
const baseRunRecordSchema = z.object({
|
|
54
|
+
id: z.string(),
|
|
55
|
+
appId: z.string(),
|
|
56
|
+
trigger: z.object({
|
|
57
|
+
kind: z.enum(["schedule", "host-event", "external"]),
|
|
58
|
+
event: z.string().optional(),
|
|
59
|
+
}),
|
|
60
|
+
status: runStatusSchema,
|
|
61
|
+
startedAt: z.string(),
|
|
62
|
+
finishedAt: z.string().optional(),
|
|
63
|
+
steps: z.array(z.object({
|
|
64
|
+
id: z.string(),
|
|
65
|
+
tool: z.string(),
|
|
66
|
+
outcome: z.enum(["ok", "error", "pending-approval", "blocked", "connect-required"]),
|
|
67
|
+
at: z.string(),
|
|
68
|
+
detail: z.string().optional(),
|
|
69
|
+
})),
|
|
70
|
+
summary: z.string().optional(),
|
|
71
|
+
error: z.object({ code: z.string(), message: z.string() }).optional(),
|
|
72
|
+
});
|
|
73
|
+
const internalRunRecordSchema = baseRunRecordSchema.extend({ __resume: resumeSchema.optional() });
|
|
74
|
+
const runRowDataSchema = z.object({
|
|
75
|
+
appId: z.string(),
|
|
76
|
+
trigger: baseRunRecordSchema.shape.trigger,
|
|
77
|
+
status: runStatusSchema,
|
|
78
|
+
record: internalRunRecordSchema,
|
|
79
|
+
startedAt: z.string(),
|
|
80
|
+
finishedAt: z.string().optional(),
|
|
81
|
+
});
|
|
82
|
+
const clone = (value) => globalThis.structuredClone(value);
|
|
83
|
+
const id = (prefix) => `${prefix}${globalThis.crypto.randomUUID()}`;
|
|
84
|
+
const message = (error) => error instanceof Error ? error.message : String(error);
|
|
85
|
+
const allRecords = async (records, query = {}) => {
|
|
86
|
+
const found = [];
|
|
87
|
+
let cursor;
|
|
88
|
+
do {
|
|
89
|
+
const page = await records.list({ ...query, ...(cursor === undefined ? {} : { cursor }) });
|
|
90
|
+
found.push(...page.records);
|
|
91
|
+
if (page.cursor === undefined || page.cursor === cursor)
|
|
92
|
+
break;
|
|
93
|
+
cursor = page.cursor;
|
|
94
|
+
} while (cursor !== undefined);
|
|
95
|
+
return found;
|
|
96
|
+
};
|
|
97
|
+
const parseAppRow = (record) => {
|
|
98
|
+
const result = appRowSchema.safeParse(record.data);
|
|
99
|
+
if (!result.success)
|
|
100
|
+
throw new VendoError("validation", `invalid app row ${record.id}: ${result.error.issues[0]?.message ?? "invalid"}`);
|
|
101
|
+
return result.data;
|
|
102
|
+
};
|
|
103
|
+
const parseRunRow = (record) => {
|
|
104
|
+
const result = runRowDataSchema.safeParse(record.data);
|
|
105
|
+
if (!result.success)
|
|
106
|
+
throw new VendoError("validation", `invalid run row ${record.id}: ${result.error.issues[0]?.message ?? "invalid"}`);
|
|
107
|
+
return result.data;
|
|
108
|
+
};
|
|
109
|
+
// Callers already validated the row via parseRunRow; only __resume needs stripping.
|
|
110
|
+
const publicRun = ({ __resume: _, ...record }) => record;
|
|
111
|
+
const triggerEvent = (source) => source.kind === "host-event" || source.kind === "external" ? source.event : undefined;
|
|
112
|
+
const durationMs = (value) => {
|
|
113
|
+
const match = /^(\d+)([smhd])$/.exec(value);
|
|
114
|
+
if (match === null)
|
|
115
|
+
return null;
|
|
116
|
+
const count = Number(match[1]);
|
|
117
|
+
if (!Number.isSafeInteger(count) || count <= 0)
|
|
118
|
+
return null;
|
|
119
|
+
const units = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
120
|
+
return count * units[match[2]];
|
|
121
|
+
};
|
|
122
|
+
const validateTrigger = (value) => {
|
|
123
|
+
const parsed = triggerSchema.safeParse(value);
|
|
124
|
+
if (!parsed.success)
|
|
125
|
+
throw new VendoError("validation", parsed.error.issues[0]?.message ?? "invalid trigger");
|
|
126
|
+
const trigger = parsed.data;
|
|
127
|
+
if (trigger.on.kind === "schedule") {
|
|
128
|
+
if (trigger.on.every !== undefined && durationMs(trigger.on.every) === null) {
|
|
129
|
+
throw new VendoError("validation", "schedule every must match <n><s|m|h|d> with n > 0");
|
|
130
|
+
}
|
|
131
|
+
if (trigger.on.cron !== undefined) {
|
|
132
|
+
if (trigger.on.cron.trim().split(/\s+/).length !== 5) {
|
|
133
|
+
throw new VendoError("validation", "schedule cron must contain exactly 5 fields");
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
new Cron(trigger.on.cron, { timezone: "UTC", paused: true });
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
throw new VendoError("validation", `invalid schedule cron: ${message(error)}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (trigger.on.at !== undefined && !Number.isFinite(Date.parse(trigger.on.at))) {
|
|
143
|
+
throw new VendoError("validation", "schedule at must be an ISO date-time");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return trigger;
|
|
147
|
+
};
|
|
148
|
+
const evaluate = async (expression, input) => await jsonata(expression).evaluate(input);
|
|
149
|
+
const stepArgs = async (step, event, outputs, item) => {
|
|
150
|
+
const context = { event, steps: outputs, item };
|
|
151
|
+
const args = {};
|
|
152
|
+
for (const [key, expression] of Object.entries(step.args ?? {})) {
|
|
153
|
+
args[key] = await evaluate(expression, context);
|
|
154
|
+
}
|
|
155
|
+
return args;
|
|
156
|
+
};
|
|
157
|
+
const outcomeDetail = (outcome) => {
|
|
158
|
+
if (outcome.status === "error")
|
|
159
|
+
return outcome.error.message;
|
|
160
|
+
if (outcome.status === "blocked")
|
|
161
|
+
return outcome.reason;
|
|
162
|
+
if (outcome.status === "pending-approval")
|
|
163
|
+
return outcome.approvalId;
|
|
164
|
+
if (outcome.status === "connect-required")
|
|
165
|
+
return outcome.connect.message;
|
|
166
|
+
return undefined;
|
|
167
|
+
};
|
|
168
|
+
const errorForOutcome = (outcome) => {
|
|
169
|
+
if (outcome.status === "error")
|
|
170
|
+
return outcome.error;
|
|
171
|
+
if (outcome.status === "blocked")
|
|
172
|
+
return { code: "blocked", message: outcome.reason };
|
|
173
|
+
// An away run has no user to show a connect card to; the run fails with an
|
|
174
|
+
// actionable message and the user connects in-product before re-running.
|
|
175
|
+
if (outcome.status === "connect-required")
|
|
176
|
+
return { code: "connect-required", message: outcome.connect.message };
|
|
177
|
+
return { code: "blocked", message: `approval required: ${outcome.approvalId}` };
|
|
178
|
+
};
|
|
179
|
+
const base64url = (bytes) => {
|
|
180
|
+
let binary = "";
|
|
181
|
+
for (const byte of bytes)
|
|
182
|
+
binary += String.fromCharCode(byte);
|
|
183
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/u, "");
|
|
184
|
+
};
|
|
185
|
+
const decodeBase64 = (value, url = false) => {
|
|
186
|
+
try {
|
|
187
|
+
let normalized = url ? value.replace(/-/g, "+").replace(/_/g, "/") : value;
|
|
188
|
+
normalized += "=".repeat((4 - normalized.length % 4) % 4);
|
|
189
|
+
const binary = atob(normalized);
|
|
190
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const verifySignature = async (secret, signature, signed) => {
|
|
197
|
+
const keyBytes = decodeBase64(secret, true);
|
|
198
|
+
const signatureBytes = decodeBase64(signature);
|
|
199
|
+
if (keyBytes === null || signatureBytes === null)
|
|
200
|
+
return false;
|
|
201
|
+
try {
|
|
202
|
+
const key = await globalThis.crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
203
|
+
return await globalThis.crypto.subtle.verify("HMAC", key, signatureBytes, signed);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const signedWebhookBytes = (deliveryId, timestamp, raw) => {
|
|
210
|
+
const prefix = new TextEncoder().encode(`${deliveryId}.${timestamp}.`);
|
|
211
|
+
const signed = new Uint8Array(prefix.length + raw.length);
|
|
212
|
+
signed.set(prefix);
|
|
213
|
+
signed.set(raw, prefix.length);
|
|
214
|
+
return signed;
|
|
215
|
+
};
|
|
216
|
+
const readLimitedBody = async (request, limit) => {
|
|
217
|
+
if (request.body === null)
|
|
218
|
+
return new Uint8Array();
|
|
219
|
+
const reader = request.body.getReader();
|
|
220
|
+
const chunks = [];
|
|
221
|
+
let length = 0;
|
|
222
|
+
try {
|
|
223
|
+
while (true) {
|
|
224
|
+
const { done, value } = await reader.read();
|
|
225
|
+
if (done)
|
|
226
|
+
break;
|
|
227
|
+
length += value.byteLength;
|
|
228
|
+
if (length > limit) {
|
|
229
|
+
await reader.cancel().catch(() => undefined);
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
chunks.push(value);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
reader.releaseLock();
|
|
237
|
+
}
|
|
238
|
+
const body = new Uint8Array(length);
|
|
239
|
+
let offset = 0;
|
|
240
|
+
for (const chunk of chunks) {
|
|
241
|
+
body.set(chunk, offset);
|
|
242
|
+
offset += chunk.byteLength;
|
|
243
|
+
}
|
|
244
|
+
return body;
|
|
245
|
+
};
|
|
246
|
+
const terminalStatus = (status) => status === "ok" || status === "error" || status === "stopped";
|
|
247
|
+
const syncRun = (target, source) => {
|
|
248
|
+
delete target.__resume;
|
|
249
|
+
delete target.finishedAt;
|
|
250
|
+
delete target.summary;
|
|
251
|
+
delete target.error;
|
|
252
|
+
Object.assign(target, clone(source));
|
|
253
|
+
};
|
|
254
|
+
const validateForEachItems = (step, value) => {
|
|
255
|
+
if (!Array.isArray(value))
|
|
256
|
+
throw new Error(`step ${step.id} forEach did not produce an array`);
|
|
257
|
+
if (value.length > FOREACH_MAX_ITEMS)
|
|
258
|
+
throw new Error(`step ${step.id} forEach exceeds ${FOREACH_MAX_ITEMS} items`);
|
|
259
|
+
return value;
|
|
260
|
+
};
|
|
261
|
+
export const createAutomationsEngine = (config) => {
|
|
262
|
+
const now = () => config.now?.() ?? new Date();
|
|
263
|
+
const iso = () => now().toISOString();
|
|
264
|
+
const stopped = new Set();
|
|
265
|
+
const active = new Set();
|
|
266
|
+
const resuming = new Set();
|
|
267
|
+
const inFlightDeliveries = new Set();
|
|
268
|
+
const abortControllers = new Map();
|
|
269
|
+
const engineInstanceId = globalThis.crypto.randomUUID();
|
|
270
|
+
let tickTail = Promise.resolve();
|
|
271
|
+
// Absent localTriggerKinds → every kind fires locally (today's behavior, unchanged).
|
|
272
|
+
const firesLocally = (kind) => config.localTriggerKinds === undefined || config.localTriggerKinds.has(kind);
|
|
273
|
+
const appRecord = async (appId) => {
|
|
274
|
+
const record = await config.store.records(APPS).get(appId);
|
|
275
|
+
return record === null ? null : { record, row: parseAppRow(record) };
|
|
276
|
+
};
|
|
277
|
+
const ownedApp = async (appId, subject) => {
|
|
278
|
+
const found = await appRecord(appId);
|
|
279
|
+
if (found === null || found.row.subject !== subject)
|
|
280
|
+
throw new VendoError("not-found", `app not found: ${appId}`);
|
|
281
|
+
return found;
|
|
282
|
+
};
|
|
283
|
+
const writeApp = async (record, row) => {
|
|
284
|
+
// trigger_kind lets the tick/emit fetch apps by trigger kind (the reserved store derives it
|
|
285
|
+
// from a column and ignores caller refs; a generic StoreAdapter honors what we pass here).
|
|
286
|
+
await config.store.records(APPS).put({
|
|
287
|
+
id: record.id,
|
|
288
|
+
data: row,
|
|
289
|
+
refs: {
|
|
290
|
+
subject: row.subject,
|
|
291
|
+
...(row.doc.trigger === undefined ? {} : { trigger_kind: row.doc.trigger.on.kind }),
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
};
|
|
295
|
+
const descriptors = async () => new Map((await config.tools.descriptors()).map((descriptor) => [descriptor.name, descriptor]));
|
|
296
|
+
const liveGrant = async (subject, appId, descriptor) => {
|
|
297
|
+
const records = await allRecords(config.store.records(GRANTS), {
|
|
298
|
+
refs: { subject, tool: descriptor.name, app_id: appId },
|
|
299
|
+
});
|
|
300
|
+
const at = now().getTime();
|
|
301
|
+
return records.some((record) => {
|
|
302
|
+
const parsed = permissionGrantSchema.safeParse(record.data);
|
|
303
|
+
if (!parsed.success)
|
|
304
|
+
return false;
|
|
305
|
+
const grant = parsed.data;
|
|
306
|
+
return grant.subject === subject
|
|
307
|
+
&& grant.tool === descriptor.name
|
|
308
|
+
&& grant.descriptorHash === descriptorHash(descriptor)
|
|
309
|
+
&& grant.appId === appId
|
|
310
|
+
&& grant.source === "automation"
|
|
311
|
+
&& grant.duration === "standing"
|
|
312
|
+
&& grant.scope.kind === "tool"
|
|
313
|
+
&& grant.revokedAt === undefined
|
|
314
|
+
&& (grant.expiresAt === undefined || Date.parse(grant.expiresAt) > at);
|
|
315
|
+
});
|
|
316
|
+
};
|
|
317
|
+
const audit = async (ctx, status, extra = {}) => {
|
|
318
|
+
const event = {
|
|
319
|
+
id: id("aud_"),
|
|
320
|
+
at: iso(),
|
|
321
|
+
kind: "run",
|
|
322
|
+
principal: ctx.principal,
|
|
323
|
+
venue: "automation",
|
|
324
|
+
presence: "away",
|
|
325
|
+
...(ctx.appId === undefined ? {} : { appId: ctx.appId }),
|
|
326
|
+
...(ctx.trigger === undefined ? {} : { trigger: ctx.trigger }),
|
|
327
|
+
detail: { status, ...extra },
|
|
328
|
+
};
|
|
329
|
+
await config.guard.report(event);
|
|
330
|
+
};
|
|
331
|
+
const writeRun = async (record) => {
|
|
332
|
+
const stored = await config.store.records(RUNS).get(record.id);
|
|
333
|
+
if (stored !== null) {
|
|
334
|
+
const current = parseRunRow(stored).record;
|
|
335
|
+
if (terminalStatus(current.status)) {
|
|
336
|
+
syncRun(record, current);
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
await config.store.records(RUNS).put({
|
|
341
|
+
id: record.id,
|
|
342
|
+
data: {
|
|
343
|
+
appId: record.appId,
|
|
344
|
+
trigger: record.trigger,
|
|
345
|
+
status: record.status,
|
|
346
|
+
record,
|
|
347
|
+
startedAt: record.startedAt,
|
|
348
|
+
...(record.finishedAt === undefined ? {} : { finishedAt: record.finishedAt }),
|
|
349
|
+
},
|
|
350
|
+
refs: { app_id: record.appId, status: record.status },
|
|
351
|
+
});
|
|
352
|
+
return true;
|
|
353
|
+
};
|
|
354
|
+
const runContext = (run, subject) => ({
|
|
355
|
+
principal: { kind: "user", subject },
|
|
356
|
+
venue: "automation",
|
|
357
|
+
presence: "away",
|
|
358
|
+
sessionId: `sess_${run.id}`,
|
|
359
|
+
appId: run.appId,
|
|
360
|
+
trigger: { runId: run.id, kind: run.trigger.kind },
|
|
361
|
+
});
|
|
362
|
+
const terminal = async (run, ctx, status, summary, error) => {
|
|
363
|
+
delete run.__resume;
|
|
364
|
+
run.status = status;
|
|
365
|
+
run.finishedAt = iso();
|
|
366
|
+
run.summary = summary;
|
|
367
|
+
if (error === undefined)
|
|
368
|
+
delete run.error;
|
|
369
|
+
else
|
|
370
|
+
run.error = error;
|
|
371
|
+
if (await writeRun(run))
|
|
372
|
+
await audit(ctx, status);
|
|
373
|
+
};
|
|
374
|
+
const park = async (run, ctx, state) => {
|
|
375
|
+
if (new TextEncoder().encode(JSON.stringify(state)).byteLength > RESUME_MAX_BYTES) {
|
|
376
|
+
await terminal(run, ctx, "error", `stopped at ${run.steps.at(-1)?.id ?? "step"}: persisted resume state exceeds 512 KiB`, { code: "validation", message: "persisted resume state exceeds 512 KiB" });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
run.status = "pending-approval";
|
|
380
|
+
run.summary = `stopped at ${run.steps.at(-1)?.id ?? "step"}: approval required`;
|
|
381
|
+
run.__resume = clone(state);
|
|
382
|
+
if (!await writeRun(run))
|
|
383
|
+
return;
|
|
384
|
+
await config.store.records(PARKED).put({ id: state.approvalId, data: { runId: run.id } });
|
|
385
|
+
await audit(ctx, "pending-approval");
|
|
386
|
+
};
|
|
387
|
+
const appendOutcome = (run, step, outcome) => {
|
|
388
|
+
run.steps.push({
|
|
389
|
+
id: step.id,
|
|
390
|
+
tool: step.tool,
|
|
391
|
+
outcome: outcome.status,
|
|
392
|
+
at: iso(),
|
|
393
|
+
...(outcomeDetail(outcome) === undefined ? {} : { detail: outcomeDetail(outcome) }),
|
|
394
|
+
});
|
|
395
|
+
};
|
|
396
|
+
const executeCall = async (appId, step, call, ctx) => step.tool.startsWith("fn:")
|
|
397
|
+
? await config.apps.call(appId, step.tool, call.args, ctx)
|
|
398
|
+
: await config.tools.execute(call, ctx);
|
|
399
|
+
const finishStoppedIfNeeded = async (run) => {
|
|
400
|
+
if (stopped.has(run.id)) {
|
|
401
|
+
// runs.stop persisted and audited the authoritative stopped row; this is a stale in-flight copy.
|
|
402
|
+
run.status = "stopped";
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
const stored = await config.store.records(RUNS).get(run.id);
|
|
406
|
+
if (stored !== null) {
|
|
407
|
+
const current = parseRunRow(stored).record;
|
|
408
|
+
if (terminalStatus(current.status)) {
|
|
409
|
+
syncRun(run, current);
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (terminalStatus(run.status))
|
|
414
|
+
return true;
|
|
415
|
+
return false;
|
|
416
|
+
};
|
|
417
|
+
const failStep = async (run, ctx, step, error) => {
|
|
418
|
+
const failed = { status: "error", error: { code: "validation", message: message(error) } };
|
|
419
|
+
appendOutcome(run, step, failed);
|
|
420
|
+
await terminal(run, ctx, "error", `stopped at ${step.id}: ${message(error)}`, failed.error);
|
|
421
|
+
};
|
|
422
|
+
const continueSteps = async (app, trigger, run, ctx, state) => {
|
|
423
|
+
if (trigger.run.kind !== "steps")
|
|
424
|
+
throw new VendoError("validation", "steps run expected");
|
|
425
|
+
const steps = trigger.run.steps;
|
|
426
|
+
for (let stepIndex = state.stepIndex; stepIndex < steps.length; stepIndex += 1) {
|
|
427
|
+
if (await finishStoppedIfNeeded(run))
|
|
428
|
+
return;
|
|
429
|
+
const step = steps[stepIndex];
|
|
430
|
+
let items = stepIndex === state.stepIndex ? state.iterationItems : undefined;
|
|
431
|
+
let outputs = stepIndex === state.stepIndex ? state.iterationOutputs ?? [] : [];
|
|
432
|
+
let iterationStart = stepIndex === state.stepIndex ? state.forEachIndex ?? 0 : 0;
|
|
433
|
+
try {
|
|
434
|
+
if (items === undefined) {
|
|
435
|
+
if (step.if !== undefined && !await evaluate(step.if, { event: state.event, steps: state.stepOutputs, item: undefined })) {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (step.forEach !== undefined) {
|
|
439
|
+
const evaluated = await evaluate(step.forEach, { event: state.event, steps: state.stepOutputs, item: undefined });
|
|
440
|
+
items = validateForEachItems(step, evaluated);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
await failStep(run, ctx, step, error);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const iterations = items === undefined
|
|
449
|
+
? [{}]
|
|
450
|
+
: items.map((item, index) => ({ item, index }));
|
|
451
|
+
for (let index = iterationStart; index < iterations.length; index += 1) {
|
|
452
|
+
if (await finishStoppedIfNeeded(run))
|
|
453
|
+
return;
|
|
454
|
+
const iteration = iterations[index];
|
|
455
|
+
let args;
|
|
456
|
+
try {
|
|
457
|
+
args = await stepArgs(step, state.event, state.stepOutputs, iteration.item);
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
await failStep(run, ctx, step, error);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const call = { id: id("call_"), tool: step.tool, args };
|
|
464
|
+
const outcome = await executeCall(app.doc.id, step, call, ctx);
|
|
465
|
+
if (await finishStoppedIfNeeded(run))
|
|
466
|
+
return;
|
|
467
|
+
appendOutcome(run, step, outcome);
|
|
468
|
+
if (outcome.status === "pending-approval") {
|
|
469
|
+
await park(run, ctx, {
|
|
470
|
+
stepIndex,
|
|
471
|
+
...(items === undefined ? {} : { forEachIndex: index, iterationItems: items, iterationOutputs: outputs }),
|
|
472
|
+
event: state.event,
|
|
473
|
+
stepOutputs: state.stepOutputs,
|
|
474
|
+
call,
|
|
475
|
+
approvalId: outcome.approvalId,
|
|
476
|
+
});
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (outcome.status !== "ok") {
|
|
480
|
+
const error = errorForOutcome(outcome);
|
|
481
|
+
await terminal(run, ctx, "error", `stopped at ${step.id}: ${error.message}`, error);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (items === undefined)
|
|
485
|
+
state.stepOutputs[step.id] = outcome.output;
|
|
486
|
+
else
|
|
487
|
+
outputs.push(outcome.output);
|
|
488
|
+
}
|
|
489
|
+
if (items !== undefined)
|
|
490
|
+
state.stepOutputs[step.id] = outputs;
|
|
491
|
+
state.iterationItems = undefined;
|
|
492
|
+
state.iterationOutputs = undefined;
|
|
493
|
+
state.forEachIndex = undefined;
|
|
494
|
+
}
|
|
495
|
+
const okCount = run.steps.filter((entry) => entry.outcome === "ok").length;
|
|
496
|
+
await terminal(run, ctx, "ok", `${okCount} ${okCount === 1 ? "step" : "steps"} ok`);
|
|
497
|
+
};
|
|
498
|
+
const runAgentic = async (trigger, run, ctx, abortSignal) => {
|
|
499
|
+
if (trigger.run.kind !== "agentic")
|
|
500
|
+
throw new VendoError("validation", "agentic run expected");
|
|
501
|
+
if (config.runner === undefined) {
|
|
502
|
+
await terminal(run, ctx, "error", "agentic runs unavailable", { code: "not-implemented", message: "agentic runs unavailable" });
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
const report = await config.runner({
|
|
507
|
+
prompt: trigger.run.prompt,
|
|
508
|
+
tools: config.tools,
|
|
509
|
+
budget: { maxToolCalls: trigger.run.budget?.maxToolCalls ?? 50 },
|
|
510
|
+
abortSignal,
|
|
511
|
+
}, ctx);
|
|
512
|
+
// Cross-instance stops cannot reach this process's controller, so the persisted
|
|
513
|
+
// terminal-row check remains the best-effort fallback for a late result.
|
|
514
|
+
if (await finishStoppedIfNeeded(run))
|
|
515
|
+
return;
|
|
516
|
+
run.steps = report.toolCalls.map(({ call, outcome }) => ({
|
|
517
|
+
id: call.id,
|
|
518
|
+
tool: call.tool,
|
|
519
|
+
outcome,
|
|
520
|
+
at: iso(),
|
|
521
|
+
}));
|
|
522
|
+
await terminal(run, ctx, report.status, report.summary);
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
if (await finishStoppedIfNeeded(run))
|
|
526
|
+
return;
|
|
527
|
+
await terminal(run, ctx, "error", message(error), { code: "not-implemented", message: message(error) });
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
// Mint the run and its record synchronously (so the id is known immediately), then
|
|
531
|
+
// execute the whole automation on the returned `done` promise. Splitting the id from the
|
|
532
|
+
// completion lets the tick collect runIds without blocking on each run to finish, and lets
|
|
533
|
+
// it bound how long it waits on any single run (see runFiredSchedules).
|
|
534
|
+
const launchRun = (app, kind, event) => {
|
|
535
|
+
const trigger = validateTrigger(app.doc.trigger);
|
|
536
|
+
const runId = id("run_");
|
|
537
|
+
const startedAt = iso();
|
|
538
|
+
const record = {
|
|
539
|
+
id: runId,
|
|
540
|
+
appId: app.doc.id,
|
|
541
|
+
trigger: {
|
|
542
|
+
kind,
|
|
543
|
+
...(triggerEvent(trigger.on) === undefined ? {} : { event: triggerEvent(trigger.on) }),
|
|
544
|
+
},
|
|
545
|
+
status: "running",
|
|
546
|
+
startedAt,
|
|
547
|
+
steps: [],
|
|
548
|
+
};
|
|
549
|
+
const ctx = runContext(record, app.subject);
|
|
550
|
+
const agentController = trigger.run.kind === "agentic" ? new AbortController() : undefined;
|
|
551
|
+
if (agentController !== undefined)
|
|
552
|
+
abortControllers.set(runId, agentController);
|
|
553
|
+
const done = (async () => {
|
|
554
|
+
try {
|
|
555
|
+
await writeRun(record);
|
|
556
|
+
await audit(ctx, "running");
|
|
557
|
+
active.add(runId);
|
|
558
|
+
try {
|
|
559
|
+
if (trigger.run.kind === "steps") {
|
|
560
|
+
await continueSteps(app, trigger, record, ctx, { stepIndex: 0, event, stepOutputs: {} });
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
await runAgentic(trigger, record, ctx, agentController.signal);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
finally {
|
|
567
|
+
active.delete(runId);
|
|
568
|
+
stopped.delete(runId);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
finally {
|
|
572
|
+
if (agentController !== undefined)
|
|
573
|
+
abortControllers.delete(runId);
|
|
574
|
+
}
|
|
575
|
+
})();
|
|
576
|
+
return { runId, done };
|
|
577
|
+
};
|
|
578
|
+
const startRun = async (app, kind, event) => {
|
|
579
|
+
const { runId, done } = launchRun(app, kind, event);
|
|
580
|
+
await done;
|
|
581
|
+
return runId;
|
|
582
|
+
};
|
|
583
|
+
const delay = (ms) => new Promise((resolve) => {
|
|
584
|
+
const timer = setTimeout(resolve, ms);
|
|
585
|
+
// Never keep the event loop alive just for the tick's timeout.
|
|
586
|
+
timer.unref?.();
|
|
587
|
+
});
|
|
588
|
+
// Execute fired automations with bounded parallelism and an optional per-run timeout, so
|
|
589
|
+
// one hung/slow run cannot block other tenants or overrun the tick interval. All runIds are
|
|
590
|
+
// returned regardless of whether their run finished within the timeout (a timed-out run keeps
|
|
591
|
+
// running detached and persists its own terminal state).
|
|
592
|
+
const runFiredSchedules = async (fired) => {
|
|
593
|
+
const concurrency = Math.max(1, Math.floor(config.tickConcurrency ?? 4));
|
|
594
|
+
const timeoutMs = config.runTimeoutMs;
|
|
595
|
+
const ids = new Array(fired.length);
|
|
596
|
+
let next = 0;
|
|
597
|
+
const worker = async () => {
|
|
598
|
+
while (true) {
|
|
599
|
+
const index = next;
|
|
600
|
+
next += 1;
|
|
601
|
+
if (index >= fired.length)
|
|
602
|
+
return;
|
|
603
|
+
const entry = fired[index];
|
|
604
|
+
let launched;
|
|
605
|
+
try {
|
|
606
|
+
launched = launchRun(entry.row, "schedule", { scheduledFor: entry.scheduledFor, firedAt: entry.firedAt });
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// A run that cannot even start (e.g. an invalid trigger) is skipped so other
|
|
610
|
+
// tenants' fired runs still proceed.
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
ids[index] = launched.runId;
|
|
614
|
+
// A detached (timed-out) run must never surface as an unhandled rejection.
|
|
615
|
+
const settled = launched.done.catch(() => undefined);
|
|
616
|
+
if (timeoutMs === undefined)
|
|
617
|
+
await settled;
|
|
618
|
+
else
|
|
619
|
+
await Promise.race([settled, delay(timeoutMs)]);
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, fired.length) }, () => worker()));
|
|
623
|
+
return ids.filter((value) => value !== undefined);
|
|
624
|
+
};
|
|
625
|
+
const mintGrant = async (request) => {
|
|
626
|
+
const grant = {
|
|
627
|
+
id: id("grt_"),
|
|
628
|
+
subject: request.ctx.principal.subject,
|
|
629
|
+
tool: request.call.tool,
|
|
630
|
+
descriptorHash: descriptorHash(request.descriptor),
|
|
631
|
+
scope: { kind: "tool" },
|
|
632
|
+
duration: "standing",
|
|
633
|
+
...(request.ctx.appId === undefined ? {} : { appId: request.ctx.appId }),
|
|
634
|
+
source: "automation",
|
|
635
|
+
grantedAt: iso(),
|
|
636
|
+
};
|
|
637
|
+
await config.store.records(GRANTS).put({
|
|
638
|
+
id: grant.id,
|
|
639
|
+
data: grant,
|
|
640
|
+
refs: {
|
|
641
|
+
subject: grant.subject,
|
|
642
|
+
tool: grant.tool,
|
|
643
|
+
...(grant.appId === undefined ? {} : { app_id: grant.appId }),
|
|
644
|
+
},
|
|
645
|
+
});
|
|
646
|
+
};
|
|
647
|
+
const markConsumed = async (record) => {
|
|
648
|
+
const data = approvalRowSchema.parse(record.data);
|
|
649
|
+
await config.store.records(APPROVALS).put({
|
|
650
|
+
id: record.id,
|
|
651
|
+
data: { ...data, consumedAt: iso() },
|
|
652
|
+
});
|
|
653
|
+
};
|
|
654
|
+
const resumeRun = async (approvalId, approved) => {
|
|
655
|
+
const dropPark = () => config.store.records(PARKED).delete(approvalId);
|
|
656
|
+
const parkedRecord = await config.store.records(PARKED).get(approvalId);
|
|
657
|
+
if (parkedRecord === null)
|
|
658
|
+
return;
|
|
659
|
+
const { runId } = parkedSchema.parse(parkedRecord.data);
|
|
660
|
+
if (resuming.has(runId))
|
|
661
|
+
return;
|
|
662
|
+
resuming.add(runId);
|
|
663
|
+
try {
|
|
664
|
+
const stored = await config.store.records(RUNS).get(runId);
|
|
665
|
+
if (stored === null) {
|
|
666
|
+
await dropPark();
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const run = parseRunRow(stored).record;
|
|
670
|
+
if (run.status !== "pending-approval" || run.__resume?.approvalId !== approvalId) {
|
|
671
|
+
if (run.status === "running" && run.__resume?.approvalId === approvalId && run.__resume.claimedBy !== undefined) {
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
await dropPark();
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const approval = await config.store.records(APPROVALS).get(approvalId);
|
|
678
|
+
if (approval === null)
|
|
679
|
+
return;
|
|
680
|
+
const approvalData = approvalRowSchema.parse(approval.data);
|
|
681
|
+
const claimedBy = `${engineInstanceId}:${globalThis.crypto.randomUUID()}`;
|
|
682
|
+
const claims = config.store.records(RESUME_CLAIMS);
|
|
683
|
+
const atomicClaim = claims.atomic === undefined
|
|
684
|
+
? undefined
|
|
685
|
+
: await claims.atomic.insertIfAbsent({
|
|
686
|
+
id: approvalId,
|
|
687
|
+
data: { runId, claimedBy, claimedAt: iso() },
|
|
688
|
+
});
|
|
689
|
+
if (claims.atomic !== undefined && atomicClaim === null)
|
|
690
|
+
return;
|
|
691
|
+
run.status = "running";
|
|
692
|
+
delete run.summary;
|
|
693
|
+
run.__resume.claimedBy = claimedBy;
|
|
694
|
+
if (!await writeRun(run))
|
|
695
|
+
return;
|
|
696
|
+
if (claims.atomic === undefined) {
|
|
697
|
+
// Optional-capability fallback: preserve the prior single-instance behavior.
|
|
698
|
+
// The unique write/read narrows, but cannot close, a cross-process race.
|
|
699
|
+
const claimedRecord = await config.store.records(RUNS).get(runId);
|
|
700
|
+
if (claimedRecord === null)
|
|
701
|
+
return;
|
|
702
|
+
const claimedRun = parseRunRow(claimedRecord).record;
|
|
703
|
+
if (claimedRun.status !== "running" || claimedRun.__resume?.claimedBy !== claimedBy)
|
|
704
|
+
return;
|
|
705
|
+
syncRun(run, claimedRun);
|
|
706
|
+
}
|
|
707
|
+
const appFound = await appRecord(run.appId);
|
|
708
|
+
if (appFound === null) {
|
|
709
|
+
const ctx = runContext(run, approvalData.request.ctx.principal.subject);
|
|
710
|
+
await terminal(run, ctx, "stopped", "app deleted before resume");
|
|
711
|
+
await dropPark();
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const ctx = runContext(run, appFound.row.subject);
|
|
715
|
+
if (!appFound.row.enabled || appFound.row.doc.trigger === undefined) {
|
|
716
|
+
await terminal(run, ctx, "stopped", "automation disabled before resume");
|
|
717
|
+
await dropPark();
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (await finishStoppedIfNeeded(run)) {
|
|
721
|
+
await dropPark();
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
await audit(ctx, "running");
|
|
725
|
+
if (!approved) {
|
|
726
|
+
const state = run.__resume;
|
|
727
|
+
const pending = [...run.steps].reverse().find((entry) => entry.outcome === "pending-approval" && entry.detail === approvalId);
|
|
728
|
+
if (pending !== undefined) {
|
|
729
|
+
pending.outcome = "blocked";
|
|
730
|
+
pending.detail = "user declined approval";
|
|
731
|
+
pending.at = iso();
|
|
732
|
+
}
|
|
733
|
+
const declinedStepId = state !== undefined && appFound.row.doc.trigger.run.kind === "steps"
|
|
734
|
+
? appFound.row.doc.trigger.run.steps[state.stepIndex]?.id ?? "step"
|
|
735
|
+
: "step";
|
|
736
|
+
await terminal(run, ctx, "error", `stopped at ${declinedStepId}: user declined`, {
|
|
737
|
+
code: "blocked",
|
|
738
|
+
message: "the user declined the approval",
|
|
739
|
+
});
|
|
740
|
+
await dropPark();
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const state = run.__resume;
|
|
744
|
+
// The run is claimed (status "running", claimedBy persisted) from here on: a throw
|
|
745
|
+
// escaping this section would strand it in "running" forever (re-entry short-circuits
|
|
746
|
+
// on the claim and sweepParked only scans "pending-approval"), so any resume failure
|
|
747
|
+
// must land the run on a terminal row instead.
|
|
748
|
+
let trigger;
|
|
749
|
+
let step;
|
|
750
|
+
let outcome;
|
|
751
|
+
try {
|
|
752
|
+
await mintGrant(approvalData.request);
|
|
753
|
+
trigger = validateTrigger(appFound.row.doc.trigger);
|
|
754
|
+
if (trigger.run.kind !== "steps")
|
|
755
|
+
throw new VendoError("validation", "parked agentic run is invalid");
|
|
756
|
+
const parkedStep = trigger.run.steps[state.stepIndex];
|
|
757
|
+
if (parkedStep === undefined)
|
|
758
|
+
throw new VendoError("validation", "parked step is missing");
|
|
759
|
+
step = parkedStep;
|
|
760
|
+
outcome = await executeCall(run.appId, step, state.call, ctx);
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
await terminal(run, ctx, "error", `stopped at resume: ${message(error)}`, {
|
|
764
|
+
code: "validation",
|
|
765
|
+
message: message(error),
|
|
766
|
+
});
|
|
767
|
+
await dropPark();
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (await finishStoppedIfNeeded(run)) {
|
|
771
|
+
await dropPark();
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const pending = [...run.steps].reverse().find((entry) => entry.outcome === "pending-approval" && entry.detail === approvalId);
|
|
775
|
+
if (pending !== undefined) {
|
|
776
|
+
pending.outcome = outcome.status;
|
|
777
|
+
// An explicit undefined property is not JSON — drop the key instead.
|
|
778
|
+
const detail = outcomeDetail(outcome);
|
|
779
|
+
if (detail === undefined)
|
|
780
|
+
delete pending.detail;
|
|
781
|
+
else
|
|
782
|
+
pending.detail = detail;
|
|
783
|
+
pending.at = iso();
|
|
784
|
+
}
|
|
785
|
+
else
|
|
786
|
+
appendOutcome(run, step, outcome);
|
|
787
|
+
if (outcome.status === "pending-approval") {
|
|
788
|
+
state.approvalId = outcome.approvalId;
|
|
789
|
+
delete state.claimedBy;
|
|
790
|
+
await dropPark();
|
|
791
|
+
await park(run, ctx, state);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (outcome.status !== "ok") {
|
|
795
|
+
const error = errorForOutcome(outcome);
|
|
796
|
+
await terminal(run, ctx, "error", `stopped at ${step.id}: ${error.message}`, error);
|
|
797
|
+
await dropPark();
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (state.iterationItems === undefined)
|
|
801
|
+
state.stepOutputs[step.id] = outcome.output;
|
|
802
|
+
else
|
|
803
|
+
(state.iterationOutputs ??= []).push(outcome.output);
|
|
804
|
+
delete run.__resume;
|
|
805
|
+
if (!await writeRun(run)) {
|
|
806
|
+
await dropPark();
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
await dropPark();
|
|
810
|
+
await continueSteps(appFound.row, trigger, run, ctx, {
|
|
811
|
+
stepIndex: state.iterationItems === undefined ? state.stepIndex + 1 : state.stepIndex,
|
|
812
|
+
event: state.event,
|
|
813
|
+
stepOutputs: state.stepOutputs,
|
|
814
|
+
...(state.iterationItems === undefined ? {} : {
|
|
815
|
+
iterationItems: state.iterationItems,
|
|
816
|
+
iterationOutputs: state.iterationOutputs,
|
|
817
|
+
forEachIndex: (state.forEachIndex ?? 0) + 1,
|
|
818
|
+
}),
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
finally {
|
|
822
|
+
resuming.delete(runId);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
const handleDecision = async (approvalId, approved) => {
|
|
826
|
+
const capture = await config.store.records(CAPTURES).get(approvalId);
|
|
827
|
+
if (capture !== null) {
|
|
828
|
+
captureSchema.parse(capture.data);
|
|
829
|
+
const approval = await config.store.records(APPROVALS).get(approvalId);
|
|
830
|
+
if (approved && approval !== null) {
|
|
831
|
+
const data = approvalRowSchema.parse(approval.data);
|
|
832
|
+
await mintGrant(data.request);
|
|
833
|
+
await markConsumed(approval);
|
|
834
|
+
}
|
|
835
|
+
await config.store.records(CAPTURES).delete(approvalId);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (await config.store.records(PARKED).get(approvalId) !== null) {
|
|
839
|
+
await resumeRun(approvalId, approved);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
const approval = await config.store.records(APPROVALS).get(approvalId);
|
|
843
|
+
if (approval === null || !approved)
|
|
844
|
+
return;
|
|
845
|
+
const data = approvalRowSchema.parse(approval.data);
|
|
846
|
+
if (data.status === "approved"
|
|
847
|
+
&& data.consumedAt === undefined
|
|
848
|
+
&& data.request.ctx.venue === "automation"
|
|
849
|
+
&& data.request.ctx.appId !== undefined) {
|
|
850
|
+
// AgentRunReport has no continuation token in v0. Approval arms the
|
|
851
|
+
// app-bound authority for the next agentic firing instead of replaying
|
|
852
|
+
// and duplicating the completed prefix of an agent run.
|
|
853
|
+
await mintGrant(data.request);
|
|
854
|
+
await markConsumed(approval);
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
// Returned as a thenable so a guard that awaits subscribers (ours does)
|
|
858
|
+
// makes decide() deterministic through resumption; guards that don't still
|
|
859
|
+
// get fire-and-forget behavior.
|
|
860
|
+
config.guard.onApprovalDecision((approvalId, approved) => handleDecision(approvalId, approved));
|
|
861
|
+
const enable = async (appId, ctx) => {
|
|
862
|
+
const found = await ownedApp(appId, ctx.principal.subject);
|
|
863
|
+
if (found.row.doc.trigger === undefined)
|
|
864
|
+
throw new VendoError("validation", "app has no trigger");
|
|
865
|
+
const trigger = validateTrigger(found.row.doc.trigger);
|
|
866
|
+
const byName = await descriptors();
|
|
867
|
+
const surface = trigger.run.kind === "steps"
|
|
868
|
+
? [...new Set(trigger.run.steps.map((step) => step.tool).filter((tool) => !tool.startsWith("fn:")))]
|
|
869
|
+
// PR flag: without a model seat, agentic capture conservatively exposes every bound descriptor.
|
|
870
|
+
: [...byName.keys()];
|
|
871
|
+
const missing = [];
|
|
872
|
+
for (const tool of surface) {
|
|
873
|
+
const descriptor = byName.get(tool);
|
|
874
|
+
if (descriptor === undefined)
|
|
875
|
+
throw new VendoError("validation", `unknown tool in automation: ${tool}`);
|
|
876
|
+
if (await liveGrant(found.row.subject, appId, descriptor))
|
|
877
|
+
continue;
|
|
878
|
+
const request = {
|
|
879
|
+
id: id("apr_"),
|
|
880
|
+
call: { id: id("call_"), tool, args: {} },
|
|
881
|
+
descriptor: clone(descriptor),
|
|
882
|
+
inputPreview: `Allow "${found.row.doc.name}" to use ${tool} while you're away (standing, this app only)`,
|
|
883
|
+
ctx: {
|
|
884
|
+
principal: clone(ctx.principal),
|
|
885
|
+
venue: "automation",
|
|
886
|
+
presence: "present",
|
|
887
|
+
appId,
|
|
888
|
+
},
|
|
889
|
+
createdAt: iso(),
|
|
890
|
+
};
|
|
891
|
+
await config.store.records(APPROVALS).put({
|
|
892
|
+
id: request.id,
|
|
893
|
+
data: { request, status: "pending", sessionId: ctx.sessionId },
|
|
894
|
+
});
|
|
895
|
+
await config.store.records(CAPTURES).put({
|
|
896
|
+
id: request.id,
|
|
897
|
+
data: { appId, subject: found.row.subject, tool, descriptorHash: descriptorHash(descriptor) },
|
|
898
|
+
});
|
|
899
|
+
missing.push(request);
|
|
900
|
+
}
|
|
901
|
+
found.row.enabled = true;
|
|
902
|
+
await writeApp(found.record, found.row);
|
|
903
|
+
if (trigger.on.kind === "schedule") {
|
|
904
|
+
const cursor = await config.store.records(SCHEDULE).get(appId);
|
|
905
|
+
if (cursor === null) {
|
|
906
|
+
await config.store.records(SCHEDULE).put({ id: appId, data: { lastFiredAt: iso() } });
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (trigger.on.kind === "external") {
|
|
910
|
+
const secret = await config.store.records(WEBHOOK).get(appId);
|
|
911
|
+
if (secret === null) {
|
|
912
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(32));
|
|
913
|
+
await config.store.records(WEBHOOK).put({ id: appId, data: { secret: base64url(bytes) } });
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return { enabled: true, missing };
|
|
917
|
+
};
|
|
918
|
+
const disable = async (appId, ctx) => {
|
|
919
|
+
const found = await ownedApp(appId, ctx.principal.subject);
|
|
920
|
+
found.row.enabled = false;
|
|
921
|
+
await writeApp(found.record, found.row);
|
|
922
|
+
};
|
|
923
|
+
const list = async (ctx) => {
|
|
924
|
+
const records = await allRecords(config.store.records(APPS), { refs: { subject: ctx.principal.subject } });
|
|
925
|
+
return records.map(parseAppRow)
|
|
926
|
+
.filter((row) => row.subject === ctx.principal.subject && row.doc.trigger !== undefined)
|
|
927
|
+
.map((row) => ({ app: row.doc, enabled: row.enabled }));
|
|
928
|
+
};
|
|
929
|
+
const sweepParked = async () => {
|
|
930
|
+
const runs = await allRecords(config.store.records(RUNS), { refs: { status: "pending-approval" } });
|
|
931
|
+
for (const record of runs) {
|
|
932
|
+
const run = parseRunRow(record).record;
|
|
933
|
+
const approvalId = run.__resume?.approvalId;
|
|
934
|
+
if (approvalId === undefined)
|
|
935
|
+
continue;
|
|
936
|
+
const approval = await config.store.records(APPROVALS).get(approvalId);
|
|
937
|
+
if (approval === null)
|
|
938
|
+
continue;
|
|
939
|
+
const decision = approvalRowSchema.safeParse(approval.data);
|
|
940
|
+
if (!decision.success || decision.data.status === "pending")
|
|
941
|
+
continue;
|
|
942
|
+
await resumeRun(approvalId, decision.data.status === "approved");
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
const runTick = async (providedNow) => {
|
|
946
|
+
await sweepParked();
|
|
947
|
+
// Cloud (or whatever other authority) already fires schedule automations for this
|
|
948
|
+
// deployment — firing them here too would double-run them. Approval resumption
|
|
949
|
+
// (sweepParked, above) is not a firing path, so it stays unconditional.
|
|
950
|
+
if (!firesLocally("schedule"))
|
|
951
|
+
return [];
|
|
952
|
+
const at = providedNow ?? now();
|
|
953
|
+
const atIso = at.toISOString();
|
|
954
|
+
// Fetch only schedule-triggered apps (indexed trigger_kind ref) instead of scanning every
|
|
955
|
+
// app for every subject, then batch every schedule cursor in one query (was an N+1 get).
|
|
956
|
+
const appRecords = await allRecords(config.store.records(APPS), { refs: { trigger_kind: "schedule" } });
|
|
957
|
+
const rows = appRecords
|
|
958
|
+
.map(parseAppRow)
|
|
959
|
+
.filter((row) => row.enabled && row.doc.trigger?.on.kind === "schedule");
|
|
960
|
+
const scheduleRecords = config.store.records(SCHEDULE);
|
|
961
|
+
const cursorRecords = rows.length === 0
|
|
962
|
+
? []
|
|
963
|
+
: await allRecords(scheduleRecords, { ids: rows.map((row) => row.doc.id) });
|
|
964
|
+
const cursorById = new Map(cursorRecords.map((record) => [record.id, record]));
|
|
965
|
+
const fired = [];
|
|
966
|
+
for (const row of rows) {
|
|
967
|
+
const trigger = validateTrigger(row.doc.trigger);
|
|
968
|
+
if (trigger.on.kind !== "schedule")
|
|
969
|
+
continue;
|
|
970
|
+
const cursorRecord = cursorById.get(row.doc.id) ?? null;
|
|
971
|
+
const cursor = cursorRecord === null
|
|
972
|
+
? { lastFiredAt: at.toISOString() }
|
|
973
|
+
: scheduleSchema.parse(cursorRecord.data);
|
|
974
|
+
let scheduledFor;
|
|
975
|
+
if (trigger.on.cron !== undefined) {
|
|
976
|
+
const next = new Cron(trigger.on.cron, { timezone: "UTC", paused: true }).nextRun(new Date(cursor.lastFiredAt));
|
|
977
|
+
if (next !== null && next.getTime() <= at.getTime())
|
|
978
|
+
scheduledFor = next.toISOString();
|
|
979
|
+
}
|
|
980
|
+
else if (trigger.on.every !== undefined) {
|
|
981
|
+
const interval = durationMs(trigger.on.every);
|
|
982
|
+
const due = Date.parse(cursor.lastFiredAt) + interval;
|
|
983
|
+
if (due <= at.getTime())
|
|
984
|
+
scheduledFor = new Date(due).toISOString();
|
|
985
|
+
}
|
|
986
|
+
else if (trigger.on.at !== undefined && cursor.firedAt === undefined && Date.parse(trigger.on.at) <= at.getTime()) {
|
|
987
|
+
scheduledFor = trigger.on.at;
|
|
988
|
+
}
|
|
989
|
+
if (scheduledFor === undefined) {
|
|
990
|
+
if (cursorRecord === null) {
|
|
991
|
+
if (scheduleRecords.atomic === undefined)
|
|
992
|
+
await scheduleRecords.put({ id: row.doc.id, data: cursor });
|
|
993
|
+
else
|
|
994
|
+
await scheduleRecords.atomic.insertIfAbsent({ id: row.doc.id, data: cursor });
|
|
995
|
+
}
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
const nextCursor = {
|
|
999
|
+
...cursor,
|
|
1000
|
+
lastFiredAt: at.toISOString(),
|
|
1001
|
+
...(trigger.on.at === undefined ? {} : { firedAt: at.toISOString() }),
|
|
1002
|
+
};
|
|
1003
|
+
let claimed = true;
|
|
1004
|
+
if (cursorRecord === null) {
|
|
1005
|
+
if (scheduleRecords.atomic === undefined)
|
|
1006
|
+
await scheduleRecords.put({ id: row.doc.id, data: nextCursor });
|
|
1007
|
+
else
|
|
1008
|
+
claimed = await scheduleRecords.atomic.insertIfAbsent({ id: row.doc.id, data: nextCursor }) !== null;
|
|
1009
|
+
}
|
|
1010
|
+
else if (scheduleRecords.atomic !== undefined && cursorRecord.revision !== undefined) {
|
|
1011
|
+
claimed = await scheduleRecords.atomic.compareAndSwap({ id: row.doc.id, data: nextCursor }, cursorRecord.revision) !== null;
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
await scheduleRecords.put({ id: row.doc.id, data: nextCursor });
|
|
1015
|
+
}
|
|
1016
|
+
if (!claimed)
|
|
1017
|
+
continue;
|
|
1018
|
+
fired.push({ row, scheduledFor, firedAt: atIso });
|
|
1019
|
+
}
|
|
1020
|
+
return await runFiredSchedules(fired);
|
|
1021
|
+
};
|
|
1022
|
+
const tick = (providedNow) => {
|
|
1023
|
+
const result = tickTail.then(() => runTick(providedNow));
|
|
1024
|
+
tickTail = result.then(() => undefined, () => undefined);
|
|
1025
|
+
return result;
|
|
1026
|
+
};
|
|
1027
|
+
const start = (intervalMs = 60_000) => {
|
|
1028
|
+
let ticking = false;
|
|
1029
|
+
const timer = setInterval(() => {
|
|
1030
|
+
if (ticking)
|
|
1031
|
+
return;
|
|
1032
|
+
ticking = true;
|
|
1033
|
+
// A failed tick must never surface as an unhandled rejection and crash the
|
|
1034
|
+
// host; the next interval retries.
|
|
1035
|
+
void tick().catch(() => undefined).finally(() => { ticking = false; });
|
|
1036
|
+
}, intervalMs);
|
|
1037
|
+
return () => clearInterval(timer);
|
|
1038
|
+
};
|
|
1039
|
+
const emit = async (event, payload, principal) => {
|
|
1040
|
+
// Only host-event apps for this subject (indexed refs) — was a full scan of the subject's apps.
|
|
1041
|
+
const records = await allRecords(config.store.records(APPS), {
|
|
1042
|
+
refs: { subject: principal.subject, trigger_kind: "host-event" },
|
|
1043
|
+
});
|
|
1044
|
+
const ids = [];
|
|
1045
|
+
for (const record of records) {
|
|
1046
|
+
const row = parseAppRow(record);
|
|
1047
|
+
const source = row.doc.trigger?.on;
|
|
1048
|
+
if (row.enabled && row.subject === principal.subject && source?.kind === "host-event" && source.event === event) {
|
|
1049
|
+
ids.push(await startRun(row, "host-event", payload));
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
return ids;
|
|
1053
|
+
};
|
|
1054
|
+
const envelope = (status, code, text) => Response.json({ error: { code, message: text } }, { status });
|
|
1055
|
+
const rejectWebhook = async (source, text, response = { status: 401, code: "blocked" }) => {
|
|
1056
|
+
await config.guard.report({
|
|
1057
|
+
id: id("aud_"),
|
|
1058
|
+
at: iso(),
|
|
1059
|
+
kind: "run",
|
|
1060
|
+
// Reserved namespace (block-actions design §C): runtime-minted webhook
|
|
1061
|
+
// principals live under `vendo:` so they can never collide with a
|
|
1062
|
+
// host-resolved subject.
|
|
1063
|
+
principal: { kind: "user", subject: webhookSubject(source) },
|
|
1064
|
+
venue: "automation",
|
|
1065
|
+
presence: "away",
|
|
1066
|
+
detail: { status: "webhook-rejected", reason: text },
|
|
1067
|
+
});
|
|
1068
|
+
return envelope(response.status, response.code, text);
|
|
1069
|
+
};
|
|
1070
|
+
const webhook = async (request) => {
|
|
1071
|
+
// Cloud (or whatever other authority) already delivers external events for this
|
|
1072
|
+
// deployment (Composio → Cloud) — launching a run here too would double-run it. No
|
|
1073
|
+
// verification, no audit: this is not a rejection, just a no-op the other authority
|
|
1074
|
+
// is already handling.
|
|
1075
|
+
if (!firesLocally("external"))
|
|
1076
|
+
return Response.json({ deferred: true }, { status: 200 });
|
|
1077
|
+
const source = new URL(request.url).pathname.split("/").filter(Boolean).at(-1) ?? "";
|
|
1078
|
+
const headerResult = z.object({
|
|
1079
|
+
id: z.string().min(1),
|
|
1080
|
+
timestamp: z.string().regex(/^\d+$/),
|
|
1081
|
+
signature: z.string().regex(/^v1,.+$/),
|
|
1082
|
+
}).safeParse({
|
|
1083
|
+
id: request.headers.get("webhook-id"),
|
|
1084
|
+
timestamp: request.headers.get("webhook-timestamp"),
|
|
1085
|
+
signature: request.headers.get("webhook-signature"),
|
|
1086
|
+
});
|
|
1087
|
+
if (!headerResult.success)
|
|
1088
|
+
return await rejectWebhook(source, "invalid webhook headers");
|
|
1089
|
+
const oversized = { status: 413, code: "validation" };
|
|
1090
|
+
const contentLength = request.headers.get("content-length");
|
|
1091
|
+
if (contentLength !== null && /^\d+$/.test(contentLength) && Number(contentLength) > WEBHOOK_MAX_BYTES) {
|
|
1092
|
+
return await rejectWebhook(source, "webhook body exceeds 1 MiB", oversized);
|
|
1093
|
+
}
|
|
1094
|
+
const rawBytes = await readLimitedBody(request, WEBHOOK_MAX_BYTES);
|
|
1095
|
+
if (rawBytes === null)
|
|
1096
|
+
return await rejectWebhook(source, "webhook body exceeds 1 MiB", oversized);
|
|
1097
|
+
const timestampMs = Number(headerResult.data.timestamp) * 1_000;
|
|
1098
|
+
if (!Number.isSafeInteger(timestampMs) || Math.abs(now().getTime() - timestampMs) > 300_000) {
|
|
1099
|
+
return await rejectWebhook(source, "webhook timestamp is outside the allowed window");
|
|
1100
|
+
}
|
|
1101
|
+
// Standard-Webhooks senders may send several space-separated signatures
|
|
1102
|
+
// (key rotation): accept the delivery if ANY v1 candidate verifies.
|
|
1103
|
+
const signatures = headerResult.data.signature
|
|
1104
|
+
.split(/\s+/)
|
|
1105
|
+
.filter((entry) => entry.startsWith("v1,"))
|
|
1106
|
+
.map((entry) => entry.slice(3));
|
|
1107
|
+
const signed = signedWebhookBytes(headerResult.data.id, headerResult.data.timestamp, rawBytes);
|
|
1108
|
+
const appRecords = await allRecords(config.store.records(APPS));
|
|
1109
|
+
const verified = [];
|
|
1110
|
+
for (const record of appRecords) {
|
|
1111
|
+
const row = parseAppRow(record);
|
|
1112
|
+
const trigger = row.doc.trigger?.on;
|
|
1113
|
+
if (!row.enabled || trigger?.kind !== "external" || trigger.connector !== source)
|
|
1114
|
+
continue;
|
|
1115
|
+
const secretRecord = await config.store.records(WEBHOOK).get(row.doc.id);
|
|
1116
|
+
if (secretRecord === null)
|
|
1117
|
+
continue;
|
|
1118
|
+
const secret = webhookSchema.safeParse(secretRecord.data);
|
|
1119
|
+
if (!secret.success)
|
|
1120
|
+
continue;
|
|
1121
|
+
let matched = false;
|
|
1122
|
+
for (const candidate of signatures) {
|
|
1123
|
+
if (await verifySignature(secret.data.secret, candidate, signed)) {
|
|
1124
|
+
matched = true;
|
|
1125
|
+
break;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
if (matched)
|
|
1129
|
+
verified.push(row);
|
|
1130
|
+
}
|
|
1131
|
+
if (verified.length === 0)
|
|
1132
|
+
return await rejectWebhook(source, "webhook signature verification failed");
|
|
1133
|
+
let body;
|
|
1134
|
+
try {
|
|
1135
|
+
body = JSON.parse(new TextDecoder().decode(rawBytes));
|
|
1136
|
+
}
|
|
1137
|
+
catch {
|
|
1138
|
+
return envelope(400, "validation", "webhook body must be valid JSON");
|
|
1139
|
+
}
|
|
1140
|
+
const ids = [];
|
|
1141
|
+
let deduped = 0;
|
|
1142
|
+
for (const row of verified) {
|
|
1143
|
+
const deliveryKey = `${row.doc.id}:${headerResult.data.id}`;
|
|
1144
|
+
if (inFlightDeliveries.has(deliveryKey)) {
|
|
1145
|
+
deduped += 1;
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
inFlightDeliveries.add(deliveryKey);
|
|
1149
|
+
try {
|
|
1150
|
+
const deliveries = config.store.records(DELIVERIES);
|
|
1151
|
+
const delivery = {
|
|
1152
|
+
id: deliveryKey,
|
|
1153
|
+
data: { appId: row.doc.id, deliveryId: headerResult.data.id, receivedAt: iso() },
|
|
1154
|
+
};
|
|
1155
|
+
if (deliveries.atomic === undefined) {
|
|
1156
|
+
if (await deliveries.get(deliveryKey) !== null) {
|
|
1157
|
+
deduped += 1;
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
await deliveries.put(delivery);
|
|
1161
|
+
}
|
|
1162
|
+
else if (await deliveries.atomic.insertIfAbsent(delivery) === null) {
|
|
1163
|
+
deduped += 1;
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
ids.push(await startRun(row, "external", body));
|
|
1167
|
+
}
|
|
1168
|
+
finally {
|
|
1169
|
+
inFlightDeliveries.delete(deliveryKey);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
if (ids.length === 0 && deduped > 0)
|
|
1173
|
+
return Response.json({ deduped: true }, { status: 200 });
|
|
1174
|
+
return Response.json({ runIds: ids }, { status: 200 });
|
|
1175
|
+
};
|
|
1176
|
+
const dryRun = async (appId, ctx, event) => {
|
|
1177
|
+
const found = await ownedApp(appId, ctx.principal.subject);
|
|
1178
|
+
if (found.row.doc.trigger === undefined)
|
|
1179
|
+
throw new VendoError("validation", "app has no trigger");
|
|
1180
|
+
const trigger = validateTrigger(found.row.doc.trigger);
|
|
1181
|
+
const byName = await descriptors();
|
|
1182
|
+
const plan = { steps: [], grantsMissing: [] };
|
|
1183
|
+
const add = async (stepId, tool) => {
|
|
1184
|
+
if (tool.startsWith("fn:")) {
|
|
1185
|
+
plan.steps.push({ id: stepId, tool, wouldAsk: false });
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
const descriptor = byName.get(tool);
|
|
1189
|
+
if (descriptor === undefined)
|
|
1190
|
+
throw new VendoError("validation", `unknown tool in automation: ${tool}`);
|
|
1191
|
+
const granted = await liveGrant(found.row.subject, appId, descriptor);
|
|
1192
|
+
plan.steps.push({ id: stepId, tool, wouldAsk: descriptor.critical === true || !granted });
|
|
1193
|
+
if (!descriptor.critical && !granted && !plan.grantsMissing.includes(tool))
|
|
1194
|
+
plan.grantsMissing.push(tool);
|
|
1195
|
+
};
|
|
1196
|
+
if (trigger.run.kind === "agentic") {
|
|
1197
|
+
for (const descriptor of byName.values())
|
|
1198
|
+
await add(descriptor.name, descriptor.name);
|
|
1199
|
+
return plan;
|
|
1200
|
+
}
|
|
1201
|
+
const outputs = {};
|
|
1202
|
+
for (const step of trigger.run.steps) {
|
|
1203
|
+
if (event === undefined) {
|
|
1204
|
+
await add(step.id, step.tool);
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
try {
|
|
1208
|
+
if (step.if !== undefined && !await evaluate(step.if, { event, steps: outputs, item: undefined }))
|
|
1209
|
+
continue;
|
|
1210
|
+
if (step.forEach === undefined) {
|
|
1211
|
+
await stepArgs(step, event, outputs);
|
|
1212
|
+
await add(step.id, step.tool);
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
const items = validateForEachItems(step, await evaluate(step.forEach, { event, steps: outputs, item: undefined }));
|
|
1216
|
+
for (const item of items) {
|
|
1217
|
+
await stepArgs(step, event, outputs, item);
|
|
1218
|
+
await add(step.id, step.tool);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
catch {
|
|
1222
|
+
// Nothing executes in a dry run, so `steps.<id>` outputs stay empty —
|
|
1223
|
+
// expressions over them cannot expand. Degrade to the static entry
|
|
1224
|
+
// rather than failing the preview.
|
|
1225
|
+
await add(step.id, step.tool);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return plan;
|
|
1229
|
+
};
|
|
1230
|
+
const runsGet = async (runId, ctx) => {
|
|
1231
|
+
const stored = await config.store.records(RUNS).get(runId);
|
|
1232
|
+
if (stored === null)
|
|
1233
|
+
return null;
|
|
1234
|
+
const run = parseRunRow(stored).record;
|
|
1235
|
+
const app = await appRecord(run.appId);
|
|
1236
|
+
return app === null || app.row.subject !== ctx.principal.subject ? null : publicRun(run);
|
|
1237
|
+
};
|
|
1238
|
+
const runsList = async (filter, ctx) => {
|
|
1239
|
+
// Scope BEFORE paginating: filtering after the page both under-fills pages
|
|
1240
|
+
// and leaks a cursor (an existence oracle) to non-owners.
|
|
1241
|
+
if (filter.appId !== undefined) {
|
|
1242
|
+
const app = await appRecord(filter.appId);
|
|
1243
|
+
if (app === null || app.row.subject !== ctx.principal.subject)
|
|
1244
|
+
return { runs: [] };
|
|
1245
|
+
}
|
|
1246
|
+
const refs = {
|
|
1247
|
+
...(filter.appId === undefined ? {} : { app_id: filter.appId }),
|
|
1248
|
+
...(filter.status === undefined ? {} : { status: filter.status }),
|
|
1249
|
+
};
|
|
1250
|
+
const runs = [];
|
|
1251
|
+
const owned = new Map();
|
|
1252
|
+
let cursor = filter.cursor;
|
|
1253
|
+
// Without an appId scope, walk store pages until a page is filled with the
|
|
1254
|
+
// caller's runs — bounded so a foreign-heavy table cannot be scanned
|
|
1255
|
+
// unboundedly. Each fetch asks for exactly the remaining page budget, so
|
|
1256
|
+
// the store cursor always sits at the consumption boundary: pages never
|
|
1257
|
+
// overfill and the returned cursor never skips rows.
|
|
1258
|
+
for (let pages = 0; pages < 20 && runs.length < RUNS_PAGE_LIMIT; pages += 1) {
|
|
1259
|
+
const page = await config.store.records(RUNS).list({
|
|
1260
|
+
refs,
|
|
1261
|
+
limit: RUNS_PAGE_LIMIT - runs.length,
|
|
1262
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
1263
|
+
});
|
|
1264
|
+
for (const stored of page.records) {
|
|
1265
|
+
const run = parseRunRow(stored).record;
|
|
1266
|
+
let mine = owned.get(run.appId);
|
|
1267
|
+
if (mine === undefined) {
|
|
1268
|
+
const app = await appRecord(run.appId);
|
|
1269
|
+
mine = app !== null && app.row.subject === ctx.principal.subject;
|
|
1270
|
+
owned.set(run.appId, mine);
|
|
1271
|
+
}
|
|
1272
|
+
if (mine)
|
|
1273
|
+
runs.push(publicRun(run));
|
|
1274
|
+
}
|
|
1275
|
+
cursor = page.cursor;
|
|
1276
|
+
if (cursor === undefined)
|
|
1277
|
+
break;
|
|
1278
|
+
}
|
|
1279
|
+
return { runs, ...(cursor === undefined ? {} : { cursor }) };
|
|
1280
|
+
};
|
|
1281
|
+
const runsStop = async (runId, ctx) => {
|
|
1282
|
+
const stored = await config.store.records(RUNS).get(runId);
|
|
1283
|
+
if (stored === null)
|
|
1284
|
+
throw new VendoError("not-found", `run not found: ${runId}`);
|
|
1285
|
+
const run = parseRunRow(stored).record;
|
|
1286
|
+
const app = await appRecord(run.appId);
|
|
1287
|
+
if (app === null || app.row.subject !== ctx.principal.subject)
|
|
1288
|
+
throw new VendoError("not-found", `run not found: ${runId}`);
|
|
1289
|
+
if (run.status !== "running" && run.status !== "pending-approval") {
|
|
1290
|
+
throw new VendoError("conflict", `run cannot be stopped from status ${run.status}`);
|
|
1291
|
+
}
|
|
1292
|
+
stopped.add(runId);
|
|
1293
|
+
abortControllers.get(runId)?.abort();
|
|
1294
|
+
const parkedApprovalId = run.__resume?.approvalId;
|
|
1295
|
+
const runCtx = runContext(run, app.row.subject);
|
|
1296
|
+
await terminal(run, runCtx, "stopped", "stopped by user");
|
|
1297
|
+
if (parkedApprovalId !== undefined)
|
|
1298
|
+
await config.store.records(PARKED).delete(parkedApprovalId);
|
|
1299
|
+
if (!active.has(runId))
|
|
1300
|
+
stopped.delete(runId);
|
|
1301
|
+
};
|
|
1302
|
+
return {
|
|
1303
|
+
enable,
|
|
1304
|
+
disable,
|
|
1305
|
+
list,
|
|
1306
|
+
tick,
|
|
1307
|
+
start,
|
|
1308
|
+
emit,
|
|
1309
|
+
webhook,
|
|
1310
|
+
runs: { get: runsGet, list: runsList, stop: runsStop },
|
|
1311
|
+
dryRun,
|
|
1312
|
+
};
|
|
1313
|
+
};
|
|
1314
|
+
//# sourceMappingURL=engine.js.map
|