diff --git a/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.stories.tsx b/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.stories.tsx index db389ec24b..814314f50e 100644 --- a/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.stories.tsx +++ b/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.stories.tsx @@ -689,14 +689,14 @@ export function Overview() { /> { />, ); - expect(markup).toContain("Environment is unavailable"); - expect(markup).toContain("This thread can't run any more work."); + expect(markup).toContain("Environment archived"); + expect(markup).toContain("This environment has been archived."); + expect(markup).not.toContain("to keep working"); expect(markup).toContain('role="status"'); expect(markup).not.toContain(" { expectedLabel: "Thread is archived", }, { - label: "environment gone", + label: "environment archived", archivedSection: null, environmentGoneSection: { status: "destroyed" as const }, - expectedLabel: "Environment is unavailable", + expectedLabel: "Environment archived", }, ])( "keeps the $label read-only status visible in compact mode", @@ -150,6 +151,35 @@ describe("ThreadPromptContextBanner", () => { }, ); + it("prioritizes the archived-environment status over unarchiving", () => { + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain("Environment archived"); + expect(markup).not.toContain("Thread is archived"); + expect(markup).not.toContain(">Unarchive<"); + }); + it("labels a standalone pull request without non-actionable attention text", () => { const markup = renderToStaticMarkup( = { }; const ARCHIVED_THREAD_STATUS_LABEL = "Thread is archived"; -const ENVIRONMENT_GONE_STATUS_LABEL = "Environment is unavailable"; -const ENVIRONMENT_GONE_ARIA_LABEL = - "Environment is unavailable. This thread can't run any more work."; +const ENVIRONMENT_GONE_STATUS_COPY: Record< + ThreadPromptEnvironmentGoneSection["status"], + { ariaLabel: string; label: string } +> = { + destroying: { + ariaLabel: "This environment is being archived.", + label: "Archiving environment...", + }, + destroyed: { + ariaLabel: "This environment has been archived.", + label: "Environment archived", + }, +}; const PROMPT_BANNER_ACTION_FILL_CLASS = "bg-background shadow-xs"; const PROMPT_BANNER_ACTION_INTERACTIVE_CLASS = "cursor-pointer text-muted-foreground transition-colors hover:bg-state-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"; @@ -863,21 +873,21 @@ export function ThreadPromptContextBanner({ onToggleSection, }: ThreadPromptContextBannerProps) { if (archivedSection || environmentGoneSection) { + const environmentGone = environmentGoneSection !== null; + const environmentGoneCopy = environmentGoneSection + ? ENVIRONMENT_GONE_STATUS_COPY[environmentGoneSection.status] + : null; return ( ({ + closePanesForThreads: vi.fn(), + dialogOnClose: vi.fn(), + dialogOnOpen: vi.fn(), + dialogOnOpenChange: vi.fn(), + mutation: vi.fn(), + navigate: vi.fn(), +})); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => mocks.navigate }; +}); + +vi.mock("jotai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSetAtom: () => mocks.closePanesForThreads, + }; +}); + +vi.mock("@/components/dialogs/ThreadDeleteDialog", () => ({ + ThreadDeleteDialog: () => null, +})); + +vi.mock("@/components/dialogs/ThreadRenameDialog", () => ({ + ThreadRenameDialog: () => null, +})); + +vi.mock("@/components/ui/app-toast", () => ({ + appToast: { + dismiss: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + message: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + }, +})); + +vi.mock("@/hooks/mutations/thread-state-mutations", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/hooks/mutations/thread-state-mutations") + >(); + return { + ...actual, + useDeleteThread: () => ({ isPending: false, mutate: mocks.mutation }), + useMarkThreadRead: () => ({ mutate: mocks.mutation }), + useMarkThreadUnread: () => ({ mutate: mocks.mutation }), + usePinThread: () => ({ mutate: mocks.mutation }), + useUnpinThread: () => ({ mutate: mocks.mutation }), + useUpdateThread: () => ({ isPending: false, mutate: mocks.mutation }), + }; +}); + +vi.mock("@/lib/sdk", () => ({ + sdk: { + threads: { + archiveAll: vi.fn(), + childSummary: vi.fn(), + unarchive: vi.fn(), + }, + }, +})); + +vi.mock("@/hooks/useDialogState", () => ({ + useDialogState: () => ({ + onClose: mocks.dialogOnClose, + onOpen: mocks.dialogOnOpen, + onOpenChange: mocks.dialogOnOpenChange, + target: null, + }), +})); + +vi.mock("@/hooks/useRouteState", () => ({ + useRouteState: () => ({ threadId: null }), +})); + +function makeThread(overrides: Partial = {}): Thread { + return { + archivedAt: null, + childOrigin: null, + createdAt: 1, + deletedAt: null, + environmentId: "env_test", + id: "thr_parent", + lastReadAt: null, + latestAttentionAt: 1, + originKind: null, + originPluginId: null, + parentThreadId: null, + pinnedAt: null, + projectId: "proj_test", + providerId: "codex", + sectionId: null, + sourceThreadId: null, + status: "idle", + title: "Investigate archive behavior", + titleFallback: null, + updatedAt: 1, + visibility: "visible", + ...overrides, + }; +} + +function ArchiveButton({ thread }: { thread: Thread }) { + const { archiveThreadAndChildren } = useThreadActions(); + return ( + + ); +} + +function renderProvider(children: ReactNode) { + return render( + + {children} + , + ); +} + +let queryClient: QueryClient; + +beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false }, + }, + }); + vi.mocked(sdk.threads.archiveAll).mockResolvedValue({ + archivedThreadIds: ["thr_parent", "thr_child"], + ok: true, + }); + vi.mocked(sdk.threads.unarchive).mockResolvedValue({ ok: true }); + mocks.closePanesForThreads.mockReturnValue({ + focusedRoute: null, + removedAny: false, + }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("ThreadActionsProvider archive feedback", () => { + it("shows one archive toast whose Undo restores the parent and children", async () => { + renderProvider(); + + fireEvent.click(screen.getByRole("button", { name: "Archive" })); + + await vi.waitFor(() => { + expect(appToast.success).toHaveBeenCalledTimes(1); + }); + expect(appToast.message).not.toHaveBeenCalled(); + const toastOptions = vi.mocked(appToast.success).mock.calls[0]?.[1]; + expect(toastOptions).toMatchObject({ + action: { label: "Undo" }, + duration: 10_000, + id: "thread-archived-thr_parent", + }); + + const undoAction = toastOptions?.action; + if (undoAction === undefined) { + throw new Error("Expected archive toast to provide Undo"); + } + render(); + fireEvent.click(screen.getByRole("button", { name: "Run undo" })); + + await vi.waitFor(() => { + expect(sdk.threads.unarchive).toHaveBeenCalledTimes(2); + }); + expect(sdk.threads.unarchive).toHaveBeenNthCalledWith(1, { + threadId: "thr_parent", + }); + expect(sdk.threads.unarchive).toHaveBeenNthCalledWith(2, { + threadId: "thr_child", + }); + }); +}); diff --git a/apps/app/src/components/thread/ThreadActionsProvider.tsx b/apps/app/src/components/thread/ThreadActionsProvider.tsx index 4f41ae7bcf..1615422d80 100644 --- a/apps/app/src/components/thread/ThreadActionsProvider.tsx +++ b/apps/app/src/components/thread/ThreadActionsProvider.tsx @@ -85,6 +85,13 @@ interface ThreadActionContext { childThreadCount: number; } +/** + * Keeps immediate archive feedback actionable without pinning a toast for the + * full server-side recovery window. The archived thread's normal Unarchive + * action remains available while its environment is still retiring. + */ +const ARCHIVE_UNDO_TOAST_DURATION_MS = 10_000; + export function ThreadActionsProvider({ children, }: ThreadActionsProviderProps) { @@ -343,7 +350,18 @@ export function ThreadActionsProvider({ appToast.dismiss(toastId); }} />, - { id: toastId }, + { + action: { + label: "Undo", + onClick: () => { + for (const threadId of response.archivedThreadIds) { + unarchiveMutate({ id: threadId }); + } + }, + }, + duration: ARCHIVE_UNDO_TOAST_DURATION_MS, + id: toastId, + }, ); }, onError: (error) => { @@ -363,6 +381,7 @@ export function ThreadActionsProvider({ closePanesForThreads, navigate, syncNavigationAfterClose, + unarchiveMutate, ], ); diff --git a/apps/server/src/constants.ts b/apps/server/src/constants.ts index 24858ef652..de3b926e62 100644 --- a/apps/server/src/constants.ts +++ b/apps/server/src/constants.ts @@ -3,6 +3,16 @@ 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. UI affordances may be + * shorter-lived than this server-side recovery window. The destroy gate uses + * the lifecycle-owned `retireRequestedAt` timestamp, so metadata updates cannot + * move the clock and the window remains durable across restart. + */ +export const MANAGED_ENVIRONMENT_RETIRE_GRACE_MS = 5 * 60_000; export const WORKSPACE_DIFF_MAX_DIFF_BYTES = 2 * 1024 * 1024; export const WORKSPACE_DIFF_MAX_FILE_LIST_BYTES = 256 * 1024; diff --git a/apps/server/src/routes/threads/actions.ts b/apps/server/src/routes/threads/actions.ts index 0f120826df..fd404a0583 100644 --- a/apps/server/src/routes/threads/actions.ts +++ b/apps/server/src/routes/threads/actions.ts @@ -39,6 +39,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 { parseSafeRelativeRoutePath } from "../relative-route-path.js"; import { validatePromptAttachmentReferences } from "../../services/projects/attachments.js"; @@ -702,10 +703,13 @@ 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 remains read-only. The user can + // hand its context and surviving branch off to a new thread instead. post(routes.unarchive, (context) => { const thread = requirePublicThread(deps.db, context.req.param("id")); const providerThreadId = getLastProviderThreadId(deps, thread.id); @@ -713,6 +717,12 @@ export function registerThreadActionRoutes(app: Hono, deps: AppDeps): void { 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, diff --git a/apps/server/src/services/environments/environment-cleanup-internal.ts b/apps/server/src/services/environments/environment-cleanup-internal.ts index c5cbaeba37..d3cef1b35d 100644 --- a/apps/server/src/services/environments/environment-cleanup-internal.ts +++ b/apps/server/src/services/environments/environment-cleanup-internal.ts @@ -4,6 +4,7 @@ import { countLiveThreadsInEnvironment, getEnvironment, hasPendingThreadShutdownInEnvironment, + hasRevivableArchivedThreadInEnvironment, listLiveThreadsInEnvironment, type DbNotifier, type DbQueryConnection, @@ -204,7 +205,10 @@ export function settleEnvironmentDestroyCommandResult( args.deps, { environmentId: args.command.environmentId, - event: { type: "destroy.completed" }, + event: { + type: "destroy.completed", + destroyAttemptId: args.execution.id, + }, }, ); if (!outcome.applied) { @@ -370,7 +374,10 @@ async function advanceEnvironmentCleanup( } applyLoggedEnvironmentLifecycleEvent(deps, { environmentId: environment.id, - event: { type: "destroy.completed" }, + event: { + type: "destroy.completed", + destroyAttemptId: execution.id, + }, }); return; } @@ -392,6 +399,30 @@ 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. + // `retireRequestedAt` is stamped by the lifecycle event and survives restart + // without moving when unrelated environment metadata changes. 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 && + refreshedEnvironment.retireRequestedAt !== null && + Date.now() - refreshedEnvironment.retireRequestedAt < + deps.config.managedEnvironmentRetireGraceMs && + hasRevivableArchivedThreadInEnvironment(deps.db, { + environmentId: refreshedEnvironment.id, + }) + ) { + return; + } + if ( countLiveThreadsInEnvironment(deps.db, { environmentId: refreshedEnvironment.id, @@ -420,7 +451,10 @@ async function advanceEnvironmentCleanup( if (!claimedEnvironment.path) { applyLoggedEnvironmentLifecycleEvent(deps, { environmentId: claimedEnvironment.id, - event: { type: "destroy.completed" }, + event: { + type: "destroy.completed", + destroyAttemptId: execution.id, + }, }); return; } diff --git a/apps/server/src/services/environments/environment-provisioning-internal.ts b/apps/server/src/services/environments/environment-provisioning-internal.ts index e65fe4a02e..da9393c7ac 100644 --- a/apps/server/src/services/environments/environment-provisioning-internal.ts +++ b/apps/server/src/services/environments/environment-provisioning-internal.ts @@ -107,7 +107,10 @@ interface EnvironmentProvisionTransactionDeps extends EnvironmentProvisionWriteD } interface CompletePathlessDestroyInTransactionArgs { - environment: Pick; + environment: Pick< + NonNullable>, + "destroyAttemptId" | "path" | "status" + >; environmentId: string; } @@ -455,7 +458,10 @@ function completePathlessDestroyInTransaction( deps, { environmentId: args.environmentId, - event: { type: "destroy.completed" }, + event: { + type: "destroy.completed", + destroyAttemptId: args.environment.destroyAttemptId, + }, }, ); if (completedOutcome.applied) { diff --git a/apps/server/src/services/system/periodic-sweeps.ts b/apps/server/src/services/system/periodic-sweeps.ts index 2bde27b221..4dadcbf137 100644 --- a/apps/server/src/services/system/periodic-sweeps.ts +++ b/apps/server/src/services/system/periodic-sweeps.ts @@ -175,14 +175,11 @@ export async function runPeriodicSweepJobs( } } -async function evaluateManagedEnvironmentArchiveCleanupCandidates( +async function advanceRetiringManagedEnvironments( deps: LoggedPendingInteractionWorkSessionDeps, - orphanedDestroyUpdatedBefore: number, ): Promise { - recoverOrphanedEnvironmentDestroyRequests(deps, { - updatedBefore: orphanedDestroyUpdatedBefore, - }); - + // The advance enforces the archive grace window per environment, so this sweeps + // every retiring candidate each tick and lets in-grace ones short-circuit. const environmentsToClean = sweepManagedEnvironments(deps.db); if (environmentsToClean.length === 0) { return { @@ -379,23 +376,24 @@ export async function runManagedEnvironmentArchiveCleanupRecoverySweep( deps: LoggedPendingInteractionWorkSessionDeps, now: number, ): Promise { + // Orphaned-destroy recovery only touches environments stuck `destroying` for + // longer than the daemon command timeout, so it stays throttled — it is a rare + // backstop, not the steady-state driver. if ( - now - lastManagedEnvironmentArchiveCleanupRecoveryAt < + now - lastManagedEnvironmentArchiveCleanupRecoveryAt >= MANAGED_ENVIRONMENT_ARCHIVE_CLEANUP_RECOVERY_INTERVAL_MS ) { - return; - } - - const result = await evaluateManagedEnvironmentArchiveCleanupCandidates( - deps, - now - ORPHANED_ENVIRONMENT_DESTROY_RECOVERY_DELAY_MS, - ); - if ( - result.candidates > 0 && - result.hostUnavailableDeferrals < result.candidates - ) { + recoverOrphanedEnvironmentDestroyRequests(deps, { + updatedBefore: now - ORPHANED_ENVIRONMENT_DESTROY_RECOVERY_DELAY_MS, + }); lastManagedEnvironmentArchiveCleanupRecoveryAt = now; } + + // Grace-gated destroy runs every tick: a retiring managed worktree is reclaimed + // ~one sweep tick after its archive grace window elapses. The advance enforces + // the window against the durable `updatedAt` clock (no in-memory timer), so it + // survives restart. + await advanceRetiringManagedEnvironments(deps); } export async function runProjectDeletionSweep( @@ -594,7 +592,15 @@ export async function runStartupRecoverySweep( ): Promise { await runEnvironmentProvisioningSweep(deps); await runThreadLifecycleSweep(deps); - await evaluateManagedEnvironmentArchiveCleanupCandidates(deps, Date.now()); + // A daemon can reconnect and settle an in-flight destroy after the server + // restarts. Apply the same orphan timeout used by periodic recovery instead + // of immediately moving every `destroying` row to `error`; genuinely stale + // attempts are still recovered, and a matching late success can settle from + // `error` as a final backstop. + recoverOrphanedEnvironmentDestroyRequests(deps, { + updatedBefore: Date.now() - ORPHANED_ENVIRONMENT_DESTROY_RECOVERY_DELAY_MS, + }); + await advanceRetiringManagedEnvironments(deps); } export async function runPeriodicSweeps( diff --git a/apps/server/src/start-server.ts b/apps/server/src/start-server.ts index b6217b1c0f..38b5cb39f5 100644 --- a/apps/server/src/start-server.ts +++ b/apps/server/src/start-server.ts @@ -23,6 +23,7 @@ import { createTelemetryService } from "./services/system/telemetry.js"; import { TerminalSessionLifecycle } from "./services/terminals/terminal-session-lifecycle.js"; import { resolveThreadStorageRootPath } from "./services/threads/thread-storage.js"; import { createLifecycleDedupers } from "./lifecycle-dedupers.js"; +import { MANAGED_ENVIRONMENT_RETIRE_GRACE_MS } from "./constants.js"; import type { ServerRuntimeConfig } from "./types.js"; import { NotificationHub } from "./ws/hub.js"; import { WatchInterestCoordinator } from "./ws/watch-interests.js"; @@ -77,6 +78,7 @@ export async function runServer(serverConfig: ServerConfig): Promise { inheritedSkillsRootPaths: serverConfig.BB_INHERITED_SKILLS_ROOTS, inferenceModel: serverConfig.BB_INFERENCE, isDevelopment: !isProduction, + managedEnvironmentRetireGraceMs: MANAGED_ENVIRONMENT_RETIRE_GRACE_MS, openAiApiKey: serverConfig.OPENAI_API_KEY, serverPort: serverConfig.BB_SERVER_PORT, sharedSkillRoots: { user: [], project: [] }, diff --git a/apps/server/src/types.ts b/apps/server/src/types.ts index 35d0ceae78..118eb0f1c9 100644 --- a/apps/server/src/types.ts +++ b/apps/server/src/types.ts @@ -32,6 +32,13 @@ export interface ServerRuntimeConfig { inheritedSkillsRootPaths: string[]; inferenceModel: string; isDevelopment: boolean; + /** + * Grace window (ms) after the last live thread in a managed environment is + * archived before its worktree is destroyed, during which an accidental + * archive can be undone losslessly. Defaults to + * {@link MANAGED_ENVIRONMENT_RETIRE_GRACE_MS}; set to 0 to destroy immediately. + */ + managedEnvironmentRetireGraceMs: number; openAiApiKey: string; serverPort: number; sharedSkillRoots: ProviderNativeSkillRoots; diff --git a/apps/server/test/helpers/test-app.ts b/apps/server/test/helpers/test-app.ts index 036d447d94..fc1c0e7f80 100644 --- a/apps/server/test/helpers/test-app.ts +++ b/apps/server/test/helpers/test-app.ts @@ -20,6 +20,7 @@ import { TerminalSessionLifecycle } from "../../src/services/terminals/terminal- import { resolveThreadStorageRootPath } from "../../src/services/threads/thread-storage.js"; import { createLifecycleDedupers } from "../../src/lifecycle-dedupers.js"; import type { ServerAppDeps, ServerRuntimeConfig } from "../../src/types.js"; +import { MANAGED_ENVIRONMENT_RETIRE_GRACE_MS } from "../../src/constants.js"; import type { NotificationHub } from "../../src/ws/hub.js"; import { NotificationHub as NotificationHubImpl } from "../../src/ws/hub.js"; import { WatchInterestCoordinator } from "../../src/ws/watch-interests.js"; @@ -132,6 +133,7 @@ export async function createTestAppHarness( inheritedSkillsRootPaths: [], inferenceModel: "test/mock-model", isDevelopment: true, + managedEnvironmentRetireGraceMs: MANAGED_ENVIRONMENT_RETIRE_GRACE_MS, openAiApiKey: "test-openai-key", serverPort: 3334, sharedSkillRoots: { user: [], project: [] }, diff --git a/apps/server/test/public/public-thread-environment-decoupling.test.ts b/apps/server/test/public/public-thread-environment-decoupling.test.ts index cc88a8ae9e..c79cc57d34 100644 --- a/apps/server/test/public/public-thread-environment-decoupling.test.ts +++ b/apps/server/test/public/public-thread-environment-decoupling.test.ts @@ -21,10 +21,10 @@ import { withTestHarness } from "../helpers/test-app.js"; * of reprovisioning. */ describe("thread environment decoupling (B*)", () => { - it("un-archives without touching a retiring environment", async () => { + it("revives a retiring environment on un-archive (lossless undo of an accidental archive)", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps, { - id: "host-unarchive-pure", + id: "host-unarchive-revive", }); const { project } = seedProjectWithSource(harness.deps, { hostId: host.id, @@ -32,6 +32,7 @@ describe("thread environment decoupling (B*)", () => { const environment = seedEnvironment(harness.deps, { hostId: host.id, managed: true, + path: "/tmp/unarchive-revive", projectId: project.id, status: "retiring", workspaceProvisionType: "managed-worktree", @@ -49,11 +50,12 @@ describe("thread environment decoupling (B*)", () => { ); expect(response.status).toBe(200); - // The thread is un-archived (pure record op)... expect(getThread(harness.db, thread.id)?.archivedAt).toBeNull(); - // ...and the retiring environment lifecycle is left untouched. + // The retiring environment is revived to ready via retire.cancelled: its + // worktree was never destroyed during the grace window, so the undo is + // lossless. expect(getEnvironment(harness.db, environment.id)).toMatchObject({ - status: "retiring", + status: "ready", }); }); }); diff --git a/apps/server/test/services/managed-environment-cleanup-recovery.test.ts b/apps/server/test/services/managed-environment-cleanup-recovery.test.ts index 2cea5c6af3..6e5bd89bd1 100644 --- a/apps/server/test/services/managed-environment-cleanup-recovery.test.ts +++ b/apps/server/test/services/managed-environment-cleanup-recovery.test.ts @@ -1,9 +1,12 @@ import { eq } from "drizzle-orm"; import { + archiveThread, createEnvironment, + createThread, environments, getEnvironment, hostDaemonSessions, + markThreadDeleted, } from "@bb/db"; import { describe, expect, it, vi } from "vitest"; import { @@ -11,20 +14,19 @@ import { settleEnvironmentDestroyCommandResult, } from "../../src/services/environments/environment-cleanup-internal.js"; import { - MANAGED_ENVIRONMENT_ARCHIVE_CLEANUP_RECOVERY_INTERVAL_MS, runManagedEnvironmentArchiveCleanupRecoverySweep, runStartupRecoverySweep, } from "../../src/services/system/periodic-sweeps.js"; -import { - listQueuedEnvironmentCommands, -} from "../helpers/commands.js"; +import { MANAGED_ENVIRONMENT_RETIRE_GRACE_MS } from "../../src/constants.js"; +import { LIVE_DAEMON_COMMAND_TIMEOUT_MS } from "../../src/services/hosts/live-command.js"; +import { listQueuedEnvironmentCommands } from "../helpers/commands.js"; import { seedHostSession, seedProjectWithSource } from "../helpers/seed.js"; import { withTestHarness } from "../helpers/test-app.js"; const SWEEP_START_MS = 4_000_000_000_000; describe("managed environment cleanup recovery sweep", () => { - it("marks stale destroying cleanup requests as error without retrying blindly", async () => { + it("keeps a recent in-flight destroy recoverable across startup and accepts its success", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps); const { project } = seedProjectWithSource(harness.deps, { @@ -34,24 +36,26 @@ describe("managed environment cleanup recovery sweep", () => { hostId: host.id, isGitRepo: false, managed: true, - path: "/tmp/stale-destroying-environment", + path: "/tmp/in-flight-destroying-environment", projectId: project.id, status: "destroying", workspaceProvisionType: "managed-worktree", }); - const staleUpdatedAt = Date.now() - 1; + const recentUpdatedAt = Date.now() - 1; harness.db .update(environments) .set({ - destroyAttemptId: "rpc-stale-destroying", - updatedAt: staleUpdatedAt, + destroyAttemptId: "rpc-in-flight-destroying", + updatedAt: recentUpdatedAt, }) .where(eq(environments.id, environment.id)) .run(); await runStartupRecoverySweep(harness.deps); - expect(getEnvironment(harness.db, environment.id)?.status).toBe("error"); + expect(getEnvironment(harness.db, environment.id)?.status).toBe( + "destroying", + ); expect( listQueuedEnvironmentCommands( harness, @@ -59,6 +63,101 @@ describe("managed environment cleanup recovery sweep", () => { environment.id, ), ).toHaveLength(0); + + harness.db.transaction((tx) => { + settleEnvironmentDestroyCommandResult({ + command: { + type: "environment.destroy", + environmentId: environment.id, + workspaceContext: { + workspacePath: "/tmp/in-flight-destroying-environment", + workspaceProvisionType: "managed-worktree", + }, + }, + deps: { ...harness.deps, db: tx, hub: harness.hub }, + execution: { + createdAt: recentUpdatedAt, + hostId: host.id, + id: "rpc-in-flight-destroying", + }, + report: { + completedAt: Date.now(), + executionId: "rpc-in-flight-destroying", + ok: true, + result: {}, + type: "environment.destroy", + }, + }); + }); + + expect(getEnvironment(harness.db, environment.id)?.status).toBe( + "destroyed", + ); + }); + }); + + it("accepts a matching late success after stale startup recovery marks the destroy lost", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const workspacePath = "/tmp/stale-destroy-late-success"; + const staleUpdatedAt = Date.now() - LIVE_DAEMON_COMMAND_TIMEOUT_MS - 1; + const environment = createEnvironment(harness.db, harness.hub, { + hostId: host.id, + isGitRepo: false, + managed: true, + path: workspacePath, + projectId: project.id, + status: "destroying", + workspaceProvisionType: "managed-worktree", + }); + harness.db + .update(environments) + .set({ + destroyAttemptId: "rpc-late-success", + updatedAt: staleUpdatedAt, + }) + .where(eq(environments.id, environment.id)) + .run(); + + await runStartupRecoverySweep(harness.deps); + expect(getEnvironment(harness.db, environment.id)).toMatchObject({ + destroyAttemptId: "rpc-late-success", + status: "error", + }); + + harness.db.transaction((tx) => { + settleEnvironmentDestroyCommandResult({ + command: { + type: "environment.destroy", + environmentId: environment.id, + workspaceContext: { + workspacePath, + workspaceProvisionType: "managed-worktree", + }, + }, + deps: { ...harness.deps, db: tx, hub: harness.hub }, + execution: { + createdAt: staleUpdatedAt, + hostId: host.id, + id: "rpc-late-success", + }, + report: { + completedAt: Date.now(), + executionId: "rpc-late-success", + ok: true, + result: {}, + type: "environment.destroy", + }, + }); + }); + + expect(getEnvironment(harness.db, environment.id)).toMatchObject({ + destroyAttemptId: null, + status: "destroyed", + }); }); }); @@ -69,7 +168,8 @@ describe("managed environment cleanup recovery sweep", () => { hostId: host.id, }); const workspacePath = "/tmp/stale-failure-after-retry"; - const oldExecutionCreatedAt = Date.now() - 10_000; + const oldExecutionCreatedAt = + Date.now() - LIVE_DAEMON_COMMAND_TIMEOUT_MS - 1; const environment = createEnvironment(harness.db, harness.hub, { hostId: host.id, isGitRepo: false, @@ -292,7 +392,7 @@ describe("managed environment cleanup recovery sweep", () => { status: "destroying", workspaceProvisionType: "managed-worktree", }); - const staleUpdatedAt = Date.now() - 1; + const staleUpdatedAt = Date.now() - LIVE_DAEMON_COMMAND_TIMEOUT_MS - 1; harness.db .update(environments) .set({ @@ -455,61 +555,114 @@ describe("managed environment cleanup recovery sweep", () => { }); }); - it("throttles recovery without arming the throttle on empty sweeps", async () => { + it("defers a retiring environment's destroy until its grace window elapses while a revivable archived thread remains, then destroys it on the next sweep regardless of the recovery throttle", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps); const { project } = seedProjectWithSource(harness.deps, { hostId: host.id, }); + // First sweep arms the (15-minute) orphaned-destroy recovery throttle. await runManagedEnvironmentArchiveCleanupRecoverySweep( harness.deps, SWEEP_START_MS, ); - const firstEnvironment = createEnvironment(harness.db, harness.hub, { + const environment = createEnvironment(harness.db, harness.hub, { hostId: host.id, + isGitRepo: false, managed: true, + path: "/tmp/grace-window-environment", projectId: project.id, status: "retiring", workspaceProvisionType: "managed-worktree", }); + // An archived (not deleted) thread keeps the environment revivable via + // unarchive, so the grace window applies. + const thread = createThread(harness.db, harness.hub, { + projectId: project.id, + environmentId: environment.id, + providerId: "codex", + status: "idle", + }); + archiveThread(harness.db, harness.hub, thread.id); + // Freshly retired → still inside the grace window → not destroyed yet. await runManagedEnvironmentArchiveCleanupRecoverySweep( harness.deps, SWEEP_START_MS + 1, ); + expect(getEnvironment(harness.db, environment.id)?.status).toBe( + "retiring", + ); + expect( + listQueuedEnvironmentCommands( + harness, + "environment.destroy", + environment.id, + ), + ).toHaveLength(0); - expect(getEnvironment(harness.db, firstEnvironment.id)?.status).toBe( - "destroyed", + // Past the grace window → destroyed on the very next sweep, even though the + // recovery throttle window has not elapsed: the grace-gated retiring sweep + // is not throttled, only the orphaned-destroy recovery is. + harness.db + .update(environments) + .set({ + retireRequestedAt: + Date.now() - MANAGED_ENVIRONMENT_RETIRE_GRACE_MS - 1, + }) + .where(eq(environments.id, environment.id)) + .run(); + await runManagedEnvironmentArchiveCleanupRecoverySweep( + harness.deps, + SWEEP_START_MS + 2, ); + expect(getEnvironment(harness.db, environment.id)?.status).toBe( + "destroying", + ); + expect( + listQueuedEnvironmentCommands( + harness, + "environment.destroy", + environment.id, + ), + ).toHaveLength(1); + }); + }); + + it("destroys a retiring environment immediately when its only thread is deleted (nothing to unarchive)", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); - const throttledEnvironment = createEnvironment(harness.db, harness.hub, { + const environment = createEnvironment(harness.db, harness.hub, { hostId: host.id, + isGitRepo: false, managed: true, + path: "/tmp/deleted-thread-environment", projectId: project.id, status: "retiring", workspaceProvisionType: "managed-worktree", }); + const thread = createThread(harness.db, harness.hub, { + projectId: project.id, + environmentId: environment.id, + providerId: "codex", + status: "idle", + }); + markThreadDeleted(harness.db, harness.hub, { threadId: thread.id }); - await runManagedEnvironmentArchiveCleanupRecoverySweep( - harness.deps, - SWEEP_START_MS + 10_000, - ); - - expect(getEnvironment(harness.db, throttledEnvironment.id)?.status).toBe( - "retiring", - ); - - await runManagedEnvironmentArchiveCleanupRecoverySweep( - harness.deps, - SWEEP_START_MS + - 1 + - MANAGED_ENVIRONMENT_ARCHIVE_CLEANUP_RECOVERY_INTERVAL_MS, - ); - - expect(getEnvironment(harness.db, throttledEnvironment.id)?.status).toBe( - "destroyed", + // Freshly retired, but the only thread is deleted (not archived): there is + // nothing to unarchive, so the grace window does not apply and cleanup + // destroys the orphaned workspace right away. + await runEnvironmentCleanupAdvance(harness.deps, { + environmentId: environment.id, + }); + expect(getEnvironment(harness.db, environment.id)?.status).toBe( + "destroying", ); }); }); diff --git a/apps/server/test/system/bb-app-managed-config.test.ts b/apps/server/test/system/bb-app-managed-config.test.ts index 8cf96556d9..e2399d4871 100644 --- a/apps/server/test/system/bb-app-managed-config.test.ts +++ b/apps/server/test/system/bb-app-managed-config.test.ts @@ -72,6 +72,7 @@ function createRuntimeConfig(): ServerRuntimeConfig { inheritedSkillsRootPaths: [], inferenceModel: "openai/gpt-4o-mini", isDevelopment: false, + managedEnvironmentRetireGraceMs: 5 * 60_000, openAiApiKey: "ambient-openai-key", serverPort: 38886, sharedSkillRoots: { user: [], project: [] }, diff --git a/docs/lifecycle-diagrams.md b/docs/lifecycle-diagrams.md index d64d64e056..77819498a8 100644 --- a/docs/lifecycle-diagrams.md +++ b/docs/lifecycle-diagrams.md @@ -65,7 +65,8 @@ flowchart LR retiring -->|"destroy.started ⟨managed⟩"| destroying error -->|"provision.requested"| provisioning error -->|"destroy.started ⟨managed⟩"| destroying - destroying -->|"destroy.completed"| destroyed + error -->|"destroy.completed ⟨matchingDestroyAttempt⟩"| destroyed + destroying -->|"destroy.completed ⟨matchingDestroyAttempt⟩"| destroyed destroying -->|"destroy.failed ⟨matchingDestroyAttempt⟩"| retiring destroying -->|"destroy.lost"| error ``` diff --git a/packages/db/drizzle/0091_daffy_dark_phoenix.sql b/packages/db/drizzle/0091_daffy_dark_phoenix.sql new file mode 100644 index 0000000000..c37de6e22b --- /dev/null +++ b/packages/db/drizzle/0091_daffy_dark_phoenix.sql @@ -0,0 +1,5 @@ +ALTER TABLE `environments` ADD `retire_requested_at` integer; +--> statement-breakpoint +UPDATE `environments` +SET `retire_requested_at` = `updated_at` +WHERE `status` = 'retiring'; diff --git a/packages/db/drizzle/meta/0091_snapshot.json b/packages/db/drizzle/meta/0091_snapshot.json new file mode 100644 index 0000000000..bac0d30116 --- /dev/null +++ b/packages/db/drizzle/meta/0091_snapshot.json @@ -0,0 +1,3439 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5b18174f-ab74-455c-ade6-d10d8677baf2", + "prevId": "cdabc1eb-e39f-413c-95b6-3c63cab15374", + "tables": { + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "caffeinate": { + "name": "caffeinate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_keyboard_hints": { + "name": "show_keyboard_hints", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "steer_active_thread_on_enter": { + "name": "steer_active_thread_on_enter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_unhandled_provider_events": { + "name": "show_unhandled_provider_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "codex_memory_enabled": { + "name": "codex_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "claude_code_memory_enabled": { + "name": "claude_code_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "codex_subagents_disabled": { + "name": "codex_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_subagents_disabled": { + "name": "claude_code_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_workflows_disabled": { + "name": "claude_code_workflows_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "keybinding_overrides": { + "name": "keybinding_overrides", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_theme": { + "name": "app_theme", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "favicon_color": { + "name": "favicon_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refillInterval": { + "name": "refillInterval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refillAmount": { + "name": "refillAmount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRefillAt": { + "name": "lastRefillAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitEnabled": { + "name": "rateLimitEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitTimeWindow": { + "name": "rateLimitTimeWindow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitMax": { + "name": "rateLimitMax", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestCount": { + "name": "requestCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRequest": { + "name": "lastRequest", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configId": { + "name": "configId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_unique": { + "name": "apikey_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + "referenceId" + ], + "isUnique": false + }, + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + "configId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "apikey_referenceId_user_id_fk": { + "name": "apikey_referenceId_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "referenceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environments": { + "name": "environments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "managed": { + "name": "managed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_git_repo": { + "name": "is_git_repo", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_worktree": { + "name": "is_worktree", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_base_branch": { + "name": "merge_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destroy_attempt_id": { + "name": "destroy_attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retire_requested_at": { + "name": "retire_requested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_provision_type": { + "name": "workspace_provision_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provisioning'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environments_project_host_path_idx": { + "name": "environments_project_host_path_idx", + "columns": [ + "project_id", + "host_id", + "path" + ], + "isUnique": true + }, + "environments_host_path_lookup_idx": { + "name": "environments_host_path_lookup_idx", + "columns": [ + "host_id", + "path" + ], + "isUnique": false + }, + "environments_project_idx": { + "name": "environments_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "environments_status_idx": { + "name": "environments_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "environments_project_id_projects_id_fk": { + "name": "environments_project_id_projects_id_fk", + "tableFrom": "environments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_host_id_hosts_id_fk": { + "name": "environments_host_id_hosts_id_fk", + "tableFrom": "environments", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "events_thread_sequence_idx": { + "name": "events_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": true + }, + "events_thread_type_item_kind_sequence_idx": { + "name": "events_thread_type_item_kind_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_kind", + "sequence" + ], + "isUnique": false + }, + "events_background_task_thread_type_item_sequence_idx": { + "name": "events_background_task_thread_type_item_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" = 'backgroundTask'" + }, + "events_thread_type_sequence_idx": { + "name": "events_thread_type_sequence_idx", + "columns": [ + "thread_id", + "type", + "sequence" + ], + "isUnique": false + }, + "events_thread_turn_type_item_sequence_idx": { + "name": "events_thread_turn_type_item_sequence_idx", + "columns": [ + "thread_id", + "turn_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false + }, + "events_environment_idx": { + "name": "events_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "events_completed_item_truncation_idx": { + "name": "events_completed_item_truncation_idx", + "columns": [ + "item_kind", + "created_at", + "id" + ], + "isUnique": false, + "where": "\"events\".\"type\" = 'item/completed'" + }, + "events_goal_thread_sequence_idx": { + "name": "events_goal_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('thread/goal/updated', 'thread/goal/cleared')" + } + }, + "foreignKeys": { + "events_thread_id_threads_id_fk": { + "name": "events_thread_id_threads_id_fk", + "tableFrom": "events", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "events_environment_id_environments_id_fk": { + "name": "events_environment_id_environments_id_fk", + "tableFrom": "events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "events_scope_shape_check": { + "name": "events_scope_shape_check", + "value": "(\n (\"events\".\"scope_kind\" = 'turn' AND \"events\".\"turn_id\" IS NOT NULL)\n OR\n (\"events\".\"scope_kind\" = 'thread' AND \"events\".\"turn_id\" IS NULL)\n )" + } + } + }, + "host_daemon_sessions": { + "name": "host_daemon_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_type": { + "name": "host_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_dir": { + "name": "data_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heartbeat_interval_ms": { + "name": "heartbeat_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_timeout_ms": { + "name": "lease_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_daemon_sessions_host_status_idx": { + "name": "host_daemon_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "host_daemon_sessions_host_latest_idx": { + "name": "host_daemon_sessions_host_latest_idx", + "columns": [ + "host_id", + "updated_at", + "created_at", + "id" + ], + "isUnique": false + }, + "host_daemon_sessions_closed_prune_idx": { + "name": "host_daemon_sessions_closed_prune_idx", + "columns": [ + "status", + "closed_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_daemon_sessions_host_id_hosts_id_fk": { + "name": "host_daemon_sessions_host_id_hosts_id_fk", + "tableFrom": "host_daemon_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hosts": { + "name": "hosts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connect_machine_id": { + "name": "connect_machine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_permission_mode": { + "name": "max_permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'full'" + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_rejected_protocol_version": { + "name": "last_rejected_protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hosts_last_seen_idx": { + "name": "hosts_last_seen_idx", + "columns": [ + "last_seen_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "catalog_entry_id": { + "name": "catalog_entry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'path'" + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_builtin_name": { + "name": "source_builtin_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_package": { + "name": "source_npm_package", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_registry": { + "name": "source_npm_registry", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_requested_spec": { + "name": "source_npm_requested_spec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_spec_kind": { + "name": "source_npm_spec_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_url": { + "name": "source_git_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_subdirectory": { + "name": "source_git_subdirectory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_requested_ref": { + "name": "source_git_requested_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_ref_kind": { + "name": "source_git_ref_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_integrity": { + "name": "npm_integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_update_check_at": { + "name": "last_update_check_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "available_compatible_version": { + "name": "available_compatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "newest_incompatible_version": { + "name": "newest_incompatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "update_status_detail": { + "name": "update_status_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_version": { + "name": "last_failure_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_detail": { + "name": "last_failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_artifact_id": { + "name": "active_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "normalization_version": { + "name": "normalization_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "root_dir": { + "name": "root_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plugins_active_artifact_id_plugin_artifacts_id_fk": { + "name": "plugins_active_artifact_id_plugin_artifacts_id_fk", + "tableFrom": "plugins", + "tableTo": "plugin_artifacts", + "columnsFrom": [ + "active_artifact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_scan_cursors": { + "name": "maintenance_scan_cursors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_created_at": { + "name": "last_created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "maintenance_scan_cursors_path_idx": { + "name": "maintenance_scan_cursors_path_idx", + "columns": [ + "policy", + "version", + "item_kind", + "output_path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_interactions": { + "name": "pending_interactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provider'" + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "renderer_id": { + "name": "renderer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_interactions_provider_request_idx": { + "name": "pending_interactions_provider_request_idx", + "columns": [ + "provider_id", + "provider_thread_id", + "provider_request_id" + ], + "isUnique": true + }, + "pending_interactions_thread_created_idx": { + "name": "pending_interactions_thread_created_idx", + "columns": [ + "thread_id", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_thread_status_created_idx": { + "name": "pending_interactions_thread_status_created_idx", + "columns": [ + "thread_id", + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_status_created_idx": { + "name": "pending_interactions_status_created_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_plugin_status_created_idx": { + "name": "pending_interactions_plugin_status_created_idx", + "columns": [ + "plugin_id", + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "pending_interactions_thread_id_threads_id_fk": { + "name": "pending_interactions_thread_id_threads_id_fk", + "tableFrom": "pending_interactions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_artifacts": { + "name": "plugin_artifacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrity": { + "name": "integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "validation_result": { + "name": "validation_result", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validated_at": { + "name": "validated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "plugin_artifacts_plugin_idx": { + "name": "plugin_artifacts_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_kv": { + "name": "plugin_kv", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_kv_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_kv_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_schedules": { + "name": "plugin_schedules", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_schedules_plugin_id_name_pk": { + "columns": [ + "plugin_id", + "name" + ], + "name": "plugin_schedules_plugin_id_name_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_settings_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_settings_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_state_snapshots": { + "name": "plugin_state_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_artifact_id": { + "name": "from_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_artifact_id": { + "name": "to_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_path": { + "name": "snapshot_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "database_path": { + "name": "database_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state_path": { + "name": "state_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secrets_path": { + "name": "secrets_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registration_path": { + "name": "registration_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rollback_candidate_version": { + "name": "rollback_candidate_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_source_fingerprint": { + "name": "rollback_source_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_bb_version": { + "name": "rollback_bb_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_sdk_version": { + "name": "rollback_sdk_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_detail": { + "name": "rollback_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_until": { + "name": "retained_until", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_state_snapshots_plugin_idx": { + "name": "plugin_state_snapshots_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + }, + "plugin_state_snapshots_retention_idx": { + "name": "plugin_state_snapshots_retention_idx", + "columns": [ + "retained_until" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_execution_defaults": { + "name": "project_execution_defaults", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_execution_defaults_project_idx": { + "name": "project_execution_defaults_project_idx", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_execution_defaults_project_id_projects_id_fk": { + "name": "project_execution_defaults_project_id_projects_id_fk", + "tableFrom": "project_execution_defaults", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_sources": { + "name": "project_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_sources_project_idx": { + "name": "project_sources_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "project_sources_host_idx": { + "name": "project_sources_host_idx", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "project_sources_project_host_idx": { + "name": "project_sources_project_host_idx", + "columns": [ + "project_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_sources_project_id_projects_id_fk": { + "name": "project_sources_project_id_projects_id_fk", + "tableFrom": "project_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_sources_host_id_hosts_id_fk": { + "name": "project_sources_host_id_hosts_id_fk", + "tableFrom": "project_sources", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_sources_shape_check": { + "name": "project_sources_shape_check", + "value": "(\n \"project_sources\".\"type\" = 'local_path' AND \"project_sources\".\"host_id\" IS NOT NULL AND \"project_sources\".\"path\" IS NOT NULL\n )" + } + } + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'standard'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'V'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_updated_idx": { + "name": "projects_updated_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "projects_deleted_idx": { + "name": "projects_deleted_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "projects_sort_idx": { + "name": "projects_sort_idx", + "columns": [ + "sort_key", + "id" + ], + "isUnique": false + }, + "projects_personal_singleton_idx": { + "name": "projects_personal_singleton_idx", + "columns": [ + "kind" + ], + "isUnique": true, + "where": "\"projects\".\"kind\" = 'personal'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompt_history_entries": { + "name": "prompt_history_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "prompt_history_entries_thread_request_idx": { + "name": "prompt_history_entries_thread_request_idx", + "columns": [ + "thread_id", + "request_sequence" + ], + "isUnique": true + }, + "prompt_history_entries_project_scope_created_idx": { + "name": "prompt_history_entries_project_scope_created_idx", + "columns": [ + "project_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + }, + "prompt_history_entries_thread_scope_created_idx": { + "name": "prompt_history_entries_thread_scope_created_idx", + "columns": [ + "thread_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "prompt_history_entries_project_id_projects_id_fk": { + "name": "prompt_history_entries_project_id_projects_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prompt_history_entries_thread_id_threads_id_fk": { + "name": "prompt_history_entries_thread_id_threads_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queued_thread_messages": { + "name": "queued_thread_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_thread_id": { + "name": "sender_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_with_next": { + "name": "group_with_next", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "queued_thread_messages_thread_created_idx": { + "name": "queued_thread_messages_thread_created_idx", + "columns": [ + "thread_id", + "created_at", + "id" + ], + "isUnique": false + }, + "queued_thread_messages_thread_sort_idx": { + "name": "queued_thread_messages_thread_sort_idx", + "columns": [ + "thread_id", + "sort_key", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "queued_thread_messages_thread_id_threads_id_fk": { + "name": "queued_thread_messages_thread_id_threads_id_fk", + "tableFrom": "queued_thread_messages", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_experiments": { + "name": "system_experiments", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daemon_session_id": { + "name": "daemon_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initial_cwd": { + "name": "initial_cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cols": { + "name": "cols", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rows": { + "name": "rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_user_input_at": { + "name": "last_user_input_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_thread_status_updated_idx": { + "name": "terminal_sessions_thread_status_updated_idx", + "columns": [ + "thread_id", + "status", + "updated_at" + ], + "isUnique": false + }, + "terminal_sessions_environment_status_idx": { + "name": "terminal_sessions_environment_status_idx", + "columns": [ + "environment_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_host_status_idx": { + "name": "terminal_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_daemon_session_idx": { + "name": "terminal_sessions_daemon_session_idx", + "columns": [ + "daemon_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_thread_id_threads_id_fk": { + "name": "terminal_sessions_thread_id_threads_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_environment_id_environments_id_fk": { + "name": "terminal_sessions_environment_id_environments_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_host_id_hosts_id_fk": { + "name": "terminal_sessions_host_id_hosts_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": { + "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "host_daemon_sessions", + "columnsFrom": [ + "daemon_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_dynamic_context_file_states": { + "name": "thread_dynamic_context_file_states", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_status": { + "name": "content_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shown_at": { + "name": "shown_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_dynamic_context_file_states_thread_file_idx": { + "name": "thread_dynamic_context_file_states_thread_file_idx", + "columns": [ + "thread_id", + "file_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "thread_dynamic_context_file_states_thread_id_threads_id_fk": { + "name": "thread_dynamic_context_file_states_thread_id_threads_id_fk", + "tableFrom": "thread_dynamic_context_file_states", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_search_segments": { + "name": "thread_search_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_seq": { + "name": "source_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_search_segments_source_idx": { + "name": "thread_search_segments_source_idx", + "columns": [ + "thread_id", + "source_kind", + "source_key" + ], + "isUnique": true + }, + "thread_search_segments_thread_idx": { + "name": "thread_search_segments_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_search_segments_thread_id_threads_id_fk": { + "name": "thread_search_segments_thread_id_threads_id_fk", + "tableFrom": "thread_search_segments", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_sections": { + "name": "thread_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_sections_name_idx": { + "name": "thread_sections_name_idx", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_tabs": { + "name": "thread_tabs", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tabs_json": { + "name": "tabs_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_tabs_thread_id_threads_id_fk": { + "name": "thread_tabs_thread_id_threads_id_fk", + "tableFrom": "thread_tabs", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "threads": { + "name": "threads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_override": { + "name": "model_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_level_override": { + "name": "reasoning_level_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title_fallback": { + "name": "title_fallback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'starting'" + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_thread_id": { + "name": "source_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "child_origin": { + "name": "child_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_plugin_id": { + "name": "origin_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'visible'" + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_sort_key": { + "name": "pin_sort_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_attention_at": { + "name": "latest_attention_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "threads_project_updated_idx": { + "name": "threads_project_updated_idx", + "columns": [ + "project_id", + "updated_at" + ], + "isUnique": false + }, + "threads_project_archived_deleted_idx": { + "name": "threads_project_archived_deleted_idx", + "columns": [ + "project_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_pin_sort_idx": { + "name": "threads_pin_sort_idx", + "columns": [ + "archived_at", + "deleted_at", + "pin_sort_key", + "id" + ], + "isUnique": false, + "where": "\"threads\".\"pinned_at\" IS NOT NULL" + }, + "threads_environment_idx": { + "name": "threads_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "threads_parent_idx": { + "name": "threads_parent_idx", + "columns": [ + "parent_thread_id" + ], + "isUnique": false + }, + "threads_source_origin_idx": { + "name": "threads_source_origin_idx", + "columns": [ + "source_thread_id", + "origin_kind" + ], + "isUnique": false + }, + "threads_origin_plugin_archived_idx": { + "name": "threads_origin_plugin_archived_idx", + "columns": [ + "origin_plugin_id", + "archived_at" + ], + "isUnique": false + }, + "threads_section_archived_deleted_idx": { + "name": "threads_section_archived_deleted_idx", + "columns": [ + "section_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_archived_status_idx": { + "name": "threads_archived_status_idx", + "columns": [ + "archived_at", + "status" + ], + "isUnique": false + }, + "threads_environment_archived_deleted_idx": { + "name": "threads_environment_archived_deleted_idx", + "columns": [ + "environment_id", + "archived_at", + "deleted_at" + ], + "isUnique": false + }, + "threads_active_maintenance_idx": { + "name": "threads_active_maintenance_idx", + "columns": [ + "status" + ], + "isUnique": false, + "where": "\"threads\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "threads_project_id_projects_id_fk": { + "name": "threads_project_id_projects_id_fk", + "tableFrom": "threads", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "threads_environment_id_environments_id_fk": { + "name": "threads_environment_id_environments_id_fk", + "tableFrom": "threads", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_section_id_thread_sections_id_fk": { + "name": "threads_section_id_thread_sections_id_fk", + "tableFrom": "threads", + "tableTo": "thread_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_parent_thread_id_threads_id_fk": { + "name": "threads_parent_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "parent_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_source_thread_id_threads_id_fk": { + "name": "threads_source_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "source_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index b9fd80bd92..c3987d2b50 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -638,6 +638,13 @@ "when": 1786384629899, "tag": "0090_equal_reaper", "breakpoints": true + }, + { + "idx": 91, + "version": "6", + "when": 1786416023798, + "tag": "0091_daffy_dark_phoenix", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/data/environments.ts b/packages/db/src/data/environments.ts index a390841d86..63a8909c67 100644 --- a/packages/db/src/data/environments.ts +++ b/packages/db/src/data/environments.ts @@ -57,6 +57,7 @@ export function createEnvironment( mergeBaseBranch: input.mergeBaseBranch ?? null, workspaceProvisionType: input.workspaceProvisionType, status: input.status ?? "provisioning", + retireRequestedAt: input.status === "retiring" ? now : null, createdAt: now, updatedAt: now, }) @@ -434,18 +435,35 @@ function applyEnvironmentLifecycleEventRecord( }; } + const now = Date.now(); const set: Partial = { status: evaluation.to, - updatedAt: Date.now(), + updatedAt: now, }; + if (args.event.type === "retire.requested") { + set.retireRequestedAt = now; + } else if ( + evaluation.to === "ready" || + evaluation.to === "provisioning" || + evaluation.to === "destroyed" + ) { + set.retireRequestedAt = null; + } if (args.event.type === "destroy.started") { set.destroyAttemptId = args.event.destroyAttemptId; } - if (args.event.type === "destroy.failed" || args.event.type === "destroy.lost") { + if ( + args.event.type === "destroy.failed" || + evaluation.to === "ready" || + evaluation.to === "provisioning" + ) { set.destroyAttemptId = null; } if (evaluation.to === "destroyed") { set.destroyAttemptId = null; + // The workspace no longer exists. Release its path claim and avoid + // retaining stale host-local filesystem data on the terminal row. + set.path = null; } // Compare-and-set on the loaded status: belt-and-braces under diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 720d7750e9..f861e9a49b 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -93,6 +93,7 @@ export { hasLiveThreadAtHostPath, hasNonTerminalThreadInEnvironment, hasPendingThreadShutdownInEnvironment, + hasRevivableArchivedThreadInEnvironment, listHostThreadIds, listActiveVisiblePinnedThreadRoots, listActiveVisiblePinnedThreadRootsWithPendingInteractionState, diff --git a/packages/db/src/data/sweeps.ts b/packages/db/src/data/sweeps.ts index 77f1947e42..5768cb6ae8 100644 --- a/packages/db/src/data/sweeps.ts +++ b/packages/db/src/data/sweeps.ts @@ -8,10 +8,7 @@ import { import { type ThreadEventItemType } from "@bb/domain"; import type { DbConnection } from "../connection.js"; import type { DbNotifier } from "../notifier.js"; -import { - environments, - maintenanceScanCursors, -} from "../schema.js"; +import { environments, maintenanceScanCursors } from "../schema.js"; /** Destroyed environments are hard-deleted after 7 days. */ const DESTROYED_ENVIRONMENT_TTL_MS = 7 * 24 * 60 * 60_000; @@ -336,6 +333,13 @@ export function truncateCompletedEventItemOutputs( * Sweep retiring managed environments with zero non-archived threads. * Returns the list of environment records that are candidates for cleanup. * The caller decides what to do (e.g., queue destroy commands). + * + * The archive grace window (delay a retiring environment's destroy so an + * accidental archive can be undone) is enforced by the server in + * `advanceEnvironmentCleanup`, not here: this sweep returns a candidate as soon + * as it is retiring with no live threads, and the advance defers the actual + * destroy until the grace window elapses. Keeping the grace check in one place + * (the advance) avoids splitting the policy across the db query. */ export function sweepManagedEnvironments(db: DbConnection) { const rows = db diff --git a/packages/db/src/data/threads.ts b/packages/db/src/data/threads.ts index d30fcf9072..da3b0de2fb 100644 --- a/packages/db/src/data/threads.ts +++ b/packages/db/src/data/threads.ts @@ -545,6 +545,10 @@ export interface ListLiveThreadsInEnvironmentArgs { environmentId: string; } +export interface HasRevivableArchivedThreadInEnvironmentArgs { + environmentId: string; +} + export interface CountNonDeletedAssignedChildThreadsArgs { parentThreadId: string; } @@ -1180,6 +1184,34 @@ export function countLiveThreadsInEnvironment( return liveThreadCount?.count ?? 0; } +/** + * Whether the environment has a thread that is archived but not deleted — i.e. a + * thread that could still be unarchived. The archive grace window (which delays + * destroying a retiring environment's worktree so an accidental archive can be + * undone) only applies when such a revivable thread exists; an environment left + * retiring solely by deleted/tombstoned threads has nothing to undo and is + * cleaned up immediately. + */ +export function hasRevivableArchivedThreadInEnvironment( + db: ThreadWriteConnection, + args: HasRevivableArchivedThreadInEnvironmentArgs, +): boolean { + const row = db + .select({ id: threads.id }) + .from(threads) + .where( + and( + eq(threads.environmentId, args.environmentId), + isNotNull(threads.archivedAt), + isNull(threads.deletedAt), + ), + ) + .limit(1) + .get(); + + return row !== undefined; +} + export function listLiveThreadsInEnvironment( db: ThreadWriteConnection, args: ListLiveThreadsInEnvironmentArgs, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 213bd69406..417388441d 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -440,6 +440,9 @@ export const environments = sqliteTable( defaultBranch: text("default_branch"), mergeBaseBranch: text("merge_base_branch"), destroyAttemptId: text("destroy_attempt_id"), + // Durable product-policy clock. Unlike updatedAt, metadata polling cannot + // move the start of an accidental-archive recovery window. + retireRequestedAt: integer("retire_requested_at"), workspaceProvisionType: text("workspace_provision_type") .$type() .notNull(), diff --git a/packages/db/test/data/environment-lifecycle.test.ts b/packages/db/test/data/environment-lifecycle.test.ts index 96ed3b1c04..699888cbdd 100644 --- a/packages/db/test/data/environment-lifecycle.test.ts +++ b/packages/db/test/data/environment-lifecycle.test.ts @@ -13,6 +13,7 @@ import { EnvironmentLifecycleEventNotAppliedError, getEnvironment, requireEnvironmentLifecycleEventApplied, + updateEnvironmentMetadata, type CreateEnvironmentInput, } from "../../src/data/environments.js"; import { @@ -97,6 +98,50 @@ describe("applyEnvironmentLifecycleEvent", () => { expect(getEnvironment(db, environment.id)?.status).toBe("ready"); }); + it("keeps the retirement clock stable across metadata writes and clears it on revival", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(1_000); + const { db, seedEnvironment } = setup(); + const environment = seedEnvironment({ managed: true, status: "ready" }); + + vi.setSystemTime(2_000); + const retiring = applyEnvironmentLifecycleEvent(db, noopNotifier, { + environmentId: environment.id, + event: { type: "retire.requested" }, + }); + expect(retiring.applied).toBe(true); + expect(getEnvironment(db, environment.id)).toMatchObject({ + retireRequestedAt: 2_000, + status: "retiring", + updatedAt: 2_000, + }); + + vi.setSystemTime(3_000); + updateEnvironmentMetadata(db, noopNotifier, environment.id, { + name: "renamed while retiring", + }); + expect(getEnvironment(db, environment.id)).toMatchObject({ + retireRequestedAt: 2_000, + updatedAt: 3_000, + }); + + vi.setSystemTime(4_000); + const revived = applyEnvironmentLifecycleEvent(db, noopNotifier, { + environmentId: environment.id, + event: { type: "retire.cancelled" }, + }); + expect(revived.applied).toBe(true); + expect(getEnvironment(db, environment.id)).toMatchObject({ + retireRequestedAt: null, + status: "ready", + updatedAt: 4_000, + }); + } finally { + vi.useRealTimers(); + } + }); + it("no-ops as illegal-transition and leaves the row untouched", () => { vi.useFakeTimers(); try { @@ -173,11 +218,11 @@ describe("applyEnvironmentLifecycleEvent", () => { const first = applyEnvironmentLifecycleEvent(db, noopNotifier, { environmentId: environment.id, - event: { type: "destroy.completed" }, + event: { type: "destroy.completed", destroyAttemptId: null }, }); const second = applyEnvironmentLifecycleEvent(db, noopNotifier, { environmentId: environment.id, - event: { type: "destroy.completed" }, + event: { type: "destroy.completed", destroyAttemptId: null }, }); expect(first.applied).toBe(true); @@ -281,12 +326,16 @@ describe("applyEnvironmentLifecycleEvent", () => { const outcome = applyEnvironmentLifecycleEvent(db, spy, { environmentId: environment.id, - event: { type: "destroy.completed" }, + event: { + type: "destroy.completed", + destroyAttemptId: "rpc_claim", + }, }); expect(outcome.applied).toBe(true); expect(getEnvironment(db, environment.id)).toMatchObject({ destroyAttemptId: null, + path: null, status: "destroyed", }); expect(spy.notifyEnvironment).toHaveBeenCalledExactlyOnceWith( @@ -295,6 +344,56 @@ describe("applyEnvironmentLifecycleEvent", () => { ); }); + it("accepts a matching late destroy success after the attempt was marked lost", () => { + const { db, seedEnvironment } = setup(); + const environment = seedEnvironment({ + managed: true, + path: "/tmp/destroy-late-success", + status: "destroying", + }); + db.update(environments) + .set({ destroyAttemptId: "rpc_late" }) + .where(eq(environments.id, environment.id)) + .run(); + + const lost = applyEnvironmentLifecycleEvent(db, noopNotifier, { + environmentId: environment.id, + event: { type: "destroy.lost" }, + }); + expect(lost.applied).toBe(true); + expect(getEnvironment(db, environment.id)).toMatchObject({ + destroyAttemptId: "rpc_late", + status: "error", + }); + + const stale = applyEnvironmentLifecycleEvent(db, noopNotifier, { + environmentId: environment.id, + event: { + type: "destroy.completed", + destroyAttemptId: "rpc_older", + }, + }); + expect(stale).toEqual({ + applied: false, + detail: "destroyAttemptId mismatch", + reason: "superseded", + }); + + const completed = applyEnvironmentLifecycleEvent(db, noopNotifier, { + environmentId: environment.id, + event: { + type: "destroy.completed", + destroyAttemptId: "rpc_late", + }, + }); + expect(completed.applied).toBe(true); + expect(getEnvironment(db, environment.id)).toMatchObject({ + destroyAttemptId: null, + path: null, + status: "destroyed", + }); + }); + it("restores the settled state and clears the attempt on a matching destroy failure", () => { const { db, seedEnvironment } = setup(); const environment = seedEnvironment({ @@ -324,6 +423,10 @@ describe("requireEnvironmentLifecycleEventApplied", () => { it("returns the updated environment when applied", () => { const { db, seedEnvironment } = setup(); const environment = seedEnvironment({ status: "error" }); + db.update(environments) + .set({ destroyAttemptId: "rpc_lost" }) + .where(eq(environments.id, environment.id)) + .run(); const updated = requireEnvironmentLifecycleEventApplied( applyEnvironmentLifecycleEvent(db, noopNotifier, { @@ -332,6 +435,7 @@ describe("requireEnvironmentLifecycleEventApplied", () => { }), ); expect(updated.status).toBe("provisioning"); + expect(updated.destroyAttemptId).toBeNull(); }); it("throws a typed error carrying reason and detail on a no-op", () => { diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 4454c3f6c4..7e65c696c3 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -298,6 +298,7 @@ function dropRewindAddedTables(db: DbConnection): void { .prepare("ALTER TABLE hosts DROP COLUMN last_rejected_protocol_version") .run(); dropHostMaxPermissionModeColumn(db); + dropEnvironmentRetireRequestedAtColumn(db); dropThreadSectionSchema(db); restoreWideExperimentsTable(db); // system_experiments predates thread search, so the table itself isn't @@ -421,6 +422,12 @@ const experimentKeyValueMigrationPath = resolve( "drizzle", "0090_equal_reaper.sql", ); +const retireRequestedAtMigrationPath = resolve( + __dirname, + "..", + "drizzle", + "0091_daffy_dark_phoenix.sql", +); const sidebarOrderingMigrationPath = resolve( __dirname, "..", @@ -592,6 +599,19 @@ function dropEnvironmentDestroyAttemptIdColumn(db: DbConnection): void { .run(); } +// Migration 0091 adds the dedicated archive-grace clock. Rewind scenarios +// that clear its journal row must remove the column before replaying the ADD. +function dropEnvironmentRetireRequestedAtColumn(db: DbConnection): void { + const columns = db.$client + .prepare<[], TableInfoRow>("PRAGMA table_info(environments)") + .all(); + if (columns.some((column) => column.name === "retire_requested_at")) { + db.$client + .prepare("ALTER TABLE environments DROP COLUMN retire_requested_at") + .run(); + } +} + /** * cleanup_mode existed since the baseline and is dropped by 0033, so a forward * replay from before 0033 must first restore it for 0033's DROP COLUMN to apply @@ -647,6 +667,7 @@ function dropQueuedMessageSenderThreadIdColumn(db: DbConnection): void { /** Tables created by migrations after 0023, dropped so migrate() re-applies. */ function dropPost0023Tables(db: DbConnection): void { + dropEnvironmentRetireRequestedAtColumn(db); dropProjectGitRemoteUrlColumn(db); db.$client.prepare("DROP TABLE IF EXISTS thread_tabs").run(); db.$client.exec(` @@ -1258,6 +1279,46 @@ function deleteDeferredCleanupMigrationRows(db: DbConnection): void { } describe("migrate", () => { + it("backfills the retirement clock only for environments already retiring", () => { + const db = createConnection(":memory:"); + try { + db.$client.exec(` + CREATE TABLE environments ( + id text PRIMARY KEY NOT NULL, + status text NOT NULL, + updated_at integer NOT NULL + ); + INSERT INTO environments (id, status, updated_at) VALUES + ('env_retiring', 'retiring', 1234), + ('env_ready', 'ready', 2345); + `); + + runMigrationFile({ + db, + migrationPath: retireRequestedAtMigrationPath, + }); + + expect( + db.$client + .prepare<[], { id: string; retireRequestedAt: number | null }>( + ` + SELECT + id, + retire_requested_at AS retireRequestedAt + FROM environments + ORDER BY id + `, + ) + .all(), + ).toEqual([ + { id: "env_ready", retireRequestedAt: null }, + { id: "env_retiring", retireRequestedAt: 1234 }, + ]); + } finally { + closeConnection(db); + } + }); + it("moves experiment columns into key/value rows without losing values", () => { const db = createConnection(":memory:"); @@ -1307,6 +1368,7 @@ describe("migrate", () => { restoreWideExperimentsTable(db); dropOnboardingCompletedAtColumn(db); dropNewOnboardingExperimentColumn(db); + dropEnvironmentRetireRequestedAtColumn(db); // Delete by the journal timestamp, not a hash substring: migration hashes // are hex and can contain "0085" by coincidence. db.$client @@ -1595,6 +1657,7 @@ describe("migrate", () => { dropOnboardingCompletedAtColumn(db); dropNewOnboardingExperimentColumn(db); dropHostMaxPermissionModeColumn(db); + dropEnvironmentRetireRequestedAtColumn(db); migrate(db); @@ -1993,6 +2056,7 @@ describe("migrate", () => { dropOnboardingCompletedAtColumn(db); dropNewOnboardingExperimentColumn(db); dropHostMaxPermissionModeColumn(db); + dropEnvironmentRetireRequestedAtColumn(db); expect( db.$client @@ -2088,6 +2152,7 @@ describe("migrate", () => { dropOnboardingCompletedAtColumn(db); dropNewOnboardingExperimentColumn(db); dropHostMaxPermissionModeColumn(db); + dropEnvironmentRetireRequestedAtColumn(db); expect(() => migrate(db)).not.toThrow(); diff --git a/packages/domain/src/environment-lifecycle.ts b/packages/domain/src/environment-lifecycle.ts index a8c79a703c..09540d5850 100644 --- a/packages/domain/src/environment-lifecycle.ts +++ b/packages/domain/src/environment-lifecycle.ts @@ -6,9 +6,9 @@ import type { EnvironmentStatus } from "./environment.js"; * (status, event) → next status and ENVIRONMENT_LIFECYCLE_EVENT_PREDICATES * declares which row-level signals supersede each event. * - * Unlike thread events, two destroy events carry a `destroyAttemptId` + * Unlike thread events, three destroy events carry a `destroyAttemptId` * payload: the db writer stamps it on start and the evaluator compares it - * on failure settlement, replacing the old per-attempt CAS in + * on completion/failure settlement, replacing the old per-attempt CAS in * restoreEnvironmentAfterDestroyAttemptFailure. * * Vocabulary (sources are the call sites inventoried in @@ -26,8 +26,8 @@ import type { EnvironmentStatus } from "./environment.js"; * cleanup started destroy. * - `destroy.started` — cleanup started destroying a retiring/error * environment (stamps destroyAttemptId). - * - `destroy.completed` — destroy completed; the workspace is gone or no - * workspace existed. + * - `destroy.completed` — the matching destroy completed; the workspace is + * gone, or no workspace existed (`destroyAttemptId: null`). * - `destroy.failed` — destroy failed; the matching attempt restores cleanup * intent for retry. * - `destroy.lost` — destroy result was lost and workspace existence is @@ -41,7 +41,7 @@ export type EnvironmentLifecycleEvent = | { type: "retire.requested" } | { type: "retire.cancelled" } | { type: "destroy.started"; destroyAttemptId: string } - | { type: "destroy.completed" } + | { type: "destroy.completed"; destroyAttemptId: string | null } | { type: "destroy.failed"; destroyAttemptId: string } | { type: "destroy.lost" }; @@ -73,7 +73,7 @@ export const ENVIRONMENT_LIFECYCLE_EVENT_PREDICATES: Record< "retire.requested": { managed: true }, "retire.cancelled": {}, "destroy.started": { managed: true }, - "destroy.completed": {}, + "destroy.completed": { matchingDestroyAttempt: true }, "destroy.failed": { matchingDestroyAttempt: true }, "destroy.lost": {}, }; @@ -126,6 +126,10 @@ export const ENVIRONMENT_LIFECYCLE: Record< // settlement only fires from provisioning.) "provision.requested": "provisioning", "destroy.started": "destroying", + // A daemon can report success after startup recovery classified its + // in-flight attempt as lost. Attempt matching makes that late settlement + // safe while rejecting success from an older, superseded retry. + "destroy.completed": "destroyed", }, destroying: { // No provision.* here: nothing reprovisions a destroying environment, so a diff --git a/packages/domain/test/environment-lifecycle.test.ts b/packages/domain/test/environment-lifecycle.test.ts index 0c3cbbfafc..9962a8aaa9 100644 --- a/packages/domain/test/environment-lifecycle.test.ts +++ b/packages/domain/test/environment-lifecycle.test.ts @@ -27,6 +27,7 @@ const allEventTypes: readonly EnvironmentLifecycleEventType[] = [ const payloadEventTypes: readonly EnvironmentLifecycleEventType[] = [ "destroy.started", + "destroy.completed", "destroy.failed", ]; @@ -35,6 +36,7 @@ function eventOfType( ): EnvironmentLifecycleEvent { switch (eventType) { case "destroy.started": + case "destroy.completed": case "destroy.failed": return { type: eventType, destroyAttemptId: "rpc_attempt" }; default: @@ -130,6 +132,7 @@ describe("ENVIRONMENT_LIFECYCLE table", () => { error: { "provision.requested": "provisioning", "destroy.started": "destroying", + "destroy.completed": "destroyed", }, destroying: { "destroy.completed": "destroyed", @@ -149,7 +152,7 @@ describe("ENVIRONMENT_LIFECYCLE table", () => { "retire.requested": { managed: true }, "retire.cancelled": {}, "destroy.started": { managed: true }, - "destroy.completed": {}, + "destroy.completed": { matchingDestroyAttempt: true }, "destroy.failed": { matchingDestroyAttempt: true }, "destroy.lost": {}, }); diff --git a/packages/scripts/test/seed-perf-fixture.test.ts b/packages/scripts/test/seed-perf-fixture.test.ts index c6415c8714..c5990904ff 100644 --- a/packages/scripts/test/seed-perf-fixture.test.ts +++ b/packages/scripts/test/seed-perf-fixture.test.ts @@ -111,5 +111,5 @@ describe("seedPerfFixture", () => { expect(secondResult.eventRowCount).toBe(result.eventRowCount); secondDb.$client.close(); db.$client.close(); - }); + }, 15_000); }); diff --git a/plans/environment-archive-grace-period.md b/plans/environment-archive-grace-period.md new file mode 100644 index 0000000000..cab4a94956 --- /dev/null +++ b/plans/environment-archive-grace-period.md @@ -0,0 +1,89 @@ +# Environment archive grace period + +Status: implemented 2026-06-17. + +## Outcome + +Archiving the last live thread in a managed environment now has a durable +five-minute grace window. The archive toast offers **Undo** for 10 seconds; the +thread's normal **Unarchive** action remains available for the rest of the grace +window. Unarchiving sends the existing `retire.cancelled` lifecycle event and +preserves the intact worktree, including uncommitted work. + +Once cleanup has started, a destroyed environment remains terminal and its +thread remains archived and read-only. Uncommitted and untracked work cannot be +recovered after the old worktree has been removed. + +There is deliberately no restore route, same-thread environment replacement, +daemon protocol change, or special existing-branch checkout path. + +## Lifecycle + +The existing environment state machine remains authoritative: + +- `ready` → `retire.requested` → `retiring` +- `retiring` → `retire.cancelled` → `ready` +- `retiring` → `destroy.started` → `destroying` +- `destroying` → `destroy.completed` → `destroyed` + +`destroyed` remains terminal. + +The server's `managedEnvironmentRetireGraceMs` defaults to five minutes. Cleanup +uses the retiring environment row's lifecycle-owned `retireRequestedAt` value +instead of `updatedAt` or an in-memory timer, so metadata writes cannot extend +the clock and restart does not bypass the window. Grace applies only to a +path-bearing retiring environment with a non-deleted archived thread that could +still be revived. Deleted/tombstoned-only environments are reclaimed without +waiting. + +The periodic sweep evaluates retiring managed environments every tick. The +cleanup advance owns the grace decision, keeping the policy in one place. +Orphaned `destroying` recovery remains the slower backstop. Startup honors the +same orphan timeout instead of immediately failing an in-flight daemon command. +Destroy completion is correlated to its attempt id, so a matching late success +can still converge `error` to terminal `destroyed` while a stale attempt cannot. + +## User flows + +### Accidental archive, still inside grace + +1. Archiving the last live thread moves the environment to `retiring`. +2. The toast remains visible for 10 seconds and offers **Undo**. +3. Toast Undo or the archived thread's **Unarchive** action unarchives the + thread and emits `retire.cancelled` during the five-minute grace window. +4. The same environment and intact worktree return to `ready`. + +### Cleanup already finished + +1. The source thread stays archived and its old environment stays `destroyed`. +2. While destruction is in progress, its context banner shows **Archiving + environment...**. Once destruction finishes, the banner shows **Environment + archived**. +3. Unarchiving the thread remains a record operation, but cannot revive the + terminal environment or restore its removed worktree. + +## Boundaries and data model + +- The grace period does not add a new public HTTP, SDK, CLI, or daemon contract. + It changes the behavior behind the existing archive and unarchive operations. +- The host daemon continues to receive ordinary new-worktree provision commands. +- `HOST_DAEMON_PROTOCOL_VERSION` is unchanged. +- A nullable `retireRequestedAt` column is the durable lifecycle-owned grace + clock; it is set on `retire.requested` and cleared when retirement ends. +- Destroyed environment rows are pruned after the existing seven-day retention + period. The removed workspace path is cleared on destroy completion. +- The only new database query answers whether a retiring environment has a + revivable archived thread; it is a targeted `WHERE` query. + +## Verification + +Tests cover: + +- grace-window deferral, cancellation, expiry, restart recovery, and deletion; +- Undo toast behavior; +- archived-environment banner priority and copy; +- destroyed-environment pruning after the retention period. + +The integration harness keeps `managedEnvironmentRetireGraceMs: 0` because it +has no periodic sweep or controlled clock; server-level lifecycle tests cover +the grace timing itself. diff --git a/tests/integration/helpers/harness.ts b/tests/integration/helpers/harness.ts index 11f0709da1..3ae15a8822 100644 --- a/tests/integration/helpers/harness.ts +++ b/tests/integration/helpers/harness.ts @@ -237,6 +237,11 @@ async function startIntegrationServer( threadStorageRootPath, transcriptionModel: "test/mock-transcription", isDevelopment: false, + // The integration harness runs no periodic sweep and has no time control, so + // the archive grace window is disabled here: archiving the last live thread + // tears down its workspace immediately, as these tests expect. The grace + // window itself is covered by the server-level cleanup tests. + managedEnvironmentRetireGraceMs: 0, }; const terminalSessions = new TerminalSessionLifecycle({ attachTimeoutMs: 50,