mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Environments give each agent run an execution target: the local host, SSH, or a sandbox provider > - A managed deployment can provision one platform-managed sandbox environment through the `PAPERCLIP_MANAGED_CONFIG` `environments` section > - That row is fully locked today. A tenant cannot add environment variables for their agents. There is also no way to hide local execution — run selection falls back to the local row > - A platform that manages the sandbox for its tenants needs both: the tenant adds env vars (and nothing else), and local execution is neither visible nor reachable > - This pull request opens exactly one tenant edit (env vars) on the managed sandbox row, and adds an `enableManagedSandboxOnly` mode that hides local and makes run selection fail closed > - The benefit is a complete managed-sandbox experience with no change for self-hosted instances ## Linked Issues or Issue Description **Subsystem affected** Environments (managed sandbox provisioning, environment routes, run environment selection) and the environments UI. **Problem or motivation** Platform-provisioned sandbox environments (`metadata.managedByPaperclip`) reject every write on cloud-managed instances. Agents often need environment variables inside their sandbox. The tenant has no way to set them on the managed row. Separately, an operator cannot remove local execution: the environment list always shows the local row, and run selection falls back to it when no default is set. **Proposed solution** Allow an envVars-only PATCH on the managed sandbox row, and echo those env vars back for editing. Add a managed-tier feature (`enableManagedSandboxOnly`) that hides the local environment from all read surfaces and redirects local-landing run selection to the managed sandbox environment, failing closed when it is unavailable. **Alternatives considered** UI-only hiding of the local row. This was rejected: it does not stop a run from resolving to local, so it is presentation without enforcement. Full unlock of the managed row was also rejected: name, driver, and config stay platform-owned so boot reconciliation cannot fight tenant edits. ## What Changed - `server/src/routes/environments.ts`: the platform-provisioned write floor admits an envVars-only PATCH on the generalized managed sandbox row (sandbox driver, `managedByPaperclip`, not legacy kubernetes-marker rows). Name, driver, config, status, metadata, and DELETE stay rejected. The read floor stops blanking env vars on that row; credential-shaped config keys stay redacted for every actor. Legacy kubernetes-marker rows keep the full floor. - Same file: under `enableManagedSandboxOnly`, the environments list and the by-id read omit the local row for every actor, including instance admins. - `server/src/services/execution-workspace-policy.ts`: `resolveExecutionWorkspaceEnvironmentId` gains the managed-sandbox-only inputs. A selection that lands on the local environment is redirected to the managed sandbox environment. With no active managed row it throws `ManagedSandboxUnavailableError` — never local. Non-local selections (ssh, user-created sandboxes) are untouched. - `server/src/services/heartbeat.ts`: the run path reads the flag, looks up the managed row (`findManagedSandboxEnvironment`, new read-only finder in `environments.ts`), and passes both to the resolver. Mirrors the forced-kubernetes precedent, which keeps precedence when both regimes are on. - `server/src/services/managed-environments.ts`: after a successful reconcile, the instance default environment moves to the managed sandbox row when the current default is unset, local, or dangling. A tenant-chosen custom environment is never overridden. - `packages/shared`: new `enableManagedSandboxOnly` key (schema default false, catalog tier `managed`, cloudDefault false, selfHostedDefault false) and the matching interface field. - UI: managed rows show a "Managed by Paperclip" lock badge; editing one opens a dedicated env-vars-only editor that sends the one PATCH shape the server admits (the old full form failed with a 403 on save). New `ui/src/lib/managed-sandbox-environment.ts` mirrors the local filter for cached lists (applied in the project picker; the agent picker already excluded local). The experimental settings page gains the toggle at its alphabetical card position. `environmentsApi.update` now declares the `envVars` field it already sent. - Tests: environment route floor coverage (envVars-only accepted, mixed bodies rejected, legacy rows still blanked and locked, local hidden and 404 under the flag, self-hosted unchanged), an embedded-postgres service test pinning that boot reconciliation never touches tenant env vars, resolver redirect/fail-closed cases, managed-environments default-stamping cases, and UI lib/settings tests. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/environment-routes.test.ts src/__tests__/environment-service.test.ts src/__tests__/execution-workspace-policy.test.ts src/services/managed-environments.test.ts` — all pass. - `pnpm --filter @paperclipai/shared exec vitest run` — 425 pass (catalog/schema default parity is pinned by an existing test). - `pnpm --filter @paperclipai/ui exec tsc --noEmit` and the affected UI suites (CompanyEnvironments, InstanceExperimentalSettings incl. card-order test, new lib test) — all pass. - Full workspace `pnpm test`: 3,414 passed. 17 files report failures on this machine; the identical 17 fail on a clean `origin/master` worktree in the same environment (git-worktree/skills/embedded-postgres environment dependencies and plugin-SDK zero-test collections). One additional file (`issue-monitor-scheduler.test.ts`) failed one timing-sensitive test in one of two full-suite runs and passes 7/7 in isolation on this branch — a flake in a domain this diff does not touch. The branch introduces no new failures. - Self-hosted zero-delta: every new behavior is gated on the cloud-managed instance check or the new flag, which defaults to false in schema and catalog; pinned by the "does not floor platform-marked rows on self-hosted instances" and flag-off tests. ## Risks - Behavior is opt-in twice over: the write-floor exception applies only to rows the managed-config provisioner stamps, and the hiding/forcing applies only when `enableManagedSandboxOnly` is on (default false everywhere). Self-hosted instances see no change. - The env-vars echo is scoped to the generalized managed sandbox row; legacy kubernetes-marker rows keep the blanket floor because pre-generalization builds may have written platform values there. - Fail-closed run selection means a managed instance with the flag on and an archived managed row (provider plugin down) refuses runs with a precise error instead of running locally. That is the intended posture. ## Model Used Claude Fable 5 (`claude-fable-5`), extended thinking, agentic tool use via Claude Code CLI. ## 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
933 lines
33 KiB
TypeScript
933 lines
33 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type {
|
|
InstanceExperimentalSettings as InstanceExperimentalSettingsPayload,
|
|
InstanceExperimentalSettingsWithManaged,
|
|
IssueGraphLivenessAutoRecoveryPreview,
|
|
} from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { InstanceExperimentalSettings } from "./InstanceExperimentalSettings";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
|
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
|
getExperimental: vi.fn(),
|
|
updateExperimental: vi.fn(),
|
|
previewIssueGraphLivenessAutoRecovery: vi.fn(),
|
|
runIssueGraphLivenessAutoRecovery: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/api/instanceSettings", () => ({
|
|
instanceSettingsApi: mockInstanceSettingsApi,
|
|
}));
|
|
|
|
vi.mock("../context/BreadcrumbContext", () => ({
|
|
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
|
}));
|
|
|
|
async function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void> = undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
await result;
|
|
}
|
|
|
|
async function flushReact() {
|
|
for (let index = 0; index < 5; index += 1) {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
}
|
|
flushSync(() => {});
|
|
}
|
|
|
|
const CONFERENCE_TOGGLE_SELECTOR =
|
|
'button[aria-label="Toggle conference room chat experimental setting"]';
|
|
const STREAMLINED_TOGGLE_SELECTOR =
|
|
'button[aria-label="Toggle streamlined left navigation experimental setting"]';
|
|
const TASK_WATCHDOGS_TOGGLE_SELECTOR =
|
|
'button[aria-label="Toggle task watchdogs experimental setting"]';
|
|
const CLASSIC_TASK_INTERFACE_TOGGLE_SELECTOR =
|
|
'button[aria-label="Toggle classic task interface experimental setting"]';
|
|
const GOALS_SIDEBAR_LINK_TOGGLE_SELECTOR =
|
|
'button[aria-label="Toggle goals sidebar link experimental setting"]';
|
|
const DECISIONS_TOGGLE_SELECTOR =
|
|
'button[aria-label="Toggle decisions experimental setting"]';
|
|
const SERVER_INFO_TOGGLE_SELECTOR =
|
|
'button[aria-label="Toggle server info debug view experimental setting"]';
|
|
const BUILT_IN_AGENTS_TOGGLE_SELECTOR =
|
|
'button[aria-label="Toggle built-in agents experimental setting"]';
|
|
const BETA_SKILLS_TOGGLE_SELECTOR =
|
|
'button[aria-label="Toggle beta skills experimental setting"]';
|
|
const APPS_TOGGLE_SELECTOR = 'button[aria-label="Toggle apps experimental setting"]';
|
|
const SUMMARIES_TOGGLE_SELECTOR =
|
|
'button[aria-label="Toggle summaries experimental setting"]';
|
|
const STATUS_CARDS_TOGGLE_SELECTOR =
|
|
'button[aria-label="Toggle status cards experimental setting"]';
|
|
const AUTO_RECOVERY_TOGGLE_SELECTOR =
|
|
'button[aria-label="Toggle task graph liveness auto-recovery"]';
|
|
|
|
function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
|
return {
|
|
enableEnvironments: false,
|
|
enableManagedSandboxOnly: false,
|
|
enableIsolatedWorkspaces: false,
|
|
enableStreamlinedLeftNavigation: true,
|
|
enableApps: false,
|
|
enablePipelines: false,
|
|
enableCases: false,
|
|
enableConferenceRoomChat: false,
|
|
enableClassicTaskInterface: false,
|
|
enableIssuePlanDecompositions: false,
|
|
enableExperimentalFileViewer: false,
|
|
enableExternalObjects: false,
|
|
enableBuiltInAgents: false,
|
|
enableBetaSkills: false,
|
|
enableSummaries: false,
|
|
enableStatusCards: false,
|
|
enableDecisions: false,
|
|
enableGoalsSidebarLink: false,
|
|
enableTaskWatchdogs: false,
|
|
enableServerInfoDebugView: false,
|
|
enableSimplifiedEnglishInteractions: false,
|
|
enableSmokeLab: false,
|
|
autoRestartDevServerWhenIdle: false,
|
|
enableIssueGraphLivenessAutoRecovery: false,
|
|
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
|
enableWorkspaceBranchReconcileForward: true,
|
|
enableWorkspaceDirtyQuarantineRepair: true,
|
|
enableOwnerInstanceAdmin: false,
|
|
enableWorktreeRunExecution: false,
|
|
worktreeRunExecutionActivatedAt: null,
|
|
worktreeRunExecutionActivationInstanceId: null,
|
|
};
|
|
}
|
|
|
|
function emptyRecoveryPreview(): IssueGraphLivenessAutoRecoveryPreview {
|
|
return {
|
|
lookbackHours: 24,
|
|
cutoff: "2026-07-12T16:00:00.000Z",
|
|
generatedAt: "2026-07-13T16:00:00.000Z",
|
|
findings: 0,
|
|
recoverableFindings: 0,
|
|
skippedOutsideLookback: 0,
|
|
items: [],
|
|
};
|
|
}
|
|
|
|
const WORKTREE_RUN_EXECUTION_TOGGLE_SELECTOR =
|
|
'button[aria-label="Toggle worktree run execution setting"]';
|
|
|
|
function setWorktreeRuntimeMeta(enabled: boolean) {
|
|
const name = "paperclip-worktree-enabled";
|
|
let meta = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`);
|
|
if (enabled) {
|
|
if (!meta) {
|
|
meta = document.createElement("meta");
|
|
meta.setAttribute("name", name);
|
|
document.head.appendChild(meta);
|
|
}
|
|
meta.setAttribute("content", "true");
|
|
} else if (meta) {
|
|
meta.remove();
|
|
}
|
|
}
|
|
|
|
function setWorktreeInstanceIdMeta(instanceId: string | null) {
|
|
const name = "paperclip-instance-id";
|
|
let meta = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`);
|
|
if (instanceId) {
|
|
if (!meta) {
|
|
meta = document.createElement("meta");
|
|
meta.setAttribute("name", name);
|
|
document.head.appendChild(meta);
|
|
}
|
|
meta.setAttribute("content", instanceId);
|
|
} else if (meta) {
|
|
meta.remove();
|
|
}
|
|
}
|
|
|
|
describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
let currentExperimentalSettings: InstanceExperimentalSettingsPayload;
|
|
|
|
async function renderPage() {
|
|
root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
flushSync(() => {
|
|
root!.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<InstanceExperimentalSettings />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
currentExperimentalSettings = defaultExperimentalSettings();
|
|
mockInstanceSettingsApi.getExperimental.mockImplementation(async () => ({
|
|
...currentExperimentalSettings,
|
|
}));
|
|
mockInstanceSettingsApi.updateExperimental.mockImplementation(async (patch) => {
|
|
currentExperimentalSettings = { ...currentExperimentalSettings, ...patch };
|
|
return { ...currentExperimentalSettings };
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => {
|
|
root?.unmount();
|
|
});
|
|
root = null;
|
|
container.remove();
|
|
setWorktreeRuntimeMeta(false);
|
|
setWorktreeInstanceIdMeta(null);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("renders a page-level warning about instability and lack of guarantees", async () => {
|
|
await renderPage();
|
|
|
|
const warning = [...container.querySelectorAll('[role="alert"]')].find((alert) =>
|
|
alert.textContent?.includes("Experimental features may break at any time."),
|
|
);
|
|
expect(warning?.textContent).toContain("Experimental features may break at any time.");
|
|
expect(warning?.textContent).toContain("no compatibility guarantees");
|
|
});
|
|
|
|
it("enables the Apps UI from experimental settings", async () => {
|
|
await renderPage();
|
|
|
|
const toggle = container.querySelector<HTMLButtonElement>(APPS_TOGGLE_SELECTOR);
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
|
|
|
await act(() => toggle?.click());
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ enableApps: true });
|
|
expect(container.querySelector(APPS_TOGGLE_SELECTOR)?.getAttribute("aria-checked")).toBe("true");
|
|
});
|
|
|
|
it("does not render the Conference Room Chat experimental setting for now", async () => {
|
|
await renderPage();
|
|
|
|
const headings = [...container.querySelectorAll("section h2")].map((h) => h.textContent);
|
|
expect(headings).not.toContain("Conference Room Chat");
|
|
expect(container.querySelector(CONFERENCE_TOGGLE_SELECTOR)).toBeNull();
|
|
});
|
|
|
|
it("does not render the Pipelines experimental setting for now", async () => {
|
|
await renderPage();
|
|
|
|
const headings = [...container.querySelectorAll("section h2")].map((h) => h.textContent);
|
|
expect(headings).not.toContain("Pipelines");
|
|
expect(container.querySelector('button[aria-label="Toggle pipelines experimental setting"]')).toBeNull();
|
|
});
|
|
|
|
it("does not render the toggle even when the stored flag is currently enabled", async () => {
|
|
currentExperimentalSettings = {
|
|
...currentExperimentalSettings,
|
|
enableConferenceRoomChat: true,
|
|
};
|
|
await renderPage();
|
|
|
|
const toggle = container.querySelector(CONFERENCE_TOGGLE_SELECTOR);
|
|
expect(toggle).toBeNull();
|
|
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("no longer renders the Streamlined Left Navigation toggle (opt-out retired, PAP-12472)", async () => {
|
|
await renderPage();
|
|
|
|
const headings = [...container.querySelectorAll("section h2")].map((h) => h.textContent);
|
|
expect(headings).not.toContain("Streamlined Left Navigation Bar");
|
|
expect(container.querySelector(STREAMLINED_TOGGLE_SELECTOR)).toBeNull();
|
|
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders and patches the Task Watchdogs experimental toggle on and off", async () => {
|
|
await renderPage();
|
|
|
|
expect(container.textContent).toContain("Task Watchdogs");
|
|
expect(container.textContent).toContain(
|
|
"Show task detail controls for configuring watchdog agents that verify stopped task subtrees and restore live paths when work should continue.",
|
|
);
|
|
|
|
const toggle = container.querySelector<HTMLButtonElement>(TASK_WATCHDOGS_TOGGLE_SELECTOR);
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
|
|
|
await act(async () => {
|
|
toggle?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
|
enableTaskWatchdogs: true,
|
|
});
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
|
|
|
flushSync(() => {
|
|
root?.unmount();
|
|
});
|
|
root = null;
|
|
container.textContent = "";
|
|
await renderPage();
|
|
|
|
const enabledToggle = container.querySelector<HTMLButtonElement>(TASK_WATCHDOGS_TOGGLE_SELECTOR);
|
|
expect(enabledToggle?.getAttribute("aria-checked")).toBe("true");
|
|
|
|
await act(async () => {
|
|
enabledToggle?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({
|
|
enableTaskWatchdogs: false,
|
|
});
|
|
});
|
|
|
|
it("renders and patches the Classic Task Interface experimental toggle on and off", async () => {
|
|
await renderPage();
|
|
|
|
expect(container.textContent).toContain("Classic Task Interface");
|
|
expect(container.textContent).toContain(
|
|
"Restores the previous task detail page",
|
|
);
|
|
expect(container.textContent).toContain(
|
|
"Switching takes effect immediately. No task data is affected.",
|
|
);
|
|
|
|
const toggle = container.querySelector<HTMLButtonElement>(
|
|
CLASSIC_TASK_INTERFACE_TOGGLE_SELECTOR,
|
|
);
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
|
|
|
await act(async () => {
|
|
toggle?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
|
enableClassicTaskInterface: true,
|
|
});
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
|
|
|
flushSync(() => {
|
|
root?.unmount();
|
|
});
|
|
root = null;
|
|
container.textContent = "";
|
|
await renderPage();
|
|
|
|
const enabledToggle = container.querySelector<HTMLButtonElement>(
|
|
CLASSIC_TASK_INTERFACE_TOGGLE_SELECTOR,
|
|
);
|
|
expect(enabledToggle?.getAttribute("aria-checked")).toBe("true");
|
|
|
|
await act(async () => {
|
|
enabledToggle?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({
|
|
enableClassicTaskInterface: false,
|
|
});
|
|
});
|
|
|
|
it("renders and patches the Decisions experimental toggle", async () => {
|
|
await renderPage();
|
|
|
|
expect(container.textContent).toContain("Decisions");
|
|
expect(container.textContent).toContain(
|
|
"Show the Decisions item in the main sidebar",
|
|
);
|
|
|
|
const toggle = container.querySelector<HTMLButtonElement>(DECISIONS_TOGGLE_SELECTOR);
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
|
|
|
await act(async () => {
|
|
toggle?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
|
enableDecisions: true,
|
|
});
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
|
});
|
|
|
|
it("renders and patches the Goals Sidebar Link experimental toggle", async () => {
|
|
await renderPage();
|
|
|
|
expect(container.textContent).toContain("Goals Sidebar Link");
|
|
expect(container.textContent).toContain(
|
|
"Restore the Goals item in the main sidebar while the goals surface is being evaluated.",
|
|
);
|
|
|
|
const toggle = container.querySelector<HTMLButtonElement>(GOALS_SIDEBAR_LINK_TOGGLE_SELECTOR);
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
|
|
|
await act(async () => {
|
|
toggle?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
|
enableGoalsSidebarLink: true,
|
|
});
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
|
});
|
|
|
|
it("hides the worktree run-execution toggle when not running in a worktree", async () => {
|
|
setWorktreeRuntimeMeta(false);
|
|
await renderPage();
|
|
|
|
const headings = [...container.querySelectorAll("section h2")].map((h) => h.textContent);
|
|
expect(headings).not.toContain("Run tasks in this worktree");
|
|
expect(container.querySelector(WORKTREE_RUN_EXECUTION_TOGGLE_SELECTOR)).toBeNull();
|
|
});
|
|
|
|
it("renders and patches the worktree run-execution toggle when in a worktree", async () => {
|
|
setWorktreeRuntimeMeta(true);
|
|
await renderPage();
|
|
|
|
expect(container.textContent).toContain("Run tasks in this worktree");
|
|
expect(container.textContent).toContain(
|
|
"isolated git-worktree preview instance",
|
|
);
|
|
|
|
const toggle = container.querySelector<HTMLButtonElement>(WORKTREE_RUN_EXECUTION_TOGGLE_SELECTOR);
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
|
|
|
await act(async () => {
|
|
toggle?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
|
enableWorktreeRunExecution: true,
|
|
});
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
|
});
|
|
|
|
it("shows the cutoff-copy for the worktree run-execution toggle when off", async () => {
|
|
setWorktreeRuntimeMeta(true);
|
|
await renderPage();
|
|
|
|
expect(container.textContent).toContain(
|
|
"Only tasks created after enabling will run automatically",
|
|
);
|
|
expect(container.textContent).toContain("Toggling off and on resets the cutoff.");
|
|
// Off => no armed banner and no fail-closed hint.
|
|
expect(container.textContent).not.toContain("Running tasks created after");
|
|
expect(container.textContent).not.toContain("Execution is suppressed");
|
|
});
|
|
|
|
it("shows the armed timestamp when the flag matches the current instance", async () => {
|
|
setWorktreeRuntimeMeta(true);
|
|
setWorktreeInstanceIdMeta("inst-current");
|
|
currentExperimentalSettings = {
|
|
...currentExperimentalSettings,
|
|
enableWorktreeRunExecution: true,
|
|
worktreeRunExecutionActivatedAt: "2026-07-10T18:34:00.000Z",
|
|
worktreeRunExecutionActivationInstanceId: "inst-current",
|
|
};
|
|
await renderPage();
|
|
|
|
expect(container.textContent).toContain("Running tasks created after");
|
|
expect(container.textContent).not.toContain("Execution is suppressed");
|
|
const toggle = container.querySelector<HTMLButtonElement>(WORKTREE_RUN_EXECUTION_TOGGLE_SELECTOR);
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
|
});
|
|
|
|
it("fails closed with a re-enable hint when the flag was armed in another instance", async () => {
|
|
setWorktreeRuntimeMeta(true);
|
|
setWorktreeInstanceIdMeta("inst-current");
|
|
currentExperimentalSettings = {
|
|
...currentExperimentalSettings,
|
|
enableWorktreeRunExecution: true,
|
|
worktreeRunExecutionActivatedAt: "2026-07-10T18:34:00.000Z",
|
|
worktreeRunExecutionActivationInstanceId: "inst-other",
|
|
};
|
|
await renderPage();
|
|
|
|
expect(container.textContent).toContain("Execution is suppressed");
|
|
expect(container.textContent).toContain("armed in a different instance");
|
|
expect(container.textContent).toContain("Toggle it off and back on");
|
|
expect(container.textContent).not.toContain("Running tasks created after");
|
|
});
|
|
|
|
it("fails closed with a re-enable hint when the activation cutoff is missing", async () => {
|
|
setWorktreeRuntimeMeta(true);
|
|
setWorktreeInstanceIdMeta("inst-current");
|
|
currentExperimentalSettings = {
|
|
...currentExperimentalSettings,
|
|
enableWorktreeRunExecution: true,
|
|
worktreeRunExecutionActivatedAt: null,
|
|
worktreeRunExecutionActivationInstanceId: null,
|
|
};
|
|
await renderPage();
|
|
|
|
expect(container.textContent).toContain("Execution is suppressed");
|
|
expect(container.textContent).toContain("missing its activation cutoff");
|
|
expect(container.textContent).not.toContain("Running tasks created after");
|
|
});
|
|
|
|
it("renders and patches the Built-in Agents experimental toggle", async () => {
|
|
await renderPage();
|
|
|
|
expect(container.textContent).toContain("Built-in Agents");
|
|
expect(container.textContent).toContain("Show Paperclip-managed built-in agent surfaces");
|
|
|
|
const toggle = container.querySelector<HTMLButtonElement>(BUILT_IN_AGENTS_TOGGLE_SELECTOR);
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
|
|
|
await act(async () => {
|
|
toggle?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
|
enableBuiltInAgents: true,
|
|
});
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
|
});
|
|
|
|
it("renders and patches the Beta skills experimental toggle", async () => {
|
|
await renderPage();
|
|
|
|
expect(container.textContent).toContain("Beta skills");
|
|
expect(container.textContent).toContain("pin beta releases of the Paperclip core skill");
|
|
|
|
const toggle = container.querySelector<HTMLButtonElement>(BETA_SKILLS_TOGGLE_SELECTOR);
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
|
|
|
await act(async () => {
|
|
toggle?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
|
enableBetaSkills: true,
|
|
});
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
|
});
|
|
|
|
it("renders and patches the Summaries experimental toggle", async () => {
|
|
await renderPage();
|
|
|
|
expect(container.textContent).toContain("Summaries");
|
|
expect(container.textContent).toContain("Show Summarizer-generated status slots");
|
|
|
|
const toggle = container.querySelector<HTMLButtonElement>(SUMMARIES_TOGGLE_SELECTOR);
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
|
|
|
await act(async () => {
|
|
toggle?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
|
enableSummaries: true,
|
|
});
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
|
});
|
|
|
|
it("enables Summaries when enabling the Status Cards experimental toggle", async () => {
|
|
await renderPage();
|
|
|
|
expect(container.textContent).toContain("Status Cards");
|
|
expect(container.textContent).toContain("experimental shared status-card board");
|
|
|
|
const toggle = container.querySelector<HTMLButtonElement>(STATUS_CARDS_TOGGLE_SELECTOR);
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
|
|
|
await act(async () => {
|
|
toggle?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
|
enableSummaries: true,
|
|
enableStatusCards: true,
|
|
});
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
|
expect(
|
|
container.querySelector<HTMLButtonElement>(SUMMARIES_TOGGLE_SELECTOR)?.getAttribute("aria-checked"),
|
|
).toBe("true");
|
|
});
|
|
|
|
it("disables Status Cards when disabling Summaries", async () => {
|
|
currentExperimentalSettings = {
|
|
...currentExperimentalSettings,
|
|
enableSummaries: true,
|
|
enableStatusCards: true,
|
|
};
|
|
await renderPage();
|
|
|
|
const summariesToggle = container.querySelector<HTMLButtonElement>(SUMMARIES_TOGGLE_SELECTOR);
|
|
await act(async () => {
|
|
summariesToggle?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
|
enableSummaries: false,
|
|
enableStatusCards: false,
|
|
});
|
|
expect(summariesToggle?.getAttribute("aria-checked")).toBe("false");
|
|
expect(
|
|
container.querySelector<HTMLButtonElement>(STATUS_CARDS_TOGGLE_SELECTOR)?.getAttribute("aria-checked"),
|
|
).toBe("false");
|
|
});
|
|
|
|
it("renders and patches the Server Info Debug View experimental toggle", async () => {
|
|
await renderPage();
|
|
|
|
expect(container.textContent).toContain("Server Info Debug View");
|
|
expect(container.textContent).toContain(
|
|
'Show a "Server" section in the account drawer with the current server restart time and running commit.',
|
|
);
|
|
|
|
const toggle = container.querySelector<HTMLButtonElement>(SERVER_INFO_TOGGLE_SELECTOR);
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
|
|
|
await act(async () => {
|
|
toggle?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
|
enableServerInfoDebugView: true,
|
|
});
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
|
});
|
|
|
|
it("removes the auto-recovery confirmation overlay after enabling only", async () => {
|
|
mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery.mockResolvedValue(emptyRecoveryPreview());
|
|
await renderPage();
|
|
|
|
const toggle = container.querySelector<HTMLButtonElement>(AUTO_RECOVERY_TOGGLE_SELECTOR);
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
|
|
|
await act(async () => {
|
|
toggle?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery).toHaveBeenCalledWith({
|
|
lookbackHours: 24,
|
|
});
|
|
expect(document.body.textContent).toContain("Confirm auto-recovery");
|
|
expect(document.body.querySelector('[data-slot="dialog-overlay"]')).not.toBeNull();
|
|
|
|
const enableOnlyButton = [...document.body.querySelectorAll<HTMLButtonElement>("button")].find(
|
|
(button) => button.textContent === "Enable only",
|
|
);
|
|
|
|
await act(async () => {
|
|
enableOnlyButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
|
enableIssueGraphLivenessAutoRecovery: true,
|
|
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
|
});
|
|
expect(document.body.textContent).not.toContain("Confirm auto-recovery");
|
|
expect(document.body.querySelector('[data-slot="dialog-overlay"]')).toBeNull();
|
|
const enabledToggle = container.querySelector<HTMLButtonElement>(AUTO_RECOVERY_TOGGLE_SELECTOR);
|
|
expect(enabledToggle?.getAttribute("aria-checked")).toBe("true");
|
|
});
|
|
|
|
it("removes the auto-recovery confirmation overlay after enabling and running", async () => {
|
|
mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery.mockResolvedValue(emptyRecoveryPreview());
|
|
mockInstanceSettingsApi.runIssueGraphLivenessAutoRecovery.mockResolvedValue({
|
|
findings: 0,
|
|
autoRecoveryEnabled: true,
|
|
lookbackHours: 24,
|
|
cutoff: "2026-07-12T16:00:00.000Z",
|
|
escalationsCreated: 0,
|
|
existingEscalations: 0,
|
|
skipped: 0,
|
|
skippedAutoRecoveryDisabled: 0,
|
|
});
|
|
await renderPage();
|
|
|
|
const toggle = container.querySelector<HTMLButtonElement>(AUTO_RECOVERY_TOGGLE_SELECTOR);
|
|
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
|
|
|
await act(async () => {
|
|
toggle?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).toContain("Confirm auto-recovery");
|
|
expect(document.body.querySelector('[data-slot="dialog-overlay"]')).not.toBeNull();
|
|
|
|
const enableAndRunButton = [...document.body.querySelectorAll<HTMLButtonElement>("button")].find(
|
|
(button) => button.textContent === "Enable",
|
|
);
|
|
|
|
await act(async () => {
|
|
enableAndRunButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
|
enableIssueGraphLivenessAutoRecovery: true,
|
|
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
|
});
|
|
expect(mockInstanceSettingsApi.runIssueGraphLivenessAutoRecovery).toHaveBeenCalledWith({
|
|
lookbackHours: 24,
|
|
});
|
|
expect(document.body.textContent).not.toContain("Confirm auto-recovery");
|
|
expect(document.body.querySelector('[data-slot="dialog-overlay"]')).toBeNull();
|
|
const enabledToggle = container.querySelector<HTMLButtonElement>(AUTO_RECOVERY_TOGGLE_SELECTOR);
|
|
expect(enabledToggle?.getAttribute("aria-checked")).toBe("true");
|
|
});
|
|
});
|
|
|
|
describe("InstanceExperimentalSettings — cloud-managed keys", () => {
|
|
const MANAGED_BADGE_TEXT = "Managed by Paperclip Cloud";
|
|
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
let queryClient: QueryClient;
|
|
|
|
async function renderPage(settings: InstanceExperimentalSettingsWithManaged) {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ ...settings });
|
|
root = createRoot(container);
|
|
queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
flushSync(() => {
|
|
root!.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<InstanceExperimentalSettings />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
mockInstanceSettingsApi.updateExperimental.mockImplementation(async (patch) => ({
|
|
...defaultExperimentalSettings(),
|
|
...patch,
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => {
|
|
root?.unmount();
|
|
});
|
|
root = null;
|
|
container.remove();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("renders a managed key locked with the badge while unmanaged keys stay editable", async () => {
|
|
await renderPage({
|
|
...defaultExperimentalSettings(),
|
|
enableApps: true,
|
|
managedKeys: {
|
|
enableApps: { managed: true, managedBy: "paperclip-cloud" },
|
|
},
|
|
});
|
|
|
|
expect(container.textContent).toContain(MANAGED_BADGE_TEXT);
|
|
|
|
const appsToggle = container.querySelector<HTMLButtonElement>(APPS_TOGGLE_SELECTOR);
|
|
expect(appsToggle?.getAttribute("aria-checked")).toBe("true");
|
|
expect(appsToggle?.disabled).toBe(true);
|
|
|
|
await act(() => appsToggle?.click());
|
|
await flushReact();
|
|
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
|
|
|
|
const summariesToggle = container.querySelector<HTMLButtonElement>(SUMMARIES_TOGGLE_SELECTOR);
|
|
expect(summariesToggle?.disabled).toBe(false);
|
|
|
|
await act(() => summariesToggle?.click());
|
|
await flushReact();
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
|
enableSummaries: true,
|
|
});
|
|
});
|
|
|
|
it("locks Status Cards when managed Summaries is disabled", async () => {
|
|
await renderPage({
|
|
...defaultExperimentalSettings(),
|
|
managedKeys: {
|
|
enableSummaries: { managed: true, managedBy: "paperclip-cloud" },
|
|
},
|
|
});
|
|
|
|
const statusCardsToggle = container.querySelector<HTMLButtonElement>(STATUS_CARDS_TOGGLE_SELECTOR);
|
|
expect(statusCardsToggle?.disabled).toBe(true);
|
|
|
|
await act(() => statusCardsToggle?.click());
|
|
await flushReact();
|
|
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("locks Summaries on when managed Status Cards is enabled", async () => {
|
|
await renderPage({
|
|
...defaultExperimentalSettings(),
|
|
enableSummaries: true,
|
|
enableStatusCards: true,
|
|
managedKeys: {
|
|
enableStatusCards: { managed: true, managedBy: "paperclip-cloud" },
|
|
},
|
|
});
|
|
|
|
const summariesToggle = container.querySelector<HTMLButtonElement>(SUMMARIES_TOGGLE_SELECTOR);
|
|
expect(summariesToggle?.disabled).toBe(true);
|
|
|
|
await act(() => summariesToggle?.click());
|
|
await flushReact();
|
|
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("locks the managed auto-recovery toggle without opening the preview dialog", async () => {
|
|
await renderPage({
|
|
...defaultExperimentalSettings(),
|
|
managedKeys: {
|
|
enableIssueGraphLivenessAutoRecovery: { managed: true, managedBy: "paperclip-cloud" },
|
|
},
|
|
});
|
|
|
|
const toggle = container.querySelector<HTMLButtonElement>(AUTO_RECOVERY_TOGGLE_SELECTOR);
|
|
expect(toggle?.disabled).toBe(true);
|
|
|
|
await act(() => toggle?.click());
|
|
await flushReact();
|
|
|
|
expect(mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery).not.toHaveBeenCalled();
|
|
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
|
|
expect(document.body.textContent).not.toContain("Confirm auto-recovery");
|
|
});
|
|
|
|
it("closes an open recovery preview when a refresh marks auto-recovery as managed", async () => {
|
|
mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery.mockResolvedValue(
|
|
emptyRecoveryPreview(),
|
|
);
|
|
const settings = defaultExperimentalSettings();
|
|
await renderPage(settings);
|
|
|
|
const toggle = container.querySelector<HTMLButtonElement>(AUTO_RECOVERY_TOGGLE_SELECTOR);
|
|
await act(() => toggle?.click());
|
|
await flushReact();
|
|
expect(document.body.textContent).toContain("Confirm auto-recovery");
|
|
|
|
const managedSettings: InstanceExperimentalSettingsWithManaged = {
|
|
...settings,
|
|
enableIssueGraphLivenessAutoRecovery: true,
|
|
managedKeys: {
|
|
enableIssueGraphLivenessAutoRecovery: { managed: true, managedBy: "paperclip-cloud" },
|
|
},
|
|
};
|
|
await act(() => {
|
|
queryClient.setQueryData(queryKeys.instance.experimentalSettings, managedSettings);
|
|
});
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).not.toContain("Confirm auto-recovery");
|
|
expect(document.body.querySelector('[data-slot="dialog-overlay"]')).toBeNull();
|
|
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
|
|
expect(mockInstanceSettingsApi.runIssueGraphLivenessAutoRecovery).not.toHaveBeenCalled();
|
|
|
|
const lockedToggle = container.querySelector<HTMLButtonElement>(AUTO_RECOVERY_TOGGLE_SELECTOR);
|
|
expect(lockedToggle?.disabled).toBe(true);
|
|
});
|
|
|
|
it("renders no managed badge and keeps toggles editable without managedKeys (self-hosted)", async () => {
|
|
await renderPage(defaultExperimentalSettings());
|
|
|
|
expect(container.textContent).not.toContain(MANAGED_BADGE_TEXT);
|
|
|
|
const appsToggle = container.querySelector<HTMLButtonElement>(APPS_TOGGLE_SELECTOR);
|
|
expect(appsToggle?.disabled).toBe(false);
|
|
|
|
await act(() => appsToggle?.click());
|
|
await flushReact();
|
|
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ enableApps: true });
|
|
});
|
|
});
|
|
|
|
describe("InstanceExperimentalSettings — card ordering and headings (PAP-393)", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
|
|
async function renderPage(settings: InstanceExperimentalSettingsWithManaged) {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ ...settings });
|
|
root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
flushSync(() => {
|
|
root!.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<InstanceExperimentalSettings />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
mockInstanceSettingsApi.updateExperimental.mockImplementation(async (patch) => ({
|
|
...defaultExperimentalSettings(),
|
|
...patch,
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => {
|
|
root?.unmount();
|
|
});
|
|
root = null;
|
|
container.remove();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("renders every card heading in alphabetical order", async () => {
|
|
await renderPage(defaultExperimentalSettings());
|
|
|
|
const headings = [...container.querySelectorAll("h2")].map(
|
|
(heading) => heading.textContent ?? "",
|
|
);
|
|
|
|
// Sanity: the page rendered a meaningful set of cards, not an empty list.
|
|
expect(headings.length).toBeGreaterThan(10);
|
|
|
|
const alphabetical = [...headings].sort((a, b) =>
|
|
a.localeCompare(b, undefined, { sensitivity: "base" }),
|
|
);
|
|
expect(headings).toEqual(alphabetical);
|
|
});
|
|
|
|
it("no longer renders an 'Experimental' secondary badge on any card", async () => {
|
|
await renderPage(defaultExperimentalSettings());
|
|
|
|
const badges = [...container.querySelectorAll('[data-slot="badge"]')].map(
|
|
(badge) => badge.textContent?.trim(),
|
|
);
|
|
expect(badges).not.toContain("Experimental");
|
|
});
|
|
});
|