Files
PaperClipAI/server/src/services/agents.ts
T
ad961227f5 feat(secrets): add user-specific runtime secrets (#8825)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent runs often need provider credentials, API tokens, and other
environment-bound secrets.
> - Company-level secrets work for shared credentials, but they do not
model values that should differ by human operator.
> - Without a user-scoped model, a run can dispatch without knowing
whether the responsible human has supplied the needed value.
> - Paperclip also needs run attribution to make those user-scoped
runtime checks deterministic and auditable.
> - This pull request adds user-specific secret definitions, per-user
values, environment bindings, responsible-user attribution, and runtime
resolution gates.
> - The benefit is that teams can define the secret once, let each user
provide their own value, and block runs before dispatch when required
user secrets or active definitions are unavailable.

## Linked Issues or Issue Description

Refs #224
Refs #6057

This PR implements user-specific secret support as a core
secret-management capability rather than a one-off adapter setting. It
is related to existing public work on company secrets UI and runtime
secret refs, but is distinct because the value is owned by the
responsible user and resolved at run dispatch time.

Related PR search before opening found existing secrets work such as
#1550, #8256, #8614, #8634, and #8647; none of those add the full
user-secret definition/value/runtime gate covered here.

## What Changed

- Added user-secret definitions and per-user "My secrets" values,
keeping stored values out of access metadata.
- Added `user_secret_ref` environment bindings and UI affordances to
pick them alongside existing secret refs.
- Added responsible-user runtime resolution so user-secret refs resolve
against the human responsible for the run.
- Added pre-dispatch missing-secret gates so runs fail before adapter
dispatch when required user values are absent or definitions are
inactive.
- Added low-trust allowlist hardening for user-secret runtime access.
- Added issue, routine, run, and agent API key responsible-user
attribution and fail-closed dispatch behavior when attribution cannot be
resolved.
- Added denial-copy mapping so responsible-user authorization failures
surface as actionable run outcomes instead of opaque setup failures.
- Added OpenAPI documentation for the user-secret routes.
- Rebases cleanly on current `master`; migrations were renumbered
incrementally as `0128_user_specific_secrets`,
`0129_agent_api_key_responsible_user`, and
`0130_run_responsible_user_invariant` after upstream `0126`/`0127`
migrations.
- Removed previously committed local design screenshots so the PR
contains code/docs/tests only.

## Verification

- PASS: PR head `2527febd106bcf3ca264ca0da7fca491084192d6` is based on
`paperclipai/paperclip:master`.
- PASS: `git diff --check`
- PASS: `git diff --name-only public/master...HEAD | rg
'^(pnpm-lock\\.yaml|\\.github/workflows/|screenshots/)' || true`
produced no files.
- PASS: migration journal audit confirmed unique indexes through `130`
with tail entries `0126_issue_comment_derived_attribution`,
`0127_environment_custom_images_instance_scoped`,
`0128_user_specific_secrets`, `0129_agent_api_key_responsible_user`, and
`0130_run_responsible_user_invariant`.
- PASS: `pnpm --filter @paperclipai/ui typecheck`
- PASS: `pnpm --filter @paperclipai/server typecheck`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-responsible-user-invariant.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-active-run-output-watchdog.test.ts
src/__tests__/heartbeat-stale-queue-invalidation.test.ts
src/__tests__/heartbeat-workspace-finalize-branch.test.ts
src/__tests__/issue-monitor-scheduler.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-comment-wake-batching.test.ts
src/__tests__/heartbeat-retry-scheduling.test.ts
src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts
src/__tests__/heartbeat-plugin-environment.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/low-trust-red-team-routes.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/secrets-service.test.ts` (55 tests)
- PASS: `pnpm vitest run server/src/__tests__/secrets-routes.test.ts
server/src/__tests__/secrets-service.test.ts` (89 tests after final
Greptile cleanup fixes)
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-issue-liveness-escalation.test.ts` (17 tests
after the final rebase CI fix)
- PASS: focused server Vitest batches covering heartbeat recovery,
project env, plugin env, routines, low-trust, pipelines, monitors,
watchdog, and stale queue paths.
- PASS: GitHub checks are green on
`2527febd106bcf3ca264ca0da7fca491084192d6`, including Typecheck +
Release Registry, Build, General tests, serialized server suites, e2e,
Canary Dry Run, verify, security checks, and Greptile Review.
- PASS: Greptile Review completed successfully on
`2527febd106bcf3ca264ca0da7fca491084192d6` with Confidence Score 5/5,
and GraphQL review-thread audit returned zero unresolved non-outdated
threads.

## Risks

- Runtime behavior now depends on a run having a correct responsible
user; missing or incorrect responsibility assignment can block runs
before adapter dispatch.
- `user_secret_ref` bindings intentionally expose metadata without
values, but UI/API callers may need to handle the new binding kind
explicitly.
- External secret providers and IAM policies are not automatically
provisioned by this PR; operators still need to configure provider-side
access for non-local vaults.
- The PR is broad across db/shared/server/UI/runtime paths, so release
validation should include both API and UI secret workflows before merge.
- The migration renumbering is intentionally incremental after upstream
migrations; the branch migrations use guarded
column/table/index/constraint creation so users who tested the older
draft numbering should not hit duplicate DDL for the existing objects.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5-based coding agent (`gpt-5`), Codex local adapter
with shell/tool use and code execution. Context window and internal
reasoning mode are not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 05:58:20 -05:00

931 lines
32 KiB
TypeScript

import { createHash, randomBytes } from "node:crypto";
import { and, desc, eq, gte, inArray, lt, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
agentConfigRevisions,
agentApiKeys,
agentRuntimeState,
agentTaskSessions,
agentWakeupRequests,
activityLog,
costEvents,
heartbeatRunEvents,
heartbeatRuns,
issueExecutionDecisions,
issues,
issueComments,
} from "@paperclipai/db";
import {
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
getAgentWorkEligibility,
isUuidLike,
normalizeAgentApiKeyScope,
normalizeAgentUrlKey,
type AgentEligibilityAgent,
type AgentApiKeyScope,
} from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js";
import { normalizeAgentPermissions } from "./agent-permissions.js";
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
import { secretService } from "./secrets.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",
"defaultEnvironmentId",
"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,
defaultEnvironmentId: row.defaultEnvironmentId,
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 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;
}
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;
}
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 : {},
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 agentService(db: Db) {
const secretsSvc = secretService(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) {
return withUrlKey({
...row,
permissions: normalizeAgentPermissions(row.permissions, row.role),
});
}
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);
return {
...base,
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 requireGetById(id: string) {
const agent = await getById(id);
if (!agent) throw notFound("Agent not found");
return agent;
}
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 syncAgentSecretBindings(
agent: { id: string; companyId: string; adapterConfig: unknown },
dbClient: Db = db,
) {
const scopedSecretsSvc = dbClient === db ? secretsSvc : secretService(dbClient);
await syncAgentAdapterEnvBindings({
secretsSvc: scopedSecretsSvc,
companyId: agent.companyId,
agentId: agent.id,
adapterConfig: agent.adapterConfig,
});
}
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);
}
if (
Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig") &&
isPlainRecord(normalizedPatch.adapterConfig)
) {
normalizedPatch.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
normalizedPatch.adapterConfig,
{ adapterType: (normalizedPatch.adapterType ?? existing.adapterType) as string },
);
}
const shouldRecordRevision = Boolean(options?.recordRevision) && hasConfigPatchFields(normalizedPatch);
const beforeConfig = shouldRecordRevision ? buildConfigSnapshot(existing) : null;
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const updated = await tx
.update(agents)
.set({ ...normalizedPatch, updatedAt: new Date() })
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) return null;
if (Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig")) {
await syncAgentSecretBindings(updated, txDb);
}
const normalizedUpdated = await agentService(txDb).getById(updated.id);
if (!normalizedUpdated) {
throw notFound("Agent not found");
}
if (shouldRecordRevision && beforeConfig) {
const afterConfig = buildConfigSnapshot(normalizedUpdated);
const changedKeys = diffConfigSnapshot(beforeConfig, afterConfig);
if (changedKeys.length > 0) {
await tx.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, allCompanyRows] = await Promise.all([
db.select().from(agents).where(and(...conditions)),
listCompanyAgentRows(companyId),
]);
const hydrated = await hydrateAgentSpend(rows);
return normalizeAgentRows(hydrated, allCompanyRows);
},
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 runtimeConfig = normalizeRuntimeConfigForNewAgent(data.runtimeConfig);
const adapterType = data.adapterType ?? "process";
const adapterConfig = isPlainRecord(data.adapterConfig)
? await secretsSvc.normalizeAdapterConfigForPersistence(companyId, data.adapterConfig, { adapterType })
: {};
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const created = await tx
.insert(agents)
.values({
...data,
name: uniqueName,
companyId,
role,
adapterType,
adapterConfig,
permissions: normalizedPermissions,
runtimeConfig,
})
.returning()
.then((rows) => rows[0]);
await syncAgentSecretBindings(created, txDb);
const normalizedCreated = await agentService(txDb).getById(created.id);
if (!normalizedCreated) {
throw notFound("Agent not found");
}
return normalizedCreated;
});
},
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(),
errorReason: null,
updatedAt: new Date(),
})
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return updated ? getById(updated.id) : 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,
errorReason: null,
updatedAt: new Date(),
})
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return updated ? getById(updated.id) : null;
},
clearError: async (id: string) => {
const existing = await getById(id);
if (!existing) return null;
if (existing.status === "terminated") throw conflict("Cannot clear error on terminated agent");
if (existing.status === "pending_approval") {
throw conflict("Pending approval agents cannot have errors cleared");
}
if (existing.status !== "error") {
throw conflict("Only agents in error status can have their error cleared");
}
const updated = await db
.update(agents)
.set({
status: "idle",
pauseReason: null,
pausedAt: null,
errorReason: null,
updatedAt: new Date(),
})
.where(and(eq(agents.id, id), eq(agents.status, "error")))
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) {
throw conflict("Only agents in error status can have their error cleared");
}
return getById(updated.id);
},
terminate: async (id: string) => {
const existing = await getById(id);
if (!existing) return null;
await db
.update(agents)
.set({
status: "terminated",
pauseReason: null,
pausedAt: null,
errorReason: 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
.update(issues)
.set({ assigneeAgentId: null, createdByAgentId: null })
.where(or(eq(issues.assigneeAgentId, id), eq(issues.createdByAgentId, id)));
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.agentId, id));
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.agentId, id));
await tx.delete(activityLog).where(
or(
eq(activityLog.agentId, id),
sql`${activityLog.runId} in (select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.agentId} = ${id})`,
),
);
await tx.delete(issueExecutionDecisions).where(eq(issueExecutionDecisions.actorAgentId, id));
await tx.delete(issueComments).where(eq(issueComments.authorAgentId, 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 activatedAgent = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const updated = await tx
.update(agents)
.set({ status: "idle", updatedAt: new Date() })
.where(and(eq(agents.id, id), eq(agents.status, "pending_approval")))
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) return null;
await syncAgentSecretBindings(updated, txDb);
const agent = await agentService(txDb).getById(updated.id);
if (!agent) {
throw notFound("Agent not found");
}
return agent;
});
if (activatedAgent) {
return { agent: activatedAgent, activated: true };
}
const existing = await getById(id);
return existing ? { agent: existing, activated: false } : null;
},
updatePermissions: async (id: string, permissions: Record<string, unknown> & { canCreateAgents: boolean }) => {
const existing = await getById(id);
if (!existing) return null;
const updated = await db
.update(agents)
.set({
permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }, existing.role),
updatedAt: new Date(),
})
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return updated ? getById(updated.id) : 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,
scope: AgentApiKeyScope = { kind: "standard" },
options?: { responsibleUserId?: string | null },
) => {
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,
responsibleUserId: options?.responsibleUserId?.trim() || null,
scopeConfig: scope.kind === "standard" ? null : scope,
})
.returning()
.then((rows) => rows[0]);
return {
id: created.id,
name: created.name,
scope: normalizeAgentApiKeyScope(created.scopeConfig),
responsibleUserId: created.responsibleUserId,
token,
createdAt: created.createdAt,
};
},
listKeys: (id: string) =>
db
.select({
id: agentApiKeys.id,
name: agentApiKeys.name,
responsibleUserId: agentApiKeys.responsibleUserId,
scopeConfig: agentApiKeys.scopeConfig,
createdAt: agentApiKeys.createdAt,
revokedAt: agentApiKeys.revokedAt,
})
.from(agentApiKeys)
.where(eq(agentApiKeys.agentId, id))
.then((rows) => rows.map((row) => ({
id: row.id,
name: row.name,
scope: normalizeAgentApiKeyScope(row.scopeConfig),
responsibleUserId: row.responsibleUserId,
createdAt: row.createdAt,
revokedAt: row.revokedAt,
}))),
getKeyById: async (keyId: string) =>
db
.select({
id: agentApiKeys.id,
agentId: agentApiKeys.agentId,
companyId: agentApiKeys.companyId,
name: agentApiKeys.name,
responsibleUserId: agentApiKeys.responsibleUserId,
scopeConfig: agentApiKeys.scopeConfig,
createdAt: agentApiKeys.createdAt,
revokedAt: agentApiKeys.revokedAt,
})
.from(agentApiKeys)
.where(eq(agentApiKeys.id, keyId))
.then((rows) => {
const row = rows[0] ?? null;
return row
? {
...row,
scope: normalizeAgentApiKeyScope(row.scopeConfig),
}
: null;
}),
revokeKey: async (agentId: string, keyId: string) => {
const rows = await db
.update(agentApiKeys)
.set({ revokedAt: new Date() })
.where(and(eq(agentApiKeys.id, keyId), eq(agentApiKeys.agentId, agentId)))
.returning();
return rows[0] ?? null;
},
orgForCompany: async (companyId: string) => {
const allCompanyRows = await listCompanyAgentRows(companyId);
const rows = allCompanyRows.filter((row) => row.status !== "terminated");
const normalizedRows = normalizeAgentRows(rows, allCompanyRows);
const byManager = new Map<string | null, typeof normalizedRows>();
for (const row of normalizedRows) {
const key = row.reportsTo && rows.some((candidate) => candidate.id === row.reportsTo) ? 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 = normalizeAgentRows(rows, rows)
.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;
},
};
}