From 8bff7783831727d5122d0a0aad8041253ec741d8 Mon Sep 17 00:00:00 2001
From: Sawyer Hood
Date: Tue, 2 Jun 2026 17:27:14 -0700
Subject: [PATCH 01/12] Global app storage + top-level Apps sidebar; remove
built-in status app
Apps are now global on the local host (filesystem source-of-truth at
/apps//, manifest.id == applicationId, no install
DB). Adds global /api/v1/apps/* routes, BB_APPS_ROOT/BB_APP_* runtime roots,
app-session message target context, atomic create/tombstone-delete, bb app
current CLI, daemon app-data tracking + apps-root watcher, and a top-level
sidebar Apps section (drops per-manager app nesting). Removes the old
thread-scoped /threads/:id/apps routes and the built-in manager status app.
---
.../src/components/layout/AppLayout.test.tsx | 4 +-
.../secondary-panel/AppTabContent.test.tsx | 88 +-
.../secondary-panel/AppTabContent.tsx | 37 +-
.../secondary-panel/FilePreview.test.tsx | 10 +-
.../ManagerThreadStorageBrowser.stories.tsx | 4 +-
.../secondary-panel/NewTabFileSearch.tsx | 44 +-
.../secondary-panel/NewTabPage.test.tsx | 42 +-
.../ThreadSecondaryPanel.test.tsx | 18 +-
.../ThreadSecondaryPanelNewTab.stories.tsx | 26 +-
.../useThreadFileTabs.test.tsx | 61 +-
.../secondary-panel/useThreadFileTabs.ts | 92 +-
.../components/sidebar/ProjectList.test.tsx | 242 ++-
.../src/components/sidebar/ProjectList.tsx | 43 +-
.../app/src/components/sidebar/ProjectRow.tsx | 79 +-
.../components/sidebar/SidebarAppsSection.tsx | 140 ++
.../src/components/sidebar/ThreadAppRow.tsx | 94 --
apps/app/src/components/sidebar/ThreadRow.tsx | 22 +-
.../sidebar/sidebarCollapsedAtoms.ts | 3 +-
.../cache-owners/cache-owner-registry.test.ts | 12 +-
.../cache-owners/realtime-cache-registry.ts | 6 -
.../cache-owners/system-cache-effects.ts | 12 +-
apps/app/src/hooks/queries/query-keys.ts | 86 +-
apps/app/src/hooks/queries/thread-queries.ts | 65 +-
.../src/hooks/realtime-cache-effects.test.ts | 86 -
.../hooks/useFileSearchSuggestions.test.tsx | 24 +-
.../app/src/hooks/useFileSearchSuggestions.ts | 18 +-
apps/app/src/lib/api.ts | 26 +-
apps/app/src/lib/file-content-urls.ts | 30 +-
.../src/lib/fixed-panel-tabs-state.test.ts | 4 +-
apps/app/src/lib/fixed-panel-tabs-state.ts | 33 +-
.../views/thread-detail/ThreadDetailView.tsx | 42 +-
apps/cli/src/__tests__/command-output.test.ts | 101 +-
apps/cli/src/commands/app.ts | 447 +++--
.../src/app-data-change-reporter.test.ts | 114 +-
.../src/app-data-change-reporter.ts | 219 ++-
apps/host-daemon/src/app-data-files.test.ts | 73 +-
apps/host-daemon/src/app-data-files.ts | 194 ++-
apps/host-daemon/src/app.test.ts | 3 +
apps/host-daemon/src/app.ts | 47 +-
.../src/command-handlers/host-files.test.ts | 6 +-
apps/host-daemon/src/runtime-manager.test.ts | 114 +-
apps/host-daemon/src/runtime-manager.ts | 129 +-
.../host-daemon/src/runtime-shell-env.test.ts | 6 +
apps/host-daemon/src/runtime-shell-env.ts | 2 +
apps/host-daemon/src/start-host-daemon.ts | 2 +
apps/host-daemon/test/helpers/test-server.ts | 1 +
apps/server/src/internal/app-data-changes.ts | 56 +-
apps/server/src/internal/session.ts | 7 +
apps/server/src/routes/apps.ts | 1456 +++++++++++++++++
apps/server/src/routes/threads/apps.ts | 1320 ---------------
apps/server/src/routes/threads/index.ts | 2 -
apps/server/src/server.ts | 2 +
.../apps/tracked-application-data-targets.ts | 79 +
.../src/services/threads/app-client-script.ts | 24 +-
.../services/threads/blank-app-scaffold.ts | 13 +-
.../apps/status/manifest.json | 9 -
.../threads/manager-storage-templates.ts | 112 +-
apps/server/src/ws/hub.ts | 4 +-
apps/server/test/app/hub.test.ts | 20 +-
.../internal/internal-app-data-change.test.ts | 105 +-
apps/server/test/public/public-apps.test.ts | 229 +++
.../test/public/public-thread-apps.test.ts | 1375 ----------------
...blic-threads.manager-and-ownership.test.ts | 20 +-
.../threads/app-client-script.test.ts | 21 +-
.../threads/manager-storage-templates.test.ts | 113 +-
docs/migrating-status-to-apps.md | 199 ---
packages/config/package.json | 5 +
packages/config/src/app-storage-paths.ts | 34 +
packages/domain/src/apps.ts | 5 +-
packages/domain/src/change-kinds.ts | 1 +
packages/domain/src/index.ts | 4 +-
packages/host-daemon-contract/src/index.ts | 2 +
packages/host-daemon-contract/src/session.ts | 19 +-
.../test/contract.test.ts | 10 +-
.../host-watcher/src/host-watcher-types.ts | 59 +-
packages/host-watcher/src/index.ts | 4 +
.../host-watcher/src/parcel-host-watcher.ts | 219 ++-
.../test/thread-storage-watch.test.ts | 118 +-
packages/server-contract/src/api-types.ts | 63 +-
packages/server-contract/src/index.ts | 7 +-
packages/server-contract/src/public-api.ts | 81 +-
.../server-contract/test/contract.test.ts | 14 +-
.../src/generated/templates.generated.ts | 18 +-
.../templates/src/templates/bb-guide-app.md | 128 +-
.../templates/bb-guide-manager-templates.md | 61 +-
.../src/templates/bb-guide-overview.md | 2 +-
.../templates/manager-agent-instructions.md | 3 +-
.../system-message-manager-quick-start.md | 2 +-
.../system-message-manager-welcome.md | 19 +-
packages/templates/test/templates.test.ts | 11 +-
90 files changed, 4005 insertions(+), 5001 deletions(-)
create mode 100644 apps/app/src/components/sidebar/SidebarAppsSection.tsx
delete mode 100644 apps/app/src/components/sidebar/ThreadAppRow.tsx
create mode 100644 apps/server/src/routes/apps.ts
delete mode 100644 apps/server/src/routes/threads/apps.ts
create mode 100644 apps/server/src/services/apps/tracked-application-data-targets.ts
delete mode 100644 apps/server/src/services/threads/default-template/apps/status/manifest.json
create mode 100644 apps/server/test/public/public-apps.test.ts
delete mode 100644 apps/server/test/public/public-thread-apps.test.ts
delete mode 100644 docs/migrating-status-to-apps.md
create mode 100644 packages/config/src/app-storage-paths.ts
diff --git a/apps/app/src/components/layout/AppLayout.test.tsx b/apps/app/src/components/layout/AppLayout.test.tsx
index 093741282..0d9853275 100644
--- a/apps/app/src/components/layout/AppLayout.test.tsx
+++ b/apps/app/src/components/layout/AppLayout.test.tsx
@@ -351,12 +351,12 @@ describe("AppLayout desktop chrome", () => {
await renderAppLayout({
desktopInfo: null,
initialEntry: "/projects/proj_sidebar_resize",
- children: ,
+ children: ,
});
const appLayoutRoot = await screen.findByTestId("app-layout-root");
const resizeHandle = screen.getByTestId("app-sidebar-resize-handle");
- const iframe = screen.getByTitle("Status app");
+ const iframe = screen.getByTitle("Review Board app");
expect(appLayoutRoot.contains(iframe)).toBe(true);
expect(screen.queryByTestId("iframe-drag-guard-overlay")).toBeNull();
diff --git a/apps/app/src/components/secondary-panel/AppTabContent.test.tsx b/apps/app/src/components/secondary-panel/AppTabContent.test.tsx
index 21c48d36a..cdc937b2b 100644
--- a/apps/app/src/components/secondary-panel/AppTabContent.test.tsx
+++ b/apps/app/src/components/secondary-panel/AppTabContent.test.tsx
@@ -1,13 +1,10 @@
// @vitest-environment jsdom
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
-import type { AppDetail, AppSummary } from "@bb/server-contract";
+import type { AppDetail } from "@bb/server-contract";
import * as api from "@/lib/api";
import { afterEach, describe, expect, it, vi } from "vitest";
-import {
- threadAppQueryKey,
- threadAppsQueryKey,
-} from "@/hooks/queries/query-keys";
+import { appQueryKey } from "@/hooks/queries/query-keys";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { AppTabContent } from "./AppTabContent";
@@ -16,25 +13,31 @@ vi.mock("@/lib/api", async (importOriginal) => {
return {
...actual,
- getThreadApp: vi.fn(),
- getThreadAppMarkdownPreview: vi.fn(),
+ getApp: vi.fn(),
+ getAppMarkdownPreview: vi.fn(),
};
});
const HTML_APP: AppDetail = {
- id: "status",
- name: "Status",
+ applicationId: "app_status",
+ name: "Review Board",
entry: { path: "index.html", kind: "html" },
capabilities: ["data", "message"],
icon: { kind: "builtin", name: "ListTodo" },
+ appsRootPath: "/tmp/bb-data/apps",
+ appRootPath: "/tmp/bb-data/apps/app_status",
+ appDataPath: "/tmp/bb-data/apps/app_status/data",
};
const MARKDOWN_APP: AppDetail = {
- id: "readme",
+ applicationId: "app_readme",
name: "Readme",
entry: { path: "docs/index.md", kind: "md" },
capabilities: [],
icon: { kind: "builtin", name: "GridView" },
+ appsRootPath: "/tmp/bb-data/apps",
+ appRootPath: "/tmp/bb-data/apps/app_readme",
+ appDataPath: "/tmp/bb-data/apps/app_readme/data",
};
afterEach(() => {
@@ -44,58 +47,42 @@ afterEach(() => {
});
describe("AppTabContent", () => {
- it("renders app-list metadata while the detail refetch is still pending", () => {
- vi.mocked(api.getThreadApp).mockReturnValue(new Promise(() => {}));
- const { queryClient, wrapper } = createQueryClientTestHarness();
- queryClient.setQueryData(threadAppsQueryKey("thr_1"), [
- HTML_APP,
- ]);
-
- render( , { wrapper });
-
- const frame = screen.getByTitle("Status");
- expect(frame.getAttribute("src")).toMatch(
- /^\/api\/v1\/threads\/thr_1\/apps\/status\/\?v=\d+$/u,
- );
- expect(api.getThreadApp).toHaveBeenCalledWith(
- "thr_1",
- "status",
- expect.any(AbortSignal),
- );
- });
-
it("renders HTML apps in the injected app iframe route", async () => {
- vi.mocked(api.getThreadApp).mockResolvedValue(HTML_APP);
+ vi.mocked(api.getApp).mockResolvedValue(HTML_APP);
const { wrapper } = createQueryClientTestHarness();
- render( , { wrapper });
+ render( , {
+ wrapper,
+ });
- const frame = await screen.findByTitle("Status");
+ const frame = await screen.findByTitle("Review Board");
expect(frame.getAttribute("src")).toMatch(
- /^\/api\/v1\/threads\/thr_1\/apps\/status\/\?v=\d+$/u,
+ /^\/api\/v1\/apps\/app_status\/\?targetThreadId=thr_1&v=\d+$/u,
);
expect(frame.getAttribute("sandbox")).toBeNull();
- expect(api.getThreadAppMarkdownPreview).not.toHaveBeenCalled();
+ expect(api.getAppMarkdownPreview).not.toHaveBeenCalled();
});
it("reloads HTML app iframes when app detail data refreshes", async () => {
- vi.mocked(api.getThreadApp).mockReturnValue(new Promise(() => {}));
+ vi.mocked(api.getApp).mockReturnValue(new Promise(() => {}));
const { queryClient, wrapper } = createQueryClientTestHarness();
- const appQueryKey = threadAppQueryKey("thr_1", "status");
- queryClient.setQueryData(appQueryKey, HTML_APP, {
+ const queryKey = appQueryKey("app_status");
+ queryClient.setQueryData(queryKey, HTML_APP, {
updatedAt: 1_000,
});
- render( , { wrapper });
+ render( , {
+ wrapper,
+ });
- const firstFrame = screen.getByTitle("Status");
+ const firstFrame = screen.getByTitle("Review Board");
const firstSrc = firstFrame.getAttribute("src");
act(() => {
queryClient.setQueryData(
- appQueryKey,
+ queryKey,
{
...HTML_APP,
- name: "Status",
+ name: "Review Board",
},
{
updatedAt: 2_000,
@@ -104,31 +91,32 @@ describe("AppTabContent", () => {
});
await waitFor(() => {
- expect(screen.getByTitle("Status").getAttribute("src")).not.toBe(
+ expect(screen.getByTitle("Review Board").getAttribute("src")).not.toBe(
firstSrc,
);
});
});
it("renders markdown apps through the static markdown preview path", async () => {
- vi.mocked(api.getThreadApp).mockResolvedValue(MARKDOWN_APP);
- vi.mocked(api.getThreadAppMarkdownPreview).mockResolvedValue({
+ vi.mocked(api.getApp).mockResolvedValue(MARKDOWN_APP);
+ vi.mocked(api.getAppMarkdownPreview).mockResolvedValue({
kind: "text",
path: "docs/index.md",
name: "index.md",
- url: "/api/v1/threads/thr_1/apps/readme/docs/index.md",
+ url: "/api/v1/apps/app_readme/assets/docs/index.md",
mimeType: "text/markdown",
content: "# App Notes\n\nStatic content.",
});
const { wrapper } = createQueryClientTestHarness();
- render( , { wrapper });
+ render( , {
+ wrapper,
+ });
expect(await screen.findByText("App Notes")).toBeTruthy();
expect(screen.getByText("Static content.")).toBeTruthy();
- expect(api.getThreadAppMarkdownPreview).toHaveBeenCalledWith(
- "thr_1",
- "readme",
+ expect(api.getAppMarkdownPreview).toHaveBeenCalledWith(
+ "app_readme",
"docs/index.md",
expect.any(AbortSignal),
);
diff --git a/apps/app/src/components/secondary-panel/AppTabContent.tsx b/apps/app/src/components/secondary-panel/AppTabContent.tsx
index 4fe39a9b1..920646cb8 100644
--- a/apps/app/src/components/secondary-panel/AppTabContent.tsx
+++ b/apps/app/src/components/secondary-panel/AppTabContent.tsx
@@ -1,11 +1,11 @@
import { useMemo } from "react";
import {
- useThreadApp,
- useThreadAppMarkdownPreview,
+ useApp,
+ useAppMarkdownPreview,
} from "@/hooks/queries/thread-queries";
import {
- buildThreadAppAssetBaseUrl,
- buildThreadAppEntryUrl,
+ buildAppAssetBaseUrl,
+ buildAppEntryUrl,
} from "@/lib/file-content-urls";
import { createAssetMarkdownUrlTransform } from "@/lib/markdown-url-transform";
import { FilePreview as FilePreviewSurface } from "./FilePreview";
@@ -13,33 +13,32 @@ import { FilePreview as FilePreviewSurface } from "./FilePreview";
const APP_HEADER_MODE = "none";
interface BuildReloadableAppEntryUrlArgs {
- appId: string;
+ applicationId: string;
reloadToken: number;
threadId: string;
}
export interface AppTabContentProps {
- appId: string;
+ applicationId: string;
threadId: string;
}
function buildReloadableAppEntryUrl({
- appId,
+ applicationId,
reloadToken,
threadId,
}: BuildReloadableAppEntryUrlArgs): string {
- return `${buildThreadAppEntryUrl(threadId, appId)}?v=${encodeURIComponent(
+ return `${buildAppEntryUrl(applicationId, threadId)}&v=${encodeURIComponent(
String(reloadToken),
)}`;
}
-export function AppTabContent({ appId, threadId }: AppTabContentProps) {
- const appDetail = useThreadApp(threadId, appId);
+export function AppTabContent({ applicationId, threadId }: AppTabContentProps) {
+ const appDetail = useApp(applicationId);
const markdownEntryPath =
appDetail.data?.entry.kind === "md" ? appDetail.data.entry.path : null;
- const markdownPreview = useThreadAppMarkdownPreview(
- threadId,
- appId,
+ const markdownPreview = useAppMarkdownPreview(
+ applicationId,
markdownEntryPath,
{
enabled: markdownEntryPath !== null,
@@ -49,8 +48,8 @@ export function AppTabContent({ appId, threadId }: AppTabContentProps) {
if (markdownEntryPath === null) {
return null;
}
- return buildThreadAppAssetBaseUrl(threadId, appId, markdownEntryPath);
- }, [appId, markdownEntryPath, threadId]);
+ return buildAppAssetBaseUrl(applicationId, markdownEntryPath);
+ }, [applicationId, markdownEntryPath]);
const markdownUrlTransform = useMemo(() => {
if (markdownAssetBaseUrl === null) {
return undefined;
@@ -60,17 +59,17 @@ export function AppTabContent({ appId, threadId }: AppTabContentProps) {
const htmlEntryUrl = useMemo(
() =>
buildReloadableAppEntryUrl({
- appId,
+ applicationId,
reloadToken: appDetail.dataUpdatedAt,
threadId,
}),
- [appDetail.dataUpdatedAt, appId, threadId],
+ [appDetail.dataUpdatedAt, applicationId, threadId],
);
if (appDetail.isError) {
return (
diff --git a/apps/app/src/components/secondary-panel/FilePreview.test.tsx b/apps/app/src/components/secondary-panel/FilePreview.test.tsx
index 15b39bba2..48da6a928 100644
--- a/apps/app/src/components/secondary-panel/FilePreview.test.tsx
+++ b/apps/app/src/components/secondary-panel/FilePreview.test.tsx
@@ -26,8 +26,8 @@ describe("FilePreview", () => {
state={{
kind: "iframe",
sandbox: null,
- title: "Status",
- url: "/api/v1/threads/thr_1/apps/status/",
+ title: "Review Board",
+ url: "/api/v1/apps/app_review_board/?targetThreadId=thr_1",
}}
/>,
);
@@ -54,13 +54,13 @@ describe("FilePreview", () => {
state={{
kind: "iframe",
sandbox: null,
- title: "Status",
- url: "/api/v1/threads/thr_1/apps/status/",
+ title: "Review Board",
+ url: "/api/v1/apps/app_review_board/?targetThreadId=thr_1",
}}
/>,
);
- fireEvent.load(screen.getByTitle("Status"));
+ fireEvent.load(screen.getByTitle("Review Board"));
act(() => {
vi.advanceTimersByTime(160);
});
diff --git a/apps/app/src/components/secondary-panel/ManagerThreadStorageBrowser.stories.tsx b/apps/app/src/components/secondary-panel/ManagerThreadStorageBrowser.stories.tsx
index dc1fe0d6a..72d6d6be1 100644
--- a/apps/app/src/components/secondary-panel/ManagerThreadStorageBrowser.stories.tsx
+++ b/apps/app/src/components/secondary-panel/ManagerThreadStorageBrowser.stories.tsx
@@ -28,8 +28,8 @@ function makeFile(path: string): WorkspaceFile {
const FILES: WorkspaceFile[] = [
makeFile("ASYNC.md"),
- makeFile("apps/status/manifest.json"),
- makeFile("apps/status/data/state.json"),
+ makeFile("notes/current-work.md"),
+ makeFile("plans/kickoff.md"),
makeFile("PREFERENCES.md"),
];
diff --git a/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx b/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx
index 39327117c..2ad26ee73 100644
--- a/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx
+++ b/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx
@@ -33,16 +33,16 @@ import { isDesktopBrowserAvailable } from "@/lib/bb-desktop";
import { formatRelativeTime } from "@/lib/relative-time";
import { isPromptDraftEmpty, type PromptDraftState } from "@/lib/prompt-draft";
-export const CREATE_APP_PROMPT_TEMPLATE = `You are creating a new bb app for this thread.
+export const CREATE_APP_PROMPT_TEMPLATE = `You are creating a new global bb app.
Apps system reference — run \`bb guide app\` for full detail. Layout:
-- apps//manifest.json — { manifestVersion: 1, id, name, icon | logo.svg, entry, contributions: ["thread.app"], capabilities: ["data"?, "message"?] }
-- apps//assets/index.html — self-contained static HTML/CSS/JS/SVG served by bb; use inline/relative files, no web server, npm, or build step
-- apps//data/state.json — initial state if the app uses window.bb.data
+- /apps//manifest.json — { manifestVersion: 1, id: applicationId, name, icon | logo.svg, entry, capabilities: ["data"?, "message"?] }
+- /apps//assets/index.html — self-contained static HTML/CSS/JS/SVG served by bb; use inline/relative files, no web server, npm, or build step
+- /apps//data/state.json — state if the app uses window.bb.data
In the page, use window.bb.data for live state (read / write / delete / list / onChange; onChange replays + streams) and window.bb.message(text) to send the thread a prompt. Guard with \`window.bb?.data?.…\` since capabilities are advisory.
-Scaffold with \`bb app new\` — the default styling is wired up already, so build on top of it and keep the UI polished, accessible, and dense like the rest of bb.
+Scaffold with \`bb app new --name "Name"\` or, inside an app-capable runtime, inspect \`bb app current --json\` and write directly to \`BB_APP_ROOT\` / \`BB_APP_DATA_PATH\`. The application id is the opaque \`app_...\` folder name; display names are not identifiers.
What I want:
@@ -217,14 +217,6 @@ const RECENT_CHIP_ICON_NAME = {
} satisfies Record;
const RECENT_ENTRY_ID_PREFIX = "file-search-result-recent";
-function slugifyAppName(name: string): string {
- return name
- .trim()
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-+|-+$/g, "");
-}
-
function getAvailableFileSearchSources({
projectId,
currentThreadId,
@@ -245,7 +237,9 @@ function getAvailableFileSearchSources({
function getFileSearchResultId(suggestion: FileSearchSuggestion): string {
const idSegment =
- suggestion.entryKind === "app" ? suggestion.appId : suggestion.path;
+ suggestion.entryKind === "app"
+ ? suggestion.applicationId
+ : suggestion.path;
return `file-search-result-${suggestion.source}-${encodeURIComponent(
idSegment,
)}`;
@@ -442,8 +436,6 @@ function AppResultRow({
const handleSelect = useCallback(() => {
onSelect(suggestion);
}, [onSelect, suggestion]);
- const showAppId = suggestion.appId !== slugifyAppName(suggestion.name);
-
return (
{suggestion.name}
- {showAppId ? (
- <>
-
- ·
-
-
- {suggestion.appId}
-
- >
- ) : null}
+
+ ·
+
+
+ {suggestion.applicationId}
+
);
@@ -787,7 +775,7 @@ export function NewTabFileSearch({
const handleAppSelect = useCallback(
(suggestion: AppSearchSuggestion) => {
- onSelect({ source: "app", appId: suggestion.appId });
+ onSelect({ source: "app", applicationId: suggestion.applicationId });
},
[onSelect],
);
@@ -1067,7 +1055,7 @@ function NewTabResults({
const suggestion = entry.suggestion;
return (
{
return {
...actual,
searchProjectPaths: vi.fn(),
- listThreadApps: vi.fn(),
+ listApps: vi.fn(),
listThreadStoragePaths: vi.fn(),
};
});
@@ -74,9 +74,9 @@ function makePathResponse(
};
}
-const STATUS_APP: AppSummary = {
- id: "status",
- name: "Status",
+const APP: AppSummary = {
+ applicationId: "app_status",
+ name: "Review Board",
entry: { path: "index.html", kind: "html" },
capabilities: ["data", "message"],
icon: { kind: "builtin", name: "ListTodo" },
@@ -124,7 +124,7 @@ function seedRecentItems(threadId: string, items: ThreadRecentItem[]): void {
}
function mockEmptySearchSources(): void {
- vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.mocked(api.listApps).mockResolvedValue([]);
vi.mocked(api.searchProjectPaths).mockResolvedValue(makePathResponse([]));
vi.mocked(api.listThreadStoragePaths).mockResolvedValue({
...makePathResponse([]),
@@ -162,7 +162,7 @@ afterEach(() => {
describe("NewTabPage", () => {
it("autofocuses search and selects a workspace result", async () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.mocked(api.listApps).mockResolvedValue([]);
vi.mocked(api.searchProjectPaths).mockResolvedValue(
makePathResponse([
{
@@ -192,23 +192,23 @@ describe("NewTabPage", () => {
});
it("lists apps and opens an app selection", async () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([STATUS_APP]);
+ vi.mocked(api.listApps).mockResolvedValue([APP]);
const { onSelect } = renderNewTabPage({
currentThreadId: "thr-manager",
currentThreadType: "manager",
});
expect(await screen.findByText("Apps")).toBeTruthy();
- fireEvent.click(await screen.findByRole("option", { name: /Status/u }));
+ fireEvent.click(await screen.findByRole("option", { name: /Review Board/u }));
expect(onSelect).toHaveBeenCalledWith({
source: "app",
- appId: "status",
+ applicationId: "app_status",
});
});
it("prefills the composer draft with the create-app prompt", () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.mocked(api.listApps).mockResolvedValue([]);
renderNewTabPage({ projectId: "proj-1" });
fireEvent.click(screen.getByRole("option", { name: /Create App/u }));
@@ -233,7 +233,7 @@ describe("NewTabPage", () => {
});
it("leaves a non-empty composer draft unchanged when replacement is canceled", () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.mocked(api.listApps).mockResolvedValue([]);
vi.spyOn(window, "confirm").mockReturnValue(false);
setStoredThreadDraft(DRAFT_WITH_ATTACHMENT);
renderNewTabPage({ projectId: "proj-1" });
@@ -244,7 +244,7 @@ describe("NewTabPage", () => {
});
it("replaces non-empty composer text and attachments after confirmation", () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.mocked(api.listApps).mockResolvedValue([]);
vi.spyOn(window, "confirm").mockReturnValue(true);
setStoredThreadDraft(DRAFT_WITH_ATTACHMENT);
renderNewTabPage({ projectId: "proj-1" });
@@ -258,13 +258,13 @@ describe("NewTabPage", () => {
});
it("arrows past the app rows onto the create-app entry as the last option", async () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([STATUS_APP]);
+ vi.mocked(api.listApps).mockResolvedValue([APP]);
renderNewTabPage({ projectId: "proj-1" });
const input = screen.getByRole("textbox", {
name: "Search apps and files",
});
- const appOption = await screen.findByRole("option", { name: /Status/u });
+ const appOption = await screen.findByRole("option", { name: /Review Board/u });
const createAppOption = screen.getByRole("option", {
name: /Create App/u,
});
@@ -286,13 +286,13 @@ describe("NewTabPage", () => {
});
it("prefills the composer draft when the create-app entry is activated by Enter", async () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([STATUS_APP]);
+ vi.mocked(api.listApps).mockResolvedValue([APP]);
renderNewTabPage({ projectId: "proj-1" });
const input = screen.getByRole("textbox", {
name: "Search apps and files",
});
- await screen.findByRole("option", { name: /Status/u });
+ await screen.findByRole("option", { name: /Review Board/u });
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
@@ -304,7 +304,7 @@ describe("NewTabPage", () => {
});
it("selects a manager thread-storage result with the keyboard", async () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.mocked(api.listApps).mockResolvedValue([]);
vi.mocked(api.searchProjectPaths).mockResolvedValue(
makePathResponse([
{
@@ -333,7 +333,7 @@ describe("NewTabPage", () => {
const input = screen.getByRole("textbox", {
name: "Search apps and files",
});
- fireEvent.change(input, { target: { value: "status" } });
+ fireEvent.change(input, { target: { value: "app_status" } });
await screen.findByText("Files");
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
@@ -351,7 +351,7 @@ describe("NewTabPage", () => {
screen.getByText("No searchable app or file source is available."),
).toBeTruthy();
expect(api.searchProjectPaths).not.toHaveBeenCalled();
- expect(api.listThreadApps).not.toHaveBeenCalled();
+ expect(api.listApps).not.toHaveBeenCalled();
expect(api.listThreadStoragePaths).not.toHaveBeenCalled();
});
});
@@ -467,7 +467,7 @@ describe("NewTabPage recent section", () => {
it("reaches a recent row via keyboard navigation", async () => {
mockEmptySearchSources();
- vi.mocked(api.listThreadApps).mockResolvedValue([STATUS_APP]);
+ vi.mocked(api.listApps).mockResolvedValue([APP]);
const threadId = "thr-recent-keys";
seedRecentItems(threadId, [
{
@@ -488,7 +488,7 @@ describe("NewTabPage recent section", () => {
// Await the app row so the async sources have settled and the active-index
// reset no longer fires; the recent row trails Apps + Create App in one
// shared index space, so ArrowUp wraps onto it from the first entry.
- const appOption = await screen.findByRole("option", { name: /Status/u });
+ const appOption = await screen.findByRole("option", { name: /Review Board/u });
const recentOption = screen.getByRole("option", {
name: /swap-model\.md/u,
});
diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx
index 64028215a..ddc583a71 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx
@@ -189,13 +189,13 @@ afterEach(() => {
describe("ThreadSecondaryPanel", () => {
it.each([
{
- name: "status app iframe",
+ name: "app iframe",
activeTab: buildActiveFileTab({
- id: "app:status",
- filename: "Status",
+ id: "app:app_review_board",
+ filename: "Review Board",
isPinned: true,
}),
- iframeTitle: "Status app",
+ iframeTitle: "Review Board app",
closeButtonLabel: null,
},
{
@@ -275,9 +275,9 @@ describe("ThreadSecondaryPanel", () => {
])(
"keeps iframe previews interactable after secondary panel resize ends via $name",
async ({ finishDrag }) => {
- const activeStatusTab: SecondaryPanelFileTab = {
- id: "app:status",
- filename: "Status",
+ const activeAppTab: SecondaryPanelFileTab = {
+ id: "app:app_review_board",
+ filename: "Review Board",
isActive: true,
isPinned: true,
statusLabel: null,
@@ -286,8 +286,8 @@ describe("ThreadSecondaryPanel", () => {
};
renderPanel({
- fileTabs: [activeStatusTab],
- fileTabContent: ,
+ fileTabs: [activeAppTab],
+ fileTabContent: ,
renderAsDrawer: false,
});
diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx
index 78499f26c..719cb1533 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx
@@ -10,8 +10,8 @@ import type {
import { StoryCard, StoryRow } from "../../../.ladle/story-card";
import { createAppQueryClient } from "@/lib/query-client";
import {
+ appsQueryKey,
projectPathsQueryKey,
- threadAppsQueryKey,
threadStoragePathsQueryKey,
} from "@/hooks/queries/query-keys";
import { ThreadSecondaryPanel } from "./ThreadSecondaryPanel";
@@ -87,10 +87,10 @@ const THREAD_STORAGE_PATH_RESULTS: WorkspacePathEntry[] = [
},
];
-const THREAD_APPS_RESPONSE: AppSummary[] = [
+const APPS_RESPONSE: AppSummary[] = [
{
- id: "status",
- name: "Status",
+ applicationId: "app_story_review_board",
+ name: "Review Board",
entry: { path: "index.html", kind: "html" },
capabilities: ["data", "message"],
icon: { kind: "builtin", name: "ListTodo" },
@@ -99,21 +99,21 @@ const THREAD_APPS_RESPONSE: AppSummary[] = [
const APPS_ROW_APPS: AppSummary[] = [
{
- id: "status",
+ applicationId: "app_status",
name: "Status",
entry: { path: "index.html", kind: "html" },
capabilities: ["data", "message"],
icon: { kind: "builtin", name: "ListTodo" },
},
{
- id: "workspace-map",
+ applicationId: "app_workspace_map",
name: "Workspace Map",
entry: { path: "index.html", kind: "html" },
capabilities: ["data"],
icon: { kind: "builtin", name: "GridView" },
},
{
- id: "release-notes",
+ applicationId: "app_release_notes",
name: "Release Notes",
entry: { path: "index.html", kind: "html" },
capabilities: ["message"],
@@ -259,9 +259,7 @@ function useStoryQueryClient({
),
makeWorkspacePathResponse(workspacePaths),
);
- if (currentThreadId.length > 0) {
- queryClient.setQueryData(threadAppsQueryKey(currentThreadId), apps);
- }
+ queryClient.setQueryData(appsQueryKey(), apps);
queryClient.setQueryData(
threadStoragePathsQueryKey(currentThreadId, {
limit: STORY_SOURCE_LIMIT,
@@ -333,11 +331,11 @@ function NewTabPanelStory({
{
id:
selection.source === "app"
- ? `app:${selection.appId}`
+ ? `app:${selection.applicationId}`
: `${selection.source}:${selection.path}`,
filename:
selection.source === "app"
- ? selection.appId
+ ? selection.applicationId
: (selection.path.split("/").at(-1) ?? selection.path),
isActive: true,
statusLabel: null,
@@ -370,7 +368,7 @@ function NewTabPanelStory({
: "thread storage file"}
- {selection.source === "app" ? selection.appId : selection.path}
+ {selection.source === "app" ? selection.applicationId : selection.path}
);
@@ -454,7 +452,7 @@ export function NewTab() {
hint="active New tab seeded with workspace and manager thread-storage matches"
>
tab.appId);
+ return tabs.filter(isAppTab).map((tab) => tab.applicationId);
}
function tabIds(tabs: readonly SecondaryFileFixedPanelTab[]): string[] {
@@ -167,8 +167,8 @@ function hostFileTabId(path: string): string {
return `host-file-preview:${encodeURIComponent(path)}`;
}
-function appTabId(appId: string): string {
- return `app:${encodeURIComponent(appId)}`;
+function appTabId(applicationId: string): string {
+ return `app:${encodeURIComponent(applicationId)}`;
}
function newTabId(): string {
@@ -236,7 +236,7 @@ function getStoredStoragePaths(state: FixedPanelTabsState): string[] {
}
function getStoredAppIds(state: FixedPanelTabsState): string[] {
- return state.secondary.tabs.filter(isAppTab).map((tab) => tab.appId);
+ return state.secondary.tabs.filter(isAppTab).map((tab) => tab.applicationId);
}
afterEach(() => {
@@ -434,22 +434,17 @@ describe("useThreadFileTabs", () => {
expect(result.current.activeHostFilePath).toBeNull();
});
- it("orders file tabs by open order with the pinned status app first", async () => {
+ it("orders file tabs by open order", () => {
const { result } = renderThreadFileTabsHook({
- apps: [{ id: STATUS_APP_ID }],
+ apps: [{ applicationId: "app_review" }],
environmentId: "env-one",
threadType: "manager",
storageFiles: [{ path: "notes.md" }],
threadId: "thr-manager-open-order",
});
- await waitFor(() => {
- expect(appTabIds(result.current.orderedSecondaryFileTabs)).toEqual([
- STATUS_APP_ID,
- ]);
- });
-
act(() => {
+ result.current.openApp("app_review");
result.current.openWorkspaceFile(
buildWorkspaceFileTab({ lineNumber: null, path: "src/app.ts" }),
);
@@ -458,7 +453,7 @@ describe("useThreadFileTabs", () => {
});
expect(tabIds(result.current.orderedSecondaryFileTabs)).toEqual([
- appTabId(STATUS_APP_ID),
+ appTabId("app_review"),
workspaceFileTabId("src/app.ts"),
storageFileTabId("notes.md"),
hostFileTabId("/tmp/host.md"),
@@ -783,35 +778,35 @@ describe("useThreadFileTabs", () => {
expect(result.current.activeStorageFilePath).toBeNull();
});
- it("opens the status app tab for managers and keeps it pinned", async () => {
+ it("closes app tabs", () => {
const { result } = renderThreadFileTabsHook({
- apps: [{ id: STATUS_APP_ID }],
+ apps: [{ applicationId: "app_review" }],
environmentId: null,
threadType: "manager",
storageFiles: undefined,
- threadId: "thr-manager-status-app",
- });
-
- await waitFor(() => {
- expect(appTabIds(result.current.orderedSecondaryFileTabs)).toEqual([
- STATUS_APP_ID,
- ]);
+ threadId: "thr-manager-app",
});
- expect(result.current.activeAppId).toBe(STATUS_APP_ID);
act(() => {
- result.current.closeAppTab(STATUS_APP_ID);
+ result.current.openApp("app_review");
});
expect(appTabIds(result.current.orderedSecondaryFileTabs)).toEqual([
- STATUS_APP_ID,
+ "app_review",
]);
- expect(result.current.activeAppId).toBe(STATUS_APP_ID);
+ expect(result.current.activeAppId).toBe("app_review");
+
+ act(() => {
+ result.current.closeAppTab("app_review");
+ });
+
+ expect(appTabIds(result.current.orderedSecondaryFileTabs)).toEqual([]);
+ expect(result.current.activeAppId).toBeNull();
});
it("opens an app tab from launcher search selection", () => {
const { result } = renderThreadFileTabsHook({
- apps: [{ id: "demo" }],
+ apps: [{ applicationId: "app_demo" }],
environmentId: "env-one",
threadType: "standard",
storageFiles: undefined,
@@ -822,16 +817,16 @@ describe("useThreadFileTabs", () => {
result.current.openNewTab();
result.current.selectFileSearchResult({
source: "app",
- appId: "demo",
+ applicationId: "app_demo",
});
});
expect(appTabIds(result.current.orderedSecondaryFileTabs)).toEqual([
- "demo",
+ "app_demo",
]);
- expect(result.current.activeAppId).toBe("demo");
+ expect(result.current.activeAppId).toBe("app_demo");
expect(getStoredAppIds(readStoredState("thr-app-selection"))).toEqual([
- "demo",
+ "app_demo",
]);
});
diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
index c41dbe9c8..904d78875 100644
--- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
+++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
@@ -29,14 +29,12 @@ import {
} from "@/lib/file-preview";
import { useRecordThreadRecentItem } from "./threadRecentItems";
-export const STATUS_APP_ID = "status";
-
-interface ThreadAppTabDescriptor {
- id: string;
+interface AppTabDescriptor {
+ applicationId: string;
}
interface UseThreadFileTabsParams {
- apps?: readonly ThreadAppTabDescriptor[] | undefined;
+ apps?: readonly AppTabDescriptor[] | undefined;
threadId: string | null | undefined;
environmentId: string | null | undefined;
threadType: ThreadType | undefined;
@@ -67,7 +65,7 @@ export interface FileSearchThreadStorageSelection {
export interface FileSearchAppSelection {
source: "app";
- appId: string;
+ applicationId: string;
}
export type FileSearchSelection =
@@ -200,10 +198,11 @@ function pruneStorageTabs(
function pruneAppTabs(
tabs: readonly FixedPanelTab[],
- knownAppIds: ReadonlySet,
+ knownApplicationIds: ReadonlySet,
): readonly FixedPanelTab[] {
const nextTabs = tabs.filter(
- (tab) => !isAppTab(tab) || knownAppIds.has(tab.appId),
+ (tab) =>
+ !isAppTab(tab) || knownApplicationIds.has(tab.applicationId),
);
return nextTabs.length === tabs.length ? tabs : nextTabs;
}
@@ -222,8 +221,8 @@ function createStorageTab(path: string): ThreadStorageFilePreviewFixedPanelTab {
});
}
-function createAppTab(appId: string): AppFixedPanelTab {
- return createAppFixedPanelTab({ appId });
+function createAppTab(applicationId: string): AppFixedPanelTab {
+ return createAppFixedPanelTab({ applicationId });
}
function findWorkspaceTab(
@@ -264,10 +263,10 @@ function findStorageFileTab(
function findAppTab(
tabs: readonly FixedPanelTab[],
- appId: string,
+ applicationId: string,
): AppFixedPanelTab | null {
for (const tab of tabs) {
- if (isAppTab(tab) && tab.appId === appId) {
+ if (isAppTab(tab) && tab.applicationId === applicationId) {
return tab;
}
}
@@ -340,19 +339,10 @@ interface BuildOrderedSecondaryFileTabsArgs {
isManagerThread: boolean;
}
-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 app is floated to the
- * front; everything else (workspace, host, storage, new tab) keeps its
- * insertion order regardless of type.
+ * order the user opened them. Workspace, host, storage, app, browser, and new
+ * tabs keep their insertion order regardless of type.
*/
function buildOrderedSecondaryFileTabs({
tabs,
@@ -387,10 +377,7 @@ function buildOrderedSecondaryFileTabs({
break;
}
}
- return [
- ...displayable.filter(isPinnedSecondaryFileTab),
- ...displayable.filter((tab) => !isPinnedSecondaryFileTab(tab)),
- ];
+ return displayable;
}
/**
@@ -402,11 +389,11 @@ function buildOrderedSecondaryFileTabs({
*/
export function useOpenThreadAppTab(
threadId: string | null | undefined,
-): (appId: string) => void {
+): (applicationId: string) => void {
const updateFixedPanelTabsState = useUpdateFixedPanelTabsState(threadId);
return useCallback(
- (appId: string) => {
- const nextTab = createAppTab(appId);
+ (applicationId: string) => {
+ const nextTab = createAppTab(applicationId);
updateFixedPanelTabsState((state) => {
const tabs = upsertSecondaryTab(state.secondary.tabs, nextTab);
if (
@@ -443,8 +430,8 @@ export function useThreadFileTabs({
const resolvedEnvironmentId = isThreadResolved
? (environmentId ?? null)
: undefined;
- const appIds = useMemo(
- () => (apps ? new Set(apps.map((app) => app.id)) : null),
+ const applicationIds = useMemo(
+ () => (apps ? new Set(apps.map((app) => app.applicationId)) : null),
[apps],
);
@@ -519,9 +506,9 @@ export function useThreadFileTabs({
]);
useEffect(() => {
- if (!isThreadResolved || appIds === null) return;
+ if (!isThreadResolved || applicationIds === null) return;
updateFixedPanelTabsState((state) => {
- const tabs = pruneAppTabs(state.secondary.tabs, appIds);
+ const tabs = pruneAppTabs(state.secondary.tabs, applicationIds);
const activeTabId = isActiveTabStillOpen(
tabs,
state.secondary.activeTabId,
@@ -535,29 +522,7 @@ export function useThreadFileTabs({
tabs,
});
});
- }, [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]);
+ }, [applicationIds, isThreadResolved, updateFixedPanelTabsState]);
const openWorkspaceFile = useCallback(
({ lineNumber, path, source, statusLabel }: WorkspaceFileTabState) => {
@@ -745,10 +710,9 @@ export function useThreadFileTabs({
const openApp = useOpenThreadAppTab(threadId);
const closeAppTab = useCallback(
- (appId: string) => {
- if (appId === STATUS_APP_ID) return;
+ (applicationId: string) => {
updateFixedPanelTabsState((state) => {
- const tab = findAppTab(state.secondary.tabs, appId);
+ const tab = findAppTab(state.secondary.tabs, applicationId);
if (!tab) {
return state;
}
@@ -768,9 +732,9 @@ export function useThreadFileTabs({
);
const activateAppTab = useCallback(
- (appId: string) => {
+ (applicationId: string) => {
updateFixedPanelTabsState((state) => {
- const tab = findAppTab(state.secondary.tabs, appId);
+ const tab = findAppTab(state.secondary.tabs, applicationId);
if (!tab) {
return state;
}
@@ -979,7 +943,7 @@ export function useThreadFileTabs({
const selectFileSearchResult = useCallback(
(selection: FileSearchSelection) => {
if (selection.source === "app") {
- const nextTab = createAppTab(selection.appId);
+ const nextTab = createAppTab(selection.applicationId);
updateFixedPanelTabsState((state) => replaceNewTab({ nextTab, state }));
return;
}
@@ -1072,7 +1036,7 @@ export function useThreadFileTabs({
activateHostFileTab,
activateStorageFileTab,
activateWorkspaceFileTab,
- activeAppId: activeAppTab?.appId ?? null,
+ activeAppId: activeAppTab?.applicationId ?? null,
activeBrowserTab,
activeHostFileLineNumber: activeHostFileTab?.lineNumber ?? null,
activeHostFilePath: activeHostFileTab?.path ?? null,
diff --git a/apps/app/src/components/sidebar/ProjectList.test.tsx b/apps/app/src/components/sidebar/ProjectList.test.tsx
index 9b6035e90..2d463323a 100644
--- a/apps/app/src/components/sidebar/ProjectList.test.tsx
+++ b/apps/app/src/components/sidebar/ProjectList.test.tsx
@@ -29,7 +29,11 @@ import {
resetFakeReconnectingWebSockets,
} from "@/test/fake-reconnecting-websocket";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
-import { installFetchRoutes, jsonResponse } from "@/test/http-test-utils";
+import {
+ installFetchRoutes,
+ jsonResponse,
+ type FetchRoute,
+} from "@/test/http-test-utils";
import { wsManager } from "@/lib/ws";
import { useFixedPanelTabsState } from "@/lib/fixed-panel-tabs";
import { getThreadConversationCollapsedAtom } from "@/components/secondary-panel/threadSecondaryPanelAtoms";
@@ -72,7 +76,7 @@ type ProjectThreadListEntryOverrides = Partial;
type ProjectWithThreadsOverrides = Partial;
interface MakeAppArgs {
- id: string;
+ applicationId: string;
name: string;
icon: AppSummary["icon"];
}
@@ -137,9 +141,26 @@ function makeThreadListEntry(
};
}
-function makeApp({ id, name, icon }: MakeAppArgs): AppSummary {
+// The sidebar now lists global apps unconditionally, so every render hits
+// `GET /api/v1/apps`. Default it to an empty list when a test doesn't care about
+// apps, so those tests don't have to register the route by hand.
+function installProjectListFetchRoutes(routes: FetchRoute[]) {
+ const hasAppsRoute = routes.some(
+ (route) => route.pathname === "/api/v1/apps",
+ );
+ return installFetchRoutes(
+ hasAppsRoute
+ ? routes
+ : [
+ ...routes,
+ { pathname: "/api/v1/apps", handler: () => jsonResponse([]) },
+ ],
+ );
+}
+
+function makeApp({ applicationId, name, icon }: MakeAppArgs): AppSummary {
return {
- id,
+ applicationId,
name,
entry: { path: "index.html", kind: "html" },
capabilities: [],
@@ -147,20 +168,21 @@ function makeApp({ id, name, icon }: MakeAppArgs): AppSummary {
};
}
-const STATUS_APP = makeApp({
- id: "status",
- name: "Status",
+const REVIEW_BOARD_APP = makeApp({
+ applicationId: "app_review_board",
+ name: "Review Board",
icon: { kind: "builtin", name: "ListTodo" },
});
-// Manager app rows render after sidebar bootstrap plus a per-manager app query.
-const MANAGER_APP_ROW_TIMEOUT_MS = 5_000;
+// App rows render after the global app query resolves, which trails the sidebar
+// bootstrap, so allow a little extra time when finding one.
+const APP_ROW_TIMEOUT_MS = 5_000;
-function findStatusAppButton() {
+function findReviewBoardAppButton() {
return screen.findByRole(
"button",
- { name: "Open Status app" },
- { timeout: MANAGER_APP_ROW_TIMEOUT_MS },
+ { name: "Open Review Board app" },
+ { timeout: APP_ROW_TIMEOUT_MS },
);
}
@@ -346,7 +368,7 @@ describe("ProjectList", () => {
projects,
threadsByProjectId,
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () => {
@@ -417,7 +439,7 @@ describe("ProjectList", () => {
});
it("does not show project error or empty states before the websocket connects", async () => {
- const fetchMock = installFetchRoutes([
+ const fetchMock = installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () => new Response("starting", { status: 503 }),
@@ -469,7 +491,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -525,7 +547,7 @@ describe("ProjectList", () => {
expect(screen.getByText("Project Thread")).toBeTruthy();
});
- it("renders manager app rows directly under their owning manager", async () => {
+ it("lists global apps once in a top-level Apps section, not nested under managers", async () => {
const project = makeProjectResponse({
id: "project-1",
name: "Project One",
@@ -548,7 +570,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -563,8 +585,8 @@ describe("ProjectList", () => {
),
},
{
- pathname: `/api/v1/threads/${managerThread.id}/apps`,
- handler: () => jsonResponse([STATUS_APP]),
+ pathname: "/api/v1/apps",
+ handler: () => jsonResponse([REVIEW_BOARD_APP]),
},
{
pathname: "/api/v1/projects",
@@ -590,27 +612,34 @@ describe("ProjectList", () => {
await renderProjectList();
- const appRow = await findStatusAppButton();
+ const appRow = await findReviewBoardAppButton();
+ const appsLabel = screen.getByText("Apps");
const managerLabel = screen.getByText("Sidebar Manager");
- const workerLabel = screen.getByText("Worker Thread");
- expect(appRow.classList.contains("pl-14")).toBe(true);
- expect(appRow.parentElement?.className).toContain("before:left-10");
+ // One global app → exactly one app row, regardless of the manager present.
+ expect(
+ screen.getAllByRole("button", { name: "Open Review Board app" }),
+ ).toHaveLength(1);
+ // Top-level indent (pl-2), not the manager-nested indent (pl-14).
+ expect(appRow.classList.contains("pl-2")).toBe(true);
+ expect(appRow.classList.contains("pl-14")).toBe(false);
+ // The row lives in the standalone Apps section, after the manager that is
+ // listed under Projects — it is not a child of the manager group.
expect(
Boolean(
- managerLabel.compareDocumentPosition(appRow) &
+ managerLabel.compareDocumentPosition(appsLabel) &
Node.DOCUMENT_POSITION_FOLLOWING,
),
).toBe(true);
expect(
Boolean(
- appRow.compareDocumentPosition(workerLabel) &
+ appsLabel.compareDocumentPosition(appRow) &
Node.DOCUMENT_POSITION_FOLLOWING,
),
).toBe(true);
});
- it("opens a manager app row in the owning thread secondary panel", async () => {
+ it("opens a global app in the selected thread's secondary panel", async () => {
const project = makeProjectResponse({
id: "project-1",
name: "Project One",
@@ -627,7 +656,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -640,8 +669,8 @@ describe("ProjectList", () => {
),
},
{
- pathname: `/api/v1/threads/${managerThread.id}/apps`,
- handler: () => jsonResponse([STATUS_APP]),
+ pathname: "/api/v1/apps",
+ handler: () => jsonResponse([REVIEW_BOARD_APP]),
},
{
pathname: "/api/v1/projects",
@@ -665,18 +694,25 @@ describe("ProjectList", () => {
},
]);
+ // The app opens into whatever thread is in view, so start on the manager.
+ window.history.pushState(
+ null,
+ "",
+ `/projects/${project.id}/threads/${managerThread.id}`,
+ );
+
await renderProjectList(
{},
{ extraUi: },
);
- fireEvent.click(
- await findStatusAppButton(),
- );
+ fireEvent.click(await findReviewBoardAppButton());
await waitFor(() => {
const probe = screen.getByTestId("panel-state");
- expect(probe.textContent).toContain('"activeTabId":"app:status"');
+ expect(probe.textContent).toContain(
+ '"activeTabId":"app:app_review_board"',
+ );
expect(probe.textContent).toContain('"isOpen":true');
});
expect(window.location.pathname).toBe(
@@ -684,7 +720,64 @@ describe("ProjectList", () => {
);
});
- it("hides manager app rows when the owning manager is collapsed", async () => {
+ it("disables global app rows when no thread is selected", async () => {
+ const project = makeProjectResponse({
+ id: "project-1",
+ name: "Project One",
+ });
+ const personalProject = makeProjectWithThreadsResponse({
+ id: PERSONAL_PROJECT_ID,
+ kind: "personal",
+ name: "Personal",
+ threads: [],
+ });
+ installProjectListFetchRoutes([
+ {
+ pathname: "/api/v1/sidebar-bootstrap",
+ handler: () =>
+ jsonResponse(
+ buildSidebarNavigationResponse({
+ personalProject,
+ projects: [project],
+ }),
+ ),
+ },
+ {
+ pathname: "/api/v1/apps",
+ handler: () => jsonResponse([REVIEW_BOARD_APP]),
+ },
+ {
+ pathname: "/api/v1/projects",
+ handler: () => jsonResponse([project]),
+ },
+ {
+ pathname: "/api/v1/threads",
+ handler: () => jsonResponse([]),
+ },
+ {
+ pathname: "/api/v1/system/config",
+ handler: () =>
+ jsonResponse({
+ hostDaemonPort: null,
+ voiceTranscriptionEnabled: false,
+ }),
+ },
+ {
+ pathname: "/api/v1/hosts",
+ handler: () => jsonResponse([]),
+ },
+ ]);
+
+ // Root view: no thread is in view, so there is no panel to host the app.
+ window.history.pushState(null, "", "/");
+
+ await renderProjectList();
+
+ const appRow = await findReviewBoardAppButton();
+ expect(appRow).toHaveProperty("disabled", true);
+ });
+
+ it("keeps the Apps section visible when a manager is collapsed", async () => {
const project = makeProjectResponse({
id: "project-1",
name: "Project One",
@@ -707,7 +800,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -722,8 +815,8 @@ describe("ProjectList", () => {
),
},
{
- pathname: `/api/v1/threads/${managerThread.id}/apps`,
- handler: () => jsonResponse([STATUS_APP]),
+ pathname: "/api/v1/apps",
+ handler: () => jsonResponse([REVIEW_BOARD_APP]),
},
{
pathname: "/api/v1/projects",
@@ -749,21 +842,21 @@ describe("ProjectList", () => {
await renderProjectList();
- expect(
- await findStatusAppButton(),
- ).toBeTruthy();
+ expect(await findReviewBoardAppButton()).toBeTruthy();
expect(screen.getByText("Worker Thread")).toBeTruthy();
+ // Apps are no longer nested under managers, so collapsing the manager hides
+ // its worker thread but leaves the global Apps section untouched.
fireEvent.click(
screen.getByRole("button", {
name: "Collapse Sidebar Manager threads",
}),
);
- expect(
- screen.queryByRole("button", { name: "Open Status app" }),
- ).toBeNull();
expect(screen.queryByText("Worker Thread")).toBeNull();
+ expect(
+ screen.getByRole("button", { name: "Open Review Board app" }),
+ ).toBeTruthy();
});
it("orders project hover actions as menu, new manager, then new thread", async () => {
@@ -780,7 +873,7 @@ describe("ProjectList", () => {
});
const reuseValues: (string | null)[] = [];
const staleReuseValue = encodeReuseValue("env-stale");
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -890,7 +983,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -967,7 +1060,7 @@ describe("ProjectList", () => {
});
it("shows projects unavailable when the project request fails after the websocket connects", async () => {
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () => new Response("starting", { status: 503 }),
@@ -1001,7 +1094,7 @@ describe("ProjectList", () => {
it("shows threads unavailable when the sidebar navigation fails after the websocket connects", async () => {
const project = makeProjectResponse();
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () => new Response("starting", { status: 503 }),
@@ -1051,7 +1144,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [projectlessThread],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1117,7 +1210,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1177,7 +1270,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1245,7 +1338,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1311,7 +1404,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [projectlessManager, projectlessChild, projectlessThread],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1323,7 +1416,7 @@ describe("ProjectList", () => {
),
},
{
- pathname: `/api/v1/threads/${projectlessManager.id}/apps`,
+ pathname: "/api/v1/apps",
handler: () => jsonResponse([]),
},
{
@@ -1378,7 +1471,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [projectlessThread],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1432,7 +1525,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1512,7 +1605,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1574,7 +1667,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1587,8 +1680,8 @@ describe("ProjectList", () => {
),
},
{
- pathname: `/api/v1/threads/${managerThread.id}/apps`,
- handler: () => jsonResponse([STATUS_APP]),
+ pathname: "/api/v1/apps",
+ handler: () => jsonResponse([REVIEW_BOARD_APP]),
},
{
pathname: "/api/v1/projects",
@@ -1624,7 +1717,7 @@ describe("ProjectList", () => {
{ extraUi: },
);
- const appRow = await findStatusAppButton();
+ const appRow = await findReviewBoardAppButton();
const findManagerRow = () =>
screen
.getByText("Sidebar Manager")
@@ -1650,7 +1743,7 @@ describe("ProjectList", () => {
).toBe("true");
});
expect(
- screen.getByRole("button", { name: "Open Status app" }).className,
+ screen.getByRole("button", { name: "Open Review Board app" }).className,
).toContain("bg-sidebar-border");
expect(findManagerRow()?.className).not.toContain("bg-sidebar-border");
@@ -1665,7 +1758,7 @@ describe("ProjectList", () => {
expect(findManagerRow()?.className).toContain("bg-sidebar-border");
});
expect(
- screen.getByRole("button", { name: "Open Status app" }).className,
+ screen.getByRole("button", { name: "Open Review Board app" }).className,
).not.toContain("bg-sidebar-border");
});
@@ -1686,7 +1779,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1699,8 +1792,8 @@ describe("ProjectList", () => {
),
},
{
- pathname: `/api/v1/threads/${managerThread.id}/apps`,
- handler: () => jsonResponse([STATUS_APP]),
+ pathname: "/api/v1/apps",
+ handler: () => jsonResponse([REVIEW_BOARD_APP]),
},
{
pathname: "/api/v1/projects",
@@ -1738,7 +1831,7 @@ describe("ProjectList", () => {
// Opening the app collapses the conversation so the app fills the view.
fireEvent.click(
- await findStatusAppButton(),
+ await findReviewBoardAppButton(),
);
await waitFor(() => {
expect(
@@ -1782,7 +1875,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1795,12 +1888,8 @@ describe("ProjectList", () => {
),
},
{
- pathname: `/api/v1/threads/${managerA.id}/apps`,
- handler: () => jsonResponse([STATUS_APP]),
- },
- {
- pathname: `/api/v1/threads/${managerB.id}/apps`,
- handler: () => jsonResponse([]),
+ pathname: "/api/v1/apps",
+ handler: () => jsonResponse([REVIEW_BOARD_APP]),
},
{
pathname: "/api/v1/projects",
@@ -1843,10 +1932,9 @@ describe("ProjectList", () => {
},
);
- // Collapse A's conversation by opening its app full-screen.
- fireEvent.click(
- await findStatusAppButton(),
- );
+ // The single global app row opens into the selected thread (Manager A),
+ // collapsing A's conversation to show the app full-screen.
+ fireEvent.click(await findReviewBoardAppButton());
await waitFor(() => {
expect(
screen.getByTestId(`conversation-collapsed:${managerA.id}`).textContent,
diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx
index bbd99ffb4..5a6bbb0f2 100644
--- a/apps/app/src/components/sidebar/ProjectList.tsx
+++ b/apps/app/src/components/sidebar/ProjectList.tsx
@@ -33,13 +33,14 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
-import type { ProjectResponse } from "@bb/server-contract";
+import type { AppSummary, ProjectResponse } from "@bb/server-contract";
import {
findLocalPathProjectSourceForHost,
PERSONAL_PROJECT_ID,
type ThreadListEntry,
} from "@bb/domain";
import { useAppRoute } from "@/hooks/useAppRoute";
+import { useApps } from "@/hooks/queries/thread-queries";
import {
useConnectionAwareQueryState,
type ConnectionAwareQueryStatus,
@@ -86,6 +87,7 @@ import {
COARSE_POINTER_ROW_HEIGHT_CLASS,
} from "@/components/ui/coarse-pointer-sizing.js";
import { ProjectRow, ProjectThreadTree } from "./ProjectRow";
+import { SidebarAppsSection } from "./SidebarAppsSection";
import type {
ProjectRowDragBindings,
ProjectRowProps,
@@ -244,7 +246,12 @@ function hasSameSidebarSectionOrder(
}
function isSidebarSectionId(value: string): value is SidebarSectionId {
- return value === "pinned" || value === "projects" || value === "threads";
+ return (
+ value === "pinned" ||
+ value === "projects" ||
+ value === "threads" ||
+ value === "apps"
+ );
}
function normalizeSidebarSectionOrder(
@@ -276,6 +283,8 @@ const EMPTY_PROJECT_THREAD_LIST_STATE: ProjectThreadListState = {
status: "loading",
};
+const EMPTY_APPS: readonly AppSummary[] = [];
+
function getProjectThreadListState({
status,
threads,
@@ -618,6 +627,8 @@ function ProjectListComponent({
const setRootComposeMode = useSetRootComposeMode();
const sidebarNavigationQuery = useSidebarNavigation();
const sidebarNavigation = sidebarNavigationQuery.data;
+ const appsQuery = useApps();
+ const apps = appsQuery.data ?? EMPTY_APPS;
const projects = useMemo(
() => sidebarNavigation?.projects.map(stripProjectThreads),
[sidebarNavigation],
@@ -894,12 +905,17 @@ function ProjectListComponent({
[threads],
);
const hasPinnedSection = pinnedSidebarState.rootItems.length > 0;
+ // No apps → no section: the empty Apps list adds nothing, so it stays hidden
+ // (like the Pinned section) until at least one global app exists.
+ const hasAppsSection = apps.length > 0;
const visibleSidebarSectionOrder = useMemo(
() =>
- sidebarSectionOrder.filter(
- (sectionId) => sectionId !== "pinned" || hasPinnedSection,
- ),
- [hasPinnedSection, sidebarSectionOrder],
+ sidebarSectionOrder.filter((sectionId) => {
+ if (sectionId === "pinned") return hasPinnedSection;
+ if (sectionId === "apps") return hasAppsSection;
+ return true;
+ }),
+ [hasAppsSection, hasPinnedSection, sidebarSectionOrder],
);
const threadsByProject = useMemo(() => {
const grouped = new Map();
@@ -1159,6 +1175,7 @@ function ProjectListComponent({
onReorderManager={handleReorderManager}
/>
);
+ const appsSectionContent = ;
const projectsSectionActions = onNewProject ? (
{projectsSectionContent}
- ) : (
+ ) : sectionId === "threads" ? (
{threadsSectionContent}
+ ) : (
+
+ {appsSectionContent}
+
),
)}
diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx
index d51e51ed7..789c86e18 100644
--- a/apps/app/src/components/sidebar/ProjectRow.tsx
+++ b/apps/app/src/components/sidebar/ProjectRow.tsx
@@ -9,7 +9,6 @@ import {
type ReactNode,
} from "react";
import { flushSync } from "react-dom";
-import { useAtomValue } from "jotai";
import {
closestCenter,
DndContext,
@@ -31,11 +30,10 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { ThreadListEntry } from "@bb/domain";
-import type { AppSummary, ProjectResponse } from "@bb/server-contract";
+import type { ProjectResponse } from "@bb/server-contract";
import { NavLink, useNavigate } from "react-router-dom";
import { useCreateThreadInWorktree } from "@/hooks/useCreateThreadInWorktree";
import { useArchiveEnvironmentThreads } from "@/hooks/mutations/environment-mutations";
-import { useThreadApps } from "@/hooks/queries/thread-queries";
import { Button } from "@/components/ui/button.js";
import {
DropdownMenu,
@@ -75,9 +73,6 @@ import {
import { cn } from "@/lib/utils";
import { getEnvironmentWorkspaceLabelIconName } from "@/lib/environment-workspace-display";
import { getProjectSettingsRoutePath } from "@/lib/app-route-paths";
-import { useFixedPanelTabsState } from "@/lib/fixed-panel-tabs";
-import type { FixedPanelTabsState } from "@/lib/fixed-panel-tabs-state";
-import { getThreadConversationCollapsedAtom } from "@/components/secondary-panel/threadSecondaryPanelAtoms";
import {
applyNeighborReorder,
buildNeighborReorderRequest,
@@ -90,7 +85,6 @@ import {
type ThreadRowDragBindings,
type ThreadRowOptions,
} from "./ThreadRow";
-import { ThreadAppRow } from "./ThreadAppRow";
import {
buildProjectThreadGroups,
type EnvironmentThreadGroup,
@@ -109,7 +103,6 @@ import {
SIDEBAR_SECTION_GROUP_LINE_CLASS,
SIDEBAR_SECTION_LINE_CONTINUATION_CLASS,
SIDEBAR_STANDARD_ROW_PADDING_CLASS,
- type SidebarThreadRowIndent,
} from "./sidebarRowClasses";
import { SIDEBAR_SORTABLE_TRANSITION } from "./sortableMotion";
import {
@@ -230,7 +223,6 @@ type ProjectItemClickCaptureHandler = MouseEventHandler;
type ProjectThreadListClickCaptureHandler = MouseEventHandler;
const EMPTY_PROJECT_THREADS: ThreadListEntry[] = [];
-const EMPTY_THREAD_APPS: readonly AppSummary[] = [];
const PROJECT_ROW_LEADING_SLOT_CLASS =
"h-7 w-8 max-md:pointer-coarse:h-10 max-md:pointer-coarse:w-10";
@@ -244,13 +236,6 @@ interface ManagerThreadOrderEntry {
id: string;
}
-interface ActiveThreadAppIdArgs {
- fixedPanelTabsState: FixedPanelTabsState;
- isConversationCollapsed: boolean;
- selectedThreadId?: string;
- threadId: string;
-}
-
function getManagerThreadGroupId(
managerThreadGroup: ManagerThreadGroup,
): string {
@@ -334,12 +319,6 @@ function getProjectThreadTreeManagedChildOptions(
: THREAD_ROW_PROJECT_MANAGED_CHILD_OPTIONS;
}
-function getProjectThreadTreeManagedAppIndent(
- variant: ProjectThreadTreeVariant,
-): SidebarThreadRowIndent {
- return variant === "section" ? "project-child" : "nested-child";
-}
-
function getProjectThreadTreeEnvGroupedChildOptions(
variant: ProjectThreadTreeVariant,
): ThreadRowOptions {
@@ -388,29 +367,6 @@ function getProjectThreadTreeManagerLineContinuationClassName(
: SIDEBAR_MANAGER_LINE_CONTINUATION_CLASS;
}
-function getActiveThreadAppId({
- fixedPanelTabsState,
- isConversationCollapsed,
- selectedThreadId,
- threadId,
-}: ActiveThreadAppIdArgs): string | null {
- // An app is the active surface only for the open thread, and only while the
- // conversation is tucked into the collapsed rail with the panel showing the
- // app full. With the conversation visible it stays the selected surface, so
- // no app row is highlighted and the manager row reads as selected instead.
- if (selectedThreadId !== threadId || !isConversationCollapsed) {
- return null;
- }
-
- const { activeTabId, isOpen, tabs } = fixedPanelTabsState.secondary;
- if (!isOpen || activeTabId === null) {
- return null;
- }
-
- const activeTab = tabs.find((tab) => tab.id === activeTabId);
- return activeTab?.kind === "app" ? activeTab.appId : null;
-}
-
function ProjectThreadTreeGroup({
children,
variant,
@@ -894,20 +850,7 @@ export const ManagerThreadGroupRow = memo(function ManagerThreadGroupRow({
sortableStyle,
}: ManagerThreadGroupRowProps) {
const { managerThread, managedItems, stats } = managerThreadGroup;
- const managerAppsQuery = useThreadApps(managerThread.id);
- const managerApps = managerAppsQuery.data ?? EMPTY_THREAD_APPS;
- const fixedPanelTabsState = useFixedPanelTabsState(managerThread.id);
- const isConversationCollapsed = useAtomValue(
- getThreadConversationCollapsedAtom(managerThread.id),
- );
- const activeAppId = getActiveThreadAppId({
- fixedPanelTabsState,
- isConversationCollapsed,
- selectedThreadId,
- threadId: managerThread.id,
- });
- const nestedChildCount = stats.managedChildCount + managerApps.length;
- const appIndent = getProjectThreadTreeManagedAppIndent(variant);
+ const nestedChildCount = stats.managedChildCount;
const managerOptions = useMemo(
() => ({
kind: "manager",
@@ -939,10 +882,10 @@ export const ManagerThreadGroupRow = memo(function ManagerThreadGroupRow({
@@ -953,16 +896,6 @@ export const ManagerThreadGroupRow = memo(function ManagerThreadGroupRow({
getProjectThreadTreeChildGroupLineClassName(variant),
)}
>
- {managerApps.map((app) => (
-
- ))}
{managedItems.map((item) =>
item.kind === "thread" ? (
{
+ if (threadId === undefined || projectId === undefined) {
+ return;
+ }
+ openThreadAppTab(app.applicationId);
+ setConversationCollapsed(true);
+ navigate(getThreadRoutePath({ projectId, threadId }));
+ }, [
+ app.applicationId,
+ navigate,
+ openThreadAppTab,
+ projectId,
+ setConversationCollapsed,
+ threadId,
+ ]);
+
+ return (
+
+
+
+
+ {app.name}
+
+ );
+});
+
+/**
+ * Top-level sidebar list of the global apps. Apps are not nested under any
+ * project or manager: there is one canonical list, sourced from `useApps()` by
+ * the caller. Opening an app reuses the per-thread panel path against the
+ * currently selected thread (see `SidebarAppRow`), and the active highlight
+ * follows that thread's full-screen app surface so it never collides with the
+ * selected thread row.
+ */
+export const SidebarAppsSection = memo(function SidebarAppsSection({
+ apps,
+}: SidebarAppsSectionProps) {
+ const { projectId: selectedProjectId, threadId: selectedThreadId } =
+ useAppRoute();
+ const fixedPanelTabsState = useFixedPanelTabsState(selectedThreadId);
+ const isConversationCollapsed = useAtomValue(
+ getThreadConversationCollapsedAtom(selectedThreadId),
+ );
+ const activeAppId =
+ selectedThreadId !== undefined && isConversationCollapsed
+ ? getActiveSecondaryAppId(fixedPanelTabsState)
+ : null;
+
+ return (
+
+ {apps.map((app) => (
+
+ ))}
+
+ );
+});
diff --git a/apps/app/src/components/sidebar/ThreadAppRow.tsx b/apps/app/src/components/sidebar/ThreadAppRow.tsx
deleted file mode 100644
index 4cb9ed985..000000000
--- a/apps/app/src/components/sidebar/ThreadAppRow.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import { memo, useCallback } from "react";
-import { useNavigate } from "react-router-dom";
-import { useSetAtom } from "jotai";
-import type { AppSummary } from "@bb/server-contract";
-import { ResolvedAppIcon } from "@/components/secondary-panel/AppIcon";
-import { useOpenThreadAppTab } from "@/components/secondary-panel/useThreadFileTabs";
-import { getThreadConversationCollapsedAtom } from "@/components/secondary-panel/threadSecondaryPanelAtoms";
-import {
- COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS,
- COARSE_POINTER_GLYPH_BOX_CLASS,
- COARSE_POINTER_ICON_SIZE_CLASS,
-} from "@/components/ui/coarse-pointer-sizing.js";
-import { getThreadRoutePath } from "@/lib/app-route-paths";
-import { cn } from "@/lib/utils";
-import {
- SIDEBAR_ROW_BASE_CLASS,
- SIDEBAR_ROW_GLYPH_SLOT_CLASS,
- SIDEBAR_ROW_INTERACTIVE_STATE_CLASS,
- getSidebarThreadRowPaddingClass,
- type SidebarThreadRowIndent,
-} from "./sidebarRowClasses";
-
-interface ThreadAppRowProps {
- app: AppSummary;
- indent: SidebarThreadRowIndent;
- isActive: boolean;
- projectId: string;
- threadId: string;
-}
-
-function ThreadAppRowComponent({
- app,
- indent,
- isActive,
- projectId,
- threadId,
-}: ThreadAppRowProps) {
- const navigate = useNavigate();
- const openThreadAppTab = useOpenThreadAppTab(threadId);
- const setConversationCollapsed = useSetAtom(
- getThreadConversationCollapsedAtom(threadId),
- );
- const openApp = useCallback(() => {
- // Opening an app makes it the active surface: reveal its tab in the
- // secondary panel (openThreadAppTab also opens the panel) and tuck the
- // conversation into the collapsed rail so the app fills the view.
- openThreadAppTab(app.id);
- setConversationCollapsed(true);
- navigate(getThreadRoutePath({ projectId, threadId }));
- }, [
- app.id,
- navigate,
- openThreadAppTab,
- projectId,
- setConversationCollapsed,
- threadId,
- ]);
-
- return (
-
-
-
-
- {app.name}
-
- );
-}
-
-export const ThreadAppRow = memo(ThreadAppRowComponent);
diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx
index e11ccdbb9..fb5bf718b 100644
--- a/apps/app/src/components/sidebar/ThreadRow.tsx
+++ b/apps/app/src/components/sidebar/ThreadRow.tsx
@@ -1,11 +1,13 @@
import { memo, useCallback, useState, type MouseEventHandler } from "react";
-import { useSetAtom } from "jotai";
+import { useAtomValue, useSetAtom } from "jotai";
import type {
DraggableAttributes,
DraggableSyntheticListeners,
} from "@dnd-kit/core";
import type { ThreadListEntry } from "@bb/domain";
import { getThreadConversationCollapsedAtom } from "@/components/secondary-panel/threadSecondaryPanelAtoms";
+import { useFixedPanelTabsState } from "@/lib/fixed-panel-tabs";
+import { getActiveSecondaryAppId } from "@/lib/fixed-panel-tabs-state";
import { Icon, type IconName } from "@/components/ui/icon.js";
import { SidebarStickyTier } from "@/components/ui/sidebar.js";
import { NavLink } from "react-router-dom";
@@ -300,6 +302,18 @@ function ThreadRowComponent({
const setConversationCollapsed = useSetAtom(
getThreadConversationCollapsedAtom(thread.id),
);
+ // When this thread tucks its conversation into the collapsed rail to show an
+ // app full-screen, the app's sidebar row owns the single selected highlight,
+ // so this row drops its own selected background even though it is the route's
+ // selected thread. Keeps exactly one row highlighted across the sidebar.
+ const isConversationCollapsed = useAtomValue(
+ getThreadConversationCollapsedAtom(thread.id),
+ );
+ const fixedPanelTabsState = useFixedPanelTabsState(thread.id);
+ const appOwnsSurface =
+ isConversationCollapsed &&
+ getActiveSecondaryAppId(fixedPanelTabsState) !== null;
+ const showActive = isActive && !appOwnsSurface;
const hasPendingInteraction = thread.hasPendingInteraction;
const threadIsBusy = isBusyThread(thread) && !hasPendingInteraction;
const showUnreadBadge = !hasPendingInteraction && isUnreadDoneThread(thread);
@@ -353,7 +367,7 @@ function ThreadRowComponent({
? COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS
: COARSE_POINTER_ROW_HEIGHT_CLASS,
getSidebarThreadRowPaddingClass(options.indent),
- isActive
+ showActive
? "bg-sidebar-border text-sidebar-foreground"
: SIDEBAR_ROW_INTERACTIVE_STATE_CLASS,
);
@@ -377,8 +391,8 @@ function ThreadRowComponent({
onClick={() => {
// Selecting a thread/agent row restores its conversation: the inverse
// of opening an app row, which tucks the conversation into the
- // collapsed rail so the app fills the view (see ThreadAppRow). Both
- // write this thread's own collapse flag, so selecting one thread
+ // collapsed rail so the app fills the view (see SidebarAppsSection).
+ // Both write this thread's own collapse flag, so selecting one thread
// never disturbs another's full-screen-app state.
setConversationCollapsed(false);
onProjectSelect?.();
diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts
index 4e3b90ca9..df0bec3ad 100644
--- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts
+++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts
@@ -6,12 +6,13 @@ const COLLAPSED_MANAGERS_STORAGE_KEY = "bb.sidebar.collapsedManagers";
const COLLAPSED_ENVIRONMENTS_STORAGE_KEY = "bb.sidebar.collapsedEnvironments";
const SIDEBAR_SECTION_ORDER_STORAGE_KEY = "bb.sidebar.sectionOrder";
-export type SidebarSectionId = "pinned" | "projects" | "threads";
+export type SidebarSectionId = "pinned" | "projects" | "threads" | "apps";
export const DEFAULT_SIDEBAR_SECTION_ORDER: readonly SidebarSectionId[] = [
"pinned",
"projects",
"threads",
+ "apps",
];
export const collapsedProjectIdsAtom = atomWithStorage(
diff --git a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts
index 5086bf6e7..11039ecdb 100644
--- a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts
+++ b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts
@@ -130,6 +130,9 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = {
],
"hooks/cache-owners/realtime-cache-registry.ts": [
"allHostQueryKeyPrefix",
+ "allAppMarkdownPreviewQueryKeyPrefix",
+ "allAppQueryKeyPrefix",
+ "allAppsQueryKeyPrefix",
"allSystemExecutionOptionsQueryKeyPrefix",
"allThreadQueryKeyPrefix",
"allThreadTerminalsQueryKeyPrefix",
@@ -139,9 +142,6 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = {
"hostsQueryKey",
"sidebarNavigationQueryKey",
"systemProvidersQueryKey",
- "threadAppMarkdownPreviewQueryKeyPrefix",
- "threadAppQueryKeyPrefix",
- "threadAppsQueryKey",
"threadListQueryKey",
"threadQueryKey",
"threadStorageFilePreviewQueryKeyPrefix",
@@ -157,11 +157,11 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = {
"allEnvironmentQueryKeyPrefix",
"allEnvironmentWorkStatusQueryKeyPrefix",
"allHostQueryKeyPrefix",
+ "allAppMarkdownPreviewQueryKeyPrefix",
+ "allAppQueryKeyPrefix",
+ "allAppsQueryKeyPrefix",
"allProjectPathsQueryKeyPrefix",
"allSystemExecutionOptionsQueryKeyPrefix",
- "allThreadAppMarkdownPreviewQueryKeyPrefix",
- "allThreadAppQueryKeyPrefix",
- "allThreadAppsQueryKeyPrefix",
"allThreadDefaultExecutionOptionsQueryKeyPrefix",
"allThreadPendingInteractionsQueryKeyPrefix",
"allThreadQueryKeyPrefix",
diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
index 6fa2ab45e..4d60f8a14 100644
--- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
+++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
@@ -31,9 +31,6 @@ import {
hostsQueryKey,
sidebarNavigationQueryKey,
systemProvidersQueryKey,
- threadAppMarkdownPreviewQueryKeyPrefix,
- threadAppQueryKeyPrefix,
- threadAppsQueryKey,
threadListQueryKey,
threadQueryKey,
threadTerminalsQueryKey,
@@ -577,9 +574,6 @@ function dirtyThreadStorageQueriesForEnvironment({
queryKeys.push(threadStorageFilesForThreadQueryKeyPrefix(threadId));
queryKeys.push(threadStoragePathsForThreadQueryKeyPrefix(threadId));
queryKeys.push(threadStorageFilePreviewQueryKeyPrefix(threadId));
- queryKeys.push(threadAppsQueryKey(threadId));
- queryKeys.push(threadAppQueryKeyPrefix(threadId));
- queryKeys.push(threadAppMarkdownPreviewQueryKeyPrefix(threadId));
}
return queryKeys;
}
diff --git a/apps/app/src/hooks/cache-owners/system-cache-effects.ts b/apps/app/src/hooks/cache-owners/system-cache-effects.ts
index 63b253360..08656253a 100644
--- a/apps/app/src/hooks/cache-owners/system-cache-effects.ts
+++ b/apps/app/src/hooks/cache-owners/system-cache-effects.ts
@@ -10,9 +10,9 @@ import {
allProjectPathsQueryKeyPrefix,
allSystemExecutionOptionsQueryKeyPrefix,
allThreadDefaultExecutionOptionsQueryKeyPrefix,
- allThreadAppMarkdownPreviewQueryKeyPrefix,
- allThreadAppQueryKeyPrefix,
- allThreadAppsQueryKeyPrefix,
+ allAppMarkdownPreviewQueryKeyPrefix,
+ allAppQueryKeyPrefix,
+ allAppsQueryKeyPrefix,
allThreadQueuedMessagesQueryKeyPrefix,
allThreadPendingInteractionsQueryKeyPrefix,
allThreadQueryKeyPrefix,
@@ -121,9 +121,9 @@ function getServerReconnectInvalidationQueryKeys(): QueryKey[] {
threadPromptHistoryQueryKeyPrefix(),
allThreadPendingInteractionsQueryKeyPrefix(),
allThreadDefaultExecutionOptionsQueryKeyPrefix(),
- allThreadAppsQueryKeyPrefix(),
- allThreadAppQueryKeyPrefix(),
- allThreadAppMarkdownPreviewQueryKeyPrefix(),
+ allAppsQueryKeyPrefix(),
+ allAppQueryKeyPrefix(),
+ allAppMarkdownPreviewQueryKeyPrefix(),
allThreadStorageFilesQueryKeyPrefix(),
allThreadStoragePathsQueryKeyPrefix(),
allThreadStorageFilePreviewQueryKeyPrefix(),
diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts
index 3a1dc1159..a186e7a94 100644
--- a/apps/app/src/hooks/queries/query-keys.ts
+++ b/apps/app/src/hooks/queries/query-keys.ts
@@ -36,9 +36,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_APPS_QUERY_KEY = "threadApps";
-export const THREAD_APP_QUERY_KEY = "threadApp";
-export const THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY = "threadAppMarkdownPreview";
+export const APPS_QUERY_KEY = "apps";
+export const APP_QUERY_KEY = "app";
+export const APP_MARKDOWN_PREVIEW_QUERY_KEY = "appMarkdownPreview";
export const THREAD_HOST_FILE_PREVIEW_QUERY_KEY = "threadHostFilePreview";
export const ENVIRONMENT_QUERY_KEY = "environment";
export const ENVIRONMENT_WORK_STATUS_QUERY_KEY = "environmentWorkStatus";
@@ -238,35 +238,21 @@ export type ThreadStorageFilePreviewQueryKeyPrefix = readonly [
typeof THREAD_STORAGE_FILE_PREVIEW_QUERY_KEY,
string,
];
-export type AllThreadAppsQueryKeyPrefix = readonly [
- typeof THREAD_APPS_QUERY_KEY,
+export type AllAppsQueryKeyPrefix = readonly [typeof APPS_QUERY_KEY];
+export type AppsQueryKey = readonly [typeof APPS_QUERY_KEY];
+export type AllAppQueryKeyPrefix = readonly [typeof APP_QUERY_KEY];
+export type AppQueryKey = readonly [typeof APP_QUERY_KEY, string];
+export type AppQueryKeyPrefix = readonly [typeof APP_QUERY_KEY];
+export type AllAppMarkdownPreviewQueryKeyPrefix = readonly [
+ typeof APP_MARKDOWN_PREVIEW_QUERY_KEY,
];
-export type ThreadAppsQueryKey = readonly [
- typeof THREAD_APPS_QUERY_KEY,
- string,
-];
-export type AllThreadAppQueryKeyPrefix = readonly [typeof THREAD_APP_QUERY_KEY];
-export type ThreadAppQueryKey = readonly [
- typeof THREAD_APP_QUERY_KEY,
- string,
- string,
-];
-export type ThreadAppQueryKeyPrefix = readonly [
- typeof THREAD_APP_QUERY_KEY,
- string,
-];
-export type AllThreadAppMarkdownPreviewQueryKeyPrefix = readonly [
- typeof THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY,
-];
-export type ThreadAppMarkdownPreviewQueryKey = readonly [
- typeof THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY,
- string,
+export type AppMarkdownPreviewQueryKey = readonly [
+ typeof APP_MARKDOWN_PREVIEW_QUERY_KEY,
string,
string | null | undefined,
];
-export type ThreadAppMarkdownPreviewQueryKeyPrefix = readonly [
- typeof THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY,
- string,
+export type AppMarkdownPreviewQueryKeyPrefix = readonly [
+ typeof APP_MARKDOWN_PREVIEW_QUERY_KEY,
];
export type ThreadHostFilePreviewQueryKey = readonly [
typeof THREAD_HOST_FILE_PREVIEW_QUERY_KEY,
@@ -670,47 +656,39 @@ export function threadStorageFilePreviewQueryKeyPrefix(
return [THREAD_STORAGE_FILE_PREVIEW_QUERY_KEY, threadId];
}
-export function allThreadAppsQueryKeyPrefix(): AllThreadAppsQueryKeyPrefix {
- return [THREAD_APPS_QUERY_KEY];
+export function allAppsQueryKeyPrefix(): AllAppsQueryKeyPrefix {
+ return [APPS_QUERY_KEY];
}
-export function threadAppsQueryKey(threadId: string): ThreadAppsQueryKey {
- return [THREAD_APPS_QUERY_KEY, threadId];
+export function appsQueryKey(): AppsQueryKey {
+ return [APPS_QUERY_KEY];
}
-export function allThreadAppQueryKeyPrefix(): AllThreadAppQueryKeyPrefix {
- return [THREAD_APP_QUERY_KEY];
+export function allAppQueryKeyPrefix(): AllAppQueryKeyPrefix {
+ return [APP_QUERY_KEY];
}
-export function threadAppQueryKey(
- threadId: string,
- appId: string,
-): ThreadAppQueryKey {
- return [THREAD_APP_QUERY_KEY, threadId, appId];
+export function appQueryKey(applicationId: string): AppQueryKey {
+ return [APP_QUERY_KEY, applicationId];
}
-export function threadAppQueryKeyPrefix(
- threadId: string,
-): ThreadAppQueryKeyPrefix {
- return [THREAD_APP_QUERY_KEY, threadId];
+export function appQueryKeyPrefix(): AppQueryKeyPrefix {
+ return [APP_QUERY_KEY];
}
-export function allThreadAppMarkdownPreviewQueryKeyPrefix(): AllThreadAppMarkdownPreviewQueryKeyPrefix {
- return [THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY];
+export function allAppMarkdownPreviewQueryKeyPrefix(): AllAppMarkdownPreviewQueryKeyPrefix {
+ return [APP_MARKDOWN_PREVIEW_QUERY_KEY];
}
-export function threadAppMarkdownPreviewQueryKey(
- threadId: string,
- appId: string,
+export function appMarkdownPreviewQueryKey(
+ applicationId: string,
entryPath: string | null | undefined,
-): ThreadAppMarkdownPreviewQueryKey {
- return [THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY, threadId, appId, entryPath];
+): AppMarkdownPreviewQueryKey {
+ return [APP_MARKDOWN_PREVIEW_QUERY_KEY, applicationId, entryPath];
}
-export function threadAppMarkdownPreviewQueryKeyPrefix(
- threadId: string,
-): ThreadAppMarkdownPreviewQueryKeyPrefix {
- return [THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY, threadId];
+export function appMarkdownPreviewQueryKeyPrefix(): AppMarkdownPreviewQueryKeyPrefix {
+ return [APP_MARKDOWN_PREVIEW_QUERY_KEY];
}
export function threadHostFilePreviewQueryKey(
diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts
index 8dc4204b9..b3fa366c2 100644
--- a/apps/app/src/hooks/queries/thread-queries.ts
+++ b/apps/app/src/hooks/queries/thread-queries.ts
@@ -49,9 +49,9 @@ import {
threadStorageFilesQueryKey,
threadStoragePathsQueryKey,
threadStorageFilePreviewQueryKey,
- threadAppMarkdownPreviewQueryKey,
- threadAppQueryKey,
- threadAppsQueryKey,
+ appMarkdownPreviewQueryKey,
+ appQueryKey,
+ appsQueryKey,
threadHostFilePreviewQueryKey,
threadTimelineQueryKey,
threadTimelineTurnSummaryDetailsQueryKey,
@@ -528,70 +528,51 @@ export function useThreadStorageFilePreview(
}
/**
- * Thread apps rarely change within a session and are read from both the sidebar
- * (a query per manager row) and the thread detail view. A shared default stale
- * window lets navigation reuse a recent sidebar fetch instead of refetching on
- * detail mount; callers can still override `staleTime` explicitly.
+ * Apps rarely change within a session and are read from both the sidebar and
+ * thread detail view. A shared default stale window lets navigation reuse a
+ * recent fetch instead of refetching on detail mount; callers can still
+ * override `staleTime` explicitly.
*/
-const THREAD_APPS_STALE_TIME_MS = 30_000;
+const APPS_STALE_TIME_MS = 30_000;
-export function useThreadApps(id: string, options?: QueryOptions) {
+export function useApps(options?: QueryOptions) {
return useQuery({
- queryKey: threadAppsQueryKey(id),
- queryFn: ({ signal }) =>
- api.listThreadApps(requireThreadId(id, "useThreadApps"), signal),
- enabled: (options?.enabled ?? true) && Boolean(id),
+ queryKey: appsQueryKey(),
+ queryFn: ({ signal }) => api.listApps(signal),
+ enabled: options?.enabled ?? true,
refetchOnMount: options?.refetchOnMount ?? true,
refetchOnWindowFocus: false,
- staleTime: options?.staleTime ?? THREAD_APPS_STALE_TIME_MS,
+ staleTime: options?.staleTime ?? APPS_STALE_TIME_MS,
});
}
-export function useThreadApp(
- id: string,
- appId: string | null | undefined,
+export function useApp(
+ applicationId: string | null | undefined,
options?: QueryOptions,
) {
- const queryClient = useQueryClient();
-
return useQuery({
- queryKey: threadAppQueryKey(id, appId ?? ""),
+ queryKey: appQueryKey(applicationId ?? ""),
queryFn: ({ signal }) =>
- api.getThreadApp(
- requireThreadId(id, "useThreadApp"),
- appId ?? "",
- signal,
- ),
- enabled: (options?.enabled ?? true) && Boolean(id) && Boolean(appId),
+ api.getApp(applicationId ?? "", signal),
+ enabled: (options?.enabled ?? true) && Boolean(applicationId),
refetchOnMount: options?.refetchOnMount ?? true,
refetchOnWindowFocus: false,
- placeholderData: () =>
- queryClient
- .getQueryData(threadAppsQueryKey(id))
- ?.find((app) => app.id === appId),
staleTime: options?.staleTime,
});
}
-export function useThreadAppMarkdownPreview(
- id: string,
- appId: string | null | undefined,
+export function useAppMarkdownPreview(
+ applicationId: string | null | undefined,
entryPath: string | null | undefined,
options?: QueryOptions,
) {
return useQuery({
- queryKey: threadAppMarkdownPreviewQueryKey(id, appId ?? "", entryPath),
+ queryKey: appMarkdownPreviewQueryKey(applicationId ?? "", entryPath),
queryFn: ({ signal }) =>
- api.getThreadAppMarkdownPreview(
- requireThreadId(id, "useThreadAppMarkdownPreview"),
- appId ?? "",
- entryPath ?? "",
- signal,
- ),
+ api.getAppMarkdownPreview(applicationId ?? "", entryPath ?? "", signal),
enabled:
(options?.enabled ?? true) &&
- Boolean(id) &&
- Boolean(appId) &&
+ Boolean(applicationId) &&
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 3c4ceacde..fbe725c99 100644
--- a/apps/app/src/hooks/realtime-cache-effects.test.ts
+++ b/apps/app/src/hooks/realtime-cache-effects.test.ts
@@ -17,9 +17,6 @@ import {
projectSourceBranchesQueryKey,
projectsQueryKey,
sidebarNavigationQueryKey,
- threadAppMarkdownPreviewQueryKey,
- threadAppQueryKey,
- threadAppsQueryKey,
threadQueuedMessagesQueryKey,
threadListQueryKey,
threadPromptHistoryQueryKey,
@@ -583,89 +580,6 @@ describe("createRealtimeCacheEffects", () => {
effects.dispose();
});
- it("refetches active thread app queries for thread storage changes", async () => {
- vi.useFakeTimers();
- const { effects, queryClient } = createRealtimeEffectsTestContext();
- const threadKey = threadQueryKey("thr_1");
- const appListKey = threadAppsQueryKey("thr_1");
- const appDetailKey = threadAppQueryKey("thr_1", "status");
- const markdownPreviewKey = threadAppMarkdownPreviewQueryKey(
- "thr_1",
- "status",
- "index.md",
- );
- const nextAppDetail = {
- id: "status",
- name: "Status",
- entry: { path: "index.html", kind: "html" },
- capabilities: ["data"],
- icon: { kind: "builtin", name: "ListTodo" },
- };
- const nextMarkdownPreview = {
- kind: "text",
- content: "new",
- mimeType: "text/markdown",
- path: "index.md",
- url: "/new",
- };
- queryClient.setQueryData(threadKey, {
- id: "thr_1",
- environmentId: "env-1",
- });
- queryClient.setQueryData(appListKey, []);
- queryClient.setQueryData(appDetailKey, {
- ...nextAppDetail,
- name: "Old Status",
- });
- queryClient.setQueryData(markdownPreviewKey, {
- ...nextMarkdownPreview,
- content: "old",
- });
- const appListQueryFn = vi.fn(async () => [nextAppDetail]);
- const appDetailQueryFn = vi.fn(async () => nextAppDetail);
- const markdownPreviewQueryFn = vi.fn(async () => nextMarkdownPreview);
- const appListObserver = new QueryObserver(queryClient, {
- queryKey: appListKey,
- queryFn: appListQueryFn,
- staleTime: Infinity,
- });
- const appDetailObserver = new QueryObserver(queryClient, {
- queryKey: appDetailKey,
- queryFn: appDetailQueryFn,
- staleTime: Infinity,
- });
- const markdownPreviewObserver = new QueryObserver(queryClient, {
- queryKey: markdownPreviewKey,
- queryFn: markdownPreviewQueryFn,
- staleTime: Infinity,
- });
- const unsubscribeAppList = appListObserver.subscribe(() => {});
- const unsubscribeAppDetail = appDetailObserver.subscribe(() => {});
- const unsubscribeMarkdownPreview = markdownPreviewObserver.subscribe(
- () => {},
- );
- appListQueryFn.mockClear();
- appDetailQueryFn.mockClear();
- markdownPreviewQueryFn.mockClear();
-
- effects.handleChanged({
- type: "changed",
- entity: "environment",
- id: "env-1",
- changes: ["thread-storage-changed"],
- });
- await vi.advanceTimersByTimeAsync(250);
-
- expect(appListQueryFn).toHaveBeenCalledTimes(1);
- expect(appDetailQueryFn).toHaveBeenCalledTimes(1);
- expect(markdownPreviewQueryFn).toHaveBeenCalledTimes(1);
-
- unsubscribeAppList();
- unsubscribeAppDetail();
- unsubscribeMarkdownPreview();
- effects.dispose();
- });
-
it("does not invalidate timeline queries for status-only thread changes", () => {
const { effects, queryClient } = createRealtimeEffectsTestContext();
const timelineKey = threadTimelineQueryKey("thr_1", undefined);
diff --git a/apps/app/src/hooks/useFileSearchSuggestions.test.tsx b/apps/app/src/hooks/useFileSearchSuggestions.test.tsx
index c2937f257..db49e2fc8 100644
--- a/apps/app/src/hooks/useFileSearchSuggestions.test.tsx
+++ b/apps/app/src/hooks/useFileSearchSuggestions.test.tsx
@@ -17,7 +17,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
return {
...actual,
searchProjectPaths: vi.fn(),
- listThreadApps: vi.fn(),
+ listApps: vi.fn(),
listThreadStoragePaths: vi.fn(),
};
});
@@ -63,9 +63,9 @@ function isFilePathSearchSuggestion(
return suggestion.entryKind === "file";
}
-const STATUS_APP: AppSummary = {
- id: "status",
- name: "Status",
+const APP: AppSummary = {
+ applicationId: "app_status",
+ name: "Review Board",
entry: { path: "index.html", kind: "html" },
capabilities: ["data", "message"],
icon: { kind: "builtin", name: "ListTodo" },
@@ -78,7 +78,7 @@ afterEach(() => {
describe("useFileSearchSuggestions", () => {
it("merges workspace and manager thread-storage file results", async () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.mocked(api.listApps).mockResolvedValue([]);
vi.mocked(api.searchProjectPaths).mockResolvedValue(
makePathResponse([
{
@@ -106,7 +106,7 @@ describe("useFileSearchSuggestions", () => {
() =>
useFileSearchSuggestions({
projectId: "proj-1",
- query: "status",
+ query: "app_status",
limit: 2,
environmentId: "env-1",
currentThreadId: "thr-manager",
@@ -126,7 +126,7 @@ describe("useFileSearchSuggestions", () => {
).toEqual(["notes/status.md", "src/project.ts"]);
expect(api.searchProjectPaths).toHaveBeenCalledWith({
projectId: "proj-1",
- query: "status",
+ query: "app_status",
limit: 4,
environmentId: "env-1",
includeFiles: true,
@@ -136,7 +136,7 @@ describe("useFileSearchSuggestions", () => {
id: "thr-manager",
options: {
limit: 4,
- query: "status",
+ query: "app_status",
includeFiles: true,
includeDirectories: false,
},
@@ -145,7 +145,7 @@ describe("useFileSearchSuggestions", () => {
});
it("returns matching apps before files", async () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([STATUS_APP]);
+ vi.mocked(api.listApps).mockResolvedValue([APP]);
vi.mocked(api.searchProjectPaths).mockResolvedValue(
makePathResponse([
{
@@ -165,7 +165,7 @@ describe("useFileSearchSuggestions", () => {
() =>
useFileSearchSuggestions({
projectId: "proj-1",
- query: "status",
+ query: "app_status",
environmentId: "env-1",
currentThreadId: "thr-manager",
currentThreadType: "manager",
@@ -180,8 +180,8 @@ describe("useFileSearchSuggestions", () => {
expect(result.current.suggestions[0]).toMatchObject({
source: "app",
entryKind: "app",
- appId: "status",
- name: "Status",
+ applicationId: "app_status",
+ name: "Review Board",
});
expect(result.current.suggestions[1]).toMatchObject({
source: "workspace",
diff --git a/apps/app/src/hooks/useFileSearchSuggestions.ts b/apps/app/src/hooks/useFileSearchSuggestions.ts
index af7c4e4b6..2317d5640 100644
--- a/apps/app/src/hooks/useFileSearchSuggestions.ts
+++ b/apps/app/src/hooks/useFileSearchSuggestions.ts
@@ -6,7 +6,7 @@ import {
type PathSuggestion,
type PathSuggestionSource,
} from "./usePathSuggestions";
-import { useThreadApps } from "./queries/thread-queries";
+import { useApps } from "./queries/thread-queries";
const DEFAULT_FILE_SEARCH_SUGGESTION_LIMIT = 8;
@@ -14,7 +14,7 @@ export interface AppSearchSuggestion {
source: "app";
entryKind: "app";
app: AppSummary;
- appId: string;
+ applicationId: string;
name: string;
score: number;
}
@@ -84,7 +84,7 @@ function scoreAppSearchMatch(app: AppSummary, normalizedQuery: string): number {
}
const normalizedName = app.name.toLowerCase();
- const normalizedId = app.id.toLowerCase();
+ const normalizedId = app.applicationId.toLowerCase();
if (normalizedName === normalizedQuery || normalizedId === normalizedQuery) {
return 100;
}
@@ -119,7 +119,7 @@ function buildAppSearchSuggestions({
source: "app",
entryKind: "app",
app,
- appId: app.id,
+ applicationId: app.applicationId,
name: app.name,
score,
});
@@ -149,17 +149,17 @@ export function useFileSearchSuggestions(
includeDirectories: false,
});
const canSearchApps = Boolean(args.currentThreadId);
- const threadApps = useThreadApps(args.currentThreadId ?? "", {
+ const apps = useApps({
enabled: canSearchApps,
});
const appSuggestions = useMemo(
() =>
buildAppSearchSuggestions({
- apps: threadApps.data ?? [],
+ apps: apps.data ?? [],
limit,
query: args.query ?? "",
}),
- [args.query, limit, threadApps.data],
+ [args.query, apps.data, limit],
);
const fileSuggestions = useMemo(
() =>
@@ -180,8 +180,8 @@ export function useFileSearchSuggestions(
suggestions,
isLoading:
suggestions.length === 0 &&
- (pathSuggestions.isLoading || (canSearchApps && threadApps.isLoading)),
- isError: pathSuggestions.isError || (canSearchApps && threadApps.isError),
+ (pathSuggestions.isLoading || (canSearchApps && apps.isLoading)),
+ isError: pathSuggestions.isError || (canSearchApps && apps.isError),
isDebouncing: pathSuggestions.isDebouncing,
isUnavailable: !canSearchApps && !canSearchWorkspace && !canSearchThreadStorage,
};
diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts
index f64088b1e..182b8ee6d 100644
--- a/apps/app/src/lib/api.ts
+++ b/apps/app/src/lib/api.ts
@@ -92,7 +92,7 @@ import {
type FilePreviewTarget,
} from "./file-preview";
import {
- buildThreadAppAssetUrl,
+ buildAppAssetUrl,
buildThreadHostFileContentUrl,
buildThreadStorageContentUrl,
} from "./file-content-urls";
@@ -886,34 +886,28 @@ export async function getThreadStorageFilePreview(
);
}
-export async function listThreadApps(
- id: string,
+export async function listApps(
signal?: AbortSignal,
): Promise {
return request(
- apiClient.threads[":id"].apps.$get(
- { param: { id } },
- requestOptions(signal),
- ),
+ apiClient.apps.$get({}, requestOptions(signal)),
);
}
-export async function getThreadApp(
- id: string,
- appId: string,
+export async function getApp(
+ applicationId: string,
signal?: AbortSignal,
): Promise {
return request(
- apiClient.threads[":id"].apps[":appId"].$get(
- { param: { id, appId } },
+ apiClient.apps[":applicationId"].$get(
+ { param: { applicationId } },
requestOptions(signal),
),
);
}
-export async function getThreadAppMarkdownPreview(
- id: string,
- appId: string,
+export async function getAppMarkdownPreview(
+ applicationId: string,
path: string,
signal?: AbortSignal,
): Promise {
@@ -921,7 +915,7 @@ export async function getThreadAppMarkdownPreview(
{
name: path.split("/").at(-1),
path,
- url: buildThreadAppAssetUrl(id, appId, path),
+ url: buildAppAssetUrl(applicationId, path),
},
signal,
);
diff --git a/apps/app/src/lib/file-content-urls.ts b/apps/app/src/lib/file-content-urls.ts
index 13ee9726b..1195be64f 100644
--- a/apps/app/src/lib/file-content-urls.ts
+++ b/apps/app/src/lib/file-content-urls.ts
@@ -35,33 +35,31 @@ export function buildThreadStorageRawContentUrl(
return `/api/v1/threads/${encodeURIComponent(threadId)}/thread-storage/files/${encodePathSegments(path)}`;
}
-export function buildThreadAppEntryUrl(
- threadId: string,
- appId: string,
+export function buildAppEntryUrl(
+ applicationId: string,
+ targetThreadId: string,
): string {
- return `/api/v1/threads/${encodeURIComponent(
- threadId,
- )}/apps/${encodeURIComponent(appId)}/`;
+ return `/api/v1/apps/${encodeURIComponent(
+ applicationId,
+ )}/?targetThreadId=${encodeURIComponent(targetThreadId)}`;
}
-export function buildThreadAppAssetUrl(
- threadId: string,
- appId: string,
+export function buildAppAssetUrl(
+ applicationId: string,
path: string,
): string {
- return `/api/v1/threads/${encodeURIComponent(
- threadId,
- )}/apps/${encodeURIComponent(appId)}/${encodePathSegments(path)}`;
+ return `/api/v1/apps/${encodeURIComponent(
+ applicationId,
+ )}/assets/${encodePathSegments(path)}`;
}
-export function buildThreadAppAssetBaseUrl(
- threadId: string,
- appId: string,
+export function buildAppAssetBaseUrl(
+ applicationId: string,
entryPath: string,
): string {
const lastSlash = entryPath.lastIndexOf("/");
const basePath = lastSlash === -1 ? "" : entryPath.slice(0, lastSlash + 1);
- return buildThreadAppAssetUrl(threadId, appId, basePath);
+ return buildAppAssetUrl(applicationId, 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 7368e46d8..7d69304c5 100644
--- a/apps/app/src/lib/fixed-panel-tabs-state.test.ts
+++ b/apps/app/src/lib/fixed-panel-tabs-state.test.ts
@@ -86,11 +86,11 @@ describe("fixed panel tabs state storage", () => {
});
it("round-trips app tabs", () => {
- const appTab = createAppFixedPanelTab({ appId: "status" });
+ const appTab = createAppFixedPanelTab({ applicationId: "app_status" });
const state = makeFixedPanelTabsState({
secondary: {
tabs: [appTab],
- activeTabId: appTabId("status"),
+ activeTabId: appTabId("app_status"),
isOpen: true,
},
});
diff --git a/apps/app/src/lib/fixed-panel-tabs-state.ts b/apps/app/src/lib/fixed-panel-tabs-state.ts
index 39ad99a13..64ef625bd 100644
--- a/apps/app/src/lib/fixed-panel-tabs-state.ts
+++ b/apps/app/src/lib/fixed-panel-tabs-state.ts
@@ -82,7 +82,7 @@ const threadStorageFilePreviewFixedPanelTabSchema = z
.strict();
const appFixedPanelTabSchema = z
.object({
- appId: z.string().min(1),
+ applicationId: z.string().min(1),
id: z.string().min(1),
kind: z.literal("app"),
})
@@ -194,7 +194,7 @@ export interface ThreadStorageFilePreviewFixedPanelTab {
}
export interface AppFixedPanelTab {
- appId: string;
+ applicationId: string;
id: string;
kind: "app";
}
@@ -268,6 +268,23 @@ export interface FixedPanelTabsState {
lastUsedAt: number;
}
+/**
+ * The application id of the secondary panel's active tab when that tab is an
+ * app and the panel is open, else null. Lets sidebar rows tell whether a
+ * thread's panel is currently showing a given app without reaching into the
+ * tab list shape themselves.
+ */
+export function getActiveSecondaryAppId(
+ state: FixedPanelTabsState,
+): string | null {
+ const { activeTabId, isOpen, tabs } = state.secondary;
+ if (!isOpen || activeTabId === null) {
+ return null;
+ }
+ const activeTab = tabs.find((tab) => tab.id === activeTabId);
+ return activeTab?.kind === "app" ? activeTab.applicationId : null;
+}
+
interface FixedPanelTabsStorageKeyArgs {
threadId: string;
}
@@ -317,7 +334,7 @@ interface CreateThreadStorageFilePreviewFixedPanelTabArgs {
}
interface CreateAppFixedPanelTabArgs {
- appId: string;
+ applicationId: string;
}
interface CreateBrowserFixedPanelTabArgs {
@@ -401,11 +418,11 @@ export function createThreadStorageFilePreviewFixedPanelTab({
}
export function createAppFixedPanelTab({
- appId,
+ applicationId,
}: CreateAppFixedPanelTabArgs): AppFixedPanelTab {
return {
- appId,
- id: `app:${encodeURIComponent(appId)}`,
+ applicationId,
+ id: `app:${encodeURIComponent(applicationId)}`,
kind: "app",
};
}
@@ -413,7 +430,7 @@ export function createAppFixedPanelTab({
/**
* Browser tabs get a fresh unique id per instance — the URL is mutable (it
* changes on every navigation), so it cannot serve as a stable identity the way
- * an app id or file path does.
+ * an application id or file path does.
*/
export function createBrowserFixedPanelTab({
url,
@@ -697,7 +714,7 @@ export function areFixedPanelTabsEquivalent(
a.path === b.path
);
case "app":
- return b.kind === "app" && a.appId === b.appId;
+ return b.kind === "app" && a.applicationId === b.applicationId;
case "browser":
return b.kind === "browser" && a.url === b.url && a.title === b.title;
case "thread-storage-file-preview":
diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
index 4b98086c3..1460c4000 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
@@ -31,9 +31,9 @@ import {
} from "../../hooks/queries/environment-queries";
import {
getLatestPendingInteraction,
+ useApps,
useProjectThreadSubset,
useThread,
- useThreadApps,
useThreadComposerBootstrap,
useThreadDetailBootstrap,
useThreadPendingInteractions,
@@ -99,10 +99,7 @@ import {
import { getBrowserUrlHost } from "@/lib/browser-url";
import { ResolvedAppIcon } from "@/components/secondary-panel/AppIcon";
import { useManagerStorageBrowser } from "@/components/secondary-panel/useManagerStorageBrowser";
-import {
- STATUS_APP_ID,
- useThreadFileTabs,
-} from "@/components/secondary-panel/useThreadFileTabs";
+import { useThreadFileTabs } from "@/components/secondary-panel/useThreadFileTabs";
import type { SecondaryPanelFileTab } from "@/components/secondary-panel/ThreadSecondaryPanel";
import { useEnvironmentMergeBase } from "@/components/secondary-panel/git-diff/useEnvironmentMergeBase";
import { useThreadGitActions } from "./useThreadGitActions";
@@ -306,8 +303,8 @@ export function ThreadDetailView() {
threadId,
threadType: thread?.type,
});
- const threadAppsQuery = useThreadApps(threadId ?? "", {
- enabled: Boolean(threadId) && thread !== undefined,
+ const appsQuery = useApps({
+ enabled: thread !== undefined,
});
const {
activateAppTab,
@@ -336,7 +333,6 @@ export function ThreadDetailView() {
isNewTabActive,
openBrowserTab,
openNewTab,
- openApp,
openHostFile,
openStorageFile,
openWorkspaceFile,
@@ -344,7 +340,7 @@ export function ThreadDetailView() {
selectFileSearchResult,
updateBrowserTab,
} = useThreadFileTabs({
- apps: threadAppsQuery.data,
+ apps: appsQuery.data,
threadId,
environmentId: thread?.environmentId,
threadType: thread?.type,
@@ -375,16 +371,9 @@ export function ThreadDetailView() {
setThreadSecondaryPanel(null);
return;
}
- if (isManagerThread && activeFixedSecondaryTab === null) {
- openApp(STATUS_APP_ID);
- return;
- }
toggleDefaultPersistedSecondaryPanel();
}, [
- activeFixedSecondaryTab,
fixedPanelTabsState.secondary.isOpen,
- isManagerThread,
- openApp,
setThreadSecondaryPanel,
toggleDefaultPersistedSecondaryPanel,
]);
@@ -581,30 +570,29 @@ export function ThreadDetailView() {
},
[openSecondaryPanelDiffFile, openWorkspaceFile],
);
- const threadAppsById = useMemo(() => {
+ const appsById = useMemo(() => {
const entries = new Map(
- (threadAppsQuery.data ?? []).map((app) => [app.id, app]),
+ (appsQuery.data ?? []).map((app) => [app.applicationId, app]),
);
return entries;
- }, [threadAppsQuery.data]);
+ }, [appsQuery.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;
+ const app = appsById.get(tab.applicationId);
+ const appName = app?.name ?? tab.applicationId;
return {
id: tab.id,
filename: appName,
- isActive: tab.appId === activeAppId,
- isPinned: tab.appId === STATUS_APP_ID,
+ isActive: tab.applicationId === activeAppId,
leadingVisual: app ? (
) : undefined,
statusLabel: null,
- onSelect: () => activateAppTab(tab.appId),
- onClose: () => closeAppTab(tab.appId),
+ onSelect: () => activateAppTab(tab.applicationId),
+ onClose: () => closeAppTab(tab.applicationId),
};
}
case "browser": {
@@ -683,7 +671,7 @@ export function ThreadDetailView() {
closeWorkspaceFileTab,
isNewTabActive,
orderedSecondaryFileTabs,
- threadAppsById,
+ appsById,
]);
const requestedMergeBaseBranch =
selectedMergeBaseBranch ?? environmentMergeBaseBranch;
@@ -1222,7 +1210,7 @@ export function ThreadDetailView() {
onSelect={selectFileSearchResult}
/>
) : activeAppId ? (
-
+
) : activeWorkspaceFilePath ? (
{
const output = collectLogPayloads(vi.mocked(console.log)).join("\n");
expect(output.trim().length).toBeGreaterThan(0);
expect(output).toContain("Apps");
- expect(output).toContain("apps/status/data/state.json");
+ expect(output).toContain("/apps//");
expect(output).toContain("window.bb.data");
- expect(output).toContain("bb app list --self");
+ expect(output).toContain("bb app current --json");
+ expect(output).toContain("Do not start a web server");
});
it("bb guide unknown chapter lists styling in available chapters", async () => {
@@ -1151,23 +1152,22 @@ 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",
+ applicationId: "app_status",
+ name: "Project Status",
entry: { path: "index.html", kind: "html" },
capabilities: ["data", "message"],
icon: { kind: "builtin", name: "ListTodo" },
},
{
- id: "demo",
+ applicationId: "app_demo",
name: "Demo",
entry: { path: "readme.md", kind: "md" },
capabilities: [],
icon: {
kind: "logo",
- url: "/api/v1/threads/thr_current/apps/demo/icon",
+ url: "/api/v1/apps/app_demo/icon",
},
},
];
@@ -1176,12 +1176,8 @@ describe("CLI command output contracts", () => {
asServerClient({
api: {
v1: {
- threads: {
- ":id": {
- apps: {
- $get: get,
- },
- },
+ apps: {
+ $get: get,
},
},
},
@@ -1192,32 +1188,30 @@ describe("CLI command output contracts", () => {
registerAppCommands(program, () => "http://server"),
);
- expect(get).toHaveBeenCalledWith({ param: { id: "thr_current" } });
+ expect(get).toHaveBeenCalledWith();
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",
+ "Application ID Name Entry Capabilities Icon\n-------------------------------- ------------------------ ------------------------ ------------------------ ------------------\napp_status Project Status html:index.html data,message ListTodo\n-------------------------------- ------------------------ ------------------------ ------------------------ ------------------\napp_demo 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");
+ it("bb app new creates a global app by display name", async () => {
const created = {
- id: "demo",
+ applicationId: "app_demo",
name: "Demo",
entry: { path: "index.html", kind: "html" },
capabilities: ["data", "message"],
icon: { kind: "builtin", name: "ListTodo" },
+ appsRootPath: "/tmp/bb-data/apps",
+ appRootPath: "/tmp/bb-data/apps/app_demo",
+ appDataPath: "/tmp/bb-data/apps/app_demo/data",
};
const post = vi.fn(async () => created);
createClientMock.mockReturnValue(
asServerClient({
api: {
v1: {
- threads: {
- ":id": {
- apps: {
- $post: post,
- },
- },
+ apps: {
+ $post: post,
},
},
},
@@ -1225,57 +1219,40 @@ describe("CLI command output contracts", () => {
);
await runCommand(
- ["app", "new", "demo", "--template", "status"],
+ ["app", "new", "--name", "Demo"],
(program) => registerAppCommands(program, () => "http://server"),
);
expect(post).toHaveBeenCalledWith({
- param: { id: "thr_current" },
- json: { id: "demo", name: "demo", template: "status" },
+ json: { name: "Demo" },
});
expect(collectLogPayloads(vi.mocked(console.log))).toEqual([
- "App created: demo",
- " Name: Demo",
- " Entry: html:index.html",
- " Capabilities: data,message",
- " Icon: ListTodo",
+ "Application ID: app_demo",
+ " Name: Demo",
+ " Entry: html:index.html",
+ " Capabilities: data,message",
+ " Icon: ListTodo",
+ " App root: /tmp/bb-data/apps/app_demo",
+ " App data path: /tmp/bb-data/apps/app_demo/data",
]);
});
- 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,
- },
- },
- },
- },
- },
- }),
- );
+ it("bb app current renders runtime app paths", async () => {
+ vi.stubEnv("BB_APP_ID", "app_current");
+ vi.stubEnv("BB_APP_ROOT", "/tmp/bb-data/apps/app_current");
+ vi.stubEnv("BB_APP_DATA_PATH", "/tmp/bb-data/apps/app_current/data");
+ vi.stubEnv("BB_APPS_ROOT", "/tmp/bb-data/apps");
- await runCommand(["app", "new", "My App"], (program) =>
+ await runCommand(["app", "current"], (program) =>
registerAppCommands(program, () => "http://server"),
);
- expect(post).toHaveBeenCalledWith({
- param: { id: "thr_current" },
- json: { id: "my-app", name: "My App", template: "blank" },
- });
+ expect(collectLogPayloads(vi.mocked(console.log))).toEqual([
+ "Application ID: app_current",
+ " App root: /tmp/bb-data/apps/app_current",
+ " App data path: /tmp/bb-data/apps/app_current/data",
+ " Apps root: /tmp/bb-data/apps",
+ ]);
});
it("bb manager status includes managed child threads", async () => {
diff --git a/apps/cli/src/commands/app.ts b/apps/cli/src/commands/app.ts
index 28552c8e8..b172cb9fd 100644
--- a/apps/cli/src/commands/app.ts
+++ b/apps/cli/src/commands/app.ts
@@ -1,101 +1,76 @@
+import { Buffer } from "node:buffer";
+import { readFile } from "node:fs/promises";
import { Command } from "commander";
-import { appIdSchema, type AppId } from "@bb/domain";
+import { applicationIdSchema, jsonValueSchema } from "@bb/domain";
+import type { ApplicationId, JsonValue } from "@bb/domain";
import type {
+ AppDataEntry,
+ AppDataListResponse,
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 {
+interface AppJsonOptions {
json?: boolean;
- self?: boolean;
}
-interface AppNewCommandOptions extends AppThreadCommandOptions {
- id?: string;
- template?: string;
+interface AppNewCommandOptions extends AppJsonOptions {
+ name: string;
}
-interface AppRemoveCommandOptions extends AppThreadCommandOptions {
+interface AppDeleteCommandOptions extends AppJsonOptions {
yes?: boolean;
}
-interface ResolveAppCommandThreadArgs {
- options: AppThreadCommandOptions;
- threadId: string | undefined;
-}
+interface AppDataListCommandOptions extends AppJsonOptions {}
-interface AppOpenPayload {
- app: AppDetail;
- threadId: string;
- url: string;
+interface AppDataWriteCommandOptions {
+ file?: string;
+ stdin?: boolean;
}
-interface ResolveNewAppIdArgs {
- id: string | undefined;
- name: string;
+interface AppMessageCommandOptions {
+ json: string;
+ targetThread: 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;
+interface CurrentAppRuntimeContext {
+ applicationId: ApplicationId;
+ appRootPath: string;
+ appDataPath: string;
+ appsRootPath: string;
}
-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);
+function parseApplicationId(value: string): ApplicationId {
+ const parsed = applicationIdSchema.safeParse(value);
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.`,
- );
+ throw new Error("Invalid applicationId. Expected an app_-prefixed id.");
}
-function appUrl(baseUrl: string, threadId: string, appId: string): string {
- return `${baseUrl.replace(/\/$/u, "")}/api/v1/threads/${encodeURIComponent(
- threadId,
- )}/apps/${encodeURIComponent(appId)}/`;
+function encodePathSegments(value: string): string {
+ return value.split("/").map(encodeURIComponent).join("/");
+}
+
+function appDataUrl(
+ baseUrl: string,
+ applicationId: ApplicationId,
+ dataPath: string,
+): string {
+ return `${baseUrl.replace(/\/$/u, "")}/api/v1/apps/${encodeURIComponent(
+ applicationId,
+ )}/data/${encodePathSegments(dataPath)}`;
}
function formatIcon(icon: AppIcon): string {
@@ -110,12 +85,12 @@ function printAppsTable(apps: AppSummary[]): void {
console.log(
renderBorderlessTable(
{
- head: ["ID", "Name", "Entry", "Capabilities", "Icon"],
- colWidths: [24, 24, 24, 24, 18],
+ head: ["Application ID", "Name", "Entry", "Capabilities", "Icon"],
+ colWidths: [32, 24, 24, 24, 18],
trimTrailingWhitespace: true,
},
apps.map((app) => [
- app.id,
+ app.applicationId,
app.name,
`${app.entry.kind}:${app.entry.path}`,
app.capabilities.join(",") || "-",
@@ -126,157 +101,297 @@ function printAppsTable(apps: AppSummary[]): void {
}
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)}`);
+ console.log(`Application ID: ${app.applicationId}`);
+ 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)}`);
+ console.log(` App root: ${app.appRootPath}`);
+ console.log(` App data path: ${app.appDataPath}`);
+}
+
+function printDataEntries(entries: AppDataEntry[]): void {
+ if (entries.length === 0) {
+ console.log("No app data");
+ return;
+ }
+ console.log(
+ renderBorderlessTable(
+ {
+ head: ["Path", "Size", "Version"],
+ colWidths: [36, 10, 64],
+ trimTrailingWhitespace: true,
+ },
+ entries.map((entry) => [
+ entry.path,
+ String(entry.sizeBytes),
+ entry.version,
+ ]),
+ ),
+ );
+}
+
+function parseJsonValueInput(value: string): JsonValue {
+ return jsonValueSchema.parse(JSON.parse(value));
+}
+
+async function readStdin(): Promise {
+ const chunks: Buffer[] = [];
+ for await (const chunk of process.stdin) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ return Buffer.concat(chunks).toString("utf8");
+}
+
+async function readWriteValue(opts: AppDataWriteCommandOptions): Promise {
+ if (opts.file && opts.stdin) {
+ throw new Error("Use either --file or --stdin, not both.");
+ }
+ if (!opts.file && !opts.stdin) {
+ throw new Error("Provide --file or --stdin.");
+ }
+ const content =
+ opts.file !== undefined ? await readFile(opts.file, "utf8") : await readStdin();
+ return parseJsonValueInput(content);
+}
+
+function readCurrentAppRuntimeContext(): CurrentAppRuntimeContext {
+ const applicationId = process.env.BB_APP_ID;
+ const appRootPath = process.env.BB_APP_ROOT;
+ const appDataPath = process.env.BB_APP_DATA_PATH;
+ const appsRootPath = process.env.BB_APPS_ROOT;
+ if (!applicationId || !appRootPath || !appDataPath || !appsRootPath) {
+ throw new Error("current_app_unavailable");
+ }
+ return {
+ applicationId: parseApplicationId(applicationId),
+ appRootPath,
+ appDataPath,
+ appsRootPath,
+ };
}
export function registerAppCommands(
program: Command,
getUrl: ResolveServerUrl,
): void {
- const app = program.command("app").description("Manage thread apps");
+ const app = program.command("app").description("Manage global apps");
+
+ app
+ .command("list")
+ .description("List global apps")
+ .option("--json", "Print machine-readable JSON output")
+ .action(
+ action(async (opts: AppJsonOptions) => {
+ const client = createClient(getUrl());
+ const apps = await unwrap(client.api.v1.apps.$get());
+ if (outputJson(opts, apps)) return;
+ printAppsTable(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")
+ .command("new")
+ .description("Create a global app")
+ .requiredOption("--name ", "Human display name")
+ .option("--json", "Print machine-readable JSON output")
+ .action(
+ action(async (opts: AppNewCommandOptions) => {
+ const client = createClient(getUrl());
+ const created = await unwrap(
+ client.api.v1.apps.$post({
+ json: { name: opts.name },
+ }),
+ );
+ if (outputJson(opts, created)) return;
+ printAppDetail(created);
+ }),
+ );
+
+ app
+ .command("current")
+ .description("Show current app runtime context")
+ .option("--json", "Print machine-readable JSON output")
+ .action(
+ action(async (opts: AppJsonOptions) => {
+ const current = readCurrentAppRuntimeContext();
+ if (outputJson(opts, current)) return;
+ console.log(`Application ID: ${current.applicationId}`);
+ console.log(` App root: ${current.appRootPath}`);
+ console.log(` App data path: ${current.appDataPath}`);
+ console.log(` Apps root: ${current.appsRootPath}`);
+ }),
+ );
+
+ app
+ .command("show ")
+ .description("Show a global app")
+ .option("--json", "Print machine-readable JSON output")
+ .action(
+ action(async (rawApplicationId: string, opts: AppJsonOptions) => {
+ const applicationId = parseApplicationId(rawApplicationId);
+ const client = createClient(getUrl());
+ const detail = await unwrap(
+ client.api.v1.apps[":applicationId"].$get({
+ param: { applicationId },
+ }),
+ );
+ if (outputJson(opts, detail)) return;
+ printAppDetail(detail);
+ }),
+ );
+
+ app
+ .command("delete ")
+ .description("Delete a global app")
+ .option("--yes", "Skip the confirmation prompt")
.option("--json", "Print machine-readable JSON output")
.action(
action(
async (
- name: string,
- threadIdArg: string | undefined,
- opts: AppNewCommandOptions,
+ rawApplicationId: string,
+ opts: AppDeleteCommandOptions,
) => {
- const threadId = resolveAppCommandThread({
- threadId: threadIdArg,
- options: opts,
- });
- const template = parseAppTemplate(opts.template);
+ const applicationId = parseApplicationId(rawApplicationId);
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,
+ const appDetail = await unwrap(
+ client.api.v1.apps[":applicationId"].$get({
+ param: { applicationId },
+ }),
+ );
+ if (!opts.yes) {
+ const confirmed = await confirmDestructiveAction(
+ `Delete app "${appDetail.name}" (${applicationId})? This cannot be undone.`,
+ );
+ if (!confirmed) {
+ console.log(`App ${applicationId} deletion cancelled`);
+ return;
+ }
+ }
+ await unwrap<{ ok: true }>(
+ client.api.v1.apps[":applicationId"].$delete({
+ param: { applicationId },
}),
);
- if (outputJson(opts, created)) return;
- printAppDetail(created);
+ const payload = { ok: true, applicationId };
+ if (outputJson(opts, payload)) return;
+ console.log(`App ${applicationId} deleted`);
},
),
);
- app
- .command("list [threadId]")
- .description("List apps in a thread")
- .option("--self", "Target BB_THREAD_ID explicitly")
+ const data = app.command("data").description("Manage global app data");
+
+ data
+ .command("list [path]")
+ .description("List app data entries")
.option("--json", "Print machine-readable JSON output")
.action(
action(
async (
- threadIdArg: string | undefined,
- opts: AppThreadCommandOptions,
+ rawApplicationId: string,
+ prefix: string | undefined,
+ opts: AppDataListCommandOptions,
) => {
- const threadId = resolveAppCommandThread({
- threadId: threadIdArg,
- options: opts,
- });
+ const applicationId = parseApplicationId(rawApplicationId);
const client = createClient(getUrl());
- const apps = await unwrap(
- client.api.v1.threads[":id"].apps.$get({
- param: { id: threadId },
+ const response = await unwrap(
+ client.api.v1.apps[":applicationId"].data.$get({
+ param: { applicationId },
+ query: prefix ? { prefix } : {},
}),
);
- if (outputJson(opts, apps)) return;
- printAppsTable(apps);
+ if (outputJson(opts, response.entries)) return;
+ printDataEntries(response.entries);
},
),
);
- app
- .command("open [threadId]")
- .description("Print an app URL")
- .option("--self", "Target BB_THREAD_ID explicitly")
- .option("--json", "Print machine-readable JSON output")
+ data
+ .command("read ")
+ .description("Read an app data JSON value")
+ .action(
+ action(async (rawApplicationId: string, dataPath: string) => {
+ const applicationId = parseApplicationId(rawApplicationId);
+ const response = await unwrap(
+ fetch(appDataUrl(getUrl(), applicationId, dataPath), {
+ method: "GET",
+ headers: { Accept: "application/json" },
+ }),
+ );
+ console.log(JSON.stringify(response.value, null, 2));
+ }),
+ );
+
+ data
+ .command("write ")
+ .description("Write an app data JSON value")
+ .option("--file ", "Read JSON value from a local file")
+ .option("--stdin", "Read JSON value from stdin")
.action(
action(
async (
- name: string,
- threadIdArg: string | undefined,
- opts: AppThreadCommandOptions,
+ rawApplicationId: string,
+ dataPath: string,
+ opts: AppDataWriteCommandOptions,
) => {
- 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 applicationId = parseApplicationId(rawApplicationId);
+ const value = await readWriteValue(opts);
+ await unwrap(
+ fetch(appDataUrl(getUrl(), applicationId, dataPath), {
+ method: "PUT",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ value }),
}),
);
- const payload: AppOpenPayload = {
- threadId,
- app: appDetail,
- url: appUrl(baseUrl, threadId, appDetail.id),
- };
- if (outputJson(opts, payload)) return;
- console.log(payload.url);
+ console.log(`Wrote ${dataPath}`);
},
),
);
+ data
+ .command("delete ")
+ .description("Delete an app data value")
+ .action(
+ action(async (rawApplicationId: string, dataPath: string) => {
+ const applicationId = parseApplicationId(rawApplicationId);
+ await unwrap<{ ok: true }>(
+ fetch(appDataUrl(getUrl(), applicationId, dataPath), {
+ method: "DELETE",
+ headers: { Accept: "application/json" },
+ }),
+ );
+ console.log(`Deleted ${dataPath}`);
+ }),
+ );
+
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")
+ .command("message ")
+ .description("Send an app message to a target thread")
+ .requiredOption("--target-thread ", "Target thread")
+ .requiredOption("--json ", "JSON payload to send")
.action(
action(
async (
- name: string,
- threadIdArg: string | undefined,
- opts: AppRemoveCommandOptions,
+ rawApplicationId: string,
+ opts: AppMessageCommandOptions,
) => {
- const threadId = resolveAppCommandThread({
- threadId: threadIdArg,
- options: opts,
- });
+ const applicationId = parseApplicationId(rawApplicationId);
+ const payload = parseJsonValueInput(opts.json);
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 },
+ await unwrap(
+ client.api.v1.apps[":applicationId"].message.$post({
+ param: { applicationId },
+ json: {
+ payload,
+ targetThreadId: opts.targetThread,
+ },
}),
);
- const payload = { ok: true, threadId, appId: name };
- if (outputJson(opts, payload)) return;
- console.log(`App ${name} removed`);
+ console.log(`Message sent to ${opts.targetThread}`);
},
),
);
diff --git a/apps/host-daemon/src/app-data-change-reporter.test.ts b/apps/host-daemon/src/app-data-change-reporter.test.ts
index e97fcbf98..e578e6512 100644
--- a/apps/host-daemon/src/app-data-change-reporter.test.ts
+++ b/apps/host-daemon/src/app-data-change-reporter.test.ts
@@ -39,13 +39,8 @@ afterEach(async () => {
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 appDataPath = path.join(rootPath, "apps", "app_status", "data");
+ const statePath = path.join(appDataPath, "state.json");
const posted: HostDaemonAppDataChangePayload[] = [];
const reporter = new AppDataChangeReporter({
logger: createLogger(),
@@ -55,24 +50,24 @@ describe("AppDataChangeReporter", () => {
postAppDataResync: async () => undefined,
});
+ await reporter.replaceTrackedApplications({
+ targets: [{ applicationId: "app_status", appDataPath }],
+ });
await writeJsonFile(statePath, { workers: [] });
await reporter.observe({
- appId: "status",
+ applicationId: "app_status",
+ appDataPath,
path: "state.json",
- threadId: "thr_one",
- threadStoragePath: rootPath,
});
await reporter.observe({
- appId: "status",
+ applicationId: "app_status",
+ appDataPath,
path: "state.json",
- threadId: "thr_one",
- threadStoragePath: rootPath,
});
expect(posted).toHaveLength(1);
expect(posted[0]).toMatchObject({
- appId: "status",
- threadId: "thr_one",
+ applicationId: "app_status",
path: "state.json",
deleted: false,
value: { workers: [] },
@@ -82,13 +77,8 @@ describe("AppDataChangeReporter", () => {
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 appDataPath = path.join(rootPath, "apps", "app_status", "data");
+ const statePath = path.join(appDataPath, "state.json");
const posted: HostDaemonAppDataChangePayload[] = [];
const reporter = new AppDataChangeReporter({
logger: createLogger(),
@@ -99,24 +89,24 @@ describe("AppDataChangeReporter", () => {
});
await writeJsonFile(statePath, { workers: [] });
+ await reporter.replaceTrackedApplications({
+ targets: [{ applicationId: "app_status", appDataPath }],
+ });
await reporter.observe({
- appId: "status",
+ applicationId: "app_status",
+ appDataPath,
path: "state.json",
- threadId: "thr_one",
- threadStoragePath: rootPath,
});
await fs.rm(statePath);
await reporter.observe({
- appId: "status",
+ applicationId: "app_status",
+ appDataPath,
path: "state.json",
- threadId: "thr_one",
- threadStoragePath: rootPath,
});
- expect(posted).toHaveLength(2);
- expect(posted[1]).toEqual({
- appId: "status",
- threadId: "thr_one",
+ expect(posted).toHaveLength(1);
+ expect(posted[0]).toEqual({
+ applicationId: "app_status",
path: "state.json",
deleted: true,
value: null,
@@ -126,15 +116,10 @@ describe("AppDataChangeReporter", () => {
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 appDataPath = path.join(rootPath, "apps", "app_status", "data");
+ const statePath = path.join(appDataPath, "state.json");
const posted: HostDaemonAppDataChangePayload[] = [];
- const resyncs: Array<{ appId: string; threadId: string }> = [];
+ const resyncs: Array<{ applicationId: string }> = [];
const reporter = new AppDataChangeReporter({
logger: createLogger(),
postAppDataChange: async (payload) => {
@@ -146,31 +131,28 @@ describe("AppDataChangeReporter", () => {
});
await writeJsonFile(statePath, { workers: [] });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_one", threadStoragePath: rootPath }],
+ await reporter.replaceTrackedApplications({
+ targets: [{ applicationId: "app_status", appDataPath }],
});
await reporter.observe({
- appId: "status",
+ applicationId: "app_status",
+ appDataPath,
path: "state.json",
- threadId: "thr_one",
- threadStoragePath: rootPath,
});
- expect(resyncs).toEqual([{ appId: "status", threadId: "thr_one" }]);
+ expect(resyncs).toEqual([{ applicationId: "app_status" }]);
expect(posted).toHaveLength(0);
await writeJsonFile(statePath, { workers: [{ id: "worker-1" }] });
await reporter.observe({
- appId: "status",
+ applicationId: "app_status",
+ appDataPath,
path: "state.json",
- threadId: "thr_one",
- threadStoragePath: rootPath,
});
expect(posted).toHaveLength(1);
expect(posted[0]).toMatchObject({
- appId: "status",
- threadId: "thr_one",
+ applicationId: "app_status",
path: "state.json",
deleted: false,
value: { workers: [{ id: "worker-1" }] },
@@ -179,14 +161,9 @@ describe("AppDataChangeReporter", () => {
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 appDataPath = path.join(rootPath, "apps", "app_status", "data");
+ const statePath = path.join(appDataPath, "state.json");
+ const resyncs: Array<{ applicationId: string }> = [];
const reporter = new AppDataChangeReporter({
logger: createLogger(),
postAppDataChange: async () => undefined,
@@ -196,22 +173,22 @@ describe("AppDataChangeReporter", () => {
});
await writeJsonFile(statePath, { workers: [] });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_one", threadStoragePath: rootPath }],
+ await reporter.replaceTrackedApplications({
+ targets: [{ applicationId: "app_status", appDataPath }],
});
await fs.rm(statePath);
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_one", threadStoragePath: rootPath }],
+ await reporter.replaceTrackedApplications({
+ targets: [{ applicationId: "app_status", appDataPath }],
});
expect(resyncs).toEqual([
- { appId: "status", threadId: "thr_one" },
- { appId: "status", threadId: "thr_one" },
+ { applicationId: "app_status" },
+ { applicationId: "app_status" },
]);
});
it("posts requested app data resync hints", async () => {
- const resyncs: Array<{ appId: string; threadId: string }> = [];
+ const resyncs: Array<{ applicationId: string }> = [];
const reporter = new AppDataChangeReporter({
logger: createLogger(),
postAppDataChange: async () => undefined,
@@ -221,10 +198,9 @@ describe("AppDataChangeReporter", () => {
});
await reporter.requestResync({
- appId: "status",
- threadId: "thr_one",
+ applicationId: "app_status",
});
- expect(resyncs).toEqual([{ appId: "status", threadId: "thr_one" }]);
+ expect(resyncs).toEqual([{ applicationId: "app_status" }]);
});
});
diff --git a/apps/host-daemon/src/app-data-change-reporter.ts b/apps/host-daemon/src/app-data-change-reporter.ts
index 449d6fd49..dac92e2f0 100644
--- a/apps/host-daemon/src/app-data-change-reporter.ts
+++ b/apps/host-daemon/src/app-data-change-reporter.ts
@@ -1,17 +1,18 @@
import type { HostDaemonAppDataChangePayload } from "@bb/host-daemon-contract";
import {
appDataPathSchema,
- appIdSchema,
+ applicationIdSchema,
type AppDataPath,
- type AppId,
+ type ApplicationId,
} 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,
+ listApplicationDataFromTargets,
+ readApplicationDataFromTarget,
+ type ApplicationDataEntry,
+ type ApplicationDataTarget,
} from "./app-data-files.js";
interface CreateAppDataChangeReporterOptions {
@@ -25,14 +26,12 @@ interface AppDataCacheEntry {
}
interface AppDataResyncPayload {
- appId: AppId;
- threadId: string;
+ applicationId: ApplicationId;
}
interface AppDataCacheKeyArgs {
- appId: AppId;
+ applicationId: ApplicationId;
path: AppDataPath;
- threadId: string;
}
interface ReportObservedDeleteArgs extends AppDataCacheKeyArgs {
@@ -48,40 +47,34 @@ interface AppDataReporterGenerationArgs {
generation: number;
}
-interface TrackedAppDataThread {
- threadId: string;
- threadStoragePath: string;
+interface ReplaceTrackedApplicationDataTargetsArgs {
+ targets: readonly ApplicationDataTarget[];
}
-interface ReplaceTrackedAppDataThreadsArgs {
- targets: readonly TrackedAppDataThread[];
-}
-
-interface ReprimeThreadArgs {
+interface ReprimeApplicationsArgs {
generation: number;
- target: TrackedAppDataThread;
+ targets: readonly ApplicationDataTarget[];
}
-interface ApplyThreadSnapshotArgs extends ReprimeThreadArgs {
- snapshotEntries: readonly ThreadAppDataEntry[];
- snapshotAppIds: readonly AppId[];
+interface ApplyApplicationSnapshotArgs extends ReprimeApplicationsArgs {
+ snapshotEntries: readonly ApplicationDataEntry[];
+ snapshotApplicationIds: readonly ApplicationId[];
}
interface CachedAppDataKey {
- appId: AppId;
+ applicationId: ApplicationId;
cacheKey: string;
path: AppDataPath;
}
export interface ObserveAppDataChangeArgs {
- appId: AppId;
+ applicationId: ApplicationId;
+ appDataPath: string;
path: AppDataPath;
- threadId: string;
- threadStoragePath: string;
}
function appDataCacheKey(args: AppDataCacheKeyArgs): string {
- return `${args.threadId}\0${args.appId}\0${args.path}`;
+ return `${args.applicationId}\0${args.path}`;
}
function isMissingAppDataEntryError(error: Error): boolean {
@@ -99,38 +92,30 @@ function isNonReportableAppDataReadError(error: Error): boolean {
export class AppDataChangeReporter {
private readonly cache = new Map();
- private readonly appIdsByThreadId = new Map>();
private readonly pendingByCacheKey = new Map>();
- private readonly trackedThreadIds = new Set();
+ private readonly trackedTargets = new Map();
private generation = 0;
constructor(private readonly options: CreateAppDataChangeReporterOptions) {}
- async replaceTrackedThreads(
- args: ReplaceTrackedAppDataThreadsArgs,
+ async replaceTrackedApplications(
+ args: ReplaceTrackedApplicationDataTargetsArgs,
): Promise {
this.generation += 1;
const generation = this.generation;
- const nextThreadIds = new Set(
- args.targets.map((target) => target.threadId),
- );
- this.replaceTrackedThreadIds(nextThreadIds);
+ this.replaceTrackedTargets(args.targets);
this.pendingByCacheKey.clear();
- await Promise.all(
- args.targets.map((target) =>
- this.reprimeThread({
- generation,
- target,
- }),
- ),
- );
+ await this.reprimeApplications({
+ generation,
+ targets: args.targets,
+ });
}
observe(args: ObserveAppDataChangeArgs): Promise {
- this.trackApp({
- appId: args.appId,
- threadId: args.threadId,
- });
+ const target = this.trackedTargets.get(args.applicationId);
+ if (!target) {
+ return Promise.resolve();
+ }
const cacheKey = appDataCacheKey(args);
const generation = this.generation;
const previous = this.pendingByCacheKey.get(cacheKey) ?? Promise.resolve();
@@ -138,16 +123,19 @@ export class AppDataChangeReporter {
.catch(() => undefined)
.then(() =>
this.reportObservedChange({
- change: args,
+ change: {
+ applicationId: args.applicationId,
+ appDataPath: target.appDataPath,
+ path: args.path,
+ },
generation,
}),
)
.catch((error) => {
this.options.logger.warn(
{
- appId: args.appId,
+ applicationId: args.applicationId,
path: args.path,
- threadId: args.threadId,
...runtimeErrorLogFields(error),
},
"Failed to report observed app data change",
@@ -163,10 +151,6 @@ export class AppDataChangeReporter {
}
requestResync(args: AppDataResyncPayload): Promise {
- this.trackApp({
- appId: args.appId,
- threadId: args.threadId,
- });
return this.postResyncHint(args);
}
@@ -174,81 +158,74 @@ export class AppDataChangeReporter {
return args.generation === this.generation;
}
- private replaceTrackedThreadIds(threadIds: ReadonlySet): void {
- for (const threadId of Array.from(this.trackedThreadIds)) {
- if (threadIds.has(threadId)) {
+ private replaceTrackedTargets(
+ targets: readonly ApplicationDataTarget[],
+ ): void {
+ const nextApplicationIds = new Set(
+ targets.map((target) => target.applicationId),
+ );
+ for (const applicationId of Array.from(this.trackedTargets.keys())) {
+ if (nextApplicationIds.has(applicationId)) {
continue;
}
- this.trackedThreadIds.delete(threadId);
- this.appIdsByThreadId.delete(threadId);
- for (const cached of this.cachedKeysForThread({ threadId })) {
+ this.trackedTargets.delete(applicationId);
+ for (const cached of this.cachedKeysForApplication({ applicationId })) {
this.cache.delete(cached.cacheKey);
}
}
- for (const threadId of threadIds) {
- this.trackedThreadIds.add(threadId);
+ for (const target of targets) {
+ this.trackedTargets.set(target.applicationId, target);
}
}
- 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`;
+ private cachedKeysForApplication(args: {
+ applicationId: ApplicationId;
+ }): CachedAppDataKey[] {
+ const prefix = `${args.applicationId}\0`;
return Array.from(this.cache.keys())
.filter((cacheKey) => cacheKey.startsWith(prefix))
.map((cacheKey) => {
- const [, rawAppId, rawDataPath] = cacheKey.split("\0");
+ const [rawApplicationId, rawDataPath] = cacheKey.split("\0");
return {
- appId: appIdSchema.parse(rawAppId),
+ applicationId: applicationIdSchema.parse(rawApplicationId),
path: appDataPathSchema.parse(rawDataPath),
cacheKey,
};
});
}
- private async reprimeThread(args: ReprimeThreadArgs): Promise {
+ private async reprimeApplications(
+ args: ReprimeApplicationsArgs,
+ ): Promise {
try {
- const snapshot = await listThreadAppDataFromRoot({
- rootPath: args.target.threadStoragePath,
+ const snapshot = await listApplicationDataFromTargets({
+ targets: args.targets,
});
if (!this.isCurrentGeneration({ generation: args.generation })) {
return;
}
- const previousAppIds = new Set(
- this.appIdsByThreadId.get(args.target.threadId) ?? [],
- );
- await this.applyThreadSnapshot({
+ const previousApplicationIds = new Set(this.trackedTargets.keys());
+ await this.applyApplicationSnapshot({
...args,
snapshotEntries: snapshot.entries,
- snapshotAppIds: snapshot.appIds,
+ snapshotApplicationIds: snapshot.applicationIds,
});
- const resyncAppIds = new Set([
- ...previousAppIds,
- ...snapshot.appIds,
+ const resyncApplicationIds = new Set([
+ ...previousApplicationIds,
+ ...snapshot.applicationIds,
]);
await Promise.all(
- Array.from(resyncAppIds)
+ Array.from(resyncApplicationIds)
.sort((left, right) => left.localeCompare(right))
- .map((appId) =>
+ .map((applicationId) =>
this.postResyncHint({
- appId,
- threadId: args.target.threadId,
+ applicationId,
}),
),
);
} catch (error) {
this.options.logger.warn(
{
- threadId: args.target.threadId,
- threadStoragePath: args.target.threadStoragePath,
...runtimeErrorLogFields(error),
},
"Failed to reprime app data change cache",
@@ -256,30 +233,26 @@ export class AppDataChangeReporter {
}
}
- private async applyThreadSnapshot(
- args: ApplyThreadSnapshotArgs,
+ private async applyApplicationSnapshot(
+ args: ApplyApplicationSnapshotArgs,
): 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,
+ applicationId: snapshotEntry.applicationId,
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);
+ for (const applicationId of args.snapshotApplicationIds) {
+ for (const cached of this.cachedKeysForApplication({ applicationId })) {
+ if (!seenCacheKeys.has(cached.cacheKey)) {
+ this.cache.delete(cached.cacheKey);
+ }
}
}
}
@@ -290,8 +263,7 @@ export class AppDataChangeReporter {
} catch (error) {
this.options.logger.warn(
{
- appId: args.appId,
- threadId: args.threadId,
+ applicationId: args.applicationId,
...runtimeErrorLogFields(error),
},
"Failed to report app data resync hint",
@@ -305,14 +277,17 @@ export class AppDataChangeReporter {
if (!this.isCurrentGeneration({ generation: args.generation })) {
return;
}
+ const target = this.trackedTargets.get(args.change.applicationId);
+ if (!target) {
+ return;
+ }
const cacheKey = appDataCacheKey(args.change);
const previous = this.cache.get(cacheKey);
try {
- const entry = await readAppDataFromRoot({
- appId: args.change.appId,
+ const entry = await readApplicationDataFromTarget({
path: args.change.path,
- rootPath: args.change.threadStoragePath,
+ target,
});
if (!this.isCurrentGeneration({ generation: args.generation })) {
return;
@@ -321,8 +296,7 @@ export class AppDataChangeReporter {
return;
}
await this.options.postAppDataChange({
- appId: args.change.appId,
- threadId: args.change.threadId,
+ applicationId: args.change.applicationId,
path: entry.path,
deleted: false,
value: entry.value,
@@ -331,27 +305,21 @@ export class AppDataChangeReporter {
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,
+ applicationId: args.change.applicationId,
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,
+ applicationId: args.change.applicationId,
path: args.change.path,
- threadId: args.change.threadId,
...runtimeErrorLogFields(error),
},
"Ignoring unreadable observed app data file",
@@ -368,9 +336,12 @@ export class AppDataChangeReporter {
if (!this.isCurrentGeneration({ generation: args.generation })) {
return;
}
+ const cacheKey = appDataCacheKey(args);
+ if (!this.cache.has(cacheKey)) {
+ return;
+ }
await this.options.postAppDataChange({
- appId: args.appId,
- threadId: args.threadId,
+ applicationId: args.applicationId,
path: args.path,
deleted: true,
value: null,
@@ -379,10 +350,6 @@ export class AppDataChangeReporter {
if (!this.isCurrentGeneration({ generation: args.generation })) {
return;
}
- this.trackApp({
- appId: args.appId,
- threadId: args.threadId,
- });
- this.cache.delete(appDataCacheKey(args));
+ this.cache.delete(cacheKey);
}
}
diff --git a/apps/host-daemon/src/app-data-files.test.ts b/apps/host-daemon/src/app-data-files.test.ts
index cab6c6dc0..056ba1045 100644
--- a/apps/host-daemon/src/app-data-files.test.ts
+++ b/apps/host-daemon/src/app-data-files.test.ts
@@ -1,20 +1,73 @@
import { randomUUID } from "node:crypto";
+import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
-import { describe, expect, it } from "vitest";
-import { listThreadAppDataFromRoot } from "./app-data-files.js";
+import { afterEach, describe, expect, it } from "vitest";
+import { listApplicationDataTargetsFromRoot } from "./app-data-files.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;
+}
+
+afterEach(async () => {
+ await Promise.all(
+ tempDirs
+ .splice(0)
+ .map((dir) => fs.rm(dir, { recursive: true, force: true })),
+ );
+});
describe("app data files", () => {
- it("treats a missing thread storage root as an empty snapshot", async () => {
- const missingRoot = path.join(
- os.tmpdir(),
- `bb-missing-thread-storage-${randomUUID()}`,
- );
+ it("treats a missing apps root as an empty target list", async () => {
+ const missingRoot = path.join(os.tmpdir(), `bb-missing-apps-${randomUUID()}`);
await expect(
- listThreadAppDataFromRoot({
- rootPath: missingRoot,
+ listApplicationDataTargetsFromRoot({
+ appsRootPath: missingRoot,
}),
- ).resolves.toEqual({ appIds: [], entries: [] });
+ ).resolves.toEqual([]);
+ });
+
+ it("lists valid global application data targets", async () => {
+ const dataDir = await makeTempDir("bb-app-data-files-");
+ const appsRootPath = path.join(dataDir, "apps");
+ const applicationPath = path.join(appsRootPath, "app_valid");
+ await fs.mkdir(path.join(applicationPath, "data"), { recursive: true });
+ await fs.writeFile(
+ path.join(applicationPath, "manifest.json"),
+ JSON.stringify({
+ manifestVersion: 1,
+ id: "app_valid",
+ name: "Valid App",
+ entry: "index.html",
+ }),
+ "utf8",
+ );
+ await fs.mkdir(path.join(appsRootPath, "app_broken"), {
+ recursive: true,
+ });
+ await fs.writeFile(
+ path.join(appsRootPath, "app_broken", "manifest.json"),
+ JSON.stringify({
+ manifestVersion: 1,
+ id: "app_other",
+ name: "Broken App",
+ }),
+ "utf8",
+ );
+
+ const resolvedApplicationPath = await fs.realpath(applicationPath);
+ await expect(
+ listApplicationDataTargetsFromRoot({ appsRootPath }),
+ ).resolves.toEqual([
+ {
+ applicationId: "app_valid",
+ appDataPath: path.join(resolvedApplicationPath, "data"),
+ },
+ ]);
});
});
diff --git a/apps/host-daemon/src/app-data-files.ts b/apps/host-daemon/src/app-data-files.ts
index 2e1d5fb86..532ff6900 100644
--- a/apps/host-daemon/src/app-data-files.ts
+++ b/apps/host-daemon/src/app-data-files.ts
@@ -2,12 +2,17 @@ import { createHash } from "node:crypto";
import type { Stats } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
+import {
+ resolveApplicationDataPath,
+ resolveApplicationManifestPath,
+ resolveAppsRootPath,
+} from "@bb/config/app-storage-paths";
import {
appDataPathSchema,
- appIdSchema,
+ applicationIdSchema,
jsonValueSchema,
type AppDataPath,
- type AppId,
+ type ApplicationId,
type JsonValue,
} from "@bb/domain";
import {
@@ -26,25 +31,24 @@ export interface AppDataEntry {
version: string;
}
-export interface ThreadAppDataEntry {
- appId: AppId;
+export interface ApplicationDataEntry {
+ applicationId: ApplicationId;
entry: AppDataEntry;
}
-export interface ThreadAppDataSnapshot {
- appIds: AppId[];
- entries: ThreadAppDataEntry[];
+export interface ApplicationDataSnapshot {
+ applicationIds: ApplicationId[];
+ entries: ApplicationDataEntry[];
}
-interface AppDataTargetArgs {
- appId: AppId;
- path: AppDataPath;
- rootPath: string;
+export interface ApplicationDataTarget {
+ applicationId: ApplicationId;
+ appDataPath: string;
}
-interface ResolveAppDataRootArgs {
- appId: AppId;
- rootPath: string;
+interface AppDataTargetArgs {
+ path: AppDataPath;
+ target: ApplicationDataTarget;
}
interface ReadAppDataJsonArgs {
@@ -57,8 +61,12 @@ interface ListAppDataFilesArgs {
currentDirectory: string;
}
-interface ListThreadAppDataArgs {
- rootPath: string;
+interface ListApplicationDataArgs {
+ targets: readonly ApplicationDataTarget[];
+}
+
+interface ListApplicationDataTargetsFromRootArgs {
+ appsRootPath: string;
}
function sha256(bytes: Buffer): string {
@@ -91,41 +99,68 @@ function parseJsonValue(args: ReadAppDataJsonArgs): JsonValue {
}
}
+function isIgnoredApplicationStorageEntry(entryName: string): boolean {
+ return (
+ entryName.startsWith(".tmp-app_") || entryName.startsWith(".delete-app_")
+ );
+}
+
+async function isValidApplicationManifest(
+ appsRootPath: string,
+ applicationId: ApplicationId,
+): Promise {
+ const dataDir = path.dirname(appsRootPath);
+ try {
+ const rawManifest = JSON.parse(
+ await fs.readFile(
+ resolveApplicationManifestPath(dataDir, applicationId),
+ "utf8",
+ ),
+ );
+ return (
+ rawManifest !== null &&
+ typeof rawManifest === "object" &&
+ "id" in rawManifest &&
+ rawManifest.id === applicationId &&
+ "name" in rawManifest &&
+ typeof rawManifest.name === "string" &&
+ rawManifest.name.trim().length > 0
+ );
+ } catch {
+ return false;
+ }
+}
+
async function resolveAppDataRoot(
- args: ResolveAppDataRootArgs,
+ target: ApplicationDataTarget,
): Promise {
- const appId = appIdSchema.parse(args.appId);
- if (!path.isAbsolute(args.rootPath)) {
- throw new CommandDispatchError("invalid_path", "rootPath must be absolute");
+ if (!path.isAbsolute(target.appDataPath)) {
+ throw new CommandDispatchError(
+ "invalid_path",
+ "appDataPath 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,
+ path: target.appDataPath,
});
- if (!isPathWithinRoot(resolvedAppDataPath, threadStoragePath)) {
+ const appRootPath = path.dirname(resolvedAppDataPath);
+ if (!isPathWithinRoot(resolvedAppDataPath, appRootPath)) {
throw new CommandDispatchError(
"invalid_path",
- "App data path escapes thread storage root",
+ "App data path escapes app root",
);
}
return resolvedAppDataPath;
}
-export async function readAppDataFromRoot(
+export async function readApplicationDataFromTarget(
args: AppDataTargetArgs,
): Promise {
const appDataPath = appDataPathSchema.parse(args.path);
let appDataRoot: string;
try {
- appDataRoot = await resolveAppDataRoot({
- appId: args.appId,
- rootPath: args.rootPath,
- });
+ appDataRoot = await resolveAppDataRoot(args.target);
} catch (error) {
if (isFsErrorWithCode(error, "ENOENT")) {
throw new ExpectedCommandDispatchError(
@@ -204,56 +239,58 @@ async function listAppDataFilePaths(
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");
- }
- let threadStoragePath: string;
+export async function listApplicationDataTargetsFromRoot(
+ args: ListApplicationDataTargetsFromRootArgs,
+): Promise {
+ let appsRootPath;
try {
- threadStoragePath = await resolveNonSymlinkDirectoryPath({
- description: "Thread storage root",
- path: args.rootPath,
+ appsRootPath = await resolveNonSymlinkDirectoryPath({
+ description: "Apps root",
+ path: args.appsRootPath,
});
} catch (error) {
if (isFsErrorWithCode(error, "ENOENT")) {
- // Archived/deleted thread storage can be cleaned up while the daemon is
- // still reconciling tracked targets. A missing root is an empty snapshot.
- return { appIds: [], entries: [] };
+ return [];
}
throw error;
}
- 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: [] };
+ const entries = await fs.readdir(appsRootPath, { withFileTypes: true });
+ const dataDir = path.dirname(appsRootPath);
+ const targets: ApplicationDataTarget[] = [];
+ for (const entry of entries) {
+ if (
+ !entry.isDirectory() ||
+ isIgnoredApplicationStorageEntry(entry.name)
+ ) {
+ continue;
}
- throw error;
- }
-
- const appIds: AppId[] = [];
- const snapshotEntries: ThreadAppDataEntry[] = [];
- for (const directory of appDirectories) {
- if (!directory.isDirectory()) {
+ const parsed = applicationIdSchema.safeParse(entry.name);
+ if (!parsed.success) {
continue;
}
- const parsedAppId = appIdSchema.safeParse(directory.name);
- if (!parsedAppId.success) {
+ if (!(await isValidApplicationManifest(appsRootPath, parsed.data))) {
continue;
}
- const appId = parsedAppId.data;
- appIds.push(appId);
- const appDataRoot = path.join(appsRoot, appId, "data");
+ targets.push({
+ applicationId: parsed.data,
+ appDataPath: resolveApplicationDataPath(dataDir, parsed.data),
+ });
+ }
+ return targets.sort((left, right) =>
+ left.applicationId.localeCompare(right.applicationId),
+ );
+}
+
+export async function listApplicationDataFromTargets(
+ args: ListApplicationDataArgs,
+): Promise {
+ const applicationIds: ApplicationId[] = [];
+ const snapshotEntries: ApplicationDataEntry[] = [];
+ for (const target of args.targets) {
+ applicationIds.push(target.applicationId);
let resolvedAppDataRoot;
try {
- resolvedAppDataRoot = await resolveNonSymlinkDirectoryPath({
- description: "App data directory",
- path: appDataRoot,
- });
+ resolvedAppDataRoot = await resolveAppDataRoot(target);
} catch (error) {
if (isFsErrorWithCode(error, "ENOENT")) {
continue;
@@ -266,25 +303,30 @@ export async function listThreadAppDataFromRoot(
});
for (const dataPath of dataPaths) {
snapshotEntries.push({
- appId,
- entry: await readAppDataFromRoot({
- appId,
+ applicationId: target.applicationId,
+ entry: await readApplicationDataFromTarget({
path: dataPath,
- rootPath: threadStoragePath,
+ target,
}),
});
}
}
- appIds.sort((left, right) => left.localeCompare(right));
+ applicationIds.sort((left, right) => left.localeCompare(right));
snapshotEntries.sort((left, right) => {
- const appOrder = left.appId.localeCompare(right.appId);
+ const appOrder = left.applicationId.localeCompare(right.applicationId);
return appOrder === 0
? left.entry.path.localeCompare(right.entry.path)
: appOrder;
});
return {
- appIds,
+ applicationIds,
entries: snapshotEntries,
};
}
+
+export async function ensureAppsRootPath(dataDir: string): Promise {
+ const appsRootPath = resolveAppsRootPath(dataDir);
+ await fs.mkdir(appsRootPath, { recursive: true });
+ return appsRootPath;
+}
diff --git a/apps/host-daemon/src/app.test.ts b/apps/host-daemon/src/app.test.ts
index cd5b87545..04a210909 100644
--- a/apps/host-daemon/src/app.test.ts
+++ b/apps/host-daemon/src/app.test.ts
@@ -134,6 +134,7 @@ function createFetchRecorder(
heartbeatIntervalMs: 30000,
leaseTimeoutMs: 90000,
trackedThreadTargets: [],
+ trackedApplicationDataTargets: [],
retiredEnvironmentIds: args.retiredEnvironmentIds ?? [],
},
{ status: 201 },
@@ -573,6 +574,7 @@ describe("createHostDaemonApp", () => {
heartbeatIntervalMs: 30000,
leaseTimeoutMs: 90000,
trackedThreadTargets: [],
+ trackedApplicationDataTargets: [],
retiredEnvironmentIds: [],
},
{ status: 201 },
@@ -651,6 +653,7 @@ describe("createHostDaemonApp", () => {
shutdown: vi.fn(async () => undefined),
} satisfies AgentRuntime;
const hostWatcher = {
+ watchApplicationStorageRoot: vi.fn(() => () => undefined),
watchWorkspace: vi.fn(() => stopWatchingStatus),
watchThreadStorageRoot: vi.fn(() => () => undefined),
} satisfies HostWatcher;
diff --git a/apps/host-daemon/src/app.ts b/apps/host-daemon/src/app.ts
index 378f99482..2474ec04c 100644
--- a/apps/host-daemon/src/app.ts
+++ b/apps/host-daemon/src/app.ts
@@ -1,4 +1,3 @@
-import path from "node:path";
import { CommandRouter } from "./command-router.js";
import { createDaemon, type HostDaemon } from "./daemon.js";
import {
@@ -34,6 +33,10 @@ import {
import { createReplayCaptureService } from "@bb/replay-capture/writer";
import { createServerClient } from "./server-client.js";
import { AppDataChangeReporter } from "./app-data-change-reporter.js";
+import {
+ ensureAppsRootPath,
+ listApplicationDataTargetsFromRoot,
+} from "./app-data-files.js";
import {
ServerConnection,
type CreateReconnectingWebSocket,
@@ -325,6 +328,7 @@ export async function createHostDaemonApp(
? { env: { BB_THREAD_STORAGE: options.threadStorageRootPath } }
: {},
);
+ const appsRootPath = await ensureAppsRootPath(options.dataDir);
const sessionState: SessionState = {
value: null,
};
@@ -371,6 +375,12 @@ export async function createHostDaemonApp(
postAppDataResync: (payload) => serverClient.postAppDataResync(payload),
});
+ async function refreshTrackedApplicationDataTargets(): Promise {
+ const targets = await listApplicationDataTargetsFromRoot({ appsRootPath });
+ runtimeManager.replaceTrackedApplicationDataTargets(targets);
+ await appDataChangeReporter.replaceTrackedApplications({ targets });
+ }
+
function buildInteractiveInterruptKey(
request: PendingInteractiveInterruptRequest,
): string {
@@ -495,6 +505,7 @@ export async function createHostDaemonApp(
createRuntime: options.createRuntime,
hostWatcher: options.hostWatcher,
shellEnv: options.runtimeShellEnv,
+ appsRootPath,
onCapture: (entry) => {
replayCapture?.recordRuntimeCaptureEntry(entry);
},
@@ -531,12 +542,32 @@ export async function createHostDaemonApp(
change: "thread-storage-changed",
});
},
- onThreadAppDataChanged: (change) => {
+ onApplicationStorageTargetsChanged: () => {
+ void refreshTrackedApplicationDataTargets().catch((error) => {
+ options.logger.warn(
+ {
+ appsRootPath,
+ ...runtimeErrorLogFields(error),
+ },
+ "Failed to refresh tracked app data targets",
+ );
+ });
+ },
+ onApplicationDataChanged: (change) => {
void appDataChangeReporter.observe(change);
},
- onThreadAppDataResync: (change) => {
+ onApplicationDataResync: (change) => {
void appDataChangeReporter.requestResync(change);
},
+ onApplicationStorageWatchError: ({ error }) => {
+ options.logger.warn(
+ {
+ rootPath: error.rootPath,
+ watchError: error.message,
+ },
+ "Application storage watch unavailable; retrying in background",
+ );
+ },
onThreadStorageWatchError: ({ error }) => {
options.logger.warn(
{
@@ -742,11 +773,11 @@ export async function createHostDaemonApp(
runtimeManager.replaceTrackedThreadStorageTargets(
session.trackedThreadTargets,
);
- void appDataChangeReporter.replaceTrackedThreads({
- targets: session.trackedThreadTargets.map((target) => ({
- threadId: target.threadId,
- threadStoragePath: path.join(threadStorageRootPath, target.threadId),
- })),
+ runtimeManager.replaceTrackedApplicationDataTargets(
+ session.trackedApplicationDataTargets,
+ );
+ void appDataChangeReporter.replaceTrackedApplications({
+ targets: session.trackedApplicationDataTargets,
});
void eventBuffer.flush().catch((error) => {
options.logger.warn(
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 7e3d356b1..d189004af 100644
--- a/apps/host-daemon/src/command-handlers/host-files.test.ts
+++ b/apps/host-daemon/src/command-handlers/host-files.test.ts
@@ -249,19 +249,19 @@ describe("writeHostRelativeFile and deleteHostRelativeFile", () => {
const result = await writeHostRelativeFile({
type: "host.write_file_relative",
rootPath,
- path: "apps/status/data/state.json",
+ path: "data/state.json",
dotfiles: "deny",
content,
contentEncoding: "utf8",
});
expect(result).toMatchObject({
- path: "apps/status/data/state.json",
+ path: "data/state.json",
hash: createHash("sha256").update(content).digest("hex"),
sizeBytes: Buffer.byteLength(content),
});
await expect(
- fs.readFile(path.join(rootPath, "apps/status/data/state.json"), "utf8"),
+ fs.readFile(path.join(rootPath, "data/state.json"), "utf8"),
).resolves.toBe(content);
});
diff --git a/apps/host-daemon/src/runtime-manager.test.ts b/apps/host-daemon/src/runtime-manager.test.ts
index 97b639501..14dbde4a7 100644
--- a/apps/host-daemon/src/runtime-manager.test.ts
+++ b/apps/host-daemon/src/runtime-manager.test.ts
@@ -8,6 +8,7 @@ import type { ThreadEvent } from "@bb/domain";
import { threadScope, turnScope } from "@bb/domain";
import type {
HostWatcher,
+ WatchApplicationStorageRootArgs,
ThreadStorageWatchError,
WatchThreadStorageRootArgs,
WatchWorkspaceArgs,
@@ -53,6 +54,9 @@ type WatchWorkspaceImplementation = (
type WatchThreadStorageRootImplementation = (
args: WatchThreadStorageRootArgs,
) => StopWatchingPathChanges;
+type WatchApplicationStorageRootImplementation = (
+ args: WatchApplicationStorageRootArgs,
+) => StopWatchingPathChanges;
interface RunGitOptions {
cwd: string;
}
@@ -202,6 +206,7 @@ function createFakeWorkspace(path: string) {
function createFakeHostWatcher(
args: {
+ watchApplicationStorageRootImplementation?: WatchApplicationStorageRootImplementation;
watchThreadStorageRootImplementation?: WatchThreadStorageRootImplementation;
watchWorkspaceImplementation?: WatchWorkspaceImplementation;
} = {},
@@ -212,13 +217,20 @@ function createFakeHostWatcher(
const watchThreadStorageRoot = vi.fn(
args.watchThreadStorageRootImplementation ?? ((_args) => () => undefined),
);
+ const watchApplicationStorageRoot =
+ vi.fn(
+ args.watchApplicationStorageRootImplementation ??
+ ((_args) => () => undefined),
+ );
const hostWatcher = {
+ watchApplicationStorageRoot,
watchWorkspace,
watchThreadStorageRoot,
} satisfies HostWatcher;
return {
hostWatcher,
+ watchApplicationStorageRoot,
watchThreadStorageRoot,
watchWorkspace,
};
@@ -1086,15 +1098,11 @@ describe("RuntimeManager", () => {
},
});
const onThreadStorageChanged = vi.fn();
- const onThreadAppDataChanged = vi.fn();
- const onThreadAppDataResync = vi.fn();
const manager = new RuntimeManager({
hostWatcher,
provisionWorkspace: createProvisionWorkspaceMock("/tmp/env-storage"),
createRuntime: vi.fn(() => createFakeRuntime()),
onThreadStorageChanged,
- onThreadAppDataChanged,
- onThreadAppDataResync,
threadStorageRootPath: "/tmp/bb-data/thread-storage",
});
@@ -1115,20 +1123,6 @@ describe("RuntimeManager", () => {
environmentId: "env-storage",
threadId: "thread-2",
});
- watchThreadStorageRootArgs?.onChange({
- kind: "thread-app-data-changed",
- appId: "status",
- environmentId: "env-storage",
- path: "state.json",
- threadId: "thread-1",
- });
- watchThreadStorageRootArgs?.onChange({
- kind: "thread-app-data-resync",
- appId: "status",
- environmentId: "env-storage",
- threadId: "thread-1",
- });
-
expect(watchThreadStorageRoot).toHaveBeenCalledTimes(1);
expect(watchThreadStorageRoot).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1144,22 +1138,82 @@ describe("RuntimeManager", () => {
threadId: "thread-2",
});
expect(onThreadStorageChanged).toHaveBeenCalledTimes(2);
- expect(onThreadAppDataChanged).toHaveBeenCalledWith({
- appId: "status",
- environmentId: "env-storage",
+ expect(stopWatchingPathChanges).not.toHaveBeenCalled();
+
+ await manager.destroyEnvironment("env-storage");
+
+ expect(stopWatchingPathChanges).toHaveBeenCalledTimes(1);
+ });
+
+ it("installs one shared application storage root watcher for app data", async () => {
+ const stopWatchingPathChanges = vi.fn(() => undefined);
+ let watchApplicationStorageRootArgs:
+ | WatchApplicationStorageRootArgs
+ | undefined;
+ const { hostWatcher, watchApplicationStorageRoot } = createFakeHostWatcher({
+ watchApplicationStorageRootImplementation: (args) => {
+ watchApplicationStorageRootArgs = args;
+ return stopWatchingPathChanges;
+ },
+ });
+ const onApplicationStorageTargetsChanged = vi.fn();
+ const onApplicationDataChanged = vi.fn();
+ const onApplicationDataResync = vi.fn();
+ const manager = new RuntimeManager({
+ appsRootPath: "/tmp/bb-data/apps",
+ hostWatcher,
+ provisionWorkspace: createProvisionWorkspaceMock("/tmp/env-storage"),
+ createRuntime: vi.fn(() => createFakeRuntime()),
+ onApplicationStorageTargetsChanged,
+ onApplicationDataChanged,
+ onApplicationDataResync,
+ });
+
+ manager.replaceTrackedApplicationDataTargets([
+ {
+ applicationId: "app_status",
+ appDataPath: "/tmp/bb-data/apps/app_status/data",
+ },
+ ]);
+
+ expect(watchApplicationStorageRoot).toHaveBeenCalledTimes(1);
+ expect(watchApplicationStorageRoot).toHaveBeenCalledWith(
+ expect.objectContaining({
+ appsRootPath: "/tmp/bb-data/apps",
+ }),
+ );
+ expect(
+ watchApplicationStorageRootArgs?.resolveApplicationTarget("app_status"),
+ ).toEqual({
+ applicationId: "app_status",
+ appDataPath: "/tmp/bb-data/apps/app_status/data",
+ });
+
+ watchApplicationStorageRootArgs?.onChange({
+ kind: "application-storage-targets-changed",
+ });
+ watchApplicationStorageRootArgs?.onChange({
+ kind: "application-data-changed",
+ applicationId: "app_status",
+ appDataPath: "/tmp/bb-data/apps/app_status/data",
path: "state.json",
- threadId: "thread-1",
- threadStoragePath: "/tmp/bb-data/thread-storage/thread-1",
});
- expect(onThreadAppDataResync).toHaveBeenCalledWith({
- appId: "status",
- environmentId: "env-storage",
- threadId: "thread-1",
- threadStoragePath: "/tmp/bb-data/thread-storage/thread-1",
+ watchApplicationStorageRootArgs?.onChange({
+ kind: "application-data-resync",
+ applicationId: "app_status",
});
- expect(stopWatchingPathChanges).not.toHaveBeenCalled();
- await manager.destroyEnvironment("env-storage");
+ expect(onApplicationStorageTargetsChanged).toHaveBeenCalledTimes(1);
+ expect(onApplicationDataChanged).toHaveBeenCalledWith({
+ applicationId: "app_status",
+ appDataPath: "/tmp/bb-data/apps/app_status/data",
+ path: "state.json",
+ });
+ expect(onApplicationDataResync).toHaveBeenCalledWith({
+ applicationId: "app_status",
+ });
+
+ await manager.shutdownAll();
expect(stopWatchingPathChanges).toHaveBeenCalledTimes(1);
});
diff --git a/apps/host-daemon/src/runtime-manager.ts b/apps/host-daemon/src/runtime-manager.ts
index aea4290fa..ce03a4a17 100644
--- a/apps/host-daemon/src/runtime-manager.ts
+++ b/apps/host-daemon/src/runtime-manager.ts
@@ -8,7 +8,7 @@ import {
} from "@bb/agent-runtime";
import type {
AppDataPath,
- AppId,
+ ApplicationId,
PendingInteractionCreate,
PendingInteractionResolution,
ThreadEvent,
@@ -23,9 +23,12 @@ import type {
HostDaemonActiveThread,
HostDaemonEnvironmentChange,
HostDaemonLoadedEnvironment,
+ HostDaemonTrackedApplicationDataTarget,
HostDaemonTrackedThreadTarget,
} from "@bb/host-daemon-contract";
import type {
+ ApplicationDataWatchTarget,
+ ApplicationStorageWatchError,
HostWatcher,
ThreadStorageWatchError,
WorkspaceWatchError,
@@ -56,6 +59,11 @@ interface ThreadStorageTarget {
threadId: string;
}
+interface ApplicationDataTarget {
+ applicationId: ApplicationId;
+ appDataPath: string;
+}
+
interface WorkspaceWatchState {
lastLocalFingerprint: string | null;
lastSharedRefsFingerprint: string | null;
@@ -156,19 +164,14 @@ export interface RuntimeEntry {
threads: Map;
}
-export interface ThreadAppDataChangedNotification {
- appId: AppId;
- environmentId: string;
+export interface ApplicationDataChangedNotification {
+ applicationId: ApplicationId;
+ appDataPath: string;
path: AppDataPath;
- threadId: string;
- threadStoragePath: string;
}
-export interface ThreadAppDataResyncNotification {
- appId: AppId;
- environmentId: string;
- threadId: string;
- threadStoragePath: string;
+export interface ApplicationDataResyncNotification {
+ applicationId: ApplicationId;
}
export interface EnsureEnvironmentArgs {
@@ -199,8 +202,13 @@ export interface RuntimeManagerOptions {
environmentId: string;
threadId: string;
}) => void;
- onThreadAppDataChanged?: (args: ThreadAppDataChangedNotification) => void;
- onThreadAppDataResync?: (args: ThreadAppDataResyncNotification) => void;
+ appsRootPath?: string | null;
+ onApplicationStorageTargetsChanged?: () => void;
+ onApplicationDataChanged?: (args: ApplicationDataChangedNotification) => void;
+ onApplicationDataResync?: (args: ApplicationDataResyncNotification) => void;
+ onApplicationStorageWatchError?: (args: {
+ error: ApplicationStorageWatchError;
+ }) => void;
onThreadStorageWatchError?: (args: {
error: ThreadStorageWatchError;
}) => void;
@@ -218,6 +226,7 @@ export interface RuntimeManagerOptions {
}
interface RuntimeWorkspaceWriteRootsArgs {
+ appsRootPath: string | null | undefined;
threadStorageRootPath: string | null | undefined;
workspaceRoots: readonly string[];
}
@@ -233,10 +242,15 @@ export class RuntimeManager {
string,
ThreadStorageTarget
>();
+ private readonly trackedApplicationDataTargets = new Map<
+ ApplicationId,
+ ApplicationDataTarget
+ >();
private providerMaintenanceRuntime: AgentRuntime | null = null;
private pendingProviderMaintenanceRuntime: Promise | null =
null;
private managedShellEnv: NonNullable = {};
+ private stopWatchingApplicationStorageRoot: StopWatching = STOP_WATCHING;
private stopWatchingThreadStorageRoot: StopWatching = STOP_WATCHING;
constructor(private readonly options: RuntimeManagerOptions = {}) {
@@ -244,12 +258,16 @@ export class RuntimeManager {
this.hostWatcher = options.hostWatcher;
this.provisionWorkspace = options.provisionWorkspace ?? provisionWorkspace;
this.baseShellEnv = { ...(options.shellEnv ?? {}) };
+ this.ensureApplicationStorageWatcher();
}
private runtimeWorkspaceWriteRoots(
args: RuntimeWorkspaceWriteRootsArgs,
): string[] {
const roots = [...args.workspaceRoots];
+ if (args.appsRootPath) {
+ roots.push(args.appsRootPath);
+ }
if (args.threadStorageRootPath) {
// Provider runtimes are environment-scoped and may host multiple threads.
// BB_THREAD_STORAGE still points agents at their own thread subdirectory;
@@ -515,6 +533,19 @@ export class RuntimeManager {
this.stopWatchingThreadStorageIfNoTrackedThreads();
}
+ replaceTrackedApplicationDataTargets(
+ targets: readonly HostDaemonTrackedApplicationDataTarget[],
+ ): void {
+ this.trackedApplicationDataTargets.clear();
+ for (const target of targets) {
+ this.trackedApplicationDataTargets.set(target.applicationId, {
+ applicationId: target.applicationId,
+ appDataPath: target.appDataPath,
+ });
+ }
+ this.ensureApplicationStorageWatcher();
+ }
+
async openWorkspace(path: string): Promise {
return this.provisionWorkspace({
workspaceProvisionType: "unmanaged",
@@ -682,6 +713,7 @@ export class RuntimeManager {
this.entries.clear();
this.pendingEntries.clear();
this.trackedThreadStorageTargets.clear();
+ this.trackedApplicationDataTargets.clear();
for (const entry of entries) {
await this.stopWatchingStatus(entry);
@@ -702,6 +734,8 @@ export class RuntimeManager {
}
await this.stopWatchingThreadStorageRoot();
this.stopWatchingThreadStorageRoot = STOP_WATCHING;
+ await this.stopWatchingApplicationStorageRoot();
+ this.stopWatchingApplicationStorageRoot = STOP_WATCHING;
}
private buildUnexpectedProviderExitEvents(
@@ -756,7 +790,9 @@ export class RuntimeManager {
let runtime: AgentRuntime | null = null;
runtime = this.createRuntime({
workspacePath,
- additionalWorkspaceWriteRoots: [],
+ additionalWorkspaceWriteRoots: this.options.appsRootPath
+ ? [this.options.appsRootPath]
+ : [],
shellEnv: this.getShellEnv(),
threadStorageRootPath: this.options.threadStorageRootPath ?? undefined,
bridgeBundleDir: this.options.bridgeBundleDir,
@@ -815,6 +851,7 @@ export class RuntimeManager {
workspace.getAdditionalWorkspaceWriteRoots(),
]);
const additionalWorkspaceWriteRoots = this.runtimeWorkspaceWriteRoots({
+ appsRootPath: this.options.appsRootPath,
threadStorageRootPath: this.options.threadStorageRootPath,
workspaceRoots: workspaceWriteRoots,
});
@@ -956,32 +993,52 @@ export class RuntimeManager {
threadId: event.threadId,
});
}
- if (event.kind === "thread-app-data-changed") {
- this.options.onThreadAppDataChanged?.({
- appId: event.appId,
- environmentId: event.environmentId,
+ },
+ onWatchError: (error) => {
+ this.options.onThreadStorageWatchError?.({
+ error,
+ });
+ },
+ });
+ }
+
+ private ensureApplicationStorageWatcher(): void {
+ if (
+ !this.hostWatcher ||
+ this.stopWatchingApplicationStorageRoot !== STOP_WATCHING
+ ) {
+ return;
+ }
+
+ const appsRootPath = this.options.appsRootPath;
+ if (!appsRootPath) {
+ return;
+ }
+
+ this.stopWatchingApplicationStorageRoot =
+ this.hostWatcher.watchApplicationStorageRoot({
+ appsRootPath,
+ resolveApplicationTarget: (applicationId) =>
+ this.findTrackedApplicationDataTarget(applicationId),
+ onChange: (event) => {
+ if (event.kind === "application-storage-targets-changed") {
+ this.options.onApplicationStorageTargetsChanged?.();
+ }
+ if (event.kind === "application-data-changed") {
+ this.options.onApplicationDataChanged?.({
+ applicationId: event.applicationId,
+ appDataPath: event.appDataPath,
path: event.path,
- threadId: event.threadId,
- threadStoragePath: path.join(
- threadStorageRootPath,
- event.threadId,
- ),
});
}
- if (event.kind === "thread-app-data-resync") {
- this.options.onThreadAppDataResync?.({
- appId: event.appId,
- environmentId: event.environmentId,
- threadId: event.threadId,
- threadStoragePath: path.join(
- threadStorageRootPath,
- event.threadId,
- ),
+ if (event.kind === "application-data-resync") {
+ this.options.onApplicationDataResync?.({
+ applicationId: event.applicationId,
});
}
},
onWatchError: (error) => {
- this.options.onThreadStorageWatchError?.({
+ this.options.onApplicationStorageWatchError?.({
error,
});
},
@@ -994,6 +1051,12 @@ export class RuntimeManager {
return this.trackedThreadStorageTargets.get(threadId) ?? null;
}
+ private findTrackedApplicationDataTarget(
+ applicationId: ApplicationId,
+ ): ApplicationDataWatchTarget | null {
+ return this.trackedApplicationDataTargets.get(applicationId) ?? null;
+ }
+
private removeTrackedThreadStorageTargetsForEnvironment(
environmentId: string,
): void {
diff --git a/apps/host-daemon/src/runtime-shell-env.test.ts b/apps/host-daemon/src/runtime-shell-env.test.ts
index c9a90bd08..1dd25dbd5 100644
--- a/apps/host-daemon/src/runtime-shell-env.test.ts
+++ b/apps/host-daemon/src/runtime-shell-env.test.ts
@@ -142,6 +142,7 @@ describe("prepareRuntimeShellEnv", () => {
it("prepends the configured bb executable directory to PATH", () => {
expect(
prepareRuntimeShellEnv({
+ appsRootPath: "/tmp/bb-data/apps",
bbExecutableDirectory: "/tmp/bb-bin",
hostDaemonPort: 3002,
inheritedPath: "/usr/bin",
@@ -149,6 +150,7 @@ describe("prepareRuntimeShellEnv", () => {
}),
).toEqual({
PATH: `/tmp/bb-bin${delimiter}/usr/bin`,
+ BB_APPS_ROOT: "/tmp/bb-data/apps",
BB_SERVER_URL: "http://127.0.0.1:3334",
BB_HOST_DAEMON_PORT: "3002",
});
@@ -159,12 +161,14 @@ describe("prepareRuntimeShellEnv", () => {
expect(
prepareRuntimeShellEnv({
+ appsRootPath: "/tmp/bb-data/apps",
bbExecutableDirectory: "/tmp/bb-bin",
hostDaemonPort: 3002,
serverUrl: "http://127.0.0.1:3334",
}),
).toEqual({
PATH: `/tmp/bb-bin${delimiter}/usr/local/bin:/usr/bin`,
+ BB_APPS_ROOT: "/tmp/bb-data/apps",
BB_SERVER_URL: "http://127.0.0.1:3334",
BB_HOST_DAEMON_PORT: "3002",
});
@@ -173,12 +177,14 @@ describe("prepareRuntimeShellEnv", () => {
it("omits the host daemon port when the local API is disabled", () => {
expect(
prepareRuntimeShellEnv({
+ appsRootPath: "/tmp/bb-data/apps",
bbExecutableDirectory: "/tmp/bb-bin",
inheritedPath: "/usr/bin",
serverUrl: "http://127.0.0.1:3334",
}),
).toEqual({
PATH: `/tmp/bb-bin${delimiter}/usr/bin`,
+ BB_APPS_ROOT: "/tmp/bb-data/apps",
BB_SERVER_URL: "http://127.0.0.1:3334",
});
});
diff --git a/apps/host-daemon/src/runtime-shell-env.ts b/apps/host-daemon/src/runtime-shell-env.ts
index 17b017d7d..b53c6dd49 100644
--- a/apps/host-daemon/src/runtime-shell-env.ts
+++ b/apps/host-daemon/src/runtime-shell-env.ts
@@ -10,6 +10,7 @@ export interface ResolveLocalBbExecutableDirectoryOptions {
}
export interface PrepareRuntimeShellEnvOptions {
+ appsRootPath: string;
bbExecutableDirectory: string;
hostDaemonPort?: number;
serverUrl: string;
@@ -97,6 +98,7 @@ export function prepareRuntimeShellEnv(
options.bbExecutableDirectory,
options.inheritedPath ?? process.env.PATH,
),
+ BB_APPS_ROOT: options.appsRootPath,
BB_SERVER_URL: options.serverUrl,
};
assignIfDefined({
diff --git a/apps/host-daemon/src/start-host-daemon.ts b/apps/host-daemon/src/start-host-daemon.ts
index d6790e116..a74feabe7 100644
--- a/apps/host-daemon/src/start-host-daemon.ts
+++ b/apps/host-daemon/src/start-host-daemon.ts
@@ -3,6 +3,7 @@ import {
loadHostDaemonStartConfig,
type HostDaemonConnectionConfig,
} from "@bb/config/host-daemon";
+import { resolveAppsRootPath } from "@bb/config/app-storage-paths";
import type { HostType, ToolCallRequest, ToolCallResponse } from "@bb/domain";
import { createHostWatcher, type HostWatcher } from "@bb/host-watcher";
import { createLogger } from "@bb/logger";
@@ -158,6 +159,7 @@ export async function startHostDaemon(
hostType,
}));
const runtimeShellEnv = prepareRuntimeShellEnv({
+ appsRootPath: resolveAppsRootPath(dataDir),
bbExecutableDirectory,
hostDaemonPort: localApiConfig?.port,
serverUrl,
diff --git a/apps/host-daemon/test/helpers/test-server.ts b/apps/host-daemon/test/helpers/test-server.ts
index 47138ab09..30396c702 100644
--- a/apps/host-daemon/test/helpers/test-server.ts
+++ b/apps/host-daemon/test/helpers/test-server.ts
@@ -266,6 +266,7 @@ export async function createTestServer(
heartbeatIntervalMs: options.heartbeatIntervalMs ?? 25,
leaseTimeoutMs: options.leaseTimeoutMs ?? 1_000,
trackedThreadTargets: options.trackedThreadTargets ?? [],
+ trackedApplicationDataTargets: [],
retiredEnvironmentIds: [],
},
201,
diff --git a/apps/server/src/internal/app-data-changes.ts b/apps/server/src/internal/app-data-changes.ts
index 13a70484c..e8db5f3f4 100644
--- a/apps/server/src/internal/app-data-changes.ts
+++ b/apps/server/src/internal/app-data-changes.ts
@@ -7,46 +7,10 @@ import {
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 {
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,
@@ -59,17 +23,15 @@ export function registerInternalAppDataChangeRoutes(
"/session/app-data-change",
hostDaemonAppDataChangeRequestSchema,
async (context, payload) => {
- requireSessionOwnedThread({
+ requireAuthenticatedDaemonSession({
context,
- deps,
+ db: deps.db,
sessionId: payload.sessionId,
- threadId: payload.threadId,
});
- deps.hub.notifyThreadAppData({
+ deps.hub.notifyAppData({
type: "app-data.changed",
- threadId: payload.threadId,
- appId: payload.appId,
+ applicationId: payload.applicationId,
path: payload.path,
value: payload.value,
deleted: payload.deleted,
@@ -83,17 +45,15 @@ export function registerInternalAppDataChangeRoutes(
"/session/app-data-resync",
hostDaemonAppDataResyncRequestSchema,
async (context, payload) => {
- requireSessionOwnedThread({
+ requireAuthenticatedDaemonSession({
context,
- deps,
+ db: deps.db,
sessionId: payload.sessionId,
- threadId: payload.threadId,
});
- deps.hub.notifyThreadAppData({
+ deps.hub.notifyAppData({
type: "app-data.resync",
- threadId: payload.threadId,
- appId: payload.appId,
+ applicationId: payload.applicationId,
});
return context.json({ ok: true });
},
diff --git a/apps/server/src/internal/session.ts b/apps/server/src/internal/session.ts
index 61a17fb98..f8f6f9dc7 100644
--- a/apps/server/src/internal/session.ts
+++ b/apps/server/src/internal/session.ts
@@ -24,6 +24,7 @@ import {
import { requireAuthenticatedDaemonSession } from "./session-state.js";
import { readAttachment } from "../services/projects/attachments.js";
import { handleHostSessionOpened } from "./session-owner-side-effects.js";
+import { listTrackedApplicationDataTargets } from "../services/apps/tracked-application-data-targets.js";
export function registerInternalSessionRoutes(app: Hono, deps: AppDeps): void {
const { get, post } = typedRoutes(app, {
@@ -73,6 +74,11 @@ export function registerInternalSessionRoutes(app: Hono, deps: AppDeps): void {
leaseTimeoutMs: LEASE_TIMEOUT_MS,
});
+ const trackedApplicationDataTargets =
+ await listTrackedApplicationDataTargets({
+ dataDir: session.dataDir,
+ });
+
await handleHostSessionOpened(deps, {
activeThreads: payload.activeThreads,
hostId: daemon.hostId,
@@ -103,6 +109,7 @@ export function registerInternalSessionRoutes(app: Hono, deps: AppDeps): void {
heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
leaseTimeoutMs: LEASE_TIMEOUT_MS,
trackedThreadTargets,
+ trackedApplicationDataTargets,
retiredEnvironmentIds,
},
201,
diff --git a/apps/server/src/routes/apps.ts b/apps/server/src/routes/apps.ts
new file mode 100644
index 000000000..3d44f1f4f
--- /dev/null
+++ b/apps/server/src/routes/apps.ts
@@ -0,0 +1,1456 @@
+import { Buffer } from "node:buffer";
+import { createHash, randomUUID } from "node:crypto";
+import {
+ mkdir,
+ readdir,
+ readFile,
+ rename,
+ rm,
+ stat,
+ writeFile,
+} from "node:fs/promises";
+import type { Dirent, Stats } from "node:fs";
+import path from "node:path";
+import mimeTypes from "mime-types";
+import type { Hono } from "hono";
+import type { ZodIssue } from "zod";
+import {
+ resolveApplicationAssetsPath,
+ resolveApplicationDataPath,
+ resolveApplicationManifestPath,
+ resolveApplicationPath,
+ resolveAppsRootPath,
+} from "@bb/config/app-storage-paths";
+import {
+ appDataPathSchema,
+ applicationIdSchema,
+ jsonValueSchema,
+} from "@bb/domain";
+import type { AppDataPath, ApplicationId, JsonValue } from "@bb/domain";
+import {
+ appDataListQuerySchema,
+ appDataWriteRequestSchema,
+ appManifestSchema,
+ appMessageRequestSchema,
+ createAppRequestSchema,
+ typedRoutes,
+ type AppCapability,
+ type AppDataEntry,
+ type AppDetail,
+ type AppEntry,
+ type AppIcon,
+ type AppManifest,
+ type AppSummary,
+ type PublicApiSchema,
+} from "@bb/server-contract";
+import type { AppDeps, LoggedWorkSessionDeps } from "../types.js";
+import { ApiError } from "../errors.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";
+
+interface InvalidAppManifestErrorArgs extends ApplicationManifestReadArgs {
+ issues: AppManifestValidationIssues;
+ manifestPath: string;
+}
+
+interface LogInvalidAppManifestArgs {
+ error: InvalidAppManifestError;
+ message: string;
+}
+
+interface ApplicationManifestReadArgs {
+ applicationId: ApplicationId;
+ dataDir: string;
+}
+
+interface ApplicationSummaryArgs extends ApplicationManifestReadArgs {
+ manifest: AppManifest;
+}
+
+interface ApplicationDetailArgs extends ApplicationManifestReadArgs {}
+
+interface ReadApplicationRelativeFileArgs {
+ applicationId: ApplicationId;
+ dataDir: string;
+ dotfiles: "allow" | "deny";
+ path: string;
+ rootKind: "app" | "assets" | "data";
+}
+
+interface ApplicationRootForKindArgs {
+ applicationId: ApplicationId;
+ dataDir: string;
+ rootKind: "app" | "assets" | "data";
+}
+
+interface ReadApplicationDataEntryArgs {
+ applicationId: ApplicationId;
+ dataDir: string;
+ dataPath: AppDataPath;
+}
+
+interface ListApplicationDataEntriesArgs {
+ applicationId: ApplicationId;
+ dataDir: string;
+ dataPath: AppDataPath | "";
+}
+
+interface WriteApplicationDataEntryArgs
+ extends ReadApplicationDataEntryArgs {
+ value: JsonValue;
+}
+
+interface DeleteApplicationDataEntryArgs extends ReadApplicationDataEntryArgs {}
+
+interface CreateInjectedAppHtmlResponseArgs {
+ appSessionToken: AppSessionToken | null;
+ applicationId: ApplicationId;
+ capabilities: AppCapability[];
+ html: string;
+ requestUrl: string;
+ targetThreadId: string | null;
+}
+
+interface ApplicationAssetRouteSegmentArgs {
+ applicationId: string;
+}
+
+interface LogoResolution {
+ extension: string;
+}
+
+interface CreateGlobalApplicationArgs {
+ dataDir: string;
+ name: string;
+}
+
+interface CreateApplicationTempRootArgs {
+ appsRootPath: string;
+ applicationId: ApplicationId;
+}
+
+interface PublishApplicationTempRootArgs {
+ applicationPath: string;
+ tempRootPath: string;
+}
+
+interface AppSession {
+ applicationId: ApplicationId;
+ projectId: string;
+ threadId: string;
+}
+
+interface ResolveAppMessageTargetArgs {
+ applicationId: ApplicationId;
+ payload: {
+ appSessionToken?: string;
+ targetThreadId?: string;
+ };
+ sessions: AppSessionStore;
+}
+
+interface CreateAppSessionArgs {
+ applicationId: ApplicationId;
+ projectId: string;
+ threadId: string;
+}
+
+interface AppSessionStore {
+ create(args: CreateAppSessionArgs): AppSessionToken;
+ get(token: string): AppSession | null;
+}
+
+type AppAssetPath = SafeRelativeRoutePath;
+type AppManifestValidationIssues = readonly ZodIssue[];
+type AppManifestValidationLoggerDeps = Pick;
+type AppSessionToken = string;
+type LogoExtension = "svg" | "png" | "jpg" | "jpeg";
+
+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: readonly LogoExtension[] = ["svg", "png", "jpg", "jpeg"];
+const APP_SESSION_TOKEN_PREFIX = "appsess_";
+const INVALID_APP_MANIFEST_MESSAGE =
+ "App manifest failed validation. Inspect manifest.json or rebuild the app.";
+
+class InvalidAppManifestError extends ApiError {
+ readonly applicationId: ApplicationId;
+ readonly manifestPath: string;
+ readonly issues: AppManifestValidationIssues;
+
+ constructor(args: InvalidAppManifestErrorArgs) {
+ super(422, "invalid_manifest", INVALID_APP_MANIFEST_MESSAGE);
+ this.name = "InvalidAppManifestError";
+ this.applicationId = args.applicationId;
+ this.manifestPath = args.manifestPath;
+ this.issues = args.issues;
+ }
+}
+
+function sha256(bytes: Buffer): string {
+ return createHash("sha256").update(bytes).digest("hex");
+}
+
+function canonicalizeJson(value: JsonValue | AppManifest): string {
+ return `${JSON.stringify(value, null, 2)}\n`;
+}
+
+function isFsErrorWithCode(error: Error, code: string): boolean {
+ return "code" in error && error.code === code;
+}
+
+function parseApplicationId(rawApplicationId: string): ApplicationId {
+ const parsed = applicationIdSchema.safeParse(rawApplicationId);
+ if (!parsed.success) {
+ throw new ApiError(400, "invalid_request", "Invalid applicationId");
+ }
+ 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 applicationAssetRouteSegment(
+ args: ApplicationAssetRouteSegmentArgs,
+): string {
+ return `/apps/${encodeURIComponent(args.applicationId)}/assets/`;
+}
+
+function applicationDataRouteSegment(
+ args: ApplicationAssetRouteSegmentArgs,
+): string {
+ return `/apps/${encodeURIComponent(args.applicationId)}/data/`;
+}
+
+function parseAppDataRoutePath(
+ rawPath: string,
+): AppDataPath {
+ return parseAppDataPath(
+ parseSafeRelativeRoutePath({
+ rawPath,
+ dotfileSegmentPolicy: "allow",
+ invalidPathMessage: "Invalid app data path",
+ }).relativePath,
+ );
+}
+
+function parseOptionalAppDataPrefix(
+ rawPrefix: string | undefined,
+): AppDataPath | "" {
+ if (rawPrefix === undefined || rawPrefix === "") {
+ return "";
+ }
+ return parseAppDataPath(rawPrefix);
+}
+
+function applicationRootForKind(args: ApplicationRootForKindArgs): string {
+ if (args.rootKind === "app") {
+ return resolveApplicationPath(args.dataDir, args.applicationId);
+ }
+ if (args.rootKind === "assets") {
+ return resolveApplicationAssetsPath(args.dataDir, args.applicationId);
+ }
+ return resolveApplicationDataPath(args.dataDir, args.applicationId);
+}
+
+function parseApplicationAssetPath(rawPath: string): AppAssetPath {
+ return parseSafeRelativeRoutePath({
+ rawPath,
+ directoryIndexPath: "index.html",
+ dotfileSegmentPolicy: "not-found",
+ invalidPathMessage: "Invalid app asset path",
+ });
+}
+
+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(
+ {
+ applicationId: args.error.applicationId,
+ manifestPath: args.error.manifestPath,
+ issueSummary: summarizeAppManifestValidationIssues(args.error.issues),
+ issues: args.error.issues,
+ },
+ args.message,
+ );
+}
+
+async function readApplicationRelativeFile(
+ args: ReadApplicationRelativeFileArgs,
+): Promise {
+ const rootPath = applicationRootForKind(args);
+ const filePath = path.join(rootPath, args.path);
+ if (
+ args.dotfiles === "deny" &&
+ args.path.split("/").some((segment) => segment.startsWith("."))
+ ) {
+ throw new ApiError(404, "ENOENT", "App file not found");
+ }
+ const relativePath = path.relative(rootPath, filePath);
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
+ throw new ApiError(400, "invalid_request", "Invalid app file path");
+ }
+ try {
+ return await readFile(filePath);
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ throw new ApiError(404, "ENOENT", "App file not found");
+ }
+ throw error;
+ }
+}
+
+async function statApplicationRelativeFile(
+ args: ReadApplicationRelativeFileArgs,
+): Promise {
+ const rootPath = applicationRootForKind(args);
+ const filePath = path.join(rootPath, args.path);
+ const relativePath = path.relative(rootPath, filePath);
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
+ throw new ApiError(400, "invalid_request", "Invalid app file path");
+ }
+ try {
+ return await stat(filePath);
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ throw new ApiError(404, "ENOENT", "App file not found");
+ }
+ throw error;
+ }
+}
+
+async function ensureApplicationFolderExists(
+ args: ApplicationManifestReadArgs,
+): Promise {
+ try {
+ const applicationStats = await stat(
+ resolveApplicationPath(args.dataDir, args.applicationId),
+ );
+ if (!applicationStats.isDirectory()) {
+ throw new InvalidAppManifestError({
+ ...args,
+ manifestPath: resolveApplicationManifestPath(
+ args.dataDir,
+ args.applicationId,
+ ),
+ issues: [],
+ });
+ }
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ throw new ApiError(404, "app_missing", "App not found");
+ }
+ throw error;
+ }
+}
+
+async function readApplicationManifest(
+ args: ApplicationManifestReadArgs,
+): Promise {
+ await ensureApplicationFolderExists(args);
+ const manifestPath = resolveApplicationManifestPath(
+ args.dataDir,
+ args.applicationId,
+ );
+ let manifestContent: string;
+ try {
+ manifestContent = await readFile(manifestPath, "utf8");
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ throw new InvalidAppManifestError({
+ ...args,
+ manifestPath,
+ issues: [],
+ });
+ }
+ throw error;
+ }
+
+ let parsedJson;
+ try {
+ parsedJson = JSON.parse(manifestContent);
+ } catch {
+ throw new ApiError(422, "invalid_manifest", "App manifest is not valid JSON");
+ }
+ const manifest = appManifestSchema.safeParse(parsedJson);
+ if (!manifest.success) {
+ throw new InvalidAppManifestError({
+ ...args,
+ manifestPath,
+ issues: manifest.error.issues,
+ });
+ }
+ if (manifest.data.id !== args.applicationId) {
+ throw new ApiError(
+ 422,
+ "invalid_manifest",
+ "App manifest id must match its directory name",
+ );
+ }
+ return manifest.data;
+}
+
+async function readApplicationManifestForRequest(
+ deps: AppDeps,
+ args: ApplicationManifestReadArgs,
+): Promise {
+ try {
+ return await readApplicationManifest(args);
+ } catch (error) {
+ if (error instanceof InvalidAppManifestError) {
+ logInvalidAppManifest(deps, {
+ error,
+ message: "Rejected invalid global 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_manifest",
+ "App entry must end in .html or .md",
+ );
+}
+
+async function resolveApplicationEntry(
+ args: ApplicationManifestReadArgs & { 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 statApplicationRelativeFile({
+ ...args,
+ path: candidate,
+ rootKind: "assets",
+ dotfiles: "deny",
+ });
+ 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(
+ args: ApplicationManifestReadArgs,
+): Promise {
+ let entries: Dirent[];
+ try {
+ entries = await readdir(resolveApplicationPath(args.dataDir, args.applicationId), {
+ withFileTypes: true,
+ });
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ return null;
+ }
+ throw error;
+ }
+ const topLevelFiles = new Set(
+ entries.filter((entry) => entry.isFile()).map((entry) => entry.name),
+ );
+ for (const extension of LOGO_EXTENSIONS) {
+ if (topLevelFiles.has(`logo.${extension}`)) {
+ return { extension };
+ }
+ }
+ return null;
+}
+
+async function resolveApplicationIcon(
+ args: ApplicationSummaryArgs,
+): Promise {
+ if (args.manifest.icon !== undefined) {
+ return { kind: "builtin", name: args.manifest.icon };
+ }
+ const logo = await tryResolveLogo(args);
+ if (logo) {
+ return {
+ kind: "logo",
+ url: `/api/v1/apps/${encodeURIComponent(args.applicationId)}/icon`,
+ };
+ }
+ return { kind: "builtin", name: "GridView" };
+}
+
+async function buildApplicationSummary(
+ args: ApplicationSummaryArgs,
+): Promise {
+ const [entry, icon] = await Promise.all([
+ resolveApplicationEntry(args),
+ resolveApplicationIcon(args),
+ ]);
+ return {
+ applicationId: args.manifest.id,
+ name: args.manifest.name,
+ entry,
+ capabilities: args.manifest.capabilities,
+ icon,
+ };
+}
+
+async function buildApplicationDetail(
+ deps: AppDeps,
+ args: ApplicationDetailArgs,
+): Promise {
+ const manifest = await readApplicationManifestForRequest(deps, args);
+ return {
+ ...(await buildApplicationSummary({
+ ...args,
+ manifest,
+ })),
+ appsRootPath: resolveAppsRootPath(args.dataDir),
+ appRootPath: resolveApplicationPath(args.dataDir, args.applicationId),
+ appDataPath: resolveApplicationDataPath(args.dataDir, args.applicationId),
+ };
+}
+
+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`,
+ );
+ }
+}
+
+function isIgnoredApplicationStorageEntry(entryName: string): boolean {
+ return (
+ entryName.startsWith(".tmp-app_") || entryName.startsWith(".delete-app_")
+ );
+}
+
+async function listGlobalApplications(deps: AppDeps): Promise {
+ const appsRootPath = resolveAppsRootPath(deps.config.dataDir);
+ let entries: Dirent[];
+ try {
+ entries = await readdir(appsRootPath, { withFileTypes: true });
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ return [];
+ }
+ throw error;
+ }
+
+ const applicationIds = entries
+ .filter(
+ (entry) =>
+ entry.isDirectory() && !isIgnoredApplicationStorageEntry(entry.name),
+ )
+ .map((entry) => applicationIdSchema.safeParse(entry.name))
+ .filter((entry) => entry.success)
+ .map((entry) => entry.data)
+ .sort((left, right) => left.localeCompare(right));
+
+ const summaries: AppSummary[] = [];
+ for (const applicationId of applicationIds) {
+ try {
+ const manifest = await readApplicationManifest({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ summaries.push(
+ await buildApplicationSummary({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ manifest,
+ }),
+ );
+ } catch (error) {
+ if (error instanceof InvalidAppManifestError) {
+ logInvalidAppManifest(deps, {
+ error,
+ message: "Skipping invalid global app manifest",
+ });
+ continue;
+ }
+ if (error instanceof ApiError && error.body.code === "invalid_manifest") {
+ deps.logger.warn(
+ {
+ applicationId,
+ appRootPath: resolveApplicationPath(
+ deps.config.dataDir,
+ applicationId,
+ ),
+ message: error.body.message,
+ },
+ "Skipping invalid global app manifest",
+ );
+ continue;
+ }
+ throw error;
+ }
+ }
+ return summaries;
+}
+
+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 {
+ return createHtmlResponse(
+ injectAppClientScript(args.html, {
+ appId: args.applicationId,
+ applicationId: args.applicationId,
+ appSessionToken: args.appSessionToken,
+ capabilities: args.capabilities,
+ targetThreadId: args.targetThreadId,
+ dataUrl: `/api/v1/apps/${encodeURIComponent(args.applicationId)}/data`,
+ messageUrl: `/api/v1/apps/${encodeURIComponent(
+ args.applicationId,
+ )}/message`,
+ wsUrl: buildAppWebSocketUrl(deps, args.requestUrl),
+ }),
+ );
+}
+
+function createAppSessionStore(): AppSessionStore {
+ const sessions = new Map();
+ return {
+ create(args) {
+ const token = `${APP_SESSION_TOKEN_PREFIX}${randomUUID().replaceAll(
+ "-",
+ "",
+ )}`;
+ sessions.set(token, args);
+ return token;
+ },
+ get(token) {
+ return sessions.get(token) ?? null;
+ },
+ };
+}
+
+function maybeCreateAppSession(
+ deps: AppDeps,
+ sessions: AppSessionStore,
+ applicationId: ApplicationId,
+ rawTargetThreadId: string | undefined,
+): AppSessionToken | null {
+ if (rawTargetThreadId === undefined || rawTargetThreadId.trim() === "") {
+ return null;
+ }
+ const thread = requirePublicThread(deps.db, rawTargetThreadId);
+ return sessions.create({
+ applicationId,
+ projectId: thread.projectId,
+ threadId: thread.id,
+ });
+}
+
+async function serveApplicationEntry(
+ deps: AppDeps,
+ sessions: AppSessionStore,
+ rawApplicationId: string,
+ requestUrl: string,
+ rawTargetThreadId: string | undefined,
+): Promise {
+ const applicationId = parseApplicationId(rawApplicationId);
+ const manifest = await readApplicationManifestForRequest(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ const entry = await resolveApplicationEntry({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ manifest,
+ });
+ if (entry.kind !== "html") {
+ throw new ApiError(404, "invalid_request", "App entry is not HTML");
+ }
+ const metadata = await statApplicationRelativeFile({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ rootKind: "assets",
+ path: entry.path,
+ dotfiles: "deny",
+ });
+ if (metadata.size > HTML_ENTRY_MAX_BYTES) {
+ throw new ApiError(
+ 413,
+ "file_too_large",
+ "App HTML entry exceeds the 5 MB limit",
+ false,
+ );
+ }
+ const result = await readApplicationRelativeFile({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ rootKind: "assets",
+ path: entry.path,
+ dotfiles: "deny",
+ });
+ const token = maybeCreateAppSession(
+ deps,
+ sessions,
+ applicationId,
+ rawTargetThreadId,
+ );
+ return createInjectedAppHtmlResponse(deps, {
+ applicationId,
+ requestUrl,
+ targetThreadId: rawTargetThreadId ?? null,
+ appSessionToken: token,
+ html: result.toString("utf8"),
+ capabilities: manifest.capabilities,
+ });
+}
+
+async function serveApplicationAsset(
+ deps: AppDeps,
+ rawApplicationId: string,
+ rawPath: string,
+): Promise {
+ const applicationId = parseApplicationId(rawApplicationId);
+ const assetPath = parseApplicationAssetPath(rawPath);
+ await readApplicationManifestForRequest(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ const result = await readApplicationRelativeFile({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ rootKind: "assets",
+ path: assetPath.relativePath,
+ dotfiles: "deny",
+ });
+ const contentType =
+ mimeTypes.lookup(assetPath.relativePath) || "application/octet-stream";
+ return new Response(new Uint8Array(result), {
+ status: 200,
+ headers: {
+ "cache-control": NO_STORE_CACHE_CONTROL,
+ "content-type": contentType,
+ "x-content-type-options": CONTENT_TYPE_OPTIONS,
+ },
+ });
+}
+
+async function serveApplicationIcon(
+ deps: AppDeps,
+ rawApplicationId: string,
+): Promise {
+ const applicationId = parseApplicationId(rawApplicationId);
+ const manifest = await readApplicationManifestForRequest(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ if (manifest.icon !== undefined) {
+ throw new ApiError(404, "ENOENT", "App uses a built-in icon");
+ }
+ const logo = await tryResolveLogo({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ if (!logo) {
+ throw new ApiError(404, "ENOENT", "App logo not found");
+ }
+ const logoPath = `logo.${logo.extension}`;
+ const metadata = await statApplicationRelativeFile({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ rootKind: "app",
+ path: logoPath,
+ dotfiles: "deny",
+ });
+ if (metadata.size > LOGO_MAX_BYTES) {
+ throw new ApiError(
+ 413,
+ "file_too_large",
+ "App logo exceeds the 1 MB limit",
+ false,
+ );
+ }
+ const result = await readApplicationRelativeFile({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ rootKind: "app",
+ path: logoPath,
+ dotfiles: "deny",
+ });
+ const contentType = mimeTypes.lookup(logoPath) || "application/octet-stream";
+ return new Response(new Uint8Array(result), {
+ status: 200,
+ headers: {
+ "cache-control": NO_STORE_CACHE_CONTROL,
+ "content-type": contentType,
+ "x-content-type-options": CONTENT_TYPE_OPTIONS,
+ },
+ });
+}
+
+async function readApplicationDataEntry(
+ args: ReadApplicationDataEntryArgs,
+): Promise {
+ const filePath = path.join(
+ resolveApplicationDataPath(args.dataDir, args.applicationId),
+ ...args.dataPath.split("/"),
+ );
+ let bytes: Buffer;
+ let metadata: Stats;
+ try {
+ [bytes, metadata] = await Promise.all([readFile(filePath), stat(filePath)]);
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ throw new ApiError(404, "ENOENT", `App data not found: ${args.dataPath}`);
+ }
+ throw error;
+ }
+ let value: JsonValue;
+ 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`,
+ );
+ }
+ return {
+ path: args.dataPath,
+ value,
+ version: sha256(bytes),
+ sizeBytes: metadata.size,
+ modifiedAtMs: metadata.mtimeMs,
+ };
+}
+
+async function listAppDataFilePaths(
+ appDataRoot: string,
+ currentDirectory: string,
+): Promise {
+ const entries = await readdir(currentDirectory, { withFileTypes: true });
+ const paths: AppDataPath[] = [];
+ for (const entry of entries) {
+ if (entry.name.startsWith(".")) {
+ continue;
+ }
+ const entryPath = path.join(currentDirectory, entry.name);
+ if (entry.isDirectory()) {
+ paths.push(...(await listAppDataFilePaths(appDataRoot, entryPath)));
+ continue;
+ }
+ if (!entry.isFile()) {
+ continue;
+ }
+ const relativePath = path
+ .relative(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));
+}
+
+function shouldListAppDataPrefixAfterReadError(error: Error): boolean {
+ return error instanceof ApiError && error.body.code === "ENOENT";
+}
+
+async function listApplicationDataEntries(
+ args: ListApplicationDataEntriesArgs,
+): Promise {
+ if (args.dataPath !== "") {
+ try {
+ return [
+ await readApplicationDataEntry({
+ applicationId: args.applicationId,
+ dataPath: args.dataPath,
+ dataDir: args.dataDir,
+ }),
+ ];
+ } catch (error) {
+ if (
+ !(error instanceof Error) ||
+ !shouldListAppDataPrefixAfterReadError(error)
+ ) {
+ throw error;
+ }
+ }
+ }
+
+ const appDataRoot = resolveApplicationDataPath(
+ args.dataDir,
+ args.applicationId,
+ );
+ const listRoot = args.dataPath
+ ? path.join(appDataRoot, args.dataPath)
+ : appDataRoot;
+ let dataPaths: AppDataPath[];
+ try {
+ dataPaths = await listAppDataFilePaths(appDataRoot, listRoot);
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ return [];
+ }
+ throw error;
+ }
+
+ return Promise.all(
+ dataPaths.map((dataPath) =>
+ readApplicationDataEntry({
+ applicationId: args.applicationId,
+ dataDir: args.dataDir,
+ dataPath,
+ }),
+ ),
+ );
+}
+
+async function writeApplicationDataEntry(
+ args: WriteApplicationDataEntryArgs,
+): Promise {
+ const content = canonicalizeJson(args.value);
+ const appDataRoot = resolveApplicationDataPath(
+ args.dataDir,
+ args.applicationId,
+ );
+ const filePath = path.join(appDataRoot, ...args.dataPath.split("/"));
+ const relativePath = path.relative(appDataRoot, filePath);
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
+ throw new ApiError(400, "invalid_request", "Invalid app data path");
+ }
+ await mkdir(path.dirname(filePath), { recursive: true });
+ await writeFile(filePath, content, "utf8");
+ return readApplicationDataEntry(args);
+}
+
+async function deleteApplicationDataEntry(
+ args: DeleteApplicationDataEntryArgs,
+): Promise {
+ const appDataRoot = resolveApplicationDataPath(
+ args.dataDir,
+ args.applicationId,
+ );
+ const filePath = path.join(appDataRoot, ...args.dataPath.split("/"));
+ const relativePath = path.relative(appDataRoot, filePath);
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
+ throw new ApiError(400, "invalid_request", "Invalid app data path");
+ }
+ try {
+ await rm(filePath);
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ throw new ApiError(404, "ENOENT", `App data not found: ${args.dataPath}`);
+ }
+ throw error;
+ }
+}
+
+function createApplicationId(): ApplicationId {
+ return applicationIdSchema.parse(
+ `app_${randomUUID().replaceAll("-", "").slice(0, 24)}`,
+ );
+}
+
+function createTempNonce(): string {
+ return randomUUID().replaceAll("-", "").slice(0, 16);
+}
+
+async function createApplicationTempRoot(
+ args: CreateApplicationTempRootArgs,
+): Promise {
+ const tempRootPath = path.join(
+ args.appsRootPath,
+ `.tmp-${args.applicationId}-${createTempNonce()}`,
+ );
+ await mkdir(tempRootPath, { recursive: false });
+ return tempRootPath;
+}
+
+async function writeInitialApplicationFiles(
+ tempRootPath: string,
+ applicationId: ApplicationId,
+ name: string,
+): Promise {
+ const manifest: AppManifest = {
+ manifestVersion: 1,
+ id: applicationId,
+ name,
+ entry: "index.html",
+ capabilities: ["data", "message"],
+ };
+ await mkdir(path.join(tempRootPath, ASSETS_DIRECTORY_NAME), {
+ recursive: true,
+ });
+ await mkdir(path.join(tempRootPath, DATA_DIRECTORY_NAME), {
+ recursive: true,
+ });
+ await writeFile(
+ path.join(tempRootPath, MANIFEST_FILE_NAME),
+ canonicalizeJson(manifest),
+ "utf8",
+ );
+ await writeFile(
+ path.join(tempRootPath, ASSETS_DIRECTORY_NAME, "index.html"),
+ buildBlankAppIndexHtml({ name }),
+ "utf8",
+ );
+ await writeFile(
+ path.join(tempRootPath, DATA_DIRECTORY_NAME, "state.json"),
+ canonicalizeJson({}),
+ "utf8",
+ );
+ appManifestSchema.parse(
+ JSON.parse(await readFile(path.join(tempRootPath, MANIFEST_FILE_NAME), "utf8")),
+ );
+}
+
+async function publishApplicationTempRoot(
+ args: PublishApplicationTempRootArgs,
+): Promise<"published" | "collision"> {
+ try {
+ await stat(args.applicationPath);
+ return "collision";
+ } catch (error) {
+ if (!(error instanceof Error) || !isFsErrorWithCode(error, "ENOENT")) {
+ throw error;
+ }
+ }
+ try {
+ await rename(args.tempRootPath, args.applicationPath);
+ return "published";
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ (isFsErrorWithCode(error, "EEXIST") ||
+ isFsErrorWithCode(error, "ENOTEMPTY"))
+ ) {
+ return "collision";
+ }
+ throw error;
+ }
+}
+
+export async function createGlobalApplication(
+ args: CreateGlobalApplicationArgs,
+): Promise {
+ const appsRootPath = resolveAppsRootPath(args.dataDir);
+ await mkdir(appsRootPath, { recursive: true });
+
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ const applicationId = createApplicationId();
+ const applicationPath = resolveApplicationPath(args.dataDir, applicationId);
+ let tempRootPath: string | null = null;
+ try {
+ tempRootPath = await createApplicationTempRoot({
+ appsRootPath,
+ applicationId,
+ });
+ await writeInitialApplicationFiles(tempRootPath, applicationId, args.name);
+ const result = await publishApplicationTempRoot({
+ applicationPath,
+ tempRootPath,
+ });
+ if (result === "published") {
+ return applicationId;
+ }
+ await rm(tempRootPath, { recursive: true, force: true });
+ tempRootPath = null;
+ } catch (error) {
+ if (tempRootPath !== null) {
+ await rm(tempRootPath, { recursive: true, force: true }).catch(
+ () => undefined,
+ );
+ }
+ throw new ApiError(
+ 500,
+ "scaffold_failed",
+ error instanceof Error ? error.message : "Failed to scaffold app",
+ );
+ }
+ }
+
+ throw new ApiError(
+ 500,
+ "app_id_allocation_failed",
+ "Could not allocate a unique app id",
+ );
+}
+
+async function deleteGlobalApplication(
+ dataDir: string,
+ applicationId: ApplicationId,
+): Promise<"deleted" | "partial"> {
+ const applicationPath = resolveApplicationPath(dataDir, applicationId);
+ try {
+ await stat(applicationPath);
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ throw new ApiError(404, "app_missing", "App not found");
+ }
+ throw error;
+ }
+ const tombstonePath = path.join(
+ resolveAppsRootPath(dataDir),
+ `.delete-${applicationId}-${createTempNonce()}`,
+ );
+ try {
+ await rename(applicationPath, tombstonePath);
+ } catch (error) {
+ throw new ApiError(
+ 500,
+ "delete_failed",
+ error instanceof Error ? error.message : "Failed to delete app",
+ );
+ }
+ try {
+ await rm(tombstonePath, { recursive: true, force: true });
+ return "deleted";
+ } catch {
+ return "partial";
+ }
+}
+
+function formatAppMessagePayload(payload: JsonValue): string {
+ return typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
+}
+
+function resolveAppMessageTarget(
+ deps: AppDeps,
+ args: ResolveAppMessageTargetArgs,
+): AppSession {
+ const token =
+ args.payload.appSessionToken === undefined
+ ? null
+ : args.sessions.get(args.payload.appSessionToken);
+ if (args.payload.appSessionToken !== undefined && token === null) {
+ throw new ApiError(403, "forbidden", "Invalid app session token");
+ }
+ if (token !== null && token.applicationId !== args.applicationId) {
+ throw new ApiError(403, "forbidden", "App session token does not match app");
+ }
+ if (args.payload.targetThreadId !== undefined) {
+ const thread = requirePublicThread(deps.db, args.payload.targetThreadId);
+ if (token !== null && token.threadId !== thread.id) {
+ throw new ApiError(403, "forbidden", "App message target mismatch");
+ }
+ return {
+ applicationId: args.applicationId,
+ threadId: thread.id,
+ projectId: thread.projectId,
+ };
+ }
+ if (token !== null) {
+ return token;
+ }
+ throw new ApiError(
+ 400,
+ "message_target_required",
+ "App message requires an app session token or targetThreadId",
+ );
+}
+
+export function registerGlobalAppRoutes(app: Hono, deps: AppDeps): void {
+ const { get, post, put, del } = typedRoutes(app, {
+ onValidationError: (msg) => new ApiError(400, "invalid_request", msg),
+ });
+ const appSessions = createAppSessionStore();
+
+ get("/apps", async (context) =>
+ context.json(await listGlobalApplications(deps)),
+ );
+
+ post("/apps", createAppRequestSchema, async (context, payload) => {
+ const applicationId = await createGlobalApplication({
+ dataDir: deps.config.dataDir,
+ name: payload.name,
+ });
+ const detail = await buildApplicationDetail(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ return context.json(detail, 201);
+ });
+
+ get("/apps/:applicationId", async (context) => {
+ const applicationId = parseApplicationId(
+ context.req.param("applicationId"),
+ );
+ return context.json(
+ await buildApplicationDetail(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ }),
+ );
+ });
+
+ del("/apps/:applicationId", async (context) => {
+ const applicationId = parseApplicationId(
+ context.req.param("applicationId"),
+ );
+ const result = await deleteGlobalApplication(
+ deps.config.dataDir,
+ applicationId,
+ );
+ if (result === "partial") {
+ throw new ApiError(
+ 500,
+ "delete_partial",
+ "App was removed from normal lists, but tombstone cleanup failed",
+ );
+ }
+ return context.json({ ok: true });
+ });
+
+ get(
+ "/apps/:applicationId/data",
+ appDataListQuerySchema,
+ async (context, query) => {
+ const applicationId = parseApplicationId(
+ context.req.param("applicationId"),
+ );
+ const manifest = await readApplicationManifestForRequest(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ assertAppCapability(manifest, "data");
+ const dataPath = parseOptionalAppDataPrefix(query.prefix);
+ return context.json({
+ entries: await listApplicationDataEntries({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ dataPath,
+ }),
+ });
+ },
+ );
+
+ post(
+ "/apps/:applicationId/message",
+ appMessageRequestSchema,
+ async (context, payload) => {
+ const applicationId = parseApplicationId(
+ context.req.param("applicationId"),
+ );
+ const manifest = await readApplicationManifestForRequest(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ assertAppCapability(manifest, "message");
+ const target = resolveAppMessageTarget(deps, {
+ applicationId,
+ payload,
+ sessions: appSessions,
+ });
+ const thread = requirePublicThread(deps.db, target.threadId);
+ const environment = await requireThreadCommandEnvironment(deps, {
+ thread,
+ });
+ await sendThreadMessage(deps, {
+ environment,
+ thread,
+ trigger: "user",
+ payload: {
+ input: [
+ {
+ type: "text",
+ text: formatAppMessagePayload(payload.payload),
+ },
+ ],
+ mode: "auto",
+ },
+ });
+ return context.json({ ok: true }, 202);
+ },
+ );
+
+ app.get("/apps/:applicationId/", async (context) =>
+ serveApplicationEntry(
+ deps,
+ appSessions,
+ context.req.param("applicationId"),
+ context.req.url,
+ context.req.query("targetThreadId"),
+ ),
+ );
+
+ app.get("/apps/:applicationId/icon", async (context) =>
+ serveApplicationIcon(deps, context.req.param("applicationId")),
+ );
+
+ get("/apps/:applicationId/data/*", async (context) => {
+ const applicationId = parseApplicationId(
+ context.req.param("applicationId"),
+ );
+ const manifest = await readApplicationManifestForRequest(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ assertAppCapability(manifest, "data");
+ const dataPath = parseAppDataRoutePath(
+ extractRoutePath({
+ requestUrl: context.req.url,
+ routeSegment: applicationDataRouteSegment({ applicationId }),
+ }),
+ );
+ const entry = await readApplicationDataEntry({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ dataPath,
+ });
+ const response = context.json(entry);
+ response.headers.set("cache-control", NO_STORE_CACHE_CONTROL);
+ response.headers.set("etag", `"${entry.version}"`);
+ return response;
+ });
+
+ put(
+ "/apps/:applicationId/data/*",
+ appDataWriteRequestSchema,
+ async (context, payload) => {
+ const applicationId = parseApplicationId(
+ context.req.param("applicationId"),
+ );
+ const manifest = await readApplicationManifestForRequest(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ assertAppCapability(manifest, "data");
+ const dataPath = parseAppDataRoutePath(
+ extractRoutePath({
+ requestUrl: context.req.url,
+ routeSegment: applicationDataRouteSegment({ applicationId }),
+ }),
+ );
+ const entry = await writeApplicationDataEntry({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ 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("/apps/:applicationId/data/*", async (context) => {
+ const applicationId = parseApplicationId(
+ context.req.param("applicationId"),
+ );
+ const manifest = await readApplicationManifestForRequest(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ assertAppCapability(manifest, "data");
+ const dataPath = parseAppDataRoutePath(
+ extractRoutePath({
+ requestUrl: context.req.url,
+ routeSegment: applicationDataRouteSegment({ applicationId }),
+ }),
+ );
+ await deleteApplicationDataEntry({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ dataPath,
+ });
+ const response = context.json({ ok: true });
+ response.headers.set("cache-control", NO_STORE_CACHE_CONTROL);
+ return response;
+ });
+
+ app.get("/apps/:applicationId/assets/*", async (context) => {
+ const applicationId = context.req.param("applicationId");
+ return serveApplicationAsset(
+ deps,
+ applicationId,
+ extractRoutePath({
+ requestUrl: context.req.url,
+ routeSegment: applicationAssetRouteSegment({ applicationId }),
+ }),
+ );
+ });
+}
diff --git a/apps/server/src/routes/threads/apps.ts b/apps/server/src/routes/threads/apps.ts
deleted file mode 100644
index a884b8ee1..000000000
--- a/apps/server/src/routes/threads/apps.ts
+++ /dev/null
@@ -1,1320 +0,0 @@
-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,
- type HostDaemonDurableCommandType,
- type HostDaemonOnlineRpcResult,
- type HostDaemonRetryableOnlineRpcCommand,
- type HostDaemonRetryableOnlineRpcCommandType,
- type HostDaemonRetryableOnlineRpcResult,
-} 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 { callHostRetryableOnlineRpc } from "../../services/hosts/online-rpc.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 AppManifestValidationIssues = readonly ZodIssue[];
-type AppManifestValidationLoggerDeps = Pick;
-
-interface ReadHostCommandArgs<
- TType extends HostDaemonRetryableOnlineRpcCommandType,
-> {
- command: Extract;
- hostId: string;
-}
-
-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 readHostCommand<
- TType extends HostDaemonRetryableOnlineRpcCommandType,
->(
- deps: AppDeps,
- args: ReadHostCommandArgs,
-): Promise> {
- try {
- return await callHostRetryableOnlineRpc(deps, {
- hostId: args.hostId,
- timeoutMs: COMMAND_TIMEOUT_MS,
- command: args.command,
- });
- } catch (error) {
- remapAppCommandError(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 readHostCommand(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 readHostCommand(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: HostDaemonOnlineRpcResult<"host.list_paths">;
- try {
- listResult = await readHostCommand(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: HostDaemonOnlineRpcResult<"host.list_paths">;
- try {
- listResult = await readHostCommand(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: HostDaemonOnlineRpcResult<"host.list_paths">;
- try {
- listResult = await readHostCommand(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/index.ts b/apps/server/src/routes/threads/index.ts
index 73f8159de..d5c0519a0 100644
--- a/apps/server/src/routes/threads/index.ts
+++ b/apps/server/src/routes/threads/index.ts
@@ -1,7 +1,6 @@
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";
@@ -13,7 +12,6 @@ export function registerThreadRoutes(app: Hono, deps: AppDeps): void {
if (deps.config.featureFlags.terminals) {
registerThreadTerminalRoutes(app, deps);
}
- registerThreadAppRoutes(app, deps);
registerThreadDataRoutes(app, deps);
registerThreadInteractionRoutes(app, deps);
}
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 888ee7d66..38d4c6eb0 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -11,6 +11,7 @@ import {
import type { AppDeps, ServerAppDeps } from "./types.js";
import { ApiError, errorToResponse } from "./errors.js";
import { registerAutomationRoutes } from "./routes/automations.js";
+import { registerGlobalAppRoutes } from "./routes/apps.js";
import { registerEnvironmentRoutes } from "./routes/environments.js";
import { registerFileRoutes } from "./routes/files.js";
import { registerHostRoutes } from "./routes/hosts.js";
@@ -244,6 +245,7 @@ export function createApp(
return next();
});
const publicApi = new Hono();
+ registerGlobalAppRoutes(publicApi, deps);
registerProjectRoutes(publicApi, deps);
registerAutomationRoutes(publicApi, deps);
registerFileRoutes(publicApi, deps);
diff --git a/apps/server/src/services/apps/tracked-application-data-targets.ts b/apps/server/src/services/apps/tracked-application-data-targets.ts
new file mode 100644
index 000000000..97f7ad53c
--- /dev/null
+++ b/apps/server/src/services/apps/tracked-application-data-targets.ts
@@ -0,0 +1,79 @@
+import { readdir, readFile } from "node:fs/promises";
+import {
+ resolveApplicationDataPath,
+ resolveApplicationManifestPath,
+ resolveAppsRootPath,
+} from "@bb/config/app-storage-paths";
+import { applicationIdSchema, type ApplicationId } from "@bb/domain";
+import { appManifestSchema } from "@bb/server-contract";
+
+export interface TrackedApplicationDataTarget {
+ applicationId: ApplicationId;
+ appDataPath: string;
+}
+
+interface ListTrackedApplicationDataTargetsArgs {
+ dataDir: string;
+}
+
+function isIgnoredApplicationStorageEntry(entryName: string): boolean {
+ return (
+ entryName.startsWith(".tmp-app_") || entryName.startsWith(".delete-app_")
+ );
+}
+
+async function isValidApplicationManifest(
+ dataDir: string,
+ applicationId: ApplicationId,
+): Promise {
+ try {
+ const manifest = appManifestSchema.parse(
+ JSON.parse(
+ await readFile(
+ resolveApplicationManifestPath(dataDir, applicationId),
+ "utf8",
+ ),
+ ),
+ );
+ return manifest.id === applicationId;
+ } catch {
+ return false;
+ }
+}
+
+export async function listTrackedApplicationDataTargets(
+ args: ListTrackedApplicationDataTargetsArgs,
+): Promise {
+ let entries;
+ try {
+ entries = await readdir(resolveAppsRootPath(args.dataDir), {
+ withFileTypes: true,
+ });
+ } catch {
+ return [];
+ }
+
+ const targets: TrackedApplicationDataTarget[] = [];
+ for (const entry of entries) {
+ if (
+ !entry.isDirectory() ||
+ isIgnoredApplicationStorageEntry(entry.name)
+ ) {
+ continue;
+ }
+ const parsed = applicationIdSchema.safeParse(entry.name);
+ if (!parsed.success) {
+ continue;
+ }
+ if (!(await isValidApplicationManifest(args.dataDir, parsed.data))) {
+ continue;
+ }
+ targets.push({
+ applicationId: parsed.data,
+ appDataPath: resolveApplicationDataPath(args.dataDir, parsed.data),
+ });
+ }
+ return targets.sort((left, right) =>
+ left.applicationId.localeCompare(right.applicationId),
+ );
+}
diff --git a/apps/server/src/services/threads/app-client-script.ts b/apps/server/src/services/threads/app-client-script.ts
index 262e3708d..90e315d63 100644
--- a/apps/server/src/services/threads/app-client-script.ts
+++ b/apps/server/src/services/threads/app-client-script.ts
@@ -1,12 +1,14 @@
-import type { AppId } from "@bb/domain";
+import type { ApplicationId } from "@bb/domain";
import type { AppCapability } from "@bb/server-contract";
export interface AppClientBootstrap {
- appId: AppId;
+ appId: ApplicationId;
+ applicationId: ApplicationId;
+ appSessionToken: string | null;
capabilities: AppCapability[];
dataUrl: string;
messageUrl: string;
- threadId: string;
+ targetThreadId: string | null;
wsUrl: string;
}
@@ -241,7 +243,7 @@ function buildAppClientJavascript(bootstrapJson: string): string {
var socketSubscribed = false;
var subscriptionReady = null;
var resolveSubscriptionReady = null;
- var subscriptionEntityId = bootstrap.threadId + ":app:" + bootstrap.appId + ":data";
+ var subscriptionEntityId = bootstrap.applicationId + ":data";
function pathMatchesPrefix(path, prefix) {
return prefix === "" || path === prefix || path.indexOf(prefix + "/") === 0;
@@ -274,8 +276,7 @@ function buildAppClientJavascript(bootstrapJson: string): string {
function handleBroadcast(message) {
if (
!message ||
- message.threadId !== bootstrap.threadId ||
- message.appId !== bootstrap.appId
+ message.applicationId !== bootstrap.applicationId
) {
return;
}
@@ -449,8 +450,8 @@ function buildAppClientJavascript(bootstrapJson: string): string {
}
function message(text) {
- if (typeof text !== "string") {
- throw new TypeError("window.bb.message(text) requires a string");
+ if (text === undefined || !isJsonValue(text)) {
+ throw new TypeError("window.bb.message(payload) requires a JSON value");
}
return fetch(bootstrap.messageUrl, {
method: "POST",
@@ -459,14 +460,17 @@ function buildAppClientJavascript(bootstrapJson: string): string {
"Accept": "application/json",
"Content-Type": "application/json"
},
- body: JSON.stringify({ text: text })
+ body: JSON.stringify({
+ payload: text,
+ appSessionToken: bootstrap.appSessionToken || undefined
+ })
}).then(function (response) {
if (response.ok) return undefined;
return rejectResponse(response);
});
}
- var bb = { appId: bootstrap.appId };
+ var bb = { appId: bootstrap.appId, applicationId: bootstrap.applicationId };
if (hasCapability("data")) {
bb.data = {
read: read,
diff --git a/apps/server/src/services/threads/blank-app-scaffold.ts b/apps/server/src/services/threads/blank-app-scaffold.ts
index 489cdd965..d0efa5f52 100644
--- a/apps/server/src/services/threads/blank-app-scaffold.ts
+++ b/apps/server/src/services/threads/blank-app-scaffold.ts
@@ -159,12 +159,9 @@ const BLANK_APP_SCAFFOLD_EXTRA_STYLES = ``;
-// 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").
+// Blank-template scaffold rendered into an app's assets/index.html. Composes
+// the documented bb default styling head with a scaffold-only style block so
+// new apps start out looking bb-native rather than like bare HTML.
const BLANK_APP_INDEX_HTML_TEMPLATE = `
@@ -202,8 +199,8 @@ const BLANK_APP_INDEX_HTML_TEMPLATE = `
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
deleted file mode 100644
index 8f83d9dc3..000000000
--- a/apps/server/src/services/threads/default-template/apps/status/manifest.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "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 fb7718590..23a148ecf 100644
--- a/apps/server/src/services/threads/manager-storage-templates.ts
+++ b/apps/server/src/services/threads/manager-storage-templates.ts
@@ -1,56 +1,25 @@
-import { constants as fsConstants, readFileSync } from "node:fs";
+import { constants as fsConstants } from "node:fs";
import type { Dirent } from "node:fs";
-import {
- copyFile,
- mkdir,
- readdir,
- readFile,
- writeFile,
-} from "node:fs/promises";
+import { copyFile, mkdir, readdir, readFile } from "node:fs/promises";
import path from "node:path";
-import { fileURLToPath } from "node:url";
import {
managerTemplateNameSchema,
type ManagerTemplateName,
} 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");
-
-function loadDefaultTemplateAsset(fileName: string): string {
- return readFileSync(path.join(defaultTemplateAssetDir, fileName), "utf8");
-}
-
type ManagerTemplateLogger = Pick;
-interface BuiltInManagerTemplateFile {
- content: string;
- fileName: string;
-}
-
interface TemplateFileToCopy {
relativePath: string;
sourcePath: string;
}
-interface BuiltInManagerTemplateSet {
- files: readonly BuiltInManagerTemplateFile[];
- name: ManagerTemplateName;
-}
-
interface ResolveManagerTemplateNameArgs {
dataDir: string;
explicitTemplateName: ManagerTemplateName | null;
@@ -72,14 +41,6 @@ interface CopyTemplateFilesArgs {
threadStoragePath: string;
}
-interface CopyBuiltInTemplateFilesArgs {
- files: readonly BuiltInManagerTemplateFile[];
- logger: ManagerTemplateLogger;
- templateName: ManagerTemplateName;
- threadId: string;
- threadStoragePath: string;
-}
-
interface FsErrorWithCodeArgs {
code: string;
error: unknown;
@@ -95,34 +56,6 @@ interface ManagerTemplateSetPathArgs extends ManagerTemplateRootPathArgs {
type CopyTemplateFilesResult = "copied" | "missing";
-// 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: "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,
- },
- ],
-};
-
function isFsErrorWithCode(args: FsErrorWithCodeArgs): boolean {
return (
typeof args.error === "object" &&
@@ -277,37 +210,6 @@ async function collectTemplateFiles(
return files;
}
-async function copyBuiltInTemplateFiles(
- args: CopyBuiltInTemplateFilesArgs,
-): Promise {
- await mkdir(args.threadStoragePath, { recursive: true });
-
- 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",
- });
- } catch (error) {
- if (isFsErrorWithCode({ error, code: "EEXIST" })) {
- continue;
- }
- throw error;
- }
- }
-
- args.logger.debug(
- {
- templateName: args.templateName,
- threadId: args.threadId,
- threadStoragePath: args.threadStoragePath,
- },
- "Overlaid bundled apps/status seed onto manager storage",
- );
-}
-
export async function seedManagerThreadStorage(
deps: LoggedWorkSessionDeps,
args: SeedManagerThreadStorageArgs,
@@ -342,15 +244,7 @@ export async function seedManagerThreadStorage(
templateDirPath,
threadId: args.threadId,
},
- "Manager template directory is missing; overlaying bundled seed only",
+ "Manager template directory is missing; no template files were seeded",
);
}
-
- 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/ws/hub.ts b/apps/server/src/ws/hub.ts
index 435b66282..4850281e5 100644
--- a/apps/server/src/ws/hub.ts
+++ b/apps/server/src/ws/hub.ts
@@ -505,9 +505,9 @@ export class NotificationHub implements DbNotifier {
}
}
- notifyThreadAppData(message: AppDataBroadcastMessage): void {
+ notifyAppData(message: AppDataBroadcastMessage): void {
this.notifyClientsByKey(
- subKey("thread", `${message.threadId}:app:${message.appId}:data`),
+ subKey("app", `${message.applicationId}:data`),
JSON.stringify(appDataBroadcastMessageSchema.parse(message)),
);
}
diff --git a/apps/server/test/app/hub.test.ts b/apps/server/test/app/hub.test.ts
index eb35ca35f..f0dc9476f 100644
--- a/apps/server/test/app/hub.test.ts
+++ b/apps/server/test/app/hub.test.ts
@@ -91,11 +91,10 @@ describe("NotificationHub", () => {
const hub = new NotificationHub();
const socket = createMockSocket();
- hub.subscribe(socket, "thread", "thread-1:app:status:data");
- hub.notifyThreadAppData({
+ hub.subscribe(socket, "app", "app_status:data");
+ hub.notifyAppData({
type: "app-data.changed",
- threadId: "thread-1",
- appId: "status",
+ applicationId: "app_status",
path: "state.json",
value: { workers: [] },
deleted: false,
@@ -105,8 +104,7 @@ describe("NotificationHub", () => {
expect(socket.messages).toHaveLength(1);
expect(JSON.parse(socket.messages[0])).toEqual({
type: "app-data.changed",
- threadId: "thread-1",
- appId: "status",
+ applicationId: "app_status",
path: "state.json",
value: { workers: [] },
deleted: false,
@@ -118,18 +116,16 @@ describe("NotificationHub", () => {
const hub = new NotificationHub();
const socket = createMockSocket();
- hub.subscribe(socket, "thread", "thread-1:app:status:data");
- hub.notifyThreadAppData({
+ hub.subscribe(socket, "app", "app_status:data");
+ hub.notifyAppData({
type: "app-data.resync",
- threadId: "thread-1",
- appId: "status",
+ applicationId: "app_status",
});
expect(socket.messages).toHaveLength(1);
expect(JSON.parse(socket.messages[0])).toEqual({
type: "app-data.resync",
- threadId: "thread-1",
- appId: "status",
+ applicationId: "app_status",
});
});
diff --git a/apps/server/test/internal/internal-app-data-change.test.ts b/apps/server/test/internal/internal-app-data-change.test.ts
index 39b10a2f1..4e5296fbc 100644
--- a/apps/server/test/internal/internal-app-data-change.test.ts
+++ b/apps/server/test/internal/internal-app-data-change.test.ts
@@ -1,38 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { internalAuthHeaders } from "../helpers/commands.js";
import { readJson } from "../helpers/json.js";
-import {
- seedEnvironment,
- seedHostSession,
- seedProjectWithSource,
- seedThread,
-} from "../helpers/seed.js";
+import { seedHostSession } from "../helpers/seed.js";
import { withTestHarness } from "../helpers/test-app.js";
describe("internal app-data change route", () => {
- it("broadcasts daemon-reported app data changes for session-owned threads", async () => {
+ it("broadcasts daemon-reported app data changes", async () => {
await withTestHarness(async (harness) => {
- const { host, session } = seedHostSession(harness.deps, {
+ const { session } = seedHostSession(harness.deps, {
id: "host-app-data-change",
});
- const { project } = seedProjectWithSource(harness.deps, {
- hostId: host.id,
- });
- const environment = seedEnvironment(harness.deps, {
- hostId: host.id,
- projectId: project.id,
- path: "/tmp/app-data-change",
- status: "ready",
- });
- const thread = seedThread(harness.deps, {
- projectId: project.id,
- environmentId: environment.id,
- type: "manager",
- });
- const notifyThreadAppDataSpy = vi.spyOn(
- harness.hub,
- "notifyThreadAppData",
- );
+ const notifyAppDataSpy = vi.spyOn(harness.hub, "notifyAppData");
const response = await harness.app.request(
"/internal/session/app-data-change",
@@ -41,8 +19,7 @@ describe("internal app-data change route", () => {
headers: internalAuthHeaders(harness),
body: JSON.stringify({
sessionId: session.id,
- threadId: thread.id,
- appId: "status",
+ applicationId: "app_status",
path: "state.json",
value: { workers: [] },
deleted: false,
@@ -52,10 +29,9 @@ describe("internal app-data change route", () => {
);
expect(response.status).toBe(200);
- expect(notifyThreadAppDataSpy).toHaveBeenCalledWith({
+ expect(notifyAppDataSpy).toHaveBeenCalledWith({
type: "app-data.changed",
- threadId: thread.id,
- appId: "status",
+ applicationId: "app_status",
path: "state.json",
value: { workers: [] },
deleted: false,
@@ -64,32 +40,9 @@ describe("internal app-data change route", () => {
});
});
- it("rejects daemon-reported app data changes for threads owned by another host", async () => {
+ it("rejects daemon-reported app data changes for unknown sessions", async () => {
await withTestHarness(async (harness) => {
- const hostA = seedHostSession(harness.deps, {
- id: "host-app-data-change-a",
- });
- const hostB = seedHostSession(harness.deps, {
- id: "host-app-data-change-b",
- });
- const { project } = seedProjectWithSource(harness.deps, {
- hostId: hostB.host.id,
- });
- const environment = seedEnvironment(harness.deps, {
- hostId: hostB.host.id,
- projectId: project.id,
- path: "/tmp/app-data-change-other",
- status: "ready",
- });
- const thread = seedThread(harness.deps, {
- projectId: project.id,
- environmentId: environment.id,
- type: "manager",
- });
- const notifyThreadAppDataSpy = vi.spyOn(
- harness.hub,
- "notifyThreadAppData",
- );
+ const notifyAppDataSpy = vi.spyOn(harness.hub, "notifyAppData");
const response = await harness.app.request(
"/internal/session/app-data-change",
@@ -97,9 +50,8 @@ describe("internal app-data change route", () => {
method: "POST",
headers: internalAuthHeaders(harness),
body: JSON.stringify({
- sessionId: hostA.session.id,
- threadId: thread.id,
- appId: "status",
+ sessionId: "missing-session",
+ applicationId: "app_status",
path: "state.json",
value: null,
deleted: true,
@@ -108,37 +60,20 @@ describe("internal app-data change route", () => {
},
);
- expect(response.status).toBe(403);
+ expect(response.status).toBe(401);
await expect(readJson(response)).resolves.toMatchObject({
- code: "invalid_request",
+ code: "inactive_session",
});
- expect(notifyThreadAppDataSpy).not.toHaveBeenCalled();
+ expect(notifyAppDataSpy).not.toHaveBeenCalled();
});
});
it("broadcasts daemon-requested app data resync hints", async () => {
await withTestHarness(async (harness) => {
- const { host, session } = seedHostSession(harness.deps, {
+ const { 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 notifyAppDataSpy = vi.spyOn(harness.hub, "notifyAppData");
const response = await harness.app.request(
"/internal/session/app-data-resync",
@@ -147,17 +82,15 @@ describe("internal app-data change route", () => {
headers: internalAuthHeaders(harness),
body: JSON.stringify({
sessionId: session.id,
- threadId: thread.id,
- appId: "status",
+ applicationId: "app_status",
}),
},
);
expect(response.status).toBe(200);
- expect(notifyThreadAppDataSpy).toHaveBeenCalledWith({
+ expect(notifyAppDataSpy).toHaveBeenCalledWith({
type: "app-data.resync",
- threadId: thread.id,
- appId: "status",
+ applicationId: "app_status",
});
});
});
diff --git a/apps/server/test/public/public-apps.test.ts b/apps/server/test/public/public-apps.test.ts
new file mode 100644
index 000000000..ce901989d
--- /dev/null
+++ b/apps/server/test/public/public-apps.test.ts
@@ -0,0 +1,229 @@
+import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+import {
+ resolveApplicationAssetsPath,
+ resolveApplicationDataPath,
+ resolveApplicationManifestPath,
+ resolveApplicationPath,
+ resolveAppsRootPath,
+} from "@bb/config/app-storage-paths";
+import {
+ appDetailSchema,
+ appSummarySchema,
+ type AppManifest,
+} from "@bb/server-contract";
+import { describe, expect, it } from "vitest";
+import { readJson } from "../helpers/json.js";
+import {
+ seedEnvironment,
+ seedHostSession,
+ seedProjectWithSource,
+ seedThread,
+ seedThreadRuntimeState,
+} from "../helpers/seed.js";
+import { withTestHarness } from "../helpers/test-app.js";
+
+const VALID_APP_ID = "app_valid";
+const VALID_MANIFEST: AppManifest = {
+ manifestVersion: 1,
+ id: VALID_APP_ID,
+ name: "Valid App",
+ icon: "ListTodo",
+ entry: "index.html",
+ capabilities: ["data", "message"],
+};
+
+async function writeApplication(
+ dataDir: string,
+ manifest: AppManifest,
+): Promise {
+ await mkdir(resolveApplicationAssetsPath(dataDir, manifest.id), {
+ recursive: true,
+ });
+ await mkdir(resolveApplicationDataPath(dataDir, manifest.id), {
+ recursive: true,
+ });
+ await writeFile(
+ resolveApplicationManifestPath(dataDir, manifest.id),
+ `${JSON.stringify(manifest, null, 2)}\n`,
+ "utf8",
+ );
+ await writeFile(
+ path.join(resolveApplicationAssetsPath(dataDir, manifest.id), "index.html"),
+ "Valid App ",
+ "utf8",
+ );
+}
+
+describe("public global app routes", () => {
+ it("lists and gets valid global apps by application id", async () => {
+ await withTestHarness(async (harness) => {
+ await writeApplication(harness.config.dataDir, VALID_MANIFEST);
+ await mkdir(
+ resolveApplicationPath(harness.config.dataDir, "app_invalid"),
+ { recursive: true },
+ );
+ await writeFile(
+ resolveApplicationManifestPath(harness.config.dataDir, "app_invalid"),
+ JSON.stringify({
+ ...VALID_MANIFEST,
+ id: "app_other",
+ name: "Invalid",
+ }),
+ "utf8",
+ );
+
+ const listResponse = await harness.app.request("/api/v1/apps");
+ expect(listResponse.status).toBe(200);
+ const apps = appSummarySchema.array().parse(await readJson(listResponse));
+ expect(apps.map((app) => app.applicationId)).toEqual([VALID_APP_ID]);
+
+ const getResponse = await harness.app.request(
+ `/api/v1/apps/${VALID_APP_ID}`,
+ );
+ expect(getResponse.status).toBe(200);
+ const detail = appDetailSchema.parse(await readJson(getResponse));
+ expect(detail).toMatchObject({
+ applicationId: VALID_APP_ID,
+ name: "Valid App",
+ appRootPath: resolveApplicationPath(
+ harness.config.dataDir,
+ VALID_APP_ID,
+ ),
+ appDataPath: resolveApplicationDataPath(
+ harness.config.dataDir,
+ VALID_APP_ID,
+ ),
+ });
+ });
+ });
+
+ it("returns app_missing and invalid_manifest distinctly", async () => {
+ await withTestHarness(async (harness) => {
+ const missingResponse = await harness.app.request(
+ "/api/v1/apps/app_missing",
+ );
+ expect(missingResponse.status).toBe(404);
+ await expect(readJson(missingResponse)).resolves.toMatchObject({
+ code: "app_missing",
+ });
+
+ await mkdir(
+ resolveApplicationPath(harness.config.dataDir, "app_invalid"),
+ { recursive: true },
+ );
+ await writeFile(
+ resolveApplicationManifestPath(harness.config.dataDir, "app_invalid"),
+ JSON.stringify({ ...VALID_MANIFEST, id: "app_other" }),
+ "utf8",
+ );
+
+ const invalidResponse = await harness.app.request(
+ "/api/v1/apps/app_invalid",
+ );
+ expect(invalidResponse.status).toBe(422);
+ await expect(readJson(invalidResponse)).resolves.toMatchObject({
+ code: "invalid_manifest",
+ });
+ });
+ });
+
+ it("creates and deletes apps atomically on the filesystem", async () => {
+ await withTestHarness(async (harness) => {
+ const createResponse = await harness.app.request("/api/v1/apps", {
+ method: "POST",
+ body: JSON.stringify({ name: "Created App" }),
+ });
+ expect(createResponse.status).toBe(201);
+ const created = appDetailSchema.parse(await readJson(createResponse));
+ expect(created.applicationId).toMatch(/^app_[A-Za-z0-9_-]+$/u);
+ expect(created.name).toBe("Created App");
+
+ const manifestText = await readFile(
+ resolveApplicationManifestPath(
+ harness.config.dataDir,
+ created.applicationId,
+ ),
+ "utf8",
+ );
+ expect(JSON.parse(manifestText)).toMatchObject({
+ id: created.applicationId,
+ name: "Created App",
+ });
+ const appRootEntries = await readdir(resolveAppsRootPath(harness.config.dataDir));
+ expect(appRootEntries.some((entry) => entry.startsWith(".tmp-app_"))).toBe(
+ false,
+ );
+
+ const deleteResponse = await harness.app.request(
+ `/api/v1/apps/${created.applicationId}`,
+ { method: "DELETE" },
+ );
+ expect(deleteResponse.status).toBe(200);
+ const deletedGetResponse = await harness.app.request(
+ `/api/v1/apps/${created.applicationId}`,
+ );
+ expect(deletedGetResponse.status).toBe(404);
+ });
+ });
+
+ it("requires an explicit message target outside an iframe session", async () => {
+ await withTestHarness(async (harness) => {
+ await writeApplication(harness.config.dataDir, VALID_MANIFEST);
+
+ const response = await harness.app.request(
+ `/api/v1/apps/${VALID_APP_ID}/message`,
+ {
+ method: "POST",
+ body: JSON.stringify({ payload: "hello" }),
+ },
+ );
+
+ expect(response.status).toBe(400);
+ await expect(readJson(response)).resolves.toMatchObject({
+ code: "message_target_required",
+ });
+ });
+ });
+
+ it("accepts explicit targetThreadId for non-iframe app messages", async () => {
+ await withTestHarness(async (harness) => {
+ await writeApplication(harness.config.dataDir, VALID_MANIFEST);
+ const { host } = seedHostSession(harness.deps, {
+ id: "host-app-message",
+ });
+ const { project } = seedProjectWithSource(harness.deps, {
+ hostId: host.id,
+ });
+ const environment = seedEnvironment(harness.deps, {
+ hostId: host.id,
+ projectId: project.id,
+ path: "/tmp/app-message",
+ status: "ready",
+ });
+ const thread = seedThread(harness.deps, {
+ projectId: project.id,
+ environmentId: environment.id,
+ });
+ seedThreadRuntimeState(harness.deps, {
+ environmentId: environment.id,
+ providerThreadId: "provider-app-message",
+ threadId: thread.id,
+ });
+
+ const response = await harness.app.request(
+ `/api/v1/apps/${VALID_APP_ID}/message`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ payload: "hello",
+ targetThreadId: thread.id,
+ }),
+ },
+ );
+
+ expect(response.status).toBe(202);
+ });
+ });
+});
diff --git a/apps/server/test/public/public-thread-apps.test.ts b/apps/server/test/public/public-thread-apps.test.ts
deleted file mode 100644
index e47e37014..000000000
--- a/apps/server/test/public/public-thread-apps.test.ts
+++ /dev/null
@@ -1,1375 +0,0 @@
-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, withTestHarness } 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 () => {
- await withTestHarness(async (harness) => {
- 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" });
- });
- });
-
- 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 () => {
- await withTestHarness(async (harness) => {
- 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"),
- });
- });
- });
-
- it("returns an invalid-manifest error when app detail manifest validation fails", async () => {
- await withTestHarness(async (harness) => {
- 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");
- });
- });
-
- it("returns an invalid-manifest error before serving app assets", async () => {
- await withTestHarness(async (harness) => {
- 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"),
- });
- });
- });
-
- it("serves HTML app entries with capability-scoped window.bb injection", async () => {
- await withTestHarness(async (harness) => {
- 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");
- });
- });
-
- it("serves flat app asset URLs from the internal assets directory", async () => {
- await withTestHarness(async (harness) => {
- 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');");
- });
- });
-
- it("serves nested flat app asset URLs without collapsing path segments", async () => {
- await withTestHarness(async (harness) => {
- 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;",
- );
- });
- });
-
- 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"),
- 'bb shell',
- "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 () => {
- await withTestHarness(async (harness) => {
- 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 });
- });
- });
-
- it("lists app data subtree prefixes when the prefix is a directory", async () => {
- await withTestHarness(async (harness) => {
- 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,
- },
- ],
- },
- );
- });
- });
-
- it("serves top-level logo icons and 404s built-in icons", async () => {
- await withTestHarness(async (harness) => {
- 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);
- });
- });
-
- it("does not serve logo symlinks omitted by the daemon path listing", async () => {
- await withTestHarness(async (harness) => {
- 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);
- });
- });
-
- it("scaffolds status-template apps through the server lifecycle route", async () => {
- await withTestHarness(async (harness) => {
- 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"],
- });
- });
- });
-
- it("scaffolds blank-template apps with the bb-styled index.html", async () => {
- await withTestHarness(async (harness) => {
- 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 keeps the blank scaffold aligned with app guidance.
- expect(html).toContain(
- "No web server or build step needed.",
- );
- // 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"],
- });
- });
- });
-
- it("HTML-escapes the app name in the blank scaffold to block XSS", async () => {
- await withTestHarness(async (harness) => {
- 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);
- });
- });
-});
diff --git a/apps/server/test/public/public-threads.manager-and-ownership.test.ts b/apps/server/test/public/public-threads.manager-and-ownership.test.ts
index 05af96067..944c869c7 100644
--- a/apps/server/test/public/public-threads.manager-and-ownership.test.ts
+++ b/apps/server/test/public/public-threads.manager-and-ownership.test.ts
@@ -471,7 +471,7 @@ describe("public thread manager and ownership routes", () => {
name: "default",
files: {
"PREFERENCES.md": "default prefs\n",
- "apps/status/data/state.json": '{"prs":[]}\n',
+ "notes/state.json": '{"prs":[]}\n',
},
});
@@ -529,8 +529,11 @@ describe("public thread manager and ownership routes", () => {
readFile(path.join(storagePath, "PREFERENCES.md"), "utf8"),
).resolves.toBe("default prefs\n");
await expect(
- readFile(path.join(storagePath, "apps/status/data/state.json"), "utf8"),
+ readFile(path.join(storagePath, "notes/state.json"), "utf8"),
).resolves.toBe('{"prs":[]}\n');
+ await expect(
+ stat(path.join(storagePath, "apps", "status", "manifest.json")),
+ ).rejects.toThrow();
} finally {
await harness.cleanup();
await rm(dataDir, { recursive: true, force: true });
@@ -1107,17 +1110,8 @@ describe("public thread manager and ownership routes", () => {
stat(path.join(storagePath, "PREFERENCES.md")),
).rejects.toThrow();
await expect(
- readFile(path.join(storagePath, "apps/status/manifest.json"), "utf8"),
- ).resolves.toContain('"id": "status"');
- await expect(
- readFile(
- path.join(storagePath, "apps/status/assets/index.html"),
- "utf8",
- ),
- ).resolves.toContain("Status ");
- await expect(
- readFile(path.join(storagePath, "apps/status/data/state.json"), "utf8"),
- ).resolves.toBe("{}\n");
+ stat(path.join(storagePath, "apps", "status", "manifest.json")),
+ ).rejects.toThrow();
} finally {
await harness.cleanup();
await rm(dataDir, { recursive: true, force: true });
diff --git a/apps/server/test/services/threads/app-client-script.test.ts b/apps/server/test/services/threads/app-client-script.test.ts
index c2ad6b769..f3752e119 100644
--- a/apps/server/test/services/threads/app-client-script.test.ts
+++ b/apps/server/test/services/threads/app-client-script.test.ts
@@ -34,11 +34,13 @@ interface DeferredResponse {
}
const bootstrap: AppClientBootstrap = {
- appId: "status",
+ appId: "app_status",
+ applicationId: "app_status",
+ appSessionToken: "appsess_test",
capabilities: ["data", "message"],
- threadId: "thr_123",
- dataUrl: "/api/v1/threads/thr_123/apps/status/data",
- messageUrl: "/api/v1/threads/thr_123/apps/status/message",
+ dataUrl: "/api/v1/apps/app_status/data",
+ messageUrl: "/api/v1/apps/app_status/message",
+ targetThreadId: "thr_123",
wsUrl: "ws://server/ws",
};
@@ -147,10 +149,10 @@ describe("app client script", () => {
expect(JSON.parse(socket.messages[0] ?? "")).toEqual({
type: "subscribe",
entity: "thread",
- id: "thr_123:app:status:data",
+ id: "app_status:data",
});
expect(fetchMock).toHaveBeenCalledWith(
- "/api/v1/threads/thr_123/apps/status/data",
+ "/api/v1/apps/app_status/data",
expect.objectContaining({ method: "GET" }),
);
});
@@ -194,12 +196,12 @@ describe("app client script", () => {
{
type: "subscribe",
entity: "thread",
- id: "thr_123:app:status:data",
+ id: "app_status:data",
},
{
type: "unsubscribe",
entity: "thread",
- id: "thr_123:app:status:data",
+ id: "app_status:data",
},
]);
});
@@ -248,8 +250,7 @@ describe("app client script", () => {
await flushPromises();
socket.emit({
type: "app-data.resync",
- threadId: "thr_123",
- appId: "status",
+ applicationId: "app_status",
});
await vi.waitFor(() => {
expect(callback).toHaveBeenCalledTimes(2);
diff --git a/apps/server/test/threads/manager-storage-templates.test.ts b/apps/server/test/threads/manager-storage-templates.test.ts
index 547d46ffb..573fa68d6 100644
--- a/apps/server/test/threads/manager-storage-templates.test.ts
+++ b/apps/server/test/threads/manager-storage-templates.test.ts
@@ -18,7 +18,6 @@ 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, withTestHarness } from "../helpers/test-app.js";
@@ -76,42 +75,6 @@ async function createSeedHarness(): Promise {
};
}
-async function readBundledManifestJson(): Promise {
- return readFile(
- new URL(
- "../../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 {
@@ -169,7 +132,7 @@ describe("manager storage templates", () => {
});
});
- it("seeds the bundled status app when default resolves and no user template directory exists", async () => {
+ it("does not seed hidden default files when no user template directory exists", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
try {
const threadStoragePath = await seedStorage({
@@ -180,7 +143,7 @@ describe("manager storage templates", () => {
threadId: "thr-default-fallback",
});
- await expectBundledStatusAppSeeded(threadStoragePath);
+ await expect(stat(threadStoragePath)).rejects.toThrow();
await expect(
stat(managerTemplateRootPath({ dataDir })),
).rejects.toThrow();
@@ -190,7 +153,7 @@ describe("manager storage templates", () => {
}
});
- it("overlays the bundled status app on top of user-authored default files", async () => {
+ it("copies user-authored default template files", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
try {
await writeManagerTemplateSet({
@@ -198,6 +161,7 @@ describe("manager storage templates", () => {
name: DEFAULT_MANAGER_TEMPLATE_NAME,
files: {
"USER.md": "user notes\n",
+ "notes/state.json": '{"prs":[]}\n',
},
});
@@ -212,25 +176,41 @@ describe("manager storage templates", () => {
await expect(
readFile(path.join(threadStoragePath, "USER.md"), "utf8"),
).resolves.toBe("user notes\n");
- await expectBundledStatusAppSeeded(threadStoragePath);
+ await expect(
+ readFile(path.join(threadStoragePath, "notes/state.json"), "utf8"),
+ ).resolves.toBe('{"prs":[]}\n');
+ await expect(
+ stat(path.join(threadStoragePath, "apps", "status", "manifest.json")),
+ ).rejects.toThrow();
} 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 () => {
+ it("does not overwrite files that already exist in thread storage", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
try {
await writeManagerTemplateSet({
dataDir,
name: DEFAULT_MANAGER_TEMPLATE_NAME,
files: {
- "apps/status/manifest.json": '{"user":true}\n',
+ "USER.md": "template notes\n",
},
});
+ const threadStoragePath = path.join(
+ dataDir,
+ "thread-storage",
+ "thr-user-overrides",
+ );
+ await mkdir(threadStoragePath, { recursive: true });
+ await writeFile(
+ path.join(threadStoragePath, "USER.md"),
+ "existing notes\n",
+ "utf8",
+ );
- const threadStoragePath = await seedStorage({
+ await seedStorage({
dataDir,
explicitTemplateName: null,
harness,
@@ -239,30 +219,15 @@ describe("manager storage templates", () => {
});
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);
+ readFile(path.join(threadStoragePath, "USER.md"), "utf8"),
+ ).resolves.toBe("existing notes\n");
} finally {
await harness.cleanup();
await rm(dataDir, { recursive: true, force: true });
}
});
- it("overlays the bundled status app even when the default template directory is empty", async () => {
+ it("creates an empty storage directory when the default template directory is empty", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
try {
await writeManagerTemplateSet({
@@ -279,14 +244,18 @@ describe("manager storage templates", () => {
threadId: "thr-empty-default",
});
- await expectBundledStatusAppSeeded(threadStoragePath);
+ const storageStat = await stat(threadStoragePath);
+ expect(storageStat.isDirectory()).toBe(true);
+ await expect(
+ stat(path.join(threadStoragePath, "apps", "status", "manifest.json")),
+ ).rejects.toThrow();
} finally {
await harness.cleanup();
await rm(dataDir, { recursive: true, force: true });
}
});
- it("warns and still overlays bundled status when active points to a missing non-default template", async () => {
+ it("warns and seeds nothing when active points to a missing non-default template", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
const logger = {
...testLogger,
@@ -308,13 +277,13 @@ describe("manager storage templates", () => {
threadId: "thr-missing-active",
});
- await expectBundledStatusAppSeeded(threadStoragePath);
+ await expect(stat(threadStoragePath)).rejects.toThrow();
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
templateName: MINE_MANAGER_TEMPLATE_NAME,
threadId: "thr-missing-active",
}),
- "Manager template directory is missing; overlaying bundled seed only",
+ "Manager template directory is missing; no template files were seeded",
);
} finally {
await harness.cleanup();
@@ -322,7 +291,7 @@ describe("manager storage templates", () => {
}
});
- it("warns and still overlays bundled status when an explicit non-default template is missing", async () => {
+ it("warns and seeds nothing when an explicit non-default template is missing", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
const logger = {
...testLogger,
@@ -339,13 +308,13 @@ describe("manager storage templates", () => {
threadId: "thr-missing-explicit",
});
- await expectBundledStatusAppSeeded(threadStoragePath);
+ await expect(stat(threadStoragePath)).rejects.toThrow();
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
templateName: MINE_MANAGER_TEMPLATE_NAME,
threadId: "thr-missing-explicit",
}),
- "Manager template directory is missing; overlaying bundled seed only",
+ "Manager template directory is missing; no template files were seeded",
);
} finally {
await harness.cleanup();
@@ -353,7 +322,7 @@ describe("manager storage templates", () => {
}
});
- it("seeds from the active non-default template and overlays bundled status when that directory exists", async () => {
+ it("seeds from the active non-default template when that directory exists", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
try {
await writeActiveManagerTemplate({
@@ -389,7 +358,9 @@ describe("manager storage templates", () => {
"utf8",
),
).resolves.toBe("{}\n");
- await expectBundledStatusAppSeeded(threadStoragePath);
+ await expect(
+ stat(path.join(threadStoragePath, "apps", "status", "manifest.json")),
+ ).rejects.toThrow();
} 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
deleted file mode 100644
index 9ea6c3374..000000000
--- a/docs/migrating-status-to-apps.md
+++ /dev/null
@@ -1,199 +0,0 @@
-# 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. Apps are served directly from these
-static files, so there is no app-specific web server, npm install, or build
-step requirement.
-
----
-
-## 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 are moving existing Vite build output, set
-`build.assetsDir = ""` so emitted CSS/JS/assets sit alongside `index.html`
-inside `assets/`; otherwise keep the app as plain static files.
-
-### 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/config/package.json b/packages/config/package.json
index 3f53eb5a5..4e9770c48 100644
--- a/packages/config/package.json
+++ b/packages/config/package.json
@@ -97,6 +97,11 @@
"source": "./src/bb-app-managed-config.ts",
"types": "./src/bb-app-managed-config.ts",
"default": "./src/bb-app-managed-config.ts"
+ },
+ "./app-storage-paths": {
+ "source": "./src/app-storage-paths.ts",
+ "types": "./src/app-storage-paths.ts",
+ "default": "./src/app-storage-paths.ts"
}
},
"scripts": {
diff --git a/packages/config/src/app-storage-paths.ts b/packages/config/src/app-storage-paths.ts
new file mode 100644
index 000000000..c7fa5f857
--- /dev/null
+++ b/packages/config/src/app-storage-paths.ts
@@ -0,0 +1,34 @@
+import { join } from "node:path";
+import type { ApplicationId } from "@bb/domain";
+
+export function resolveAppsRootPath(dataDir: string): string {
+ return join(dataDir, "apps");
+}
+
+export function resolveApplicationPath(
+ dataDir: string,
+ applicationId: ApplicationId,
+): string {
+ return join(resolveAppsRootPath(dataDir), applicationId);
+}
+
+export function resolveApplicationManifestPath(
+ dataDir: string,
+ applicationId: ApplicationId,
+): string {
+ return join(resolveApplicationPath(dataDir, applicationId), "manifest.json");
+}
+
+export function resolveApplicationAssetsPath(
+ dataDir: string,
+ applicationId: ApplicationId,
+): string {
+ return join(resolveApplicationPath(dataDir, applicationId), "assets");
+}
+
+export function resolveApplicationDataPath(
+ dataDir: string,
+ applicationId: ApplicationId,
+): string {
+ return join(resolveApplicationPath(dataDir, applicationId), "data");
+}
diff --git a/packages/domain/src/apps.ts b/packages/domain/src/apps.ts
index 5f72ec99a..b513b6d8f 100644
--- a/packages/domain/src/apps.ts
+++ b/packages/domain/src/apps.ts
@@ -1,13 +1,14 @@
import { z } from "zod";
+const APPLICATION_ID_PATTERN = /^app_[A-Za-z0-9_-]{1,80}$/u;
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 applicationIdSchema = z.string().regex(APPLICATION_ID_PATTERN);
+export type ApplicationId = z.infer;
export const appDataPathSchema = z.string().superRefine((value, context) => {
if (
diff --git a/packages/domain/src/change-kinds.ts b/packages/domain/src/change-kinds.ts
index c0e410d1b..0524352d9 100644
--- a/packages/domain/src/change-kinds.ts
+++ b/packages/domain/src/change-kinds.ts
@@ -7,6 +7,7 @@ export const REALTIME_ENTITIES = [
"environment",
"host",
"system",
+ "app",
] as const;
export type RealtimeEntity = (typeof REALTIME_ENTITIES)[number];
export const realtimeEntitySchema = z.enum(REALTIME_ENTITIES);
diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts
index 90b402e28..68726c138 100644
--- a/packages/domain/src/index.ts
+++ b/packages/domain/src/index.ts
@@ -59,8 +59,8 @@ 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 { appDataPathSchema, applicationIdSchema } from "./apps.js";
+export type { AppDataPath, ApplicationId } from "./apps.js";
export { threadDynamicContextFileStatusValues } from "./manager-dynamic-context.js";
export type { ThreadDynamicContextFileStatus } from "./manager-dynamic-context.js";
diff --git a/packages/host-daemon-contract/src/index.ts b/packages/host-daemon-contract/src/index.ts
index e7941f378..3e144cec7 100644
--- a/packages/host-daemon-contract/src/index.ts
+++ b/packages/host-daemon-contract/src/index.ts
@@ -200,6 +200,7 @@ export {
hostDaemonSessionCloseReasonSchema,
hostDaemonSessionOpenRequestSchema,
hostDaemonSessionOpenResponseSchema,
+ hostDaemonTrackedApplicationDataTargetSchema,
hostDaemonTerminalOutputChunkSchema,
hostDaemonTrackedThreadTargetSchema,
hostDaemonToolCallRequestSchema,
@@ -238,6 +239,7 @@ export type {
HostDaemonSessionCloseReason,
HostDaemonSessionOpenRequest,
HostDaemonSessionOpenResponse,
+ HostDaemonTrackedApplicationDataTarget,
HostDaemonTrackedThreadTarget,
HostDaemonToolCallRequest,
HostDaemonToolCallResponse,
diff --git a/packages/host-daemon-contract/src/session.ts b/packages/host-daemon-contract/src/session.ts
index fb8542878..d6125d82b 100644
--- a/packages/host-daemon-contract/src/session.ts
+++ b/packages/host-daemon-contract/src/session.ts
@@ -8,7 +8,7 @@ import {
pendingInteractionCreateSchema,
pendingInteractionStatusSchema,
appDataPathSchema,
- appIdSchema,
+ applicationIdSchema,
terminalColsSchema,
terminalDataBase64Schema,
terminalRowsSchema,
@@ -55,6 +55,14 @@ export type HostDaemonTrackedThreadTarget = z.infer<
typeof hostDaemonTrackedThreadTargetSchema
>;
+export const hostDaemonTrackedApplicationDataTargetSchema = z.object({
+ applicationId: applicationIdSchema,
+ appDataPath: z.string().min(1),
+});
+export type HostDaemonTrackedApplicationDataTarget = z.infer<
+ typeof hostDaemonTrackedApplicationDataTargetSchema
+>;
+
export const hostDaemonSessionOpenRequestSchema = z.object({
hostId: z.string().min(1),
instanceId: z.string().min(1),
@@ -98,6 +106,9 @@ export const hostDaemonSessionOpenResponseSchema = z
heartbeatIntervalMs: z.number().int().positive(),
leaseTimeoutMs: z.number().int().positive(),
trackedThreadTargets: z.array(hostDaemonTrackedThreadTargetSchema),
+ trackedApplicationDataTargets: z.array(
+ hostDaemonTrackedApplicationDataTargetSchema,
+ ),
retiredEnvironmentIds: z.array(z.string().min(1)).default([]),
})
.strict();
@@ -213,8 +224,7 @@ export type HostDaemonEnvironmentChangePayload = z.infer<
const hostDaemonAppDataChangePayloadBaseSchema = z
.object({
- threadId: z.string().min(1),
- appId: appIdSchema,
+ applicationId: applicationIdSchema,
path: appDataPathSchema,
value: jsonValueSchema.nullable(),
deleted: z.boolean(),
@@ -266,8 +276,7 @@ export type HostDaemonAppDataChangeRequest = z.infer<
export const hostDaemonAppDataResyncPayloadSchema = z
.object({
- threadId: z.string().min(1),
- appId: appIdSchema,
+ applicationId: applicationIdSchema,
})
.strict();
export type HostDaemonAppDataResyncPayload = z.infer<
diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts
index 2231f010f..84a4e3ca5 100644
--- a/packages/host-daemon-contract/test/contract.test.ts
+++ b/packages/host-daemon-contract/test/contract.test.ts
@@ -796,13 +796,13 @@ describe("host-daemon command schemas", () => {
expect(
hostDaemonOnlineRpcCommandSchema.parse({
type: "host.read_file_relative",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/apps/status/assets",
+ rootPath: "/tmp/bb-data/apps/app_demo/assets",
path: "logo.png",
dotfiles: "deny",
}),
).toMatchObject({
type: "host.read_file_relative",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/apps/status/assets",
+ rootPath: "/tmp/bb-data/apps/app_demo/assets",
path: "logo.png",
dotfiles: "deny",
});
@@ -810,7 +810,7 @@ describe("host-daemon command schemas", () => {
expect(
hostDaemonCommandSchema.parse({
type: "host.write_file_relative",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/apps/status/data",
+ rootPath: "/tmp/bb-data/apps/app_demo/data",
path: "state.json",
dotfiles: "deny",
content: "[1,2,3]\n",
@@ -824,7 +824,7 @@ describe("host-daemon command schemas", () => {
expect(
hostDaemonCommandSchema.parse({
type: "host.delete_file_relative",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/apps/status/data",
+ rootPath: "/tmp/bb-data/apps/app_demo/data",
path: "state.json",
dotfiles: "deny",
}),
@@ -1088,7 +1088,7 @@ describe("host-daemon command schemas", () => {
expect(() =>
hostDaemonOnlineRpcCommandSchema.parse({
type: "host.read_file_relative",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/apps/status/assets",
+ rootPath: "/tmp/bb-data/apps/app_demo/assets",
path: "logo.png",
}),
).toThrow();
diff --git a/packages/host-watcher/src/host-watcher-types.ts b/packages/host-watcher/src/host-watcher-types.ts
index f94b4ad0c..f86848c1c 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 { AppDataPath, AppId } from "@bb/domain";
+import type { AppDataPath, ApplicationId } from "@bb/domain";
import type { WorkspaceStatusWatchChangeKind } from "./watch-status-types.js";
export type HostObservedChange =
@@ -15,17 +15,17 @@ export type HostObservedChange =
threadId: string;
}
| {
- kind: "thread-app-data-changed";
- appId: AppId;
- environmentId: string;
+ kind: "application-storage-targets-changed";
+ }
+ | {
+ kind: "application-data-changed";
+ applicationId: ApplicationId;
+ appDataPath: string;
path: AppDataPath;
- threadId: string;
}
| {
- kind: "thread-app-data-resync";
- appId: AppId;
- environmentId: string;
- threadId: string;
+ kind: "application-data-resync";
+ applicationId: ApplicationId;
};
export type WorkspaceObservedChange = Extract<
@@ -34,12 +34,19 @@ export type WorkspaceObservedChange = Extract<
>;
export type ThreadStorageObservedChange = Extract<
+ HostObservedChange,
+ {
+ kind: "thread-storage-changed";
+ }
+>;
+
+export type ApplicationStorageObservedChange = Extract<
HostObservedChange,
{
kind:
- | "thread-storage-changed"
- | "thread-app-data-changed"
- | "thread-app-data-resync";
+ | "application-storage-targets-changed"
+ | "application-data-changed"
+ | "application-data-resync";
}
>;
@@ -58,7 +65,16 @@ export interface ThreadStorageWatchError {
threadId?: string;
}
-export type HostWatchError = WorkspaceWatchError | ThreadStorageWatchError;
+export interface ApplicationStorageWatchError {
+ kind: "application-storage-watch-error";
+ rootPath: string;
+ message: string;
+}
+
+export type HostWatchError =
+ | WorkspaceWatchError
+ | ThreadStorageWatchError
+ | ApplicationStorageWatchError;
export interface ThreadStorageWatchTarget {
environmentId: string;
@@ -79,11 +95,28 @@ export interface WatchThreadStorageRootArgs {
onWatchError: (error: ThreadStorageWatchError) => void;
}
+export interface ApplicationDataWatchTarget {
+ applicationId: ApplicationId;
+ appDataPath: string;
+}
+
+export interface WatchApplicationStorageRootArgs {
+ appsRootPath: string;
+ resolveApplicationTarget: (
+ applicationId: ApplicationId,
+ ) => ApplicationDataWatchTarget | null;
+ onChange: (event: ApplicationStorageObservedChange) => void;
+ onWatchError: (error: ApplicationStorageWatchError) => void;
+}
+
export interface HostWatcher {
watchWorkspace(args: WatchWorkspaceArgs): () => void | Promise;
watchThreadStorageRoot(
args: WatchThreadStorageRootArgs,
): () => void | Promise;
+ watchApplicationStorageRoot(
+ args: WatchApplicationStorageRootArgs,
+ ): () => void | Promise;
}
export interface CreateHostWatcherArgs {
diff --git a/packages/host-watcher/src/index.ts b/packages/host-watcher/src/index.ts
index 2eaa0f5bb..e61f5dfe3 100644
--- a/packages/host-watcher/src/index.ts
+++ b/packages/host-watcher/src/index.ts
@@ -8,8 +8,12 @@ export type {
HostObservedChange,
HostWatchError,
HostWatcher,
+ ApplicationDataWatchTarget,
+ ApplicationStorageObservedChange,
+ ApplicationStorageWatchError,
ThreadStorageWatchError,
ThreadStorageWatchTarget,
+ WatchApplicationStorageRootArgs,
WatchThreadStorageRootArgs,
WatchWorkspaceArgs,
WorkspaceWatchError,
diff --git a/packages/host-watcher/src/parcel-host-watcher.ts b/packages/host-watcher/src/parcel-host-watcher.ts
index 26988d2f2..15567198a 100644
--- a/packages/host-watcher/src/parcel-host-watcher.ts
+++ b/packages/host-watcher/src/parcel-host-watcher.ts
@@ -1,16 +1,19 @@
import path from "node:path";
import {
appDataPathSchema,
- appIdSchema,
+ applicationIdSchema,
type AppDataPath,
- type AppId,
+ type ApplicationId,
} from "@bb/domain";
import { watchPathChanges } from "./watch-path.js";
import { watchWorkspaceStatus } from "./watch-status.js";
import type {
HostWatcher,
+ ApplicationStorageObservedChange,
+ ApplicationDataWatchTarget,
ThreadStorageObservedChange,
ThreadStorageWatchTarget,
+ WatchApplicationStorageRootArgs,
WatchThreadStorageRootArgs,
WatchWorkspaceArgs,
} from "./host-watcher-types.js";
@@ -25,15 +28,23 @@ interface ThreadStoragePath {
threadId: string;
}
-interface ThreadAppDataPath {
- appId: AppId;
+interface ApplicationStoragePathArgs {
+ appsRootPath: string;
+ changedPath: string;
+}
+
+interface ApplicationStoragePath {
+ applicationId: ApplicationId | null;
+ parts: string[];
+}
+
+interface ApplicationDataPath {
+ applicationId: ApplicationId;
path: AppDataPath;
- threadId: string;
}
-interface ThreadAppDataRootPath {
- appId: AppId;
- threadId: string;
+interface ApplicationDataRootPath {
+ applicationId: ApplicationId;
}
interface CollectThreadStorageObservedChangesArgs {
@@ -42,6 +53,14 @@ interface CollectThreadStorageObservedChangesArgs {
resolveThreadTarget: (threadId: string) => ThreadStorageWatchTarget | null;
}
+interface CollectApplicationStorageObservedChangesArgs {
+ appsRootPath: string;
+ changedPaths: string[];
+ resolveApplicationTarget: (
+ applicationId: ApplicationId,
+ ) => ApplicationDataWatchTarget | null;
+}
+
function toThreadStoragePath(
args: ThreadStoragePathArgs,
): ThreadStoragePath | null {
@@ -67,41 +86,76 @@ function toThreadStoragePath(
};
}
-function isAppDataSubtreePath(path: ThreadStoragePath): boolean {
- return path.parts[1] === "apps" && path.parts[3] === "data";
+function toApplicationStoragePath(
+ args: ApplicationStoragePathArgs,
+): ApplicationStoragePath | null {
+ const relativePath = path.relative(args.appsRootPath, args.changedPath);
+ if (
+ relativePath.length === 0 ||
+ relativePath.startsWith("..") ||
+ path.isAbsolute(relativePath)
+ ) {
+ return null;
+ }
+ const parts = relativePath.split(path.sep).filter(Boolean);
+ const rawApplicationId = parts[0];
+ if (!rawApplicationId) {
+ return {
+ applicationId: null,
+ parts,
+ };
+ }
+ const parsed = applicationIdSchema.safeParse(rawApplicationId);
+ return {
+ applicationId: parsed.success ? parsed.data : null,
+ parts,
+ };
+}
+
+function isApplicationDataSubtreePath(path: ApplicationStoragePath): boolean {
+ return path.parts[1] === "data";
}
-function toThreadAppDataPath(
- path: ThreadStoragePath,
-): ThreadAppDataPath | null {
- const rootPath = toThreadAppDataRootPath(path);
- if (!rootPath || path.parts.length < 5) {
+function isApplicationManifestPath(path: ApplicationStoragePath): boolean {
+ return path.parts.length === 2 && path.parts[1] === "manifest.json";
+}
+
+function isApplicationFolderPath(path: ApplicationStoragePath): boolean {
+ return path.parts.length === 1;
+}
+
+function isIgnoredApplicationStorageEntry(path: ApplicationStoragePath): boolean {
+ const firstPart = path.parts[0] ?? "";
+ return (
+ firstPart.startsWith(".tmp-app_") || firstPart.startsWith(".delete-app_")
+ );
+}
+
+function toApplicationDataPath(
+ path: ApplicationStoragePath,
+): ApplicationDataPath | null {
+ const rootPath = toApplicationDataRootPath(path);
+ if (!rootPath || path.parts.length < 3) {
return null;
}
- const dataPath = appDataPathSchema.safeParse(path.parts.slice(4).join("/"));
+ const dataPath = appDataPathSchema.safeParse(path.parts.slice(2).join("/"));
if (!dataPath.success) {
return null;
}
return {
- appId: rootPath.appId,
- threadId: path.threadId,
+ applicationId: rootPath.applicationId,
path: dataPath.data,
};
}
-function toThreadAppDataRootPath(
- path: ThreadStoragePath,
-): ThreadAppDataRootPath | null {
- if (!isAppDataSubtreePath(path)) {
- return null;
- }
- const appId = appIdSchema.safeParse(path.parts[2]);
- if (!appId.success) {
+function toApplicationDataRootPath(
+ path: ApplicationStoragePath,
+): ApplicationDataRootPath | null {
+ if (!path.applicationId || !isApplicationDataSubtreePath(path)) {
return null;
}
return {
- appId: appId.data,
- threadId: path.threadId,
+ applicationId: path.applicationId,
};
}
@@ -109,8 +163,6 @@ export function collectThreadStorageObservedChanges(
args: CollectThreadStorageObservedChangesArgs,
): ThreadStorageObservedChange[] {
const storageChanges = new Map();
- const appDataChanges = new Map();
- const appDataResyncs = new Map();
for (const changedPath of args.changedPaths) {
const storagePath = toThreadStoragePath({
changedPath,
@@ -123,47 +175,71 @@ export function collectThreadStorageObservedChanges(
if (!target) {
continue;
}
- if (isAppDataSubtreePath(storagePath)) {
- const appDataRootPath = toThreadAppDataRootPath(storagePath);
- const appDataPath = toThreadAppDataPath(storagePath);
+ storageChanges.set(`${target.environmentId}:${target.threadId}`, target);
+ }
+
+ return Array.from(storageChanges.values()).map((target) => ({
+ kind: "thread-storage-changed",
+ environmentId: target.environmentId,
+ threadId: target.threadId,
+ }));
+}
+
+export function collectApplicationStorageObservedChanges(
+ args: CollectApplicationStorageObservedChangesArgs,
+): ApplicationStorageObservedChange[] {
+ let targetsChanged = false;
+ const appDataChanges = new Map();
+ const appDataResyncs = new Map();
+
+ for (const changedPath of args.changedPaths) {
+ const storagePath = toApplicationStoragePath({
+ changedPath,
+ appsRootPath: args.appsRootPath,
+ });
+ if (!storagePath || isIgnoredApplicationStorageEntry(storagePath)) {
+ continue;
+ }
+ if (
+ storagePath.applicationId === null ||
+ isApplicationFolderPath(storagePath) ||
+ isApplicationManifestPath(storagePath)
+ ) {
+ targetsChanged = true;
+ continue;
+ }
+ if (isApplicationDataSubtreePath(storagePath)) {
+ const appDataRootPath = toApplicationDataRootPath(storagePath);
+ const appDataPath = toApplicationDataPath(storagePath);
if (appDataPath) {
+ const target = args.resolveApplicationTarget(
+ appDataPath.applicationId,
+ );
+ if (!target) {
+ continue;
+ }
appDataChanges.set(
- `${target.environmentId}:${target.threadId}:${appDataPath.appId}:${appDataPath.path}`,
+ `${appDataPath.applicationId}:${appDataPath.path}`,
{
- kind: "thread-app-data-changed",
- appId: appDataPath.appId,
- environmentId: target.environmentId,
+ kind: "application-data-changed",
+ applicationId: appDataPath.applicationId,
+ appDataPath: target.appDataPath,
path: appDataPath.path,
- threadId: target.threadId,
},
);
} else if (appDataRootPath) {
- storageChanges.set(
- `${target.environmentId}:${target.threadId}`,
- target,
- );
- appDataResyncs.set(
- `${target.environmentId}:${target.threadId}:${appDataRootPath.appId}`,
- {
- kind: "thread-app-data-resync",
- appId: appDataRootPath.appId,
- environmentId: target.environmentId,
- threadId: target.threadId,
- },
- );
+ appDataResyncs.set(appDataRootPath.applicationId, {
+ kind: "application-data-resync",
+ applicationId: appDataRootPath.applicationId,
+ });
}
continue;
}
- storageChanges.set(`${target.environmentId}:${target.threadId}`, target);
}
- const observedChanges: ThreadStorageObservedChange[] = [];
- for (const target of storageChanges.values()) {
- observedChanges.push({
- kind: "thread-storage-changed",
- environmentId: target.environmentId,
- threadId: target.threadId,
- });
+ const observedChanges: ApplicationStorageObservedChange[] = [];
+ if (targetsChanged) {
+ observedChanges.push({ kind: "application-storage-targets-changed" });
}
observedChanges.push(...appDataChanges.values());
observedChanges.push(...appDataResyncs.values());
@@ -215,9 +291,34 @@ function watchThreadStorageRoot(
});
}
+function watchApplicationStorageRoot(
+ args: WatchApplicationStorageRootArgs,
+): () => Promise {
+ return watchPathChanges(args.appsRootPath, {
+ onChange: ({ changedPaths }) => {
+ const events = collectApplicationStorageObservedChanges({
+ changedPaths,
+ appsRootPath: args.appsRootPath,
+ resolveApplicationTarget: args.resolveApplicationTarget,
+ });
+ for (const event of events) {
+ args.onChange(event);
+ }
+ },
+ onWatchError: (error) => {
+ args.onWatchError({
+ kind: "application-storage-watch-error",
+ rootPath: error.rootPath,
+ message: error.message,
+ });
+ },
+ });
+}
+
export function createParcelHostWatcher(): HostWatcher {
return {
watchWorkspace,
watchThreadStorageRoot,
+ watchApplicationStorageRoot,
};
}
diff --git a/packages/host-watcher/test/thread-storage-watch.test.ts b/packages/host-watcher/test/thread-storage-watch.test.ts
index 8d487b0c4..32430d7d5 100644
--- a/packages/host-watcher/test/thread-storage-watch.test.ts
+++ b/packages/host-watcher/test/thread-storage-watch.test.ts
@@ -1,7 +1,13 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
-import type { ThreadStorageWatchTarget } from "../src/host-watcher-types.js";
-import { collectThreadStorageObservedChanges } from "../src/parcel-host-watcher.js";
+import type {
+ ApplicationDataWatchTarget,
+ ThreadStorageWatchTarget,
+} from "../src/host-watcher-types.js";
+import {
+ collectApplicationStorageObservedChanges,
+ collectThreadStorageObservedChanges,
+} from "../src/parcel-host-watcher.js";
function createResolver(
targets: Record,
@@ -9,6 +15,12 @@ function createResolver(
return (threadId) => targets[threadId] ?? null;
}
+function createApplicationResolver(
+ targets: Record,
+): (applicationId: string) => ApplicationDataWatchTarget | null {
+ return (applicationId) => targets[applicationId] ?? null;
+}
+
describe("thread storage watcher classification", () => {
it("emits broad storage changes for ordinary thread storage changes", () => {
const rootPath = path.join("/tmp", "thread-storage");
@@ -46,78 +58,80 @@ describe("thread storage watcher classification", () => {
]);
});
- it("emits targeted app data changes without broad storage changes", () => {
- const rootPath = path.join("/tmp", "thread-storage");
- const changes = collectThreadStorageObservedChanges({
- threadStorageRootPath: rootPath,
+ it("emits app storage target refreshes for app folders and manifests", () => {
+ const rootPath = path.join("/tmp", "apps");
+ const changes = collectApplicationStorageObservedChanges({
+ appsRootPath: rootPath,
changedPaths: [
- 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_unknown",
- "apps",
- "status",
- "data",
- "state.json",
- ),
+ path.join(rootPath, "app_status", "manifest.json"),
+ path.join(rootPath, "app_new"),
+ path.join(rootPath, "bad.app", "data", "state.json"),
+ path.join(rootPath, ".tmp-app_app_status-abc", "manifest.json"),
+ path.join(rootPath, ".delete-app_app_status-abc", "manifest.json"),
],
- resolveThreadTarget: createResolver({
- thr_one: {
- environmentId: "env_one",
- threadId: "thr_one",
+ resolveApplicationTarget: createApplicationResolver({}),
+ });
+
+ expect(changes).toEqual([
+ { kind: "application-storage-targets-changed" },
+ ]);
+ });
+
+ it("emits targeted app data changes", () => {
+ const rootPath = path.join("/tmp", "apps");
+ const changes = collectApplicationStorageObservedChanges({
+ appsRootPath: rootPath,
+ changedPaths: [
+ path.join(rootPath, "app_status", "data", "state.json"),
+ path.join(rootPath, "app_status", "data", "state.json"),
+ path.join(rootPath, "app_kanban", "data", "cards", "1"),
+ path.join(rootPath, "app_unknown", "data", "state.json"),
+ path.join(rootPath, ".tmp-app_app_status-abc", "data", "state.json"),
+ ],
+ resolveApplicationTarget: createApplicationResolver({
+ app_status: {
+ applicationId: "app_status",
+ appDataPath: path.join(rootPath, "app_status", "data"),
+ },
+ app_kanban: {
+ applicationId: "app_kanban",
+ appDataPath: path.join(rootPath, "app_kanban", "data"),
},
}),
});
expect(changes).toEqual([
{
- kind: "thread-app-data-changed",
- appId: "status",
- environmentId: "env_one",
+ kind: "application-data-changed",
+ applicationId: "app_status",
+ appDataPath: path.join(rootPath, "app_status", "data"),
path: "state.json",
- threadId: "thr_one",
},
{
- kind: "thread-app-data-changed",
- appId: "kanban",
- environmentId: "env_one",
+ kind: "application-data-changed",
+ applicationId: "app_kanban",
+ appDataPath: path.join(rootPath, "app_kanban", "data"),
path: "cards/1",
- threadId: "thr_one",
},
]);
});
- it("emits resync hints and broad storage changes for unclassifiable app data changes", () => {
- const rootPath = path.join("/tmp", "thread-storage");
- const changes = collectThreadStorageObservedChanges({
- threadStorageRootPath: rootPath,
+ it("emits app data resync hints for unclassifiable app data changes", () => {
+ const rootPath = path.join("/tmp", "apps");
+ const changes = collectApplicationStorageObservedChanges({
+ appsRootPath: rootPath,
changedPaths: [
- path.join(rootPath, "thr_one", "apps", "status", "data"),
- path.join(rootPath, "thr_one", "apps", "status", "data", ".state.tmp"),
- path.join(rootPath, "thr_one", "apps", "status", "data"),
+ path.join(rootPath, "app_status", "data"),
+ path.join(rootPath, "app_status", "data", ".state.tmp"),
+ path.join(rootPath, "app_status", "data"),
],
- resolveThreadTarget: createResolver({
- thr_one: {
- environmentId: "env_one",
- threadId: "thr_one",
- },
- }),
+ resolveApplicationTarget: createApplicationResolver({}),
});
expect(changes).toEqual([
{
- kind: "thread-storage-changed",
- environmentId: "env_one",
- threadId: "thr_one",
- },
- {
- kind: "thread-app-data-resync",
- appId: "status",
- environmentId: "env_one",
- threadId: "thr_one",
+ kind: "application-data-resync",
+ applicationId: "app_status",
},
]);
});
diff --git a/packages/server-contract/src/api-types.ts b/packages/server-contract/src/api-types.ts
index c3f94dfc2..02cb2300f 100644
--- a/packages/server-contract/src/api-types.ts
+++ b/packages/server-contract/src/api-types.ts
@@ -36,13 +36,13 @@ import {
managerTemplateNameSchema,
jsonValueSchema,
appDataPathSchema,
- appIdSchema,
+ applicationIdSchema,
callerExecutionInputSourceSchema,
} from "@bb/domain";
import { workspaceResolutionFailureSchema } from "@bb/host-daemon-contract";
import type {
AppDataPath,
- AppId,
+ ApplicationId,
CallerExecutionInputSource,
GitBranchName,
JsonValue,
@@ -1353,31 +1353,16 @@ 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 const appManifestSchema = z
.object({
- manifestVersion: z.literal(1),
- id: appIdSchema,
+ manifestVersion: z.literal(1).default(1),
+ id: applicationIdSchema,
name: z.string().min(1).max(80),
icon: appIconNameSchema.optional(),
entry: appEntryPathSchema.optional(),
- contributions: z.array(appContributionSchema).min(1),
- capabilities: z.array(appCapabilitySchema),
+ capabilities: z.array(appCapabilitySchema).default([]),
})
- .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',
- });
- }
- });
+ .strict();
export type AppManifest = z.infer;
export const appIconSchema = z.discriminatedUnion("kind", [
@@ -1398,7 +1383,7 @@ export type AppIcon = z.infer;
export const appSummarySchema = z
.object({
- id: appIdSchema,
+ applicationId: applicationIdSchema,
name: z.string().min(1).max(80),
entry: appEntrySchema,
capabilities: z.array(appCapabilitySchema),
@@ -1407,22 +1392,21 @@ export const appSummarySchema = z
.strict();
export type AppSummary = z.infer;
-export const appDetailSchema = appSummarySchema;
+export const appDetailSchema = appSummarySchema
+ .extend({
+ appsRootPath: z.string().min(1),
+ appRootPath: z.string().min(1),
+ appDataPath: z.string().min(1),
+ })
+ .strict();
export type AppDetail = z.infer;
-export const appTemplateSchema = z.enum(["blank", "status"]);
-export type AppTemplate = z.infer;
-
-export const createThreadAppRequestSchema = z
+export const createAppRequestSchema = z
.object({
- id: appIdSchema,
name: z.string().min(1).max(80),
- template: appTemplateSchema,
})
.strict();
-export type CreateThreadAppRequest = z.infer<
- typeof createThreadAppRequestSchema
->;
+export type CreateAppRequest = z.infer;
export const appDataEntrySchema = z
.object({
@@ -1461,7 +1445,9 @@ export type AppDataWriteRequest = z.infer;
export const appMessageRequestSchema = z
.object({
- text: z.string().min(1),
+ payload: jsonValueSchema,
+ appSessionToken: z.string().regex(/^appsess_[A-Za-z0-9_-]+$/u).optional(),
+ targetThreadId: z.string().min(1).optional(),
})
.strict();
export type AppMessageRequest = z.infer;
@@ -1469,8 +1455,7 @@ export type AppMessageRequest = z.infer;
export const appDataChangedBroadcastMessageSchema = z
.object({
type: z.literal("app-data.changed"),
- threadId: z.string().min(1),
- appId: appIdSchema,
+ applicationId: applicationIdSchema,
path: appDataPathSchema,
value: jsonValueSchema.nullable(),
deleted: z.boolean(),
@@ -1481,8 +1466,7 @@ export const appDataChangedBroadcastMessageSchema = z
export const appDataResyncBroadcastMessageSchema = z
.object({
type: z.literal("app-data.resync"),
- threadId: z.string().min(1),
- appId: appIdSchema,
+ applicationId: applicationIdSchema,
})
.strict();
@@ -1519,9 +1503,10 @@ export interface BbData {
}
export interface Bb {
- appId: AppId;
+ appId: ApplicationId;
+ applicationId: ApplicationId;
data?: BbData;
- message?(text: string): Promise;
+ message?(payload: JsonValue): Promise;
}
declare global {
diff --git a/packages/server-contract/src/index.ts b/packages/server-contract/src/index.ts
index fe3952c09..97194a842 100644
--- a/packages/server-contract/src/index.ts
+++ b/packages/server-contract/src/index.ts
@@ -192,7 +192,6 @@ export {
threadStoragePathListResponseSchema,
threadStoragePathsQuerySchema,
appCapabilitySchema,
- appContributionSchema,
appDataBroadcastMessageSchema,
appDataChangedBroadcastMessageSchema,
appDataEntrySchema,
@@ -211,8 +210,7 @@ export {
appManifestSchema,
appMessageRequestSchema,
appSummarySchema,
- appTemplateSchema,
- createThreadAppRequestSchema,
+ createAppRequestSchema,
projectAttachmentContentQuerySchema,
projectBranchesQuerySchema,
projectBranchesResponseSchema,
@@ -392,13 +390,12 @@ export type {
AppManifest,
AppMessageRequest,
AppSummary,
- AppTemplate,
Bb,
BbData,
BbDataChangeCallback,
BbDataChangeEvent,
BbDataEntry,
- CreateThreadAppRequest,
+ CreateAppRequest,
ProjectAttachmentContentQuery,
ProjectBranchesQuery,
ProjectBranchesResponse,
diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts
index 0048e95c0..c00582edb 100644
--- a/packages/server-contract/src/public-api.ts
+++ b/packages/server-contract/src/public-api.ts
@@ -28,11 +28,11 @@ import type {
AppDetail,
AppMessageRequest,
AppSummary,
+ CreateAppRequest,
CreateAutomationRequest,
CreateHostJoinRequest,
CreateHostJoinResponse,
CreateQueuedMessageRequest,
- CreateThreadAppRequest,
CreateManagerThreadRequest,
CreateProjectRequest,
CreateProjectSourceRequest,
@@ -126,7 +126,7 @@ import type { ApiError } from "./errors.js";
type PathProjectSourceId = { param: { id: string; sourceId: string } };
type PathProjectManagerThreadId = { param: { id: string; threadId: string } };
-type PathThreadApp = { param: { id: string; appId: string } };
+type PathApplicationApp = { param: { applicationId: string } };
export type PublicApiSchema = {
// ─── Development Only ────────────────────────────────────────────────
@@ -145,6 +145,48 @@ export type PublicApiSchema = {
>;
};
+ // ─── Apps ────────────────────────────────────────────────────────────
+
+ "/apps": {
+ /** List global local-host apps by scanning valid manifests in the app root. */
+ $get: Endpoint;
+ /** Create one global local-host app with an allocated applicationId. */
+ $post: Endpoint<{ json: CreateAppRequest }, AppDetail, 201>;
+ };
+ "/apps/:applicationId": {
+ /** Resolve one global app manifest and canonical storage paths. */
+ $get: Endpoint;
+ /** Delete one global app folder, including assets and data. */
+ $delete: Endpoint;
+ };
+ "/apps/:applicationId/data": {
+ /** List JSON value files at or below an app data prefix. */
+ $get: Endpoint<
+ PathApplicationApp & { query?: AppDataListQuery },
+ AppDataListResponse
+ >;
+ };
+ "/apps/:applicationId/data/*": {
+ /**
+ * Read, write, or delete one app data JSON file. The wildcard suffix is
+ * validated by the route because hono-typed-routes only types named params.
+ */
+ $get: Endpoint;
+ $put: Endpoint<
+ PathApplicationApp & { json: AppDataWriteRequest },
+ AppDataReadResponse
+ >;
+ $delete: Endpoint;
+ };
+ "/apps/:applicationId/message": {
+ /** Send a message from a global app to an explicit thread target context. */
+ $post: Endpoint<
+ PathApplicationApp & { json: AppMessageRequest },
+ { ok: true },
+ 202
+ >;
+ };
+
// ─── Projects ────────────────────────────────────────────────────────
"/projects": {
@@ -600,41 +642,6 @@ export type PublicApiSchema = {
/** Returns the last used options for the thread for use as defaults in the UI. */
$get: Endpoint;
};
- "/threads/:id/apps": {
- /** List thread apps by reading validated manifests from thread storage. */
- $get: Endpoint;
- /** Scaffold a new thread app under apps// using a server-owned template. */
- $post: Endpoint;
- };
- "/threads/:id/apps/:appId": {
- /** Resolve one thread app manifest, entry, and icon. */
- $get: Endpoint;
- /** Remove one thread app directory from thread storage. */
- $delete: Endpoint;
- };
- "/threads/:id/apps/:appId/data": {
- /** List JSON value files at or below an app data prefix. */
- $get: Endpoint<
- PathThreadApp & { query?: AppDataListQuery },
- AppDataListResponse
- >;
- };
- "/threads/:id/apps/:appId/data/*": {
- /**
- * Read, write, or delete one app data JSON file. The wildcard suffix is
- * validated by the route because hono-typed-routes only types named params.
- */
- $get: Endpoint;
- $put: Endpoint<
- PathThreadApp & { json: AppDataWriteRequest },
- AppDataReadResponse
- >;
- $delete: Endpoint;
- };
- "/threads/:id/apps/:appId/message": {
- /** Send a message from an app iframe to its owning thread. */
- $post: Endpoint;
- };
"/threads/:id/thread-storage/files": {
/**
* List files in the durable thread storage for a thread environment.
diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts
index 0a97e257b..85f906c00 100644
--- a/packages/server-contract/test/contract.test.ts
+++ b/packages/server-contract/test/contract.test.ts
@@ -683,11 +683,10 @@ describe("server-contract canonical schemas", () => {
it("validates app manifests, icon names, entries, and data broadcasts", () => {
const manifest = {
manifestVersion: 1,
- id: "status",
+ id: "app_status",
name: "Status",
icon: "ListTodo",
entry: "index.html",
- contributions: ["thread.app"],
capabilities: ["data", "message"],
};
@@ -707,14 +706,19 @@ describe("server-contract canonical schemas", () => {
expect(
contract.appManifestSchema.safeParse({
...manifest,
- contributions: ["thread.app", "sidebar"],
+ id: "status",
+ }).success,
+ ).toBe(false);
+ expect(
+ contract.appManifestSchema.safeParse({
+ ...manifest,
+ contributions: ["sidebar"],
}).success,
).toBe(false);
const message = {
type: "app-data.changed",
- threadId: "thr_123",
- appId: "status",
+ applicationId: "app_status",
path: "state.json",
value: { workers: [] },
deleted: false,
diff --git a/packages/templates/src/generated/templates.generated.ts b/packages/templates/src/generated/templates.generated.ts
index 3502f73f5..863917cf1 100644
--- a/packages/templates/src/generated/templates.generated.ts
+++ b/packages/templates/src/generated/templates.generated.ts
@@ -18,13 +18,13 @@ export const templateDefinitions = [
},
{
"id": "bbGuideApp",
- "body": "Apps\n\nApps are the supported way to build dashboards, control panels, and other\ninteractive surfaces inside thread storage. A manager's primary status surface\nis the built-in `status` app. Keep that app current instead of creating legacy\ntop-level status files.\n\nImportant: a bb app is self-contained static HTML/CSS/JS/SVG. Put files under\n`apps//assets/`; bb serves the `entry` file directly from the flat app URL\nthrough the host daemon. Do not start a web server, localhost dev server, npm\ninstall, build step, bundler, or framework for a normal app. Inline CSS/JS,\nrelative asset refs, and CDN resources such as Tailwind or fonts are fine.\n\nStorage layout:\n\n```text\n$BB_THREAD_STORAGE/\n apps/\n status/\n manifest.json\n assets/\n index.html\n data/\n state.json\n```\n\nEach app is rooted at `apps//`. The manifest lives at\n`apps//manifest.json`, browser files live under `apps//assets/`, and\ndurable JSON state lives under `apps//data/`. The `status` app should keep\nits primary shared state in `apps/status/data/state.json`.\n\nManifest:\n\n```json\n{\n \"manifestVersion\": 1,\n \"id\": \"status\",\n \"name\": \"Status\",\n \"icon\": \"ListTodo\",\n \"entry\": \"index.html\",\n \"contributions\": [\"thread.app\"],\n \"capabilities\": [\"data\", \"message\"]\n}\n```\n\n`id` uses letters, numbers, underscores, and hyphens. `entry` is relative to\n`assets/`. HTML entries load in an app iframe and receive the `window.bb`\nbridge according to `capabilities`; Markdown entries render as static documents\nand do not receive `window.bb`. `capabilities` controls which `window.bb`\nhelpers are injected for HTML entries: `data` enables `window.bb.data`, and\n`message` enables `window.bb.message`.\n\nThe served app URL is flat: `/api/v1/threads//apps//`\nmaps to `apps//assets/` on disk. HTML should use flat relative refs\nlike `./index-abc.js`, not `./assets/index-abc.js`. If you are migrating an\nexisting Vite build output, set `build.assetsDir = \"\"` so emitted files sit\nalongside `index.html`; new bb apps should stay plain static files.\n\nThe icon is optional and uses a built-in icon name. Icon resolution order is:\n\n1. `manifest.icon`, when present, as a built-in icon.\n2. A custom top-level `logo.svg`, `logo.png`, `logo.jpg`, or `logo.jpeg` in the\n app root, up to 1 MB.\n3. The built-in `GridView` fallback.\n\nCLI:\n\n```bash\nbb app list --self\nbb app new \"Review Board\" --id review-board --template blank --self\nbb app new \"Status\" --id status --template status --self\nbb app open status --self\nbb app rm review-board --self --yes\n```\n\nPass a thread id instead of `--self` to target another thread. `bb app open`\nprints the app URL. `--json` is available for scripts.\n\nAgent writes:\n\nWrite app data directly to `apps//data/` using a temp file in the\nsame directory and then `mv` into place. Same-directory rename is atomic on\nmacOS and Linux, and bb broadcasts the committed app-data change.\n\n```bash\ndir=\"$BB_THREAD_STORAGE/apps/status/data\"\nmkdir -p \"$dir\"\ntmp=$(mktemp \"$dir/.state.XXXXXX\")\nprintf '%s\\n' '{\"tasks\":[],\"updatedAt\":\"2026-05-28T00:00:00Z\"}' > \"$tmp\" &&\n mv \"$tmp\" \"$dir/state.json\"\n```\n\nData paths are relative to the app's `data/` directory. They must not start or\nend with `/`, must not contain backslashes, dot-prefixed segments, `.` or `..`,\nand may be nested up to eight path segments. Each segment may use letters,\nnumbers, dots, underscores, and hyphens.\n\nBrowser API:\n\n```ts\nwindow.bb.appId\nawait window.bb.data?.read(\"state.json\")\nawait window.bb.data?.write(\"state.json\", { tasks: [] })\nawait window.bb.data?.delete(\"state.json\")\nconst entries = await window.bb.data?.list(\"\")\nconst unsubscribe = window.bb.data?.onChange(\"\", (event) => {\n console.log(event.path, event.value, event.deleted)\n})\nawait window.bb.message?.(\"Please review the current blockers.\")\n```\n\n`window.bb.data` reads and writes JSON values. `onChange(prefix, callback)`\nmatches a single data file when `prefix` equals that path and matches a subtree\nwhen the changed path is below `prefix + \"/\"`; `\"\"` matches all app data.\nRegistering a listener immediately replays existing matching data, and bb\nreplays again after reconnects or app-data resync hints. Later filesystem\nwrites, browser writes, and deletes are delivered after that replay.\n`window.bb.message(text)` sends a normal follow-up message to the thread that\nowns the app.\n\nMinimal status app pattern:\n\n```html\n\n Current work \n \n \n\n```\n\nStyling:\n\nMake app UI quiet, dense, and consistent with bb unless the user asks for a\ndifferent direction. Use Tailwind for layout utilities if helpful, and use the\nbb-style tokens below for colors, fonts, borders, radius, and shadows. Apps\nrender in iframes, so external resources such as Google Fonts, Tailwind CDN,\nremote images, and stylesheets load normally.\n\n```html\n\n \n \n \n\n```\n\nKeep one canonical app for each concept. For manager progress, update the\n`status` app instead of creating parallel views. Use additional apps only when\nthey are distinct tools or dashboards.\n\nRelated guides:\n\n bb guide overview\n bb guide managers\n bb guide manager-templates\n bb guide async",
+ "body": "Apps\n\nApps are global within the local host data directory. They are the supported\nway to build dashboards, control panels, and other interactive surfaces that\ncan open inside a thread panel.\n\nImportant: a bb app is self-contained static HTML/CSS/JS/SVG. Put browser\nfiles under `/apps//assets/`; bb serves the `entry`\nfile directly from `/api/v1/apps//`. Do not start a web server,\nlocalhost dev server, npm install, build step, bundler, or framework for a\nnormal app. Inline CSS/JS, relative asset refs, and CDN resources such as\nTailwind or fonts are fine.\n\nStorage layout:\n\n```text\n/\n apps/\n app_k9D2example/\n manifest.json\n assets/\n index.html\n data/\n state.json\n```\n\nEach app is rooted at `/apps//`. The manifest lives at\n`manifest.json`, browser files live under `assets/`, and durable JSON state\nlives under `data/`.\n\nThe app exists only when the local filesystem contains a valid manifest at\n`/apps//manifest.json`. `manifest.id` is the canonical\napplication id: it must be opaque, path-safe, `app_`-prefixed, globally unique\ninside the data dir, and equal to the containing folder name. `manifest.name`\nis a human display name only. Display names are not identifiers and may repeat.\n\nManifest:\n\n```json\n{\n \"manifestVersion\": 1,\n \"id\": \"app_k9D2example\",\n \"name\": \"Review Board\",\n \"icon\": \"ListTodo\",\n \"entry\": \"index.html\",\n \"capabilities\": [\"data\", \"message\"]\n}\n```\n\n`entry` is relative to `assets/`. HTML entries load in an app iframe and receive\nthe `window.bb` bridge according to `capabilities`; Markdown entries render as\nstatic documents and do not receive `window.bb`. `capabilities` controls which\n`window.bb` helpers are injected for HTML entries: `data` enables\n`window.bb.data`, and `message` enables `window.bb.message`.\n\nThe served app URL is flat: `/api/v1/apps//` maps to\n`/apps//assets/` on disk. HTML should use flat\nrelative refs like `./index-abc.js`, not `./assets/index-abc.js`. If you are\nmigrating an existing Vite build output, set `build.assetsDir = \"\"` so emitted\nfiles sit alongside `index.html`; new bb apps should stay plain static files.\n\nThe icon is optional and uses a built-in icon name. Icon resolution order is:\n\n1. `manifest.icon`, when present, as a built-in icon.\n2. A custom top-level `logo.svg`, `logo.png`, `logo.jpg`, or `logo.jpeg` in the\n app root, up to 1 MB.\n3. The built-in `GridView` fallback.\n\nCLI:\n\n```bash\nbb app list\nbb app new --name \"Review Board\"\nbb app show app_k9D2example\nbb app data list app_k9D2example\nbb app data read app_k9D2example state.json\nbb app data write app_k9D2example state.json --file ./state.json\nbb app message app_k9D2example --target-thread thr_123 --json '\"Please review the current blockers.\"'\nbb app delete app_k9D2example --yes\n```\n\n`--json` is available for scripts. Commands accept application ids only, never\ndisplay names. There is no host selector in v1; apps are local-host only.\n\nInside an app-capable runtime, inspect the current app context:\n\n```bash\nbb app current --json\n```\n\nOutside a current-app runtime, this returns `current_app_unavailable`.\n\nRuntime paths:\n\n```bash\necho \"$BB_APPS_ROOT\" # /apps\necho \"$BB_APP_ID\" # current application id, when available\necho \"$BB_APP_ROOT\" # /apps/, when available\necho \"$BB_APP_DATA_PATH\" # /apps//data, when available\n```\n\nAgent writes:\n\nWhen a runtime has `BB_APP_ROOT`, create or edit the app directly in that\ncanonical folder. Write app data with a temp file in the same directory and\nthen `mv` into place. Same-directory rename is atomic on macOS and Linux, and\nbb broadcasts the committed app-data change.\n\n```bash\ndir=\"$BB_APP_DATA_PATH\"\nmkdir -p \"$dir\"\ntmp=$(mktemp \"$dir/.state.XXXXXX\")\nprintf '%s\\n' '{\"tasks\":[],\"updatedAt\":\"2026-06-02T00:00:00Z\"}' > \"$tmp\" &&\n mv \"$tmp\" \"$dir/state.json\"\n```\n\nData paths are relative to the app's `data/` directory. They must not start or\nend with `/`, must not contain backslashes, dot-prefixed segments, `.` or `..`,\nand may be nested up to eight path segments. Each segment may use letters,\nnumbers, dots, underscores, and hyphens.\n\nBrowser API:\n\n```ts\nwindow.bb.applicationId\nwindow.bb.appId\nawait window.bb.data?.read(\"state.json\")\nawait window.bb.data?.write(\"state.json\", { tasks: [] })\nawait window.bb.data?.delete(\"state.json\")\nconst entries = await window.bb.data?.list(\"\")\nconst unsubscribe = window.bb.data?.onChange(\"\", (event) => {\n console.log(event.path, event.value, event.deleted)\n})\nawait window.bb.message?.(\"Please review the current blockers.\")\n```\n\n`window.bb.data` reads and writes JSON values. `onChange(prefix, callback)`\nmatches a single data file when `prefix` equals that path and matches a subtree\nwhen the changed path is below `prefix + \"/\"`; `\"\"` matches all app data.\nRegistering a listener immediately replays existing matching data, and bb\nreplays again after reconnects or app-data resync hints. Later filesystem\nwrites, browser writes, and deletes are delivered after that replay.\n\n`window.bb.message(payload)` sends a normal follow-up message to the thread\ncontext that opened the app. Non-iframe callers must provide a target thread\nthrough the message API or CLI; without a target, bb returns\n`message_target_required`. App data remains global. Only message delivery is\ncontextual.\n\nMinimal app pattern:\n\n```html\n\n Current work \n \n \n\n```\n\nStyling:\n\nMake app UI quiet, dense, and consistent with bb unless the user asks for a\ndifferent direction. Use Tailwind for layout utilities if helpful, and use the\nbb-style tokens below for colors, fonts, borders, radius, and shadows. Apps\nrender in iframes, so external resources such as Google Fonts, Tailwind CDN,\nremote images, and stylesheets load normally.\n\n```html\n\n \n \n \n\n```\n\nKeep one canonical app for each concept. Use additional apps only when they are\ndistinct tools or dashboards.\n\nRelated guides:\n\n bb guide overview\n bb guide managers\n bb guide manager-templates\n bb guide async",
"fileName": "bb-guide-app.md",
"kind": "instruction",
"title": "bb Guide - Apps",
- "summary": "Thread app storage, browser API, CLI, and styling reference.",
- "intent": "Explain the only supported way to build manager-visible dashboards and interactive thread apps.",
- "editingNotes": "Keep this aligned with routes/threads/apps.ts, app-client-script.ts, app CLI commands, and the default status app template.",
+ "summary": "Global app storage, browser API, CLI, and styling reference.",
+ "intent": "Explain how to build global bb apps and use app data, messaging, and runtime paths.",
+ "editingNotes": "Keep this aligned with routes/apps.ts, app-client-script.ts, and app CLI commands.",
"variables": {}
},
{
@@ -62,7 +62,7 @@ export const templateDefinitions = [
},
{
"id": "bbGuideManagerTemplates",
- "body": "Manager templates\n\nManager templates are named bundles of starter files for manager thread\nstorage. When bb starts a new manager thread, the server resolves a template\nand recursively copies regular files into the new manager's thread storage\nbefore the host daemon receives the initial `thread.start` command. This is how\na fresh manager can boot with starter `PREFERENCES.md`, `ASYNC.md`, and\n`apps/status/` files.\n\nDirectory layout:\n\n```text\n/manager-templates/\n active\n default/\n apps/\n status/\n manifest.json\n assets/\n index.html\n data/\n state.json\n sawyer-next/\n PREFERENCES.md\n apps/\n status/\n manifest.json\n assets/\n index.html\n data/\n state.json\n```\n\nIn this guide, `` is your bb data directory. Packaged installs\ndefault to `$HOME/.bb`. In source development, `pnpm dev` sets `BB_DATA_DIR`\nto the current checkout's data directory; use `$BB_DATA_DIR/manager-templates/`.\nOverride packaged installs with the `BB_DATA_DIR` env var.\n\n`active` is a plain text file. bb reads the first line, trims it, and uses it\nas the template name. Missing or empty `active` means `default`. An invalid\nname logs a warning and falls back to `default`. Template names must be one\ndirectory name: 1-128 characters, no `/` or `\\`, and not `.` or `..`.\n\nEach subdirectory is a template set. The directory name is the template name.\n\nWhat gets seeded:\n\nbb recursively copies every regular file from the selected template directory\ninto `