setQuery(event.target.value)}
onKeyDown={handleInputKeyDown}
disabled={isUnavailable}
- aria-label="Search files"
+ aria-label="Search apps and files"
aria-activedescendant={
- activeSuggestion ? getFileSearchResultId(activeSuggestion) : undefined
+ activeSuggestion
+ ? getFileSearchResultId(activeSuggestion)
+ : undefined
+ }
+ placeholder={
+ isUnavailable ? "No searchable source" : "Search apps and files"
}
- placeholder={isUnavailable ? "No searchable source" : "Search files"}
className="h-8 pl-8 pr-8 text-xs focus-visible:ring-0"
/>
{isDebouncing ? (
@@ -361,59 +558,152 @@ export function NewTabFileSearch({
{isUnavailable ? (
- ) : isError ? (
-
- ) : isLoading ? (
-
- ) : sections.length > 0 ? (
-
- {sections.map((section, sectionIndex) => (
+ )}
+
+ );
+}
+
+interface NewTabResultsProps {
+ activeIndex: number;
+ canCreateApp: boolean;
+ hasQuery: boolean;
+ isError: boolean;
+ isLoading: boolean;
+ onActivateIndex: (index: number) => void;
+ onAppSelect: (suggestion: AppSearchSuggestion) => void;
+ onCreateApp: () => void;
+ onFileSelect: (suggestion: FilePathSearchSuggestion) => void;
+ sections: readonly FileSearchSection[];
+ showAppsSectionShell: boolean;
+}
+
+function NewTabResults({
+ activeIndex,
+ canCreateApp,
+ hasQuery,
+ isError,
+ isLoading,
+ onActivateIndex,
+ onAppSelect,
+ onCreateApp,
+ onFileSelect,
+ sections,
+ showAppsSectionShell,
+}: NewTabResultsProps) {
+ const appsSection = sections.find((section) => section.kind === "apps");
+ const filesSection = sections.find((section) => section.kind === "files");
+ const showAppsSection = showAppsSectionShell || appsSection !== undefined;
+ const showFilesSection = filesSection !== undefined;
+ const showLoading = isLoading && !showFilesSection;
+ const showError = isError && !showFilesSection && !showLoading;
+ const showEmptyMessage =
+ !showAppsSection && !showFilesSection && !showLoading && !showError;
+
+ if (showEmptyMessage) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {showAppsSection ? (
+
+
+ {FILE_SEARCH_SECTION_LABELS.apps}
+
+ {appsSection && appsSection.items.length > 0 ? (
0 && "mt-2")}
+ role="listbox"
+ aria-label={FILE_SEARCH_SECTION_LABELS.apps}
+ className="flex flex-col gap-px"
>
-
- {section.label}
-
-
- {section.items.map(({ suggestion, index }) => (
-
+ suggestion.entryKind === "app" ? (
+ setActiveIndex(index)}
- onSelect={handleSuggestionSelect}
+ onActivate={() => onActivateIndex(index)}
+ onSelect={onAppSelect}
/>
- ))}
-
+ ) : null,
+ )}
+
+ ) : null}
+ {canCreateApp ? (
+ 0 && "mt-px",
+ )}
+ >
+
- ))}
+ ) : null}
+
+ ) : null}
+
+ {showFilesSection && filesSection ? (
+
+
+ {FILE_SEARCH_SECTION_LABELS.files}
+
+
+ {filesSection.items.map(({ suggestion, index }) =>
+ suggestion.entryKind === "file" ? (
+ onActivateIndex(index)}
+ onSelect={onFileSelect}
+ />
+ ) : null,
+ )}
+
+
+ ) : showLoading || showError ? (
+
+
- ) : (
-
- )}
+ ) : null}
);
}
diff --git a/apps/app/src/components/secondary-panel/NewTabPage.test.tsx b/apps/app/src/components/secondary-panel/NewTabPage.test.tsx
index 205af5582..875cfd1de 100644
--- a/apps/app/src/components/secondary-panel/NewTabPage.test.tsx
+++ b/apps/app/src/components/secondary-panel/NewTabPage.test.tsx
@@ -1,11 +1,17 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
-import type { WorkspacePathEntry } from "@bb/server-contract";
+import type { AppSummary, WorkspacePathEntry } from "@bb/server-contract";
import * as api from "@/lib/api";
+import {
+ parsePromptDraftStorage,
+ serializePromptDraftStorage,
+ type PromptDraftState,
+} from "@/lib/prompt-draft";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { NewTabPage } from "./NewTabPage";
+import { CREATE_APP_PROMPT_TEMPLATE } from "./NewTabFileSearch";
import type { FileSearchSelection } from "./useThreadFileTabs";
vi.mock("@/lib/api", async (importOriginal) => {
@@ -14,6 +20,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
return {
...actual,
searchProjectPaths: vi.fn(),
+ listThreadApps: vi.fn(),
listThreadStoragePaths: vi.fn(),
};
});
@@ -34,9 +41,12 @@ interface RenderNewTabPageArgs {
projectId?: string;
currentThreadId?: string;
currentThreadType?: "manager" | "standard";
+ onCreateAppPromptPrefill?: CreateAppPromptPrefillHandler;
onSelect?: (selection: FileSearchSelection) => void;
}
+type CreateAppPromptPrefillHandler = () => void;
+
function getPathName(pathValue: string): string {
return pathValue.split("/").at(-1) ?? pathValue;
}
@@ -60,6 +70,45 @@ function makePathResponse(
};
}
+const STATUS_APP: AppSummary = {
+ id: "status",
+ name: "Status",
+ entry: { path: "index.html", kind: "html" },
+ capabilities: ["data", "message"],
+ icon: { kind: "builtin", name: "ListTodo" },
+};
+
+const THREAD_DRAFT_STORAGE_KEY = "bb.promptbox.contents-proj-1-thr-standard-3";
+
+const DRAFT_WITH_ATTACHMENT = {
+ text: "Keep this draft",
+ attachments: [
+ {
+ type: "localFile",
+ path: "/tmp/spec.md",
+ name: "spec.md",
+ sizeBytes: 42,
+ mimeType: "text/markdown",
+ },
+ ],
+} satisfies PromptDraftState;
+
+function getStoredThreadDraft(): PromptDraftState {
+ return parsePromptDraftStorage(
+ window.localStorage.getItem(THREAD_DRAFT_STORAGE_KEY),
+ );
+}
+
+function setStoredThreadDraft(draft: PromptDraftState): void {
+ const serialized = serializePromptDraftStorage(draft);
+ if (serialized === null) {
+ window.localStorage.removeItem(THREAD_DRAFT_STORAGE_KEY);
+ return;
+ }
+
+ window.localStorage.setItem(THREAD_DRAFT_STORAGE_KEY, serialized);
+}
+
function renderNewTabPage(args: RenderNewTabPageArgs = {}) {
const { wrapper } = createQueryClientTestHarness();
const onSelect: (selection: FileSearchSelection) => void =
@@ -73,6 +122,7 @@ function renderNewTabPage(args: RenderNewTabPageArgs = {}) {
currentThreadId={args.currentThreadId ?? "thr-standard"}
currentThreadType={args.currentThreadType ?? "standard"}
focusRequest={0}
+ onCreateAppPromptPrefill={args.onCreateAppPromptPrefill}
onSelect={onSelect}
/>,
{ wrapper },
@@ -82,11 +132,14 @@ function renderNewTabPage(args: RenderNewTabPageArgs = {}) {
afterEach(() => {
cleanup();
+ window.localStorage.clear();
+ vi.restoreAllMocks();
vi.clearAllMocks();
});
describe("NewTabPage", () => {
it("autofocuses search and selects a workspace result", async () => {
+ vi.mocked(api.listThreadApps).mockResolvedValue([]);
vi.mocked(api.searchProjectPaths).mockResolvedValue(
makePathResponse([
{
@@ -98,16 +151,16 @@ describe("NewTabPage", () => {
);
const { onSelect } = renderNewTabPage({ projectId: "proj-1" });
- const input = screen.getByRole("textbox", { name: "Search files" });
+ const input = screen.getByRole("textbox", {
+ name: "Search apps and files",
+ });
expect(document.activeElement).toBe(input);
fireEvent.change(input, { target: { value: "app" } });
- expect(await screen.findByText("Workspace")).toBeTruthy();
+ expect(await screen.findByText("Files")).toBeTruthy();
expect(await screen.findByText("app.ts")).toBeTruthy();
expect(await screen.findByText(/src/u)).toBeTruthy();
expect(screen.queryByText("Manager Storage")).toBeNull();
- fireEvent.click(
- await screen.findByRole("option", { name: /app\.ts/u }),
- );
+ fireEvent.click(await screen.findByRole("option", { name: /app\.ts/u }));
expect(onSelect).toHaveBeenCalledWith({
source: "workspace",
@@ -115,7 +168,71 @@ describe("NewTabPage", () => {
});
});
+ it("lists apps and opens an app selection", async () => {
+ vi.mocked(api.listThreadApps).mockResolvedValue([STATUS_APP]);
+ const { onSelect } = renderNewTabPage({
+ currentThreadId: "thr-manager",
+ currentThreadType: "manager",
+ });
+
+ expect(await screen.findByText("Apps")).toBeTruthy();
+ fireEvent.click(await screen.findByRole("option", { name: /Status/u }));
+
+ expect(onSelect).toHaveBeenCalledWith({
+ source: "app",
+ appId: "status",
+ });
+ });
+
+ it("prefills the composer draft with the create-app prompt", () => {
+ vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ renderNewTabPage({ projectId: "proj-1" });
+
+ fireEvent.click(screen.getByRole("button", { name: /Create App/u }));
+
+ expect(getStoredThreadDraft()).toEqual({
+ text: CREATE_APP_PROMPT_TEMPLATE,
+ attachments: [],
+ });
+ });
+
+ it("structures the create-app prompt with no placeholders and ends ready for the user", () => {
+ expect(CREATE_APP_PROMPT_TEMPLATE).not.toMatch(/\[NAME\]/u);
+ expect(CREATE_APP_PROMPT_TEMPLATE).not.toMatch(
+ /\[DESCRIBE WHAT IT SHOULD DO\]/u,
+ );
+ expect(CREATE_APP_PROMPT_TEMPLATE).toContain("bb guide app");
+ expect(CREATE_APP_PROMPT_TEMPLATE).toContain("window.bb.data");
+ expect(CREATE_APP_PROMPT_TEMPLATE.endsWith("What I want:\n\n")).toBe(true);
+ });
+
+ it("leaves a non-empty composer draft unchanged when replacement is canceled", () => {
+ vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.spyOn(window, "confirm").mockReturnValue(false);
+ setStoredThreadDraft(DRAFT_WITH_ATTACHMENT);
+ renderNewTabPage({ projectId: "proj-1" });
+
+ fireEvent.click(screen.getByRole("button", { name: /Create App/u }));
+
+ expect(getStoredThreadDraft()).toEqual(DRAFT_WITH_ATTACHMENT);
+ });
+
+ it("replaces non-empty composer text and attachments after confirmation", () => {
+ vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.spyOn(window, "confirm").mockReturnValue(true);
+ setStoredThreadDraft(DRAFT_WITH_ATTACHMENT);
+ renderNewTabPage({ projectId: "proj-1" });
+
+ fireEvent.click(screen.getByRole("button", { name: /Create App/u }));
+
+ expect(getStoredThreadDraft()).toEqual({
+ text: CREATE_APP_PROMPT_TEMPLATE,
+ attachments: [],
+ });
+ });
+
it("selects a manager thread-storage result with the keyboard", async () => {
+ vi.mocked(api.listThreadApps).mockResolvedValue([]);
vi.mocked(api.searchProjectPaths).mockResolvedValue(
makePathResponse([
{
@@ -141,10 +258,11 @@ describe("NewTabPage", () => {
currentThreadType: "manager",
});
- const input = screen.getByRole("textbox", { name: "Search files" });
+ const input = screen.getByRole("textbox", {
+ name: "Search apps and files",
+ });
fireEvent.change(input, { target: { value: "status" } });
- await screen.findByText("Workspace");
- await screen.findByText("Manager Storage");
+ await screen.findByText("Files");
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
@@ -155,12 +273,13 @@ describe("NewTabPage", () => {
});
it("renders an unavailable state without querying", () => {
- renderNewTabPage();
+ renderNewTabPage({ currentThreadId: "" });
expect(
- screen.getByText("No searchable file source is available."),
+ screen.getByText("No searchable app or file source is available."),
).toBeTruthy();
expect(api.searchProjectPaths).not.toHaveBeenCalled();
+ expect(api.listThreadApps).not.toHaveBeenCalled();
expect(api.listThreadStoragePaths).not.toHaveBeenCalled();
});
});
diff --git a/apps/app/src/components/secondary-panel/NewTabPage.tsx b/apps/app/src/components/secondary-panel/NewTabPage.tsx
index 29c7ef399..63ad3725a 100644
--- a/apps/app/src/components/secondary-panel/NewTabPage.tsx
+++ b/apps/app/src/components/secondary-panel/NewTabPage.tsx
@@ -3,10 +3,9 @@ import { NewTabFileSearch, type NewTabFileSearchProps } from "./NewTabFileSearch
export type NewTabPageProps = NewTabFileSearchProps;
/**
- * Browser-style "New Tab" landing page for the secondary panel. Today its only
- * capability is file search, but it is structured as a page that hosts sections
- * so future entry points (e.g. open a terminal, quick links) can be added
- * alongside the search without reshaping the tab.
+ * Browser-style "New Tab" landing page for the secondary panel. It hosts the
+ * unified app and file launcher so future entry points can sit beside it
+ * without reshaping the tab.
*/
export function NewTabPage(props: NewTabPageProps) {
return (
diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx
index 372e55be1..715c0c955 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx
@@ -208,9 +208,9 @@ export function Overview() {
hint="leftmost tab is pinned (no close X); other tabs render the close affordance as usual"
>
{
describe("ThreadSecondaryPanel", () => {
it.each([
{
- name: "STATUS iframe",
+ name: "status app iframe",
activeTab: buildActiveFileTab({
- id: "storage:STATUS",
- filename: "STATUS",
+ id: "app:status",
+ filename: "Status",
isPinned: true,
}),
- iframeTitle: "Manager status",
+ iframeTitle: "Status app",
closeButtonLabel: null,
},
{
@@ -249,8 +249,8 @@ describe("ThreadSecondaryPanel", () => {
"keeps iframe previews interactable after secondary panel resize ends via $name",
async ({ finishDrag }) => {
const activeStatusTab: SecondaryPanelFileTab = {
- id: "storage:STATUS",
- filename: "STATUS",
+ id: "app:status",
+ filename: "Status",
isActive: true,
isPinned: true,
statusLabel: null,
@@ -260,7 +260,7 @@ describe("ThreadSecondaryPanel", () => {
renderPanel({
fileTabs: [activeStatusTab],
- fileTabContent: ,
+ fileTabContent: ,
renderAsDrawer: false,
});
@@ -268,7 +268,7 @@ describe("ThreadSecondaryPanel", () => {
"#thread-detail-secondary-panel",
);
const aside = panel?.querySelector("aside");
- const iframe = screen.getByTitle("Manager status");
+ const iframe = screen.getByTitle("Status app");
const resizeHandle = screen.getByLabelText(
"Resize thread and secondary panels",
);
diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx
index d7a328aad..376c4bb0f 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx
@@ -53,6 +53,7 @@ export interface SecondaryPanelFileTab {
filename: string;
isActive: boolean;
isPinned?: boolean;
+ leadingVisual?: ReactNode;
statusLabel: WorkspaceFilePreviewStatusLabel | null;
onSelect: () => void;
onClose: () => void;
@@ -403,6 +404,7 @@ function FileTab({ tab }: { tab: SecondaryPanelFileTab }) {
return (
Selected{" "}
- {selection.source === "workspace" ? "workspace" : "thread storage"}{" "}
- file
+ {selection.source === "app"
+ ? "app"
+ : selection.source === "workspace"
+ ? "workspace file"
+ : "thread storage file"}
- {selection.path}
+ {selection.source === "app" ? selection.appId : selection.path}
);
diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx
index a6b75bb5e..d4a7a274e 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx
@@ -26,7 +26,6 @@ import {
SecondaryPanelFilePreview,
ThreadStorageFilePreview,
} from "./ThreadStorageFilePreview";
-import { isManagerStatusStorageFilePath } from "./managerStorage";
const GIT_DIFF_SKELETON_FILE_COUNT = 3;
const PANEL_SCROLL_SLOT_CLASS =
@@ -100,9 +99,7 @@ export interface HostFilePreviewTabContentProps {
export interface ThreadStorageFilePreviewTabContentProps {
activePath: string;
copyPath?: string | null;
- isManagerThread: boolean;
onOpenInEditor?: (path: string) => void;
- pinnedPath: string;
threadId: string;
}
@@ -362,19 +359,14 @@ export function HostFilePreviewTabContent({
export function ThreadStorageFilePreviewTabContent({
activePath,
copyPath = null,
- isManagerThread,
onOpenInEditor,
- pinnedPath,
threadId,
}: ThreadStorageFilePreviewTabContentProps) {
- const isManagerStatusTab = isManagerStatusStorageFilePath(activePath);
const {
data: threadStorageFilePreview,
error: threadStorageFilePreviewError,
isLoading: isThreadStorageFilePreviewLoading,
- } = useThreadStorageFilePreview(threadId, activePath, {
- enabled: isManagerThread && !isManagerStatusTab,
- });
+ } = useThreadStorageFilePreview(threadId, activePath);
return (
);
diff --git a/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.test.tsx b/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.test.tsx
index 33309a1c8..1aea540dc 100644
--- a/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.test.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.test.tsx
@@ -1,22 +1,8 @@
// @vitest-environment jsdom
-import {
- cleanup,
- fireEvent,
- render,
- screen,
- waitFor,
-} from "@testing-library/react";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FilePreview } from "@/lib/file-preview";
-import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
-import { installFetchRoutes, jsonResponse } from "@/test/http-test-utils";
-import {
- MANAGER_STATUS_FILE_PATH,
- MANAGER_STATUS_HTML_FILE_PATH,
- MANAGER_STATUS_INDEX_FILE_PATH,
- MANAGER_STATUS_MARKDOWN_FILE_PATH,
-} from "./managerStorage";
import {
SecondaryPanelFilePreview,
ThreadStorageFilePreview,
@@ -134,236 +120,26 @@ describe("ThreadStorageFilePreview", () => {
expect(screen.queryByText("Empty file.")).toBeNull();
});
- it("renders raw STATUS html and folder sources in an unsandboxed iframe", async () => {
- let resolveVersion = (response: Response) => response;
- const versionResponse = new Promise
((resolve) => {
- resolveVersion = (response: Response) => {
- resolve(response);
- return response;
- };
- });
- installFetchRoutes([
- {
- pathname: "/api/v1/threads/thr_manager/status-version",
- handler: () => versionResponse,
- },
- ]);
- const { wrapper } = createQueryClientTestHarness();
- const { container } = render(
- ,
- { wrapper },
- );
-
- expect(container.querySelector("iframe")).toBeNull();
- expect(screen.queryByText(MANAGER_STATUS_FILE_PATH)).toBeNull();
- expect(
- screen.queryByRole("button", { name: "Copy file path" }),
- ).toBeNull();
-
- resolveVersion(jsonResponse({ source: "html", hash: "status-hash-1" }));
-
- await vi.waitFor(() => {
- const iframe = container.querySelector("iframe");
- expect(iframe).not.toBeNull();
- expect(iframe?.getAttribute("src")).toBe(
- "/api/v1/threads/thr_manager/status/?v=status-hash-1",
- );
- expect(iframe?.hasAttribute("sandbox")).toBe(false);
- expect(iframe?.style.width).toBe("100%");
- expect(iframe?.style.height).toBe("100%");
- expect(iframe?.style.border).toBe("0px");
- });
- expect(screen.queryByText(MANAGER_STATUS_FILE_PATH)).toBeNull();
- expect(
- screen.queryByRole("button", { name: "Copy file path" }),
- ).toBeNull();
- });
-
- it("updates the STATUS iframe src when the polled hash changes", async () => {
- let requestCount = 0;
- installFetchRoutes([
- {
- pathname: "/api/v1/threads/thr_manager/status-version",
- handler: () => {
- requestCount += 1;
- return jsonResponse({
- source: "folder",
- hash: requestCount === 1 ? "status-hash-1" : "status-hash-2",
- });
- },
- },
- ]);
- const { wrapper } = createQueryClientTestHarness();
- const { container } = render(
- ,
- { wrapper },
- );
-
- await waitFor(() => {
- expect(container.querySelector("iframe")?.getAttribute("src")).toBe(
- "/api/v1/threads/thr_manager/status/?v=status-hash-1",
- );
- });
-
- await waitFor(
- () => {
- expect(container.querySelector("iframe")?.getAttribute("src")).toBe(
- "/api/v1/threads/thr_manager/status/?v=status-hash-2",
- );
- },
- { timeout: 2_500 },
- );
- }, 8_000);
-
it("renders generic storage HTML files through a sandboxed raw iframe", () => {
- const { wrapper } = createQueryClientTestHarness();
const { container } = render(
window.preview = true",
- path: "reports/status.html",
+ path: "reports/preview.html",
})}
isLoading={false}
- pinnedPath={MANAGER_STATUS_FILE_PATH}
threadId="thr_manager"
/>,
- { wrapper },
);
const iframe = container.querySelector("iframe");
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("src")).toBe(
- "/api/v1/threads/thr_manager/thread-storage/files/reports/status.html",
+ "/api/v1/threads/thr_manager/thread-storage/files/reports/preview.html",
);
expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts");
expect(iframe?.getAttribute("srcdoc")).toBeNull();
- expect(container.textContent).not.toContain("bbStatusState");
- expect(container.textContent).not.toContain("bbThreadTell");
- });
-
- it.each([
- MANAGER_STATUS_HTML_FILE_PATH,
- MANAGER_STATUS_INDEX_FILE_PATH,
- MANAGER_STATUS_MARKDOWN_FILE_PATH,
- ])("renders %s through the unified STATUS route", async (activePath) => {
- installFetchRoutes([
- {
- pathname: "/api/v1/threads/thr_manager/status-version",
- handler: () => jsonResponse({ source: "html", hash: "status-hash" }),
- },
- ]);
- const { wrapper } = createQueryClientTestHarness();
- const { container } = render(
- ,
- { wrapper },
- );
-
- await waitFor(() => {
- expect(container.querySelector("iframe")?.getAttribute("src")).toBe(
- "/api/v1/threads/thr_manager/status/?v=status-hash",
- );
- });
- });
-
- it("renders STATUS.md directly with relative status assets resolved", async () => {
- installFetchRoutes([
- {
- pathname: "/api/v1/threads/thr_manager/status-version",
- handler: () => jsonResponse({ source: "md", hash: "status-hash" }),
- },
- {
- pathname: "/api/v1/threads/thr_manager/thread-storage/content",
- handler: (request) => {
- const url = new URL(request.url);
- expect(url.searchParams.get("path")).toBe(
- MANAGER_STATUS_MARKDOWN_FILE_PATH,
- );
- return new Response(
- "\n\n[details](details.html)",
- {
- headers: { "content-type": "text/markdown" },
- },
- );
- },
- },
- ]);
- const { wrapper } = createQueryClientTestHarness();
- const { container } = render(
- ,
- { wrapper },
- );
-
- await waitFor(() => {
- expect(
- screen.getByRole("img", { name: "chart" }).getAttribute("src"),
- ).toBe("/api/v1/threads/thr_manager/status/chart.png");
- });
- expect(
- screen.getByRole("link", { name: "details" }).getAttribute("href"),
- ).toBe("/api/v1/threads/thr_manager/status/details.html");
- expect(
- screen.queryByRole("tablist", { name: "Markdown view mode" }),
- ).toBeNull();
- expect(screen.queryByText(MANAGER_STATUS_MARKDOWN_FILE_PATH)).toBeNull();
- expect(
- screen.queryByRole("button", { name: "Copy file path" }),
- ).toBeNull();
- expect(container.querySelector("iframe")).toBeNull();
- });
-
- it("renders empty STATUS directly", async () => {
- installFetchRoutes([
- {
- pathname: "/api/v1/threads/thr_manager/status-version",
- handler: () => jsonResponse({ source: "empty", hash: "status-hash" }),
- },
- ]);
- const { wrapper } = createQueryClientTestHarness();
- const { container } = render(
- ,
- { wrapper },
- );
-
- await screen.findByText("Manager hasn't written a status yet.");
- expect(container.querySelector("iframe")).toBeNull();
+ expect(container.textContent).not.toContain("window.bb");
});
});
diff --git a/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx b/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx
index 5ef63f160..998b4b6c3 100644
--- a/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx
@@ -1,24 +1,9 @@
-import { useMemo } from "react";
-import { defaultUrlTransform, type UrlTransform } from "react-markdown";
import {
FilePreview as FilePreviewSurface,
type FilePreviewFile,
- type FilePreviewHeaderMode,
} from "./FilePreview";
-import {
- MANAGER_STATUS_MARKDOWN_FILE_PATH,
- isManagerStatusStorageFilePath,
-} from "./managerStorage";
-import {
- useThreadStatusMarkdownPreview,
- useThreadStatusVersion,
-} from "@/hooks/queries/thread-queries";
import { HttpError } from "@/lib/api";
-import {
- buildThreadStatusContentUrl,
- buildThreadStorageRawContentUrl,
-} from "@/lib/file-content-urls";
-import type { ThreadStatusVersionResponse } from "@bb/server-contract";
+import { buildThreadStorageRawContentUrl } from "@/lib/file-content-urls";
import type {
FilePreview,
TextFilePreview,
@@ -30,7 +15,6 @@ import { isHtmlFilePreviewPath } from "@/lib/file-preview";
// realistic previews, but omit allow-same-origin so the frame gets an opaque
// origin and cannot read bb app cookies, storage, or same-origin APIs.
const GENERIC_HTML_IFRAME_SANDBOX = "allow-scripts";
-const MANAGER_STATUS_HEADER_MODE: FilePreviewHeaderMode = "none";
interface FilePreviewBaseProps {
activePath: string;
@@ -43,13 +27,11 @@ interface FilePreviewBaseProps {
}
interface ThreadStorageFilePreviewProps extends FilePreviewBaseProps {
- pinnedPath: string;
threadId: string;
}
interface SecondaryPanelFilePreviewProps extends FilePreviewBaseProps {
htmlPreviewUrl?: string | null;
- pendingNotFoundPath?: string;
statusLabel?: WorkspaceFilePreviewStatusLabel | null;
}
@@ -68,37 +50,6 @@ function buildTextPreviewFile({
};
}
-const STATUS_RELATIVE_ASSET_URL_PATTERN =
- /^(?![a-z][a-z\d+.-]*:|\/\/|\/|#|\?)/iu;
-
-function isStatusRelativeAssetUrl(url: string): boolean {
- return url.length > 0 && STATUS_RELATIVE_ASSET_URL_PATTERN.test(url);
-}
-
-function resolveStatusAssetUrl(assetBaseUrl: string, url: string): string {
- const baseUrl = new URL(assetBaseUrl, window.location.origin);
- const assetUrl = new URL(url, baseUrl);
- return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`;
-}
-
-function createStatusMarkdownUrlTransform(assetBaseUrl: string): UrlTransform {
- return (url) => {
- const transformedUrl = defaultUrlTransform(url);
- if (!isStatusRelativeAssetUrl(transformedUrl)) {
- return transformedUrl;
- }
-
- return resolveStatusAssetUrl(assetBaseUrl, transformedUrl);
- };
-}
-
-function buildManagerStatusPreviewUrl(
- threadId: string,
- version: ThreadStatusVersionResponse,
-): string {
- return buildThreadStatusContentUrl(threadId, version.hash);
-}
-
export function SecondaryPanelFilePreview({
activePath,
copyPath = null,
@@ -108,22 +59,10 @@ export function SecondaryPanelFilePreview({
isLoading,
lineNumber = null,
onOpenInEditor,
- pendingNotFoundPath,
statusLabel = null,
}: SecondaryPanelFilePreviewProps) {
if (error) {
const isNotFound = error instanceof HttpError && error.status === 404;
- if (isNotFound && activePath === pendingNotFoundPath) {
- return (
-
- );
- }
return (
buildThreadStatusContentUrl(threadId),
- [threadId],
- );
- const statusMarkdownUrlTransform = useMemo(() => {
- return createStatusMarkdownUrlTransform(statusMarkdownAssetBaseUrl);
- }, [statusMarkdownAssetBaseUrl]);
-
- if (isManagerStatusTab) {
- if (statusVersion.isError) {
- return (
-
- );
- }
-
- if (!statusVersion.data) {
- return (
-
- );
- }
-
- if (statusVersion.data.source === "empty") {
- return (
-
- );
- }
-
- if (statusVersion.data.source === "md") {
- if (statusMarkdownPreview.isError) {
- return (
-
- );
- }
-
- if (!statusMarkdownPreview.data) {
- return (
-
- );
- }
-
- if (statusMarkdownPreview.data.kind !== "text") {
- return (
-
- );
- }
-
- return (
-
- );
- }
-
- return (
-
- );
- }
-
return (
);
}
diff --git a/apps/app/src/components/secondary-panel/managerStorage.ts b/apps/app/src/components/secondary-panel/managerStorage.ts
deleted file mode 100644
index 68225d95a..000000000
--- a/apps/app/src/components/secondary-panel/managerStorage.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export {
- MANAGER_STATUS_FILE_PATH,
- MANAGER_STATUS_HTML_FILE_PATH,
- MANAGER_STATUS_INDEX_FILE_PATH,
- MANAGER_STATUS_MARKDOWN_FILE_PATH,
- isManagerStatusStorageFilePath,
- resolveManagerStatusStorageTabPath,
- resolvePinnedManagerStorageFilePath,
-} from "@/lib/manager-status-storage";
-export type {
- ManagerStorageFileEntry,
- ManagerStorageFiles,
-} from "@/lib/manager-status-storage";
diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.tsx b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.tsx
index a0e71ecc4..c25947537 100644
--- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.tsx
+++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.tsx
@@ -17,6 +17,7 @@ import {
getFixedPanelTabsStateStorageKey,
parseFixedPanelTabsState,
serializeFixedPanelTabsState,
+ type AppFixedPanelTab,
type FixedPanelTab,
type FixedPanelTabsState,
type HostFilePreviewFixedPanelTab,
@@ -25,13 +26,7 @@ import {
type WorkspaceFilePreviewFixedPanelTab,
} from "@/lib/fixed-panel-tabs-state";
import { useFixedPanelTabsState } from "@/lib/fixed-panel-tabs";
-import {
- MANAGER_STATUS_FILE_PATH,
- MANAGER_STATUS_HTML_FILE_PATH,
- MANAGER_STATUS_INDEX_FILE_PATH,
- MANAGER_STATUS_MARKDOWN_FILE_PATH,
-} from "./managerStorage";
-import { useThreadFileTabs } from "./useThreadFileTabs";
+import { STATUS_APP_ID, useThreadFileTabs } from "./useThreadFileTabs";
const NOW = 1_700_000_000_000;
const WORKING_TREE_SOURCE: EnvironmentFilePreviewSource = {
@@ -48,6 +43,7 @@ interface TestWrapperProps {
}
interface HookProps {
+ apps?: readonly { id: string }[];
environmentId: string | null | undefined;
storageFiles: readonly { path: string }[] | undefined;
threadId: string;
@@ -118,6 +114,10 @@ function isHostFilePreviewTab(
return tab.kind === "host-file-preview";
}
+function isAppTab(tab: FixedPanelTab): tab is AppFixedPanelTab {
+ return tab.kind === "app";
+}
+
function workspaceFileStates(
tabs: readonly SecondaryFileFixedPanelTab[],
): WorkspaceFileTabState[] {
@@ -144,6 +144,14 @@ function storageFilePaths(
return tabs.filter(isStorageFilePreviewTab).map((tab) => tab.path);
}
+function appTabIds(tabs: readonly SecondaryFileFixedPanelTab[]): string[] {
+ return tabs.filter(isAppTab).map((tab) => tab.appId);
+}
+
+function tabIds(tabs: readonly SecondaryFileFixedPanelTab[]): string[] {
+ return tabs.map((tab) => tab.id);
+}
+
function workspaceFileTabId(path: string): string {
return `workspace-file-preview:${encodeURIComponent(path)}`;
}
@@ -156,6 +164,10 @@ function hostFileTabId(path: string): string {
return `host-file-preview:${encodeURIComponent(path)}`;
}
+function appTabId(appId: string): string {
+ return `app:${encodeURIComponent(appId)}`;
+}
+
function newTabId(): string {
return "new-tab";
}
@@ -180,7 +192,7 @@ function createStoredStorageTab(
): ThreadStorageFilePreviewFixedPanelTab {
return {
id: storageFileTabId(path),
- isPinned: path === MANAGER_STATUS_FILE_PATH,
+ isPinned: false,
kind: "thread-storage-file-preview",
path,
};
@@ -220,6 +232,10 @@ function getStoredStoragePaths(state: FixedPanelTabsState): string[] {
.map((tab) => tab.path);
}
+function getStoredAppIds(state: FixedPanelTabsState): string[] {
+ return state.secondary.tabs.filter(isAppTab).map((tab) => tab.appId);
+}
+
afterEach(() => {
cleanup();
window.localStorage.clear();
@@ -298,18 +314,14 @@ describe("useThreadFileTabs", () => {
expect(result.current.activeWorkspaceFilePath).toBe("src/one.ts");
});
- it("keeps workspace and storage active tabs mutually exclusive", async () => {
+ it("keeps workspace and storage active tabs mutually exclusive", () => {
const { result } = renderThreadFileTabsHook({
environmentId: "env-one",
threadType: "manager",
- storageFiles: [{ path: "STATUS" }, { path: "notes.md" }],
+ storageFiles: [{ path: "notes.md" }],
threadId: "thr-manager",
});
- await waitFor(() => {
- expect(result.current.activeStorageFilePath).toBe("STATUS");
- });
-
act(() => {
result.current.openWorkspaceFile(
buildWorkspaceFileTab({
@@ -372,39 +384,31 @@ describe("useThreadFileTabs", () => {
expect(result.current.activeHostFilePath).toBeNull();
});
- it("orders file tabs by open order with the pinned status tab first", async () => {
+ it("orders file tabs by open order with the pinned status app first", async () => {
const { result } = renderThreadFileTabsHook({
+ apps: [{ id: STATUS_APP_ID }],
environmentId: "env-one",
threadType: "manager",
- storageFiles: [{ path: "STATUS.md" }, { path: "notes.md" }],
+ storageFiles: [{ path: "notes.md" }],
threadId: "thr-manager-open-order",
});
await waitFor(() => {
- expect(storageFilePaths(result.current.orderedSecondaryFileTabs)).toEqual(
- ["STATUS"],
- );
+ expect(appTabIds(result.current.orderedSecondaryFileTabs)).toEqual([
+ STATUS_APP_ID,
+ ]);
});
act(() => {
result.current.openWorkspaceFile(
buildWorkspaceFileTab({ lineNumber: null, path: "src/app.ts" }),
);
- });
- act(() => {
result.current.openStorageFile("notes.md");
- });
- act(() => {
result.current.openHostFile({ lineNumber: null, path: "/tmp/host.md" });
});
- // notes.md is opened after the workspace file, so it stays after it —
- // tabs interleave by open order rather than grouping by type, while the
- // pinned STATUS tab remains first.
- expect(
- result.current.orderedSecondaryFileTabs.map((tab) => tab.id),
- ).toEqual([
- storageFileTabId("STATUS"),
+ expect(tabIds(result.current.orderedSecondaryFileTabs)).toEqual([
+ appTabId(STATUS_APP_ID),
workspaceFileTabId("src/app.ts"),
storageFileTabId("notes.md"),
hostFileTabId("/tmp/host.md"),
@@ -544,112 +548,37 @@ describe("useThreadFileTabs", () => {
expect(result.current.activeWorkspaceFilePath).toBeNull();
});
- it("seeds and prunes manager storage tabs", async () => {
+ it("prunes manager storage tabs against the current storage file list", async () => {
const { result, rerender } = renderThreadFileTabsHook({
environmentId: null,
threadType: "manager",
- storageFiles: [{ path: "STATUS" }, { path: "notes.md" }],
+ storageFiles: [{ path: "notes.md" }, { path: "plan.md" }],
threadId: "thr-manager",
});
- await waitFor(() => {
- expect(storageFilePaths(result.current.orderedSecondaryFileTabs)).toEqual(
- ["STATUS"],
- );
- });
-
act(() => {
result.current.openStorageFile("notes.md");
+ result.current.openStorageFile("plan.md");
});
expect(storageFilePaths(result.current.orderedSecondaryFileTabs)).toEqual([
- "STATUS",
"notes.md",
+ "plan.md",
]);
+ expect(result.current.activeStorageFilePath).toBe("plan.md");
rerender({
- environmentId: null,
- threadType: "manager",
- storageFiles: [{ path: "STATUS" }],
- threadId: "thr-manager",
- });
-
- await waitFor(() => {
- expect(storageFilePaths(result.current.orderedSecondaryFileTabs)).toEqual(
- ["STATUS"],
- );
- });
- expect(result.current.activeStorageFilePath).toBe("STATUS");
- });
-
- it("uses the unified STATUS tab even when STATUS.html is present", async () => {
- const { result } = renderThreadFileTabsHook({
- environmentId: null,
- threadType: "manager",
- storageFiles: [
- { path: MANAGER_STATUS_MARKDOWN_FILE_PATH },
- { path: MANAGER_STATUS_HTML_FILE_PATH },
- { path: "notes.md" },
- ],
- threadId: "thr-manager-html-status",
- });
-
- await waitFor(() => {
- expect(storageFilePaths(result.current.orderedSecondaryFileTabs)).toEqual(
- [MANAGER_STATUS_FILE_PATH],
- );
- });
- expect(result.current.activeStorageFilePath).toBe(
- MANAGER_STATUS_FILE_PATH,
- );
- expect(result.current.pinnedStorageFilePath).toBe(
- MANAGER_STATUS_FILE_PATH,
- );
-
- act(() => {
- result.current.openStorageFile(MANAGER_STATUS_HTML_FILE_PATH);
- result.current.openStorageFile(MANAGER_STATUS_MARKDOWN_FILE_PATH);
- result.current.openStorageFile(MANAGER_STATUS_INDEX_FILE_PATH);
- });
-
- expect(storageFilePaths(result.current.orderedSecondaryFileTabs)).toEqual([
- MANAGER_STATUS_FILE_PATH,
- ]);
- expect(result.current.activeStorageFilePath).toBe(
- MANAGER_STATUS_FILE_PATH,
- );
- });
-
- it("uses STATUS for the pinned manager storage tab without STATUS.html", async () => {
- const { result } = renderThreadFileTabsHook({
- environmentId: null,
- threadType: "manager",
- storageFiles: [{ path: MANAGER_STATUS_MARKDOWN_FILE_PATH }],
- threadId: "thr-manager-md-status",
- });
-
- await waitFor(() => {
- expect(storageFilePaths(result.current.orderedSecondaryFileTabs)).toEqual(
- [MANAGER_STATUS_FILE_PATH],
- );
- });
- expect(result.current.pinnedStorageFilePath).toBe(MANAGER_STATUS_FILE_PATH);
- });
-
- it("keeps the STATUS pending tab when no status file exists", async () => {
- const { result } = renderThreadFileTabsHook({
environmentId: null,
threadType: "manager",
storageFiles: [{ path: "notes.md" }],
- threadId: "thr-manager-no-status",
+ threadId: "thr-manager",
});
await waitFor(() => {
expect(storageFilePaths(result.current.orderedSecondaryFileTabs)).toEqual(
- [MANAGER_STATUS_FILE_PATH],
+ ["notes.md"],
);
});
- expect(result.current.activeStorageFilePath).toBe(MANAGER_STATUS_FILE_PATH);
- expect(result.current.pinnedStorageFilePath).toBe(MANAGER_STATUS_FILE_PATH);
+ expect(result.current.activeStorageFilePath).toBeNull();
});
it("keeps seeded manager storage tabs while thread type is unresolved", async () => {
@@ -660,7 +589,7 @@ describe("useThreadFileTabs", () => {
createEmptyFixedPanelTabsState({
secondary: {
tabs: [
- createStoredStorageTab("STATUS"),
+ createStoredStorageTab("overview.md"),
createStoredStorageTab("notes.md"),
],
activeTabId: storageFileTabId("notes.md"),
@@ -680,7 +609,7 @@ describe("useThreadFileTabs", () => {
[],
);
expect(getStoredStoragePaths(readStoredState(threadId))).toEqual([
- "STATUS",
+ "overview.md",
"notes.md",
]);
expect(readStoredState(threadId).secondary.activeTabId).toBe(
@@ -690,13 +619,13 @@ describe("useThreadFileTabs", () => {
rerender({
environmentId: null,
threadType: "manager",
- storageFiles: [{ path: "STATUS" }, { path: "notes.md" }],
+ storageFiles: [{ path: "overview.md" }, { path: "notes.md" }],
threadId,
});
await waitFor(() => {
expect(storageFilePaths(result.current.orderedSecondaryFileTabs)).toEqual(
- ["STATUS", "notes.md"],
+ ["overview.md", "notes.md"],
);
});
expect(result.current.activeStorageFilePath).toBe("notes.md");
@@ -752,7 +681,7 @@ describe("useThreadFileTabs", () => {
expect(result.current.activeWorkspaceFilePath).toBe("src/app.ts");
});
- it("seeds the pinned manager tab without stealing active seeded storage", async () => {
+ it("keeps active seeded storage when it remains in the file list", async () => {
vi.spyOn(Date, "now").mockReturnValue(NOW);
const threadId = "thr-manager-seeded-active";
seedStoredState(
@@ -769,101 +698,90 @@ describe("useThreadFileTabs", () => {
const { result } = renderThreadFileTabsHook({
environmentId: null,
threadType: "manager",
- storageFiles: [{ path: "STATUS" }, { path: "notes.md" }],
+ storageFiles: [{ path: "notes.md" }],
threadId,
});
await waitFor(() => {
expect(storageFilePaths(result.current.orderedSecondaryFileTabs)).toEqual(
- ["STATUS", "notes.md"],
+ ["notes.md"],
);
});
expect(result.current.activeStorageFilePath).toBe("notes.md");
});
- it("keeps the pinned manager storage tab open when close is requested", async () => {
+ it("closes manager storage tabs", () => {
const { result } = renderThreadFileTabsHook({
environmentId: null,
threadType: "manager",
- storageFiles: [{ path: "STATUS" }],
- threadId: "thr-manager-pinned",
+ storageFiles: [{ path: "notes.md" }],
+ threadId: "thr-manager-storage-close",
});
- await waitFor(() => {
- expect(storageFilePaths(result.current.orderedSecondaryFileTabs)).toEqual(
- ["STATUS"],
- );
+ act(() => {
+ result.current.openStorageFile("notes.md");
});
+ expect(result.current.activeStorageFilePath).toBe("notes.md");
act(() => {
- result.current.closeStorageFileTab("STATUS");
+ result.current.closeStorageFileTab("notes.md");
});
- expect(storageFilePaths(result.current.orderedSecondaryFileTabs)).toEqual([
- "STATUS",
- ]);
- expect(result.current.activeStorageFilePath).toBe("STATUS");
+ expect(storageFilePaths(result.current.orderedSecondaryFileTabs)).toEqual(
+ [],
+ );
+ expect(result.current.activeStorageFilePath).toBeNull();
});
- it("returns to the pinned manager storage tab when the active storage tab closes", async () => {
+ it("opens the status app tab for managers and keeps it pinned", async () => {
const { result } = renderThreadFileTabsHook({
+ apps: [{ id: STATUS_APP_ID }],
environmentId: null,
threadType: "manager",
- storageFiles: [{ path: "STATUS" }, { path: "notes.md" }],
- threadId: "thr-manager-close-active",
+ storageFiles: undefined,
+ threadId: "thr-manager-status-app",
});
await waitFor(() => {
- expect(result.current.activeStorageFilePath).toBe("STATUS");
- });
-
- act(() => {
- result.current.openStorageFile("notes.md");
+ expect(appTabIds(result.current.orderedSecondaryFileTabs)).toEqual([
+ STATUS_APP_ID,
+ ]);
});
- expect(result.current.activeStorageFilePath).toBe("notes.md");
+ expect(result.current.activeAppId).toBe(STATUS_APP_ID);
act(() => {
- result.current.closeStorageFileTab("notes.md");
+ result.current.closeAppTab(STATUS_APP_ID);
});
- await waitFor(() => {
- expect(result.current.activeStorageFilePath).toBe("STATUS");
- });
+ expect(appTabIds(result.current.orderedSecondaryFileTabs)).toEqual([
+ STATUS_APP_ID,
+ ]);
+ expect(result.current.activeAppId).toBe(STATUS_APP_ID);
});
- it("keeps the pinned manager storage tab when the file list omits it", async () => {
- vi.spyOn(Date, "now").mockReturnValue(NOW);
- const threadId = "thr-manager-pinned-omitted";
- seedStoredState(
- threadId,
- createEmptyFixedPanelTabsState({
- secondary: {
- tabs: [
- createStoredStorageTab("STATUS"),
- createStoredStorageTab("notes.md"),
- ],
- activeTabId: storageFileTabId("STATUS"),
- isOpen: true,
- },
- lastUsedAt: NOW,
- }),
- );
+ it("opens an app tab from launcher search selection", () => {
const { result } = renderThreadFileTabsHook({
- environmentId: null,
- threadType: "manager",
- storageFiles: [{ path: "notes.md" }],
- threadId,
+ apps: [{ id: "demo" }],
+ environmentId: "env-one",
+ threadType: "standard",
+ storageFiles: undefined,
+ threadId: "thr-app-selection",
});
- await waitFor(() => {
- expect(storageFilePaths(result.current.orderedSecondaryFileTabs)).toEqual(
- ["STATUS", "notes.md"],
- );
+ act(() => {
+ result.current.openNewTab();
+ result.current.selectFileSearchResult({
+ source: "app",
+ appId: "demo",
+ });
});
- expect(result.current.activeStorageFilePath).toBe("STATUS");
- expect(getStoredStoragePaths(readStoredState(threadId))).toEqual([
- "STATUS",
- "notes.md",
+
+ expect(appTabIds(result.current.orderedSecondaryFileTabs)).toEqual([
+ "demo",
+ ]);
+ expect(result.current.activeAppId).toBe("demo");
+ expect(getStoredAppIds(readStoredState("thr-app-selection"))).toEqual([
+ "demo",
]);
});
@@ -915,7 +833,7 @@ describe("useThreadFileTabs", () => {
createEmptyFixedPanelTabsState({
secondary: {
tabs: [
- createStoredStorageTab("STATUS"),
+ createStoredStorageTab("overview.md"),
createStoredStorageTab("notes.md"),
],
activeTabId: storageFileTabId("notes.md"),
@@ -927,7 +845,7 @@ describe("useThreadFileTabs", () => {
const { result } = renderThreadFileTabsHook({
environmentId: null,
threadType: "manager",
- storageFiles: [{ path: "STATUS" }, { path: "notes.md" }],
+ storageFiles: [{ path: "overview.md" }, { path: "notes.md" }],
threadId,
});
@@ -939,12 +857,12 @@ describe("useThreadFileTabs", () => {
act(() => {
result.current.openStorageFile("notes.md");
result.current.activateStorageFileTab("notes.md");
- result.current.closeStorageFileTab("STATUS");
+ result.current.closeStorageFileTab("missing.md");
});
expect(readStoredState(threadId).lastUsedAt).toBe(NOW);
expect(getStoredStoragePaths(readStoredState(threadId))).toEqual([
- "STATUS",
+ "overview.md",
"notes.md",
]);
});
@@ -955,8 +873,8 @@ describe("useThreadFileTabs", () => {
threadId,
createEmptyFixedPanelTabsState({
secondary: {
- tabs: [createStoredStorageTab("STATUS")],
- activeTabId: storageFileTabId("STATUS"),
+ tabs: [createStoredStorageTab("notes.md")],
+ activeTabId: storageFileTabId("notes.md"),
isOpen: true,
},
lastUsedAt: Date.now(),
diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
index ff848bdaf..ecc890f55 100644
--- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
+++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect } from "react";
+import { useCallback, useEffect, useMemo } from "react";
import type { ThreadType } from "@bb/domain";
import {
useFixedPanelTabsState,
@@ -6,10 +6,12 @@ import {
} from "@/lib/fixed-panel-tabs";
import {
areFixedPanelTabsEquivalent,
+ createAppFixedPanelTab,
createHostFilePreviewFixedPanelTab,
createNewTabFixedPanelTab,
createThreadStorageFilePreviewFixedPanelTab,
createWorkspaceFilePreviewFixedPanelTab,
+ type AppFixedPanelTab,
type FixedPanelTab,
type FixedPanelTabsState,
type HostFilePreviewFixedPanelTab,
@@ -23,13 +25,15 @@ import {
type HostFileTabState,
type WorkspaceFileTabState,
} from "@/lib/file-preview";
-import {
- isManagerStatusStorageFilePath,
- resolveManagerStatusStorageTabPath,
- resolvePinnedManagerStorageFilePath,
-} from "./managerStorage";
+
+export const STATUS_APP_ID = "status";
+
+interface ThreadAppTabDescriptor {
+ id: string;
+}
interface UseThreadFileTabsParams {
+ apps?: readonly ThreadAppTabDescriptor[] | undefined;
threadId: string | null | undefined;
environmentId: string | null | undefined;
threadType: ThreadType | undefined;
@@ -58,9 +62,15 @@ export interface FileSearchThreadStorageSelection {
path: string;
}
+export interface FileSearchAppSelection {
+ source: "app";
+ appId: string;
+}
+
export type FileSearchSelection =
| FileSearchWorkspaceSelection
- | FileSearchThreadStorageSelection;
+ | FileSearchThreadStorageSelection
+ | FileSearchAppSelection;
function isWorkspaceFilePreviewTab(
tab: FixedPanelTab,
@@ -80,6 +90,10 @@ function isStorageFilePreviewTab(
return tab.kind === "thread-storage-file-preview";
}
+function isAppTab(tab: FixedPanelTab): tab is AppFixedPanelTab {
+ return tab.kind === "app";
+}
+
function isNewTab(tab: FixedPanelTab): tab is NewTabFixedPanelTab {
return tab.kind === "new-tab";
}
@@ -118,19 +132,6 @@ function setSecondaryTabs({
};
}
-function removeMismatchedManagerStatusTabs(
- tabs: readonly FixedPanelTab[],
- pinnedStorageFilePath: string,
-): readonly FixedPanelTab[] {
- const nextTabs = tabs.filter(
- (tab) =>
- !isStorageFilePreviewTab(tab) ||
- tab.path === pinnedStorageFilePath ||
- !isManagerStatusStorageFilePath(tab.path),
- );
- return nextTabs.length === tabs.length ? tabs : nextTabs;
-}
-
function upsertSecondaryTab(
tabs: readonly FixedPanelTab[],
nextTab: FixedPanelTab,
@@ -184,6 +185,16 @@ function pruneStorageTabs(
return nextTabs.length === tabs.length ? tabs : nextTabs;
}
+function pruneAppTabs(
+ tabs: readonly FixedPanelTab[],
+ knownAppIds: ReadonlySet,
+): readonly FixedPanelTab[] {
+ const nextTabs = tabs.filter(
+ (tab) => !isAppTab(tab) || knownAppIds.has(tab.appId),
+ );
+ return nextTabs.length === tabs.length ? tabs : nextTabs;
+}
+
function isActiveTabStillOpen(
tabs: readonly FixedPanelTab[],
activeTabId: string | null,
@@ -191,26 +202,15 @@ function isActiveTabStillOpen(
return activeTabId !== null && tabs.some((tab) => tab.id === activeTabId);
}
-function createStorageTab(
- path: string,
- pinnedStorageFilePath: string,
-): ThreadStorageFilePreviewFixedPanelTab {
- const tabPath = resolveManagerStatusStorageTabPath(path);
+function createStorageTab(path: string): ThreadStorageFilePreviewFixedPanelTab {
return createThreadStorageFilePreviewFixedPanelTab({
- isPinned: tabPath === pinnedStorageFilePath,
- path: tabPath,
+ isPinned: false,
+ path,
});
}
-function getManagerDefaultActiveTabId(
- tabs: readonly FixedPanelTab[],
- pinnedStorageFilePath: string,
-): string {
- const pinnedTab = findStorageFileTab(tabs, pinnedStorageFilePath);
- return (
- pinnedTab?.id ??
- createStorageTab(pinnedStorageFilePath, pinnedStorageFilePath).id
- );
+function createAppTab(appId: string): AppFixedPanelTab {
+ return createAppFixedPanelTab({ appId });
}
function findWorkspaceTab(
@@ -249,6 +249,18 @@ function findStorageFileTab(
return null;
}
+function findAppTab(
+ tabs: readonly FixedPanelTab[],
+ appId: string,
+): AppFixedPanelTab | null {
+ for (const tab of tabs) {
+ if (isAppTab(tab) && tab.appId === appId) {
+ return tab;
+ }
+ }
+ return null;
+}
+
function findNewTab(
tabs: readonly FixedPanelTab[],
): NewTabFixedPanelTab | null {
@@ -303,13 +315,17 @@ interface BuildOrderedSecondaryFileTabsArgs {
isManagerThread: boolean;
}
-function isPinnedStorageTab(tab: SecondaryFileFixedPanelTab): boolean {
- return tab.kind === "thread-storage-file-preview" && tab.isPinned;
+function isPinnedAppTab(tab: SecondaryFileFixedPanelTab): boolean {
+ return tab.kind === "app" && tab.appId === STATUS_APP_ID;
+}
+
+function isPinnedSecondaryFileTab(tab: SecondaryFileFixedPanelTab): boolean {
+ return isPinnedAppTab(tab);
}
/**
* Flattens the secondary panel's tabs into the closable file-tab strip, in the
- * order the user opened them. The pinned manager status tab is floated to the
+ * order the user opened them. The pinned manager status app is floated to the
* front; everything else (workspace, host, storage, new tab) keeps its
* insertion order regardless of type.
*/
@@ -330,6 +346,7 @@ function buildOrderedSecondaryFileTabs({
}
break;
case "host-file-preview":
+ case "app":
case "new-tab":
displayable.push(tab);
break;
@@ -345,12 +362,13 @@ function buildOrderedSecondaryFileTabs({
}
}
return [
- ...displayable.filter(isPinnedStorageTab),
- ...displayable.filter((tab) => !isPinnedStorageTab(tab)),
+ ...displayable.filter(isPinnedSecondaryFileTab),
+ ...displayable.filter((tab) => !isPinnedSecondaryFileTab(tab)),
];
}
export function useThreadFileTabs({
+ apps,
threadId,
environmentId,
threadType,
@@ -360,11 +378,13 @@ export function useThreadFileTabs({
const updateFixedPanelTabsState = useUpdateFixedPanelTabsState(threadId);
const isThreadResolved = threadType !== undefined;
const isManagerThread = threadType === "manager";
- const pinnedStorageFilePath =
- resolvePinnedManagerStorageFilePath(storageFiles);
const resolvedEnvironmentId = isThreadResolved
? (environmentId ?? null)
: undefined;
+ const appIds = useMemo(
+ () => (apps ? new Set(apps.map((app) => app.id)) : null),
+ [apps],
+ );
useEffect(() => {
if (resolvedEnvironmentId === undefined) return;
@@ -411,25 +431,17 @@ export function useThreadFileTabs({
}, [isManagerThread, isThreadResolved, updateFixedPanelTabsState]);
useEffect(() => {
- if (!isManagerThread) {
- return;
- }
+ if (!isThreadResolved || !storageFiles) return;
+ if (!isManagerThread) return;
updateFixedPanelTabsState((state) => {
- const pinnedTab = createStorageTab(
- pinnedStorageFilePath,
- pinnedStorageFilePath,
- );
- const baseTabs = removeMismatchedManagerStatusTabs(
- state.secondary.tabs,
- pinnedStorageFilePath,
- );
- const tabs = upsertSecondaryTab(baseTabs, pinnedTab);
+ const knownPaths = new Set(storageFiles.map((file) => file.path));
+ const tabs = pruneStorageTabs(state.secondary.tabs, knownPaths);
const activeTabId = isActiveTabStillOpen(
tabs,
state.secondary.activeTabId,
)
? state.secondary.activeTabId
- : pinnedTab.id;
+ : null;
return setSecondaryTabs({
activeTabId,
isOpen: state.secondary.isOpen,
@@ -438,28 +450,22 @@ export function useThreadFileTabs({
});
});
}, [
- fixedPanelTabsState.secondary.activeTabId,
- fixedPanelTabsState.secondary.tabs,
isManagerThread,
- pinnedStorageFilePath,
+ isThreadResolved,
+ storageFiles,
updateFixedPanelTabsState,
]);
useEffect(() => {
- if (!isThreadResolved || !storageFiles) return;
- if (!isManagerThread) return;
+ if (!isThreadResolved || appIds === null) return;
updateFixedPanelTabsState((state) => {
- const knownPaths = new Set([
- pinnedStorageFilePath,
- ...storageFiles.map((file) => file.path),
- ]);
- const tabs = pruneStorageTabs(state.secondary.tabs, knownPaths);
+ const tabs = pruneAppTabs(state.secondary.tabs, appIds);
const activeTabId = isActiveTabStillOpen(
tabs,
state.secondary.activeTabId,
)
? state.secondary.activeTabId
- : getManagerDefaultActiveTabId(tabs, pinnedStorageFilePath);
+ : null;
return setSecondaryTabs({
activeTabId,
isOpen: state.secondary.isOpen,
@@ -467,13 +473,29 @@ export function useThreadFileTabs({
tabs,
});
});
- }, [
- isManagerThread,
- isThreadResolved,
- pinnedStorageFilePath,
- storageFiles,
- updateFixedPanelTabsState,
- ]);
+ }, [appIds, isThreadResolved, updateFixedPanelTabsState]);
+
+ useEffect(() => {
+ if (!isManagerThread || appIds === null || !appIds.has(STATUS_APP_ID)) {
+ return;
+ }
+ updateFixedPanelTabsState((state) => {
+ const statusAppTab = createAppTab(STATUS_APP_ID);
+ const tabs = upsertSecondaryTab(state.secondary.tabs, statusAppTab);
+ const activeTabId = isActiveTabStillOpen(
+ tabs,
+ state.secondary.activeTabId,
+ )
+ ? state.secondary.activeTabId
+ : statusAppTab.id;
+ return setSecondaryTabs({
+ activeTabId,
+ isOpen: state.secondary.isOpen,
+ state,
+ tabs,
+ });
+ });
+ }, [appIds, isManagerThread, updateFixedPanelTabsState]);
const openWorkspaceFile = useCallback(
({ lineNumber, path, source, statusLabel }: WorkspaceFileTabState) => {
@@ -558,7 +580,7 @@ export function useThreadFileTabs({
const openStorageFile = useCallback(
(path: string) => {
if (!isManagerThread) return;
- const nextTab = createStorageTab(path, pinnedStorageFilePath);
+ const nextTab = createStorageTab(path);
updateFixedPanelTabsState((state) => {
const tabs = upsertSecondaryTab(state.secondary.tabs, nextTab);
if (
@@ -576,7 +598,7 @@ export function useThreadFileTabs({
});
});
},
- [isManagerThread, pinnedStorageFilePath, updateFixedPanelTabsState],
+ [isManagerThread, updateFixedPanelTabsState],
);
const openHostFile = useCallback(
@@ -651,9 +673,76 @@ export function useThreadFileTabs({
[updateFixedPanelTabsState],
);
+ const openApp = useCallback(
+ (appId: string) => {
+ const nextTab = createAppTab(appId);
+ updateFixedPanelTabsState((state) => {
+ const tabs = upsertSecondaryTab(state.secondary.tabs, nextTab);
+ if (
+ tabs === state.secondary.tabs &&
+ state.secondary.activeTabId === nextTab.id &&
+ state.secondary.isOpen
+ ) {
+ return state;
+ }
+ return setSecondaryTabs({
+ activeTabId: nextTab.id,
+ isOpen: true,
+ state,
+ tabs,
+ });
+ });
+ },
+ [updateFixedPanelTabsState],
+ );
+
+ const closeAppTab = useCallback(
+ (appId: string) => {
+ if (appId === STATUS_APP_ID) return;
+ updateFixedPanelTabsState((state) => {
+ const tab = findAppTab(state.secondary.tabs, appId);
+ if (!tab) {
+ return state;
+ }
+ const tabs = removeSecondaryTab(state.secondary.tabs, tab.id);
+ return setSecondaryTabs({
+ activeTabId:
+ state.secondary.activeTabId === tab.id
+ ? null
+ : state.secondary.activeTabId,
+ isOpen: state.secondary.isOpen,
+ state,
+ tabs,
+ });
+ });
+ },
+ [updateFixedPanelTabsState],
+ );
+
+ const activateAppTab = useCallback(
+ (appId: string) => {
+ updateFixedPanelTabsState((state) => {
+ const tab = findAppTab(state.secondary.tabs, appId);
+ if (!tab) {
+ return state;
+ }
+ if (state.secondary.activeTabId === tab.id && state.secondary.isOpen) {
+ return state;
+ }
+ return setSecondaryTabs({
+ activeTabId: tab.id,
+ isOpen: true,
+ state,
+ tabs: state.secondary.tabs,
+ });
+ });
+ },
+ [updateFixedPanelTabsState],
+ );
+
const closeStorageFileTab = useCallback(
(path: string) => {
- if (!isManagerThread || path === pinnedStorageFilePath) return;
+ if (!isManagerThread) return;
updateFixedPanelTabsState((state) => {
const tab = findStorageFileTab(state.secondary.tabs, path);
if (!tab) {
@@ -671,7 +760,7 @@ export function useThreadFileTabs({
});
});
},
- [isManagerThread, pinnedStorageFilePath, updateFixedPanelTabsState],
+ [isManagerThread, updateFixedPanelTabsState],
);
const activateStorageFileTab = useCallback(
@@ -723,10 +812,7 @@ export function useThreadFileTabs({
if (!existingTab) {
return state;
}
- if (
- state.secondary.activeTabId === newTab.id &&
- state.secondary.isOpen
- ) {
+ if (state.secondary.activeTabId === newTab.id && state.secondary.isOpen) {
return state;
}
return setSecondaryTabs({
@@ -759,6 +845,12 @@ export function useThreadFileTabs({
const selectFileSearchResult = useCallback(
(selection: FileSearchSelection) => {
+ if (selection.source === "app") {
+ const nextTab = createAppTab(selection.appId);
+ updateFixedPanelTabsState((state) => replaceNewTab({ nextTab, state }));
+ return;
+ }
+
if (selection.source === "workspace") {
if (resolvedEnvironmentId === undefined) return;
const nextTab = createWorkspaceFilePreviewFixedPanelTab({
@@ -770,24 +862,15 @@ export function useThreadFileTabs({
statusLabel: null,
},
});
- updateFixedPanelTabsState((state) =>
- replaceNewTab({ nextTab, state }),
- );
+ updateFixedPanelTabsState((state) => replaceNewTab({ nextTab, state }));
return;
}
if (!isManagerThread) return;
- const nextTab = createStorageTab(selection.path, pinnedStorageFilePath);
- updateFixedPanelTabsState((state) =>
- replaceNewTab({ nextTab, state }),
- );
+ const nextTab = createStorageTab(selection.path);
+ updateFixedPanelTabsState((state) => replaceNewTab({ nextTab, state }));
},
- [
- isManagerThread,
- pinnedStorageFilePath,
- resolvedEnvironmentId,
- updateFixedPanelTabsState,
- ],
+ [isManagerThread, resolvedEnvironmentId, updateFixedPanelTabsState],
);
const clearActiveFileTabs = useCallback(() => {
@@ -797,7 +880,8 @@ export function useThreadFileTabs({
!activeTab ||
(activeTab.kind !== "workspace-file-preview" &&
activeTab.kind !== "host-file-preview" &&
- activeTab.kind !== "thread-storage-file-preview")
+ activeTab.kind !== "thread-storage-file-preview" &&
+ activeTab.kind !== "app")
) {
return state;
}
@@ -827,14 +911,17 @@ export function useThreadFileTabs({
: null;
const activeHostFileTab =
activeTab?.kind === "host-file-preview" ? activeTab : null;
+ const activeAppTab = activeTab?.kind === "app" ? activeTab : null;
const activeNewTab = activeTab?.kind === "new-tab" ? activeTab : null;
return {
orderedSecondaryFileTabs,
+ activateAppTab,
activateNewTab,
activateHostFileTab,
activateStorageFileTab,
activateWorkspaceFileTab,
+ activeAppId: activeAppTab?.appId ?? null,
activeHostFileLineNumber: activeHostFileTab?.lineNumber ?? null,
activeHostFilePath: activeHostFileTab?.path ?? null,
activeStorageFilePath: activeStorageFileTab?.path ?? null,
@@ -843,6 +930,7 @@ export function useThreadFileTabs({
activeWorkspaceFileSource: activeWorkspaceFileTab?.source ?? null,
activeWorkspaceFileStatusLabel: activeWorkspaceFileTab?.statusLabel ?? null,
clearActiveFileTabs,
+ closeAppTab,
closeHostFileTab,
closeNewTab,
closeStorageFileTab,
@@ -850,10 +938,10 @@ export function useThreadFileTabs({
hasNewTab: findNewTab(fixedPanelTabsState.secondary.tabs) !== null,
isNewTabActive: activeNewTab !== null,
openNewTab,
+ openApp,
openHostFile,
openStorageFile,
openWorkspaceFile,
- pinnedStorageFilePath,
selectFileSearchResult,
};
}
diff --git a/apps/app/src/components/ui/icon.tsx b/apps/app/src/components/ui/icon.tsx
index 1a8ee25ae..d0d07da61 100644
--- a/apps/app/src/components/ui/icon.tsx
+++ b/apps/app/src/components/ui/icon.tsx
@@ -40,6 +40,7 @@ import {
FolderRemoveIcon,
GitBranchIcon,
GitMergeIcon,
+ GridViewIcon,
InformationCircleIcon,
LaptopIcon,
LayoutTwoColumnIcon,
@@ -103,6 +104,7 @@ const ICON_MAP = {
FolderPlus: FolderAddIcon,
GitBranch: GitBranchIcon,
GitMerge: GitMergeIcon,
+ GridView: GridViewIcon,
Info: InformationCircleIcon,
Laptop: LaptopIcon,
ListTodo: CheckListIcon,
diff --git a/apps/app/src/components/ui/tab-pill.tsx b/apps/app/src/components/ui/tab-pill.tsx
index c29fb5418..c981685e3 100644
--- a/apps/app/src/components/ui/tab-pill.tsx
+++ b/apps/app/src/components/ui/tab-pill.tsx
@@ -1,5 +1,6 @@
import { Icon } from "@/components/ui/icon.js";
import { cn } from "@/lib/utils";
+import type { ReactNode } from "react";
const TAB_PILL_DEFAULT_LABEL_MAX_WIDTH_CLASS = "max-w-[180px]";
@@ -12,6 +13,7 @@ export interface TabPillCloseAction {
export interface TabPillProps {
label: string;
+ leadingVisual?: ReactNode;
secondaryLabel?: string | null;
/** Extra classes for the label text (e.g. `line-through` for a done tab). */
labelClassName?: string;
@@ -24,6 +26,7 @@ export interface TabPillProps {
export function TabPill({
label,
+ leadingVisual,
secondaryLabel = null,
labelClassName,
title,
@@ -52,6 +55,11 @@ export function TabPill({
isClosable ? "pr-1" : "rounded-r-md pr-2",
)}
>
+ {leadingVisual ? (
+
+ {leadingVisual}
+
+ ) : null}
{label}
diff --git a/apps/app/src/hooks/environment-cache-effects.ts b/apps/app/src/hooks/environment-cache-effects.ts
index 4352a2e6d..721138eb1 100644
--- a/apps/app/src/hooks/environment-cache-effects.ts
+++ b/apps/app/src/hooks/environment-cache-effects.ts
@@ -12,7 +12,6 @@ import {
threadStorageFilePreviewQueryKeyPrefix,
threadStorageFilesForThreadQueryKeyPrefix,
threadStoragePathsForThreadQueryKeyPrefix,
- threadStatusVersionQueryKey,
} from "./queries/query-keys";
import type {
EnvironmentArg,
@@ -106,7 +105,4 @@ export function invalidateThreadStorageQueries({
queryClient.invalidateQueries({
queryKey: threadStorageFilePreviewQueryKeyPrefix(threadId),
});
- queryClient.invalidateQueries({
- queryKey: threadStatusVersionQueryKey(threadId),
- });
}
diff --git a/apps/app/src/hooks/mutation-cache-effects.ts b/apps/app/src/hooks/mutation-cache-effects.ts
index 576797eb8..787b9a9a9 100644
--- a/apps/app/src/hooks/mutation-cache-effects.ts
+++ b/apps/app/src/hooks/mutation-cache-effects.ts
@@ -20,7 +20,6 @@ import {
threadStorageFilePreviewQueryKeyPrefix,
threadStorageFilesForThreadQueryKeyPrefix,
threadStoragePathsForThreadQueryKeyPrefix,
- threadStatusVersionQueryKey,
threadTimelineQueryKeyPrefix,
} from "./queries/query-keys";
import type {
@@ -248,9 +247,6 @@ export function removeThreadScopedQueries({
queryClient.removeQueries({
queryKey: threadStorageFilePreviewQueryKeyPrefix(threadId),
});
- queryClient.removeQueries({
- queryKey: threadStatusVersionQueryKey(threadId),
- });
}
export { removeEnvironmentScopedQueries };
diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts
index dfe3b2580..5350b6620 100644
--- a/apps/app/src/hooks/queries/query-keys.ts
+++ b/apps/app/src/hooks/queries/query-keys.ts
@@ -37,9 +37,9 @@ export const THREAD_TERMINALS_QUERY_KEY = "threadTerminals";
export const THREAD_STORAGE_FILES_QUERY_KEY = "threadStorageFiles";
export const THREAD_STORAGE_PATHS_QUERY_KEY = "threadStoragePaths";
export const THREAD_STORAGE_FILE_PREVIEW_QUERY_KEY = "threadStorageFilePreview";
-export const THREAD_STATUS_VERSION_QUERY_KEY = "threadStatusVersion";
-export const THREAD_STATUS_MARKDOWN_PREVIEW_QUERY_KEY =
- "threadStatusMarkdownPreview";
+export const THREAD_APPS_QUERY_KEY = "threadApps";
+export const THREAD_APP_QUERY_KEY = "threadApp";
+export const THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY = "threadAppMarkdownPreview";
export const THREAD_HOST_FILE_PREVIEW_QUERY_KEY = "threadHostFilePreview";
export const ENVIRONMENT_QUERY_KEY = "environment";
export const ENVIRONMENT_WORK_STATUS_QUERY_KEY = "environmentWorkStatus";
@@ -254,12 +254,18 @@ export type ThreadStorageFilePreviewQueryKeyPrefix = readonly [
typeof THREAD_STORAGE_FILE_PREVIEW_QUERY_KEY,
string,
];
-export type ThreadStatusVersionQueryKey = readonly [
- typeof THREAD_STATUS_VERSION_QUERY_KEY,
+export type ThreadAppsQueryKey = readonly [
+ typeof THREAD_APPS_QUERY_KEY,
string,
];
-export type ThreadStatusMarkdownPreviewQueryKey = readonly [
- typeof THREAD_STATUS_MARKDOWN_PREVIEW_QUERY_KEY,
+export type ThreadAppQueryKey = readonly [
+ typeof THREAD_APP_QUERY_KEY,
+ string,
+ string,
+];
+export type ThreadAppMarkdownPreviewQueryKey = readonly [
+ typeof THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY,
+ string,
string,
string | null | undefined,
];
@@ -689,17 +695,23 @@ export function threadStorageFilePreviewQueryKeyPrefix(
return [THREAD_STORAGE_FILE_PREVIEW_QUERY_KEY, threadId];
}
-export function threadStatusVersionQueryKey(
+export function threadAppsQueryKey(threadId: string): ThreadAppsQueryKey {
+ return [THREAD_APPS_QUERY_KEY, threadId];
+}
+
+export function threadAppQueryKey(
threadId: string,
-): ThreadStatusVersionQueryKey {
- return [THREAD_STATUS_VERSION_QUERY_KEY, threadId];
+ appId: string,
+): ThreadAppQueryKey {
+ return [THREAD_APP_QUERY_KEY, threadId, appId];
}
-export function threadStatusMarkdownPreviewQueryKey(
+export function threadAppMarkdownPreviewQueryKey(
threadId: string,
- versionHash: string | null | undefined,
-): ThreadStatusMarkdownPreviewQueryKey {
- return [THREAD_STATUS_MARKDOWN_PREVIEW_QUERY_KEY, threadId, versionHash];
+ appId: string,
+ entryPath: string | null | undefined,
+): ThreadAppMarkdownPreviewQueryKey {
+ return [THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY, threadId, appId, entryPath];
}
export function threadHostFilePreviewQueryKey(
diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx
index 2b29bab47..ac217d051 100644
--- a/apps/app/src/hooks/queries/thread-queries.test.tsx
+++ b/apps/app/src/hooks/queries/thread-queries.test.tsx
@@ -26,8 +26,6 @@ import {
useThreadQueuedMessages,
useThreadPendingInteractions,
useThreadPromptHistory,
- useThreadStatusMarkdownPreview,
- useThreadStatusVersion,
} from "./thread-queries";
import {
hostsQueryKey,
@@ -40,8 +38,6 @@ import {
threadPromptHistoryQueryKey,
threadListQueryKey,
threadQueryKey,
- threadStatusMarkdownPreviewQueryKey,
- threadStatusVersionQueryKey,
threadTimelineQueryKey,
} from "./query-keys";
@@ -332,10 +328,9 @@ describe("thread query bootstraps", () => {
queryClient.getQueryData(threadTimelineQueryKey("thread-1", "standard")),
).toBeUndefined();
- const timelineResult = renderHook(
- () => useThreadTimeline("thread-1"),
- { wrapper },
- );
+ const timelineResult = renderHook(() => useThreadTimeline("thread-1"), {
+ wrapper,
+ });
await waitFor(() => {
expect(timelineResult.result.current.data).toEqual(timeline);
@@ -447,11 +442,7 @@ describe("thread query bootstraps", () => {
supportsRename: true,
supportsServiceTier: true,
supportsUserQuestion: true,
- supportedPermissionModes: [
- "full",
- "workspace-write",
- "readonly",
- ],
+ supportedPermissionModes: ["full", "workspace-write", "readonly"],
},
},
],
@@ -866,88 +857,6 @@ describe("thread prompt history query", () => {
});
});
-describe("thread status version query", () => {
- it("polls every two seconds and stops when unmounted", async () => {
- vi.useFakeTimers();
- let requestCount = 0;
- installFetchRoutes([
- {
- pathname: "/api/v1/threads/thread-1/status-version",
- handler: () => {
- requestCount += 1;
- return jsonResponse({
- source: "folder",
- hash: `status-hash-${requestCount}`,
- });
- },
- },
- ]);
- const { queryClient, wrapper } = createWrapper();
-
- const { result, unmount } = renderHook(
- () => useThreadStatusVersion("thread-1"),
- { wrapper },
- );
-
- await vi.waitFor(() => {
- expect(result.current.status).toBe("success");
- });
- expect(requestCount).toBe(1);
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(2_000);
- });
- expect(requestCount).toBe(2);
- expect(
- queryClient.getQueryData(threadStatusVersionQueryKey("thread-1")),
- ).toEqual({ source: "folder", hash: "status-hash-2" });
-
- unmount();
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(2_100);
- });
- expect(requestCount).toBe(2);
- });
-});
-
-describe("thread status markdown preview query", () => {
- it("loads STATUS.md through the existing storage preview route under the status hash", async () => {
- installFetchRoutes([
- {
- pathname: "/api/v1/threads/thread-1/thread-storage/content",
- handler: (request) => {
- const url = new URL(request.url);
- expect(url.searchParams.get("path")).toBe("STATUS.md");
- return new Response("# Status\n", {
- headers: { "content-type": "text/markdown" },
- });
- },
- },
- ]);
- const { queryClient, wrapper } = createWrapper();
-
- const { result } = renderHook(
- () => useThreadStatusMarkdownPreview("thread-1", "status-hash-1"),
- { wrapper },
- );
-
- await waitFor(() => {
- expect(result.current.status).toBe("success");
- });
- expect(
- queryClient.getQueryData(
- threadStatusMarkdownPreviewQueryKey("thread-1", "status-hash-1"),
- ),
- ).toMatchObject({
- kind: "text",
- content: "# Status\n",
- mimeType: "text/markdown",
- path: "STATUS.md",
- });
- });
-});
-
describe("thread host file preview query", () => {
it("loads host file content lazily through the thread-scoped route", async () => {
const hostPath = "/Users/me/notes/plan.md";
diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts
index be7823b41..e5fccb611 100644
--- a/apps/app/src/hooks/queries/thread-queries.ts
+++ b/apps/app/src/hooks/queries/thread-queries.ts
@@ -20,13 +20,14 @@ import type {
ManagerTimelineView,
ThreadPendingInteractionsResponse,
ThreadResponse,
- ThreadStatusVersionResponse,
ThreadWithIncludesResponse,
ThreadStorageFileListResponse,
ThreadStoragePathListResponse,
ThreadTimelineResponse,
TimelineTurnSummaryDetailsRequest,
TimelineTurnSummaryDetailsResponse,
+ AppDetail,
+ AppSummary,
} from "@bb/server-contract";
import type { ThreadListFilters, FilePreview } from "@/lib/api";
import type { PathListOptions } from "@/lib/path-list-options";
@@ -55,14 +56,14 @@ import {
threadStorageFilesQueryKey,
threadStoragePathsQueryKey,
threadStorageFilePreviewQueryKey,
- threadStatusMarkdownPreviewQueryKey,
- threadStatusVersionQueryKey,
+ threadAppMarkdownPreviewQueryKey,
+ threadAppQueryKey,
+ threadAppsQueryKey,
threadHostFilePreviewQueryKey,
threadTimelineQueryKey,
type ArchivedThreadsKindFilter,
} from "./query-keys";
import { ARCHIVED_THREADS_PAGE_SIZE } from "./archived-threads-page-size";
-import { MANAGER_STATUS_MARKDOWN_FILE_PATH } from "@/lib/manager-status-storage";
interface QueryOptions {
enabled?: boolean;
@@ -71,7 +72,6 @@ interface QueryOptions {
}
const THREAD_LIST_STALE_TIME_MS = 10_000;
-const THREAD_STATUS_VERSION_REFETCH_INTERVAL_MS = 2_000;
interface ThreadComposerBootstrapQueryOptions extends QueryOptions {
environmentId?: string;
@@ -639,36 +639,58 @@ export function useThreadStorageFilePreview(
});
}
-export function useThreadStatusVersion(id: string, options?: QueryOptions) {
- return useQuery({
- queryKey: threadStatusVersionQueryKey(id),
+export function useThreadApps(id: string, options?: QueryOptions) {
+ return useQuery({
+ queryKey: threadAppsQueryKey(id),
queryFn: ({ signal }) =>
- api.getThreadStatusVersion(
- requireThreadId(id, "useThreadStatusVersion"),
+ api.listThreadApps(requireThreadId(id, "useThreadApps"), signal),
+ enabled: (options?.enabled ?? true) && Boolean(id),
+ refetchOnMount: options?.refetchOnMount ?? true,
+ refetchOnWindowFocus: false,
+ staleTime: options?.staleTime,
+ });
+}
+
+export function useThreadApp(
+ id: string,
+ appId: string | null | undefined,
+ options?: QueryOptions,
+) {
+ return useQuery({
+ queryKey: threadAppQueryKey(id, appId ?? ""),
+ queryFn: ({ signal }) =>
+ api.getThreadApp(
+ requireThreadId(id, "useThreadApp"),
+ appId ?? "",
signal,
),
- enabled: (options?.enabled ?? true) && Boolean(id),
- refetchInterval: THREAD_STATUS_VERSION_REFETCH_INTERVAL_MS,
- refetchIntervalInBackground: false,
- refetchOnWindowFocus: true,
+ enabled: (options?.enabled ?? true) && Boolean(id) && Boolean(appId),
+ refetchOnMount: options?.refetchOnMount ?? true,
+ refetchOnWindowFocus: false,
staleTime: options?.staleTime,
});
}
-export function useThreadStatusMarkdownPreview(
+export function useThreadAppMarkdownPreview(
id: string,
- versionHash: string | null | undefined,
+ appId: string | null | undefined,
+ entryPath: string | null | undefined,
options?: QueryOptions,
) {
return useQuery({
- queryKey: threadStatusMarkdownPreviewQueryKey(id, versionHash),
+ queryKey: threadAppMarkdownPreviewQueryKey(id, appId ?? "", entryPath),
queryFn: ({ signal }) =>
- api.getThreadStorageFilePreview(
- requireThreadId(id, "useThreadStatusMarkdownPreview"),
- MANAGER_STATUS_MARKDOWN_FILE_PATH,
+ api.getThreadAppMarkdownPreview(
+ requireThreadId(id, "useThreadAppMarkdownPreview"),
+ appId ?? "",
+ entryPath ?? "",
signal,
),
- enabled: (options?.enabled ?? true) && Boolean(id) && Boolean(versionHash),
+ enabled:
+ (options?.enabled ?? true) &&
+ Boolean(id) &&
+ Boolean(appId) &&
+ Boolean(entryPath),
refetchOnWindowFocus: false,
staleTime: options?.staleTime,
});
diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts
index 8dfefb3e2..044a23a52 100644
--- a/apps/app/src/hooks/realtime-cache-effects.test.ts
+++ b/apps/app/src/hooks/realtime-cache-effects.test.ts
@@ -6,7 +6,6 @@ import {
PROJECT_CHANGE_KINDS,
THREAD_CHANGE_KINDS,
} from "@bb/domain";
-import type { ThreadStatusVersionResponse } from "@bb/server-contract";
import { createAppQueryClient } from "@/lib/query-client";
import {
archivedThreadsListQueryKey,
@@ -22,7 +21,7 @@ import {
threadPromptHistoryQueryKey,
threadQueryKey,
threadTerminalsQueryKey,
- threadStatusVersionQueryKey,
+ threadStorageFilePreviewQueryKey,
threadTimelineQueryKey,
} from "./queries/query-keys";
import { createRealtimeCacheEffects } from "./realtime-cache-effects";
@@ -462,32 +461,43 @@ describe("createRealtimeCacheEffects", () => {
effects.dispose();
});
- it("refetches active status version queries for thread storage changes", async () => {
+ it("refetches active thread storage preview queries for thread storage changes", async () => {
vi.useFakeTimers();
const { effects, queryClient } = createRealtimeEffectsTestContext();
const threadKey = threadQueryKey("thr_1");
- const statusVersionKey = threadStatusVersionQueryKey("thr_1");
- const initialStatusVersion: ThreadStatusVersionResponse = {
- source: "folder",
- hash: "status-old",
+ const storagePreviewKey = threadStorageFilePreviewQueryKey(
+ "thr_1",
+ "notes.md",
+ );
+ const initialStoragePreview = {
+ kind: "text",
+ content: "old",
+ mimeType: "text/plain",
+ path: "notes.md",
+ url: "/old",
};
- const nextStatusVersion: ThreadStatusVersionResponse = {
- source: "folder",
- hash: "status-new",
+ const nextStoragePreview = {
+ kind: "text",
+ content: "new",
+ mimeType: "text/plain",
+ path: "notes.md",
+ url: "/new",
};
queryClient.setQueryData(threadKey, {
id: "thr_1",
environmentId: "env-1",
});
- queryClient.setQueryData(statusVersionKey, initialStatusVersion);
- const statusVersionQueryFn = vi.fn(async () => nextStatusVersion);
- const statusVersionObserver = new QueryObserver(queryClient, {
- queryKey: statusVersionKey,
- queryFn: statusVersionQueryFn,
+ queryClient.setQueryData(storagePreviewKey, initialStoragePreview);
+ const storagePreviewQueryFn = vi.fn(async () => nextStoragePreview);
+ const storagePreviewObserver = new QueryObserver(queryClient, {
+ queryKey: storagePreviewKey,
+ queryFn: storagePreviewQueryFn,
staleTime: Infinity,
});
- const unsubscribeStatusVersion = statusVersionObserver.subscribe(() => {});
- statusVersionQueryFn.mockClear();
+ const unsubscribeStoragePreview = storagePreviewObserver.subscribe(
+ () => {},
+ );
+ storagePreviewQueryFn.mockClear();
effects.handleChanged({
type: "changed",
@@ -497,12 +507,12 @@ describe("createRealtimeCacheEffects", () => {
});
await vi.advanceTimersByTimeAsync(250);
- expect(statusVersionQueryFn).toHaveBeenCalledTimes(1);
- expect(queryClient.getQueryData(statusVersionKey)).toEqual(
- nextStatusVersion,
+ expect(storagePreviewQueryFn).toHaveBeenCalledTimes(1);
+ expect(queryClient.getQueryData(storagePreviewKey)).toEqual(
+ nextStoragePreview,
);
- unsubscribeStatusVersion();
+ unsubscribeStoragePreview();
effects.dispose();
});
diff --git a/apps/app/src/hooks/realtime-cache-registry.ts b/apps/app/src/hooks/realtime-cache-registry.ts
index 944d0dfdc..f85bc809e 100644
--- a/apps/app/src/hooks/realtime-cache-registry.ts
+++ b/apps/app/src/hooks/realtime-cache-registry.ts
@@ -50,7 +50,6 @@ import {
threadStorageFilePreviewQueryKeyPrefix,
threadStorageFilesForThreadQueryKeyPrefix,
threadStoragePathsForThreadQueryKeyPrefix,
- threadStatusVersionQueryKey,
threadTimelineQueryKeyPrefix,
} from "./queries/query-keys";
@@ -565,7 +564,6 @@ function dirtyThreadStorageQueriesForEnvironment({
queryKeys.push(threadStorageFilesForThreadQueryKeyPrefix(threadId));
queryKeys.push(threadStoragePathsForThreadQueryKeyPrefix(threadId));
queryKeys.push(threadStorageFilePreviewQueryKeyPrefix(threadId));
- queryKeys.push(threadStatusVersionQueryKey(threadId));
}
return queryKeys;
}
diff --git a/apps/app/src/hooks/useFileSearchSuggestions.test.tsx b/apps/app/src/hooks/useFileSearchSuggestions.test.tsx
index 11bd20d6a..c2937f257 100644
--- a/apps/app/src/hooks/useFileSearchSuggestions.test.tsx
+++ b/apps/app/src/hooks/useFileSearchSuggestions.test.tsx
@@ -1,11 +1,15 @@
// @vitest-environment jsdom
import { cleanup, renderHook, waitFor } from "@testing-library/react";
-import type { WorkspacePathEntry } from "@bb/server-contract";
+import type { AppSummary, WorkspacePathEntry } from "@bb/server-contract";
import * as api from "@/lib/api";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
-import { useFileSearchSuggestions } from "./useFileSearchSuggestions";
+import {
+ useFileSearchSuggestions,
+ type FilePathSearchSuggestion,
+ type FileSearchSuggestion,
+} from "./useFileSearchSuggestions";
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal();
@@ -13,6 +17,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
return {
...actual,
searchProjectPaths: vi.fn(),
+ listThreadApps: vi.fn(),
listThreadStoragePaths: vi.fn(),
};
});
@@ -52,6 +57,20 @@ function makePathResponse(
};
}
+function isFilePathSearchSuggestion(
+ suggestion: FileSearchSuggestion,
+): suggestion is FilePathSearchSuggestion {
+ return suggestion.entryKind === "file";
+}
+
+const STATUS_APP: AppSummary = {
+ id: "status",
+ name: "Status",
+ entry: { path: "index.html", kind: "html" },
+ capabilities: ["data", "message"],
+ icon: { kind: "builtin", name: "ListTodo" },
+};
+
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -59,6 +78,7 @@ afterEach(() => {
describe("useFileSearchSuggestions", () => {
it("merges workspace and manager thread-storage file results", async () => {
+ vi.mocked(api.listThreadApps).mockResolvedValue([]);
vi.mocked(api.searchProjectPaths).mockResolvedValue(
makePathResponse([
{
@@ -99,10 +119,11 @@ describe("useFileSearchSuggestions", () => {
expect(result.current.suggestions).toHaveLength(2);
});
- expect(result.current.suggestions.map((suggestion) => suggestion.path)).toEqual([
- "notes/status.md",
- "src/project.ts",
- ]);
+ expect(
+ result.current.suggestions
+ .filter(isFilePathSearchSuggestion)
+ .map((suggestion) => suggestion.path),
+ ).toEqual(["notes/status.md", "src/project.ts"]);
expect(api.searchProjectPaths).toHaveBeenCalledWith({
projectId: "proj-1",
query: "status",
@@ -123,6 +144,52 @@ describe("useFileSearchSuggestions", () => {
});
});
+ it("returns matching apps before files", async () => {
+ vi.mocked(api.listThreadApps).mockResolvedValue([STATUS_APP]);
+ vi.mocked(api.searchProjectPaths).mockResolvedValue(
+ makePathResponse([
+ {
+ kind: "file",
+ path: "notes/status.md",
+ score: 90,
+ },
+ ]),
+ );
+ vi.mocked(api.listThreadStoragePaths).mockResolvedValue({
+ ...makePathResponse([]),
+ storageRootPath: "/tmp/thread-storage",
+ });
+
+ const { wrapper } = createQueryClientTestHarness();
+ const { result } = renderHook(
+ () =>
+ useFileSearchSuggestions({
+ projectId: "proj-1",
+ query: "status",
+ environmentId: "env-1",
+ currentThreadId: "thr-manager",
+ currentThreadType: "manager",
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ expect(result.current.suggestions).toHaveLength(2);
+ });
+
+ expect(result.current.suggestions[0]).toMatchObject({
+ source: "app",
+ entryKind: "app",
+ appId: "status",
+ name: "Status",
+ });
+ expect(result.current.suggestions[1]).toMatchObject({
+ source: "workspace",
+ entryKind: "file",
+ path: "notes/status.md",
+ });
+ });
+
it("excludes directory results defensively", async () => {
vi.mocked(api.searchProjectPaths).mockResolvedValue(
makePathResponse([
diff --git a/apps/app/src/hooks/useFileSearchSuggestions.ts b/apps/app/src/hooks/useFileSearchSuggestions.ts
index 92196e0a3..af7c4e4b6 100644
--- a/apps/app/src/hooks/useFileSearchSuggestions.ts
+++ b/apps/app/src/hooks/useFileSearchSuggestions.ts
@@ -1,14 +1,25 @@
import { useMemo } from "react";
import type { ThreadType } from "@bb/domain";
+import type { AppSummary } from "@bb/server-contract";
import {
usePathSuggestions,
type PathSuggestion,
type PathSuggestionSource,
} from "./usePathSuggestions";
+import { useThreadApps } from "./queries/thread-queries";
const DEFAULT_FILE_SEARCH_SUGGESTION_LIMIT = 8;
-export interface FileSearchSuggestion {
+export interface AppSearchSuggestion {
+ source: "app";
+ entryKind: "app";
+ app: AppSummary;
+ appId: string;
+ name: string;
+ score: number;
+}
+
+export interface FilePathSearchSuggestion {
source: PathSuggestionSource;
entryKind: "file";
path: string;
@@ -17,6 +28,10 @@ export interface FileSearchSuggestion {
positions: number[];
}
+export type FileSearchSuggestion =
+ | AppSearchSuggestion
+ | FilePathSearchSuggestion;
+
export interface UseFileSearchSuggestionsArgs {
projectId: string | undefined;
query: string | null;
@@ -38,6 +53,12 @@ interface FilePathSuggestion extends PathSuggestion {
entryKind: "file";
}
+interface BuildAppSearchSuggestionsArgs {
+ apps: readonly AppSummary[];
+ limit: number;
+ query: string;
+}
+
function isFilePathSuggestion(
suggestion: PathSuggestion,
): suggestion is FilePathSuggestion {
@@ -46,7 +67,7 @@ function isFilePathSuggestion(
function toFileSearchSuggestion(
suggestion: FilePathSuggestion,
-): FileSearchSuggestion {
+): FilePathSearchSuggestion {
return {
source: suggestion.source,
entryKind: "file",
@@ -57,6 +78,63 @@ function toFileSearchSuggestion(
};
}
+function scoreAppSearchMatch(app: AppSummary, normalizedQuery: string): number {
+ if (normalizedQuery.length === 0) {
+ return 0;
+ }
+
+ const normalizedName = app.name.toLowerCase();
+ const normalizedId = app.id.toLowerCase();
+ if (normalizedName === normalizedQuery || normalizedId === normalizedQuery) {
+ return 100;
+ }
+ if (
+ normalizedName.startsWith(normalizedQuery) ||
+ normalizedId.startsWith(normalizedQuery)
+ ) {
+ return 90;
+ }
+ if (normalizedName.includes(normalizedQuery)) {
+ return 80;
+ }
+ if (normalizedId.includes(normalizedQuery)) {
+ return 70;
+ }
+ return -1;
+}
+
+function buildAppSearchSuggestions({
+ apps,
+ limit,
+ query,
+}: BuildAppSearchSuggestionsArgs): AppSearchSuggestion[] {
+ const normalizedQuery = query.trim().toLowerCase();
+ const suggestions: AppSearchSuggestion[] = [];
+ for (const app of apps) {
+ const score = scoreAppSearchMatch(app, normalizedQuery);
+ if (score < 0) {
+ continue;
+ }
+ suggestions.push({
+ source: "app",
+ entryKind: "app",
+ app,
+ appId: app.id,
+ name: app.name,
+ score,
+ });
+ }
+
+ return suggestions
+ .sort((left, right) => {
+ if (left.score !== right.score) {
+ return right.score - left.score;
+ }
+ return left.name.localeCompare(right.name);
+ })
+ .slice(0, limit);
+}
+
export function useFileSearchSuggestions(
args: UseFileSearchSuggestionsArgs,
): UseFileSearchSuggestionsResult {
@@ -70,22 +148,41 @@ export function useFileSearchSuggestions(
currentThreadType: args.currentThreadType,
includeDirectories: false,
});
- const suggestions = useMemo(
+ const canSearchApps = Boolean(args.currentThreadId);
+ const threadApps = useThreadApps(args.currentThreadId ?? "", {
+ enabled: canSearchApps,
+ });
+ const appSuggestions = useMemo(
+ () =>
+ buildAppSearchSuggestions({
+ apps: threadApps.data ?? [],
+ limit,
+ query: args.query ?? "",
+ }),
+ [args.query, limit, threadApps.data],
+ );
+ const fileSuggestions = useMemo(
() =>
pathSuggestions.suggestions
.filter(isFilePathSuggestion)
.map(toFileSearchSuggestion),
[pathSuggestions.suggestions],
);
+ const suggestions = useMemo(
+ () => [...appSuggestions, ...fileSuggestions],
+ [appSuggestions, fileSuggestions],
+ );
const canSearchWorkspace = Boolean(args.projectId);
const canSearchThreadStorage =
args.currentThreadType === "manager" && Boolean(args.currentThreadId);
return {
suggestions,
- isLoading: pathSuggestions.isLoading,
- isError: pathSuggestions.isError,
+ isLoading:
+ suggestions.length === 0 &&
+ (pathSuggestions.isLoading || (canSearchApps && threadApps.isLoading)),
+ isError: pathSuggestions.isError || (canSearchApps && threadApps.isError),
isDebouncing: pathSuggestions.isDebouncing,
- isUnavailable: !canSearchWorkspace && !canSearchThreadStorage,
+ isUnavailable: !canSearchApps && !canSearchWorkspace && !canSearchThreadStorage,
};
}
diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts
index edc0e6a9a..ca01d786e 100644
--- a/apps/app/src/lib/api.ts
+++ b/apps/app/src/lib/api.ts
@@ -53,7 +53,6 @@ import type {
ThreadQueuedMessageListResponse,
ThreadListResponse,
ThreadResponse,
- ThreadStatusVersionResponse,
ThreadWithIncludesResponse,
PathListIncludeQueryValue,
BranchListQuery,
@@ -81,6 +80,8 @@ import type {
ReplayCaptureListResponse,
ReplayRunRequest,
ReplayRunResponse,
+ AppDetail,
+ AppSummary,
} from "@bb/server-contract";
import { apiClient, toRelativeUrl } from "./api-server";
import {
@@ -91,6 +92,7 @@ import {
type FilePreviewTarget,
} from "./file-preview";
import {
+ buildThreadAppAssetUrl,
buildThreadHostFileContentUrl,
buildThreadStorageContentUrl,
} from "./file-content-urls";
@@ -882,18 +884,47 @@ export async function getThreadStorageFilePreview(
);
}
-export async function getThreadStatusVersion(
+export async function listThreadApps(
id: string,
signal?: AbortSignal,
-): Promise {
- return request(
- apiClient.threads[":id"]["status-version"].$get(
+): Promise {
+ return request(
+ apiClient.threads[":id"].apps.$get(
{ param: { id } },
requestOptions(signal),
),
);
}
+export async function getThreadApp(
+ id: string,
+ appId: string,
+ signal?: AbortSignal,
+): Promise {
+ return request(
+ apiClient.threads[":id"].apps[":appId"].$get(
+ { param: { id, appId } },
+ requestOptions(signal),
+ ),
+ );
+}
+
+export async function getThreadAppMarkdownPreview(
+ id: string,
+ appId: string,
+ path: string,
+ signal?: AbortSignal,
+): Promise {
+ return loadFilePreview(
+ {
+ name: path.split("/").at(-1),
+ path,
+ url: buildThreadAppAssetUrl(id, appId, path),
+ },
+ signal,
+ );
+}
+
export async function getThreadHostFilePreview(
id: string,
path: string,
diff --git a/apps/app/src/lib/file-content-urls.ts b/apps/app/src/lib/file-content-urls.ts
index 92a07ab86..13ee9726b 100644
--- a/apps/app/src/lib/file-content-urls.ts
+++ b/apps/app/src/lib/file-content-urls.ts
@@ -35,14 +35,33 @@ export function buildThreadStorageRawContentUrl(
return `/api/v1/threads/${encodeURIComponent(threadId)}/thread-storage/files/${encodePathSegments(path)}`;
}
-export function buildThreadStatusContentUrl(
+export function buildThreadAppEntryUrl(
threadId: string,
- hash?: string,
+ appId: string,
): string {
- const baseUrl = `/api/v1/threads/${encodeURIComponent(threadId)}/status/`;
- return hash === undefined
- ? baseUrl
- : `${baseUrl}?v=${encodeURIComponent(hash)}`;
+ return `/api/v1/threads/${encodeURIComponent(
+ threadId,
+ )}/apps/${encodeURIComponent(appId)}/`;
+}
+
+export function buildThreadAppAssetUrl(
+ threadId: string,
+ appId: string,
+ path: string,
+): string {
+ return `/api/v1/threads/${encodeURIComponent(
+ threadId,
+ )}/apps/${encodeURIComponent(appId)}/${encodePathSegments(path)}`;
+}
+
+export function buildThreadAppAssetBaseUrl(
+ threadId: string,
+ appId: string,
+ entryPath: string,
+): string {
+ const lastSlash = entryPath.lastIndexOf("/");
+ const basePath = lastSlash === -1 ? "" : entryPath.slice(0, lastSlash + 1);
+ return buildThreadAppAssetUrl(threadId, appId, basePath);
}
export function buildThreadHostFileContentUrl(
diff --git a/apps/app/src/lib/fixed-panel-tabs-state.test.ts b/apps/app/src/lib/fixed-panel-tabs-state.test.ts
index e46fafd08..7368e46d8 100644
--- a/apps/app/src/lib/fixed-panel-tabs-state.test.ts
+++ b/apps/app/src/lib/fixed-panel-tabs-state.test.ts
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
EMPTY_FIXED_PANEL_TABS_STATE,
FIXED_PANEL_TABS_IDLE_EXPIRY_MS,
+ createAppFixedPanelTab,
createEmptyFixedPanelTabsState,
createNewTabFixedPanelTab,
getFixedPanelTabsStateStorageKey,
@@ -25,6 +26,10 @@ function workspaceFileTabId(path: string): string {
return `workspace-file-preview:${encodeURIComponent(path)}`;
}
+function appTabId(appId: string): string {
+ return `app:${encodeURIComponent(appId)}`;
+}
+
function terminalTabId(terminalId: string): string {
return `terminal:${encodeURIComponent(terminalId)}`;
}
@@ -80,6 +85,25 @@ describe("fixed panel tabs state storage", () => {
).toEqual(state);
});
+ it("round-trips app tabs", () => {
+ const appTab = createAppFixedPanelTab({ appId: "status" });
+ const state = makeFixedPanelTabsState({
+ secondary: {
+ tabs: [appTab],
+ activeTabId: appTabId("status"),
+ isOpen: true,
+ },
+ });
+
+ expect(
+ parseFixedPanelTabsState({
+ initialValue: EMPTY_FIXED_PANEL_TABS_STATE,
+ now: NOW,
+ storedValue: serializeFixedPanelTabsState({ state }),
+ }),
+ ).toEqual(state);
+ });
+
it("falls back for invalid JSON, invalid shapes, and unsupported regions", () => {
const validState = makeFixedPanelTabsState();
const invalidStoredValues = [
diff --git a/apps/app/src/lib/fixed-panel-tabs-state.ts b/apps/app/src/lib/fixed-panel-tabs-state.ts
index af0dac2d8..88e9d3e49 100644
--- a/apps/app/src/lib/fixed-panel-tabs-state.ts
+++ b/apps/app/src/lib/fixed-panel-tabs-state.ts
@@ -76,6 +76,13 @@ const threadStorageFilePreviewFixedPanelTabSchema = z
path: z.string().min(1),
})
.strict();
+const appFixedPanelTabSchema = z
+ .object({
+ appId: z.string().min(1),
+ id: z.string().min(1),
+ kind: z.literal("app"),
+ })
+ .strict();
const newTabFixedPanelTabSchema = z
.object({
id: z.literal(NEW_TAB_TAB_ID),
@@ -95,6 +102,7 @@ const secondaryFixedPanelTabSchema = z.discriminatedUnion("kind", [
workspaceFilePreviewFixedPanelTabSchema,
hostFilePreviewFixedPanelTabSchema,
threadStorageFilePreviewFixedPanelTabSchema,
+ appFixedPanelTabSchema,
newTabFixedPanelTabSchema,
]);
const bottomFixedPanelTabSchema = z.discriminatedUnion("kind", [
@@ -172,6 +180,12 @@ export interface ThreadStorageFilePreviewFixedPanelTab {
path: string;
}
+export interface AppFixedPanelTab {
+ appId: string;
+ id: string;
+ kind: "app";
+}
+
export interface NewTabFixedPanelTab {
id: typeof NEW_TAB_TAB_ID;
kind: "new-tab";
@@ -189,6 +203,7 @@ export type SecondaryFixedPanelTab =
| WorkspaceFilePreviewFixedPanelTab
| HostFilePreviewFixedPanelTab
| ThreadStorageFilePreviewFixedPanelTab
+ | AppFixedPanelTab
| NewTabFixedPanelTab;
/**
@@ -200,6 +215,7 @@ export type SecondaryFileFixedPanelTab =
| WorkspaceFilePreviewFixedPanelTab
| HostFilePreviewFixedPanelTab
| ThreadStorageFilePreviewFixedPanelTab
+ | AppFixedPanelTab
| NewTabFixedPanelTab;
export type BottomFixedPanelTab = TerminalFixedPanelTab;
@@ -270,6 +286,10 @@ interface CreateThreadStorageFilePreviewFixedPanelTabArgs {
path: string;
}
+interface CreateAppFixedPanelTabArgs {
+ appId: string;
+}
+
interface CreateWorkspaceFilePreviewFixedPanelTabArgs {
environmentId: string | null;
tab: WorkspaceFileTabState;
@@ -346,6 +366,16 @@ export function createThreadStorageFilePreviewFixedPanelTab({
};
}
+export function createAppFixedPanelTab({
+ appId,
+}: CreateAppFixedPanelTabArgs): AppFixedPanelTab {
+ return {
+ appId,
+ id: `app:${encodeURIComponent(appId)}`,
+ kind: "app",
+ };
+}
+
export function createNewTabFixedPanelTab(): NewTabFixedPanelTab {
return {
id: NEW_TAB_TAB_ID,
@@ -616,6 +646,8 @@ export function areFixedPanelTabsEquivalent(
a.lineNumber === b.lineNumber &&
a.path === b.path
);
+ case "app":
+ return b.kind === "app" && a.appId === b.appId;
case "thread-storage-file-preview":
return (
b.kind === "thread-storage-file-preview" &&
diff --git a/apps/app/src/lib/manager-status-storage.ts b/apps/app/src/lib/manager-status-storage.ts
deleted file mode 100644
index 5cd646c19..000000000
--- a/apps/app/src/lib/manager-status-storage.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-export const MANAGER_STATUS_FILE_PATH = "STATUS";
-export const MANAGER_STATUS_INDEX_FILE_PATH = "STATUS/index.html";
-export const MANAGER_STATUS_MARKDOWN_FILE_PATH = "STATUS.md";
-export const MANAGER_STATUS_HTML_FILE_PATH = "STATUS.html";
-
-export interface ManagerStorageFileEntry {
- path: string;
-}
-
-export type ManagerStorageFiles = readonly ManagerStorageFileEntry[];
-
-export function isManagerStatusStorageFilePath(path: string): boolean {
- return (
- path === MANAGER_STATUS_FILE_PATH ||
- path === MANAGER_STATUS_INDEX_FILE_PATH ||
- path === MANAGER_STATUS_MARKDOWN_FILE_PATH ||
- path === MANAGER_STATUS_HTML_FILE_PATH
- );
-}
-
-export function resolveManagerStatusStorageTabPath(path: string): string {
- return isManagerStatusStorageFilePath(path)
- ? MANAGER_STATUS_FILE_PATH
- : path;
-}
-
-export function resolvePinnedManagerStorageFilePath(
- _storageFiles: ManagerStorageFiles | undefined,
-): string {
- return MANAGER_STATUS_FILE_PATH;
-}
diff --git a/apps/app/src/lib/markdown-url-transform.ts b/apps/app/src/lib/markdown-url-transform.ts
new file mode 100644
index 000000000..5ca43e8c1
--- /dev/null
+++ b/apps/app/src/lib/markdown-url-transform.ts
@@ -0,0 +1,27 @@
+import { defaultUrlTransform, type UrlTransform } from "react-markdown";
+
+const RELATIVE_ASSET_URL_PATTERN =
+ /^(?![a-z][a-z\d+.-]*:|\/\/|\/|#|\?)/iu;
+
+function isRelativeAssetUrl(url: string): boolean {
+ return url.length > 0 && RELATIVE_ASSET_URL_PATTERN.test(url);
+}
+
+function resolveAssetUrl(assetBaseUrl: string, url: string): string {
+ const baseUrl = new URL(assetBaseUrl, window.location.origin);
+ const assetUrl = new URL(url, baseUrl);
+ return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`;
+}
+
+export function createAssetMarkdownUrlTransform(
+ assetBaseUrl: string,
+): UrlTransform {
+ return (url) => {
+ const transformedUrl = defaultUrlTransform(url);
+ if (!isRelativeAssetUrl(transformedUrl)) {
+ return transformedUrl;
+ }
+
+ return resolveAssetUrl(assetBaseUrl, transformedUrl);
+ };
+}
diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
index b6cdb2498..cc62ced2b 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
@@ -65,6 +65,9 @@ type ComposerQueryRefetchOnMount = boolean | "always";
const ignorePromptBannerFileClick = () => {};
+export const THREAD_DETAIL_COMPOSER_TEXTAREA_ID =
+ "thread-detail-follow-up-composer";
+
interface ThreadDetailPromptAreaProps {
canUseGitUi: boolean;
composerQueriesEnabled: boolean;
@@ -852,6 +855,7 @@ export function ThreadDetailPromptArea({
return (
;
type SecondaryPanelChangeHandler = (panel: ThreadSecondaryPanelTab) => void;
+function focusThreadDetailComposer(): void {
+ window.requestAnimationFrame(() => {
+ const composer = document.getElementById(
+ THREAD_DETAIL_COMPOSER_TEXTAREA_ID,
+ );
+ if (!(composer instanceof HTMLTextAreaElement)) {
+ return;
+ }
+
+ composer.focus();
+ const cursor = composer.value.length;
+ composer.setSelectionRange(cursor, cursor);
+ });
+}
+
function buildHostConnectionNotice(
thread: ThreadWithRuntime,
): HostConnectionNotice | null {
@@ -266,11 +290,16 @@ export function ThreadDetailView() {
threadId,
threadType: thread?.type,
});
+ const threadAppsQuery = useThreadApps(threadId ?? "", {
+ enabled: Boolean(threadId) && thread !== undefined,
+ });
const {
+ activateAppTab,
activateNewTab,
activateHostFileTab,
activateStorageFileTab,
activateWorkspaceFileTab,
+ activeAppId,
activeHostFileLineNumber,
activeHostFilePath,
activeStorageFilePath,
@@ -279,19 +308,21 @@ export function ThreadDetailView() {
activeWorkspaceFileSource,
activeWorkspaceFileStatusLabel,
clearActiveFileTabs,
+ closeAppTab,
closeHostFileTab,
closeNewTab,
closeStorageFileTab,
closeWorkspaceFileTab,
isNewTabActive,
openNewTab,
+ openApp,
openHostFile,
openStorageFile,
openWorkspaceFile,
orderedSecondaryFileTabs,
- pinnedStorageFilePath,
selectFileSearchResult,
} = useThreadFileTabs({
+ apps: threadAppsQuery.data,
threadId,
environmentId: thread?.environmentId,
threadType: thread?.type,
@@ -308,7 +339,7 @@ export function ThreadDetailView() {
return;
}
if (isManagerThread && activeFixedSecondaryTab === null) {
- openStorageFile(pinnedStorageFilePath);
+ openApp(STATUS_APP_ID);
return;
}
toggleDefaultPersistedSecondaryPanel();
@@ -316,8 +347,7 @@ export function ThreadDetailView() {
activeFixedSecondaryTab,
fixedPanelTabsState.secondary.isOpen,
isManagerThread,
- openStorageFile,
- pinnedStorageFilePath,
+ openApp,
setThreadSecondaryPanel,
toggleDefaultPersistedSecondaryPanel,
]);
@@ -469,6 +499,11 @@ export function ThreadDetailView() {
openNewTab();
setNewTabFocusRequest((current) => current + 1);
}, [openNewTab]);
+ const handleCreateAppPromptPrefill = useCallback(() => {
+ closeNewTab();
+ closeSecondaryPanel();
+ focusThreadDetailComposer();
+ }, [closeNewTab, closeSecondaryPanel]);
const handleTerminalPanelResize = useCallback(
(sizePercent: number) => {
const panelHeightPercent = Math.round(sizePercent);
@@ -500,10 +535,32 @@ export function ThreadDetailView() {
},
[openSecondaryPanelDiffFile, openWorkspaceFile],
);
+ const threadAppsById = useMemo(() => {
+ const entries = new Map(
+ (threadAppsQuery.data ?? []).map((app) => [app.id, app]),
+ );
+ return entries;
+ }, [threadAppsQuery.data]);
const fileTabs = useMemo(() => {
const filenameOf = (path: string) => path.split("/").at(-1) ?? path;
const tabs = orderedSecondaryFileTabs.map((tab): SecondaryPanelFileTab => {
switch (tab.kind) {
+ case "app": {
+ const app = threadAppsById.get(tab.appId);
+ const appName = app?.name ?? tab.appId;
+ return {
+ id: tab.id,
+ filename: appName,
+ isActive: tab.appId === activeAppId,
+ isPinned: tab.appId === STATUS_APP_ID,
+ leadingVisual: app ? (
+
+ ) : undefined,
+ statusLabel: null,
+ onSelect: () => activateAppTab(tab.appId),
+ onClose: () => closeAppTab(tab.appId),
+ };
+ }
case "workspace-file-preview":
return {
id: tab.id,
@@ -545,19 +602,23 @@ export function ThreadDetailView() {
});
return tabs.length > 0 ? tabs : undefined;
}, [
+ activateAppTab,
activateNewTab,
activateHostFileTab,
activateStorageFileTab,
activateWorkspaceFileTab,
+ activeAppId,
activeHostFilePath,
activeStorageFilePath,
activeWorkspaceFilePath,
+ closeAppTab,
closeHostFileTab,
closeNewTab,
closeStorageFileTab,
closeWorkspaceFileTab,
isNewTabActive,
orderedSecondaryFileTabs,
+ threadAppsById,
]);
const requestedMergeBaseBranch =
selectedMergeBaseBranch ?? environmentMergeBaseBranch;
@@ -1083,8 +1144,11 @@ export function ThreadDetailView() {
currentThreadId={thread.id}
currentThreadType={thread.type}
focusRequest={newTabFocusRequest}
+ onCreateAppPromptPrefill={handleCreateAppPromptPrefill}
onSelect={selectFileSearchResult}
/>
+ ) : activeAppId ? (
+
) : activeWorkspaceFilePath ? (
) : undefined;
diff --git a/apps/app/src/views/thread-detail/threadLocalFileHtmlPreviewRouting.test.tsx b/apps/app/src/views/thread-detail/threadLocalFileHtmlPreviewRouting.test.tsx
index 4d0365ddf..b83a59c92 100644
--- a/apps/app/src/views/thread-detail/threadLocalFileHtmlPreviewRouting.test.tsx
+++ b/apps/app/src/views/thread-detail/threadLocalFileHtmlPreviewRouting.test.tsx
@@ -13,7 +13,6 @@ import {
HostFilePreviewTabContent,
ThreadStorageFilePreviewTabContent,
} from "@/components/secondary-panel/ThreadSecondaryPanelTabContent";
-import { MANAGER_STATUS_FILE_PATH } from "@/components/secondary-panel/managerStorage";
import { useThreadStorageViewer } from "@/components/secondary-panel/useThreadStorageViewer";
import type { ThreadTimelineLocalFileLink } from "@/components/thread/timeline";
import { MarkdownPreview } from "@/components/ui/markdown-preview";
@@ -123,8 +122,6 @@ function MarkdownHtmlPreviewHarness({
{openedFile?.kind === "thread-storage" ? (
) : null}
@@ -225,8 +222,6 @@ function PanelClosedThreadStorageMarkdownLinkHarness({
{openedFile?.kind === "thread-storage" ? (
) : null}
diff --git a/apps/app/src/views/thread-detail/threadSecondaryPanelSelection.ts b/apps/app/src/views/thread-detail/threadSecondaryPanelSelection.ts
index c204bca47..ab3ecedef 100644
--- a/apps/app/src/views/thread-detail/threadSecondaryPanelSelection.ts
+++ b/apps/app/src/views/thread-detail/threadSecondaryPanelSelection.ts
@@ -56,6 +56,7 @@ function getSecondaryPanelForFixedTab(
case "workspace-file-preview":
case "host-file-preview":
case "thread-storage-file-preview":
+ case "app":
case "new-tab":
return "thread-info";
case "terminal":
diff --git a/apps/cli/src/__tests__/command-output.test.ts b/apps/cli/src/__tests__/command-output.test.ts
index 2ccc34be0..a92a9226b 100644
--- a/apps/cli/src/__tests__/command-output.test.ts
+++ b/apps/cli/src/__tests__/command-output.test.ts
@@ -44,6 +44,7 @@ vi.mock("../daemon.js", () => ({
import { createClient, unwrap } from "../client.js";
import { fetchLocalHostId } from "../daemon.js";
+import { registerAppCommands } from "../commands/app.js";
import { registerEnvironmentCommands } from "../commands/environment.js";
import { registerGuideCommand } from "../commands/guide.js";
import { registerHostCommands } from "../commands/host.js";
@@ -400,14 +401,14 @@ describe("CLI command output contracts", () => {
vi.unstubAllEnvs();
});
- it("bb guide styling prints the styling chapter", async () => {
+ it("bb guide styling redirects to the app chapter", async () => {
await runCommand(["guide", "styling"], registerGuideCommand);
const output = collectLogPayloads(vi.mocked(console.log)).join("\n");
expect(output.trim().length).toBeGreaterThan(0);
- expect(output).toContain("Status styling");
+ expect(output).toContain("Apps");
+ expect(output).toContain("Styling:");
expect(output).toContain("https://cdn.tailwindcss.com");
- expect(output).toContain("--background: oklch(0.9551 0 0);");
expect(output).toContain("@media (prefers-color-scheme: dark)");
});
@@ -433,19 +434,18 @@ describe("CLI command output contracts", () => {
expect(output).not.toContain("~/.bb/manager-templates");
expect(output).not.toContain("~/.bb-dev/manager-templates");
expect(output).toContain("bb manager hire --template sawyer-next");
- expect(output).toContain("Only top-level regular files are copied");
+ expect(output).toContain("recursively copies every regular file");
});
- it("bb guide status-state prints the STATUS state chapter", async () => {
- await runCommand(["guide", "status-state"], registerGuideCommand);
+ it("bb guide app prints the app chapter", async () => {
+ await runCommand(["guide", "app"], registerGuideCommand);
const output = collectLogPayloads(vi.mocked(console.log)).join("\n");
expect(output.trim().length).toBeGreaterThan(0);
- expect(output).toContain("STATUS state");
- expect(output).toContain("window.bbStatusState");
- expect(output).toContain("Sending a message to the manager");
- expect(output).toContain("window.bbThreadTell(text)");
- expect(output).toContain("$BB_THREAD_STORAGE/STATUS-data");
+ expect(output).toContain("Apps");
+ expect(output).toContain("apps/status/data/state.json");
+ expect(output).toContain("window.bb.data");
+ expect(output).toContain("bb app list --self");
});
it("bb guide unknown chapter lists styling in available chapters", async () => {
@@ -456,7 +456,7 @@ describe("CLI command output contracts", () => {
const errorOutput = collectLogLines(vi.mocked(console.error)).join("\n");
expect(errorOutput).toContain("Unknown guide chapter 'missing'");
expect(errorOutput).toContain(
- "Available: threads, environments, managers, manager-templates, status-state, providers, projects, hosts, styling, async.",
+ "Available: threads, environments, managers, manager-templates, app, providers, projects, hosts, styling, async.",
);
});
@@ -1176,6 +1176,134 @@ describe("CLI command output contracts", () => {
]);
});
+ it("bb app list renders resolved app summaries", async () => {
+ vi.stubEnv("BB_THREAD_ID", "thr_current");
+ const apps = [
+ {
+ id: "status",
+ name: "Status",
+ entry: { path: "index.html", kind: "html" },
+ capabilities: ["data", "message"],
+ icon: { kind: "builtin", name: "ListTodo" },
+ },
+ {
+ id: "demo",
+ name: "Demo",
+ entry: { path: "readme.md", kind: "md" },
+ capabilities: [],
+ icon: {
+ kind: "logo",
+ url: "/api/v1/threads/thr_current/apps/demo/icon",
+ },
+ },
+ ];
+ const get = vi.fn(async () => apps);
+ createClientMock.mockReturnValue(
+ asServerClient({
+ api: {
+ v1: {
+ threads: {
+ ":id": {
+ apps: {
+ $get: get,
+ },
+ },
+ },
+ },
+ },
+ }),
+ );
+
+ await runCommand(["app", "list"], (program) =>
+ registerAppCommands(program, () => "http://server"),
+ );
+
+ expect(get).toHaveBeenCalledWith({ param: { id: "thr_current" } });
+ expect(collectLogPayloads(vi.mocked(console.log))).toEqual([
+ "ID Name Entry Capabilities Icon\n------------------------ ------------------------ ------------------------ ------------------------ ------------------\nstatus Status html:index.html data,message ListTodo\n------------------------ ------------------------ ------------------------ ------------------------ ------------------\ndemo Demo md:readme.md - logo",
+ ]);
+ });
+
+ it("bb app new targets the current thread and posts the selected template", async () => {
+ vi.stubEnv("BB_THREAD_ID", "thr_current");
+ const created = {
+ id: "demo",
+ name: "Demo",
+ entry: { path: "index.html", kind: "html" },
+ capabilities: ["data", "message"],
+ icon: { kind: "builtin", name: "ListTodo" },
+ };
+ const post = vi.fn(async () => created);
+ createClientMock.mockReturnValue(
+ asServerClient({
+ api: {
+ v1: {
+ threads: {
+ ":id": {
+ apps: {
+ $post: post,
+ },
+ },
+ },
+ },
+ },
+ }),
+ );
+
+ await runCommand(
+ ["app", "new", "demo", "--template", "status"],
+ (program) => registerAppCommands(program, () => "http://server"),
+ );
+
+ expect(post).toHaveBeenCalledWith({
+ param: { id: "thr_current" },
+ json: { id: "demo", name: "demo", template: "status" },
+ });
+ expect(collectLogPayloads(vi.mocked(console.log))).toEqual([
+ "App created: demo",
+ " Name: Demo",
+ " Entry: html:index.html",
+ " Capabilities: data,message",
+ " Icon: ListTodo",
+ ]);
+ });
+
+ it("bb app new derives a valid id from a display name", async () => {
+ vi.stubEnv("BB_THREAD_ID", "thr_current");
+ const created = {
+ id: "my-app",
+ name: "My App",
+ entry: { path: "index.html", kind: "html" },
+ capabilities: ["data"],
+ icon: { kind: "builtin", name: "GridView" },
+ };
+ const post = vi.fn(async () => created);
+ createClientMock.mockReturnValue(
+ asServerClient({
+ api: {
+ v1: {
+ threads: {
+ ":id": {
+ apps: {
+ $post: post,
+ },
+ },
+ },
+ },
+ },
+ }),
+ );
+
+ await runCommand(["app", "new", "My App"], (program) =>
+ registerAppCommands(program, () => "http://server"),
+ );
+
+ expect(post).toHaveBeenCalledWith({
+ param: { id: "thr_current" },
+ json: { id: "my-app", name: "My App", template: "blank" },
+ });
+ });
+
it("bb manager status includes managed child threads", async () => {
const managerThread: Thread = makeThread({
id: "thread-manager-1",
diff --git a/apps/cli/src/commands/app.ts b/apps/cli/src/commands/app.ts
new file mode 100644
index 000000000..28552c8e8
--- /dev/null
+++ b/apps/cli/src/commands/app.ts
@@ -0,0 +1,283 @@
+import { Command } from "commander";
+import { appIdSchema, type AppId } from "@bb/domain";
+import type {
+ AppDetail,
+ AppIcon,
+ AppSummary,
+ AppTemplate,
+ CreateThreadAppRequest,
+} from "@bb/server-contract";
+import { appTemplateSchema } from "@bb/server-contract";
+import { action } from "../action.js";
+import { createClient, unwrap } from "../client.js";
+import { renderBorderlessTable } from "../table.js";
+import {
+ confirmDestructiveAction,
+ outputJson,
+ printContextLabel,
+ requireThreadIdWithLabelOrSelf,
+} from "./helpers.js";
+
+type ResolveServerUrl = () => string;
+
+interface AppThreadCommandOptions {
+ json?: boolean;
+ self?: boolean;
+}
+
+interface AppNewCommandOptions extends AppThreadCommandOptions {
+ id?: string;
+ template?: string;
+}
+
+interface AppRemoveCommandOptions extends AppThreadCommandOptions {
+ yes?: boolean;
+}
+
+interface ResolveAppCommandThreadArgs {
+ options: AppThreadCommandOptions;
+ threadId: string | undefined;
+}
+
+interface AppOpenPayload {
+ app: AppDetail;
+ threadId: string;
+ url: string;
+}
+
+interface ResolveNewAppIdArgs {
+ id: string | undefined;
+ name: string;
+}
+
+function parseAppTemplate(value: string | undefined): AppTemplate {
+ const parsed = appTemplateSchema.safeParse(value ?? "blank");
+ if (!parsed.success) {
+ throw new Error("Invalid app template. Expected 'blank' or 'status'.");
+ }
+ return parsed.data;
+}
+
+function resolveAppCommandThread(args: ResolveAppCommandThreadArgs): string {
+ const resolved = requireThreadIdWithLabelOrSelf(
+ args.threadId,
+ args.options,
+ );
+ printContextLabel(resolved, "Thread", "BB_THREAD_ID", args.options);
+ return resolved.id;
+}
+
+function slugifyAppName(name: string): string {
+ return name
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/gu, "-")
+ .replace(/-+/gu, "-")
+ .replace(/^-|-$/gu, "");
+}
+
+function resolveNewAppId(args: ResolveNewAppIdArgs): AppId {
+ const candidate = args.id ?? slugifyAppName(args.name);
+ const parsed = appIdSchema.safeParse(candidate);
+ if (parsed.success) {
+ return parsed.data;
+ }
+ if (args.id !== undefined) {
+ throw new Error(
+ "Invalid app id. Use letters, numbers, underscores, or hyphens.",
+ );
+ }
+ throw new Error(
+ `Could not derive a valid app id from "${args.name}". Pass --id with letters, numbers, underscores, or hyphens.`,
+ );
+}
+
+function appUrl(baseUrl: string, threadId: string, appId: string): string {
+ return `${baseUrl.replace(/\/$/u, "")}/api/v1/threads/${encodeURIComponent(
+ threadId,
+ )}/apps/${encodeURIComponent(appId)}/`;
+}
+
+function formatIcon(icon: AppIcon): string {
+ return icon.kind === "builtin" ? icon.name : "logo";
+}
+
+function printAppsTable(apps: AppSummary[]): void {
+ if (apps.length === 0) {
+ console.log("No apps");
+ return;
+ }
+ console.log(
+ renderBorderlessTable(
+ {
+ head: ["ID", "Name", "Entry", "Capabilities", "Icon"],
+ colWidths: [24, 24, 24, 24, 18],
+ trimTrailingWhitespace: true,
+ },
+ apps.map((app) => [
+ app.id,
+ app.name,
+ `${app.entry.kind}:${app.entry.path}`,
+ app.capabilities.join(",") || "-",
+ formatIcon(app.icon),
+ ]),
+ ),
+ );
+}
+
+function printAppDetail(app: AppDetail): void {
+ console.log(`App created: ${app.id}`);
+ console.log(` Name: ${app.name}`);
+ console.log(` Entry: ${app.entry.kind}:${app.entry.path}`);
+ console.log(` Capabilities: ${app.capabilities.join(",") || "-"}`);
+ console.log(` Icon: ${formatIcon(app.icon)}`);
+}
+
+export function registerAppCommands(
+ program: Command,
+ getUrl: ResolveServerUrl,
+): void {
+ const app = program.command("app").description("Manage thread apps");
+
+ app
+ .command("new [threadId]")
+ .description("Create a new app in a thread")
+ .option("--id ", "App id. Defaults to a slug derived from name.")
+ .option("--template ", "App template: blank or status", "blank")
+ .option("--self", "Target BB_THREAD_ID explicitly")
+ .option("--json", "Print machine-readable JSON output")
+ .action(
+ action(
+ async (
+ name: string,
+ threadIdArg: string | undefined,
+ opts: AppNewCommandOptions,
+ ) => {
+ const threadId = resolveAppCommandThread({
+ threadId: threadIdArg,
+ options: opts,
+ });
+ const template = parseAppTemplate(opts.template);
+ const client = createClient(getUrl());
+ const request: CreateThreadAppRequest = {
+ id: resolveNewAppId({ id: opts.id, name }),
+ name,
+ template,
+ };
+ const created = await unwrap(
+ client.api.v1.threads[":id"].apps.$post({
+ param: { id: threadId },
+ json: request,
+ }),
+ );
+ if (outputJson(opts, created)) return;
+ printAppDetail(created);
+ },
+ ),
+ );
+
+ app
+ .command("list [threadId]")
+ .description("List apps in a thread")
+ .option("--self", "Target BB_THREAD_ID explicitly")
+ .option("--json", "Print machine-readable JSON output")
+ .action(
+ action(
+ async (
+ threadIdArg: string | undefined,
+ opts: AppThreadCommandOptions,
+ ) => {
+ const threadId = resolveAppCommandThread({
+ threadId: threadIdArg,
+ options: opts,
+ });
+ const client = createClient(getUrl());
+ const apps = await unwrap(
+ client.api.v1.threads[":id"].apps.$get({
+ param: { id: threadId },
+ }),
+ );
+ if (outputJson(opts, apps)) return;
+ printAppsTable(apps);
+ },
+ ),
+ );
+
+ app
+ .command("open [threadId]")
+ .description("Print an app URL")
+ .option("--self", "Target BB_THREAD_ID explicitly")
+ .option("--json", "Print machine-readable JSON output")
+ .action(
+ action(
+ async (
+ name: string,
+ threadIdArg: string | undefined,
+ opts: AppThreadCommandOptions,
+ ) => {
+ const threadId = resolveAppCommandThread({
+ threadId: threadIdArg,
+ options: opts,
+ });
+ const baseUrl = getUrl();
+ const client = createClient(baseUrl);
+ const appDetail = await unwrap(
+ client.api.v1.threads[":id"].apps[":appId"].$get({
+ param: { id: threadId, appId: name },
+ }),
+ );
+ const payload: AppOpenPayload = {
+ threadId,
+ app: appDetail,
+ url: appUrl(baseUrl, threadId, appDetail.id),
+ };
+ if (outputJson(opts, payload)) return;
+ console.log(payload.url);
+ },
+ ),
+ );
+
+ app
+ .command("rm [threadId]")
+ .description("Remove an app from a thread")
+ .option("--yes", "Skip the confirmation prompt")
+ .option("--self", "Target BB_THREAD_ID explicitly")
+ .option("--json", "Print machine-readable JSON output")
+ .action(
+ action(
+ async (
+ name: string,
+ threadIdArg: string | undefined,
+ opts: AppRemoveCommandOptions,
+ ) => {
+ const threadId = resolveAppCommandThread({
+ threadId: threadIdArg,
+ options: opts,
+ });
+ const client = createClient(getUrl());
+ const appDetail = await unwrap(
+ client.api.v1.threads[":id"].apps[":appId"].$get({
+ param: { id: threadId, appId: name },
+ }),
+ );
+ if (!opts.yes) {
+ const confirmed = await confirmDestructiveAction(
+ `Remove app "${appDetail.name}" from thread ${threadId}? This cannot be undone.`,
+ );
+ if (!confirmed) {
+ console.log(`App ${name} removal cancelled`);
+ return;
+ }
+ }
+ await unwrap<{ ok: true }>(
+ client.api.v1.threads[":id"].apps[":appId"].$delete({
+ param: { id: threadId, appId: name },
+ }),
+ );
+ const payload = { ok: true, threadId, appId: name };
+ if (outputJson(opts, payload)) return;
+ console.log(`App ${name} removed`);
+ },
+ ),
+ );
+}
diff --git a/apps/cli/src/commands/guide.ts b/apps/cli/src/commands/guide.ts
index 04dc40b84..ec9a97cf5 100644
--- a/apps/cli/src/commands/guide.ts
+++ b/apps/cli/src/commands/guide.ts
@@ -9,11 +9,11 @@ const guideChapters: Record = {
environments: "bbGuideEnvironments",
managers: "bbGuideManagers",
"manager-templates": "bbGuideManagerTemplates",
- "status-state": "bbGuideStatusState",
+ app: "bbGuideApp",
providers: "bbGuideProviders",
projects: "bbGuideProjects",
hosts: "bbGuideHosts",
- styling: "bbGuideStyling",
+ styling: "bbGuideApp",
async: "bbGuideAsync",
};
diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts
index f3a4e472f..0cf62003a 100644
--- a/apps/cli/src/index.ts
+++ b/apps/cli/src/index.ts
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { Command } from "commander";
+import { registerAppCommands } from "./commands/app.js";
import { registerEnvironmentCommands } from "./commands/environment.js";
import { registerGuideCommand } from "./commands/guide.js";
import { registerHostCommands } from "./commands/host.js";
@@ -65,6 +66,7 @@ registerProjectCommands(program, getUrl);
registerHostCommands(program, getUrl);
registerProviderCommands(program, getUrl);
registerManagerCommands(program, getUrl);
+registerAppCommands(program, getUrl);
registerThreadCommands(program, getUrl);
registerReplayCommands(program, getUrl);
registerEnvironmentCommands(program, getUrl);
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index a9a86c50b..fcc0b8e13 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -28,6 +28,7 @@
},
"devDependencies": {
"@bb/config": "workspace:*",
+ "@bb/domain": "workspace:*",
"@bb/server-contract": "workspace:*",
"@bb/tsconfig": "workspace:*",
"@types/node": "^24.9.0",
diff --git a/apps/desktop/src/local-view.ts b/apps/desktop/src/local-view.ts
index 253c80107..6143957e3 100644
--- a/apps/desktop/src/local-view.ts
+++ b/apps/desktop/src/local-view.ts
@@ -1,4 +1,5 @@
import { stripVTControlCharacters } from "node:util";
+import { escapeHtmlText } from "@bb/domain";
export type LocalViewModel = LoadingViewModel | StartupErrorViewModel;
@@ -19,24 +20,6 @@ export interface CreateLocalViewUrlArgs {
viewModel: LocalViewModel;
}
-function escapeHtml(value: string): string {
- return value.replace(/[&<>"']/gu, (character) => {
- if (character === "&") {
- return "&";
- }
- if (character === "<") {
- return "<";
- }
- if (character === ">") {
- return ">";
- }
- if (character === '"') {
- return """;
- }
- return "'";
- });
-}
-
function formatPlainLogText(value: string): string {
return stripVTControlCharacters(value).replace(/\r\n?/gu, "\n");
}
@@ -45,8 +28,8 @@ function renderLoadingView(viewModel: LoadingViewModel): string {
return `
- ${escapeHtml(viewModel.title)}
- ${escapeHtml(viewModel.message)}
+ ${escapeHtmlText(viewModel.title)}
+ ${escapeHtmlText(viewModel.message)}
`;
}
@@ -54,11 +37,11 @@ function renderLoadingView(viewModel: LoadingViewModel): string {
function renderErrorView(viewModel: StartupErrorViewModel): string {
const logText = formatPlainLogText(viewModel.logText);
const logs =
- logText.trim().length > 0 ? `${escapeHtml(logText)}` : "";
+ logText.trim().length > 0 ? `${escapeHtmlText(logText)}` : "";
return `
- ${escapeHtml(viewModel.title)}
- ${escapeHtml(viewModel.details)}
+ ${escapeHtmlText(viewModel.title)}
+ ${escapeHtmlText(viewModel.details)}
${logs}
`;
diff --git a/apps/desktop/src/log-viewer.ts b/apps/desktop/src/log-viewer.ts
index 9fb526fb1..5e397c39d 100644
--- a/apps/desktop/src/log-viewer.ts
+++ b/apps/desktop/src/log-viewer.ts
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
import { mkdir, readdir, stat } from "node:fs/promises";
import { watch, type FSWatcher } from "node:fs";
import { join } from "node:path";
+import { escapeHtmlText } from "@bb/domain";
import {
LOG_VIEWER_VISIBLE_LINE_LIMIT,
type LogViewerComponent,
@@ -50,10 +51,6 @@ export interface LogLineBuffer {
stop(): void;
}
-interface EscapeHtmlArgs {
- value: string;
-}
-
interface ParseLogFileCandidateArgs {
component: LogViewerComponent;
fileName: string;
@@ -114,24 +111,6 @@ interface LogLineBufferState {
visibleLines: LogViewerLine[];
}
-function escapeHtml(args: EscapeHtmlArgs): string {
- return args.value.replace(/[&<>"']/gu, (character) => {
- if (character === "&") {
- return "&";
- }
- if (character === "<") {
- return "<";
- }
- if (character === ">") {
- return ">";
- }
- if (character === '"') {
- return """;
- }
- return "'";
- });
-}
-
export function createLogLineBuffer(
args: CreateLogLineBufferArgs,
): LogLineBuffer {
@@ -210,7 +189,7 @@ export function createLogLineBuffer(
}
export function createLogViewerViewUrl(args: CreateLogViewerViewUrlArgs): string {
- const escapedLogDir = escapeHtml({ value: args.logDir });
+ const escapedLogDir = escapeHtmlText(args.logDir);
const html = `
diff --git a/apps/host-daemon/src/app-data-change-reporter.test.ts b/apps/host-daemon/src/app-data-change-reporter.test.ts
new file mode 100644
index 000000000..51efe18d7
--- /dev/null
+++ b/apps/host-daemon/src/app-data-change-reporter.test.ts
@@ -0,0 +1,212 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { HostDaemonAppDataChangePayload } from "@bb/host-daemon-contract";
+import type { HostDaemonLogger } from "./logger.js";
+import { AppDataChangeReporter } from "./app-data-change-reporter.js";
+
+const tempDirs: string[] = [];
+
+async function makeTempDir(prefix: string): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
+ tempDirs.push(dir);
+ return dir;
+}
+
+async function writeJsonFile(filePath: string, value: object): Promise {
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
+}
+
+function createLogger(): HostDaemonLogger {
+ return {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ };
+}
+
+afterEach(async () => {
+ await Promise.all(
+ tempDirs.splice(0).map(async (dir) => {
+ await fs.rm(dir, { recursive: true, force: true });
+ }),
+ );
+});
+
+describe("AppDataChangeReporter", () => {
+ it("posts changed app data values and suppresses duplicate versions", async () => {
+ const rootPath = await makeTempDir("bb-app-data-reporter-");
+ const statePath = path.join(
+ rootPath,
+ "apps",
+ "status",
+ "data",
+ "state.json",
+ );
+ const posted: HostDaemonAppDataChangePayload[] = [];
+ const reporter = new AppDataChangeReporter({
+ logger: createLogger(),
+ postAppDataChange: async (payload) => {
+ posted.push(payload);
+ },
+ postAppDataResync: async () => undefined,
+ });
+
+ await writeJsonFile(statePath, { workers: [] });
+ await reporter.observe({
+ appId: "status",
+ path: "state.json",
+ threadId: "thr_one",
+ threadStoragePath: rootPath,
+ });
+ await reporter.observe({
+ appId: "status",
+ path: "state.json",
+ threadId: "thr_one",
+ threadStoragePath: rootPath,
+ });
+
+ expect(posted).toHaveLength(1);
+ expect(posted[0]).toMatchObject({
+ appId: "status",
+ threadId: "thr_one",
+ path: "state.json",
+ deleted: false,
+ value: { workers: [] },
+ });
+ expect(posted[0]?.version).toMatch(/^[a-f0-9]{64}$/u);
+ });
+
+ it("posts deleted events after observed files are removed", async () => {
+ const rootPath = await makeTempDir("bb-app-data-reporter-delete-");
+ const statePath = path.join(
+ rootPath,
+ "apps",
+ "status",
+ "data",
+ "state.json",
+ );
+ const posted: HostDaemonAppDataChangePayload[] = [];
+ const reporter = new AppDataChangeReporter({
+ logger: createLogger(),
+ postAppDataChange: async (payload) => {
+ posted.push(payload);
+ },
+ postAppDataResync: async () => undefined,
+ });
+
+ await writeJsonFile(statePath, { workers: [] });
+ await reporter.observe({
+ appId: "status",
+ path: "state.json",
+ threadId: "thr_one",
+ threadStoragePath: rootPath,
+ });
+ await fs.rm(statePath);
+ await reporter.observe({
+ appId: "status",
+ path: "state.json",
+ threadId: "thr_one",
+ threadStoragePath: rootPath,
+ });
+
+ expect(posted).toHaveLength(2);
+ expect(posted[1]).toEqual({
+ appId: "status",
+ threadId: "thr_one",
+ path: "state.json",
+ deleted: true,
+ value: null,
+ version: null,
+ });
+ });
+
+ it("re-primes tracked threads and posts resync hints after reconnect", async () => {
+ const rootPath = await makeTempDir("bb-app-data-reporter-reprime-");
+ const statePath = path.join(
+ rootPath,
+ "apps",
+ "status",
+ "data",
+ "state.json",
+ );
+ const posted: HostDaemonAppDataChangePayload[] = [];
+ const resyncs: Array<{ appId: string; threadId: string }> = [];
+ const reporter = new AppDataChangeReporter({
+ logger: createLogger(),
+ postAppDataChange: async (payload) => {
+ posted.push(payload);
+ },
+ postAppDataResync: async (payload) => {
+ resyncs.push(payload);
+ },
+ });
+
+ await writeJsonFile(statePath, { workers: [] });
+ await reporter.replaceTrackedThreads({
+ targets: [{ threadId: "thr_one", threadStoragePath: rootPath }],
+ });
+ await reporter.observe({
+ appId: "status",
+ path: "state.json",
+ threadId: "thr_one",
+ threadStoragePath: rootPath,
+ });
+
+ expect(resyncs).toEqual([{ appId: "status", threadId: "thr_one" }]);
+ expect(posted).toHaveLength(0);
+
+ await writeJsonFile(statePath, { workers: [{ id: "worker-1" }] });
+ await reporter.observe({
+ appId: "status",
+ path: "state.json",
+ threadId: "thr_one",
+ threadStoragePath: rootPath,
+ });
+
+ expect(posted).toHaveLength(1);
+ expect(posted[0]).toMatchObject({
+ appId: "status",
+ threadId: "thr_one",
+ path: "state.json",
+ deleted: false,
+ value: { workers: [{ id: "worker-1" }] },
+ });
+ });
+
+ it("posts resync hints for apps whose data disappeared while disconnected", async () => {
+ const rootPath = await makeTempDir("bb-app-data-reporter-delete-resync-");
+ const statePath = path.join(
+ rootPath,
+ "apps",
+ "status",
+ "data",
+ "state.json",
+ );
+ const resyncs: Array<{ appId: string; threadId: string }> = [];
+ const reporter = new AppDataChangeReporter({
+ logger: createLogger(),
+ postAppDataChange: async () => undefined,
+ postAppDataResync: async (payload) => {
+ resyncs.push(payload);
+ },
+ });
+
+ await writeJsonFile(statePath, { workers: [] });
+ await reporter.replaceTrackedThreads({
+ targets: [{ threadId: "thr_one", threadStoragePath: rootPath }],
+ });
+ await fs.rm(statePath);
+ await reporter.replaceTrackedThreads({
+ targets: [{ threadId: "thr_one", threadStoragePath: rootPath }],
+ });
+
+ expect(resyncs).toEqual([
+ { appId: "status", threadId: "thr_one" },
+ { appId: "status", threadId: "thr_one" },
+ ]);
+ });
+});
diff --git a/apps/host-daemon/src/app-data-change-reporter.ts b/apps/host-daemon/src/app-data-change-reporter.ts
new file mode 100644
index 000000000..eea40c909
--- /dev/null
+++ b/apps/host-daemon/src/app-data-change-reporter.ts
@@ -0,0 +1,384 @@
+import type { HostDaemonAppDataChangePayload } from "@bb/host-daemon-contract";
+import {
+ appDataPathSchema,
+ appIdSchema,
+ type AppDataPath,
+ type AppId,
+} from "@bb/domain";
+import { CommandDispatchError } from "./command-dispatch-support.js";
+import { runtimeErrorLogFields } from "./error-utils.js";
+import type { HostDaemonLogger } from "./logger.js";
+import {
+ listThreadAppDataFromRoot,
+ readAppDataFromRoot,
+ type ThreadAppDataEntry,
+} from "./app-data-files.js";
+
+interface CreateAppDataChangeReporterOptions {
+ logger: HostDaemonLogger;
+ postAppDataChange: (
+ payload: HostDaemonAppDataChangePayload,
+ ) => Promise;
+ postAppDataResync: (payload: AppDataResyncPayload) => Promise;
+}
+
+interface AppDataCacheEntry {
+ version: string;
+}
+
+interface AppDataResyncPayload {
+ appId: AppId;
+ threadId: string;
+}
+
+interface AppDataCacheKeyArgs {
+ appId: AppId;
+ path: AppDataPath;
+ threadId: string;
+}
+
+interface ReportObservedDeleteArgs extends AppDataCacheKeyArgs {
+ generation: number;
+}
+
+interface ReportObservedChangeArgs {
+ change: ObserveAppDataChangeArgs;
+ generation: number;
+}
+
+interface AppDataReporterGenerationArgs {
+ generation: number;
+}
+
+interface TrackedAppDataThread {
+ threadId: string;
+ threadStoragePath: string;
+}
+
+interface ReplaceTrackedAppDataThreadsArgs {
+ targets: readonly TrackedAppDataThread[];
+}
+
+interface ReprimeThreadArgs {
+ generation: number;
+ target: TrackedAppDataThread;
+}
+
+interface ApplyThreadSnapshotArgs extends ReprimeThreadArgs {
+ snapshotEntries: readonly ThreadAppDataEntry[];
+ snapshotAppIds: readonly AppId[];
+}
+
+interface CachedAppDataKey {
+ appId: AppId;
+ cacheKey: string;
+ path: AppDataPath;
+}
+
+export interface ObserveAppDataChangeArgs {
+ appId: AppId;
+ path: AppDataPath;
+ threadId: string;
+ threadStoragePath: string;
+}
+
+function appDataCacheKey(args: AppDataCacheKeyArgs): string {
+ return `${args.threadId}\0${args.appId}\0${args.path}`;
+}
+
+function isMissingAppDataEntryError(error: Error): boolean {
+ return error instanceof CommandDispatchError && error.code === "ENOENT";
+}
+
+function isNonReportableAppDataReadError(error: Error): boolean {
+ return (
+ error instanceof CommandDispatchError &&
+ (error.code === "invalid_json" ||
+ error.code === "invalid_path" ||
+ error.code === "file_too_large")
+ );
+}
+
+export class AppDataChangeReporter {
+ private readonly cache = new Map();
+ private readonly appIdsByThreadId = new Map>();
+ private readonly pendingByCacheKey = new Map>();
+ private readonly trackedThreadIds = new Set();
+ private generation = 0;
+
+ constructor(private readonly options: CreateAppDataChangeReporterOptions) {}
+
+ async replaceTrackedThreads(
+ args: ReplaceTrackedAppDataThreadsArgs,
+ ): Promise {
+ this.generation += 1;
+ const generation = this.generation;
+ const nextThreadIds = new Set(
+ args.targets.map((target) => target.threadId),
+ );
+ this.replaceTrackedThreadIds(nextThreadIds);
+ this.pendingByCacheKey.clear();
+ await Promise.all(
+ args.targets.map((target) =>
+ this.reprimeThread({
+ generation,
+ target,
+ }),
+ ),
+ );
+ }
+
+ observe(args: ObserveAppDataChangeArgs): Promise {
+ this.trackApp({
+ appId: args.appId,
+ threadId: args.threadId,
+ });
+ const cacheKey = appDataCacheKey(args);
+ const generation = this.generation;
+ const previous = this.pendingByCacheKey.get(cacheKey) ?? Promise.resolve();
+ const pending = previous
+ .catch(() => undefined)
+ .then(() =>
+ this.reportObservedChange({
+ change: args,
+ generation,
+ }),
+ )
+ .catch((error) => {
+ this.options.logger.warn(
+ {
+ appId: args.appId,
+ path: args.path,
+ threadId: args.threadId,
+ ...runtimeErrorLogFields(error),
+ },
+ "Failed to report observed app data change",
+ );
+ })
+ .finally(() => {
+ if (this.pendingByCacheKey.get(cacheKey) === pending) {
+ this.pendingByCacheKey.delete(cacheKey);
+ }
+ });
+ this.pendingByCacheKey.set(cacheKey, pending);
+ return pending;
+ }
+
+ private isCurrentGeneration(args: AppDataReporterGenerationArgs): boolean {
+ return args.generation === this.generation;
+ }
+
+ private replaceTrackedThreadIds(threadIds: ReadonlySet): void {
+ for (const threadId of Array.from(this.trackedThreadIds)) {
+ if (threadIds.has(threadId)) {
+ continue;
+ }
+ this.trackedThreadIds.delete(threadId);
+ this.appIdsByThreadId.delete(threadId);
+ for (const cached of this.cachedKeysForThread({ threadId })) {
+ this.cache.delete(cached.cacheKey);
+ }
+ }
+ for (const threadId of threadIds) {
+ this.trackedThreadIds.add(threadId);
+ }
+ }
+
+ private trackApp(args: AppDataResyncPayload): void {
+ this.trackedThreadIds.add(args.threadId);
+ let appIds = this.appIdsByThreadId.get(args.threadId);
+ if (!appIds) {
+ appIds = new Set();
+ this.appIdsByThreadId.set(args.threadId, appIds);
+ }
+ appIds.add(args.appId);
+ }
+
+ private cachedKeysForThread(args: {
+ threadId: string;
+ }): CachedAppDataKey[] {
+ const prefix = `${args.threadId}\0`;
+ return Array.from(this.cache.keys())
+ .filter((cacheKey) => cacheKey.startsWith(prefix))
+ .map((cacheKey) => {
+ const [, rawAppId, rawDataPath] = cacheKey.split("\0");
+ return {
+ appId: appIdSchema.parse(rawAppId),
+ path: appDataPathSchema.parse(rawDataPath),
+ cacheKey,
+ };
+ });
+ }
+
+ private async reprimeThread(args: ReprimeThreadArgs): Promise {
+ try {
+ const snapshot = await listThreadAppDataFromRoot({
+ rootPath: args.target.threadStoragePath,
+ });
+ if (!this.isCurrentGeneration({ generation: args.generation })) {
+ return;
+ }
+ const previousAppIds = new Set(
+ this.appIdsByThreadId.get(args.target.threadId) ?? [],
+ );
+ await this.applyThreadSnapshot({
+ ...args,
+ snapshotEntries: snapshot.entries,
+ snapshotAppIds: snapshot.appIds,
+ });
+ const resyncAppIds = new Set([
+ ...previousAppIds,
+ ...snapshot.appIds,
+ ]);
+ await Promise.all(
+ Array.from(resyncAppIds)
+ .sort((left, right) => left.localeCompare(right))
+ .map((appId) =>
+ this.postResyncHint({
+ appId,
+ threadId: args.target.threadId,
+ }),
+ ),
+ );
+ } catch (error) {
+ this.options.logger.warn(
+ {
+ threadId: args.target.threadId,
+ threadStoragePath: args.target.threadStoragePath,
+ ...runtimeErrorLogFields(error),
+ },
+ "Failed to reprime app data change cache",
+ );
+ }
+ }
+
+ private async applyThreadSnapshot(
+ args: ApplyThreadSnapshotArgs,
+ ): Promise {
+ const seenCacheKeys = new Set();
+ const appIds = new Set(args.snapshotAppIds);
+ for (const snapshotEntry of args.snapshotEntries) {
+ if (!this.isCurrentGeneration({ generation: args.generation })) {
+ return;
+ }
+ appIds.add(snapshotEntry.appId);
+ const cacheKey = appDataCacheKey({
+ appId: snapshotEntry.appId,
+ path: snapshotEntry.entry.path,
+ threadId: args.target.threadId,
+ });
+ seenCacheKeys.add(cacheKey);
+ this.cache.set(cacheKey, { version: snapshotEntry.entry.version });
+ }
+ this.appIdsByThreadId.set(args.target.threadId, appIds);
+ for (const cached of this.cachedKeysForThread({
+ threadId: args.target.threadId,
+ })) {
+ if (!seenCacheKeys.has(cached.cacheKey)) {
+ this.cache.delete(cached.cacheKey);
+ }
+ }
+ }
+
+ private async postResyncHint(args: AppDataResyncPayload): Promise {
+ try {
+ await this.options.postAppDataResync(args);
+ } catch (error) {
+ this.options.logger.warn(
+ {
+ appId: args.appId,
+ threadId: args.threadId,
+ ...runtimeErrorLogFields(error),
+ },
+ "Failed to report app data resync hint",
+ );
+ }
+ }
+
+ private async reportObservedChange(
+ args: ReportObservedChangeArgs,
+ ): Promise {
+ if (!this.isCurrentGeneration({ generation: args.generation })) {
+ return;
+ }
+ const cacheKey = appDataCacheKey(args.change);
+ const previous = this.cache.get(cacheKey);
+
+ try {
+ const entry = await readAppDataFromRoot({
+ appId: args.change.appId,
+ path: args.change.path,
+ rootPath: args.change.threadStoragePath,
+ });
+ if (!this.isCurrentGeneration({ generation: args.generation })) {
+ return;
+ }
+ if (previous?.version === entry.version) {
+ return;
+ }
+ await this.options.postAppDataChange({
+ appId: args.change.appId,
+ threadId: args.change.threadId,
+ path: entry.path,
+ deleted: false,
+ value: entry.value,
+ version: entry.version,
+ });
+ if (!this.isCurrentGeneration({ generation: args.generation })) {
+ return;
+ }
+ this.trackApp({
+ appId: args.change.appId,
+ threadId: args.change.threadId,
+ });
+ this.cache.set(cacheKey, { version: entry.version });
+ } catch (error) {
+ if (error instanceof Error && isMissingAppDataEntryError(error)) {
+ await this.reportObservedDelete({
+ appId: args.change.appId,
+ generation: args.generation,
+ path: args.change.path,
+ threadId: args.change.threadId,
+ });
+ return;
+ }
+ if (error instanceof Error && isNonReportableAppDataReadError(error)) {
+ this.options.logger.warn(
+ {
+ appId: args.change.appId,
+ path: args.change.path,
+ threadId: args.change.threadId,
+ ...runtimeErrorLogFields(error),
+ },
+ "Ignoring unreadable observed app data file",
+ );
+ return;
+ }
+ throw error;
+ }
+ }
+
+ private async reportObservedDelete(
+ args: ReportObservedDeleteArgs,
+ ): Promise {
+ if (!this.isCurrentGeneration({ generation: args.generation })) {
+ return;
+ }
+ await this.options.postAppDataChange({
+ appId: args.appId,
+ threadId: args.threadId,
+ path: args.path,
+ deleted: true,
+ value: null,
+ version: null,
+ });
+ if (!this.isCurrentGeneration({ generation: args.generation })) {
+ return;
+ }
+ this.trackApp({
+ appId: args.appId,
+ threadId: args.threadId,
+ });
+ this.cache.delete(appDataCacheKey(args));
+ }
+}
diff --git a/apps/host-daemon/src/app-data-files.ts b/apps/host-daemon/src/app-data-files.ts
new file mode 100644
index 000000000..39d320046
--- /dev/null
+++ b/apps/host-daemon/src/app-data-files.ts
@@ -0,0 +1,280 @@
+import { createHash } from "node:crypto";
+import type { Stats } from "node:fs";
+import fs from "node:fs/promises";
+import path from "node:path";
+import {
+ appDataPathSchema,
+ appIdSchema,
+ jsonValueSchema,
+ type AppDataPath,
+ type AppId,
+ type JsonValue,
+} from "@bb/domain";
+import {
+ CommandDispatchError,
+ ExpectedCommandDispatchError,
+} from "./command-dispatch-support.js";
+import { isFsErrorWithCode } from "./fs-errors.js";
+import { NON_IMAGE_FILE_SIZE_LIMIT_BYTES } from "./command-handlers/file-read.js";
+import { resolveNonSymlinkDirectoryPath } from "./command-handlers/root-path.js";
+
+export interface AppDataEntry {
+ modifiedAtMs: number;
+ path: AppDataPath;
+ sizeBytes: number;
+ value: JsonValue;
+ version: string;
+}
+
+export interface ThreadAppDataEntry {
+ appId: AppId;
+ entry: AppDataEntry;
+}
+
+export interface ThreadAppDataSnapshot {
+ appIds: AppId[];
+ entries: ThreadAppDataEntry[];
+}
+
+interface AppDataTargetArgs {
+ appId: AppId;
+ path: AppDataPath;
+ rootPath: string;
+}
+
+interface ResolveAppDataRootArgs {
+ appId: AppId;
+ rootPath: string;
+}
+
+interface ReadAppDataJsonArgs {
+ bytes: Buffer;
+ path: AppDataPath;
+}
+
+interface ListAppDataFilesArgs {
+ appDataRoot: string;
+ currentDirectory: string;
+}
+
+interface ListThreadAppDataArgs {
+ rootPath: string;
+}
+
+function sha256(bytes: Buffer): string {
+ return createHash("sha256").update(bytes).digest("hex");
+}
+
+function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
+ const relativePath = path.relative(rootPath, candidatePath);
+ return (
+ relativePath === "" ||
+ (!relativePath.startsWith("..") && !path.isAbsolute(relativePath))
+ );
+}
+
+function createFileTooLargeError(stat: Stats): CommandDispatchError {
+ return new CommandDispatchError(
+ "file_too_large",
+ `File size ${stat.size} bytes exceeds the ${Math.floor(NON_IMAGE_FILE_SIZE_LIMIT_BYTES / (1024 * 1024))} MB limit`,
+ );
+}
+
+function parseJsonValue(args: ReadAppDataJsonArgs): JsonValue {
+ try {
+ return jsonValueSchema.parse(JSON.parse(args.bytes.toString("utf8")));
+ } catch {
+ throw new CommandDispatchError(
+ "invalid_json",
+ `App data path ${args.path} does not contain valid JSON`,
+ );
+ }
+}
+
+async function resolveAppDataRoot(
+ args: ResolveAppDataRootArgs,
+): Promise {
+ const appId = appIdSchema.parse(args.appId);
+ if (!path.isAbsolute(args.rootPath)) {
+ throw new CommandDispatchError("invalid_path", "rootPath must be absolute");
+ }
+ const threadStoragePath = await resolveNonSymlinkDirectoryPath({
+ description: "Thread storage root",
+ path: args.rootPath,
+ });
+ const appDataPath = path.join(threadStoragePath, "apps", appId, "data");
+ const resolvedAppDataPath = await resolveNonSymlinkDirectoryPath({
+ description: "App data directory",
+ path: appDataPath,
+ });
+ if (!isPathWithinRoot(resolvedAppDataPath, threadStoragePath)) {
+ throw new CommandDispatchError(
+ "invalid_path",
+ "App data path escapes thread storage root",
+ );
+ }
+ return resolvedAppDataPath;
+}
+
+export async function readAppDataFromRoot(
+ args: AppDataTargetArgs,
+): Promise {
+ const appDataPath = appDataPathSchema.parse(args.path);
+ let appDataRoot: string;
+ try {
+ appDataRoot = await resolveAppDataRoot({
+ appId: args.appId,
+ rootPath: args.rootPath,
+ });
+ } catch (error) {
+ if (isFsErrorWithCode(error, "ENOENT")) {
+ throw new ExpectedCommandDispatchError(
+ "ENOENT",
+ `Path does not exist: ${appDataPath}`,
+ );
+ }
+ throw error;
+ }
+ const filePath = path.join(appDataRoot, ...appDataPath.split("/"));
+ let stat;
+ try {
+ stat = await fs.lstat(filePath);
+ } catch (error) {
+ if (isFsErrorWithCode(error, "ENOENT")) {
+ throw new ExpectedCommandDispatchError(
+ "ENOENT",
+ `Path does not exist: ${appDataPath}`,
+ );
+ }
+ throw error;
+ }
+ if (stat.isSymbolicLink() || !stat.isFile()) {
+ throw new CommandDispatchError(
+ "invalid_path",
+ `App data path ${appDataPath} is not a regular file`,
+ );
+ }
+ if (stat.size > NON_IMAGE_FILE_SIZE_LIMIT_BYTES) {
+ throw createFileTooLargeError(stat);
+ }
+
+ const bytes = await fs.readFile(filePath);
+ return {
+ modifiedAtMs: stat.mtimeMs,
+ path: appDataPath,
+ sizeBytes: stat.size,
+ value: parseJsonValue({ bytes, path: appDataPath }),
+ version: sha256(bytes),
+ };
+}
+
+async function listAppDataFilePaths(
+ args: ListAppDataFilesArgs,
+): Promise {
+ const entries = await fs.readdir(args.currentDirectory, {
+ withFileTypes: true,
+ });
+ const paths: AppDataPath[] = [];
+ for (const entry of entries) {
+ if (entry.name.startsWith(".")) {
+ continue;
+ }
+ const entryPath = path.join(args.currentDirectory, entry.name);
+ if (entry.isDirectory()) {
+ paths.push(
+ ...(await listAppDataFilePaths({
+ appDataRoot: args.appDataRoot,
+ currentDirectory: entryPath,
+ })),
+ );
+ continue;
+ }
+ if (!entry.isFile()) {
+ continue;
+ }
+ const relativePath = path
+ .relative(args.appDataRoot, entryPath)
+ .split(path.sep)
+ .join("/");
+ const parsed = appDataPathSchema.safeParse(relativePath);
+ if (parsed.success) {
+ paths.push(parsed.data);
+ }
+ }
+ return paths.sort((left, right) => left.localeCompare(right));
+}
+
+export async function listThreadAppDataFromRoot(
+ args: ListThreadAppDataArgs,
+): Promise {
+ if (!path.isAbsolute(args.rootPath)) {
+ throw new CommandDispatchError("invalid_path", "rootPath must be absolute");
+ }
+ const threadStoragePath = await resolveNonSymlinkDirectoryPath({
+ description: "Thread storage root",
+ path: args.rootPath,
+ });
+ const appsRoot = path.join(threadStoragePath, "apps");
+ let appDirectories;
+ try {
+ appDirectories = await fs.readdir(appsRoot, { withFileTypes: true });
+ } catch (error) {
+ if (isFsErrorWithCode(error, "ENOENT")) {
+ return { appIds: [], entries: [] };
+ }
+ throw error;
+ }
+
+ const appIds: AppId[] = [];
+ const snapshotEntries: ThreadAppDataEntry[] = [];
+ for (const directory of appDirectories) {
+ if (!directory.isDirectory()) {
+ continue;
+ }
+ const parsedAppId = appIdSchema.safeParse(directory.name);
+ if (!parsedAppId.success) {
+ continue;
+ }
+ const appId = parsedAppId.data;
+ appIds.push(appId);
+ const appDataRoot = path.join(appsRoot, appId, "data");
+ let resolvedAppDataRoot;
+ try {
+ resolvedAppDataRoot = await resolveNonSymlinkDirectoryPath({
+ description: "App data directory",
+ path: appDataRoot,
+ });
+ } catch (error) {
+ if (isFsErrorWithCode(error, "ENOENT")) {
+ continue;
+ }
+ throw error;
+ }
+ const dataPaths = await listAppDataFilePaths({
+ appDataRoot: resolvedAppDataRoot,
+ currentDirectory: resolvedAppDataRoot,
+ });
+ for (const dataPath of dataPaths) {
+ snapshotEntries.push({
+ appId,
+ entry: await readAppDataFromRoot({
+ appId,
+ path: dataPath,
+ rootPath: threadStoragePath,
+ }),
+ });
+ }
+ }
+
+ appIds.sort((left, right) => left.localeCompare(right));
+ snapshotEntries.sort((left, right) => {
+ const appOrder = left.appId.localeCompare(right.appId);
+ return appOrder === 0
+ ? left.entry.path.localeCompare(right.entry.path)
+ : appOrder;
+ });
+ return {
+ appIds,
+ entries: snapshotEntries,
+ };
+}
diff --git a/apps/host-daemon/src/app.ts b/apps/host-daemon/src/app.ts
index 677e8fdb8..7142cbe93 100644
--- a/apps/host-daemon/src/app.ts
+++ b/apps/host-daemon/src/app.ts
@@ -1,8 +1,5 @@
import path from "node:path";
-import {
- CommandRouter,
- isStatusDataSetCommandResultNotification,
-} from "./command-router.js";
+import { CommandRouter } from "./command-router.js";
import { createDaemon, type HostDaemon } from "./daemon.js";
import {
createEventBuffer,
@@ -34,7 +31,7 @@ import {
} from "./terminals/terminal-manager.js";
import { createReplayCaptureService } from "@bb/replay-capture/writer";
import { createServerClient } from "./server-client.js";
-import { StatusDataChangeReporter } from "./status-data-change-reporter.js";
+import { AppDataChangeReporter } from "./app-data-change-reporter.js";
import {
ServerConnection,
type CreateReconnectingWebSocket,
@@ -372,10 +369,10 @@ export async function createHostDaemonApp(
reportEnvironmentChange: (change) =>
serverClient.postEnvironmentChange(change),
});
- const statusDataChangeReporter = new StatusDataChangeReporter({
+ const appDataChangeReporter = new AppDataChangeReporter({
logger: options.logger,
- postStatusDataChange: (payload) =>
- serverClient.postStatusDataChange(payload),
+ postAppDataChange: (payload) => serverClient.postAppDataChange(payload),
+ postAppDataResync: (payload) => serverClient.postAppDataResync(payload),
});
function buildInteractiveInterruptKey(
@@ -505,13 +502,6 @@ export async function createHostDaemonApp(
replayCapture?.recordRuntimeCaptureEntry(entry);
},
onEvent: ({ environmentId, event }) => {
- const threadStatusDataTarget = {
- threadId: event.threadId,
- threadStoragePath: path.join(threadStorageRootPath, event.threadId),
- };
- statusDataChangeReporter.trackThread({
- threadId: event.threadId,
- });
try {
eventBuffer.push({
threadId: event.threadId,
@@ -536,9 +526,6 @@ export async function createHostDaemonApp(
threadId: event.threadId,
event,
});
- if (event.type === "turn/completed") {
- void statusDataChangeReporter.reconcileThread(threadStatusDataTarget);
- }
},
onThreadStorageChanged: ({ environmentId }) => {
environmentChangeReporter.queue({
@@ -546,8 +533,8 @@ export async function createHostDaemonApp(
change: "thread-storage-changed",
});
},
- onThreadStatusDataChanged: (change) => {
- void statusDataChangeReporter.observe(change);
+ onThreadAppDataChanged: (change) => {
+ void appDataChangeReporter.observe(change);
},
onThreadStorageWatchError: ({ error }) => {
options.logger.warn(
@@ -696,21 +683,6 @@ export async function createHostDaemonApp(
emit: (event) => eventBuffer.push(event),
flush: () => eventBuffer.flush(),
},
- onStatusDataCommandResult: (notification) => {
- if (isStatusDataSetCommandResultNotification(notification)) {
- statusDataChangeReporter.recordCommandSet({
- threadId: notification.command.threadId,
- key: notification.result.key,
- value: notification.result.value,
- version: notification.result.version,
- });
- return;
- }
- statusDataChangeReporter.recordCommandDelete({
- threadId: notification.command.threadId,
- key: notification.result.key,
- });
- },
reportResult: async (report) => {
await serverClient.reportCommandResult(report);
},
@@ -748,7 +720,7 @@ export async function createHostDaemonApp(
runtimeManager.replaceTrackedThreadStorageTargets(
session.trackedThreadTargets,
);
- void statusDataChangeReporter.replaceTrackedThreads({
+ void appDataChangeReporter.replaceTrackedThreads({
targets: session.trackedThreadTargets.map((target) => ({
threadId: target.threadId,
threadStoragePath: path.join(threadStorageRootPath, target.threadId),
diff --git a/apps/host-daemon/src/command-dispatch.ts b/apps/host-daemon/src/command-dispatch.ts
index 604dcf5c1..f93ac13b7 100644
--- a/apps/host-daemon/src/command-dispatch.ts
+++ b/apps/host-daemon/src/command-dispatch.ts
@@ -21,10 +21,10 @@ import {
listHostFiles,
listHostPaths,
deleteHostRelativeFile,
+ deleteHostRelativePath,
readHostFile,
readHostFileMetadata,
readHostRelativeFile,
- readHostStatusVersion,
writeHostRelativeFile,
} from "./command-handlers/host-files.js";
import { resolveInteractiveRequest } from "./command-handlers/interactive.js";
@@ -39,12 +39,6 @@ import {
transcribeCodexVoice,
} from "./codex-chatgpt-client.js";
import { listManagerTemplatesCommand } from "./command-handlers/manager-templates.js";
-import {
- deleteHostStatusData,
- listHostStatusData,
- readHostStatusData,
- writeHostStatusData,
-} from "./command-handlers/status-data.js";
import {
ensureThreadRuntime,
handleThreadDeleted,
@@ -293,38 +287,6 @@ const commandHandlers: CommandHandlerMap = {
command: Extract,
_options: CommandDispatchOptions,
) => readHostFileMetadata(command),
- "host.status_version": async (
- command: Extract,
- _options: CommandDispatchOptions,
- ) => readHostStatusVersion(command),
- "host.status_data.list": async (
- command: Extract,
- options: CommandDispatchOptions,
- ) =>
- listHostStatusData(command, {
- threadStorageRootPath: options.threadStorageRootPath,
- }),
- "host.status_data.get": async (
- command: Extract,
- options: CommandDispatchOptions,
- ) =>
- readHostStatusData(command, {
- threadStorageRootPath: options.threadStorageRootPath,
- }),
- "host.status_data.set": async (
- command: Extract,
- options: CommandDispatchOptions,
- ) =>
- writeHostStatusData(command, {
- threadStorageRootPath: options.threadStorageRootPath,
- }),
- "host.status_data.delete": async (
- command: Extract,
- options: CommandDispatchOptions,
- ) =>
- deleteHostStatusData(command, {
- threadStorageRootPath: options.threadStorageRootPath,
- }),
"host.read_file": async (
command: Extract,
_options: CommandDispatchOptions,
@@ -341,6 +303,10 @@ const commandHandlers: CommandHandlerMap = {
command: Extract,
_options: CommandDispatchOptions,
) => deleteHostRelativeFile(command),
+ "host.delete_path_relative": async (
+ command: Extract,
+ _options: CommandDispatchOptions,
+ ) => deleteHostRelativePath(command),
"provider.list": async (
_command: Extract,
options: CommandDispatchOptions,
diff --git a/apps/host-daemon/src/command-handlers/file-list.test.ts b/apps/host-daemon/src/command-handlers/file-list.test.ts
index 4b6a84592..5ea132ca9 100644
--- a/apps/host-daemon/src/command-handlers/file-list.test.ts
+++ b/apps/host-daemon/src/command-handlers/file-list.test.ts
@@ -236,6 +236,27 @@ describe("listPathsRecursively", () => {
}
});
+ it("does not return symlinked files as regular path entries", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "bb-file-list-"));
+ try {
+ await fs.writeFile(path.join(root, "state.json"), "{}");
+ await fs.symlink(path.join(root, "state.json"), path.join(root, "logo.svg"));
+
+ const result = await listPathsRecursively({
+ dir: root,
+ root,
+ includeFiles: true,
+ includeDirectories: false,
+ });
+
+ expect(result).toEqual([
+ { kind: "file", path: "state.json", name: "state.json" },
+ ]);
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
+ });
+
it("normalizes Windows separators before returning paths", () => {
expect(normalizeListedPath("src\\components\\Button.tsx")).toBe(
"src/components/Button.tsx",
diff --git a/apps/host-daemon/src/command-handlers/file-list.ts b/apps/host-daemon/src/command-handlers/file-list.ts
index 14d93e0a3..1f1c96bde 100644
--- a/apps/host-daemon/src/command-handlers/file-list.ts
+++ b/apps/host-daemon/src/command-handlers/file-list.ts
@@ -142,6 +142,7 @@ export async function listPathsRecursively(
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
if (entry.name === "node_modules") continue;
+ if (entry.isSymbolicLink()) continue;
const fullPath = path.join(args.dir, entry.name);
const relativePath = normalizeListedPath(
diff --git a/apps/host-daemon/src/command-handlers/file-read.ts b/apps/host-daemon/src/command-handlers/file-read.ts
index 93ce37c84..e80e26bde 100644
--- a/apps/host-daemon/src/command-handlers/file-read.ts
+++ b/apps/host-daemon/src/command-handlers/file-read.ts
@@ -20,6 +20,7 @@ export interface ReadFileForTransportResult {
content: string;
contentEncoding: FileContentEncoding;
mimeType?: string;
+ modifiedAtMs?: number;
path: string;
sizeBytes: number;
}
@@ -325,6 +326,7 @@ export async function readFileForTransport(
: fileContents.toString("base64"),
contentEncoding,
...(mimeType ? { mimeType } : {}),
+ modifiedAtMs: stat.mtimeMs,
sizeBytes: stat.size,
};
}
@@ -370,6 +372,7 @@ export async function readRootRelativeFileForTransport(
: fileContents.toString("base64"),
contentEncoding,
...(mimeType ? { mimeType } : {}),
+ modifiedAtMs: stat.mtimeMs,
sizeBytes: stat.size,
};
}
diff --git a/apps/host-daemon/src/command-handlers/file-write.ts b/apps/host-daemon/src/command-handlers/file-write.ts
index 539d402fa..d2e37f964 100644
--- a/apps/host-daemon/src/command-handlers/file-write.ts
+++ b/apps/host-daemon/src/command-handlers/file-write.ts
@@ -68,6 +68,11 @@ interface DeleteRootRelativeFileResult {
previousHash: string | null;
}
+interface DeleteRootRelativePathResult {
+ deleted: boolean;
+ path: string;
+}
+
function validateRootRelativeWritePath(
args: ValidateRootRelativeWritePathArgs,
): ValidatedRootRelativeWritePath {
@@ -138,6 +143,9 @@ async function ensureWritableRoot(rootPath: string): Promise {
async function resolveWritableTarget(
args: ResolveWritableTargetArgs,
): Promise {
+ if (!path.isAbsolute(args.rootPath)) {
+ throw new CommandDispatchError("invalid_path", "rootPath must be absolute");
+ }
const realRootPath = await ensureWritableRoot(args.rootPath);
const absolutePath = path.join(realRootPath, ...args.relativePath.segments);
const parentPath = path.dirname(absolutePath);
@@ -260,3 +268,68 @@ export async function deleteRootRelativeFile(
previousHash: existing.hash,
};
}
+
+export async function deleteRootRelativePath(
+ args: DeleteRootRelativeFileArgs,
+): Promise {
+ const relativePath = validateRootRelativeWritePath({
+ relativePath: args.relativePath,
+ dotfiles: args.dotfiles,
+ });
+ if (!path.isAbsolute(args.rootPath)) {
+ throw new CommandDispatchError("invalid_path", "rootPath must be absolute");
+ }
+ let realRootPath: string;
+ try {
+ realRootPath = await resolveNonSymlinkDirectoryPath({
+ description: "Root path",
+ path: args.rootPath,
+ });
+ } catch (error) {
+ if (isFsErrorWithCode(error, "ENOENT")) {
+ return {
+ path: relativePath.resultPath,
+ deleted: false,
+ };
+ }
+ throw error;
+ }
+ const targetPath = path.join(realRootPath, ...relativePath.segments);
+ const parentPath = path.dirname(targetPath);
+ const realParentPath = await resolveNonSymlinkDirectoryPath({
+ description: "Parent path",
+ path: parentPath,
+ });
+ if (!isPathWithinRoot(realParentPath, realRootPath)) {
+ throw new CommandDispatchError(
+ "invalid_path",
+ `Path "${relativePath.resultPath}" escapes delete root`,
+ );
+ }
+ const confinedPath = path.join(realParentPath, path.basename(targetPath));
+
+ let stat;
+ try {
+ stat = await fs.lstat(confinedPath);
+ } catch (error) {
+ if (isFsErrorWithCode(error, "ENOENT")) {
+ return {
+ path: relativePath.resultPath,
+ deleted: false,
+ };
+ }
+ throw error;
+ }
+ if (stat.isSymbolicLink()) {
+ throw new CommandDispatchError(
+ "invalid_path",
+ "Path is a symlink, not a directory or file",
+ );
+ }
+
+ await fs.rm(confinedPath, { recursive: stat.isDirectory(), force: true });
+ return {
+ path: relativePath.resultPath,
+ deleted: true,
+ };
+}
diff --git a/apps/host-daemon/src/command-handlers/host-files.test.ts b/apps/host-daemon/src/command-handlers/host-files.test.ts
index 7d3704eed..7e3d356b1 100644
--- a/apps/host-daemon/src/command-handlers/host-files.test.ts
+++ b/apps/host-daemon/src/command-handlers/host-files.test.ts
@@ -14,31 +14,13 @@ import {
readHostFile,
readHostFileMetadata,
readHostRelativeFile,
- readHostStatusVersion,
writeHostRelativeFile,
deleteHostRelativeFile,
+ deleteHostRelativePath,
} from "./host-files.js";
const execFileAsync = promisify(execFile);
const tempDirs: string[] = [];
-const EXPECTED_UNREADABLE_DIRECTORY_HASH_SENTINEL = "unreadable-directory";
-
-type ExpectedStatusVersionSource = "folder" | "html" | "md" | "empty";
-type ExpectedStatusVersionHashEntry =
- | ExpectedStatusVersionFileHashEntry
- | ExpectedStatusVersionUnreadableHashEntry;
-
-interface ExpectedStatusVersionFileHashEntry {
- kind: "file";
- modifiedAtNs: bigint;
- path: string;
- sizeBytes: bigint;
-}
-
-interface ExpectedStatusVersionUnreadableHashEntry {
- kind: "unreadable";
- path: string;
-}
async function makeTempDir(prefix: string): Promise {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
@@ -86,56 +68,6 @@ async function captureReadHostRelativeFileError(
throw new Error("Expected readHostRelativeFile to fail");
}
-function makeStatusVersionCommand(
- storageRootPath: string,
-): CommandOf<"host.status_version"> {
- return {
- type: "host.status_version",
- sources: [
- {
- source: "folder",
- rootPath: path.join(storageRootPath, "STATUS"),
- indexPath: "index.html",
- dotfiles: "deny",
- },
- {
- source: "html",
- rootPath: storageRootPath,
- path: "STATUS.html",
- dotfiles: "allow",
- },
- {
- source: "md",
- rootPath: storageRootPath,
- path: "STATUS.md",
- dotfiles: "allow",
- },
- ],
- };
-}
-
-function hashExpectedStatusVersion(
- source: ExpectedStatusVersionSource,
- entries: readonly ExpectedStatusVersionHashEntry[],
-): string {
- const hash = createHash("sha256");
- hash.update(`source:${source}\n`);
- for (const entry of [...entries].sort((left, right) =>
- left.path.localeCompare(right.path),
- )) {
- if (entry.kind === "unreadable") {
- hash.update(
- `${entry.path}\0${EXPECTED_UNREADABLE_DIRECTORY_HASH_SENTINEL}\n`,
- );
- continue;
- }
- hash.update(
- `${entry.path}\0${entry.sizeBytes.toString()}\0${entry.modifiedAtNs.toString()}\n`,
- );
- }
- return hash.digest("hex");
-}
-
afterEach(async () => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
@@ -190,7 +122,7 @@ describe("readHostFile (no ref — disk read)", () => {
it("marks missing targets under an existing root as expected", async () => {
const repoPath = await initRepo();
- const missingPath = path.join(repoPath, "STATUS.md");
+ const missingPath = path.join(repoPath, "notes.md");
const thrown = await captureReadHostFileError({
type: "host.read_file",
path: missingPath,
@@ -225,7 +157,7 @@ describe("readHostFile (no ref — disk read)", () => {
it("marks missing roots as expected", async () => {
const parentPath = await makeTempDir("bb-host-files-missing-root-");
const rootPath = path.join(parentPath, "missing-root");
- const missingPath = path.join(rootPath, "STATUS.md");
+ const missingPath = path.join(rootPath, "notes.md");
const thrown = await captureReadHostFileError({
type: "host.read_file",
path: missingPath,
@@ -313,23 +245,23 @@ describe("readHostFileMetadata", () => {
describe("writeHostRelativeFile and deleteHostRelativeFile", () => {
it("writes JSON bytes beneath the root and returns a content hash", async () => {
const rootPath = await makeTempDir("bb-host-relative-write-");
- const content = "{\"ok\":true}\n";
+ const content = '{"ok":true}\n';
const result = await writeHostRelativeFile({
type: "host.write_file_relative",
rootPath,
- path: "STATUS-data/tasks.json",
+ path: "apps/status/data/state.json",
dotfiles: "deny",
content,
contentEncoding: "utf8",
});
expect(result).toMatchObject({
- path: "STATUS-data/tasks.json",
+ path: "apps/status/data/state.json",
hash: createHash("sha256").update(content).digest("hex"),
sizeBytes: Buffer.byteLength(content),
});
await expect(
- fs.readFile(path.join(rootPath, "STATUS-data/tasks.json"), "utf8"),
+ fs.readFile(path.join(rootPath, "apps/status/data/state.json"), "utf8"),
).resolves.toBe(content);
});
@@ -409,9 +341,9 @@ describe("writeHostRelativeFile and deleteHostRelativeFile", () => {
deleted: true,
previousHash: written.hash,
});
- await expect(fs.stat(path.join(rootPath, "tasks.json"))).rejects.toMatchObject(
- { code: "ENOENT" },
- );
+ await expect(
+ fs.stat(path.join(rootPath, "tasks.json")),
+ ).rejects.toMatchObject({ code: "ENOENT" });
await expect(
deleteHostRelativeFile({
@@ -426,173 +358,49 @@ describe("writeHostRelativeFile and deleteHostRelativeFile", () => {
previousHash: null,
});
});
-});
-
-describe("readHostStatusVersion", () => {
- it("hashes folder mode from stat metadata and changes on file mutations", async () => {
- const storageRootPath = await makeTempDir("bb-status-version-test-");
- const statusRootPath = path.join(storageRootPath, "STATUS");
- await fs.mkdir(statusRootPath);
- const indexPath = path.join(statusRootPath, "index.html");
- await fs.writeFile(indexPath, "Ready
", "utf8");
-
- const command = makeStatusVersionCommand(storageRootPath);
- const initial = await readHostStatusVersion(command);
- const unchanged = await readHostStatusVersion(command);
-
- expect(initial.source).toBe("folder");
- expect(unchanged.hash).toBe(initial.hash);
-
- const assetPath = path.join(statusRootPath, "logo.png");
- await fs.writeFile(assetPath, "asset", "utf8");
- const afterAdd = await readHostStatusVersion(command);
- expect(afterAdd.hash).not.toBe(initial.hash);
-
- await fs.rm(assetPath);
- const afterRemove = await readHostStatusVersion(command);
- expect(afterRemove.hash).not.toBe(afterAdd.hash);
-
- const bumpedTime = new Date(Date.now() + 10_000);
- await fs.utimes(indexPath, bumpedTime, bumpedTime);
- const afterMtime = await readHostStatusVersion(command);
- expect(afterMtime.hash).not.toBe(afterRemove.hash);
-
- await fs.writeFile(indexPath, "Ready now
", "utf8");
- const afterSize = await readHostStatusVersion(command);
- expect(afterSize.hash).not.toBe(afterMtime.hash);
- });
-
- it("tracks source precedence across folder, html, md, and empty modes", async () => {
- const storageRootPath = await makeTempDir("bb-status-version-precedence-");
- const command = makeStatusVersionCommand(storageRootPath);
-
- const empty = await readHostStatusVersion(command);
- expect(empty.source).toBe("empty");
-
- await fs.writeFile(
- path.join(storageRootPath, "STATUS.md"),
- "# Status",
- "utf8",
- );
- const markdown = await readHostStatusVersion(command);
- expect(markdown.source).toBe("md");
- expect(markdown.hash).not.toBe(empty.hash);
-
- await fs.writeFile(
- path.join(storageRootPath, "STATUS.html"),
- "Status
",
- "utf8",
- );
- const html = await readHostStatusVersion(command);
- expect(html.source).toBe("html");
- expect(html.hash).not.toBe(markdown.hash);
-
- const statusRootPath = path.join(storageRootPath, "STATUS");
- await fs.mkdir(statusRootPath);
- await fs.writeFile(
- path.join(statusRootPath, "index.html"),
- "Folder
",
- "utf8",
- );
- const folder = await readHostStatusVersion(command);
- expect(folder.source).toBe("folder");
- expect(folder.hash).not.toBe(html.hash);
- });
-
- it("hashes single-file modes from stat metadata", async () => {
- const storageRootPath = await makeTempDir("bb-status-version-single-file-");
- const command = makeStatusVersionCommand(storageRootPath);
- const statusHtmlPath = path.join(storageRootPath, "STATUS.html");
-
- await fs.writeFile(statusHtmlPath, "Status
", "utf8");
- const initial = await readHostStatusVersion(command);
- const unchanged = await readHostStatusVersion(command);
-
- expect(initial.source).toBe("html");
- expect(unchanged.hash).toBe(initial.hash);
-
- const bumpedTime = new Date(Date.now() + 10_000);
- await fs.utimes(statusHtmlPath, bumpedTime, bumpedTime);
- const afterMtime = await readHostStatusVersion(command);
- expect(afterMtime.hash).not.toBe(initial.hash);
-
- await fs.writeFile(statusHtmlPath, "Status changed
", "utf8");
- const afterSize = await readHostStatusVersion(command);
- expect(afterSize.hash).not.toBe(afterMtime.hash);
- });
- it("does not hash dotfiles or symlink escapes under STATUS", async () => {
- const storageRootPath = await makeTempDir("bb-status-version-hidden-");
- const statusRootPath = path.join(storageRootPath, "STATUS");
- await fs.mkdir(statusRootPath);
+ it("deletes directories recursively beneath the root", async () => {
+ const rootPath = await makeTempDir("bb-host-relative-delete-dir-");
+ await fs.mkdir(path.join(rootPath, "apps", "demo", "assets"), {
+ recursive: true,
+ });
await fs.writeFile(
- path.join(statusRootPath, "index.html"),
- "ready",
+ path.join(rootPath, "apps", "demo", "manifest.json"),
+ "{}\n",
"utf8",
);
- const command = makeStatusVersionCommand(storageRootPath);
- const initial = await readHostStatusVersion(command);
-
- await fs.writeFile(path.join(statusRootPath, ".env"), "secret", "utf8");
- const afterDotfile = await readHostStatusVersion(command);
- expect(afterDotfile.hash).toBe(initial.hash);
-
- const outsidePath = path.join(storageRootPath, "outside.txt");
- const symlinkPath = path.join(statusRootPath, "outside-link.txt");
- await fs.writeFile(outsidePath, "outside", "utf8");
- await fs.symlink(outsidePath, symlinkPath);
- const afterSymlinkEscape = await readHostStatusVersion(command);
- expect(afterSymlinkEscape.hash).toBe(initial.hash);
- });
-
- it("keeps folder mode and marks unreadable child directories in the hash", async () => {
- const storageRootPath = await makeTempDir(
- "bb-status-version-unreadable-child-",
- );
- const statusRootPath = path.join(storageRootPath, "STATUS");
- const unreadableDirPath = path.join(statusRootPath, "private");
- await fs.mkdir(unreadableDirPath, { recursive: true });
- const indexPath = path.join(statusRootPath, "index.html");
- await fs.writeFile(indexPath, "Ready
", "utf8");
await fs.writeFile(
- path.join(unreadableDirPath, "nested.txt"),
- "secret",
+ path.join(rootPath, "apps", "demo", "assets", "index.html"),
+ "Demo
",
"utf8",
);
- const command = makeStatusVersionCommand(storageRootPath);
- const indexStat = await fs.stat(indexPath, { bigint: true });
- let unreadableHash = "";
-
- await fs.chmod(unreadableDirPath, 0);
- try {
- const unreadable = await readHostStatusVersion(command);
- const unreadableAgain = await readHostStatusVersion(command);
-
- expect(unreadable.source).toBe("folder");
- expect(unreadableAgain.hash).toBe(unreadable.hash);
- expect(unreadable.hash).toBe(
- hashExpectedStatusVersion("folder", [
- {
- kind: "file",
- modifiedAtNs: indexStat.mtimeNs,
- path: "index.html",
- sizeBytes: indexStat.size,
- },
- {
- kind: "unreadable",
- path: "private",
- },
- ]),
- );
- unreadableHash = unreadable.hash;
- } finally {
- await fs.chmod(unreadableDirPath, 0o700);
- }
+ await expect(
+ deleteHostRelativePath({
+ type: "host.delete_path_relative",
+ rootPath: path.join(rootPath, "apps"),
+ path: "demo",
+ dotfiles: "deny",
+ }),
+ ).resolves.toEqual({
+ path: "demo",
+ deleted: true,
+ });
+ await expect(
+ fs.stat(path.join(rootPath, "apps", "demo")),
+ ).rejects.toMatchObject({ code: "ENOENT" });
- const restored = await readHostStatusVersion(command);
- expect(restored.source).toBe("folder");
- expect(restored.hash).not.toBe(unreadableHash);
+ await expect(
+ deleteHostRelativePath({
+ type: "host.delete_path_relative",
+ rootPath: path.join(rootPath, "apps"),
+ path: "demo",
+ dotfiles: "deny",
+ }),
+ ).resolves.toEqual({
+ path: "demo",
+ deleted: false,
+ });
});
});
diff --git a/apps/host-daemon/src/command-handlers/host-files.ts b/apps/host-daemon/src/command-handlers/host-files.ts
index 82e3492b7..98c9901cf 100644
--- a/apps/host-daemon/src/command-handlers/host-files.ts
+++ b/apps/host-daemon/src/command-handlers/host-files.ts
@@ -17,10 +17,10 @@ import {
} from "./file-read.js";
import {
deleteRootRelativeFile,
+ deleteRootRelativePath,
writeRootRelativeFile,
} from "./file-write.js";
import { resolveNonSymlinkDirectoryPath } from "./root-path.js";
-import { readHostStatusVersion } from "./status-version.js";
/**
* Conservative subset of git's ref name grammar. We only need to refuse
@@ -156,8 +156,6 @@ export async function readHostFileMetadata(
});
}
-export { readHostStatusVersion };
-
export async function readHostRelativeFile(
command: CommandOf<"host.read_file_relative">,
): Promise> {
@@ -189,3 +187,13 @@ export async function deleteHostRelativeFile(
dotfiles: command.dotfiles,
});
}
+
+export async function deleteHostRelativePath(
+ command: CommandOf<"host.delete_path_relative">,
+): Promise> {
+ return deleteRootRelativePath({
+ rootPath: command.rootPath,
+ relativePath: command.path,
+ dotfiles: command.dotfiles,
+ });
+}
diff --git a/apps/host-daemon/src/command-handlers/manager-templates.test.ts b/apps/host-daemon/src/command-handlers/manager-templates.test.ts
index 6231f314f..150babca1 100644
--- a/apps/host-daemon/src/command-handlers/manager-templates.test.ts
+++ b/apps/host-daemon/src/command-handlers/manager-templates.test.ts
@@ -32,9 +32,9 @@ async function writeActiveFile(dataDir: string, name: string): Promise {
afterEach(async () => {
await Promise.all(
- tempDirs.splice(0).map((dir) =>
- fs.rm(dir, { recursive: true, force: true }),
- ),
+ tempDirs
+ .splice(0)
+ .map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
@@ -52,7 +52,7 @@ describe("listManagerTemplates", () => {
await writeTemplate({
dataDir,
name: "default",
- files: { "STATUS.html": "ok" },
+ files: { "PREFERENCES.md": "ok" },
});
await fs.mkdir(path.join(dataDir, "manager-templates", "empty-set"), {
recursive: true,
@@ -68,7 +68,7 @@ describe("listManagerTemplates", () => {
await writeTemplate({
dataDir,
name: "default",
- files: { "STATUS.html": "ok" },
+ files: { "PREFERENCES.md": "ok" },
});
const root = path.join(dataDir, "manager-templates");
await fs.writeFile(path.join(root, "stray-file"), "ignored", "utf8");
@@ -85,12 +85,12 @@ describe("listManagerTemplates", () => {
await writeTemplate({
dataDir,
name: "default",
- files: { "STATUS.html": "ok" },
+ files: { "PREFERENCES.md": "ok" },
});
await writeTemplate({
dataDir,
name: "sawyer-next",
- files: { "STATUS.html": "ok" },
+ files: { "PREFERENCES.md": "ok" },
});
await writeActiveFile(dataDir, "sawyer-next");
expect(await listManagerTemplates({ dataDir })).toEqual({
@@ -104,7 +104,7 @@ describe("listManagerTemplates", () => {
await writeTemplate({
dataDir,
name: "default",
- files: { "STATUS.html": "ok" },
+ files: { "PREFERENCES.md": "ok" },
});
await fs.writeFile(
path.join(dataDir, "manager-templates", "active"),
@@ -122,7 +122,7 @@ describe("listManagerTemplates", () => {
await writeTemplate({
dataDir,
name: "default",
- files: { "STATUS.html": "ok" },
+ files: { "PREFERENCES.md": "ok" },
});
await writeActiveFile(dataDir, "ghost-template");
expect(await listManagerTemplates({ dataDir })).toEqual({
diff --git a/apps/host-daemon/src/command-handlers/status-data.test.ts b/apps/host-daemon/src/command-handlers/status-data.test.ts
deleted file mode 100644
index 3d446a024..000000000
--- a/apps/host-daemon/src/command-handlers/status-data.test.ts
+++ /dev/null
@@ -1,419 +0,0 @@
-import { createHash } from "node:crypto";
-import fs from "node:fs/promises";
-import os from "node:os";
-import path from "node:path";
-import { afterEach, describe, expect, it } from "vitest";
-import {
- CommandDispatchError,
- isExpectedCommandDispatchError,
- type CommandOf,
-} from "../command-dispatch-support.js";
-import {
- deleteHostStatusData,
- listHostStatusData,
- readHostStatusData,
- writeHostStatusData,
-} from "./status-data.js";
-import { NON_IMAGE_FILE_SIZE_LIMIT_BYTES } from "./file-read.js";
-
-const tempDirs: string[] = [];
-const THREAD_ID = "thr_status_data";
-
-interface TestThreadStorage {
- threadStoragePath: string;
- threadStorageRootPath: string;
-}
-
-async function makeTempDir(prefix: string): Promise {
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
- tempDirs.push(dir);
- return dir;
-}
-
-function sha256Text(content: string): string {
- return createHash("sha256").update(content).digest("hex");
-}
-
-function listCommand(threadId = THREAD_ID): CommandOf<"host.status_data.list"> {
- return {
- type: "host.status_data.list",
- threadId,
- };
-}
-
-function getCommand(
- threadId = THREAD_ID,
- key = "tasks",
-): CommandOf<"host.status_data.get"> {
- return {
- type: "host.status_data.get",
- threadId,
- key,
- };
-}
-
-function commandOptions(threadStorageRootPath: string) {
- return { threadStorageRootPath };
-}
-
-async function makeThreadStorage(prefix: string): Promise {
- const threadStorageRootPath = await makeTempDir(prefix);
- return {
- threadStoragePath: path.join(threadStorageRootPath, THREAD_ID),
- threadStorageRootPath,
- };
-}
-
-async function captureReadStatusDataError(
- command: CommandOf<"host.status_data.get">,
- threadStorageRootPath: string,
-): Promise {
- try {
- await readHostStatusData(command, commandOptions(threadStorageRootPath));
- } catch (error) {
- return error;
- }
-
- throw new Error("Expected readHostStatusData to fail");
-}
-
-afterEach(async () => {
- while (tempDirs.length > 0) {
- const dir = tempDirs.pop();
- if (dir) {
- await fs.rm(dir, { recursive: true, force: true });
- }
- }
-});
-
-describe("host STATUS-data commands", () => {
- it("lists and reads valid top-level STATUS-data JSON files", async () => {
- const { threadStoragePath, threadStorageRootPath } =
- await makeThreadStorage("bb-host-status-data-list-");
- const statusDataRootPath = path.join(threadStoragePath, "STATUS-data");
- const tasksJson = '["one"]\n';
- const prefsJson = '{"compact":true}\n';
- await fs.mkdir(path.join(statusDataRootPath, "nested"), {
- recursive: true,
- });
- await fs.writeFile(path.join(statusDataRootPath, "tasks.json"), tasksJson);
- await fs.writeFile(path.join(statusDataRootPath, "prefs.json"), prefsJson);
- await fs.writeFile(path.join(statusDataRootPath, "bad.name.json"), "1\n");
- await fs.writeFile(
- path.join(statusDataRootPath, "nested", "ignored.json"),
- "true\n",
- );
-
- await expect(
- listHostStatusData(listCommand(), commandOptions(threadStorageRootPath)),
- ).resolves.toEqual({
- values: {
- tasks: ["one"],
- prefs: { compact: true },
- },
- versions: {
- tasks: sha256Text(tasksJson),
- prefs: sha256Text(prefsJson),
- },
- hash: sha256Text(
- `prefs\0${sha256Text(prefsJson)}\n` +
- `tasks\0${sha256Text(tasksJson)}\n`,
- ),
- });
-
- await expect(
- readHostStatusData(getCommand(), commandOptions(threadStorageRootPath)),
- ).resolves.toEqual({
- key: "tasks",
- value: ["one"],
- version: sha256Text(tasksJson),
- sizeBytes: Buffer.byteLength(tasksJson),
- modifiedAtMs: expect.any(Number),
- });
- });
-
- it("writes canonical JSON and returns previous value metadata", async () => {
- const { threadStoragePath, threadStorageRootPath } =
- await makeThreadStorage("bb-host-status-data-write-");
- const previousValue = [{ id: "task-1", title: "Review" }];
- const nextValue = { compact: true, selected: null };
- const previousJson = `${JSON.stringify(previousValue, null, 2)}\n`;
- const nextJson = `${JSON.stringify(nextValue, null, 2)}\n`;
-
- await expect(
- writeHostStatusData(
- {
- type: "host.status_data.set",
- threadId: THREAD_ID,
- key: "tasks",
- value: previousValue,
- },
- commandOptions(threadStorageRootPath),
- ),
- ).resolves.toMatchObject({
- key: "tasks",
- value: previousValue,
- version: sha256Text(previousJson),
- previousValue: null,
- previousValuePresent: false,
- previousVersion: null,
- });
-
- await expect(
- writeHostStatusData(
- {
- type: "host.status_data.set",
- threadId: THREAD_ID,
- key: "tasks",
- value: nextValue,
- },
- commandOptions(threadStorageRootPath),
- ),
- ).resolves.toMatchObject({
- key: "tasks",
- value: nextValue,
- version: sha256Text(nextJson),
- previousValue,
- previousValuePresent: true,
- previousVersion: sha256Text(previousJson),
- });
-
- await expect(
- fs.readFile(
- path.join(threadStoragePath, "STATUS-data", "tasks.json"),
- "utf8",
- ),
- ).resolves.toBe(nextJson);
- });
-
- it("deletes values idempotently and preserves JSON null as a previous value", async () => {
- const { threadStoragePath, threadStorageRootPath } =
- await makeThreadStorage("bb-host-status-data-delete-");
- const nullJson = "null\n";
- await writeHostStatusData(
- {
- type: "host.status_data.set",
- threadId: THREAD_ID,
- key: "tasks",
- value: null,
- },
- commandOptions(threadStorageRootPath),
- );
-
- await expect(
- deleteHostStatusData(
- {
- type: "host.status_data.delete",
- threadId: THREAD_ID,
- key: "tasks",
- },
- commandOptions(threadStorageRootPath),
- ),
- ).resolves.toEqual({
- key: "tasks",
- deleted: true,
- previousValue: null,
- previousValuePresent: true,
- previousVersion: sha256Text(nullJson),
- });
- await expect(
- fs.stat(path.join(threadStoragePath, "STATUS-data", "tasks.json")),
- ).rejects.toMatchObject({ code: "ENOENT" });
-
- await expect(
- deleteHostStatusData(
- {
- type: "host.status_data.delete",
- threadId: THREAD_ID,
- key: "tasks",
- },
- commandOptions(threadStorageRootPath),
- ),
- ).resolves.toEqual({
- key: "tasks",
- deleted: false,
- previousValue: null,
- previousValuePresent: false,
- previousVersion: null,
- });
- });
-
- it("handles missing roots without warning-class errors for expected misses", async () => {
- const threadStorageRootPath = await makeTempDir(
- "bb-host-status-data-missing-",
- );
- const threadId = "missing-thread-storage";
-
- await expect(
- listHostStatusData(
- listCommand(threadId),
- commandOptions(threadStorageRootPath),
- ),
- ).resolves.toEqual({
- values: {},
- versions: {},
- hash: sha256Text(""),
- });
- const thrown = await captureReadStatusDataError(
- getCommand(threadId),
- threadStorageRootPath,
- );
- expect(thrown).toMatchObject({
- code: "ENOENT",
- message: "Path does not exist: tasks.json",
- name: "ExpectedCommandDispatchError",
- });
- expect(isExpectedCommandDispatchError(thrown)).toBe(true);
- await expect(
- deleteHostStatusData(
- {
- type: "host.status_data.delete",
- threadId,
- key: "tasks",
- },
- commandOptions(threadStorageRootPath),
- ),
- ).resolves.toEqual({
- key: "tasks",
- deleted: false,
- previousValue: null,
- previousValuePresent: false,
- previousVersion: null,
- });
- });
-
- it("rejects malformed JSON and symlink STATUS-data files", async () => {
- const { threadStoragePath, threadStorageRootPath } =
- await makeThreadStorage("bb-host-status-data-invalid-");
- const statusDataRootPath = path.join(threadStoragePath, "STATUS-data");
- await fs.mkdir(statusDataRootPath, { recursive: true });
- await fs.writeFile(path.join(statusDataRootPath, "tasks.json"), "{nope");
-
- await expect(
- readHostStatusData(getCommand(), commandOptions(threadStorageRootPath)),
- ).rejects.toMatchObject({
- code: "invalid_json",
- message: "STATUS-data/tasks.json does not contain valid JSON",
- });
- await expect(
- listHostStatusData(listCommand(), commandOptions(threadStorageRootPath)),
- ).rejects.toMatchObject({
- code: "invalid_json",
- });
-
- await fs.rm(path.join(statusDataRootPath, "tasks.json"));
- await fs.writeFile(path.join(threadStoragePath, "outside.json"), "[]\n");
- await fs.symlink(
- path.join(threadStoragePath, "outside.json"),
- path.join(statusDataRootPath, "tasks.json"),
- );
- await expect(
- readHostStatusData(getCommand(), commandOptions(threadStorageRootPath)),
- ).rejects.toBeInstanceOf(CommandDispatchError);
- await expect(
- readHostStatusData(getCommand(), commandOptions(threadStorageRootPath)),
- ).rejects.toMatchObject({
- code: "invalid_path",
- });
- });
-
- it("rejects oversized STATUS-data files before reading contents", async () => {
- const { threadStoragePath, threadStorageRootPath } =
- await makeThreadStorage("bb-host-status-data-large-");
- const statusDataRootPath = path.join(threadStoragePath, "STATUS-data");
- const statusDataPath = path.join(statusDataRootPath, "tasks.json");
- await fs.mkdir(statusDataRootPath, { recursive: true });
- await fs.writeFile(statusDataPath, "[]\n");
- await fs.truncate(statusDataPath, NON_IMAGE_FILE_SIZE_LIMIT_BYTES + 1);
-
- await expect(
- readHostStatusData(getCommand(), commandOptions(threadStorageRootPath)),
- ).rejects.toMatchObject({
- code: "file_too_large",
- message: expect.stringContaining("25 MB limit"),
- });
- await expect(
- listHostStatusData(listCommand(), commandOptions(threadStorageRootPath)),
- ).rejects.toMatchObject({
- code: "file_too_large",
- message: expect.stringContaining("25 MB limit"),
- });
- });
-
- it("rejects symlink STATUS-data directories without touching their targets", async () => {
- const { threadStoragePath, threadStorageRootPath } =
- await makeThreadStorage("bb-host-status-data-symlink-dir-");
- const targetRootPath = await makeTempDir(
- "bb-host-status-data-symlink-dir-target-",
- );
- const targetContent = '["target"]\n';
- await fs.mkdir(threadStoragePath, { recursive: true });
- await fs.writeFile(path.join(targetRootPath, "tasks.json"), targetContent);
- await fs.symlink(
- targetRootPath,
- path.join(threadStoragePath, "STATUS-data"),
- );
-
- await expect(
- listHostStatusData(listCommand(), commandOptions(threadStorageRootPath)),
- ).rejects.toMatchObject({
- code: "invalid_path",
- });
- await expect(
- readHostStatusData(getCommand(), commandOptions(threadStorageRootPath)),
- ).rejects.toMatchObject({
- code: "invalid_path",
- });
- await expect(
- writeHostStatusData(
- {
- type: "host.status_data.set",
- threadId: THREAD_ID,
- key: "tasks",
- value: ["changed"],
- },
- commandOptions(threadStorageRootPath),
- ),
- ).rejects.toMatchObject({
- code: "invalid_path",
- });
- await expect(
- deleteHostStatusData(
- {
- type: "host.status_data.delete",
- threadId: THREAD_ID,
- key: "tasks",
- },
- commandOptions(threadStorageRootPath),
- ),
- ).rejects.toMatchObject({
- code: "invalid_path",
- });
- await expect(
- fs.readFile(path.join(targetRootPath, "tasks.json"), "utf8"),
- ).resolves.toBe(targetContent);
- });
-
- it("rejects non-absolute roots", async () => {
- await expect(
- listHostStatusData(listCommand(), commandOptions("relative")),
- ).rejects.toMatchObject({
- code: "invalid_path",
- });
- });
-
- it("rejects thread IDs that escape the thread storage root", async () => {
- const threadStorageRootPath = await makeTempDir(
- "bb-host-status-data-escape-",
- );
-
- await expect(
- listHostStatusData(
- listCommand("../outside"),
- commandOptions(threadStorageRootPath),
- ),
- ).rejects.toMatchObject({
- code: "invalid_path",
- });
- });
-});
diff --git a/apps/host-daemon/src/command-handlers/status-data.ts b/apps/host-daemon/src/command-handlers/status-data.ts
deleted file mode 100644
index 2683fecf3..000000000
--- a/apps/host-daemon/src/command-handlers/status-data.ts
+++ /dev/null
@@ -1,555 +0,0 @@
-import { createHash, randomUUID } from "node:crypto";
-import type { Stats } from "node:fs";
-import fs from "node:fs/promises";
-import path from "node:path";
-import type { HostDaemonCommandResult } from "@bb/host-daemon-contract";
-import {
- jsonValueSchema,
- type JsonValue,
- type StatusDataKey,
-} from "@bb/domain";
-import {
- parseStatusDataFileName,
- statusDataFileName,
- STATUS_DATA_DIRECTORY_NAME,
-} from "@bb/host-watcher";
-import {
- CommandDispatchError,
- ExpectedCommandDispatchError,
- type CommandOf,
-} from "../command-dispatch-support.js";
-import { isFsErrorWithCode } from "../fs-errors.js";
-import { NON_IMAGE_FILE_SIZE_LIMIT_BYTES } from "./file-read.js";
-import { resolveNonSymlinkDirectoryPath } from "./root-path.js";
-
-interface StatusDataEntry {
- key: StatusDataKey;
- modifiedAtMs: number;
- sizeBytes: number;
- value: JsonValue;
- version: string;
-}
-
-interface StatusDataPreviousValue {
- previousValue: JsonValue | null;
- previousValuePresent: boolean;
- previousVersion: string | null;
-}
-
-interface ResolveThreadStorageRootArgs {
- rootPath: string;
-}
-
-interface ResolveStatusDataRootArgs {
- threadStorageRootPath: string;
-}
-
-interface ResolvedStatusDataRoot {
- fingerprint: StatusDataDirectoryFingerprint;
- path: string;
-}
-
-export type StatusDataDirectoryFingerprint = string;
-
-export interface ReadStatusDataDirectoryFingerprintFromRootArgs {
- rootPath: string;
-}
-
-interface HostStatusDataCommandOptions {
- threadStorageRootPath: string;
-}
-
-type HostStatusDataCommand = CommandOf<
- | "host.status_data.list"
- | "host.status_data.get"
- | "host.status_data.set"
- | "host.status_data.delete"
->;
-
-interface ReadStatusDataEntryArgs {
- key: StatusDataKey;
- statusDataRootPath: string;
-}
-
-interface WriteStatusDataEntryArgs extends ReadStatusDataEntryArgs {
- value: JsonValue;
-}
-
-interface ParseStatusDataJsonArgs {
- bytes: Buffer;
- key: StatusDataKey;
-}
-
-function sha256(bytes: Buffer): string {
- return createHash("sha256").update(bytes).digest("hex");
-}
-
-function statusDataDirectoryFingerprint(stat: Stats): string {
- return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`;
-}
-
-function canonicalizeStatusJson(value: JsonValue): string {
- return `${JSON.stringify(value, null, 2)}\n`;
-}
-
-function statusDataRelativePath(key: StatusDataKey): string {
- return statusDataFileName(key);
-}
-
-function statusDataFilePath(
- statusDataRootPath: string,
- key: StatusDataKey,
-): string {
- return path.join(statusDataRootPath, statusDataRelativePath(key));
-}
-
-function createMissingStatusDataKeyError(key: StatusDataKey): Error {
- return new ExpectedCommandDispatchError(
- "ENOENT",
- `Path does not exist: ${statusDataRelativePath(key)}`,
- );
-}
-
-function parseStatusDataJson(args: ParseStatusDataJsonArgs): JsonValue {
- try {
- return jsonValueSchema.parse(JSON.parse(args.bytes.toString("utf8")));
- } catch {
- throw new CommandDispatchError(
- "invalid_json",
- `STATUS-data/${args.key}.json does not contain valid JSON`,
- );
- }
-}
-
-function createStatusDataFileTooLargeError(stat: Stats): CommandDispatchError {
- return new CommandDispatchError(
- "file_too_large",
- `File size ${stat.size} bytes exceeds the ${Math.floor(NON_IMAGE_FILE_SIZE_LIMIT_BYTES / (1024 * 1024))} MB limit`,
- );
-}
-
-function createPreviousValue(
- entry: StatusDataEntry | null,
-): StatusDataPreviousValue {
- return {
- previousValue: entry?.value ?? null,
- previousValuePresent: entry !== null,
- previousVersion: entry?.version ?? null,
- };
-}
-
-function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
- const relativePath = path.relative(rootPath, candidatePath);
- return (
- relativePath === "" ||
- (!relativePath.startsWith("..") && !path.isAbsolute(relativePath))
- );
-}
-
-function resolveCommandThreadStoragePath(
- command: HostStatusDataCommand,
- options: HostStatusDataCommandOptions,
-): string {
- if (!path.isAbsolute(options.threadStorageRootPath)) {
- throw new CommandDispatchError(
- "invalid_path",
- "threadStorageRootPath must be absolute",
- );
- }
- const rootPath = path.resolve(
- options.threadStorageRootPath,
- command.threadId,
- );
- if (!isPathWithinRoot(rootPath, options.threadStorageRootPath)) {
- throw new CommandDispatchError(
- "invalid_path",
- "threadId resolves outside thread storage root",
- );
- }
- return rootPath;
-}
-
-function isStatusDataKey(key: StatusDataKey | null): key is StatusDataKey {
- return key !== null;
-}
-
-async function resolveExistingThreadStorageRoot(
- args: ResolveThreadStorageRootArgs,
-): Promise {
- if (!path.isAbsolute(args.rootPath)) {
- throw new CommandDispatchError("invalid_path", "rootPath must be absolute");
- }
- try {
- return await resolveNonSymlinkDirectoryPath({
- description: "Thread storage root",
- path: args.rootPath,
- });
- } catch (error) {
- if (isFsErrorWithCode(error, "ENOENT")) {
- return null;
- }
- throw error;
- }
-}
-
-async function ensureThreadStorageRoot(
- args: ResolveThreadStorageRootArgs,
-): Promise {
- if (!path.isAbsolute(args.rootPath)) {
- throw new CommandDispatchError("invalid_path", "rootPath must be absolute");
- }
- await fs.mkdir(args.rootPath, { recursive: true });
- return resolveNonSymlinkDirectoryPath({
- description: "Thread storage root",
- path: args.rootPath,
- });
-}
-
-async function resolveExistingStatusDataRoot(
- args: ResolveStatusDataRootArgs,
-): Promise {
- const statusDataRootPath = path.join(
- args.threadStorageRootPath,
- STATUS_DATA_DIRECTORY_NAME,
- );
- try {
- const resolvedPath = await resolveNonSymlinkDirectoryPath({
- description: "STATUS-data directory",
- path: statusDataRootPath,
- });
- const stat = await fs.stat(resolvedPath);
- return {
- fingerprint: statusDataDirectoryFingerprint(stat),
- path: resolvedPath,
- };
- } catch (error) {
- if (isFsErrorWithCode(error, "ENOENT")) {
- return null;
- }
- throw error;
- }
-}
-
-async function ensureStatusDataRoot(
- args: ResolveStatusDataRootArgs,
-): Promise {
- const statusDataRootPath = path.join(
- args.threadStorageRootPath,
- STATUS_DATA_DIRECTORY_NAME,
- );
- try {
- await fs.mkdir(statusDataRootPath, { recursive: true });
- } catch (error) {
- if (!isFsErrorWithCode(error, "EEXIST")) {
- throw error;
- }
- }
- const resolvedPath = await resolveNonSymlinkDirectoryPath({
- description: "STATUS-data directory",
- path: statusDataRootPath,
- });
- const stat = await fs.stat(resolvedPath);
- return {
- fingerprint: statusDataDirectoryFingerprint(stat),
- path: resolvedPath,
- };
-}
-
-async function readStatusDataEntryOrNull(
- args: ReadStatusDataEntryArgs,
-): Promise {
- const filePath = statusDataFilePath(args.statusDataRootPath, args.key);
- let stat;
- try {
- stat = await fs.lstat(filePath);
- } catch (error) {
- if (isFsErrorWithCode(error, "ENOENT")) {
- return null;
- }
- throw error;
- }
-
- if (stat.isSymbolicLink()) {
- throw new CommandDispatchError(
- "invalid_path",
- `STATUS-data/${args.key}.json is a symlink, not a file`,
- );
- }
- if (!stat.isFile()) {
- throw new CommandDispatchError(
- "invalid_path",
- `STATUS-data/${args.key}.json is not a file`,
- );
- }
- if (stat.size > NON_IMAGE_FILE_SIZE_LIMIT_BYTES) {
- throw createStatusDataFileTooLargeError(stat);
- }
-
- const bytes = await fs.readFile(filePath);
- return {
- key: args.key,
- value: parseStatusDataJson({ key: args.key, bytes }),
- version: sha256(bytes),
- sizeBytes: stat.size,
- modifiedAtMs: stat.mtimeMs,
- };
-}
-
-async function requireStatusDataEntry(
- args: ReadStatusDataEntryArgs,
-): Promise {
- const entry = await readStatusDataEntryOrNull(args);
- if (!entry) {
- throw createMissingStatusDataKeyError(args.key);
- }
- return entry;
-}
-
-async function writeStatusDataEntry(
- args: WriteStatusDataEntryArgs,
-): Promise {
- await fs.mkdir(args.statusDataRootPath, { recursive: true });
- const filePath = statusDataFilePath(args.statusDataRootPath, args.key);
- const content = canonicalizeStatusJson(args.value);
- const bytes = Buffer.from(content, "utf8");
- const tempPath = path.join(
- args.statusDataRootPath,
- `.${args.key}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`,
- );
-
- await fs.writeFile(tempPath, bytes, { flag: "wx" });
- try {
- await fs.rename(tempPath, filePath);
- } catch (error) {
- await fs.rm(tempPath, { force: true });
- throw error;
- }
-
- const stat = await fs.stat(filePath);
- return {
- key: args.key,
- value: args.value,
- version: sha256(bytes),
- sizeBytes: stat.size,
- modifiedAtMs: stat.mtimeMs,
- };
-}
-
-async function deleteStatusDataEntry(
- args: ReadStatusDataEntryArgs,
-): Promise {
- const filePath = statusDataFilePath(args.statusDataRootPath, args.key);
- try {
- await fs.rm(filePath);
- } catch (error) {
- if (isFsErrorWithCode(error, "ENOENT")) {
- return;
- }
- throw error;
- }
-}
-
-export async function listHostStatusData(
- command: CommandOf<"host.status_data.list">,
- options: HostStatusDataCommandOptions,
-): Promise> {
- return listHostStatusDataFromRoot({
- rootPath: resolveCommandThreadStoragePath(command, options),
- });
-}
-
-export async function listHostStatusDataFromRoot(
- args: ResolveThreadStorageRootArgs,
-): Promise> {
- const threadStorageRootPath = await resolveExistingThreadStorageRoot({
- rootPath: args.rootPath,
- });
- if (threadStorageRootPath === null) {
- return {
- values: {},
- versions: {},
- hash: sha256(Buffer.from("")),
- };
- }
-
- const statusDataRoot = await resolveExistingStatusDataRoot({
- threadStorageRootPath,
- });
- if (statusDataRoot === null) {
- return {
- values: {},
- versions: {},
- hash: sha256(Buffer.from("")),
- };
- }
- let entries;
- try {
- entries = await fs.readdir(statusDataRoot.path, { withFileTypes: true });
- } catch (error) {
- if (isFsErrorWithCode(error, "ENOENT")) {
- return {
- values: {},
- versions: {},
- hash: sha256(Buffer.from("")),
- };
- }
- throw error;
- }
-
- const keys = entries
- .filter((entry) => entry.isFile())
- .map((entry) => parseStatusDataFileName(entry.name))
- .filter(isStatusDataKey);
- const statusEntries = await Promise.all(
- keys.map((key) =>
- requireStatusDataEntry({
- statusDataRootPath: statusDataRoot.path,
- key,
- }),
- ),
- );
-
- const values: Record = {};
- const versions: Record = {};
- for (const statusEntry of statusEntries) {
- values[statusEntry.key] = statusEntry.value;
- versions[statusEntry.key] = statusEntry.version;
- }
-
- const aggregateHash = createHash("sha256");
- for (const key of Object.keys(versions).sort()) {
- aggregateHash.update(`${key}\0${versions[key]}\n`);
- }
-
- return {
- values,
- versions,
- hash: aggregateHash.digest("hex"),
- };
-}
-
-export async function readStatusDataDirectoryFingerprintFromRoot(
- args: ReadStatusDataDirectoryFingerprintFromRootArgs,
-): Promise {
- const threadStorageRootPath = await resolveExistingThreadStorageRoot({
- rootPath: args.rootPath,
- });
- if (threadStorageRootPath === null) {
- return null;
- }
-
- const statusDataRoot = await resolveExistingStatusDataRoot({
- threadStorageRootPath,
- });
- return statusDataRoot?.fingerprint ?? null;
-}
-
-export async function readHostStatusData(
- command: CommandOf<"host.status_data.get">,
- options: HostStatusDataCommandOptions,
-): Promise> {
- return readHostStatusDataFromRoot({
- rootPath: resolveCommandThreadStoragePath(command, options),
- key: command.key,
- });
-}
-
-export async function readHostStatusDataFromRoot(
- args: ResolveThreadStorageRootArgs & { key: StatusDataKey },
-): Promise> {
- const threadStorageRootPath = await resolveExistingThreadStorageRoot({
- rootPath: args.rootPath,
- });
- if (threadStorageRootPath === null) {
- throw createMissingStatusDataKeyError(args.key);
- }
- const statusDataRoot = await resolveExistingStatusDataRoot({
- threadStorageRootPath,
- });
- if (statusDataRoot === null) {
- throw createMissingStatusDataKeyError(args.key);
- }
- return requireStatusDataEntry({
- statusDataRootPath: statusDataRoot.path,
- key: args.key,
- });
-}
-
-export async function writeHostStatusData(
- command: CommandOf<"host.status_data.set">,
- options: HostStatusDataCommandOptions,
-): Promise> {
- const threadStorageRootPath = await ensureThreadStorageRoot({
- rootPath: resolveCommandThreadStoragePath(command, options),
- });
- const statusDataRoot = await ensureStatusDataRoot({
- threadStorageRootPath,
- });
- const previous = await readStatusDataEntryOrNull({
- statusDataRootPath: statusDataRoot.path,
- key: command.key,
- });
- const entry = await writeStatusDataEntry({
- statusDataRootPath: statusDataRoot.path,
- key: command.key,
- value: command.value,
- });
- return {
- ...entry,
- ...createPreviousValue(previous),
- };
-}
-
-export async function deleteHostStatusData(
- command: CommandOf<"host.status_data.delete">,
- options: HostStatusDataCommandOptions,
-): Promise> {
- const threadStorageRootPath = await resolveExistingThreadStorageRoot({
- rootPath: resolveCommandThreadStoragePath(command, options),
- });
- if (threadStorageRootPath === null) {
- return {
- key: command.key,
- deleted: false,
- previousValue: null,
- previousValuePresent: false,
- previousVersion: null,
- };
- }
-
- const statusDataRoot = await resolveExistingStatusDataRoot({
- threadStorageRootPath,
- });
- if (statusDataRoot === null) {
- return {
- key: command.key,
- deleted: false,
- previousValue: null,
- previousValuePresent: false,
- previousVersion: null,
- };
- }
- const previous = await readStatusDataEntryOrNull({
- statusDataRootPath: statusDataRoot.path,
- key: command.key,
- });
- if (previous === null) {
- return {
- key: command.key,
- deleted: false,
- previousValue: null,
- previousValuePresent: false,
- previousVersion: null,
- };
- }
-
- await deleteStatusDataEntry({
- statusDataRootPath: statusDataRoot.path,
- key: command.key,
- });
- return {
- key: command.key,
- deleted: true,
- ...createPreviousValue(previous),
- };
-}
diff --git a/apps/host-daemon/src/command-handlers/status-version.ts b/apps/host-daemon/src/command-handlers/status-version.ts
deleted file mode 100644
index 611b2deaf..000000000
--- a/apps/host-daemon/src/command-handlers/status-version.ts
+++ /dev/null
@@ -1,491 +0,0 @@
-import { createHash } from "node:crypto";
-import type { BigIntStats, Dirent } from "node:fs";
-import fs from "node:fs/promises";
-import path from "node:path";
-import type {
- HostDaemonCommandResult,
- HostReadFileRelativeDotfilePolicy,
- HostStatusVersionFileSource,
- HostStatusVersionFolderSource,
- HostStatusVersionSource,
-} from "@bb/host-daemon-contract";
-import { CommandDispatchError } from "../command-dispatch-support.js";
-import type { CommandOf } from "../command-dispatch-support.js";
-import { isFsErrorWithCode } from "../fs-errors.js";
-import { resolveNonSymlinkDirectoryPath } from "./root-path.js";
-
-type StatusVersionHashEntry =
- | StatusVersionFileEntry
- | StatusVersionUnreadableEntry;
-
-interface StatusVersionFileEntry {
- kind: "file";
- modifiedAtNs: bigint;
- path: string;
- sizeBytes: bigint;
-}
-
-interface StatusVersionUnreadableEntry {
- kind: "unreadable";
- path: string;
-}
-
-interface ValidatedRelativePath {
- resultPath: string;
- segments: readonly string[];
-}
-
-interface ResolveSourceFileArgs {
- dotfiles: HostReadFileRelativeDotfilePolicy;
- path: string;
- rootPath: string;
-}
-
-interface WalkDirectoryArgs {
- dotfiles: HostReadFileRelativeDotfilePolicy;
- physicalDirPath: string;
- relativeDirPath: string;
- rootPath: string;
- seenDirectories: Set;
-}
-
-type WalkEntryResolution =
- | { kind: "directory"; readablePath: string }
- | { kind: "file"; entry: StatusVersionFileEntry }
- | { kind: "skip" };
-
-interface ResolveReadablePathArgs {
- fullPath: string;
- resultPath: string;
- rootPath: string;
-}
-
-interface StatReadableFileArgs {
- readablePath: string;
- resultPath: string;
-}
-
-interface ResolveWalkEntryArgs {
- fullPath: string;
- relativePath: string;
- rootPath: string;
-}
-
-const UNREADABLE_DIRECTORY_PATH = ".";
-const UNREADABLE_DIRECTORY_HASH_SENTINEL = "unreadable-directory";
-
-const STATUS_SOURCE_UNUSABLE_ERROR_CODES = new Set([
- "EACCES",
- "ELOOP",
- "ENOENT",
- "ENOTDIR",
- "EPERM",
- "invalid_path",
-]);
-
-function validateRelativePath(
- relativePath: string,
- dotfiles: HostReadFileRelativeDotfilePolicy,
-): ValidatedRelativePath {
- if (
- relativePath.includes("\0") ||
- relativePath.includes("\\") ||
- path.posix.isAbsolute(relativePath)
- ) {
- throw new CommandDispatchError("invalid_path", "Path must be relative");
- }
-
- const segments = relativePath.split("/");
- if (
- segments.some(
- (segment) => segment.length === 0 || segment === "." || segment === "..",
- )
- ) {
- throw new CommandDispatchError("invalid_path", "Path must be relative");
- }
-
- if (
- dotfiles === "deny" &&
- segments.some((segment) => segment.startsWith("."))
- ) {
- throw new CommandDispatchError(
- "ENOENT",
- `Path does not exist: ${relativePath}`,
- );
- }
-
- return {
- resultPath: segments.join("/"),
- segments,
- };
-}
-
-function assertAbsoluteRootPath(rootPath: string): void {
- if (!path.isAbsolute(rootPath)) {
- throw new CommandDispatchError("invalid_path", "rootPath must be absolute");
- }
-}
-
-function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
- const relativePath = path.relative(rootPath, candidatePath);
- return (
- relativePath === "" ||
- (!relativePath.startsWith("..") && !path.isAbsolute(relativePath))
- );
-}
-
-function isUnavailableStatusSourceError(error: Error): boolean {
- return (
- error instanceof CommandDispatchError &&
- STATUS_SOURCE_UNUSABLE_ERROR_CODES.has(error.code)
- );
-}
-
-function toMappedFsError(error: Error, resultPath: string): Error {
- if (isFsErrorWithCode(error, "ENOENT")) {
- return new CommandDispatchError(
- "ENOENT",
- `Path does not exist: ${resultPath}`,
- );
- }
- if (isFsErrorWithCode(error, "ENOTDIR")) {
- return new CommandDispatchError(
- "ENOTDIR",
- `Path is not a directory: ${resultPath}`,
- );
- }
- if (isFsErrorWithCode(error, "EACCES")) {
- return new CommandDispatchError(
- "EACCES",
- `Permission denied: ${resultPath}`,
- );
- }
- if (isFsErrorWithCode(error, "EPERM")) {
- return new CommandDispatchError(
- "EPERM",
- `Operation not permitted: ${resultPath}`,
- );
- }
- if (isFsErrorWithCode(error, "ELOOP")) {
- return new CommandDispatchError(
- "ELOOP",
- `Too many symbolic links: ${resultPath}`,
- );
- }
- return error;
-}
-
-function throwMappedFsError(error: Error, resultPath: string): never {
- throw toMappedFsError(error, resultPath);
-}
-
-async function resolveStatusRootPath(rootPath: string): Promise {
- assertAbsoluteRootPath(rootPath);
- try {
- return await resolveNonSymlinkDirectoryPath({
- description: "Root path",
- path: rootPath,
- });
- } catch (error) {
- if (error instanceof Error) {
- throwMappedFsError(error, rootPath);
- }
- throw error;
- }
-}
-
-async function resolveReadablePath(
- args: ResolveReadablePathArgs,
-): Promise {
- try {
- const readablePath = await fs.realpath(args.fullPath);
- if (!isPathWithinRoot(readablePath, args.rootPath)) {
- throw new CommandDispatchError(
- "invalid_path",
- `Path "${args.resultPath}" escapes read root`,
- );
- }
- return readablePath;
- } catch (error) {
- if (error instanceof Error) {
- throwMappedFsError(error, args.resultPath);
- }
- throw error;
- }
-}
-
-async function statReadableFile(
- args: StatReadableFileArgs,
-): Promise {
- try {
- const stat = await fs.stat(args.readablePath, { bigint: true });
- if (stat.isDirectory()) {
- throw new CommandDispatchError(
- "invalid_path",
- "Path is a directory, not a file",
- );
- }
- return {
- kind: "file",
- modifiedAtNs: stat.mtimeNs,
- path: args.resultPath,
- sizeBytes: stat.size,
- };
- } catch (error) {
- if (error instanceof Error) {
- throwMappedFsError(error, args.resultPath);
- }
- throw error;
- }
-}
-
-async function resolveWalkEntry(
- args: ResolveWalkEntryArgs,
-): Promise {
- let readablePath: string;
- try {
- readablePath = await resolveReadablePath({
- fullPath: args.fullPath,
- resultPath: args.relativePath,
- rootPath: args.rootPath,
- });
- } catch (error) {
- if (error instanceof Error && isUnavailableStatusSourceError(error)) {
- return { kind: "skip" };
- }
- throw error;
- }
-
- let stat: BigIntStats;
- try {
- stat = await fs.stat(readablePath, { bigint: true });
- } catch (error) {
- if (error instanceof Error) {
- try {
- throwMappedFsError(error, args.relativePath);
- } catch (mappedError) {
- if (
- mappedError instanceof Error &&
- isUnavailableStatusSourceError(mappedError)
- ) {
- return { kind: "skip" };
- }
- throw mappedError;
- }
- }
- throw error;
- }
-
- if (stat.isDirectory()) {
- return { kind: "directory", readablePath };
- }
- if (stat.isFile()) {
- return {
- kind: "file",
- entry: {
- kind: "file",
- modifiedAtNs: stat.mtimeNs,
- path: args.relativePath,
- sizeBytes: stat.size,
- },
- };
- }
- return { kind: "skip" };
-}
-
-async function statRelativeFile(
- args: ResolveSourceFileArgs,
-): Promise {
- const rootPath = await resolveStatusRootPath(args.rootPath);
- const relativePath = validateRelativePath(args.path, args.dotfiles);
- const readablePath = await resolveReadablePath({
- fullPath: path.join(rootPath, ...relativePath.segments),
- resultPath: relativePath.resultPath,
- rootPath,
- });
- return statReadableFile({
- readablePath,
- resultPath: relativePath.resultPath,
- });
-}
-
-function toChildRelativePath(
- relativeDirPath: string,
- childName: string,
-): string {
- return relativeDirPath.length === 0
- ? childName
- : `${relativeDirPath}/${childName}`;
-}
-
-async function walkDirectory(
- args: WalkDirectoryArgs,
-): Promise {
- let entries: Dirent[];
- try {
- entries = await fs.readdir(args.physicalDirPath, { withFileTypes: true });
- } catch (error) {
- if (error instanceof Error) {
- const mappedError = toMappedFsError(
- error,
- args.relativeDirPath || args.rootPath,
- );
- if (
- mappedError instanceof Error &&
- isUnavailableStatusSourceError(mappedError)
- ) {
- return [
- {
- kind: "unreadable",
- path: args.relativeDirPath || UNREADABLE_DIRECTORY_PATH,
- },
- ];
- }
- throw mappedError;
- }
- throw error;
- }
-
- const fileEntries: StatusVersionHashEntry[] = [];
- for (const entry of entries) {
- if (args.dotfiles === "deny" && entry.name.startsWith(".")) {
- continue;
- }
-
- const relativePath = toChildRelativePath(args.relativeDirPath, entry.name);
- try {
- validateRelativePath(relativePath, args.dotfiles);
- } catch (error) {
- if (error instanceof Error && isUnavailableStatusSourceError(error)) {
- continue;
- }
- throw error;
- }
- const resolvedEntry = await resolveWalkEntry({
- fullPath: path.join(args.physicalDirPath, entry.name),
- relativePath,
- rootPath: args.rootPath,
- });
- if (resolvedEntry.kind === "skip") {
- continue;
- }
-
- if (resolvedEntry.kind === "directory") {
- if (args.seenDirectories.has(resolvedEntry.readablePath)) {
- continue;
- }
- args.seenDirectories.add(resolvedEntry.readablePath);
- fileEntries.push(
- ...(await walkDirectory({
- ...args,
- physicalDirPath: resolvedEntry.readablePath,
- relativeDirPath: relativePath,
- })),
- );
- continue;
- }
-
- fileEntries.push(resolvedEntry.entry);
- }
-
- return fileEntries;
-}
-
-function hashStatusVersion(
- source: HostStatusVersionSource,
- entries: readonly StatusVersionHashEntry[],
-): string {
- const hash = createHash("sha256");
- hash.update(`source:${source}\n`);
- for (const entry of [...entries].sort((left, right) =>
- left.path.localeCompare(right.path),
- )) {
- if (entry.kind === "unreadable") {
- // Keep unreadable child directories in the tuple so STATUS folder mode
- // stays aligned with /status/ when index.html is still valid.
- hash.update(`${entry.path}\0${UNREADABLE_DIRECTORY_HASH_SENTINEL}\n`);
- continue;
- }
- hash.update(
- `${entry.path}\0${entry.sizeBytes.toString()}\0${entry.modifiedAtNs.toString()}\n`,
- );
- }
- return hash.digest("hex");
-}
-
-async function resolveFolderSource(
- source: HostStatusVersionFolderSource,
-): Promise> {
- await statRelativeFile({
- rootPath: source.rootPath,
- path: source.indexPath,
- dotfiles: source.dotfiles,
- });
-
- const rootPath = await resolveStatusRootPath(source.rootPath);
- const entries = await walkDirectory({
- rootPath,
- physicalDirPath: rootPath,
- relativeDirPath: "",
- dotfiles: source.dotfiles,
- seenDirectories: new Set([rootPath]),
- });
- return {
- source: source.source,
- hash: hashStatusVersion(source.source, entries),
- };
-}
-
-async function resolveFileSource(
- source: HostStatusVersionFileSource,
-): Promise> {
- const entry = await statRelativeFile({
- rootPath: source.rootPath,
- path: source.path,
- dotfiles: source.dotfiles,
- });
- return {
- source: source.source,
- hash: hashStatusVersion(source.source, [entry]),
- };
-}
-
-async function tryResolveFolderSource(
- source: HostStatusVersionFolderSource,
-): Promise | null> {
- try {
- return await resolveFolderSource(source);
- } catch (error) {
- if (error instanceof Error && isUnavailableStatusSourceError(error)) {
- return null;
- }
- throw error;
- }
-}
-
-async function tryResolveFileSource(
- source: HostStatusVersionFileSource,
-): Promise | null> {
- try {
- return await resolveFileSource(source);
- } catch (error) {
- if (error instanceof Error && isUnavailableStatusSourceError(error)) {
- return null;
- }
- throw error;
- }
-}
-
-export async function readHostStatusVersion(
- command: CommandOf<"host.status_version">,
-): Promise> {
- const [folderSource, htmlSource, markdownSource] = command.sources;
- return (
- (await tryResolveFolderSource(folderSource)) ??
- (await tryResolveFileSource(htmlSource)) ??
- (await tryResolveFileSource(markdownSource)) ?? {
- source: "empty",
- hash: hashStatusVersion("empty", []),
- }
- );
-}
diff --git a/apps/host-daemon/src/command-router.ts b/apps/host-daemon/src/command-router.ts
index aa2b1a7bd..32cdcd91a 100644
--- a/apps/host-daemon/src/command-router.ts
+++ b/apps/host-daemon/src/command-router.ts
@@ -39,37 +39,13 @@ interface EnvironmentLaneState {
type FileWriteLaneCommand = Extract<
HostDaemonCommandEnvelope["command"],
- { type: "host.write_file_relative" | "host.delete_file_relative" }
->;
-type StatusDataSetCommand = Extract<
- HostDaemonCommand,
- { type: "host.status_data.set" }
->;
-type StatusDataDeleteCommand = Extract<
- HostDaemonCommand,
- { type: "host.status_data.delete" }
+ {
+ type:
+ | "host.write_file_relative"
+ | "host.delete_file_relative"
+ | "host.delete_path_relative";
+ }
>;
-
-export interface StatusDataSetCommandResultNotification {
- command: StatusDataSetCommand;
- result: HostDaemonCommandResult<"host.status_data.set">;
-}
-
-export interface StatusDataDeleteCommandResultNotification {
- command: StatusDataDeleteCommand;
- result: HostDaemonCommandResult<"host.status_data.delete">;
-}
-
-export type StatusDataCommandResultNotification =
- | StatusDataSetCommandResultNotification
- | StatusDataDeleteCommandResultNotification;
-
-export function isStatusDataSetCommandResultNotification(
- notification: StatusDataCommandResultNotification,
-): notification is StatusDataSetCommandResultNotification {
- return notification.command.type === "host.status_data.set";
-}
-
interface EnvironmentLaneWorkMetrics {
startedAtMs: number | null;
}
@@ -126,9 +102,6 @@ export interface CommandRouterOptions {
threadStorageRootPath: string;
logger: CommandRouterLogger;
readFetchedAt?: ReadCommandFetchedAt;
- onStatusDataCommandResult?: (
- notification: StatusDataCommandResultNotification,
- ) => void;
now?: () => number;
}
@@ -343,24 +316,6 @@ export class CommandRouter {
};
try {
- if (command.type === "host.status_data.set") {
- const result = await dispatchCommand(command, dispatchOptions);
- this.options.onStatusDataCommandResult?.({ command, result });
- return this.createSuccessfulCommandResult({
- baseReport,
- handlerStartedAtMs,
- result,
- });
- }
- if (command.type === "host.status_data.delete") {
- const result = await dispatchCommand(command, dispatchOptions);
- this.options.onStatusDataCommandResult?.({ command, result });
- return this.createSuccessfulCommandResult({
- baseReport,
- handlerStartedAtMs,
- result,
- });
- }
const result = await dispatchCommand(command, dispatchOptions);
return this.createSuccessfulCommandResult({
baseReport,
@@ -555,7 +510,8 @@ export class CommandRouter {
): command is FileWriteLaneCommand {
return (
command.type === "host.write_file_relative" ||
- command.type === "host.delete_file_relative"
+ command.type === "host.delete_file_relative" ||
+ command.type === "host.delete_path_relative"
);
}
diff --git a/apps/host-daemon/src/runtime-manager.test.ts b/apps/host-daemon/src/runtime-manager.test.ts
index 4ec5af8e0..8d3de2092 100644
--- a/apps/host-daemon/src/runtime-manager.test.ts
+++ b/apps/host-daemon/src/runtime-manager.test.ts
@@ -1024,13 +1024,13 @@ describe("RuntimeManager", () => {
},
});
const onThreadStorageChanged = vi.fn();
- const onThreadStatusDataChanged = vi.fn();
+ const onThreadAppDataChanged = vi.fn();
const manager = new RuntimeManager({
hostWatcher,
provisionWorkspace: createProvisionWorkspaceMock("/tmp/env-storage"),
createRuntime: vi.fn(() => createFakeRuntime()),
onThreadStorageChanged,
- onThreadStatusDataChanged,
+ onThreadAppDataChanged,
threadStorageRootPath: "/tmp/bb-data/thread-storage",
});
@@ -1052,10 +1052,11 @@ describe("RuntimeManager", () => {
threadId: "thread-2",
});
watchThreadStorageRootArgs?.onChange({
- kind: "thread-status-data-changed",
+ kind: "thread-app-data-changed",
+ appId: "status",
environmentId: "env-storage",
+ path: "state.json",
threadId: "thread-1",
- key: "state",
});
expect(watchThreadStorageRoot).toHaveBeenCalledTimes(1);
@@ -1073,10 +1074,11 @@ describe("RuntimeManager", () => {
threadId: "thread-2",
});
expect(onThreadStorageChanged).toHaveBeenCalledTimes(2);
- expect(onThreadStatusDataChanged).toHaveBeenCalledWith({
+ expect(onThreadAppDataChanged).toHaveBeenCalledWith({
+ appId: "status",
environmentId: "env-storage",
+ path: "state.json",
threadId: "thread-1",
- key: "state",
threadStoragePath: "/tmp/bb-data/thread-storage/thread-1",
});
expect(stopWatchingPathChanges).not.toHaveBeenCalled();
diff --git a/apps/host-daemon/src/runtime-manager.ts b/apps/host-daemon/src/runtime-manager.ts
index 2e05148a0..d5ce9d0a7 100644
--- a/apps/host-daemon/src/runtime-manager.ts
+++ b/apps/host-daemon/src/runtime-manager.ts
@@ -7,9 +7,10 @@ import {
type AgentRuntimeProcessExitInfo,
} from "@bb/agent-runtime";
import type {
+ AppDataPath,
+ AppId,
PendingInteractionCreate,
PendingInteractionResolution,
- StatusDataKey,
ThreadEvent,
WorkspaceProvisionType,
} from "@bb/domain";
@@ -152,9 +153,10 @@ export interface RuntimeEntry {
threads: Map;
}
-export interface ThreadStatusDataChangedNotification {
+export interface ThreadAppDataChangedNotification {
+ appId: AppId;
environmentId: string;
- key: StatusDataKey;
+ path: AppDataPath;
threadId: string;
threadStoragePath: string;
}
@@ -187,9 +189,7 @@ export interface RuntimeManagerOptions {
environmentId: string;
threadId: string;
}) => void;
- onThreadStatusDataChanged?: (
- args: ThreadStatusDataChangedNotification,
- ) => void;
+ onThreadAppDataChanged?: (args: ThreadAppDataChangedNotification) => void;
onThreadStorageWatchError?: (args: {
error: ThreadStorageWatchError;
}) => void;
@@ -915,10 +915,11 @@ export class RuntimeManager {
threadId: event.threadId,
});
}
- if (event.kind === "thread-status-data-changed") {
- this.options.onThreadStatusDataChanged?.({
+ if (event.kind === "thread-app-data-changed") {
+ this.options.onThreadAppDataChanged?.({
+ appId: event.appId,
environmentId: event.environmentId,
- key: event.key,
+ path: event.path,
threadId: event.threadId,
threadStoragePath: path.join(
threadStorageRootPath,
diff --git a/apps/host-daemon/src/server-client.test.ts b/apps/host-daemon/src/server-client.test.ts
index 17f22bf81..de62102bb 100644
--- a/apps/host-daemon/src/server-client.test.ts
+++ b/apps/host-daemon/src/server-client.test.ts
@@ -1,7 +1,6 @@
import { AbortError } from "p-retry";
import { describe, expect, it, vi } from "vitest";
import type { PendingInteractionCreate } from "@bb/domain";
-import { hostDaemonStatusDataChangeRequestSchema } from "@bb/host-daemon-contract";
import { createServerClient, ServerResponseError } from "./server-client.js";
function createLogger() {
@@ -227,51 +226,6 @@ describe("createServerClient", () => {
});
});
- it("posts STATUS-data changes with the current session id", async () => {
- const fetchFn = vi.fn(async (input, init) => {
- const url = new URL(String(input));
- expect(url.pathname).toBe("/internal/session/status-data-change");
- const body = init?.body;
- if (typeof body !== "string") {
- throw new Error("Expected string request body");
- }
- expect(
- hostDaemonStatusDataChangeRequestSchema.parse(JSON.parse(body)),
- ).toEqual({
- sessionId: "session-1",
- threadId: "thr_123",
- key: "state",
- deleted: false,
- value: { status: "running" },
- version: "version-next",
- previousValue: { status: "queued" },
- previousValuePresent: true,
- previousVersion: "version-prev",
- });
- return new Response(null, { status: 204 });
- });
- const client = createServerClient({
- fetchFn,
- getSessionId: () => "session-1",
- hostKey: "host-key",
- logger: createLogger(),
- serverUrl: "https://bb.example.test",
- });
-
- await client.postStatusDataChange({
- threadId: "thr_123",
- key: "state",
- deleted: false,
- value: { status: "running" },
- version: "version-next",
- previousValue: { status: "queued" },
- previousValuePresent: true,
- previousVersion: "version-prev",
- });
-
- expect(fetchFn).toHaveBeenCalledTimes(1);
- });
-
it("retries retryable interactive request registration responses after the attempt hook", async () => {
let calls = 0;
const fetchFn = vi.fn(async () => {
diff --git a/apps/host-daemon/src/server-client.ts b/apps/host-daemon/src/server-client.ts
index c574166dc..e68b4a4c4 100644
--- a/apps/host-daemon/src/server-client.ts
+++ b/apps/host-daemon/src/server-client.ts
@@ -6,11 +6,12 @@ import {
hostDaemonCommandResultResponseSchema,
hostDaemonCommandResultReportSchema,
hostDaemonCommandsQuerySchema,
+ hostDaemonAppDataChangeRequestSchema,
+ hostDaemonAppDataResyncRequestSchema,
hostDaemonEnvironmentChangeRequestSchema,
hostDaemonEventBatchRequestSchema,
hostDaemonEventBatchResponseSchema,
hostDaemonProjectAttachmentContentQuerySchema,
- hostDaemonStatusDataChangeRequestSchema,
hostDaemonInteractiveInterruptRequestSchema,
hostDaemonInteractiveInterruptResponseSchema,
hostDaemonInteractiveRequestResponseSchema,
@@ -23,13 +24,14 @@ import {
type HostDaemonInteractiveInterruptResponse,
type HostDaemonInteractiveRequestResponse,
type HostDaemonActiveThread,
+ type HostDaemonAppDataChangePayload,
+ type HostDaemonAppDataResyncPayload,
type HostDaemonCommandEnvelope,
type HostDaemonCommandResultReportWithoutSession,
type HostDaemonEventEnvelope,
type HostDaemonEnvironmentChangePayload,
type HostDaemonSessionOpenRequest,
type HostDaemonSessionOpenResponse,
- type HostDaemonStatusDataChangePayload,
type HostDaemonToolCallResponse,
} from "@bb/host-daemon-contract";
import type { PendingInteractionCreate, ToolCallRequest } from "@bb/domain";
@@ -226,9 +228,8 @@ export interface ServerClient {
postEnvironmentChange(
args: HostDaemonEnvironmentChangePayload,
): Promise;
- postStatusDataChange(
- args: HostDaemonStatusDataChangePayload,
- ): Promise;
+ postAppDataChange(args: HostDaemonAppDataChangePayload): Promise;
+ postAppDataResync(args: HostDaemonAppDataResyncPayload): Promise;
postEvents(events: HostDaemonEventEnvelope[]): Promise;
callTool(request: ToolCallRequest): Promise;
registerInteractiveRequest(
@@ -656,13 +657,32 @@ export function createServerClient(
}
},
- async postStatusDataChange(args): Promise {
- const payload = hostDaemonStatusDataChangeRequestSchema.parse({
+ async postAppDataChange(args): Promise {
+ const payload = hostDaemonAppDataChangeRequestSchema.parse({
+ sessionId: requireSessionId(),
+ ...args,
+ });
+ const response = await fetchFn(
+ buildInternalUrl("/session/app-data-change"),
+ {
+ method: "POST",
+ headers: headers(),
+ body: JSON.stringify(payload),
+ },
+ );
+
+ if (!response.ok) {
+ throw await createResponseError("post app data change", response);
+ }
+ },
+
+ async postAppDataResync(args): Promise {
+ const payload = hostDaemonAppDataResyncRequestSchema.parse({
sessionId: requireSessionId(),
...args,
});
const response = await fetchFn(
- buildInternalUrl("/session/status-data-change"),
+ buildInternalUrl("/session/app-data-resync"),
{
method: "POST",
headers: headers(),
@@ -671,7 +691,7 @@ export function createServerClient(
);
if (!response.ok) {
- throw await createResponseError("post STATUS-data change", response);
+ throw await createResponseError("post app data resync", response);
}
},
diff --git a/apps/host-daemon/src/status-data-change-reporter.test.ts b/apps/host-daemon/src/status-data-change-reporter.test.ts
deleted file mode 100644
index 30d5bb7b8..000000000
--- a/apps/host-daemon/src/status-data-change-reporter.test.ts
+++ /dev/null
@@ -1,792 +0,0 @@
-import { createHash } from "node:crypto";
-import fs from "node:fs/promises";
-import os from "node:os";
-import path from "node:path";
-import type { HostDaemonStatusDataChangePayload } from "@bb/host-daemon-contract";
-import type { JsonValue, StatusDataKey } from "@bb/domain";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import type { HostDaemonLogger } from "./logger.js";
-import { NON_IMAGE_FILE_SIZE_LIMIT_BYTES } from "./command-handlers/file-read.js";
-import { StatusDataChangeReporter } from "./status-data-change-reporter.js";
-
-interface WriteStatusDataFileArgs {
- key: StatusDataKey;
- threadStoragePath: string;
- value: JsonValue;
-}
-
-interface WriteRawStatusDataFileArgs {
- content: string;
- key: StatusDataKey;
- threadStoragePath: string;
-}
-
-interface DeferredVoid {
- promise: Promise;
- resolve(): void;
-}
-
-const tempDirs: string[] = [];
-
-function createLogger() {
- return {
- debug: vi.fn(),
- error: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- } satisfies HostDaemonLogger;
-}
-
-async function makeTempDir(prefix: string): Promise {
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
- tempDirs.push(dir);
- return dir;
-}
-
-function sha256(content: string): string {
- return createHash("sha256")
- .update(Buffer.from(content, "utf8"))
- .digest("hex");
-}
-
-function canonicalStatusDataJson(value: JsonValue): string {
- return `${JSON.stringify(value, null, 2)}\n`;
-}
-
-function createDeferredVoid(): DeferredVoid {
- let resolvePromise: () => void = () => {};
- const promise = new Promise((resolve) => {
- resolvePromise = resolve;
- });
- return {
- promise,
- resolve: resolvePromise,
- };
-}
-
-async function writeRawStatusDataFile(
- args: WriteRawStatusDataFileArgs,
-): Promise {
- const rootPath = path.join(args.threadStoragePath, "STATUS-data");
- await fs.mkdir(rootPath, { recursive: true });
- await fs.writeFile(path.join(rootPath, `${args.key}.json`), args.content);
- return args.content;
-}
-
-async function writeStatusDataFile(
- args: WriteStatusDataFileArgs,
-): Promise {
- return writeRawStatusDataFile({
- key: args.key,
- threadStoragePath: args.threadStoragePath,
- content: canonicalStatusDataJson(args.value),
- });
-}
-
-afterEach(async () => {
- vi.restoreAllMocks();
- await Promise.all(
- tempDirs
- .splice(0)
- .map((dir) => fs.rm(dir, { recursive: true, force: true })),
- );
-});
-
-describe("StatusDataChangeReporter", () => {
- it("primes current files without posting and reports later updates with previous metadata", async () => {
- const threadStoragePath = await makeTempDir(
- "bb-status-data-reporter-update-",
- );
- const previousContent = await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "old" },
- });
- const posted: HostDaemonStatusDataChangePayload[] = [];
- const reporter = new StatusDataChangeReporter({
- logger: createLogger(),
- postStatusDataChange: async (payload) => {
- posted.push(payload);
- },
- });
-
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_update", threadStoragePath }],
- });
- expect(posted).toEqual([]);
-
- const nextContent = await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "new" },
- });
- await reporter.observe({
- threadId: "thr_update",
- threadStoragePath,
- key: "state",
- });
-
- expect(posted).toEqual([
- {
- threadId: "thr_update",
- key: "state",
- deleted: false,
- value: { status: "new" },
- version: sha256(nextContent),
- previousValue: { status: "old" },
- previousValuePresent: true,
- previousVersion: sha256(previousContent),
- },
- ]);
- });
-
- it("reports newly-created files without previous metadata", async () => {
- const threadStoragePath = await makeTempDir(
- "bb-status-data-reporter-create-",
- );
- const posted: HostDaemonStatusDataChangePayload[] = [];
- const reporter = new StatusDataChangeReporter({
- logger: createLogger(),
- postStatusDataChange: async (payload) => {
- posted.push(payload);
- },
- });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_create", threadStoragePath }],
- });
-
- const content = await writeStatusDataFile({
- threadStoragePath,
- key: "progress",
- value: 1,
- });
- await reporter.observe({
- threadId: "thr_create",
- threadStoragePath,
- key: "progress",
- });
-
- expect(posted).toEqual([
- {
- threadId: "thr_create",
- key: "progress",
- deleted: false,
- value: 1,
- version: sha256(content),
- previousValue: null,
- previousValuePresent: false,
- previousVersion: null,
- },
- ]);
- });
-
- it("reconciles files for a thread first observed after session open", async () => {
- const threadStoragePath = await makeTempDir(
- "bb-status-data-reporter-late-track-",
- );
- const content = await writeStatusDataFile({
- threadStoragePath,
- key: "progress",
- value: { step: 1 },
- });
- const posted: HostDaemonStatusDataChangePayload[] = [];
- const reporter = new StatusDataChangeReporter({
- logger: createLogger(),
- postStatusDataChange: async (payload) => {
- posted.push(payload);
- },
- });
-
- await reporter.reconcileThread({
- threadId: "thr_late_track",
- threadStoragePath,
- });
- reporter.trackThread({ threadId: "thr_late_track" });
- await reporter.reconcileThread({
- threadId: "thr_late_track",
- threadStoragePath,
- });
-
- expect(posted).toEqual([
- {
- threadId: "thr_late_track",
- key: "progress",
- deleted: false,
- value: { step: 1 },
- version: sha256(content),
- previousValue: null,
- previousValuePresent: false,
- previousVersion: null,
- },
- ]);
- });
-
- it("retries observed updates after a failed post", async () => {
- const threadStoragePath = await makeTempDir(
- "bb-status-data-reporter-retry-",
- );
- const previousContent = await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "old" },
- });
- const logger = createLogger();
- const posted: HostDaemonStatusDataChangePayload[] = [];
- let attempts = 0;
- const reporter = new StatusDataChangeReporter({
- logger,
- postStatusDataChange: async (payload) => {
- attempts += 1;
- if (attempts === 1) {
- throw new Error("offline");
- }
- posted.push(payload);
- },
- });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_retry", threadStoragePath }],
- });
-
- const nextContent = await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "new" },
- });
- await reporter.observe({
- threadId: "thr_retry",
- threadStoragePath,
- key: "state",
- });
-
- expect(posted).toEqual([]);
- expect(logger.warn).toHaveBeenCalledWith(
- expect.objectContaining({
- key: "state",
- threadId: "thr_retry",
- }),
- "Failed to report observed STATUS-data change",
- );
-
- await reporter.observe({
- threadId: "thr_retry",
- threadStoragePath,
- key: "state",
- });
-
- expect(attempts).toBe(2);
- expect(posted).toEqual([
- {
- threadId: "thr_retry",
- key: "state",
- deleted: false,
- value: { status: "new" },
- version: sha256(nextContent),
- previousValue: { status: "old" },
- previousValuePresent: true,
- previousVersion: sha256(previousContent),
- },
- ]);
- });
-
- it("reports deleted files only when a previous cached value exists", async () => {
- const threadStoragePath = await makeTempDir(
- "bb-status-data-reporter-delete-",
- );
- const previousContent = await writeStatusDataFile({
- threadStoragePath,
- key: "summary",
- value: null,
- });
- const posted: HostDaemonStatusDataChangePayload[] = [];
- const reporter = new StatusDataChangeReporter({
- logger: createLogger(),
- postStatusDataChange: async (payload) => {
- posted.push(payload);
- },
- });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_delete", threadStoragePath }],
- });
-
- await fs.rm(path.join(threadStoragePath, "STATUS-data", "summary.json"));
- await reporter.observe({
- threadId: "thr_delete",
- threadStoragePath,
- key: "summary",
- });
- await reporter.observe({
- threadId: "thr_delete",
- threadStoragePath,
- key: "summary",
- });
-
- expect(posted).toEqual([
- {
- threadId: "thr_delete",
- key: "summary",
- deleted: true,
- value: null,
- version: null,
- previousValue: null,
- previousValuePresent: true,
- previousVersion: sha256(previousContent),
- },
- ]);
- });
-
- it("does not post when an observed file still has the cached version", async () => {
- const threadStoragePath = await makeTempDir(
- "bb-status-data-reporter-same-",
- );
- await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "same" },
- });
- const postStatusDataChange = vi.fn(async () => undefined);
- const reporter = new StatusDataChangeReporter({
- logger: createLogger(),
- postStatusDataChange,
- });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_same", threadStoragePath }],
- });
-
- await reporter.observe({
- threadId: "thr_same",
- threadStoragePath,
- key: "state",
- });
-
- expect(postStatusDataChange).not.toHaveBeenCalled();
- });
-
- it("serializes concurrent observations for the same key and reports the final value", async () => {
- const threadStoragePath = await makeTempDir(
- "bb-status-data-reporter-same-key-race-",
- );
- const previousContent = await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "old" },
- });
- const firstPostStarted = createDeferredVoid();
- const releaseFirstPost = createDeferredVoid();
- const posted: HostDaemonStatusDataChangePayload[] = [];
- let postCount = 0;
- const reporter = new StatusDataChangeReporter({
- logger: createLogger(),
- postStatusDataChange: async (payload) => {
- postCount += 1;
- posted.push(payload);
- if (postCount === 1) {
- firstPostStarted.resolve();
- await releaseFirstPost.promise;
- }
- },
- });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_same_key_race", threadStoragePath }],
- });
-
- const intermediateContent = await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "intermediate" },
- });
- const firstObserve = reporter.observe({
- threadId: "thr_same_key_race",
- threadStoragePath,
- key: "state",
- });
- await firstPostStarted.promise;
- const finalContent = await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "final" },
- });
- const secondObserve = reporter.observe({
- threadId: "thr_same_key_race",
- threadStoragePath,
- key: "state",
- });
- releaseFirstPost.resolve();
- await Promise.all([firstObserve, secondObserve]);
-
- expect(posted).toEqual([
- {
- threadId: "thr_same_key_race",
- key: "state",
- deleted: false,
- value: { status: "intermediate" },
- version: sha256(intermediateContent),
- previousValue: { status: "old" },
- previousValuePresent: true,
- previousVersion: sha256(previousContent),
- },
- {
- threadId: "thr_same_key_race",
- key: "state",
- deleted: false,
- value: { status: "final" },
- version: sha256(finalContent),
- previousValue: { status: "intermediate" },
- previousValuePresent: true,
- previousVersion: sha256(intermediateContent),
- },
- ]);
- });
-
- it("suppresses observed changes already applied by daemon commands", async () => {
- const threadStoragePath = await makeTempDir(
- "bb-status-data-reporter-command-",
- );
- await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "old" },
- });
- const postStatusDataChange = vi.fn(async () => undefined);
- const reporter = new StatusDataChangeReporter({
- logger: createLogger(),
- postStatusDataChange,
- });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_command", threadStoragePath }],
- });
-
- const nextContent = await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "new" },
- });
- reporter.recordCommandSet({
- threadId: "thr_command",
- key: "state",
- value: { status: "new" },
- version: sha256(nextContent),
- });
- await reporter.observe({
- threadId: "thr_command",
- threadStoragePath,
- key: "state",
- });
-
- await fs.rm(path.join(threadStoragePath, "STATUS-data", "state.json"));
- reporter.recordCommandDelete({
- threadId: "thr_command",
- key: "state",
- });
- await reporter.observe({
- threadId: "thr_command",
- threadStoragePath,
- key: "state",
- });
-
- expect(postStatusDataChange).not.toHaveBeenCalled();
- });
-
- it("logs command results for untracked threads without seeding the cache", async () => {
- const threadStoragePath = await makeTempDir(
- "bb-status-data-reporter-untracked-command-",
- );
- const logger = createLogger();
- const postStatusDataChange = vi.fn(async () => undefined);
- const reporter = new StatusDataChangeReporter({
- logger,
- postStatusDataChange,
- });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_tracked", threadStoragePath }],
- });
-
- reporter.recordCommandSet({
- threadId: "thr_untracked",
- key: "state",
- value: { status: "new" },
- version: "command-version",
- });
- reporter.recordCommandDelete({
- threadId: "thr_untracked",
- key: "state",
- });
-
- expect(logger.warn).toHaveBeenCalledTimes(2);
- expect(logger.warn).toHaveBeenCalledWith(
- {
- key: "state",
- threadId: "thr_untracked",
- },
- "Ignoring STATUS-data command result for untracked thread",
- );
- expect(postStatusDataChange).not.toHaveBeenCalled();
- });
-
- it("reconciles missed file watcher events from a thread snapshot", async () => {
- const threadStoragePath = await makeTempDir(
- "bb-status-data-reporter-reconcile-",
- );
- const previousContent = await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "old" },
- });
- const goneContent = await writeStatusDataFile({
- threadStoragePath,
- key: "gone",
- value: true,
- });
- const posted: HostDaemonStatusDataChangePayload[] = [];
- const reporter = new StatusDataChangeReporter({
- logger: createLogger(),
- postStatusDataChange: async (payload) => {
- posted.push(payload);
- },
- });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_reconcile", threadStoragePath }],
- });
-
- const nextContent = await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "new" },
- });
- const addedContent = await writeStatusDataFile({
- threadStoragePath,
- key: "added",
- value: 2,
- });
- await fs.rm(path.join(threadStoragePath, "STATUS-data", "gone.json"));
- await reporter.reconcileThread({
- threadId: "thr_reconcile",
- threadStoragePath,
- });
-
- expect(posted).toEqual([
- {
- threadId: "thr_reconcile",
- key: "added",
- deleted: false,
- value: 2,
- version: sha256(addedContent),
- previousValue: null,
- previousValuePresent: false,
- previousVersion: null,
- },
- {
- threadId: "thr_reconcile",
- key: "state",
- deleted: false,
- value: { status: "new" },
- version: sha256(nextContent),
- previousValue: { status: "old" },
- previousValuePresent: true,
- previousVersion: sha256(previousContent),
- },
- {
- threadId: "thr_reconcile",
- key: "gone",
- deleted: true,
- value: null,
- version: null,
- previousValue: true,
- previousValuePresent: true,
- previousVersion: sha256(goneContent),
- },
- ]);
- });
-
- it("reconciles previously tracked thread changes when tracked threads are replaced", async () => {
- const threadStoragePath = await makeTempDir(
- "bb-status-data-reporter-reconnect-",
- );
- const previousContent = await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "old" },
- });
- const goneContent = await writeStatusDataFile({
- threadStoragePath,
- key: "gone",
- value: true,
- });
- const posted: HostDaemonStatusDataChangePayload[] = [];
- const reporter = new StatusDataChangeReporter({
- logger: createLogger(),
- postStatusDataChange: async (payload) => {
- posted.push(payload);
- },
- });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_reconnect", threadStoragePath }],
- });
-
- const nextContent = await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "new" },
- });
- const addedContent = await writeStatusDataFile({
- threadStoragePath,
- key: "added",
- value: 2,
- });
- await fs.rm(path.join(threadStoragePath, "STATUS-data", "gone.json"));
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_reconnect", threadStoragePath }],
- });
-
- expect(posted).toEqual([
- {
- threadId: "thr_reconnect",
- key: "added",
- deleted: false,
- value: 2,
- version: sha256(addedContent),
- previousValue: null,
- previousValuePresent: false,
- previousVersion: null,
- },
- {
- threadId: "thr_reconnect",
- key: "state",
- deleted: false,
- value: { status: "new" },
- version: sha256(nextContent),
- previousValue: { status: "old" },
- previousValuePresent: true,
- previousVersion: sha256(previousContent),
- },
- {
- threadId: "thr_reconnect",
- key: "gone",
- deleted: true,
- value: null,
- version: null,
- previousValue: true,
- previousValuePresent: true,
- previousVersion: sha256(goneContent),
- },
- ]);
- });
-
- it("reconciles reconnect-created files for previously empty tracked threads", async () => {
- const threadStoragePath = await makeTempDir(
- "bb-status-data-reporter-empty-reconnect-",
- );
- const posted: HostDaemonStatusDataChangePayload[] = [];
- const reporter = new StatusDataChangeReporter({
- logger: createLogger(),
- postStatusDataChange: async (payload) => {
- posted.push(payload);
- },
- });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_empty_reconnect", threadStoragePath }],
- });
-
- const content = await writeStatusDataFile({
- threadStoragePath,
- key: "progress",
- value: 1,
- });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_empty_reconnect", threadStoragePath }],
- });
-
- expect(posted).toEqual([
- {
- threadId: "thr_empty_reconnect",
- key: "progress",
- deleted: false,
- value: 1,
- version: sha256(content),
- previousValue: null,
- previousValuePresent: false,
- previousVersion: null,
- },
- ]);
- });
-
- it("logs and skips unreadable observed STATUS-data files", async () => {
- const threadStoragePath = await makeTempDir(
- "bb-status-data-reporter-invalid-",
- );
- await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "old" },
- });
- const logger = createLogger();
- const postStatusDataChange = vi.fn(async () => undefined);
- const reporter = new StatusDataChangeReporter({
- logger,
- postStatusDataChange,
- });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_invalid", threadStoragePath }],
- });
-
- await writeRawStatusDataFile({
- threadStoragePath,
- key: "state",
- content: "{",
- });
- await reporter.observe({
- threadId: "thr_invalid",
- threadStoragePath,
- key: "state",
- });
-
- expect(postStatusDataChange).not.toHaveBeenCalled();
- expect(logger.warn).toHaveBeenCalledWith(
- expect.objectContaining({
- key: "state",
- threadId: "thr_invalid",
- }),
- "Ignoring unreadable observed STATUS-data file",
- );
- });
-
- it("logs and skips oversized observed STATUS-data files", async () => {
- const threadStoragePath = await makeTempDir(
- "bb-status-data-reporter-large-",
- );
- await writeStatusDataFile({
- threadStoragePath,
- key: "state",
- value: { status: "old" },
- });
- const logger = createLogger();
- const postStatusDataChange = vi.fn(async () => undefined);
- const reporter = new StatusDataChangeReporter({
- logger,
- postStatusDataChange,
- });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_large", threadStoragePath }],
- });
-
- const statusDataPath = path.join(
- threadStoragePath,
- "STATUS-data",
- "state.json",
- );
- await fs.truncate(statusDataPath, NON_IMAGE_FILE_SIZE_LIMIT_BYTES + 1);
- await reporter.observe({
- threadId: "thr_large",
- threadStoragePath,
- key: "state",
- });
-
- expect(postStatusDataChange).not.toHaveBeenCalled();
- expect(logger.warn).toHaveBeenCalledWith(
- expect.objectContaining({
- key: "state",
- threadId: "thr_large",
- }),
- "Ignoring unreadable observed STATUS-data file",
- );
- });
-});
diff --git a/apps/host-daemon/src/status-data-change-reporter.ts b/apps/host-daemon/src/status-data-change-reporter.ts
deleted file mode 100644
index 30c61a9af..000000000
--- a/apps/host-daemon/src/status-data-change-reporter.ts
+++ /dev/null
@@ -1,437 +0,0 @@
-import path from "node:path";
-import type { HostDaemonStatusDataChangePayload } from "@bb/host-daemon-contract";
-import type { JsonValue, StatusDataKey } from "@bb/domain";
-import { STATUS_DATA_DIRECTORY_NAME } from "@bb/host-watcher";
-import {
- readStatusDataDirectoryFingerprintFromRoot,
- readHostStatusDataFromRoot,
-} from "./command-handlers/status-data.js";
-import { CommandDispatchError } from "./command-dispatch-support.js";
-import { runtimeErrorLogFields } from "./error-utils.js";
-import type { HostDaemonLogger } from "./logger.js";
-import {
- readPreviousValue,
- statusDataCacheKey,
- StatusDataCache,
- type CachedStatusDataValue,
-} from "./status-data-reporter-cache.js";
-import {
- applyStatusDataSnapshotDiff,
- shouldSkipUnchangedStatusDataSnapshot,
-} from "./status-data-snapshot-diff.js";
-
-interface CreateStatusDataChangeReporterOptions {
- logger: HostDaemonLogger;
- postStatusDataChange: (
- payload: HostDaemonStatusDataChangePayload,
- ) => Promise;
-}
-
-interface StatusDataCacheKeyArgs {
- key: StatusDataKey;
- threadId: string;
-}
-
-interface ReportObservedDeleteArgs extends StatusDataCacheKeyArgs {
- generation: number;
- previous: CachedStatusDataValue | undefined;
- threadStoragePath: string;
-}
-
-interface ReportObservedChangeArgs {
- change: ObserveStatusDataChangeArgs;
- generation: number;
-}
-
-interface PrimeThreadArgs {
- generation: number;
- target: TrackedStatusDataThread;
-}
-
-interface ReportThreadSnapshotDiffArgs extends ReconcileThreadStatusDataArgs {
- generation: number;
-}
-
-interface ApplyThreadSnapshotArgs extends ReconcileThreadStatusDataArgs {
- generation: number;
- postChanges: boolean;
- skipUnchangedFingerprint: boolean;
-}
-
-interface RefreshThreadFingerprintArgs extends ReconcileThreadStatusDataArgs {
- generation: number;
-}
-
-interface StatusDataReporterGenerationArgs {
- generation: number;
-}
-
-export interface ObserveStatusDataChangeArgs {
- key: StatusDataKey;
- threadId: string;
- threadStoragePath: string;
-}
-
-export interface TrackedStatusDataThread {
- threadId: string;
- threadStoragePath: string;
-}
-
-export interface TrackStatusDataThreadArgs {
- threadId: string;
-}
-
-export interface ReplaceTrackedStatusDataThreadsArgs {
- targets: readonly TrackedStatusDataThread[];
-}
-
-export interface ReconcileThreadStatusDataArgs {
- threadId: string;
- threadStoragePath: string;
-}
-
-export interface RecordStatusDataCommandSetArgs {
- key: StatusDataKey;
- threadId: string;
- value: JsonValue;
- version: string;
-}
-
-export interface RecordStatusDataCommandDeleteArgs {
- key: StatusDataKey;
- threadId: string;
-}
-
-function isMissingStatusDataEntryError(error: Error): boolean {
- return error instanceof CommandDispatchError && error.code === "ENOENT";
-}
-
-function isNonReportableStatusDataReadError(error: Error): boolean {
- return (
- error instanceof CommandDispatchError &&
- (error.code === "invalid_json" ||
- error.code === "invalid_path" ||
- error.code === "file_too_large")
- );
-}
-
-export class StatusDataChangeReporter {
- // This cache tracks values known by the server, not merely values seen on disk.
- private readonly cache = new StatusDataCache();
- private readonly pendingByCacheKey = new Map>();
- private generation = 0;
-
- constructor(
- private readonly options: CreateStatusDataChangeReporterOptions,
- ) {}
-
- private isCurrentGeneration(args: StatusDataReporterGenerationArgs): boolean {
- return args.generation === this.generation;
- }
-
- async replaceTrackedThreads(
- args: ReplaceTrackedStatusDataThreadsArgs,
- ): Promise {
- this.generation += 1;
- const generation = this.generation;
- const nextThreadIds = new Set(
- args.targets.map((target) => target.threadId),
- );
- this.pendingByCacheKey.clear();
- const previouslyTrackedThreadIds = this.cache.replaceTrackedThreadIds({
- threadIds: nextThreadIds,
- });
- await Promise.all(
- args.targets.map((target) =>
- previouslyTrackedThreadIds.has(target.threadId)
- ? this.reconcileThread(target)
- : this.primeThread({ generation, target }),
- ),
- );
- }
-
- trackThread(args: TrackStatusDataThreadArgs): void {
- this.cache.trackThread({ threadId: args.threadId });
- }
-
- recordCommandSet(args: RecordStatusDataCommandSetArgs): void {
- if (!this.cache.isTracked({ threadId: args.threadId })) {
- this.options.logger.warn(
- {
- key: args.key,
- threadId: args.threadId,
- },
- "Ignoring STATUS-data command result for untracked thread",
- );
- return;
- }
- this.cache.setValue({
- key: args.key,
- threadId: args.threadId,
- value: args.value,
- version: args.version,
- });
- }
-
- recordCommandDelete(args: RecordStatusDataCommandDeleteArgs): void {
- if (!this.cache.isTracked({ threadId: args.threadId })) {
- this.options.logger.warn(
- {
- key: args.key,
- threadId: args.threadId,
- },
- "Ignoring STATUS-data command result for untracked thread",
- );
- return;
- }
- this.cache.deleteValue({ key: args.key, threadId: args.threadId });
- }
-
- async reconcileThread(args: ReconcileThreadStatusDataArgs): Promise {
- if (!this.cache.isTracked({ threadId: args.threadId })) {
- return;
- }
- const generation = this.generation;
- try {
- await this.reportThreadSnapshotDiff({
- ...args,
- generation,
- });
- } catch (error) {
- this.options.logger.warn(
- {
- threadId: args.threadId,
- threadStoragePath: args.threadStoragePath,
- ...runtimeErrorLogFields(error),
- },
- "Failed to reconcile STATUS-data changes",
- );
- }
- }
-
- observe(args: ObserveStatusDataChangeArgs): Promise {
- const cacheKey = statusDataCacheKey(args);
- const generation = this.generation;
- const previous = this.pendingByCacheKey.get(cacheKey) ?? Promise.resolve();
- const pending = previous
- .catch(() => undefined)
- .then(() =>
- this.reportObservedChange({
- change: args,
- generation,
- }),
- )
- .catch((error) => {
- this.options.logger.warn(
- {
- key: args.key,
- threadId: args.threadId,
- ...runtimeErrorLogFields(error),
- },
- "Failed to report observed STATUS-data change",
- );
- })
- .finally(() => {
- if (this.pendingByCacheKey.get(cacheKey) === pending) {
- this.pendingByCacheKey.delete(cacheKey);
- }
- });
- this.pendingByCacheKey.set(cacheKey, pending);
- return pending;
- }
-
- private async primeThread(args: PrimeThreadArgs): Promise {
- try {
- await this.applyThreadSnapshot({
- generation: args.generation,
- postChanges: false,
- skipUnchangedFingerprint: false,
- threadId: args.target.threadId,
- threadStoragePath: args.target.threadStoragePath,
- });
- } catch (error) {
- this.options.logger.warn(
- {
- threadId: args.target.threadId,
- threadStoragePath: args.target.threadStoragePath,
- ...runtimeErrorLogFields(error),
- },
- "Failed to prime STATUS-data change cache",
- );
- }
- }
-
- private async reportObservedChange(
- args: ReportObservedChangeArgs,
- ): Promise {
- if (args.generation !== this.generation) {
- return;
- }
- const previous = this.cache.getValue({
- key: args.change.key,
- threadId: args.change.threadId,
- });
-
- try {
- const entry = await readHostStatusDataFromRoot({
- rootPath: args.change.threadStoragePath,
- key: args.change.key,
- });
- if (args.generation !== this.generation) {
- return;
- }
- if (previous?.version === entry.version) {
- return;
- }
- await this.options.postStatusDataChange({
- threadId: args.change.threadId,
- key: args.change.key,
- deleted: false,
- value: entry.value,
- version: entry.version,
- ...readPreviousValue(previous),
- });
- if (args.generation !== this.generation) {
- return;
- }
- this.cache.setValue({
- key: args.change.key,
- threadId: args.change.threadId,
- value: entry.value,
- version: entry.version,
- });
- await this.refreshThreadFingerprint({
- generation: args.generation,
- threadId: args.change.threadId,
- threadStoragePath: args.change.threadStoragePath,
- });
- return;
- } catch (error) {
- if (error instanceof Error && isMissingStatusDataEntryError(error)) {
- await this.reportObservedDelete({
- generation: args.generation,
- key: args.change.key,
- previous,
- threadId: args.change.threadId,
- threadStoragePath: args.change.threadStoragePath,
- });
- return;
- }
- if (error instanceof Error && isNonReportableStatusDataReadError(error)) {
- this.options.logger.warn(
- {
- key: args.change.key,
- statusDataPath: path.join(
- args.change.threadStoragePath,
- STATUS_DATA_DIRECTORY_NAME,
- `${args.change.key}.json`,
- ),
- threadId: args.change.threadId,
- ...runtimeErrorLogFields(error),
- },
- "Ignoring unreadable observed STATUS-data file",
- );
- return;
- }
- throw error;
- }
- }
-
- private async reportObservedDelete(
- args: ReportObservedDeleteArgs,
- ): Promise {
- if (args.generation !== this.generation || args.previous === undefined) {
- return;
- }
- await this.options.postStatusDataChange({
- threadId: args.threadId,
- key: args.key,
- deleted: true,
- value: null,
- version: null,
- ...readPreviousValue(args.previous),
- });
- if (args.generation !== this.generation) {
- return;
- }
- this.cache.deleteValue({ key: args.key, threadId: args.threadId });
- await this.refreshThreadFingerprint({
- generation: args.generation,
- threadId: args.threadId,
- threadStoragePath: args.threadStoragePath,
- });
- }
-
- private async refreshThreadFingerprint(
- args: RefreshThreadFingerprintArgs,
- ): Promise {
- try {
- const fingerprint = await readStatusDataDirectoryFingerprintFromRoot({
- rootPath: args.threadStoragePath,
- });
- if (args.generation !== this.generation) {
- return;
- }
- this.cache.setFingerprint({
- fingerprint,
- threadId: args.threadId,
- });
- } catch (error) {
- this.options.logger.warn(
- {
- threadId: args.threadId,
- threadStoragePath: args.threadStoragePath,
- ...runtimeErrorLogFields(error),
- },
- "Failed to refresh STATUS-data directory fingerprint",
- );
- }
- }
-
- private async reportThreadSnapshotDiff(
- args: ReportThreadSnapshotDiffArgs,
- ): Promise {
- await this.applyThreadSnapshot({
- ...args,
- postChanges: true,
- skipUnchangedFingerprint: true,
- });
- }
-
- private async applyThreadSnapshot(
- args: ApplyThreadSnapshotArgs,
- ): Promise {
- if (!this.isCurrentGeneration({ generation: args.generation })) {
- return;
- }
- if (
- args.skipUnchangedFingerprint &&
- (await shouldSkipUnchangedStatusDataSnapshot({
- cache: this.cache,
- generation: args.generation,
- isCurrentGeneration: (generationArgs) =>
- this.isCurrentGeneration(generationArgs),
- threadId: args.threadId,
- threadStoragePath: args.threadStoragePath,
- }))
- ) {
- return;
- }
-
- await applyStatusDataSnapshotDiff({
- cache: this.cache,
- generation: args.generation,
- isCurrentGeneration: (generationArgs) =>
- this.isCurrentGeneration(generationArgs),
- postChanges: args.postChanges,
- postStatusDataChange: (payload) =>
- this.options.postStatusDataChange(payload),
- threadId: args.threadId,
- threadStoragePath: args.threadStoragePath,
- });
- if (!this.isCurrentGeneration({ generation: args.generation })) {
- return;
- }
- await this.refreshThreadFingerprint(args);
- }
-}
diff --git a/apps/host-daemon/src/status-data-reporter-cache.ts b/apps/host-daemon/src/status-data-reporter-cache.ts
deleted file mode 100644
index 7d2c1dfb0..000000000
--- a/apps/host-daemon/src/status-data-reporter-cache.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-import type { JsonValue, StatusDataKey } from "@bb/domain";
-import type { StatusDataDirectoryFingerprint } from "./command-handlers/status-data.js";
-
-export interface CachedStatusDataValue {
- value: JsonValue;
- version: string;
-}
-
-export interface StatusDataCacheKeyArgs {
- key: StatusDataKey;
- threadId: string;
-}
-
-export interface CachedStatusDataKey {
- cacheKey: string;
- key: StatusDataKey;
-}
-
-export interface SetCachedStatusDataValueArgs
- extends StatusDataCacheKeyArgs,
- CachedStatusDataValue {}
-
-export interface ThreadIdArgs {
- threadId: string;
-}
-
-export interface ReplaceTrackedThreadIdsArgs {
- threadIds: ReadonlySet;
-}
-
-export interface SetThreadFingerprintArgs extends ThreadIdArgs {
- fingerprint: StatusDataDirectoryFingerprint | null;
-}
-
-export interface StatusDataPreviousMetadata {
- previousValue: JsonValue | null;
- previousValuePresent: boolean;
- previousVersion: string | null;
-}
-
-export function statusDataCacheKey(args: StatusDataCacheKeyArgs): string {
- return `${args.threadId}\0${args.key}`;
-}
-
-export function readPreviousValue(
- cached: CachedStatusDataValue | undefined,
-): StatusDataPreviousMetadata {
- return {
- previousValue: cached?.value ?? null,
- previousValuePresent: cached !== undefined,
- previousVersion: cached?.version ?? null,
- };
-}
-
-export class StatusDataCache {
- private readonly values = new Map();
- private readonly fingerprintsByThreadId = new Map<
- string,
- StatusDataDirectoryFingerprint | null
- >();
- private readonly keysByThreadId = new Map>();
- // Threads remain tracked even when they have no STATUS-data keys yet.
- private readonly trackedThreadIds = new Set();
-
- replaceTrackedThreadIds(args: ReplaceTrackedThreadIdsArgs): Set {
- const previouslyTrackedThreadIds = new Set(this.trackedThreadIds);
- this.pruneUntrackedThreadState(args);
- this.trackedThreadIds.clear();
- for (const threadId of args.threadIds) {
- this.trackedThreadIds.add(threadId);
- }
- return previouslyTrackedThreadIds;
- }
-
- trackThread(args: ThreadIdArgs): void {
- this.trackedThreadIds.add(args.threadId);
- }
-
- isTracked(args: ThreadIdArgs): boolean {
- return this.trackedThreadIds.has(args.threadId);
- }
-
- getValue(args: StatusDataCacheKeyArgs): CachedStatusDataValue | undefined {
- return this.values.get(statusDataCacheKey(args));
- }
-
- setValue(args: SetCachedStatusDataValueArgs): void {
- this.values.set(statusDataCacheKey(args), {
- value: args.value,
- version: args.version,
- });
- let keys = this.keysByThreadId.get(args.threadId);
- if (!keys) {
- keys = new Set();
- this.keysByThreadId.set(args.threadId, keys);
- }
- keys.add(args.key);
- }
-
- deleteValue(args: StatusDataCacheKeyArgs): void {
- this.values.delete(statusDataCacheKey(args));
- const keys = this.keysByThreadId.get(args.threadId);
- if (!keys) {
- return;
- }
- keys.delete(args.key);
- if (keys.size === 0) {
- this.keysByThreadId.delete(args.threadId);
- }
- }
-
- cachedKeysForThread(args: ThreadIdArgs): CachedStatusDataKey[] {
- return Array.from(this.keysByThreadId.get(args.threadId) ?? [])
- .sort()
- .map((key) => ({
- key,
- cacheKey: statusDataCacheKey({ key, threadId: args.threadId }),
- }));
- }
-
- hasFingerprint(args: ThreadIdArgs): boolean {
- return this.fingerprintsByThreadId.has(args.threadId);
- }
-
- getFingerprint(
- args: ThreadIdArgs,
- ): StatusDataDirectoryFingerprint | null | undefined {
- return this.fingerprintsByThreadId.get(args.threadId);
- }
-
- setFingerprint(args: SetThreadFingerprintArgs): void {
- this.fingerprintsByThreadId.set(args.threadId, args.fingerprint);
- }
-
- private pruneUntrackedThreadState(args: ReplaceTrackedThreadIdsArgs): void {
- for (const threadId of Array.from(this.trackedThreadIds)) {
- if (args.threadIds.has(threadId)) {
- continue;
- }
- for (const cached of this.cachedKeysForThread({ threadId })) {
- this.values.delete(cached.cacheKey);
- }
- this.fingerprintsByThreadId.delete(threadId);
- this.keysByThreadId.delete(threadId);
- this.trackedThreadIds.delete(threadId);
- }
- }
-}
diff --git a/apps/host-daemon/src/status-data-snapshot-diff.ts b/apps/host-daemon/src/status-data-snapshot-diff.ts
deleted file mode 100644
index 52ad1f7fd..000000000
--- a/apps/host-daemon/src/status-data-snapshot-diff.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-import type { HostDaemonStatusDataChangePayload } from "@bb/host-daemon-contract";
-import { statusDataKeySchema, type StatusDataKey } from "@bb/domain";
-import {
- listHostStatusDataFromRoot,
- readStatusDataDirectoryFingerprintFromRoot,
-} from "./command-handlers/status-data.js";
-import {
- readPreviousValue,
- type StatusDataCache,
-} from "./status-data-reporter-cache.js";
-
-export interface StatusDataSnapshotGenerationArgs {
- generation: number;
-}
-
-export type StatusDataSnapshotGenerationGuard = (
- args: StatusDataSnapshotGenerationArgs,
-) => boolean;
-
-export type PostStatusDataChange = (
- payload: HostDaemonStatusDataChangePayload,
-) => Promise;
-
-export interface ShouldSkipUnchangedStatusDataSnapshotArgs {
- cache: StatusDataCache;
- generation: number;
- isCurrentGeneration: StatusDataSnapshotGenerationGuard;
- threadId: string;
- threadStoragePath: string;
-}
-
-export interface ApplyStatusDataSnapshotDiffArgs {
- cache: StatusDataCache;
- generation: number;
- isCurrentGeneration: StatusDataSnapshotGenerationGuard;
- postChanges: boolean;
- postStatusDataChange: PostStatusDataChange;
- threadId: string;
- threadStoragePath: string;
-}
-
-export async function shouldSkipUnchangedStatusDataSnapshot(
- args: ShouldSkipUnchangedStatusDataSnapshotArgs,
-): Promise {
- const currentFingerprint = await readStatusDataDirectoryFingerprintFromRoot({
- rootPath: args.threadStoragePath,
- });
- if (!args.isCurrentGeneration({ generation: args.generation })) {
- return true;
- }
- return (
- args.cache.hasFingerprint({ threadId: args.threadId }) &&
- args.cache.getFingerprint({ threadId: args.threadId }) ===
- currentFingerprint
- );
-}
-
-export async function applyStatusDataSnapshotDiff(
- args: ApplyStatusDataSnapshotDiffArgs,
-): Promise {
- const result = await listHostStatusDataFromRoot({
- rootPath: args.threadStoragePath,
- });
- if (!args.isCurrentGeneration({ generation: args.generation })) {
- return;
- }
- const seenKeys = new Set();
-
- for (const rawKey of Object.keys(result.versions).sort()) {
- if (!args.isCurrentGeneration({ generation: args.generation })) {
- return;
- }
- const key = statusDataKeySchema.parse(rawKey);
- const value = result.values[key];
- const version = result.versions[key];
- if (value === undefined || version === undefined) {
- continue;
- }
- seenKeys.add(key);
- const previous = args.cache.getValue({ key, threadId: args.threadId });
- if (previous?.version === version) {
- continue;
- }
- if (args.postChanges) {
- await args.postStatusDataChange({
- threadId: args.threadId,
- key,
- deleted: false,
- value,
- version,
- ...readPreviousValue(previous),
- });
- if (!args.isCurrentGeneration({ generation: args.generation })) {
- return;
- }
- }
- args.cache.setValue({
- key,
- threadId: args.threadId,
- value,
- version,
- });
- }
-
- for (const cached of args.cache.cachedKeysForThread({
- threadId: args.threadId,
- })) {
- if (!args.isCurrentGeneration({ generation: args.generation })) {
- return;
- }
- if (seenKeys.has(cached.key)) {
- continue;
- }
- const previous = args.cache.getValue({
- key: cached.key,
- threadId: args.threadId,
- });
- if (previous === undefined) {
- continue;
- }
- if (args.postChanges) {
- await args.postStatusDataChange({
- threadId: args.threadId,
- key: cached.key,
- deleted: true,
- value: null,
- version: null,
- ...readPreviousValue(previous),
- });
- if (!args.isCurrentGeneration({ generation: args.generation })) {
- return;
- }
- }
- args.cache.deleteValue({ key: cached.key, threadId: args.threadId });
- }
-}
diff --git a/apps/host-daemon/test/command/command-router.test.ts b/apps/host-daemon/test/command/command-router.test.ts
index 2e529c469..8e9944784 100644
--- a/apps/host-daemon/test/command/command-router.test.ts
+++ b/apps/host-daemon/test/command/command-router.test.ts
@@ -333,7 +333,7 @@ describe("CommandRouter", () => {
it("reports missing host file reads without warning", async () => {
const rootPath = await makeTempDir("bb-command-router-read-file-");
- const missingPath = path.join(rootPath, "STATUS.md");
+ const missingPath = path.join(rootPath, "notes.md");
const reportResult = vi.fn(async () => undefined);
const logger = createLogger();
const router = new CommandRouter({
@@ -350,7 +350,7 @@ describe("CommandRouter", () => {
await router.handleCommands([
{
- id: "read-missing-status",
+ id: "read-missing-file",
cursor: 1,
command: {
type: "host.read_file",
@@ -362,7 +362,7 @@ describe("CommandRouter", () => {
expect(reportResult).toHaveBeenCalledWith(
expect.objectContaining({
- commandId: "read-missing-status",
+ commandId: "read-missing-file",
errorCode: "ENOENT",
errorMessage: `Path does not exist: ${missingPath}`,
ok: false,
@@ -375,7 +375,7 @@ describe("CommandRouter", () => {
it("reports missing host file roots without warning", async () => {
const parentPath = await makeTempDir("bb-command-router-read-file-root-");
const rootPath = path.join(parentPath, "missing-root");
- const missingPath = path.join(rootPath, "STATUS.md");
+ const missingPath = path.join(rootPath, "notes.md");
const reportResult = vi.fn(async () => undefined);
const logger = createLogger();
const router = new CommandRouter({
@@ -392,7 +392,7 @@ describe("CommandRouter", () => {
await router.handleCommands([
{
- id: "read-missing-root-status",
+ id: "read-missing-root-file",
cursor: 1,
command: {
type: "host.read_file",
@@ -404,7 +404,7 @@ describe("CommandRouter", () => {
expect(reportResult).toHaveBeenCalledWith(
expect.objectContaining({
- commandId: "read-missing-root-status",
+ commandId: "read-missing-root-file",
errorCode: "ENOENT",
errorMessage: `Path does not exist: ${missingPath}`,
ok: false,
@@ -416,7 +416,7 @@ describe("CommandRouter", () => {
it("reports missing host relative file reads without warning", async () => {
const parentPath = await makeTempDir("bb-command-router-relative-file-");
- const rootPath = path.join(parentPath, "STATUS");
+ const rootPath = path.join(parentPath, "assets");
const reportResult = vi.fn(async () => undefined);
const logger = createLogger();
const router = new CommandRouter({
@@ -703,8 +703,8 @@ describe("CommandRouter", () => {
});
it("serializes relative host file writes with last-write-wins behavior", async () => {
- const rootPath = await makeTempDir("bb-command-router-status-data-");
- const initialContent = "[\"seed\"]\n";
+ const rootPath = await makeTempDir("bb-command-router-app-data-");
+ const initialContent = '["seed"]\n';
await fs.writeFile(path.join(rootPath, "todos.json"), initialContent);
const reports: HostDaemonCommandResultReportWithoutSession[] = [];
const router = new CommandRouter({
@@ -730,7 +730,7 @@ describe("CommandRouter", () => {
rootPath,
path: "todos.json",
dotfiles: "deny",
- content: "[\"a\"]\n",
+ content: '["a"]\n',
contentEncoding: "utf8",
},
},
@@ -742,7 +742,7 @@ describe("CommandRouter", () => {
rootPath,
path: "todos.json",
dotfiles: "deny",
- content: "[\"b\"]\n",
+ content: '["b"]\n',
contentEncoding: "utf8",
},
},
@@ -753,7 +753,7 @@ describe("CommandRouter", () => {
const finalContent = await fs.readFile(path.join(rootPath, "todos.json"), {
encoding: "utf8",
});
- expect(finalContent).toBe("[\"b\"]\n");
+ expect(finalContent).toBe('["b"]\n');
});
it("flushes buffered provider events before reporting thread command results", async () => {
diff --git a/apps/server/src/internal/app-data-changes.ts b/apps/server/src/internal/app-data-changes.ts
new file mode 100644
index 000000000..d94e75686
--- /dev/null
+++ b/apps/server/src/internal/app-data-changes.ts
@@ -0,0 +1,110 @@
+import {
+ hostDaemonAppDataChangeRequestSchema,
+ hostDaemonAppDataResyncRequestSchema,
+ typedRoutes,
+ type HostDaemonInternalSchema,
+} from "@bb/host-daemon-contract";
+import type { Hono } from "hono";
+import type { AppDeps } from "../types.js";
+import { ApiError } from "../errors.js";
+import {
+ requireEnvironment,
+ requirePublicThread,
+} from "../services/lib/entity-lookup.js";
+import { runWithDaemonCommandWaitForbidden } from "../services/hosts/command-wait-context.js";
+import {
+ requireAuthenticatedDaemonSession,
+ type RequireAuthenticatedDaemonSessionArgs,
+} from "./session-state.js";
+
+interface RequireSessionOwnedThreadArgs {
+ context: RequireAuthenticatedDaemonSessionArgs["context"];
+ deps: AppDeps;
+ sessionId: string;
+ threadId: string;
+}
+
+function requireSessionOwnedThread(args: RequireSessionOwnedThreadArgs): void {
+ const session = requireAuthenticatedDaemonSession({
+ context: args.context,
+ db: args.deps.db,
+ sessionId: args.sessionId,
+ });
+ const thread = requirePublicThread(args.deps.db, args.threadId);
+ if (!thread.environmentId) {
+ throw new ApiError(
+ 403,
+ "invalid_request",
+ "Thread does not belong to an environment",
+ );
+ }
+ const environment = requireEnvironment(args.deps.db, thread.environmentId);
+ if (environment.hostId !== session.hostId) {
+ throw new ApiError(
+ 403,
+ "invalid_request",
+ "Thread does not belong to the session host",
+ );
+ }
+}
+
+export function registerInternalAppDataChangeRoutes(
+ app: Hono,
+ deps: AppDeps,
+): void {
+ const { post } = typedRoutes(app, {
+ onValidationError: (msg) => new ApiError(400, "invalid_request", msg),
+ });
+
+ post(
+ "/session/app-data-change",
+ hostDaemonAppDataChangeRequestSchema,
+ (context, payload) =>
+ runWithDaemonCommandWaitForbidden({
+ reason: "/session/app-data-change",
+ work: async () => {
+ requireSessionOwnedThread({
+ context,
+ deps,
+ sessionId: payload.sessionId,
+ threadId: payload.threadId,
+ });
+
+ deps.hub.notifyThreadAppData({
+ type: "app-data.changed",
+ threadId: payload.threadId,
+ appId: payload.appId,
+ path: payload.path,
+ value: payload.value,
+ deleted: payload.deleted,
+ version: payload.version,
+ });
+ return context.json({ ok: true });
+ },
+ }),
+ );
+
+ post(
+ "/session/app-data-resync",
+ hostDaemonAppDataResyncRequestSchema,
+ (context, payload) =>
+ runWithDaemonCommandWaitForbidden({
+ reason: "/session/app-data-resync",
+ work: async () => {
+ requireSessionOwnedThread({
+ context,
+ deps,
+ sessionId: payload.sessionId,
+ threadId: payload.threadId,
+ });
+
+ deps.hub.notifyThreadAppData({
+ type: "app-data.resync",
+ threadId: payload.threadId,
+ appId: payload.appId,
+ });
+ return context.json({ ok: true });
+ },
+ }),
+ );
+}
diff --git a/apps/server/src/internal/command-result-owners.ts b/apps/server/src/internal/command-result-owners.ts
index 961370a29..03929c1e4 100644
--- a/apps/server/src/internal/command-result-owners.ts
+++ b/apps/server/src/internal/command-result-owners.ts
@@ -747,11 +747,7 @@ const commandResultOwners: CommandResultOwnerRegistry = {
"host.read_file_relative": null,
"host.write_file_relative": null,
"host.delete_file_relative": null,
- "host.status_version": null,
- "host.status_data.list": null,
- "host.status_data.get": null,
- "host.status_data.set": null,
- "host.status_data.delete": null,
+ "host.delete_path_relative": null,
"codex.inference.complete": null,
"interactive.resolve": defineCommandResultOwner({
applySideEffects: handleInteractiveResolveResult,
diff --git a/apps/server/src/internal/status-data-changes.ts b/apps/server/src/internal/status-data-changes.ts
deleted file mode 100644
index 188f8e415..000000000
--- a/apps/server/src/internal/status-data-changes.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import {
- hostDaemonStatusDataChangeRequestSchema,
- typedRoutes,
- type HostDaemonInternalSchema,
-} from "@bb/host-daemon-contract";
-import type { Hono } from "hono";
-import type { AppDeps } from "../types.js";
-import { ApiError } from "../errors.js";
-import {
- requireEnvironment,
- requirePublicThread,
-} from "../services/lib/entity-lookup.js";
-import { runWithDaemonCommandWaitForbidden } from "../services/hosts/command-wait-context.js";
-import { requireAuthenticatedDaemonSession } from "./session-state.js";
-
-export function registerInternalStatusDataChangeRoutes(
- app: Hono,
- deps: AppDeps,
-): void {
- const { post } = typedRoutes(app, {
- onValidationError: (msg) => new ApiError(400, "invalid_request", msg),
- });
-
- post(
- "/session/status-data-change",
- hostDaemonStatusDataChangeRequestSchema,
- (context, payload) =>
- runWithDaemonCommandWaitForbidden({
- reason: "/session/status-data-change",
- work: async () => {
- const session = requireAuthenticatedDaemonSession({
- context,
- db: deps.db,
- sessionId: payload.sessionId,
- });
- const thread = requirePublicThread(deps.db, payload.threadId);
- if (!thread.environmentId) {
- throw new ApiError(
- 403,
- "invalid_request",
- "Thread does not belong to an environment",
- );
- }
- const environment = requireEnvironment(deps.db, thread.environmentId);
- if (environment.hostId !== session.hostId) {
- throw new ApiError(
- 403,
- "invalid_request",
- "Thread does not belong to the session host",
- );
- }
-
- deps.hub.notifyThreadStatusData({
- type: "status-data.changed",
- threadId: payload.threadId,
- key: payload.key,
- value: payload.value,
- deleted: payload.deleted,
- previousValue: payload.previousValue,
- previousValuePresent: payload.previousValuePresent,
- version: payload.version,
- writerClientId: null,
- operationId: null,
- });
- return context.json({ ok: true });
- },
- }),
- );
-}
diff --git a/apps/server/src/routes/relative-route-path.ts b/apps/server/src/routes/relative-route-path.ts
new file mode 100644
index 000000000..e138de188
--- /dev/null
+++ b/apps/server/src/routes/relative-route-path.ts
@@ -0,0 +1,92 @@
+import path from "node:path";
+import { ApiError } from "../errors.js";
+
+export type DotfileSegmentPolicy = "allow" | "not-found";
+
+export interface SafeRelativeRoutePath {
+ relativePath: string;
+}
+
+export interface ParseSafeRelativeRoutePathArgs {
+ dotfileSegmentPolicy: DotfileSegmentPolicy;
+ invalidPathMessage: string;
+ rawPath: string;
+ directoryIndexPath?: string;
+}
+
+export interface DecodeRoutePathArgs {
+ invalidPathMessage: string;
+ rawPath: string;
+}
+
+export interface ExtractRoutePathArgs {
+ requestUrl: string;
+ routeSegment: string;
+}
+
+function createInvalidPathError(message: string): ApiError {
+ return new ApiError(400, "invalid_path", message);
+}
+
+function createNotFoundError(relativePath: string): ApiError {
+ return new ApiError(404, "ENOENT", `Path does not exist: ${relativePath}`);
+}
+
+export function decodeRoutePath(args: DecodeRoutePathArgs): string {
+ try {
+ return decodeURIComponent(args.rawPath);
+ } catch {
+ throw createInvalidPathError(args.invalidPathMessage);
+ }
+}
+
+export function parseSafeRelativeRoutePath(
+ args: ParseSafeRelativeRoutePathArgs,
+): SafeRelativeRoutePath {
+ const decodedPath = decodeRoutePath({
+ rawPath: args.rawPath,
+ invalidPathMessage: args.invalidPathMessage,
+ });
+ const relativePath =
+ args.directoryIndexPath !== undefined && decodedPath.endsWith("/")
+ ? `${decodedPath}${args.directoryIndexPath}`
+ : decodedPath;
+
+ if (
+ relativePath.length === 0 ||
+ relativePath.includes("\0") ||
+ relativePath.includes("\\") ||
+ path.posix.isAbsolute(relativePath)
+ ) {
+ throw createInvalidPathError(args.invalidPathMessage);
+ }
+
+ const segments = relativePath.split("/");
+ if (
+ segments.some(
+ (segment) => segment.length === 0 || segment === "." || segment === "..",
+ )
+ ) {
+ throw createInvalidPathError(args.invalidPathMessage);
+ }
+
+ if (
+ args.dotfileSegmentPolicy === "not-found" &&
+ segments.some((segment) => segment.startsWith("."))
+ ) {
+ throw createNotFoundError(relativePath);
+ }
+
+ return {
+ relativePath: segments.join("/"),
+ };
+}
+
+export function extractRoutePath(args: ExtractRoutePathArgs): string {
+ const requestPath = new URL(args.requestUrl).pathname;
+ const routeSegmentIndex = requestPath.indexOf(args.routeSegment);
+ if (routeSegmentIndex === -1) {
+ return "";
+ }
+ return requestPath.slice(routeSegmentIndex + args.routeSegment.length);
+}
diff --git a/apps/server/src/routes/threads/apps.ts b/apps/server/src/routes/threads/apps.ts
new file mode 100644
index 000000000..8e84cba57
--- /dev/null
+++ b/apps/server/src/routes/threads/apps.ts
@@ -0,0 +1,1298 @@
+import { Buffer } from "node:buffer";
+import { createHash } from "node:crypto";
+import path from "node:path";
+import mimeTypes from "mime-types";
+import type { Hono } from "hono";
+import type { ZodIssue } from "zod";
+import {
+ FILE_LIST_LIMIT_MAX,
+ type HostDaemonCommand,
+ type HostDaemonCommandResult,
+} from "@bb/host-daemon-contract";
+import {
+ appDataPathSchema,
+ appIdSchema,
+ jsonValueSchema,
+} from "@bb/domain";
+import type { AppDataPath, AppId, JsonValue } from "@bb/domain";
+import {
+ appDataListQuerySchema,
+ appDataWriteRequestSchema,
+ appManifestSchema,
+ appMessageRequestSchema,
+ createThreadAppRequestSchema,
+ typedRoutes,
+ type AppCapability,
+ type AppDataEntry,
+ type AppDetail,
+ type AppEntry,
+ type AppIcon,
+ type AppManifest,
+ type AppSummary,
+ type AppTemplate,
+ type CreateThreadAppRequest,
+ type PublicApiSchema,
+} from "@bb/server-contract";
+import type { AppDeps, LoggedWorkSessionDeps } from "../../types.js";
+import { COMMAND_TIMEOUT_MS } from "../../constants.js";
+import { ApiError } from "../../errors.js";
+import { queueCommandAndWait } from "../../services/hosts/command-wait.js";
+import {
+ createDaemonFileContentResponse,
+ decodeDaemonFileContent,
+ type DaemonFileReadResult,
+ remapDaemonFileRouteError,
+} from "../../services/hosts/daemon-file-response.js";
+import { requirePublicThread } from "../../services/lib/entity-lookup.js";
+import { requireThreadCommandEnvironment } from "../../services/threads/thread-command-environment.js";
+import { sendThreadMessage } from "../../services/threads/thread-send.js";
+import { injectAppClientScript } from "../../services/threads/app-client-script.js";
+import { buildBlankAppIndexHtml } from "../../services/threads/blank-app-scaffold.js";
+import {
+ extractRoutePath,
+ parseSafeRelativeRoutePath,
+ type SafeRelativeRoutePath,
+} from "../relative-route-path.js";
+import { requireThreadStorageTarget } from "./data.js";
+
+interface ThreadAppsTarget {
+ hostId: string;
+ storagePath: string;
+}
+
+interface AppManifestReadArgs {
+ appId: AppId;
+ target: ThreadAppsTarget;
+}
+
+interface InvalidAppManifestErrorArgs extends AppManifestReadArgs {
+ issues: AppManifestValidationIssues;
+}
+
+interface LogInvalidAppManifestArgs {
+ error: InvalidAppManifestError;
+ message: string;
+}
+
+interface AppSummaryArgs extends AppManifestReadArgs {
+ manifest: AppManifest;
+ requestThreadId: string;
+}
+
+interface AppDetailArgs extends AppManifestReadArgs {
+ requestThreadId: string;
+}
+
+interface AppRootArgs {
+ appId: AppId;
+ target: ThreadAppsTarget;
+}
+
+interface AppDataRootArgs extends AppRootArgs {}
+
+interface ReadAppRelativeFileArgs {
+ appId: AppId;
+ dotfiles: "allow" | "deny";
+ path: string;
+ rootKind: "app" | "assets" | "data";
+ target: ThreadAppsTarget;
+}
+
+interface ReadAppFileMetadataArgs {
+ appId: AppId;
+ path: string;
+ rootKind: "app" | "assets" | "data";
+ target: ThreadAppsTarget;
+}
+
+interface AppRootForKindArgs {
+ appId: AppId;
+ rootKind: "app" | "assets" | "data";
+ target: ThreadAppsTarget;
+}
+
+interface ReadAppDataEntryArgs {
+ appId: AppId;
+ dataPath: AppDataPath;
+ target: ThreadAppsTarget;
+}
+
+interface ListAppDataEntriesArgs {
+ appId: AppId;
+ dataPath: AppDataPath | "";
+ target: ThreadAppsTarget;
+}
+
+interface WriteAppDataEntryArgs extends ReadAppDataEntryArgs {
+ value: JsonValue;
+}
+
+interface DeleteAppDataEntryArgs extends ReadAppDataEntryArgs {}
+
+interface CreateInjectedAppHtmlResponseArgs {
+ appId: AppId;
+ capabilities: AppCapability[];
+ html: string;
+ requestUrl: string;
+ threadId: string;
+}
+
+interface AppAssetRouteSegmentArgs {
+ appId: string;
+ threadId: string;
+}
+
+type AppAssetPath = SafeRelativeRoutePath;
+
+interface LogoResolution {
+ extension: string;
+}
+
+interface TemplateFile {
+ content: string;
+ path: string;
+}
+
+interface ScaffoldAppArgs {
+ request: CreateThreadAppRequest;
+ target: ThreadAppsTarget;
+ threadId: string;
+}
+
+type HostCommandType =
+ | "host.file_metadata"
+ | "host.list_paths"
+ | "host.read_file_relative"
+ | "host.write_file_relative"
+ | "host.delete_file_relative"
+ | "host.delete_path_relative";
+
+type AppManifestValidationIssues = readonly ZodIssue[];
+type AppManifestValidationLoggerDeps = Pick;
+
+interface QueueHostCommandArgs {
+ command: Extract;
+ hostId: string;
+}
+
+const APPS_DIRECTORY_NAME = "apps";
+const ASSETS_DIRECTORY_NAME = "assets";
+const DATA_DIRECTORY_NAME = "data";
+const MANIFEST_FILE_NAME = "manifest.json";
+const HTML_CONTENT_TYPE = "text/html; charset=utf-8";
+const NO_STORE_CACHE_CONTROL = "no-store";
+const CONTENT_TYPE_OPTIONS = "nosniff";
+const HTML_ENTRY_MAX_BYTES = 5 * 1024 * 1024;
+const LOGO_MAX_BYTES = 1024 * 1024;
+const LOGO_EXTENSIONS = ["svg", "png", "jpg", "jpeg"] as const;
+const APP_ROUTE_DATA_SEGMENT = "/data/";
+const INVALID_APP_MANIFEST_MESSAGE =
+ "App manifest failed validation. Inspect manifest.json or rebuild the app.";
+
+class InvalidAppManifestError extends ApiError {
+ readonly appId: AppId;
+ readonly manifestPath: string;
+ readonly issues: AppManifestValidationIssues;
+
+ constructor(args: InvalidAppManifestErrorArgs) {
+ super(422, "invalid_manifest", INVALID_APP_MANIFEST_MESSAGE);
+ this.name = "InvalidAppManifestError";
+ this.appId = args.appId;
+ this.manifestPath = path.join(appRootPath(args), MANIFEST_FILE_NAME);
+ this.issues = args.issues;
+ }
+}
+
+function sha256(bytes: Buffer): string {
+ return createHash("sha256").update(bytes).digest("hex");
+}
+
+function canonicalizeJson(value: JsonValue): string {
+ return `${JSON.stringify(value, null, 2)}\n`;
+}
+
+function parseAppId(rawAppId: string): AppId {
+ const parsed = appIdSchema.safeParse(rawAppId);
+ if (!parsed.success) {
+ throw new ApiError(400, "invalid_request", "Invalid app id");
+ }
+ return parsed.data;
+}
+
+function parseAppDataPath(rawPath: string): AppDataPath {
+ const parsed = appDataPathSchema.safeParse(rawPath);
+ if (!parsed.success) {
+ throw new ApiError(400, "invalid_request", "Invalid app data path");
+ }
+ return parsed.data;
+}
+
+function parseAppDataRoutePath(rawPath: string): AppDataPath {
+ return parseAppDataPath(
+ parseSafeRelativeRoutePath({
+ rawPath,
+ dotfileSegmentPolicy: "allow",
+ invalidPathMessage: "Invalid app data path",
+ }).relativePath,
+ );
+}
+
+function appAssetRouteSegment(args: AppAssetRouteSegmentArgs): string {
+ return `/threads/${encodeURIComponent(args.threadId)}/apps/${encodeURIComponent(args.appId)}/`;
+}
+
+function parseOptionalAppDataPrefix(
+ rawPrefix: string | undefined,
+): AppDataPath | "" {
+ if (rawPrefix === undefined || rawPrefix === "") {
+ return "";
+ }
+ return parseAppDataPath(rawPrefix);
+}
+
+function appRootPath(args: AppRootArgs): string {
+ return path.join(args.target.storagePath, APPS_DIRECTORY_NAME, args.appId);
+}
+
+function appAssetsRootPath(args: AppRootArgs): string {
+ return path.join(appRootPath(args), ASSETS_DIRECTORY_NAME);
+}
+
+function appDataRootPath(args: AppDataRootArgs): string {
+ return path.join(appRootPath(args), DATA_DIRECTORY_NAME);
+}
+
+function appRootForKind(args: AppRootForKindArgs): string {
+ if (args.rootKind === "app") {
+ return appRootPath(args);
+ }
+ if (args.rootKind === "assets") {
+ return appAssetsRootPath(args);
+ }
+ return appDataRootPath(args);
+}
+
+function parseAppAssetPath(rawPath: string): AppAssetPath {
+ return parseSafeRelativeRoutePath({
+ rawPath,
+ directoryIndexPath: "index.html",
+ dotfileSegmentPolicy: "not-found",
+ invalidPathMessage: "Invalid app asset path",
+ });
+}
+
+function decodeDaemonTextFile(result: DaemonFileReadResult): string {
+ return Buffer.from(decodeDaemonFileContent(result)).toString("utf8");
+}
+
+function summarizeAppManifestValidationIssues(
+ issues: AppManifestValidationIssues,
+): string {
+ return issues
+ .map((issue) => {
+ const issuePath = issue.path.map(String).join(".");
+ return `${issuePath || ""}: ${issue.message}`;
+ })
+ .join("; ");
+}
+
+function logInvalidAppManifest(
+ deps: AppManifestValidationLoggerDeps,
+ args: LogInvalidAppManifestArgs,
+): void {
+ deps.logger.warn(
+ {
+ appId: args.error.appId,
+ manifestPath: args.error.manifestPath,
+ issueSummary: summarizeAppManifestValidationIssues(args.error.issues),
+ issues: args.error.issues,
+ },
+ args.message,
+ );
+}
+
+function remapAppCommandError(error: unknown): never {
+ if (!(error instanceof ApiError)) {
+ throw error;
+ }
+ if (error.body.code === "ENOENT") {
+ throw new ApiError(
+ 404,
+ error.body.code,
+ error.body.message,
+ error.body.retryable,
+ );
+ }
+ if (error.body.code === "invalid_path") {
+ throw new ApiError(
+ 400,
+ error.body.code,
+ error.body.message,
+ error.body.retryable,
+ );
+ }
+ if (error.body.code === "invalid_json") {
+ throw new ApiError(
+ 422,
+ error.body.code,
+ error.body.message,
+ error.body.retryable,
+ );
+ }
+ throw error;
+}
+
+async function queueHostCommand(
+ deps: AppDeps,
+ args: QueueHostCommandArgs,
+): Promise> {
+ try {
+ return await queueCommandAndWait(deps, {
+ hostId: args.hostId,
+ timeoutMs: COMMAND_TIMEOUT_MS,
+ command: args.command,
+ });
+ } catch (error) {
+ remapAppCommandError(error);
+ }
+}
+
+async function requireThreadAppsTarget(
+ deps: LoggedWorkSessionDeps,
+ threadId: string,
+): Promise {
+ const target = await requireThreadStorageTarget(deps, { threadId });
+ return {
+ hostId: target.hostId,
+ storagePath: target.storagePath,
+ };
+}
+
+async function readAppRelativeFile(
+ deps: AppDeps,
+ args: ReadAppRelativeFileArgs,
+): Promise {
+ return queueHostCommand(deps, {
+ hostId: args.target.hostId,
+ command: {
+ type: "host.read_file_relative",
+ rootPath: appRootForKind(args),
+ path: args.path,
+ dotfiles: args.dotfiles,
+ },
+ });
+}
+
+async function readAppFileMetadata(
+ deps: AppDeps,
+ args: ReadAppFileMetadataArgs,
+): Promise> {
+ return queueHostCommand(deps, {
+ hostId: args.target.hostId,
+ command: {
+ type: "host.file_metadata",
+ path: path.join(appRootForKind(args), args.path),
+ rootPath: appRootForKind(args),
+ },
+ });
+}
+
+async function readAppManifest(
+ deps: AppDeps,
+ args: AppManifestReadArgs,
+): Promise {
+ const result = await readAppRelativeFile(deps, {
+ appId: args.appId,
+ target: args.target,
+ rootKind: "app",
+ path: MANIFEST_FILE_NAME,
+ dotfiles: "deny",
+ });
+ let parsedJson: unknown;
+ try {
+ parsedJson = JSON.parse(decodeDaemonTextFile(result));
+ } catch {
+ throw new ApiError(422, "invalid_json", "App manifest is not valid JSON");
+ }
+ const manifest = appManifestSchema.safeParse(parsedJson);
+ if (!manifest.success) {
+ throw new InvalidAppManifestError({
+ appId: args.appId,
+ target: args.target,
+ issues: manifest.error.issues,
+ });
+ }
+ if (manifest.data.id !== args.appId) {
+ throw new ApiError(
+ 422,
+ "invalid_request",
+ "App manifest id must match its directory name",
+ );
+ }
+ return manifest.data;
+}
+
+async function readAppManifestForRequest(
+ deps: AppDeps,
+ args: AppManifestReadArgs,
+): Promise {
+ try {
+ return await readAppManifest(deps, args);
+ } catch (error) {
+ if (error instanceof InvalidAppManifestError) {
+ logInvalidAppManifest(deps, {
+ error,
+ message: "Rejected invalid thread app manifest",
+ });
+ }
+ throw error;
+ }
+}
+
+function entryKindForPath(entryPath: string): AppEntry["kind"] {
+ const normalized = entryPath.toLowerCase();
+ if (normalized.endsWith(".html")) {
+ return "html";
+ }
+ if (normalized.endsWith(".md")) {
+ return "md";
+ }
+ throw new ApiError(
+ 422,
+ "invalid_request",
+ "App entry must end in .html or .md",
+ );
+}
+
+async function resolveAppEntry(
+ deps: AppDeps,
+ args: AppManifestReadArgs & { manifest: AppManifest },
+): Promise {
+ if (args.manifest.entry !== undefined) {
+ return {
+ path: args.manifest.entry,
+ kind: entryKindForPath(args.manifest.entry),
+ };
+ }
+
+ for (const candidate of ["index.html", "index.md"]) {
+ try {
+ await readAppFileMetadata(deps, {
+ appId: args.appId,
+ target: args.target,
+ path: candidate,
+ rootKind: "assets",
+ });
+ return {
+ path: candidate,
+ kind: entryKindForPath(candidate),
+ };
+ } catch (error) {
+ if (error instanceof ApiError && error.body.code === "ENOENT") {
+ continue;
+ }
+ throw error;
+ }
+ }
+
+ throw new ApiError(
+ 404,
+ "ENOENT",
+ "App entry not found: index.html or index.md",
+ );
+}
+
+async function tryResolveLogo(
+ deps: AppDeps,
+ args: AppManifestReadArgs,
+): Promise {
+ let listResult: HostDaemonCommandResult<"host.list_paths">;
+ try {
+ listResult = await queueHostCommand(deps, {
+ hostId: args.target.hostId,
+ command: {
+ type: "host.list_paths",
+ path: appRootPath(args),
+ limit: LOGO_EXTENSIONS.length,
+ includeFiles: true,
+ includeDirectories: false,
+ },
+ });
+ } catch (error) {
+ if (error instanceof ApiError && error.body.code === "ENOENT") {
+ return null;
+ }
+ throw error;
+ }
+
+ const topLevelFiles = new Set(
+ listResult.paths
+ .filter((entry) => entry.kind === "file" && !entry.path.includes("/"))
+ .map((entry) => entry.path),
+ );
+ for (const extension of LOGO_EXTENSIONS) {
+ if (topLevelFiles.has(`logo.${extension}`)) {
+ return { extension };
+ }
+ }
+ return null;
+}
+
+async function resolveAppIcon(
+ deps: AppDeps,
+ args: AppSummaryArgs,
+): Promise {
+ if (args.manifest.icon !== undefined) {
+ return { kind: "builtin", name: args.manifest.icon };
+ }
+ const logo = await tryResolveLogo(deps, args);
+ if (logo) {
+ return {
+ kind: "logo",
+ url: `/api/v1/threads/${encodeURIComponent(
+ args.requestThreadId,
+ )}/apps/${encodeURIComponent(args.appId)}/icon`,
+ };
+ }
+ return { kind: "builtin", name: "GridView" };
+}
+
+async function buildAppSummary(
+ deps: AppDeps,
+ args: AppSummaryArgs,
+): Promise {
+ const [entry, icon] = await Promise.all([
+ resolveAppEntry(deps, args),
+ resolveAppIcon(deps, args),
+ ]);
+ return {
+ id: args.manifest.id,
+ name: args.manifest.name,
+ entry,
+ capabilities: args.manifest.capabilities,
+ icon,
+ };
+}
+
+async function buildAppDetail(
+ deps: AppDeps,
+ args: AppDetailArgs,
+): Promise {
+ let manifest: AppManifest;
+ try {
+ manifest = await readAppManifestForRequest(deps, args);
+ } catch (error) {
+ if (
+ error instanceof ApiError &&
+ error.status === 404 &&
+ error.body.code === "ENOENT" &&
+ error.body.message.includes(MANIFEST_FILE_NAME)
+ ) {
+ throw new ApiError(
+ 404,
+ "app_not_provisioned",
+ `App "${args.appId}" is not provisioned yet; missing ${MANIFEST_FILE_NAME}. Restart bb from a current build if this manager was created before app seeding was available.`,
+ );
+ }
+ throw error;
+ }
+ return buildAppSummary(deps, {
+ ...args,
+ manifest,
+ });
+}
+
+function assertAppCapability(
+ manifest: AppManifest,
+ capability: AppCapability,
+): void {
+ if (!manifest.capabilities.includes(capability)) {
+ throw new ApiError(
+ 403,
+ "invalid_request",
+ `App does not have the ${capability} capability`,
+ );
+ }
+}
+
+async function listThreadApps(
+ deps: AppDeps,
+ threadId: string,
+): Promise {
+ const target = await requireThreadAppsTarget(deps, threadId);
+ let listResult: HostDaemonCommandResult<"host.list_paths">;
+ try {
+ listResult = await queueHostCommand(deps, {
+ hostId: target.hostId,
+ command: {
+ type: "host.list_paths",
+ path: path.join(target.storagePath, APPS_DIRECTORY_NAME),
+ limit: FILE_LIST_LIMIT_MAX,
+ includeFiles: false,
+ includeDirectories: true,
+ },
+ });
+ } catch (error) {
+ if (error instanceof ApiError && error.body.code === "ENOENT") {
+ return [];
+ }
+ throw error;
+ }
+
+ const appIds = listResult.paths
+ .filter((entry) => entry.kind === "directory" && !entry.path.includes("/"))
+ .map((entry) => appIdSchema.safeParse(entry.path))
+ .filter((entry) => entry.success)
+ .map((entry) => entry.data)
+ .sort((left, right) => left.localeCompare(right));
+
+ const summaries: AppSummary[] = [];
+ for (const appId of appIds) {
+ try {
+ const manifest = await readAppManifest(deps, { appId, target });
+ summaries.push(
+ await buildAppSummary(deps, {
+ appId,
+ target,
+ manifest,
+ requestThreadId: threadId,
+ }),
+ );
+ } catch (error) {
+ if (error instanceof InvalidAppManifestError) {
+ logInvalidAppManifest(deps, {
+ error,
+ message: "Skipping invalid thread app manifest",
+ });
+ continue;
+ }
+ throw error;
+ }
+ }
+ return summaries;
+}
+
+async function validateAppManifestForServe(
+ deps: AppDeps,
+ args: AppManifestReadArgs,
+): Promise {
+ await readAppManifestForRequest(deps, args);
+}
+
+function createHtmlResponse(html: string): Response {
+ return new Response(html, {
+ status: 200,
+ headers: {
+ "cache-control": NO_STORE_CACHE_CONTROL,
+ "content-type": HTML_CONTENT_TYPE,
+ "x-content-type-options": CONTENT_TYPE_OPTIONS,
+ },
+ });
+}
+
+function buildAppWebSocketUrl(
+ deps: LoggedWorkSessionDeps,
+ requestUrl: string,
+): string {
+ if (deps.config.isDevelopment) {
+ return `ws://localhost:${deps.config.serverPort}/ws`;
+ }
+ const url = new URL(requestUrl);
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
+ url.pathname = "/ws";
+ url.search = "";
+ url.hash = "";
+ return url.toString();
+}
+
+function createInjectedAppHtmlResponse(
+ deps: LoggedWorkSessionDeps,
+ args: CreateInjectedAppHtmlResponseArgs,
+): Response {
+ // Phase 1 app capabilities are advisory because app HTML is still served from
+ // bb's same origin. We gate the injected bridge and app-owned routes by the
+ // manifest-declared list, but real isolation waits for the third-party
+ // extensions phase: sandboxed/opaque-origin iframes with a postMessage bridge.
+ return createHtmlResponse(
+ injectAppClientScript(args.html, {
+ appId: args.appId,
+ capabilities: args.capabilities,
+ threadId: args.threadId,
+ dataUrl: `/api/v1/threads/${encodeURIComponent(
+ args.threadId,
+ )}/apps/${encodeURIComponent(args.appId)}/data`,
+ messageUrl: `/api/v1/threads/${encodeURIComponent(
+ args.threadId,
+ )}/apps/${encodeURIComponent(args.appId)}/message`,
+ wsUrl: buildAppWebSocketUrl(deps, args.requestUrl),
+ }),
+ );
+}
+
+async function serveAppEntry(
+ deps: AppDeps,
+ threadId: string,
+ rawAppId: string,
+ requestUrl: string,
+): Promise {
+ const appId = parseAppId(rawAppId);
+ const target = await requireThreadAppsTarget(deps, threadId);
+ const manifest = await readAppManifestForRequest(deps, { appId, target });
+ const entry = await resolveAppEntry(deps, { appId, target, manifest });
+ if (entry.kind !== "html") {
+ throw new ApiError(404, "invalid_request", "App entry is not HTML");
+ }
+ const metadata = await readAppFileMetadata(deps, {
+ appId,
+ target,
+ rootKind: "assets",
+ path: entry.path,
+ });
+ if (metadata.sizeBytes > HTML_ENTRY_MAX_BYTES) {
+ throw new ApiError(
+ 413,
+ "file_too_large",
+ "App HTML entry exceeds the 5 MB limit",
+ false,
+ );
+ }
+ const result = await readAppRelativeFile(deps, {
+ appId,
+ target,
+ rootKind: "assets",
+ path: entry.path,
+ dotfiles: "deny",
+ });
+ return createInjectedAppHtmlResponse(deps, {
+ appId,
+ requestUrl,
+ threadId,
+ html: decodeDaemonTextFile(result),
+ capabilities: manifest.capabilities,
+ });
+}
+
+async function serveAppAsset(
+ deps: AppDeps,
+ threadId: string,
+ rawAppId: string,
+ rawPath: string,
+): Promise {
+ const appId = parseAppId(rawAppId);
+ const assetPath = parseAppAssetPath(rawPath);
+ const target = await requireThreadAppsTarget(deps, threadId);
+ await validateAppManifestForServe(deps, { appId, target });
+ try {
+ const result = await readAppRelativeFile(deps, {
+ appId,
+ target,
+ rootKind: "assets",
+ path: assetPath.relativePath,
+ dotfiles: "deny",
+ });
+ return createDaemonFileContentResponse(result, {
+ headers: {
+ "cache-control": NO_STORE_CACHE_CONTROL,
+ "x-content-type-options": CONTENT_TYPE_OPTIONS,
+ },
+ });
+ } catch (error) {
+ return remapDaemonFileRouteError(error);
+ }
+}
+
+async function serveAppIcon(
+ deps: AppDeps,
+ threadId: string,
+ rawAppId: string,
+): Promise {
+ const appId = parseAppId(rawAppId);
+ const target = await requireThreadAppsTarget(deps, threadId);
+ const manifest = await readAppManifestForRequest(deps, { appId, target });
+ if (manifest.icon !== undefined) {
+ throw new ApiError(404, "ENOENT", "App uses a built-in icon");
+ }
+ const logo = await tryResolveLogo(deps, { appId, target });
+ if (!logo) {
+ throw new ApiError(404, "ENOENT", "App logo not found");
+ }
+ const logoPath = `logo.${logo.extension}`;
+ const metadata = await readAppFileMetadata(deps, {
+ appId,
+ target,
+ rootKind: "app",
+ path: logoPath,
+ });
+ if (metadata.sizeBytes > LOGO_MAX_BYTES) {
+ throw new ApiError(
+ 413,
+ "file_too_large",
+ "App logo exceeds the 1 MB limit",
+ false,
+ );
+ }
+ const result = await readAppRelativeFile(deps, {
+ appId,
+ target,
+ rootKind: "app",
+ path: logoPath,
+ dotfiles: "deny",
+ });
+ const contentType = mimeTypes.lookup(logoPath) || "application/octet-stream";
+ return new Response(decodeDaemonFileContent(result), {
+ status: 200,
+ headers: {
+ "cache-control": NO_STORE_CACHE_CONTROL,
+ "content-type": contentType,
+ "x-content-type-options": CONTENT_TYPE_OPTIONS,
+ },
+ });
+}
+
+async function readAppDataEntry(
+ deps: AppDeps,
+ args: ReadAppDataEntryArgs,
+): Promise {
+ const file = await readAppRelativeFile(deps, {
+ appId: args.appId,
+ target: args.target,
+ rootKind: "data",
+ path: args.dataPath,
+ dotfiles: "deny",
+ });
+ let value: JsonValue;
+ const bytes = Buffer.from(decodeDaemonFileContent(file));
+ try {
+ value = jsonValueSchema.parse(JSON.parse(bytes.toString("utf8")));
+ } catch {
+ throw new ApiError(
+ 422,
+ "invalid_json",
+ `App data path ${args.dataPath} does not contain valid JSON`,
+ );
+ }
+ const modifiedAtMs =
+ file.modifiedAtMs ??
+ (
+ await readAppFileMetadata(deps, {
+ appId: args.appId,
+ target: args.target,
+ rootKind: "data",
+ path: args.dataPath,
+ })
+ ).modifiedAtMs;
+ return {
+ path: args.dataPath,
+ value,
+ version: sha256(bytes),
+ sizeBytes: file.sizeBytes,
+ modifiedAtMs,
+ };
+}
+
+function shouldListAppDataPrefixAfterReadError(error: Error): boolean {
+ return (
+ error instanceof ApiError &&
+ (error.body.code === "ENOENT" ||
+ (error.body.code === "invalid_path" &&
+ error.body.message.includes("Path is a directory")))
+ );
+}
+
+async function listAppDataEntries(
+ deps: AppDeps,
+ args: ListAppDataEntriesArgs,
+): Promise {
+ if (args.dataPath !== "") {
+ try {
+ return [
+ await readAppDataEntry(deps, {
+ appId: args.appId,
+ dataPath: args.dataPath,
+ target: args.target,
+ }),
+ ];
+ } catch (error) {
+ if (
+ !(error instanceof Error) ||
+ !shouldListAppDataPrefixAfterReadError(error)
+ ) {
+ throw error;
+ }
+ }
+ }
+
+ const listRoot = args.dataPath
+ ? path.join(appDataRootPath(args), args.dataPath)
+ : appDataRootPath(args);
+ let listResult: HostDaemonCommandResult<"host.list_paths">;
+ try {
+ listResult = await queueHostCommand(deps, {
+ hostId: args.target.hostId,
+ command: {
+ type: "host.list_paths",
+ path: listRoot,
+ limit: FILE_LIST_LIMIT_MAX,
+ includeFiles: true,
+ includeDirectories: false,
+ },
+ });
+ } catch (error) {
+ if (error instanceof ApiError && error.body.code === "ENOENT") {
+ return [];
+ }
+ throw error;
+ }
+
+ const dataPaths = listResult.paths
+ .filter((entry) => entry.kind === "file")
+ .map((entry) =>
+ args.dataPath ? `${args.dataPath}/${entry.path}` : entry.path,
+ )
+ .map((entryPath) => appDataPathSchema.safeParse(entryPath))
+ .filter((entryPath) => entryPath.success)
+ .map((entryPath) => entryPath.data)
+ .sort((left, right) => left.localeCompare(right));
+
+ return Promise.all(
+ dataPaths.map((dataPath) =>
+ readAppDataEntry(deps, {
+ appId: args.appId,
+ dataPath,
+ target: args.target,
+ }),
+ ),
+ );
+}
+
+async function writeAppDataEntry(
+ deps: AppDeps,
+ args: WriteAppDataEntryArgs,
+): Promise {
+ const content = canonicalizeJson(args.value);
+ const result = await queueHostCommand(deps, {
+ hostId: args.target.hostId,
+ command: {
+ type: "host.write_file_relative",
+ rootPath: appDataRootPath(args),
+ path: args.dataPath,
+ dotfiles: "deny",
+ content,
+ contentEncoding: "utf8",
+ },
+ });
+ return {
+ path: args.dataPath,
+ value: args.value,
+ version: result.hash,
+ sizeBytes: result.sizeBytes,
+ modifiedAtMs: result.modifiedAtMs,
+ };
+}
+
+async function deleteAppDataEntry(
+ deps: AppDeps,
+ args: DeleteAppDataEntryArgs,
+): Promise {
+ await queueHostCommand(deps, {
+ hostId: args.target.hostId,
+ command: {
+ type: "host.delete_file_relative",
+ rootPath: appDataRootPath(args),
+ path: args.dataPath,
+ dotfiles: "deny",
+ },
+ });
+}
+
+function createTemplateManifest(
+ request: CreateThreadAppRequest,
+ template: AppTemplate,
+): AppManifest {
+ const manifest: AppManifest = {
+ manifestVersion: 1,
+ id: request.id,
+ name: request.name,
+ entry: "index.html",
+ contributions: ["thread.app"],
+ capabilities: ["data", "message"],
+ };
+ if (template === "status") {
+ manifest.icon = "ListTodo";
+ }
+ return manifest;
+}
+
+function templateFilesForRequest(
+ request: CreateThreadAppRequest,
+): TemplateFile[] {
+ const manifest = createTemplateManifest(request, request.template);
+ return [
+ {
+ path: MANIFEST_FILE_NAME,
+ content: canonicalizeJson(manifest),
+ },
+ {
+ path: "assets/index.html",
+ content: buildBlankAppIndexHtml({ name: request.name }),
+ },
+ {
+ path: "data/state.json",
+ content: canonicalizeJson({}),
+ },
+ ];
+}
+
+async function scaffoldApp(
+ deps: AppDeps,
+ args: ScaffoldAppArgs,
+): Promise {
+ try {
+ await readAppManifestForRequest(deps, {
+ appId: args.request.id,
+ target: args.target,
+ });
+ throw new ApiError(409, "invalid_request", "App already exists");
+ } catch (error) {
+ if (!(error instanceof ApiError) || error.status !== 404) {
+ throw error;
+ }
+ }
+
+ const files = templateFilesForRequest(args.request);
+ for (const file of files) {
+ await queueHostCommand(deps, {
+ hostId: args.target.hostId,
+ command: {
+ type: "host.write_file_relative",
+ rootPath: appRootPath({
+ appId: args.request.id,
+ target: args.target,
+ }),
+ path: file.path,
+ dotfiles: "deny",
+ content: file.content,
+ contentEncoding: "utf8",
+ },
+ });
+ }
+ return buildAppDetail(deps, {
+ appId: args.request.id,
+ target: args.target,
+ requestThreadId: args.threadId,
+ });
+}
+
+export function registerThreadAppRoutes(app: Hono, deps: AppDeps): void {
+ const { get, post, put, del } = typedRoutes(app, {
+ onValidationError: (msg) => new ApiError(400, "invalid_request", msg),
+ });
+
+ get("/threads/:id/apps", async (context) =>
+ context.json(await listThreadApps(deps, context.req.param("id"))),
+ );
+
+ post(
+ "/threads/:id/apps",
+ createThreadAppRequestSchema,
+ async (context, payload) => {
+ const threadId = context.req.param("id");
+ const target = await requireThreadAppsTarget(deps, threadId);
+ const detail = await scaffoldApp(deps, {
+ target,
+ request: payload,
+ threadId,
+ });
+ return context.json(detail, 201);
+ },
+ );
+
+ get("/threads/:id/apps/:appId", async (context) => {
+ const threadId = context.req.param("id");
+ const appId = parseAppId(context.req.param("appId"));
+ const target = await requireThreadAppsTarget(deps, threadId);
+ return context.json(
+ await buildAppDetail(deps, {
+ appId,
+ target,
+ requestThreadId: threadId,
+ }),
+ );
+ });
+
+ del("/threads/:id/apps/:appId", async (context) => {
+ const threadId = context.req.param("id");
+ const appId = parseAppId(context.req.param("appId"));
+ const target = await requireThreadAppsTarget(deps, threadId);
+ const result = await queueHostCommand(deps, {
+ hostId: target.hostId,
+ command: {
+ type: "host.delete_path_relative",
+ rootPath: path.join(target.storagePath, APPS_DIRECTORY_NAME),
+ path: appId,
+ dotfiles: "deny",
+ },
+ });
+ if (!result.deleted) {
+ throw new ApiError(404, "ENOENT", `App not found: ${appId}`);
+ }
+ return context.json({ ok: true });
+ });
+
+ get(
+ "/threads/:id/apps/:appId/data",
+ appDataListQuerySchema,
+ async (context, query) => {
+ const target = await requireThreadAppsTarget(
+ deps,
+ context.req.param("id"),
+ );
+ const appId = parseAppId(context.req.param("appId"));
+ assertAppCapability(
+ await readAppManifestForRequest(deps, { appId, target }),
+ "data",
+ );
+ const dataPath = parseOptionalAppDataPrefix(query.prefix);
+ return context.json({
+ entries: await listAppDataEntries(deps, {
+ appId,
+ target,
+ dataPath,
+ }),
+ });
+ },
+ );
+
+ post(
+ "/threads/:id/apps/:appId/message",
+ appMessageRequestSchema,
+ async (context, payload) => {
+ const thread = requirePublicThread(deps.db, context.req.param("id"));
+ const manifest = await readAppManifestForRequest(deps, {
+ appId: parseAppId(context.req.param("appId")),
+ target: await requireThreadAppsTarget(deps, thread.id),
+ });
+ assertAppCapability(manifest, "message");
+ const environment = await requireThreadCommandEnvironment(deps, {
+ thread,
+ });
+ await sendThreadMessage(deps, {
+ environment,
+ thread,
+ trigger: "user",
+ payload: {
+ input: [{ type: "text", text: payload.text }],
+ mode: "auto",
+ },
+ });
+ return context.json({ ok: true });
+ },
+ );
+
+ app.get("/threads/:id/apps/:appId/", async (context) =>
+ serveAppEntry(
+ deps,
+ context.req.param("id"),
+ context.req.param("appId"),
+ context.req.url,
+ ),
+ );
+
+ app.get("/threads/:id/apps/:appId/icon", async (context) =>
+ serveAppIcon(deps, context.req.param("id"), context.req.param("appId")),
+ );
+
+ get("/threads/:id/apps/:appId/data/*", async (context) => {
+ const target = await requireThreadAppsTarget(deps, context.req.param("id"));
+ const appId = parseAppId(context.req.param("appId"));
+ assertAppCapability(
+ await readAppManifestForRequest(deps, { appId, target }),
+ "data",
+ );
+ const dataPath = parseAppDataRoutePath(
+ extractRoutePath({
+ requestUrl: context.req.url,
+ routeSegment: APP_ROUTE_DATA_SEGMENT,
+ }),
+ );
+ const entry = await readAppDataEntry(deps, {
+ appId,
+ target,
+ dataPath,
+ });
+ const response = context.json(entry);
+ response.headers.set("cache-control", NO_STORE_CACHE_CONTROL);
+ response.headers.set("etag", `"${entry.version}"`);
+ return response;
+ });
+
+ put(
+ "/threads/:id/apps/:appId/data/*",
+ appDataWriteRequestSchema,
+ async (context, payload) => {
+ const target = await requireThreadAppsTarget(
+ deps,
+ context.req.param("id"),
+ );
+ const appId = parseAppId(context.req.param("appId"));
+ const manifest = await readAppManifestForRequest(deps, {
+ appId,
+ target,
+ });
+ assertAppCapability(manifest, "data");
+ const dataPath = parseAppDataRoutePath(
+ extractRoutePath({
+ requestUrl: context.req.url,
+ routeSegment: APP_ROUTE_DATA_SEGMENT,
+ }),
+ );
+ const entry = await writeAppDataEntry(deps, {
+ appId,
+ target,
+ dataPath,
+ value: payload.value,
+ });
+ const response = context.json(entry);
+ response.headers.set("cache-control", NO_STORE_CACHE_CONTROL);
+ response.headers.set("etag", `"${entry.version}"`);
+ return response;
+ },
+ );
+
+ del("/threads/:id/apps/:appId/data/*", async (context) => {
+ const target = await requireThreadAppsTarget(deps, context.req.param("id"));
+ const appId = parseAppId(context.req.param("appId"));
+ const manifest = await readAppManifestForRequest(deps, { appId, target });
+ assertAppCapability(manifest, "data");
+ const dataPath = parseAppDataRoutePath(
+ extractRoutePath({
+ requestUrl: context.req.url,
+ routeSegment: APP_ROUTE_DATA_SEGMENT,
+ }),
+ );
+ await deleteAppDataEntry(deps, {
+ appId,
+ target,
+ dataPath,
+ });
+ const response = context.json({ ok: true });
+ response.headers.set("cache-control", NO_STORE_CACHE_CONTROL);
+ return response;
+ });
+
+ // Keep this flat asset wildcard last among GET app routes so typed app
+ // endpoints such as /data/* and /icon keep their route ownership.
+ app.get("/threads/:id/apps/:appId/*", async (context) => {
+ const threadId = context.req.param("id");
+ const appId = context.req.param("appId");
+ return serveAppAsset(
+ deps,
+ threadId,
+ appId,
+ extractRoutePath({
+ requestUrl: context.req.url,
+ routeSegment: appAssetRouteSegment({ threadId, appId }),
+ }),
+ );
+ });
+}
diff --git a/apps/server/src/routes/threads/data.ts b/apps/server/src/routes/threads/data.ts
index 1a987203b..755b2298b 100644
--- a/apps/server/src/routes/threads/data.ts
+++ b/apps/server/src/routes/threads/data.ts
@@ -1,11 +1,6 @@
-import { Buffer } from "node:buffer";
import path from "node:path";
import { listQueuedThreadMessages } from "@bb/db";
-import {
- FILE_LIST_LIMIT_MAX,
- type HostDaemonCommand,
- type HostReadFileRelativeDotfilePolicy,
-} from "@bb/host-daemon-contract";
+import { FILE_LIST_LIMIT_MAX } from "@bb/host-daemon-contract";
import type { Hono } from "hono";
import { PROMPT_HISTORY_ENTRY_LIMIT, threadEventTypeSchema } from "@bb/domain";
import {
@@ -21,7 +16,6 @@ import {
typedRoutes,
type PublicApiSchema,
type ThreadComposerBootstrapResponse,
- type ThreadStatusVersionResponse,
type ThreadTimelineQuery,
} from "@bb/server-contract";
import type {
@@ -43,7 +37,6 @@ import {
import { queueCommandAndWait } from "../../services/hosts/command-wait.js";
import {
createDaemonFileContentResponse,
- decodeDaemonFileContent,
type DaemonFileReadResult,
remapDaemonFileRouteError,
} from "../../services/hosts/daemon-file-response.js";
@@ -66,15 +59,16 @@ import {
import { getLastExecutionOptions } from "../../services/threads/thread-events.js";
import { resolveSystemExecutionOptions } from "../../services/system/execution-options.js";
import { listThreadPromptHistory } from "../../services/prompt-history.js";
-import {
- injectStatusStateClientScript,
- type StatusStateBootstrap,
-} from "../../services/threads/status-state-client-script.js";
import {
parseInteger,
parseOptionalInteger,
} from "../../services/lib/validation.js";
import { parsePathKindInclusion } from "../path-list-inclusion.js";
+import {
+ extractRoutePath,
+ parseSafeRelativeRoutePath,
+ type SafeRelativeRoutePath,
+} from "../relative-route-path.js";
interface ThreadComposerExecutionOptionsSource {
archivedAt: number | null;
@@ -147,46 +141,15 @@ interface RequireThreadStorageTargetArgs {
threadId: string;
}
-interface ReadThreadStorageStatusFileArgs {
- target: ThreadStorageTarget;
- rootPath: string;
- relativePath: string;
- dotfiles: HostReadFileRelativeDotfilePolicy;
-}
-
-type HostStatusVersionCommand = Extract<
- HostDaemonCommand,
- { type: "host.status_version" }
->;
+type RawFileRoutePath = SafeRelativeRoutePath;
-interface StatusAssetPath {
- relativePath: string;
-}
-
-interface RawFileRoutePath {
- relativePath: string;
-}
-
-const STATUS_DIRECTORY_NAME = "STATUS";
-const STATUS_INDEX_FILE_PATH = "index.html";
-const STATUS_HTML_FILE_PATH = "STATUS.html";
-const STATUS_MARKDOWN_FILE_PATH = "STATUS.md";
-const STATUS_ROUTE_SEGMENT = "/status/";
const THREAD_STORAGE_FILE_ROUTE_SEGMENT = "/thread-storage/files/";
const THREAD_WORKTREE_FILE_ROUTE_SEGMENT = "/worktree/files/";
-const STATUS_NO_STORE_CACHE_CONTROL = "no-store";
-const STATUS_HTML_CONTENT_TYPE = "text/html; charset=utf-8";
-const STATUS_CONTENT_TYPE_OPTIONS = "nosniff";
+const RAW_FILE_NO_STORE_CACHE_CONTROL = "no-store";
+const RAW_FILE_HTML_CONTENT_TYPE = "text/html; charset=utf-8";
+const RAW_FILE_CONTENT_TYPE_OPTIONS = "nosniff";
const HTML_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
const GENERIC_HTML_PREVIEW_CSP = "sandbox allow-scripts";
-const UNUSABLE_STATUS_SOURCE_ERROR_CODES = new Set([
- "EACCES",
- "ELOOP",
- "ENOENT",
- "ENOTDIR",
- "EPERM",
- "invalid_path",
-]);
function parseThreadStorageFileListLimit(rawLimit: string | undefined): number {
const limit = Math.min(
@@ -283,285 +246,21 @@ export async function requireThreadStorageTarget(
};
}
-function createStatusNotFoundError(relativePath: string): ApiError {
- return new ApiError(404, "ENOENT", `Path does not exist: ${relativePath}`);
-}
-
-function createStatusInvalidPathError(): ApiError {
- return new ApiError(400, "invalid_path", "Invalid status asset path");
-}
-
-function decodeStatusRoutePath(rawPath: string): string {
- try {
- return decodeURIComponent(rawPath);
- } catch {
- throw createStatusInvalidPathError();
- }
-}
-
-function parseStatusAssetPath(rawPath: string): StatusAssetPath {
- const decodedPath = decodeStatusRoutePath(rawPath);
- const requestedDirectoryIndex = decodedPath.endsWith("/");
- const relativePath = requestedDirectoryIndex
- ? `${decodedPath}${STATUS_INDEX_FILE_PATH}`
- : decodedPath;
-
- if (
- relativePath.length === 0 ||
- relativePath.includes("\0") ||
- relativePath.includes("\\") ||
- path.posix.isAbsolute(relativePath)
- ) {
- throw createStatusInvalidPathError();
- }
-
- const segments = relativePath.split("/");
- if (
- segments.some(
- (segment) => segment.length === 0 || segment === "." || segment === "..",
- )
- ) {
- throw createStatusInvalidPathError();
- }
-
- if (segments.some((segment) => segment.startsWith("."))) {
- throw createStatusNotFoundError(relativePath);
- }
-
- return {
- relativePath: segments.join("/"),
- };
-}
-
-function createRawFileInvalidPathError(): ApiError {
- return new ApiError(400, "invalid_path", "Invalid file path");
-}
-
-function decodeRawFileRoutePath(rawPath: string): string {
- try {
- return decodeURIComponent(rawPath);
- } catch {
- throw createRawFileInvalidPathError();
- }
-}
-
function parseRawFileRoutePath(rawPath: string): RawFileRoutePath {
- const relativePath = decodeRawFileRoutePath(rawPath);
- if (
- relativePath.length === 0 ||
- relativePath.includes("\0") ||
- relativePath.includes("\\") ||
- path.posix.isAbsolute(relativePath)
- ) {
- throw createRawFileInvalidPathError();
- }
-
- const segments = relativePath.split("/");
- if (
- segments.some(
- (segment) => segment.length === 0 || segment === "." || segment === "..",
- )
- ) {
- throw createRawFileInvalidPathError();
- }
-
- return {
- relativePath: segments.join("/"),
- };
-}
-
-function extractThreadStatusPath(requestUrl: string): string {
- const requestPath = new URL(requestUrl).pathname;
- const statusSegmentIndex = requestPath.indexOf(STATUS_ROUTE_SEGMENT);
- if (statusSegmentIndex === -1) {
- return "";
- }
- return requestPath.slice(statusSegmentIndex + STATUS_ROUTE_SEGMENT.length);
+ return parseSafeRelativeRoutePath({
+ rawPath,
+ dotfileSegmentPolicy: "allow",
+ invalidPathMessage: "Invalid file path",
+ });
}
function extractRawFileRoutePath(
requestUrl: string,
routeSegment: string,
): string {
- const requestPath = new URL(requestUrl).pathname;
- const routeSegmentIndex = requestPath.indexOf(routeSegment);
- if (routeSegmentIndex === -1) {
- return "";
- }
- return requestPath.slice(routeSegmentIndex + routeSegment.length);
-}
-
-function shouldFallbackStatusRead(error: unknown): boolean {
- return (
- error instanceof ApiError &&
- UNUSABLE_STATUS_SOURCE_ERROR_CODES.has(error.body.code)
- );
-}
-
-function remapStatusAssetReadError(error: unknown): never {
- if (!(error instanceof ApiError)) {
- throw error;
- }
-
- if (error.body.code === "ENOENT") {
- throw new ApiError(
- 404,
- error.body.code,
- error.body.message,
- error.body.retryable,
- );
- }
- if (error.body.code === "invalid_path") {
- throw new ApiError(
- 400,
- error.body.code,
- error.body.message,
- error.body.retryable,
- );
- }
- throw error;
-}
-
-async function readThreadStorageStatusFile(
- deps: LoggedWorkSessionDeps,
- args: ReadThreadStorageStatusFileArgs,
-): Promise {
- return queueCommandAndWait(deps, {
- hostId: args.target.hostId,
- timeoutMs: COMMAND_TIMEOUT_MS,
- command: {
- type: "host.read_file_relative",
- rootPath: args.rootPath,
- path: args.relativePath,
- dotfiles: args.dotfiles,
- },
- });
-}
-
-async function tryReadThreadStorageStatusFile(
- deps: LoggedWorkSessionDeps,
- args: ReadThreadStorageStatusFileArgs,
-): Promise {
- try {
- return await readThreadStorageStatusFile(deps, args);
- } catch (error) {
- if (shouldFallbackStatusRead(error)) {
- return null;
- }
- throw error;
- }
-}
-
-function buildStatusVersionCommand(
- target: ThreadStorageTarget,
-): HostStatusVersionCommand {
- return {
- type: "host.status_version",
- sources: [
- {
- source: "folder",
- rootPath: path.join(target.storagePath, STATUS_DIRECTORY_NAME),
- indexPath: STATUS_INDEX_FILE_PATH,
- dotfiles: "deny",
- },
- {
- source: "html",
- rootPath: target.storagePath,
- path: STATUS_HTML_FILE_PATH,
- dotfiles: "allow",
- },
- {
- source: "md",
- rootPath: target.storagePath,
- path: STATUS_MARKDOWN_FILE_PATH,
- dotfiles: "allow",
- },
- ],
- };
-}
-
-async function readThreadStatusVersion(
- deps: LoggedWorkSessionDeps,
- threadId: string,
-): Promise {
- const target = await requireThreadStorageTarget(deps, { threadId });
- return queueCommandAndWait(deps, {
- hostId: target.hostId,
- timeoutMs: COMMAND_TIMEOUT_MS,
- command: buildStatusVersionCommand(target),
- });
-}
-
-function createStatusHtmlResponse(html: string): Response {
- return new Response(html, {
- status: 200,
- headers: {
- "cache-control": STATUS_NO_STORE_CACHE_CONTROL,
- "content-type": STATUS_HTML_CONTENT_TYPE,
- "x-content-type-options": STATUS_CONTENT_TYPE_OPTIONS,
- },
- });
-}
-
-function buildStatusStateWebSocketUrl(
- deps: LoggedWorkSessionDeps,
- requestUrl: string,
-): string {
- if (deps.config.isDevelopment) {
- return `ws://localhost:${deps.config.serverPort}/ws`;
- }
- const url = new URL(requestUrl);
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
- url.pathname = "/ws";
- url.search = "";
- url.hash = "";
- return url.toString();
-}
-
-function buildStatusStateBootstrap(
- deps: LoggedWorkSessionDeps,
- args: {
- requestUrl: string;
- threadId: string;
- },
-): StatusStateBootstrap {
- return {
- threadId: args.threadId,
- listUrl: `/api/v1/threads/${encodeURIComponent(args.threadId)}/status-data`,
- mutationUrl: `/api/v1/threads/${encodeURIComponent(args.threadId)}/status-state`,
- sendMessageUrl: `/api/v1/threads/${encodeURIComponent(args.threadId)}/send`,
- wsUrl: buildStatusStateWebSocketUrl(deps, args.requestUrl),
- };
-}
-
-function createInjectedStatusHtmlResponse(
- deps: LoggedWorkSessionDeps,
- args: {
- html: string;
- requestUrl: string;
- threadId: string;
- },
-): Response {
- return createStatusHtmlResponse(
- injectStatusStateClientScript(
- args.html,
- buildStatusStateBootstrap(deps, {
- requestUrl: args.requestUrl,
- threadId: args.threadId,
- }),
- ),
- );
-}
-
-function createStatusFileResponse(
- result: DaemonFileReadResult,
- cacheControl: string,
-): Response {
- return createDaemonFileContentResponse(result, {
- headers: {
- "cache-control": cacheControl,
- "x-content-type-options": STATUS_CONTENT_TYPE_OPTIONS,
- },
+ return extractRoutePath({
+ requestUrl,
+ routeSegment,
});
}
@@ -586,87 +285,16 @@ function createRawFilePreviewResponse(
): Response {
assertHtmlPreviewSize(relativePath, result.sizeBytes);
const headers = new Headers({
- "cache-control": STATUS_NO_STORE_CACHE_CONTROL,
- "x-content-type-options": STATUS_CONTENT_TYPE_OPTIONS,
+ "cache-control": RAW_FILE_NO_STORE_CACHE_CONTROL,
+ "x-content-type-options": RAW_FILE_CONTENT_TYPE_OPTIONS,
});
if (isHtmlPreviewPath(relativePath)) {
headers.set("content-security-policy", GENERIC_HTML_PREVIEW_CSP);
- headers.set("content-type", STATUS_HTML_CONTENT_TYPE);
+ headers.set("content-type", RAW_FILE_HTML_CONTENT_TYPE);
}
return createDaemonFileContentResponse(result, { headers });
}
-function decodeDaemonTextFile(result: DaemonFileReadResult): string {
- return Buffer.from(decodeDaemonFileContent(result)).toString("utf8");
-}
-
-function createStatusNoRawHtmlError(): ApiError {
- return new ApiError(
- 404,
- "ENOENT",
- "No raw manager status HTML source exists",
- );
-}
-
-async function serveThreadStatusRoot(
- deps: LoggedWorkSessionDeps,
- threadId: string,
- requestUrl: string,
-): Promise {
- const target = await requireThreadStorageTarget(deps, { threadId });
- const statusRootPath = path.join(target.storagePath, STATUS_DIRECTORY_NAME);
- const statusIndex = await tryReadThreadStorageStatusFile(deps, {
- target,
- rootPath: statusRootPath,
- relativePath: STATUS_INDEX_FILE_PATH,
- dotfiles: "deny",
- });
- if (statusIndex) {
- return createInjectedStatusHtmlResponse(deps, {
- requestUrl,
- threadId,
- html: decodeDaemonTextFile(statusIndex),
- });
- }
-
- const statusHtml = await tryReadThreadStorageStatusFile(deps, {
- target,
- rootPath: target.storagePath,
- relativePath: STATUS_HTML_FILE_PATH,
- dotfiles: "allow",
- });
- if (statusHtml) {
- return createInjectedStatusHtmlResponse(deps, {
- requestUrl,
- threadId,
- html: decodeDaemonTextFile(statusHtml),
- });
- }
-
- throw createStatusNoRawHtmlError();
-}
-
-async function serveThreadStatusAsset(
- deps: LoggedWorkSessionDeps,
- threadId: string,
- rawPath: string,
-): Promise {
- const assetPath = parseStatusAssetPath(rawPath);
- const target = await requireThreadStorageTarget(deps, { threadId });
-
- try {
- const result = await readThreadStorageStatusFile(deps, {
- target,
- rootPath: path.join(target.storagePath, STATUS_DIRECTORY_NAME),
- relativePath: assetPath.relativePath,
- dotfiles: "deny",
- });
- return createStatusFileResponse(result, STATUS_NO_STORE_CACHE_CONTROL);
- } catch (error) {
- return remapStatusAssetReadError(error);
- }
-}
-
async function serveThreadStorageRawFile(
deps: LoggedWorkSessionDeps,
threadId: string,
@@ -869,39 +497,8 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void {
return context.json(getLastExecutionOptions(deps, context.req.param("id")));
});
- get("/threads/:id/status-version", async (context) => {
- context.header("cache-control", STATUS_NO_STORE_CACHE_CONTROL);
- return context.json(
- await readThreadStatusVersion(deps, context.req.param("id")),
- );
- });
-
- app.get("/threads/:id/status", (context) => {
- const requestPath = new URL(context.req.url).pathname;
- return context.redirect(`${requestPath}/`, 308);
- });
-
- // This route intentionally sits outside @bb/server-contract: it returns
- // arbitrary manager-authored HTML/static bytes, including wildcard asset
- // paths, rather than a typed JSON API response.
- app.get("/threads/:id/status/", async (context) =>
- serveThreadStatusRoot(deps, context.req.param("id"), context.req.url),
- );
-
- app.get("/threads/:id/status/*", async (context) => {
- const rawStatusPath = extractThreadStatusPath(context.req.url);
- if (rawStatusPath.length === 0) {
- return serveThreadStatusRoot(
- deps,
- context.req.param("id"),
- context.req.url,
- );
- }
- return serveThreadStatusAsset(deps, context.req.param("id"), rawStatusPath);
- });
-
// Generic iframe previews use path-shaped raw URLs so relative links resolve
- // beside the HTML file. Unlike STATUS, these routes never inject bb globals.
+ // beside the HTML file. These routes never inject app bridge globals.
app.get("/threads/:id/worktree/files/*", async (context) =>
serveThreadWorktreeRawFile(
deps,
diff --git a/apps/server/src/routes/threads/index.ts b/apps/server/src/routes/threads/index.ts
index 386ffe276..73f8159de 100644
--- a/apps/server/src/routes/threads/index.ts
+++ b/apps/server/src/routes/threads/index.ts
@@ -1,10 +1,10 @@
import type { Hono } from "hono";
import type { AppDeps } from "../../types.js";
import { registerThreadActionRoutes } from "./actions.js";
+import { registerThreadAppRoutes } from "./apps.js";
import { registerThreadBaseRoutes } from "./base.js";
import { registerThreadDataRoutes } from "./data.js";
import { registerThreadInteractionRoutes } from "./interactions.js";
-import { registerThreadStatusDataRoutes } from "./status-data.js";
import { registerThreadTerminalRoutes } from "./terminals.js";
export function registerThreadRoutes(app: Hono, deps: AppDeps): void {
@@ -13,7 +13,7 @@ export function registerThreadRoutes(app: Hono, deps: AppDeps): void {
if (deps.config.featureFlags.terminals) {
registerThreadTerminalRoutes(app, deps);
}
- registerThreadStatusDataRoutes(app, deps);
+ registerThreadAppRoutes(app, deps);
registerThreadDataRoutes(app, deps);
registerThreadInteractionRoutes(app, deps);
}
diff --git a/apps/server/src/routes/threads/status-data.ts b/apps/server/src/routes/threads/status-data.ts
deleted file mode 100644
index fc8f7ea51..000000000
--- a/apps/server/src/routes/threads/status-data.ts
+++ /dev/null
@@ -1,300 +0,0 @@
-import type { Hono } from "hono";
-import { z } from "zod";
-import type {
- HostDaemonCommand,
- HostDaemonCommandResult,
-} from "@bb/host-daemon-contract";
-import { jsonValueSchema, type JsonValue } from "@bb/domain";
-import {
- typedRoutes,
- type PublicApiSchema,
- type StatusStateBroadcastMessage,
-} from "@bb/server-contract";
-import type { AppDeps } from "../../types.js";
-import { COMMAND_TIMEOUT_MS } from "../../constants.js";
-import { ApiError } from "../../errors.js";
-import { queueCommandAndWait } from "../../services/hosts/command-wait.js";
-import {
- requireEnvironment,
- requirePublicThread,
-} from "../../services/lib/entity-lookup.js";
-import {
- threadEnvironmentUnavailableDetails,
- throwThreadEnvironmentUnavailable,
-} from "../../services/lib/lifecycle-api-errors.js";
-import {
- STATUS_DATA_NO_STORE_CACHE_CONTROL,
- STATUS_STATE_CLIENT_HEADER,
- STATUS_STATE_OPERATION_HEADER,
- createStatusDataEtag,
- createStatusDataGetResponse,
- parseStatusDataKey,
-} from "../../services/threads/status-data-files.js";
-
-interface BrowserStatusStateSetPayload {
- value: JsonValue;
-}
-
-interface RequireThreadStatusDataTargetArgs {
- threadId: string;
-}
-
-interface PutBrowserStatusStateArgs {
- headers: Headers;
- key: string;
- payload: BrowserStatusStateSetPayload;
- threadId: string;
-}
-
-interface DeleteBrowserStatusStateArgs {
- headers: Headers;
- key: string;
- threadId: string;
-}
-
-interface ThreadStatusDataTarget {
- hostId: string;
- threadId: string;
-}
-
-type StatusDataCommandType =
- | "host.status_data.list"
- | "host.status_data.get"
- | "host.status_data.set"
- | "host.status_data.delete";
-
-interface QueueStatusDataCommandArgs {
- command: Extract;
- hostId: string;
-}
-
-const browserStatusStateSetPayloadSchema = z
- .object({
- value: jsonValueSchema,
- })
- .strict();
-
-async function requireThreadStatusDataTarget(
- deps: AppDeps,
- args: RequireThreadStatusDataTargetArgs,
-): Promise {
- const thread = requirePublicThread(deps.db, args.threadId);
- if (!thread.environmentId) {
- throwThreadEnvironmentUnavailable(
- threadEnvironmentUnavailableDetails("never_attached", null),
- );
- }
- const environment = requireEnvironment(deps.db, thread.environmentId);
- return {
- hostId: environment.hostId,
- threadId: thread.id,
- };
-}
-
-async function parseBrowserStatusStateSetPayload(
- request: Request,
-): Promise {
- let rawPayload: unknown;
- try {
- rawPayload = await request.json();
- } catch {
- throw new ApiError(400, "invalid_request", "Request body must be JSON");
- }
- const parsed = browserStatusStateSetPayloadSchema.safeParse(rawPayload);
- if (!parsed.success) {
- throw new ApiError(400, "invalid_request", "Invalid status state payload");
- }
- return parsed.data;
-}
-
-function remapStatusDataCommandError(error: unknown): never {
- if (!(error instanceof ApiError)) {
- throw error;
- }
-
- if (error.body.code === "ENOENT") {
- throw new ApiError(
- 404,
- error.body.code,
- error.body.message,
- error.body.retryable,
- );
- }
- if (error.body.code === "invalid_path") {
- throw new ApiError(
- 400,
- error.body.code,
- error.body.message,
- error.body.retryable,
- );
- }
- if (error.body.code === "invalid_json") {
- throw new ApiError(
- 422,
- error.body.code,
- error.body.message,
- error.body.retryable,
- );
- }
- throw error;
-}
-
-async function queueStatusDataCommand(
- deps: AppDeps,
- args: QueueStatusDataCommandArgs,
-): Promise> {
- try {
- return await queueCommandAndWait(deps, {
- hostId: args.hostId,
- timeoutMs: COMMAND_TIMEOUT_MS,
- command: args.command,
- });
- } catch (error) {
- remapStatusDataCommandError(error);
- }
-}
-
-async function putBrowserStatusState(
- deps: AppDeps,
- args: PutBrowserStatusStateArgs,
-): Promise {
- const key = parseStatusDataKey(args.key);
- const target = await requireThreadStatusDataTarget(deps, {
- threadId: args.threadId,
- });
- const writeResult = await queueStatusDataCommand(deps, {
- hostId: target.hostId,
- command: {
- type: "host.status_data.set",
- threadId: target.threadId,
- key,
- value: args.payload.value,
- },
- });
-
- const message: StatusStateBroadcastMessage = {
- type: "status-data.changed",
- threadId: args.threadId,
- key,
- value: writeResult.value,
- deleted: false,
- previousValue: writeResult.previousValue,
- previousValuePresent: writeResult.previousValuePresent,
- version: writeResult.version,
- writerClientId: args.headers.get(STATUS_STATE_CLIENT_HEADER),
- operationId: args.headers.get(STATUS_STATE_OPERATION_HEADER),
- };
- deps.hub.notifyThreadStatusData(message);
-
- return new Response(
- JSON.stringify(createStatusDataGetResponse(writeResult)),
- {
- status: 200,
- headers: {
- "cache-control": STATUS_DATA_NO_STORE_CACHE_CONTROL,
- "content-type": "application/json",
- etag: createStatusDataEtag(writeResult.version),
- },
- },
- );
-}
-
-async function deleteBrowserStatusState(
- deps: AppDeps,
- args: DeleteBrowserStatusStateArgs,
-): Promise {
- const key = parseStatusDataKey(args.key);
- const target = await requireThreadStatusDataTarget(deps, {
- threadId: args.threadId,
- });
- const deleteResult = await queueStatusDataCommand(deps, {
- hostId: target.hostId,
- command: {
- type: "host.status_data.delete",
- threadId: target.threadId,
- key,
- },
- });
- if (deleteResult.deleted) {
- const message: StatusStateBroadcastMessage = {
- type: "status-data.changed",
- threadId: args.threadId,
- key,
- value: null,
- deleted: true,
- previousValue: deleteResult.previousValue,
- previousValuePresent: deleteResult.previousValuePresent,
- version: null,
- writerClientId: args.headers.get(STATUS_STATE_CLIENT_HEADER),
- operationId: args.headers.get(STATUS_STATE_OPERATION_HEADER),
- };
- deps.hub.notifyThreadStatusData(message);
- }
-
- return new Response(JSON.stringify({ ok: true }), {
- status: 200,
- headers: {
- "cache-control": STATUS_DATA_NO_STORE_CACHE_CONTROL,
- "content-type": "application/json",
- },
- });
-}
-
-export function registerThreadStatusDataRoutes(app: Hono, deps: AppDeps): void {
- const { get } = typedRoutes(app, {
- onValidationError: (msg) => new ApiError(400, "invalid_request", msg),
- });
-
- get("/threads/:id/status-data", async (context) => {
- const target = await requireThreadStatusDataTarget(deps, {
- threadId: context.req.param("id"),
- });
- context.header("cache-control", STATUS_DATA_NO_STORE_CACHE_CONTROL);
- return context.json(
- await queueStatusDataCommand(deps, {
- hostId: target.hostId,
- command: {
- type: "host.status_data.list",
- threadId: target.threadId,
- },
- }),
- );
- });
-
- get("/threads/:id/status-data/:key", async (context) => {
- const target = await requireThreadStatusDataTarget(deps, {
- threadId: context.req.param("id"),
- });
- const key = parseStatusDataKey(context.req.param("key"));
- const entry = await queueStatusDataCommand(deps, {
- hostId: target.hostId,
- command: {
- type: "host.status_data.get",
- threadId: target.threadId,
- key,
- },
- });
- context.header("cache-control", STATUS_DATA_NO_STORE_CACHE_CONTROL);
- context.header("etag", createStatusDataEtag(entry.version));
- return context.json(createStatusDataGetResponse(entry));
- });
-
- // Contract-private, not network-private: the injected same-origin
- // window.bbStatusState client uses this endpoint for browser mutations.
- app.put("/threads/:id/status-state/:key", async (context) => {
- return putBrowserStatusState(deps, {
- threadId: context.req.param("id"),
- key: context.req.param("key"),
- headers: context.req.raw.headers,
- payload: await parseBrowserStatusStateSetPayload(context.req.raw),
- });
- });
-
- app.delete("/threads/:id/status-state/:key", async (context) => {
- return deleteBrowserStatusState(deps, {
- threadId: context.req.param("id"),
- key: context.req.param("key"),
- headers: context.req.raw.headers,
- });
- });
-}
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 7acd18286..bafc06f35 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -21,12 +21,12 @@ import { registerDevelopmentOnlyReplayRoutes } from "./routes/internal-replay.js
import { registerThreadRoutes } from "./routes/threads/index.js";
import { registerInternalCommandRoutes } from "./internal/commands.js";
import { registerInternalCommandResultRoutes } from "./internal/command-result-route.js";
+import { registerInternalAppDataChangeRoutes } from "./internal/app-data-changes.js";
import { registerInternalEnvironmentChangeRoutes } from "./internal/environment-changes.js";
import { registerInternalEventRoutes } from "./internal/events.js";
import { registerInternalHostRoutes } from "./internal/hosts.js";
import { registerInternalInteractiveRequestRoutes } from "./internal/interactive-requests.js";
import { registerInternalSessionRoutes } from "./internal/session.js";
-import { registerInternalStatusDataChangeRoutes } from "./internal/status-data-changes.js";
import { registerInternalToolCallRoutes } from "./internal/tool-calls.js";
import {
setAuthenticatedDaemon,
@@ -116,9 +116,7 @@ function shouldLogSlowApiRequest(args: ShouldLogSlowApiRequestArgs): boolean {
return !THREAD_EVENT_WAIT_PATH_PATTERN.test(args.path);
}
-function createStaticResponseHeaders(
- args: StaticResponseHeadersArgs,
-): Headers {
+function createStaticResponseHeaders(args: StaticResponseHeadersArgs): Headers {
const headers = new Headers();
headers.set("content-type", args.contentType);
headers.set(
@@ -257,6 +255,9 @@ export function createApp(
registerManagerTemplateRoutes(publicApi, deps);
registerDevelopmentOnlyReplayRoutes(publicApi, deps);
app.route("/api/v1", publicApi);
+ app.use("/api/v1/*", () => {
+ throw new ApiError(404, "not_found", "Not found");
+ });
const internalApi = new Hono();
registerInternalHostRoutes(internalApi, deps);
@@ -264,7 +265,7 @@ export function createApp(
registerInternalCommandRoutes(internalApi, deps);
registerInternalCommandResultRoutes(internalApi, deps);
registerInternalEnvironmentChangeRoutes(internalApi, deps);
- registerInternalStatusDataChangeRoutes(internalApi, deps);
+ registerInternalAppDataChangeRoutes(internalApi, deps);
registerInternalEventRoutes(internalApi, deps);
registerInternalToolCallRoutes(internalApi, deps);
registerInternalInteractiveRequestRoutes(internalApi, deps);
diff --git a/apps/server/src/services/threads/app-client-script.ts b/apps/server/src/services/threads/app-client-script.ts
new file mode 100644
index 000000000..262e3708d
--- /dev/null
+++ b/apps/server/src/services/threads/app-client-script.ts
@@ -0,0 +1,494 @@
+import type { AppId } from "@bb/domain";
+import type { AppCapability } from "@bb/server-contract";
+
+export interface AppClientBootstrap {
+ appId: AppId;
+ capabilities: AppCapability[];
+ dataUrl: string;
+ messageUrl: string;
+ threadId: string;
+ wsUrl: string;
+}
+
+interface CreateAppClientScriptArgs {
+ bootstrap: AppClientBootstrap;
+}
+
+const APP_CLIENT_SCRIPT_MARKER = "data-bb-app-client";
+
+function escapedJsonForInlineScript(value: AppClientBootstrap): string {
+ return JSON.stringify(value).replace(/${buildAppClientJavascript(
+ bootstrapJson,
+ )}`;
+}
+
+export function injectAppClientScript(
+ html: string,
+ bootstrap: AppClientBootstrap,
+): string {
+ if (html.includes(APP_CLIENT_SCRIPT_MARKER)) {
+ return html;
+ }
+
+ const script = createAppClientScript({ bootstrap });
+ const firstScriptIndex = html.search(/
+
+
+
+ `;
+
+// Second `;
+
+// Blank-template scaffold rendered into apps//assets/index.html. Composes
+// the documented bb default styling head with a scaffold-only style block and
+// the task-list visual vocabulary borrowed from the bundled status app, so new
+// apps start out looking bb-native rather than like bare HTML. The same
+// scaffold powers both `bb app new` (any name) and the bundled default status
+// app seeded into every manager thread (name="Status").
+const BLANK_APP_INDEX_HTML_TEMPLATE = `
+
+
+
+
+ ${BLANK_APP_NAME_PLACEHOLDER}
+ ${BB_DEFAULT_STYLING_HEAD}
+ ${BLANK_APP_SCAFFOLD_EXTRA_STYLES}
+
+
+
+
+
+
+ Ask your agent to customize the status app how you please.
+
+
+
+ Example tasks
+
+ implementing
+ Sample task title
+ in progress
+
+
+ blocked
+ Another sample row
+ blocked
+
+
+
+
+
+
+
+`;
+
+export function buildBlankAppIndexHtml(
+ args: BuildBlankAppIndexHtmlArgs,
+): string {
+ return BLANK_APP_INDEX_HTML_TEMPLATE.replaceAll(
+ BLANK_APP_NAME_PLACEHOLDER,
+ escapeHtmlText(args.name),
+ );
+}
diff --git a/apps/server/src/services/threads/default-template/STATUS.html b/apps/server/src/services/threads/default-template/STATUS.html
deleted file mode 100644
index 2cf56fdf1..000000000
--- a/apps/server/src/services/threads/default-template/STATUS.html
+++ /dev/null
@@ -1,380 +0,0 @@
-
-
-
-
-
- Manager Status
-
-
-
-
-
-
-
-
-
- Open PRs · 0
- No open PRs.
-
-
-
- Active workers · 0
- No active workers.
-
-
-
-
-
diff --git a/apps/server/src/services/threads/default-template/apps/status/manifest.json b/apps/server/src/services/threads/default-template/apps/status/manifest.json
new file mode 100644
index 000000000..8f83d9dc3
--- /dev/null
+++ b/apps/server/src/services/threads/default-template/apps/status/manifest.json
@@ -0,0 +1,9 @@
+{
+ "manifestVersion": 1,
+ "id": "status",
+ "name": "Status",
+ "icon": "ListTodo",
+ "entry": "index.html",
+ "contributions": ["thread.app"],
+ "capabilities": ["data", "message"]
+}
diff --git a/apps/server/src/services/threads/manager-storage-templates.ts b/apps/server/src/services/threads/manager-storage-templates.ts
index 26074eb76..fb7718590 100644
--- a/apps/server/src/services/threads/manager-storage-templates.ts
+++ b/apps/server/src/services/threads/manager-storage-templates.ts
@@ -15,11 +15,18 @@ import {
} from "@bb/domain";
import type { LoggedWorkSessionDeps, ServerLogger } from "../../types.js";
import { ensureHostSessionReadyForWork } from "../hosts/host-lifecycle.js";
+import { buildBlankAppIndexHtml } from "./blank-app-scaffold.js";
export const MANAGER_TEMPLATE_DIR_NAME = "manager-templates";
export const ACTIVE_MANAGER_TEMPLATE_FILE_NAME = "active";
export const DEFAULT_MANAGER_TEMPLATE_NAME: ManagerTemplateName = "default";
+const BUNDLED_STATUS_APP_NAME = "Status";
+const BUNDLED_STATUS_APP_INDEX_HTML = buildBlankAppIndexHtml({
+ name: BUNDLED_STATUS_APP_NAME,
+});
+const BUNDLED_STATUS_APP_STATE_JSON = "{}\n";
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const defaultTemplateAssetDir = path.join(moduleDir, "default-template");
@@ -34,6 +41,11 @@ interface BuiltInManagerTemplateFile {
fileName: string;
}
+interface TemplateFileToCopy {
+ relativePath: string;
+ sourcePath: string;
+}
+
interface BuiltInManagerTemplateSet {
files: readonly BuiltInManagerTemplateFile[];
name: ManagerTemplateName;
@@ -83,14 +95,30 @@ interface ManagerTemplateSetPathArgs extends ManagerTemplateRootPathArgs {
type CopyTemplateFilesResult = "copied" | "missing";
-// Built-in defaults stay in the server bundle. They are copied only as a
-// seed-time fallback when the user has not authored manager-templates/default.
+// Built-in defaults stay in the server bundle. They are always overlaid on top
+// of any user-authored template copy so newly-provisioned threads have a
+// working status surface even if the user's template omits these files.
+// User-authored files win because they are copied first and the overlay uses
+// the `wx` flag, which refuses to overwrite existing destinations.
+//
+// The bundled status app shares its index.html with the `bb app new` blank
+// scaffold (via buildBlankAppIndexHtml) so new users open a bb-styled
+// starting-point dashboard they can ask their agent to customize, rather than
+// inheriting any one workflow-specific UI.
const BUILT_IN_DEFAULT_MANAGER_TEMPLATE_SET: BuiltInManagerTemplateSet = {
name: DEFAULT_MANAGER_TEMPLATE_NAME,
files: [
{
- fileName: "STATUS.html",
- content: loadDefaultTemplateAsset("STATUS.html"),
+ fileName: "apps/status/manifest.json",
+ content: loadDefaultTemplateAsset("apps/status/manifest.json"),
+ },
+ {
+ fileName: "apps/status/assets/index.html",
+ content: BUNDLED_STATUS_APP_INDEX_HTML,
+ },
+ {
+ fileName: "apps/status/data/state.json",
+ content: BUNDLED_STATUS_APP_STATE_JSON,
},
],
};
@@ -167,9 +195,9 @@ async function readActiveManagerTemplateName(
async function copyTemplateFiles(
args: CopyTemplateFilesArgs,
): Promise {
- let entries: Dirent[];
+ let files: TemplateFileToCopy[];
try {
- entries = await readdir(args.templateDirPath, { withFileTypes: true });
+ files = await collectTemplateFiles(args.templateDirPath);
} catch (error) {
if (isFsErrorWithCode({ error, code: "ENOENT" })) {
return "missing";
@@ -179,14 +207,18 @@ async function copyTemplateFiles(
await mkdir(args.threadStoragePath, { recursive: true });
- for (const entry of entries) {
- if (!entry.isFile()) {
- continue;
- }
- const sourcePath = path.join(args.templateDirPath, entry.name);
- const destinationPath = path.join(args.threadStoragePath, entry.name);
+ for (const file of files) {
+ const destinationPath = path.join(
+ args.threadStoragePath,
+ file.relativePath,
+ );
try {
- await copyFile(sourcePath, destinationPath, fsConstants.COPYFILE_EXCL);
+ await mkdir(path.dirname(destinationPath), { recursive: true });
+ await copyFile(
+ file.sourcePath,
+ destinationPath,
+ fsConstants.COPYFILE_EXCL,
+ );
} catch (error) {
if (isFsErrorWithCode({ error, code: "EEXIST" })) {
continue;
@@ -194,7 +226,7 @@ async function copyTemplateFiles(
if (isFsErrorWithCode({ error, code: "ENOENT" })) {
args.logger.warn(
{
- sourcePath,
+ sourcePath: file.sourcePath,
templateName: args.templateName,
threadId: args.threadId,
},
@@ -209,6 +241,42 @@ async function copyTemplateFiles(
return "copied";
}
+async function collectTemplateFiles(
+ rootPath: string,
+): Promise {
+ const pendingDirs = [rootPath];
+ const files: TemplateFileToCopy[] = [];
+
+ while (pendingDirs.length > 0) {
+ const currentDir = pendingDirs.shift();
+ if (!currentDir) {
+ continue;
+ }
+ const entries: Dirent[] = await readdir(currentDir, {
+ withFileTypes: true,
+ });
+ for (const entry of entries) {
+ const sourcePath = path.join(currentDir, entry.name);
+ if (entry.isDirectory()) {
+ pendingDirs.push(sourcePath);
+ continue;
+ }
+ if (!entry.isFile()) {
+ continue;
+ }
+ files.push({
+ sourcePath,
+ relativePath: path
+ .relative(rootPath, sourcePath)
+ .split(path.sep)
+ .join("/"),
+ });
+ }
+ }
+
+ return files;
+}
+
async function copyBuiltInTemplateFiles(
args: CopyBuiltInTemplateFilesArgs,
): Promise {
@@ -217,6 +285,7 @@ async function copyBuiltInTemplateFiles(
for (const file of args.files) {
const destinationPath = path.join(args.threadStoragePath, file.fileName);
try {
+ await mkdir(path.dirname(destinationPath), { recursive: true });
await writeFile(destinationPath, file.content, {
encoding: "utf8",
flag: "wx",
@@ -235,7 +304,7 @@ async function copyBuiltInTemplateFiles(
threadId: args.threadId,
threadStoragePath: args.threadStoragePath,
},
- "Seeded manager storage from built-in template fallback",
+ "Overlaid bundled apps/status seed onto manager storage",
);
}
@@ -262,27 +331,26 @@ export async function seedManagerThreadStorage(
threadId: args.threadId,
threadStoragePath: args.threadStoragePath,
});
- if (copyResult === "copied") {
- return;
- }
- if (templateName === BUILT_IN_DEFAULT_MANAGER_TEMPLATE_SET.name) {
- await copyBuiltInTemplateFiles({
- files: BUILT_IN_DEFAULT_MANAGER_TEMPLATE_SET.files,
- logger: deps.logger,
- templateName,
- threadId: args.threadId,
- threadStoragePath: args.threadStoragePath,
- });
- return;
+ if (
+ copyResult === "missing" &&
+ templateName !== DEFAULT_MANAGER_TEMPLATE_NAME
+ ) {
+ deps.logger.warn(
+ {
+ templateName,
+ templateDirPath,
+ threadId: args.threadId,
+ },
+ "Manager template directory is missing; overlaying bundled seed only",
+ );
}
- deps.logger.warn(
- {
- templateName,
- templateDirPath,
- threadId: args.threadId,
- },
- "Manager template directory is missing; skipping storage seed",
- );
+ await copyBuiltInTemplateFiles({
+ files: BUILT_IN_DEFAULT_MANAGER_TEMPLATE_SET.files,
+ logger: deps.logger,
+ templateName,
+ threadId: args.threadId,
+ threadStoragePath: args.threadStoragePath,
+ });
}
diff --git a/apps/server/src/services/threads/status-data-files.ts b/apps/server/src/services/threads/status-data-files.ts
deleted file mode 100644
index 2635ba43d..000000000
--- a/apps/server/src/services/threads/status-data-files.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import {
- statusDataKeySchema,
- type JsonValue,
- type StatusDataKey,
-} from "@bb/domain";
-import type { ThreadStatusDataGetResponse } from "@bb/server-contract";
-import { ApiError } from "../../errors.js";
-
-export const STATUS_DATA_NO_STORE_CACHE_CONTROL = "no-store";
-export const STATUS_STATE_CLIENT_HEADER = "x-bb-status-state-client";
-export const STATUS_STATE_OPERATION_HEADER = "x-bb-status-state-operation";
-
-interface StatusDataEntry {
- key: StatusDataKey;
- value: JsonValue;
- version: string;
- sizeBytes: number;
- modifiedAtMs: number;
-}
-
-export function parseStatusDataKey(rawKey: string): StatusDataKey {
- const parsed = statusDataKeySchema.safeParse(rawKey);
- if (!parsed.success) {
- throw new ApiError(400, "invalid_request", "Invalid status-data key");
- }
- return parsed.data;
-}
-
-export function createStatusDataGetResponse(
- entry: StatusDataEntry,
-): ThreadStatusDataGetResponse {
- return {
- key: entry.key,
- value: entry.value,
- version: entry.version,
- sizeBytes: entry.sizeBytes,
- modifiedAtMs: entry.modifiedAtMs,
- };
-}
-
-export function createStatusDataEtag(version: string): string {
- return `"${version}"`;
-}
diff --git a/apps/server/src/services/threads/status-state-client-script.ts b/apps/server/src/services/threads/status-state-client-script.ts
deleted file mode 100644
index 7ed891126..000000000
--- a/apps/server/src/services/threads/status-state-client-script.ts
+++ /dev/null
@@ -1,533 +0,0 @@
-export interface StatusStateBootstrap {
- listUrl: string;
- mutationUrl: string;
- sendMessageUrl: string;
- threadId: string;
- wsUrl: string;
-}
-
-interface CreateStatusStateClientScriptArgs {
- bootstrap: StatusStateBootstrap;
-}
-
-const STATUS_STATE_SCRIPT_MARKER = "data-bb-status-state-client";
-
-function escapedJsonForInlineScript(value: StatusStateBootstrap): string {
- return JSON.stringify(value).replace(/${buildStatusStateClientJavascript(
- bootstrapJson,
- )}`;
-}
-
-export function injectStatusStateClientScript(
- html: string,
- bootstrap: StatusStateBootstrap,
-): string {
- if (html.includes(STATUS_STATE_SCRIPT_MARKER)) {
- return html;
- }
-
- const script = createStatusStateClientScript({ bootstrap });
- const firstScriptIndex = html.search(/",
+ '',
);
await writeFile(
join(staticDir, "assets", "index-test.js"),
@@ -33,6 +33,19 @@ describe("production static cache headers", () => {
expect(assetResponse.headers.get("cache-control")).toBe(
"public, max-age=31536000, immutable",
);
+
+ const apiMissResponse = await serverApp.app.request(
+ "/api/v1/does-not-exist.js",
+ );
+ const apiMissBody = await apiMissResponse.text();
+ expect(apiMissResponse.status).toBe(404);
+ expect(apiMissResponse.headers.get("content-type")).toBe(
+ "application/json",
+ );
+ expect(apiMissBody).not.toContain("index-test.js");
+ expect(JSON.parse(apiMissBody)).toMatchObject({
+ code: "not_found",
+ });
} finally {
await serverApp.closeWebSockets();
await harness.cleanup();
diff --git a/apps/server/test/internal/internal-status-data-change.test.ts b/apps/server/test/internal/internal-app-data-change.test.ts
similarity index 51%
rename from apps/server/test/internal/internal-status-data-change.test.ts
rename to apps/server/test/internal/internal-app-data-change.test.ts
index dfaafa664..4163e90cc 100644
--- a/apps/server/test/internal/internal-status-data-change.test.ts
+++ b/apps/server/test/internal/internal-app-data-change.test.ts
@@ -9,12 +9,12 @@ import {
} from "../helpers/seed.js";
import { createTestAppHarness } from "../helpers/test-app.js";
-describe("internal STATUS-data change route", () => {
- it("broadcasts daemon-reported STATUS-data changes for session-owned threads", async () => {
+describe("internal app-data change route", () => {
+ it("broadcasts daemon-reported app data changes for session-owned threads", async () => {
const harness = await createTestAppHarness();
try {
const { host, session } = seedHostSession(harness.deps, {
- id: "host-status-data-change",
+ id: "host-app-data-change",
});
const { project } = seedProjectWithSource(harness.deps, {
hostId: host.id,
@@ -22,7 +22,7 @@ describe("internal STATUS-data change route", () => {
const environment = seedEnvironment(harness.deps, {
hostId: host.id,
projectId: project.id,
- path: "/tmp/status-data-change",
+ path: "/tmp/app-data-change",
status: "ready",
});
const thread = seedThread(harness.deps, {
@@ -30,56 +30,51 @@ describe("internal STATUS-data change route", () => {
environmentId: environment.id,
type: "manager",
});
- const notifyThreadStatusDataSpy = vi.spyOn(
+ const notifyThreadAppDataSpy = vi.spyOn(
harness.hub,
- "notifyThreadStatusData",
+ "notifyThreadAppData",
);
const response = await harness.app.request(
- "/internal/session/status-data-change",
+ "/internal/session/app-data-change",
{
method: "POST",
headers: internalAuthHeaders(harness),
body: JSON.stringify({
sessionId: session.id,
threadId: thread.id,
- key: "state",
- value: { status: "running" },
+ appId: "status",
+ path: "state.json",
+ value: { workers: [] },
deleted: false,
- previousValue: { status: "queued" },
- previousValuePresent: true,
version: "version-next",
- previousVersion: "version-prev",
}),
},
);
expect(response.status).toBe(200);
- expect(notifyThreadStatusDataSpy).toHaveBeenCalledWith({
- type: "status-data.changed",
+ expect(notifyThreadAppDataSpy).toHaveBeenCalledWith({
+ type: "app-data.changed",
threadId: thread.id,
- key: "state",
- value: { status: "running" },
+ appId: "status",
+ path: "state.json",
+ value: { workers: [] },
deleted: false,
- previousValue: { status: "queued" },
- previousValuePresent: true,
version: "version-next",
- writerClientId: null,
- operationId: null,
});
} finally {
await harness.cleanup();
}
});
- it("rejects daemon-reported STATUS-data changes for threads owned by another host", async () => {
+ it("rejects daemon-reported app data changes for threads owned by another host", async () => {
const harness = await createTestAppHarness();
try {
const hostA = seedHostSession(harness.deps, {
- id: "host-status-data-change-a",
+ id: "host-app-data-change-a",
});
const hostB = seedHostSession(harness.deps, {
- id: "host-status-data-change-b",
+ id: "host-app-data-change-b",
});
const { project } = seedProjectWithSource(harness.deps, {
hostId: hostB.host.id,
@@ -87,7 +82,7 @@ describe("internal STATUS-data change route", () => {
const environment = seedEnvironment(harness.deps, {
hostId: hostB.host.id,
projectId: project.id,
- path: "/tmp/status-data-change-other",
+ path: "/tmp/app-data-change-other",
status: "ready",
});
const thread = seedThread(harness.deps, {
@@ -95,26 +90,24 @@ describe("internal STATUS-data change route", () => {
environmentId: environment.id,
type: "manager",
});
- const notifyThreadStatusDataSpy = vi.spyOn(
+ const notifyThreadAppDataSpy = vi.spyOn(
harness.hub,
- "notifyThreadStatusData",
+ "notifyThreadAppData",
);
const response = await harness.app.request(
- "/internal/session/status-data-change",
+ "/internal/session/app-data-change",
{
method: "POST",
headers: internalAuthHeaders(harness),
body: JSON.stringify({
sessionId: hostA.session.id,
threadId: thread.id,
- key: "state",
+ appId: "status",
+ path: "state.json",
value: null,
deleted: true,
- previousValue: { status: "running" },
- previousValuePresent: true,
version: null,
- previousVersion: "version-prev",
}),
},
);
@@ -123,7 +116,56 @@ describe("internal STATUS-data change route", () => {
await expect(readJson(response)).resolves.toMatchObject({
code: "invalid_request",
});
- expect(notifyThreadStatusDataSpy).not.toHaveBeenCalled();
+ expect(notifyThreadAppDataSpy).not.toHaveBeenCalled();
+ } finally {
+ await harness.cleanup();
+ }
+ });
+
+ it("broadcasts daemon-requested app data resync hints", async () => {
+ const harness = await createTestAppHarness();
+ try {
+ const { host, session } = seedHostSession(harness.deps, {
+ id: "host-app-data-resync",
+ });
+ const { project } = seedProjectWithSource(harness.deps, {
+ hostId: host.id,
+ });
+ const environment = seedEnvironment(harness.deps, {
+ hostId: host.id,
+ projectId: project.id,
+ path: "/tmp/app-data-resync",
+ status: "ready",
+ });
+ const thread = seedThread(harness.deps, {
+ projectId: project.id,
+ environmentId: environment.id,
+ type: "manager",
+ });
+ const notifyThreadAppDataSpy = vi.spyOn(
+ harness.hub,
+ "notifyThreadAppData",
+ );
+
+ const response = await harness.app.request(
+ "/internal/session/app-data-resync",
+ {
+ method: "POST",
+ headers: internalAuthHeaders(harness),
+ body: JSON.stringify({
+ sessionId: session.id,
+ threadId: thread.id,
+ appId: "status",
+ }),
+ },
+ );
+
+ expect(response.status).toBe(200);
+ expect(notifyThreadAppDataSpy).toHaveBeenCalledWith({
+ type: "app-data.resync",
+ threadId: thread.id,
+ appId: "status",
+ });
} finally {
await harness.cleanup();
}
diff --git a/apps/server/test/public/public-thread-apps.test.ts b/apps/server/test/public/public-thread-apps.test.ts
new file mode 100644
index 000000000..eaeeedd58
--- /dev/null
+++ b/apps/server/test/public/public-thread-apps.test.ts
@@ -0,0 +1,1417 @@
+import { createHash } from "node:crypto";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import type { JsonValue } from "@bb/domain";
+import type { HostDaemonCommand } from "@bb/host-daemon-contract";
+import {
+ appDataListResponseSchema,
+ appDataReadResponseSchema,
+ appDetailSchema,
+ appSummarySchema,
+ type AppManifest,
+} from "@bb/server-contract";
+import { describe, expect, it, vi } from "vitest";
+import {
+ reportQueuedCommandError,
+ reportQueuedCommandSuccess,
+ waitForQueuedCommand,
+ waitForQueuedCommandAfter,
+ type QueuedCommand,
+} from "../helpers/commands.js";
+import { readJson } from "../helpers/json.js";
+import { createApp } from "../../src/server.js";
+import type { TestAppHarness } from "../helpers/test-app.js";
+import {
+ seedEnvironment,
+ seedHostSession,
+ seedProjectWithSource,
+ seedThread,
+} from "../helpers/seed.js";
+import { createTestAppHarness } from "../helpers/test-app.js";
+
+interface ManagerThreadStorageFixture {
+ hostId: string;
+ threadId: string;
+ storageRootPath: string;
+}
+
+interface ReadFileResultArgs {
+ content: string;
+ mimeType?: string;
+ path: string;
+}
+
+interface PathEntryArgs {
+ kind: "directory" | "file";
+ path: string;
+}
+
+interface ManifestReadArgs {
+ appId: string;
+ afterCursor?: number;
+ fixture: ManagerThreadStorageFixture;
+ harness: TestAppHarness;
+ manifest: JsonValue;
+}
+
+type WriteFileRelativeQueuedCommand = QueuedCommand<
+ Extract
+>;
+
+const STATUS_MANIFEST: AppManifest = {
+ manifestVersion: 1,
+ id: "status",
+ name: "Status",
+ icon: "ListTodo",
+ entry: "index.html",
+ contributions: ["thread.app"],
+ capabilities: ["data", "message"],
+};
+
+function seedManagerThreadStorage(
+ harness: TestAppHarness,
+): ManagerThreadStorageFixture {
+ const { host } = seedHostSession(harness.deps, {
+ id: "host-thread-apps",
+ });
+ 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,
+ type: "manager",
+ });
+ return {
+ hostId: host.id,
+ threadId: thread.id,
+ storageRootPath: `/tmp/bb-host-data/${host.id}/thread-storage/${thread.id}`,
+ };
+}
+
+function appRoot(fixture: ManagerThreadStorageFixture, appId: string): string {
+ return `${fixture.storageRootPath}/apps/${appId}`;
+}
+
+function appAssetsRoot(
+ fixture: ManagerThreadStorageFixture,
+ appId: string,
+): string {
+ return `${appRoot(fixture, appId)}/assets`;
+}
+
+function appDataRoot(
+ fixture: ManagerThreadStorageFixture,
+ appId: string,
+): string {
+ return `${appRoot(fixture, appId)}/data`;
+}
+
+function sha256Text(content: string): string {
+ return createHash("sha256").update(content).digest("hex");
+}
+
+function readFileResult(args: ReadFileResultArgs) {
+ return {
+ path: args.path,
+ content: args.content,
+ contentEncoding: "utf8" as const,
+ ...(args.mimeType ? { mimeType: args.mimeType } : {}),
+ sizeBytes: Buffer.byteLength(args.content),
+ };
+}
+
+function pathEntry(args: PathEntryArgs) {
+ return {
+ kind: args.kind,
+ path: args.path,
+ name: args.path.split("/").at(-1) ?? args.path,
+ score: 1,
+ positions: [],
+ };
+}
+
+function requireWriteFileRelativeCommand(
+ queued: QueuedCommand,
+): WriteFileRelativeQueuedCommand {
+ if (queued.command.type === "host.write_file_relative") {
+ return {
+ command: queued.command,
+ row: queued.row,
+ };
+ }
+ throw new Error("Expected host.write_file_relative command");
+}
+
+async function reportManifestRead(
+ args: ManifestReadArgs,
+): Promise {
+ const queued = args.afterCursor
+ ? await waitForQueuedCommandAfter(
+ args.harness,
+ args.afterCursor,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appRoot(args.fixture, args.appId) &&
+ command.path === "manifest.json",
+ )
+ : await waitForQueuedCommand(
+ args.harness,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appRoot(args.fixture, args.appId) &&
+ command.path === "manifest.json",
+ );
+ const content = `${JSON.stringify(args.manifest, null, 2)}\n`;
+ await reportQueuedCommandSuccess(
+ args.harness,
+ queued,
+ readFileResult({
+ path: "manifest.json",
+ content,
+ mimeType: "application/json",
+ }),
+ );
+ return queued;
+}
+
+describe("public thread app routes", () => {
+ it("lists app summaries from daemon-owned manifests", async () => {
+ const harness = await createTestAppHarness();
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const request = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps`,
+ );
+ const listCommand = await waitForQueuedCommand(
+ harness,
+ ({ command }) =>
+ command.type === "host.list_paths" &&
+ command.path === `${fixture.storageRootPath}/apps`,
+ );
+ await reportQueuedCommandSuccess(harness, listCommand, {
+ paths: [
+ pathEntry({ kind: "directory", path: "demo" }),
+ pathEntry({ kind: "directory", path: "status" }),
+ ],
+ truncated: false,
+ });
+ await reportManifestRead({
+ harness,
+ fixture,
+ appId: "demo",
+ afterCursor: listCommand.row.cursor,
+ manifest: {
+ ...STATUS_MANIFEST,
+ id: "demo",
+ name: "Demo",
+ icon: "GridView",
+ },
+ });
+ await reportManifestRead({
+ harness,
+ fixture,
+ appId: "status",
+ afterCursor: listCommand.row.cursor,
+ manifest: STATUS_MANIFEST,
+ });
+
+ const response = await request;
+ expect(response.status).toBe(200);
+ const apps = appSummarySchema.array().parse(await readJson(response));
+ expect(apps.map((app) => app.id)).toEqual(["demo", "status"]);
+ expect(apps[0]?.icon).toEqual({ kind: "builtin", name: "GridView" });
+ expect(apps[1]?.icon).toEqual({ kind: "builtin", name: "ListTodo" });
+ } finally {
+ await harness.cleanup();
+ }
+ });
+
+ it("skips invalid app manifests when listing app summaries", async () => {
+ const harness = await createTestAppHarness();
+ const warn = vi.spyOn(harness.deps.logger, "warn");
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const request = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps`,
+ );
+ const listCommand = await waitForQueuedCommand(
+ harness,
+ ({ command }) =>
+ command.type === "host.list_paths" &&
+ command.path === `${fixture.storageRootPath}/apps`,
+ );
+ await reportQueuedCommandSuccess(harness, listCommand, {
+ paths: [
+ pathEntry({ kind: "directory", path: "broken" }),
+ pathEntry({ kind: "directory", path: "status" }),
+ ],
+ truncated: false,
+ });
+ await reportManifestRead({
+ harness,
+ fixture,
+ appId: "broken",
+ afterCursor: listCommand.row.cursor,
+ manifest: {
+ ...STATUS_MANIFEST,
+ id: "broken",
+ name: "Broken",
+ icon: "NotAnIcon",
+ },
+ });
+ await reportManifestRead({
+ harness,
+ fixture,
+ appId: "status",
+ afterCursor: listCommand.row.cursor,
+ manifest: STATUS_MANIFEST,
+ });
+
+ const response = await request;
+ expect(response.status).toBe(200);
+ const apps = appSummarySchema.array().parse(await readJson(response));
+ expect(apps.map((app) => app.id)).toEqual(["status"]);
+ expect(warn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ appId: "broken",
+ manifestPath: `${appRoot(fixture, "broken")}/manifest.json`,
+ issueSummary: expect.stringContaining("icon"),
+ issues: expect.any(Array),
+ }),
+ "Skipping invalid thread app manifest",
+ );
+ } finally {
+ warn.mockRestore();
+ await harness.cleanup();
+ }
+ });
+
+ it("returns a provisioned-app error when app detail is missing manifest.json", async () => {
+ const harness = await createTestAppHarness();
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const request = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps/status`,
+ );
+ const manifestCommand = await waitForQueuedCommand(
+ harness,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appRoot(fixture, "status") &&
+ command.path === "manifest.json",
+ );
+ await reportQueuedCommandError(harness, manifestCommand, {
+ errorCode: "ENOENT",
+ errorMessage: "Path does not exist: manifest.json",
+ });
+
+ const response = await request;
+ expect(response.status).toBe(404);
+ await expect(readJson(response)).resolves.toMatchObject({
+ code: "app_not_provisioned",
+ message: expect.stringContaining("missing manifest.json"),
+ });
+ } finally {
+ await harness.cleanup();
+ }
+ });
+
+ it("returns an invalid-manifest error when app detail manifest validation fails", async () => {
+ const harness = await createTestAppHarness();
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const request = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps/broken`,
+ );
+ await reportManifestRead({
+ harness,
+ fixture,
+ appId: "broken",
+ manifest: {
+ ...STATUS_MANIFEST,
+ id: "broken",
+ name: "Broken",
+ icon: "NotAnIcon",
+ },
+ });
+
+ const response = await request;
+ expect(response.status).toBe(422);
+ const body = await readJson(response);
+ expect(body).toMatchObject({
+ code: "invalid_manifest",
+ message: expect.stringContaining("failed validation"),
+ });
+ expect(JSON.stringify(body)).not.toContain("NotAnIcon");
+ } finally {
+ await harness.cleanup();
+ }
+ });
+
+ it("returns an invalid-manifest error before serving app assets", async () => {
+ const harness = await createTestAppHarness();
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const request = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps/broken/index.html`,
+ );
+ await reportManifestRead({
+ harness,
+ fixture,
+ appId: "broken",
+ manifest: {
+ ...STATUS_MANIFEST,
+ id: "broken",
+ name: "Broken",
+ icon: "NotAnIcon",
+ },
+ });
+
+ const response = await request;
+ expect(response.status).toBe(422);
+ await expect(readJson(response)).resolves.toMatchObject({
+ code: "invalid_manifest",
+ message: expect.stringContaining("failed validation"),
+ });
+ } finally {
+ await harness.cleanup();
+ }
+ });
+
+ it("serves HTML app entries with capability-scoped window.bb injection", async () => {
+ const harness = await createTestAppHarness();
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const html =
+ "Status";
+ const request = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps/status/`,
+ );
+ const manifestCommand = await reportManifestRead({
+ harness,
+ fixture,
+ appId: "status",
+ manifest: STATUS_MANIFEST,
+ });
+ const metadataCommand = await waitForQueuedCommandAfter(
+ harness,
+ manifestCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.file_metadata" &&
+ command.path === `${appAssetsRoot(fixture, "status")}/index.html`,
+ );
+ await reportQueuedCommandSuccess(harness, metadataCommand, {
+ path: `${appAssetsRoot(fixture, "status")}/index.html`,
+ modifiedAtMs: 1234,
+ sizeBytes: Buffer.byteLength(html),
+ });
+ const entryCommand = await waitForQueuedCommandAfter(
+ harness,
+ metadataCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appAssetsRoot(fixture, "status") &&
+ command.path === "index.html",
+ );
+ await reportQueuedCommandSuccess(
+ harness,
+ entryCommand,
+ readFileResult({
+ path: "index.html",
+ content: html,
+ mimeType: "text/html",
+ }),
+ );
+
+ const response = await request;
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toBe(
+ "text/html; charset=utf-8",
+ );
+ const body = await response.text();
+ expect(body).toContain("data-bb-app-client");
+ expect(body).toContain("window.bb");
+ expect(body).toContain('"capabilities":["data","message"]');
+ expect(body).toContain("Status");
+ } finally {
+ await harness.cleanup();
+ }
+ });
+
+ it("serves flat app asset URLs from the internal assets directory", async () => {
+ const harness = await createTestAppHarness();
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const request = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps/status/index-Cd7sCqsN.js`,
+ );
+ const manifestCommand = await reportManifestRead({
+ harness,
+ fixture,
+ appId: "status",
+ manifest: STATUS_MANIFEST,
+ });
+ const assetCommand = await waitForQueuedCommandAfter(
+ harness,
+ manifestCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appAssetsRoot(fixture, "status") &&
+ command.path === "index-Cd7sCqsN.js",
+ );
+ expect(assetCommand.command).toMatchObject({ dotfiles: "deny" });
+ await reportQueuedCommandSuccess(
+ harness,
+ assetCommand,
+ readFileResult({
+ path: "index-Cd7sCqsN.js",
+ content: "console.log('status');",
+ mimeType: "application/javascript",
+ }),
+ );
+
+ const response = await request;
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toBe(
+ "application/javascript",
+ );
+ expect(response.headers.get("x-content-type-options")).toBe("nosniff");
+ await expect(response.text()).resolves.toBe("console.log('status');");
+ } finally {
+ await harness.cleanup();
+ }
+ });
+
+ it("serves nested flat app asset URLs without collapsing path segments", async () => {
+ const harness = await createTestAppHarness();
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const request = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps/status/chunks/index-Cd7sCqsN.js`,
+ );
+ const manifestCommand = await reportManifestRead({
+ harness,
+ fixture,
+ appId: "status",
+ manifest: STATUS_MANIFEST,
+ });
+ const assetCommand = await waitForQueuedCommandAfter(
+ harness,
+ manifestCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appAssetsRoot(fixture, "status") &&
+ command.path === "chunks/index-Cd7sCqsN.js",
+ );
+ await reportQueuedCommandSuccess(
+ harness,
+ assetCommand,
+ readFileResult({
+ path: "chunks/index-Cd7sCqsN.js",
+ content: "export const status = true;",
+ mimeType: "application/javascript",
+ }),
+ );
+
+ const response = await request;
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toBe(
+ "application/javascript",
+ );
+ await expect(response.text()).resolves.toBe(
+ "export const status = true;",
+ );
+ } finally {
+ await harness.cleanup();
+ }
+ });
+
+ it("returns JSON 404 for missing flat app assets instead of the outer SPA shell", async () => {
+ const staticDir = await mkdtemp(path.join(tmpdir(), "bb-apps-static-"));
+ await writeFile(
+ path.join(staticDir, "index.html"),
+ 'bbshell',
+ "utf8",
+ );
+ const harness = await createTestAppHarness();
+ const serverApp = createApp(harness.deps, { staticDir });
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const request = serverApp.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps/status/missing.js`,
+ );
+ const manifestCommand = await reportManifestRead({
+ harness,
+ fixture,
+ appId: "status",
+ manifest: STATUS_MANIFEST,
+ });
+ const assetCommand = await waitForQueuedCommandAfter(
+ harness,
+ manifestCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appAssetsRoot(fixture, "status") &&
+ command.path === "missing.js",
+ );
+ await reportQueuedCommandError(harness, assetCommand, {
+ errorCode: "ENOENT",
+ errorMessage: "Path does not exist: missing.js",
+ });
+
+ const response = await request;
+ const body = await response.text();
+ expect(response.status).toBe(404);
+ expect(response.headers.get("content-type")).toBe("application/json");
+ expect(body).not.toContain("bb-app-shell-root");
+ expect(JSON.parse(body)).toMatchObject({
+ code: "ENOENT",
+ message: "Path does not exist: missing.js",
+ });
+ } finally {
+ await serverApp.closeWebSockets();
+ await harness.cleanup();
+ await rm(staticDir, { recursive: true, force: true });
+ }
+ });
+
+ it("proxies app data list, read, write, and delete through generic daemon file commands", async () => {
+ const harness = await createTestAppHarness();
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const stateJson = `${JSON.stringify({ workers: [] }, null, 2)}\n`;
+
+ const listRequest = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps/status/data`,
+ );
+ const listManifest = await reportManifestRead({
+ harness,
+ fixture,
+ appId: "status",
+ manifest: STATUS_MANIFEST,
+ });
+ const listCommand = await waitForQueuedCommandAfter(
+ harness,
+ listManifest.row.cursor,
+ ({ command }) =>
+ command.type === "host.list_paths" &&
+ command.path === appDataRoot(fixture, "status"),
+ );
+ await reportQueuedCommandSuccess(harness, listCommand, {
+ paths: [pathEntry({ kind: "file", path: "state.json" })],
+ truncated: false,
+ });
+ const listRead = await waitForQueuedCommandAfter(
+ harness,
+ listCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appDataRoot(fixture, "status") &&
+ command.path === "state.json",
+ );
+ await reportQueuedCommandSuccess(
+ harness,
+ listRead,
+ readFileResult({
+ path: "state.json",
+ content: stateJson,
+ mimeType: "application/json",
+ }),
+ );
+ const listMetadata = await waitForQueuedCommandAfter(
+ harness,
+ listCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.file_metadata" &&
+ command.path === `${appDataRoot(fixture, "status")}/state.json`,
+ );
+ await reportQueuedCommandSuccess(harness, listMetadata, {
+ path: `${appDataRoot(fixture, "status")}/state.json`,
+ modifiedAtMs: 1234,
+ sizeBytes: Buffer.byteLength(stateJson),
+ });
+ const listResponse = await listRequest;
+ expect(listResponse.status).toBe(200);
+ expect(
+ appDataListResponseSchema.parse(await readJson(listResponse)),
+ ).toEqual({
+ entries: [
+ {
+ path: "state.json",
+ value: { workers: [] },
+ version: sha256Text(stateJson),
+ sizeBytes: Buffer.byteLength(stateJson),
+ modifiedAtMs: 1234,
+ },
+ ],
+ });
+
+ const readRequest = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps/status/data/state.json`,
+ );
+ const readManifest = await reportManifestRead({
+ harness,
+ fixture,
+ appId: "status",
+ afterCursor: listMetadata.row.cursor,
+ manifest: STATUS_MANIFEST,
+ });
+ const readCommand = await waitForQueuedCommandAfter(
+ harness,
+ readManifest.row.cursor,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appDataRoot(fixture, "status") &&
+ command.path === "state.json",
+ );
+ await reportQueuedCommandSuccess(
+ harness,
+ readCommand,
+ readFileResult({
+ path: "state.json",
+ content: stateJson,
+ mimeType: "application/json",
+ }),
+ );
+ const readMetadata = await waitForQueuedCommandAfter(
+ harness,
+ readManifest.row.cursor,
+ ({ command }) =>
+ command.type === "host.file_metadata" &&
+ command.path === `${appDataRoot(fixture, "status")}/state.json`,
+ );
+ await reportQueuedCommandSuccess(harness, readMetadata, {
+ path: `${appDataRoot(fixture, "status")}/state.json`,
+ modifiedAtMs: 2345,
+ sizeBytes: Buffer.byteLength(stateJson),
+ });
+ const readResponse = await readRequest;
+ expect(readResponse.status).toBe(200);
+ expect(
+ appDataReadResponseSchema.parse(await readJson(readResponse)),
+ ).toEqual({
+ path: "state.json",
+ value: { workers: [] },
+ version: sha256Text(stateJson),
+ sizeBytes: Buffer.byteLength(stateJson),
+ modifiedAtMs: 2345,
+ });
+
+ const nextValue = { workers: [{ id: "worker-1" }] };
+ const nextJson = `${JSON.stringify(nextValue, null, 2)}\n`;
+ const writeRequest = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps/status/data/state.json`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ value: nextValue }),
+ },
+ );
+ const writeManifest = await reportManifestRead({
+ harness,
+ fixture,
+ appId: "status",
+ afterCursor: readMetadata.row.cursor,
+ manifest: STATUS_MANIFEST,
+ });
+ const writeCommand = await waitForQueuedCommandAfter(
+ harness,
+ writeManifest.row.cursor,
+ ({ command }) =>
+ command.type === "host.write_file_relative" &&
+ command.rootPath === appDataRoot(fixture, "status") &&
+ command.path === "state.json",
+ );
+ expect(writeCommand.command).toMatchObject({
+ dotfiles: "deny",
+ content: nextJson,
+ contentEncoding: "utf8",
+ });
+ await reportQueuedCommandSuccess(harness, writeCommand, {
+ path: "state.json",
+ hash: sha256Text(nextJson),
+ modifiedAtMs: 3456,
+ sizeBytes: Buffer.byteLength(nextJson),
+ });
+ const writeResponse = await writeRequest;
+ expect(writeResponse.status).toBe(200);
+ expect(
+ appDataReadResponseSchema.parse(await readJson(writeResponse)),
+ ).toEqual({
+ path: "state.json",
+ value: nextValue,
+ version: sha256Text(nextJson),
+ sizeBytes: Buffer.byteLength(nextJson),
+ modifiedAtMs: 3456,
+ });
+
+ const deleteRequest = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps/status/data/state.json`,
+ { method: "DELETE" },
+ );
+ const deleteManifest = await reportManifestRead({
+ harness,
+ fixture,
+ appId: "status",
+ afterCursor: writeCommand.row.cursor,
+ manifest: STATUS_MANIFEST,
+ });
+ const deleteCommand = await waitForQueuedCommandAfter(
+ harness,
+ deleteManifest.row.cursor,
+ ({ command }) =>
+ command.type === "host.delete_file_relative" &&
+ command.rootPath === appDataRoot(fixture, "status") &&
+ command.path === "state.json",
+ );
+ await reportQueuedCommandSuccess(harness, deleteCommand, {
+ path: "state.json",
+ deleted: true,
+ previousHash: sha256Text(nextJson),
+ });
+ const deleteResponse = await deleteRequest;
+ expect(deleteResponse.status).toBe(200);
+ await expect(readJson(deleteResponse)).resolves.toEqual({ ok: true });
+ } finally {
+ await harness.cleanup();
+ }
+ });
+
+ it("lists app data subtree prefixes when the prefix is a directory", async () => {
+ const harness = await createTestAppHarness();
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const oneJson = `${JSON.stringify({ title: "One" }, null, 2)}\n`;
+ const twoJson = `${JSON.stringify({ title: "Two" }, null, 2)}\n`;
+
+ const request = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps/status/data?prefix=tasks`,
+ );
+ const manifestCommand = await reportManifestRead({
+ harness,
+ fixture,
+ appId: "status",
+ manifest: STATUS_MANIFEST,
+ });
+ const prefixRead = await waitForQueuedCommandAfter(
+ harness,
+ manifestCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appDataRoot(fixture, "status") &&
+ command.path === "tasks",
+ );
+ await reportQueuedCommandError(harness, prefixRead, {
+ errorCode: "invalid_path",
+ errorMessage: "Path is a directory, not a file",
+ });
+ const listCommand = await waitForQueuedCommandAfter(
+ harness,
+ prefixRead.row.cursor,
+ ({ command }) =>
+ command.type === "host.list_paths" &&
+ command.path === `${appDataRoot(fixture, "status")}/tasks`,
+ );
+ await reportQueuedCommandSuccess(harness, listCommand, {
+ paths: [
+ pathEntry({ kind: "file", path: "one.json" }),
+ pathEntry({ kind: "file", path: "nested/two.json" }),
+ ],
+ truncated: false,
+ });
+
+ const oneRead = await waitForQueuedCommandAfter(
+ harness,
+ listCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appDataRoot(fixture, "status") &&
+ command.path === "tasks/one.json",
+ );
+ await reportQueuedCommandSuccess(
+ harness,
+ oneRead,
+ readFileResult({
+ path: "tasks/one.json",
+ content: oneJson,
+ mimeType: "application/json",
+ }),
+ );
+ const twoRead = await waitForQueuedCommandAfter(
+ harness,
+ listCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appDataRoot(fixture, "status") &&
+ command.path === "tasks/nested/two.json",
+ );
+ await reportQueuedCommandSuccess(
+ harness,
+ twoRead,
+ readFileResult({
+ path: "tasks/nested/two.json",
+ content: twoJson,
+ mimeType: "application/json",
+ }),
+ );
+ const oneMetadata = await waitForQueuedCommandAfter(
+ harness,
+ oneRead.row.cursor,
+ ({ command }) =>
+ command.type === "host.file_metadata" &&
+ command.path === `${appDataRoot(fixture, "status")}/tasks/one.json`,
+ );
+ await reportQueuedCommandSuccess(harness, oneMetadata, {
+ path: `${appDataRoot(fixture, "status")}/tasks/one.json`,
+ modifiedAtMs: 1111,
+ sizeBytes: Buffer.byteLength(oneJson),
+ });
+ const twoMetadata = await waitForQueuedCommandAfter(
+ harness,
+ twoRead.row.cursor,
+ ({ command }) =>
+ command.type === "host.file_metadata" &&
+ command.path ===
+ `${appDataRoot(fixture, "status")}/tasks/nested/two.json`,
+ );
+ await reportQueuedCommandSuccess(harness, twoMetadata, {
+ path: `${appDataRoot(fixture, "status")}/tasks/nested/two.json`,
+ modifiedAtMs: 2222,
+ sizeBytes: Buffer.byteLength(twoJson),
+ });
+
+ const response = await request;
+ expect(response.status).toBe(200);
+ expect(appDataListResponseSchema.parse(await readJson(response))).toEqual(
+ {
+ entries: [
+ {
+ path: "tasks/nested/two.json",
+ value: { title: "Two" },
+ version: sha256Text(twoJson),
+ sizeBytes: Buffer.byteLength(twoJson),
+ modifiedAtMs: 2222,
+ },
+ {
+ path: "tasks/one.json",
+ value: { title: "One" },
+ version: sha256Text(oneJson),
+ sizeBytes: Buffer.byteLength(oneJson),
+ modifiedAtMs: 1111,
+ },
+ ],
+ },
+ );
+ } finally {
+ await harness.cleanup();
+ }
+ });
+
+ it("serves top-level logo icons and 404s built-in icons", async () => {
+ const harness = await createTestAppHarness();
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const logoManifest: AppManifest = {
+ manifestVersion: 1,
+ id: "demo",
+ name: "Demo",
+ entry: "index.html",
+ contributions: ["thread.app"],
+ capabilities: ["data", "message"],
+ };
+ const logoSvg = '';
+ const request = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps/demo/icon`,
+ );
+ const manifestCommand = await reportManifestRead({
+ harness,
+ fixture,
+ appId: "demo",
+ manifest: logoManifest,
+ });
+ const logoListCommand = await waitForQueuedCommandAfter(
+ harness,
+ manifestCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.list_paths" &&
+ command.path === appRoot(fixture, "demo"),
+ );
+ await reportQueuedCommandSuccess(harness, logoListCommand, {
+ paths: [pathEntry({ kind: "file", path: "logo.svg" })],
+ truncated: false,
+ });
+ const metadataCommand = await waitForQueuedCommandAfter(
+ harness,
+ logoListCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.file_metadata" &&
+ command.path === `${appRoot(fixture, "demo")}/logo.svg`,
+ );
+ await reportQueuedCommandSuccess(harness, metadataCommand, {
+ path: `${appRoot(fixture, "demo")}/logo.svg`,
+ modifiedAtMs: 4567,
+ sizeBytes: Buffer.byteLength(logoSvg),
+ });
+ const readCommand = await waitForQueuedCommandAfter(
+ harness,
+ metadataCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appRoot(fixture, "demo") &&
+ command.path === "logo.svg",
+ );
+ await reportQueuedCommandSuccess(
+ harness,
+ readCommand,
+ readFileResult({
+ path: "logo.svg",
+ content: logoSvg,
+ mimeType: "image/svg+xml",
+ }),
+ );
+ const response = await request;
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toBe("image/svg+xml");
+ await expect(response.text()).resolves.toBe(logoSvg);
+
+ const builtInRequest = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps/status/icon`,
+ );
+ await reportManifestRead({
+ harness,
+ fixture,
+ appId: "status",
+ afterCursor: readCommand.row.cursor,
+ manifest: STATUS_MANIFEST,
+ });
+ const builtInResponse = await builtInRequest;
+ expect(builtInResponse.status).toBe(404);
+ } finally {
+ await harness.cleanup();
+ }
+ });
+
+ it("does not serve logo symlinks omitted by the daemon path listing", async () => {
+ const harness = await createTestAppHarness();
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const logoManifest: AppManifest = {
+ manifestVersion: 1,
+ id: "demo",
+ name: "Demo",
+ entry: "index.html",
+ contributions: ["thread.app"],
+ capabilities: ["data", "message"],
+ };
+ const request = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps/demo/icon`,
+ );
+ const manifestCommand = await reportManifestRead({
+ harness,
+ fixture,
+ appId: "demo",
+ manifest: logoManifest,
+ });
+ const logoListCommand = await waitForQueuedCommandAfter(
+ harness,
+ manifestCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.list_paths" &&
+ command.path === appRoot(fixture, "demo"),
+ );
+ await reportQueuedCommandSuccess(harness, logoListCommand, {
+ paths: [pathEntry({ kind: "file", path: "manifest.json" })],
+ truncated: false,
+ });
+
+ const response = await request;
+ expect(response.status).toBe(404);
+ } finally {
+ await harness.cleanup();
+ }
+ });
+
+ it("scaffolds status-template apps through the server lifecycle route", async () => {
+ const harness = await createTestAppHarness();
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const request = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ id: "demo",
+ name: "Demo",
+ template: "status",
+ }),
+ },
+ );
+ const existingCommand = await waitForQueuedCommand(
+ harness,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appRoot(fixture, "demo") &&
+ command.path === "manifest.json",
+ );
+ await reportQueuedCommandError(harness, existingCommand, {
+ errorCode: "ENOENT",
+ errorMessage: "Path does not exist: manifest.json",
+ });
+
+ const manifestWrite = await waitForQueuedCommandAfter(
+ harness,
+ existingCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.write_file_relative" &&
+ command.rootPath === appRoot(fixture, "demo") &&
+ command.path === "manifest.json",
+ );
+ expect(manifestWrite.command).toMatchObject({
+ dotfiles: "deny",
+ contentEncoding: "utf8",
+ });
+ const manifestWriteCommand =
+ requireWriteFileRelativeCommand(manifestWrite);
+ await reportQueuedCommandSuccess(harness, manifestWrite, {
+ path: "manifest.json",
+ hash: sha256Text(manifestWriteCommand.command.content),
+ modifiedAtMs: 1000,
+ sizeBytes: Buffer.byteLength(manifestWriteCommand.command.content),
+ });
+
+ const htmlWrite = await waitForQueuedCommandAfter(
+ harness,
+ manifestWrite.row.cursor,
+ ({ command }) =>
+ command.type === "host.write_file_relative" &&
+ command.rootPath === appRoot(fixture, "demo") &&
+ command.path === "assets/index.html",
+ );
+ const htmlWriteCommand = requireWriteFileRelativeCommand(htmlWrite);
+ await reportQueuedCommandSuccess(harness, htmlWrite, {
+ path: "assets/index.html",
+ hash: sha256Text(htmlWriteCommand.command.content),
+ modifiedAtMs: 1001,
+ sizeBytes: Buffer.byteLength(htmlWriteCommand.command.content),
+ });
+
+ const stateWrite = await waitForQueuedCommandAfter(
+ harness,
+ htmlWrite.row.cursor,
+ ({ command }) =>
+ command.type === "host.write_file_relative" &&
+ command.rootPath === appRoot(fixture, "demo") &&
+ command.path === "data/state.json",
+ );
+ const stateWriteCommand = requireWriteFileRelativeCommand(stateWrite);
+ await reportQueuedCommandSuccess(harness, stateWrite, {
+ path: "data/state.json",
+ hash: sha256Text(stateWriteCommand.command.content),
+ modifiedAtMs: 1002,
+ sizeBytes: Buffer.byteLength(stateWriteCommand.command.content),
+ });
+
+ await reportManifestRead({
+ harness,
+ fixture,
+ appId: "demo",
+ afterCursor: stateWrite.row.cursor,
+ manifest: {
+ manifestVersion: 1,
+ id: "demo",
+ name: "Demo",
+ icon: "ListTodo",
+ entry: "index.html",
+ contributions: ["thread.app"],
+ capabilities: ["data", "message"],
+ },
+ });
+
+ const response = await request;
+ expect(response.status).toBe(201);
+ expect(appDetailSchema.parse(await readJson(response))).toMatchObject({
+ id: "demo",
+ name: "Demo",
+ entry: { kind: "html", path: "index.html" },
+ icon: { kind: "builtin", name: "ListTodo" },
+ capabilities: ["data", "message"],
+ });
+ } finally {
+ await harness.cleanup();
+ }
+ });
+
+ it("scaffolds blank-template apps with the bb-styled index.html", async () => {
+ const harness = await createTestAppHarness();
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const request = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ id: "blank-demo",
+ name: "Blank Demo",
+ template: "blank",
+ }),
+ },
+ );
+ const existingCommand = await waitForQueuedCommand(
+ harness,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appRoot(fixture, "blank-demo") &&
+ command.path === "manifest.json",
+ );
+ await reportQueuedCommandError(harness, existingCommand, {
+ errorCode: "ENOENT",
+ errorMessage: "Path does not exist: manifest.json",
+ });
+
+ const manifestWrite = await waitForQueuedCommandAfter(
+ harness,
+ existingCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.write_file_relative" &&
+ command.rootPath === appRoot(fixture, "blank-demo") &&
+ command.path === "manifest.json",
+ );
+ const manifestWriteCommand =
+ requireWriteFileRelativeCommand(manifestWrite);
+ await reportQueuedCommandSuccess(harness, manifestWrite, {
+ path: "manifest.json",
+ hash: sha256Text(manifestWriteCommand.command.content),
+ modifiedAtMs: 1000,
+ sizeBytes: Buffer.byteLength(manifestWriteCommand.command.content),
+ });
+
+ const htmlWrite = await waitForQueuedCommandAfter(
+ harness,
+ manifestWrite.row.cursor,
+ ({ command }) =>
+ command.type === "host.write_file_relative" &&
+ command.rootPath === appRoot(fixture, "blank-demo") &&
+ command.path === "assets/index.html",
+ );
+ const htmlWriteCommand = requireWriteFileRelativeCommand(htmlWrite);
+ const html = htmlWriteCommand.command.content;
+ // bb design tokens come through verbatim from `bb guide styling`.
+ expect(html).toContain('--font-sans: "Inter"');
+ expect(html).toContain("oklch(0.9551 0 0)");
+ expect(html).toContain("@media (prefers-color-scheme: dark)");
+ // Placeholder copy invites the user to extend the scaffold via their agent.
+ expect(html).toContain(
+ "Ask your agent to customize the status app how you please.",
+ );
+ // Task-list row vocabulary is present so the scaffold looks bb-native.
+ expect(html).toContain('class="row"');
+ expect(html).toContain('class="pill"');
+ // App name is interpolated into the visible title.
+ expect(html).toContain("Blank Demo");
+ await reportQueuedCommandSuccess(harness, htmlWrite, {
+ path: "assets/index.html",
+ hash: sha256Text(html),
+ modifiedAtMs: 1001,
+ sizeBytes: Buffer.byteLength(html),
+ });
+
+ const stateWrite = await waitForQueuedCommandAfter(
+ harness,
+ htmlWrite.row.cursor,
+ ({ command }) =>
+ command.type === "host.write_file_relative" &&
+ command.rootPath === appRoot(fixture, "blank-demo") &&
+ command.path === "data/state.json",
+ );
+ const stateWriteCommand = requireWriteFileRelativeCommand(stateWrite);
+ await reportQueuedCommandSuccess(harness, stateWrite, {
+ path: "data/state.json",
+ hash: sha256Text(stateWriteCommand.command.content),
+ modifiedAtMs: 1002,
+ sizeBytes: Buffer.byteLength(stateWriteCommand.command.content),
+ });
+
+ const manifestRead = await reportManifestRead({
+ harness,
+ fixture,
+ appId: "blank-demo",
+ afterCursor: stateWrite.row.cursor,
+ manifest: {
+ manifestVersion: 1,
+ id: "blank-demo",
+ name: "Blank Demo",
+ entry: "index.html",
+ contributions: ["thread.app"],
+ capabilities: ["data", "message"],
+ },
+ });
+
+ // No icon in manifest -> server probes the app root for a logo file.
+ const logoListCommand = await waitForQueuedCommandAfter(
+ harness,
+ manifestRead.row.cursor,
+ ({ command }) =>
+ command.type === "host.list_paths" &&
+ command.path === appRoot(fixture, "blank-demo"),
+ );
+ await reportQueuedCommandSuccess(harness, logoListCommand, {
+ paths: [],
+ truncated: false,
+ });
+
+ const response = await request;
+ expect(response.status).toBe(201);
+ expect(appDetailSchema.parse(await readJson(response))).toMatchObject({
+ id: "blank-demo",
+ name: "Blank Demo",
+ entry: { kind: "html", path: "index.html" },
+ icon: { kind: "builtin", name: "GridView" },
+ capabilities: ["data", "message"],
+ });
+ } finally {
+ await harness.cleanup();
+ }
+ });
+
+ it("HTML-escapes the app name in the blank scaffold to block XSS", async () => {
+ const harness = await createTestAppHarness();
+ try {
+ const fixture = seedManagerThreadStorage(harness);
+ const maliciousName = ` & "q" 'a'`;
+ const request = harness.app.request(
+ `/api/v1/threads/${fixture.threadId}/apps`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ id: "xss-demo",
+ name: maliciousName,
+ template: "blank",
+ }),
+ },
+ );
+ const existingCommand = await waitForQueuedCommand(
+ harness,
+ ({ command }) =>
+ command.type === "host.read_file_relative" &&
+ command.rootPath === appRoot(fixture, "xss-demo") &&
+ command.path === "manifest.json",
+ );
+ await reportQueuedCommandError(harness, existingCommand, {
+ errorCode: "ENOENT",
+ errorMessage: "Path does not exist: manifest.json",
+ });
+
+ const manifestWrite = await waitForQueuedCommandAfter(
+ harness,
+ existingCommand.row.cursor,
+ ({ command }) =>
+ command.type === "host.write_file_relative" &&
+ command.rootPath === appRoot(fixture, "xss-demo") &&
+ command.path === "manifest.json",
+ );
+ const manifestWriteCommand =
+ requireWriteFileRelativeCommand(manifestWrite);
+ await reportQueuedCommandSuccess(harness, manifestWrite, {
+ path: "manifest.json",
+ hash: sha256Text(manifestWriteCommand.command.content),
+ modifiedAtMs: 2000,
+ sizeBytes: Buffer.byteLength(manifestWriteCommand.command.content),
+ });
+
+ const htmlWrite = await waitForQueuedCommandAfter(
+ harness,
+ manifestWrite.row.cursor,
+ ({ command }) =>
+ command.type === "host.write_file_relative" &&
+ command.rootPath === appRoot(fixture, "xss-demo") &&
+ command.path === "assets/index.html",
+ );
+ const htmlWriteCommand = requireWriteFileRelativeCommand(htmlWrite);
+ const html = htmlWriteCommand.command.content;
+
+ // Raw special characters from the name must never reach the rendered
+ // HTML where they would be interpreted as markup.
+ expect(html).not.toContain("");
+ expect(html).not.toContain('name & "q"');
+ expect(html).not.toContain("'a'");
+ // Each special char in the name is replaced with its entity form.
+ expect(html).toContain("<script>alert(1)</script>");
+ expect(html).toContain("&");
+ expect(html).toContain(""q"");
+ expect(html).toContain("'a'");
+ // The escaped name shows up in both the and the visible header.
+ const escapedName =
+ "<script>alert(1)</script> & "q" 'a'";
+ expect(html).toContain(`${escapedName}`);
+ expect(html).toContain(
+ `${escapedName}`,
+ );
+
+ await reportQueuedCommandSuccess(harness, htmlWrite, {
+ path: "assets/index.html",
+ hash: sha256Text(html),
+ modifiedAtMs: 2001,
+ sizeBytes: Buffer.byteLength(html),
+ });
+
+ const stateWrite = await waitForQueuedCommandAfter(
+ harness,
+ htmlWrite.row.cursor,
+ ({ command }) =>
+ command.type === "host.write_file_relative" &&
+ command.rootPath === appRoot(fixture, "xss-demo") &&
+ command.path === "data/state.json",
+ );
+ const stateWriteCommand = requireWriteFileRelativeCommand(stateWrite);
+ await reportQueuedCommandSuccess(harness, stateWrite, {
+ path: "data/state.json",
+ hash: sha256Text(stateWriteCommand.command.content),
+ modifiedAtMs: 2002,
+ sizeBytes: Buffer.byteLength(stateWriteCommand.command.content),
+ });
+
+ const manifestRead = await reportManifestRead({
+ harness,
+ fixture,
+ appId: "xss-demo",
+ afterCursor: stateWrite.row.cursor,
+ manifest: {
+ manifestVersion: 1,
+ id: "xss-demo",
+ name: maliciousName,
+ entry: "index.html",
+ contributions: ["thread.app"],
+ capabilities: ["data", "message"],
+ },
+ });
+
+ const logoListCommand = await waitForQueuedCommandAfter(
+ harness,
+ manifestRead.row.cursor,
+ ({ command }) =>
+ command.type === "host.list_paths" &&
+ command.path === appRoot(fixture, "xss-demo"),
+ );
+ await reportQueuedCommandSuccess(harness, logoListCommand, {
+ paths: [],
+ truncated: false,
+ });
+
+ const response = await request;
+ expect(response.status).toBe(201);
+ } finally {
+ await harness.cleanup();
+ }
+ });
+});
diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts
index 8790c26b7..0f6ad15aa 100644
--- a/apps/server/test/public/public-thread-data.test.ts
+++ b/apps/server/test/public/public-thread-data.test.ts
@@ -1,5 +1,4 @@
-import { and, desc, eq } from "drizzle-orm";
-import { createHash } from "node:crypto";
+import { and, eq } from "drizzle-orm";
import {
archiveThread,
claimQueuedThreadMessage,
@@ -12,7 +11,6 @@ import {
getQueuedThreadMessage,
listQueuedThreadMessages,
getThread,
- hostDaemonCommands,
queuedThreadMessages,
} from "@bb/db";
import {
@@ -20,21 +18,12 @@ import {
threadQueuedMessageSchema,
threadScope,
threadSchema,
- turnRequestEventDataSchema,
- type TurnRequestEventData,
turnScope,
} from "@bb/domain";
import {
- type BbStatusState,
- type BbThreadTell,
- statusIframeThreadTellRequestSchema,
- type ThreadStatusVersionResponse,
- threadStatusDataGetResponseSchema,
- threadStatusDataListResponseSchema,
type TimelineRow,
threadComposerBootstrapResponseSchema,
threadQueuedMessageListResponseSchema,
- threadStatusVersionResponseSchema,
threadTimelineResponseSchema,
threadWithIncludesResponseSchema,
timelineTurnSummaryDetailsResponseSchema,
@@ -42,14 +31,12 @@ import {
import { z } from "zod";
import { describe, expect, it, vi } from "vitest";
import {
- type QueuedCommand,
reportQueuedCommandError,
reportQueuedCommandSuccess,
waitForQueuedCommand,
waitForQueuedCommandAfter,
} from "../helpers/commands.js";
import { readJson } from "../helpers/json.js";
-import type { TestAppHarness } from "../helpers/test-app.js";
import {
seedQueuedMessage,
seedEnvironment,
@@ -78,183 +65,6 @@ const threadEventWaitResponseSchema = z.object({
type TimelineTurnRow = Extract;
-interface ManagerThreadStorageFixture {
- environmentId: string;
- hostId: string;
- threadId: string;
- storageRootPath: string;
-}
-
-interface StatusTellScriptWindow {
- bbStatusState?: BbStatusState;
- bbThreadTell?: BbThreadTell;
- crypto: {
- randomUUID(): string;
- };
-}
-
-interface ExecuteInjectedStatusClientScriptArgs {
- fetch: typeof fetch;
- html: string;
- window: StatusTellScriptWindow;
-}
-
-interface StatusTellFetchCall {
- init: RequestInit | undefined;
- input: string;
-}
-
-interface MockSocket {
- messages: string[];
- close(code?: number, reason?: string): void;
- send(data: string): void;
-}
-
-class StatusTellNoopWebSocket {
- constructor(readonly url: string) {}
-}
-
-function createMockSocket(): MockSocket {
- const messages: string[] = [];
- return {
- messages,
- close() {},
- send(data: string) {
- messages.push(data);
- },
- };
-}
-
-function seedManagerThreadStorage(
- harness: TestAppHarness,
-): ManagerThreadStorageFixture {
- 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,
- type: "manager",
- });
- return {
- environmentId: environment.id,
- hostId: host.id,
- threadId: thread.id,
- storageRootPath: `/tmp/bb-host-data/${host.id}/thread-storage/${thread.id}`,
- };
-}
-
-function sha256Text(content: string): string {
- return createHash("sha256").update(content).digest("hex");
-}
-
-function extractInjectedStatusClientScript(html: string): string {
- const match =
- /";
- const injected = injectStatusStateClientScript(html, bootstrap);
-
- expect(injected.indexOf("data-bb-status-state-client")).toBeLessThan(
- injected.indexOf("window.userRan"),
- );
- expect(injected).toContain("window.bbStatusState");
- expect(injected).toContain("window.bbThreadTell");
- });
-
- it("installs bbThreadTell and posts text to the owning thread send route", async () => {
- const calls: FetchCall[] = [];
- const fetchMock: typeof fetch = async (input, init) => {
- if (typeof input !== "string") {
- throw new Error("Expected string URL");
- }
- calls.push({ input, init });
- return jsonResponse({ ok: true });
- };
- const windowObject: ScriptWindow = {
- bbStatusState: existingStatusState(),
- crypto: { randomUUID: () => "op-from-crypto" },
- };
-
- executeScript({
- html: injectStatusStateClientScript(
- "",
- bootstrap,
- ),
- window: windowObject,
- fetch: fetchMock,
- });
-
- await requireBbThreadTell(windowObject)("hello from iframe");
-
- expect(calls).toHaveLength(1);
- const call = calls[0];
- expect(call.input).toBe("/api/v1/threads/thread-1/send");
- expect(call.init?.method).toBe("POST");
- expect(call.init?.credentials).toBe("same-origin");
- const headers = new Headers(call.init?.headers);
- expect(headers.get("accept")).toBe("application/json");
- expect(headers.get("content-type")).toBe("application/json");
- expect(
- statusIframeThreadTellRequestSchema.parse(
- JSON.parse(requireStringRequestBody(call.init)),
- ),
- ).toEqual({
- input: [{ type: "text", text: "hello from iframe" }],
- mode: "auto",
- });
- });
-
- it("throws bbThreadTell non-string input errors synchronously", () => {
- const calls: FetchCall[] = [];
- const fetchMock: typeof fetch = async (input, init) => {
- if (typeof input !== "string") {
- throw new Error("Expected string URL");
- }
- calls.push({ input, init });
- return jsonResponse({ ok: true });
- };
- const windowObject: ScriptWindow = {
- bbStatusState: existingStatusState(),
- crypto: { randomUUID: () => "op-from-crypto" },
- };
-
- executeScript({
- html: injectStatusStateClientScript(
- "",
- bootstrap,
- ),
- window: windowObject,
- fetch: fetchMock,
- });
-
- expect(() => {
- // @ts-expect-error Runtime validation intentionally rejects bad callers.
- requireBbThreadTell(windowObject)(123);
- }).toThrow(TypeError);
- expect(calls).toHaveLength(0);
- });
-
- it("rejects bbThreadTell with the server 4xx message and error metadata", async () => {
- const fetchMock: typeof fetch = async () =>
- new Response(
- JSON.stringify({
- code: "invalid_request",
- message: "Thread is archived",
- retryable: false,
- }),
- { status: 409, headers: { "content-type": "application/json" } },
- );
- const windowObject: ScriptWindow = {
- bbStatusState: existingStatusState(),
- crypto: { randomUUID: () => "op-from-crypto" },
- };
-
- executeScript({
- html: injectStatusStateClientScript(
- "",
- bootstrap,
- ),
- window: windowObject,
- fetch: fetchMock,
- });
-
- await expect(
- requireBbThreadTell(windowObject)("ping"),
- ).rejects.toMatchObject({
- code: "invalid_request",
- message: "Thread is archived",
- retryable: false,
- status: 409,
- });
- });
-
- it("rejects bbThreadTell 5xx responses with a generic server error", async () => {
- const fetchMock: typeof fetch = async () =>
- new Response(
- JSON.stringify({
- code: "internal_error",
- message: "database detail that should not leak",
- }),
- { status: 503, headers: { "content-type": "application/json" } },
- );
- const windowObject: ScriptWindow = {
- bbStatusState: existingStatusState(),
- crypto: { randomUUID: () => "op-from-crypto" },
- };
-
- executeScript({
- html: injectStatusStateClientScript(
- "",
- bootstrap,
- ),
- window: windowObject,
- fetch: fetchMock,
- });
-
- await expect(
- requireBbThreadTell(windowObject)("ping"),
- ).rejects.toMatchObject({
- message: "bbThreadTell failed: server error (503)",
- status: 503,
- });
- });
-
- it("hydrates, fires immediate listeners, writes optimistically, and reconciles broadcasts", async () => {
- let operationId = "";
- const fetchMock = vi.fn(
- async (_input: RequestInfo | URL, init?: RequestInit) => {
- if (!init?.method) {
- return new Response(
- JSON.stringify({
- values: { todos: ["seed"] },
- versions: { todos: "v1" },
- hash: "list-hash",
- }),
- { status: 200, headers: { "content-type": "application/json" } },
- );
- }
-
- operationId =
- new Headers(init.headers).get("x-bb-status-state-operation") ?? "";
- return new Response(
- JSON.stringify({
- key: "todos",
- value: ["next"],
- version: "v2",
- sizeBytes: 9,
- modifiedAtMs: 10,
- }),
- { status: 200, headers: { "content-type": "application/json" } },
- );
- },
- );
- const windowObject: ScriptWindow = {
- crypto: { randomUUID: () => "op-from-crypto" },
- };
-
- executeScript({
- html: injectStatusStateClientScript(
- "",
- bootstrap,
- ),
- window: windowObject,
- fetch: fetchMock,
- });
- const state = windowObject.bbStatusState;
- if (!state) {
- throw new Error("bbStatusState was not installed");
- }
-
- const calls: CallbackCall[] = [];
- state.on("todos", (newValue, prevValue, key, event) => {
- calls.push({ newValue, prevValue, key, event });
- });
-
- const listed = await state.list();
- expect(listed.todos).toEqual(["seed"]);
- expect(calls).toEqual([
- {
- newValue: ["seed"],
- prevValue: undefined,
- key: "todos",
- event: {
- source: "remote",
- operation: "hydrate",
- optimistic: false,
- version: "v1",
- error: null,
- },
- },
- ]);
-
- await state.set("todos", ["next"]);
- expect(calls[1]).toEqual({
- newValue: ["next"],
- prevValue: ["seed"],
- key: "todos",
- event: {
- source: "local",
- operation: "set",
- optimistic: true,
- version: null,
- error: null,
- },
- });
-
- const socket = FakeWebSocket.instances[0];
- expect(socket.url).toBe("ws://localhost:3334/ws");
- expect(JSON.parse(socket.sent[0])).toEqual({
- type: "subscribe",
- entity: "thread",
- id: "thread-1:status-data",
- });
- socket.emit(
- JSON.stringify({
- type: "status-data.changed",
- threadId: "thread-1",
- key: "todos",
- value: ["next"],
- deleted: false,
- previousValue: ["seed"],
- previousValuePresent: true,
- version: "v2",
- writerClientId: "client",
- operationId,
- }),
- );
- expect(calls).toHaveLength(2);
-
- const immediateCalls: CallbackCall[] = [];
- state.on("todos", (newValue, prevValue, key, event) => {
- immediateCalls.push({ newValue, prevValue, key, event });
- });
- expect(immediateCalls).toEqual([
- {
- newValue: ["next"],
- prevValue: undefined,
- key: "todos",
- event: {
- source: "remote",
- operation: "hydrate",
- optimistic: false,
- version: "v2",
- error: null,
- },
- },
- ]);
- });
-
- it("replays broadcasts after an in-flight hydration snapshot so newer realtime values win", async () => {
- const listDeferred = createDeferredResponse();
- const fetchMock = vi.fn(async () => listDeferred.promise);
- const windowObject: ScriptWindow = {
- crypto: { randomUUID: () => "op-from-crypto" },
- };
-
- executeScript({
- html: injectStatusStateClientScript(
- "",
- bootstrap,
- ),
- window: windowObject,
- fetch: fetchMock,
- });
- const state = windowObject.bbStatusState;
- if (!state) {
- throw new Error("bbStatusState was not installed");
- }
- const calls: CallbackCall[] = [];
- state.on("todos", (newValue, prevValue, key, event) => {
- calls.push({ newValue, prevValue, key, event });
- });
-
- await Promise.resolve();
- FakeWebSocket.instances[0].emit(
- JSON.stringify(
- makeBroadcast({
- key: "todos",
- value: ["broadcast"],
- version: "v2",
- }),
- ),
- );
- listDeferred.resolve(
- listResponse({
- values: { todos: ["stale"] },
- versions: { todos: "v1" },
- hash: "list-hash",
- }),
- );
-
- const listed = await state.list();
- expect(listed.todos).toEqual(["broadcast"]);
- expect(calls.map((call) => call.newValue)).toEqual([
- ["stale"],
- ["broadcast"],
- ]);
- expect(calls.at(-1)?.event).toEqual({
- source: "remote",
- operation: "set",
- optimistic: false,
- version: "v2",
- error: null,
- });
- });
-
- it("resyncs changed, added, and deleted keys after reconnect", async () => {
- vi.useFakeTimers();
- const snapshots: ThreadStatusDataListResponse[] = [
- {
- values: { todos: ["old"], removed: true },
- versions: { todos: "v1", removed: "remove-v1" },
- hash: "initial-hash",
- },
- {
- values: { todos: ["new"], extra: 1 },
- versions: { todos: "v2", extra: "extra-v1" },
- hash: "resync-hash",
- },
- ];
- const fetchMock = vi.fn(async () => {
- const next = snapshots.shift();
- if (!next) {
- throw new Error("Unexpected list request");
- }
- return listResponse(next);
- });
- const windowObject: ScriptWindow = {
- crypto: { randomUUID: () => "op-from-crypto" },
- };
-
- executeScript({
- html: injectStatusStateClientScript(
- "",
- bootstrap,
- ),
- window: windowObject,
- fetch: fetchMock,
- });
- const state = windowObject.bbStatusState;
- if (!state) {
- throw new Error("bbStatusState was not installed");
- }
- const calls: CallbackCall[] = [];
- state.on("*", (newValue, prevValue, key, event) => {
- calls.push({ newValue, prevValue, key, event });
- });
- await state.list();
-
- FakeWebSocket.instances[0].close();
- await vi.advanceTimersByTimeAsync(1000);
- await Promise.resolve();
- await Promise.resolve();
- await state.list();
-
- expect(fetchMock).toHaveBeenCalledTimes(2);
- expect(await state.get("todos")).toEqual(["new"]);
- expect(await state.get("extra")).toBe(1);
- expect(await state.get("removed")).toBeUndefined();
- expect(calls.slice(2)).toEqual([
- {
- newValue: ["new"],
- prevValue: ["old"],
- key: "todos",
- event: {
- source: "remote",
- operation: "resync",
- optimistic: false,
- version: "v2",
- error: null,
- },
- },
- {
- newValue: 1,
- prevValue: undefined,
- key: "extra",
- event: {
- source: "remote",
- operation: "resync",
- optimistic: false,
- version: "extra-v1",
- error: null,
- },
- },
- {
- newValue: undefined,
- prevValue: true,
- key: "removed",
- event: {
- source: "remote",
- operation: "resync",
- optimistic: false,
- version: null,
- error: null,
- },
- },
- ]);
- });
-
- it("fires wildcard hydration once per existing key when registered after hydration", async () => {
- const fetchMock = vi.fn(async () =>
- listResponse({
- values: { todos: ["seed"], filters: { done: false } },
- versions: { todos: "v1", filters: "v2" },
- hash: "list-hash",
- }),
- );
- const windowObject: ScriptWindow = {
- crypto: { randomUUID: () => "op-from-crypto" },
- };
-
- executeScript({
- html: injectStatusStateClientScript(
- "",
- bootstrap,
- ),
- window: windowObject,
- fetch: fetchMock,
- });
- const state = windowObject.bbStatusState;
- if (!state) {
- throw new Error("bbStatusState was not installed");
- }
- await state.list();
-
- const calls: CallbackCall[] = [];
- state.on("*", (newValue, prevValue, key, event) => {
- calls.push({ newValue, prevValue, key, event });
- });
-
- expect(calls).toEqual([
- {
- newValue: ["seed"],
- prevValue: undefined,
- key: "todos",
- event: {
- source: "remote",
- operation: "hydrate",
- optimistic: false,
- version: "v1",
- error: null,
- },
- },
- {
- newValue: { done: false },
- prevValue: undefined,
- key: "filters",
- event: {
- source: "remote",
- operation: "hydrate",
- optimistic: false,
- version: "v2",
- error: null,
- },
- },
- ]);
- });
-
- it("reverts optimistic set state and emits a revert event when the write fails", async () => {
- const fetchMock = vi.fn(
- async (_input: RequestInfo | URL, init?: RequestInit) => {
- if (!init?.method) {
- return listResponse({
- values: { todos: ["seed"] },
- versions: { todos: "v1" },
- hash: "list-hash",
- });
- }
- return new Response("write failed", { status: 500 });
- },
- );
- const windowObject: ScriptWindow = {
- crypto: { randomUUID: () => "op-from-crypto" },
- };
-
- executeScript({
- html: injectStatusStateClientScript(
- "",
- bootstrap,
- ),
- window: windowObject,
- fetch: fetchMock,
- });
- const state = windowObject.bbStatusState;
- if (!state) {
- throw new Error("bbStatusState was not installed");
- }
- const calls: CallbackCall[] = [];
- state.on("todos", (newValue, prevValue, key, event) => {
- calls.push({ newValue, prevValue, key, event });
- });
- await state.list();
-
- await expect(state.set("todos", ["next"])).rejects.toThrow("write failed");
-
- expect(await state.get("todos")).toEqual(["seed"]);
- expect(calls.slice(1)).toEqual([
- {
- newValue: ["next"],
- prevValue: ["seed"],
- key: "todos",
- event: {
- source: "local",
- operation: "set",
- optimistic: true,
- version: null,
- error: null,
- },
- },
- {
- newValue: ["seed"],
- prevValue: ["next"],
- key: "todos",
- event: {
- source: "local",
- operation: "revert",
- optimistic: false,
- version: "v1",
- error: "write failed",
- },
- },
- ]);
- });
-});
diff --git a/apps/server/test/threads/manager-storage-templates.test.ts b/apps/server/test/threads/manager-storage-templates.test.ts
index fb657a6f5..c2808dc73 100644
--- a/apps/server/test/threads/manager-storage-templates.test.ts
+++ b/apps/server/test/threads/manager-storage-templates.test.ts
@@ -1,7 +1,6 @@
import {
mkdir,
mkdtemp,
- readdir,
readFile,
rm,
stat,
@@ -19,6 +18,7 @@ import {
managerTemplateRootPath,
seedManagerThreadStorage,
} from "../../src/services/threads/manager-storage-templates.js";
+import { buildBlankAppIndexHtml } from "../../src/services/threads/blank-app-scaffold.js";
import type { TestAppHarness } from "../helpers/test-app.js";
import { seedHost } from "../helpers/seed.js";
import { createTestAppHarness, testLogger } from "../helpers/test-app.js";
@@ -76,16 +76,42 @@ async function createSeedHarness(): Promise {
};
}
-async function readBundledStatusTemplate(): Promise {
+async function readBundledManifestJson(): Promise {
return readFile(
new URL(
- "../../src/services/threads/default-template/STATUS.html",
+ "../../src/services/threads/default-template/apps/status/manifest.json",
import.meta.url,
),
"utf8",
);
}
+const BUNDLED_STATUS_INDEX_HTML = buildBlankAppIndexHtml({ name: "Status" });
+const BUNDLED_STATUS_STATE_JSON = "{}\n";
+
+async function expectBundledStatusAppSeeded(
+ threadStoragePath: string,
+): Promise {
+ await expect(
+ readFile(
+ path.join(threadStoragePath, "apps/status/manifest.json"),
+ "utf8",
+ ),
+ ).resolves.toBe(await readBundledManifestJson());
+ await expect(
+ readFile(
+ path.join(threadStoragePath, "apps/status/assets/index.html"),
+ "utf8",
+ ),
+ ).resolves.toBe(BUNDLED_STATUS_INDEX_HTML);
+ await expect(
+ readFile(
+ path.join(threadStoragePath, "apps/status/data/state.json"),
+ "utf8",
+ ),
+ ).resolves.toBe(BUNDLED_STATUS_STATE_JSON);
+}
+
async function writeManagerTemplateSet(
args: WriteManagerTemplateSetArgs,
): Promise {
@@ -95,7 +121,9 @@ async function writeManagerTemplateSet(
);
await mkdir(templateDir, { recursive: true });
for (const [fileName, content] of Object.entries(args.files)) {
- await writeFile(path.join(templateDir, fileName), content, "utf8");
+ const filePath = path.join(templateDir, fileName);
+ await mkdir(path.dirname(filePath), { recursive: true });
+ await writeFile(filePath, content, "utf8");
}
}
@@ -144,7 +172,7 @@ describe("manager storage templates", () => {
}
});
- it("seeds bundled status when default resolves and no user template directory exists", async () => {
+ it("seeds the bundled status app when default resolves and no user template directory exists", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
try {
const threadStoragePath = await seedStorage({
@@ -155,9 +183,7 @@ describe("manager storage templates", () => {
threadId: "thr-default-fallback",
});
- await expect(
- readFile(path.join(threadStoragePath, "STATUS.html"), "utf8"),
- ).resolves.toBe(await readBundledStatusTemplate());
+ await expectBundledStatusAppSeeded(threadStoragePath);
await expect(
stat(managerTemplateRootPath({ dataDir })),
).rejects.toThrow();
@@ -167,14 +193,14 @@ describe("manager storage templates", () => {
}
});
- it("seeds user-authored default files without bundled fallback", async () => {
+ it("overlays the bundled status app on top of user-authored default files", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
try {
await writeManagerTemplateSet({
dataDir,
name: DEFAULT_MANAGER_TEMPLATE_NAME,
files: {
- "STATUS.html": "user status\n",
+ "USER.md": "user notes\n",
},
});
@@ -187,15 +213,59 @@ describe("manager storage templates", () => {
});
await expect(
- readFile(path.join(threadStoragePath, "STATUS.html"), "utf8"),
- ).resolves.toBe("user status\n");
+ readFile(path.join(threadStoragePath, "USER.md"), "utf8"),
+ ).resolves.toBe("user notes\n");
+ await expectBundledStatusAppSeeded(threadStoragePath);
+ } finally {
+ await harness.cleanup();
+ await rm(dataDir, { recursive: true, force: true });
+ }
+ });
+
+ it("user-authored files win over the bundled overlay at the same path", async () => {
+ const { dataDir, harness, hostId } = await createSeedHarness();
+ try {
+ await writeManagerTemplateSet({
+ dataDir,
+ name: DEFAULT_MANAGER_TEMPLATE_NAME,
+ files: {
+ "apps/status/manifest.json": '{"user":true}\n',
+ },
+ });
+
+ const threadStoragePath = await seedStorage({
+ dataDir,
+ explicitTemplateName: null,
+ harness,
+ hostId,
+ threadId: "thr-user-overrides",
+ });
+
+ await expect(
+ readFile(
+ path.join(threadStoragePath, "apps/status/manifest.json"),
+ "utf8",
+ ),
+ ).resolves.toBe('{"user":true}\n');
+ await expect(
+ readFile(
+ path.join(threadStoragePath, "apps/status/assets/index.html"),
+ "utf8",
+ ),
+ ).resolves.toBe(BUNDLED_STATUS_INDEX_HTML);
+ await expect(
+ readFile(
+ path.join(threadStoragePath, "apps/status/data/state.json"),
+ "utf8",
+ ),
+ ).resolves.toBe(BUNDLED_STATUS_STATE_JSON);
} finally {
await harness.cleanup();
await rm(dataDir, { recursive: true, force: true });
}
});
- it("does not mix bundled files into an existing empty default template directory", async () => {
+ it("overlays the bundled status app even when the default template directory is empty", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
try {
await writeManagerTemplateSet({
@@ -212,14 +282,14 @@ describe("manager storage templates", () => {
threadId: "thr-empty-default",
});
- await expect(readdir(threadStoragePath)).resolves.toEqual([]);
+ await expectBundledStatusAppSeeded(threadStoragePath);
} finally {
await harness.cleanup();
await rm(dataDir, { recursive: true, force: true });
}
});
- it("warns and skips seeding when active points to a missing non-default template", async () => {
+ it("warns and still overlays bundled status when active points to a missing non-default template", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
const logger = {
...testLogger,
@@ -241,13 +311,13 @@ describe("manager storage templates", () => {
threadId: "thr-missing-active",
});
- await expect(stat(threadStoragePath)).rejects.toThrow();
+ await expectBundledStatusAppSeeded(threadStoragePath);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
templateName: MINE_MANAGER_TEMPLATE_NAME,
threadId: "thr-missing-active",
}),
- "Manager template directory is missing; skipping storage seed",
+ "Manager template directory is missing; overlaying bundled seed only",
);
} finally {
await harness.cleanup();
@@ -255,7 +325,7 @@ describe("manager storage templates", () => {
}
});
- it("warns and skips seeding when an explicit non-default template is missing", async () => {
+ it("warns and still overlays bundled status when an explicit non-default template is missing", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
const logger = {
...testLogger,
@@ -272,13 +342,13 @@ describe("manager storage templates", () => {
threadId: "thr-missing-explicit",
});
- await expect(stat(threadStoragePath)).rejects.toThrow();
+ await expectBundledStatusAppSeeded(threadStoragePath);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
templateName: MINE_MANAGER_TEMPLATE_NAME,
threadId: "thr-missing-explicit",
}),
- "Manager template directory is missing; skipping storage seed",
+ "Manager template directory is missing; overlaying bundled seed only",
);
} finally {
await harness.cleanup();
@@ -286,7 +356,7 @@ describe("manager storage templates", () => {
}
});
- it("seeds from the active non-default template when that directory exists", async () => {
+ it("seeds from the active non-default template and overlays bundled status when that directory exists", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
try {
await writeActiveManagerTemplate({
@@ -297,7 +367,8 @@ describe("manager storage templates", () => {
dataDir,
name: MINE_MANAGER_TEMPLATE_NAME,
files: {
- "STATUS.html": "mine status\n",
+ "apps/mine/manifest.json": "{}\n",
+ "apps/mine/data/state.json": "{}\n",
},
});
@@ -310,8 +381,18 @@ describe("manager storage templates", () => {
});
await expect(
- readFile(path.join(threadStoragePath, "STATUS.html"), "utf8"),
- ).resolves.toBe("mine status\n");
+ readFile(
+ path.join(threadStoragePath, "apps/mine/manifest.json"),
+ "utf8",
+ ),
+ ).resolves.toBe("{}\n");
+ await expect(
+ readFile(
+ path.join(threadStoragePath, "apps/mine/data/state.json"),
+ "utf8",
+ ),
+ ).resolves.toBe("{}\n");
+ await expectBundledStatusAppSeeded(threadStoragePath);
} finally {
await harness.cleanup();
await rm(dataDir, { recursive: true, force: true });
diff --git a/docs/migrating-status-to-apps.md b/docs/migrating-status-to-apps.md
new file mode 100644
index 000000000..30017b87d
--- /dev/null
+++ b/docs/migrating-status-to-apps.md
@@ -0,0 +1,196 @@
+# Migrating from STATUS to Apps
+
+The legacy **STATUS** surface (`STATUS.html` / `STATUS.md` / a `STATUS/` folder
+plus the `STATUS-data/` key–value store) has been removed. A thread now exposes
+one or more **apps** instead, and the old single status dashboard becomes a
+regular app with the id `status`.
+
+This guide is for an **existing manager** (or any thread) whose storage still
+contains the old STATUS files and needs to move to the new format. New threads
+already seed a `status` app automatically and need no migration.
+
+For the full feature reference, run `bb guide app`. This doc only covers the
+mechanical migration.
+
+---
+
+## What changed
+
+| Legacy STATUS | New Apps |
+| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
+| `STATUS/index.html`, `STATUS.html`, or `STATUS.md` in the thread-storage root | `apps//assets/` on disk, served at `/api/v1/threads//apps//` |
+| One implicit status surface per thread | Any number of apps; the dashboard is just the app with id `status` |
+| `STATUS-data/.json` (flat keys, `^[A-Za-z0-9_-]{1,80}$`) | `apps//data/` (nested paths allowed; default: a single `data/state.json`) |
+| `window.bbStatusState` (`get`/`set`/`delete`/`list`/`on`) | `window.bb.data` (`read`/`write`/`delete`/`list`/`onChange`) |
+| `window.bbThreadTell(text)` | `window.bb.message(text)` |
+| Served at `/api/v1/threads//status/` | Served at `/api/v1/threads//apps//` |
+| Globals always injected | Injected only for **HTML** entries, gated by manifest `capabilities`; **Markdown** entries are static (no `window.bb`) |
+
+---
+
+## Target layout
+
+```text
+/
+ apps/
+ status/
+ manifest.json # metadata — NOT served
+ assets/ # internal storage for browser files
+ index.html # (or index.md)
+ data/ # file-based key/value store
+ state.json # default: keep all state in one blob
+ logo.svg # optional; otherwise a built-in icon is used
+```
+
+`manifest.json` for the status dashboard:
+
+```json
+{
+ "manifestVersion": 1,
+ "id": "status",
+ "name": "Status",
+ "icon": "ListTodo",
+ "entry": "index.html",
+ "contributions": ["thread.app"],
+ "capabilities": ["data", "message"]
+}
+```
+
+- `id` must match the directory name and `^[A-Za-z0-9_-]+$`.
+- `entry` is resolved relative to `assets/`. Use `index.html` for an
+ interactive dashboard, or `index.md` for a static document (Markdown apps do
+ **not** get `window.bb`).
+- `icon` is a built-in icon name. To use a custom image instead, drop a
+ top-level `logo.svg` / `logo.png` / `logo.jpg` and omit `icon`; with neither,
+ apps fall back to the `GridView` icon.
+- `capabilities`: `data` injects `window.bb.data`, `message` injects
+ `window.bb.message`. List only what the app uses.
+
+Browser files under `assets/` are served from the flat app URL. For example,
+`apps/status/assets/index-Cd7sCqsN.js` is requested as
+`/api/v1/threads//apps/status/index-Cd7sCqsN.js`; do not include an
+`assets/` segment in HTML references.
+
+---
+
+## Migration steps
+
+### 1. Create the app directory
+
+```bash
+APP="$BB_THREAD_STORAGE/apps/status"
+mkdir -p "$APP/assets" "$APP/data"
+```
+
+### 2. Move the markup into `assets/`
+
+- `STATUS.html` → `apps/status/assets/index.html`
+- `STATUS/index.html` (+ its CSS/JS/images/fonts) → `apps/status/assets/` (keep the same relative structure; everything under `assets/` is served from the flat app URL)
+- `STATUS.md` → `apps/status/assets/index.md` (set `entry` to `index.md`; remember a Markdown entry is static)
+
+Use flat relative HTML references, such as `` or ``. If you build with Vite, set `build.assetsDir = ""` so
+emitted CSS/JS/assets sit alongside `index.html` inside `assets/`.
+
+### 3. Migrate the state
+
+The default convention is a **single `data/state.json` blob**. If your old
+dashboard used a few `STATUS-data/*.json` keys, consolidate them:
+
+```bash
+# Example: fold STATUS-data/prs.json + STATUS-data/workers.json into one blob.
+jq -n \
+ --slurpfile prs "$BB_THREAD_STORAGE/STATUS-data/prs.json" \
+ --slurpfile workers "$BB_THREAD_STORAGE/STATUS-data/workers.json" \
+ '{ prs: $prs[0], workers: $workers[0] }' \
+ > "$BB_THREAD_STORAGE/apps/status/data/state.json"
+```
+
+Or, if you prefer to keep separate keys, each old `STATUS-data/.json`
+becomes `apps/status/data/.json` (nested paths like `data/tasks/123` are
+also allowed — see `bb guide app` for the path rules).
+
+### 4. Update the in-page JavaScript
+
+Replace the old globals (HTML entries only):
+
+| Old | New |
+| -------------------------------------- | ----------------------------------------- |
+| `window.bbStatusState.get(key)` | `await window.bb.data.read(path)` |
+| `window.bbStatusState.set(key, value)` | `await window.bb.data.write(path, value)` |
+| `window.bbStatusState.delete(key)` | `await window.bb.data.delete(path)` |
+| `window.bbStatusState.list()` | `await window.bb.data.list(prefix)` |
+| `window.bbStatusState.on(key, cb)` | `window.bb.data.onChange(prefix, cb)` |
+| `window.bbStatusState.on("*", cb)` | `window.bb.data.onChange("", cb)` |
+| `window.bbThreadTell(text)` | `await window.bb.message(text)` |
+
+Notes:
+
+- All `window.bb.data` methods are async (return Promises). `read` resolves to
+ the parsed JSON value or `undefined`.
+- `onChange(prefix, cb)` does **subtree** matching: `onChange("tasks")` fires
+ for `tasks` and `tasks/*` but not `tasksfoo`; `onChange("")` watches
+ everything. It **replays** existing matches once on registration, then streams
+ live changes. The callback receives `{ path, value, deleted }`.
+- If you consolidated into a single `state.json`, the common pattern is:
+
+ ```js
+ window.bb.data.read("state.json").then(render);
+ window.bb.data.onChange("state.json", (event) => render(event.value));
+ ```
+
+- Guard the helpers (`window.bb.data?.…`, `window.bb.message?.(…)`) — they are
+ only present when the matching capability is declared.
+
+### 5. Update how the agent / maintainer writes state
+
+Agents (and any maintainer worker) write app data by writing the files
+**directly on disk** — the daemon watches `data/` and broadcasts changes to open
+clients. Use the same atomic temp-file + rename pattern as before, just to the
+new path:
+
+```bash
+DIR="$BB_THREAD_STORAGE/apps/status/data"
+mkdir -p "$DIR"
+tmp=$(mktemp "$DIR/.state.XXXXXX")
+printf '%s\n' "$NEW_STATE_JSON" > "$tmp" && mv "$tmp" "$DIR/state.json"
+```
+
+(The browser side uses `window.bb.data.write(...)`, which routes through the
+daemon to the same file. Both paths converge on the one watched directory.)
+
+If a long-running maintainer worker used to write `STATUS-data/task_*.json`,
+re-point it at `apps/status/data/…` (a single `state.json` is recommended).
+
+### 6. Remove the old STATUS files
+
+Once the app renders correctly:
+
+```bash
+rm -rf "$BB_THREAD_STORAGE"/STATUS.html \
+ "$BB_THREAD_STORAGE"/STATUS.md \
+ "$BB_THREAD_STORAGE"/STATUS \
+ "$BB_THREAD_STORAGE"/STATUS-data
+```
+
+### 7. Verify
+
+```bash
+bb app list # should show the `status` app
+bb app open status # prints the served URL to open in the panel
+```
+
+Or open the thread's secondary panel: the pinned `Status` tab (and the `+`
+launcher) should show the app, and writing `data/state.json` should update it
+live with no reload.
+
+---
+
+## Quick reference
+
+- Full feature docs: `bb guide app` (manifest, `window.bb`, data paths, icons,
+ entry types, the `bb app` CLI).
+- Styling tokens for app HTML are documented there too (`bb guide styling`
+ redirects to the app guide).
+- `bb app new ` scaffolds additional apps; `bb app rm ` removes one.
diff --git a/packages/domain/src/apps.ts b/packages/domain/src/apps.ts
new file mode 100644
index 000000000..5f72ec99a
--- /dev/null
+++ b/packages/domain/src/apps.ts
@@ -0,0 +1,52 @@
+import { z } from "zod";
+
+const APP_DATA_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]{1,80}$/u;
+const APP_DATA_PATH_MAX_DEPTH = 8;
+// Keep these app-data path limits in sync with the injected app client
+// validator in apps/server/src/services/threads/app-client-script.ts.
+const APP_DATA_PATH_MAX_LENGTH = 512;
+
+export const appIdSchema = z.string().regex(/^[A-Za-z0-9_-]+$/u);
+export type AppId = z.infer;
+
+export const appDataPathSchema = z.string().superRefine((value, context) => {
+ if (
+ value.length === 0 ||
+ value.length > APP_DATA_PATH_MAX_LENGTH ||
+ value.includes("\0") ||
+ value.includes("\\") ||
+ value.startsWith("/") ||
+ value.endsWith("/")
+ ) {
+ context.addIssue({
+ code: "custom",
+ message: "Invalid app data path",
+ });
+ return;
+ }
+
+ const segments = value.split("/");
+ if (segments.length > APP_DATA_PATH_MAX_DEPTH) {
+ context.addIssue({
+ code: "custom",
+ message: "App data path is too deep",
+ });
+ return;
+ }
+
+ for (const segment of segments) {
+ if (
+ segment === "." ||
+ segment === ".." ||
+ segment.startsWith(".") ||
+ !APP_DATA_PATH_SEGMENT_PATTERN.test(segment)
+ ) {
+ context.addIssue({
+ code: "custom",
+ message: "Invalid app data path segment",
+ });
+ return;
+ }
+ }
+});
+export type AppDataPath = z.infer;
diff --git a/packages/domain/src/html-escape.ts b/packages/domain/src/html-escape.ts
new file mode 100644
index 000000000..2b620effc
--- /dev/null
+++ b/packages/domain/src/html-escape.ts
@@ -0,0 +1,14 @@
+const HTML_ESCAPE_REPLACEMENTS: Record = {
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+};
+
+export function escapeHtmlText(value: string): string {
+ return value.replace(
+ /[&<>"']/gu,
+ (character) => HTML_ESCAPE_REPLACEMENTS[character] ?? character,
+ );
+}
diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts
index 1aeefcd9b..a94b77830 100644
--- a/packages/domain/src/index.ts
+++ b/packages/domain/src/index.ts
@@ -55,6 +55,9 @@ export type {
export { managerTemplateNameSchema } from "./manager-templates.js";
export type { ManagerTemplateName } from "./manager-templates.js";
+export { appDataPathSchema, appIdSchema } from "./apps.js";
+export type { AppDataPath, AppId } from "./apps.js";
+
export { threadDynamicContextFileStatusValues } from "./manager-dynamic-context.js";
export type { ThreadDynamicContextFileStatus } from "./manager-dynamic-context.js";
@@ -441,9 +444,6 @@ export type {
export { jsonObjectSchema, jsonValueSchema } from "./json-value.js";
export type { JsonObject, JsonValue } from "./json-value.js";
-export { statusDataKeySchema } from "./status-data.js";
-export type { StatusDataKey } from "./status-data.js";
-
export {
assertThreadEventScope,
getThreadEventScopeTurnId,
@@ -534,6 +534,8 @@ export type {
export { toPositiveNumber } from "./number-utils.js";
+export { escapeHtmlText } from "./html-escape.js";
+
export { activeThinkingSchema } from "./active-thinking.js";
export type { ActiveThinking } from "./active-thinking.js";
diff --git a/packages/domain/src/status-data.ts b/packages/domain/src/status-data.ts
deleted file mode 100644
index 68bac3f5a..000000000
--- a/packages/domain/src/status-data.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { z } from "zod";
-
-export const statusDataKeySchema = z.string().regex(/^[A-Za-z0-9_-]{1,80}$/u);
-export type StatusDataKey = z.infer;
diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts
index 3fe99b881..d15788222 100644
--- a/packages/host-daemon-contract/src/commands.ts
+++ b/packages/host-daemon-contract/src/commands.ts
@@ -17,8 +17,6 @@ import {
clientTurnRequestIdSchema,
gitBranchNameSchema,
jsonObjectSchema,
- jsonValueSchema,
- statusDataKeySchema,
} from "@bb/domain";
import {
replayCaptureDaemonListResponseSchema,
@@ -27,7 +25,7 @@ import {
} from "@bb/replay-capture/schema";
import { z } from "zod";
-export const HOST_DAEMON_PROTOCOL_VERSION = 24 as const;
+export const HOST_DAEMON_PROTOCOL_VERSION = 26 as const;
export const FILE_LIST_QUERY_MAX_LENGTH = 256;
export const FILE_LIST_LIMIT_MAX = 10_000;
@@ -50,15 +48,11 @@ export const HOST_DAEMON_COMMAND_TYPES = [
"host.list_branches",
"host.list_manager_templates",
"host.file_metadata",
- "host.status_version",
- "host.status_data.list",
- "host.status_data.get",
- "host.status_data.set",
- "host.status_data.delete",
"host.read_file",
"host.read_file_relative",
"host.write_file_relative",
"host.delete_file_relative",
+ "host.delete_path_relative",
"provider.list",
"provider.list_models",
"environment.provision",
@@ -333,108 +327,20 @@ export const hostDeleteFileRelativeCommandSchema = z
})
.strict();
-export const hostFileMetadataCommandSchema = z
- .object({
- type: z.literal("host.file_metadata"),
- path: z.string().min(1),
- rootPath: z.string().min(1).optional(),
- })
- .strict();
-
-export const hostStatusVersionSourceSchema = z.enum([
- "folder",
- "html",
- "md",
- "empty",
-]);
-export type HostStatusVersionSource = z.infer<
- typeof hostStatusVersionSourceSchema
->;
-
-export const hostStatusVersionFolderSourceSchema = z
+export const hostDeletePathRelativeCommandSchema = z
.object({
- source: z.literal("folder"),
- rootPath: z.string().min(1),
- indexPath: z.string().min(1),
- dotfiles: hostReadFileRelativeDotfilePolicySchema,
- })
- .strict();
-export type HostStatusVersionFolderSource = z.infer<
- typeof hostStatusVersionFolderSourceSchema
->;
-
-export const hostStatusVersionHtmlSourceSchema = z
- .object({
- source: z.literal("html"),
+ type: z.literal("host.delete_path_relative"),
rootPath: z.string().min(1),
path: z.string().min(1),
dotfiles: hostReadFileRelativeDotfilePolicySchema,
})
.strict();
-export type HostStatusVersionHtmlSource = z.infer<
- typeof hostStatusVersionHtmlSourceSchema
->;
-export const hostStatusVersionMarkdownSourceSchema = z
+export const hostFileMetadataCommandSchema = z
.object({
- source: z.literal("md"),
- rootPath: z.string().min(1),
+ type: z.literal("host.file_metadata"),
path: z.string().min(1),
- dotfiles: hostReadFileRelativeDotfilePolicySchema,
- })
- .strict();
-export type HostStatusVersionMarkdownSource = z.infer<
- typeof hostStatusVersionMarkdownSourceSchema
->;
-
-export const hostStatusVersionFileSourceSchema = z.union([
- hostStatusVersionHtmlSourceSchema,
- hostStatusVersionMarkdownSourceSchema,
-]);
-export type HostStatusVersionFileSource = z.infer<
- typeof hostStatusVersionFileSourceSchema
->;
-
-export const hostStatusVersionCommandSchema = z
- .object({
- type: z.literal("host.status_version"),
- sources: z.tuple([
- hostStatusVersionFolderSourceSchema,
- hostStatusVersionHtmlSourceSchema,
- hostStatusVersionMarkdownSourceSchema,
- ]),
- })
- .strict();
-
-const hostStatusDataTargetSchema = z.object({
- threadId: z.string().min(1),
-});
-
-export const hostStatusDataListCommandSchema = hostStatusDataTargetSchema
- .extend({
- type: z.literal("host.status_data.list"),
- })
- .strict();
-
-export const hostStatusDataGetCommandSchema = hostStatusDataTargetSchema
- .extend({
- type: z.literal("host.status_data.get"),
- key: statusDataKeySchema,
- })
- .strict();
-
-export const hostStatusDataSetCommandSchema = hostStatusDataTargetSchema
- .extend({
- type: z.literal("host.status_data.set"),
- key: statusDataKeySchema,
- value: jsonValueSchema,
- })
- .strict();
-
-export const hostStatusDataDeleteCommandSchema = hostStatusDataTargetSchema
- .extend({
- type: z.literal("host.status_data.delete"),
- key: statusDataKeySchema,
+ rootPath: z.string().min(1).optional(),
})
.strict();
@@ -667,15 +573,11 @@ const hostDaemonNonProvisionCommandSchema = z.discriminatedUnion("type", [
hostListBranchesCommandSchema,
hostListManagerTemplatesCommandSchema,
hostFileMetadataCommandSchema,
- hostStatusVersionCommandSchema,
- hostStatusDataListCommandSchema,
- hostStatusDataGetCommandSchema,
- hostStatusDataSetCommandSchema,
- hostStatusDataDeleteCommandSchema,
hostReadFileCommandSchema,
hostReadFileRelativeCommandSchema,
hostWriteFileRelativeCommandSchema,
hostDeleteFileRelativeCommandSchema,
+ hostDeletePathRelativeCommandSchema,
providerListCommandSchema,
providerListModelsCommandSchema,
environmentDestroyCommandSchema,
@@ -704,11 +606,6 @@ export function shouldFlushEventsBeforeReportingCommandResult(
case "environment.destroy":
case "host.list_branches":
case "host.file_metadata":
- case "host.status_version":
- case "host.status_data.list":
- case "host.status_data.get":
- case "host.status_data.set":
- case "host.status_data.delete":
case "host.list_files":
case "host.list_paths":
case "host.list_manager_templates":
@@ -716,6 +613,7 @@ export function shouldFlushEventsBeforeReportingCommandResult(
case "host.read_file_relative":
case "host.write_file_relative":
case "host.delete_file_relative":
+ case "host.delete_path_relative":
case "codex.inference.complete":
case "provider.list":
case "provider.list_models":
@@ -742,6 +640,7 @@ const fileReadResultSchema = z.object({
contentEncoding: z.enum(["base64", "utf8"]),
mimeType: z.string().optional(),
sizeBytes: z.number().int().nonnegative(),
+ modifiedAtMs: z.number().nonnegative().optional(),
});
const fileMetadataResultSchema = z.object({
@@ -763,96 +662,11 @@ const fileDeleteResultSchema = z.object({
previousHash: z.string().nullable(),
});
-const statusVersionResultSchema = z.object({
- source: hostStatusVersionSourceSchema,
- hash: z.string().min(1),
+const pathDeleteResultSchema = z.object({
+ path: z.string(),
+ deleted: z.boolean(),
});
-const statusDataValueRecordSchema = z.record(
- statusDataKeySchema,
- jsonValueSchema,
-);
-
-const statusDataVersionRecordSchema = z.record(
- statusDataKeySchema,
- z.string().min(1),
-);
-
-const statusDataListResultSchema = z
- .object({
- values: statusDataValueRecordSchema,
- versions: statusDataVersionRecordSchema,
- hash: z.string().min(1),
- })
- .strict();
-
-const statusDataEntryResultSchema = z
- .object({
- key: statusDataKeySchema,
- value: jsonValueSchema,
- version: z.string().min(1),
- sizeBytes: z.number().int().nonnegative(),
- modifiedAtMs: z.number().nonnegative(),
- })
- .strict();
-
-const statusDataPreviousValueSchema = z
- .object({
- previousValue: jsonValueSchema.nullable(),
- previousValuePresent: z.boolean(),
- previousVersion: z.string().min(1).nullable(),
- })
- .strict();
-
-interface StatusDataPreviousValueInvariantInput {
- previousValuePresent: boolean;
- previousVersion: string | null;
-}
-
-function refineStatusDataPreviousValue(
- value: StatusDataPreviousValueInvariantInput,
- context: z.RefinementCtx,
-): void {
- if (!value.previousValuePresent && value.previousVersion !== null) {
- context.addIssue({
- code: "custom",
- path: ["previousVersion"],
- message:
- "previousVersion must be null when previousValuePresent is false",
- });
- }
- if (value.previousValuePresent && value.previousVersion === null) {
- context.addIssue({
- code: "custom",
- path: ["previousVersion"],
- message: "previousVersion is required when previousValuePresent is true",
- });
- }
-}
-
-const statusDataSetResultSchema = statusDataEntryResultSchema
- .merge(statusDataPreviousValueSchema)
- .strict()
- .superRefine(refineStatusDataPreviousValue);
-
-const statusDataDeleteResultSchema = z
- .object({
- key: statusDataKeySchema,
- deleted: z.boolean(),
- })
- .merge(statusDataPreviousValueSchema)
- .strict()
- .superRefine((value, context) => {
- refineStatusDataPreviousValue(value, context);
- if (value.deleted !== value.previousValuePresent) {
- context.addIssue({
- code: "custom",
- path: ["deleted"],
- message: "deleted must match previousValuePresent",
- });
- }
- });
-
const fileListResultSchema = z.object({
files: z.array(z.object({ path: z.string(), name: z.string() })),
truncated: z.boolean(),
@@ -891,11 +705,6 @@ export const hostDaemonCommandResultSchemaByType = {
"host.list_files": fileListResultSchema,
"host.list_paths": pathListResultSchema,
"host.file_metadata": fileMetadataResultSchema,
- "host.status_version": statusVersionResultSchema,
- "host.status_data.list": statusDataListResultSchema,
- "host.status_data.get": statusDataEntryResultSchema,
- "host.status_data.set": statusDataSetResultSchema,
- "host.status_data.delete": statusDataDeleteResultSchema,
"host.list_branches": projectSourceCheckoutSchema,
"host.list_manager_templates": z.object({
/** Sorted alphabetically. Includes only template names that contain at least one regular file. */
@@ -911,6 +720,7 @@ export const hostDaemonCommandResultSchemaByType = {
"host.read_file_relative": fileReadResultSchema,
"host.write_file_relative": fileWriteResultSchema,
"host.delete_file_relative": fileDeleteResultSchema,
+ "host.delete_path_relative": pathDeleteResultSchema,
"provider.list": z.object({
providers: z.array(providerInfoSchema),
}),
diff --git a/packages/host-daemon-contract/src/index.ts b/packages/host-daemon-contract/src/index.ts
index e7db35f96..2c0e0004d 100644
--- a/packages/host-daemon-contract/src/index.ts
+++ b/packages/host-daemon-contract/src/index.ts
@@ -110,22 +110,13 @@ export {
hostListPathsCommandSchema,
hostListBranchesCommandSchema,
hostListManagerTemplatesCommandSchema,
- hostStatusVersionCommandSchema,
- hostStatusDataDeleteCommandSchema,
- hostStatusDataGetCommandSchema,
- hostStatusDataListCommandSchema,
- hostStatusDataSetCommandSchema,
managerTemplateSummarySchema,
- hostStatusVersionFileSourceSchema,
- hostStatusVersionFolderSourceSchema,
- hostStatusVersionHtmlSourceSchema,
- hostStatusVersionMarkdownSourceSchema,
- hostStatusVersionSourceSchema,
hostReadFileCommandSchema,
hostReadFileRelativeCommandSchema,
hostReadFileRelativeDotfilePolicySchema,
hostWriteFileRelativeCommandSchema,
hostDeleteFileRelativeCommandSchema,
+ hostDeletePathRelativeCommandSchema,
providerListCommandSchema,
providerListModelsCommandSchema,
replayCaptureGetCommandSchema,
@@ -157,11 +148,6 @@ export type {
HostPathEntryKind,
HostReadFileRelativeDotfilePolicy,
ManagerTemplateSummary,
- HostStatusVersionFileSource,
- HostStatusVersionFolderSource,
- HostStatusVersionHtmlSource,
- HostStatusVersionMarkdownSource,
- HostStatusVersionSource,
TurnSubmitTarget,
WorkspaceContext,
} from "./commands.js";
@@ -177,6 +163,10 @@ export {
hostDaemonCommandBatchSchema,
hostDaemonCommandsQuerySchema,
hostDaemonCommandResultResponseSchema,
+ hostDaemonAppDataChangePayloadSchema,
+ hostDaemonAppDataChangeRequestSchema,
+ hostDaemonAppDataResyncPayloadSchema,
+ hostDaemonAppDataResyncRequestSchema,
hostDaemonDaemonWsMessageSchema,
hostDaemonEnvironmentChangePayloadSchema,
hostDaemonEnvironmentChangeRequestSchema,
@@ -195,8 +185,6 @@ export {
hostDaemonSessionCloseReasonSchema,
hostDaemonSessionOpenRequestSchema,
hostDaemonSessionOpenResponseSchema,
- hostDaemonStatusDataChangePayloadSchema,
- hostDaemonStatusDataChangeRequestSchema,
hostDaemonTerminalOutputChunkSchema,
hostDaemonTrackedThreadTargetSchema,
hostDaemonToolCallRequestSchema,
@@ -210,6 +198,10 @@ export type {
HostDaemonEnrollResponse,
HostDaemonCommandsQuery,
HostDaemonCommandResultResponse,
+ HostDaemonAppDataChangePayload,
+ HostDaemonAppDataChangeRequest,
+ HostDaemonAppDataResyncPayload,
+ HostDaemonAppDataResyncRequest,
HostDaemonDaemonWsMessage,
HostDaemonEnvironmentChange,
HostDaemonEnvironmentChangePayload,
@@ -230,8 +222,6 @@ export type {
HostDaemonSessionCloseReason,
HostDaemonSessionOpenRequest,
HostDaemonSessionOpenResponse,
- HostDaemonStatusDataChangePayload,
- HostDaemonStatusDataChangeRequest,
HostDaemonTerminalOutputChunk,
HostDaemonTrackedThreadTarget,
HostDaemonToolCallRequest,
diff --git a/packages/host-daemon-contract/src/session.ts b/packages/host-daemon-contract/src/session.ts
index c276d7be3..20ac2eb74 100644
--- a/packages/host-daemon-contract/src/session.ts
+++ b/packages/host-daemon-contract/src/session.ts
@@ -7,7 +7,8 @@ import {
jsonValueSchema,
pendingInteractionCreateSchema,
pendingInteractionStatusSchema,
- statusDataKeySchema,
+ appDataPathSchema,
+ appIdSchema,
terminalColsSchema,
terminalDataBase64Schema,
terminalRowsSchema,
@@ -205,73 +206,77 @@ export type HostDaemonEnvironmentChangeRequest = z.infer<
typeof hostDaemonEnvironmentChangeRequestSchema
>;
-const hostDaemonStatusDataChangePayloadBaseSchema = z
+const hostDaemonAppDataChangePayloadBaseSchema = z
.object({
threadId: z.string().min(1),
- key: statusDataKeySchema,
+ appId: appIdSchema,
+ path: appDataPathSchema,
value: jsonValueSchema.nullable(),
deleted: z.boolean(),
- previousValue: jsonValueSchema.nullable(),
- previousValuePresent: z.boolean(),
version: z.string().min(1).nullable(),
- previousVersion: z.string().min(1).nullable(),
})
.strict();
-type HostDaemonStatusDataChangePayloadBase = z.infer<
- typeof hostDaemonStatusDataChangePayloadBaseSchema
+type HostDaemonAppDataChangePayloadBase = z.infer<
+ typeof hostDaemonAppDataChangePayloadBaseSchema
>;
-function validateHostDaemonStatusDataChangePayload(
- payload: HostDaemonStatusDataChangePayloadBase,
+function validateHostDaemonAppDataChangePayload(
+ payload: HostDaemonAppDataChangePayloadBase,
context: z.RefinementCtx,
): void {
if (payload.deleted && payload.version !== null) {
context.addIssue({
code: "custom",
path: ["version"],
- message: "version must be null for deleted STATUS-data changes",
+ message: "version must be null for deleted app data changes",
});
}
if (!payload.deleted && payload.version === null) {
context.addIssue({
code: "custom",
path: ["version"],
- message: "version is required for non-deleted STATUS-data changes",
- });
- }
- if (!payload.previousValuePresent && payload.previousVersion !== null) {
- context.addIssue({
- code: "custom",
- path: ["previousVersion"],
- message: "previousVersion must be null when previousValuePresent is false",
- });
- }
- if (payload.previousValuePresent && payload.previousVersion === null) {
- context.addIssue({
- code: "custom",
- path: ["previousVersion"],
- message: "previousVersion is required when previousValuePresent is true",
+ message: "version is required for non-deleted app data changes",
});
}
}
-export const hostDaemonStatusDataChangePayloadSchema =
- hostDaemonStatusDataChangePayloadBaseSchema.superRefine(
- validateHostDaemonStatusDataChangePayload,
+export const hostDaemonAppDataChangePayloadSchema =
+ hostDaemonAppDataChangePayloadBaseSchema.superRefine(
+ validateHostDaemonAppDataChangePayload,
);
-export type HostDaemonStatusDataChangePayload = z.infer<
- typeof hostDaemonStatusDataChangePayloadSchema
+export type HostDaemonAppDataChangePayload = z.infer<
+ typeof hostDaemonAppDataChangePayloadSchema
>;
-export const hostDaemonStatusDataChangeRequestSchema = z
+export const hostDaemonAppDataChangeRequestSchema = z
.object({
sessionId: z.string().min(1),
- ...hostDaemonStatusDataChangePayloadBaseSchema.shape,
+ ...hostDaemonAppDataChangePayloadBaseSchema.shape,
})
.strict()
- .superRefine(validateHostDaemonStatusDataChangePayload);
-export type HostDaemonStatusDataChangeRequest = z.infer<
- typeof hostDaemonStatusDataChangeRequestSchema
+ .superRefine(validateHostDaemonAppDataChangePayload);
+export type HostDaemonAppDataChangeRequest = z.infer<
+ typeof hostDaemonAppDataChangeRequestSchema
+>;
+
+export const hostDaemonAppDataResyncPayloadSchema = z
+ .object({
+ threadId: z.string().min(1),
+ appId: appIdSchema,
+ })
+ .strict();
+export type HostDaemonAppDataResyncPayload = z.infer<
+ typeof hostDaemonAppDataResyncPayloadSchema
+>;
+
+export const hostDaemonAppDataResyncRequestSchema =
+ hostDaemonAppDataResyncPayloadSchema
+ .extend({
+ sessionId: z.string().min(1),
+ })
+ .strict();
+export type HostDaemonAppDataResyncRequest = z.infer<
+ typeof hostDaemonAppDataResyncRequestSchema
>;
export const hostDaemonHeartbeatPayloadSchema = z.object({
@@ -575,9 +580,13 @@ export type HostDaemonInternalSchema = {
/** Used by the daemon to report raw environment workspace change hints for server-side validation and fan-out. */
$post: Endpoint<{ json: HostDaemonEnvironmentChangeRequest }, { ok: true }>;
};
- "/session/status-data-change": {
- /** Used by the daemon to report host-local STATUS-data file changes for server websocket fan-out. */
- $post: Endpoint<{ json: HostDaemonStatusDataChangeRequest }, { ok: true }>;
+ "/session/app-data-change": {
+ /** Used by the daemon to report host-local app data file changes for server websocket fan-out. */
+ $post: Endpoint<{ json: HostDaemonAppDataChangeRequest }, { ok: true }>;
+ };
+ "/session/app-data-resync": {
+ /** Used by the daemon to request client-side app data resync after reconnect reconciliation. */
+ $post: Endpoint<{ json: HostDaemonAppDataResyncRequest }, { ok: true }>;
};
"/session/tool-call": {
/** Used by the daemon to execute server-side tool calls on behalf of a provider (e.g. message_user). */
diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts
index e4c0529ce..09694488b 100644
--- a/packages/host-daemon-contract/test/contract.test.ts
+++ b/packages/host-daemon-contract/test/contract.test.ts
@@ -396,87 +396,6 @@ describe("host-daemon command schemas", () => {
rootPath: "/tmp/bb-data/thread-storage/thread-123",
});
- expect(
- hostDaemonCommandSchema.parse({
- type: "host.status_version",
- sources: [
- {
- source: "folder",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/STATUS",
- indexPath: "index.html",
- dotfiles: "deny",
- },
- {
- source: "html",
- rootPath: "/tmp/bb-data/thread-storage/thread-123",
- path: "STATUS.html",
- dotfiles: "allow",
- },
- {
- source: "md",
- rootPath: "/tmp/bb-data/thread-storage/thread-123",
- path: "STATUS.md",
- dotfiles: "allow",
- },
- ],
- }),
- ).toMatchObject({
- type: "host.status_version",
- sources: [
- { source: "folder", indexPath: "index.html", dotfiles: "deny" },
- { source: "html", path: "STATUS.html", dotfiles: "allow" },
- { source: "md", path: "STATUS.md", dotfiles: "allow" },
- ],
- });
-
- expect(
- hostDaemonCommandSchema.parse({
- type: "host.status_data.list",
- threadId: "thread-123",
- }),
- ).toEqual({
- type: "host.status_data.list",
- threadId: "thread-123",
- });
-
- expect(
- hostDaemonCommandSchema.parse({
- type: "host.status_data.get",
- threadId: "thread-123",
- key: "tasks",
- }),
- ).toEqual({
- type: "host.status_data.get",
- threadId: "thread-123",
- key: "tasks",
- });
-
- expect(
- hostDaemonCommandSchema.parse({
- type: "host.status_data.set",
- threadId: "thread-123",
- key: "tasks",
- value: [{ id: "task-1", title: "Review" }],
- }),
- ).toEqual({
- type: "host.status_data.set",
- threadId: "thread-123",
- key: "tasks",
- value: [{ id: "task-1", title: "Review" }],
- });
-
- expect(
- hostDaemonCommandSchema.parse({
- type: "host.status_data.delete",
- threadId: "thread-123",
- key: "tasks",
- }),
- ).toEqual({
- type: "host.status_data.delete",
- threadId: "thread-123",
- key: "tasks",
- });
-
expect(
hostDaemonCommandSchema.parse({
type: "host.read_file",
@@ -516,41 +435,53 @@ describe("host-daemon command schemas", () => {
expect(
hostDaemonCommandSchema.parse({
type: "host.read_file_relative",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/STATUS",
- path: "assets/logo.png",
+ rootPath: "/tmp/bb-data/thread-storage/thread-123/apps/status/assets",
+ path: "logo.png",
dotfiles: "deny",
}),
).toMatchObject({
type: "host.read_file_relative",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/STATUS",
- path: "assets/logo.png",
+ rootPath: "/tmp/bb-data/thread-storage/thread-123/apps/status/assets",
+ path: "logo.png",
dotfiles: "deny",
});
expect(
hostDaemonCommandSchema.parse({
type: "host.write_file_relative",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/STATUS-data",
- path: "tasks.json",
+ rootPath: "/tmp/bb-data/thread-storage/thread-123/apps/status/data",
+ path: "state.json",
dotfiles: "deny",
content: "[1,2,3]\n",
contentEncoding: "utf8",
}),
).toMatchObject({
type: "host.write_file_relative",
- path: "tasks.json",
+ path: "state.json",
});
expect(
hostDaemonCommandSchema.parse({
type: "host.delete_file_relative",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/STATUS-data",
- path: "tasks.json",
+ rootPath: "/tmp/bb-data/thread-storage/thread-123/apps/status/data",
+ path: "state.json",
dotfiles: "deny",
}),
).toMatchObject({
type: "host.delete_file_relative",
- path: "tasks.json",
+ path: "state.json",
+ });
+
+ expect(
+ hostDaemonCommandSchema.parse({
+ type: "host.delete_path_relative",
+ rootPath: "/tmp/bb-data/thread-storage/thread-123/apps",
+ path: "demo",
+ dotfiles: "deny",
+ }),
+ ).toMatchObject({
+ type: "host.delete_path_relative",
+ path: "demo",
});
expect(
@@ -733,24 +664,8 @@ describe("host-daemon command schemas", () => {
expect(() =>
hostDaemonCommandSchema.parse({
type: "host.read_file_relative",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/STATUS",
- path: "assets/logo.png",
- }),
- ).toThrow();
-
- expect(() =>
- hostDaemonCommandSchema.parse({
- type: "host.status_data.get",
- threadId: "thread-123",
- key: "../tasks",
- }),
- ).toThrow();
-
- expect(() =>
- hostDaemonCommandSchema.parse({
- type: "host.status_data.set",
- threadId: "thread-123",
- key: "tasks",
+ rootPath: "/tmp/bb-data/thread-storage/thread-123/apps/status/assets",
+ path: "logo.png",
}),
).toThrow();
});
@@ -1372,145 +1287,15 @@ describe("host-daemon command schemas", () => {
});
expect(
- hostDaemonCommandResultSchemaByType["host.status_version"].parse({
- source: "folder",
- hash: "abc123",
- }),
- ).toEqual({
- source: "folder",
- hash: "abc123",
- });
-
- expect(
- hostDaemonCommandResultSchemaByType["host.status_data.list"].parse({
- values: {
- tasks: [{ id: "task-1" }],
- prefs: { compact: true },
- },
- versions: {
- tasks: "tasks-hash",
- prefs: "prefs-hash",
- },
- hash: "aggregate-hash",
- }),
- ).toEqual({
- values: {
- tasks: [{ id: "task-1" }],
- prefs: { compact: true },
- },
- versions: {
- tasks: "tasks-hash",
- prefs: "prefs-hash",
- },
- hash: "aggregate-hash",
- });
-
- expect(
- hostDaemonCommandResultSchemaByType["host.status_data.get"].parse({
- key: "tasks",
- value: [{ id: "task-1" }],
- version: "tasks-hash",
- sizeBytes: 24,
- modifiedAtMs: 1234.5,
- }),
- ).toEqual({
- key: "tasks",
- value: [{ id: "task-1" }],
- version: "tasks-hash",
- sizeBytes: 24,
- modifiedAtMs: 1234.5,
- });
-
- expect(
- hostDaemonCommandResultSchemaByType["host.status_data.set"].parse({
- key: "tasks",
- value: [{ id: "task-2" }],
- version: "next-hash",
- sizeBytes: 24,
- modifiedAtMs: 1234.5,
- previousValue: [{ id: "task-1" }],
- previousValuePresent: true,
- previousVersion: "previous-hash",
- }),
- ).toMatchObject({
- key: "tasks",
- version: "next-hash",
- previousValuePresent: true,
- previousVersion: "previous-hash",
- });
-
- expect(
- hostDaemonCommandResultSchemaByType["host.status_data.delete"].parse({
- key: "tasks",
+ hostDaemonCommandResultSchemaByType["host.delete_path_relative"].parse({
+ path: "demo",
deleted: true,
- previousValue: null,
- previousValuePresent: true,
- previousVersion: "previous-null-hash",
}),
).toEqual({
- key: "tasks",
+ path: "demo",
deleted: true,
- previousValue: null,
- previousValuePresent: true,
- previousVersion: "previous-null-hash",
});
- expect(() =>
- hostDaemonCommandResultSchemaByType["host.status_data.delete"].parse({
- key: "tasks",
- deleted: false,
- previousValue: ["stale"],
- previousValuePresent: true,
- previousVersion: "previous-hash",
- }),
- ).toThrow();
-
- expect(() =>
- hostDaemonCommandResultSchemaByType["host.status_data.set"].parse({
- key: "tasks",
- value: [{ id: "task-2" }],
- version: "next-hash",
- sizeBytes: 24,
- modifiedAtMs: 1234.5,
- previousValue: null,
- previousValuePresent: false,
- previousVersion: "previous-hash",
- }),
- ).toThrow();
-
- expect(() =>
- hostDaemonCommandResultSchemaByType["host.status_data.set"].parse({
- key: "tasks",
- value: [{ id: "task-2" }],
- version: "next-hash",
- sizeBytes: 24,
- modifiedAtMs: 1234.5,
- previousValue: [{ id: "task-1" }],
- previousValuePresent: true,
- previousVersion: null,
- }),
- ).toThrow();
-
- expect(() =>
- hostDaemonCommandResultSchemaByType["host.status_data.delete"].parse({
- key: "tasks",
- deleted: false,
- previousValue: null,
- previousValuePresent: false,
- previousVersion: "previous-hash",
- }),
- ).toThrow();
-
- expect(() =>
- hostDaemonCommandResultSchemaByType["host.status_data.delete"].parse({
- key: "tasks",
- deleted: true,
- previousValue: [{ id: "task-1" }],
- previousValuePresent: true,
- previousVersion: null,
- }),
- ).toThrow();
-
expect(() =>
hostDaemonCommandResultSchemaByType["workspace.commit"].parse({
commitSha: "",
@@ -1802,61 +1587,64 @@ describe("host-daemon session schemas", () => {
});
expect(
- contract.hostDaemonStatusDataChangeRequestSchema.parse({
+ contract.hostDaemonAppDataChangeRequestSchema.parse({
sessionId: "session_123",
threadId: "thr_123",
- key: "tasks",
- value: [{ id: "task-2" }],
+ appId: "status",
+ path: "state.json",
+ value: { workers: [] },
deleted: false,
- previousValue: [{ id: "task-1" }],
- previousValuePresent: true,
version: "next-hash",
- previousVersion: "previous-hash",
}),
).toEqual({
sessionId: "session_123",
threadId: "thr_123",
- key: "tasks",
- value: [{ id: "task-2" }],
+ appId: "status",
+ path: "state.json",
+ value: { workers: [] },
deleted: false,
- previousValue: [{ id: "task-1" }],
- previousValuePresent: true,
version: "next-hash",
- previousVersion: "previous-hash",
});
expect(
- contract.hostDaemonStatusDataChangeRequestSchema.parse({
+ contract.hostDaemonAppDataChangeRequestSchema.parse({
sessionId: "session_123",
threadId: "thr_123",
- key: "tasks",
+ appId: "status",
+ path: "state.json",
value: null,
deleted: true,
- previousValue: null,
- previousValuePresent: true,
version: null,
- previousVersion: "previous-null-hash",
}),
).toMatchObject({
deleted: true,
- previousValuePresent: true,
version: null,
});
expect(() =>
- contract.hostDaemonStatusDataChangeRequestSchema.parse({
+ contract.hostDaemonAppDataChangeRequestSchema.parse({
sessionId: "session_123",
threadId: "thr_123",
- key: "tasks",
- value: [],
+ appId: "status",
+ path: "state.json",
+ value: { workers: [] },
deleted: false,
- previousValue: null,
- previousValuePresent: false,
version: null,
- previousVersion: null,
}),
).toThrow();
+ expect(
+ contract.hostDaemonAppDataResyncRequestSchema.parse({
+ sessionId: "session_123",
+ threadId: "thr_123",
+ appId: "status",
+ }),
+ ).toEqual({
+ sessionId: "session_123",
+ threadId: "thr_123",
+ appId: "status",
+ });
+
expect(
hostDaemonInteractiveRequestSchema.parse({
sessionId: "session_123",
@@ -2089,8 +1877,11 @@ describe("host-daemon session schemas", () => {
expect(client.session["environment-change"].$url().pathname).toBe(
"/internal/session/environment-change",
);
- expect(client.session["status-data-change"].$url().pathname).toBe(
- "/internal/session/status-data-change",
+ expect(client.session["app-data-change"].$url().pathname).toBe(
+ "/internal/session/app-data-change",
+ );
+ expect(client.session["app-data-resync"].$url().pathname).toBe(
+ "/internal/session/app-data-resync",
);
});
});
diff --git a/packages/host-watcher/src/host-watcher-types.ts b/packages/host-watcher/src/host-watcher-types.ts
index 628af6e01..e003033a6 100644
--- a/packages/host-watcher/src/host-watcher-types.ts
+++ b/packages/host-watcher/src/host-watcher-types.ts
@@ -1,5 +1,5 @@
import type { HostType } from "@bb/domain";
-import type { StatusDataKey } from "@bb/domain";
+import type { AppDataPath, AppId } from "@bb/domain";
import type { WorkspaceStatusWatchChangeKind } from "./watch-status-types.js";
export type HostObservedChange =
@@ -15,9 +15,10 @@ export type HostObservedChange =
threadId: string;
}
| {
- kind: "thread-status-data-changed";
+ kind: "thread-app-data-changed";
+ appId: AppId;
environmentId: string;
- key: StatusDataKey;
+ path: AppDataPath;
threadId: string;
};
@@ -28,7 +29,9 @@ export type WorkspaceObservedChange = Extract<
export type ThreadStorageObservedChange = Extract<
HostObservedChange,
- { kind: "thread-storage-changed" | "thread-status-data-changed" }
+ {
+ kind: "thread-storage-changed" | "thread-app-data-changed";
+ }
>;
export interface WorkspaceWatchError {
diff --git a/packages/host-watcher/src/index.ts b/packages/host-watcher/src/index.ts
index 01ef67cb5..2eaa0f5bb 100644
--- a/packages/host-watcher/src/index.ts
+++ b/packages/host-watcher/src/index.ts
@@ -18,12 +18,6 @@ export type {
WorkspaceStatusChangeEvent,
WorkspaceStatusWatchChangeKind,
} from "./watch-status-types.js";
-export {
- parseStatusDataFileName,
- statusDataFileName,
- STATUS_DATA_DIRECTORY_NAME,
- STATUS_DATA_FILE_EXTENSION,
-} from "./status-data-paths.js";
export async function createHostWatcher(
_args: CreateHostWatcherArgs,
diff --git a/packages/host-watcher/src/parcel-host-watcher.ts b/packages/host-watcher/src/parcel-host-watcher.ts
index 6c4fea07c..ca0bc31a5 100644
--- a/packages/host-watcher/src/parcel-host-watcher.ts
+++ b/packages/host-watcher/src/parcel-host-watcher.ts
@@ -1,9 +1,10 @@
import path from "node:path";
-import type { StatusDataKey } from "@bb/domain";
import {
- parseStatusDataFileName,
- STATUS_DATA_DIRECTORY_NAME,
-} from "./status-data-paths.js";
+ appDataPathSchema,
+ appIdSchema,
+ type AppDataPath,
+ type AppId,
+} from "@bb/domain";
import { watchPathChanges } from "./watch-path.js";
import { watchWorkspaceStatus } from "./watch-status.js";
import type {
@@ -24,8 +25,9 @@ interface ThreadStoragePath {
threadId: string;
}
-interface ThreadStatusDataPath {
- key: StatusDataKey;
+interface ThreadAppDataPath {
+ appId: AppId;
+ path: AppDataPath;
threadId: string;
}
@@ -60,23 +62,28 @@ function toThreadStoragePath(
};
}
-function isStatusDataSubtreePath(path: ThreadStoragePath): boolean {
- return path.parts[1] === STATUS_DATA_DIRECTORY_NAME;
+function isAppDataSubtreePath(path: ThreadStoragePath): boolean {
+ return path.parts[1] === "apps" && path.parts[3] === "data";
}
-function toThreadStatusDataPath(
+function toThreadAppDataPath(
path: ThreadStoragePath,
-): ThreadStatusDataPath | null {
- if (!isStatusDataSubtreePath(path) || path.parts.length !== 3) {
+): ThreadAppDataPath | null {
+ if (!isAppDataSubtreePath(path) || path.parts.length < 5) {
return null;
}
- const parsedKey = parseStatusDataFileName(path.parts[2] ?? "");
- if (!parsedKey) {
+ const appId = appIdSchema.safeParse(path.parts[2]);
+ if (!appId.success) {
+ return null;
+ }
+ const dataPath = appDataPathSchema.safeParse(path.parts.slice(4).join("/"));
+ if (!dataPath.success) {
return null;
}
return {
+ appId: appId.data,
threadId: path.threadId,
- key: parsedKey,
+ path: dataPath.data,
};
}
@@ -84,7 +91,7 @@ export function collectThreadStorageObservedChanges(
args: CollectThreadStorageObservedChangesArgs,
): ThreadStorageObservedChange[] {
const storageChanges = new Map();
- const statusDataChanges = new Map();
+ const appDataChanges = new Map();
for (const changedPath of args.changedPaths) {
const storagePath = toThreadStoragePath({
changedPath,
@@ -97,16 +104,17 @@ export function collectThreadStorageObservedChanges(
if (!target) {
continue;
}
- if (isStatusDataSubtreePath(storagePath)) {
- const statusDataPath = toThreadStatusDataPath(storagePath);
- if (statusDataPath) {
- statusDataChanges.set(
- `${target.environmentId}:${target.threadId}:${statusDataPath.key}`,
+ if (isAppDataSubtreePath(storagePath)) {
+ const appDataPath = toThreadAppDataPath(storagePath);
+ if (appDataPath) {
+ appDataChanges.set(
+ `${target.environmentId}:${target.threadId}:${appDataPath.appId}:${appDataPath.path}`,
{
- kind: "thread-status-data-changed",
+ kind: "thread-app-data-changed",
+ appId: appDataPath.appId,
environmentId: target.environmentId,
+ path: appDataPath.path,
threadId: target.threadId,
- key: statusDataPath.key,
},
);
}
@@ -123,7 +131,7 @@ export function collectThreadStorageObservedChanges(
threadId: target.threadId,
});
}
- observedChanges.push(...statusDataChanges.values());
+ observedChanges.push(...appDataChanges.values());
return observedChanges;
}
diff --git a/packages/host-watcher/src/status-data-paths.ts b/packages/host-watcher/src/status-data-paths.ts
deleted file mode 100644
index a43ab934a..000000000
--- a/packages/host-watcher/src/status-data-paths.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { statusDataKeySchema, type StatusDataKey } from "@bb/domain";
-
-export const STATUS_DATA_DIRECTORY_NAME = "STATUS-data";
-export const STATUS_DATA_FILE_EXTENSION = ".json";
-
-export function statusDataFileName(key: StatusDataKey): string {
- return `${key}${STATUS_DATA_FILE_EXTENSION}`;
-}
-
-export function parseStatusDataFileName(
- fileName: string,
-): StatusDataKey | null {
- if (!fileName.endsWith(STATUS_DATA_FILE_EXTENSION)) {
- return null;
- }
- const rawKey = fileName.slice(0, -STATUS_DATA_FILE_EXTENSION.length);
- const parsed = statusDataKeySchema.safeParse(rawKey);
- return parsed.success ? parsed.data : null;
-}
diff --git a/packages/host-watcher/test/thread-storage-watch.test.ts b/packages/host-watcher/test/thread-storage-watch.test.ts
index 402998e35..107fb9e82 100644
--- a/packages/host-watcher/test/thread-storage-watch.test.ts
+++ b/packages/host-watcher/test/thread-storage-watch.test.ts
@@ -10,21 +10,15 @@ function createResolver(
}
describe("thread storage watcher classification", () => {
- it("emits broad storage changes and targeted STATUS-data changes", () => {
+ it("emits broad storage changes for ordinary thread storage changes", () => {
const rootPath = path.join("/tmp", "thread-storage");
const changes = collectThreadStorageObservedChanges({
threadStorageRootPath: rootPath,
changedPaths: [
path.join(rootPath, "thr_one", "notes.md"),
- path.join(rootPath, "thr_one", "STATUS-data", "tasks.json"),
- path.join(rootPath, "thr_one", "STATUS-data", "tasks.json"),
path.join(rootPath, "thr_two", "reports", "summary.html"),
- path.join(rootPath, "thr_two", "STATUS-data", "prefs.json"),
- path.join(rootPath, "thr_two", "STATUS-data", "nested", "x.json"),
- path.join(rootPath, "thr_two", "STATUS-data", ".tmp.json"),
- path.join(rootPath, "thr_three", "STATUS-data", "state.json"),
- path.join(rootPath, "thr_unknown", "STATUS-data", "tasks.json"),
- path.join(rootPath, "..", "outside", "STATUS-data", "tasks.json"),
+ path.join(rootPath, "thr_unknown", "notes.md"),
+ path.join(rootPath, "..", "outside", "notes.md"),
],
resolveThreadTarget: createResolver({
thr_one: {
@@ -35,10 +29,6 @@ describe("thread storage watcher classification", () => {
environmentId: "env_two",
threadId: "thr_two",
},
- thr_three: {
- environmentId: "env_three",
- threadId: "thr_three",
- },
}),
});
@@ -53,36 +43,35 @@ describe("thread storage watcher classification", () => {
environmentId: "env_two",
threadId: "thr_two",
},
- {
- kind: "thread-status-data-changed",
- environmentId: "env_one",
- threadId: "thr_one",
- key: "tasks",
- },
- {
- kind: "thread-status-data-changed",
- environmentId: "env_two",
- threadId: "thr_two",
- key: "prefs",
- },
- {
- kind: "thread-status-data-changed",
- environmentId: "env_three",
- threadId: "thr_three",
- key: "state",
- },
]);
});
- it("does not emit broad storage changes for STATUS-data subtree noise", () => {
+ it("emits targeted app data changes without broad storage changes", () => {
const rootPath = path.join("/tmp", "thread-storage");
const changes = collectThreadStorageObservedChanges({
threadStorageRootPath: rootPath,
changedPaths: [
- path.join(rootPath, "thr_one", "STATUS-data"),
- path.join(rootPath, "thr_one", "STATUS-data", ".tasks.tmp"),
- path.join(rootPath, "thr_one", "STATUS-data", "nested", "x.json"),
- path.join(rootPath, "thr_one", "STATUS-data", "invalid.key.json"),
+ path.join(rootPath, "thr_one", "apps", "status", "data", "state.json"),
+ path.join(rootPath, "thr_one", "apps", "status", "data", "state.json"),
+ path.join(rootPath, "thr_one", "apps", "kanban", "data", "cards", "1"),
+ path.join(rootPath, "thr_one", "apps", "bad.app", "data", "state.json"),
+ path.join(rootPath, "thr_one", "apps", "status", "data", ".state.tmp"),
+ path.join(
+ rootPath,
+ "thr_one",
+ "apps",
+ "status",
+ "assets",
+ "index.html",
+ ),
+ path.join(
+ rootPath,
+ "thr_unknown",
+ "apps",
+ "status",
+ "data",
+ "state.json",
+ ),
],
resolveThreadTarget: createResolver({
thr_one: {
@@ -92,6 +81,26 @@ describe("thread storage watcher classification", () => {
}),
});
- expect(changes).toEqual([]);
+ expect(changes).toEqual([
+ {
+ kind: "thread-storage-changed",
+ environmentId: "env_one",
+ threadId: "thr_one",
+ },
+ {
+ kind: "thread-app-data-changed",
+ appId: "status",
+ environmentId: "env_one",
+ path: "state.json",
+ threadId: "thr_one",
+ },
+ {
+ kind: "thread-app-data-changed",
+ appId: "kanban",
+ environmentId: "env_one",
+ path: "cards/1",
+ threadId: "thr_one",
+ },
+ ]);
});
});
diff --git a/packages/server-contract/src/api-types.ts b/packages/server-contract/src/api-types.ts
index 797805c1c..f4ff31422 100644
--- a/packages/server-contract/src/api-types.ts
+++ b/packages/server-contract/src/api-types.ts
@@ -34,9 +34,15 @@ import {
workspaceStatusSchema,
managerTemplateNameSchema,
jsonValueSchema,
- statusDataKeySchema,
+ appDataPathSchema,
+ appIdSchema,
+} from "@bb/domain";
+import type {
+ AppDataPath,
+ AppId,
+ GitBranchName,
+ JsonValue,
} from "@bb/domain";
-import type { GitBranchName, JsonValue, StatusDataKey } from "@bb/domain";
import { apiErrorSchema } from "./errors.js";
import { timelineRowSchema } from "./thread-timeline.js";
@@ -376,37 +382,6 @@ export const sendMessageRequestSchema = z.object({
});
export type SendMessageRequest = z.infer;
-export const statusIframeThreadTellInputSchema = z.object({
- type: z.literal("text"),
- text: z.string(),
-});
-export type StatusIframeThreadTellInput = z.infer<
- typeof statusIframeThreadTellInputSchema
->;
-
-export const statusIframeThreadTellRequestSchema = z.object({
- input: z.tuple([statusIframeThreadTellInputSchema]),
- mode: z.literal("auto"),
-});
-export type StatusIframeThreadTellRequest = z.infer<
- typeof statusIframeThreadTellRequestSchema
->;
-
-export interface BbThreadTell {
- (text: string): Promise;
-}
-
-export interface BbStatusIframeWindow extends Window {
- bbStatusState: BbStatusState;
- bbThreadTell: BbThreadTell;
-}
-
-declare global {
- interface Window {
- bbThreadTell?: BbThreadTell;
- }
-}
-
export const sendQueuedMessageModeSchema = z.enum(["auto", "steer"]);
export type SendQueuedMessageMode = z.infer;
@@ -1036,116 +1011,303 @@ export type ThreadHostFileContentQuery = z.infer<
typeof threadHostFileContentQuerySchema
>;
-export const threadStatusVersionSourceSchema = z.enum([
- "folder",
- "html",
- "md",
- "empty",
+// Keep app path limits in sync with packages/domain/src/apps.ts and the
+// injected app client validator in app-client-script.ts.
+const appEntryPathSegmentPattern = /^[A-Za-z0-9._-]{1,120}$/u;
+
+function isValidAppEntryPath(value: string): boolean {
+ if (
+ value.length === 0 ||
+ value.length > 512 ||
+ value.includes("\0") ||
+ value.includes("\\") ||
+ value.startsWith("/") ||
+ value.endsWith("/")
+ ) {
+ return false;
+ }
+
+ const segments = value.split("/");
+ return segments.every(
+ (segment) =>
+ segment !== "." &&
+ segment !== ".." &&
+ !segment.startsWith(".") &&
+ appEntryPathSegmentPattern.test(segment),
+ );
+}
+
+export const appIconNameValues = [
+ "AlertCircle",
+ "AlertTriangle",
+ "AlignLeft",
+ "Archive",
+ "ArchiveRestore",
+ "ArrowDown",
+ "ArrowRight",
+ "ArrowUp",
+ "AudioLines",
+ "Check",
+ "ChevronDown",
+ "ChevronLeft",
+ "ChevronRight",
+ "ChevronUp",
+ "ChevronsDown",
+ "ChevronsUp",
+ "Circle",
+ "CircleCheck",
+ "CircleDashed",
+ "CircleX",
+ "Columns2",
+ "Container",
+ "Copy",
+ "CornerDownLeft",
+ "CornerDownRight",
+ "Edit",
+ "ExternalLink",
+ "FileDiff",
+ "File",
+ "FileQuestion",
+ "FileX2",
+ "Folder",
+ "FolderOpen",
+ "FolderMinus",
+ "FolderPlus",
+ "GitBranch",
+ "GitMerge",
+ "GridView",
+ "Info",
+ "Laptop",
+ "ListTodo",
+ "Maximize2",
+ "MessageSquarePlus",
+ "MessageSquare",
+ "Mic",
+ "Minimize2",
+ "MoreHorizontal",
+ "PanelBottom",
+ "PanelLeft",
+ "PanelRight",
+ "Paperclip",
+ "Plus",
+ "RotateCcw",
+ "Rows2",
+ "Search",
+ "Settings",
+ "Spinner",
+ "Square",
+ "Terminal",
+ "Trash2",
+ "UserRound",
+ "UserRoundPlus",
+ "X",
+ "Zap",
+] as const;
+export const appIconNameSchema = z.enum(appIconNameValues);
+export type AppIconName = z.infer;
+
+export const appEntryKindSchema = z.enum(["html", "md"]);
+export type AppEntryKind = z.infer;
+
+export const appEntryPathSchema = z
+ .string()
+ .refine(isValidAppEntryPath, "Invalid app entry path");
+export type AppEntryPath = z.infer;
+
+export const appEntrySchema = z
+ .object({
+ path: appEntryPathSchema,
+ kind: appEntryKindSchema,
+ })
+ .strict();
+export type AppEntry = z.infer;
+
+export const appCapabilitySchema = z.enum(["data", "message"]);
+export type AppCapability = z.infer;
+
+export const appContributionSchema = z.enum(["thread.app"]);
+export type AppContribution = z.infer;
+
+export const appManifestSchema = z
+ .object({
+ manifestVersion: z.literal(1),
+ id: appIdSchema,
+ name: z.string().min(1).max(80),
+ icon: appIconNameSchema.optional(),
+ entry: appEntryPathSchema.optional(),
+ contributions: z.array(appContributionSchema).min(1),
+ capabilities: z.array(appCapabilitySchema),
+ })
+ .strict()
+ .superRefine((manifest, context) => {
+ if (
+ manifest.contributions.length !== 1 ||
+ manifest.contributions[0] !== "thread.app"
+ ) {
+ context.addIssue({
+ code: "custom",
+ path: ["contributions"],
+ message: 'Only ["thread.app"] is supported',
+ });
+ }
+ });
+export type AppManifest = z.infer;
+
+export const appIconSchema = z.discriminatedUnion("kind", [
+ z
+ .object({
+ kind: z.literal("builtin"),
+ name: appIconNameSchema,
+ })
+ .strict(),
+ z
+ .object({
+ kind: z.literal("logo"),
+ url: z.string().min(1),
+ })
+ .strict(),
]);
-export type ThreadStatusVersionSource = z.infer<
- typeof threadStatusVersionSourceSchema
+export type AppIcon = z.infer;
+
+export const appSummarySchema = z
+ .object({
+ id: appIdSchema,
+ name: z.string().min(1).max(80),
+ entry: appEntrySchema,
+ capabilities: z.array(appCapabilitySchema),
+ icon: appIconSchema,
+ })
+ .strict();
+export type AppSummary = z.infer;
+
+export const appDetailSchema = appSummarySchema;
+export type AppDetail = z.infer