Files
PaperClipAI/server/src/services/instance-settings.ts
T
5acf56658b feat(onboarding): first task opens as a chat with a chief of staff (#13068)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Onboarding ends by handing a new user to their first agent on a
seeded first task
> - Today the wizard asks for a mission up front, the UI composes what
the agent is told, and the agent starts running before the user says
anything
> - New users get a cold, ticket-shaped start, and nobody can edit the
agent's brief or persona without a code change
> - This pull request makes the first task a short chat: a four-step
wizard, a chief-of-staff persona, a greeting plus a two-option opening
card, server-owned markdown texts, and no run until the user answers
> - It also gives question cards one consistent action row (Cancel /
Skip / Next), makes agent hires idempotent within a run, and turns the
Paperclip Runner flag on by default for self-hosted instances
> - The benefit is a first run the user steers, with texts a board
operator can edit as markdown

## Linked Issues or Issue Description

No public GitHub issue exists for this change. The feature request
fields follow.

Related PRs and issues:

- Refs #11043 — an earlier draft of the first-task onboarding
experience. This PR supersedes it.
- Refs #11280 — a report about the onboarding first-task route test.
This PR extends that test file.

### Subsystem affected

Onboarding wizard, the seeded first task and its texts, task-chat
question cards, agent hiring, and the instance experimental settings.

### Problem or motivation

The onboarding wizard collects a mission through two extra steps and a
questionnaire. The UI then composes the first agent's instructions and
the first task description from those answers. The first task wakes the
agent at once, so the agent runs and posts before the user types a word.
Board operators cannot change the greeting, the brief, or the persona
without editing TypeScript. Question cards in chat behave differently
per adapter, and a single-select pick submits on click. A misread hire
response could create a duplicate agent that the creating agent cannot
remove.

### Proposed solution

Reduce the wizard to four steps and stop the UI from authoring agent
texts. Move the greeting, the brief, the chief-of-staff persona, and the
opening question into markdown and JSON files that the server loads at
runtime. Seed the persona onto the first agent through an explicit hire
marker. Do not wake the first task until the user answers the opening
card or types. Give every question card the same Cancel / Skip / Next
actions. Add an experimental toggle that switches the single-task
proposal between one confirmation card and a plan document with a
checkbox card. Make agent hires idempotent within a run.

### Alternatives considered

- Keep the mission questionnaire and feed it into the brief. Rejected:
the agent asks better questions in chat, and the wizard gets shorter.
- Keep the first task open-ended with a plain composer. Rejected: a
two-option card gives the user a clear first move.
- Derive the plan-document behaviour from the user's intent only.
Rejected in favour of an explicit experimental toggle so operators can
choose.
- Key the "pick does not submit" behaviour off the presence of a submit
label. Rejected: several adapters set a submit label on single-select
cards, and their cards would change behaviour.

### Roadmap alignment

`ROADMAP.md` lists no planned core work on onboarding or the first task.
This change refines the existing flow and does not duplicate planned
work.

## What Changed

- Wizard: four steps (Name your organization, Create your first agent,
Connect a model, Review). The front door and both mission steps are
removed with their state and saved-progress keys. The UI no longer
composes the first agent's instructions or the first task description.
- Server-owned texts: the greeting, the brief with two proposal
variants, the chief-of-staff persona, the opening question, and a README
live in `server/src/onboarding-assets/first-task/` and load at runtime.
The create route stores the assembled brief and ignores any client
description.
- Persona seed: an `onboardingFirstAgent` marker on the hire lets the
server seed the chief-of-staff persona over the first agent's entry
file. Board-authored hires only. The persona tells the agent the hire
response shape and to list agents before it acts on an unclear result.
- No auto-run: the first task does not queue an assignment wake. The
stranded-assignment reconciler leaves it idle until a user comment or an
answered card exists.
- Opening card: the server seeds an `ask_user_questions` card right
after the greeting with two options: "Interview me and propose a plan
and an agent team to execute it." and "I have a task in mind" with free
text. Answering wakes the agent.
- Experimental toggle `enableFirstTaskPlanProposal` (default off): the
single-task proposal is one confirmation card, or a plan document plus a
checkbox card when on.
- Question cards: every `ask_user_questions` card renders Cancel, Skip,
and Next (the submit label on the last question). Skip hides on required
questions. Picking an option no longer advances or submits by itself.
- Wizard guards: the dashboard's agentless offer ignores a cached empty
agent list while a refetch is in flight. The hire step adopts an agent
that already carries the typed name instead of hiring "Name 2".
- Agent hires are idempotent within a run: a retry of the identical
request under the same run id returns the existing agent with `200` and
`idempotent: true`. The fingerprint covers the whole validated request,
so a corrected payload is a new hire. Lookup, create, and activity
record run under one lock per company and run, so overlapping retries
cannot both create.
- The Paperclip Runner experimental flag defaults to on for self-hosted
instances. Cloud keeps its declared default: a managed instance whose
tenant row and managed overlay omit the flag resolves it to off.
- Question cards: a send that finds an earlier required answer missing
returns to that question with a message instead of failing silently.
- The two onboarding e2e specs follow the new wizard: the front door and
growth intake shots are gone, and the planning-mode spec dismisses the
opening card before it reads the composer.
- Docs: `docs/board-operator/editing-first-task-texts.md` explains how
to edit the texts and the toggle.

## Verification

Commands, run from the repo root:

```
pnpm -r --filter './packages/*' --filter '!@paperclipai/paperclip-runner' build
pnpm --filter ./packages/shared typecheck
pnpm --filter ./ui typecheck
pnpm --filter ./server exec tsc --noEmit
pnpm check:token-gates
pnpm --filter ./ui exec vitest run OnboardingWizard onboarding QuestionForm InteractionCard ProtocolCard TaskChatComposer Dashboard feature
PAPERCLIP_IN_WORKTREE=false pnpm --filter ./server exec vitest run onboarding-first-task heartbeat-process-recovery agent-hire-idempotency instance-settings agent-skills-routes issue-onboarding onboarding-greeting --testTimeout=90000
```

Results on this branch:

- Typecheck is clean for shared, ui, and server.
- Token gates: 4 of 4 clean.
- UI: 344 tests pass across 23 files.
- Server: all suites pass. The first test in `agent-skills-routes` has
its own 10 s cap and needs about 15 s on my laptop for the app cold
start. It passes with a longer cap. This PR does not change that cap.

Manual steps on a dev instance:

1. Open `/onboarding`. Confirm four steps: Name your organization,
Create your first agent, Connect a model, Review.
2. Finish the wizard. Confirm the first task shows the chief-of-staff
greeting and the opening card with two options. Confirm no run starts.
3. Pick "Interview me…". Confirm no run starts. Press Continue. Confirm
a run starts and an interview card of 3–4 questions arrives.
4. On a fresh organization, pick "I have a task in mind", type a task,
and press Continue. Confirm a proposal arrives as one confirmation card.
5. Turn on Settings → Experimental → "First task: propose with a plan
document" and repeat step 4. Confirm a plan document and a checkbox card
arrive.
6. Visit the dashboard after the hire. Confirm the wizard does not
reopen and one agent exists.
7. Open any question card. Confirm Cancel returns the plain composer
with the card still pending, Skip advances an optional question, and
Next moves to the next question.

Design reference with flow diagrams, chat mock-ups, and live captures:
https://pages.paperclip.ing/first-task-flow/proposed/

## Risks

- `pnpm dev` now builds the runner daemon because the Paperclip Runner
flag is on by default. Developers without a Rust toolchain must set
`PAPERCLIP_RUNNER_BINARY` or turn the flag off. Self-hosted instances
that never set the flag now let qualified agents use the runner.
- The wizard drops the mission steps and their saved-progress keys. A
user who is mid-wizard on an older build restarts at step 1 after an
upgrade. Existing organizations are not touched.
- The first task no longer runs on its own. A user who neither answers
the card nor types sees no agent activity. This is intended.
- The persona seed applies only to hires that carry the marker from the
wizard. API hires are unchanged.
- Hire idempotency is scoped to one run id and to the exact request.
Retries across runs, or with a changed payload, still create a second
agent. The lock is per server process, which matches how an instance
serves its API.
- Single-select question cards no longer submit on pick. Users of
adapters that relied on that behaviour now press Next.
- No database migrations.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude (Anthropic) through Claude Code. `claude-fable-5-1` with
extended thinking, tool use, and code execution wrote most commits.
`claude-opus-4-8` wrote the toggle, texts, wizard, and idempotency
commits, as the `Co-Authored-By` trailers show.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 20:19:14 -07:00

572 lines
22 KiB
TypeScript

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";
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,
keyboardShortcuts: parsed.data.keyboardShortcuts ?? 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,
keyboardShortcuts: 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,
enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false,
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? 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,
enablePipelines: parsed.data.enablePipelines ?? false,
enableCases: parsed.data.enableCases ?? 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,
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,
enableManagedSandboxOnly: false,
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: true,
enableStreamlinedUi: true,
enableApps: true,
enablePipelines: false,
enableCases: false,
enableConferenceRoomChat: false,
enableClassicTaskInterface: false,
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableSmokeLab: false,
enableBuiltInAgents: false,
enableBetaSkills: false,
enableSummaries: false,
enableStatusCards: false,
enableDecisions: 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 enableApps. Accept the
// document during rollout, but never let the retired flag disable Apps.
if (key === "enableApps") 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 pipelines) 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);
// 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 { experimental, managedKeys } = applyManagedExperimentalOverlay(
applyCloudCatalogDefaults(normalizeExperimentalSettings(raw), raw, managedConfig),
managedConfig,
);
// 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 (): Promise<InstanceGeneralSettings> => {
const row = await getOrCreateRow();
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> => {
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 = stripCloudCatalogDefaultEchoes(
current.experimental,
patch,
applyExperimentalSettingsPatch(current.experimental, patch, options),
managedConfig,
);
const now = new Date();
const [updated] = await db
.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)),
};
}