mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents produce artifacts: documents, files, pictures, video and links. The app shows them on the Artifacts page, in the right panel's Artifacts tab and in the task thread > - The artifact directions lab (#17213) settled a presentation for those surfaces, MVP V1, drawn from the PCLP-Core mocks. It is a reference, not product code > - Shipping it means product components on product data, token-only, behind a switch, one surface at a time, so the current presentation stays the default until the new one has been used > - This pull request adds the switch (Settings > Experimental > Artifacts V1), the `--artifact-*` tokens, the V1 primitives as product components, and the V1 presentation on all three surfaces behind the switch: the Artifacts page, the panel's Artifacts tab and the task thread > - The benefit is the redesign in the product, reversible by one setting, with every existing behaviour of those surfaces kept ## Linked Issues or Issue Description No public issue exists. This section follows the feature request template; the plan is `doc/plans/2026-10-22-artifacts-mvp-v1.md`. **Subsystem affected** The web UI (`ui/`): the Artifacts page, the right panel's Artifacts tab and the task thread. The experimental settings contract in `packages/shared` and `server` gains one boolean. **Problem or motivation** The three artifact surfaces grew separately and their artifact presentation is dated and inconsistent. The lab produced a measured direction; nothing in the product uses it yet. **Proposed solution** An experimental setting, `enableArtifactsV1`, default off, with the same plumbing as the other experimental settings. Product primitives under `ui/src/components/artifacts/v1` (kind helpers and adapters, the sheet and print artwork, the stack, the page tiles with the Paper Mono pill, the row the panel and thread will share), driven by `--artifact-*` tokens in `index.css` with light and dark values. With the setting on: the Artifacts page shows one row of views (Tasks, All files, Images, Videos, Documents, Text, Files) and the V1 tiles, a task with several artifacts as a stack whose newest artifact is the first page, everything else as single tiles; the panel's Artifacts tab is one flat list of V1 rows, newest first, with the expandable rows (Markdown review surface, document annotations, deep links) keeping their behaviour behind the row as their face; the thread shows a message's files as rows under its footer and registered deliverables and documents as one row each. The hover movement respects reduced motion. **Alternatives considered** Replacing the current cards outright: no fallback and three surfaces to land at once. Mounting the lab's components: they are typed on lab fixtures and keep lab-local tokens, which the token gates reject. The primitives were ported instead and the lab stays a reference. **Roadmap alignment** Supports the "Artifacts & Work Products" theme in `ROADMAP.md`. Presentation only; no data model change. **Additional context** No new API fields: the tiles show title, identifier, count and "Updated …"; rows take sizes from the work-product and attachment payloads; a picture's aspect ratio comes from the loaded image, 4:3 until it loads. The plan records what each surface keeps and what it drops (run sections in the panel; the "Images · N / Files · N" labels and the four-up grid in the thread). ## What Changed - Add the `enableArtifactsV1` experimental setting: shared type, validator and feature catalog, server defaults and managed keys, the `useArtifactsV1Enabled` hook, and the Settings > Experimental toggle (in its alphabetical place). - Add the `--artifact-*` tokens and the Artifacts V1 component rules to `ui/src/index.css`: light values on `:root`, dark overrides, and the hover movement under `prefers-reduced-motion: no-preference`. - Add `ui/src/components/artifacts/v1/`: `kinds.ts` (kind treatment, labels, glyphs, the server's media-kind rule, adapters from `CompanyArtifact`), `geometry.ts` (the measured constants), `when.ts`, `ArtifactSheet`, `ArtifactPrint`, `ArtifactStack`, `ArtifactFront`, `ArtifactTile` (stack and single tiles, caption, grid), `ArtifactRow` (the row and its inline group), `ArtifactsV1Filters`, with the mock's SVG assets. - Wire the Artifacts page: with the setting on, the toolbar's filter row becomes the V1 views and the grid renders V1 tiles; the toolbar takes an optional `filters` row for this. The old cards and kind filters stay the default. - Wire the panel's Artifacts tab (`IssuePropertiesArtifactsTab`): with the setting on, one flat list of V1 rows, newest first; `DocumentRow` and `MarkdownWorkProductRow` take a `face` render prop so the V1 row fronts their unchanged expansion, review and deep-link behaviour; `PanelFileRow` opens attachments and attachment-backed work products the way the shipped card does (gallery, text tab, new tab) with open and download beside the name; other work products are a link row with their status. - Wire the task thread: `TaskChatBubble` hangs a message's files under its footer as V1 rows inline (pictures and video to the gallery, text to a text tab, files to a new tab) in place of the media grid and file chips; `TaskChatProtocolCard`'s resource rows render deliverables, documents and attachments as one V1 row each, opening where the card did. - `ArtifactRow` keeps trailing controls beside its own control (no nested interactive elements) and can be a link, an in-app anchor, a new-tab anchor or a button; adapters read work products, documents, attachments and message references. - Add a catalogued Storybook story, `Components/Artifact V1 primitives`, under `documents-artifacts`, on fixtures. - Add tests: the primitives (kinds, dates, print fitting, tiles, rows), the hook, the Artifacts page, the panel's Artifacts tab, the task-chat bubble and the protocol card, each with the setting on and off; the settings page and server tests list the new key. - Add `doc/plans/2026-10-22-artifacts-mvp-v1.md`. ## Verification - Run `cd ui && pnpm typecheck`: the Storybook catalog accepts the new story, and `tsc -b` is clean apart from the five pre-existing `PluginOrganizationSwitcher` errors. - Run `pnpm check:token-gates` and `node scripts/check-maps.mjs`: clean. - Run `cd ui && npx vitest run src/components/artifacts src/components/issue-properties src/components/task-chat/TaskChatBubble.test.tsx src/components/task-chat/TaskChatBubble.artifactsV1.test.tsx src/components/task-chat/TaskChatProtocolCard.test.tsx src/components/task-chat/TaskChatProtocolCard.artifactsV1.test.tsx src/pages/Artifacts.test.tsx src/hooks/useArtifactsV1Enabled.test.ts src/pages/InstanceExperimentalSettings.test.tsx`: the suites pass (151 tests in the last run). - Run `cd packages/shared && npx vitest run src/feature-catalog.test.ts` and `cd server && npx vitest run src/__tests__/instance-settings-service.test.ts`: pass. - Manual: open Settings > Experimental, turn on Artifacts V1. On the Artifacts page the view row reads Tasks, All files, Images, Videos, Documents, Text, Files; tasks with several artifacts show as stacks, hovering fans the pages and lifts the artwork (not under reduced motion); Images shows single tiles with bordered prints. Open a task's Artifacts tab: one list of rows, newest first; a Markdown work product still expands into its review surface, a document still opens in its tab. In the thread, a message's files sit under its footer as rows. Turn the setting off: everything returns to the current presentation. - Storybook: `Tasks and conversations / Documents, attachments, and work products / Components / Artifact V1 primitives`. - Not run: the full `pnpm test:run` and `pnpm build` (CI runs them), and a browser pass against a dev instance with real artifacts on the three surfaces; that pass is the remaining step before this leaves draft. ## Risks - Off by default; with the setting off, the only changes on a product path are an optional `filters` prop on the toolbar, an optional `face` prop on two panel rows, a read of the setting in the bubble and the protocol card, and the new token declarations. - With the setting on, the Artifacts page's view tabs set two URL parameters (`groupBy`, `kind`) at once; the group-by menu and search are unchanged. - A picture with unknown dimensions renders at 4:3 until it loads, then the print refits once; the frame's height is fixed so nothing below it moves. - About thirty new `--artifact-*` tokens, namespaced and documented in place. ## Model Used - Claude Fable 5.1 by Anthropic, model ID `claude-fable-5-1`, used through Claude Code in the desktop app. Extended reasoning was on. Capabilities used: tool use (shell, file edits, a headless Playwright browser for the lab's measurements, the Figma MCP server for the mocks during the lab work) and image input. The context window is the Claude Code session's; its exact size is not surfaced to the session. ## 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 (#17213 is the lab this builds on) - [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 (the suites listed above; CI runs the full set) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (the plan document; no command or product behaviour changes outside the setting) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
634 lines
26 KiB
TypeScript
634 lines
26 KiB
TypeScript
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 => {
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.mcpEvents, "*");
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.dotEvents, "*");
|
|
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)),
|
|
};
|
|
}
|