Files
PaperClipAI/ui/src/lib/workspace-access-state.ts
T
DottaandPaperclip 141f202e40 Clean up experimental settings features (#12681)
## Thinking Path

> - Paperclip is the open source app that people use to manage AI agents
for work.
> - Instance settings control optional product features and developer
tools.
> - The experimental settings page mixed active experiments, internal
tools, and old recovery controls.
> - Some workspace links also used the selected company instead of the
workspace owner.
> - These problems made settings hard to scan and could send users to
the wrong company route.
> - This pull request removes old controls, groups developer settings,
and resolves workspace links from workspace data.
> - The benefit is a smaller settings surface and correct workspace
navigation.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

This improves the instance experimental settings page, task watchdog
controls, dependency wake recovery, and execution workspace routes.

**Current behavior**

The settings page shows old recovery controls and mixes product
experiments with internal developer settings. Task watchdogs require an
extra feature flag. Some direct workspace links use the current company
prefix instead of the company that owns the workspace.

**Proposed behavior**

Remove the old task recovery experiment and its unused API surface. Make
task watchdog controls available without the removed flag. Put worktree
execution and managed environment controls in the developer section.
Resolve direct workspace links from the workspace owner and reject a
company prefix that does not own the workspace.

**Reason and benefit**

The smaller settings page is easier to understand. The server keeps only
the dependency wake backstop that it still uses. Workspace links open
under the correct company route.

**Breaking changes**

This removes the experimental issue graph recovery preview and run
endpoints. It also removes the task watchdog feature flag. Task watchdog
data and dependency wake behavior remain available.

## What Changed

- Removed the old task watchdog and issue graph recovery feature flags.
- Removed the old issue graph recovery preview, run controls, API
contracts, and unused recovery implementation.
- Kept resolved dependency wakes as the scheduler backstop.
- Grouped product experiments and Paperclip developer settings on the
instance settings page.
- Made task watchdog controls available without an extra experimental
flag.
- Added owner-aware redirects and company checks for execution workspace
routes.
- Hid the false stopped-state badge while a workspace has no active
runtime state.
- Updated focused server and UI tests for the new behavior.

## Verification

- `pnpm check:token-gates`
- `pnpm -r typecheck`
- `pnpm build`
- `pnpm test:run` completed with 5,620 passing tests and four failures
in unchanged workspace runtime port tests. The same four failures repeat
when the two files run alone.
- The complete GitHub CI matrix passed, including all server, serialized
server, build, canary, and end-to-end jobs.

## Risks

- Clients that call the removed experimental recovery endpoints must
stop calling them.
- The route checks depend on workspace detail access. An unknown or
cross-company workspace returns the global not-found page.
- There are no database migrations, lockfile changes, workflow changes,
or design image changes.

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

## Model Used

OpenAI Codex with GPT-5. The exact deployment ID and context window are
not exposed. Reasoning, tool use, and code execution were enabled.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-01 14:23:05 -05:00

317 lines
13 KiB
TypeScript

import type {
WorkspaceOperation,
WorkspaceReadiness,
WorkspaceReadinessState,
WorkspaceRuntimeService,
} from "@paperclipai/shared";
/**
* Derives the workspace access state the UI shows (PAP-17572).
*
* The board cannot read a cloned workspace's protected health directly, so state
* comes from three server-side facts it *can* see: the live runtime rows, the
* workspace operation log, and the readiness the control plane reported when it
* last tried to mint a login handoff.
*
* Every state carries one concrete next action. The failure this replaces was a
* generic "Load failed" (or worse, a green badge) that told an operator nothing
* about whether to wait, start, repair, or read a log.
*/
export type WorkspaceAccessActionKind =
| "open"
| "start"
| "repair"
| "view_logs"
/** Nothing to do but wait for a running operation. */
| "wait";
export type WorkspaceAccessAction = {
kind: WorkspaceAccessActionKind;
label: string;
};
export type WorkspaceAccessNotice = {
title: string;
description: string;
action: WorkspaceAccessAction;
};
export type WorkspaceAccessDisplayState = WorkspaceReadinessState | "stopped";
export type WorkspaceAccessState = {
state: WorkspaceAccessDisplayState;
title: string;
description: string;
action: WorkspaceAccessAction;
/** True when a password-independent handoff is the expected way in. */
handoffAvailable: boolean;
/** A non-blocking historical failure that is still useful to inspect. */
secondaryNotice?: WorkspaceAccessNotice;
};
/** What the control plane said the last time a handoff was requested. */
export type WorkspaceLoginHandoffFailureInfo = {
reason: string;
detail?: string | null;
readiness?: WorkspaceReadiness | null;
};
function latestOperation(operations: WorkspaceOperation[], phase: WorkspaceOperation["phase"]) {
return operations.find((operation) => operation.phase === phase) ?? null;
}
function describeSeedPhase(readiness: WorkspaceReadiness | null | undefined): string | null {
if (!readiness?.failurePhase && !readiness?.seedPhase) return null;
return readiness.failurePhase ?? readiness.seedPhase ?? null;
}
function timestampMs(value: Date | string | null | undefined): number | null {
if (!value) return null;
const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : null;
}
function failedRepairNotice(repair: WorkspaceOperation): WorkspaceAccessNotice {
const phase = typeof repair.metadata?.repairPhase === "string" ? repair.metadata.repairPhase : null;
return {
title: "Repair failed",
description: phase
? `The repair stopped during ${phase}. The pre-repair backup was kept.`
: "The repair stopped before the workspace became usable. The pre-repair backup was kept.",
action: { kind: "view_logs", label: "View repair log" },
};
}
function failedProvisionNotice(provision: WorkspaceOperation): WorkspaceAccessNotice {
const phase = typeof provision.metadata?.seedFailurePhase === "string"
? provision.metadata.seedFailurePhase
: null;
return {
title: "Database provisioning failed",
description: phase
? `The earlier clone attempt failed during ${phase}. The workspace later became usable.`
: "An earlier clone attempt failed, but the workspace later became usable.",
action: { kind: "view_logs", label: "View provisioning log" },
};
}
const HANDOFF_REASON_COPY: Record<string, string> = {
handoff_not_configured:
"This instance has no workspace login handoff configured, so opening the board falls back to snapshot-local credentials.",
no_board_identity:
"Your session has no cloned user to sign in as, so opening the board falls back to snapshot-local credentials.",
runtime_not_running: "No healthy runtime service is publishing a URL for this workspace yet.",
runtime_url_unusable: "The runtime row is publishing a URL Paperclip cannot open.",
workspace_not_ready: "The cloned database is not ready to accept a login yet.",
};
const READINESS_FAILURE_COPY: Record<string, string> = {
database_unreachable: "The isolated database is not answering.",
clone_data_missing: "The clone restored no organization or issue rows.",
clone_data_unreadable: "The cloned product tables could not be read.",
cloned_membership_missing: "No cloned user has an active organization membership.",
cloned_identity_unreadable: "The cloned identity tables could not be read.",
auth_handoff_not_configured: "The workspace was started without a login handoff key.",
seed_manifest_unreadable: "The seed manifest is unreadable, so the restore cannot be trusted.",
};
/**
* Human cause for a readiness rejection, preferring the specific recorded phase
* over a generic sentence so the copy names what to fix.
*/
export function describeWorkspaceReadinessCause(
failure: WorkspaceLoginHandoffFailureInfo | null | undefined,
): string | null {
if (!failure) return null;
const phase = describeSeedPhase(failure.readiness);
if (phase && READINESS_FAILURE_COPY[phase]) return READINESS_FAILURE_COPY[phase];
if (phase) return `Last recorded phase: ${phase}.`;
if (failure.detail && READINESS_FAILURE_COPY[failure.detail]) return READINESS_FAILURE_COPY[failure.detail];
return HANDOFF_REASON_COPY[failure.reason] ?? null;
}
export function resolveWorkspaceAccessState(input: {
runtimeServices: WorkspaceRuntimeService[] | null | undefined;
operations: WorkspaceOperation[] | null | undefined;
handoffFailure?: WorkspaceLoginHandoffFailureInfo | null;
}): WorkspaceAccessState {
const operations = input.operations ?? [];
const runtimeServices = input.runtimeServices ?? [];
const repair = latestOperation(operations, "workspace_repair");
const provision =
latestOperation(operations, "workspace_seed")
?? latestOperation(operations, "workspace_runtime_provision")
?? latestOperation(operations, "workspace_provision");
const failure = input.handoffFailure ?? null;
const cause = describeWorkspaceReadinessCause(failure);
const handoffAvailable = failure?.reason !== "handoff_not_configured" && failure?.reason !== "no_board_identity";
const servingService = runtimeServices.find(
(service) => service.status === "running" && service.healthStatus === "healthy" && service.url,
);
const startingService = runtimeServices.find(
(service) => service.status === "provisioning" || service.status === "starting",
);
const repairFinishedAt = timestampMs(repair?.finishedAt);
const servingServiceStartedAt = timestampMs(servingService?.startedAt);
const provisionFinishedAt = timestampMs(provision?.finishedAt);
const readinessConfirmsServing = Boolean(servingService && failure?.readiness?.state === "ready");
const runtimeStartedAfterRepair = repairFinishedAt !== null
&& servingServiceStartedAt !== null
&& repairFinishedAt < servingServiceStartedAt;
const repairFailureWasSuperseded = repair?.status === "failed" && Boolean(
servingService
&& (readinessConfirmsServing || runtimeStartedAfterRepair),
);
const successfulRepairFinishedAt = repair?.status === "succeeded"
? timestampMs(repair.finishedAt)
: null;
// A failed seed is historical once the workspace is demonstrably serving,
// or once a later repair has replaced and revalidated that database.
const provisionFailureWasSuperseded = provision?.status === "failed" && Boolean(
servingService
|| (
provisionFinishedAt !== null
&& successfulRepairFinishedAt !== null
&& provisionFinishedAt < successfulRepairFinishedAt
),
);
const secondaryNotice = repair?.status === "failed" && repairFailureWasSuperseded
? failedRepairNotice(repair)
: provision?.status === "failed" && provisionFailureWasSuperseded
? failedProvisionNotice(provision)
: undefined;
// A live repair outranks everything: it is already changing the answer.
if (repair?.status === "running") {
const phase = typeof repair.metadata?.repairPhase === "string" ? repair.metadata.repairPhase : null;
return {
state: "repairing",
title: "Repairing workspace database",
description: phase
? `Only the isolated database is replaced; the git worktree and your files are preserved. Current phase: ${phase}.`
: "Only the isolated database is replaced; the git worktree and your files are preserved.",
action: { kind: "wait", label: "Repair in progress" },
handoffAvailable,
};
}
if (repair?.status === "failed" && !repairFailureWasSuperseded) {
const notice = failedRepairNotice(repair);
return {
state: "failed",
...notice,
handoffAvailable,
};
}
if (provision?.status === "running") {
return {
state: "provisioning",
title: "Provisioning database",
description: "Restoring the isolated database clone for this workspace. This runs once before the first start.",
action: { kind: "wait", label: "Provisioning" },
handoffAvailable,
};
}
if (provision?.status === "failed" && !provisionFailureWasSuperseded) {
const seedPhase = typeof provision.metadata?.seedFailurePhase === "string"
? provision.metadata.seedFailurePhase
: null;
return {
state: "failed",
title: "Database provisioning failed",
description: seedPhase
? `The clone failed during ${seedPhase}. Repairing replaces only the isolated database.`
: "The clone did not finish, so this workspace has no usable database yet.",
action: { kind: "repair", label: "Repair workspace" },
handoffAvailable,
};
}
// Readiness the control plane actually observed beats anything inferred from
// runtime rows, because it is the only signal that looked inside the clone.
const staleNotReadyFailure = failure?.reason === "workspace_not_ready" && readinessConfirmsServing;
if (failure && !staleNotReadyFailure) {
if (failure.reason === "runtime_not_running" && !servingService && !startingService) {
return {
state: "stopped",
title: "Workspace is not running",
description: "Start the workspace runtime to publish its board.",
action: { kind: "start", label: "Start workspace" },
handoffAvailable,
};
}
if (failure.reason === "workspace_not_ready" || failure.reason === "runtime_url_unusable") {
const readinessState = failure.readiness?.state;
const validating = readinessState === "validating" || readinessState === "provisioning";
return {
state: validating ? "validating" : "degraded",
title: validating ? "Validating clone" : "Workspace is degraded",
description: [
cause ?? "The workspace is serving, but its clone did not pass the readiness contract.",
validating ? "Paperclip is still confirming the clone." : "One bounded repair replaces the isolated database.",
].join(" "),
action: validating
? { kind: "wait", label: "Validating" }
: { kind: "repair", label: "Repair workspace" },
handoffAvailable,
};
}
if (!handoffAvailable) {
return {
state: servingService ? "ready" : "degraded",
title: servingService ? "Ready — snapshot-local sign-in" : "Workspace is degraded",
description: cause ?? "Opening the board will ask for the credentials captured in this snapshot.",
action: servingService
? { kind: "open", label: "Open workspace" }
: { kind: "start", label: "Start workspace" },
handoffAvailable: false,
secondaryNotice,
};
}
}
if (startingService) {
return {
state: "provisioning",
title: "Workspace is starting",
description: "Paperclip is starting the workspace runtime and waiting for its board URL.",
action: { kind: "wait", label: "Starting workspace" },
handoffAvailable,
};
}
if (servingService) {
return {
state: "ready",
title: "Ready",
description: "Opening the workspace signs you in to the cloned board without a password.",
action: { kind: "open", label: "Open workspace" },
handoffAvailable,
secondaryNotice,
};
}
const unhealthyService = runtimeServices.find(
(service) => service.status === "running" && service.healthStatus !== "healthy",
);
if (unhealthyService) {
return {
state: "degraded",
title: "Workspace is degraded",
description: cause
?? "The runtime is up but did not report a usable database, so Paperclip will not publish it as ready.",
action: { kind: "repair", label: "Repair workspace" },
handoffAvailable,
};
}
return {
state: "stopped",
title: "Workspace is not running",
description: "Start the workspace runtime to publish its board.",
action: { kind: "start", label: "Start workspace" },
handoffAvailable,
};
}