317 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|