mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
Restore complete parent sources in local integration resolution
Reconstruct conflicted files from immutable parents and apply only line-bounded additive resolutions. Keep this unpublished integration correction visible in history. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
94ddb74c0c
commit
a836109fb0
10 files changed
+25029
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1 +1,562 @@
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
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 notifyRunDispatch(tx, { companyId: id });
|
||||
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 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 notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||||
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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -2,4 +2,136 @@ import { signalDatabaseWork, subscribeDatabaseWork } from "@paperclipai/db";
|
||||
|
||||
/** Topics for existing durable queues; register intent before their writes. */
|
||||
export const DELIVERY_QUEUES = {
|
||||
scheduledRetryDispatch: "scheduled-retry-dispatch",
|
||||
queuedRunDispatch: "queued-run-dispatch",
|
||||
statusCardEvaluation: "status-card-evaluation",
|
||||
runEvidenceMigration: "run-evidence-migration",
|
||||
providerTraceRetention: "provider-trace-retention",
|
||||
nativeRunnerEvidenceRetention: "native-runner-evidence-retention",
|
||||
skillVersionRetention: "skill-version-retention",
|
||||
databaseBackups: "database-backups",
|
||||
importSpoolCleanup: "import-spool-cleanup",
|
||||
externalConversationSettlement: "external-conversation-settlement",
|
||||
nativeRecoveryNotices: "native-recovery-notices",
|
||||
nativeEnvironmentSettlement: "native-environment-settlement",
|
||||
nativeRunProjection: "native-run-projection",
|
||||
nativeResultCommit: "native-result-commit",
|
||||
nativeWorkspaceFinalization: "native-workspace-finalization",
|
||||
retainedNativeSessionCleanup: "retained-native-session-cleanup",
|
||||
nativeStatusReassessment: "native-status-reassessment",
|
||||
nativeReviewResponse: "native-review-response",
|
||||
nativeResponsePresentation: "native-response-presentation",
|
||||
nativePendingEffects: "native-pending-effects",
|
||||
unsafeWorkspaceExportRecovery: "unsafe-workspace-export-recovery",
|
||||
automaticCompletionReviews: "automatic-completion-reviews",
|
||||
obsoletePolicyReviews: "obsolete-policy-reviews",
|
||||
cancelledWorkspacePreservation: "cancelled-workspace-preservation",
|
||||
instructionCollection: "instruction-collection",
|
||||
directoryCleanup: "directory-cleanup",
|
||||
instructionCommit: "instruction-commit",
|
||||
scheduledRetryCancellation: "scheduled-retry-cancellation",
|
||||
executionFinalization: "execution-finalization",
|
||||
workspaceRecovery: "workspace-recovery",
|
||||
executionReplacement: "execution-replacement",
|
||||
executionContinuation: "execution-continuation",
|
||||
executionStatus: "execution-status",
|
||||
executionDisposition: "execution-disposition",
|
||||
environmentCleanup: "environment-cleanup",
|
||||
approvalQueueSeeding: "approval-queue-seeding",
|
||||
approvalRetention: "approval-retention",
|
||||
joinRequestRetention: "join-request-retention",
|
||||
decisionTargets: "decision-targets",
|
||||
decisionEffectRecovery: "decision-effect-recovery",
|
||||
decisionExpiry: "decision-expiry",
|
||||
decisionContinuation: "decision-continuation",
|
||||
|
||||
decisionArchiveDelivery: "decision-archive-delivery",
|
||||
toolConnectionHealth: "tool-connection-health",
|
||||
toolReviewProjection: "tool-review-projection",
|
||||
toolReviewInterruption: "tool-review-interruption",
|
||||
toolReviewRecovery: "tool-review-recovery",
|
||||
toolReviewExpiry: "tool-review-expiry",
|
||||
gatewayCredentialExpiry: "gateway-credential-expiry",
|
||||
secretProposalExpiry: "secret-proposal-expiry",
|
||||
customImageExpiry: "custom-image-expiry",
|
||||
historicalCleanup: "historical-cleanup",
|
||||
adapterLoginCleanup: "adapter-login-cleanup",
|
||||
setupTokenCleanup: "setup-token-cleanup",
|
||||
localLoginCleanup: "local-login-cleanup",
|
||||
budgetEnforcement: "budget-enforcement",
|
||||
accounting: "cost-accounting",
|
||||
taskDrainExpiry: "task-drain-expiry",
|
||||
agentLifecycle: "agent-lifecycle",
|
||||
chatBoardMessages: "chat-board-messages",
|
||||
chatTaskStarts: "chat-task-starts",
|
||||
chatReceiptReactions: "chat-receipt-reactions",
|
||||
chatSessionStops: "chat-session-stops",
|
||||
chatProviderEffects: "chat-provider-effects",
|
||||
chatGitHubWebhookRecovery: "chat-github-webhook-recovery",
|
||||
chatGitHubIngress: "chat-github-ingress",
|
||||
chatGitHubPublications: "chat-github-publications",
|
||||
chatGitHubReviewProjection: "chat-github-review-projection",
|
||||
chatGitHubReviewChecks: "chat-github-review-checks",
|
||||
chatGitHubTerminalResponses: "chat-github-terminal-responses",
|
||||
chatGitHubAcknowledgements: "chat-github-acknowledgements",
|
||||
chatFailedRunRetries: "chat-failed-run-retries",
|
||||
chatTelegramMaintenance: "chat-telegram-maintenance",
|
||||
chatReceivers: "chat-receivers",
|
||||
voiceSessionRecovery: "voice-session-recovery",
|
||||
voiceReports: "voice-call-reports",
|
||||
voiceInboundRetention: "voice-inbound-retention",
|
||||
voiceInboundCleanup: "voice-inbound-cleanup",
|
||||
voiceReplies: "voice-reply-delivery",
|
||||
chatTeamsConsentExpiry: "chat-teams-consent-expiry",
|
||||
chatTeamsAttemptRecovery: "chat-teams-attempt-recovery",
|
||||
chatCommandRegistration: "chat-command-registration",
|
||||
chatTelegramSubscriptions: "chat-telegram-subscriptions",
|
||||
chatSessionSyncs: "chat-session-syncs",
|
||||
chatVerification: "chat-verification",
|
||||
chatInbound: "chat-inbound",
|
||||
chatPublications: "chat-publications",
|
||||
chatNativeProgress: "chat-native-progress",
|
||||
chatInboundWakeNotices: "chat-inbound-wake-notices",
|
||||
chatInteractionContinuations: "chat-interaction-continuations",
|
||||
chatTerminalConfirmations: "chat-terminal-confirmations",
|
||||
chatFailedRetryNotices: "chat-failed-retry-notices",
|
||||
chatRunMilestones: "chat-run-milestones",
|
||||
chatReceipts: "chat-slack-receipts",
|
||||
browser: "browser-use",
|
||||
email: "email-channels",
|
||||
museMaintenance: "muse-maintenance",
|
||||
mcpEvents: "mcp-events",
|
||||
dotEvents: "dot-events",
|
||||
grokDispatch: "grok-dispatch",
|
||||
skillSourceSync: "skill-source-sync",
|
||||
skillPackages: "skill-packages",
|
||||
runtimeAssetRetention: "runtime-asset-retention",
|
||||
fastResponse: "fast-responses",
|
||||
feedback: "feedback-exports",
|
||||
chatCompletion: "chat-completions",
|
||||
connection: "connection-continuations",
|
||||
gitRestorePoints: "git-restore-points",
|
||||
nativeWorkspaceSyncRetention: "native-workspace-sync-retention",
|
||||
question: "question-responses",
|
||||
pluginSchedules: "plugin-schedules",
|
||||
pluginLifecycle: "plugin-resource-lifecycle",
|
||||
toolAction: "tool-action-receipts",
|
||||
terminalWorkspaces: "terminal-workspaces",
|
||||
routineSchedules: "routine-schedules",
|
||||
issueMonitors: "issue-monitors",
|
||||
agentTimers: "agent-timers",
|
||||
caseHandoffs: "case-handoffs",
|
||||
nativeCheckpointExpiration: "native-checkpoint-expiration",
|
||||
} as const;
|
||||
export type DeliveryQueue = typeof DELIVERY_QUEUES[keyof typeof DELIVERY_QUEUES];
|
||||
|
||||
export async function notifyDeliveryWork(transaction: object, queue: DeliveryQueue, hint?: string): Promise<void> {
|
||||
await signalDatabaseWork(transaction, queue, hint);
|
||||
}
|
||||
// Public delivery notification observers only see settled work. The coordinator
|
||||
// uses the lower-level lifecycle subscription to also fence in-flight writes.
|
||||
export function subscribeDeliveryWork(owner: object, queue: DeliveryQueue, wake: () => void): () => void {
|
||||
return subscribeDatabaseWork(owner, queue, event => {
|
||||
if (event === "settled") wake();
|
||||
});
|
||||
}
|
||||
@@ -1 +1,32 @@
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork, type DeliveryQueue } from "./delivery-work-notifications.js";
|
||||
|
||||
export type ExecutionWorkScope = { companyId: string; agentId?: string; runId?: string; issueId?: string };
|
||||
|
||||
export async function notifyExecutionRunWork(transaction: object, scope?: ExecutionWorkScope) {
|
||||
if (scope) {
|
||||
await notifyRunDispatch(transaction, scope);
|
||||
await notifyStatusCardIssueChange(transaction, scope.companyId);
|
||||
}
|
||||
for (const queue of [DELIVERY_QUEUES.executionStatus, DELIVERY_QUEUES.executionFinalization,
|
||||
DELIVERY_QUEUES.executionReplacement, DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.issueMonitors]) {
|
||||
await notifyDeliveryWork(transaction, queue);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeExecutionControl<T>(
|
||||
db: Db | Parameters<Parameters<Db["transaction"]>[0]>[0],
|
||||
queue: DeliveryQueue | readonly DeliveryQueue[],
|
||||
write: (tx: Db) => PromiseLike<T>,
|
||||
scope: ExecutionWorkScope | undefined,
|
||||
): Promise<T> {
|
||||
return db.transaction(async transaction => {
|
||||
await notifyRunDispatch(transaction, scope);
|
||||
if (scope) await notifyStatusCardIssueChange(transaction, scope.companyId);
|
||||
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.issueMonitors);
|
||||
for (const topic of typeof queue === "string" ? [queue] : queue) await notifyDeliveryWork(transaction, topic);
|
||||
return write(transaction as unknown as Db);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1 +1,671 @@
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
import { notifyStatusCardEvaluation } from "./status-card-notifications.js";
|
||||
import { notifySkillVersionRetentionReconstruction } from "./skill-version-retention-notifications.js";
|
||||
import { notifyNativeEnvironmentSettlement } from "./native-environment-settlement-notifications.js";
|
||||
import { notifyExternalAgentSettlement } from "../modules/external-agents/index.js";
|
||||
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
|
||||
import { notifyNativeRecoveryNotices } from "./native-recovery-notice-notifications.js";
|
||||
import { notifyNativeRunProjection } from "./native-run-projection-notifications.js";
|
||||
import { notifyNativeResultCommit } from "./native-result-commit-notifications.js";
|
||||
import { notifyNativeWorkspaceFinalization } from "./native-workspace-finalization-notifications.js";
|
||||
import { notifyCancelledWorkspacePreservation } from "./cancelled-workspace-preservation-notifications.js";
|
||||
import { notifyInstructionCollection } from "./instruction-collection-notifications.js";
|
||||
import { notifyDirectoryCleanup } from "./directory-cleanup-notifications.js";
|
||||
import { notifyInstructionCommit } from "./instruction-commit-notifications.js";
|
||||
import { notifyScheduledRetryCancellation } from "./scheduled-retry-cancellation-notifications.js";
|
||||
import { notifyVoiceChange } from "./voice/voice-recovery-notifications.js";
|
||||
import { notifyToolConnectionHealth } from "./tool-connection-health-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companies, instanceSettings } from "@paperclipai/db";
|
||||
|
||||
/**
|
||||
* A `Db` or an open transaction handle — the subset of query builders the
|
||||
* settings writes use. Lets `update` run inside a caller's transaction so
|
||||
* it commits atomically with a sibling write.
|
||||
*/
|
||||
type InstanceSettingsTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
export type InstanceSettingsWriteDb = Pick<
|
||||
Db | InstanceSettingsTransaction,
|
||||
"select" | "insert" | "update"
|
||||
>;
|
||||
import {
|
||||
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||
DEFAULT_BACKUP_RETENTION,
|
||||
PAPERCLIP_CLOUD_MANAGED_BY,
|
||||
instanceGeneralSettingsSchema,
|
||||
type InstanceGeneralSettings,
|
||||
instanceExperimentalSettingsSchema,
|
||||
type InstanceExperimentalSettings,
|
||||
type InstanceExperimentalSettingsWithManaged,
|
||||
type ManagedExperimentalFeatureKey,
|
||||
type ManagedSettingMetadata,
|
||||
type PatchInstanceGeneralSettings,
|
||||
type InstanceSettings,
|
||||
type PatchInstanceSettings,
|
||||
type PatchInstanceExperimentalSettings,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
INSTANCE_FEATURE_CATALOG,
|
||||
applyOperatorGeneralDefaults,
|
||||
stripOperatorGeneralEchoes,
|
||||
} from "@paperclipai/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getManagedInstanceConfig, type ManagedInstanceConfig } from "./managed-config.js";
|
||||
import { getOperatorSettingDefaults } from "./setting-defaults.js";
|
||||
import { isCloudStagingExperimentalSetting, isCloudStagingInstance } from "./cloud-instance.js";
|
||||
import { forbidden } from "../errors.js";
|
||||
|
||||
const DEFAULT_SINGLETON_KEY = "default";
|
||||
const instanceGeneralSettingsStorageSchema = instanceGeneralSettingsSchema.strip();
|
||||
const instanceExperimentalSettingsStorageSchema = instanceExperimentalSettingsSchema.strip();
|
||||
const TRUTHY_RUNTIME_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
|
||||
|
||||
interface InstanceSettingsServiceOptions {
|
||||
runtimeEnv?: Record<string, string | undefined>;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
type WorktreeRunExecutionSuppressedReason =
|
||||
| "not_worktree_runtime"
|
||||
| "flag_disabled"
|
||||
| "missing_cutoff"
|
||||
| "missing_instance_id"
|
||||
| "instance_id_mismatch"
|
||||
| "settings_read_error";
|
||||
|
||||
export type WorktreeRunExecutionActivationState =
|
||||
| {
|
||||
armed: true;
|
||||
cutoff: string;
|
||||
activationInstanceId: string;
|
||||
reason: null;
|
||||
}
|
||||
| {
|
||||
armed: false;
|
||||
cutoff: null;
|
||||
activationInstanceId: string | null;
|
||||
reason: WorktreeRunExecutionSuppressedReason;
|
||||
};
|
||||
|
||||
export function isTruthyRuntimeEnvValue(value: string | undefined) {
|
||||
return typeof value === "string" && TRUTHY_RUNTIME_ENV_VALUES.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function getRuntimeInstanceId(env: Record<string, string | undefined>) {
|
||||
const instanceId = env.PAPERCLIP_INSTANCE_ID?.trim();
|
||||
return instanceId ? instanceId : null;
|
||||
}
|
||||
|
||||
function stripServerManagedExperimentalPatchFields(
|
||||
patch: PatchInstanceExperimentalSettings | Record<string, unknown>,
|
||||
): PatchInstanceExperimentalSettings {
|
||||
const {
|
||||
worktreeRunExecutionActivatedAt: _ignoredActivatedAt,
|
||||
worktreeRunExecutionActivationInstanceId: _ignoredActivationInstanceId,
|
||||
...patchable
|
||||
} = patch as Record<string, unknown>;
|
||||
return patchable as PatchInstanceExperimentalSettings;
|
||||
}
|
||||
|
||||
export function applyExperimentalSettingsPatch(
|
||||
current: unknown,
|
||||
patch: PatchInstanceExperimentalSettings | Record<string, unknown>,
|
||||
options: InstanceSettingsServiceOptions = {},
|
||||
): InstanceExperimentalSettings {
|
||||
const previousExperimental = normalizeExperimentalSettings(current);
|
||||
const patchable = stripServerManagedExperimentalPatchFields(patch);
|
||||
const nextExperimental = normalizeExperimentalSettings({
|
||||
...previousExperimental,
|
||||
...patchable,
|
||||
});
|
||||
const hasWorktreeRunExecutionPatch = Object.prototype.hasOwnProperty.call(
|
||||
patchable,
|
||||
"enableWorktreeRunExecution",
|
||||
);
|
||||
|
||||
if (!hasWorktreeRunExecutionPatch) {
|
||||
return nextExperimental;
|
||||
}
|
||||
|
||||
if (nextExperimental.enableWorktreeRunExecution !== true) {
|
||||
return {
|
||||
...nextExperimental,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (previousExperimental.enableWorktreeRunExecution === true) {
|
||||
return nextExperimental;
|
||||
}
|
||||
|
||||
const runtimeEnv = options.runtimeEnv ?? process.env;
|
||||
if (!isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE)) {
|
||||
return nextExperimental;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextExperimental,
|
||||
worktreeRunExecutionActivatedAt: (options.now ?? (() => new Date()))().toISOString(),
|
||||
worktreeRunExecutionActivationInstanceId: getRuntimeInstanceId(runtimeEnv),
|
||||
};
|
||||
}
|
||||
|
||||
function suppressWorktreeRunExecution(
|
||||
reason: WorktreeRunExecutionSuppressedReason,
|
||||
activationInstanceId: string | null = null,
|
||||
): WorktreeRunExecutionActivationState {
|
||||
return {
|
||||
armed: false,
|
||||
cutoff: null,
|
||||
activationInstanceId,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWorktreeRunExecutionActivation(
|
||||
experimental: InstanceExperimentalSettings,
|
||||
currentInstanceId: string | null | undefined,
|
||||
): WorktreeRunExecutionActivationState {
|
||||
if (experimental.enableWorktreeRunExecution !== true) {
|
||||
return suppressWorktreeRunExecution(
|
||||
"flag_disabled",
|
||||
experimental.worktreeRunExecutionActivationInstanceId,
|
||||
);
|
||||
}
|
||||
if (!experimental.worktreeRunExecutionActivatedAt) {
|
||||
return suppressWorktreeRunExecution(
|
||||
"missing_cutoff",
|
||||
experimental.worktreeRunExecutionActivationInstanceId,
|
||||
);
|
||||
}
|
||||
if (!currentInstanceId) {
|
||||
return suppressWorktreeRunExecution(
|
||||
"missing_instance_id",
|
||||
experimental.worktreeRunExecutionActivationInstanceId,
|
||||
);
|
||||
}
|
||||
if (experimental.worktreeRunExecutionActivationInstanceId !== currentInstanceId) {
|
||||
return suppressWorktreeRunExecution(
|
||||
"instance_id_mismatch",
|
||||
experimental.worktreeRunExecutionActivationInstanceId,
|
||||
);
|
||||
}
|
||||
return {
|
||||
armed: true,
|
||||
cutoff: experimental.worktreeRunExecutionActivatedAt,
|
||||
activationInstanceId: currentInstanceId,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveWorktreeRunExecutionActivationState(options: {
|
||||
getExperimental: () => Promise<InstanceExperimentalSettings>;
|
||||
runtimeEnv?: Record<string, string | undefined>;
|
||||
}): Promise<WorktreeRunExecutionActivationState> {
|
||||
const runtimeEnv = options.runtimeEnv ?? process.env;
|
||||
if (!isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE)) {
|
||||
return suppressWorktreeRunExecution("not_worktree_runtime");
|
||||
}
|
||||
try {
|
||||
return resolveWorktreeRunExecutionActivation(
|
||||
await options.getExperimental(),
|
||||
getRuntimeInstanceId(runtimeEnv),
|
||||
);
|
||||
} catch {
|
||||
return suppressWorktreeRunExecution("settings_read_error");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
|
||||
const parsed = instanceGeneralSettingsStorageSchema.safeParse(raw ?? {});
|
||||
if (parsed.success) {
|
||||
return {
|
||||
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
|
||||
feedbackDataSharingPreference:
|
||||
parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||
backupRetention: parsed.data.backupRetention ?? DEFAULT_BACKUP_RETENTION,
|
||||
// Absent => unrestricted; only carry through an explicit policy.
|
||||
...(parsed.data.executionMode ? { executionMode: parsed.data.executionMode } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||
backupRetention: DEFAULT_BACKUP_RETENTION,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings {
|
||||
const parsed = instanceExperimentalSettingsStorageSchema.safeParse(raw ?? {});
|
||||
if (parsed.success) {
|
||||
return {
|
||||
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
||||
enableNativeRunner: parsed.data.enableNativeRunner ?? true,
|
||||
enableAiConnectionRouters: parsed.data.enableAiConnectionRouters ?? false,
|
||||
enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false,
|
||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||
enableIsolatedWorkspacesByDefault: parsed.data.enableIsolatedWorkspacesByDefault ?? false,
|
||||
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true,
|
||||
enableStreamlinedUi: parsed.data.enableStreamlinedUi ?? true,
|
||||
// Apps graduated from Experimental. Ignore historical off values while
|
||||
// continuing to accept the compatibility key in stored settings.
|
||||
enableApps: true,
|
||||
enableMcpAggregators: true,
|
||||
enablePublicMcp: parsed.data.enablePublicMcp ?? false,
|
||||
enableOpenAiDot: parsed.data.enableOpenAiDot ?? false,
|
||||
enableMuse: parsed.data.enableMuse ?? false,
|
||||
enableGrokBot: parsed.data.enableGrokBot ?? false,
|
||||
enableChatConnectors: parsed.data.enableChatConnectors ?? false,
|
||||
enableGitHubReviewBots: parsed.data.enableGitHubReviewBots ?? false,
|
||||
enableMemoryConnectors: parsed.data.enableMemoryConnectors ?? false,
|
||||
enablePaperclipNotebook: parsed.data.enablePaperclipNotebook ?? false,
|
||||
enableCases: parsed.data.enableCases ?? false,
|
||||
enableAgentChat: parsed.data.enableAgentChat ?? false,
|
||||
enableCombinedInboxTasks: parsed.data.enableCombinedInboxTasks ?? false,
|
||||
enableArtifactsV1: parsed.data.enableArtifactsV1 ?? false,
|
||||
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
|
||||
enableClassicTaskInterface: parsed.data.enableClassicTaskInterface ?? false,
|
||||
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
|
||||
enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false,
|
||||
enableExternalObjects: parsed.data.enableExternalObjects ?? false,
|
||||
enableSmokeLab: parsed.data.enableSmokeLab ?? false,
|
||||
enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false,
|
||||
enableBetaSkills: parsed.data.enableBetaSkills ?? false,
|
||||
enableSummaries: parsed.data.enableSummaries ?? false,
|
||||
enableStatusCards: parsed.data.enableStatusCards ?? false,
|
||||
enableDecisions: parsed.data.enableDecisions ?? false,
|
||||
enableFastResponses: parsed.data.enableFastResponses ?? false,
|
||||
enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false,
|
||||
enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false,
|
||||
enablePaperclipDeveloperMode: parsed.data.enablePaperclipDeveloperMode ?? false,
|
||||
enableSimplifiedEnglishInteractions: parsed.data.enableSimplifiedEnglishInteractions ?? false,
|
||||
enableFirstTaskPlanProposal: parsed.data.enableFirstTaskPlanProposal ?? false,
|
||||
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
||||
enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? true,
|
||||
enableWorkspaceDirtyQuarantineRepair: parsed.data.enableWorkspaceDirtyQuarantineRepair ?? true,
|
||||
enableOwnerInstanceAdmin: parsed.data.enableOwnerInstanceAdmin ?? false,
|
||||
enableSandboxDuplexBridge: parsed.data.enableSandboxDuplexBridge ?? false,
|
||||
enableRunnerPreviewIngress: parsed.data.enableRunnerPreviewIngress ?? false,
|
||||
enableWorktreeRunExecution: parsed.data.enableWorktreeRunExecution ?? false,
|
||||
worktreeRunExecutionActivatedAt: parsed.data.worktreeRunExecutionActivatedAt ?? null,
|
||||
worktreeRunExecutionActivationInstanceId:
|
||||
parsed.data.worktreeRunExecutionActivationInstanceId ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
enableEnvironments: false,
|
||||
enableNativeRunner: true,
|
||||
enableAiConnectionRouters: false,
|
||||
enableManagedSandboxOnly: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableIsolatedWorkspacesByDefault: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
enableStreamlinedUi: true,
|
||||
enableApps: true,
|
||||
enableMcpAggregators: true,
|
||||
enablePublicMcp: false,
|
||||
enableOpenAiDot: false,
|
||||
enableMuse: false,
|
||||
enableGrokBot: false,
|
||||
enableChatConnectors: false,
|
||||
enableGitHubReviewBots: false,
|
||||
enableMemoryConnectors: false,
|
||||
enablePaperclipNotebook: false,
|
||||
enableCases: false,
|
||||
enableAgentChat: false,
|
||||
enableCombinedInboxTasks: false,
|
||||
enableArtifactsV1: false,
|
||||
enableConferenceRoomChat: false,
|
||||
enableClassicTaskInterface: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableExternalObjects: false,
|
||||
enableSmokeLab: false,
|
||||
enableBuiltInAgents: false,
|
||||
enableBetaSkills: false,
|
||||
enableSummaries: false,
|
||||
enableStatusCards: false,
|
||||
enableDecisions: false,
|
||||
enableFastResponses: false,
|
||||
enableGoalsSidebarLink: false,
|
||||
enableServerInfoDebugView: false,
|
||||
enablePaperclipDeveloperMode: false,
|
||||
enableSimplifiedEnglishInteractions: false,
|
||||
enableFirstTaskPlanProposal: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: true,
|
||||
enableOwnerInstanceAdmin: false,
|
||||
enableSandboxDuplexBridge: false,
|
||||
enableRunnerPreviewIngress: false,
|
||||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export type ManagedExperimentalKeyMetadata = Partial<
|
||||
Record<ManagedExperimentalFeatureKey, ManagedSettingMetadata>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Overlay the cloud managed-config feature values over normalized settings.
|
||||
*
|
||||
* Read-time precedence: code floor (cloud) > managed overlay > tenant DB
|
||||
* value > schema default. (No code floors are expressed as flags today —
|
||||
* floors are enforced in code at the guarded routes, independent of any
|
||||
* flag value.) The overlay is deliberately never persisted: it re-asserts on
|
||||
* every read, so a DB restore or manual row edit cannot resurrect a
|
||||
* capability the harness has disabled.
|
||||
*/
|
||||
export function applyManagedExperimentalOverlay(
|
||||
experimental: InstanceExperimentalSettings,
|
||||
managedConfig: ManagedInstanceConfig | null,
|
||||
): { experimental: InstanceExperimentalSettings; managedKeys: ManagedExperimentalKeyMetadata } {
|
||||
if (!managedConfig) return { experimental, managedKeys: {} };
|
||||
const next: InstanceExperimentalSettings = { ...experimental };
|
||||
const managedKeys: ManagedExperimentalKeyMetadata = {};
|
||||
for (const [key, value] of Object.entries(managedConfig.features) as Array<
|
||||
[ManagedExperimentalFeatureKey, boolean]
|
||||
>) {
|
||||
// Existing Cloud stack configs may still carry retired flags. Accept the
|
||||
// document during rollout, but never let retired flags disable Apps or MCP aggregators.
|
||||
if (key === "enableApps" || key === "enableMcpAggregators") continue;
|
||||
next[key] = value;
|
||||
managedKeys[key] = { managed: true, managedBy: PAPERCLIP_CLOUD_MANAGED_BY };
|
||||
}
|
||||
return { experimental: next, managedKeys };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep self-hosted-only defaults out of Cloud.
|
||||
*
|
||||
* The experimental schema carries one default per flag, and the feature
|
||||
* catalog pins it to `selfHostedDefault`. A flag that is on by default for
|
||||
* self-hosted but off by default for Cloud (`selfHostedDefault: true`,
|
||||
* `cloudDefault: false`) would therefore normalize to "on" for a managed
|
||||
* instance whose tenant row and managed overlay both leave it unset. Re-assert
|
||||
* the declared Cloud default for exactly those flags. An explicit tenant value
|
||||
* or a managed feature value still wins (the overlay is applied afterwards).
|
||||
*/
|
||||
export function applyCloudCatalogDefaults(
|
||||
experimental: InstanceExperimentalSettings,
|
||||
rawStored: unknown,
|
||||
managedConfig: ManagedInstanceConfig | null,
|
||||
): InstanceExperimentalSettings {
|
||||
if (!managedConfig) return experimental;
|
||||
const stored =
|
||||
rawStored && typeof rawStored === "object" && !Array.isArray(rawStored)
|
||||
? (rawStored as Record<string, unknown>)
|
||||
: {};
|
||||
const next: InstanceExperimentalSettings = { ...experimental };
|
||||
for (const [key, entry] of Object.entries(INSTANCE_FEATURE_CATALOG)) {
|
||||
if (entry.cloudDefault !== false || entry.selfHostedDefault !== true) continue;
|
||||
if (typeof stored[key] === "boolean") continue;
|
||||
if (typeof managedConfig.features[key as ManagedExperimentalFeatureKey] === "boolean") continue;
|
||||
(next as unknown as Record<string, unknown>)[key] = false;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the write path from freezing a self-hosted default into a Cloud row.
|
||||
*
|
||||
* `updateExperimental` persists the whole normalized object, and the schema
|
||||
* normalizes an omitted flag to its self-hosted default. Without this step an
|
||||
* unrelated experimental write (say, turning on Cases) would store
|
||||
* `enableNativeRunner: true` on a managed instance whose tenant row had never
|
||||
* mentioned the flag; every later read would then treat the stored boolean as
|
||||
* an explicit tenant choice and stop re-asserting the Cloud default.
|
||||
*
|
||||
* For each guarded flag (see `applyCloudCatalogDefaults`), the stored key is
|
||||
* left absent unless the tenant already stored a boolean or this patch sets
|
||||
* the flag to something other than the Cloud default. A patch value equal to
|
||||
* the Cloud default is a full-GET echo of the read-time overlay, not a
|
||||
* choice, and is stripped the same way `stripOperatorGeneralEchoes` treats
|
||||
* operator defaults. Self-hosted rows are returned untouched.
|
||||
*/
|
||||
export function stripCloudCatalogDefaultEchoes(
|
||||
rawStored: unknown,
|
||||
patch: PatchInstanceExperimentalSettings | Record<string, unknown>,
|
||||
next: InstanceExperimentalSettings,
|
||||
managedConfig: ManagedInstanceConfig | null,
|
||||
): Partial<InstanceExperimentalSettings> {
|
||||
if (!managedConfig) return next;
|
||||
const stored =
|
||||
rawStored && typeof rawStored === "object" && !Array.isArray(rawStored)
|
||||
? (rawStored as Record<string, unknown>)
|
||||
: {};
|
||||
const patchRecord = patch as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = { ...next };
|
||||
for (const [key, entry] of Object.entries(INSTANCE_FEATURE_CATALOG)) {
|
||||
if (entry.cloudDefault !== false || entry.selfHostedDefault !== true) continue;
|
||||
if (typeof stored[key] === "boolean") continue;
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(patchRecord, key) &&
|
||||
typeof patchRecord[key] === "boolean" &&
|
||||
patchRecord[key] !== entry.cloudDefault
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
delete result[key];
|
||||
}
|
||||
return result as Partial<InstanceExperimentalSettings>;
|
||||
}
|
||||
|
||||
export function instanceSettingsService(db: Db, options: InstanceSettingsServiceOptions = {}) {
|
||||
// Fail closed: a malformed PAPERCLIP_MANAGED_CONFIG throws here (and at
|
||||
// boot in index.ts) rather than silently running without the overlay.
|
||||
const managedConfig = getManagedInstanceConfig(options.runtimeEnv ?? process.env);
|
||||
const staging = isCloudStagingInstance(options.runtimeEnv ?? process.env);
|
||||
const enforcedManagedConfig = staging && managedConfig
|
||||
? {
|
||||
...managedConfig,
|
||||
features: Object.fromEntries(Object.entries(managedConfig.features)
|
||||
.filter(([key, enabled]) => enabled || !isCloudStagingExperimentalSetting(key))),
|
||||
}
|
||||
: managedConfig;
|
||||
// Same posture for PAPERCLIP_SETTING_DEFAULTS: parsed once, applied per
|
||||
// read, never persisted (see applyOperatorGeneralDefaults) — including on
|
||||
// the write path, where a full-GET echo of the overlaid value is stripped
|
||||
// back to the schema default (see stripOperatorGeneralEchoes).
|
||||
const operatorDefaults = getOperatorSettingDefaults(options.runtimeEnv ?? process.env);
|
||||
|
||||
function toGeneralView(raw: unknown): InstanceGeneralSettings {
|
||||
return applyOperatorGeneralDefaults(normalizeGeneralSettings(raw), operatorDefaults);
|
||||
}
|
||||
|
||||
function toExperimentalView(raw: unknown): InstanceExperimentalSettingsWithManaged {
|
||||
const stored = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
||||
const defaults = staging ? { ...managedConfig?.features, ...stored } : raw;
|
||||
const { experimental, managedKeys } = applyManagedExperimentalOverlay(
|
||||
applyCloudCatalogDefaults(normalizeExperimentalSettings(defaults), defaults, managedConfig),
|
||||
enforcedManagedConfig,
|
||||
);
|
||||
// Self-hosted responses stay byte-identical: no managedKeys field at all.
|
||||
return managedConfig ? { ...experimental, managedKeys } : experimental;
|
||||
}
|
||||
|
||||
function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings {
|
||||
return {
|
||||
id: row.id,
|
||||
defaultEnvironmentId: row.defaultEnvironmentId ?? null,
|
||||
general: toGeneralView(row.general),
|
||||
experimental: toExperimentalView(row.experimental),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
} as InstanceSettings;
|
||||
}
|
||||
async function getOrCreateRow(runner: InstanceSettingsWriteDb = db) {
|
||||
const existing = await runner
|
||||
.select()
|
||||
.from(instanceSettings)
|
||||
.where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) return existing;
|
||||
|
||||
const now = new Date();
|
||||
const [created] = await runner
|
||||
.insert(instanceSettings)
|
||||
.values({
|
||||
singletonKey: DEFAULT_SINGLETON_KEY,
|
||||
general: {},
|
||||
experimental: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [instanceSettings.singletonKey],
|
||||
set: {
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (created) return created;
|
||||
|
||||
const raced = await runner
|
||||
.select()
|
||||
.from(instanceSettings)
|
||||
.where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (raced) return raced;
|
||||
|
||||
throw new Error("Failed to initialize instance settings row");
|
||||
}
|
||||
|
||||
return {
|
||||
get: async (): Promise<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
|
||||
|
||||
update: async (
|
||||
patch: PatchInstanceSettings,
|
||||
writeOptions?: { db?: InstanceSettingsWriteDb },
|
||||
): Promise<InstanceSettings> => {
|
||||
// The write may run inside a caller-supplied transaction so it commits
|
||||
// atomically with a sibling write (e.g. clearing the managed-default
|
||||
// stamp on the environment row alongside a defaultEnvironmentId
|
||||
// change). Reads use the same runner so the row is visible to the tx.
|
||||
const runner = writeOptions?.db ?? db;
|
||||
const current = await getOrCreateRow(runner);
|
||||
const now = new Date();
|
||||
const [updated] = await runner
|
||||
.update(instanceSettings)
|
||||
.set({
|
||||
...(Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId")
|
||||
? { defaultEnvironmentId: patch.defaultEnvironmentId ?? null }
|
||||
: {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(instanceSettings.id, current.id))
|
||||
.returning();
|
||||
return toInstanceSettings(updated ?? current);
|
||||
},
|
||||
|
||||
getGeneral: async (
|
||||
readOptions?: { db?: InstanceSettingsWriteDb },
|
||||
): Promise<InstanceGeneralSettings> => {
|
||||
const row = await getOrCreateRow(readOptions?.db);
|
||||
return toGeneralView(row.general);
|
||||
},
|
||||
|
||||
getExperimental: async (): Promise<InstanceExperimentalSettingsWithManaged> => {
|
||||
const row = await getOrCreateRow();
|
||||
return toExperimentalView(row.experimental);
|
||||
},
|
||||
|
||||
updateGeneral: async (patch: PatchInstanceGeneralSettings): Promise<InstanceSettings> => {
|
||||
const current = await getOrCreateRow();
|
||||
const storedGeneral = normalizeGeneralSettings(current.general);
|
||||
// A full-GET echo carries the overlaid operator value for a field the
|
||||
// user never chose; stripping it keeps the overlay strictly read-time,
|
||||
// so changing or unsetting the variable later still takes effect.
|
||||
const nextGeneral = stripOperatorGeneralEchoes(
|
||||
storedGeneral,
|
||||
normalizeGeneralSettings({ ...storedGeneral, ...patch }),
|
||||
operatorDefaults,
|
||||
);
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(instanceSettings)
|
||||
.set({
|
||||
general: { ...nextGeneral },
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(instanceSettings.id, current.id))
|
||||
.returning();
|
||||
return toInstanceSettings(updated ?? current);
|
||||
},
|
||||
|
||||
updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise<InstanceSettings> => {
|
||||
for (const [key, enabled] of Object.entries(managedConfig?.features ?? {})) {
|
||||
if (enabled && patch[key as ManagedExperimentalFeatureKey] === false) {
|
||||
throw forbidden(`${key} is enabled by Paperclip Cloud and cannot be disabled on this instance`, {
|
||||
code: "settings_operator_managed",
|
||||
});
|
||||
}
|
||||
}
|
||||
const current = await getOrCreateRow();
|
||||
// Guarded Cloud flags stay absent from the row unless chosen, so the
|
||||
// read-time catalog default keeps applying (see stripCloudCatalogDefaultEchoes).
|
||||
const nextExperimental = staging
|
||||
? applyExperimentalSettingsPatch(toExperimentalView(current.experimental), patch, options)
|
||||
: stripCloudCatalogDefaultEchoes(
|
||||
current.experimental,
|
||||
patch,
|
||||
applyExperimentalSettingsPatch(current.experimental, patch, options),
|
||||
managedConfig,
|
||||
);
|
||||
if (staging) {
|
||||
const stored = current.experimental as Record<string, unknown> | null;
|
||||
for (const key of Object.keys(INSTANCE_FEATURE_CATALOG) as ManagedExperimentalFeatureKey[]) {
|
||||
if (typeof stored?.[key] !== "boolean" && !Object.prototype.hasOwnProperty.call(patch, key)) {
|
||||
delete nextExperimental[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
const now = new Date();
|
||||
const [updated] = await db.transaction(async tx => {
|
||||
if (patch.enableStatusCards !== undefined) await notifyStatusCardEvaluation(tx);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.mcpEvents, "*");
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.dotEvents, "*");
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.grokDispatch);
|
||||
if (patch.enableWorktreeRunExecution !== undefined) await notifyScheduledRetryCancellation(tx);
|
||||
if (patch.enableWorktreeRunExecution !== undefined) {
|
||||
await notifyRunDispatch(tx);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyExternalAgentSettlement(tx);
|
||||
await notifyInstructionCommit(tx);
|
||||
await notifyDirectoryCleanup(tx);
|
||||
await notifySkillVersionRetentionReconstruction(tx);
|
||||
await notifyInstructionCollection(tx);
|
||||
await notifyCancelledWorkspacePreservation(tx);
|
||||
await notifyExternalConversationSettlement(tx);
|
||||
await notifyNativeRecoveryNotices(tx);
|
||||
await notifyNativeRunProjection(tx);
|
||||
await notifyNativeEnvironmentSettlement(tx);
|
||||
await notifyNativeResultCommit(tx);
|
||||
await notifyNativeWorkspaceFinalization(tx);
|
||||
}
|
||||
if (patch.enableChatConnectors !== undefined) await notifyVoiceChange(tx);
|
||||
await notifyToolConnectionHealth(tx, "*");
|
||||
return tx
|
||||
.update(instanceSettings)
|
||||
.set({
|
||||
experimental: { ...nextExperimental },
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(instanceSettings.id, current.id))
|
||||
.returning();
|
||||
});
|
||||
return toInstanceSettings(updated ?? current);
|
||||
},
|
||||
|
||||
listCompanyIds: async (): Promise<string[]> =>
|
||||
db
|
||||
.select({ id: companies.id })
|
||||
.from(companies)
|
||||
.then((rows) => rows.map((row) => row.id)),
|
||||
};
|
||||
}
|
||||
@@ -1 +1,15 @@
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
|
||||
import { notifyNativeReviewResponse } from "./native-review-response-notifications.js";
|
||||
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
|
||||
|
||||
export async function notifyIssueReviewState(tx: object, scope: { companyId: string; issueId?: string; runId?: string; agentId?: string }) {
|
||||
await notifyRunDispatch(tx, scope);
|
||||
await notifyStatusCardIssueChange(tx, scope.companyId);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyExternalConversationSettlement(tx, scope);
|
||||
await notifyNativeReviewResponse(tx, scope);
|
||||
await notifyNativeStatusReassessment(tx, scope);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user