mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/58/changes/paperclip/58 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent timers must start due work and preserve queue admission rules. > - The server woke their scan on every heartbeat interval, even with no eligible timer. > - Idle scans keep the database and instance active. > - This pull request derives the next deadline from saved policy and work records. > - Supported producer transactions notify the shared coordinator when eligibility changes. > - An idle timer worker stops scanning and resumes when real work or a deadline requires it. ## Linked Issues or Issue Description Refs #17863. This is slice 63 of the recurring-work migration and follows the merged issue-monitor deadline PR. Related: #16632 changes timer issue scope; #16881 changes live-run eligibility. This PR preserves the current admission policy. Those policy changes are separate work. A future time-sensitive predicate must expose its next transition deadline. **What existing behavior does this improve?** Agent timer heartbeat scheduling in the server. **Current behavior** A recurring heartbeat wakes the timer scan even when companies are paused, timers are disabled, or no actionable task exists. **Proposed behavior** Register the earliest eligible interval or positive daily-cap reset deadline. Re-read on supported producer commits and startup. Leave no recurring database scan when no timer obligation remains. ## What Changed - Keep policy and deadline calculation in the heartbeat queue. Scheduling retains company, reporting-chain, worktree, and CAS gates. - Use the existing deadline coordinator. Remove only the timer wake from the server heartbeat loop. - Notify before existing transaction writes that change eligibility or deadlines. Cover configuration, lifecycle, company budgets, task ownership, recovery, conversations, accounting corrections, and completion. - Use the lifecycle status-change hook for saved hire rejection. Cover checkout's second adoption transaction after an initial failed CAS. - Preserve pending work through idle and maintenance drains. Check cancellation between due agents. Reconstruct on restart. - Document the supported writer model and producer inventory. Add no schema, table, or provider branch. ## Verification - **1,710 passing cases across 49 complete files**, no skips, on accepted source `32db86c1974dcbf794f01a8c5e42024e2ad071fc`. Coverage includes real timer policy/CAS/producers, monitor and shared deadline consumers, native reconstruction, Slack lifecycle, skills/settings/configuration, queue isolation and agent lifecycle. - Database build, production server typechecking/build, module boundaries, target migration order and Runner profile identity pass. Twenty-nine fresh PostgreSQL 17 schemas plus per-file fixtures applied all 412 journal entries through index 413; all selected source hashes and temporary fixture imports were restored exactly. - Four new deadline composition cases verify future/overdue timing, minimum scan spacing and disarming. A controlled mutation dropping only the inherited continuation option fails; the composed source passes with identical assertions and timers. - Normal parent reconciliation preserves task-drain expiry and both deadline contracts. The final monitor-parent publication changes only its audit, preserving every qualified runtime and selected test source. - Historical failures and review corrections remain in [the timer audit](doc/idle-agent-timers.md). No full local repository-suite, live provider, measured suspend/wake, external-alarm or fleet-savings claim. Final-head CI/security, exact-head Greptile 5/5 and actionable Ponytail findings remain separate merge gates. ## Risks - Process-local notifications cover supported service/API writers in one server process. Direct SQL and independent replicas need an explicit ingress contract. Startup reconstruction is not live cross-process notification. - Notifications can batch for up to the configured heartbeat interval. Known deadlines retain their scheduler path; failed reconciliation retains bounded retry. - Enabled timers without an actionable-work requirement remain real recurring obligations. Zero caps require policy changes; positive reached caps use UTC midnight. - The timer fixture substitutes adapter enqueue. Existing admission tests also pass, but this is not deployed provider or sleep qualification. - Cloud does not gain legacy agent timer alarms in this PR. Existing sleep-safety blockers remain. No database/host suspension or fleet savings claim is made. - Parent-stack CI and review must pass before merge. This PR does not authorize merging its dependencies. ## Model Used OpenAI Codex (GPT-6-based coding agent), using repository inspection, normal Git merges, composition proofs, a controlled red/green regression and complete PostgreSQL integration suites. No subagents were used in this reconciliation. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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: Codex <noreply@openai.com>
557 lines
22 KiB
TypeScript
557 lines
22 KiB
TypeScript
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
|
|
import { notifyCompanyRetentionWork } from "./retention-work-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";
|
|
|
|
|
|
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,
|
|
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) => {
|
|
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) {
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.routineSchedules);
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
|
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 notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.routineSchedules);
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
|
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;
|
|
}),
|
|
};
|
|
}
|