mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Users send work to agents as assignments, comments and Retries. The wake queue delivers each one as a run > - When an admission check refused a wake for a temporary reason, the wake often ended as a terminal skip, so the work was lost > - Restart-chaos waves found dropped comments and Retries (RC-37, s7532) and double delivery (RC-53). Each fix covered one hold type > - The durable ownership plan (section E) asks for one outbox rule: a closed set of terminal outcomes, a saved deferral for every temporary refusal, and one promotion path > - This pull request adds that rule. It saves messages refused by a pause, promotes deferred intents from every hold-release event through one function, and adds a reconciler backstop > - The benefit is that an accepted message is delivered exactly once after a pause lifts, and the reconciler reports any intent that is stuck or dropped ## Linked Issues or Issue Description Refs #19831 (plan, section E), #19838 (inventory, wake-queue rows and section 1b), #19927 (Phase 2 data: the I4 false positive), #19702, #19741, #19829, #19826. Builds on #19944 (section D, merged). **What happened?** - A user commented while the agent was paused, or while the company was paused. Admission refused the wake. Nothing saved the message as pending, so when the pause lifted, nothing delivered it. - Hold-release events woke different subsets of workers. A saved intent then waited out a 30-second refusal backoff after its hold cleared. - The I4 reconciler check matched free text with a regular expression. Phase 2 found a false positive: a delivered retry whose error said "retrying once". **Expected behavior** An accepted work intent ends only as delivered, consumed by a later run, task cancelled, or hard stop. Every temporary refusal saves the intent as `deferred` with its reason and its release trigger. Every hold-release event promotes deferred intents through one function (invariant 4). **Steps to reproduce** Pause an agent. Post a user comment on its task. Resume the agent. On `master` the comment is never delivered as a wake. ## What Changed - New `server/src/services/work-intent-outbox.ts`: - `WORK_INTENT_TERMINAL_OUTCOMES`: the closed set of terminal outcomes. - `TEMPORARY_REFUSAL_TRIGGERS`: maps each temporary refusal code to its release trigger. - `promoteDeferredWorkIntents`: the one promotion function. It removes the 30-second refusal backoff from the matching deferred intents. It then wakes run dispatch, the saved-receipt worker and stranded-work recovery, and hints each saved intent of the issue. - `heartbeat/queue.ts`: the #19829 save-as-deferred block is now a shared helper. A user's message refused by a paused agent, a paused company, or a budget pause that is not a reached hard stop is saved with `executionWait: { reason, trigger, message }`. The caller still gets a 409. The message is saved only when the task's latest run is terminal, because the release drain promotes a saved message behind that run. A task that never ran keeps the message in the task, and stranded-work recovery starts the task once the pause lifts. A `pending_approval` agent and a task-tree hold are not changed here: the release drain still fails a promoted wake for a pending approval, and tree holds have their own resume path. - Hold-release events call `promoteDeferredWorkIntents`: - accounting settled (`run-cost-accounting.ts`); - environment lease released (`environments.ts`, two sites); - native run projected terminal (`native-run-projection.ts`); - budget pause lifted (`budgets.ts`); - company reactivated (`companies.ts`); - task-tree hold released (`issue-tree-control.ts`); - agent lifecycle ready (`agent-lifecycle-driver.ts`). - Reconciler (`recovery/ownership-registry.ts`, `ownership-invariant-report.ts`, `native-finalization-reconciler.ts`): - registers three new kinds: `wake_request_deferred`, `queued_run` and `scheduled_retry_run`; - I4 now reads the closed refusal code (the wake `reason`) of accepted intents, not free text; - a new I4 check reports a deferred pause refusal whose pause has lifted for more than 5 minutes. The worker then promotes it as a backstop; - a lifted pause inside its 5-minute grace window returns its deadline through `nextDueAt`, and the worker also wakes on agent-lifecycle signals (company and agent pause changes). So a missed release event is still checked. - `doc/SPEC-implementation.md`: adds the rule "Accepted work intents are delivered exactly once". ## Verification Each focused file ran alone in one vitest process (`--maxWorkers=1`) on embedded Postgres. Results at `2875c85fd2`: | Question | Loop | Elapsed | Result | |---|---|---|---| | Is a comment refused by a pause saved, then delivered once after the pause lifts? | `heartbeat-held-comment-wake.test.ts -t "pause lifts"` | 8 s | 2 pass. With `queue.ts` from `master`, both fail: no intent is saved | | Does I4 use the closed code, and does it catch a stuck deferral? | `ownership-reconciliation-report.test.ts` | 45 s | 20 pass. This includes the Phase 2 "retrying once" false positive and the case "paused, then lifted" | | No regression on the paths that changed | held-comment (15), archived-company guard (8), undispatched execution wait (32), deferred receipt work (45), recovery pause durability (16), native run projection work (55), tree-control routes and services (19, 1, 7) | 8–334 s each | All pass | - Exactly-once: each pause test runs two promotions (a release event, then the reconciler backstop) and many dispatch passes. It asserts one delivery. The existing consumption tests (RC-53) still pass. - Server package typecheck passes. - Speed: nothing new runs before `turn.start`. The new admission work happens only on a refusal. Promotion runs in the release transaction and in the background reconciler. - Not run here: chaos waves. The coordinating session schedules them on the shared harness. ## Risks - `promoteDeferredWorkIntents` moves `updated_at` back for deferred intents that were refused in the last 30 seconds. A busy company with many release events can retry refused intents more often. Each retry re-runs normal admission, and the refusal resets the backoff. - More release sites now wake run dispatch and stranded-work recovery. These notifications are coalesced per company. - Lifting a pause is now the operator's explicit authorization to send what the pause refused, as Send is after a Stop. When the pause stopped the task's run, the lifted pause issues the task's next explicit wake, which adopts the saved message once. Other messages queued behind an acknowledged Stop keep the existing rule. If Stop recovery still holds the task, the message waits for that recovery. - An agent pause's stop sweep keeps the messages that the pause deferred (`preservePauseDeferred`). Terminate and cleanup stops still end them. - The backstop check reads only pause refusals (`company.inactive`, `agent.not_invokable`), because those holds can be checked exactly. Other deferred kinds keep their existing promotion paths. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
581 lines
23 KiB
TypeScript
581 lines
23 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,
|
|
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;
|
|
}),
|
|
};
|
|
}
|