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:
DottaandPaperclip committed 2026-10-30 18:17:28 -05:00
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
+561
View File
@@ -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
+670
View File
@@ -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