mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - The inbox collects requests that need a human decision. > - Teams need to know who owns each request and who may act on it. > - A shared close operation must differ from hiding a card for one person. > - This pull request adds personal routing and durable outcomes to the existing attention service. > - People can claim or transfer work and inspect what another person did for them. > - Domain permissions still decide who may resolve each request. ## Linked Issues or Issue Description **Subsystem affected** Server, shared contracts, and database. This is the routing foundation for a stacked inbox UI change. **Problem or motivation** A company-wide attention feed does not give teams a clear personal queue. Archive, withdrawal, transfer, and decisions by another person need distinct scopes and records. **Proposed solution** Resolve a request audience from its addressee, task participants, and configured owners. Provide personal, unassigned, company, all-assignee, and archived views. Add claim, transfer, personal archive and snooze, shared withdrawal, receipts, and personal history. **Alternatives considered** A single shared inbox leaves ownership and personal hiding ambiguous. The service keeps one canonical request and projects its audience instead of copying requests for each person. **Roadmap alignment** This extends the existing Multiple Human Users and Agent Reviews and Approvals capabilities in ROADMAP.md. **Additional context** Refs #19363 and #19520 for the current inbox navigation and confirmation replacement rules. Refs #20268 for related actionable-request work. That PR filters actionability; this stack adds audience routing and personal outcomes. This is part 1 of a two-PR stack. The inbox UI PR #20961 targets this branch. ## What Changed - Add nullable company and project decision owners and a company budget owner. - Add audience views and personal badge counts to the canonical attention projections. - Add claim, transfer, withdrawal, personal archive, snooze, and restore operations with permission checks and activity records. Lock addressed transfers to the current addressee or routing administrators, and validate the recipient against canonical resolution policies. - Add receipts and scoped history for work handled by another person. - Keep closed-task requests and replaced confirmations out of current cards. - Add an idempotent migration and a concurrent receipt-history index. - Update typed fixtures so the existing UI remains compatible with the new contracts. ## Verification - Server layered typechecking passes, including all three test-tree partitions. - Seven routing and interaction-resolution suites pass all 249 tests against a dedicated external PostgreSQL test database on `4caecc21d8`. Six new regressions failed before the repair; they cover takeover followed by approval, legitimate delegation, excluded creators/review requesters, and unauthorized claims. - Prior focused verification covers private approval history and receipts, transfer visibility, audience escalation, withdrawal governance, hidden-card counts, per-recipient confirmation replacement, and SDK source contracts. - A synthetic three-person company passed 37 routing and lifecycle acceptance checks. The UI stack adds browser acceptance for the shared controls. - The complete [GitHub Actions CI matrix](https://github.com/paperclipai/paperclip/actions/runs/43134997331) passes on `4caecc21d891677323a7335f23b1a015cce24c13` (Linux/Node 24: typecheck, build, general and serialized tests, Runner support, browser shards, and aggregate gates). Greptile is 5/5 on that exact head with no open findings. The fresh security scan passes after explicitly rechecking the addressee-transfer repair. - `pnpm verify` reports exit 2 because a CI-global script changed; full CI supplies that broader coverage. Embedded PostgreSQL could not start on this Mac because shared-memory slots were exhausted, so local database checks use a separate external test database. Linux CI covers the embedded setup. ## Risks - Personal badge counts change to the viewer's assigned requests. Accessible feed totals and task membership keep their existing scope. - The migration adds nullable owner fields and a dismissal fingerprint. Its index runs outside a transaction. The migration is classified and rerunnable. - Routing changes the addressee used by resolution authorization. The current addressee or a routing administrator must authorize an addressed transfer; the recipient must satisfy writer access, canonical resolver exclusions, and task review policy under row locks. - The shared validator export barrel crosses 1,000 lines because it exports the new attention schemas. Schema implementation stays in the dedicated attention validator module. ## Model Used OpenAI GPT-6 through Codex. Used repository tools, code execution, and browser checks. This session does not expose a more specific runtime model ID or its context-window size. ## 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 described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [x] I have run focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (OpenAPI contracts and docs/api/attention.md) - [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: Scott Tong <scott@scottsmbpm5max.lan> Co-authored-by: Paperclip <noreply@paperclip.ing>
583 lines
24 KiB
TypeScript
583 lines
24 KiB
TypeScript
import { notifyMuseContactVerification } from "../modules/external-agents/index.js";
|
|
import { promoteDeferredWorkIntents } from "./work-intent-outbox.js";
|
|
import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js";
|
|
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
|
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
|
import { notifyGitHubCompanyChange } from "./github-connection-notifications.js";
|
|
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
|
|
import { notifyCompanyRetentionWork } from "./retention-work-notifications.js";
|
|
import { notifyExternalObjectRefresh } from "./external-object-refresh-notifications.js";
|
|
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
|
import { isRunnerApiResponseAsset } from "./asset-content-access.js";
|
|
import { deleteCompany } from "./company-deletion.js";
|
|
import { fenceActiveCompanyAgentStopsInTransaction } from "./agent-lifecycle.js";
|
|
import { publishAccountingActivities } from "./accounting-transaction.js";
|
|
import { budgetServiceInTransaction, 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 { logActivity, type ActivityPublication } from "./activity-log.js";
|
|
import { builtInAgentService } from "./built-in-agents.js";
|
|
import type { Company } from "@paperclipai/shared";
|
|
|
|
|
|
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 builtInAgents = builtInAgentService(db);
|
|
|
|
type CompanyTx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
|
|
|
async function applyArchiveCascadeInTx(tx: CompanyTx, id: string) {
|
|
const [agentCount] = await tx.select({ value: count() }).from(agents).where(and(eq(agents.companyId, id), notInArray(agents.status, ["paused", "terminated", "pending_approval"])));
|
|
|
|
const [runCount] = await tx
|
|
.select({ value: count() })
|
|
.from(heartbeatRuns)
|
|
.where(and(
|
|
eq(heartbeatRuns.companyId, id),
|
|
inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry"]),
|
|
));
|
|
|
|
await notifyChatInboundWakeNotices(tx, { companyId: 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 { agentsPauseRequested: agentCount.value, runsCancellationRequested: runCount.value };
|
|
}
|
|
|
|
async function finalizeArchive(
|
|
id: string,
|
|
actor: CompanyActivityActor,
|
|
cascade: { agentsPauseRequested: number; runsCancellationRequested: number },
|
|
) {
|
|
// The committed company status blocks dispatch immediately. Lifecycle
|
|
// projection owns provider termination, including paused agents with work.
|
|
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: {
|
|
agentsPauseRequested: cascade.agentsPauseRequested,
|
|
runsCancellationRequested: cascade.runsCancellationRequested,
|
|
},
|
|
});
|
|
}
|
|
|
|
const companySelection = {
|
|
id: companies.id,
|
|
name: companies.name,
|
|
description: companies.description,
|
|
status: companies.status,
|
|
pauseReason: companies.pauseReason,
|
|
pausedAt: companies.pausedAt,
|
|
issuePrefix: companies.issuePrefix,
|
|
issueCounter: companies.issueCounter,
|
|
budgetMonthlyCents: companies.budgetMonthlyCents,
|
|
spentMonthlyCents: companies.spentMonthlyCents,
|
|
defaultResponsibleUserId: companies.defaultResponsibleUserId,
|
|
defaultDecisionOwnerUserId: companies.defaultDecisionOwnerUserId,
|
|
budgetOwnerUserId: companies.budgetOwnerUserId,
|
|
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; status: string; pauseReason: string | null }>(company: T) {
|
|
return {
|
|
...company,
|
|
status: company.status as Company["status"],
|
|
pauseReason: company.pauseReason as Company["pauseReason"],
|
|
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) => {
|
|
if (data.status !== undefined || data.budgetMonthlyCents !== undefined) {
|
|
await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, id)).for("no key update");
|
|
}
|
|
const existing = await getCompanyQuery(tx)
|
|
.where(eq(companies.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) return null;
|
|
|
|
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);
|
|
|
|
if (companyPatch.status !== undefined && companyPatch.status !== existing.status) {
|
|
if(willReactivate)await notifyMuseContactVerification(tx,{companyId:id});
|
|
await notifyRunDispatch(tx, { companyId: id });
|
|
await notifyGitHubCompanyChange(tx, id);
|
|
await notifyDeferredExecutionEligibility(tx, { companyId: id });
|
|
if (companyPatch.status === "active") await promoteDeferredWorkIntents(tx, { companyId: id, trigger: "pause_lifted" });
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
|
await notifyExternalObjectRefresh(tx, id);
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.routineSchedules);
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
|
await notifyStatusCardIssueChange(tx, id);
|
|
await notifyCompanyRetentionWork(tx, id);
|
|
await notifyChatInboundWakeNotices(tx, { companyId: id });
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle, id);
|
|
}
|
|
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 (companyPatch.status !== existing.status && (companyPatch.status === "paused" || companyPatch.status === "archived")) {
|
|
await fenceActiveCompanyAgentStopsInTransaction(tx as unknown as Db, id, companyPatch.status);
|
|
}
|
|
|
|
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;
|
|
publishAccountingActivities(id, budgetPublications);
|
|
// 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))
|
|
.for("no key update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) return null;
|
|
|
|
const wasAlreadyArchived = existing.status === "archived";
|
|
|
|
if (!wasAlreadyArchived) {
|
|
await notifyRunDispatch(tx, { companyId: id });
|
|
await notifyGitHubCompanyChange(tx, id);
|
|
await notifyDeferredExecutionEligibility(tx, { companyId: id });
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
|
await notifyExternalObjectRefresh(tx, id);
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.routineSchedules);
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
|
await notifyStatusCardIssueChange(tx, id);
|
|
await notifyCompanyRetentionWork(tx, id);
|
|
await notifyChatInboundWakeNotices(tx, { companyId: id });
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle, id);
|
|
await tx
|
|
.update(companies)
|
|
.set({ status: "archived", updatedAt: new Date() })
|
|
.where(eq(companies.id, id));
|
|
}
|
|
|
|
const cascade = wasAlreadyArchived ? null : await applyArchiveCascadeInTx(tx, id);
|
|
if (!wasAlreadyArchived) await fenceActiveCompanyAgentStopsInTransaction(tx as unknown as Db, id, "archived");
|
|
|
|
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;
|
|
|
|
// 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;
|
|
}),
|
|
};
|
|
}
|