Spaces:
Paused
Paused
| import { createHash, randomBytes } from "node:crypto"; | |
| import { and, desc, eq, gte, inArray, lt, ne, sql } from "drizzle-orm"; | |
| import type { Db } from "@paperclipai/db"; | |
| import { | |
| agents, | |
| agentConfigRevisions, | |
| agentApiKeys, | |
| agentRuntimeState, | |
| agentTaskSessions, | |
| agentWakeupRequests, | |
| costEvents, | |
| heartbeatRunEvents, | |
| heartbeatRuns, | |
| } from "@paperclipai/db"; | |
| import { isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared"; | |
| import { conflict, notFound, unprocessable } from "../errors.js"; | |
| import { normalizeAgentPermissions } from "./agent-permissions.js"; | |
| import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js"; | |
| function hashToken(token: string) { | |
| return createHash("sha256").update(token).digest("hex"); | |
| } | |
| function createToken() { | |
| return `pcp_${randomBytes(24).toString("hex")}`; | |
| } | |
| const CONFIG_REVISION_FIELDS = [ | |
| "name", | |
| "role", | |
| "title", | |
| "reportsTo", | |
| "capabilities", | |
| "adapterType", | |
| "adapterConfig", | |
| "runtimeConfig", | |
| "budgetMonthlyCents", | |
| "metadata", | |
| ] as const; | |
| type ConfigRevisionField = (typeof CONFIG_REVISION_FIELDS)[number]; | |
| type AgentConfigSnapshot = Pick<typeof agents.$inferSelect, ConfigRevisionField>; | |
| interface RevisionMetadata { | |
| createdByAgentId?: string | null; | |
| createdByUserId?: string | null; | |
| source?: string; | |
| rolledBackFromRevisionId?: string | null; | |
| } | |
| interface UpdateAgentOptions { | |
| recordRevision?: RevisionMetadata; | |
| } | |
| interface AgentShortnameRow { | |
| id: string; | |
| name: string; | |
| status: string; | |
| } | |
| interface AgentShortnameCollisionOptions { | |
| excludeAgentId?: string | null; | |
| } | |
| function isPlainRecord(value: unknown): value is Record<string, unknown> { | |
| return typeof value === "object" && value !== null && !Array.isArray(value); | |
| } | |
| function jsonEqual(left: unknown, right: unknown): boolean { | |
| return JSON.stringify(left) === JSON.stringify(right); | |
| } | |
| 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, | |
| reportsTo: row.reportsTo, | |
| capabilities: row.capabilities, | |
| adapterType: row.adapterType, | |
| adapterConfig, | |
| runtimeConfig, | |
| budgetMonthlyCents: row.budgetMonthlyCents, | |
| metadata, | |
| }; | |
| } | |
| 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)); | |
| } | |
| function hasConfigPatchFields(data: Partial<typeof agents.$inferInsert>) { | |
| return CONFIG_REVISION_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(data, field)); | |
| } | |
| function diffConfigSnapshot( | |
| before: AgentConfigSnapshot, | |
| after: AgentConfigSnapshot, | |
| ): string[] { | |
| return CONFIG_REVISION_FIELDS.filter((field) => !jsonEqual(before[field], after[field])); | |
| } | |
| 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"); | |
| } | |
| 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: isPlainRecord(snapshot.runtimeConfig) ? snapshot.runtimeConfig : {}, | |
| 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 agentService(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 normalizeAgentRow(row: typeof agents.$inferSelect) { | |
| return withUrlKey({ | |
| ...row, | |
| permissions: normalizeAgentPermissions(row.permissions, row.role), | |
| }); | |
| } | |
| 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)::int`, | |
| }) | |
| .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 [hydrated] = await hydrateAgentSpend([row]); | |
| return normalizeAgentRow(hydrated); | |
| } | |
| 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`, | |
| ); | |
| } | |
| } | |
| async function updateAgent( | |
| id: string, | |
| data: Partial<typeof agents.$inferInsert>, | |
| options?: UpdateAgentOptions, | |
| ) { | |
| const existing = await getById(id); | |
| if (!existing) return null; | |
| if (existing.status === "terminated" && data.status && data.status !== "terminated") { | |
| throw conflict("Terminated agents cannot be resumed"); | |
| } | |
| if ( | |
| existing.status === "pending_approval" && | |
| data.status && | |
| data.status !== "pending_approval" && | |
| data.status !== "terminated" | |
| ) { | |
| throw conflict("Pending approval agents cannot be activated directly"); | |
| } | |
| if (data.reportsTo !== undefined) { | |
| if (data.reportsTo) { | |
| await ensureManager(existing.companyId, data.reportsTo); | |
| } | |
| await assertNoCycle(id, data.reportsTo); | |
| } | |
| if (data.name !== undefined) { | |
| const previousShortname = normalizeAgentUrlKey(existing.name); | |
| const nextShortname = normalizeAgentUrlKey(data.name); | |
| if (previousShortname !== nextShortname) { | |
| await assertCompanyShortnameAvailable(existing.companyId, data.name, { excludeAgentId: id }); | |
| } | |
| } | |
| const normalizedPatch = { ...data } as Partial<typeof agents.$inferInsert>; | |
| if (data.permissions !== undefined) { | |
| const role = (data.role ?? existing.role) as string; | |
| normalizedPatch.permissions = normalizeAgentPermissions(data.permissions, role); | |
| } | |
| const shouldRecordRevision = Boolean(options?.recordRevision) && hasConfigPatchFields(normalizedPatch); | |
| const beforeConfig = shouldRecordRevision ? buildConfigSnapshot(existing) : null; | |
| const updated = await db | |
| .update(agents) | |
| .set({ ...normalizedPatch, updatedAt: new Date() }) | |
| .where(eq(agents.id, id)) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| const normalizedUpdated = updated ? normalizeAgentRow(updated) : null; | |
| if (normalizedUpdated && shouldRecordRevision && beforeConfig) { | |
| const afterConfig = buildConfigSnapshot(normalizedUpdated); | |
| const changedKeys = diffConfigSnapshot(beforeConfig, afterConfig); | |
| if (changedKeys.length > 0) { | |
| await db.insert(agentConfigRevisions).values({ | |
| companyId: normalizedUpdated.companyId, | |
| agentId: normalizedUpdated.id, | |
| createdByAgentId: options?.recordRevision?.createdByAgentId ?? null, | |
| createdByUserId: options?.recordRevision?.createdByUserId ?? null, | |
| source: options?.recordRevision?.source ?? "patch", | |
| rolledBackFromRevisionId: options?.recordRevision?.rolledBackFromRevisionId ?? null, | |
| changedKeys, | |
| beforeConfig: beforeConfig as unknown as Record<string, unknown>, | |
| afterConfig: afterConfig as unknown as Record<string, unknown>, | |
| }); | |
| } | |
| } | |
| return normalizedUpdated; | |
| } | |
| return { | |
| list: async (companyId: string, options?: { includeTerminated?: boolean }) => { | |
| const conditions = [eq(agents.companyId, companyId)]; | |
| if (!options?.includeTerminated) { | |
| conditions.push(ne(agents.status, "terminated")); | |
| } | |
| const rows = await db.select().from(agents).where(and(...conditions)); | |
| const hydrated = await hydrateAgentSpend(rows); | |
| return hydrated.map(normalizeAgentRow); | |
| }, | |
| getById, | |
| create: async (companyId: string, data: Omit<typeof agents.$inferInsert, "companyId">) => { | |
| if (data.reportsTo) { | |
| await ensureManager(companyId, data.reportsTo); | |
| } | |
| const existingAgents = await db | |
| .select({ id: agents.id, name: agents.name, status: agents.status }) | |
| .from(agents) | |
| .where(eq(agents.companyId, companyId)); | |
| const uniqueName = deduplicateAgentName(data.name, existingAgents); | |
| const role = data.role ?? "general"; | |
| const normalizedPermissions = normalizeAgentPermissions(data.permissions, role); | |
| const created = await db | |
| .insert(agents) | |
| .values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions }) | |
| .returning() | |
| .then((rows) => rows[0]); | |
| return normalizeAgentRow(created); | |
| }, | |
| update: updateAgent, | |
| pause: async (id: string, reason: "manual" | "budget" | "system" = "manual") => { | |
| const existing = await getById(id); | |
| if (!existing) return null; | |
| if (existing.status === "terminated") throw conflict("Cannot pause terminated agent"); | |
| const updated = await db | |
| .update(agents) | |
| .set({ | |
| status: "paused", | |
| pauseReason: reason, | |
| pausedAt: new Date(), | |
| updatedAt: new Date(), | |
| }) | |
| .where(eq(agents.id, id)) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| return updated ? normalizeAgentRow(updated) : null; | |
| }, | |
| resume: async (id: string) => { | |
| const existing = await getById(id); | |
| if (!existing) return null; | |
| if (existing.status === "terminated") throw conflict("Cannot resume terminated agent"); | |
| if (existing.status === "pending_approval") { | |
| throw conflict("Pending approval agents cannot be resumed"); | |
| } | |
| const updated = await db | |
| .update(agents) | |
| .set({ | |
| status: "idle", | |
| pauseReason: null, | |
| pausedAt: null, | |
| updatedAt: new Date(), | |
| }) | |
| .where(eq(agents.id, id)) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| return updated ? normalizeAgentRow(updated) : null; | |
| }, | |
| terminate: async (id: string) => { | |
| const existing = await getById(id); | |
| if (!existing) return null; | |
| await db | |
| .update(agents) | |
| .set({ | |
| status: "terminated", | |
| pauseReason: null, | |
| pausedAt: null, | |
| updatedAt: new Date(), | |
| }) | |
| .where(eq(agents.id, id)); | |
| await db | |
| .update(agentApiKeys) | |
| .set({ revokedAt: new Date() }) | |
| .where(eq(agentApiKeys.agentId, id)); | |
| return getById(id); | |
| }, | |
| remove: async (id: string) => { | |
| const existing = await getById(id); | |
| if (!existing) return null; | |
| return db.transaction(async (tx) => { | |
| await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id)); | |
| await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.agentId, id)); | |
| await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.agentId, id)); | |
| await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, id)); | |
| await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id)); | |
| await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id)); | |
| await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.agentId, id)); | |
| const deleted = await tx | |
| .delete(agents) | |
| .where(eq(agents.id, id)) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| return deleted ? normalizeAgentRow(deleted) : null; | |
| }); | |
| }, | |
| activatePendingApproval: async (id: string) => { | |
| const existing = await getById(id); | |
| if (!existing) return null; | |
| if (existing.status !== "pending_approval") return existing; | |
| const updated = await db | |
| .update(agents) | |
| .set({ status: "idle", updatedAt: new Date() }) | |
| .where(eq(agents.id, id)) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| return updated ? normalizeAgentRow(updated) : null; | |
| }, | |
| updatePermissions: async (id: string, permissions: { canCreateAgents: boolean }) => { | |
| const existing = await getById(id); | |
| if (!existing) return null; | |
| const updated = await db | |
| .update(agents) | |
| .set({ | |
| permissions: normalizeAgentPermissions(permissions, existing.role), | |
| updatedAt: new Date(), | |
| }) | |
| .where(eq(agents.id, id)) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| return updated ? normalizeAgentRow(updated) : null; | |
| }, | |
| listConfigRevisions: async (id: string) => | |
| db | |
| .select() | |
| .from(agentConfigRevisions) | |
| .where(eq(agentConfigRevisions.agentId, id)) | |
| .orderBy(desc(agentConfigRevisions.createdAt)), | |
| getConfigRevision: async (id: string, revisionId: string) => | |
| db | |
| .select() | |
| .from(agentConfigRevisions) | |
| .where(and(eq(agentConfigRevisions.agentId, id), eq(agentConfigRevisions.id, revisionId))) | |
| .then((rows) => rows[0] ?? null), | |
| rollbackConfigRevision: async ( | |
| id: string, | |
| revisionId: string, | |
| actor: { agentId?: string | null; userId?: string | null }, | |
| ) => { | |
| const revision = await db | |
| .select() | |
| .from(agentConfigRevisions) | |
| .where(and(eq(agentConfigRevisions.agentId, id), eq(agentConfigRevisions.id, revisionId))) | |
| .then((rows) => rows[0] ?? null); | |
| if (!revision) return null; | |
| if (containsRedactedMarker(revision.afterConfig)) { | |
| throw unprocessable("Cannot roll back a revision that contains redacted secret values"); | |
| } | |
| const patch = configPatchFromSnapshot(revision.afterConfig); | |
| return updateAgent(id, patch, { | |
| recordRevision: { | |
| createdByAgentId: actor.agentId ?? null, | |
| createdByUserId: actor.userId ?? null, | |
| source: "rollback", | |
| rolledBackFromRevisionId: revision.id, | |
| }, | |
| }); | |
| }, | |
| createApiKey: async (id: string, name: string) => { | |
| const existing = await getById(id); | |
| if (!existing) throw notFound("Agent not found"); | |
| if (existing.status === "pending_approval") { | |
| throw conflict("Cannot create keys for pending approval agents"); | |
| } | |
| if (existing.status === "terminated") { | |
| throw conflict("Cannot create keys for terminated agents"); | |
| } | |
| const token = createToken(); | |
| const keyHash = hashToken(token); | |
| const created = await db | |
| .insert(agentApiKeys) | |
| .values({ | |
| agentId: id, | |
| companyId: existing.companyId, | |
| name, | |
| keyHash, | |
| }) | |
| .returning() | |
| .then((rows) => rows[0]); | |
| return { | |
| id: created.id, | |
| name: created.name, | |
| token, | |
| createdAt: created.createdAt, | |
| }; | |
| }, | |
| listKeys: (id: string) => | |
| db | |
| .select({ | |
| id: agentApiKeys.id, | |
| name: agentApiKeys.name, | |
| createdAt: agentApiKeys.createdAt, | |
| revokedAt: agentApiKeys.revokedAt, | |
| }) | |
| .from(agentApiKeys) | |
| .where(eq(agentApiKeys.agentId, id)), | |
| revokeKey: async (keyId: string) => { | |
| const rows = await db | |
| .update(agentApiKeys) | |
| .set({ revokedAt: new Date() }) | |
| .where(eq(agentApiKeys.id, keyId)) | |
| .returning(); | |
| return rows[0] ?? null; | |
| }, | |
| orgForCompany: async (companyId: string) => { | |
| const rows = await db | |
| .select() | |
| .from(agents) | |
| .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); | |
| const normalizedRows = rows.map(normalizeAgentRow); | |
| const byManager = new Map<string | null, typeof normalizedRows>(); | |
| for (const row of normalizedRows) { | |
| const key = row.reportsTo ?? null; | |
| const group = byManager.get(key) ?? []; | |
| group.push(row); | |
| byManager.set(key, group); | |
| } | |
| const build = (managerId: string | null): Array<Record<string, unknown>> => { | |
| const members = byManager.get(managerId) ?? []; | |
| return members.map((member) => ({ | |
| ...member, | |
| reports: build(member.id), | |
| })); | |
| }; | |
| return build(null); | |
| }, | |
| getChainOfCommand: async (agentId: string) => { | |
| const chain: { id: string; name: string; role: string; title: string | null }[] = []; | |
| const visited = new Set<string>([agentId]); | |
| const start = await getById(agentId); | |
| let currentId = start?.reportsTo ?? null; | |
| while (currentId && !visited.has(currentId) && chain.length < 50) { | |
| visited.add(currentId); | |
| const mgr = await getById(currentId); | |
| if (!mgr) break; | |
| chain.push({ id: mgr.id, name: mgr.name, role: mgr.role, title: mgr.title ?? null }); | |
| currentId = mgr.reportsTo ?? null; | |
| } | |
| return chain; | |
| }, | |
| runningForAgent: (agentId: string) => | |
| db | |
| .select() | |
| .from(heartbeatRuns) | |
| .where(and(eq(heartbeatRuns.agentId, agentId), inArray(heartbeatRuns.status, ["queued", "running"]))), | |
| resolveByReference: async (companyId: string, reference: string) => { | |
| const raw = reference.trim(); | |
| if (raw.length === 0) { | |
| return { agent: null, ambiguous: false } as const; | |
| } | |
| if (isUuidLike(raw)) { | |
| const byId = await getById(raw); | |
| if (!byId || byId.companyId !== companyId) { | |
| return { agent: null, ambiguous: false } as const; | |
| } | |
| return { agent: byId, ambiguous: false } as const; | |
| } | |
| const urlKey = normalizeAgentUrlKey(raw); | |
| if (!urlKey) { | |
| return { agent: null, ambiguous: false } as const; | |
| } | |
| const rows = await db.select().from(agents).where(eq(agents.companyId, companyId)); | |
| const matches = rows | |
| .map(normalizeAgentRow) | |
| .filter((agent) => agent.urlKey === urlKey && agent.status !== "terminated"); | |
| if (matches.length === 1) { | |
| return { agent: matches[0] ?? null, ambiguous: false } as const; | |
| } | |
| if (matches.length > 1) { | |
| return { agent: null, ambiguous: true } as const; | |
| } | |
| return { agent: null, ambiguous: false } as const; | |
| }, | |
| }; | |
| } | |