Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,54 @@ describe("ThreadPromptContextBanner", () => {
expect(markup).not.toContain("Provision");
});

it("renders an enabled Restore action once the environment is destroyed", () => {
const markup = renderToStaticMarkup(
<ThreadPromptContextBanner
gitSection={null}
gitSectionPending={false}
archivedSection={null}
environmentGoneSection={{
status: "destroyed",
onRestore: noop,
restorePending: false,
}}
parentThreadSection={null}
childThreadsSection={null}
pullRequestSection={null}
expandedSection={null}
onToggleSection={noop}
/>,
);

expect(markup).toContain("Restore environment");
expect(markup).toContain("<button");
expect(markup).not.toContain('disabled=""');
});

it("shows a disabled cleaning-up Restore action while the environment is destroying", () => {
const markup = renderToStaticMarkup(
<ThreadPromptContextBanner
gitSection={null}
gitSectionPending={false}
archivedSection={null}
environmentGoneSection={{
status: "destroying",
onRestore: noop,
restorePending: false,
}}
parentThreadSection={null}
childThreadsSection={null}
pullRequestSection={null}
expandedSection={null}
onToggleSection={noop}
/>,
);

expect(markup).toContain("Cleaning up...");
expect(markup).toContain('disabled=""');
expect(markup).not.toContain("Restore environment");
});

