Files
PaperClipAI/packages/shared/src/feature-catalog.ts
T
Nicky LeachandClaude Opus 5 b64469e403 feat(workspaces): add an operator default for isolated execution workspaces (#13444)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The execution workspace subsystem decides if a task run uses the
shared project checkout or an isolated per-task git worktree
> - The mode comes from the project policy, then the task settings. A
project that stores no policy always falls back to the shared checkout
> - An operator who wants every project to use isolated workspaces must
therefore edit each project one at a time, and must repeat this for each
new project
> - There is no instance-level control, so a fleet operator cannot set
this default at all
> - This pull request adds a managed experimental flag that moves the
default for projects that store no policy of their own
> - The benefit is that an operator sets the workspace default one time,
and every current and future project follows it

## Linked Issues or Issue Description

No public issue exists. The description follows the feature request
template.

**Subsystem affected**

Execution workspaces. The files are
`server/src/services/execution-workspace-policy.ts` and the run dispatch
path in `server/src/services/heartbeat.ts`.

**Problem or motivation**

`resolveExecutionWorkspaceMode` reads only the project policy, the task
settings, and a legacy field. Its last statement returns
`shared_workspace`. A project that stores no policy always gets the
shared project checkout.

An operator has no way to change this default for many projects at the
same time. The operator must edit each project, and must edit each new
project again later. Tasks in one project therefore share one checkout,
and they run one at a time when the environment driver makes the
scheduler serialize them.

**Proposed solution**

Add the managed experimental flag `enableIsolatedWorkspacesByDefault`.
When the flag is on, a project that stores no policy of its own resolves
as if it selected isolated workspaces. A project that stores a policy
keeps that policy.

The new helper substitutes a project policy. It does not move the last
statement of `resolveExecutionWorkspaceMode`. Two behaviors make this
necessary:

- A task that has no project must keep its current behavior. An isolated
workspace needs a repository to cut a worktree from.
`isUnrunnableWorktreeCombo` blocks an isolated task that has no
`projectId` and no `projectWorkspaceId`. A moved fallback would resolve
isolated for project-less tasks, such as agent chat, and stop them
before dispatch.
- The mode and the strategy must agree.
`buildExecutionWorkspaceAdapterConfig` supplies the default
`git_worktree` strategy only when one layer asserts workspace control. A
moved fallback would leave isolated mode with a `project_primary`
strategy.

**Alternatives considered**

- Change the last statement of `resolveExecutionWorkspaceMode` to
`isolated_workspace`. This is one line, but it changes the default for
every deployment. It is also not gated, so it would apply where isolated
workspaces are off.
- Write the policy to each project row with a script. This does not
cover new projects, and it does not cover new instances.
- Add an instance defaults section to the managed-config document. This
needs a new document key, new validation, and new delivery code. A
boolean flag reuses the delivery machinery that exists today.

**Roadmap alignment**

`ROADMAP.md` does not list execution workspace defaults. This change
adds an operator control to an existing capability. It does not add a
new capability.

**Additional context**

The flag is `tier: "managed"`. A cloud operator can therefore deliver it
with the managed-config machinery that exists today. No new delivery
code is needed.

## What Changed

- Add `enableIsolatedWorkspacesByDefault` to the feature catalog with
`tier: "managed"`. Both defaults are off.
- Add the flag to the experimental settings schema, the type, and both
branches of `normalizeExperimentalSettings`.
- Add `applyDefaultIsolatedExecutionWorkspacePolicy` to
`execution-workspace-policy.ts`. It substitutes `{ enabled: true,
defaultMode: "isolated_workspace" }` only when the flag is on, the task
has a project, and the project stores no policy.
- Apply the helper in the run dispatch path in `heartbeat.ts`, after the
existing `gateProjectExecutionWorkspacePolicy` call. The `hasProject`
argument reads the resolved project row, not the raw `projectId` of the
task.
- Gate the new flag behind `enableIsolatedWorkspaces` at the call site.
The new flag does nothing on its own.
- Add a toggle card to the instance experimental settings page. The card
shows only when isolated workspaces are on.
- Add eight tests for the new helper.

## Verification

Commands:

```
pnpm --filter @paperclipai/shared typecheck
pnpm --filter @paperclipai/ui typecheck
cd server && ../node_modules/.bin/tsc --noEmit
```

The server typecheck script also builds a Rust binary. I ran `tsc`
directly because this machine has no `cargo`. The server package reports
no type errors.

Tests:

```
./node_modules/.bin/vitest run \
  server/src/__tests__/execution-workspace-policy.test.ts \
  server/src/__tests__/instance-settings-service.test.ts \
  server/src/__tests__/instance-settings-cloud-defaults.test.ts \
  server/src/__tests__/instance-settings-managed-overlay.test.ts \
  server/src/__tests__/instance-settings-routes.test.ts \
  server/src/__tests__/managed-config.test.ts \
  server/src/__tests__/heartbeat-workspace-busy.test.ts \
  server/src/__tests__/heartbeat-workspace-session.test.ts \
  server/src/__tests__/heartbeat-workspace-ready-comment.test.ts \
  server/src/__tests__/execution-workspaces-service.test.ts \
  server/src/__tests__/issue-runtime-workspace-binding.test.ts \
  server/src/__tests__/run-trust-preset.test.ts \
  packages/shared/src/feature-catalog.test.ts \
  packages/shared/src/settings-visibility.test.ts \
  packages/shared/src/validators/instance.test.ts \
  ui/src/pages/InstanceExperimentalSettings.test.tsx \
  ui/src/components/Sidebar.test.tsx
```

All of these files pass. The new tests cover each of these cases:

- The helper substitutes an isolated policy for a project that stores
none.
- The helper changes nothing while the flag is off.
- The helper changes nothing for a task that has no project.
- The helper keeps a stored policy, including a policy with `enabled:
false`.
- The resolver returns `isolated_workspace` for an unpolicied project.
- An explicit task setting still wins over the operator default.
- The substituted policy produces the `git_worktree` strategy.
- A project-less task does not become an unrunnable worktree.

To confirm the behavior by hand:

1. Turn on Isolated Workspaces, then turn on Use Isolated Workspaces By
Default.
2. Open a project that has no execution workspace policy.
3. Start a task in that project.
4. The run gets its own worktree. Tasks in that project no longer wait
for each other.

## Risks

Low to medium. The details:

- The flag defaults to off, and it is inert unless
`enableIsolatedWorkspaces` is also on. An instance that does not turn on
both flags sees no change.
- A project that stores a policy keeps it. This includes a policy with
`enabled: false`, which the helper reads as a decision to stay on the
shared checkout.
- When an operator turns the flag on, the workspace configuration
fingerprint changes for projects that store no policy. Their next run
creates a new workspace. This is correct, because the mode did change,
but the first run after the change does more setup work.
- A task that is in flight when the flag changes resumes with a
different workspace path than the path its session remembers. An
operator should let current runs finish before turning the flag on.
- Isolated workspaces use more disk, because each task gets its own
worktree.

## Model Used

Claude Opus 5 (`claude-opus-5`) in Claude Code, with extended thinking
and tool use. The model read the repository, made the change, and ran
the typechecks and tests above.

## 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:59:56 -07:00

357 lines
13 KiB
TypeScript

import { z } from "zod";
import { instanceExperimentalSettingsSchema } from "./validators/instance.js";
/**
* Feature catalog for cloud-managed instances.
*
* The instance-settings zod schema is the feature manifest; this module adds
* only metadata about the flags the schema already declares. Keys are derived
* from the schema type, so adding, removing, or renaming a boolean flag in
* `instanceExperimentalSettingsSchema` without updating the metadata map is a
* compile error (and vice versa).
*
* Tiers:
* - `preference`: tenant-controllable taste setting; the cloud harness does
* not manage it.
* - `managed`: the cloud harness may set this per fleet/stack via
* `PAPERCLIP_MANAGED_CONFIG`.
* - `floor`: pinned by code on managed instances; no flag value may widen it.
*/
export const FEATURE_TIERS = ["preference", "managed", "floor"] as const;
export type FeatureTier = (typeof FEATURE_TIERS)[number];
type ExperimentalSettings = z.infer<typeof instanceExperimentalSettingsSchema>;
/**
* The boolean flag keys of the experimental settings schema. Non-flag keys
* (activation timestamps, numeric tuning values) are excluded.
*/
export type InstanceFeatureKey = {
[K in keyof ExperimentalSettings]: ExperimentalSettings[K] extends boolean ? K : never;
}[keyof ExperimentalSettings];
export interface FeatureCatalogEntry {
title: string;
description: string;
tier: FeatureTier;
/** Desired default on cloud-managed instances. */
cloudDefault: boolean;
/** Must match the schema default; enforced by test. */
selfHostedDefault: boolean;
}
export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalogEntry> = {
enableEnvironments: {
title: "Environments",
description:
"Show environment management in company settings and allow project and agent environment assignment controls.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableNativeRunner: {
title: "Paperclip Runner",
description:
"Allow explicitly configured local Codex, OpenCode, and qualified ACPX agents to use the experimental Rust Paperclip Runner, including authenticated sandbox ingress when required. Onboarding remains on legacy adapters.",
tier: "managed",
cloudDefault: false,
// On by default for self-hosted instances. Requires a Rust toolchain (or
// PAPERCLIP_RUNNER_BINARY) for `pnpm dev`, which builds runnerd whenever
// this is on.
selfHostedDefault: true,
},
enableManagedSandboxOnly: {
title: "Managed Environment Only",
description:
"Hide the local environment and run all agents in the platform-managed environment.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableIsolatedWorkspaces: {
title: "Isolated Workspaces",
description:
"Show execution workspace controls in project configuration and allow isolated workspace behavior for task runs.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableIsolatedWorkspacesByDefault: {
title: "Isolated Workspaces By Default",
description:
"Treat a project that has no execution workspace policy of its own as if it selected isolated workspaces, so its tasks get a per-task worktree instead of sharing the project checkout. Requires Isolated Workspaces. A project that carries its own policy keeps it.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableStreamlinedLeftNavigation: {
title: "Streamlined Left Navigation",
description: "Use the streamlined main sidebar navigation layout.",
tier: "preference",
cloudDefault: true,
selfHostedDefault: true,
},
enableStreamlinedUi: {
title: "Streamlined UI",
description:
"Use the streamlined application shell, shared task collections, focused task detail layout, contextual navigation, and simplified main sidebar.",
tier: "preference",
cloudDefault: true,
selfHostedDefault: true,
},
enableApps: {
title: "Apps (compatibility)",
description:
"Deprecated compatibility key. Apps is always enabled; stored and managed values are ignored.",
tier: "managed",
cloudDefault: true,
selfHostedDefault: true,
},
enableChatConnectors: {
title: "Chat connectors",
description:
"Show experimental chat connector setup and Board surfaces. Existing connections keep running when hidden; GitHub and other tool connectors are unaffected.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enablePipelines: {
title: "Pipelines",
description: "Enable pipeline definitions and pipeline-driven case production surfaces.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableCases: {
title: "Cases",
description:
"Durable work products that tasks create and iterate on. Adds the Cases tab and the agent case API.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableAgentChat: {
title: "Agent Chat",
description: "Persistent task-backed conversations that clarify goals and hand work off to tasks.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableConferenceRoomChat: {
title: "Conference Room Chat",
description:
"Add the Conference Room team chat, the live activity feed, and the redesigned onboarding; restyles task threads as chat bubbles.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableClassicTaskInterface: {
title: "Classic Task Interface",
description:
"Restore the pre-chat task detail page: the page-level header with inline description editor, the plain comment thread, and the fixed Properties sidebar. Chat-only features (streaming activity folding, inline plan/question cards, the three-mode composer) are unavailable in the classic view.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableIssuePlanDecompositions: {
title: "Task Plan Decomposition",
description: "Show accepted-plan decomposition history on task detail pages.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableExperimentalFileViewer: {
title: "Experimental File Viewer",
description:
"Show task detail controls for browsing and previewing workspace files relative to a task.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableStatusCards: {
title: "Status Cards",
description:
"Enable the experimental shared status-card board, update engine, and gated API.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableExternalObjects: {
title: "External Objects",
description:
"Detect external URLs in issues and show resolved status for pull requests, tickets, and other referenced work objects.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableSmokeLab: {
title: "Smoke Lab",
description:
"Add the Smoke Lab tab and dashboard card for exercising integration paths against deterministic local fixtures. Private deployments only.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableBuiltInAgents: {
title: "Built-in Agents",
description:
"Show Paperclip-managed built-in agent surfaces, including roster badges, the Built-in agents tab, and setup controls.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableBetaSkills: {
title: "Beta skills",
description: "Allow agents to pin beta releases of the Paperclip core skill.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableSummaries: {
title: "Summaries",
description:
"Show Summarizer-generated status slots on project and workspace pages, with on-demand refresh and revision history.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableDecisions: {
title: "Decisions",
description:
"Show the Decisions item in the main sidebar — the attention home that surfaces tasks awaiting input.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableGoalsSidebarLink: {
title: "Goals Sidebar Link",
description: "Restore the Goals item in the main sidebar while the goals surface is being evaluated.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableSimplifiedEnglishInteractions: {
title: "Simplified English Interactions",
description:
"Instruct agents to write user interactions (confirmations, questions, suggested tasks) in ASD-STE100 Simplified Technical English with brief decision context.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableServerInfoDebugView: {
title: "Server Info Debug View",
description:
"Show a Server section in the account drawer with the current server restart time and running commit.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enablePaperclipDeveloperMode: {
title: "Paperclip Developer Mode",
description:
"Show internal Paperclip maintainer tools and observability links, including Honeycomb trace queries on run pages.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
autoRestartDevServerWhenIdle: {
title: "Auto-Restart Dev Server When Idle",
description:
"In local development, wait for queued and running agent runs to finish, then restart the server automatically when backend changes make the current boot stale.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableWorkspaceBranchReconcileForward: {
title: "Workspace Branch Reconcile Forward",
description:
"Let execution workspaces reconcile a diverged recorded branch forward instead of failing branch containment.",
tier: "managed",
cloudDefault: true,
selfHostedDefault: true,
},
enableWorkspaceDirtyQuarantineRepair: {
title: "Workspace Dirty Quarantine Repair",
description:
"Let workspace runtime recovery quarantine and repair dirty execution workspaces before runs.",
tier: "managed",
cloudDefault: true,
selfHostedDefault: true,
},
enableOwnerInstanceAdmin: {
title: "Owner Instance Admin",
description:
"On cloud-managed instances, grant the stack owner instance-admin access to their own dedicated instance. Elevation is computed at the trusted-header auth boundary; no instance admin role rows are created. Inert on self-hosted instances.",
tier: "managed",
cloudDefault: true,
selfHostedDefault: false,
},
enableSandboxDuplexBridge: {
title: "Sandbox Duplex Bridge",
description:
"Let a run open the sandbox duplex command-stream bridge when the provider grants the capability. The host reads this per run before it selects the transport. Off keeps the file bridge for every run.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableRunnerPreviewIngress: {
title: "Runner Preview Ingress (Deprecated)",
description:
"Compatibility-only key retained for older managed configs. Runner ingress follows the Paperclip Runner setting.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableWorktreeRunExecution: {
title: "Worktree Run Execution",
description:
"Let the scheduler execute runs inside an isolated git-worktree preview instance for tasks created after activation.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableFirstTaskPlanProposal: {
title: "First task: propose with a plan document",
description:
"When the user's first request is a single task, the chief of staff writes a short plan document and a checkbox card instead of a one-card confirmation. Applies to organizations created after the toggle is flipped.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
};
export const INSTANCE_FEATURE_KEYS = Object.keys(INSTANCE_FEATURE_CATALOG).sort() as InstanceFeatureKey[];
/**
* Shape of the `feature-catalog.json` release artifact the cloud harness
* imports per app release and validates feature writes against.
*/
export const featureCatalogArtifactSchema = z
.object({
catalogVersion: z.string().min(1),
features: z.record(
z.string().min(1),
z.object({ tier: z.enum(FEATURE_TIERS) }).strict(),
),
})
.strict();
export type FeatureCatalogArtifact = z.infer<typeof featureCatalogArtifactSchema>;
export function buildFeatureCatalogArtifact(catalogVersion: string): FeatureCatalogArtifact {
if (catalogVersion.trim().length === 0) {
throw new Error("catalogVersion must be a non-empty string");
}
const features: FeatureCatalogArtifact["features"] = {};
for (const key of INSTANCE_FEATURE_KEYS) {
features[key] = { tier: INSTANCE_FEATURE_CATALOG[key].tier };
}
return { catalogVersion, features };
}
/** Deterministic serialization (sorted keys, trailing newline) for the artifact file. */
export function renderFeatureCatalogArtifact(catalogVersion: string): string {
return `${JSON.stringify(buildFeatureCatalogArtifact(catalogVersion), null, 2)}\n`;
}