diff --git a/plugins/docs/server.test.ts b/plugins/docs/server.test.ts index bd936d142f..8eb3b2c24c 100644 --- a/plugins/docs/server.test.ts +++ b/plugins/docs/server.test.ts @@ -1151,6 +1151,138 @@ describe("Docs vault operations", () => { ]); }); + it("routes project-backed workspace files through the selected or primary source", async () => { + const host = createFakePluginHost({ + pluginId: "simple-notes", + sdk: { + files: { + mkdir: async () => ({ ok: true as const }), + read: async ({ path: openedPath }) => ({ + path: openedPath, + content: "# Project plan", + contentEncoding: "utf8" as const, + mimeType: "text/markdown", + sizeBytes: 14, + modifiedAtMs: 1, + sha256: "project-sha", + }), + write: async () => ({ + outcome: "written" as const, + sha256: "project-written-sha", + sizeBytes: 22, + }), + createPreview: async () => ({ + baseUrl: "/project-preview", + expiresAtMs: Date.now() + 60_000, + }), + }, + projects: { + get: async () => ({ + sources: [ + { + hostId: "host_remote", + path: "/remote/project", + isDefault: true, + type: "local_path", + }, + { + hostId: "host_primary", + path: "/primary/project", + isDefault: false, + type: "local_path", + }, + ], + }), + }, + system: { + config: async () => ({ primaryHostId: "host_primary" }), + }, + }, + }); + await simpleNotes(host.bb); + host.harness.sdk.calls.length = 0; + const selectedSource = { + kind: "workspace", + threadId: null, + environmentId: null, + projectId: "project_1", + experimental_hostId: "host_remote", + }; + + await expect( + host.harness.callRpc("openFile", { + source: selectedSource, + path: "docs/plan.md", + }), + ).resolves.toMatchObject({ previewPath: "docs/plan.md" }); + await expect( + host.harness.callRpc("saveOpenedFile", { + source: selectedSource, + path: "docs/plan.md", + content: "# Updated project plan", + expectedSha256: "project-sha", + }), + ).resolves.toMatchObject({ outcome: "written" }); + await expect( + host.harness.callRpc("openFile", { + source: { + kind: "workspace", + threadId: null, + environmentId: null, + projectId: "project_1", + }, + path: "docs/plan.md", + }), + ).resolves.toMatchObject({ + previewPath: "docs/plan.md", + }); + await expect( + host.harness.callRpc("openFile", { + source: { ...selectedSource, experimental_hostId: "host_missing" }, + path: "docs/plan.md", + }), + ).rejects.toThrow("This project has no workspace on the selected host"); + + expect(host.harness.sdk.callsTo("projects.get")).toEqual([ + [{ projectId: "project_1" }], + [{ projectId: "project_1" }], + [{ projectId: "project_1" }], + [{ projectId: "project_1" }], + ]); + expect(host.harness.sdk.callsTo("system.config")).toEqual([[]]); + expect(host.harness.sdk.callsTo("files.read")).toEqual([ + [ + { + hostId: "host_remote", + path: "/remote/project/docs/plan.md", + rootPath: "/remote/project", + }, + ], + [ + { + hostId: "host_primary", + path: "/primary/project/docs/plan.md", + rootPath: "/primary/project", + }, + ], + ]); + expect(host.harness.sdk.callsTo("files.createPreview")).toEqual([ + [{ hostId: "host_remote", rootPath: "/remote/project" }], + [{ hostId: "host_primary", rootPath: "/primary/project" }], + ]); + expect(host.harness.sdk.callsTo("files.write")).toEqual([ + [ + { + hostId: "host_remote", + path: "/remote/project/docs/plan.md", + rootPath: "/remote/project", + content: "# Updated project plan", + expectedSha256: "project-sha", + }, + ], + ]); + }); + it("opens and saves thread-storage Markdown files on the thread's host", async () => { const storageRootPath = String.raw`C:\bb\thread-storage\thread_1`; const openedPath = String.raw`C:\bb\thread-storage\thread_1\reports\plan.md`; diff --git a/plugins/docs/server.ts b/plugins/docs/server.ts index 34cc16c8e3..1d3094cad1 100644 --- a/plugins/docs/server.ts +++ b/plugins/docs/server.ts @@ -1033,6 +1033,40 @@ export default async function plugin( hostId: environment.hostId, }; } + if (source.kind === "workspace" && source.projectId) { + const hostId = + source.experimental_hostId ?? + (await bb.sdk.system.config()).primaryHostId; + if (!hostId) { + throw new Error("This project has no primary host"); + } + const project = await bb.sdk.projects.get({ + projectId: source.projectId, + }); + const matchingSources = project.sources.filter( + (projectSource) => projectSource.hostId === hostId, + ); + const [projectSource] = matchingSources; + if (!projectSource) { + throw new Error( + source.experimental_hostId + ? "This project has no workspace on the selected host" + : "This project has no workspace on the primary host", + ); + } + if (matchingSources.length > 1) { + throw new Error("This project has multiple workspaces on that host"); + } + const rootPath = normalizeHostRoot(projectSource.path); + if (!isAbsoluteHostPath(rootPath)) { + throw new Error("This project has no absolute workspace path"); + } + return { + path: hostPathApi(rootPath).join(rootPath, ...filePath.split("/")), + rootPath, + hostId, + }; + } if (source.kind === "thread-storage") { if (!source.threadId) { throw new Error("Thread-storage files require a thread ID");