@intelligo-dev/executions 1.0.0-beta.1 → 1.0.0-beta.13

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.
@@ -0,0 +1,525 @@
1
+ /**
2
+ * Execution lifecycle — the SaaS boundary around a native AI run.
3
+ *
4
+ * const run = await executions.begin({ workspaceId, userId, capability });
5
+ * if (!run.allowed) return refuse(run.reason);
6
+ * try {
7
+ * const result = await supportAgent.generate(messages); // native
8
+ * await run.complete({ usage: result.usage, model: result.model });
9
+ * return result;
10
+ * } catch (error) {
11
+ * await run.fail({ error });
12
+ * throw error;
13
+ * }
14
+ *
15
+ * The handle knows nothing about agents, tools, messages, or streams —
16
+ * only actor, workspace, capability, entitlement, status, usage, cost,
17
+ * credits, and audit.
18
+ *
19
+ * Every terminal transition is idempotent: calling complete() twice, or
20
+ * fail() after complete(), leaves the first outcome in place. Streaming
21
+ * routes have several plausible finish paths (usage resolved, client
22
+ * abort, error) and must be able to fire whichever arrives first
23
+ * without racing.
24
+ */
25
+
26
+ import { db } from "@intelligo-dev/core/db";
27
+ import { createLogger } from "@intelligo-dev/core/logger";
28
+ import type { Money } from "@intelligo-dev/core/money";
29
+ import { recordAuditEvent } from "@intelligo-dev/audit";
30
+ import { and, eq } from "drizzle-orm";
31
+
32
+ import { executions } from "./db/schema";
33
+ import type { ExecutionPorts } from "./ports";
34
+
35
+ const log = createLogger("Executions");
36
+
37
+ /**
38
+ * Row lifecycle. `settling` is non-terminal and deliberate: it marks
39
+ * the window where usage is being charged, so a second complete() or a
40
+ * racing fail() cannot re-enter it, and a settlement that dies mid-way
41
+ * leaves a state the stale sweep reports rather than a row that claims
42
+ * success for usage nobody recorded.
43
+ */
44
+ export type ExecutionStatus =
45
+ "running" | "settling" | "succeeded" | "failed" | "refused";
46
+
47
+ export type BeginExecutionInput = {
48
+ workspaceId: string;
49
+ userId?: string | null;
50
+ /** Product-defined verb, e.g. "support.reply". */
51
+ capability: string;
52
+ /** Supply to reuse an existing correlation id; generated otherwise. */
53
+ requestId?: string;
54
+ /** Model the caller intends to use — informs the entitlement hold. */
55
+ model?: string;
56
+ metadata?: Record<string, unknown>;
57
+ };
58
+
59
+ export type CompleteExecutionInput = {
60
+ usage?: {
61
+ inputTokens?: number;
62
+ outputTokens?: number;
63
+ totalTokens?: number;
64
+ };
65
+ model?: string;
66
+ metadata?: Record<string, unknown>;
67
+ };
68
+
69
+ export type ExecutionRun = {
70
+ id: string;
71
+ requestId: string;
72
+ /** False when entitlement refused; complete()/fail() are then no-ops. */
73
+ allowed: boolean;
74
+ /** Stable refusal code from the entitlement port; set when allowed is false. */
75
+ code?: string;
76
+ /** Set when allowed is false. */
77
+ reason?: string;
78
+ /** The hold entitlement took for this run. */
79
+ estimated?: Money;
80
+ usingTrialCredits: boolean;
81
+ complete(input?: CompleteExecutionInput): Promise<void>;
82
+ fail(input: { error: unknown }): Promise<void>;
83
+ };
84
+
85
+ function errorMessage(error: unknown): string {
86
+ return error instanceof Error ? error.message : String(error);
87
+ }
88
+
89
+ export function createExecutions(ports: ExecutionPorts = {}) {
90
+ async function begin(input: BeginExecutionInput): Promise<ExecutionRun> {
91
+ const requestId = input.requestId ?? crypto.randomUUID();
92
+ const id = crypto.randomUUID();
93
+ const startedAt = new Date();
94
+
95
+ const decision = ports.checkEntitlement
96
+ ? await ports.checkEntitlement({
97
+ workspaceId: input.workspaceId,
98
+ userId: input.userId,
99
+ capability: input.capability,
100
+ requestId,
101
+ model: input.model,
102
+ })
103
+ : { allowed: true };
104
+
105
+ const row = db.insert(executions).values({
106
+ id,
107
+ workspaceId: input.workspaceId,
108
+ userId: input.userId ?? null,
109
+ capability: input.capability,
110
+ requestId,
111
+ status: decision.allowed ? "running" : "refused",
112
+ model: input.model ?? null,
113
+ reservedMicros: decision.estimated?.amount ?? null,
114
+ currency: decision.estimated?.currency ?? null,
115
+ refusalReason: decision.allowed ? null : (decision.reason ?? "refused"),
116
+ startedAt,
117
+ finishedAt: decision.allowed ? null : startedAt,
118
+ durationMs: decision.allowed ? null : 0,
119
+ metadata: input.metadata ?? null,
120
+ });
121
+ try {
122
+ await row;
123
+ } catch (error) {
124
+ // No row means nothing will ever settle or release the hold
125
+ // entitlement just took (a reused `requestId` hits the unique
126
+ // index here), so give it back before failing.
127
+ if (decision.allowed && ports.releaseHold) {
128
+ await ports
129
+ .releaseHold({ workspaceId: input.workspaceId, requestId })
130
+ .catch((releaseError) =>
131
+ log.warn("Hold release failed after insert error", {
132
+ requestId,
133
+ error: errorMessage(releaseError),
134
+ })
135
+ );
136
+ }
137
+ throw error;
138
+ }
139
+
140
+ const usingTrialCredits = decision.usingTrialCredits ?? false;
141
+
142
+ if (!decision.allowed) {
143
+ await recordAuditEvent({
144
+ workspaceId: input.workspaceId,
145
+ actorId: input.userId ?? null,
146
+ action: "execution.refused",
147
+ resourceKind: "execution",
148
+ resourceId: id,
149
+ outcome: "failed",
150
+ metadata: {
151
+ capability: input.capability,
152
+ requestId,
153
+ code: decision.code,
154
+ reason: decision.reason,
155
+ },
156
+ });
157
+
158
+ return {
159
+ id,
160
+ requestId,
161
+ allowed: false,
162
+ code: decision.code,
163
+ reason: decision.reason,
164
+ estimated: decision.estimated,
165
+ usingTrialCredits,
166
+ async complete() {},
167
+ async fail() {},
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Compare-and-swap the row's status. Returns false when another
173
+ * path already moved it, so the caller can skip the side effects
174
+ * that belong to the transition it lost.
175
+ */
176
+ async function transition(
177
+ from: ExecutionStatus,
178
+ to: ExecutionStatus,
179
+ fields: Record<string, unknown> = {}
180
+ ): Promise<boolean> {
181
+ const updated = await db
182
+ .update(executions)
183
+ .set({ status: to, ...fields })
184
+ .where(and(eq(executions.id, id), eq(executions.status, from)))
185
+ .returning();
186
+ return updated.length > 0;
187
+ }
188
+
189
+ /** Terminal transition: stamps the finish time and duration. */
190
+ function finishFields(fields: Record<string, unknown>) {
191
+ const finishedAt = new Date();
192
+ return {
193
+ finishedAt,
194
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
195
+ ...fields,
196
+ };
197
+ }
198
+
199
+ return {
200
+ id,
201
+ requestId,
202
+ allowed: true,
203
+ estimated: decision.estimated,
204
+ usingTrialCredits,
205
+
206
+ async complete(result: CompleteExecutionInput = {}) {
207
+ const inputTokens = result.usage?.inputTokens ?? 0;
208
+ const outputTokens = result.usage?.outputTokens ?? 0;
209
+ const totalTokens =
210
+ result.usage?.totalTokens ?? inputTokens + outputTokens;
211
+ const model = result.model ?? input.model;
212
+
213
+ // Claim the right to settle BEFORE spending money, so a second
214
+ // complete() — or a complete() racing a fail() — cannot charge the
215
+ // workspace again.
216
+ // The usage is written with the claim, before any money moves,
217
+ // so a crash after this point leaves enough on the row for
218
+ // reconcile() to finish the job.
219
+ if (
220
+ !(await transition("running", "settling", {
221
+ model: model ?? null,
222
+ inputTokens,
223
+ outputTokens,
224
+ totalTokens,
225
+ }))
226
+ ) {
227
+ return;
228
+ }
229
+
230
+ let charged: Money | undefined;
231
+ if (ports.settleUsage) {
232
+ try {
233
+ const settled = await ports.settleUsage({
234
+ workspaceId: input.workspaceId,
235
+ userId: input.userId,
236
+ requestId,
237
+ capability: input.capability,
238
+ model,
239
+ inputTokens,
240
+ outputTokens,
241
+ totalTokens,
242
+ usingTrialCredits,
243
+ metadata: result.metadata ?? input.metadata,
244
+ });
245
+ charged = settled?.charged;
246
+ } catch (error) {
247
+ // Usage is money: never silently drop it. The row stays
248
+ // `settling` — a non-terminal state the stale sweep reports
249
+ // — so the execution is visibly unsettled rather than
250
+ // recorded as a successful free turn.
251
+ log.error("Usage settlement failed", {
252
+ executionId: id,
253
+ requestId,
254
+ workspaceId: input.workspaceId,
255
+ error: errorMessage(error),
256
+ });
257
+ await recordAuditEvent({
258
+ workspaceId: input.workspaceId,
259
+ actorId: input.userId ?? null,
260
+ action: "execution.settlement_failed",
261
+ resourceKind: "execution",
262
+ resourceId: id,
263
+ outcome: "failed",
264
+ metadata: {
265
+ requestId,
266
+ capability: input.capability,
267
+ error: errorMessage(error),
268
+ },
269
+ });
270
+ throw error;
271
+ }
272
+ }
273
+
274
+ await transition(
275
+ "settling",
276
+ "succeeded",
277
+ finishFields({
278
+ model: model ?? null,
279
+ inputTokens,
280
+ outputTokens,
281
+ totalTokens,
282
+ chargedMicros: charged?.amount ?? null,
283
+ // Only a real charge names the currency; a free capability
284
+ // leaves whatever the hold wrote.
285
+ ...(charged ? { currency: charged.currency } : {}),
286
+ })
287
+ );
288
+
289
+ await recordAuditEvent({
290
+ workspaceId: input.workspaceId,
291
+ actorId: input.userId ?? null,
292
+ action: "execution.completed",
293
+ resourceKind: "execution",
294
+ resourceId: id,
295
+ metadata: {
296
+ requestId,
297
+ capability: input.capability,
298
+ model,
299
+ totalTokens,
300
+ charged,
301
+ },
302
+ });
303
+ },
304
+
305
+ async fail({ error }: { error: unknown }) {
306
+ // Only from `running`. Once complete() has claimed the row for
307
+ // settlement, a late abort must not release a hold that is
308
+ // about to be charged.
309
+ const transitioned = await transition(
310
+ "running",
311
+ "failed",
312
+ finishFields({ errorMessage: errorMessage(error).slice(0, 1000) })
313
+ );
314
+ if (!transitioned) return;
315
+
316
+ if (ports.releaseHold) {
317
+ try {
318
+ await ports.releaseHold({
319
+ workspaceId: input.workspaceId,
320
+ requestId,
321
+ });
322
+ } catch (releaseError) {
323
+ // Non-fatal: an unreleased hold expires on its own.
324
+ log.warn("Hold release failed", {
325
+ executionId: id,
326
+ requestId,
327
+ error: errorMessage(releaseError),
328
+ });
329
+ }
330
+ }
331
+
332
+ await recordAuditEvent({
333
+ workspaceId: input.workspaceId,
334
+ actorId: input.userId ?? null,
335
+ action: "execution.failed",
336
+ resourceKind: "execution",
337
+ resourceId: id,
338
+ outcome: "failed",
339
+ metadata: {
340
+ requestId,
341
+ capability: input.capability,
342
+ error: errorMessage(error),
343
+ },
344
+ });
345
+ },
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Finish an execution the happy path did not.
351
+ *
352
+ * `settling` has two readings — the charge never happened, or it
353
+ * committed and the process died before the final flip — and only
354
+ * the ledger can tell them apart, so the `findSettlement` port is
355
+ * asked first. A charge that exists is confirmed onto the row; one
356
+ * that does not is re-run from the usage the claim recorded. A
357
+ * `running` row older than `abandonRunningAfterMs` (the stream died
358
+ * without reaching complete()/fail()) is failed and its hold
359
+ * released; without that option `running` rows are left alone.
360
+ *
361
+ * Every transition is the same compare-and-swap the lifecycle uses,
362
+ * so a reconcile racing a late complete()/fail() cannot double
363
+ * charge or double release.
364
+ */
365
+ async function reconcile(
366
+ executionId: string,
367
+ options: { abandonRunningAfterMs?: number } = {}
368
+ ): Promise<ReconcileResult> {
369
+ const rows = await db
370
+ .select()
371
+ .from(executions)
372
+ .where(eq(executions.id, executionId))
373
+ .limit(1);
374
+ const row = rows[0];
375
+ if (!row) return { action: "noop", status: "missing" };
376
+
377
+ const finish = (fields: Record<string, unknown>) => {
378
+ const finishedAt = new Date();
379
+ return {
380
+ finishedAt,
381
+ durationMs: finishedAt.getTime() - row.startedAt.getTime(),
382
+ ...fields,
383
+ };
384
+ };
385
+ const cas = async (
386
+ from: ExecutionStatus,
387
+ to: ExecutionStatus,
388
+ fields: Record<string, unknown>
389
+ ) => {
390
+ const updated = await db
391
+ .update(executions)
392
+ .set({ status: to, ...fields })
393
+ .where(and(eq(executions.id, executionId), eq(executions.status, from)))
394
+ .returning();
395
+ return updated.length > 0;
396
+ };
397
+ const audit = (how: string, extra: Record<string, unknown> = {}) =>
398
+ recordAuditEvent({
399
+ workspaceId: row.workspaceId,
400
+ actorId: null,
401
+ actorKind: "system",
402
+ action: "execution.reconciled",
403
+ resourceKind: "execution",
404
+ resourceId: executionId,
405
+ metadata: { requestId: row.requestId, how, ...extra },
406
+ });
407
+
408
+ if (row.status === "running") {
409
+ const cutoff = options.abandonRunningAfterMs;
410
+ if (
411
+ cutoff === undefined ||
412
+ Date.now() - row.startedAt.getTime() < cutoff
413
+ ) {
414
+ return { action: "noop", status: "running" };
415
+ }
416
+ const moved = await cas(
417
+ "running",
418
+ "failed",
419
+ finish({ errorMessage: "abandoned: no completion within the cutoff" })
420
+ );
421
+ if (!moved) return { action: "noop", status: "running" };
422
+ if (ports.releaseHold) {
423
+ try {
424
+ await ports.releaseHold({
425
+ workspaceId: row.workspaceId,
426
+ requestId: row.requestId,
427
+ });
428
+ } catch (releaseError) {
429
+ log.warn("Hold release failed during reconcile", {
430
+ executionId,
431
+ error: errorMessage(releaseError),
432
+ });
433
+ }
434
+ }
435
+ await audit("abandoned");
436
+ return { action: "abandoned" };
437
+ }
438
+
439
+ if (row.status !== "settling") {
440
+ return { action: "noop", status: row.status as ExecutionStatus };
441
+ }
442
+
443
+ if (!ports.findSettlement) {
444
+ // Without a way to ask the ledger, re-running settlement could
445
+ // charge a second time. Refuse to guess.
446
+ return {
447
+ action: "noop",
448
+ status: "settling",
449
+ reason:
450
+ "no findSettlement port bound; cannot tell whether the charge exists",
451
+ };
452
+ }
453
+
454
+ const existing = await ports.findSettlement({
455
+ workspaceId: row.workspaceId,
456
+ requestId: row.requestId,
457
+ });
458
+
459
+ if (existing) {
460
+ const moved = await cas(
461
+ "settling",
462
+ "succeeded",
463
+ finish({
464
+ chargedMicros: existing.charged?.amount ?? null,
465
+ ...(existing.charged ? { currency: existing.charged.currency } : {}),
466
+ })
467
+ );
468
+ if (moved) {
469
+ await audit("confirmed", { charged: existing.charged });
470
+ }
471
+ return { action: "confirmed", charged: existing.charged };
472
+ }
473
+
474
+ if (!ports.settleUsage || row.totalTokens === null) {
475
+ return {
476
+ action: "noop",
477
+ status: "settling",
478
+ reason: "no usage recorded on the row to settle from",
479
+ };
480
+ }
481
+
482
+ const settled = await ports.settleUsage({
483
+ workspaceId: row.workspaceId,
484
+ userId: row.userId,
485
+ requestId: row.requestId,
486
+ capability: row.capability,
487
+ model: row.model ?? undefined,
488
+ inputTokens: row.inputTokens ?? 0,
489
+ outputTokens: row.outputTokens ?? 0,
490
+ totalTokens: row.totalTokens,
491
+ // Admission's pool choice is not on the row; a settlement port
492
+ // takes the pools in its own order rather than trusting this.
493
+ usingTrialCredits: false,
494
+ metadata: row.metadata ?? undefined,
495
+ });
496
+ const charged = settled?.charged;
497
+ await cas(
498
+ "settling",
499
+ "succeeded",
500
+ finish({
501
+ chargedMicros: charged?.amount ?? null,
502
+ ...(charged ? { currency: charged.currency } : {}),
503
+ })
504
+ );
505
+ await audit("settled", { charged });
506
+ return { action: "settled", charged };
507
+ }
508
+
509
+ return { begin, reconcile };
510
+ }
511
+
512
+ export type ReconcileResult =
513
+ | {
514
+ action: "noop";
515
+ status: ExecutionStatus | "missing";
516
+ reason?: string;
517
+ }
518
+ /** The ledger already held the charge; the row now says so. */
519
+ | { action: "confirmed"; charged?: Money }
520
+ /** Settlement was re-run from the recorded usage. */
521
+ | { action: "settled"; charged?: Money }
522
+ /** A stale `running` row was failed and its hold released. */
523
+ | { action: "abandoned" };
524
+
525
+ export type Executions = ReturnType<typeof createExecutions>;
package/src/ports.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Ports the execution lifecycle depends on. `executions` must not
3
+ * import `billing`, so the lifecycle declares what it needs and the
4
+ * composition root binds billing's implementations to it.
5
+ *
6
+ * Both ports are optional: with neither bound, executions still record
7
+ * the lifecycle, they just don't gate or charge. That is what the
8
+ * reference app and any non-metered capability want.
9
+ */
10
+
11
+ import type { Money } from "@intelligo-dev/core/money";
12
+
13
+ export type EntitlementDecision = {
14
+ allowed: boolean;
15
+ /**
16
+ * Stable, machine-readable refusal code from the entitlement port
17
+ * (e.g. billing's `insufficient_credits`), for transports to map to a
18
+ * status and to localize. Opaque to the lifecycle.
19
+ */
20
+ code?: string;
21
+ /** Human-readable refusal, surfaced to the caller and recorded. */
22
+ reason?: string;
23
+ /** Worst-case charge held for this execution. */
24
+ estimated?: Money;
25
+ /** True when the hold came out of the trial grant. */
26
+ usingTrialCredits?: boolean;
27
+ };
28
+
29
+ export type EntitlementRequest = {
30
+ workspaceId: string;
31
+ userId?: string | null;
32
+ capability: string;
33
+ /** Correlates the hold with settlement. */
34
+ requestId: string;
35
+ model?: string;
36
+ };
37
+
38
+ export type UsageSettlement = {
39
+ workspaceId: string;
40
+ userId?: string | null;
41
+ requestId: string;
42
+ capability: string;
43
+ model?: string;
44
+ inputTokens: number;
45
+ outputTokens: number;
46
+ totalTokens: number;
47
+ usingTrialCredits: boolean;
48
+ metadata?: Record<string, unknown>;
49
+ };
50
+
51
+ export type SettlementResult = {
52
+ /** What the port actually charged. */
53
+ charged?: Money;
54
+ };
55
+
56
+ export type SettlementQuery = {
57
+ workspaceId: string;
58
+ requestId: string;
59
+ };
60
+
61
+ export type ExecutionPorts = {
62
+ /**
63
+ * Decide whether this execution may run and hold the worst-case
64
+ * cost. Called before the run; a refusal short-circuits it.
65
+ */
66
+ checkEntitlement?: (
67
+ request: EntitlementRequest
68
+ ) => Promise<EntitlementDecision>;
69
+
70
+ /**
71
+ * Record real usage and release the hold. Must be idempotent per
72
+ * `requestId`: the lifecycle's compare-and-swap lets one complete()
73
+ * through, but `reconcile()` re-runs settlement for a row stuck in
74
+ * `settling`, and two reconciles — or a reconcile and a settlement
75
+ * still in flight — can reach it for the same request. A second call
76
+ * returns the first charge and debits nothing (billing's
77
+ * `recordTokenUsage` does this under the workspace lock).
78
+ */
79
+ settleUsage?: (
80
+ settlement: UsageSettlement
81
+ ) => Promise<SettlementResult | void>;
82
+
83
+ /**
84
+ * Answer "was this request already charged?" for `reconcile()`: a
85
+ * row stuck in `settling` may mean the charge never happened OR that
86
+ * it committed and the process died before the final status flip.
87
+ * Return the charge if one exists, null otherwise. Optional; without
88
+ * it, reconcile cannot tell the two apart and leaves the row alone.
89
+ */
90
+ findSettlement?: (query: SettlementQuery) => Promise<SettlementResult | null>;
91
+
92
+ /**
93
+ * Release a hold without charging (run failed before producing
94
+ * usage). Optional: when absent, the hold is left to expire.
95
+ */
96
+ releaseHold?: (input: {
97
+ workspaceId: string;
98
+ requestId: string;
99
+ }) => Promise<void>;
100
+ };