mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
Route native call_api through existing agent REST permissions. Preserve admitted credentials across restarts, protect sensitive replies and private snapshots, and compare saved completion with provider history through the same redaction projection. Add permission, privacy and recovery regressions and update the access map and run-log guide. Co-Authored-By: Paperclip <noreply@paperclip.ing>
540 lines
20 KiB
TypeScript
540 lines
20 KiB
TypeScript
import { isRunnerApiResponseAsset } from "./asset-content-access.js";
|
|
import { deleteCompany } from "./company-deletion.js";
|
|
import { createAgentLifecycle } from "./agent-lifecycle.js";
|
|
import { publishAccountingActivities } from "./accounting-transaction.js";
|
|
import { budgetServiceInTransaction, deliverBudgetEnforcement, type BudgetServiceHooks } from "./budgets.js";
|
|
import { and, count, eq, gte, inArray, isNull, lt, notInArray, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
companies,
|
|
companyLogos,
|
|
assets,
|
|
agents,
|
|
agentWakeupRequests,
|
|
issues,
|
|
heartbeatRuns,
|
|
costEvents,
|
|
} from "@paperclipai/db";
|
|
import { notFound, unprocessable } from "../errors.js";
|
|
import { isCloudManagedInstance } from "./cloud-instance.js";
|
|
import { notifyCloudOfPrimaryCompanyLifecycleChange } from "./cloud-lifecycle-sync.js";
|
|
import {
|
|
MAX_ISSUE_PREFIX_ATTEMPTS,
|
|
deriveIssuePrefixBase,
|
|
isIssuePrefixConflict,
|
|
issuePrefixSuffixForAttempt,
|
|
pickAvailableIssuePrefix,
|
|
rekeyCompanyIssueIdentifiers,
|
|
} from "./issue-prefix.js";
|
|
import { environmentService } from "./environments.js";
|
|
import { heartbeatService } from "./heartbeat.js";
|
|
import { logActivity, type ActivityPublication } from "./activity-log.js";
|
|
import { builtInAgentService } from "./built-in-agents.js";
|
|
|
|
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
export interface CompanyActivityActor {
|
|
actorType: "user" | "agent" | "system" | "plugin";
|
|
actorId: string;
|
|
agentId?: string | null;
|
|
runId?: string | null;
|
|
}
|
|
|
|
const SYSTEM_COMPANY_ACTOR: CompanyActivityActor = {
|
|
actorType: "system",
|
|
actorId: "system",
|
|
agentId: null,
|
|
runId: null,
|
|
};
|
|
|
|
export function companyService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
|
const environmentsSvc = environmentService(db);
|
|
const heartbeat = heartbeatService(db);
|
|
const builtInAgents = builtInAgentService(db);
|
|
|
|
type CompanyTx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
|
|
|
async function applyArchiveCascadeInTx(tx: CompanyTx, id: string) {
|
|
const pausedAgentRows = await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.companyId, id), notInArray(agents.status, ["paused", "terminated", "pending_approval"])));
|
|
|
|
const activeRunIds = await tx
|
|
.select({ id: heartbeatRuns.id })
|
|
.from(heartbeatRuns)
|
|
.where(and(
|
|
eq(heartbeatRuns.companyId, id),
|
|
inArray(heartbeatRuns.status, ["queued", "running"]),
|
|
))
|
|
.then((rows) => rows.map((row) => row.id));
|
|
|
|
await tx
|
|
.update(agentWakeupRequests)
|
|
.set({
|
|
status: "cancelled",
|
|
error: "Cancelled because the company was archived",
|
|
finishedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(
|
|
eq(agentWakeupRequests.companyId, id),
|
|
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution", "claimed"]),
|
|
isNull(agentWakeupRequests.runId),
|
|
));
|
|
|
|
return { agentsPaused: pausedAgentRows.length, activeRunIds };
|
|
}
|
|
|
|
async function finalizeArchive(
|
|
id: string,
|
|
actor: CompanyActivityActor,
|
|
cascade: { agentsPaused: number; activeRunIds: string[] },
|
|
) {
|
|
for (const runId of cascade.activeRunIds) {
|
|
await heartbeat.cancelRun(runId, "Cancelled because the company was archived");
|
|
}
|
|
|
|
await logActivity(db, {
|
|
companyId: id,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId ?? null,
|
|
runId: actor.runId ?? null,
|
|
action: "company.archived",
|
|
entityType: "company",
|
|
entityId: id,
|
|
details: {
|
|
agentsPaused: cascade.agentsPaused,
|
|
runsCancelled: cascade.activeRunIds.length,
|
|
},
|
|
});
|
|
}
|
|
|
|
const companySelection = {
|
|
id: companies.id,
|
|
name: companies.name,
|
|
description: companies.description,
|
|
status: companies.status,
|
|
issuePrefix: companies.issuePrefix,
|
|
issueCounter: companies.issueCounter,
|
|
budgetMonthlyCents: companies.budgetMonthlyCents,
|
|
spentMonthlyCents: companies.spentMonthlyCents,
|
|
defaultResponsibleUserId: companies.defaultResponsibleUserId,
|
|
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
|
|
interactionResolverGovernance: companies.interactionResolverGovernance,
|
|
feedbackDataSharingEnabled: companies.feedbackDataSharingEnabled,
|
|
feedbackDataSharingConsentAt: companies.feedbackDataSharingConsentAt,
|
|
feedbackDataSharingConsentByUserId: companies.feedbackDataSharingConsentByUserId,
|
|
feedbackDataSharingTermsVersion: companies.feedbackDataSharingTermsVersion,
|
|
logoAssetId: companyLogos.assetId,
|
|
createdAt: companies.createdAt,
|
|
updatedAt: companies.updatedAt,
|
|
};
|
|
|
|
function enrichCompany<T extends { logoAssetId: string | null }>(company: T) {
|
|
return {
|
|
...company,
|
|
logoUrl: company.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null,
|
|
};
|
|
}
|
|
|
|
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)),
|
|
};
|
|
}
|
|
|
|
async function getMonthlySpendByCompanyIds(
|
|
companyIds: string[],
|
|
database: Pick<Db, "select"> = db,
|
|
) {
|
|
if (companyIds.length === 0) return new Map<string, number>();
|
|
const { start, end } = currentUtcMonthWindow();
|
|
const rows = await database
|
|
.select({
|
|
companyId: costEvents.companyId,
|
|
spentMonthlyCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
|
})
|
|
.from(costEvents)
|
|
.where(
|
|
and(
|
|
inArray(costEvents.companyId, companyIds),
|
|
gte(costEvents.occurredAt, start),
|
|
lt(costEvents.occurredAt, end),
|
|
),
|
|
)
|
|
.groupBy(costEvents.companyId);
|
|
return new Map(rows.map((row) => [row.companyId, Number(row.spentMonthlyCents ?? 0)]));
|
|
}
|
|
|
|
async function hydrateCompanySpend<T extends { id: string; spentMonthlyCents: number }>(
|
|
rows: T[],
|
|
database: Pick<Db, "select"> = db,
|
|
) {
|
|
const spendByCompanyId = await getMonthlySpendByCompanyIds(rows.map((row) => row.id), database);
|
|
return rows.map((row) => ({
|
|
...row,
|
|
spentMonthlyCents: spendByCompanyId.get(row.id) ?? 0,
|
|
}));
|
|
}
|
|
|
|
function getCompanyQuery(database: Pick<Db, "select">) {
|
|
return database
|
|
.select(companySelection)
|
|
.from(companies)
|
|
.leftJoin(companyLogos, eq(companyLogos.companyId, companies.id));
|
|
}
|
|
|
|
/**
|
|
* Decides whether a rename must move the company onto a new issue prefix, and
|
|
* returns the exact prefix pair to re-key.
|
|
*
|
|
* Self-hosted companies pick their prefix from the name at creation and keep
|
|
* it, so a rename leaves the prefix alone. On a hosted/managed instance the
|
|
* company is provisioned for the operator, so the name is the only prefix
|
|
* source the operator ever chose — a rename re-derives it. Returns null when
|
|
* the current prefix is already correct or when the suffix space is
|
|
* exhausted.
|
|
*/
|
|
async function resolveRenamedIssuePrefix(
|
|
tx: CompanyTx,
|
|
companyId: string,
|
|
companyPatch: Partial<typeof companies.$inferInsert>,
|
|
): Promise<{ fromPrefix: string; toPrefix: string } | null> {
|
|
// Only patch and environment facts gate the lock. Every comparison against
|
|
// the company's own name or prefix happens below, under the lock.
|
|
// An explicit prefix in the patch is the caller's decision; never override it.
|
|
if (companyPatch.issuePrefix !== undefined) return null;
|
|
const nextName = companyPatch.name;
|
|
if (typeof nextName !== "string" || nextName.trim().length === 0) return null;
|
|
if (!isCloudManagedInstance()) return null;
|
|
|
|
// Lock the company row before comparing anything against it. Two concurrent
|
|
// updates would otherwise each decide from the row they read before either
|
|
// committed, and both ways of getting that wrong end with a company whose
|
|
// prefix disagrees with its own identifiers:
|
|
//
|
|
// - Two renames: the second re-keys from the prefix it read, finds the
|
|
// identifiers the first already moved, and leaves them on the first
|
|
// rename's prefix while the row carries the second one's.
|
|
// - A rename plus a stale form that resubmits the original name: the
|
|
// second sees a name equal to the one it read, skips re-derivation, and
|
|
// restores the old name on top of the first rename's prefix.
|
|
//
|
|
// Reading the row under the lock makes the second transaction decide from
|
|
// what the first actually committed. Only a managed instance takes this
|
|
// lock, and only for an update that carries a name.
|
|
const locked = await tx
|
|
.select({ name: companies.name, issuePrefix: companies.issuePrefix })
|
|
.from(companies)
|
|
.where(eq(companies.id, companyId))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!locked || nextName === locked.name) return null;
|
|
|
|
const nextBase = deriveIssuePrefixBase(nextName);
|
|
// A rename that keeps the same base keeps the current prefix, including
|
|
// any disambiguating suffix it was allocated.
|
|
if (nextBase === deriveIssuePrefixBase(locked.name)) return null;
|
|
if (nextBase === locked.issuePrefix) return null;
|
|
|
|
const candidate = await pickAvailableIssuePrefix(tx, nextBase);
|
|
if (!candidate || candidate === locked.issuePrefix) return null;
|
|
return { fromPrefix: locked.issuePrefix, toPrefix: candidate };
|
|
}
|
|
|
|
async function createCompanyWithUniquePrefix(data: typeof companies.$inferInsert) {
|
|
const base = deriveIssuePrefixBase(data.name);
|
|
let suffix = 1;
|
|
while (suffix <= MAX_ISSUE_PREFIX_ATTEMPTS) {
|
|
const candidate = `${base}${issuePrefixSuffixForAttempt(suffix)}`;
|
|
try {
|
|
const rows = await db
|
|
.insert(companies)
|
|
.values({ ...data, issuePrefix: candidate })
|
|
.returning();
|
|
return rows[0];
|
|
} catch (error) {
|
|
if (!isIssuePrefixConflict(error)) throw error;
|
|
}
|
|
suffix += 1;
|
|
}
|
|
throw new Error("Unable to allocate unique issue prefix");
|
|
}
|
|
|
|
return {
|
|
list: async () => {
|
|
const rows = await getCompanyQuery(db);
|
|
const hydrated = await hydrateCompanySpend(rows);
|
|
return hydrated.map((row) => enrichCompany(row));
|
|
},
|
|
|
|
getById: async (id: string) => {
|
|
// Non-UUID refs previously reached the uuid-typed query and threw a
|
|
// DrizzleQueryError ("invalid input syntax for type uuid"), surfacing
|
|
// as HTTP 500 from GET /api/companies/:companyId. Treat them as
|
|
// not-found so the route returns 404.
|
|
if (!UUID_RE.test(id)) return null;
|
|
const row = await getCompanyQuery(db)
|
|
.where(eq(companies.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row) return null;
|
|
const [hydrated] = await hydrateCompanySpend([row], db);
|
|
return enrichCompany(hydrated);
|
|
},
|
|
|
|
create: async (data: typeof companies.$inferInsert) => {
|
|
const created = await createCompanyWithUniquePrefix(data);
|
|
await environmentsSvc.ensureLocalEnvironment(created.id);
|
|
await builtInAgents.autoProvisionBundledAgents(created.id);
|
|
const row = await getCompanyQuery(db)
|
|
.where(eq(companies.id, created.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row) throw notFound("Company not found after creation");
|
|
const [hydrated] = await hydrateCompanySpend([row], db);
|
|
return enrichCompany(hydrated);
|
|
},
|
|
|
|
update: async (
|
|
id: string,
|
|
data: Partial<typeof companies.$inferInsert> & { logoAssetId?: string | null },
|
|
actor: CompanyActivityActor = SYSTEM_COMPANY_ACTOR,
|
|
) => {
|
|
const budgetPublications: ActivityPublication[] = [];
|
|
const result = await db.transaction(async (tx) => {
|
|
const existing = await getCompanyQuery(tx)
|
|
.where(eq(companies.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) return null;
|
|
|
|
if (data.budgetMonthlyCents !== undefined) {
|
|
await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, id)).for("no key update");
|
|
}
|
|
const { logoAssetId, ...companyPatch } = data;
|
|
const willReactivate = existing.status !== "active" && companyPatch.status === "active";
|
|
const willArchive = existing.status !== "archived" && companyPatch.status === "archived";
|
|
|
|
if (logoAssetId !== undefined && logoAssetId !== null) {
|
|
const nextLogoAsset = await tx
|
|
.select()
|
|
.from(assets)
|
|
.where(eq(assets.id, logoAssetId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!nextLogoAsset) throw notFound("Logo asset not found");
|
|
if (await isRunnerApiResponseAsset(tx, nextLogoAsset)) {
|
|
throw unprocessable("API response snapshots cannot be company logos; upload a logo asset");
|
|
}
|
|
if (nextLogoAsset.companyId !== existing.id) {
|
|
throw unprocessable("Logo asset must belong to the same company");
|
|
}
|
|
}
|
|
|
|
const renamedPrefix = await resolveRenamedIssuePrefix(tx, id, companyPatch);
|
|
|
|
const updated = await tx
|
|
.update(companies)
|
|
.set({
|
|
...companyPatch,
|
|
...(renamedPrefix ? { issuePrefix: renamedPrefix.toPrefix } : {}),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companies.id, id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!updated) return null;
|
|
|
|
let issuePrefixRederived: {
|
|
previousIssuePrefix: string;
|
|
issuePrefix: string;
|
|
issuesRekeyed: number;
|
|
casesRekeyed: number;
|
|
} | null = null;
|
|
if (renamedPrefix) {
|
|
const rekeyed = await rekeyCompanyIssueIdentifiers(tx, {
|
|
companyId: id,
|
|
fromPrefix: renamedPrefix.fromPrefix,
|
|
toPrefix: renamedPrefix.toPrefix,
|
|
});
|
|
issuePrefixRederived = {
|
|
previousIssuePrefix: renamedPrefix.fromPrefix,
|
|
issuePrefix: renamedPrefix.toPrefix,
|
|
issuesRekeyed: rekeyed.issues,
|
|
casesRekeyed: rekeyed.cases,
|
|
};
|
|
}
|
|
|
|
let agentsRestored = 0;
|
|
if (willReactivate) {
|
|
const restoredRows = await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.companyId, id), eq(agents.pauseReason, "company_archived")));
|
|
agentsRestored = restoredRows.length;
|
|
}
|
|
|
|
const archiveCascade = willArchive ? await applyArchiveCascadeInTx(tx, id) : null;
|
|
|
|
if (logoAssetId === null) {
|
|
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
|
} else if (logoAssetId !== undefined) {
|
|
await tx
|
|
.insert(companyLogos)
|
|
.values({
|
|
companyId: id,
|
|
assetId: logoAssetId,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: companyLogos.companyId,
|
|
set: {
|
|
assetId: logoAssetId,
|
|
updatedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
|
|
if (logoAssetId !== undefined && existing.logoAssetId && existing.logoAssetId !== logoAssetId) {
|
|
await tx.delete(assets).where(eq(assets.id, existing.logoAssetId));
|
|
}
|
|
|
|
if (data.budgetMonthlyCents !== undefined) {
|
|
await budgetServiceInTransaction(tx as unknown as Db, budgetPublications).upsertPolicy(id, {
|
|
scopeType: "company", scopeId: id, amount: data.budgetMonthlyCents, isActive: data.budgetMonthlyCents > 0, windowKind: "calendar_month_utc",
|
|
}, actor.actorType === "user" ? actor.actorId : null);
|
|
const [budgetUpdated] = await tx.select().from(companies).where(eq(companies.id, id));
|
|
Object.assign(updated, budgetUpdated);
|
|
}
|
|
const [hydrated] = await hydrateCompanySpend([{
|
|
...updated,
|
|
logoAssetId: logoAssetId === undefined ? existing.logoAssetId : logoAssetId,
|
|
}], tx);
|
|
|
|
const shouldLogReactivation = willReactivate &&
|
|
(existing.status === "archived" || agentsRestored > 0);
|
|
|
|
return {
|
|
company: enrichCompany(hydrated),
|
|
reactivated: shouldLogReactivation ? { agentsRestored } : null,
|
|
archiveCascade,
|
|
unarchived: willReactivate && existing.status === "archived",
|
|
issuePrefixRederived,
|
|
};
|
|
});
|
|
if (!result) return null;
|
|
await createAgentLifecycle(db).reconcilePolicyHolds(id);
|
|
publishAccountingActivities(id, budgetPublications);
|
|
if (data.budgetMonthlyCents !== undefined) await deliverBudgetEnforcement(db, budgetHooks, id);
|
|
// Post-commit, fire-and-forget, and BEFORE any finalization that
|
|
// could throw: a Cloud-pinned primary company that crossed the
|
|
// archived boundary (either direction) rings the harness so the
|
|
// stack itself can converge. The status transaction has already
|
|
// committed, so a later cascade or activity-log failure must not
|
|
// leave Cloud unaware of a company that is in fact archived.
|
|
if (result.archiveCascade || result.unarchived) {
|
|
void notifyCloudOfPrimaryCompanyLifecycleChange(id);
|
|
}
|
|
if (result.issuePrefixRederived) {
|
|
await logActivity(db, {
|
|
companyId: id,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId ?? null,
|
|
runId: actor.runId ?? null,
|
|
action: "company.updated",
|
|
entityType: "company",
|
|
entityId: id,
|
|
details: {
|
|
source: "company_rename",
|
|
reason: "issue_prefix_rederived",
|
|
...result.issuePrefixRederived,
|
|
},
|
|
});
|
|
}
|
|
if (result.reactivated) {
|
|
await logActivity(db, {
|
|
companyId: id,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId ?? null,
|
|
runId: actor.runId ?? null,
|
|
action: "company.reactivated",
|
|
entityType: "company",
|
|
entityId: id,
|
|
details: { agentsRestored: result.reactivated.agentsRestored },
|
|
});
|
|
}
|
|
if (result.archiveCascade) {
|
|
await finalizeArchive(id, actor, result.archiveCascade);
|
|
}
|
|
return result.company;
|
|
},
|
|
|
|
archive: async (id: string, actor: CompanyActivityActor = SYSTEM_COMPANY_ACTOR) => {
|
|
const result = await db.transaction(async (tx) => {
|
|
const existing = await tx
|
|
.select({ status: companies.status })
|
|
.from(companies)
|
|
.where(eq(companies.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) return null;
|
|
|
|
const wasAlreadyArchived = existing.status === "archived";
|
|
|
|
if (!wasAlreadyArchived) {
|
|
await tx
|
|
.update(companies)
|
|
.set({ status: "archived", updatedAt: new Date() })
|
|
.where(eq(companies.id, id));
|
|
}
|
|
|
|
const cascade = wasAlreadyArchived ? null : await applyArchiveCascadeInTx(tx, id);
|
|
|
|
const row = await getCompanyQuery(tx)
|
|
.where(eq(companies.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row) return null;
|
|
const [hydrated] = await hydrateCompanySpend([row], tx);
|
|
return {
|
|
company: enrichCompany(hydrated),
|
|
cascade,
|
|
};
|
|
});
|
|
if (!result) return null;
|
|
await createAgentLifecycle(db).reconcilePolicyHolds(id);
|
|
|
|
// Same doorbell rule as update(): the archive is committed, so ring
|
|
// before finalization, which can throw without undoing it.
|
|
if (result.cascade) {
|
|
void notifyCloudOfPrimaryCompanyLifecycleChange(id);
|
|
await finalizeArchive(id, actor, result.cascade);
|
|
}
|
|
|
|
return result.company;
|
|
},
|
|
|
|
remove: (id: string) => deleteCompany(db, id),
|
|
|
|
stats: () =>
|
|
Promise.all([
|
|
db
|
|
.select({ companyId: agents.companyId, count: count() })
|
|
.from(agents)
|
|
.groupBy(agents.companyId),
|
|
db
|
|
.select({ companyId: issues.companyId, count: count() })
|
|
.from(issues)
|
|
.groupBy(issues.companyId),
|
|
]).then(([agentRows, issueRows]) => {
|
|
const result: Record<string, { agentCount: number; issueCount: number }> = {};
|
|
for (const row of agentRows) {
|
|
result[row.companyId] = { agentCount: row.count, issueCount: 0 };
|
|
}
|
|
for (const row of issueRows) {
|
|
if (result[row.companyId]) {
|
|
result[row.companyId].issueCount = row.count;
|
|
} else {
|
|
result[row.companyId] = { agentCount: 0, issueCount: row.count };
|
|
}
|
|
}
|
|
return result;
|
|
}),
|
|
};
|
|
}
|