diff --git a/apps/app/.ladle/story-fixtures.ts b/apps/app/.ladle/story-fixtures.ts index 6ee87ab29..9fecba58f 100644 --- a/apps/app/.ladle/story-fixtures.ts +++ b/apps/app/.ladle/story-fixtures.ts @@ -326,6 +326,10 @@ export function makeThreadListEntry( environmentName: null, environmentBranchName: null, environmentWorkspaceDisplayKind: "other", + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 100 }, + pullRequest: { state: "not_applicable", refreshedAt: 100 }, + }, runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, }; return { ...base, ...overrides }; diff --git a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx index af7907fd6..a8c94db66 100644 --- a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx @@ -92,6 +92,10 @@ function makeThread(overrides: Partial = {}): ThreadListEntry { environmentName: null, environmentBranchName: null, environmentWorkspaceDisplayKind: "other", + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 100 }, + pullRequest: { state: "not_applicable", refreshedAt: 100 }, + }, runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, ...overrides, }; diff --git a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx index c50f48e78..e132083b1 100644 --- a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx @@ -55,6 +55,10 @@ function createThreadListEntry({ environmentId: null, environmentName: null, environmentWorkspaceDisplayKind: "other", + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 1000 }, + pullRequest: { state: "not_applicable", refreshedAt: 1000 }, + }, hasPendingInteraction: false, id, lastReadAt: null, diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx index 1090f18e0..f746c1440 100644 --- a/apps/app/src/components/sidebar/ThreadRow.test.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx @@ -44,6 +44,10 @@ function createThread( environmentName: null, environmentBranchName: null, environmentWorkspaceDisplayKind: "other", + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 1 }, + pullRequest: { state: "not_applicable", refreshedAt: 1 }, + }, runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null, diff --git a/apps/app/src/components/sidebar/pinnedSidebarThreads.test.ts b/apps/app/src/components/sidebar/pinnedSidebarThreads.test.ts index f07056c45..9a118803f 100644 --- a/apps/app/src/components/sidebar/pinnedSidebarThreads.test.ts +++ b/apps/app/src/components/sidebar/pinnedSidebarThreads.test.ts @@ -34,6 +34,10 @@ function createThread( environmentName: null, environmentBranchName: null, environmentWorkspaceDisplayKind: "other", + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 2 }, + pullRequest: { state: "not_applicable", refreshedAt: 2 }, + }, runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null, diff --git a/apps/app/src/components/sidebar/projectThreadGroups.test.ts b/apps/app/src/components/sidebar/projectThreadGroups.test.ts index 2a679787c..7dc32f2c1 100644 --- a/apps/app/src/components/sidebar/projectThreadGroups.test.ts +++ b/apps/app/src/components/sidebar/projectThreadGroups.test.ts @@ -71,6 +71,10 @@ function createThread( environmentName: null, environmentBranchName: null, environmentWorkspaceDisplayKind: "other", + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 2 }, + pullRequest: { state: "not_applicable", refreshedAt: 2 }, + }, runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null, diff --git a/apps/app/src/components/sidebar/sortComparator.test.ts b/apps/app/src/components/sidebar/sortComparator.test.ts index 48f3dcb92..78c81e5e5 100644 --- a/apps/app/src/components/sidebar/sortComparator.test.ts +++ b/apps/app/src/components/sidebar/sortComparator.test.ts @@ -38,6 +38,10 @@ function thread(overrides: Partial): ThreadListEntry { environmentName: null, environmentBranchName: null, environmentWorkspaceDisplayKind: "other", + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 2 }, + pullRequest: { state: "not_applicable", refreshedAt: 2 }, + }, runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null, diff --git a/apps/app/src/hooks/cache-owners/query-cache.ts b/apps/app/src/hooks/cache-owners/query-cache.ts index 96261d902..c804695be 100644 --- a/apps/app/src/hooks/cache-owners/query-cache.ts +++ b/apps/app/src/hooks/cache-owners/query-cache.ts @@ -460,7 +460,7 @@ export function getEnvironmentActionInvalidationQueryKeys({ export function getCachedThreadListPlaceholder( queryClient: QueryClient, threadId: string, -): ThreadWithRuntime | undefined { +): ThreadListEntry | undefined { if (!threadId) { return undefined; } @@ -584,6 +584,13 @@ export function optimisticallyInsertThread( hasPendingInteraction: false, pinSortKey: null, environmentWorkspaceDisplayKind: "other", + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: thread.updatedAt }, + pullRequest: { + state: "not_applicable", + refreshedAt: thread.updatedAt, + }, + }, }, ...data, ]); diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index bbbf7d4a3..1f23439c9 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -279,6 +279,13 @@ export const REALTIME_THREAD_CHANGE_REGISTRY = { dirtyThreadStorageQueriesForThread, // Thread storage is resolved through the attached environment. ], }, + "environment-status-summary-changed": { + flush: "debounced", + dirty: [ + dirtyThreadListQueries, // Sidebar/list rows render environment git and PR signals. + dirtyThreadDetailQueries, // Detail consumers may reuse list-derived environment summary. + ], + }, "read-state-changed": { flush: "debounced", dirty: [ diff --git a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts index f051d0869..4b06178e0 100644 --- a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts +++ b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts @@ -56,6 +56,10 @@ function makeThreadListEntry( environmentName: "Environment", environmentBranchName: "main", environmentWorkspaceDisplayKind: "managed-worktree", + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 1 }, + pullRequest: { state: "not_applicable", refreshedAt: 1 }, + }, ...thread, }; } diff --git a/apps/app/src/hooks/mutations/thread-folder-mutations.test.tsx b/apps/app/src/hooks/mutations/thread-folder-mutations.test.tsx index 0c7ac1e4a..2b9076569 100644 --- a/apps/app/src/hooks/mutations/thread-folder-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/thread-folder-mutations.test.tsx @@ -51,6 +51,10 @@ function makeThreadListEntry( environmentHostId: "host-1", environmentName: "Environment", environmentWorkspaceDisplayKind: "managed-worktree", + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 1 }, + pullRequest: { state: "not_applicable", refreshedAt: 1 }, + }, ...thread, }; } diff --git a/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx b/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx index 5d53a5412..c27be3347 100644 --- a/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx @@ -61,6 +61,10 @@ function makeThreadResponse( return { ...makeThreadWithRuntime(thread), canSpawnChild: true, + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 1 }, + pullRequest: { state: "not_applicable", refreshedAt: 1 }, + }, ...thread, }; } @@ -77,6 +81,10 @@ function makeThreadListEntry( environmentName: "Environment", environmentBranchName: "main", environmentWorkspaceDisplayKind: "managed-worktree", + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 1 }, + pullRequest: { state: "not_applicable", refreshedAt: 1 }, + }, ...thread, }; } @@ -224,9 +232,9 @@ describe("thread state mutations", () => { await waitFor(() => { expect( - queryClient.getQueryData(threadQueryKey(threadId)) - ?.folderId, - ).toBe("fld_personal"); + queryClient.getQueryData(threadQueryKey(threadId)) + ?.folderId, + ).toBe("fld_personal"); }); expect( queryClient.getQueryData(threadListKey)?.[0]?.folderId, diff --git a/apps/app/src/hooks/queries/environment-queries.test.tsx b/apps/app/src/hooks/queries/environment-queries.test.tsx index b9d3616ba..d3524b370 100644 --- a/apps/app/src/hooks/queries/environment-queries.test.tsx +++ b/apps/app/src/hooks/queries/environment-queries.test.tsx @@ -8,7 +8,6 @@ import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { environmentPullRequestQueryKey } from "./query-keys"; import { - getEnvironmentPullRequestRefetchInterval, getEnvironmentPullRequestStaleTime, useEnvironmentPullRequest, } from "./environment-queries"; @@ -28,7 +27,6 @@ vi.mock("@/hooks/useRealtimeSubscription", () => ({ const ENVIRONMENT_ID = "env-1"; const ACTIVE_PULL_REQUEST_STALE_MS = 30_000; const SETTLED_PULL_REQUEST_STALE_MS = 60 * 60_000; -const ACTIVE_PULL_REQUEST_REFETCH_MS = 5_000; const pullRequestFixture: ThreadPullRequest = { number: 128, @@ -106,66 +104,7 @@ describe("useEnvironmentPullRequest", () => { ).toBe(SETTLED_PULL_REQUEST_STALE_MS); }); - it("polls open pull requests while checks or mergeability are still settling", () => { - expect(getEnvironmentPullRequestRefetchInterval(null)).toBe(false); - expect(getEnvironmentPullRequestRefetchInterval(undefined)).toBe(false); - expect(getEnvironmentPullRequestRefetchInterval(pullRequestFixture)).toBe( - false, - ); - expect( - getEnvironmentPullRequestRefetchInterval({ - ...pullRequestFixture, - checks: { - ...pullRequestFixture.checks, - state: "pending", - pendingCount: 1, - }, - attention: "checks_pending", - }), - ).toBe(ACTIVE_PULL_REQUEST_REFETCH_MS); - expect( - getEnvironmentPullRequestRefetchInterval({ - ...pullRequestFixture, - mergeability: { - state: "unknown", - mergeStateStatus: "UNKNOWN", - mergeable: "UNKNOWN", - }, - attention: "none", - }), - ).toBe(ACTIVE_PULL_REQUEST_REFETCH_MS); - }); - - it("does not poll draft or settled pull requests", () => { - expect( - getEnvironmentPullRequestRefetchInterval({ - ...pullRequestFixture, - state: "draft", - mergeability: { - state: "draft", - mergeStateStatus: "DRAFT", - mergeable: "UNKNOWN", - }, - attention: "draft", - }), - ).toBe(false); - expect( - getEnvironmentPullRequestRefetchInterval({ - ...pullRequestFixture, - state: "closed", - attention: "closed", - }), - ).toBe(false); - expect( - getEnvironmentPullRequestRefetchInterval({ - ...pullRequestFixture, - state: "merged", - attention: "merged", - }), - ).toBe(false); - }); - - it("refetches stale pull request data on mount and always refetches on window focus", async () => { + it("refetches stale pull request data on mount and window focus without polling", async () => { const { wrapper, queryClient } = createQueryClientTestHarness(); vi.mocked(api.getEnvironmentPullRequest).mockResolvedValue( pullRequestResponse(pullRequestFixture), @@ -185,9 +124,9 @@ describe("useEnvironmentPullRequest", () => { expect.objectContaining({ refetchOnMount: true, refetchOnWindowFocus: "always", - refetchInterval: expect.any(Function), staleTime: expect.any(Function), }), ); + expect(query?.options).not.toHaveProperty("refetchInterval"); }); }); diff --git a/apps/app/src/hooks/queries/environment-queries.ts b/apps/app/src/hooks/queries/environment-queries.ts index 0d15fcc76..5389eea7d 100644 --- a/apps/app/src/hooks/queries/environment-queries.ts +++ b/apps/app/src/hooks/queries/environment-queries.ts @@ -52,7 +52,6 @@ interface UseEnvironmentDiffFilesOptions extends QueryOptions { const ENVIRONMENT_PULL_REQUEST_STALE_MS = 30_000; const ENVIRONMENT_SETTLED_PULL_REQUEST_STALE_MS = 60 * 60_000; -const ENVIRONMENT_ACTIVE_PULL_REQUEST_REFETCH_MS = 5_000; const MERGE_BASE_BRANCHES_STALE_MS = 30_000; const MERGE_BASE_BRANCHES_LIMIT = 50; /** Staleness window for the environment diff TOC query. */ @@ -133,21 +132,6 @@ export function getEnvironmentPullRequestStaleTime( : ENVIRONMENT_PULL_REQUEST_STALE_MS; } -export function getEnvironmentPullRequestRefetchInterval( - pullRequest: ThreadPullRequest | null | undefined, -): number | false { - if (!pullRequest || pullRequest.state !== "open") { - return false; - } - if ( - pullRequest.checks.state === "pending" || - pullRequest.mergeability.state === "unknown" - ) { - return ENVIRONMENT_ACTIVE_PULL_REQUEST_REFETCH_MS; - } - return false; -} - export function useEnvironmentPullRequest( environmentId: string | null | undefined, options?: QueryOptions, @@ -165,8 +149,6 @@ export function useEnvironmentPullRequest( enabled, refetchOnMount: true, refetchOnWindowFocus: "always", - refetchInterval: (query) => - getEnvironmentPullRequestRefetchInterval(query.state.data?.pullRequest), staleTime: (query) => getEnvironmentPullRequestStaleTime(query.state.data?.pullRequest), }); diff --git a/apps/app/src/hooks/queries/query-helpers.test.ts b/apps/app/src/hooks/queries/query-helpers.test.ts index e7e85ccda..a4781125d 100644 --- a/apps/app/src/hooks/queries/query-helpers.test.ts +++ b/apps/app/src/hooks/queries/query-helpers.test.ts @@ -216,6 +216,10 @@ describe("resolveThreadPlaceholder", () => { const previousThread: ThreadResponse = { ...makeThreadWithRuntime({ id: "thread-1" }), canSpawnChild: false, + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 1 }, + pullRequest: { state: "not_applicable", refreshedAt: 1 }, + }, }; expect( diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index b69e9a400..7fdbb08a5 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -9,7 +9,7 @@ import { useDebounceValue } from "usehooks-ts"; import type { PendingInteraction, ResolvedThreadExecutionOptions, - ThreadWithRuntime, + ThreadListEntry, } from "@bb/domain"; import type { PromptHistoryResponse, @@ -518,7 +518,7 @@ export function useThread(id: string, options?: QueryOptions) { // placeholder; the real single-thread response, which carries the server- // computed value, resolves moments later. function liftThreadListPlaceholder( - thread: ThreadWithRuntime | undefined, + thread: ThreadListEntry | undefined, ): ThreadResponse | undefined { if (thread === undefined) { return undefined; diff --git a/apps/app/src/views/RootComposeMobileRecents.test.tsx b/apps/app/src/views/RootComposeMobileRecents.test.tsx index cdb401f00..7fb220b22 100644 --- a/apps/app/src/views/RootComposeMobileRecents.test.tsx +++ b/apps/app/src/views/RootComposeMobileRecents.test.tsx @@ -38,6 +38,10 @@ function makeThread(args: MakeThreadArgs): ThreadListEntry { environmentName: null, environmentBranchName: null, environmentWorkspaceDisplayKind: "other", + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 100 }, + pullRequest: { state: "not_applicable", refreshedAt: 100 }, + }, runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null, diff --git a/apps/app/src/views/RootComposeView.test.ts b/apps/app/src/views/RootComposeView.test.ts index 202042595..b4b9e42d8 100644 --- a/apps/app/src/views/RootComposeView.test.ts +++ b/apps/app/src/views/RootComposeView.test.ts @@ -86,6 +86,10 @@ function makeThread(args: MakeThreadArgs): ThreadListEntry { environmentName: null, environmentBranchName: null, environmentWorkspaceDisplayKind: "other", + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 100 }, + pullRequest: { state: "not_applicable", refreshedAt: 100 }, + }, runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null, diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index b1006778e..6df2a63d7 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -16,6 +16,7 @@ import { import { isActiveTerminalSessionStatus, resolveEnvironmentMergeBaseBranch, + type ThreadPullRequest, type ThreadListEntry, type ThreadWithRuntime, } from "@bb/domain"; @@ -36,7 +37,6 @@ import { useSendThreadMessage } from "../../hooks/mutations/thread-runtime-mutat import { useUpdateEnvironment } from "../../hooks/mutations/environment-mutations"; import { useEnvironment, - useEnvironmentPullRequest, useEnvironmentWorkStatus, } from "../../hooks/queries/environment-queries"; import { @@ -1282,10 +1282,10 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { workStatusResponse?.outcome === "unavailable" ? workStatusResponse.failure : undefined; - const pullRequestQuery = useEnvironmentPullRequest(thread?.environmentId, { - enabled: canUseGitUi && environment !== undefined, - }); - const pullRequest = pullRequestQuery.data?.pullRequest ?? null; + const pullRequest = useMemo(() => { + const summary = thread?.environmentStatusSummary.pullRequest; + return summary?.state === "available" ? summary.pullRequest : null; + }, [thread?.environmentStatusSummary.pullRequest]); const handlePullRequestReady = useCallback(async () => { const environmentId = thread?.environmentId; if (!environmentId) { diff --git a/apps/app/src/views/thread-detail/threadParentSelectorOptions.test.ts b/apps/app/src/views/thread-detail/threadParentSelectorOptions.test.ts index 581edc386..a1ff0d7c5 100644 --- a/apps/app/src/views/thread-detail/threadParentSelectorOptions.test.ts +++ b/apps/app/src/views/thread-detail/threadParentSelectorOptions.test.ts @@ -19,6 +19,10 @@ function makeThread(overrides: ThreadListEntryOverrides = {}): ThreadListEntry { environmentId: null, environmentName: null, environmentWorkspaceDisplayKind: "other", + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 1 }, + pullRequest: { state: "not_applicable", refreshedAt: 1 }, + }, hasPendingInteraction: false, id: "thr_1", lastReadAt: null, diff --git a/apps/server/src/routes/environments.ts b/apps/server/src/routes/environments.ts index da63bd257..609125be0 100644 --- a/apps/server/src/routes/environments.ts +++ b/apps/server/src/routes/environments.ts @@ -45,6 +45,7 @@ import { requireAvailableWorkspaceDiff, requireAvailableWorkspaceStatus, } from "../services/environments/workspace-rpc-results.js"; +import { refreshEnvironmentPullRequestStatusSnapshotForEnvironment } from "../services/environments/environment-status-snapshots.js"; import { rawDiffFileStatToEntry, selectInitialPatchPaths, @@ -218,6 +219,16 @@ function assertCanMergePullRequest( } } +async function refreshPullRequestSnapshotAfterAction( + deps: AppDeps, + environment: Environment, +): Promise { + await refreshEnvironmentPullRequestStatusSnapshotForEnvironment(deps, { + environmentId: environment.id, + now: Date.now(), + }); +} + /** * Pick the git ref to read for the requested side of a diff. Returns * `undefined` when the side should be read from the working tree (no ref — @@ -790,6 +801,7 @@ export function registerEnvironmentRoutes(app: Hono, deps: AppDeps): void { }, }), ); + await refreshPullRequestSnapshotAfterAction(deps, environment); return context.json({ ok: true, action: "pull_request_ready", @@ -823,6 +835,7 @@ export function registerEnvironmentRoutes(app: Hono, deps: AppDeps): void { }, }), ); + await refreshPullRequestSnapshotAfterAction(deps, environment); return context.json({ ok: true, action: "pull_request_draft", @@ -857,6 +870,7 @@ export function registerEnvironmentRoutes(app: Hono, deps: AppDeps): void { }, }), ); + await refreshPullRequestSnapshotAfterAction(deps, environment); return context.json({ ok: true, action: "pull_request_merge", diff --git a/apps/server/src/routes/threads/base.ts b/apps/server/src/routes/threads/base.ts index 501bb1d25..f770ee201 100644 --- a/apps/server/src/routes/threads/base.ts +++ b/apps/server/src/routes/threads/base.ts @@ -268,10 +268,10 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void { return context.json(toThreadResponseFromThread(deps, { thread }), 201); }); - get(routes.get, (context, query) => { + get(routes.get, async (context, query) => { const thread = requirePublicThread(deps.db, context.req.param("id")); return context.json( - buildThreadResponse(deps, { + await buildThreadResponse(deps, { includes: parseThreadIncludes(query), thread, }), diff --git a/apps/server/src/services/environments/environment-status-snapshots.ts b/apps/server/src/services/environments/environment-status-snapshots.ts new file mode 100644 index 000000000..102404516 --- /dev/null +++ b/apps/server/src/services/environments/environment-status-snapshots.ts @@ -0,0 +1,505 @@ +import { + ensureEnvironmentStatusSnapshotRows, + ensureTrackedEnvironmentStatusSnapshotRows, + getEnvironment, + getEnvironmentPullRequestStatusSnapshot, + getThread, + listDueEnvironmentGitStatusSnapshots, + listDueEnvironmentPullRequestStatusSnapshots, + listEnvironmentThreadNotificationTargets, + markEnvironmentStatusSnapshotsDue, + writeEnvironmentGitStatusSnapshot, + writeEnvironmentPullRequestStatusSnapshot, + type EnvironmentGitStatusSnapshotRow, + type EnvironmentPullRequestStatusSnapshotRow, +} from "@bb/db"; +import { + resolveEnvironmentMergeBaseBranch, + threadEnvironmentGitStatusSnapshotSchema, + threadPullRequestSchema, + type ChangedMessage, + type Environment, + type EnvironmentChangeKind, + type ThreadChangeKind, + type ThreadEnvironmentGitStatusSnapshot, + type ThreadPullRequest, + type WorkspaceChangeStats, + type WorkspaceStatus, +} from "@bb/domain"; +import { ApiError } from "../../errors.js"; +import type { + AppDeps, + LoggedPendingInteractionWorkSessionDeps, +} from "../../types.js"; +import { COMMAND_TIMEOUT_MS } from "../../constants.js"; +import { callHostRetryableOnlineRpc } from "../hosts/online-rpc.js"; +import { assembleThreadPullRequest } from "./pull-request.js"; +import { workspaceContextFromPath } from "./workspace-command-target.js"; + +type SnapshotCoordinatorDeps = Pick; +type SnapshotRefreshDeps = LoggedPendingInteractionWorkSessionDeps; +type ReadyGitEnvironment = Environment & { + isGitRepo: true; + path: string; + status: "ready"; +}; + +const SNAPSHOT_REFRESH_BATCH_LIMIT = 10; +const SNAPSHOT_STATUS_FILE_LIMIT = 5; +const GIT_STATUS_BACKSTOP_REFRESH_MS = 5 * 60_000; +const NOT_APPLICABLE_REFRESH_MS = 60 * 60_000; +const UNAVAILABLE_RETRY_MS = 30_000; +const ACTIVE_PULL_REQUEST_STALE_MS = 30_000; +const SETTLED_PULL_REQUEST_STALE_MS = 60 * 60_000; +const ACTIVE_PULL_REQUEST_REFETCH_MS = 5_000; + +const ENVIRONMENT_CHANGES_DIRTYING_BOTH = new Set([ + "environment-created", + "metadata-changed", + "status-changed", +]); +const THREAD_CHANGES_THAT_CAN_CHANGE_ELIGIBILITY = new Set([ + "archived-changed", + "environment-changed", + "thread-created", + "thread-deleted", +]); + +function hasAnyChange( + changes: readonly TChange[], + candidates: ReadonlySet, +): boolean { + return changes.some((change) => candidates.has(change)); +} + +function serializeJson(value: unknown): string { + return JSON.stringify(value); +} + +function normalizeError(error: unknown): { code: string; message: string } { + if (error instanceof ApiError) { + return { + code: error.body.code, + message: error.body.message, + }; + } + if (error instanceof Error) { + return { + code: "snapshot_refresh_failed", + message: error.message, + }; + } + return { + code: "snapshot_refresh_failed", + message: String(error), + }; +} + +function environmentCanHaveWorkspaceStatus( + environment: Environment | null, +): environment is ReadyGitEnvironment { + return ( + environment !== null && + environment.status === "ready" && + environment.path !== null && + environment.isGitRepo + ); +} + +function mapChangeStats(stats: WorkspaceChangeStats) { + return { + fileCount: stats.files.length, + insertions: stats.insertions, + deletions: stats.deletions, + files: stats.files.slice(0, SNAPSHOT_STATUS_FILE_LIMIT).map((file) => ({ + path: file.path, + status: file.status, + })), + }; +} + +function mapWorkspaceStatusToGitSnapshot( + workspaceStatus: WorkspaceStatus, +): ThreadEnvironmentGitStatusSnapshot { + const workingTree = { + ...mapChangeStats(workspaceStatus.workingTree), + hasUncommittedChanges: workspaceStatus.workingTree.hasUncommittedChanges, + state: workspaceStatus.workingTree.state, + }; + const mergeBase = workspaceStatus.mergeBase + ? { + ...mapChangeStats(workspaceStatus.mergeBase), + aheadCount: workspaceStatus.mergeBase.aheadCount, + behindCount: workspaceStatus.mergeBase.behindCount, + commitCount: workspaceStatus.mergeBase.commits.length, + hasCommittedUnmergedChanges: + workspaceStatus.mergeBase.hasCommittedUnmergedChanges, + mergeBaseBranch: workspaceStatus.mergeBase.mergeBaseBranch, + } + : null; + + return threadEnvironmentGitStatusSnapshotSchema.parse({ + checkout: workspaceStatus.checkout, + currentBranch: workspaceStatus.branch.currentBranch, + defaultBranch: workspaceStatus.branch.defaultBranch, + hasChanges: + workingTree.hasUncommittedChanges || + (mergeBase?.hasCommittedUnmergedChanges ?? false), + workingTree, + mergeBase, + }); +} + +function nextPullRequestRefreshAt( + pullRequest: ThreadPullRequest | null, + now: number, +): number { + if (pullRequest?.state === "merged" || pullRequest?.state === "closed") { + return now + SETTLED_PULL_REQUEST_STALE_MS; + } + if ( + pullRequest?.state === "open" && + (pullRequest.checks.state === "pending" || + pullRequest.mergeability.state === "unknown") + ) { + return now + ACTIVE_PULL_REQUEST_REFETCH_MS; + } + return now + ACTIVE_PULL_REQUEST_STALE_MS; +} + +function parseStoredPullRequestSnapshot( + row: EnvironmentPullRequestStatusSnapshotRow, +): ThreadPullRequest | null { + if (row.pullRequestJson === null) { + return null; + } + try { + const parsed = threadPullRequestSchema.safeParse( + JSON.parse(row.pullRequestJson), + ); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +function notifyEnvironmentStatusSummaryChanged( + deps: SnapshotRefreshDeps, + environmentId: string, +): void { + for (const target of listEnvironmentThreadNotificationTargets( + deps.db, + environmentId, + )) { + deps.hub.notifyThread( + target.threadId, + ["environment-status-summary-changed"], + { + projectId: target.projectId, + }, + ); + } +} + +async function refreshGitStatusSnapshot( + deps: SnapshotRefreshDeps, + row: EnvironmentGitStatusSnapshotRow, + now: number, +): Promise { + const environment = getEnvironment(deps.db, row.environmentId); + if (!environmentCanHaveWorkspaceStatus(environment)) { + const changed = writeEnvironmentGitStatusSnapshot(deps.db, { + environmentId: row.environmentId, + status: "not_applicable", + gitStatusJson: null, + errorCode: null, + errorMessage: null, + refreshedAt: now, + nextRefreshAt: now + NOT_APPLICABLE_REFRESH_MS, + now, + }); + if (changed) { + notifyEnvironmentStatusSummaryChanged(deps, row.environmentId); + } + return; + } + + try { + const result = await callHostRetryableOnlineRpc(deps, { + hostId: environment.hostId, + timeoutMs: COMMAND_TIMEOUT_MS, + command: { + type: "workspace.status", + environmentId: environment.id, + workspaceContext: workspaceContextFromPath({ + path: environment.path, + workspaceProvisionType: environment.workspaceProvisionType, + }), + ...(resolveEnvironmentMergeBaseBranch(environment) + ? { mergeBaseBranch: resolveEnvironmentMergeBaseBranch(environment) } + : {}), + }, + }); + + if (result.outcome === "unavailable") { + const changed = writeEnvironmentGitStatusSnapshot(deps.db, { + environmentId: row.environmentId, + status: "unavailable", + gitStatusJson: null, + errorCode: result.failure.code, + errorMessage: result.failure.message, + refreshedAt: now, + nextRefreshAt: now + UNAVAILABLE_RETRY_MS, + now, + }); + if (changed) { + notifyEnvironmentStatusSummaryChanged(deps, row.environmentId); + } + return; + } + + const snapshot = mapWorkspaceStatusToGitSnapshot(result.workspaceStatus); + const changed = writeEnvironmentGitStatusSnapshot(deps.db, { + environmentId: row.environmentId, + status: "available", + gitStatusJson: serializeJson(snapshot), + errorCode: null, + errorMessage: null, + refreshedAt: now, + nextRefreshAt: now + GIT_STATUS_BACKSTOP_REFRESH_MS, + now, + }); + if (changed) { + notifyEnvironmentStatusSummaryChanged(deps, row.environmentId); + } + } catch (error) { + const normalized = normalizeError(error); + const changed = writeEnvironmentGitStatusSnapshot(deps.db, { + environmentId: row.environmentId, + status: "unavailable", + gitStatusJson: null, + errorCode: normalized.code, + errorMessage: normalized.message, + refreshedAt: now, + nextRefreshAt: now + UNAVAILABLE_RETRY_MS, + now, + }); + if (changed) { + notifyEnvironmentStatusSummaryChanged(deps, row.environmentId); + } + } +} + +async function refreshPullRequestStatusSnapshot( + deps: SnapshotRefreshDeps, + row: EnvironmentPullRequestStatusSnapshotRow, + now: number, +): Promise { + const environment = getEnvironment(deps.db, row.environmentId); + if (!environmentCanHaveWorkspaceStatus(environment)) { + const changed = writeEnvironmentPullRequestStatusSnapshot(deps.db, { + environmentId: row.environmentId, + status: "not_applicable", + pullRequestJson: null, + errorCode: null, + errorMessage: null, + refreshedAt: now, + nextRefreshAt: now + NOT_APPLICABLE_REFRESH_MS, + now, + }); + if (changed) { + notifyEnvironmentStatusSummaryChanged(deps, row.environmentId); + } + return; + } + + try { + const result = await callHostRetryableOnlineRpc(deps, { + hostId: environment.hostId, + timeoutMs: COMMAND_TIMEOUT_MS, + command: { + type: "workspace.pull_request", + environmentId: environment.id, + workspaceContext: workspaceContextFromPath({ + path: environment.path, + workspaceProvisionType: environment.workspaceProvisionType, + }), + }, + }); + const pullRequest = assembleThreadPullRequest(result.pullRequest); + const changed = writeEnvironmentPullRequestStatusSnapshot(deps.db, { + environmentId: row.environmentId, + status: "available", + pullRequestJson: pullRequest ? serializeJson(pullRequest) : null, + errorCode: null, + errorMessage: null, + refreshedAt: now, + nextRefreshAt: nextPullRequestRefreshAt(pullRequest, now), + now, + }); + if (changed) { + notifyEnvironmentStatusSummaryChanged(deps, row.environmentId); + } + } catch (error) { + const normalized = normalizeError(error); + const previousPullRequest = parseStoredPullRequestSnapshot(row); + const changed = writeEnvironmentPullRequestStatusSnapshot(deps.db, { + environmentId: row.environmentId, + status: "unavailable", + pullRequestJson: null, + errorCode: normalized.code, + errorMessage: normalized.message, + refreshedAt: now, + nextRefreshAt: Math.min( + now + UNAVAILABLE_RETRY_MS, + nextPullRequestRefreshAt(previousPullRequest, now), + ), + now, + }); + if (changed) { + notifyEnvironmentStatusSummaryChanged(deps, row.environmentId); + } + } +} + +function dirtyEnvironmentSnapshotsForChange( + deps: SnapshotCoordinatorDeps, + message: Extract, +): void { + if (!message.id) { + return; + } + + const now = Date.now(); + ensureEnvironmentStatusSnapshotRows(deps.db, { + environmentIds: [message.id], + now, + }); + + if ( + message.changes.includes("work-status-changed") || + message.changes.includes("git-refs-changed") || + hasAnyChange(message.changes, ENVIRONMENT_CHANGES_DIRTYING_BOTH) + ) { + markEnvironmentStatusSnapshotsDue(deps.db, { + environmentIds: [message.id], + now, + }); + } +} + +function dirtyEnvironmentSnapshotsForThreadChange( + deps: SnapshotCoordinatorDeps, + message: Extract, +): void { + if ( + !hasAnyChange(message.changes, THREAD_CHANGES_THAT_CAN_CHANGE_ELIGIBILITY) + ) { + return; + } + + const now = Date.now(); + ensureTrackedEnvironmentStatusSnapshotRows(deps.db, { now }); + if (!message.id) { + return; + } + + const thread = getThread(deps.db, message.id); + if (thread?.environmentId) { + ensureEnvironmentStatusSnapshotRows(deps.db, { + environmentIds: [thread.environmentId], + now, + }); + markEnvironmentStatusSnapshotsDue(deps.db, { + environmentIds: [thread.environmentId], + now, + }); + } +} + +export class EnvironmentStatusSnapshotCoordinator { + private readonly unsubscribe: () => void; + + constructor(private readonly deps: SnapshotCoordinatorDeps) { + this.unsubscribe = this.deps.hub.onChangedMessage((message) => { + this.handleChangedMessage(message); + }); + } + + dispose(): void { + this.unsubscribe(); + } + + private handleChangedMessage(message: ChangedMessage): void { + try { + switch (message.entity) { + case "environment": + dirtyEnvironmentSnapshotsForChange(this.deps, message); + return; + case "thread": + dirtyEnvironmentSnapshotsForThreadChange(this.deps, message); + return; + case "project": + case "host": + case "system": + return; + } + } catch (error) { + this.deps.logger.warn( + { err: error }, + "Failed to mark environment status snapshot dirty", + ); + } + } +} + +export function runEnvironmentStatusSnapshotStartupRecovery( + deps: SnapshotRefreshDeps, + now: number, +): void { + ensureTrackedEnvironmentStatusSnapshotRows(deps.db, { now }); +} + +export async function refreshDueEnvironmentStatusSnapshots( + deps: SnapshotRefreshDeps, + now: number, +): Promise { + ensureTrackedEnvironmentStatusSnapshotRows(deps.db, { now }); + + const gitRows = listDueEnvironmentGitStatusSnapshots(deps.db, { + now, + limit: SNAPSHOT_REFRESH_BATCH_LIMIT, + }); + for (const row of gitRows) { + await refreshGitStatusSnapshot(deps, row, Date.now()); + } + + const pullRequestRows = listDueEnvironmentPullRequestStatusSnapshots( + deps.db, + { + now, + limit: SNAPSHOT_REFRESH_BATCH_LIMIT, + }, + ); + for (const row of pullRequestRows) { + await refreshPullRequestStatusSnapshot(deps, row, Date.now()); + } +} + +export async function refreshEnvironmentPullRequestStatusSnapshotForEnvironment( + deps: SnapshotRefreshDeps, + args: { environmentId: string; now: number }, +): Promise { + ensureEnvironmentStatusSnapshotRows(deps.db, { + environmentIds: [args.environmentId], + now: args.now, + }); + const row = getEnvironmentPullRequestStatusSnapshot( + deps.db, + args.environmentId, + ); + if (row === null) { + return; + } + await refreshPullRequestStatusSnapshot(deps, row, args.now); +} diff --git a/apps/server/src/services/system/periodic-sweeps.ts b/apps/server/src/services/system/periodic-sweeps.ts index dff3f895d..6ecdfc9b0 100644 --- a/apps/server/src/services/system/periodic-sweeps.ts +++ b/apps/server/src/services/system/periodic-sweeps.ts @@ -51,6 +51,10 @@ import { advanceThreadProvisioning } from "../threads/thread-provisioning.js"; import { runQueuedMessageAutoSendSweep } from "../threads/queued-messages.js"; import { LIVE_DAEMON_COMMAND_TIMEOUT_MS } from "../hosts/live-command.js"; import { sweepDueAutomations } from "../scheduling/automation-sweep.js"; +import { + refreshDueEnvironmentStatusSnapshots, + runEnvironmentStatusSnapshotStartupRecovery, +} from "../environments/environment-status-snapshots.js"; export type DatabaseMaintenanceSweepDeps = Pick; @@ -527,6 +531,13 @@ async function runDueAutomationSweep( await sweepDueAutomations(deps, { now }); } +async function runEnvironmentStatusSnapshotRefreshSweep( + deps: LoggedPendingInteractionWorkSessionDeps, + now: number, +): Promise { + await refreshDueEnvironmentStatusSnapshots(deps, now); +} + const PERIODIC_SWEEP_JOBS: PeriodicSweepJob[] = [ { cadenceMs: 0, @@ -594,6 +605,12 @@ const PERIODIC_SWEEP_JOBS: PeriodicSweepJob[] = [ name: "due-automation", run: runDueAutomationSweep, }, + { + cadenceMs: 0, + category: "scheduler", + name: "environment-status-snapshot-refresh", + run: runEnvironmentStatusSnapshotRefreshSweep, + }, { cadenceMs: DATABASE_MAINTENANCE_CHECK_INTERVAL_MS, category: "maintenance", @@ -605,6 +622,7 @@ const PERIODIC_SWEEP_JOBS: PeriodicSweepJob[] = [ export async function runStartupRecoverySweep( deps: LoggedPendingInteractionWorkSessionDeps, ): Promise { + runEnvironmentStatusSnapshotStartupRecovery(deps, Date.now()); await runEnvironmentProvisioningSweep(deps); await runThreadLifecycleSweep(deps); await evaluateManagedEnvironmentArchiveCleanupCandidates(deps, Date.now()); diff --git a/apps/server/src/services/threads/thread-runtime-display.ts b/apps/server/src/services/threads/thread-runtime-display.ts index c33b1a654..dabdd0310 100644 --- a/apps/server/src/services/threads/thread-runtime-display.ts +++ b/apps/server/src/services/threads/thread-runtime-display.ts @@ -1,15 +1,26 @@ import { + ensureEnvironmentStatusSnapshotRows, getEnvironment, + getEnvironmentGitStatusSnapshot, + getEnvironmentPullRequestStatusSnapshot, getLatestSessionForHost, listActiveBackgroundTaskCountsByThreadIds, listLatestSessionsForHosts, + markEnvironmentStatusSnapshotsDue, type DbConnection, type HostDaemonSessionRow, type ThreadWithPendingInteractionState, } from "@bb/db"; +import { + threadEnvironmentGitStatusSnapshotSchema, + threadPullRequestSchema, +} from "@bb/domain"; import type { Thread, ThreadActivityState, + ThreadEnvironmentGitStatusSignal, + ThreadEnvironmentPullRequestStatusSignal, + ThreadEnvironmentStatusSummary, ThreadListEntry, ThreadRuntimeState, ThreadStatus, @@ -57,6 +68,27 @@ interface ToThreadListEntryResponseFromLatestSessionArgs { thread: ThreadWithPendingInteractionState; } +interface ThreadEnvironmentStatusSnapshotFields { + environmentId: string | null; + gitStatusSnapshotJson: string | null; + gitStatusSnapshotErrorCode: string | null; + gitStatusSnapshotErrorMessage: string | null; + gitStatusSnapshotRefreshedAt: number | null; + gitStatusSnapshotStatus: string | null; + pullRequestStatusSnapshotJson: string | null; + pullRequestStatusSnapshotErrorCode: string | null; + pullRequestStatusSnapshotErrorMessage: string | null; + pullRequestStatusSnapshotRefreshedAt: number | null; + pullRequestStatusSnapshotStatus: string | null; + updatedAt: number; +} + +type SnapshotStatus = + | "available" + | "not_applicable" + | "pending" + | "unavailable"; + function threadStatusRuntimeState(status: ThreadStatus): ThreadRuntimeState { switch (status) { case "starting": @@ -197,6 +229,10 @@ export function toThreadResponseFromThread( return { ...threadWithRuntime, canSpawnChild: canThreadSpawnChild(deps, { thread: args.thread }), + environmentStatusSummary: resolveThreadEnvironmentStatusSummaryForThread( + deps, + args, + ), }; } @@ -204,6 +240,7 @@ export function toThreadListEntryResponses( deps: ThreadRuntimeDisplayDeps, args: ToThreadListEntryResponsesArgs, ): ThreadListEntry[] { + ensureSnapshotRowsForThreadListDemand(deps, args); const threadActivityById = new Map( listActiveBackgroundTaskCountsByThreadIds(deps.db, { threadIds: args.threads.map((thread) => thread.id), @@ -239,6 +276,269 @@ export function toThreadListEntryResponses( ); } +function ensureSnapshotRowsForThreadListDemand( + deps: ThreadRuntimeDisplayDeps, + args: ToThreadListEntryResponsesArgs, +): void { + const environmentIds = args.threads.flatMap((thread) => + thread.environmentId !== null && + thread.archivedAt === null && + thread.deletedAt === null + ? [thread.environmentId] + : [], + ); + const now = args.now ?? Date.now(); + ensureEnvironmentStatusSnapshotRows(deps.db, { + environmentIds, + now, + }); + markEnvironmentStatusSnapshotsDue(deps.db, { + environmentIds, + now, + }); +} + +function ensureSnapshotRowsForThreadDemand( + deps: ThreadRuntimeDisplayDeps, + args: ToThreadResponseFromThreadArgs, +): void { + const environmentId = + args.thread.environmentId !== null && + args.thread.archivedAt === null && + args.thread.deletedAt === null + ? args.thread.environmentId + : null; + if (environmentId === null) { + return; + } + + const now = args.now ?? Date.now(); + ensureEnvironmentStatusSnapshotRows(deps.db, { + environmentIds: [environmentId], + now, + }); +} + +function normalizeSnapshotStatus(status: string | null): SnapshotStatus { + switch (status) { + case "available": + case "not_applicable": + case "pending": + case "unavailable": + return status; + case null: + return "pending"; + default: + return "unavailable"; + } +} + +function snapshotUnavailableReason(args: { + code: string | null; + message: string | null; +}) { + return { + code: args.code ?? "snapshot_unavailable", + message: args.message ?? "Environment status snapshot is unavailable", + }; +} + +function parseSnapshotJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + +function toThreadEnvironmentGitStatusSignal( + thread: ThreadEnvironmentStatusSnapshotFields, +): ThreadEnvironmentGitStatusSignal { + const status = normalizeSnapshotStatus(thread.gitStatusSnapshotStatus); + switch (status) { + case "pending": + return { state: "pending" }; + case "not_applicable": + return thread.gitStatusSnapshotRefreshedAt === null + ? { state: "pending" } + : { + state: "not_applicable", + refreshedAt: thread.gitStatusSnapshotRefreshedAt, + }; + case "unavailable": + return thread.gitStatusSnapshotRefreshedAt === null + ? { state: "pending" } + : { + state: "unavailable", + refreshedAt: thread.gitStatusSnapshotRefreshedAt, + reason: snapshotUnavailableReason({ + code: thread.gitStatusSnapshotErrorCode, + message: thread.gitStatusSnapshotErrorMessage, + }), + }; + case "available": { + if ( + thread.gitStatusSnapshotJson === null || + thread.gitStatusSnapshotRefreshedAt === null + ) { + return { state: "pending" }; + } + const parsed = threadEnvironmentGitStatusSnapshotSchema.safeParse( + parseSnapshotJson(thread.gitStatusSnapshotJson), + ); + if (!parsed.success) { + return { + state: "unavailable", + refreshedAt: thread.gitStatusSnapshotRefreshedAt, + reason: { + code: "invalid_snapshot", + message: "Stored git status snapshot is invalid", + }, + }; + } + return { + state: "available", + refreshedAt: thread.gitStatusSnapshotRefreshedAt, + snapshot: parsed.data, + }; + } + } +} + +function toThreadEnvironmentPullRequestStatusSignal( + thread: ThreadEnvironmentStatusSnapshotFields, +): ThreadEnvironmentPullRequestStatusSignal { + const status = normalizeSnapshotStatus( + thread.pullRequestStatusSnapshotStatus, + ); + switch (status) { + case "pending": + return { state: "pending" }; + case "not_applicable": + return thread.pullRequestStatusSnapshotRefreshedAt === null + ? { state: "pending" } + : { + state: "not_applicable", + refreshedAt: thread.pullRequestStatusSnapshotRefreshedAt, + }; + case "unavailable": + return thread.pullRequestStatusSnapshotRefreshedAt === null + ? { state: "pending" } + : { + state: "unavailable", + refreshedAt: thread.pullRequestStatusSnapshotRefreshedAt, + reason: snapshotUnavailableReason({ + code: thread.pullRequestStatusSnapshotErrorCode, + message: thread.pullRequestStatusSnapshotErrorMessage, + }), + }; + case "available": { + if (thread.pullRequestStatusSnapshotRefreshedAt === null) { + return { state: "pending" }; + } + if (thread.pullRequestStatusSnapshotJson === null) { + return { + state: "available", + refreshedAt: thread.pullRequestStatusSnapshotRefreshedAt, + pullRequest: null, + }; + } + const parsed = threadPullRequestSchema.safeParse( + parseSnapshotJson(thread.pullRequestStatusSnapshotJson), + ); + if (!parsed.success) { + return { + state: "unavailable", + refreshedAt: thread.pullRequestStatusSnapshotRefreshedAt, + reason: { + code: "invalid_snapshot", + message: "Stored pull request status snapshot is invalid", + }, + }; + } + return { + state: "available", + refreshedAt: thread.pullRequestStatusSnapshotRefreshedAt, + pullRequest: parsed.data, + }; + } + } +} + +function toThreadEnvironmentStatusSummary( + thread: ThreadEnvironmentStatusSnapshotFields, +): ThreadEnvironmentStatusSummary { + if (thread.environmentId === null) { + return { + git: { state: "not_applicable", refreshedAt: thread.updatedAt }, + pullRequest: { state: "not_applicable", refreshedAt: thread.updatedAt }, + }; + } + + return { + git: toThreadEnvironmentGitStatusSignal(thread), + pullRequest: toThreadEnvironmentPullRequestStatusSignal(thread), + }; +} + +function toPendingThreadEnvironmentStatusSnapshotFields( + thread: Thread, +): ThreadEnvironmentStatusSnapshotFields { + return { + environmentId: thread.environmentId, + gitStatusSnapshotJson: null, + gitStatusSnapshotErrorCode: null, + gitStatusSnapshotErrorMessage: null, + gitStatusSnapshotRefreshedAt: null, + gitStatusSnapshotStatus: "pending", + pullRequestStatusSnapshotJson: null, + pullRequestStatusSnapshotErrorCode: null, + pullRequestStatusSnapshotErrorMessage: null, + pullRequestStatusSnapshotRefreshedAt: null, + pullRequestStatusSnapshotStatus: "pending", + updatedAt: thread.updatedAt, + }; +} + +function resolveThreadEnvironmentStatusSummaryForThread( + deps: ThreadRuntimeDisplayDeps, + args: ToThreadResponseFromThreadArgs, +): ThreadEnvironmentStatusSummary { + const thread = args.thread; + if (thread.environmentId === null) { + return toThreadEnvironmentStatusSummary( + toPendingThreadEnvironmentStatusSnapshotFields(thread), + ); + } + + ensureSnapshotRowsForThreadDemand(deps, args); + const gitSnapshot = getEnvironmentGitStatusSnapshot( + deps.db, + thread.environmentId, + ); + const pullRequestSnapshot = getEnvironmentPullRequestStatusSnapshot( + deps.db, + thread.environmentId, + ); + + return toThreadEnvironmentStatusSummary({ + environmentId: thread.environmentId, + gitStatusSnapshotJson: gitSnapshot?.gitStatusJson ?? null, + gitStatusSnapshotErrorCode: gitSnapshot?.errorCode ?? null, + gitStatusSnapshotErrorMessage: gitSnapshot?.errorMessage ?? null, + gitStatusSnapshotRefreshedAt: gitSnapshot?.refreshedAt ?? null, + gitStatusSnapshotStatus: gitSnapshot?.status ?? null, + pullRequestStatusSnapshotJson: pullRequestSnapshot?.pullRequestJson ?? null, + pullRequestStatusSnapshotErrorCode: pullRequestSnapshot?.errorCode ?? null, + pullRequestStatusSnapshotErrorMessage: + pullRequestSnapshot?.errorMessage ?? null, + pullRequestStatusSnapshotRefreshedAt: + pullRequestSnapshot?.refreshedAt ?? null, + pullRequestStatusSnapshotStatus: pullRequestSnapshot?.status ?? null, + updatedAt: thread.updatedAt, + }); +} + function toThreadListEntryResponseFromLatestSession( args: ToThreadListEntryResponseFromLatestSessionArgs, ): ThreadListEntry { @@ -250,6 +550,7 @@ function toThreadListEntryResponseFromLatestSession( environmentBranchName: args.thread.environmentBranchName, environmentHostId: args.thread.environmentHostId, environmentName: args.thread.environmentName, + environmentStatusSummary: toThreadEnvironmentStatusSummary(args.thread), environmentWorkspaceDisplayKind: args.thread.environmentWorkspaceDisplayKind, hasPendingInteraction: args.thread.hasPendingInteraction, diff --git a/apps/server/src/start-server.ts b/apps/server/src/start-server.ts index e29cb2e60..e67da6937 100644 --- a/apps/server/src/start-server.ts +++ b/apps/server/src/start-server.ts @@ -19,6 +19,7 @@ import { } from "./services/system/periodic-sweeps.js"; import { createTelemetryService } from "./services/system/telemetry.js"; import { TerminalSessionLifecycle } from "./services/terminals/terminal-session-lifecycle.js"; +import { EnvironmentStatusSnapshotCoordinator } from "./services/environments/environment-status-snapshots.js"; import { resolveThreadStorageRootPath } from "./services/threads/thread-storage.js"; import { createLifecycleDedupers } from "./lifecycle-dedupers.js"; import type { ServerRuntimeConfig } from "./types.js"; @@ -38,6 +39,11 @@ export async function runServer(serverConfig: ServerConfig): Promise { hub, logger, }); + const environmentStatusSnapshots = new EnvironmentStatusSnapshotCoordinator({ + db, + hub, + logger, + }); const lifecycleDedupers = createLifecycleDedupers(); const appUrl = toOptionalString(serverConfig.BB_APP_URL); const threadStorageRootPath = resolveThreadStorageRootPath({ @@ -163,7 +169,7 @@ export async function runServer(serverConfig: ServerConfig): Promise { const sweepInterval = setInterval(() => { void runPeriodicSweeps(sweepDeps); - }, 10_000); + }, 5_000); sweepInterval.unref(); let shutdownPromise: Promise | null = null; @@ -173,6 +179,7 @@ export async function runServer(serverConfig: ServerConfig): Promise { } shutdownPromise = (async () => { eventLoopStallMonitor.stop(); + environmentStatusSnapshots.dispose(); clearInterval(sweepInterval); const closeServer = new Promise((resolve, reject) => { server.close((error) => { diff --git a/apps/server/src/ws/watch-interests.ts b/apps/server/src/ws/watch-interests.ts index 817bd641c..8ef3861c7 100644 --- a/apps/server/src/ws/watch-interests.ts +++ b/apps/server/src/ws/watch-interests.ts @@ -1,4 +1,10 @@ -import { getEnvironment, getThread, type DbConnection } from "@bb/db"; +import { + getEnvironment, + getThread, + listEnvironmentSnapshotWorkspaceWatchTargetsOnHost, + listHosts, + type DbConnection, +} from "@bb/db"; import { realtimeSubscriptionTargetKey, type ChangedMessage, @@ -44,15 +50,30 @@ const WATCH_TARGET_THREAD_CHANGE_KINDS = new Set([ "thread-created", "thread-deleted", ]); +const DURABLE_WORKSPACE_WATCH_ENVIRONMENT_CHANGE_KINDS = new Set([ + "environment-created", + "environment-deleted", + "metadata-changed", + "status-changed", +]); +const DURABLE_WORKSPACE_WATCH_THREAD_CHANGE_KINDS = new Set([ + "archived-changed", + "environment-changed", + "thread-created", + "thread-deleted", +]); const THREAD_PROVISIONING_EVENT_TYPE = "system/thread-provisioning" satisfies ThreadEventType; -function emptyWatchSet(generation: number): HostDaemonWatchSet { - return { - generation, - workspaceTargets: [], - threadStorageTargets: [], - }; +function watchSetTargetsKey(watchSet: HostDaemonWatchSet): string { + return JSON.stringify({ + workspaceTargets: [...watchSet.workspaceTargets].sort((left, right) => + left.environmentId.localeCompare(right.environmentId), + ), + threadStorageTargets: [...watchSet.threadStorageTargets].sort( + (left, right) => left.threadId.localeCompare(right.threadId), + ), + }); } function isWatchableSubscriptionTarget( @@ -73,6 +94,7 @@ export class WatchInterestCoordinator { private readonly targetsByInterest = new Map(); private readonly generationByHost = new Map(); private readonly lastResolvedHostIdsByInterest = new Map>(); + private readonly lastSentWatchSetTargetsKeyByHost = new Map(); constructor(private readonly deps: WatchInterestCoordinatorDeps) { this.deps.hub.onChangedMessage((message) => { @@ -174,24 +196,31 @@ export class WatchInterestCoordinator { } reconcileWatchSetForHost(hostId: string): HostDaemonWatchSet { - return this.resolveWatchSetForHost({ + const watchSet = this.resolveWatchSetForHost({ generation: this.generationByHost.get(hostId) ?? 0, hostId, }); + this.lastSentWatchSetTargetsKeyByHost.set( + hostId, + watchSetTargetsKey(watchSet), + ); + return watchSet; } refreshWatchSetsForChangedMessage(message: ChangedMessage): void { const affectedInterestKeys = this.interestKeysForChangedMessage(message); - if (affectedInterestKeys.size === 0) { - return; - } - const affectedHostIds = new Set(); for (const key of affectedInterestKeys) { for (const hostId of this.hostIdsForInterestKey(key)) { affectedHostIds.add(hostId); } } + for (const hostId of this.hostIdsForDurableWorkspaceWatchChange(message)) { + affectedHostIds.add(hostId); + } + if (affectedHostIds.size === 0) { + return; + } this.sendSnapshotsForHosts(affectedHostIds); } @@ -201,11 +230,21 @@ export class WatchInterestCoordinator { private sendSnapshotsForHosts(hostIds: ReadonlySet): void { for (const hostId of hostIds) { + const nextWatchSet = this.resolveWatchSetForHost({ + generation: this.generationByHost.get(hostId) ?? 0, + hostId, + }); + const nextTargetsKey = watchSetTargetsKey(nextWatchSet); + if (this.lastSentWatchSetTargetsKeyByHost.get(hostId) === nextTargetsKey) { + continue; + } const generation = (this.generationByHost.get(hostId) ?? 0) + 1; this.generationByHost.set(hostId, generation); + this.lastSentWatchSetTargetsKeyByHost.set(hostId, nextTargetsKey); this.deps.hub.sendDaemonMessage(hostId, { type: "watch-set.replace", - ...this.resolveWatchSetForHost({ generation, hostId }), + ...nextWatchSet, + generation, }); } } @@ -230,10 +269,6 @@ export class WatchInterestCoordinator { generation: number; hostId: string; }): HostDaemonWatchSet { - if (this.targetsByInterest.size === 0) { - return emptyWatchSet(args.generation); - } - const workspaceTargets = new Map< string, HostDaemonWatchSetWorkspaceTarget @@ -267,6 +302,19 @@ export class WatchInterestCoordinator { } } + for (const target of listEnvironmentSnapshotWorkspaceWatchTargetsOnHost( + this.deps.db, + args.hostId, + )) { + workspaceTargets.set(target.environmentId, { + environmentId: target.environmentId, + workspaceContext: workspaceContextFromPath({ + path: target.path, + workspaceProvisionType: target.workspaceProvisionType, + }), + }); + } + return { generation: args.generation, workspaceTargets: [...workspaceTargets.values()], @@ -344,6 +392,35 @@ export class WatchInterestCoordinator { ); } + private hostIdsForDurableWorkspaceWatchChange( + message: ChangedMessage, + ): Set { + switch (message.entity) { + case "environment": + if ( + !message.changes.some((change) => + DURABLE_WORKSPACE_WATCH_ENVIRONMENT_CHANGE_KINDS.has(change), + ) + ) { + return new Set(); + } + return new Set(listHosts(this.deps.db).map((host) => host.id)); + case "thread": + if ( + !message.changes.some((change) => + DURABLE_WORKSPACE_WATCH_THREAD_CHANGE_KINDS.has(change), + ) + ) { + return new Set(); + } + return new Set(listHosts(this.deps.db).map((host) => host.id)); + case "project": + case "host": + case "system": + return new Set(); + } + } + private threadChangeCanAffectWatchTargets( changes: readonly ThreadChangeKind[], eventTypes: readonly ThreadEventType[] | undefined, diff --git a/apps/server/test/helpers/host-rpc.ts b/apps/server/test/helpers/host-rpc.ts index 0a24c0aff..dd26a9152 100644 --- a/apps/server/test/helpers/host-rpc.ts +++ b/apps/server/test/helpers/host-rpc.ts @@ -143,6 +143,9 @@ export function registerHostRpcResponder( close() {}, send(data) { const message = hostDaemonServerWsMessageSchema.parse(JSON.parse(data)); + if (message.type === "watch-set.replace") { + return; + } if (message.type !== "host-rpc.request") { throw new Error(`Unexpected daemon websocket message ${message.type}`); } diff --git a/apps/server/test/public/public-environment-action-regressions.test.ts b/apps/server/test/public/public-environment-action-regressions.test.ts index af798d259..5aea7456d 100644 --- a/apps/server/test/public/public-environment-action-regressions.test.ts +++ b/apps/server/test/public/public-environment-action-regressions.test.ts @@ -289,6 +289,16 @@ describe("public environment action regressions", () => { }); await reportQueuedCommandSuccess(harness, readyCommand, {}); + const refreshCommand = await waitForQueuedCommand( + harness, + ({ command }) => + command.type === "workspace.pull_request" && + command.environmentId === environment.id, + ); + await reportQueuedCommandSuccess(harness, refreshCommand, { + pullRequest: rawPullRequest(), + }); + const response = await responsePromise; expect(response.status).toBe(200); await expect(readJson(response)).resolves.toMatchObject({ @@ -346,6 +356,16 @@ describe("public environment action regressions", () => { }); await reportQueuedCommandSuccess(harness, draftCommand, {}); + const refreshCommand = await waitForQueuedCommand( + harness, + ({ command }) => + command.type === "workspace.pull_request" && + command.environmentId === environment.id, + ); + await reportQueuedCommandSuccess(harness, refreshCommand, { + pullRequest: rawPullRequest({ isDraft: true }), + }); + const response = await responsePromise; expect(response.status).toBe(200); await expect(readJson(response)).resolves.toMatchObject({ @@ -454,6 +474,16 @@ describe("public environment action regressions", () => { }); await reportQueuedCommandSuccess(harness, mergeCommand, {}); + const refreshCommand = await waitForQueuedCommand( + harness, + ({ command }) => + command.type === "workspace.pull_request" && + command.environmentId === environment.id, + ); + await reportQueuedCommandSuccess(harness, refreshCommand, { + pullRequest: rawPullRequest({ state: "MERGED" }), + }); + const response = await responsePromise; expect(response.status).toBe(200); await expect(readJson(response)).resolves.toMatchObject({ diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts index 281584b4e..13aeebc58 100644 --- a/apps/server/test/public/public-thread-data.test.ts +++ b/apps/server/test/public/public-thread-data.test.ts @@ -102,11 +102,20 @@ describe("public thread data routes", () => { it("embeds thread environment and host snapshots when requested", async () => { await withTestHarness(async (harness) => { - const { host, environment, thread } = seedThreadFixture(harness, { + const { host, environment, session, thread } = seedThreadFixture(harness, { session: { id: "host-thread-include", }, }); + const responder = registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle(request) { + throw new Error( + `Unexpected RPC command ${request.command.type} during thread read`, + ); + }, + }); const leanResponse = await harness.app.request( `/api/v1/threads/${thread.id}`, @@ -127,6 +136,7 @@ describe("public thread data routes", () => { expect(includedThread.environment?.id).toBe(environment.id); expect(includedThread.host?.id).toBe(host.id); expect(includedThread.host?.status).toBe("connected"); + expect(responder.requests).toEqual([]); }); }); diff --git a/apps/server/test/public/public-thread-terminals.test.ts b/apps/server/test/public/public-thread-terminals.test.ts index d9eda0c1b..65a2c791d 100644 --- a/apps/server/test/public/public-thread-terminals.test.ts +++ b/apps/server/test/public/public-thread-terminals.test.ts @@ -111,9 +111,12 @@ async function waitForDaemonMessage( messageIndex = 0, ): Promise { for (let attempt = 0; attempt < 20; attempt += 1) { - const message = socket.sentMessages[messageIndex]; + const nonWatchMessages = socket.sentMessages + .map((message) => hostDaemonServerWsMessageSchema.parse(JSON.parse(message))) + .filter((message) => message.type !== "watch-set.replace"); + const message = nonWatchMessages[messageIndex]; if (message !== undefined) { - return hostDaemonServerWsMessageSchema.parse(JSON.parse(message)); + return message; } await new Promise((resolve) => setTimeout(resolve, 5)); } diff --git a/apps/server/test/services/environments/environment-status-snapshots.test.ts b/apps/server/test/services/environments/environment-status-snapshots.test.ts new file mode 100644 index 000000000..8d30e2d4a --- /dev/null +++ b/apps/server/test/services/environments/environment-status-snapshots.test.ts @@ -0,0 +1,315 @@ +import { eq } from "drizzle-orm"; +import { describe, expect, it, vi } from "vitest"; +import { + environmentGitStatusSnapshots, + environmentPullRequestStatusSnapshots, +} from "@bb/db"; +import type { GitHostPullRequest, WorkspaceStatus } from "@bb/domain"; +import { + EnvironmentStatusSnapshotCoordinator, + refreshDueEnvironmentStatusSnapshots, +} from "../../../src/services/environments/environment-status-snapshots.js"; +import { registerHostRpcResponder } from "../../helpers/host-rpc.js"; +import { seedThreadFixture } from "../../helpers/seed.js"; +import { withTestHarness } from "../../helpers/test-app.js"; + +function workspaceStatusFixture(): WorkspaceStatus { + return { + workingTree: { + insertions: 3, + deletions: 1, + files: [ + { + path: "src/index.ts", + status: "M", + insertions: 3, + deletions: 1, + }, + ], + hasUncommittedChanges: true, + state: "dirty_and_committed_unmerged", + }, + branch: { + currentBranch: "feature/status-snapshots", + defaultBranch: "main", + }, + checkout: { + kind: "branch", + branchName: "feature/status-snapshots", + headSha: "abc123", + }, + mergeBase: { + insertions: 5, + deletions: 0, + files: [ + { + path: "README.md", + status: "A", + insertions: 5, + deletions: 0, + }, + ], + mergeBaseBranch: "main", + baseRef: "origin/main", + aheadCount: 1, + behindCount: 0, + hasCommittedUnmergedChanges: true, + commits: [ + { + sha: "abc123def456", + shortSha: "abc123d", + subject: "Add status snapshots", + authorName: "Test Author", + authoredAt: 1_000, + }, + ], + }, + }; +} + +function rawPullRequestFixture(): GitHostPullRequest { + return { + number: 42, + title: "Add status snapshots", + state: "OPEN", + url: "https://github.com/acme/bb/pull/42", + isDraft: false, + baseRefName: "main", + headRefName: "feature/status-snapshots", + updatedAt: "2026-06-16T12:30:00Z", + checks: [ + { + name: "test", + status: "completed", + conclusion: "success", + url: null, + }, + ], + reviewDecision: "APPROVED", + reviewRequestCount: 0, + mergeStateStatus: "CLEAN", + mergeable: "MERGEABLE", + }; +} + +function parseRequiredJson(value: string | null): unknown { + if (value === null) { + throw new Error("Expected JSON value"); + } + return JSON.parse(value); +} + +describe("environment status snapshots", () => { + it("refreshes due git and pull request snapshots and notifies attached threads", async () => { + await withTestHarness(async (harness) => { + const { environment, host, project, session, thread } = seedThreadFixture( + harness, + { + environment: { + path: "/tmp/status-snapshots", + workspaceProvisionType: "managed-worktree", + }, + }, + ); + const notifyThread = vi.spyOn(harness.hub, "notifyThread"); + const responder = registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle(request) { + if (request.command.type === "workspace.status") { + return { + ok: true, + result: { + outcome: "available", + workspaceStatus: workspaceStatusFixture(), + }, + }; + } + if (request.command.type === "workspace.pull_request") { + return { + ok: true, + result: { + pullRequest: rawPullRequestFixture(), + }, + }; + } + throw new Error(`Unexpected RPC command ${request.command.type}`); + }, + }); + + await refreshDueEnvironmentStatusSnapshots(harness.deps, 10_000); + + expect(responder.requests.map((request) => request.command.type)).toEqual( + ["workspace.status", "workspace.pull_request"], + ); + expect(notifyThread).toHaveBeenCalledTimes(2); + expect(notifyThread).toHaveBeenCalledWith( + thread.id, + ["environment-status-summary-changed"], + { projectId: project.id }, + ); + + const gitRow = harness.db + .select() + .from(environmentGitStatusSnapshots) + .where(eq(environmentGitStatusSnapshots.environmentId, environment.id)) + .get(); + expect(gitRow).toMatchObject({ + status: "available", + errorCode: null, + errorMessage: null, + }); + expect(gitRow?.refreshedAt).toEqual(expect.any(Number)); + expect(gitRow?.nextRefreshAt).toBe( + (gitRow?.refreshedAt ?? 0) + 5 * 60_000, + ); + expect(parseRequiredJson(gitRow?.gitStatusJson ?? null)).toMatchObject({ + checkout: { + kind: "branch", + branchName: "feature/status-snapshots", + headSha: "abc123", + }, + currentBranch: "feature/status-snapshots", + defaultBranch: "main", + hasChanges: true, + workingTree: { + fileCount: 1, + insertions: 3, + deletions: 1, + files: [{ path: "src/index.ts", status: "M" }], + hasUncommittedChanges: true, + state: "dirty_and_committed_unmerged", + }, + mergeBase: { + fileCount: 1, + insertions: 5, + deletions: 0, + files: [{ path: "README.md", status: "A" }], + aheadCount: 1, + behindCount: 0, + commitCount: 1, + hasCommittedUnmergedChanges: true, + mergeBaseBranch: "main", + }, + }); + + const pullRequestRow = harness.db + .select() + .from(environmentPullRequestStatusSnapshots) + .where( + eq( + environmentPullRequestStatusSnapshots.environmentId, + environment.id, + ), + ) + .get(); + expect(pullRequestRow).toMatchObject({ + status: "available", + errorCode: null, + errorMessage: null, + }); + expect(pullRequestRow?.refreshedAt).toEqual(expect.any(Number)); + expect(pullRequestRow?.nextRefreshAt).toBe( + (pullRequestRow?.refreshedAt ?? 0) + 30_000, + ); + expect( + parseRequiredJson(pullRequestRow?.pullRequestJson ?? null), + ).toMatchObject({ + number: 42, + title: "Add status snapshots", + state: "open", + checks: { + state: "passing", + totalCount: 1, + passedCount: 1, + failedCount: 0, + pendingCount: 0, + }, + review: { state: "approved", reviewRequestCount: 0 }, + mergeability: { + state: "mergeable", + mergeStateStatus: "CLEAN", + mergeable: "MERGEABLE", + }, + attention: "ready_to_merge", + }); + }); + }); + + it("marks both git and pull request snapshots due for workspace status changes", async () => { + await withTestHarness(async (harness) => { + const { environment } = seedThreadFixture(harness, { + environment: { + path: "/tmp/status-snapshots", + workspaceProvisionType: "managed-worktree", + }, + }); + const futureRefreshAt = Date.now() + 60_000; + harness.db + .insert(environmentGitStatusSnapshots) + .values({ + environmentId: environment.id, + status: "available", + gitStatusJson: JSON.stringify({ stale: true }), + errorCode: null, + errorMessage: null, + refreshedAt: 1, + nextRefreshAt: futureRefreshAt, + createdAt: 1, + updatedAt: 1, + }) + .run(); + harness.db + .insert(environmentPullRequestStatusSnapshots) + .values({ + environmentId: environment.id, + status: "available", + pullRequestJson: null, + errorCode: null, + errorMessage: null, + refreshedAt: 1, + nextRefreshAt: futureRefreshAt, + createdAt: 1, + updatedAt: 1, + }) + .run(); + + const coordinator = new EnvironmentStatusSnapshotCoordinator({ + db: harness.db, + hub: harness.hub, + logger: harness.deps.logger, + }); + try { + const beforeNotify = Date.now(); + harness.hub.notifyEnvironment(environment.id, ["work-status-changed"]); + + const gitRow = harness.db + .select() + .from(environmentGitStatusSnapshots) + .where( + eq(environmentGitStatusSnapshots.environmentId, environment.id), + ) + .get(); + const pullRequestRow = harness.db + .select() + .from(environmentPullRequestStatusSnapshots) + .where( + eq( + environmentPullRequestStatusSnapshots.environmentId, + environment.id, + ), + ) + .get(); + + expect(gitRow?.nextRefreshAt).toBeGreaterThanOrEqual(beforeNotify); + expect(gitRow?.nextRefreshAt).toBeLessThan(futureRefreshAt); + expect(pullRequestRow?.nextRefreshAt).toBeGreaterThanOrEqual( + beforeNotify, + ); + expect(pullRequestRow?.nextRefreshAt).toBeLessThan(futureRefreshAt); + } finally { + coordinator.dispose(); + } + }); + }); +}); diff --git a/apps/server/test/services/threads/thread-runtime-display.test.ts b/apps/server/test/services/threads/thread-runtime-display.test.ts index 69167761f..5ef3133b3 100644 --- a/apps/server/test/services/threads/thread-runtime-display.test.ts +++ b/apps/server/test/services/threads/thread-runtime-display.test.ts @@ -7,15 +7,24 @@ import { createEnvironment, createProject, createThread, + environmentGitStatusSnapshots, + environmentPullRequestStatusSnapshots, hostDaemonSessions, migrate, noopNotifier, openSession, upsertHost, + writeEnvironmentGitStatusSnapshot, + writeEnvironmentPullRequestStatusSnapshot, type DbConnection, type ThreadWithPendingInteractionState, } from "@bb/db"; -import type { Thread, ThreadRuntimeState } from "@bb/domain"; +import type { + Thread, + ThreadEnvironmentGitStatusSnapshot, + ThreadPullRequest, + ThreadRuntimeState, +} from "@bb/domain"; import { DAEMON_DISCONNECT_GRACE_MS } from "../../../src/constants.js"; import { resolveThreadRuntimeState, @@ -51,6 +60,7 @@ interface ThreadWithPinSortKey extends Thread { interface CreateThreadListEntryArgs { environmentHostId: string | null; + overrides?: Partial; thread: ThreadWithPinSortKey; } @@ -138,7 +148,71 @@ function createThreadListEntry( environmentHostId: args.environmentHostId, environmentName: null, environmentWorkspaceDisplayKind: "other", + gitStatusSnapshotJson: null, + gitStatusSnapshotErrorCode: null, + gitStatusSnapshotErrorMessage: null, + gitStatusSnapshotRefreshedAt: null, + gitStatusSnapshotStatus: null, hasPendingInteraction: false, + pullRequestStatusSnapshotJson: null, + pullRequestStatusSnapshotErrorCode: null, + pullRequestStatusSnapshotErrorMessage: null, + pullRequestStatusSnapshotRefreshedAt: null, + pullRequestStatusSnapshotStatus: null, + ...args.overrides, + }; +} + +function makeGitStatusSnapshot(): ThreadEnvironmentGitStatusSnapshot { + return { + checkout: { + kind: "branch", + branchName: "feature/status-signals", + headSha: "abc123", + }, + currentBranch: "feature/status-signals", + defaultBranch: "main", + hasChanges: true, + workingTree: { + fileCount: 1, + insertions: 12, + deletions: 3, + files: [ + { + path: "apps/app/src/components/sidebar/ThreadRow.tsx", + status: "M", + }, + ], + hasUncommittedChanges: true, + state: "dirty_uncommitted", + }, + mergeBase: null, + }; +} + +function makePullRequest(): ThreadPullRequest { + return { + number: 42, + title: "Show thread status signals", + state: "open", + url: "https://github.com/acme/bb/pull/42", + baseRefName: "main", + headRefName: "feature/status-signals", + updatedAt: "2026-01-01T00:00:00.000Z", + checks: { + state: "passing", + totalCount: 1, + passedCount: 1, + failedCount: 0, + pendingCount: 0, + }, + review: { state: "approved", reviewRequestCount: 0 }, + mergeability: { + state: "mergeable", + mergeStateStatus: "CLEAN", + mergeable: "MERGEABLE", + }, + attention: "ready_to_merge", }; } @@ -285,4 +359,108 @@ describe("thread runtime display", () => { }, ] satisfies ThreadRuntimeState[]); }); + + it("projects environment status snapshots into thread list entries", () => { + const { db, hostId } = setup(); + const now = 1_000; + const { thread } = createThreadWithEnvironment({ db, hostId }); + const gitStatusSnapshot = makeGitStatusSnapshot(); + const pullRequest = makePullRequest(); + + const [entry] = toThreadListEntryResponses( + { db }, + { + now, + threads: [ + createThreadListEntry({ + environmentHostId: hostId, + overrides: { + gitStatusSnapshotJson: JSON.stringify(gitStatusSnapshot), + gitStatusSnapshotRefreshedAt: now - 100, + gitStatusSnapshotStatus: "available", + pullRequestStatusSnapshotJson: JSON.stringify(pullRequest), + pullRequestStatusSnapshotRefreshedAt: now - 50, + pullRequestStatusSnapshotStatus: "available", + }, + thread, + }), + ], + }, + ); + + expect(entry?.environmentStatusSummary).toEqual({ + git: { + state: "available", + refreshedAt: now - 100, + snapshot: gitStatusSnapshot, + }, + pullRequest: { + state: "available", + refreshedAt: now - 50, + pullRequest, + }, + }); + }); + + it("marks demanded environment status snapshots due from thread list reads", () => { + const { db, hostId } = setup(); + const now = 1_000; + const future = now + 60_000; + const { environment, thread } = createThreadWithEnvironment({ db, hostId }); + writeEnvironmentGitStatusSnapshot(db, { + environmentId: environment.id, + status: "not_applicable", + gitStatusJson: null, + errorCode: null, + errorMessage: null, + refreshedAt: now - 100, + nextRefreshAt: future, + now: now - 100, + }); + writeEnvironmentPullRequestStatusSnapshot(db, { + environmentId: environment.id, + status: "not_applicable", + pullRequestJson: null, + errorCode: null, + errorMessage: null, + refreshedAt: now - 100, + nextRefreshAt: future, + now: now - 100, + }); + + toThreadListEntryResponses( + { db }, + { + now, + threads: [ + createThreadListEntry({ + environmentHostId: hostId, + thread, + }), + ], + }, + ); + + expect( + db + .select({ nextRefreshAt: environmentGitStatusSnapshots.nextRefreshAt }) + .from(environmentGitStatusSnapshots) + .where(eq(environmentGitStatusSnapshots.environmentId, environment.id)) + .get(), + ).toEqual({ nextRefreshAt: now }); + expect( + db + .select({ + nextRefreshAt: environmentPullRequestStatusSnapshots.nextRefreshAt, + }) + .from(environmentPullRequestStatusSnapshots) + .where( + eq( + environmentPullRequestStatusSnapshots.environmentId, + environment.id, + ), + ) + .get(), + ).toEqual({ nextRefreshAt: now }); + }); }); diff --git a/packages/db/drizzle/0048_lovely_mauler.sql b/packages/db/drizzle/0048_lovely_mauler.sql new file mode 100644 index 000000000..e806f1bd5 --- /dev/null +++ b/packages/db/drizzle/0048_lovely_mauler.sql @@ -0,0 +1,28 @@ +CREATE TABLE `environment_git_status_snapshots` ( + `environment_id` text PRIMARY KEY NOT NULL, + `status` text NOT NULL, + `git_status_json` text, + `error_code` text, + `error_message` text, + `refreshed_at` integer, + `next_refresh_at` integer NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `environment_git_status_snapshots_next_refresh_idx` ON `environment_git_status_snapshots` (`next_refresh_at`);--> statement-breakpoint +CREATE TABLE `environment_pull_request_status_snapshots` ( + `environment_id` text PRIMARY KEY NOT NULL, + `status` text NOT NULL, + `pull_request_json` text, + `error_code` text, + `error_message` text, + `refreshed_at` integer, + `next_refresh_at` integer NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `environment_pull_request_status_snapshots_next_refresh_idx` ON `environment_pull_request_status_snapshots` (`next_refresh_at`); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0048_snapshot.json b/packages/db/drizzle/meta/0048_snapshot.json new file mode 100644 index 000000000..e7da06d92 --- /dev/null +++ b/packages/db/drizzle/meta/0048_snapshot.json @@ -0,0 +1,3038 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e18967bb-6caa-4d12-965c-aad6d0142c0c", + "prevId": "fa5caec4-4ccb-4ecd-bdab-4a3efe5ad49a", + "tables": { + "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": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_mode": { + "name": "run_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "automation_runs_automation_started_idx": { + "name": "automation_runs_automation_started_idx", + "columns": [ + "automation_id", + "started_at" + ], + "isUnique": false + }, + "automation_runs_thread_idx": { + "name": "automation_runs_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_thread_id_threads_id_fk": { + "name": "automation_runs_thread_id_threads_id_fk", + "tableFrom": "automation_runs", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "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 + }, + "target_thread_id": { + "name": "target_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_config": { + "name": "trigger_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_mode": { + "name": "run_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "execution": { + "name": "execution", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_archive": { + "name": "auto_archive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_thread_id": { + "name": "created_by_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_thread_id": { + "name": "last_run_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "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": { + "automations_project_idx": { + "name": "automations_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "automations_due_idx": { + "name": "automations_due_idx", + "columns": [ + "enabled", + "trigger_type", + "next_run_at" + ], + "isUnique": false + }, + "automations_target_thread_idx": { + "name": "automations_target_thread_idx", + "columns": [ + "target_thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automations_project_id_projects_id_fk": { + "name": "automations_project_id_projects_id_fk", + "tableFrom": "automations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automations_target_thread_id_threads_id_fk": { + "name": "automations_target_thread_id_threads_id_fk", + "tableFrom": "automations", + "tableTo": "threads", + "columnsFrom": [ + "target_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_git_status_snapshots": { + "name": "environment_git_status_snapshots", + "columns": { + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status_json": { + "name": "git_status_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshed_at": { + "name": "refreshed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_refresh_at": { + "name": "next_refresh_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": { + "environment_git_status_snapshots_next_refresh_idx": { + "name": "environment_git_status_snapshots_next_refresh_idx", + "columns": [ + "next_refresh_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "environment_git_status_snapshots_environment_id_environments_id_fk": { + "name": "environment_git_status_snapshots_environment_id_environments_id_fk", + "tableFrom": "environment_git_status_snapshots", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_pull_request_status_snapshots": { + "name": "environment_pull_request_status_snapshots", + "columns": { + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pull_request_json": { + "name": "pull_request_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshed_at": { + "name": "refreshed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_refresh_at": { + "name": "next_refresh_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": { + "environment_pull_request_status_snapshots_next_refresh_idx": { + "name": "environment_pull_request_status_snapshots_next_refresh_idx", + "columns": [ + "next_refresh_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "environment_pull_request_status_snapshots_environment_id_environments_id_fk": { + "name": "environment_pull_request_status_snapshots_environment_id_environments_id_fk", + "tableFrom": "environment_pull_request_status_snapshots", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "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_host_path_idx": { + "name": "environments_host_path_idx", + "columns": [ + "host_id", + "path" + ], + "isUnique": true + }, + "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_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'" + } + }, + "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 + }, + "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 + }, + "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": {} + }, + "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 + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "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 + }, + "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 + } + }, + "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": {} + }, + "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 + }, + "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": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "claude_code_mock_cli_traffic": { + "name": "claude_code_mock_cli_traffic", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "popout_chat": { + "name": "popout_chat", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "popout_chat_hotkey": { + "name": "popout_chat_hotkey", + "type": "text", + "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_folders": { + "name": "thread_folders", + "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_folders_name_idx": { + "name": "thread_folders_name_idx", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "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": {} + }, + "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 + }, + "folder_id": { + "name": "folder_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 + }, + "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_folder_archived_deleted_idx": { + "name": "threads_folder_archived_deleted_idx", + "columns": [ + "folder_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_folder_id_thread_folders_id_fk": { + "name": "threads_folder_id_thread_folders_id_fk", + "tableFrom": "threads", + "tableTo": "thread_folders", + "columnsFrom": [ + "folder_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 30e38546f..1f18bc386 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -337,6 +337,13 @@ "when": 1782273194188, "tag": "0047_sharp_martin_li", "breakpoints": true + }, + { + "idx": 48, + "version": "6", + "when": 1782354405203, + "tag": "0048_lovely_mauler", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/data/environment-status-snapshots.ts b/packages/db/src/data/environment-status-snapshots.ts new file mode 100644 index 000000000..500c87ce8 --- /dev/null +++ b/packages/db/src/data/environment-status-snapshots.ts @@ -0,0 +1,426 @@ +import { and, asc, eq, inArray, isNull, lte, ne } from "drizzle-orm"; +import type { WorkspaceProvisionType } from "@bb/domain"; +import type { DbConnection, DbTransaction } from "../connection.js"; +import { + environmentGitStatusSnapshots, + environmentPullRequestStatusSnapshots, + environments, + threads, +} from "../schema.js"; + +type SnapshotReadConnection = DbConnection | DbTransaction; +type SnapshotWriteConnection = DbConnection | DbTransaction; + +export type EnvironmentGitStatusSnapshotRow = + typeof environmentGitStatusSnapshots.$inferSelect; +export type EnvironmentPullRequestStatusSnapshotRow = + typeof environmentPullRequestStatusSnapshots.$inferSelect; + +export type EnvironmentStatusSnapshotStatus = + | "available" + | "not_applicable" + | "unavailable"; + +interface EnsureEnvironmentStatusSnapshotRowsArgs { + environmentIds: readonly string[]; + now: number; +} + +interface DueEnvironmentStatusSnapshotsArgs { + limit: number; + now: number; +} + +interface MarkEnvironmentStatusSnapshotDueArgs { + environmentId: string; + now: number; +} + +interface MarkEnvironmentStatusSnapshotsDueArgs { + environmentIds: readonly string[]; + now: number; +} + +interface WriteSnapshotArgs { + environmentId: string; + errorCode?: string | null; + errorMessage?: string | null; + nextRefreshAt: number; + now: number; + refreshedAt: number; + status: EnvironmentStatusSnapshotStatus; +} + +export interface WriteEnvironmentGitStatusSnapshotArgs + extends WriteSnapshotArgs { + gitStatusJson?: string | null; +} + +export interface WriteEnvironmentPullRequestStatusSnapshotArgs + extends WriteSnapshotArgs { + pullRequestJson?: string | null; +} + +export interface EnvironmentSnapshotWorkspaceWatchTarget { + environmentId: string; + path: string; + workspaceProvisionType: WorkspaceProvisionType; +} + +export interface EnvironmentThreadNotificationTarget { + projectId: string; + threadId: string; +} + +function uniqueEnvironmentIds(environmentIds: readonly string[]): string[] { + return [...new Set(environmentIds.filter((id) => id.length > 0))]; +} + +function visibleGitSnapshotChanged( + existing: EnvironmentGitStatusSnapshotRow | null, + next: WriteEnvironmentGitStatusSnapshotArgs, +): boolean { + return ( + existing === null || + existing.status !== next.status || + existing.gitStatusJson !== (next.gitStatusJson ?? null) || + existing.errorCode !== (next.errorCode ?? null) || + existing.errorMessage !== (next.errorMessage ?? null) + ); +} + +function visiblePullRequestSnapshotChanged( + existing: EnvironmentPullRequestStatusSnapshotRow | null, + next: WriteEnvironmentPullRequestStatusSnapshotArgs, +): boolean { + return ( + existing === null || + existing.status !== next.status || + existing.pullRequestJson !== (next.pullRequestJson ?? null) || + existing.errorCode !== (next.errorCode ?? null) || + existing.errorMessage !== (next.errorMessage ?? null) + ); +} + +export function ensureEnvironmentStatusSnapshotRows( + db: SnapshotWriteConnection, + args: EnsureEnvironmentStatusSnapshotRowsArgs, +): void { + const environmentIds = uniqueEnvironmentIds(args.environmentIds); + if (environmentIds.length === 0) { + return; + } + + for (const environmentId of environmentIds) { + db.insert(environmentGitStatusSnapshots) + .values({ + environmentId, + status: "pending", + gitStatusJson: null, + errorCode: null, + errorMessage: null, + refreshedAt: null, + nextRefreshAt: args.now, + createdAt: args.now, + updatedAt: args.now, + }) + .onConflictDoNothing() + .run(); + + db.insert(environmentPullRequestStatusSnapshots) + .values({ + environmentId, + status: "pending", + pullRequestJson: null, + errorCode: null, + errorMessage: null, + refreshedAt: null, + nextRefreshAt: args.now, + createdAt: args.now, + updatedAt: args.now, + }) + .onConflictDoNothing() + .run(); + } +} + +export function ensureTrackedEnvironmentStatusSnapshotRows( + db: SnapshotWriteConnection, + args: { now: number }, +): void { + ensureEnvironmentStatusSnapshotRows(db, { + now: args.now, + environmentIds: listTrackedEnvironmentIds(db), + }); +} + +export function listTrackedEnvironmentIds( + db: SnapshotReadConnection, +): string[] { + return db + .select({ environmentId: threads.environmentId }) + .from(threads) + .innerJoin(environments, eq(threads.environmentId, environments.id)) + .where( + and( + isNull(threads.archivedAt), + isNull(threads.deletedAt), + ne(environments.status, "destroyed"), + ), + ) + .groupBy(threads.environmentId) + .all() + .flatMap((row) => (row.environmentId ? [row.environmentId] : [])); +} + +export function listEnvironmentSnapshotWorkspaceWatchTargetsOnHost( + db: SnapshotReadConnection, + hostId: string, +): EnvironmentSnapshotWorkspaceWatchTarget[] { + return db + .select({ + environmentId: environments.id, + path: environments.path, + workspaceProvisionType: environments.workspaceProvisionType, + }) + .from(threads) + .innerJoin(environments, eq(threads.environmentId, environments.id)) + .where( + and( + eq(environments.hostId, hostId), + eq(environments.status, "ready"), + eq(environments.isGitRepo, true), + isNull(threads.archivedAt), + isNull(threads.deletedAt), + ), + ) + .groupBy(environments.id) + .all() + .flatMap((row) => + row.path + ? [ + { + environmentId: row.environmentId, + path: row.path, + workspaceProvisionType: row.workspaceProvisionType, + }, + ] + : [], + ); +} + +export function listEnvironmentThreadNotificationTargets( + db: SnapshotReadConnection, + environmentId: string, +): EnvironmentThreadNotificationTarget[] { + return db + .select({ + projectId: threads.projectId, + threadId: threads.id, + }) + .from(threads) + .where( + and( + eq(threads.environmentId, environmentId), + isNull(threads.archivedAt), + isNull(threads.deletedAt), + ), + ) + .all(); +} + +export function listDueEnvironmentGitStatusSnapshots( + db: SnapshotReadConnection, + args: DueEnvironmentStatusSnapshotsArgs, +): EnvironmentGitStatusSnapshotRow[] { + return db + .select() + .from(environmentGitStatusSnapshots) + .where(lte(environmentGitStatusSnapshots.nextRefreshAt, args.now)) + .orderBy( + asc(environmentGitStatusSnapshots.nextRefreshAt), + asc(environmentGitStatusSnapshots.environmentId), + ) + .limit(args.limit) + .all(); +} + +export function listDueEnvironmentPullRequestStatusSnapshots( + db: SnapshotReadConnection, + args: DueEnvironmentStatusSnapshotsArgs, +): EnvironmentPullRequestStatusSnapshotRow[] { + return db + .select() + .from(environmentPullRequestStatusSnapshots) + .where(lte(environmentPullRequestStatusSnapshots.nextRefreshAt, args.now)) + .orderBy( + asc(environmentPullRequestStatusSnapshots.nextRefreshAt), + asc(environmentPullRequestStatusSnapshots.environmentId), + ) + .limit(args.limit) + .all(); +} + +export function getEnvironmentGitStatusSnapshot( + db: SnapshotReadConnection, + environmentId: string, +): EnvironmentGitStatusSnapshotRow | null { + return ( + db + .select() + .from(environmentGitStatusSnapshots) + .where(eq(environmentGitStatusSnapshots.environmentId, environmentId)) + .get() ?? null + ); +} + +export function getEnvironmentPullRequestStatusSnapshot( + db: SnapshotReadConnection, + environmentId: string, +): EnvironmentPullRequestStatusSnapshotRow | null { + return ( + db + .select() + .from(environmentPullRequestStatusSnapshots) + .where( + eq(environmentPullRequestStatusSnapshots.environmentId, environmentId), + ) + .get() ?? null + ); +} + +export function markEnvironmentGitStatusSnapshotDue( + db: SnapshotWriteConnection, + args: MarkEnvironmentStatusSnapshotDueArgs, +): void { + db.update(environmentGitStatusSnapshots) + .set({ nextRefreshAt: args.now, updatedAt: args.now }) + .where(eq(environmentGitStatusSnapshots.environmentId, args.environmentId)) + .run(); +} + +export function markEnvironmentPullRequestStatusSnapshotDue( + db: SnapshotWriteConnection, + args: MarkEnvironmentStatusSnapshotDueArgs, +): void { + db.update(environmentPullRequestStatusSnapshots) + .set({ nextRefreshAt: args.now, updatedAt: args.now }) + .where( + eq( + environmentPullRequestStatusSnapshots.environmentId, + args.environmentId, + ), + ) + .run(); +} + +export function markEnvironmentStatusSnapshotsDue( + db: SnapshotWriteConnection, + args: MarkEnvironmentStatusSnapshotsDueArgs, +): void { + const environmentIds = uniqueEnvironmentIds(args.environmentIds); + if (environmentIds.length === 0) { + return; + } + + db.update(environmentGitStatusSnapshots) + .set({ nextRefreshAt: args.now, updatedAt: args.now }) + .where(inArray(environmentGitStatusSnapshots.environmentId, environmentIds)) + .run(); + db.update(environmentPullRequestStatusSnapshots) + .set({ nextRefreshAt: args.now, updatedAt: args.now }) + .where( + inArray( + environmentPullRequestStatusSnapshots.environmentId, + environmentIds, + ), + ) + .run(); +} + +export function writeEnvironmentGitStatusSnapshot( + db: SnapshotWriteConnection, + args: WriteEnvironmentGitStatusSnapshotArgs, +): boolean { + const existing = + db + .select() + .from(environmentGitStatusSnapshots) + .where(eq(environmentGitStatusSnapshots.environmentId, args.environmentId)) + .get() ?? null; + const changed = visibleGitSnapshotChanged(existing, args); + + db.insert(environmentGitStatusSnapshots) + .values({ + environmentId: args.environmentId, + status: args.status, + gitStatusJson: args.gitStatusJson ?? null, + errorCode: args.errorCode ?? null, + errorMessage: args.errorMessage ?? null, + refreshedAt: args.refreshedAt, + nextRefreshAt: args.nextRefreshAt, + createdAt: args.now, + updatedAt: args.now, + }) + .onConflictDoUpdate({ + target: environmentGitStatusSnapshots.environmentId, + set: { + status: args.status, + gitStatusJson: args.gitStatusJson ?? null, + errorCode: args.errorCode ?? null, + errorMessage: args.errorMessage ?? null, + refreshedAt: args.refreshedAt, + nextRefreshAt: args.nextRefreshAt, + updatedAt: args.now, + }, + }) + .run(); + + return changed; +} + +export function writeEnvironmentPullRequestStatusSnapshot( + db: SnapshotWriteConnection, + args: WriteEnvironmentPullRequestStatusSnapshotArgs, +): boolean { + const existing = + db + .select() + .from(environmentPullRequestStatusSnapshots) + .where( + eq( + environmentPullRequestStatusSnapshots.environmentId, + args.environmentId, + ), + ) + .get() ?? null; + const changed = visiblePullRequestSnapshotChanged(existing, args); + + db.insert(environmentPullRequestStatusSnapshots) + .values({ + environmentId: args.environmentId, + status: args.status, + pullRequestJson: args.pullRequestJson ?? null, + errorCode: args.errorCode ?? null, + errorMessage: args.errorMessage ?? null, + refreshedAt: args.refreshedAt, + nextRefreshAt: args.nextRefreshAt, + createdAt: args.now, + updatedAt: args.now, + }) + .onConflictDoUpdate({ + target: environmentPullRequestStatusSnapshots.environmentId, + set: { + status: args.status, + pullRequestJson: args.pullRequestJson ?? null, + errorCode: args.errorCode ?? null, + errorMessage: args.errorMessage ?? null, + refreshedAt: args.refreshedAt, + nextRefreshAt: args.nextRefreshAt, + updatedAt: args.now, + }, + }) + .run(); + + return changed; +} diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 48ddcad50..5296fd1f8 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -220,6 +220,32 @@ export type { UpdateEnvironmentMetadataInput, } from "./environments.js"; +export { + ensureEnvironmentStatusSnapshotRows, + ensureTrackedEnvironmentStatusSnapshotRows, + getEnvironmentGitStatusSnapshot, + getEnvironmentPullRequestStatusSnapshot, + listDueEnvironmentGitStatusSnapshots, + listDueEnvironmentPullRequestStatusSnapshots, + listEnvironmentSnapshotWorkspaceWatchTargetsOnHost, + listEnvironmentThreadNotificationTargets, + listTrackedEnvironmentIds, + markEnvironmentGitStatusSnapshotDue, + markEnvironmentPullRequestStatusSnapshotDue, + markEnvironmentStatusSnapshotsDue, + writeEnvironmentGitStatusSnapshot, + writeEnvironmentPullRequestStatusSnapshot, +} from "./environment-status-snapshots.js"; +export type { + EnvironmentGitStatusSnapshotRow, + EnvironmentPullRequestStatusSnapshotRow, + EnvironmentSnapshotWorkspaceWatchTarget, + EnvironmentStatusSnapshotStatus, + EnvironmentThreadNotificationTarget, + WriteEnvironmentGitStatusSnapshotArgs, + WriteEnvironmentPullRequestStatusSnapshotArgs, +} from "./environment-status-snapshots.js"; + export { upsertHost, getHost, diff --git a/packages/db/src/data/threads.ts b/packages/db/src/data/threads.ts index 38eb12dc8..2347f4eab 100644 --- a/packages/db/src/data/threads.ts +++ b/packages/db/src/data/threads.ts @@ -33,6 +33,8 @@ import type { DbConnection, DbTransaction } from "../connection.js"; import type { DbQueryConnection } from "../connection.js"; import type { DbNotifier } from "../notifier.js"; import { + environmentGitStatusSnapshots, + environmentPullRequestStatusSnapshots, environments, pendingInteractions, threadSearchSegments, @@ -469,10 +471,33 @@ function threadWithPendingInteractionBaseQuery(db: DbConnection) { environmentIsWorktree: environments.isWorktree, environmentName: environments.name, environmentWorkspaceProvisionType: environments.workspaceProvisionType, + gitStatusSnapshotJson: environmentGitStatusSnapshots.gitStatusJson, + gitStatusSnapshotErrorCode: environmentGitStatusSnapshots.errorCode, + gitStatusSnapshotErrorMessage: environmentGitStatusSnapshots.errorMessage, + gitStatusSnapshotRefreshedAt: environmentGitStatusSnapshots.refreshedAt, + gitStatusSnapshotStatus: environmentGitStatusSnapshots.status, + pullRequestStatusSnapshotJson: + environmentPullRequestStatusSnapshots.pullRequestJson, + pullRequestStatusSnapshotErrorCode: + environmentPullRequestStatusSnapshots.errorCode, + pullRequestStatusSnapshotErrorMessage: + environmentPullRequestStatusSnapshots.errorMessage, + pullRequestStatusSnapshotRefreshedAt: + environmentPullRequestStatusSnapshots.refreshedAt, + pullRequestStatusSnapshotStatus: + environmentPullRequestStatusSnapshots.status, pendingInteractionCount: count(pendingInteractions.id), }) .from(threads) .leftJoin(environments, eq(threads.environmentId, environments.id)) + .leftJoin( + environmentGitStatusSnapshots, + eq(environmentGitStatusSnapshots.environmentId, environments.id), + ) + .leftJoin( + environmentPullRequestStatusSnapshots, + eq(environmentPullRequestStatusSnapshots.environmentId, environments.id), + ) .leftJoin( pendingInteractions, and( @@ -517,6 +542,16 @@ export interface ThreadWithPendingInteractionState extends ThreadRow { environmentName: string | null; hasPendingInteraction: boolean; environmentWorkspaceDisplayKind: EnvironmentWorkspaceDisplayKind; + gitStatusSnapshotJson: string | null; + gitStatusSnapshotErrorCode: string | null; + gitStatusSnapshotErrorMessage: string | null; + gitStatusSnapshotRefreshedAt: number | null; + gitStatusSnapshotStatus: string | null; + pullRequestStatusSnapshotJson: string | null; + pullRequestStatusSnapshotErrorCode: string | null; + pullRequestStatusSnapshotErrorMessage: string | null; + pullRequestStatusSnapshotRefreshedAt: number | null; + pullRequestStatusSnapshotStatus: string | null; } interface ThreadWithPendingInteractionStateRow extends ThreadRow { @@ -525,7 +560,17 @@ interface ThreadWithPendingInteractionStateRow extends ThreadRow { environmentIsWorktree: boolean | null; environmentName: string | null; environmentWorkspaceProvisionType: WorkspaceProvisionType | null; + gitStatusSnapshotJson: string | null; + gitStatusSnapshotErrorCode: string | null; + gitStatusSnapshotErrorMessage: string | null; + gitStatusSnapshotRefreshedAt: number | null; + gitStatusSnapshotStatus: string | null; pendingInteractionCount: number; + pullRequestStatusSnapshotJson: string | null; + pullRequestStatusSnapshotErrorCode: string | null; + pullRequestStatusSnapshotErrorMessage: string | null; + pullRequestStatusSnapshotRefreshedAt: number | null; + pullRequestStatusSnapshotStatus: string | null; } export interface CountLiveThreadsInEnvironmentArgs { @@ -726,7 +771,17 @@ function toThreadWithPendingInteractionState( environmentBranchName, environmentHostId, environmentName, + gitStatusSnapshotJson, + gitStatusSnapshotErrorCode, + gitStatusSnapshotErrorMessage, + gitStatusSnapshotRefreshedAt, + gitStatusSnapshotStatus, pendingInteractionCount, + pullRequestStatusSnapshotJson, + pullRequestStatusSnapshotErrorCode, + pullRequestStatusSnapshotErrorMessage, + pullRequestStatusSnapshotRefreshedAt, + pullRequestStatusSnapshotStatus, ...thread } = row; return { @@ -740,7 +795,17 @@ function toThreadWithPendingInteractionState( workspaceProvisionType: environmentWorkspaceProvisionType, }, }), + gitStatusSnapshotJson, + gitStatusSnapshotErrorCode, + gitStatusSnapshotErrorMessage, + gitStatusSnapshotRefreshedAt, + gitStatusSnapshotStatus, hasPendingInteraction: pendingInteractionCount > 0, + pullRequestStatusSnapshotJson, + pullRequestStatusSnapshotErrorCode, + pullRequestStatusSnapshotErrorMessage, + pullRequestStatusSnapshotRefreshedAt, + pullRequestStatusSnapshotStatus, }; } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index a5222261b..4849af80c 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -242,6 +242,50 @@ export const environments = sqliteTable( ], ); +export const environmentGitStatusSnapshots = sqliteTable( + "environment_git_status_snapshots", + { + environmentId: text("environment_id") + .primaryKey() + .references(() => environments.id, { onDelete: "cascade" }), + status: text("status").notNull(), + gitStatusJson: text("git_status_json"), + errorCode: text("error_code"), + errorMessage: text("error_message"), + refreshedAt: integer("refreshed_at"), + nextRefreshAt: integer("next_refresh_at").notNull(), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (table) => [ + index("environment_git_status_snapshots_next_refresh_idx").on( + table.nextRefreshAt, + ), + ], +); + +export const environmentPullRequestStatusSnapshots = sqliteTable( + "environment_pull_request_status_snapshots", + { + environmentId: text("environment_id") + .primaryKey() + .references(() => environments.id, { onDelete: "cascade" }), + status: text("status").notNull(), + pullRequestJson: text("pull_request_json"), + errorCode: text("error_code"), + errorMessage: text("error_message"), + refreshedAt: integer("refreshed_at"), + nextRefreshAt: integer("next_refresh_at").notNull(), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (table) => [ + index("environment_pull_request_status_snapshots_next_refresh_idx").on( + table.nextRefreshAt, + ), + ], +); + export const threads = sqliteTable( "threads", { diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 7ef112f8f..2647541f7 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -206,9 +206,16 @@ const latestMigrationWhen = Math.max( function dropRewindAddedTables(db: DbConnection): void { // Several tests migrate to head, rewind the schema to a legacy state, then // re-apply forward. Tables added by recent migrations must be dropped as part - // of that rewind so the forward re-migrate can re-create them: the automations - // tables (added by 0039/0041), app_theme (added by 0042), and the thread - // folder schema (thread folder columns + thread_folders table). + // of that rewind so the forward re-migrate can re-create them: the environment + // status snapshot tables, the automations tables (added by 0039/0041), + // app_theme (added by 0042), and the thread folder schema (thread folder + // columns + thread_folders table). + db.$client + .prepare("DROP TABLE IF EXISTS environment_pull_request_status_snapshots") + .run(); + db.$client + .prepare("DROP TABLE IF EXISTS environment_git_status_snapshots") + .run(); db.$client.prepare("DROP TABLE IF EXISTS automation_runs").run(); db.$client.prepare("DROP TABLE IF EXISTS automations").run(); db.$client.prepare("DROP TABLE IF EXISTS app_theme").run(); diff --git a/packages/domain/src/change-kinds.ts b/packages/domain/src/change-kinds.ts index d5e2a3105..5bcf349bc 100644 --- a/packages/domain/src/change-kinds.ts +++ b/packages/domain/src/change-kinds.ts @@ -17,6 +17,7 @@ export const THREAD_CHANGE_KINDS = [ "pin-state-changed", "parent-changed", "environment-changed", + "environment-status-summary-changed", "read-state-changed", "order-changed", "terminals-changed", diff --git a/packages/domain/src/thread.ts b/packages/domain/src/thread.ts index 9af55da44..8f8365242 100644 --- a/packages/domain/src/thread.ts +++ b/packages/domain/src/thread.ts @@ -355,6 +355,131 @@ export const threadPullRequestSchema = z .strict(); export type ThreadPullRequest = z.infer; +export const environmentStatusUnavailableReasonSchema = z + .object({ + code: z.string(), + message: z.string(), + }) + .strict(); +export type EnvironmentStatusUnavailableReason = z.infer< + typeof environmentStatusUnavailableReasonSchema +>; + +export const threadEnvironmentGitStatusFileSchema = z + .object({ + path: z.string(), + status: workspaceFileStatusKindSchema, + }) + .strict(); +export type ThreadEnvironmentGitStatusFile = z.infer< + typeof threadEnvironmentGitStatusFileSchema +>; + +export const threadEnvironmentGitStatusSectionSchema = z + .object({ + fileCount: z.number().int().nonnegative(), + insertions: z.number().int().nonnegative(), + deletions: z.number().int().nonnegative(), + files: z.array(threadEnvironmentGitStatusFileSchema), + }) + .strict(); +export type ThreadEnvironmentGitStatusSection = z.infer< + typeof threadEnvironmentGitStatusSectionSchema +>; + +export const threadEnvironmentGitStatusSnapshotSchema = z + .object({ + checkout: gitCheckoutRefSchema, + currentBranch: z.string().nullable(), + defaultBranch: z.string(), + hasChanges: z.boolean(), + workingTree: threadEnvironmentGitStatusSectionSchema.extend({ + hasUncommittedChanges: z.boolean(), + state: workspaceStateSchema, + }), + mergeBase: threadEnvironmentGitStatusSectionSchema + .extend({ + aheadCount: z.number().int().nonnegative(), + behindCount: z.number().int().nonnegative(), + commitCount: z.number().int().nonnegative(), + hasCommittedUnmergedChanges: z.boolean(), + mergeBaseBranch: z.string(), + }) + .nullable(), + }) + .strict(); +export type ThreadEnvironmentGitStatusSnapshot = z.infer< + typeof threadEnvironmentGitStatusSnapshotSchema +>; + +export const threadEnvironmentGitStatusSignalSchema = z.discriminatedUnion( + "state", + [ + z.object({ state: z.literal("pending") }).strict(), + z + .object({ + state: z.literal("not_applicable"), + refreshedAt: z.number(), + }) + .strict(), + z + .object({ + state: z.literal("unavailable"), + refreshedAt: z.number(), + reason: environmentStatusUnavailableReasonSchema, + }) + .strict(), + z + .object({ + state: z.literal("available"), + refreshedAt: z.number(), + snapshot: threadEnvironmentGitStatusSnapshotSchema, + }) + .strict(), + ], +); +export type ThreadEnvironmentGitStatusSignal = z.infer< + typeof threadEnvironmentGitStatusSignalSchema +>; + +export const threadEnvironmentPullRequestStatusSignalSchema = + z.discriminatedUnion("state", [ + z.object({ state: z.literal("pending") }).strict(), + z + .object({ + state: z.literal("not_applicable"), + refreshedAt: z.number(), + }) + .strict(), + z + .object({ + state: z.literal("unavailable"), + refreshedAt: z.number(), + reason: environmentStatusUnavailableReasonSchema, + }) + .strict(), + z + .object({ + state: z.literal("available"), + refreshedAt: z.number(), + pullRequest: threadPullRequestSchema.nullable(), + }) + .strict(), + ]); +export type ThreadEnvironmentPullRequestStatusSignal = z.infer< + typeof threadEnvironmentPullRequestStatusSignalSchema +>; + +export const threadEnvironmentStatusSummarySchema = z + .object({ + git: threadEnvironmentGitStatusSignalSchema, + pullRequest: threadEnvironmentPullRequestStatusSignalSchema, + }) + .strict(); +export type ThreadEnvironmentStatusSummary = z.infer< + typeof threadEnvironmentStatusSummarySchema +>; + export const threadQueuedMessageSchema = z.object({ id: z.string(), content: z.array(promptInputSchema).min(1), @@ -405,5 +530,6 @@ export const threadListEntrySchema = threadWithRuntimeSchema.extend({ environmentName: z.string().nullable(), environmentBranchName: z.string().nullable(), environmentWorkspaceDisplayKind: environmentWorkspaceDisplayKindSchema, + environmentStatusSummary: threadEnvironmentStatusSummarySchema, }); export type ThreadListEntry = z.infer; diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 036c99401..a137cc753 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -12,6 +12,7 @@ import { resolvedThreadExecutionOptionsSchema, serviceTierSchema, threadChildOriginSchema, + threadEnvironmentStatusSummarySchema, threadOriginKindSchema, threadListEntrySchema, threadQueuedMessageSchema, @@ -272,6 +273,7 @@ export type ThreadSearchResponse = z.infer; // depth cap. export const threadResponseSchema = threadWithRuntimeSchema.extend({ canSpawnChild: z.boolean(), + environmentStatusSummary: threadEnvironmentStatusSummarySchema, }); export type ThreadResponse = z.infer; diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index 966259edc..b4a4590e9 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -797,6 +797,10 @@ describe("server-contract canonical schemas", () => { environmentName: null, environmentBranchName: "bb/test", environmentWorkspaceDisplayKind: "managed-worktree", + environmentStatusSummary: { + git: { state: "not_applicable", refreshedAt: 2 }, + pullRequest: { state: "not_applicable", refreshedAt: 2 }, + }, }, ]), ).toMatchObject([