mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 14:02:36 +01:00
merge: integrate agent lifecycle ownership from master
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
ad5d8826ad
159 files changed
+7173
-2805
No files matched your search
@@ -0,0 +1,102 @@
|
||||
import { LOW_TRUST_REVIEW_PRESET, type PermissionKey } from "@paperclipai/shared";
|
||||
|
||||
/** Direct grants for newly activated standard agents. Existing agents are not backfilled. */
|
||||
export const NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS = [
|
||||
"agents:configure",
|
||||
"agents:suggest-changes",
|
||||
"skills:create",
|
||||
"skills:suggest-changes",
|
||||
"tools:manage_connections",
|
||||
"tools:manage_profiles",
|
||||
"tools:view_audit",
|
||||
"audit:view_agent_actions",
|
||||
"tools:use",
|
||||
"tools:manage_runtime",
|
||||
"inbox:manage",
|
||||
"tasks:assign",
|
||||
"tasks:assign_scope",
|
||||
"tasks:manage_active_checkouts",
|
||||
] as const satisfies readonly PermissionKey[];
|
||||
|
||||
export function newStandardAgentGrantScope(
|
||||
permissionKey: PermissionKey,
|
||||
agentId: string,
|
||||
): Record<string, unknown> | null {
|
||||
if (permissionKey === "tasks:assign_scope") return { subtreeRootAgentId: agentId };
|
||||
if (permissionKey === "inbox:manage") return { responsibleUserOnly: true };
|
||||
return null;
|
||||
}
|
||||
|
||||
export type NormalizedAgentPermissions = Record<string, unknown> & {
|
||||
canCreateAgents: boolean;
|
||||
canCreateSkills: boolean;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the agent-source low-trust markers consumed by
|
||||
* resolveCoreTrustPreset: the low-trust review preset (top-level or inside
|
||||
* authorizationPolicy) or a low-trust boundary. Defaults must never grant
|
||||
* agent-creation authority to a low-trust agent.
|
||||
*/
|
||||
export function permissionsImplyLowTrust(permissions: unknown): boolean {
|
||||
const record = asRecord(permissions);
|
||||
if (!record) return false;
|
||||
const authorizationPolicy = asRecord(record.authorizationPolicy);
|
||||
return (
|
||||
record.trustPreset === LOW_TRUST_REVIEW_PRESET ||
|
||||
authorizationPolicy?.trustPreset === LOW_TRUST_REVIEW_PRESET ||
|
||||
asRecord(record.reviewPreset)?.id === LOW_TRUST_REVIEW_PRESET ||
|
||||
asRecord(authorizationPolicy?.reviewPreset)?.id === LOW_TRUST_REVIEW_PRESET ||
|
||||
asRecord(authorizationPolicy?.trustBoundary) !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "create" is the context for permissions arriving on a new-agent write: the
|
||||
* hire/create default applies and the resolved value is persisted. "stored"
|
||||
* is the context for rows read back from the database: a row without an
|
||||
* explicit value stays fail-closed, so the default is never granted
|
||||
* retroactively to legacy or malformed records at read or enforcement time.
|
||||
*/
|
||||
export type AgentPermissionsContext = "create" | "stored";
|
||||
|
||||
export function defaultAgentPermissions(
|
||||
options?: { lowTrust?: boolean; context?: AgentPermissionsContext },
|
||||
): NormalizedAgentPermissions {
|
||||
return {
|
||||
canCreateAgents: options?.context === "create" && options?.lowTrust !== true,
|
||||
canCreateSkills: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAgentPermissions(
|
||||
permissions: unknown,
|
||||
options?: { context?: AgentPermissionsContext },
|
||||
): NormalizedAgentPermissions {
|
||||
const defaults = defaultAgentPermissions({
|
||||
lowTrust: permissionsImplyLowTrust(permissions),
|
||||
context: options?.context ?? "stored",
|
||||
});
|
||||
const record = asRecord(permissions);
|
||||
if (!record) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
canCreateAgents:
|
||||
typeof record.canCreateAgents === "boolean"
|
||||
? record.canCreateAgents
|
||||
: defaults.canCreateAgents,
|
||||
canCreateSkills:
|
||||
typeof record.canCreateSkills === "boolean"
|
||||
? record.canCreateSkills
|
||||
: defaults.canCreateSkills,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, eq, gte, inArray, lt, sql } from "drizzle-orm";
|
||||
import { agents, costEvents, type Db } from "@paperclipai/db";
|
||||
import {
|
||||
agentAppearanceSchema, resolveAgentAppearance, agentAvatarUrl,
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS, agentRuntimeConfigSchema, getAgentWorkEligibility,
|
||||
normalizeAgentUrlKey, type AgentEligibilityAgent, type AgentRunnerChoice,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
|
||||
import { normalizeAgentPermissions } from "./agent-permissions.js";
|
||||
import { builtInAgentMarkersEqual, readBuiltInAgentMarker } from "./built-in-agent-metadata.js";
|
||||
|
||||
export function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function createToken() {
|
||||
return `pcp_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
const CONFIG_REVISION_FIELDS = [
|
||||
"name",
|
||||
"role",
|
||||
"title",
|
||||
"icon",
|
||||
"appearance",
|
||||
"reportsTo",
|
||||
"capabilities",
|
||||
"adapterType",
|
||||
"adapterConfig",
|
||||
"runtimeConfig",
|
||||
"defaultEnvironmentId",
|
||||
"budgetMonthlyCents",
|
||||
"metadata",
|
||||
] as const;
|
||||
|
||||
type ConfigRevisionField = (typeof CONFIG_REVISION_FIELDS)[number];
|
||||
type AgentConfigSnapshot = Pick<typeof agents.$inferSelect, ConfigRevisionField>;
|
||||
|
||||
export interface RevisionMetadata {
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
source?: string;
|
||||
rolledBackFromRevisionId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Claude login context for an agent write. The route derives the owner user
|
||||
* from the authenticated actor, not from the request body, and forwards the
|
||||
* non-secret `storedSessionId` claim from a completed Claude login session. A
|
||||
* controlled internal override permits a migration or an administrator repair to
|
||||
* bind or unbind the fixed OAuth token without a claim.
|
||||
*
|
||||
* The `applyExistingWithoutClaim` field is the user-actor apply-existing path.
|
||||
* The route sets it only for an authenticated user actor and derives the owner
|
||||
* from that actor. The path binds the fixed reference to the owner stored value
|
||||
* with no login round trip. It is distinct from `allowInternalBindingOverride`,
|
||||
* which does no ownership check.
|
||||
*
|
||||
* The `inheritedFromAgentId` field is the hire-inheritance path. The route
|
||||
* sets it only for an authenticated agent actor whose hire request inherited
|
||||
* the fixed reference from that named parent. The service re-reads the parent
|
||||
* agent inside the write transaction and binds the fixed reference only when
|
||||
* the parent exists, is in the same company, is a `claude_local` agent, and
|
||||
* already holds the exact fixed binding.
|
||||
*/
|
||||
export interface ClaudeLoginContext {
|
||||
storedSessionId?: string | null;
|
||||
ownerUserId?: string | null;
|
||||
allowInternalBindingOverride?: boolean;
|
||||
applyExistingWithoutClaim?: boolean;
|
||||
inheritedFromAgentId?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateAgentOptions {
|
||||
recordRevision?: RevisionMetadata;
|
||||
allowBuiltInAgentMetadata?: boolean;
|
||||
allowPendingApprovalConfigUpdate?: boolean;
|
||||
claudeLogin?: ClaudeLoginContext;
|
||||
}
|
||||
|
||||
export type CreateAgentData = Omit<typeof agents.$inferInsert, "companyId" | "lifecycleState" | "lifecycleRequiredPluginIds" | "lifecycleHolds" | "lifecycleVersion" | "lifecycleError" | "lifecycleOperation"> & { runner?: AgentRunnerChoice };
|
||||
export type AgentConfigurationRecord = typeof agents.$inferSelect;
|
||||
export const AGENT_CONFIGURATION_FIELDS = [...CONFIG_REVISION_FIELDS, "permissions"] as const;
|
||||
export type AgentConfigurationPatch = Partial<Pick<typeof agents.$inferInsert, typeof AGENT_CONFIGURATION_FIELDS[number]>>;
|
||||
export type AgentHireRecord = Pick<typeof agents.$inferSelect, "id" | "companyId" | "adapterType" | "adapterConfig" | "permissions" | "metadata" | "status">;
|
||||
|
||||
export interface CreateAgentOptions {
|
||||
/** Reviewed/historical execution already resolved by a governed internal caller. */
|
||||
runnerResolved?: boolean;
|
||||
responsibleUserId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
aiConnectionInstall?: { connectionId: string; memberConnectionIds?: string[]; createdByUserId: string | null };
|
||||
allowBuiltInAgentMetadata?: boolean;
|
||||
claudeLogin?: ClaudeLoginContext;
|
||||
}
|
||||
|
||||
interface AgentShortnameRow {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface AgentShortnameCollisionOptions {
|
||||
excludeAgentId?: string | null;
|
||||
}
|
||||
|
||||
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function jsonEqual(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
export function buildConfigSnapshot(
|
||||
row: Pick<typeof agents.$inferSelect, ConfigRevisionField>,
|
||||
): AgentConfigSnapshot {
|
||||
const adapterConfig =
|
||||
typeof row.adapterConfig === "object" && row.adapterConfig !== null && !Array.isArray(row.adapterConfig)
|
||||
? sanitizeRecord(row.adapterConfig as Record<string, unknown>)
|
||||
: {};
|
||||
const runtimeConfig =
|
||||
typeof row.runtimeConfig === "object" && row.runtimeConfig !== null && !Array.isArray(row.runtimeConfig)
|
||||
? sanitizeRecord(row.runtimeConfig as Record<string, unknown>)
|
||||
: {};
|
||||
const metadata =
|
||||
typeof row.metadata === "object" && row.metadata !== null && !Array.isArray(row.metadata)
|
||||
? sanitizeRecord(row.metadata as Record<string, unknown>)
|
||||
: row.metadata ?? null;
|
||||
return {
|
||||
name: row.name,
|
||||
role: row.role,
|
||||
title: row.title,
|
||||
icon: row.icon,
|
||||
appearance: row.appearance,
|
||||
reportsTo: row.reportsTo,
|
||||
capabilities: row.capabilities,
|
||||
adapterType: row.adapterType,
|
||||
adapterConfig,
|
||||
runtimeConfig,
|
||||
defaultEnvironmentId: row.defaultEnvironmentId,
|
||||
budgetMonthlyCents: row.budgetMonthlyCents,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
export function containsRedactedMarker(value: unknown): boolean {
|
||||
if (value === REDACTED_EVENT_VALUE) return true;
|
||||
if (Array.isArray(value)) return value.some((item) => containsRedactedMarker(item));
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
return Object.values(value as Record<string, unknown>).some((entry) => containsRedactedMarker(entry));
|
||||
}
|
||||
|
||||
export function hasConfigPatchFields(data: Partial<typeof agents.$inferInsert>) {
|
||||
return CONFIG_REVISION_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(data, field));
|
||||
}
|
||||
|
||||
export function changedPendingApprovalConfigFields(
|
||||
existing: Omit<typeof agents.$inferSelect, "lifecycleOperation" | "lifecycleRequiredPluginIds">,
|
||||
data: Partial<typeof agents.$inferInsert>,
|
||||
) {
|
||||
return CONFIG_REVISION_FIELDS.filter((field) =>
|
||||
Object.prototype.hasOwnProperty.call(data, field) && !jsonEqual(data[field], existing[field]),
|
||||
);
|
||||
}
|
||||
|
||||
export function configPatchFromApprovalPayload(payload: Record<string, unknown>) {
|
||||
const patch: Partial<typeof agents.$inferInsert> = {};
|
||||
if (typeof payload.name === "string") patch.name = payload.name;
|
||||
if (typeof payload.role === "string") patch.role = payload.role;
|
||||
if (payload.appearance != null) patch.appearance = agentAppearanceSchema.parse(payload.appearance);
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "title")) {
|
||||
patch.title = typeof payload.title === "string" ? payload.title : null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "icon")) {
|
||||
patch.icon = typeof payload.icon === "string" ? payload.icon : null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "reportsTo")) {
|
||||
patch.reportsTo = typeof payload.reportsTo === "string" ? payload.reportsTo : null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "capabilities")) {
|
||||
patch.capabilities = typeof payload.capabilities === "string" ? payload.capabilities : null;
|
||||
}
|
||||
if (typeof payload.adapterType === "string") patch.adapterType = payload.adapterType;
|
||||
if (isPlainRecord(payload.adapterConfig)) patch.adapterConfig = payload.adapterConfig;
|
||||
if (isPlainRecord(payload.runtimeConfig)) patch.runtimeConfig = payload.runtimeConfig;
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "defaultEnvironmentId")) {
|
||||
patch.defaultEnvironmentId =
|
||||
typeof payload.defaultEnvironmentId === "string" ? payload.defaultEnvironmentId : null;
|
||||
}
|
||||
if (typeof payload.budgetMonthlyCents === "number") {
|
||||
patch.budgetMonthlyCents = payload.budgetMonthlyCents;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "metadata")) {
|
||||
patch.metadata = isPlainRecord(payload.metadata) ? payload.metadata : null;
|
||||
}
|
||||
if (isPlainRecord(payload.permissions)) {
|
||||
patch.permissions = payload.permissions;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
function parseFiniteNumberLike(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value !== "string") return null;
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function normalizeRuntimeConfigForNewAgent(runtimeConfig: unknown): Record<string, unknown> {
|
||||
const normalizedRuntimeConfig = isPlainRecord(runtimeConfig) ? { ...runtimeConfig } : {};
|
||||
const heartbeat = isPlainRecord(normalizedRuntimeConfig.heartbeat)
|
||||
? { ...normalizedRuntimeConfig.heartbeat }
|
||||
: {};
|
||||
if (parseFiniteNumberLike(heartbeat.maxConcurrentRuns) == null) {
|
||||
heartbeat.maxConcurrentRuns = AGENT_DEFAULT_MAX_CONCURRENT_RUNS;
|
||||
}
|
||||
normalizedRuntimeConfig.heartbeat = heartbeat;
|
||||
return normalizedRuntimeConfig;
|
||||
}
|
||||
|
||||
export function diffConfigSnapshot(
|
||||
before: AgentConfigSnapshot,
|
||||
after: AgentConfigSnapshot,
|
||||
): string[] {
|
||||
return CONFIG_REVISION_FIELDS.filter((field) => !jsonEqual(before[field], after[field]));
|
||||
}
|
||||
|
||||
export function configPatchFromSnapshot(snapshot: unknown): Partial<typeof agents.$inferInsert> {
|
||||
if (!isPlainRecord(snapshot)) throw unprocessable("Invalid revision snapshot");
|
||||
|
||||
if (typeof snapshot.name !== "string" || snapshot.name.length === 0) {
|
||||
throw unprocessable("Invalid revision snapshot: name");
|
||||
}
|
||||
if (typeof snapshot.role !== "string" || snapshot.role.length === 0) {
|
||||
throw unprocessable("Invalid revision snapshot: role");
|
||||
}
|
||||
if (typeof snapshot.adapterType !== "string" || snapshot.adapterType.length === 0) {
|
||||
throw unprocessable("Invalid revision snapshot: adapterType");
|
||||
}
|
||||
if (typeof snapshot.budgetMonthlyCents !== "number" || !Number.isFinite(snapshot.budgetMonthlyCents)) {
|
||||
throw unprocessable("Invalid revision snapshot: budgetMonthlyCents");
|
||||
}
|
||||
const runtimeConfig = agentRuntimeConfigSchema.safeParse(
|
||||
isPlainRecord(snapshot.runtimeConfig) ? snapshot.runtimeConfig : {},
|
||||
);
|
||||
if (!runtimeConfig.success) {
|
||||
throw unprocessable("Invalid revision snapshot: runtimeConfig");
|
||||
}
|
||||
|
||||
return {
|
||||
name: snapshot.name,
|
||||
role: snapshot.role,
|
||||
title: typeof snapshot.title === "string" || snapshot.title === null ? snapshot.title : null,
|
||||
reportsTo:
|
||||
typeof snapshot.reportsTo === "string" || snapshot.reportsTo === null ? snapshot.reportsTo : null,
|
||||
capabilities:
|
||||
typeof snapshot.capabilities === "string" || snapshot.capabilities === null
|
||||
? snapshot.capabilities
|
||||
: null,
|
||||
adapterType: snapshot.adapterType,
|
||||
adapterConfig: isPlainRecord(snapshot.adapterConfig) ? snapshot.adapterConfig : {},
|
||||
runtimeConfig: runtimeConfig.data,
|
||||
defaultEnvironmentId:
|
||||
typeof snapshot.defaultEnvironmentId === "string" || snapshot.defaultEnvironmentId === null
|
||||
? snapshot.defaultEnvironmentId
|
||||
: null,
|
||||
budgetMonthlyCents: Math.max(0, Math.floor(snapshot.budgetMonthlyCents)),
|
||||
metadata: isPlainRecord(snapshot.metadata) || snapshot.metadata === null ? snapshot.metadata : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasAgentShortnameCollision(
|
||||
candidateName: string,
|
||||
existingAgents: AgentShortnameRow[],
|
||||
options?: AgentShortnameCollisionOptions,
|
||||
): boolean {
|
||||
const candidateShortname = normalizeAgentUrlKey(candidateName);
|
||||
if (!candidateShortname) return false;
|
||||
|
||||
return existingAgents.some((agent) => {
|
||||
if (agent.status === "terminated") return false;
|
||||
if (options?.excludeAgentId && agent.id === options.excludeAgentId) return false;
|
||||
return normalizeAgentUrlKey(agent.name) === candidateShortname;
|
||||
});
|
||||
}
|
||||
|
||||
export function deduplicateAgentName(
|
||||
candidateName: string,
|
||||
existingAgents: AgentShortnameRow[],
|
||||
): string {
|
||||
if (!hasAgentShortnameCollision(candidateName, existingAgents)) {
|
||||
return candidateName;
|
||||
}
|
||||
for (let i = 2; i <= 100; i++) {
|
||||
const suffixed = `${candidateName} ${i}`;
|
||||
if (!hasAgentShortnameCollision(suffixed, existingAgents)) {
|
||||
return suffixed;
|
||||
}
|
||||
}
|
||||
return `${candidateName} ${Date.now()}`;
|
||||
}
|
||||
|
||||
export function agentRecordQueries(db: Db) {
|
||||
function currentUtcMonthWindow(now = new Date()) {
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth();
|
||||
return {
|
||||
start: new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)),
|
||||
end: new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function withUrlKey<T extends { id: string; name: string }>(row: T) {
|
||||
return {
|
||||
...row,
|
||||
urlKey: normalizeAgentUrlKey(row.name) ?? row.id,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgentBaseRow(row: typeof agents.$inferSelect) {
|
||||
const { lifecycleOperation, lifecycleRequiredPluginIds, ...publicRow } = row;
|
||||
return withUrlKey({
|
||||
...publicRow,
|
||||
permissions: normalizeAgentPermissions(row.permissions),
|
||||
});
|
||||
}
|
||||
|
||||
function toEligibilityAgent(row: Pick<typeof agents.$inferSelect, "id" | "companyId" | "name" | "status" | "reportsTo">): AgentEligibilityAgent {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
name: row.name,
|
||||
status: row.status,
|
||||
reportsTo: row.reportsTo,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgentRows(rows: (typeof agents.$inferSelect)[], allCompanyRows = rows) {
|
||||
const eligibilityAgents = allCompanyRows.map(toEligibilityAgent);
|
||||
return rows.map((row) => {
|
||||
const base = normalizeAgentBaseRow(row);
|
||||
const appearance = resolveAgentAppearance(row.appearance, row.id);
|
||||
return {
|
||||
...base,
|
||||
appearance,
|
||||
avatarUrl: agentAvatarUrl(appearance),
|
||||
orgChainHealth: getAgentWorkEligibility({
|
||||
agent: toEligibilityAgent(row),
|
||||
agents: eligibilityAgents,
|
||||
}).orgChainHealth,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAgentRow(row: typeof agents.$inferSelect, allCompanyRows?: (typeof agents.$inferSelect)[]) {
|
||||
return normalizeAgentRows([row], allCompanyRows)[0]!;
|
||||
}
|
||||
|
||||
async function listCompanyAgentRows(companyId: string) {
|
||||
return db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
}
|
||||
|
||||
async function getMonthlySpendByAgentIds(companyId: string, agentIds: string[]) {
|
||||
if (agentIds.length === 0) return new Map<string, number>();
|
||||
const { start, end } = currentUtcMonthWindow();
|
||||
const rows = await db
|
||||
.select({
|
||||
agentId: costEvents.agentId,
|
||||
spentMonthlyCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(costEvents.companyId, companyId),
|
||||
inArray(costEvents.agentId, agentIds),
|
||||
gte(costEvents.occurredAt, start),
|
||||
lt(costEvents.occurredAt, end),
|
||||
),
|
||||
)
|
||||
.groupBy(costEvents.agentId);
|
||||
return new Map(rows.map((row) => [row.agentId, Number(row.spentMonthlyCents ?? 0)]));
|
||||
}
|
||||
|
||||
async function hydrateAgentSpend<T extends { id: string; companyId: string; spentMonthlyCents: number }>(rows: T[]) {
|
||||
const agentIds = rows.map((row) => row.id);
|
||||
const companyId = rows[0]?.companyId;
|
||||
if (!companyId || agentIds.length === 0) return rows;
|
||||
const spendByAgentId = await getMonthlySpendByAgentIds(companyId, agentIds);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
spentMonthlyCents: spendByAgentId.get(row.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async function getById(id: string) {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
const [companyRows, hydrated] = await Promise.all([
|
||||
listCompanyAgentRows(row.companyId),
|
||||
hydrateAgentSpend([row]).then((rows) => rows[0]!),
|
||||
]);
|
||||
return normalizeAgentRow(hydrated, companyRows);
|
||||
}
|
||||
|
||||
async function ensureManager(companyId: string, managerId: string) {
|
||||
const manager = await getById(managerId);
|
||||
if (!manager) throw notFound("Manager not found");
|
||||
if (manager.companyId !== companyId) {
|
||||
throw unprocessable("Manager must belong to same company");
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
|
||||
async function assertNoCycle(agentId: string, reportsTo: string | null | undefined) {
|
||||
if (!reportsTo) return;
|
||||
if (reportsTo === agentId) throw unprocessable("Agent cannot report to itself");
|
||||
|
||||
let cursor: string | null = reportsTo;
|
||||
while (cursor) {
|
||||
if (cursor === agentId) throw unprocessable("Reporting relationship would create cycle");
|
||||
const next = await getById(cursor);
|
||||
cursor = next?.reportsTo ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCompanyShortnameAvailable(
|
||||
companyId: string,
|
||||
candidateName: string,
|
||||
options?: AgentShortnameCollisionOptions,
|
||||
) {
|
||||
const candidateShortname = normalizeAgentUrlKey(candidateName);
|
||||
if (!candidateShortname) return;
|
||||
|
||||
const existingAgents = await db
|
||||
.select({
|
||||
id: agents.id,
|
||||
name: agents.name,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId));
|
||||
|
||||
const hasCollision = hasAgentShortnameCollision(candidateName, existingAgents, options);
|
||||
if (hasCollision) {
|
||||
throw conflict(
|
||||
`Agent shortname '${candidateShortname}' is already in use in this company`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertBuiltInAgentMetadataMutationAllowed(
|
||||
beforeMetadata: unknown,
|
||||
afterMetadata: unknown,
|
||||
options?: { allowBuiltInAgentMetadata?: boolean },
|
||||
) {
|
||||
if (options?.allowBuiltInAgentMetadata) return;
|
||||
const beforeMarker = readBuiltInAgentMarker(beforeMetadata);
|
||||
const afterMarker = readBuiltInAgentMarker(afterMetadata);
|
||||
if (builtInAgentMarkersEqual(beforeMarker, afterMarker)) return;
|
||||
throw conflict("Built-in agent marker is managed by Paperclip and cannot be edited directly", {
|
||||
code: "built_in_agent_marker_readonly",
|
||||
key: beforeMarker?.key ?? afterMarker?.key ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return { normalizeAgentRows, normalizeAgentRow, listCompanyAgentRows, hydrateAgentSpend,
|
||||
getById, ensureManager, assertNoCycle, assertCompanyShortnameAvailable, assertBuiltInAgentMetadataMutationAllowed };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { approvals, type Db } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
|
||||
export const resolvableApprovalStatuses = ["pending", "revision_requested"];
|
||||
|
||||
export function approvalResolutionRecords(db: Db) {
|
||||
const canResolveStatuses = new Set(resolvableApprovalStatuses);
|
||||
const resolvableStatuses = resolvableApprovalStatuses;
|
||||
type ApprovalRecord = typeof approvals.$inferSelect;
|
||||
type ResolutionResult = { approval: ApprovalRecord; applied: boolean };
|
||||
async function getExistingApproval(id: string, database: Db = db) {
|
||||
const existing = await database
|
||||
.select()
|
||||
.from(approvals)
|
||||
.where(eq(approvals.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) throw notFound("Approval not found");
|
||||
return existing;
|
||||
}
|
||||
|
||||
async function resolveApproval(
|
||||
id: string,
|
||||
targetStatus: "approved" | "rejected",
|
||||
decidedByUserId: string,
|
||||
decisionNote: string | null | undefined,
|
||||
database: Db = db,
|
||||
): Promise<ResolutionResult> {
|
||||
const existing = await getExistingApproval(id, database);
|
||||
if (!canResolveStatuses.has(existing.status)) {
|
||||
if (existing.status === targetStatus) {
|
||||
return { approval: existing, applied: false };
|
||||
}
|
||||
throw unprocessable(
|
||||
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = await database
|
||||
.update(approvals)
|
||||
.set({
|
||||
status: targetStatus,
|
||||
decidedByUserId,
|
||||
decisionNote: decisionNote ?? null,
|
||||
decidedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(approvals.id, id), inArray(approvals.status, resolvableStatuses)))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (updated) {
|
||||
return { approval: updated, applied: true };
|
||||
}
|
||||
|
||||
const latest = await getExistingApproval(id, database);
|
||||
if (latest.status === targetStatus) {
|
||||
return { approval: latest, applied: false };
|
||||
}
|
||||
|
||||
throw unprocessable(
|
||||
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { getExistingApproval, resolveApproval };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export const BUILT_IN_AGENT_METADATA_KEY = "paperclipBuiltInAgent";
|
||||
|
||||
export interface BuiltInAgentMarker {
|
||||
key: string;
|
||||
featureKeys: string[];
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeFeatureKeys(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const featureKeys = value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
|
||||
return featureKeys.length === value.length ? featureKeys : null;
|
||||
}
|
||||
|
||||
export function readBuiltInAgentMarker(metadata: unknown): BuiltInAgentMarker | null {
|
||||
if (!isPlainRecord(metadata)) return null;
|
||||
const marker = metadata[BUILT_IN_AGENT_METADATA_KEY];
|
||||
if (!isPlainRecord(marker)) return null;
|
||||
const key = marker.key;
|
||||
const featureKeys = normalizeFeatureKeys(marker.featureKeys);
|
||||
if (typeof key !== "string" || key.trim().length === 0 || !featureKeys) return null;
|
||||
return { key, featureKeys };
|
||||
}
|
||||
|
||||
export function withBuiltInAgentMarker(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
marker: BuiltInAgentMarker,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...(metadata ?? {}),
|
||||
[BUILT_IN_AGENT_METADATA_KEY]: {
|
||||
key: marker.key,
|
||||
featureKeys: [...marker.featureKeys],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function builtInAgentMarkersEqual(left: BuiltInAgentMarker | null, right: BuiltInAgentMarker | null) {
|
||||
if (!left && !right) return true;
|
||||
if (!left || !right) return false;
|
||||
return left.key === right.key && JSON.stringify(left.featureKeys) === JSON.stringify(right.featureKeys);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
type CompanyDeletionTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
export interface CompanyDeletionParticipant {
|
||||
// The caller holds the company lock and removes dependent records first.
|
||||
// Use this transaction only. Throw on failure. Do not call external systems.
|
||||
deleteCompanyData(tx: CompanyDeletionTransaction, companyId: string): Promise<void>;
|
||||
}
|
||||
Reference in new issue
Block a user