From c18b22b8d7afc6b23bda615efbb717416da2d48f Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 20 Aug 2026 16:06:03 -0700 Subject: [PATCH 1/2] Resolve thread storage locations without listing --- .../cache-owners/cache-owner-registry.test.ts | 3 + .../cache-owners/mutation-cache-effects.ts | 4 + .../cache-owners/realtime-cache-registry.ts | 4 + apps/app/src/hooks/queries/query-keys.ts | 18 +++ .../src/hooks/queries/thread-queries.test.tsx | 27 +++++ apps/app/src/hooks/queries/thread-queries.ts | 18 +++ .../hooks/useResolvedLiveFileTarget.test.tsx | 113 ++++++++++++++++++ .../src/hooks/useResolvedLiveFileTarget.ts | 76 +++++------- apps/server/src/routes/threads/data.ts | 10 ++ .../test/public/public-thread-data.test.ts | 32 +++++ packages/sdk/src/areas/threads.ts | 13 ++ packages/sdk/test/public-types.test.ts | 1 + packages/server-contract/src/api/threads.ts | 10 ++ packages/server-contract/src/public-api.ts | 7 ++ .../server-contract/test/contract.test.ts | 5 + 15 files changed, 295 insertions(+), 46 deletions(-) create mode 100644 apps/app/src/hooks/useResolvedLiveFileTarget.test.tsx diff --git a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts index ae3c0621b1..358adb6558 100644 --- a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts +++ b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts @@ -102,6 +102,7 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "threadQueuedMessagesQueryKey", "threadStorageFilePreviewQueryKeyPrefix", "threadStorageFilesForThreadQueryKeyPrefix", + "threadStorageLocationQueryKey", "threadStoragePathsForThreadQueryKeyPrefix", "threadTimelineQueryKeyPrefix", "threadTimelineTurnSummaryDetailsQueryKeyPrefix", @@ -151,6 +152,7 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "allSystemProvidersQueryKeyPrefix", "allThreadStorageFilePreviewQueryKeyPrefix", "allThreadStorageFilesQueryKeyPrefix", + "allThreadStorageLocationsQueryKeyPrefix", "allThreadStoragePathsQueryKeyPrefix", "allThreadQueryKeyPrefix", "allTerminalsQueryKeyPrefix", @@ -167,6 +169,7 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "threadSearchQueryKeyPrefix", "threadStorageFilePreviewQueryKeyPrefix", "threadStorageFilesForThreadQueryKeyPrefix", + "threadStorageLocationQueryKey", "threadStoragePathsForThreadQueryKeyPrefix", "threadTimelineQueryKeyPrefix", "terminalsQueryKey", diff --git a/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts b/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts index cdc6fa8c0f..6ed9c21856 100644 --- a/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts +++ b/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts @@ -9,6 +9,7 @@ import { threadsQueryKey, threadStorageFilePreviewQueryKeyPrefix, threadStorageFilesForThreadQueryKeyPrefix, + threadStorageLocationQueryKey, threadStoragePathsForThreadQueryKeyPrefix, threadTimelineQueryKeyPrefix, threadTimelineTurnSummaryDetailsQueryKeyPrefix, @@ -330,6 +331,9 @@ export function removeThreadScopedQueries({ queryClient.removeQueries({ queryKey: threadStorageFilesForThreadQueryKeyPrefix(threadId), }); + queryClient.removeQueries({ + queryKey: threadStorageLocationQueryKey(threadId), + }); queryClient.removeQueries({ queryKey: threadStoragePathsForThreadQueryKeyPrefix(threadId), }); 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 df951fea53..2f0e06ae45 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -73,6 +73,7 @@ import { allProjectCommandsQueryKeyPrefix, allThreadStorageFilePreviewQueryKeyPrefix, allThreadStorageFilesQueryKeyPrefix, + allThreadStorageLocationsQueryKeyPrefix, allThreadStoragePathsQueryKeyPrefix, allSystemExecutionOptionsQueryKeyPrefix, allThreadQueryKeyPrefix, @@ -93,6 +94,7 @@ import { threadsQueryKey, threadStorageFilePreviewQueryKeyPrefix, threadStorageFilesForThreadQueryKeyPrefix, + threadStorageLocationQueryKey, threadStoragePathsForThreadQueryKeyPrefix, threadTimelineQueryKeyPrefix, } from "../queries/query-keys"; @@ -1001,12 +1003,14 @@ function dirtyThreadStorageQueriesForThread({ if (!threadId) { return [ allThreadStorageFilesQueryKeyPrefix(), + allThreadStorageLocationsQueryKeyPrefix(), allThreadStoragePathsQueryKeyPrefix(), allThreadStorageFilePreviewQueryKeyPrefix(), ]; } return [ threadStorageFilesForThreadQueryKeyPrefix(threadId), + threadStorageLocationQueryKey(threadId), threadStoragePathsForThreadQueryKeyPrefix(threadId), threadStorageFilePreviewQueryKeyPrefix(threadId), ]; diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 35f1c5fbf9..66c87349a0 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -37,6 +37,7 @@ export const THREAD_PENDING_INTERACTIONS_QUERY_KEY = export const TERMINALS_QUERY_KEY = "terminals"; export const PROJECT_COMMANDS_QUERY_KEY = "projectCommands"; export const THREAD_STORAGE_FILES_QUERY_KEY = "threadStorageFiles"; +export const THREAD_STORAGE_LOCATION_QUERY_KEY = "threadStorageLocation"; export const THREAD_STORAGE_PATHS_QUERY_KEY = "threadStoragePaths"; export const THREAD_STORAGE_FILE_PREVIEW_QUERY_KEY = "threadStorageFilePreview"; export const THREAD_HOST_FILE_PREVIEW_QUERY_KEY = "threadHostFilePreview"; @@ -255,6 +256,13 @@ export type ThreadStorageFilesQueryKey = readonly [ string, ThreadStorageFileListOptions, ]; +export type ThreadStorageLocationQueryKey = readonly [ + typeof THREAD_STORAGE_LOCATION_QUERY_KEY, + string, +]; +export type AllThreadStorageLocationsQueryKeyPrefix = readonly [ + typeof THREAD_STORAGE_LOCATION_QUERY_KEY, +]; export type ThreadStoragePathsQueryKey = readonly [ typeof THREAD_STORAGE_PATHS_QUERY_KEY, string, @@ -768,6 +776,16 @@ export function threadStorageFilesQueryKey( return [THREAD_STORAGE_FILES_QUERY_KEY, threadId, options]; } +export function threadStorageLocationQueryKey( + threadId: string, +): ThreadStorageLocationQueryKey { + return [THREAD_STORAGE_LOCATION_QUERY_KEY, threadId]; +} + +export function allThreadStorageLocationsQueryKeyPrefix(): AllThreadStorageLocationsQueryKeyPrefix { + return [THREAD_STORAGE_LOCATION_QUERY_KEY]; +} + export function threadStoragePathsQueryKey( threadId: string, options: PathListOptions = DEFAULT_FILE_ONLY_PATH_LIST_OPTIONS, diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index ee0dfb5af8..075fdddf16 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -32,6 +32,7 @@ import { useThreadHostFilePreview, useThreadMentionCandidates, useThreadQueuedMessages, + useThreadStorageLocation, useThreadTimeline, } from "./thread-queries"; @@ -49,6 +50,7 @@ vi.mock("@/lib/sdk", () => ({ get: vi.fn(), list: vi.fn(), queuedMessages: { list: vi.fn() }, + storageLocation: vi.fn(), timeline: vi.fn(), }, }, @@ -146,6 +148,10 @@ beforeEach(() => { vi.mocked(sdk.threads.get).mockResolvedValue(THREAD_WITH_INCLUDES); vi.mocked(sdk.threads.list).mockResolvedValue([]); vi.mocked(sdk.threads.queuedMessages.list).mockResolvedValue([]); + vi.mocked(sdk.threads.storageLocation).mockResolvedValue({ + hostId: "host-1", + storageRootPath: "/tmp/thread-storage/thread-1", + }); vi.mocked(sdk.threads.timeline).mockResolvedValue({ rows: [], activePromptMode: null, @@ -680,6 +686,27 @@ describe("useThreadMentionCandidates", () => { }); }); +describe("useThreadStorageLocation", () => { + it("requests only the storage location for the thread", async () => { + const { wrapper } = createQueryClientTestHarness(); + + const { result } = renderHook(() => useThreadStorageLocation("thread-1"), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.data).toEqual({ + hostId: "host-1", + storageRootPath: "/tmp/thread-storage/thread-1", + }); + }); + expect(sdk.threads.storageLocation).toHaveBeenCalledWith({ + threadId: "thread-1", + signal: expect.any(AbortSignal), + }); + }); +}); + describe("useThreadTimeline segment limit", () => { it("asks for the compact first window on compact viewports and keeps it for deltas", async () => { mockMatchMedia([COMPACT_VIEWPORT_QUERY]); diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index a5e2f024cd..44ab0c805d 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -19,6 +19,7 @@ import type { ThreadWithIncludesResponse, ThreadConversationOutlineResponse, ThreadStorageFileListResponse, + ThreadStorageLocationResponse, ThreadStoragePathListResponse, ThreadTimelineResponse, TimelineTurnSummaryDetailsResponse, @@ -71,6 +72,7 @@ import { threadQueryKey, threadSearchQueryKey, threadStorageFilesQueryKey, + threadStorageLocationQueryKey, threadStoragePathsQueryKey, threadStorageFilePreviewQueryKey, threadHostFilePreviewQueryKey, @@ -811,6 +813,22 @@ export function useThreadStorageFiles( }); } +export function useThreadStorageLocation(id: string, options?: QueryOptions) { + const enabled = (options?.enabled ?? true) && Boolean(id); + useThreadDetailRealtimeSubscription(id, { enabled }); + + return useQuery({ + queryKey: threadStorageLocationQueryKey(id), + queryFn: ({ signal }) => + sdk.threads.storageLocation({ + threadId: requireThreadId(id, "useThreadStorageLocation"), + signal, + }), + enabled, + ...REALTIME_OWNED_MOUNT_BASELINE_QUERY_POLICY, + }); +} + export function useThreadStoragePaths( id: string, listOptions: PathListOptions, diff --git a/apps/app/src/hooks/useResolvedLiveFileTarget.test.tsx b/apps/app/src/hooks/useResolvedLiveFileTarget.test.tsx new file mode 100644 index 0000000000..27f974244d --- /dev/null +++ b/apps/app/src/hooks/useResolvedLiveFileTarget.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom + +import { cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useResolvedLiveFileTarget } from "./useResolvedLiveFileTarget"; + +const mocks = vi.hoisted(() => ({ + isLocalDaemonHost: vi.fn(), + useEnvironment: vi.fn(), + useThreadStorageLocation: vi.fn(), +})); + +vi.mock("@/hooks/queries/environment-queries", () => ({ + useEnvironment: mocks.useEnvironment, +})); + +vi.mock("@/hooks/queries/thread-queries", () => ({ + useThreadStorageLocation: mocks.useThreadStorageLocation, +})); + +vi.mock("@/hooks/useHostDaemon", () => ({ + useHostDaemon: () => ({ + isLocalDaemonHost: mocks.isLocalDaemonHost, + }), +})); + +const target = { + kind: "thread-storage", + path: "reports/summary.md", + threadId: "thr_1", +} as const; + +beforeEach(() => { + mocks.isLocalDaemonHost.mockReturnValue(false); + mocks.useEnvironment.mockReturnValue({ + data: undefined, + isLoading: false, + }); + mocks.useThreadStorageLocation.mockReturnValue({ + data: { + hostId: "host_remote", + storageRootPath: "/var/lib/bb/thread-storage/thr_1", + }, + isError: false, + isLoading: false, + }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("useResolvedLiveFileTarget", () => { + it.each([ + { + isLocal: true, + openContext: { kind: "local" }, + }, + { + isLocal: false, + openContext: { + kind: "remote-ssh", + hostId: "host_remote", + serverOrigin: window.location.origin, + }, + }, + ] as const)( + "resolves thread storage from the direct location lookup when local is $isLocal", + ({ isLocal, openContext }) => { + mocks.isLocalDaemonHost.mockReturnValue(isLocal); + + const { result } = renderHook(() => + useResolvedLiveFileTarget(target, { enabled: true }), + ); + + expect(result.current).toEqual({ + status: "available", + absolutePath: "/var/lib/bb/thread-storage/thr_1/reports/summary.md", + hostId: "host_remote", + openContext, + }); + expect(mocks.useThreadStorageLocation).toHaveBeenCalledWith("thr_1", { + enabled: true, + }); + expect(mocks.useEnvironment).toHaveBeenCalledWith("", { + enabled: false, + }); + }, + ); + + it.each([ + { + query: { data: undefined, isError: false, isLoading: true }, + status: "loading", + }, + { + query: { data: undefined, isError: true, isLoading: false }, + status: "unavailable", + }, + ] as const)( + "preserves the $status storage lookup state", + ({ query, status }) => { + mocks.useThreadStorageLocation.mockReturnValue(query); + + const { result } = renderHook(() => + useResolvedLiveFileTarget(target, { enabled: true }), + ); + + expect(result.current).toEqual({ status }); + }, + ); +}); diff --git a/apps/app/src/hooks/useResolvedLiveFileTarget.ts b/apps/app/src/hooks/useResolvedLiveFileTarget.ts index 43a29e5959..b9a1bb9a05 100644 --- a/apps/app/src/hooks/useResolvedLiveFileTarget.ts +++ b/apps/app/src/hooks/useResolvedLiveFileTarget.ts @@ -2,19 +2,9 @@ import { useMemo } from "react"; import type { ExperimentalLiveFileTarget } from "@get-bb/plugin-sdk"; import type { OpenInTargetContext } from "@bb/host-daemon-contract"; import { useEnvironment } from "@/hooks/queries/environment-queries"; -import { - useThread, - useThreadStoragePaths, -} from "@/hooks/queries/thread-queries"; +import { useThreadStorageLocation } from "@/hooks/queries/thread-queries"; import { useHostDaemon } from "@/hooks/useHostDaemon"; -const STORAGE_ROOT_PATH_OPTIONS = { - includeDirectories: false, - includeFiles: true, - limit: 1, - query: null, -} as const; - export type ResolvedLiveFileTarget = | { status: "loading" } | { status: "unavailable" } @@ -42,23 +32,14 @@ export function useResolvedLiveFileTarget( ): ResolvedLiveFileTarget { const storageThreadId = target?.kind === "thread-storage" ? target.threadId : ""; - const threadQuery = useThread(storageThreadId, { - enabled: options.enabled && storageThreadId.length > 0, - }); const environmentId = - target?.kind === "workspace" - ? target.environmentId - : target?.kind === "thread-storage" - ? (threadQuery.data?.environmentId ?? "") - : ""; + target?.kind === "workspace" ? target.environmentId : ""; const environmentQuery = useEnvironment(environmentId, { enabled: options.enabled && environmentId.length > 0, }); - const storageQuery = useThreadStoragePaths( - storageThreadId, - STORAGE_ROOT_PATH_OPTIONS, - { enabled: options.enabled && storageThreadId.length > 0 }, - ); + const storageQuery = useThreadStorageLocation(storageThreadId, { + enabled: options.enabled && storageThreadId.length > 0, + }); const { isLocalDaemonHost } = useHostDaemon(); return useMemo(() => { @@ -78,32 +59,37 @@ export function useResolvedLiveFileTarget( }; } - if ( - (target.kind === "thread-storage" && threadQuery.isLoading) || - environmentQuery.isLoading || - (target.kind === "thread-storage" && storageQuery.isLoading) - ) { - return { status: "loading" }; + if (target.kind === "thread-storage") { + if (storageQuery.isLoading) return { status: "loading" }; + const location = storageQuery.data; + if (storageQuery.isError || location === undefined) { + return { status: "unavailable" }; + } + return { + status: "available", + absolutePath: buildAbsoluteHostPath( + location.storageRootPath, + target.path, + ), + hostId: location.hostId, + openContext: isLocalDaemonHost(location.hostId) + ? { kind: "local" } + : { + kind: "remote-ssh", + hostId: location.hostId, + serverOrigin: window.location.origin, + }, + }; } + if (environmentQuery.isLoading) return { status: "loading" }; const environment = environmentQuery.data; - if ( - environment === undefined || - environment.path === null || - (target.kind === "thread-storage" && - (threadQuery.isError || storageQuery.isError)) - ) { + if (environment === undefined || environment.path === null) { return { status: "unavailable" }; } - - const rootPath = - target.kind === "workspace" - ? environment.path - : storageQuery.data?.storageRootPath; - if (!rootPath) return { status: "unavailable" }; return { status: "available", - absolutePath: buildAbsoluteHostPath(rootPath, target.path), + absolutePath: buildAbsoluteHostPath(environment.path, target.path), hostId: environment.hostId, openContext: isLocalDaemonHost(environment.hostId) ? { kind: "local" } @@ -118,11 +104,9 @@ export function useResolvedLiveFileTarget( environmentQuery.isLoading, isLocalDaemonHost, options.enabled, - storageQuery.data?.storageRootPath, + storageQuery.data, storageQuery.isError, storageQuery.isLoading, target, - threadQuery.isError, - threadQuery.isLoading, ]); } diff --git a/apps/server/src/routes/threads/data.ts b/apps/server/src/routes/threads/data.ts index 1ec78a6fa8..20863c2bd7 100644 --- a/apps/server/src/routes/threads/data.ts +++ b/apps/server/src/routes/threads/data.ts @@ -619,6 +619,16 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { } }); + get(routes.storageLocation, async (context) => { + const target = await requireThreadStorageTarget(deps, { + threadId: context.req.param("id"), + }); + return context.json({ + hostId: target.hostId, + storageRootPath: target.storagePath, + }); + }); + get(routes.storageFile, async (context) => serveThreadStorageRawFile( deps, diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts index 79fcb5c54a..4670f21ee3 100644 --- a/apps/server/test/public/public-thread-data.test.ts +++ b/apps/server/test/public/public-thread-data.test.ts @@ -30,6 +30,7 @@ import { threadSectionSchema, threadConversationOutlineResponseSchema, threadQueuedMessageListResponseSchema, + threadStorageLocationResponseSchema, threadTimelineResponseSchema, threadWithIncludesResponseSchema, timelineTurnSummaryDetailsResponseSchema, @@ -4033,6 +4034,37 @@ describe("public thread data routes", () => { }); }); + it("resolves thread storage location without a host filesystem command", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/project-source", + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: "/tmp/project-source", + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + }); + + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/thread-storage/location`, + ); + + expect(response.status).toBe(200); + expect( + threadStorageLocationResponseSchema.parse(await readJson(response)), + ).toEqual({ + hostId: host.id, + storageRootPath: `/tmp/bb-host-data/${host.id}/thread-storage/${thread.id}`, + }); + }); + }); + it("lists thread storage paths via host.list_paths", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps); diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index 83a4d140d8..ee2847c67e 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -32,6 +32,7 @@ import type { ThreadResponse, ThreadSearchResponse, ThreadStorageFileListResponse, + ThreadStorageLocationResponse, ThreadStoragePathListResponse, ThreadTabsResponse, ThreadTimelineResponse, @@ -138,6 +139,7 @@ export type ThreadQueuedMessageGroupBoundaryResult = export type ThreadTabsResult = ThreadTabsResponse; export type ThreadTabsUpdateResult = ThreadTabsResponse; export type ThreadStorageFilesResult = ThreadStorageFileListResponse; +export type ThreadStorageLocationResult = ThreadStorageLocationResponse; export type ThreadStoragePathsResult = ThreadStoragePathListResponse; export type ThreadChildSummaryResult = ThreadChildSummaryResponse; export type ThreadDefaultExecutionOptionsResult = @@ -474,6 +476,7 @@ export interface ThreadsArea { args: ThreadTimelineTurnSummaryDetailsArgs, ): Promise; storageFiles(args: ThreadStorageFilesArgs): Promise; + storageLocation(args: ThreadStatusArgs): Promise; storagePaths(args: ThreadStoragePathsArgs): Promise; unarchive(args: ThreadActionArgs): Promise; unpin(args: ThreadActionArgs): Promise; @@ -1137,6 +1140,16 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea { ), ); }, + async storageLocation(input) { + return transport.readJson( + transport.api.v1.threads[":id"]["thread-storage"].location.$get( + { + param: { id: input.threadId }, + }, + ...signalRequestArgs(input.signal), + ), + ); + }, async storagePaths(input) { return transport.readJson( transport.api.v1.threads[":id"]["thread-storage"].paths.$get( diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts index c930d6240e..6389b7638b 100644 --- a/packages/sdk/test/public-types.test.ts +++ b/packages/sdk/test/public-types.test.ts @@ -380,6 +380,7 @@ type ExpectedThreadsKey = | "spawn" | "stop" | "storageFiles" + | "storageLocation" | "storagePaths" | "tabs" | "timeline" diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 6129bf3ea5..92cce5067d 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -771,6 +771,16 @@ export type ThreadStorageContentQuery = z.infer< typeof threadStorageContentQuerySchema >; +export const threadStorageLocationResponseSchema = z + .object({ + hostId: z.string().min(1), + storageRootPath: z.string().min(1), + }) + .strict(); +export type ThreadStorageLocationResponse = z.infer< + typeof threadStorageLocationResponseSchema +>; + export const threadHostFileContentQuerySchema = z.object({ path: z.string().min(1), }); diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index 4a9a9dc5f7..5a7ec84001 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -189,6 +189,7 @@ import type { ThreadStorageContentQuery, ThreadStorageFileListResponse, ThreadStorageFilesQuery, + ThreadStorageLocationResponse, ThreadStoragePathListResponse, ThreadStoragePathsQuery, ThreadTimelineQuery, @@ -1262,6 +1263,12 @@ export const publicApiRoutes = { ), response: jsonResponse(), }), + storageLocation: defineRoute({ + path: "/threads/:id/thread-storage/location", + method: "get", + request: noRequest(), + response: jsonResponse(), + }), storageFile: defineRoute({ path: "/threads/:id/thread-storage/files/:filePath{.+}", method: "get", diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index 4a2e7e95b9..5ed28e038f 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -1532,6 +1532,11 @@ describe("server-contract clients", () => { param: { id: "thr_123" }, }).pathname, ).toBe("/api/v1/threads/thr_123/thread-storage/files"); + expect( + publicClient.threads[":id"]["thread-storage"].location.$url({ + param: { id: "thr_123" }, + }).pathname, + ).toBe("/api/v1/threads/thr_123/thread-storage/location"); expect( publicClient.threads[":id"]["thread-storage"].paths.$url({ param: { id: "thr_123" }, From 045d9e15b063528c53daa9de977bd38641acca23 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 20 Aug 2026 16:44:30 -0700 Subject: [PATCH 2/2] Address thread storage location review gaps --- .../cache-owners/cache-owner-registry.test.ts | 1 + .../cache-owners/system-cache-effects.ts | 2 ++ .../src/hooks/system-cache-effects.test.ts | 5 ++-- .../bb-plugin-authoring/SKILL.md | 2 +- plugins/docs/server.test.ts | 30 +++++-------------- plugins/docs/server.ts | 22 +++----------- 6 files changed, 18 insertions(+), 44 deletions(-) diff --git a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts index 358adb6558..40d50c1403 100644 --- a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts +++ b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts @@ -196,6 +196,7 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "allThreadQueuedMessagesQueryKeyPrefix", "allThreadStorageFilePreviewQueryKeyPrefix", "allThreadStorageFilesQueryKeyPrefix", + "allThreadStorageLocationsQueryKeyPrefix", "allThreadStoragePathsQueryKeyPrefix", "allThreadTimelineQueryKeyPrefix", "allThreadTimelineTurnSummaryDetailsQueryKeyPrefix", diff --git a/apps/app/src/hooks/cache-owners/system-cache-effects.ts b/apps/app/src/hooks/cache-owners/system-cache-effects.ts index 77c5a4d9e8..d29b0e32bc 100644 --- a/apps/app/src/hooks/cache-owners/system-cache-effects.ts +++ b/apps/app/src/hooks/cache-owners/system-cache-effects.ts @@ -19,6 +19,7 @@ import { allThreadQueryKeyPrefix, allThreadStorageFilePreviewQueryKeyPrefix, allThreadStorageFilesQueryKeyPrefix, + allThreadStorageLocationsQueryKeyPrefix, allThreadStoragePathsQueryKeyPrefix, allThreadTimelineQueryKeyPrefix, allThreadTimelineTurnSummaryDetailsQueryKeyPrefix, @@ -186,6 +187,7 @@ function getServerReconnectInvalidationQueryKeys(): QueryKey[] { allThreadPendingInteractionsQueryKeyPrefix(), allThreadDefaultExecutionOptionsQueryKeyPrefix(), allThreadStorageFilesQueryKeyPrefix(), + allThreadStorageLocationsQueryKeyPrefix(), allThreadStoragePathsQueryKeyPrefix(), allThreadStorageFilePreviewQueryKeyPrefix(), allThreadHostFilePreviewQueryKeyPrefix(), diff --git a/apps/app/src/hooks/system-cache-effects.test.ts b/apps/app/src/hooks/system-cache-effects.test.ts index 5d8f4cf877..8ec311b3d3 100644 --- a/apps/app/src/hooks/system-cache-effects.test.ts +++ b/apps/app/src/hooks/system-cache-effects.test.ts @@ -20,6 +20,7 @@ import { threadQueryKey, threadQueuedMessagesQueryKey, threadSearchQueryKey, + threadStorageLocationQueryKey, threadTimelineQueryKey, } from "./queries/query-keys"; import { @@ -324,10 +325,10 @@ describe("system cache effects", () => { queryClient.clear(); }); - it("refetches errored queries after reconnect", async () => { + it("recovers a failed active thread-storage location query after reconnect", async () => { const queryClient = createCacheEffectQueryClient(); queryClient.mount(); - const erroredKey = hostsQueryKey(); + const erroredKey = threadStorageLocationQueryKey("thread-1"); const queryFn = vi .fn<() => Promise>() .mockRejectedValueOnce(new Error("server restarting")) diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index 355ff44966..42c2313dc5 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -582,7 +582,7 @@ signatures (see "Looking up the exact API"). | Area | Methods | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `threads` | `list` `get` `search` `spawn` `fork` `send` `update` `delete` `stop` `compact` `wait` `open` `output` `timeline` `conversationOutline` `promptHistory` `archive` `archiveAll` `unarchive` `pin` `unpin` `reorderPinned` `markRead` `markUnread` `childSummary` `paneAction` `timelineTurnSummaryDetails` `storageFiles` `storagePaths` `cancelPlan` `clearGoal` `defaultExecutionOptions`; sub-areas `events` (`list` `wait`), `interactions` (`get` `list` `cancel` `resolve` `respond`), `queuedMessages` (`create` `list` `update` `delete` `send` `reorder` `setGroupBoundary`), `tabs` (`get` `update`) | +| `threads` | `list` `get` `search` `spawn` `fork` `send` `update` `delete` `stop` `compact` `wait` `open` `output` `timeline` `conversationOutline` `promptHistory` `archive` `archiveAll` `unarchive` `pin` `unpin` `reorderPinned` `markRead` `markUnread` `childSummary` `paneAction` `timelineTurnSummaryDetails` `storageFiles` `storageLocation` `storagePaths` `cancelPlan` `clearGoal` `defaultExecutionOptions`; sub-areas `events` (`list` `wait`), `interactions` (`get` `list` `cancel` `resolve` `respond`), `queuedMessages` (`create` `list` `update` `delete` `send` `reorder` `setGroupBoundary`), `tabs` (`get` `update`) | | `threadSections` | `list` `create` `update` `delete` | | `projects` | `list` `get` `create` `update` `delete` `reorder` `paths` `files` `fileContent` `branches` `commands` `defaultExecutionOptions` `promptHistory`; sub-areas `attachments` (`upload` `read` `copy`), `sources` (`add` `update` `delete`) | | `environments` | `get` `update` `status` `paths` `commit` `archiveThreads` `diff` `diffFile` `diffFiles` `diffBranches` `diffPatch` `pullRequest` `markPullRequestDraft` `markPullRequestReady` `mergePullRequest` `squashMerge` | diff --git a/plugins/docs/server.test.ts b/plugins/docs/server.test.ts index 8eb3b2c24c..71e8b76df3 100644 --- a/plugins/docs/server.test.ts +++ b/plugins/docs/server.test.ts @@ -5,10 +5,7 @@ import path from "node:path"; import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; import { defineRpcContract } from "@get-bb/plugin-sdk"; import type { PluginRpcClient, PluginRpcHandlers } from "@get-bb/plugin-sdk"; -import { - createFakePluginHost, - makeThreadResponse, -} from "@get-bb/plugin-sdk/testing"; +import { createFakePluginHost } from "@get-bb/plugin-sdk/testing"; import simpleNotes, { docsRpcContract } from "./server"; const temporaryDirectories: string[] = []; @@ -1311,16 +1308,8 @@ describe("Docs vault operations", () => { }), }, threads: { - get: async () => ({ - ...makeThreadResponse({ - id: "thread_1", - environmentId: "environment_1", - }), - environment: { hostId: "host_remote" }, - }), - storageFiles: async () => ({ - files: [], - truncated: false, + storageLocation: async () => ({ + hostId: "host_remote", storageRootPath, }), }, @@ -1356,13 +1345,9 @@ describe("Docs vault operations", () => { sha256: "updated-thread-sha", }); - expect(host.harness.sdk.callsTo("threads.get")).toEqual([ - [{ threadId: "thread_1", include: "environment" }], - [{ threadId: "thread_1", include: "environment" }], - ]); - expect(host.harness.sdk.callsTo("threads.storageFiles")).toEqual([ - [{ threadId: "thread_1", limit: "1" }], - [{ threadId: "thread_1", limit: "1" }], + expect(host.harness.sdk.callsTo("threads.storageLocation")).toEqual([ + [{ threadId: "thread_1" }], + [{ threadId: "thread_1" }], ]); expect(host.harness.sdk.callsTo("files.read")).toEqual([ [ @@ -1403,8 +1388,7 @@ describe("Docs vault operations", () => { path: "../outside.md", }), ).rejects.toThrow("Invalid thread-storage path"); - expect(harness.sdk.callsTo("threads.get")).toEqual([]); - expect(harness.sdk.callsTo("threads.storageFiles")).toEqual([]); + expect(harness.sdk.callsTo("threads.storageLocation")).toEqual([]); }); it("publishes watched filesystem changes without waiting for the poll", async () => { diff --git a/plugins/docs/server.ts b/plugins/docs/server.ts index 1d3094cad1..3e07201111 100644 --- a/plugins/docs/server.ts +++ b/plugins/docs/server.ts @@ -1072,23 +1072,9 @@ export default async function plugin( throw new Error("Thread-storage files require a thread ID"); } const relativePath = requireThreadStoragePath(filePath); - const [thread, storage] = await Promise.all([ - bb.sdk.threads.get({ - threadId: source.threadId, - include: "environment", - }), - // The bounded listing is the SDK surface that also resolves the - // thread's absolute storage root on its owning host. - bb.sdk.threads.storageFiles({ - threadId: source.threadId, - limit: "1", - }), - ]); - const environment = - "environment" in thread ? thread.environment : undefined; - if (!environment) { - throw new Error("This thread has no environment"); - } + const storage = await bb.sdk.threads.storageLocation({ + threadId: source.threadId, + }); if (!isAbsoluteHostPath(storage.storageRootPath)) { throw new Error("This thread has no absolute storage path"); } @@ -1099,7 +1085,7 @@ export default async function plugin( ...relativePath.split("/"), ), rootPath, - hostId: environment.hostId, + hostId: storage.hostId, }; } throw new Error(