it("labels a standalone pull request without non-actionable attention text", () => {
const markup = renderToStaticMarkup(
<ThreadPromptContextBanner
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@ export interface ThreadPromptArchivedSection {
*/
export interface ThreadPromptEnvironmentGoneSection {
status: Extract<EnvironmentStatus, "destroying" | "destroyed">;
/**
* Restores the thread's environment by reprovisioning a fresh workspace on the
* thread's branch (recovering committed work). Enabled once the old workspace
* is fully gone (`destroyed`); while `destroying` the action shows a disabled
* "Cleaning up…" state. Omitted when restore isn't available (e.g. unmanaged
* environments).
*/
onRestore?: () => void;
restorePending?: boolean;
}

/**
Expand Down Expand Up @@ -418,6 +427,33 @@ function PullRequestReadyTextAction({
);
}

function EnvironmentRestoreTextAction({
status,
isPending,
onRestore,
}: {
status: Extract<EnvironmentStatus, "destroying" | "destroyed">;
isPending?: boolean;
onRestore: () => void;
}) {
const cleaningUp = status === "destroying";
const label = cleaningUp
? "Cleaning up..."
: isPending
? "Restoring..."
: "Restore environment";
return (
<button
type="button"
onClick={onRestore}
disabled={cleaningUp || Boolean(isPending)}
className="rounded px-1 py-0.5 text-xs text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
>
{label}
</button>
);
}

const PULL_REQUEST_MERGE_ACTIONS: readonly {
method: PullRequestMergeMethod;
label: string;
Expand Down Expand Up @@ -692,6 +728,12 @@ export function ThreadPromptContextBanner({
isPending={archivedSection.unarchivePending}
onUnarchive={archivedSection.onUnarchive}
/>
) : environmentGoneSection?.onRestore ? (
<EnvironmentRestoreTextAction
status={environmentGoneSection.status}
isPending={environmentGoneSection.restorePending}
onRestore={environmentGoneSection.onRestore}
/>
) : null
}
parentThreadSection={parentThreadSection}
Expand Down
38 changes: 38 additions & 0 deletions apps/app/src/hooks/mutations/thread-state-mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
UpdateThreadRequest,
} from "@bb/server-contract";
import * as api from "@/lib/api";
import { appToast } from "@/components/ui/app-toast";
import type { LifecycleErrorOperation } from "@/lib/lifecycle-errors";
import {
applyReorderPinnedThreadResult,
Expand Down Expand Up @@ -38,6 +39,14 @@ interface ThreadMutationRequest {
id: string;
}

/**
* How long the "Thread archived — Undo" toast stays up. Matches the server's
* archive grace window (`MANAGED_ENVIRONMENT_RETIRE_GRACE_MS`), within which an
* Undo revives the environment losslessly (its worktree has not been destroyed
* yet). After it elapses the durable Unarchive in the read-only banner remains.
*/
const ARCHIVE_UNDO_TOAST_DURATION_MS = 10_000;

type UpdateThreadMutationRequest = ThreadMutationRequest & UpdateThreadRequest;
type ReorderPinnedThreadMutationRequest = ThreadMutationRequest &
ReorderPinnedThreadRequest;
Expand Down Expand Up @@ -216,6 +225,22 @@ export function useArchiveThread() {
transaction: context,
});
},
onSuccess: (_data, { id }) => {
// Offer a quick, lossless Undo while the environment is still inside its
// grace window: un-archiving revives a retiring environment in place
// (retire.cancelled), so the worktree and uncommitted work are preserved.
appToast.message("Thread archived", {
action: {
label: "Undo",
onClick: () => {
void api.unarchiveThread(id).then(() => {
settleThreadListMembershipMutation({ queryClient, threadId: id });
});
},
},
duration: ARCHIVE_UNDO_TOAST_DURATION_MS,
});
},
onSettled: (_data, _error, variables) => {
settleThreadListMembershipMutation({
queryClient,
Expand Down Expand Up @@ -282,6 +307,19 @@ export function useUnarchiveThread() {
});
}

export function useRestoreThreadEnvironment() {
return useMutation({
meta: {
errorMessage: "Failed to restore environment.",
},
mutationFn: ({ id }: ThreadMutationRequest) =>
api.restoreThreadEnvironment(id),
// The server reprovisions a fresh environment and re-seeds the thread; the
// resulting thread/environment changes arrive over the realtime channel, so
// no optimistic cache mutation is needed here.
});
}

export function useDeleteThread() {
const queryClient = useQueryClient();

Expand Down
6 changes: 6 additions & 0 deletions apps/app/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1264,6 +1264,12 @@ export async function unarchiveThread(id: string): Promise<void> {
);
}

export async function restoreThreadEnvironment(id: string): Promise<void> {
await requestVoid(
apiClient.threads[":id"]["restore-environment"].$post({ param: { id } }),
);
}

export async function deleteThread(
id: string,
opts: DeleteThreadRequest,
Expand Down
20 changes: 18 additions & 2 deletions apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ import {
useSendThreadQueuedMessage,
useStopThread,
} from "@/hooks/mutations/thread-runtime-mutations";
import { useUnarchiveThread } from "@/hooks/mutations/thread-state-mutations";
import {
useRestoreThreadEnvironment,
useUnarchiveThread,
} from "@/hooks/mutations/thread-state-mutations";
import {
getLatestPendingInteraction,
useThreadQueuedMessages,
Expand Down Expand Up @@ -277,6 +280,7 @@ export function ThreadDetailPromptArea({
const reorderQueuedMessage = useReorderThreadQueuedMessage();
const stopThread = useStopThread();
const unarchiveThread = useUnarchiveThread();
const restoreEnvironment = useRestoreThreadEnvironment();
const uploadPromptAttachment = useUploadPromptAttachment();
// The personal project isn't a meaningful label in the footer, so skip it.
const projectName = useProjectDisplayName(
Expand Down Expand Up @@ -798,6 +802,12 @@ export function ThreadDetailPromptArea({
const handleUnarchiveCurrentThread = useCallback(() => {
unarchiveThread.mutate({ id: thread.id });
}, [thread.id, unarchiveThread]);
const isRestoreEnvironmentPending =
restoreEnvironment.isPending &&
restoreEnvironment.variables?.id === thread.id;
const handleRestoreEnvironment = useCallback(() => {
restoreEnvironment.mutate({ id: thread.id });
}, [thread.id, restoreEnvironment]);

const attachmentsConfig = useMemo(
() => ({
Expand Down Expand Up @@ -1017,7 +1027,11 @@ export function ThreadDetailPromptArea({
environmentGoneSection={
environmentGoneStatus === null
? null
: { status: environmentGoneStatus }
: {
status: environmentGoneStatus,
onRestore: handleRestoreEnvironment,
restorePending: isRestoreEnvironmentPending,
}
}
parentThreadSection={parentThreadSection}
childThreadsSection={childThreadsSection}
Expand Down Expand Up @@ -1070,9 +1084,11 @@ export function ThreadDetailPromptArea({
handleSendQueuedImmediately,
handleToggleBannerSection,
handleUnarchiveCurrentThread,
handleRestoreEnvironment,
environmentGoneStatus,
isFollowUpSubmitting,
isUnarchiveCurrentThreadPending,
isRestoreEnvironmentPending,
isQueueMutationPending,
goal,
isGoalExpanded,
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ export const HEARTBEAT_INTERVAL_MS = 5_000;
export const LEASE_TIMEOUT_MS = 30_000;
export const DAEMON_DISCONNECT_GRACE_MS = 5_000;
export const DAEMON_ACTIVE_WORK_DISCONNECT_GRACE_MS = LEASE_TIMEOUT_MS;
/**
* Grace window after the last live thread in a managed environment is archived
* before its worktree is destroyed. The environment stays `retiring` (revivable
* via unarchive → `retire.cancelled`, worktree intact) for this long so an
* accidental archive can be undone losslessly. Surfaced as the archive toast's
* "Undo" duration. The destroy is gated on the environment's `updatedAt` (the
* retire-requested time), so the window is durable across restart.
*/
export const MANAGED_ENVIRONMENT_RETIRE_GRACE_MS = 10_000;
export const WORKSPACE_DIFF_MAX_DIFF_BYTES = 2 * 1024 * 1024;
export const WORKSPACE_DIFF_MAX_FILE_LIST_BYTES = 256 * 1024;

Expand Down
27 changes: 23 additions & 4 deletions apps/server/src/routes/threads/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
requestEnvironmentCleanupAdvance,
wouldCleanupEnvironment,
} from "../../services/environments/environment-cleanup-internal.js";
import { applyLoggedEnvironmentLifecycleEvent } from "../../services/environments/lifecycle-outcome.js";
import { requirePublicThread } from "../../services/lib/entity-lookup.js";
import { validatePromptAttachmentReferences } from "../../services/projects/attachments.js";
import {
Expand All @@ -57,6 +58,7 @@ import {
archiveThreadAndChildren,
archiveThreadWithLifecycleEffects,
} from "../../services/threads/thread-archive.js";
import { restoreThreadEnvironment } from "../../services/threads/thread-environment-restore.js";
import {
requireThreadCommandEnvironment,
requireThreadHostCommandEnvironment,
Expand Down Expand Up @@ -376,17 +378,27 @@ export function registerThreadActionRoutes(app: Hono, deps: AppDeps): void {
});
});

// Un-archive is a pure record op: it clears archivedAt and nothing else. It
// deliberately does not touch the environment lifecycle; cleanup is monotonic
// and never cancelled, and a thread whose environment is gone surfaces a
// read-only "environment is gone" banner instead of resurrecting it.
// Un-archive clears archivedAt. When the thread's managed environment is still
// inside its archive grace window (`retiring`), un-archiving revives it via the
// existing `retire.cancelled` event so the intact worktree is restored — the
// lossless undo of an accidental archive. If the grace window already elapsed
// and the environment was destroyed, `retire.cancelled` is a no-op (illegal
// from destroying/destroyed) and the thread surfaces the read-only "environment
// is gone" banner — use Restore to reprovision — instead of resurrecting the
// terminal row.
post(routes.unarchive, (context) => {
const thread = requirePublicThread(deps.db, context.req.param("id"));
const providerThreadId = getLastProviderThreadId(deps, thread.id);
unarchiveThread(deps.db, deps.hub, thread.id);
const environment = thread.environmentId
? getEnvironment(deps.db, thread.environmentId)
: null;
if (environment?.status === "retiring") {
applyLoggedEnvironmentLifecycleEvent(deps, {
environmentId: environment.id,
event: { type: "retire.cancelled" },
});
}
if (providerThreadId && environment) {
dispatchThreadUnarchiveCommand(deps, {
environment,
Expand All @@ -397,6 +409,13 @@ export function registerThreadActionRoutes(app: Hono, deps: AppDeps): void {
return context.json({ ok: true });
});

post(routes.restoreEnvironment, async (context) => {
await restoreThreadEnvironment(deps, {
threadId: context.req.param("id"),
});
return context.json({ ok: true });
});

post(routes.read, (context) => {
requirePublicThread(deps.db, context.req.param("id"));
const thread = updateThread(deps.db, deps.hub, context.req.param("id"), {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getEnvironment,
getActiveSession,
hasPendingThreadShutdownInEnvironment,
hasRevivableArchivedThreadInEnvironment,
listLiveThreadsInEnvironment,
type DbNotifier,
type DbQueryConnection,
Expand Down Expand Up @@ -398,6 +399,29 @@ async function advanceEnvironmentCleanup(
return;
}

// Archive grace window: a freshly retired managed worktree stays revivable
// (worktree intact, undoable via unarchive → retire.cancelled) for the
// configured grace window so an accidental archive can be undone losslessly.
// `updatedAt` is the retire-requested time — a retiring row is only
// ever mutated by the events that exit retiring, so it is a faithful clock that
// survives restart. Scope: only the path-bearing `retiring` case waits; a
// pathless env (handled above) has no worktree to lose, and `error` is failed
// cleanup rather than an accidental-archive brick. The window applies only when
// the environment still has a revivable archived thread — an env left retiring
// by a deleted/tombstoned thread has nothing to unarchive, so it is cleaned up
// immediately rather than lingering.
if (
refreshedEnvironment.status === "retiring" &&
refreshedEnvironment.path !== null &&
Date.now() - refreshedEnvironment.updatedAt <
deps.config.managedEnvironmentRetireGraceMs &&
hasRevivableArchivedThreadInEnvironment(deps.db, {
environmentId: refreshedEnvironment.id,
})
) {
return;
}

if (
countLiveThreadsInEnvironment(deps.db, {
environmentId: refreshedEnvironment.id,
Expand Down
Loading
Loading