@loomwork/middleware 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +82 -2
- package/dist/claimCheck.d.ts +179 -0
- package/dist/claimCheck.d.ts.map +1 -0
- package/dist/claimCheck.js +241 -0
- package/dist/claimCheck.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/killSwitchRegistry.d.ts +74 -0
- package/dist/killSwitchRegistry.d.ts.map +1 -0
- package/dist/killSwitchRegistry.js +108 -0
- package/dist/killSwitchRegistry.js.map +1 -0
- package/dist/metering.d.ts +81 -0
- package/dist/metering.d.ts.map +1 -0
- package/dist/metering.js +153 -0
- package/dist/metering.js.map +1 -0
- package/dist/nestedBudget.d.ts +60 -0
- package/dist/nestedBudget.d.ts.map +1 -0
- package/dist/nestedBudget.js +134 -0
- package/dist/nestedBudget.js.map +1 -0
- package/dist/profile.d.ts +38 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +70 -0
- package/dist/profile.js.map +1 -0
- package/dist/rateLimit.d.ts +40 -0
- package/dist/rateLimit.d.ts.map +1 -0
- package/dist/rateLimit.js +83 -0
- package/dist/rateLimit.js.map +1 -0
- package/dist/redaction.d.ts +66 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +141 -0
- package/dist/redaction.js.map +1 -0
- package/dist/retry.d.ts +50 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +98 -0
- package/dist/retry.js.map +1 -0
- package/dist/safety.d.ts +93 -0
- package/dist/safety.d.ts.map +1 -0
- package/dist/safety.js +152 -0
- package/dist/safety.js.map +1 -0
- package/dist/streamTokenBudget.d.ts +58 -0
- package/dist/streamTokenBudget.d.ts.map +1 -0
- package/dist/streamTokenBudget.js +47 -0
- package/dist/streamTokenBudget.js.map +1 -0
- package/package.json +55 -3
- package/src/claimCheck.ts +410 -0
- package/src/index.ts +79 -0
- package/src/killSwitchRegistry.ts +168 -0
- package/src/metering.ts +226 -0
- package/src/nestedBudget.ts +200 -0
- package/src/profile.ts +86 -0
- package/src/rateLimit.ts +126 -0
- package/src/redaction.ts +184 -0
- package/src/retry.ts +133 -0
- package/src/safety.ts +235 -0
- package/src/streamTokenBudget.ts +98 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { EffectContext, EffectMiddleware } from "@loomwork/effect-runtime";
|
|
2
|
+
import { SafetyViolationError } from "./safety";
|
|
3
|
+
|
|
4
|
+
// ── Scoped kill switch (R9 — autonomy envelope) ──────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// `killSwitch(isTripped)` (safety.ts) is a synchronous per-process predicate over NOTHING:
|
|
7
|
+
// it cannot say WHICH tenant to stop, and an operator has no handle to reach it. A runaway
|
|
8
|
+
// agent for one tenant needed either a redeploy or stopping everyone.
|
|
9
|
+
//
|
|
10
|
+
// This adds the missing scopes and an admin handle:
|
|
11
|
+
// system — every effect in every tenant stops
|
|
12
|
+
// tenant — every effect whose principal chain names that tenant stops
|
|
13
|
+
// workflow — every effect of that flow (the "stop this agent" case)
|
|
14
|
+
//
|
|
15
|
+
// It is consulted BEFORE each effect (outermost middleware) and refuses with a typed
|
|
16
|
+
// `KillSwitchEngagedError`. What it does NOT do — and cannot, in a 1P middleware — is
|
|
17
|
+
// reach an effect already in flight, or cancel a running execution's children: that is the
|
|
18
|
+
// kernel's cancel cascade. A stopped execution fails at its NEXT effect; a flow that
|
|
19
|
+
// catches the refusal and carries on to a non-effect finish is not interrupted.
|
|
20
|
+
//
|
|
21
|
+
// The registry is an SPI so a deployment can back the flags with a shared store (a DB row,
|
|
22
|
+
// a feature-flag service) and stop a tenant across processes; `InMemoryKillSwitch` is the
|
|
23
|
+
// single-process implementation and the reference. `check` may be async — a networked
|
|
24
|
+
// registry should cache with a short TTL, since it sits on every effect.
|
|
25
|
+
|
|
26
|
+
export type KillScope = "system" | "tenant" | "workflow";
|
|
27
|
+
|
|
28
|
+
/** Why an effect is being refused. */
|
|
29
|
+
export interface KillVerdict {
|
|
30
|
+
scope: KillScope;
|
|
31
|
+
/** The tenant / workflow id (absent for `system`). */
|
|
32
|
+
key?: string;
|
|
33
|
+
reason: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Where the stop flags live. Return `undefined` to let the effect run. Must be cheap — it is
|
|
38
|
+
* consulted before EVERY effect — and must fail CLOSED or open as the deployment prefers;
|
|
39
|
+
* a throw is treated as a refusal (fail closed) by {@link killSwitchMiddleware}.
|
|
40
|
+
*/
|
|
41
|
+
export interface KillSwitchRegistry {
|
|
42
|
+
/**
|
|
43
|
+
* Where the stop flags live, DECLARED by the registry. Optional and additive (no SPI version
|
|
44
|
+
* bump: an absent field changes nothing for any existing registry).
|
|
45
|
+
*
|
|
46
|
+
* - `"process"` — the flags live in this process's memory ({@link InMemoryKillSwitch}); a stop
|
|
47
|
+
* issued in another process is invisible here.
|
|
48
|
+
* - `"shared"` — the flags live in a store every process reads (a DB row, a feature-flag
|
|
49
|
+
* service), so a stop issued anywhere applies here.
|
|
50
|
+
*
|
|
51
|
+
* Tooling that runs your effects OUTSIDE the long-running app (`loom resume` / `loom start`)
|
|
52
|
+
* reads this and is FAIL-CLOSED: only a registry that affirmatively declares `"shared"` is
|
|
53
|
+
* trusted to carry an operator stop across processes. Absent, or anything else, is treated as
|
|
54
|
+
* process-local. A wrapper or subclass must re-declare it: forwarding is the wrapper's claim.
|
|
55
|
+
*/
|
|
56
|
+
readonly scope?: "process" | "shared";
|
|
57
|
+
check(ctx: EffectContext): KillVerdict | undefined | Promise<KillVerdict | undefined>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class KillSwitchEngagedError extends SafetyViolationError {
|
|
61
|
+
readonly scope: KillScope;
|
|
62
|
+
readonly key: string | undefined;
|
|
63
|
+
/** A stop is a decision, not a fault: retrying it just repeats the refusal. */
|
|
64
|
+
readonly retryable = false;
|
|
65
|
+
constructor(verdict: KillVerdict) {
|
|
66
|
+
super(
|
|
67
|
+
"kill-switch",
|
|
68
|
+
`${verdict.scope}${verdict.key !== undefined ? ` "${verdict.key}"` : ""} stopped: ${verdict.reason}`,
|
|
69
|
+
"kill-switch"
|
|
70
|
+
);
|
|
71
|
+
this.name = "KillSwitchEngagedError";
|
|
72
|
+
this.scope = verdict.scope;
|
|
73
|
+
this.key = verdict.key;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Single-process registry with the operator-facing admin API. */
|
|
78
|
+
export class InMemoryKillSwitch implements KillSwitchRegistry {
|
|
79
|
+
/** Declares itself process-local (see {@link KillSwitchRegistry.scope}). */
|
|
80
|
+
readonly scope = "process" as const;
|
|
81
|
+
private system: string | undefined;
|
|
82
|
+
private readonly tenants = new Map<string, string>();
|
|
83
|
+
private readonly workflows = new Map<string, string>();
|
|
84
|
+
|
|
85
|
+
/** Stop every effect in the process. */
|
|
86
|
+
stopAll(reason = "system stop"): void {
|
|
87
|
+
this.system = reason;
|
|
88
|
+
}
|
|
89
|
+
resumeAll(): void {
|
|
90
|
+
this.system = undefined;
|
|
91
|
+
}
|
|
92
|
+
/** Stop every effect whose principal chain names `tenant`. */
|
|
93
|
+
stopTenant(tenant: string, reason = "tenant stop"): void {
|
|
94
|
+
this.tenants.set(tenant, reason);
|
|
95
|
+
}
|
|
96
|
+
resumeTenant(tenant: string): void {
|
|
97
|
+
this.tenants.delete(tenant);
|
|
98
|
+
}
|
|
99
|
+
/** Stop every effect of one flow (one agent). */
|
|
100
|
+
stopWorkflow(workflowId: string, reason = "workflow stop"): void {
|
|
101
|
+
this.workflows.set(workflowId, reason);
|
|
102
|
+
}
|
|
103
|
+
resumeWorkflow(workflowId: string): void {
|
|
104
|
+
this.workflows.delete(workflowId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** What is currently stopped, for an operator console. */
|
|
108
|
+
status(): {
|
|
109
|
+
system?: string;
|
|
110
|
+
tenants: Record<string, string>;
|
|
111
|
+
workflows: Record<string, string>;
|
|
112
|
+
} {
|
|
113
|
+
return {
|
|
114
|
+
...(this.system !== undefined && { system: this.system }),
|
|
115
|
+
tenants: Object.fromEntries(this.tenants),
|
|
116
|
+
workflows: Object.fromEntries(this.workflows),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
check(ctx: EffectContext): KillVerdict | undefined {
|
|
121
|
+
if (this.system !== undefined) return { scope: "system", reason: this.system };
|
|
122
|
+
const tenant = ctx.principal?.tenant;
|
|
123
|
+
if (tenant !== undefined) {
|
|
124
|
+
const reason = this.tenants.get(tenant);
|
|
125
|
+
if (reason !== undefined) return { scope: "tenant", key: tenant, reason };
|
|
126
|
+
}
|
|
127
|
+
const wf = this.workflows.get(ctx.workflowId);
|
|
128
|
+
if (wf !== undefined) return { scope: "workflow", key: ctx.workflowId, reason: wf };
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Refuse every effect the registry stops. Place it OUTERMOST so a stopped effect never
|
|
135
|
+
* resolves a secret or spends budget. The refusal is recorded as a durable `SAFETY_BLOCKED`
|
|
136
|
+
* fact (the same audit list the K6 kill-switch validator writes) and thrown as
|
|
137
|
+
* {@link KillSwitchEngagedError}, a `SafetyViolationError` carrying `retryable = false`
|
|
138
|
+
* (which `retryMiddleware`'s default classifier honours), so it fails the step through the
|
|
139
|
+
* existing path and is not retried.
|
|
140
|
+
*/
|
|
141
|
+
export function killSwitchMiddleware(registry: KillSwitchRegistry): EffectMiddleware {
|
|
142
|
+
return async (ctx, _input, next): Promise<unknown> => {
|
|
143
|
+
let verdict: KillVerdict | undefined;
|
|
144
|
+
try {
|
|
145
|
+
verdict = await registry.check(ctx);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// A registry that cannot answer fails CLOSED: a kill switch that errors open is a
|
|
148
|
+
// switch that is off exactly when the store it reads is down.
|
|
149
|
+
verdict = {
|
|
150
|
+
scope: "system",
|
|
151
|
+
reason: `kill-switch registry unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (verdict !== undefined) {
|
|
155
|
+
ctx.emit("SAFETY_BLOCKED", {
|
|
156
|
+
stepId: ctx.stepId,
|
|
157
|
+
validatorId: "kill-switch",
|
|
158
|
+
phase: "input",
|
|
159
|
+
reason: verdict.reason,
|
|
160
|
+
category: "kill-switch",
|
|
161
|
+
scope: verdict.scope,
|
|
162
|
+
...(verdict.key !== undefined && { key: verdict.key }),
|
|
163
|
+
});
|
|
164
|
+
throw new KillSwitchEngagedError(verdict);
|
|
165
|
+
}
|
|
166
|
+
return next();
|
|
167
|
+
};
|
|
168
|
+
}
|
package/src/metering.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { EffectContext, EffectMiddleware, ResourceSnapshot } from "@loomwork/effect-runtime";
|
|
2
|
+
|
|
3
|
+
// ── Resource accounting & quotas (Loom 2.0 K3) ───────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// The scarce resource for intelligent software is tokens / $ / latency, not CPU
|
|
6
|
+
// cycles. This middleware meters every effect it wraps as a durable RESOURCE_METERED
|
|
7
|
+
// fact (folded into `variables._resources`), and — given a budget — enforces a quota
|
|
8
|
+
// by admission control: once an execution has spent its budget, the next effect is
|
|
9
|
+
// refused (QUOTA_EXCEEDED + a thrown QuotaExceededError that fails the step). Both
|
|
10
|
+
// the accounting and the enforcement are replay-safe (metered facts live in the log;
|
|
11
|
+
// the running total is read from the deterministic fold via `ctx.resources`).
|
|
12
|
+
|
|
13
|
+
import { BUILTIN_METER_DIMENSIONS, isVocabularyName } from "@loomwork/core";
|
|
14
|
+
|
|
15
|
+
export interface ResourceDelta {
|
|
16
|
+
tokens?: number;
|
|
17
|
+
costUsd?: number;
|
|
18
|
+
ms?: number;
|
|
19
|
+
// ADR 0032 (R1) — additional dimensions, metered only where an effect can measure
|
|
20
|
+
// them (e.g. a K7 isolated child reports peak RSS). All optional and additive;
|
|
21
|
+
// an effect that doesn't report a dimension leaves it undefined (no false zeros).
|
|
22
|
+
/** Peak resident memory attributable to the effect, in bytes. */
|
|
23
|
+
memBytes?: number;
|
|
24
|
+
/** Wall-clock time, in ms (distinct from `ms` when the effect runs off-thread). */
|
|
25
|
+
wallMs?: number;
|
|
26
|
+
/** Network bytes transferred by the effect. */
|
|
27
|
+
netBytes?: number;
|
|
28
|
+
/**
|
|
29
|
+
* ADR 0115 — open dimensions (`gpu_seconds`, `credits`), by name. Folded into
|
|
30
|
+
* `_resources.custom`; the kernel assigns no unit and no price, so converting these to money
|
|
31
|
+
* is this pricing function's job. At most 32 names, each matching `VOCABULARY_NAME_PATTERN`
|
|
32
|
+
* (`@loomwork/core`), never a built-in name. A bad name (or value) makes `ctx.emit` throw AFTER the
|
|
33
|
+
* effect ran, like a built-in ceiling breach.
|
|
34
|
+
*/
|
|
35
|
+
custom?: Readonly<Record<string, number>>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MeterInfo {
|
|
39
|
+
stepId: string;
|
|
40
|
+
input: unknown;
|
|
41
|
+
/** The effect's result — `undefined` when metering an aborted effect (K8). */
|
|
42
|
+
result: unknown;
|
|
43
|
+
/** Measured wall-time of the effect, in ms. */
|
|
44
|
+
ms: number;
|
|
45
|
+
/**
|
|
46
|
+
* K8 — true when the effect was severed mid-flight (cancellation/deadline).
|
|
47
|
+
* The wall-time up to the abort is still metered so cancel-before-completion
|
|
48
|
+
* cannot evade budgets; pricing may add provider-reported partial usage.
|
|
49
|
+
*/
|
|
50
|
+
aborted?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Compute the resource delta for one effect call — e.g. tokens×rate for an LLM. */
|
|
54
|
+
export type Pricing = (info: MeterInfo) => ResourceDelta;
|
|
55
|
+
|
|
56
|
+
export interface Budget {
|
|
57
|
+
maxTokens?: number;
|
|
58
|
+
maxCostUsd?: number;
|
|
59
|
+
// ADR 0032 (R1) — ceilings for the additional dimensions. Only enforced when set.
|
|
60
|
+
maxMemBytes?: number;
|
|
61
|
+
maxWallMs?: number;
|
|
62
|
+
maxNetBytes?: number;
|
|
63
|
+
/**
|
|
64
|
+
* ADR 0115 — ceilings for open dimensions, by name (`{ gpu_seconds: 60 }`): an execution whose
|
|
65
|
+
* `_resources.custom[name]` has reached the ceiling has its next effect denied, like every
|
|
66
|
+
* other dimension. Only enforced where set.
|
|
67
|
+
*/
|
|
68
|
+
maxCustom?: Readonly<Record<string, number>>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when `name` is a built-in metering dimension (never a custom one). */
|
|
72
|
+
export function isBuiltinDimension(name: string): boolean {
|
|
73
|
+
return BUILTIN_METER_DIMENSIONS.has(name);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True when `name` is a valid custom dimension name (pattern shared with the fold's guard). */
|
|
77
|
+
export function isCustomDimensionName(name: string): boolean {
|
|
78
|
+
return isVocabularyName(name) && !BUILTIN_METER_DIMENSIONS.has(name);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validate a `Budget.maxCustom` and return a FROZEN shallow copy to enforce against (so a later
|
|
83
|
+
* mutation of the caller's object cannot smuggle in a built-in name or a bad ceiling). Refuses a
|
|
84
|
+
* built-in dimension name (use `maxTokens` etc.: a custom ceiling on `tokens` would read
|
|
85
|
+
* `_resources.custom.tokens`, which nothing meters) and a non-numeric ceiling.
|
|
86
|
+
*/
|
|
87
|
+
export function assertMaxCustom(
|
|
88
|
+
maxCustom: Readonly<Record<string, number>> | undefined
|
|
89
|
+
): Readonly<Record<string, number>> | undefined {
|
|
90
|
+
if (maxCustom === undefined) return undefined;
|
|
91
|
+
for (const [name, max] of Object.entries(maxCustom)) {
|
|
92
|
+
if (BUILTIN_METER_DIMENSIONS.has(name)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Budget.maxCustom."${name}" names a built-in dimension; use its own max field (e.g. maxTokens)`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (typeof max !== "number" || !Number.isFinite(max) || max < 0) {
|
|
98
|
+
throw new Error(`Budget.maxCustom."${name}" must be a finite non-negative number`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return Object.freeze({ ...maxCustom });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The first custom ceiling the spend has reached, as a reason — own-property reads only. */
|
|
105
|
+
export function overCustomBudget(
|
|
106
|
+
custom: Readonly<Record<string, number>> | undefined,
|
|
107
|
+
maxCustom: Readonly<Record<string, number>> | undefined
|
|
108
|
+
): string | null {
|
|
109
|
+
if (maxCustom === undefined) return null;
|
|
110
|
+
for (const [name, max] of Object.entries(maxCustom)) {
|
|
111
|
+
const spent =
|
|
112
|
+
custom !== undefined && Object.hasOwn(custom, name) ? (custom[name] as number) : 0;
|
|
113
|
+
if (spent >= max) return `${name} ${spent} >= budget ${max}`;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface MeteringOptions {
|
|
119
|
+
pricing: Pricing;
|
|
120
|
+
/** When set, an execution over budget has its next effect denied (admission control). */
|
|
121
|
+
budget?: Budget;
|
|
122
|
+
/** Injectable clock (defaults to Date.now); only affects the measured `ms`. */
|
|
123
|
+
clock?: () => number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class QuotaExceededError extends Error {
|
|
127
|
+
readonly reason: string;
|
|
128
|
+
/** A budget refusal is a decision; retrying repeats it (`retryMiddleware` honours this). */
|
|
129
|
+
readonly retryable = false;
|
|
130
|
+
constructor(reason: string) {
|
|
131
|
+
super(`Quota exceeded: ${reason}`);
|
|
132
|
+
this.name = "QuotaExceededError";
|
|
133
|
+
this.reason = reason;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function overBudget(r: ResourceSnapshot, b: Budget): string | null {
|
|
138
|
+
if (b.maxCostUsd !== undefined && r.costUsd >= b.maxCostUsd) {
|
|
139
|
+
return `cost ${r.costUsd} >= budget ${b.maxCostUsd} USD`;
|
|
140
|
+
}
|
|
141
|
+
if (b.maxTokens !== undefined && r.tokens >= b.maxTokens) {
|
|
142
|
+
return `tokens ${r.tokens} >= budget ${b.maxTokens}`;
|
|
143
|
+
}
|
|
144
|
+
if (b.maxMemBytes !== undefined && (r.memBytes ?? 0) >= b.maxMemBytes) {
|
|
145
|
+
return `memBytes ${r.memBytes ?? 0} >= budget ${b.maxMemBytes}`;
|
|
146
|
+
}
|
|
147
|
+
if (b.maxWallMs !== undefined && (r.wallMs ?? 0) >= b.maxWallMs) {
|
|
148
|
+
return `wallMs ${r.wallMs ?? 0} >= budget ${b.maxWallMs}`;
|
|
149
|
+
}
|
|
150
|
+
if (b.maxNetBytes !== undefined && (r.netBytes ?? 0) >= b.maxNetBytes) {
|
|
151
|
+
return `netBytes ${r.netBytes ?? 0} >= budget ${b.maxNetBytes}`;
|
|
152
|
+
}
|
|
153
|
+
return overCustomBudget(r.custom, b.maxCustom);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The RESOURCE_METERED payload for a delta — only dimensions the pricing reported. */
|
|
157
|
+
export function meteredPayload(
|
|
158
|
+
delta: ResourceDelta,
|
|
159
|
+
ms: number
|
|
160
|
+
): Record<string, number | Readonly<Record<string, number>>> {
|
|
161
|
+
return {
|
|
162
|
+
...(delta.tokens !== undefined && { tokens: delta.tokens }),
|
|
163
|
+
...(delta.costUsd !== undefined && { costUsd: delta.costUsd }),
|
|
164
|
+
...(delta.memBytes !== undefined && { memBytes: delta.memBytes }),
|
|
165
|
+
...(delta.wallMs !== undefined && { wallMs: delta.wallMs }),
|
|
166
|
+
...(delta.netBytes !== undefined && { netBytes: delta.netBytes }),
|
|
167
|
+
...(delta.custom !== undefined && { custom: delta.custom }),
|
|
168
|
+
ms: delta.ms ?? ms,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Read token usage from an LlmResponse-shaped result (`{ usage: { inputTokens, outputTokens } }`). */
|
|
173
|
+
export function llmUsageTokens(result: unknown): number {
|
|
174
|
+
const usage = (result as { usage?: { inputTokens?: number; outputTokens?: number } } | null)
|
|
175
|
+
?.usage;
|
|
176
|
+
if (usage === undefined || usage === null) return 0;
|
|
177
|
+
return (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function meteringMiddleware(options: MeteringOptions): EffectMiddleware {
|
|
181
|
+
const now = options.clock ?? (() => Date.now());
|
|
182
|
+
const frozenMax = assertMaxCustom(options.budget?.maxCustom);
|
|
183
|
+
const budget: Budget | undefined =
|
|
184
|
+
options.budget === undefined
|
|
185
|
+
? undefined
|
|
186
|
+
: { ...options.budget, ...(frozenMax !== undefined && { maxCustom: frozenMax }) };
|
|
187
|
+
return async (ctx: EffectContext, input: unknown, next): Promise<unknown> => {
|
|
188
|
+
// Admission control: refuse if the execution has already spent its budget.
|
|
189
|
+
if (budget !== undefined && ctx.resources !== undefined) {
|
|
190
|
+
const reason = overBudget(ctx.resources, budget);
|
|
191
|
+
if (reason !== null) {
|
|
192
|
+
ctx.emit("QUOTA_EXCEEDED", { stepId: ctx.stepId, reason });
|
|
193
|
+
throw new QuotaExceededError(reason);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const start = now();
|
|
198
|
+
let result: unknown;
|
|
199
|
+
try {
|
|
200
|
+
result = await next();
|
|
201
|
+
} catch (err) {
|
|
202
|
+
// K8 — meter an aborted effect before rethrowing. Metering normally runs
|
|
203
|
+
// after next(), so a severed call would never be seen and
|
|
204
|
+
// start-expensive-effect → cancel → repeat could evade the budget forever.
|
|
205
|
+
// Only aborts are metered here; an ordinary failure keeps today's
|
|
206
|
+
// unmetered semantics.
|
|
207
|
+
if (ctx.signal?.aborted === true) {
|
|
208
|
+
const ms = now() - start;
|
|
209
|
+
const delta = options.pricing({
|
|
210
|
+
stepId: ctx.stepId,
|
|
211
|
+
input,
|
|
212
|
+
result: undefined,
|
|
213
|
+
ms,
|
|
214
|
+
aborted: true,
|
|
215
|
+
});
|
|
216
|
+
ctx.emit("RESOURCE_METERED", meteredPayload(delta, ms));
|
|
217
|
+
}
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
const ms = now() - start;
|
|
221
|
+
|
|
222
|
+
const delta = options.pricing({ stepId: ctx.stepId, input, result, ms });
|
|
223
|
+
ctx.emit("RESOURCE_METERED", meteredPayload(delta, ms));
|
|
224
|
+
return result;
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { EffectContext, EffectMiddleware } from "@loomwork/effect-runtime";
|
|
2
|
+
import {
|
|
3
|
+
type Budget,
|
|
4
|
+
type Pricing,
|
|
5
|
+
QuotaExceededError,
|
|
6
|
+
type ResourceDelta,
|
|
7
|
+
assertMaxCustom,
|
|
8
|
+
isCustomDimensionName,
|
|
9
|
+
overCustomBudget,
|
|
10
|
+
} from "./metering";
|
|
11
|
+
|
|
12
|
+
// ── K12.3 — nested budgets (tenant → workflow → execution) ────────────────────
|
|
13
|
+
//
|
|
14
|
+
// The K3 per-execution budget is a pure fold of ONE execution's log
|
|
15
|
+
// (`ctx.resources`). A tenant- or workflow-level budget aggregates spend across
|
|
16
|
+
// MANY executions' logs — so it is admission control over a PROJECTION
|
|
17
|
+
// (eventually consistent), NOT a replay-pure per-execution fold. This is a
|
|
18
|
+
// deliberate category change (the amendment states it explicitly): the verdict
|
|
19
|
+
// is still durable and logged (QUOTA_EXCEEDED, P9), but the tenant/workflow
|
|
20
|
+
// totals live in a ledger the app maintains from the RESOURCE_METERED stream,
|
|
21
|
+
// not in any single fold. The most-restrictive bound wins, and each trip is
|
|
22
|
+
// attributed to its level.
|
|
23
|
+
|
|
24
|
+
export interface ScopeSpend {
|
|
25
|
+
tokens: number;
|
|
26
|
+
costUsd: number;
|
|
27
|
+
// ADR 0032 (R1) — additional dimensions, accumulated additively across a scope.
|
|
28
|
+
memBytes?: number;
|
|
29
|
+
wallMs?: number;
|
|
30
|
+
netBytes?: number;
|
|
31
|
+
/** ADR 0115 — open dimensions, accumulated by name (own-property keys only). */
|
|
32
|
+
custom?: Record<string, number>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Distinct custom dimension names one ledger scope keeps (the fold's own cap, ADR 0115). */
|
|
36
|
+
const LEDGER_CUSTOM_MAX = 32;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A projection of spend per scope key (e.g. `tenant:acme`, `workflow:audit`).
|
|
40
|
+
* The app updates it from the RESOURCE_METERED event stream (across all of a
|
|
41
|
+
* tenant's executions) — typically in `defineConfig({ onEvent })`. It is
|
|
42
|
+
* eventually consistent by nature; two nodes may briefly under-count, which is
|
|
43
|
+
* the accepted trade-off for cross-execution budgets.
|
|
44
|
+
*/
|
|
45
|
+
export class BudgetLedger {
|
|
46
|
+
private readonly spend = new Map<string, ScopeSpend>();
|
|
47
|
+
|
|
48
|
+
add(scopeKey: string, delta: ResourceDelta): void {
|
|
49
|
+
const cur = this.spend.get(scopeKey) ?? { tokens: 0, costUsd: 0 };
|
|
50
|
+
const memBytes = (cur.memBytes ?? 0) + (delta.memBytes ?? 0);
|
|
51
|
+
const wallMs = (cur.wallMs ?? 0) + (delta.wallMs ?? 0);
|
|
52
|
+
const netBytes = (cur.netBytes ?? 0) + (delta.netBytes ?? 0);
|
|
53
|
+
let custom = cur.custom;
|
|
54
|
+
if (delta.custom !== undefined) {
|
|
55
|
+
custom = { ...cur.custom };
|
|
56
|
+
for (const [name, v] of Object.entries(delta.custom)) {
|
|
57
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) continue;
|
|
58
|
+
if (name === "constructor" || name === "prototype" || !isCustomDimensionName(name))
|
|
59
|
+
continue;
|
|
60
|
+
if (!Object.hasOwn(custom, name) && Object.keys(custom).length >= LEDGER_CUSTOM_MAX) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
custom[name] = (Object.hasOwn(custom, name) ? (custom[name] as number) : 0) + v;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
this.spend.set(scopeKey, {
|
|
67
|
+
tokens: cur.tokens + (delta.tokens ?? 0),
|
|
68
|
+
...(custom !== undefined && { custom }),
|
|
69
|
+
costUsd: cur.costUsd + (delta.costUsd ?? 0),
|
|
70
|
+
...(memBytes > 0 && { memBytes }),
|
|
71
|
+
...(wallMs > 0 && { wallMs }),
|
|
72
|
+
...(netBytes > 0 && { netBytes }),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
spent(scopeKey: string): ScopeSpend {
|
|
77
|
+
return this.spend.get(scopeKey) ?? { tokens: 0, costUsd: 0 };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Fold a RESOURCE_METERED event into the given scope keys (from an onEvent hook). */
|
|
81
|
+
applyMetered(scopeKeys: readonly string[], payload: unknown): void {
|
|
82
|
+
const p = payload as {
|
|
83
|
+
tokens?: number;
|
|
84
|
+
costUsd?: number;
|
|
85
|
+
memBytes?: number;
|
|
86
|
+
wallMs?: number;
|
|
87
|
+
netBytes?: number;
|
|
88
|
+
custom?: Record<string, number>;
|
|
89
|
+
};
|
|
90
|
+
const delta: ResourceDelta = {
|
|
91
|
+
...(p.custom !== undefined &&
|
|
92
|
+
typeof p.custom === "object" &&
|
|
93
|
+
p.custom !== null && { custom: p.custom }),
|
|
94
|
+
...(p.tokens !== undefined && { tokens: p.tokens }),
|
|
95
|
+
...(p.costUsd !== undefined && { costUsd: p.costUsd }),
|
|
96
|
+
...(p.memBytes !== undefined && { memBytes: p.memBytes }),
|
|
97
|
+
...(p.wallMs !== undefined && { wallMs: p.wallMs }),
|
|
98
|
+
...(p.netBytes !== undefined && { netBytes: p.netBytes }),
|
|
99
|
+
};
|
|
100
|
+
for (const key of scopeKeys) this.add(key, delta);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One level of the nesting: a labelled scope key + its budget. */
|
|
105
|
+
export interface BudgetScope {
|
|
106
|
+
/** e.g. "tenant", "workflow", "execution" — recorded in the trip for attribution. */
|
|
107
|
+
level: string;
|
|
108
|
+
/** The ledger key this level accumulates under (execution uses ctx.resources instead). */
|
|
109
|
+
key: string;
|
|
110
|
+
budget: Budget;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface NestedBudgetOptions {
|
|
114
|
+
/** Cost model — same shape as `meteringMiddleware`. */
|
|
115
|
+
pricing: Pricing;
|
|
116
|
+
/** The cross-execution spend projection (tenant/workflow levels). */
|
|
117
|
+
ledger: BudgetLedger;
|
|
118
|
+
/**
|
|
119
|
+
* Resolve the nested scopes for an effect, OUTERMOST (broadest) first. The
|
|
120
|
+
* execution level should use `key: ""` — its spend is read from the pure
|
|
121
|
+
* per-execution fold (`ctx.resources`), not the ledger.
|
|
122
|
+
*/
|
|
123
|
+
scopesOf: (ctx: EffectContext) => BudgetScope[];
|
|
124
|
+
/** Injectable clock for the measured `ms`. */
|
|
125
|
+
clock?: () => number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function over(spent: ScopeSpend, b: Budget): boolean {
|
|
129
|
+
return (
|
|
130
|
+
(b.maxCostUsd !== undefined && spent.costUsd >= b.maxCostUsd) ||
|
|
131
|
+
(b.maxTokens !== undefined && spent.tokens >= b.maxTokens) ||
|
|
132
|
+
(b.maxMemBytes !== undefined && (spent.memBytes ?? 0) >= b.maxMemBytes) ||
|
|
133
|
+
(b.maxWallMs !== undefined && (spent.wallMs ?? 0) >= b.maxWallMs) ||
|
|
134
|
+
(b.maxNetBytes !== undefined && (spent.netBytes ?? 0) >= b.maxNetBytes) ||
|
|
135
|
+
overCustomBudget(spent.custom, b.maxCustom) !== null
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* K12.3 — nested-budget admission control. Before an effect runs, every scope
|
|
141
|
+
* (tenant → workflow → execution) is checked; the FIRST (most-restrictive)
|
|
142
|
+
* breach denies with a `QUOTA_EXCEEDED` fact attributed to that `level`, and the
|
|
143
|
+
* effect never runs. Otherwise the effect runs, is priced, and its delta is
|
|
144
|
+
* recorded into the ledger's non-execution scopes (the execution level is
|
|
145
|
+
* metered by the durable fold as usual) and emitted as `RESOURCE_METERED`.
|
|
146
|
+
*
|
|
147
|
+
* The execution level reads spend from `ctx.resources` (the pure fold, K3); the
|
|
148
|
+
* broader levels read from the ledger projection.
|
|
149
|
+
*/
|
|
150
|
+
export function nestedBudgetMiddleware(options: NestedBudgetOptions): EffectMiddleware {
|
|
151
|
+
const now = options.clock ?? (() => Date.now());
|
|
152
|
+
return async (ctx: EffectContext, input: unknown, next): Promise<unknown> => {
|
|
153
|
+
const scopes = options.scopesOf(ctx);
|
|
154
|
+
// Scopes are resolved per effect, so their budgets cannot be checked at construction; refuse a
|
|
155
|
+
// bad `maxCustom` (built-in name / non-numeric ceiling) here, before any scope is evaluated,
|
|
156
|
+
// rather than part-way through the admission loop. Not memoised: a mutation cannot bypass it.
|
|
157
|
+
for (const scope of scopes) assertMaxCustom(scope.budget.maxCustom);
|
|
158
|
+
|
|
159
|
+
// Admission control — most-restrictive wins, attributed to its level.
|
|
160
|
+
for (const scope of scopes) {
|
|
161
|
+
const spent: ScopeSpend =
|
|
162
|
+
scope.level === "execution"
|
|
163
|
+
? {
|
|
164
|
+
tokens: ctx.resources?.tokens ?? 0,
|
|
165
|
+
costUsd: ctx.resources?.costUsd ?? 0,
|
|
166
|
+
...(ctx.resources?.memBytes !== undefined && { memBytes: ctx.resources.memBytes }),
|
|
167
|
+
...(ctx.resources?.wallMs !== undefined && { wallMs: ctx.resources.wallMs }),
|
|
168
|
+
...(ctx.resources?.netBytes !== undefined && { netBytes: ctx.resources.netBytes }),
|
|
169
|
+
...(ctx.resources?.custom !== undefined && {
|
|
170
|
+
custom: ctx.resources.custom as Record<string, number>,
|
|
171
|
+
}),
|
|
172
|
+
}
|
|
173
|
+
: options.ledger.spent(scope.key);
|
|
174
|
+
if (over(spent, scope.budget)) {
|
|
175
|
+
const reason = `${scope.level} budget exceeded (${scope.key || ctx.executionId})`;
|
|
176
|
+
ctx.emit("QUOTA_EXCEEDED", { stepId: ctx.stepId, reason, level: scope.level });
|
|
177
|
+
throw new QuotaExceededError(reason);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const start = now();
|
|
182
|
+
const result = await next();
|
|
183
|
+
const ms = now() - start;
|
|
184
|
+
|
|
185
|
+
const delta = options.pricing({ stepId: ctx.stepId, input, result, ms });
|
|
186
|
+
ctx.emit("RESOURCE_METERED", {
|
|
187
|
+
...(delta.tokens !== undefined && { tokens: delta.tokens }),
|
|
188
|
+
...(delta.costUsd !== undefined && { costUsd: delta.costUsd }),
|
|
189
|
+
...(delta.custom !== undefined && { custom: delta.custom }),
|
|
190
|
+
ms: delta.ms ?? ms,
|
|
191
|
+
});
|
|
192
|
+
// Update the projection for the broader (non-execution) levels immediately,
|
|
193
|
+
// so within a node successive effects see the running total (the durable
|
|
194
|
+
// fold + cross-node ledger reconcile via the RESOURCE_METERED stream).
|
|
195
|
+
for (const scope of scopes) {
|
|
196
|
+
if (scope.level !== "execution") options.ledger.add(scope.key, delta);
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
};
|
|
200
|
+
}
|
package/src/profile.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { EffectMiddleware } from "@loomwork/effect-runtime";
|
|
2
|
+
|
|
3
|
+
/** Effect-side timings, in milliseconds. */
|
|
4
|
+
export interface EffectProfile {
|
|
5
|
+
/** Effect invocations that completed (successfully or not). */
|
|
6
|
+
calls: number;
|
|
7
|
+
/** Summed wall time inside effects. */
|
|
8
|
+
totalEffectMs: number;
|
|
9
|
+
/** Longest single effect. */
|
|
10
|
+
maxEffectMs: number;
|
|
11
|
+
/** Greatest number of effects in flight at once. */
|
|
12
|
+
peakConcurrentEffects: number;
|
|
13
|
+
/** Per-tool breakdown, so a single slow tool is visible rather than averaged away. */
|
|
14
|
+
byTool: Record<string, { calls: number; totalMs: number }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface EffectProfiler {
|
|
18
|
+
middleware: EffectMiddleware;
|
|
19
|
+
profile(): EffectProfile;
|
|
20
|
+
reset(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Measures what effects actually cost, as the counterpart to `ProfilingEventStore`.
|
|
25
|
+
*
|
|
26
|
+
* Together they answer [ADR 0065](../../../docs/adr/0065-group-commit-deferred-evidence-gate.md)
|
|
27
|
+
* gate 1: is **event-write throughput** the bottleneck for this workload, or is it effect
|
|
28
|
+
* latency? The gate requires that question be settled by profiling rather than intuition,
|
|
29
|
+
* and it is not rhetorical — `ExecutionJournal` queues appends on a serial tail, so the
|
|
30
|
+
* interpreter is decoupled from write latency and a workload can write a great deal without
|
|
31
|
+
* writes ever constraining it.
|
|
32
|
+
*
|
|
33
|
+
* Wire it as effect-runtime middleware in your own app, wrap your store in
|
|
34
|
+
* `ProfilingEventStore`, run real traffic, and compare `totalEffectMs` against
|
|
35
|
+
* `totalWriteMs`. Batching the write path can only help if the latter dominates.
|
|
36
|
+
*/
|
|
37
|
+
export function effectTimingMiddleware(): EffectProfiler {
|
|
38
|
+
let calls = 0;
|
|
39
|
+
let totalEffectMs = 0;
|
|
40
|
+
let maxEffectMs = 0;
|
|
41
|
+
let inFlight = 0;
|
|
42
|
+
let peakConcurrentEffects = 0;
|
|
43
|
+
const byTool = new Map<string, { calls: number; totalMs: number }>();
|
|
44
|
+
|
|
45
|
+
const middleware: EffectMiddleware = async (ctx, input, next) => {
|
|
46
|
+
const started = performance.now();
|
|
47
|
+
inFlight++;
|
|
48
|
+
if (inFlight > peakConcurrentEffects) peakConcurrentEffects = inFlight;
|
|
49
|
+
try {
|
|
50
|
+
return await next(input);
|
|
51
|
+
} finally {
|
|
52
|
+
const elapsed = performance.now() - started;
|
|
53
|
+
inFlight--;
|
|
54
|
+
calls++;
|
|
55
|
+
totalEffectMs += elapsed;
|
|
56
|
+
if (elapsed > maxEffectMs) maxEffectMs = elapsed;
|
|
57
|
+
// `stepId` is the tool identity at this seam; fall back so an unnamed effect still
|
|
58
|
+
// shows up rather than vanishing from the breakdown.
|
|
59
|
+
const tool = ctx.stepId || "<unknown>";
|
|
60
|
+
const entry = byTool.get(tool);
|
|
61
|
+
if (entry === undefined) byTool.set(tool, { calls: 1, totalMs: elapsed });
|
|
62
|
+
else {
|
|
63
|
+
entry.calls++;
|
|
64
|
+
entry.totalMs += elapsed;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
middleware,
|
|
71
|
+
profile: () => ({
|
|
72
|
+
calls,
|
|
73
|
+
totalEffectMs,
|
|
74
|
+
maxEffectMs,
|
|
75
|
+
peakConcurrentEffects,
|
|
76
|
+
byTool: Object.fromEntries([...byTool].map(([k, v]) => [k, { ...v }])),
|
|
77
|
+
}),
|
|
78
|
+
reset: () => {
|
|
79
|
+
calls = 0;
|
|
80
|
+
totalEffectMs = 0;
|
|
81
|
+
maxEffectMs = 0;
|
|
82
|
+
peakConcurrentEffects = 0;
|
|
83
|
+
byTool.clear();
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|