/*
RFC-1212: journaled usage-record emission for metered capabilities — the
billing sibling of {@link emitAgentTraffic}. Called from the dispatch boundary
(executeAction) AFTER a successful dispatch.send: exactly one AgentUsageRecord
per metered invocation; rejected, rate-limited, schema-violated, or
idempotent-replayed calls never reach this code path, so they never bill.
Free-allowance accounting decrements the same fixed-window counter
infrastructure the rate limiter uses (RFC-0291), keyed
`metering:` per caller identity; an unavailable counter store
degrades to "allowance exhausted → billable" — the call is never blocked by
metering state. Emission is fire-and-forget through the optional `usage` port
— the engine never imports billing packages (pull model, AC-9).
- Do not bill, price, or contact Stripe — the record is journaled for the
site-side billing bridge to drain.
- Do not emit for undispatched calls — metering attaches strictly
post-dispatch inside executeAction.
- Do not emit when the capability declares no metering block.
- RFC-1212: initial emission — usage port + free-window counter + workerLogUsage sink.
*/
import {
AGENT_USAGE_SCHEMA_ID,
agentUsageRecordSchema,
type AgentUsageRecord,
type CapabilityMetering,
} from "@warpgogol/werkstatt-shared/agent";
import type { CapabilityRecord } from "@warpgogol/werkstatt-shared/ontology";
import type { AgentGatePorts } from "./ports.ts";
import type { RateLimiter } from "./limits.ts";
/** Window granularity → seconds. "month" uses a 30-day fixed window. */
const WINDOW_SECONDS: Record, number> = {
day: 86_400,
month: 2_592_000,
};
/**
* Free-allowance limiter store — separate from the rate-limit store so a
* metering bucket never collides with a throttle bucket for the same
* capability. Keyed `metering:::`.
*/
const freeWindowLimiters = new Map();
/** Reset the free-allowance cache — test-only. */
export function __resetFreeWindowCache(): void {
freeWindowLimiters.clear();
}
/**
* Consume one free unit from the caller's allowance window. Returns true when
* a free unit was consumed (call is not billable), false when the allowance
* is exhausted or no counter store exists — both mean "billable".
*/
function consumeFreeUnit(
ports: AgentGatePorts,
metering: CapabilityMetering,
capabilityId: string,
identity: string,
): boolean {
const free = metering.freeUnitsPerWindow ?? 0;
if (free === 0 || !ports.createRateLimiter) return false;
const windowSeconds = WINDOW_SECONDS[metering.window ?? "month"];
const key = `metering:${capabilityId}:${free}:${windowSeconds}`;
try {
let limiter = freeWindowLimiters.get(key);
if (!limiter) {
limiter = ports.createRateLimiter(free, windowSeconds);
freeWindowLimiters.set(key, limiter);
}
return limiter.check(`${capabilityId}:${identity}`).allowed;
} catch {
// Counter store failure → allowance treated as exhausted (RFC-1212).
return false;
}
}
/**
* Emit exactly one journaled AgentUsageRecord for a dispatched metered call.
* Fire-and-forget like the traffic emit — a missing port or a throwing sink
* drops the record silently; billing transport never breaks a tool call (the
* adapter's journal is the durability boundary, replayed by the site bridge).
*/
export async function emitAgentUsage(
ports: Pick,
input: {
siteId: string;
capability: CapabilityRecord;
callerIdentity: string;
idempotencyKey?: string;
},
): Promise {
try {
const metering = input.capability.metering;
if (!ports.usage || !metering) return;
const billable = !consumeFreeUnit(
ports as AgentGatePorts,
metering,
input.capability.id,
input.callerIdentity,
);
const record = agentUsageRecordSchema.parse({
schema: AGENT_USAGE_SCHEMA_ID,
siteId: input.siteId,
capability: input.capability.id,
callerIdentity: input.callerIdentity,
units: 1,
billable,
idempotencyKey: input.idempotencyKey,
occurredAt: ports.now().toISOString(),
});
ports.usage(record);
} catch {
// Silent drop — metering never breaks the request path.
}
}
/**
* Production sink — one structured `gogol.agent-usage/1` JSON line per record
* on stdout, captured by Workers logs and drained by the site billing bridge.
* Wired in `astro.ts` buildPorts; tests inject collectors.
*/
export function workerLogUsage(record: AgentUsageRecord): void {
console.log(JSON.stringify(record));
}