paperclip/ui/src/lib/workspace-access-state.test.ts

427 lines
14 KiB
TypeScript

import type { WorkspaceOperation, WorkspaceRuntimeService } from "@paperclipai/shared";
import { describe, expect, it } from "vitest";
import {
describeWorkspaceReadinessCause,
resolveWorkspaceAccessState,
} from "./workspace-access-state";
function runtimeService(overrides: Partial<WorkspaceRuntimeService> = {}): WorkspaceRuntimeService {
return {
id: "runtime-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
executionWorkspaceId: "ews-1",
issueId: null,
scopeType: "execution_workspace",
scopeId: "ews-1",
serviceName: "paperclip-dev",
status: "running",
lifecycle: "shared",
reuseKey: null,
command: "pnpm dev:once",
cwd: "/srv/worktree",
port: 42013,
url: "https://workspace.example.ts.net:42013/",
provider: "local_process",
providerRef: null,
ownerAgentId: null,
startedByRunId: null,
lastUsedAt: new Date("2026-08-19T00:00:00.000Z"),
startedAt: new Date("2026-08-19T00:00:00.000Z"),
stoppedAt: null,
stopPolicy: null,
healthStatus: "healthy",
createdAt: new Date("2026-08-19T00:00:00.000Z"),
updatedAt: new Date("2026-08-19T00:00:00.000Z"),
...overrides,
};
}
function operation(overrides: Partial<WorkspaceOperation> = {}): WorkspaceOperation {
return {
id: "op-1",
companyId: "company-1",
executionWorkspaceId: "ews-1",
heartbeatRunId: null,
issueId: null,
phase: "workspace_seed",
command: null,
cwd: null,
status: "succeeded",
exitCode: 0,
logStore: null,
logRef: null,
logBytes: null,
logSha256: null,
logCompressed: false,
stdoutExcerpt: null,
stderrExcerpt: null,
metadata: null,
startedAt: new Date("2026-08-19T00:00:00.000Z"),
finishedAt: new Date("2026-08-19T00:01:00.000Z"),
createdAt: new Date("2026-08-19T00:00:00.000Z"),
updatedAt: new Date("2026-08-19T00:01:00.000Z"),
...overrides,
};
}
describe("resolveWorkspaceAccessState", () => {
it("is ready with a password-independent open action when a healthy runtime publishes a URL", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [runtimeService()],
operations: [operation()],
});
expect(access).toMatchObject({
state: "ready",
action: { kind: "open", label: "Open workspace" },
handoffAvailable: true,
});
expect(access.description).toContain("without a password");
});
it("shows a repair-in-progress state with the current phase and no action", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [runtimeService()],
operations: [
operation({ phase: "workspace_repair", status: "running", metadata: { repairPhase: "full_reseed" } }),
],
});
expect(access.state).toBe("repairing");
expect(access.description).toContain("full_reseed");
expect(access.description).toContain("preserved");
expect(access.action.kind).toBe("wait");
});
it("shows a failed repair with the failing phase and points at the log", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [],
operations: [
operation({ phase: "workspace_repair", status: "failed", metadata: { repairPhase: "readiness_validation" } }),
],
});
expect(access).toMatchObject({ state: "failed", action: { kind: "view_logs" } });
expect(access.title).toBe("Repair failed");
expect(access.description).toContain("readiness_validation");
expect(access.description).toContain("backup");
});
it("returns to ready after a failed repair when a newer healthy runtime is serving", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [runtimeService({ startedAt: new Date("2026-08-19T00:03:00.000Z") })],
operations: [
operation({
phase: "workspace_repair",
status: "failed",
metadata: { repairPhase: "managed_restart" },
finishedAt: new Date("2026-08-19T00:02:00.000Z"),
}),
],
});
expect(access).toMatchObject({
state: "ready",
action: { kind: "open", label: "Open workspace" },
secondaryNotice: {
title: "Repair failed",
action: { kind: "view_logs", label: "View repair log" },
},
});
expect(access.secondaryNotice?.description).toContain("managed_restart");
expect(access.secondaryNotice?.description).toContain("backup");
});
it("returns to ready when a serving runtime has subsequently reported ready", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [runtimeService({ startedAt: new Date("2026-08-19T00:01:00.000Z") })],
operations: [
operation({
phase: "workspace_repair",
status: "failed",
metadata: { repairPhase: "managed_restart" },
finishedAt: new Date("2026-08-19T00:02:00.000Z"),
}),
],
handoffFailure: {
reason: "workspace_not_ready",
readiness: {
state: "ready",
databaseReady: true,
cloneDataReady: true,
authHandoffReady: true,
authHandoffUserId: null,
seedState: "verified",
seedPhase: "complete",
seedMode: "full",
instanceId: "instance-a",
executionWorkspaceId: "ews-1",
companyId: "company-1",
failurePhase: null,
},
},
});
expect(access).toMatchObject({ state: "ready", action: { kind: "open" } });
expect(access.secondaryNotice?.action.kind).toBe("view_logs");
});
it("keeps a newer failed repair primary even when an older runtime is healthy", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [runtimeService({ startedAt: new Date("2026-08-19T00:01:00.000Z") })],
operations: [
operation({
phase: "workspace_repair",
status: "failed",
finishedAt: new Date("2026-08-19T00:02:00.000Z"),
}),
],
});
expect(access).toMatchObject({ state: "failed", action: { kind: "view_logs" } });
expect(access.secondaryNotice).toBeUndefined();
});
it("shows provisioning while the clone is restoring", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [],
operations: [operation({ status: "running" })],
});
expect(access).toMatchObject({ state: "provisioning", action: { kind: "wait" } });
expect(access.title).toBe("Provisioning database");
});
it("offers a repair when provisioning failed, naming the seed phase", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [],
operations: [operation({ status: "failed", metadata: { seedFailurePhase: "restore" } })],
});
expect(access).toMatchObject({ state: "failed", action: { kind: "repair", label: "Repair workspace" } });
expect(access.description).toContain("restore");
});
it("returns to ready after a failed seed when a later repair succeeded and a healthy runtime is serving", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [runtimeService({ startedAt: new Date("2026-08-20T00:18:00.000Z") })],
operations: [
operation({
id: "repair-1",
phase: "workspace_repair",
status: "succeeded",
startedAt: new Date("2026-08-20T00:10:00.000Z"),
finishedAt: new Date("2026-08-20T00:17:31.000Z"),
}),
operation({
id: "seed-1",
phase: "workspace_seed",
status: "failed",
metadata: { seedFailurePhase: "manifest_verification" },
startedAt: new Date("2026-08-20T00:02:00.000Z"),
finishedAt: new Date("2026-08-20T00:04:37.000Z"),
}),
],
});
expect(access).toMatchObject({
state: "ready",
action: { kind: "open", label: "Open workspace" },
secondaryNotice: {
title: "Database provisioning failed",
action: { kind: "view_logs", label: "View provisioning log" },
},
});
expect(access.secondaryNotice?.description).toContain("manifest_verification");
expect(access.secondaryNotice?.description).toContain("later became usable");
});
it("does not offer repair for a stale failed seed after a later successful repair", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [],
operations: [
operation({
id: "repair-1",
phase: "workspace_repair",
status: "succeeded",
finishedAt: new Date("2026-08-20T00:17:31.000Z"),
}),
operation({
id: "seed-1",
phase: "workspace_seed",
status: "failed",
finishedAt: new Date("2026-08-20T00:04:37.000Z"),
}),
],
});
expect(access).toMatchObject({
state: "stopped",
action: { kind: "start", label: "Start workspace" },
});
});
it("returns to ready when a healthy runtime proves a failed seed is stale", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [runtimeService()],
operations: [operation({ status: "failed" })],
});
expect(access).toMatchObject({
state: "ready",
action: { kind: "open", label: "Open workspace" },
secondaryNotice: {
title: "Database provisioning failed",
action: { kind: "view_logs" },
},
});
});
it("shows validating, not degraded, while a fresh clone is still being confirmed", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [runtimeService()],
operations: [operation()],
handoffFailure: {
reason: "workspace_not_ready",
detail: "clone_data_missing",
readiness: {
state: "validating",
databaseReady: true,
cloneDataReady: false,
authHandoffReady: true,
authHandoffUserId: null,
seedState: "unknown",
seedPhase: "legacy_complete_marker",
seedMode: null,
instanceId: "instance-a",
executionWorkspaceId: "ews-1",
companyId: "company-1",
failurePhase: "clone_data_missing",
},
},
});
expect(access).toMatchObject({ state: "validating", action: { kind: "wait" } });
expect(access.description).toContain("restored no organization or issue rows");
});
it("degrades a verified clone whose database regressed and offers one repair", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [runtimeService()],
operations: [operation()],
handoffFailure: {
reason: "workspace_not_ready",
detail: "database_unreachable",
readiness: {
state: "degraded",
databaseReady: false,
cloneDataReady: false,
authHandoffReady: false,
authHandoffUserId: null,
seedState: "verified",
seedPhase: "complete",
seedMode: "minimal",
instanceId: "instance-a",
executionWorkspaceId: "ews-1",
companyId: "company-1",
failurePhase: "database_unreachable",
},
},
});
expect(access).toMatchObject({ state: "degraded", action: { kind: "repair" } });
expect(access.description).toContain("isolated database is not answering");
});
it("labels the credential path accurately when no handoff is configured", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [runtimeService()],
operations: [operation()],
handoffFailure: { reason: "handoff_not_configured" },
});
expect(access).toMatchObject({
state: "ready",
action: { kind: "open" },
handoffAvailable: false,
});
expect(access.description).toContain("snapshot-local credentials");
});
it("offers start when nothing is running", () => {
expect(
resolveWorkspaceAccessState({
runtimeServices: [runtimeService({ status: "stopped", healthStatus: "unknown", url: null })],
operations: [],
}),
).toMatchObject({ state: "stopped", action: { kind: "start", label: "Start workspace" } });
expect(
resolveWorkspaceAccessState({
runtimeServices: [],
operations: [],
handoffFailure: { reason: "runtime_not_running" },
}),
).toMatchObject({ state: "stopped", action: { kind: "start" } });
});
it("shows provisioning only while a runtime service is actually starting", () => {
expect(
resolveWorkspaceAccessState({
runtimeServices: [runtimeService({ status: "starting", healthStatus: "unknown", url: null })],
operations: [],
handoffFailure: { reason: "runtime_not_running" },
}),
).toMatchObject({
state: "provisioning",
action: { kind: "wait", label: "Starting workspace" },
});
});
it("refuses to call an unhealthy running runtime ready", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [runtimeService({ healthStatus: "unhealthy" })],
operations: [operation()],
});
expect(access).toMatchObject({ state: "degraded", action: { kind: "repair" } });
});
it("treats a running service with no URL as not yet serving", () => {
const access = resolveWorkspaceAccessState({
runtimeServices: [runtimeService({ url: null })],
operations: [operation()],
});
expect(access.state).not.toBe("ready");
});
it("handles missing operations and services without throwing", () => {
expect(resolveWorkspaceAccessState({ runtimeServices: null, operations: undefined }).state)
.toBe("stopped");
});
});
describe("describeWorkspaceReadinessCause", () => {
it("prefers the recorded readiness phase over the generic reason", () => {
expect(
describeWorkspaceReadinessCause({
reason: "workspace_not_ready",
readiness: {
state: "degraded",
databaseReady: true,
cloneDataReady: true,
authHandoffReady: false,
authHandoffUserId: null,
seedState: "verified",
seedPhase: "complete",
seedMode: "minimal",
instanceId: "i",
executionWorkspaceId: "e",
companyId: "company-1",
failurePhase: "cloned_membership_missing",
},
}),
).toBe("No cloned user has an active organization membership.");
});
it("falls back to the reason and finally to null", () => {
expect(describeWorkspaceReadinessCause({ reason: "runtime_not_running" }))
.toContain("No healthy runtime service");
expect(describeWorkspaceReadinessCause({ reason: "something_new" })).toBeNull();
expect(describeWorkspaceReadinessCause(null)).toBeNull();
});
});