@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.
- package/NOTICE +6 -0
- package/README.md +121 -0
- package/dist/db/schema.d.ts +32 -21
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +23 -23
- package/dist/db/schema.js.map +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/lifecycle.d.ts +31 -5
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +179 -18
- package/dist/lifecycle.js.map +1 -1
- package/dist/ports.d.ts +33 -14
- package/dist/ports.d.ts.map +1 -1
- package/dist/ports.js +3 -8
- package/dist/ports.js.map +1 -1
- package/dist/pricing.d.ts +107 -137
- package/dist/pricing.d.ts.map +1 -1
- package/dist/pricing.js +129 -85
- package/dist/pricing.js.map +1 -1
- package/dist/queries.d.ts +39 -26
- package/dist/queries.d.ts.map +1 -1
- package/dist/queries.js +96 -39
- package/dist/queries.js.map +1 -1
- package/package.json +41 -13
- package/src/db/schema.ts +87 -0
- package/src/index.ts +73 -0
- package/src/lifecycle.ts +525 -0
- package/src/ports.ts +100 -0
- package/src/pricing.ts +322 -0
- package/src/queries.ts +235 -0
package/src/lifecycle.ts
ADDED
|
@@ -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
|
+
};
|