Files
PaperClipAI/server/src/services/decision-models.ts
T
DottaandPaperclip 0eaabe5c0b refactor(accounting): remove company serialization from paid settlement (#18917)
Settle paid operations through operation-scoped transactions. Preserve exact ledger evidence and replay while moving aggregate budget decisions to durable deferred work.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-11-04 07:29:28 -06:00

331 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { randomUUID } from "node:crypto";
import { and, desc, eq, gte, lt, lte, or, sql } from "drizzle-orm";
import {
agents, authUsers, budgetReservations, companies, companyMemberships, companyDecisionModels, costEvents,
decisionInvocations, heartbeatRuns, issues, projects, companySecrets, type Db,
} from "@paperclipai/db";
import {
DECISION_MODELS, decisionProviderForConnection, decisionRequestSchema, updateDecisionModelSchema,
centsToUnits, unitsToCents, type DecisionAvailability, type DecisionConnectionChoice, type DecisionHistoryEntry,
type DecisionModelSettings, type DecisionProvider, type DecisionRequest, type DecisionResult, type DecisionUnavailableReason, type UpdateDecisionModel,
} from "@paperclipai/shared";
import { HttpError, forbidden, unprocessable } from "../errors.js";
import { aiConnectionService } from "./ai-connections.js";
import { accessService } from "./access.js";
import { type AuthorizationActor } from "./authorization.js";
import { withAccountingTransaction, withOperationAccountingTransaction } from "./accounting-transaction.js";
import { withBudgetSpan } from "./budget-tracing.js";
import { budgetServiceInTransaction, type BudgetServiceHooks } from "./budgets.js";
import { createCostEventInTransaction } from "./costs.js";
import { applyCostCorrectionInTransaction } from "./accounting-corrections.js";
import { queueBudgetEvaluation } from "./budget-evaluation-work.js";
import { logActivity } from "./activity-log.js";
import { decisionReceipt, runDecisionProvider, type DecisionProviderOutcome } from "./decision-model-provider.js";
const registeredFeatures = new WeakSet<object>();
/** Register once in a server module. Feature handles cannot be supplied over JSON/API. */
export function defineDecisionFeature(id: string, options: { background?: boolean } = {}) {
if (!/^[a-z][a-z0-9_.-]{0,99}$/.test(id)) throw new Error("Invalid decision feature identifier");
const feature = Object.freeze({ id, background: options.background === true });
registeredFeatures.add(feature);
return feature;
}
export const settingsDecisionTest = defineDecisionFeature("settings.test");
export interface DecisionContext {
companyId: string;
feature: ReturnType<typeof defineDecisionFeature>;
actor: AuthorizationActor | { type: "system" };
issueId?: string;
projectId?: string;
}
class DecisionUnavailable extends Error {
constructor(readonly reason: DecisionUnavailableReason) { super(reason); }
}
function unavailableReason(error: unknown): DecisionUnavailableReason | null {
if (error instanceof DecisionUnavailable) return error.reason;
if (error instanceof HttpError) {
if (error.status === 403 || error.status === 404) return "access_denied";
if (error.status === 422) {
return (error.details as { code?: string } | undefined)?.code === "incompatible_connection" ? "incompatible_connection" : "connection_unavailable";
}
}
return null;
}
export function decisionModelService(db: Db, options: { provider?: typeof runDecisionProvider; budgetHooks?: BudgetServiceHooks } = {}) {
const providerCall = options.provider ?? runDecisionProvider;
async function settings(companyId: string): Promise<DecisionModelSettings> {
const [row] = await db.select().from(companyDecisionModels).where(eq(companyDecisionModels.companyId, companyId));
return row ? { ...row, provider: row.provider as DecisionProvider | null } : {
companyId, enabled: false, allowBackground: true, connectionId: null, grantId: null, provider: null, model: null,
};
}
async function choices(companyId: string, userId: string): Promise<DecisionConnectionChoice[]> {
const connections = aiConnectionService(db);
if (!(await connections.membership(companyId, userId))) throw forbidden();
return (await connections.list(companyId, userId)).flatMap(row => {
const provider = decisionProviderForConnection(row);
return provider && row.ownership === "shared" ? [{ ...row, decisionModel: DECISION_MODELS[provider].id }] : [];
});
}
async function configure(companyId: string, userId: string, input: UpdateDecisionModel) {
const data = updateDecisionModelSchema.parse(input);
return withAccountingTransaction(db, companyId, async (tx, publications) => {
const [previous] = await tx.select().from(companyDecisionModels).where(eq(companyDecisionModels.companyId, companyId));
// Managers can always turn off a broken/revoked connection without acquiring it.
const disabling = !data.enabled && previous?.connectionId === data.connectionId && previous?.grantId === data.grantId;
const row = !disabling && data.connectionId && data.grantId ? await aiConnectionService(tx).selectDecision({ companyId,
userId, connectionId: data.connectionId, grantId: data.grantId }) : null;
const provider = (disabling ? previous?.provider : row?.provider) as DecisionProvider | null | undefined;
const values = { ...data, allowBackground: data.allowBackground ?? previous?.allowBackground ?? true, companyId, provider: provider ?? null, model: provider ? DECISION_MODELS[provider].id : null, updatedAt: new Date() };
await tx.insert(companyDecisionModels).values(values).onConflictDoUpdate({ target: companyDecisionModels.companyId, set: values });
await logActivity(tx, { companyId, actorType: "user", actorId: userId, action: "decision_model.configured", entityType: "company", entityId: companyId,
details: { ...data, provider: values.provider, model: values.model } }, publications);
return values;
});
}
async function resolve(context: DecisionContext, executor = db, lockAdmission = false) {
if (!registeredFeatures.has(context.feature)) throw forbidden("A registered decision feature is required");
const configQuery = executor.select().from(companyDecisionModels).where(eq(companyDecisionModels.companyId, context.companyId));
const [config] = await (lockAdmission ? configQuery.for("share") : configQuery);
if (!config?.connectionId || !config.grantId) throw new DecisionUnavailable("not_configured");
if (!config.enabled) throw new DecisionUnavailable("disabled");
let userId: string | null = null, agentId: string | null = null, runId: string | null = null, identityContextId: string | null = null;
let issueId = context.issueId ?? null;
let actor = context.actor;
let admissionAgent: { status: string } | undefined;
if (actor.type === "system") {
if (!context.feature.background || !config.allowBackground) throw new DecisionUnavailable("background_disabled");
} else if (actor.type === "agent") {
if (!actor.agentId || !actor.runId || actor.companyId !== context.companyId) throw forbidden();
if (lockAdmission) [admissionAgent] = await executor.select({ status: agents.status }).from(agents).where(and(
eq(agents.id, actor.agentId), eq(agents.companyId, context.companyId),
)).for("share");
const runQuery = executor.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, context.companyId), eq(heartbeatRuns.id, actor.runId), eq(heartbeatRuns.agentId, actor.agentId)));
const [run] = await (lockAdmission ? runQuery.for("share") : runQuery);
if (!run || run.status !== "running") throw forbidden("An active run is required");
agentId = run.agentId; runId = run.id; userId = run.responsibleUserId; identityContextId = run.activeIdentityContextId;
const runIssue = run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId;
if (issueId && issueId !== runIssue) throw forbidden("Task does not belong to this run");
issueId = typeof runIssue === "string" ? runIssue : null;
const memberships = userId ? await executor.select({ companyId: companyMemberships.companyId, membershipRole: companyMemberships.membershipRole, status: companyMemberships.status })
.from(companyMemberships).where(and(eq(companyMemberships.companyId, context.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId))) : [];
actor = { ...actor, onBehalfOfUserId: userId, onBehalfOfMemberships: memberships };
} else if (actor.type === "board") userId = actor.userId ?? (actor.source === "local_implicit" ? "local-board" : null);
else throw forbidden();
if (actor.type !== "system" && !userId) throw new DecisionUnavailable("responsible_user_missing");
const [issue] = issueId ? await executor.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, context.companyId))) : [];
if (issueId && !issue) throw forbidden();
if (issue && context.projectId && context.projectId !== issue.projectId) throw forbidden("Task project mismatch");
const projectId = issue?.projectId ?? context.projectId ?? null;
if (projectId) {
const [project] = await executor.select({ id: projects.id }).from(projects).where(and(eq(projects.id, projectId), eq(projects.companyId, context.companyId)));
if (!project) throw forbidden();
}
if (actor.type !== "system") {
const access = accessService(executor);
const accessResult = await access.decide({ actor, action: issue ? "issue:read" : projectId ? "project:read" : "company_scope:read",
resource: issue ? { type: "issue", companyId: context.companyId, issueId: issue.id, projectId: issue.projectId, parentIssueId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, status: issue.status }
: projectId ? { type: "project", companyId: context.companyId, projectId } : { type: "company", companyId: context.companyId } });
if (!accessResult.allowed) throw forbidden();
}
const connection = await aiConnectionService(executor).selectDecision({ companyId: context.companyId,
connectionId: config.connectionId, grantId: config.grantId, userId, agentId, sponsoredBackground: actor.type === "system" });
if (connection.provider !== config.provider || config.model !== DECISION_MODELS[connection.provider].id) throw new DecisionUnavailable("incompatible_connection");
return { config, connection, userId, agentId, runId, identityContextId, issueId, projectId, admissionAgent,
actorType: actor.type === "board" ? "user" as const : actor.type === "agent" ? "agent" as const : "system" as const,
actorId: actor.type === "system" ? "paperclip-decisions" : agentId ?? userId! };
}
async function availability(context: DecisionContext): Promise<DecisionAvailability> {
try { await resolve(context); return { available: true }; }
catch (error) { const reason = unavailableReason(error); if (reason) return { available: false, reason }; throw error; }
}
/** Callers may memoize within their request; dispatch never consumes a cached authorization. */
function availabilityForRequest() {
const cache = new Map<DecisionContext, Promise<DecisionAvailability>>();
return (context: DecisionContext) => { let result = cache.get(context); if (!result) { result = availability(context); cache.set(context, result); } return result; };
}
async function settle(invocation: typeof decisionInvocations.$inferSelect, outcome: DecisionProviderOutcome) {
await withBudgetSpan("budget.accounting.settlement", () => withOperationAccountingTransaction(db, invocation.companyId, async (tx, publications) => {
const receipt = outcome.receipt;
// Keep dispatch identity on the invocation even if its task or agent was
// deleted while the provider worked. Ledger foreign keys use live rows.
const links: { agentId: string | null; issueId: string | null; projectId: string | null } = { agentId: null, issueId: null, projectId: null };
for (const [key, table] of [["agentId", agents], ["issueId", issues], ["projectId", projects]] as const) {
const id = invocation[key];
if (!id) continue;
const [row] = await tx.select({ id: table.id }).from(table).where(and(eq(table.id, id), eq(table.companyId, invocation.companyId))).for("key share");
if (row) links[key] = row.id;
}
const [current] = await tx.select().from(decisionInvocations).where(and(eq(decisionInvocations.companyId, invocation.companyId), eq(decisionInvocations.id, invocation.id))).for("update");
if (!current) return;
const [existing] = current.costEventId ? await tx.select({ id: costEvents.id, costStatus: costEvents.costStatus,
inputTokens: costEvents.inputTokens, cachedInputTokens: costEvents.cachedInputTokens, outputTokens: costEvents.outputTokens,
providerRequestId: costEvents.providerRequestId, pricingProvenance: costEvents.pricingProvenance,
costCents: sql<string>`${costEvents.costCents}::text` }).from(costEvents).where(and(
eq(costEvents.companyId, invocation.companyId), eq(costEvents.id, current.costEventId),
)).for("update") : [];
if (current.status !== "running") {
if (!existing || outcome.errorCode === "interrupted") return;
if (current.errorCode !== "interrupted" && (existing.costStatus !== "unpriced" || receipt.costStatus === "unpriced")) return;
}
let event: { id: string };
if (existing) {
const receiptUsage = { inputTokens: receipt.inputTokens ?? current.inputTokens ?? 0, cachedInputTokens: 0,
outputTokens: receipt.outputTokens ?? current.outputTokens ?? 0, providerRequestId: receipt.providerRequestId ?? current.providerRequestId };
// Recovery records uncertainty, not final billing evidence. A late receipt
// values the same attempt through the existing audited correction ledger.
if (existing.costStatus === "unpriced" && receipt.costStatus !== "unpriced") await applyCostCorrectionInTransaction(tx, publications, { companyId: invocation.companyId, eventId: existing.id,
idempotencyKey: `decision:${invocation.id}:late-receipt`, expectedCents: existing.costCents,
correctedCents: receipt.costCents ?? "0", reason: "Provider receipt arrived after interrupted accounting recovery",
costStatus: receipt.costStatus,
receiptUsage,
pricing: { ...receipt.pricingProvenance, evidence: JSON.stringify({ providerRequestId: receipt.providerRequestId,
inputTokens: receipt.inputTokens, outputTokens: receipt.outputTokens, evidence: receipt.pricingProvenance.evidence }) },
actorId: "cost_accounting" });
else {
if (Object.entries(receiptUsage).some(([key, value]) => existing[key as keyof typeof existing] !== value))
await applyCostCorrectionInTransaction(tx, publications, { companyId: invocation.companyId, eventId: existing.id,
idempotencyKey: `decision:${invocation.id}:late-usage`, expectedCents: existing.costCents, correctedCents: existing.costCents,
costStatus: existing.costStatus === "unpriced" ? "unpriced" : existing.costStatus === "estimated" ? "estimated" : "reported", receiptUsage,
reason: "Provider usage arrived after interrupted accounting recovery",
pricing: existing.pricingProvenance ?? { source: "unknown" }, actorId: "cost_accounting" });
if (existing.costStatus !== "unpriced") await logActivity(tx, { companyId: invocation.companyId,
actorType: "system", actorId: "cost_accounting", responsibleUserIdOverride: invocation.responsibleUserId,
action: "accounting.late_receipt_observed", entityType: "cost_event", entityId: existing.id,
details: { effectiveCostCents: existing.costCents, ...receipt },
}, publications);
}
event = existing;
} else event = await createCostEventInTransaction(tx, invocation.companyId, {
usageKind: "decision", responsibleUserId: invocation.responsibleUserId, ...links,
// Run attribution lives on the invocation. Run receipt reconciliation must not ingest this independent charge.
heartbeatRunId: null, idempotencyKey: `decision:${invocation.id}`,
provider: invocation.provider === "openrouter" ? "typesafe" : "openai", biller: invocation.provider,
billingType: "metered_api", costStatus: receipt.costStatus, model: invocation.model,
inputTokens: receipt.inputTokens ?? 0, outputTokens: receipt.outputTokens ?? 0, costCents: receipt.costCents ?? "0",
providerRequestId: receipt.providerRequestId, pricingProvenance: receipt.pricingProvenance, occurredAt: invocation.startedAt,
}, publications, { actorType: invocation.actorType as "system" | "user" | "agent", actorId: invocation.actorId, agentId: links.agentId });
const finishedAt = new Date();
await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, invocation.companyId);
await tx.update(decisionInvocations).set({ status: outcome.answers ? "succeeded" : receipt.costStatus === "unpriced" ? "unknown" : "failed",
errorCode: outcome.errorCode ?? null, costEventId: event.id, providerRequestId: receipt.providerRequestId ?? current.providerRequestId,
inputTokens: receipt.inputTokens ?? current.inputTokens, outputTokens: receipt.outputTokens ?? current.outputTokens, finishedAt, durationMs: Math.max(0, finishedAt.getTime() - invocation.startedAt.getTime()),
}).where(and(eq(decisionInvocations.companyId, invocation.companyId), eq(decisionInvocations.id, invocation.id)));
if (receipt.costStatus !== "unpriced") await tx.update(budgetReservations).set({ state: outcome.noProviderWork ? "released" : "settled", settledAt: finishedAt }).where(and(
eq(budgetReservations.companyId, invocation.companyId), eq(budgetReservations.decisionInvocationId, invocation.id), eq(budgetReservations.state, "held"),
));
}), { "budget.accounting.owner": "decision" });
}
async function decide(context: DecisionContext, request: DecisionRequest, signal?: AbortSignal): Promise<DecisionResult> {
const parsed = decisionRequestSchema.safeParse(request);
if (!parsed.success || Buffer.byteLength(JSON.stringify(parsed.data)) > 32_000) throw unprocessable("Decisions require 1–20 valid questions and at most 32 KB of text/JSON");
let selected: Awaited<ReturnType<typeof resolve>>;
try { selected = await resolve(context); }
catch (error) { const reason = unavailableReason(error); if (reason) return { status: "unavailable", reason }; throw error; }
const secretId = selected.connection.grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")?.secretId;
const [credentialSnapshot] = secretId ? await db.select({ id: companySecrets.id, status: companySecrets.status, version: companySecrets.latestVersion })
.from(companySecrets).where(and(eq(companySecrets.companyId, context.companyId), eq(companySecrets.id, secretId))) : [];
if (!credentialSnapshot || credentialSnapshot.status !== "active") return { status: "unavailable", reason: "connection_unavailable" };
let credential: string;
try { credential = await aiConnectionService(db).credential(selected.connection, 0, { responsibleUserId: selected.userId,
actorType: selected.actorType, actorId: selected.actorId, issueId: selected.issueId, heartbeatRunId: selected.runId }); }
catch (error) { return { status: "unavailable", reason: unavailableReason(error) ?? "connection_unavailable" }; }
const admission = await withOperationAccountingTransaction(db, context.companyId, async (tx, publications) => {
// Admission readers share these control fences. A manual pause either
// precedes this admission or sees its committed invocation when it wins.
const [company] = await tx.select({ status: companies.status }).from(companies)
.where(eq(companies.id, context.companyId)).for("share");
if (!company || company.status !== "active") return { reason: "budget_blocked" as const };
if (selected.projectId) {
const [project] = await tx.select({ id: projects.id }).from(projects).where(and(
eq(projects.companyId, context.companyId), eq(projects.id, selected.projectId),
)).for("share");
if (!project) return { reason: "budget_blocked" as const };
}
let current: Awaited<ReturnType<typeof resolve>>;
try { current = await resolve(context, tx, true); }
catch (error) { const reason = unavailableReason(error); if (reason) return { reason }; throw error; }
// A different setting or steering identity requires a fresh credential acquisition.
if (current.config.updatedAt.getTime() !== selected.config.updatedAt.getTime()
|| current.config.connectionId !== selected.config.connectionId || current.config.grantId !== selected.config.grantId
|| current.config.model !== selected.config.model || current.config.provider !== selected.config.provider
|| current.projectId !== selected.projectId
|| current.connection.grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")?.secretId !== credentialSnapshot.id
|| current.userId !== selected.userId || current.identityContextId !== selected.identityContextId
|| current.connection.grant.updatedAt.getTime() !== selected.connection.grant.updatedAt.getTime()
|| current.connection.connection.updatedAt.getTime() !== selected.connection.connection.updatedAt.getTime())
return { reason: "connection_unavailable" as const };
// Secret changes do not update the connection revision. Serialize against
// rotation/revocation through this invocation's admission.
const [currentSecret] = await tx.select({ status: companySecrets.status, version: companySecrets.latestVersion }).from(companySecrets)
.where(and(eq(companySecrets.companyId, context.companyId), eq(companySecrets.id, credentialSnapshot.id))).for("share");
if (!currentSecret || currentSecret.status !== "active" || currentSecret.version !== credentialSnapshot.version)
return { reason: "connection_unavailable" as const };
const budgets = budgetServiceInTransaction(tx, publications);
const { block, policies, observations } = await budgets.evaluateAdmission(context.companyId, current.agentId, { projectId: current.projectId });
const agent = current.admissionAgent;
if (block || company.status !== "active" || (current.agentId && (!agent || agent.status === "paused" || agent.status === "terminated"))) return { reason: "budget_blocked" as const };
const amount = policies.reduce((max, p) => { const n = centsToUnits(p.reservationCents); return n > max ? n : max; }, 0n);
for (const [index, policy] of policies.entries()) {
const committed = centsToUnits(observations[index].ledgerCents) + centsToUnits(observations[index].effectiveHeldCents);
if (committed >= centsToUnits(policy.amount) || committed + amount > centsToUnits(policy.amount)) return { reason: "budget_blocked" as const };
}
signal?.throwIfAborted();
await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, context.companyId);
const [invocation] = await tx.insert(decisionInvocations).values({ id: randomUUID(), companyId: context.companyId, feature: context.feature.id,
actorType: current.actorType, actorId: current.actorId, responsibleUserId: current.userId, agentId: current.agentId,
issueId: current.issueId, projectId: current.projectId, runId: current.runId, identityContextId: current.identityContextId,
connectionId: current.connection.connection.id, grantId: current.connection.grant.id, provider: current.connection.provider, model: current.config.model!,
questionTypes: Object.values(parsed.data.questions).map(q => q.type),
}).returning();
await tx.insert(budgetReservations).values({ companyId: context.companyId, decisionInvocationId: invocation.id, agentId: current.agentId,
projectId: current.projectId, amountCents: unitsToCents(amount), providerStartedAt: invocation.startedAt });
await queueBudgetEvaluation(tx, { companyId: context.companyId, agentId: current.agentId, projectId: current.projectId });
return { invocation };
});
if (admission.reason) return { status: "unavailable", reason: admission.reason };
const invocation = admission.invocation!;
let outcome: DecisionProviderOutcome;
try { outcome = await providerCall({ provider: selected.connection.provider, model: invocation.model, apiKey: credential,
request: parsed.data, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000) }); }
catch { outcome = { errorCode: "provider_failed", receipt: decisionReceipt(selected.connection.provider, null) }; }
await settle(invocation, outcome);
const { inputTokens, outputTokens, costCents, costStatus } = outcome.receipt;
const usage = { inputTokens, outputTokens, costCents, costStatus };
return outcome.answers ? { status: "succeeded", invocationId: invocation.id, answers: outcome.answers, usage }
: { status: "failed", invocationId: invocation.id, errorCode: outcome.errorCode ?? "provider_failed", usage };
}
async function recoverInterrupted() {
const stale = await db.select().from(decisionInvocations).where(and(eq(decisionInvocations.status, "running"), lt(decisionInvocations.startedAt, new Date(Date.now() - 120_000)))).limit(100);
for (const invocation of stale) await settle(invocation, { errorCode: "interrupted", receipt: decisionReceipt(invocation.provider as DecisionProvider, null) });
return stale.length;
}
async function history(companyId: string, actor: AuthorizationActor, range: { from?: Date; to?: Date; before?: Date; limit?: number } = {}): Promise<DecisionHistoryEntry[]> {
const access = accessService(db);
if (!(await access.decide({ actor, action: "company_scope:read", resource: { type: "company", companyId } })).allowed) throw forbidden();
const rows = await db.select({ invocation: decisionInvocations, userName: authUsers.name,
inputTokens: costEvents.inputTokens, outputTokens: costEvents.outputTokens, costStatus: costEvents.costStatus,
costCents: sql<string | null>`${costEvents.costCents}::text`, issue: issues,
}).from(decisionInvocations).leftJoin(costEvents, and(eq(costEvents.id, decisionInvocations.costEventId), eq(costEvents.companyId, companyId)))
.leftJoin(authUsers, eq(authUsers.id, decisionInvocations.responsibleUserId))
.leftJoin(issues, and(eq(issues.id, decisionInvocations.issueId), eq(issues.companyId, companyId)))
.where(and(eq(decisionInvocations.companyId, companyId), range.from ? gte(decisionInvocations.startedAt, range.from) : undefined,
range.to ? lte(decisionInvocations.startedAt, range.to) : undefined, range.before ? lt(decisionInvocations.startedAt, range.before) : undefined))
.orderBy(desc(decisionInvocations.startedAt), desc(decisionInvocations.id)).limit(Math.min(range.limit ?? 100, 500));
return Promise.all(rows.map(async ({ invocation: row, issue, ...cost }) => {
const visible = issue && (await access.decide({ actor, action: "issue:read", resource: { type: "issue", companyId, issueId: issue.id,
projectId: issue.projectId, parentIssueId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, status: issue.status } })).allowed;
return { id: row.id, feature: row.feature, actorType: row.actorType, responsibleUserId: row.responsibleUserId, userName: cost.userName,
issueId: visible ? issue.id : null, issueIdentifier: visible ? issue.identifier : null, agentId: row.agentId, runId: visible ? row.runId : null,
connectionId: row.connectionId, provider: row.provider, model: row.model, status: row.status, errorCode: row.errorCode,
startedAt: row.startedAt.toISOString(), finishedAt: row.finishedAt?.toISOString() ?? null, durationMs: row.durationMs,
inputTokens: row.inputTokens, outputTokens: row.outputTokens,
costCents: cost.costStatus === "unpriced" ? null : cost.costCents, costStatus: cost.costStatus };
}));
}
return { settings, choices, configure, availability, availabilityForRequest, decide, recoverInterrupted, history };
}