diff --git a/app/package.json b/app/package.json index 3c57f969..4e02ec8e 100644 --- a/app/package.json +++ b/app/package.json @@ -21,7 +21,7 @@ "perf:guard": "node perf/guard.mjs", "perf:soak": "node perf/soak.mjs", "perf:terminals": "node perf/terminals.mjs", - "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts ../site/review/review.test.mjs" + "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts src/lib/last-of.test.ts src/lib/css-has.test.ts ../site/review/review.test.mjs" }, "dependencies": { "@base-ui/react": "^1.8.0", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 4b81193d..63b54a3f 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -44,6 +44,9 @@ objc2 = "0.6" [profile.release] codegen-units = 1 lto = true -opt-level = 3 +# For size: the shell only answers the webview's calls and lays out browser +# panes (WebKit does the work), and "s" makes its code half as large as 3 +# did (the binary 7.5 -> 6.0 MB, its embedded web assets included). +opt-level = "s" panic = "abort" strip = true diff --git a/app/src/App.tsx b/app/src/App.tsx index 4428588e..d4baa9c7 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -44,29 +44,32 @@ import { startUpdater } from "@/lib/updater"; import { useWhatsNewAfterUpdate } from "@/lib/whats-new"; import { cn } from "@/lib/utils"; import { homeBox, useWorkspaces } from "@/lib/workspaces"; -import { AutomationsView } from "@/views/automations"; -import { WorktreesView } from "@/views/worktrees/worktrees-view"; import { useKitDeepLinks } from "@/views/kits/deep-link"; import { useTeamDeepLinks, useTeamWatch } from "@/views/team/team-entry"; -import { TeamSetupView } from "@/views/team/team-view"; -import { KitsView } from "@/views/kits/kits-view"; import { ReviewSheet } from "@/views/kits/review-sheet"; import { useReviewDeepLinks } from "@/views/pr-review/deep-link"; -import { PrReviewSheet } from "@/views/pr-review/review-sheet"; +import { PrReviewSheet } from "@/views/pr-review/pr-review-sheet"; import { useReviewStatuses } from "@/views/pr-review/use-review-status"; -import { ReviewView } from "@/views/review/review-view"; -import { ProjectView } from "@/views/project/project-view"; -import { DashboardView } from "@/views/dashboard"; -import { PluginScreenView } from "@/views/plugin-screen-view"; import { AddBoxDialog } from "@/views/onboarding/add-box-dialog"; import { useOnboardingActive } from "@/views/onboarding/onboarding-state"; -import { OnboardingView } from "@/views/onboarding/onboarding-view"; -import { SettingsView } from "@/views/settings/settings-view"; import { HomeView } from "@/views/home/home-view"; import { usePrefs } from "@/lib/prefs"; +import { lazyView, preloadViews } from "@/lib/lazy-view"; import { placeLabel } from "@/lib/worktree-names"; import { useRescueRemovedFocus } from "@/lib/focus-home"; +// The pages other than the workspace load on first use (lib/lazy-view.tsx). +const AutomationsView = lazyView(() => import("@/views/automations").then((m) => m.AutomationsView)); +const WorktreesView = lazyView(() => import("@/views/worktrees/worktrees-view").then((m) => m.WorktreesView)); +const TeamSetupView = lazyView(() => import("@/views/team/team-view").then((m) => m.TeamSetupView)); +const KitsView = lazyView(() => import("@/views/kits/kits-view").then((m) => m.KitsView)); +const ReviewView = lazyView(() => import("@/views/review/review-view").then((m) => m.ReviewView)); +const ProjectView = lazyView(() => import("@/views/project/project-view").then((m) => m.ProjectView)); +const DashboardView = lazyView(() => import("@/views/dashboard").then((m) => m.DashboardView)); +const PluginScreenView = lazyView(() => import("@/views/plugin-screen-view").then((m) => m.PluginScreenView)); +const OnboardingView = lazyView(() => import("@/views/onboarding/onboarding-view").then((m) => m.OnboardingView)); +const SettingsView = lazyView(() => import("@/views/settings/settings-view").then((m) => m.SettingsView)); + // The live demo's guide and script (pnpm build:demo); not in the app. const DemoGuide = __BERTH_DEMO__ ? lazy(() => import("@/demo/guide")) : null; @@ -81,6 +84,8 @@ export default function App() { useEffect(startUpdater, []); // Spinners and shimmers hold still while the window is in the background. useEffect(watchStillness, []); + // The other pages' code, fetched once the app has started and is idle. + useEffect(preloadViews, []); // Runs on the boxes (loops, attempts, flows): kept fresh for the loops // panel, Automations and Review (lib/runs.ts). const connectedToAgent = useStore((s) => !!s.client); @@ -129,7 +134,7 @@ export default function App() {
- {view.kind === "team" ? useStore.getState().setView({ kind: "workspace" })} /> : } + {view.kind === "team" ? useStore.getState().setView({ kind: "workspace" })} /> : }
@@ -237,7 +242,9 @@ function MainView() { if (view.kind === "workspace" && onboarding) { return (
- + + +
); } @@ -250,15 +257,17 @@ function MainView() { // In zen there is no sidebar: a view keeps the width it has beside one, // centred, rather than stretching across the window.
- {view.kind === "dashboard" && } - {view.kind === "automations" && } - {view.kind === "review" && } - {view.kind === "kits" && } - {view.kind === "worktrees" && } - {view.kind === "project" && } - {view.kind === "settings" && } - {view.kind === "plugin" && } - {view.kind === "team" && } + + {view.kind === "dashboard" && } + {view.kind === "automations" && } + {view.kind === "review" && } + {view.kind === "kits" && } + {view.kind === "worktrees" && } + {view.kind === "project" && } + {view.kind === "settings" && } + {view.kind === "plugin" && } + {view.kind === "team" && } +
); } diff --git a/app/src/components/add-location-dialog.tsx b/app/src/components/add-location-dialog.tsx index 644a5e0d..58446692 100644 --- a/app/src/components/add-location-dialog.tsx +++ b/app/src/components/add-location-dialog.tsx @@ -1,2 +1,24 @@ -// The dialog lives in components/add-project; this keeps the old import. -export { AddProjectDialog, AddProjectDialog as AddLocationDialog } from "@/components/add-project/add-project-dialog"; +import { Suspense } from "react"; + +import { Dialog, DialogPopup } from "@/components/ui/dialog"; +import { lazyView } from "@/lib/lazy-view"; +import { useStore } from "@/lib/store"; + +// AddProjectDialog's frame (components/add-project/add-project-dialog.tsx +// is what it does): its content loads after the app has started. +const AddProjectBody = lazyView(() => import("@/components/add-project/add-project-dialog").then((m) => m.AddProjectBody)); + +export function AddProjectDialog() { + const draft = useStore((s) => s.locationDraft); + return ( + !open && useStore.getState().closeAddLocation()}> + {/* Anchored at the top, as New worktree is: Browse and the box's + states differ in height, and a centred dialog would move its title. */} + + {draft && } + + + ); +} + +export { AddProjectDialog as AddLocationDialog }; diff --git a/app/src/components/add-project/add-project-dialog.tsx b/app/src/components/add-project/add-project-dialog.tsx index 3fcd1a13..52fd8ae3 100644 --- a/app/src/components/add-project/add-project-dialog.tsx +++ b/app/src/components/add-project/add-project-dialog.tsx @@ -10,7 +10,7 @@ import { BoxStrip } from "@/components/add-project/box-strip"; import { Scene } from "@/components/art/scenes"; import { Button } from "@/components/ui/button"; import { StepHeader } from "@/components/step-header"; -import { Dialog, DialogFooter, DialogPanel, DialogPopup } from "@/components/ui/dialog"; +import { DialogFooter, DialogPanel } from "@/components/ui/dialog"; import { Kbd } from "@/components/ui/kbd"; import { Spinner } from "@/components/ui/spinner"; import { toastManager } from "@/components/ui/toast"; @@ -30,22 +30,11 @@ import { ErrorText } from "@/components/error-note"; // name) and the box says what Enter will do there. Folders on the box and // projects on the other boxes are offered below it, and the same repository // can be set up on more boxes at once, with its kit. -export function AddProjectDialog() { - const draft = useStore((s) => s.locationDraft); - return ( - !open && useStore.getState().closeAddLocation()}> - {/* Anchored at the top, as New worktree is: Browse and the box's - states differ in height, and a centred dialog would move its title. */} - - {draft && } - - - ); -} - type Log = { box: string; lines: string[] }; -function Body({ startBox }: { startBox?: string }) { +// AddProjectBody is the dialog's content; its frame (components/ +// add-location-dialog.tsx) loads it when it first opens. +export function AddProjectBody({ startBox }: { startBox?: string }) { const status = useStore((s) => s.status); const boxes = useMemo(() => status?.boxes ?? [], [status]); const firstOnline = boxes.find((b) => b.state === "online")?.name ?? ""; diff --git a/app/src/components/art/scenes.css b/app/src/components/art/scenes.css index 5683675b..9f8de3b9 100644 --- a/app/src/components/art/scenes.css +++ b/app/src/components/art/scenes.css @@ -510,11 +510,14 @@ /* Paused: offscreen, the window hidden, behind an open dialog or alert (a confirm is role="alertdialog"), or at rest. A scene inside the dialog keeps moving; only one moves at a time. A scene rests where it is after - a few loops (scenes.tsx), and wakes when the pointer comes to it. */ + a few loops (scenes.tsx), and wakes when the pointer comes to it. + Whether a dialog is open is the root's data-berth-dialog (scenes.tsx), + not body:has(): that made every element added anywhere in the page (a + streamed reply's words) restyle all of it. */ .berth-art[data-off] *, .berth-art[data-rest] *, :root[data-berth-hidden] .berth-art *, -body:has(:is([role="dialog"], [role="alertdialog"])) .berth-art:not(:is([role="dialog"], [role="alertdialog"]) .berth-art) * { +:root[data-berth-dialog] .berth-art:not(:is([role="dialog"], [role="alertdialog"]) .berth-art) * { animation-play-state: paused; } diff --git a/app/src/components/art/scenes.tsx b/app/src/components/art/scenes.tsx index fea2a811..77fe7a21 100644 --- a/app/src/components/art/scenes.tsx +++ b/app/src/components/art/scenes.tsx @@ -52,6 +52,34 @@ if (typeof document !== "undefined") { document.addEventListener("visibilitychange", sync); } +// While a scene is drawn, the root says whether a dialog or alert is open +// (data-berth-dialog), so scenes behind it hold still (scenes.css). Only +// what came, went or changed its role is looked at, so a page busy with +// other work (a reply streaming in) costs next to nothing here. +const DIALOG = '[role="dialog"], [role="alertdialog"]'; +let drawn = 0; +let dialogs: MutationObserver | undefined; +const syncDialog = () => document.documentElement.toggleAttribute("data-berth-dialog", !!document.body.querySelector(DIALOG)); +const hasDialog = (n: Node) => n instanceof Element && (n.matches(DIALOG) || !!n.querySelector(DIALOG)); +function watchDialogs() { + if (typeof MutationObserver === "undefined" || !document.body) return () => {}; + if (drawn++ === 0) { + dialogs = new MutationObserver((records) => { + for (const r of records) { + if (r.type === "attributes" || [...r.addedNodes].some(hasDialog) || [...r.removedNodes].some(hasDialog)) return syncDialog(); + } + }); + dialogs.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["role"] }); + syncDialog(); + } + return () => { + if (--drawn > 0) return; + dialogs?.disconnect(); + dialogs = undefined; + document.documentElement.removeAttribute("data-berth-dialog"); + }; +} + // pivot turns about a point in the scene's own coordinates. const pivot = (x: number, y: number, extra?: Record) => ({ transformOrigin: `${x}px ${y}px`, ...extra }) as React.CSSProperties; const delay = (s: number) => ({ "--ba-delay": `${s}s` }) as React.CSSProperties; @@ -81,9 +109,11 @@ export function Scene({ name, width = 136, still, className }: { name: SceneName if (!el) return; const unwatch = watch(el); const unrest = rest(el); + const undialogs = watchDialogs(); return () => { unwatch(); unrest(); + undialogs(); }; }, []); const Draw = DRAW[name]; diff --git a/app/src/components/browser-devtools.tsx b/app/src/components/browser-devtools.tsx index d496e43b..73b0b866 100644 --- a/app/src/components/browser-devtools.tsx +++ b/app/src/components/browser-devtools.tsx @@ -2,13 +2,14 @@ import { invoke } from "@tauri-apps/api/core"; import { BanIcon, BugIcon, ChevronRightIcon, CircleXIcon, InfoIcon, SendIcon, SquareDashedMousePointerIcon, SquareTerminalIcon, TriangleAlertIcon, XIcon } from "lucide-react"; import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { ErrorBadge } from "@/components/devtools-badge"; import { Tip } from "@/components/tip"; import { Button } from "@/components/ui/button"; import { toastManager } from "@/components/ui/toast"; import type { BrowserContext } from "@/lib/browser-url"; import { agentOf } from "@/lib/derive"; -import { clearLog, type PaneLog, setDrawerTab, toggleDrawer, useDrawerOpen, useDrawerTab, useErrorCount, useErrorCountOf, useLog } from "@/lib/devtools"; -import { badgeText, type ConsoleEntry, consoleMessage, failed, formatMs, formatSize, isError, type NetEntry, requestMessage, shortAt, statusText } from "@/lib/devtools-model"; +import { clearLog, type PaneLog, setDrawerTab, toggleDrawer, useDrawerOpen, useDrawerTab, useErrorCount, useLog } from "@/lib/devtools"; +import { type ConsoleEntry, consoleMessage, failed, formatMs, formatSize, isError, type NetEntry, requestMessage, shortAt, statusText } from "@/lib/devtools-model"; import { send as sendPrompt } from "@/lib/orchestrate"; import { keysFor } from "@/lib/shortcuts"; import { useStore } from "@/lib/store"; @@ -68,27 +69,6 @@ export function DevtoolsToggle({ logKey, disabled }: { logKey: string; disabled? ); } -function ErrorBadge({ n, testId, className }: { n: number; testId?: string; className?: string }) { - return ( - - {badgeText(n)} - - ); -} - -// TabErrorBadge is the count on a Browser tab in the tab strip: its panes' -// page errors since they loaded. -export function TabErrorBadge({ paneIds }: { paneIds: string[] }) { - const n = useErrorCountOf(paneIds); - if (!n) return null; - // The tab is a tooltip's trigger already: the count says it all. - return ( - - - - ); -} - type Sending = { kind: "console"; entry: ConsoleEntry } | { kind: "request"; entry: NetEntry }; const HEIGHT_KEY = "berth.devtools.height"; diff --git a/app/src/components/command-palette.tsx b/app/src/components/command-palette.tsx index 447ed607..7203e24a 100644 --- a/app/src/components/command-palette.tsx +++ b/app/src/components/command-palette.tsx @@ -63,7 +63,7 @@ import { FROM_TABLE } from "@/lib/palette-shortcuts"; import { describe, keysFor, SHORTCUTS } from "@/lib/shortcuts"; import { runShortcut } from "@/hooks/use-shortcuts"; import { openRenameWorktree } from "@/components/sidebar/rename-worktree"; -import type { SettingsSectionId } from "@/views/settings/settings-view"; +import type { SettingsSectionId } from "@/views/settings/open"; import { agentOf, sessionAgent, sessionName, sortedWorktrees, worktreeOf } from "@/lib/derive"; import { openBroadcast, openPromptPicker } from "@/lib/prompts"; import { quietNow, setDoNotDisturb, setNotificationsOpen } from "@/lib/notifications"; @@ -167,7 +167,12 @@ export function CommandPalette() { setOpen(false); }; + // Closed, it keeps what it last listed (its closing frames show it) + // rather than listing every session and worktree again whenever a box + // reports in; it lists them afresh as it opens. + const shown = useRef<{ groups: Group[]; query: string }>(undefined); const groups = useMemo(() => { + if (!open && shown.current && shown.current.query === query) return shown.current.groups; const st = useStore.getState(); const go = (fn: () => void) => () => { close(true); @@ -396,6 +401,7 @@ export function CommandPalette() { // close is stable enough: it only reads refs and store setters. // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessions, boxes, status, themes, themeId, spaces, pluginCommands, query, nav.hidden, open, login]); + shown.current = { groups, query }; return ( p.chatBackground); if (bg.source === "none" || (bg.source === "image" && !bg.image)) return null; return ; -} +}); function Layer({ bg }: { bg: Bg }) { const wrap = useRef(null); diff --git a/app/src/components/conversation/chat-controls.tsx b/app/src/components/conversation/chat-controls.tsx index a45141ae..36dfb86f 100644 --- a/app/src/components/conversation/chat-controls.tsx +++ b/app/src/components/conversation/chat-controls.tsx @@ -84,7 +84,7 @@ export interface ChatControlsProps { export function ChatControls({ box, session, agent, state, stateSince, dir, who, visible, ended, onShowTerminal, onStartAgain, onSend, children }: ChatControlsProps) { const mock = isMock(); const supported = useStore((s) => !!s.boxes[box]?.info?.capabilities?.includes("controls")) || mock; - const sig = useChatSignals(box, session, visible && !ended); + const sig = useChatSignals(box, session); const { controls, refresh } = useScreenControls(box, session, visible && !ended && (agent === "claude" || agent === "codex"), stateSince); const working = state === "running"; const [stopping, setStopping] = useState(false); diff --git a/app/src/components/conversation/conversation-pane.tsx b/app/src/components/conversation/conversation-pane.tsx index c581c763..31e7faee 100644 --- a/app/src/components/conversation/conversation-pane.tsx +++ b/app/src/components/conversation/conversation-pane.tsx @@ -1,5 +1,5 @@ import { ArrowUpIcon, ListPlusIcon, MessageSquareTextIcon, MessagesSquareIcon, RefreshCwIcon, SendIcon, SquareTerminalIcon } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; import { AgentIcon, StateGlyph } from "@/components/agent-glyph"; import { DitherBand } from "@/components/art/dither-band"; @@ -35,7 +35,6 @@ import { keyOf, useConversations } from "@/lib/conversation-store"; import { agentLabel, agentOf, firstPrompt, guessAgent, sessionState, worktreeOf } from "@/lib/derive"; import type { NextStep } from "@/lib/errors"; import { errorMessage } from "@/lib/format"; -import { finishTurn, mockToolDetail, seedTranscript } from "@/lib/mock-conversation"; import { useNotifications } from "@/lib/notifications"; import { updateBoxes } from "@/lib/outdated"; import { addComment, type LineComment, pending, removeComment, sendComments, useComments } from "@/lib/review-comments"; @@ -52,6 +51,9 @@ import { ConfirmDialog } from "@/views/settings/confirm"; import { cn } from "@/lib/utils"; import { useReview } from "@/views/review/review-store"; +// The mock's scripted conversations (?mock), loaded only in mock mode. +const mockConversation = () => import("@/lib/mock-conversation"); + // ConversationPane shows an agent's pane as a conversation: the transcript, // and a reply box docked at its foot. On a box that streams transcripts it // reads the agent's own; "Thinking…" and the question come from the @@ -138,7 +140,10 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS useEffect(() => { if (!mock || !s || useConversations.getState().items[key]) return; const wt = worktreeOf(locations, s); - seedTranscript(box, session, sessionState(s, stats), wt ? (wt.worktree.main ? wt.location.name : wt.worktree.name) : session); + const state = sessionState(s, stats); + void mockConversation().then((m) => { + if (!useConversations.getState().items[key]) m.seedTranscript(box, session, state, wt ? (wt.worktree.main ? wt.location.name : wt.worktree.name) : session); + }); }, [mock, s, key, box, session, stats, locations]); // The prompt it was started with (a long one, its start). @@ -207,7 +212,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS if (!client || (!canDiff && !mock)) return undefined; return { load: (file) => boxApi.diff(client, box, session, file), - tool: (id) => (mock ? mockToolDetail(id) : boxApi.toolDetail(client, box, session, id)), + tool: (id) => (mock ? mockConversation().then((m) => m.mockToolDetail(id)) : boxApi.toolDetail(client, box, session, id)), comments: (file) => reviewKey ? { @@ -263,6 +268,21 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS // Counted only while the pane shows: a hidden chat doesn't read. const reading = visible && !mock && !away && (!listed || (feed === "loading" && !items.length && !ended)); const stalled = useStalled(reading, FIRST_READ_TIMEOUT + 2_000, `${key}:${attempt}`); + // The reply box and the controls around it take functions that stay the + // same, calling this render's: a draft streaming in (a read every 600ms) + // draws the chat again, not them (ChatFoot). + const latest = useRef(undefined); + const actions = useMemo( + () => ({ + reply: (text) => latest.current!.reply(text), + fail: (err) => latest.current!.fail(err), + again: () => latest.current!.again(), + showTerminal: () => latest.current!.showTerminal(), + hideLive: () => latest.current!.hideLive(), + sendComments: () => latest.current!.sendComments(), + }), + [], + ); const retry = () => { setAttempt((n) => n + 1); void useStore.getState().refreshBox(box); @@ -329,7 +349,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS const answer = (id: string, choice: string) => { if (mock) { useConversations.getState().update(key, id, { decided: choice }); - void finishTurn(box, session); + void mockConversation().then((m) => m.finishTurn(box, session)); return; } if (!client) return; @@ -359,7 +379,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS const reply = async (text: string) => { if (mock && state !== "running") { useConversations.getState().push(key, { kind: "user", id: `u${Date.now()}`, text }); - void finishTurn(box, session); + void mockConversation().then((m) => m.finishTurn(box, session)); return; } if (!client) return; @@ -445,6 +465,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS // leaves the agent at a text field once picked: the reply box types them. const wantsWords = forWords; const atMenu = !wantsWords && (formAsk || !!ask?.form || !!ask?.choices.length || !!s?.ask?.tool || (open?.kind === "ask" && !open.decided && (!!open.choices?.length || !!open.structured))); + latest.current = { reply, fail, again, showTerminal: onShowTerminal, hideLive: live.hide, sendComments: () => sendComments(box, session, reviewKey!) }; if (ended && !shown.length) { return ( @@ -513,23 +534,23 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS
- - {ended ? ( -
- - {agent ? agentLabel(agent) : "This agent"} has ended, so it can't take a reply. - -
- ) : ( - <> - {toSend.length > 0 && reviewKey && sendComments(box, session, reviewKey)} />} - {live.show && } - - - )} -
+
; + fail(err: unknown): void; + again(): void; + showTerminal(): void; + hideLive(): void; + sendComments(): Promise<{ sent: number; left: number; queued: boolean }>; +} + +// ChatFoot is everything under the transcript: the controls around the +// reply box (ChatControls), comments to send, the agent's own screen when +// it shows one, and the reply box. It draws again only when one of these +// props changes; actions stay the same object. +const ChatFoot = memo(function ChatFoot({ + box, + session, + agent, + state, + stateSince, + dir, + who, + visible, + ended, + comments, + live, + mode, + blocked, + hint, + actions, +}: { + box: string; + session: string; + agent?: string; + state?: string; + stateSince?: string; + dir?: string; + who: string; + visible: boolean; + ended: boolean; + comments: number; + live: boolean; + mode: "send" | "queue" | "answer"; + blocked: boolean; + hint?: string; + actions: FootActions; +}) { + const attach = useMemo(() => ({ box, session }), [box, session]); + return ( + + {ended ? ( +
+ + {agent ? agentLabel(agent) : "This agent"} has ended, so it can't take a reply. + +
+ ) : ( + <> + {comments > 0 && } + {live && } + + + )} +
+ ); +}); + // The transcript keeps what was typed, at most 4000 characters, but not // always as typed: a paste's spacing changes, an older box keeps Claude // Code's tags, and an attached image's path can read as diff --git a/app/src/components/conversation/conversation-view.tsx b/app/src/components/conversation/conversation-view.tsx index f0f47e69..b5b44964 100644 --- a/app/src/components/conversation/conversation-view.tsx +++ b/app/src/components/conversation/conversation-view.tsx @@ -32,7 +32,6 @@ import { isMock } from "@/hooks/use-berth-connection"; import { keyOf } from "@/lib/conversation-store"; import { rowKeyOf } from "@/lib/draft-text"; import { applyCut, dropOlder, loadOlder, meta, restoreOlder, setCut, useHasHistory, useHistory, useOlder } from "@/lib/history"; -import { seedLongChat } from "@/lib/mock-history"; import "@/components/conversation/conversation.css"; import "@/components/conversation/history.css"; import { TurnSync } from "@/components/workspace/compare-sync"; @@ -88,7 +87,7 @@ export function ConversationView({ items: live, onAnswer, edits, who = "The agen }, [key, chat?.visible]); // The demo's long chat (?long=5000), for measuring. useEffect(() => { - if (chat && isMock()) seedLongChat(chat.box, chat.session); + if (chat && isMock()) void import("@/lib/mock-history").then((m) => m.seedLongChat(chat.box, chat.session)); }, [chat?.box, chat?.session]); // A rewound prompt stays hidden until the agent's record no longer has it. useEffect(() => { diff --git a/app/src/components/conversation/subagent-view.tsx b/app/src/components/conversation/subagent-view.tsx index 350b371f..6f459895 100644 --- a/app/src/components/conversation/subagent-view.tsx +++ b/app/src/components/conversation/subagent-view.tsx @@ -1,5 +1,5 @@ import { AppWindowIcon, BotIcon, ChevronDownIcon, EyeIcon, RefreshCwIcon } from "lucide-react"; -import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { memo, useEffect, useId, useMemo, useRef, useState } from "react"; import { create } from "zustand"; import { StateGlyph } from "@/components/agent-glyph"; @@ -101,7 +101,7 @@ export function useHelpers(box: string, session: string, opts: { enabled?: boole return { helpers, error, retry: () => setAttempt((n) => n + 1) }; } -export function HelperSheetHost() { +export const HelperSheetHost = memo(function HelperSheetHost() { const id = useId(); const leader = useHelperSheet((s) => s.hosts[0] === id); const open = useHelperSheet((s) => s.open); @@ -111,7 +111,7 @@ export function HelperSheetHost() { }, [id]); if (!leader || !open) return null; return useHelperSheet.setState({ open: undefined })} />; -} +}); const elapsed = (ms: number) => { const s = Math.max(1, Math.round(ms / 1000)); diff --git a/app/src/components/devtools-badge.tsx b/app/src/components/devtools-badge.tsx new file mode 100644 index 00000000..44827c94 --- /dev/null +++ b/app/src/components/devtools-badge.tsx @@ -0,0 +1,28 @@ +import { useErrorCountOf } from "@/lib/devtools"; +import { badgeText } from "@/lib/devtools-model"; +import { cn } from "@/lib/utils"; + +// The page-error counts on a Browser tab and its devtools button: the tab +// strip's stays in the app while the browser pane and its drawer +// (browser-devtools.tsx) load after it has started. + +export function ErrorBadge({ n, testId, className }: { n: number; testId?: string; className?: string }) { + return ( + + {badgeText(n)} + + ); +} + +// TabErrorBadge is the count on a Browser tab in the tab strip: its panes' +// page errors since they loaded. +export function TabErrorBadge({ paneIds }: { paneIds: string[] }) { + const n = useErrorCountOf(paneIds); + if (!n) return null; + // The tab is a tooltip's trigger already: the count says it all. + return ( + + + + ); +} diff --git a/app/src/components/editors/open.ts b/app/src/components/editors/open.ts index ab6051d9..d12d3d67 100644 --- a/app/src/components/editors/open.ts +++ b/app/src/components/editors/open.ts @@ -2,7 +2,7 @@ import { toastManager } from "@/components/ui/toast"; import { type EditorId, openInEditor, SSHSetupNeeded } from "@/lib/editors"; import { errorMessage } from "@/lib/format"; import { useStore } from "@/lib/store"; -import { openSettings } from "@/views/settings/settings-view"; +import { openSettings } from "@/views/settings/open"; // openEditor opens a worktree, or a file in it, and says what went wrong in // a toast: most often that editors cannot reach the box over SSH yet. diff --git a/app/src/components/sidebar/projects.tsx b/app/src/components/sidebar/projects.tsx index f3ef0f9a..065c136e 100644 --- a/app/src/components/sidebar/projects.tsx +++ b/app/src/components/sidebar/projects.tsx @@ -14,6 +14,7 @@ import { UsersRoundIcon, } from "lucide-react"; import { memo, useMemo, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; import { AgentIcon, BoxStateDot, StateGlyph } from "@/components/agent-glyph"; import { type Action, Armed, boxActions, useArmed, ContextRow, DotsMenu, newSection, projectActions, projectGroupActions, removeWorktree, worktreeActions } from "@/components/sidebar/actions"; @@ -347,7 +348,7 @@ interface WorktreeRowProps { // the store keeps what a refresh didn't change (lib/share.ts), so a rename // or an agent's new state draws one row, not hundreds. const sameList = (a: Session[], b: Session[]) => a.length === b.length && a.every((s, i) => s === b[i]); -const sameRow = (a: WorktreeRowProps, b: WorktreeRowProps) => +const sameRow = (a: Omit, b: Omit) => a.box === b.box && a.loc === b.loc && a.wt === b.wt && @@ -458,10 +459,29 @@ function WorktreeNodes({ nodes, depth, prefs, update }: { nodes: TreeNode ); } +interface WorktreeNodeProps { + node: TreeNode; + depth: number; + prefs: SidebarPrefs; + update(p: Partial): void; +} + +// A worktree without children is drawn again only when its row would be +// (sameRow): a project's tree is made afresh whenever one of its agents +// changes, and its other rows stay as they are. +const sameLeaf = (a: WorktreeNodeProps, b: WorktreeNodeProps) => + !a.node.children.length && + !b.node.children.length && + a.depth === b.depth && + a.prefs === b.prefs && + a.update === b.update && + a.node.key === b.node.key && + sameRow(a.node.row, b.node.row); + // WorktreeNode is a worktree and, under a pill saying how many, its // children. The pill folds them; a fold never hides the worktree you have // open, and while folded it shows the most urgent agent inside. -function WorktreeNode({ node, depth, prefs, update }: { node: TreeNode; depth: number; prefs: SidebarPrefs; update(p: Partial): void }) { +const WorktreeNode = memo(function WorktreeNode({ node, depth, prefs, update }: WorktreeNodeProps) { const r = node.row; const row = ( selectWorktree(refOf(r.box, r.loc, r.wt))} /> @@ -505,7 +525,7 @@ function WorktreeNode({ node, depth, prefs, update }: { node: TreeNode; )} ); -} +}, sameLeaf); // LeavingRow is a worktree being archived or removed, in the row's place // and size so nothing shifts when it goes. @@ -755,8 +775,18 @@ function sectionActions(name: string): Action[] { // ProjectGroup is one project: its row (name, its boxes, how its agents are // doing), and under it the worktrees from all its boxes. -function ProjectGroup({ project: p, chips, prefs, update }: { project: Project; chips: boolean; prefs: SidebarPrefs; update(p: Partial): void }) { - const boxes = useStore((s) => s.boxes); +// A project is drawn again only when what it shows changed: its members' +// locations, and their boxes' names and states (not their latency, which +// changes with every status read). +const sameMember = (a: Project["members"][number], b: Project["members"][number]) => a.loc === b.loc && a.box.name === b.box.name && a.box.state === b.box.state && !!a.box.link?.slow === !!b.box.link?.slow; +const sameProject = (a: Project, b: Project) => + a === b || + (a.id === b.id && a.name === b.name && a.slug === b.slug && a.section === b.section && a.defaultBox === b.defaultBox && a.remote === b.remote && a.members.length === b.members.length && a.members.every((m, i) => sameMember(m, b.members[i]))); + +function ProjectGroupBody({ project: p, chips, prefs, update }: { project: Project; chips: boolean; prefs: SidebarPrefs; update(p: Partial): void }) { + // Only its own boxes' data: agents changing on another box leave it be. + const memberData = useStore(useShallow((s) => p.members.map((m) => s.boxes[m.box.name]))); + const boxes = useMemo(() => Object.fromEntries(p.members.map((m, i) => [m.box.name, memberData[i]])) as Record, [p.members, memberData]); const removals = useRemovals((s) => s.byKey); const current = useWorkspaces((s) => s.current); const inWorkspace = useStore((s) => s.view.kind === "workspace"); @@ -769,34 +799,48 @@ function ProjectGroup({ project: p, chips, prefs, update }: { project: Project; const defMain = def.loc.worktrees?.find((w) => w.main); // A member whose box is away keeps its rows, dimmed (WorktreeRow). - const rows = p.members.flatMap((m) => { - const data = boxes[m.box.name]; - return (m.loc.worktrees ?? []) - .filter((w) => (multi ? true : !w.main)) - .sort((a, b) => Number(!!b.main) - Number(!!a.main) || worktreeLabel(a).localeCompare(worktreeLabel(b))) - .map( - (wt): TreeRow => ({ - key: `${m.box.name}:${wt.path}`, - box: m.box.name, - loc: m.loc, - wt, - data, - sessions: worktreeSessions(data?.sessions, wt), - selected: inWorkspace && current === wsKey(m.box.name, wt.path), - chip: multi ? m.box : undefined, - away: m.box.state === "online" ? undefined : m.box, - }), - ); - }); - const isActive = (r: TreeRow) => r.sessions.some((s) => !s.exited) || r.selected || !!removalOf(removals, r.box, r.wt.path); - const active = rows.filter(isActive); - const tree = nest(rows, (r) => r.key, (r) => (r.wt.parent ? `${r.box}:${r.wt.parent}` : undefined)); - const shown = expanded ? tree : prune(tree, isActive); - const hidden = rows.length - size(shown); + const rows = useMemo( + () => + p.members.flatMap((m) => { + const data = boxes[m.box.name]; + return (m.loc.worktrees ?? []) + .filter((w) => (multi ? true : !w.main)) + .sort((a, b) => Number(!!b.main) - Number(!!a.main) || worktreeLabel(a).localeCompare(worktreeLabel(b))) + .map( + (wt): TreeRow => ({ + key: `${m.box.name}:${wt.path}`, + box: m.box.name, + loc: m.loc, + wt, + data, + sessions: worktreeSessions(data?.sessions, wt), + selected: inWorkspace && current === wsKey(m.box.name, wt.path), + chip: multi ? m.box : undefined, + away: m.box.state === "online" ? undefined : m.box, + }), + ); + }), + [p.members, multi, boxes, inWorkspace, current], + ); + const { active, shown, hidden } = useMemo(() => { + const isActive = (r: TreeRow) => r.sessions.some((s) => !s.exited) || r.selected || !!removalOf(removals, r.box, r.wt.path); + const tree = nest(rows, (r) => r.key, (r) => (r.wt.parent ? `${r.box}:${r.wt.parent}` : undefined)); + const shown = expanded ? tree : prune(tree, isActive); + return { active: rows.filter(isActive), shown, hidden: rows.length - size(shown) }; + }, [rows, removals, expanded]); // With one box, the row itself is the main checkout. const mainSel = !multi && !!defMain && inWorkspace && current === wsKey(def.box.name, defMain.path); - const allSessions = p.members.flatMap((m) => (boxes[m.box.name]?.sessions ?? []).filter((s) => !s.service && m.loc.worktrees?.some((w) => w.path === s.dir))); - const glyphSessions = multi ? (collapsed ? allSessions : []) : defMain ? worktreeSessions(boxes[def.box.name]?.sessions, defMain) : []; + const glyphSessions = useMemo( + () => + multi + ? collapsed + ? p.members.flatMap((m) => (boxes[m.box.name]?.sessions ?? []).filter((s) => !s.service && m.loc.worktrees?.some((w) => w.path === s.dir))) + : NO_SESSIONS + : defMain + ? worktreeSessions(boxes[def.box.name]?.sessions, defMain) + : NO_SESSIONS, + [multi, collapsed, p.members, boxes, def.box.name, defMain], + ); const online = p.members.some((m) => m.box.state === "online"); return ( @@ -910,6 +954,10 @@ function ProjectGroup({ project: p, chips, prefs, update }: { project: Project; ); } +const ProjectGroup = memo(ProjectGroupBody, (a, b) => a.chips === b.chips && a.prefs === b.prefs && a.update === b.update && sameProject(a.project, b.project)); + +const NO_SESSIONS: Session[] = []; + // PlaceTip says where a sidebar row is: its name, then its path on each box. function PlaceTip({ name, sub, lines, work = [] }: { name: string; sub?: string; lines: string[]; work?: string[] }) { return ( diff --git a/app/src/components/sidebar/rail.tsx b/app/src/components/sidebar/rail.tsx index 08cf6e6b..4062b86b 100644 --- a/app/src/components/sidebar/rail.tsx +++ b/app/src/components/sidebar/rail.tsx @@ -1,5 +1,5 @@ import { ChevronRightIcon, PencilIcon, ServerOffIcon, SquareArrowOutUpRightIcon } from "lucide-react"; -import { type KeyboardEvent, type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { type KeyboardEvent, memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { AgentIcon, StateGlyph } from "@/components/agent-glyph"; import { ContextRow } from "@/components/sidebar/actions"; @@ -24,6 +24,7 @@ import { renameWorktree, worktreeLabel } from "@/lib/worktree-names"; export type Lane = "waiting" | "running" | "finished" | "away"; export const LANES: Lane[] = ["waiting", "running", "finished", "away"]; +const NO_SESSIONS: Session[] = []; export interface RailAgent { id: string; @@ -61,10 +62,18 @@ export function useRailAgents(): RailAgent[] { for (const b of boxes) { const d = data[b.name]; const away = b.state === "online" ? undefined : BOX_WORDS[boxState(b, d)].lower; + // A box's sessions by where they run, so each worktree finds its own + // without going through them all. + const byDir = new Map(); + for (const s of d?.sessions ?? []) { + if (s.service || s.exited || !s.dir) continue; + const list = byDir.get(s.dir); + if (list) list.push(s); + else byDir.set(s.dir, [s]); + } for (const loc of d?.locations ?? []) { for (const wt of loc.worktrees ?? []) { - for (const s of d?.sessions ?? []) { - if (s.dir !== wt.path || s.service || s.exited) continue; + for (const s of byDir.get(wt.path) ?? NO_SESSIONS) { const agent = agentOf(s); if (!agent) continue; const state = sessionState(s, d?.stats); @@ -253,7 +262,12 @@ function tileActions(e: RailAgent) { ]; } -function AgentTile({ e }: { e: RailAgent }) { +// A tile draws again only when what it shows changed: the list is made afresh +// whenever any agent changes, and the others keep their objects (a session, +// a worktree) and their words. +const sameAgent = (a: RailAgent, b: RailAgent) => a === b || (Object.keys(a).length === Object.keys(b).length && (Object.keys(a) as (keyof RailAgent)[]).every((k) => a[k] === b[k])); + +const AgentTile = memo(function AgentTile({ e }: { e: RailAgent }) { return (
  • {/* On screen: a bar at the rail's edge, in its tab group's colour. */} @@ -292,7 +306,7 @@ function AgentTile({ e }: { e: RailAgent }) {
  • ); -} +}, (a, b) => sameAgent(a.e, b.e)); // LaneHead heads a lane with its glyph and count, so how many need you // stays in sight however far the rail scrolls. It folds the lane away. diff --git a/app/src/components/whats-new/spotlight.tsx b/app/src/components/whats-new/spotlight.tsx new file mode 100644 index 00000000..0a2c4899 --- /dev/null +++ b/app/src/components/whats-new/spotlight.tsx @@ -0,0 +1,185 @@ +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; +import { ArrowRightIcon, ArrowUpRightIcon, ChevronLeftIcon, XIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { WhatsNewArt } from "@/components/whats-new/art"; +import { type Shower, showerFor, usePreloadArt } from "@/components/whats-new/show"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogTitle } from "@/components/ui/dialog"; +import { Kbd } from "@/components/ui/kbd"; +import { useArt } from "@/lib/art/model"; +import { openDocs } from "@/lib/open-url"; +import { useStore } from "@/lib/store"; +import { cn } from "@/lib/utils"; +import { closeWhatsNew } from "@/lib/whats-new"; +import type { Release, WhatsNewItem } from "@/lib/whats-new-model"; +import { useWorkspaces } from "@/lib/workspaces"; + +// The What's new card (lib/whats-new.ts): the release's highlights one at +// a time, each with a picture of it in the app and a way there, listed +// down the side so the whole release reads at a glance. After an update a +// note at the foot of the sidebar opens it (WhatsNewNudge). + +// useShowers is each item's Show me as things stand; it follows the +// worktree in front and the artifacts known. +function useShowers(items: WhatsNewItem[], open: boolean): (Shower | undefined)[] { + usePreloadArt(open); + useArt((s) => s.byWt); + useWorkspaces((s) => s.current); + useStore((s) => s.boxes); + return items.map((i) => (i.show ? showerFor(i.show) : undefined)); +} + +function go(s: Shower) { + closeWhatsNew(); + // After the dialog has given the keyboard back, so what opens keeps it. + window.setTimeout(() => s.run(), 0); +} + +// Hint is what an item offers without a Show me: its keys and where. +// Beside a Show me, the keys alone. +function Hint({ item, where, className }: { item: WhatsNewItem; where: boolean; className?: string }) { + if (!item.keys) return null; + return ( + + {item.keys} + {where && item.where} + + ); +} + +// Also is the release's smaller things, a line each. +function Also({ release, className }: { release: Release; className?: string }) { + return ( +
    + Also in this release +
      + {release.also.map((a) => { + const to = a.show && showerFor(a.show); + return ( +
    • + {a.text}{" "} + {to && ( + + )} +
    • + ); + })} +
    +
    + ); +} + +export function SpotlightCard({ open, release }: { open: boolean; release: Release }) { + const items = release.items; + const [at, setAt] = useState(0); + const popup = useRef(null); + useEffect(() => { + if (open) setAt(0); + }, [open]); + const showers = useShowers(items, open); + const item = items[at]; + const s = showers[at]; + const last = at === items.length - 1; + const step = (d: number) => setAt((n) => Math.min(items.length - 1, Math.max(0, n + d))); + return ( + !o && closeWhatsNew()}> + { + // ← and → page from anywhere in the card; ↑ and ↓ move in its list. + if (e.defaultPrevented || e.metaKey || e.altKey || e.ctrlKey) return; + if ((e.target as HTMLElement).closest("input, textarea")) return; + if (e.key === "ArrowRight" || e.key === "ArrowLeft") { + e.preventDefault(); + step(e.key === "ArrowRight" ? 1 : -1); + } else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && e.target === popup.current) { + e.preventDefault(); + step(e.key === "ArrowDown" ? 1 : -1); + } + }} + > + setAt(Number(v))} orientation="vertical" className="flex min-h-0"> +
    + + What's new + Shipyard {release.version} + + + {items.map((it, i) => ( + + {it.title} + + ))} + + +
    +
    + }> + + +
    + What's new + Shipyard {release.version} +
    + {items.map((it, i) => ( + +
    + +
    +
    +

    {it.title}

    +

    {it.body}

    +
    + {/* Narrow, the list is hidden: the smaller things follow the last. */} + {i === items.length - 1 && } +
    + ))} + +
    + {s && ( + + )} + + {!s && !item.keys && item.docs && ( + + )} +
    +
    + + {at + 1} / {items.length} + + + {last ? ( + }>Done + ) : ( + + )} +
    +
    +
    +
    +
    +
    + ); +} diff --git a/app/src/components/whats-new/whats-new-dialog.tsx b/app/src/components/whats-new/whats-new-dialog.tsx index c0d860e9..a00c1842 100644 --- a/app/src/components/whats-new/whats-new-dialog.tsx +++ b/app/src/components/whats-new/whats-new-dialog.tsx @@ -1,192 +1,21 @@ -import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; -import { ArrowRightIcon, ArrowUpRightIcon, ChevronLeftIcon, XIcon } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { XIcon } from "lucide-react"; +import { Suspense } from "react"; -import { WhatsNewArt } from "@/components/whats-new/art"; -import { type Shower, showerFor, usePreloadArt } from "@/components/whats-new/show"; import { Button } from "@/components/ui/button"; -import { Dialog, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogTitle } from "@/components/ui/dialog"; -import { Kbd } from "@/components/ui/kbd"; -import { useArt } from "@/lib/art/model"; -import { openDocs } from "@/lib/open-url"; -import { useStore } from "@/lib/store"; -import { cn } from "@/lib/utils"; -import { closeWhatsNew, dismissNudge, openWhatsNew, useWhatsNew } from "@/lib/whats-new"; -import type { Release, WhatsNewItem } from "@/lib/whats-new-model"; -import { useWorkspaces } from "@/lib/workspaces"; +import { lazyView } from "@/lib/lazy-view"; +import { dismissNudge, openWhatsNew, useWhatsNew } from "@/lib/whats-new"; -// The What's new card (lib/whats-new.ts): the release's highlights one at -// a time, each with a picture of it in the app and a way there, listed -// down the side so the whole release reads at a glance. After an update a -// note at the foot of the sidebar opens it (WhatsNewNudge). - -// useShowers is each item's Show me as things stand; it follows the -// worktree in front and the artifacts known. -function useShowers(items: WhatsNewItem[], open: boolean): (Shower | undefined)[] { - usePreloadArt(open); - useArt((s) => s.byWt); - useWorkspaces((s) => s.current); - useStore((s) => s.boxes); - return items.map((i) => (i.show ? showerFor(i.show) : undefined)); -} +// The What's new card itself (spotlight.tsx) loads after the app has +// started: most starts have no release to show. +const SpotlightCard = lazyView(() => import("@/components/whats-new/spotlight").then((m) => m.SpotlightCard)); export function WhatsNewDialog() { const { open, release } = useWhatsNew(); if (!release) return null; - return ; -} - -function go(s: Shower) { - closeWhatsNew(); - // After the dialog has given the keyboard back, so what opens keeps it. - window.setTimeout(() => s.run(), 0); -} - -// Hint is what an item offers without a Show me: its keys and where. -// Beside a Show me, the keys alone. -function Hint({ item, where, className }: { item: WhatsNewItem; where: boolean; className?: string }) { - if (!item.keys) return null; - return ( - - {item.keys} - {where && item.where} - - ); -} - -// Also is the release's smaller things, a line each. -function Also({ release, className }: { release: Release; className?: string }) { - return ( -
    - Also in this release -
      - {release.also.map((a) => { - const to = a.show && showerFor(a.show); - return ( -
    • - {a.text}{" "} - {to && ( - - )} -
    • - ); - })} -
    -
    - ); -} - -function SpotlightCard({ open, release }: { open: boolean; release: Release }) { - const items = release.items; - const [at, setAt] = useState(0); - const popup = useRef(null); - useEffect(() => { - if (open) setAt(0); - }, [open]); - const showers = useShowers(items, open); - const item = items[at]; - const s = showers[at]; - const last = at === items.length - 1; - const step = (d: number) => setAt((n) => Math.min(items.length - 1, Math.max(0, n + d))); return ( - !o && closeWhatsNew()}> - { - // ← and → page from anywhere in the card; ↑ and ↓ move in its list. - if (e.defaultPrevented || e.metaKey || e.altKey || e.ctrlKey) return; - if ((e.target as HTMLElement).closest("input, textarea")) return; - if (e.key === "ArrowRight" || e.key === "ArrowLeft") { - e.preventDefault(); - step(e.key === "ArrowRight" ? 1 : -1); - } else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && e.target === popup.current) { - e.preventDefault(); - step(e.key === "ArrowDown" ? 1 : -1); - } - }} - > - setAt(Number(v))} orientation="vertical" className="flex min-h-0"> -
    - - What's new - Shipyard {release.version} - - - {items.map((it, i) => ( - - {it.title} - - ))} - - -
    -
    - }> - - -
    - What's new - Shipyard {release.version} -
    - {items.map((it, i) => ( - -
    - -
    -
    -

    {it.title}

    -

    {it.body}

    -
    - {/* Narrow, the list is hidden: the smaller things follow the last. */} - {i === items.length - 1 && } -
    - ))} - -
    - {s && ( - - )} - - {!s && !item.keys && item.docs && ( - - )} -
    -
    - - {at + 1} / {items.length} - - - {last ? ( - }>Done - ) : ( - - )} -
    -
    -
    -
    -
    -
    + + + ); } diff --git a/app/src/components/workspace/pane.tsx b/app/src/components/workspace/pane.tsx index 830b248b..16cea1b3 100644 --- a/app/src/components/workspace/pane.tsx +++ b/app/src/components/workspace/pane.tsx @@ -4,8 +4,6 @@ import { lazy, Suspense, useEffect, useMemo } from "react"; import { Tip } from "@/components/tip"; import { AgentIcon, StateGlyph } from "@/components/agent-glyph"; -import { BrowserPane } from "@/components/browser-pane"; -import { PreviewPane } from "@/components/preview-pane"; import { FileGlyph } from "@/components/files/file-bits"; import { EmptySide } from "@/components/workspace/compare-view"; import { CompareSideContext, type CompareSide, pageLoading } from "@/lib/compare-actions"; @@ -37,9 +35,15 @@ import { useStore } from "@/lib/store"; import { startRenaming } from "@/lib/session-title"; import { memory, memoryNote } from "@/lib/processes"; import { cn } from "@/lib/utils"; +import { lazyView } from "@/lib/lazy-view"; import { focusPane, paneBeside, paneToTab, setPaneContent, splitKey, useWorkspaces, useWorktreeRef } from "@/lib/workspaces"; import { platformKeys } from "@/lib/platform"; +// Browser and preview panes (and their devtools) load after the app has +// started, with the other pages (lib/lazy-view.tsx). +const BrowserPane = lazyView(() => import("@/components/browser-pane").then((m) => m.BrowserPane)); +const PreviewPane = lazyView(() => import("@/components/preview-pane").then((m) => m.PreviewPane)); + export { agentLabel }; // A File tab's pane and its editor load the first time one shows. @@ -139,8 +143,16 @@ export function Pane({ wsKey, tab, pane, visible, focused, split, mixed, compare void startSession(restartCommand(c.command) ?? c.agent ?? "", { kind: "replace", tab, pane: pane.id }, c.agent ? agentLabel(c.agent) : "Agent")} onShowTerminal={() => setPaneContent(wsKey, tab, pane.id, { ...c, view: "terminal" })} /> )} - {c.kind === "browser" && setPaneContent(wsKey, tab, pane.id, { kind: "browser", url })} onLoading={compare ? (l) => pageLoading(pane.id, l) : undefined} />} - {c.kind === "preview" && setPaneContent(wsKey, tab, pane.id, { kind: "preview", url })} />} + {c.kind === "browser" && ( + }> + setPaneContent(wsKey, tab, pane.id, { kind: "browser", url })} onLoading={compare ? (l) => pageLoading(pane.id, l) : undefined} /> + + )} + {c.kind === "preview" && ( + }> + setPaneContent(wsKey, tab, pane.id, { kind: "preview", url })} /> + + )} {c.kind === "file" && ( }> diff --git a/app/src/components/workspace/tab-strip.tsx b/app/src/components/workspace/tab-strip.tsx index 71b2f0b6..7523b34f 100644 --- a/app/src/components/workspace/tab-strip.tsx +++ b/app/src/components/workspace/tab-strip.tsx @@ -2,7 +2,7 @@ import { CloudOffIcon, GaugeIcon, PencilIcon, RowsIcon, SquareSplitHorizontalIco import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { StateGlyph } from "@/components/agent-glyph"; -import { TabErrorBadge } from "@/components/browser-devtools"; +import { TabErrorBadge } from "@/components/devtools-badge"; import { BoardButton } from "@/components/art/board-buttons"; import { DockButton } from "@/components/files/tree-dock"; import { Tip } from "@/components/tip"; diff --git a/app/src/hooks/use-agent-counts.ts b/app/src/hooks/use-agent-counts.ts index 89763dba..f1d8f24b 100644 --- a/app/src/hooks/use-agent-counts.ts +++ b/app/src/hooks/use-agent-counts.ts @@ -1,8 +1,9 @@ import { useMemo } from "react"; -import type { Session } from "@/lib/api"; +import type { Session, Status } from "@/lib/api"; import { type SessionState, sessionState } from "@/lib/derive"; -import { useStore } from "@/lib/store"; +import { lastOf } from "@/lib/last-of"; +import { type BoxData, useStore } from "@/lib/store"; export interface SessionEntry { box: string; @@ -11,17 +12,21 @@ export interface SessionEntry { } // useAllSessions lists every session on every online box with its state. +// Every caller gets the same list for the same store (Home has a dozen), so +// what is derived from it can be worked out once (lastOf). export function useAllSessions(): SessionEntry[] { const boxes = useStore((s) => s.boxes); const status = useStore((s) => s.status); - return useMemo(() => { - const online = new Set(status?.boxes.filter((b) => b.state === "online").map((b) => b.name)); - return Object.entries(boxes) - .filter(([box]) => online.has(box)) - .flatMap(([box, d]) => (d.sessions ?? []).map((session) => ({ box, session, state: sessionState(session, d.stats) }))); - }, [boxes, status]); + return allSessions(boxes, status); } +const allSessions = lastOf((boxes: Record, status: Status | undefined): SessionEntry[] => { + const online = new Set(status?.boxes.filter((b) => b.state === "online").map((b) => b.name)); + return Object.entries(boxes) + .filter(([box]) => online.has(box)) + .flatMap(([box, d]) => (d.sessions ?? []).map((session) => ({ box, session, state: sessionState(session, d.stats) }))); +}); + export function useAgentCounts() { const all = useAllSessions(); return useMemo( diff --git a/app/src/hooks/use-berth-connection.ts b/app/src/hooks/use-berth-connection.ts index b53b69f3..5d608085 100644 --- a/app/src/hooks/use-berth-connection.ts +++ b/app/src/hooks/use-berth-connection.ts @@ -4,7 +4,6 @@ import { mockAgentDown, waitForRetry } from "@/lib/agent-start"; import { endpoint, httpClient, type Client } from "@/lib/api"; import { handleEvent } from "@/lib/events"; import { errorMessage } from "@/lib/format"; -import { mockClient } from "@/lib/mock"; import { useStore } from "@/lib/store"; import { loadNav } from "@/lib/nav"; import { loadNotifications } from "@/lib/notifications"; @@ -35,7 +34,9 @@ export function useBerthConnection() { try { // ?mock=offline is the "not running" screen until its agent starts. if (mockAgentDown()) throw new Error("the Shipyard agent has not started yet (mock)"); - if (isMock()) return mockClient(); + // The fixtures load only in mock mode: they stay out of the app's + // startup. + if (isMock()) return (await import("@/lib/mock")).mockClient(); const client = httpClient(await endpoint()); await client.status(); return client; diff --git a/app/src/lib/art/model.ts b/app/src/lib/art/model.ts index 06f7f6ec..c5099a29 100644 --- a/app/src/lib/art/model.ts +++ b/app/src/lib/art/model.ts @@ -123,6 +123,10 @@ export function useWorktreeArt(key?: string): Art[] { const locations = useStore((s) => (box ? s.boxes[box]?.locations : undefined)); useEffect(() => { if (!key || !can || known) return; + // Several parts of a worktree's view ask for its list as it opens, in + // one render: the first one's read is the read for all of them. + const now = useArt.getState(); + if (key in now.byWt || now.loading[key]) return; const ref = refFor(key); if (ref) void loadArtifacts(ref); }, [key, can, known, locations]); diff --git a/app/src/lib/chat-controls.ts b/app/src/lib/chat-controls.ts index ad411204..f6aa9e33 100644 --- a/app/src/lib/chat-controls.ts +++ b/app/src/lib/chat-controls.ts @@ -3,9 +3,8 @@ import { create } from "zustand"; import { isMock } from "@/hooks/use-berth-connection"; import { boxApi } from "@/lib/api"; +import { useFedSignals } from "@/lib/chat-signals"; import { keyOf, useConversations } from "@/lib/conversation-store"; -import { useEventLog } from "@/lib/events"; -import { mockDemo } from "@/lib/mock"; import { useStore } from "@/lib/store"; import type { ToolDetail, TranscriptItem } from "@/lib/transcript"; @@ -55,49 +54,24 @@ export interface ScreenControls { export type NoticeItem = Extract; const enc = encodeURIComponent; -// Asking from past the end returns no items, only the crew and signals. -const PAST_END = 1_000_000_000; export const hasControls = (box: string) => !!useStore.getState().boxes[box]?.info?.capabilities?.includes("controls"); -// useChatSignals reads a session's signals every few seconds while its chat -// shows, and at once when something happens to it. -export function useChatSignals(box: string, session: string, enabled: boolean): ChatSignals | undefined { - const client = useStore((s) => s.client); +// useChatSignals is a session's signals while its chat shows. They come +// with each read of its transcript, which the chat's feed makes anyway (on +// open, every few seconds, and at once when something happens to it or +// the agent writes): the feed keeps them (lib/chat-signals), so they cost +// no read of their own. +export function useChatSignals(box: string, session: string): ChatSignals | undefined { const mock = isMock(); const [sig, setSig] = useState(); - const latest = useEventLog((s) => s.events.find((e) => e.box === box && (e.type.startsWith("agent.") || e.type.startsWith("session.")) && (e.data?.session === session || e.data?.name === session))?.time); - useEffect(() => setSig(undefined), [box, session]); + const fed = useFedSignals((s) => s.byKey[keyOf(box, session)]); useEffect(() => { - if (mock) { - setSig(mockSignals(session)); - return useMockSignals.subscribe(() => setSig(mockSignals(session))); - } - if (!client || !enabled) return; - let alive = true; - let busy = false; - const read = async () => { - if (busy || document.hidden) return; - busy = true; - try { - const r = await client.box<{ signals?: ChatSignals; source: string }>(box, "GET", `sessions/${enc(session)}/transcript?since=${PAST_END}`); - if (alive) setSig(r.signals ?? {}); - } catch { - // The conversation itself says when it can't be read. - } finally { - busy = false; - } - }; - void read(); - const soon = window.setTimeout(() => void read(), 900); - const t = window.setInterval(() => void read(), 3000); - return () => { - alive = false; - window.clearTimeout(soon); - window.clearInterval(t); - }; - }, [client, box, session, enabled, mock, latest]); - return sig; + if (!mock) return; + setSig(mockSignals(session)); + return useMockSignals.subscribe(() => setSig(mockSignals(session))); + }, [box, session, mock]); + return mock ? sig : fed; } // useScreenControls reads the mode off the agent's screen when the chat @@ -263,7 +237,7 @@ function mockSignals(session: string): ChatSignals { function mockInterrupt(box: string, session: string) { useMockSignals.setState((s) => ({ stopped: { ...s.stopped, [session]: true } })); - mockDemo.setAgent(box, session, "finished"); + void import("@/lib/mock").then((m) => m.mockDemo.setAgent(box, session, "finished")); const key = keyOf(box, session); const st = useConversations.getState(); for (const it of st.items[key] ?? []) if (it.kind === "thinking") st.remove(key, it.id); diff --git a/app/src/lib/chat-signals.ts b/app/src/lib/chat-signals.ts new file mode 100644 index 00000000..b9cfb6ec --- /dev/null +++ b/app/src/lib/chat-signals.ts @@ -0,0 +1,18 @@ +import { create } from "zustand"; + +import type { ChatSignals } from "@/lib/chat-controls"; + +// A chat's signals (its mode, model, context, task list, background work) +// come with every read of its transcript (internal/transcript/signals.go), +// so the transcript feed (lib/transcript-feed) keeps the latest here, by +// chat (keyOf), for the chat's controls: one read of the box for both. + +export const useFedSignals = create<{ byKey: Record }>()(() => ({ byKey: {} })); + +export function noteSignals(key: string, sig: ChatSignals | undefined) { + const next = sig ?? {}; + const was = useFedSignals.getState().byKey[key]; + // As it was: nothing to draw again. + if (was && JSON.stringify(was) === JSON.stringify(next)) return; + useFedSignals.setState((s) => ({ byKey: { ...s.byKey, [key]: next } })); +} diff --git a/app/src/lib/css-has.test.ts b/app/src/lib/css-has.test.ts new file mode 100644 index 00000000..71644fc8 --- /dev/null +++ b/app/src/lib/css-has.test.ts @@ -0,0 +1,38 @@ +// node --experimental-strip-types --test src/lib/css-has.test.ts (pnpm test) +// +// A rule that asks the whole page :has() (body:has(…) .x, :root:has(…)) makes +// Chrome restyle every element whenever any element is added anywhere: a +// streamed reply's words, a menu opening, a key typed into a composer. In a +// long chat that was 9 ms a draft update, and 35 ms a key on Home with 300 +// worktrees. Say it with an attribute on the root instead (scenes.tsx: +// data-berth-dialog). +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +const src = join(import.meta.dirname, ".."); + +function files(dir: string, out: string[] = []): string[] { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) files(p, out); + else if (/\.(css|tsx?)$/.test(e.name) && !e.name.endsWith(".test.ts")) out.push(p); + } + return out; +} + +// In CSS, a selector; in components, a Tailwind arbitrary variant +// ("[body:has(…)_&]:…", "[:root:has(…)_&]:…"). +const PAGE_HAS = /(?:^|[\s,{}(>~+[])(?:html|body|:root)(?:\[[^\]]*\]|\.[\w-]+)*:has\(/m; + +test("no stylesheet or class asks the whole page :has()", () => { + const found: string[] = []; + for (const f of files(src)) { + let text = readFileSync(f, "utf8"); + if (f.endsWith(".css")) text = text.replace(/\/\*[\s\S]*?\*\//g, ""); + else text = [...text.matchAll(/\[[^\]\s"'`]*:has\([^\s"'`]*/g)].map((m) => m[0]).join("\n"); + for (const line of text.split("\n")) if (PAGE_HAS.test(line)) found.push(`${f.slice(src.length + 1)}: ${line.trim()}`); + } + assert.deepEqual(found, []); +}); diff --git a/app/src/lib/last-of.test.ts b/app/src/lib/last-of.test.ts new file mode 100644 index 00000000..a9801901 --- /dev/null +++ b/app/src/lib/last-of.test.ts @@ -0,0 +1,45 @@ +// node --experimental-strip-types --test src/lib/last-of.test.ts (pnpm test) +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { lastOf } from "./last-of.ts"; + +test("the same arguments give the same answer without working it out again", () => { + let runs = 0; + const rows = lastOf((all: number[], k: string) => { + runs++; + return all.map((n) => `${k}${n}`); + }); + const all = [1, 2, 3]; + const a = rows(all, "x"); + const b = rows(all, "x"); + assert.equal(a, b); + assert.equal(runs, 1); +}); + +test("an argument that is a new object, even an equal one, works it out again", () => { + let runs = 0; + const sum = lastOf((xs: number[]) => { + runs++; + return xs.reduce((s, x) => s + x, 0); + }); + assert.equal(sum([1, 2]), 3); + assert.equal(sum([1, 2]), 3); + assert.equal(runs, 2); + const xs = [4]; + sum(xs); + sum(xs); + assert.equal(runs, 3); +}); + +test("only the last answer is kept", () => { + let runs = 0; + const twice = lastOf((n: number) => { + runs++; + return n * 2; + }); + twice(1); + twice(2); + twice(1); + assert.equal(runs, 3); +}); diff --git a/app/src/lib/last-of.ts b/app/src/lib/last-of.ts new file mode 100644 index 00000000..b8ab3239 --- /dev/null +++ b/app/src/lib/last-of.ts @@ -0,0 +1,13 @@ +// lastOf remembers fn's last answer for the same arguments (compared by +// identity): a memo every component that asks shares, where each one's own +// useMemo would work the same thing out again (Home's dozen widgets over +// every session, lib/hooks use-agent-counts). +export function lastOf(fn: (...args: A) => R): (...args: A) => R { + let last: { args: A; out: R } | undefined; + return (...args: A) => { + if (last && last.args.length === args.length && last.args.every((a, i) => Object.is(a, args[i]))) return last.out; + const out = fn(...args); + last = { args, out }; + return out; + }; +} diff --git a/app/src/lib/lazy-view.tsx b/app/src/lib/lazy-view.tsx new file mode 100644 index 00000000..02a9738a --- /dev/null +++ b/app/src/lib/lazy-view.tsx @@ -0,0 +1,60 @@ +import { type ComponentType, lazy, useState } from "react"; + +// lazyView is a page (Settings, Automations, Team setup…), a dialog's +// content or a kind of pane that loads after the app has started rather +// than with it, so they don't all weigh on its start. +// preloadViews fetches them once the app is up and idle, and a view whose +// code is in by the time it shows draws at once, without suspending (no +// blank frame on a first visit). Render it under a . +const loaders: (() => Promise)[] = []; + +export function lazyView

    (load: () => Promise>): ComponentType

    { + let loaded: ComponentType

    | undefined; + let pending: Promise> | undefined; + const get = () => + (pending ??= load().then( + (c) => (loaded = c), + (err) => { + // A failed fetch can be tried again on the next visit. + pending = undefined; + throw err; + }, + )); + loaders.push(get); + const Lazy = lazy(() => get().then((c) => ({ default: c }))); + function View(props: P) { + // Chosen once per mount: switching from Lazy to the loaded component + // under a mounted view would remount it. + const [Ready] = useState(() => loaded); + return Ready ? : ; + } + return View; +} + +// preloadViews loads every lazy view's code a while after the app starts, +// one at a time when the main thread is free. WebKit (the macOS and Linux +// app) has no requestIdleCallback; there it is a timer. +export function preloadViews(): () => void { + const ric = window.requestIdleCallback as typeof window.requestIdleCallback | undefined; + const idle = (cb: () => void) => (ric ? ric(cb, { timeout: 2000 }) : window.setTimeout(cb, 50)); + const cancel = (h: number) => (ric ? window.cancelIdleCallback(h) : window.clearTimeout(h)); + let i = 0; + let handle = 0; + let stopped = false; + const next = () => { + const load = loaders[i++]; + if (!load || stopped) return; + void load() + .catch(() => {}) + .finally(() => { + if (!stopped) handle = idle(next); + }); + }; + // After the first screen has settled. + const start = window.setTimeout(() => (handle = idle(next)), 1500); + return () => { + stopped = true; + window.clearTimeout(start); + cancel(handle); + }; +} diff --git a/app/src/lib/project-groups.ts b/app/src/lib/project-groups.ts index 032fc671..2fe1a897 100644 --- a/app/src/lib/project-groups.ts +++ b/app/src/lib/project-groups.ts @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { create } from "zustand"; +import { useShallow } from "zustand/react/shallow"; import type { BoxStatus, Location } from "@/lib/api"; import { projectKey } from "@/lib/projects"; @@ -156,9 +157,15 @@ export function deriveProjects(boxes: BoxStatus[], data: Record s.status?.boxes ?? NONE); - const data = useStore((s) => s.boxes); + // Only the boxes' locations: an agent's state changing (every few + // seconds on a busy box) leaves the projects, and the sidebar's rows drawn + // from them, as they are. + const locations = useStore(useShallow((s) => Object.fromEntries(Object.entries(s.boxes).map(([name, d]) => [name, d.locations])))); const doc = useProjectsDoc((s) => s.doc); - return useMemo(() => ({ projects: deriveProjects(boxes, data, doc), sections: doc.sections }), [boxes, data, doc]); + return useMemo(() => { + const data = Object.fromEntries(Object.entries(locations).map(([name, l]) => [name, { locations: l }])); + return { projects: deriveProjects(boxes, data, doc), sections: doc.sections }; + }, [boxes, locations, doc]); } export function projectOf(box: string, location: string): Project | undefined { diff --git a/app/src/lib/start-work.ts b/app/src/lib/start-work.ts index 91e7eec3..edfad43f 100644 --- a/app/src/lib/start-work.ts +++ b/app/src/lib/start-work.ts @@ -10,7 +10,6 @@ import { startBroadcast } from "@/lib/broadcast"; import type { AgentPick, ComposerTarget } from "@/lib/composer"; import { agentLabel } from "@/lib/derive"; import { plainError } from "@/lib/errors"; -import { playTurn } from "@/lib/mock-conversation"; import { handoff, loop, review } from "@/lib/orchestrate"; import { worktreeSlug } from "@/lib/projects"; import { usePrompts } from "@/lib/prompts"; @@ -140,7 +139,7 @@ export async function startWork(d: StartDraft): Promise { } // The demo plays a scripted turn; it starts before the pane opens, so // the pane finds the conversation already begun. - if (isMock() && d.text) void playTurn(d.box, session, d.text); + if (isMock() && d.text) void import("@/lib/mock-conversation").then((m) => m.playTurn(d.box, session, d.text)); await useStore.getState().refreshBox(d.box, ["locations", "sessions"]); await focusSession(d.box, session); save(`berth.composer.picks.${d.box}/${d.location}`, d.picks); diff --git a/app/src/lib/transcript-feed.ts b/app/src/lib/transcript-feed.ts index b9798b3e..ebbc5d4a 100644 --- a/app/src/lib/transcript-feed.ts +++ b/app/src/lib/transcript-feed.ts @@ -1,6 +1,8 @@ import { useEffect, useRef, useState } from "react"; import { boxApi, type Client, type QueuedPrompt } from "@/lib/api"; +import type { ChatSignals } from "@/lib/chat-controls"; +import { noteSignals } from "@/lib/chat-signals"; import { keyOf, offOf, useConversations } from "@/lib/conversation-store"; import { dropOlder, historyApi } from "@/lib/history"; import { useEventLog } from "@/lib/events"; @@ -35,6 +37,9 @@ export interface TranscriptResult { reset?: boolean; start?: number; file?: string; + // The agent's mode, model, context and tasks, for the chat's controls + // (lib/chat-signals). + signals?: ChatSignals; } // The record each chat shows (newer boxes), kept while the app runs: a @@ -142,6 +147,7 @@ export function useTranscriptFeed(box: string, session: string, dir: string | un const r = await client.box(box, "GET", `sessions/${encodeURIComponent(session)}/transcript?since=${since}${g}`, undefined, ctl.signal); if (!alive) return; failures = 0; + noteSignals(key, r.signals); if (r.source === "none") { setState("none"); return; diff --git a/app/src/plugins/dynamic-icon.tsx b/app/src/plugins/dynamic-icon.tsx new file mode 100644 index 00000000..f366844b --- /dev/null +++ b/app/src/plugins/dynamic-icon.tsx @@ -0,0 +1,21 @@ +import { DynamicIcon, iconNames, type IconName } from "lucide-react/dynamic"; + +// lucide's map of every icon by name (some 1,700 lazy imports) is large, so +// it loads with the first icon a plugin or a sidebar item names, not at +// start (plugins/ui.tsx's Icon). + +const known = new Set(iconNames); + +// iconName turns "PanelsTopLeft" or "panels-top-left" into lucide's name. +export function iconName(name: string): IconName | undefined { + const kebab = name + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/([A-Z])([A-Z][a-z])/g, "$1-$2") + .toLowerCase(); + return known.has(kebab) ? (kebab as IconName) : undefined; +} + +export default function NamedIcon({ name, className }: { name: string; className?: string }) { + const n = iconName(name); + return n ? : ; +} diff --git a/app/src/plugins/ui.tsx b/app/src/plugins/ui.tsx index 4f08c161..5d7f6a65 100644 --- a/app/src/plugins/ui.tsx +++ b/app/src/plugins/ui.tsx @@ -1,4 +1,4 @@ -import { DynamicIcon, iconNames, type IconName } from "lucide-react/dynamic"; +import { lazy, Suspense } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -34,21 +34,16 @@ import { cn } from "@/lib/utils"; import { WidgetEmpty, WidgetRow, WidgetSkeleton } from "@/views/home/widgets/parts"; import { PluginPage, ViewHeader } from "@/views/view-header"; -const known = new Set(iconNames); - -// iconName turns "PanelsTopLeft" or "panels-top-left" into lucide's name. -export function iconName(name: string): IconName | undefined { - const kebab = name - .replace(/([a-z0-9])([A-Z])/g, "$1-$2") - .replace(/([A-Z])([A-Z][a-z])/g, "$1-$2") - .toLowerCase(); - return known.has(kebab) ? (kebab as IconName) : undefined; -} +const NamedIcon = lazy(() => import("@/plugins/dynamic-icon")); // Icon draws any lucide icon by name, for plugins and their sidebar items. +// Like lucide's DynamicIcon it draws nothing until the icon has loaded. export function Icon({ name, className }: { name: string; className?: string }) { - const n = iconName(name); - return n ? : ; + return ( + + + + ); } // pluginUi is what @berth/plugin/ui resolves to inside a plugin. diff --git a/app/src/views/home/widgets/agents.tsx b/app/src/views/home/widgets/agents.tsx index 64919636..c05baa83 100644 --- a/app/src/views/home/widgets/agents.tsx +++ b/app/src/views/home/widgets/agents.tsx @@ -5,11 +5,12 @@ import { AgentIcon, StateGlyph } from "@/components/agent-glyph"; import { toastError } from "@/components/error-note"; import { Button } from "@/components/ui/button"; import { type SessionEntry, useAllSessions } from "@/hooks/use-agent-counts"; +import { lastOf } from "@/lib/last-of"; import { boxApi, type Session } from "@/lib/api"; import { agentLabel, agentOf, worktreeOf } from "@/lib/derive"; import { parseScreen } from "@/lib/screen-status"; import { permissionChoices } from "@/lib/screen"; -import { useStore } from "@/lib/store"; +import { type BoxData, useStore } from "@/lib/store"; import { useAsk } from "@/lib/transcript-feed"; import { cn } from "@/lib/utils"; import { focusSession, homeBox, selectWorktree, useWorkspaces, type WorktreeRef } from "@/lib/workspaces"; @@ -41,29 +42,32 @@ export interface AgentRow { export function useAgentRows(): AgentRow[] { const all = useAllSessions(); const boxes = useStore((s) => s.boxes); - return useMemo(() => { - const rows: AgentRow[] = []; - for (const e of all as SessionEntry[]) { - const agent = agentOf(e.session); - if (!agent || (e.state !== "waiting" && e.state !== "running" && e.state !== "finished")) continue; - const wt = worktreeOf(boxes[e.box]?.locations, e.session); - const place = wt ? (wt.worktree.main ? wt.location.name : `${wt.location.name} / ${worktreeLabel(wt.worktree)}`) : ""; - rows.push({ - key: `${e.box}/${e.session.name}`, - box: e.box, - session: e.session, - agent, - title: e.session.title?.trim() || place || e.session.name, - where: [e.session.title?.trim() ? place : "", e.box].filter(Boolean).join(" · "), - worktree: wt ? { box: e.box, location: wt.location.name, worktree: wt.worktree.name, path: wt.worktree.path, main: wt.worktree.main } : undefined, - state: e.state, - since: e.session.state_since ?? e.session.created, - }); - } - return rows.sort((a, b) => (b.since ?? "").localeCompare(a.since ?? "")); - }, [all, boxes]); + return agentRows(all, boxes); } +// Worked out once for every widget and count that asks (lastOf). +const agentRows = lastOf((all: SessionEntry[], boxes: Record): AgentRow[] => { + const rows: AgentRow[] = []; + for (const e of all) { + const agent = agentOf(e.session); + if (!agent || (e.state !== "waiting" && e.state !== "running" && e.state !== "finished")) continue; + const wt = worktreeOf(boxes[e.box]?.locations, e.session); + const place = wt ? (wt.worktree.main ? wt.location.name : `${wt.location.name} / ${worktreeLabel(wt.worktree)}`) : ""; + rows.push({ + key: `${e.box}/${e.session.name}`, + box: e.box, + session: e.session, + agent, + title: e.session.title?.trim() || place || e.session.name, + where: [e.session.title?.trim() ? place : "", e.box].filter(Boolean).join(" · "), + worktree: wt ? { box: e.box, location: wt.location.name, worktree: wt.worktree.name, path: wt.worktree.path, main: wt.worktree.main } : undefined, + state: e.state, + since: e.session.state_since ?? e.session.created, + }); + } + return rows.sort((a, b) => (b.since ?? "").localeCompare(a.since ?? "")); +}); + // Until the agent and every online box have answered, agent lists are // loading, not empty. export function useAgentsLoading(): boolean { diff --git a/app/src/views/onboarding/add-box-dialog.tsx b/app/src/views/onboarding/add-box-dialog.tsx index 2757a166..cc558e51 100644 --- a/app/src/views/onboarding/add-box-dialog.tsx +++ b/app/src/views/onboarding/add-box-dialog.tsx @@ -1,12 +1,16 @@ +import { Suspense } from "react"; import { create } from "zustand"; import { Dialog, DialogPopup } from "@/components/ui/dialog"; import { toastManager } from "@/components/ui/toast"; import { useStore } from "@/lib/store"; -import { AddBoxFlow } from "@/views/onboarding/add-box-flow"; +import { lazyView } from "@/lib/lazy-view"; import { prefetchTailnets } from "@/views/onboarding/tailnet"; import { openTeam, TeamBoxEntry } from "@/views/team/team-entry"; +// The flow (its install, SSH and tailnet steps) loads after the app starts. +const AddBoxFlow = lazyView(() => import("@/views/onboarding/add-box-flow").then((m) => m.AddBoxFlow)); + const useAddBox = create<{ open: boolean }>()(() => ({ open: false })); // openAddBox shows the add-a-box flow from anywhere: Settings, the sidebar, @@ -25,26 +29,28 @@ export function AddBoxDialog() { would move its title with each one. */} {open && ( - { - close(); - openTeam(undefined, "addbox"); - }} - /> - ) : undefined - } - onDone={(box) => { - close(); - toastManager.add({ title: `${box} is ready`, description: "Paired and online. Add a repo on it to start working there.", type: "success" }); - }} - /> + + { + close(); + openTeam(undefined, "addbox"); + }} + /> + ) : undefined + } + onDone={(box) => { + close(); + toastManager.add({ title: `${box} is ready`, description: "Paired and online. Add a repo on it to start working there.", type: "success" }); + }} + /> + )} diff --git a/app/src/views/pr-review/pr-review-sheet.tsx b/app/src/views/pr-review/pr-review-sheet.tsx new file mode 100644 index 00000000..7d1d427e --- /dev/null +++ b/app/src/views/pr-review/pr-review-sheet.tsx @@ -0,0 +1,21 @@ +import { Suspense, useRef } from "react"; + +import { Sheet, SheetPopup } from "@/components/ui/sheet"; +import { lazyView } from "@/lib/lazy-view"; +import { closeReviewSheet, usePrReview } from "@/lib/pr-review"; + +// PrReviewSheet's frame (review-sheet.tsx is what it shows): its content +// loads after the app has started. +const PrReviewBody = lazyView(() => import("@/views/pr-review/review-sheet").then((m) => m.PrReviewBody)); + +export function PrReviewSheet() { + const sheet = usePrReview((s) => s.sheet); + const popup = useRef(null); + return ( + !open && closeReviewSheet()}> + + {sheet && } + + + ); +} diff --git a/app/src/views/pr-review/review-sheet.tsx b/app/src/views/pr-review/review-sheet.tsx index 960d9d45..a2fa0b50 100644 --- a/app/src/views/pr-review/review-sheet.tsx +++ b/app/src/views/pr-review/review-sheet.tsx @@ -5,7 +5,7 @@ import { ErrorText } from "@/components/error-note"; import { SimpleSelect } from "@/components/simple-select"; import { Tip } from "@/components/tip"; import { Button } from "@/components/ui/button"; -import { Sheet, SheetDescription, SheetFooter, SheetHeader, SheetPanel, SheetPopup, SheetTitle } from "@/components/ui/sheet"; +import { SheetDescription, SheetFooter, SheetHeader, SheetPanel, SheetTitle } from "@/components/ui/sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { openBrowserAt } from "@/lib/actions"; import { ApiError, type Worktree } from "@/lib/api"; @@ -28,21 +28,11 @@ import { GitHubMark, StateIcon } from "@/views/team/team-parts"; // worktree at the head commit shown, set up by the team's kit, then opens // it with its dev server in a Browser tab and the Diff beside it. -export function PrReviewSheet() { - const sheet = usePrReview((s) => s.sheet); - const popup = useRef(null); - return ( - !open && closeReviewSheet()}> - - {sheet && } - - - ); -} - type Phase = { kind: "review" } | { kind: "running"; started: number; opened?: ReviewOpened; error?: string }; -function Body() { +// PrReviewBody is the sheet's content; its frame (pr-review-sheet.tsx) loads +// it when it first opens. +export function PrReviewBody() { const req = usePrReview((s) => s.sheet)!; const client = useStore((s) => s.client); const [plan, setPlan] = useState(); diff --git a/app/src/views/review/review-store.ts b/app/src/views/review/review-store.ts index 912b11de..2d171747 100644 --- a/app/src/views/review/review-store.ts +++ b/app/src/views/review/review-store.ts @@ -120,28 +120,36 @@ export const visibleEntries = (s: Pick) => let inflight: Promise | undefined; -export function refreshReview(): Promise { +// refreshReview reads the inbox again: every online box's, or only those +// named (an event on one box changes only its inbox; a box's GET review +// runs git in each of its worktrees with finished work). +export function refreshReview(only?: Iterable): Promise { if (inflight) return inflight; - inflight = doRefresh().finally(() => { + inflight = doRefresh(only ? new Set(only) : undefined).finally(() => { inflight = undefined; }); return inflight; } -async function doRefresh() { +async function doRefresh(only?: Set) { const { client, status } = useStore.getState(); if (!client || !status) return; useReview.setState({ loading: true }); - const boxes = status.boxes.filter((b) => b.state === "online").map((b) => b.name); - const outdated: string[] = []; - const errors: Record = {}; - const runs: Record = {}; + const online = status.boxes.filter((b) => b.state === "online").map((b) => b.name); + const boxes = only ? online.filter((b) => only.has(b)) : online; + // The other online boxes keep what they had. + const kept = (box: string) => !!only && !only.has(box) && online.includes(box); + const was = useReview.getState(); + const outdated: string[] = was.outdated.filter(kept); + const errors: Record = Object.fromEntries(Object.entries(was.errors).filter(([b]) => kept(b))); + const runs: Record = Object.fromEntries(Object.entries(was.runs).filter(([k]) => kept(k.slice(0, k.indexOf("|"))))); const lists = await Promise.all( boxes.map(async (box) => { try { + // Both at once: the last check a flow ran in each worktree, if any. + const checks = flowsApi.runs(client, box, undefined, 50).catch(() => [] as FlowRun[]); const items = (await client.box(box, "GET", "review")) ?? []; - // The last check a flow ran in each worktree, if any. - const recent = await flowsApi.runs(client, box, undefined, 50).catch(() => [] as FlowRun[]); + const recent = await checks; for (const r of recent) { const path = r.event?.data?.path as string | undefined; if (!path || !r.steps.some((s) => s.kind === "run")) continue; @@ -157,7 +165,7 @@ async function doRefresh() { } }), ); - const entries = lists.flat().sort((a, b) => (b.state_since ?? "").localeCompare(a.state_since ?? "")); + const entries = [...useReview.getState().entries.filter((e) => kept(e.box)), ...lists.flat()].sort((a, b) => (b.state_since ?? "").localeCompare(a.state_since ?? "")); useReview.setState({ entries, runs, outdated, errors, loading: false, loaded: true }); void fetchPullRequests(client, entries); } @@ -204,19 +212,35 @@ export function watchReview() { if (started) return; started = true; let timer = 0; - const soon = () => { + // The boxes whose events came since the last read; all: every box. + let all = false; + const boxes = new Set(); + const soon = (box?: string) => { + if (box) boxes.add(box); + else all = true; window.clearTimeout(timer); - timer = window.setTimeout(() => void refreshReview(), 1500); + timer = window.setTimeout(() => { + const only = all ? undefined : [...boxes]; + all = false; + boxes.clear(); + void refreshReview(only); + }, 1500); }; let last: BerthEvent | undefined; useEventLog.subscribe((s) => { const e = s.events[0]; if (!e || e === last) return; last = e; - if (RELEVANT.test(e.type)) soon(); + if (RELEVANT.test(e.type)) soon(e.box); }); + const onlineOf = (st: ReturnType) => + (st.status?.boxes ?? []) + .filter((b) => b.state === "online") + .map((b) => b.name) + .join(","); useStore.subscribe((s, prev) => { - if (s.client !== prev.client || s.status?.boxes.length !== prev.status?.boxes.length) soon(); + // A box that came back is read too. + if (s.client !== prev.client || s.status?.boxes.length !== prev.status?.boxes.length || (s.status !== prev.status && onlineOf(s) !== onlineOf(prev))) soon(); }); // A backstop to the events above, and not while the window is hidden. window.setInterval(() => !document.hidden && void refreshReview(), 60_000); diff --git a/app/src/views/settings/open.ts b/app/src/views/settings/open.ts new file mode 100644 index 00000000..6fbdbaf5 --- /dev/null +++ b/app/src/views/settings/open.ts @@ -0,0 +1,10 @@ +import { useStore } from "@/lib/store"; + +// Settings' sections and how to open one, apart from the view itself, which +// loads when it is first shown (App.tsx). +export type SettingsSectionId = "general" | "notifications" | "appearance" | "terminal" | "boxes" | "computers" | "phone" | "agents" | "plugins" | "shortcuts" | "labs" | "about" | "developer"; + +// openSettings shows Settings, on a section when given one. +export function openSettings(section?: SettingsSectionId) { + useStore.getState().setView({ kind: "settings", section }); +} diff --git a/app/src/views/settings/settings-view.tsx b/app/src/views/settings/settings-view.tsx index 9cc4b586..e3ed56fb 100644 --- a/app/src/views/settings/settings-view.tsx +++ b/app/src/views/settings/settings-view.tsx @@ -19,7 +19,9 @@ import { ShortcutsSection } from "@/views/settings/shortcuts-section"; import { TerminalSection } from "@/views/settings/terminal-section"; import { ViewHeaderHost } from "@/views/view-header"; -export type SettingsSectionId = "general" | "notifications" | "appearance" | "terminal" | "boxes" | "computers" | "phone" | "agents" | "plugins" | "shortcuts" | "labs" | "about" | "developer"; +import { openSettings, type SettingsSectionId } from "@/views/settings/open"; + +export { openSettings, type SettingsSectionId }; const SECTIONS: { id: SettingsSectionId; title: string; icon: ComponentType<{ className?: string }>; Component: ComponentType }[] = [ { id: "general", title: "General", icon: SlidersHorizontalIcon, Component: GeneralSection }, @@ -37,11 +39,6 @@ const SECTIONS: { id: SettingsSectionId; title: string; icon: ComponentType<{ cl { id: "developer", title: "Developer", icon: WrenchIcon, Component: DeveloperSection }, ]; -// openSettings shows Settings, on a section when given one. -export function openSettings(section?: SettingsSectionId) { - useStore.getState().setView({ kind: "settings", section }); -} - // Below 1200px of window the section list shrinks to its icons, so the // settings themselves keep their width. export function SettingsView() { diff --git a/app/src/views/team/org-field.tsx b/app/src/views/team/org-field.tsx new file mode 100644 index 00000000..98131807 --- /dev/null +++ b/app/src/views/team/org-field.tsx @@ -0,0 +1,13 @@ +import { Input } from "@/components/ui/input"; + +// OrgField takes an org, or a link to a setup (owner/repo/tree/branch/folder +// after the github.com/ it shows, or a whole URL pasted, which hides it). +export function OrgField({ value, onChange, autoFocus }: { value: string; onChange(v: string): void; autoFocus?: boolean }) { + const whole = /github\.com|:\/\//i.test(value); + return ( + + ); +} diff --git a/app/src/views/team/team-entry.tsx b/app/src/views/team/team-entry.tsx index 95c83406..cb6eacb9 100644 --- a/app/src/views/team/team-entry.tsx +++ b/app/src/views/team/team-entry.tsx @@ -11,7 +11,7 @@ import { activeRun, keyOf, loadTeams, onRepoReady, recentlyDone, teamRef, type T import { cn } from "@/lib/utils"; import { GitHubMark } from "@/views/team/team-parts"; import { runSummary } from "@/views/team/team-rail"; -import { OrgField } from "@/views/team/team-view"; +import { OrgField } from "@/views/team/org-field"; // The ways into Team setup, and what shows of it around the app while it // runs: the welcome screen's "Joining a team?", Add a box's "Set this box up diff --git a/app/src/views/team/team-view.tsx b/app/src/views/team/team-view.tsx index 79479902..13e29dd2 100644 --- a/app/src/views/team/team-view.tsx +++ b/app/src/views/team/team-view.tsx @@ -27,6 +27,7 @@ import { GitHubMark, OrgAvatar, OrgHeader, Section } from "@/views/team/team-par import { Plan } from "@/views/team/team-plan"; import { Checklist, RunCard } from "@/views/team/team-rail"; import { ViewHeader } from "@/views/view-header"; +import { OrgField } from "@/views/team/org-field"; type From = "onboarding" | "addbox" | "link" | "sidebar" | "palette" | "suggestion"; @@ -217,18 +218,6 @@ function AskOrg({ onOrg, initial = "", note }: { onOrg(org: string): void; initi ); } -// OrgField takes an org, or a link to a setup (owner/repo/tree/branch/folder -// after the github.com/ it shows, or a whole URL pasted, which hides it). -export function OrgField({ value, onChange, autoFocus }: { value: string; onChange(v: string): void; autoFocus?: boolean }) { - const whole = /github\.com|:\/\//i.test(value); - return ( - - ); -} - function OrgPage({ org, from, wantBox, wantUpdate, github, onOrg }: { org: string; from?: From; wantBox?: string; wantUpdate?: boolean; github: GitHubState; onOrg(org: string): void }) { const client = useStore((s) => s.client); const status = useStore((s) => s.status); diff --git a/app/vite.config.ts b/app/vite.config.ts index 2c32c72c..4c1a3eae 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -105,6 +105,19 @@ function workerScript(): Plugin { }; } +// startupChunks puts all the code the app starts with ($initial: what the +// entry imports, statically, all the way down) in two files: the libraries +// and the app's own. Left to itself the bundler cut it into some 300, as +// lazily loaded code shares much of it (lucide's icons one file each), and +// the window waited on every one of them before its first paint. Two load +// and compile side by side. What loads later is split as before. +const startupChunks = { + groups: [ + { name: "vendor", test: /[\\/]node_modules[\\/]/, tags: ["$initial" as const] }, + { name: "startup", tags: ["$initial" as const] }, + ], +}; + // commit is the checkout's short hash, which Copy diagnostics names as the // app's build; empty outside a git checkout. function commit(): string { @@ -149,10 +162,11 @@ export default defineConfig(({ mode }) => ({ }, base: mode === "demo" ? "./" : "/", // A desktop app loads from disk; one large chunk (xterm, React) is fine. - build: - mode === "demo" - ? { chunkSizeWarningLimit: 2000, outDir: path.resolve(import.meta.dirname, "../site/demo"), emptyOutDir: true } - : { chunkSizeWarningLimit: 2000 }, + build: { + chunkSizeWarningLimit: 2000, + ...(mode === "demo" ? { outDir: path.resolve(import.meta.dirname, "../site/demo"), emptyOutDir: true } : {}), + rolldownOptions: { output: { codeSplitting: startupChunks } }, + }, // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 9e5609b8..5c71b713 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -17,6 +17,25 @@ again. app.** Opening `http://localhost:3000` before its dev server started closed Shipyard half a second later, and again on every launch after, since the tab came back. +- **Shipyard opens faster.** It now loads four files before it first paints + instead of about three hundred, and settings, dialogs and the browser pane + load just after start. In our tests, the window drew in about a fifth of the + time. +- **Chats keep up while an agent writes.** A streamed reply costs about a + third of the work it did on the page, and typing in a chat, Home's composer + or the sidebar's menus stays quick with hundreds of worktrees. +- **The app asks the agent and your boxes less.** An idle chat makes about + half the requests it did, starting reads every box once, and a box's large + answers cross the link to your laptop about ten times smaller. +- **berthd uses less of a box.** Following a chat, a reply as it streams and + the agents' screens runs fewer tmux commands and less CPU; a chat's own + poll takes about a quarter of what it did. + Opening a long conversation uses a tenth of the memory, and a cache that grew + with every conversation now stays small. +- **The laptop agent is lighter per request.** Proxied pages, port forwards + and calls to your boxes allocate a fraction of what they did, and the kits + and team suggestions ask all your boxes at once. +- **The Mac app is 20% smaller.** ## v0.3.13 diff --git a/internal/agent/agent.go b/internal/agent/agent.go index bb6d12bf..9460f5de 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -269,6 +269,8 @@ type Agent struct { hostsMu sync.Mutex hostsAt time.Time hosts []string + // boxRoutes keeps box-routes.json between changes (boxroutes.go). + boxRoutes statefile.Cache[map[string]BoxRoutes] mu sync.Mutex svc map[string]serviceCache @@ -334,7 +336,7 @@ func Run(ctx context.Context, cfg Config) error { id: id, boxes: trust.NewStore(filepath.Join(cfg.Dir, "boxes.json")), forwards: forwardStore{path: filepath.Join(cfg.Dir, "forwards.json")}, - routes: routeStore{path: filepath.Join(cfg.Dir, "routes.json")}, + routes: newRouteStore(filepath.Join(cfg.Dir, "routes.json")), clients: map[string]*boxState{}, seqs: newSeqStore(cfg.Dir), running: map[string]*runningForward{}, @@ -942,7 +944,7 @@ func (a *Agent) status() Status { a.mu.Lock() defer a.mu.Unlock() s := Status{Boxes: []BoxStatus{}, Forwards: []ForwardStatus{}, Routes: []Route{}, Proxy: a.proxySt, TeamSuggestions: suggestions} - if routes, err := a.routes.list(); err == nil && routes != nil { + if routes, err := a.routes.current(); err == nil && routes != nil { s.Routes = routes } s.Proxy.URLPort = s.Proxy.Port diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 560b057f..e0b0c0a6 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -32,7 +32,7 @@ import ( type testBox struct { services []box.Service bus *events.Bus - t *testing.T + t testing.TB dir string address string server *wire.Server @@ -41,6 +41,8 @@ type testBox struct { done chan error // extra mounts more box routes each time the box starts. extra func(*wire.Server) + // servicesCalls counts GET /v1/services. + servicesCalls atomic.Int64 } type countingListener struct { @@ -56,13 +58,13 @@ func (l countingListener) Accept() (net.Conn, error) { return c, err } -func newBox(t *testing.T) *testBox { +func newBox(t testing.TB) *testBox { t.Helper() return newBoxWith(t, nil) } // newBoxWith is newBox with more routes, such as sessions. -func newBoxWith(t *testing.T, extra func(*wire.Server)) *testBox { +func newBoxWith(t testing.TB, extra func(*wire.Server)) *testBox { t.Helper() b := &testBox{t: t, dir: t.TempDir(), extra: extra} b.start("127.0.0.1:0") @@ -86,6 +88,7 @@ func (b *testBox) start(addr string) { b.bus = &events.Bus{} } b.server.Handle("GET /v1/services", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b.servicesCalls.Add(1) json.NewEncoder(w).Encode(b.services) })) b.server.Handle("GET /v1/events", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -185,7 +188,7 @@ func (c *clock) jump(d time.Duration) { // shortSocket returns a socket path under /tmp: test temp dirs on macOS are // longer than the 104-byte limit for Unix socket paths. -func shortSocket(t *testing.T) string { +func shortSocket(t testing.TB) string { t.Helper() dir, err := os.MkdirTemp("/tmp", "cp") if err != nil { @@ -195,19 +198,19 @@ func shortSocket(t *testing.T) string { return filepath.Join(dir, "a.sock") } -func startAgent(t *testing.T, dir string) *runningAgent { +func startAgent(t testing.TB, dir string) *runningAgent { t.Helper() return startAgentWith(t, dir, &fakeNetworks{}) } -func startAgentWith(t *testing.T, dir string, nets Networks) *runningAgent { +func startAgentWith(t testing.TB, dir string, nets Networks) *runningAgent { t.Helper() return startAgentConfig(t, dir, nets, nil) } // startAgentConfig is startAgentWith with changes to the agent's Config, // such as its log or how long a health check waits. -func startAgentConfig(t *testing.T, dir string, nets Networks, change func(*Config)) *runningAgent { +func startAgentConfig(t testing.TB, dir string, nets Networks, change func(*Config)) *runningAgent { t.Helper() proxyLn, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -266,7 +269,7 @@ func (a *runningAgent) stop() { } } -func eventually(t *testing.T, what string, cond func() bool) { +func eventually(t testing.TB, what string, cond func() bool) { t.Helper() deadline := time.Now().Add(5 * time.Second) for !cond() { @@ -277,7 +280,7 @@ func eventually(t *testing.T, what string, cond func() bool) { } } -func stateOf(t *testing.T, a *runningAgent) string { +func stateOf(t testing.TB, a *runningAgent) string { t.Helper() s, err := a.client.Status(context.Background()) if err != nil || len(s.Boxes) != 1 { @@ -305,7 +308,7 @@ func echoServer(t *testing.T) int { return ln.Addr().(*net.TCPAddr).Port } -func freePort(t *testing.T) int { +func freePort(t testing.TB) int { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { diff --git a/internal/agent/bench_test.go b/internal/agent/bench_test.go new file mode 100644 index 00000000..96499ceb --- /dev/null +++ b/internal/agent/bench_test.go @@ -0,0 +1,195 @@ +package agent + +import ( + "context" + "encoding/json" + "io" + "net" + "net/http" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/cosscom/shipyard/internal/box" + "github.com/cosscom/shipyard/internal/identity" + "github.com/cosscom/shipyard/internal/pairing" + "github.com/cosscom/shipyard/internal/trust" + "github.com/cosscom/shipyard/internal/wire" +) + +// pairAs pairs the laptop in dir with b under name, so one laptop can hold +// several boxes. +func (b *testBox) pairAs(t testing.TB, dir, name string) { + t.Helper() + id, err := identity.LoadOrCreate(filepath.Join(dir, "identity.pem")) + if err != nil { + t.Fatal(err) + } + code, err := b.server.Pending.Issue(time.Minute, time.Now()) + if err != nil { + t.Fatal(err) + } + tok := pairing.Token{Address: b.address, Fingerprint: b.server.Identity.Fingerprint(), Code: code} + if _, err := wire.Pair(context.Background(), id, tok, "laptop"); err != nil { + t.Fatal(err) + } + peer := trust.Peer{Name: name, Address: b.address, Fingerprint: tok.Fingerprint, PairedAt: time.Now()} + if err := trust.NewStore(filepath.Join(dir, "boxes.json")).Add(peer); err != nil { + t.Fatal(err) + } +} + +func benchDevServer(b *testing.B) int { + b.Helper() + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "ok") + })} + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + b.Fatal(err) + } + go srv.Serve(ln) + b.Cleanup(func() { srv.Close() }) + return ln.Addr().(*net.TCPAddr).Port +} + +// BenchmarkProxyHosts relays small GETs through a running agent's proxy to +// a dev server on a box, for each form of address a page uses. A second +// box, without the worktree, is paired too, and a few routes are saved, +// as on a laptop in use. services/op is how often the boxes were asked +// for their services. +func BenchmarkProxyHosts(b *testing.B) { + port := benchDevServer(b) + one := newBox(b) + one.services = []box.Service{{Location: "shop", Worktree: "checkout", Port: port}, {Location: "shop", Worktree: "shop", Port: port, Main: true}} + two := newBox(b) + two.services = []box.Service{{Location: "other", Worktree: "other", Port: port + 1}} + dir := b.TempDir() + one.pairAs(b, dir, "devbox") + two.pairAs(b, dir, "spare") + a := startAgentConfig(b, dir, &fakeNetworks{}, func(c *Config) { c.HealthInterval = time.Hour }) + eventually(b, "boxes online", func() bool { + s, err := a.client.Status(context.Background()) + if err != nil || len(s.Boxes) != 2 { + return false + } + return s.Boxes[0].State == StateOnline && s.Boxes[1].State == StateOnline + }) + for _, r := range []Route{ + {Pattern: "*.cal.test.localhost", Box: "devbox", Port: port}, + {Pattern: "*.docs.test.localhost", Box: "devbox", Port: port}, + {Pattern: "admin.test.localhost", Box: "spare", Port: port}, + } { + if err := a.client.Call(context.Background(), "POST", "/v1/routes", r, nil); err != nil { + b.Fatal(err) + } + } + c := &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 64}} + for _, form := range []struct{ name, host string }{ + {"port.box", strconv.Itoa(port) + ".devbox.localhost"}, + {"worktree.location.box", "checkout.shop.devbox.localhost"}, + {"worktree.location", "checkout.shop.localhost"}, + } { + host := form.host + b.Run(form.name, func(b *testing.B) { + before := one.servicesCalls.Load() + two.servicesCalls.Load() + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + req, _ := http.NewRequest(http.MethodGet, "http://"+a.proxy+"/app.js", nil) + req.Host = host + resp, err := c.Do(req) + if err != nil { + b.Error(err) + return + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b.Errorf("%s: %d", host, resp.StatusCode) + return + } + } + }) + b.ReportMetric(float64(one.servicesCalls.Load()+two.servicesCalls.Load()-before)/float64(b.N), "services/op") + }) + } +} + +// BenchmarkRouteLookup resolves a request host against the saved routes, +// which the proxy does first for every request it relays. +func BenchmarkRouteLookup(b *testing.B) { + a := &Agent{routes: newRouteStore(filepath.Join(b.TempDir(), "routes.json"))} + all := []Route{ + {Pattern: "*.cal.test.localhost", Box: "devbox", Port: 3000}, + {Pattern: "*.docs.test.localhost", Box: "devbox", Port: 3001}, + {Pattern: "admin.test.localhost", Box: "spare", Port: 3002}, + } + if err := a.routes.update(func([]Route) ([]Route, error) { return all, nil }); err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, _, ok := a.route("checkout.shop.devbox.localhost"); ok { + b.Fatal("matched") + } + } +} + +// BenchmarkInstalledKits asks every box for its kits, as the app's Kits +// view does, with boxes that each take 50ms to answer. +func BenchmarkInstalledKits(b *testing.B) { + const n, delay = 4, 50 * time.Millisecond + dir := b.TempDir() + for i := range n { + tb := newBoxWith(b, func(s *wire.Server) { + s.Handle("GET /v1/kits", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(delay) + json.NewEncoder(w).Encode([]box.InstalledKitAt{{Location: "shop", Kit: box.InstalledKit{ID: "acme"}}}) + })) + }) + tb.pairAs(b, dir, "box"+strconv.Itoa(i)) + } + a := startAgentConfig(b, dir, &fakeNetworks{}, func(c *Config) { c.HealthInterval = time.Hour }) + eventually(b, "boxes online", func() bool { + s, err := a.client.Status(context.Background()) + if err != nil || len(s.Boxes) != n { + return false + } + for _, bs := range s.Boxes { + if bs.State != StateOnline { + return false + } + } + return true + }) + b.ResetTimer() + for range b.N { + var out []InstalledKitOn + if err := a.client.Call(context.Background(), "GET", "/v1/kits/installed", nil, &out); err != nil { + b.Fatal(err) + } + if len(out) != n { + b.Fatalf("got %d kits", len(out)) + } + } + b.ReportMetric(float64(b.Elapsed().Milliseconds())/float64(b.N), "ms/op") +} + +// BenchmarkStatus is GET /v1/status, which the app reads on every box +// event and on its backstop poll. +func BenchmarkStatus(b *testing.B) { + one := newBox(b) + dir := one.pairLaptop() + a := startAgentConfig(b, dir, &fakeNetworks{}, func(c *Config) { c.HealthInterval = time.Hour }) + eventually(b, "box online", func() bool { return stateOf(b, a) == StateOnline }) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, err := a.client.Status(context.Background()); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/agent/bench_ui_test.go b/internal/agent/bench_ui_test.go new file mode 100644 index 00000000..d002beff --- /dev/null +++ b/internal/agent/bench_ui_test.go @@ -0,0 +1,56 @@ +package agent + +import ( + "context" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cosscom/shipyard/internal/box" +) + +// BenchmarkUIBoxAPI is one app request relayed to a box through the +// laptop agent's app API (/v1/boxes/{box}/api/…), the way the app reads +// every box's sessions, worktrees and files. +func BenchmarkUIBoxAPI(b *testing.B) { + one := newBox(b) + one.services = []box.Service{{Location: "shop", Worktree: "checkout", Port: 3000}} + a := startAgentConfig(b, one.pairLaptop(), &fakeNetworks{}, func(c *Config) { c.HealthInterval = time.Hour }) + eventually(b, "box online", func() bool { return stateOf(b, a) == StateOnline }) + var tok string + eventually(b, "ui token", func() bool { + raw, err := os.ReadFile(filepath.Join(a.dir, UITokenFile)) + tok = strings.TrimSpace(string(raw)) + return err == nil && tok != "" + }) + c := &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 64}} + get := func() error { + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://"+a.ui+"/v1/boxes/devbox/api/services", nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := c.Do(req) + if err != nil { + return err + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b.Fatalf("status %d", resp.StatusCode) + } + return nil + } + eventually(b, "ui up", func() bool { return get() == nil }) + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if err := get(); err != nil { + b.Error(err) + return + } + } + }) +} diff --git a/internal/agent/boxroutes.go b/internal/agent/boxroutes.go index cc5d837d..3d8c8c97 100644 --- a/internal/agent/boxroutes.go +++ b/internal/agent/boxroutes.go @@ -142,9 +142,12 @@ func RecordSSHRoute(dir, box, fingerprint, host, identity, forward string) error }) } -// routeSettings is the settings file, read at most once per sync. +// routeSettings is the settings file, read again only once it changes: +// every request the app relays to a box syncs the boxes first. The map is +// shared: callers must not change it. func (a *Agent) routeSettings() map[string]BoxRoutes { - all, err := readBoxRoutes(routeStorePath(a.cfg.Dir)) + path := routeStorePath(a.cfg.Dir) + all, err := a.boxRoutes.Load(path, func() (map[string]BoxRoutes, error) { return readBoxRoutes(path) }) if err != nil { a.cfg.Log.Printf("box routes: %v", err) return map[string]BoxRoutes{} diff --git a/internal/agent/fanout.go b/internal/agent/fanout.go new file mode 100644 index 00000000..e8d22922 --- /dev/null +++ b/internal/agent/fanout.go @@ -0,0 +1,25 @@ +package agent + +import "sync" + +// fanOutLimit is how many boxes one question is put to at a time. +const fanOutLimit = 8 + +// fanOut runs ask for each of n boxes, at most fanOutLimit at a time, and +// waits for them all: asking every box in turn would take as long as all +// their answers together, and a box that is slow or away holds up the +// rest by its whole timeout. +func fanOut(n int, ask func(i int)) { + sem := make(chan struct{}, fanOutLimit) + var wg sync.WaitGroup + for i := range n { + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + ask(i) + }() + } + wg.Wait() +} diff --git a/internal/agent/fanout_test.go b/internal/agent/fanout_test.go new file mode 100644 index 00000000..0b587ce8 --- /dev/null +++ b/internal/agent/fanout_test.go @@ -0,0 +1,34 @@ +package agent + +import ( + "sync/atomic" + "testing" + "time" +) + +func TestFanOutAsksEveryBoxAFewAtATime(t *testing.T) { + const n = 3*fanOutLimit + 1 + var running, most atomic.Int32 + asked := make([]bool, n) + fanOut(n, func(i int) { + now := running.Add(1) + for { + m := most.Load() + if now <= m || most.CompareAndSwap(m, now) { + break + } + } + time.Sleep(20 * time.Millisecond) + asked[i] = true + running.Add(-1) + }) + for i, ok := range asked { + if !ok { + t.Fatalf("box %d was not asked", i) + } + } + // More than one at once (in turn would be one), never past the limit. + if m := most.Load(); m > fanOutLimit || m < 2 { + t.Errorf("%d asked at once", m) + } +} diff --git a/internal/agent/kits.go b/internal/agent/kits.go index c50105fb..cb36ac36 100644 --- a/internal/agent/kits.go +++ b/internal/agent/kits.go @@ -390,25 +390,31 @@ func (a *Agent) installedKits(ctx context.Context) []InstalledKitOn { for _, k := range a.kits() { kept[k.ID] = k.Hash } - out := []InstalledKitOn{} + var online []string for _, b := range a.status().Boxes { - if b.State != StateOnline { - continue + if b.State == StateOnline { + online = append(online, b.Name) } - c, ok := a.client(b.Name) + } + // Every box at once; the answers keep the boxes' order. + each := make([][]box.InstalledKitAt, len(online)) + fanOut(len(online), func(i int) { + c, ok := a.client(online[i]) if !ok { - continue + return } - var at []box.InstalledKitAt cctx, cancel := context.WithTimeout(ctx, 10*time.Second) - err := box.NewClient(c).Call(cctx, http.MethodGet, "/v1/kits", nil, &at) - cancel() - if err != nil { - continue + defer cancel() + var at []box.InstalledKitAt + if box.NewClient(c).Call(cctx, http.MethodGet, "/v1/kits", nil, &at) == nil { + each[i] = at } + }) + out := []InstalledKitOn{} + for n, at := range each { for _, i := range at { h, ok := kept[i.Kit.ID] - out = append(out, InstalledKitOn{Box: b.Name, InstalledKitAt: i, Outdated: ok && h != i.Kit.Hash}) + out = append(out, InstalledKitOn{Box: online[n], InstalledKitAt: i, Outdated: ok && h != i.Kit.Hash}) } } return out diff --git a/internal/agent/routes.go b/internal/agent/routes.go index 7ecfb9e9..93dc78c7 100644 --- a/internal/agent/routes.go +++ b/internal/agent/routes.go @@ -21,7 +21,25 @@ type Route struct { var errUnknownRoute = errors.New("no route with that pattern") -type routeStore struct{ path string } +type routeStore struct { + path string + // cache keeps the routes until the file changes: the proxy looks them + // up for every request it relays. Nil reads the file every time. + cache *statefile.Cache[[]Route] +} + +func newRouteStore(path string) routeStore { + return routeStore{path: path, cache: new(statefile.Cache[[]Route])} +} + +// current is list, read again only once the file has changed. Its result +// is shared: callers must not change it. +func (s routeStore) current() ([]Route, error) { + if s.cache == nil { + return s.list() + } + return s.cache.Load(s.path, s.list) +} func (s routeStore) list() ([]Route, error) { b, err := os.ReadFile(s.path) @@ -93,10 +111,10 @@ func (a *Agent) removeRoute(pattern string) error { }) } -// route resolves a request host. Routes are read from disk each time: they -// are few and small, and edits then apply without restarting the agent. +// route resolves a request host. Routes are read from disk whenever the +// file changes (a stat tells), so edits apply without restarting the agent. func (a *Agent) route(host string) (string, int, bool) { - all, err := a.routes.list() + all, err := a.routes.current() if err != nil { return "", 0, false } diff --git a/internal/agent/routes_test.go b/internal/agent/routes_test.go new file mode 100644 index 00000000..ceb7461e --- /dev/null +++ b/internal/agent/routes_test.go @@ -0,0 +1,39 @@ +package agent + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/cosscom/shipyard/internal/statefile" +) + +// The proxy keeps the routes between requests, yet a route another +// process saves (the CLI) applies to the very next request. +func TestRoutesEditedOnDiskApplyAtOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), "routes.json") + a := &Agent{routes: newRouteStore(path)} + write := func(routes ...Route) { + t.Helper() + b, _ := json.Marshal(routes) + if err := statefile.Write(path, b); err != nil { + t.Fatal(err) + } + } + if _, _, ok := a.route("x.cal.test.localhost"); ok { + t.Fatal("routed with no routes file") + } + write(Route{Pattern: "*.cal.test.localhost", Box: "devl", Port: 3000}) + if box, port, ok := a.route("x.cal.test.localhost"); !ok || box != "devl" || port != 3000 { + t.Fatalf("route = %s %d %v", box, port, ok) + } + // Same length, straight after: another box. + write(Route{Pattern: "*.cal.test.localhost", Box: "devm", Port: 3000}) + if box, _, ok := a.route("x.cal.test.localhost"); !ok || box != "devm" { + t.Fatalf("after an edit: %s %v", box, ok) + } + write() + if _, _, ok := a.route("x.cal.test.localhost"); ok { + t.Fatal("a removed route still routes") + } +} diff --git a/internal/agent/teamsuggest.go b/internal/agent/teamsuggest.go index 2083d33c..cd873a0f 100644 --- a/internal/agent/teamsuggest.go +++ b/internal/agent/teamsuggest.go @@ -597,21 +597,29 @@ func (a *Agent) suggestLocations(ctx context.Context) map[string][]suggestLoc { a.suggest.mu.Lock() prev := a.suggest.locs a.suggest.mu.Unlock() - out := map[string][]suggestLoc{} - for _, b := range boxes { - if b.online { - if c, ok := a.client(b.name); ok { - var locs []box.Location - cctx, cancel := context.WithTimeout(ctx, 8*time.Second) - err := box.NewClient(c).Call(cctx, http.MethodGet, "/v1/locations", nil, &locs) - cancel() - if err == nil { - out[b.name] = suggestLocs(b.name, locs) - continue - } - } + // Every box at once: one that is slow or away holds up no other. + fresh := make([][]suggestLoc, len(boxes)) + fanOut(len(boxes), func(i int) { + b := boxes[i] + if !b.online { + return } - if p, ok := prev[b.name]; ok { + c, ok := a.client(b.name) + if !ok { + return + } + cctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + var locs []box.Location + if box.NewClient(c).Call(cctx, http.MethodGet, "/v1/locations", nil, &locs) == nil { + fresh[i] = suggestLocs(b.name, locs) + } + }) + out := map[string][]suggestLoc{} + for i, b := range boxes { + if fresh[i] != nil { + out[b.name] = fresh[i] + } else if p, ok := prev[b.name]; ok { out[b.name] = p } } diff --git a/internal/agent/ui.go b/internal/agent/ui.go index f703091d..9988d746 100644 --- a/internal/agent/ui.go +++ b/internal/agent/ui.go @@ -23,6 +23,7 @@ import ( "github.com/coder/websocket" "github.com/cosscom/shipyard/internal/box" + "github.com/cosscom/shipyard/internal/copybuf" "github.com/cosscom/shipyard/internal/hooks" "github.com/cosscom/shipyard/internal/statefile" "github.com/cosscom/shipyard/internal/terminal" @@ -121,6 +122,11 @@ func (a *Agent) ui(token, hostport string, inner http.Handler) http.Handler { h.Set("Access-Control-Allow-Origin", o) h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, If-None-Match") h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + // Every call carries the token, so every one is preflighted: + // without this the webview keeps a preflight for 5s only, and + // each poll costs two requests and two round trips. 600s is + // the most WebKit keeps one for. + h.Set("Access-Control-Max-Age", "600") h.Set("Vary", "Origin") } if r.Method == http.MethodOptions { @@ -272,7 +278,8 @@ func (a *Agent) relayBox(w http.ResponseWriter, r *http.Request, c *wire.Client, } w.WriteHeader(resp.StatusCode) rc := http.NewResponseController(w) - buf := make([]byte, 32<<10) + buf := copybuf.Pool{}.Get() + defer copybuf.Pool{}.Put(buf) for { n, err := resp.Body.Read(buf) if n > 0 { diff --git a/internal/agent/ui_test.go b/internal/agent/ui_test.go index 4d60c293..7581c2ba 100644 --- a/internal/agent/ui_test.go +++ b/internal/agent/ui_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "io" "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -281,3 +282,23 @@ func TestTheAppHearsEventsAsServerSentEvents(t *testing.T) { } } } + +// The app's every call carries its token, so the webview asks before each +// one (a CORS preflight). It may keep that answer for ten minutes, so a +// poll is one request rather than two. +func TestTheAppsPreflightIsKeptForTenMinutes(t *testing.T) { + h := (&Agent{}).ui("token", "127.0.0.1:1378", http.NotFoundHandler()) + req := httptest.NewRequest("OPTIONS", "http://127.0.0.1:1378/v1/boxes/devl/api/sessions", nil) + req.Host = "127.0.0.1:1378" + req.Header.Set("Origin", "tauri://localhost") + req.Header.Set("Access-Control-Request-Method", "GET") + req.Header.Set("Access-Control-Request-Headers", "authorization") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") != "tauri://localhost" { + t.Fatalf("preflight: %d %v", rec.Code, rec.Header()) + } + if got := rec.Header().Get("Access-Control-Max-Age"); got != "600" { + t.Fatalf("Access-Control-Max-Age = %q, want 600", got) + } +} diff --git a/internal/agent/worktrees.go b/internal/agent/worktrees.go index fa65f69e..a60a1481 100644 --- a/internal/agent/worktrees.go +++ b/internal/agent/worktrees.go @@ -2,6 +2,7 @@ package agent import ( "context" + "slices" "time" "github.com/cosscom/shipyard/internal/box" @@ -88,15 +89,29 @@ func (a *Agent) worktree(labels []string) (string, int, bool) { } wt, loc := labels[0], labels[1] found, foundPort := "", 0 - for _, b := range a.status().Boxes { - if port, ok := lowest(b.Name, func(s box.Service) bool { return s.Location == loc && s.Worktree == wt }); ok { + // Only the boxes' names: this runs for every request the proxy + // relays, and the whole status reads files. + for _, name := range a.boxNames() { + if port, ok := lowest(name, func(s box.Service) bool { return s.Location == loc && s.Worktree == wt }); ok { if found != "" { return "", 0, false // ambiguous: name the box } - found, foundPort = b.Name, port + found, foundPort = name, port } } return found, foundPort, found != "" } return "", 0, false } + +// boxNames is every paired box's name, sorted. +func (a *Agent) boxNames() []string { + a.mu.Lock() + names := make([]string, 0, len(a.clients)) + for name := range a.clients { + names = append(names, name) + } + a.mu.Unlock() + slices.Sort(names) + return names +} diff --git a/internal/box/api.go b/internal/box/api.go index 41650fec..d15d3811 100644 --- a/internal/box/api.go +++ b/internal/box/api.go @@ -118,11 +118,12 @@ func (b *Box) own(path string) { // laptops and, through ServeLocal, by the box's own user. func (b *Box) Mount(s *wire.Server) { route := func(pattern string, h func(http.ResponseWriter, *http.Request) error) { - s.Handle(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Large JSON answers go gzipped to a client that asks (gzip.go). + s.Handle(pattern, gzipJSON(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := h(w, r); err != nil { writeErr(w, err) } - })) + }))) } route("GET /v1/ports", b.ports) route("GET /v1/locations", b.listLocations) diff --git a/internal/box/controls.go b/internal/box/controls.go index d4b6bf61..2be3a3ad 100644 --- a/internal/box/controls.go +++ b/internal/box/controls.go @@ -185,7 +185,7 @@ func (b *Box) sessionControls(w http.ResponseWriter, r *http.Request) error { agent := controlAgent(sess) out := Controls{Agent: agent} if !sess.Exited { - screen, err := b.Sessions.Screen(r.Context(), sess.Name, 0) + screen, err := b.Sessions.capture(r.Context(), sess.Name, 0) if err != nil { return err } diff --git a/internal/box/draft.go b/internal/box/draft.go index b6a0281a..56d5f300 100644 --- a/internal/box/draft.go +++ b/internal/box/draft.go @@ -47,16 +47,17 @@ type DraftStatus struct { } func (b *Box) draft(w http.ResponseWriter, r *http.Request) error { - sess, err := b.Sessions.Get(r.Context(), r.PathValue("name")) - if err != nil { + // The session and its styled screen in one tmux command: a working + // chat polls this every second or so. + sess, raw, err := b.Sessions.getAndCapture(r.Context(), r.PathValue("name"), "-J", "-e") + if err != nil && sess.Name == "" { return err } agent := firstNonEmpty(sess.Preset, firstNonEmpty(sess.Agent, agentOf(sess.Command))) out := Draft{Agent: agent} if agent == "claude" && sess.Service == "" && !sess.Exited { - raw, err := b.Sessions.tmux(r.Context(), "capture-pane", "-p", "-J", "-e", "-t", "="+sess.Name+":") if err != nil { - return tmuxError("capture-pane", raw, err) + return err } out = ParseClaudeDraft(string(raw)) out.Agent = agent diff --git a/internal/box/gzip.go b/internal/box/gzip.go new file mode 100644 index 00000000..e06d59d7 --- /dev/null +++ b/internal/box/gzip.go @@ -0,0 +1,170 @@ +package box + +import ( + "compress/gzip" + "io" + "net/http" + "strings" + "sync" +) + +// A box's JSON answers cross the link to the laptop, often a tailnet or an +// SSH tunnel: a long conversation's first read or a box with hundreds of +// worktrees is tens or hundreds of KB that gzip cuts by about ten times. +// Go's HTTP client asks for gzip on its own and unpacks it on its own, so +// the laptop agent, the CLI and the phone's browser all get the same JSON +// as before; anything that doesn't ask gets it plain. +// +// Only a whole 200 answer of application/json of at least gzipMin bytes is +// packed. Streams (events, NDJSON, terminals), files, images and errors +// pass through as they are, and an answer flushed before gzipMin goes out +// plain from there on, so a long poll or a stream is never held back. + +const gzipMin = 1024 + +var gzipPool = sync.Pool{New: func() any { + w, _ := gzip.NewWriterLevel(io.Discard, gzip.BestSpeed) + return w +}} + +// acceptsGzip says whether the request asks for gzip (and not with q=0). +func acceptsGzip(r *http.Request) bool { + for _, part := range strings.Split(r.Header.Get("Accept-Encoding"), ",") { + name, params, _ := strings.Cut(strings.TrimSpace(part), ";") + if strings.EqualFold(strings.TrimSpace(name), "gzip") { + q := strings.ReplaceAll(strings.TrimSpace(params), " ", "") + return q != "q=0" && q != "q=0.0" && q != "q=0.00" && q != "q=0.000" + } + } + return false +} + +// gzipJSON packs h's large JSON answers for clients that ask for gzip. +func gzipJSON(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !acceptsGzip(r) || r.Header.Get("Upgrade") != "" { + h.ServeHTTP(w, r) + return + } + g := &gzipWriter{w: w} + defer g.finish() + h.ServeHTTP(g, r) + }) +} + +type gzipMode int + +const ( + gzDeciding gzipMode = iota // holding the first bytes of a JSON answer + gzPlain // passing everything through + gzPacking // writing through gzip +) + +type gzipWriter struct { + w http.ResponseWriter + status int + header bool // WriteHeader has been called (by the handler or a Write) + mode gzipMode + buf []byte + gz *gzip.Writer +} + +func (g *gzipWriter) Header() http.Header { return g.w.Header() } + +// Unwrap lets http.ResponseController reach the connection (full duplex, +// deadlines, hijacking) behind the wrapper. +func (g *gzipWriter) Unwrap() http.ResponseWriter { return g.w } + +func (g *gzipWriter) WriteHeader(status int) { + if g.header { + return + } + g.header = true + g.status = status + h := g.w.Header() + ct := h.Get("Content-Type") + if status != http.StatusOK || h.Get("Content-Encoding") != "" || !strings.HasPrefix(ct, "application/json") { + g.mode = gzPlain + g.w.WriteHeader(status) + } +} + +func (g *gzipWriter) Write(p []byte) (int, error) { + if !g.header { + g.WriteHeader(http.StatusOK) + } + switch g.mode { + case gzPlain: + return g.w.Write(p) + case gzPacking: + return g.gz.Write(p) + } + g.buf = append(g.buf, p...) + if len(g.buf) < gzipMin { + return len(p), nil + } + h := g.w.Header() + h.Set("Content-Encoding", "gzip") + h.Add("Vary", "Accept-Encoding") + h.Del("Content-Length") + g.w.WriteHeader(g.status) + g.gz = gzipPool.Get().(*gzip.Writer) + g.gz.Reset(g.w) + g.mode = gzPacking + buf := g.buf + g.buf = nil + if _, err := g.gz.Write(buf); err != nil { + return 0, err + } + return len(p), nil +} + +// plain sends what is held as it is, and everything after it too. +func (g *gzipWriter) plain() error { + g.mode = gzPlain + g.w.WriteHeader(g.status) + if len(g.buf) == 0 { + return nil + } + buf := g.buf + g.buf = nil + _, err := g.w.Write(buf) + return err +} + +// Flush sends what is written so far: a handler that flushes is streaming, +// so one that does before gzipMin goes out plain. +func (g *gzipWriter) Flush() { + _ = g.FlushError() +} + +func (g *gzipWriter) FlushError() error { + if !g.header { + g.WriteHeader(http.StatusOK) + } + switch g.mode { + case gzDeciding: + if err := g.plain(); err != nil { + return err + } + case gzPacking: + if err := g.gz.Flush(); err != nil { + return err + } + } + return http.NewResponseController(g.w).Flush() +} + +func (g *gzipWriter) finish() { + switch g.mode { + case gzDeciding: + if g.header { + _ = g.plain() + } + case gzPacking: + _ = g.gz.Close() + g.gz.Reset(io.Discard) + gzipPool.Put(g.gz) + g.gz = nil + } +} diff --git a/internal/box/gzip_test.go b/internal/box/gzip_test.go new file mode 100644 index 00000000..52bbd61f --- /dev/null +++ b/internal/box/gzip_test.go @@ -0,0 +1,247 @@ +package box + +import ( + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cosscom/shipyard/internal/transcript" +) + +// big is a JSON answer well past gzipMin. +func big() any { + out := make([]map[string]string, 60) + for i := range out { + out[i] = map[string]string{"name": fmt.Sprintf("worktree-%d", i), "path": fmt.Sprintf("/home/me/code/shop/worktree-%d", i)} + } + return out +} + +func serveGzip(t *testing.T, h http.HandlerFunc, method string, header http.Header) *http.Response { + t.Helper() + req := httptest.NewRequest(method, "/v1/x", nil) + for k, v := range header { + req.Header[k] = v + } + rec := httptest.NewRecorder() + gzipJSON(h).ServeHTTP(rec, req) + return rec.Result() +} + +func readBody(t *testing.T, resp *http.Response) string { + t.Helper() + var r io.Reader = resp.Body + if resp.Header.Get("Content-Encoding") == "gzip" { + gz, err := gzip.NewReader(resp.Body) + if err != nil { + t.Fatal(err) + } + r = gz + } + b, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +var askGzip = http.Header{"Accept-Encoding": {"gzip"}} + +func TestLargeJSONIsGzippedForAClientThatAsks(t *testing.T) { + want, _ := json.Marshal(big()) + resp := serveGzip(t, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, big()) }, "GET", askGzip) + if resp.Header.Get("Content-Encoding") != "gzip" || !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") { + t.Fatalf("headers = %v", resp.Header) + } + if got := strings.TrimSpace(readBody(t, resp)); got != string(want) { + t.Fatalf("body changed:\n%s\n%s", got, want) + } +} + +func TestAnswersPassThroughPlainWhenGzipWouldNotHelpOrIsNotAsked(t *testing.T) { + cases := map[string]struct { + h http.HandlerFunc + method string + header http.Header + }{ + "no Accept-Encoding": {func(w http.ResponseWriter, r *http.Request) { writeJSON(w, big()) }, "GET", nil}, + "gzip refused": {func(w http.ResponseWriter, r *http.Request) { writeJSON(w, big()) }, "GET", http.Header{"Accept-Encoding": {"gzip;q=0, identity"}}}, + "small": {func(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"screen": "$ "}) }, "GET", askGzip}, + "not GET": {func(w http.ResponseWriter, r *http.Request) { writeJSON(w, big()) }, "POST", askGzip}, + "an error": {func(w http.ResponseWriter, r *http.Request) { + writeErr(w, httpError{http.StatusNotFound, strings.Repeat("gone ", 400)}) + }, "GET", askGzip}, + "plain text": {func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + io.WriteString(w, strings.Repeat("log line\n", 400)) + }, "GET", askGzip}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + resp := serveGzip(t, c.h, c.method, c.header) + if resp.Header.Get("Content-Encoding") != "" { + t.Fatalf("Content-Encoding = %q", resp.Header.Get("Content-Encoding")) + } + if readBody(t, resp) == "" { + t.Fatal("empty body") + } + }) + } +} + +func TestAStreamFlushedEarlyGoesOutPlainAndAtOnce(t *testing.T) { + srv := httptest.NewServer(gzipJSON(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"first":true}`+"\n") + http.NewResponseController(w).Flush() + time.Sleep(300 * time.Millisecond) + io.WriteString(w, strings.Repeat(" ", 4096)+`{"second":true}`+"\n") + }))) + defer srv.Close() + req, _ := http.NewRequest("GET", srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + start := time.Now() + resp, err := http.DefaultTransport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.Header.Get("Content-Encoding") != "" { + t.Fatal("a flushed answer was gzipped") + } + buf := make([]byte, 64) + n, _ := resp.Body.Read(buf) + if !strings.Contains(string(buf[:n]), "first") || time.Since(start) > 250*time.Millisecond { + t.Fatalf("the first part waited: %q after %v", buf[:n], time.Since(start)) + } +} + +// Over the real link: the laptop's client asks for gzip and unpacks it on +// its own, so callers read the same JSON as before. +func TestBoxAnswersReachTheLaptopGzippedAndReadTheSame(t *testing.T) { + c, _ := servedBox(t) + root := t.TempDir() + for i := range 15 { + dir := filepath.Join(root, fmt.Sprintf("project-with-a-long-name-%02d", i)) + os.MkdirAll(dir, 0o755) + if code := call(t, c, "POST", "/v1/locations", "", map[string]string{"name": fmt.Sprintf("project-%02d", i), "path": dir}, nil); code != 200 { + t.Fatalf("add location: %d", code) + } + } + resp, err := c.Do(t.Context(), "GET", "/v1/locations", nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if !resp.Uncompressed { + t.Fatal("the answer wasn't gzipped on the link") + } + var locs []Location + if err := json.NewDecoder(resp.Body).Decode(&locs); err != nil || len(locs) != 15 { + t.Fatalf("decoded %d locations: %v", len(locs), err) + } +} + +// ---- What it saves, on sampleAnswers shaped as a busy box's ---------------------- + +func fleetLocations(worktrees int) []Location { + var out []Location + for p := 0; len(out)*10 < worktrees; p++ { + name := fmt.Sprintf("project-%02d", p) + loc := Location{Name: name, Path: "/home/me/code/" + name, Repo: true, Scripts: Scripts{Setup: "pnpm install", From: "repo"}, Remote: "git@github.com:acme/" + name + ".git", Slug: "acme/" + name, DefaultBranch: "main", RepoTrust: "trusted", Check: "pnpm test", CheckFrom: "config"} + loc.Worktrees = append(loc.Worktrees, Worktree{Name: name, Path: loc.Path, Branch: "main", Head: "4f1c2a9be0d3", Main: true}) + for w := 1; w < 10; w++ { + wn := fmt.Sprintf("%s-fix-%d", name, w) + loc.Worktrees = append(loc.Worktrees, Worktree{Name: wn, Path: "/home/me/.berth/worktrees/" + name + "/" + wn, Branch: "me/" + wn, Head: fmt.Sprintf("%012x", p*100+w), Port: 3000 + p*10 + w, Title: "Make checkout retries safe to repeat"}) + } + out = append(out, loc) + } + return out +} + +func fleetSessions(n int) []Session { + out := make([]Session, n) + t0 := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) + for i := range out { + name := fmt.Sprintf("project-%02d-fix-%d", i/9, i%9+1) + out[i] = Session{Name: name + "-claude", Location: fmt.Sprintf("project-%02d/%s", i/9, name), Dir: "/home/me/.berth/worktrees/" + name, Command: "claude", Created: t0, Agent: "claude", AgentState: "finished", StateSince: t0.Add(time.Duration(i) * time.Minute), Preset: "claude", Turn: fmt.Sprintf("%s-claude#%d", name, i), StateSeq: int64(i), Fidelity: "hooks", Title: "Make checkout retries safe to repeat"} + } + return out +} + +func chatWindow(items int) transcript.Result { + words := strings.Fields("The ledger retries now carry an idempotency key so a slow answer from the payment provider can't make a second order and the test sends the same webhook twice and checks there is one order and one charge") + res := transcript.Result{Source: "claude", Next: items, Crew: []transcript.CrewMember{}, Gen: "1.0", File: "6b1f0c2e-1d2c-4a8e-9a51-0c3e2f1d9b7a"} + for i := range items { + it := transcript.Item{ID: fmt.Sprintf("i%d", i), Off: int64(i) * 2048} + switch i % 4 { + case 0: + it.Kind, it.Text, it.UUID = "user", fmt.Sprintf("Please look at step %d of the checkout flow.", i), fmt.Sprintf("u-%d", i) + case 1: + it.Kind, it.Verb, it.Done = "tools", "Read", true + it.Items = []transcript.ToolCall{{Verb: "Read", Target: fmt.Sprintf("src/checkout/step%d.ts", i)}, {Verb: "Read", Target: fmt.Sprintf("src/payments/retry%d.ts", i)}} + default: + var b strings.Builder + for j := range 60 { + b.WriteString(words[(i+j)%len(words)]) + b.WriteByte(' ') + } + it.Kind, it.Text = "text", b.String() + } + res.Items = append(res.Items, it) + } + return res +} + +var sampleAnswers = map[string]func() any{ + "locations_300_worktrees": func() any { return fleetLocations(300) }, + "sessions_100": func() any { return fleetSessions(100) }, + "transcript_window_100": func() any { return chatWindow(100) }, +} + +// TestGzipSavings logs what each answer is, plain and gzipped (go test -run +// GzipSavings -v ./internal/box). +func TestGzipSavings(t *testing.T) { + for name, v := range sampleAnswers { + h := func(w http.ResponseWriter, r *http.Request) { writeJSON(w, v()) } + plain := serveGzip(t, h, "GET", nil) + packed := serveGzip(t, h, "GET", askGzip) + p, _ := io.ReadAll(plain.Body) + g, _ := io.ReadAll(packed.Body) + if len(g) >= len(p)/3 { + t.Errorf("%s: gzip %d of %d bytes", name, len(g), len(p)) + } + t.Logf("%s: %d bytes plain, %d gzipped (%.1fx)", name, len(p), len(g), float64(len(p))/float64(len(g))) + } +} + +func BenchmarkJSONAnswer(b *testing.B) { + for _, name := range []string{"locations_300_worktrees", "sessions_100", "transcript_window_100"} { + v := sampleAnswers[name]() + h := gzipJSON(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeJSON(w, v) })) + for _, mode := range []string{"plain", "gzip"} { + b.Run(name+"/"+mode, func(b *testing.B) { + req := httptest.NewRequest("GET", "/v1/x", nil) + if mode == "gzip" { + req.Header.Set("Accept-Encoding", "gzip") + } + b.ReportAllocs() + var n int + for b.Loop() { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + n = rec.Body.Len() + } + b.ReportMetric(float64(n), "B/answer") + }) + } + } +} diff --git a/internal/box/history.go b/internal/box/history.go index fafda8ae..9a60fdab 100644 --- a/internal/box/history.go +++ b/internal/box/history.go @@ -51,11 +51,11 @@ func olderPage(w http.ResponseWriter, r *http.Request, agent, path, dir string) // claudeRecord is a session's Claude Code record, or why there is none. func (b *Box) claudeRecord(r *http.Request) (Session, string, error) { - sess, err := b.Sessions.Get(r.Context(), r.PathValue("name")) + sess, all, err := b.Sessions.getWithAll(r.Context(), r.PathValue("name")) if err != nil { return Session{}, "", err } - agent, path, _ := b.transcriptFile(r, sess) + agent, path, _ := b.transcriptFile(r, sess, all) switch { case agent != "claude": return sess, "", httpError{http.StatusBadRequest, "this works with Claude Code's conversations; this session runs " + firstNonEmpty(agent, "no agent")} diff --git a/internal/box/locations.go b/internal/box/locations.go index ffadf0c7..b9db9742 100644 --- a/internal/box/locations.go +++ b/internal/box/locations.go @@ -196,6 +196,25 @@ func (l *Locations) List(ctx context.Context) ([]Location, error) { return out, nil } +// worktreePaths is each location with its worktrees' names and paths only, +// as List names them: for finding which worktree a directory is in, it +// skips what else List reads (remote, default branch, configs). +func (l *Locations) worktreePaths(ctx context.Context) []Location { + saved, err := l.read() + if err != nil { + return nil + } + out := make([]Location, 0, len(saved)) + for _, s := range saved { + loc := Location{Name: s.Name, Path: s.Path} + if b, err := git(ctx, "-C", s.Path, "worktree", "list", "--porcelain"); err == nil { + loc.Worktrees = parseWorktrees(b, s.Path) + } + out = append(out, loc) + } + return out +} + // withPorts adds each worktree's first port. func (l *Locations) withPorts(loc Location) Location { for i := range loc.Worktrees { diff --git a/internal/box/notify_text.go b/internal/box/notify_text.go index b11f6a3f..f3e0c1bb 100644 --- a/internal/box/notify_text.go +++ b/internal/box/notify_text.go @@ -358,7 +358,7 @@ func (h *boxNotifyHost) describe(ctx context.Context, r *Report) { sumFiles(r) return } - agent, path, _ := b.transcriptFile(requestFor(ctx), sess) + agent, path, _ := b.transcriptFile(requestFor(ctx), sess, nil) if path == "" || (agent != "claude" && agent != "codex") { sumFiles(r) return diff --git a/internal/box/perf_bench_test.go b/internal/box/perf_bench_test.go new file mode 100644 index 00000000..bb4632ed --- /dev/null +++ b/internal/box/perf_bench_test.go @@ -0,0 +1,337 @@ +package box + +import ( + "context" + "fmt" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/cosscom/shipyard/internal/events" + "github.com/cosscom/shipyard/internal/transcript" +) + +// Benchmarks for what the box does over and over while chats are open and +// agents work: answering a chat's poll, reading screens, writing the +// ledger. Each reports how many tmux commands one op ran (execs/op), as a +// fork and exec of tmux costs far more than anything else here. + +// benchSessions is testSessions for a benchmark: tmux under a private +// TMUX_TMPDIR, never the developer's own server. +func benchSessions(b *testing.B) (*Sessions, *atomic.Int64) { + b.Helper() + if _, err := exec.LookPath("tmux"); err != nil { + b.Skip("tmux not installed") + } + tmp, err := os.MkdirTemp("/tmp", "cpb") + if err != nil { + b.Fatal(err) + } + b.Setenv("TMUX_TMPDIR", tmp) + b.Setenv("TMUX", "") + b.Setenv("SHELL", "/bin/sh") + s, err := NewSessions(b.TempDir()) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { + exec.Command("tmux", "-L", tmuxSocket, "kill-server").Run() + os.RemoveAll(tmp) + }) + var execs atomic.Int64 + s.trace = func([]string) { execs.Add(1) } + return s, &execs +} + +// cpuPerOp reports berthd's own CPU time per op (cpu-ns/op) when the +// benchmark ends: tmux's is not in it, so execs/op says what it costs. +func cpuPerOp(b *testing.B) func() { + start := cpuTime() + return func() { + if b.N > 0 { + b.ReportMetric(float64(cpuTime()-start)/float64(b.N), "cpu-ns/op") + } + } +} + +func cpuTime() int64 { + var ru syscall.Rusage + syscall.Getrusage(syscall.RUSAGE_SELF, &ru) + return ru.Utime.Nano() + ru.Stime.Nano() +} + +// BenchmarkTranscriptPoll is one chat's poll of GET …/transcript with +// nothing new, in a folder where three Claude sessions run. +func BenchmarkTranscriptPoll(b *testing.B) { + ctx := context.Background() + s, execs := benchSessions(b) + cfg := b.TempDir() + b.Setenv("CLAUDE_CONFIG_DIR", cfg) + dir := b.TempDir() + for i := range 3 { + if _, err := s.create(ctx, fmt.Sprintf("acme-%d", i), "acme", dir, "sleep 600", "claude", nil, nil); err != nil { + b.Fatal(err) + } + } + proj := transcript.ClaudeDir(dir) + os.MkdirAll(proj, 0o700) + ts := time.Now().UTC().Format(time.RFC3339Nano) + var rec strings.Builder + for i := range 200 { + fmt.Fprintf(&rec, `{"type":"assistant","timestamp":%q,"uuid":"u%d","message":{"role":"assistant","content":[{"type":"text","text":"step %d of the acme fix"}]}}`+"\n", ts, i, i) + } + for i := range 3 { + os.WriteFile(filepath.Join(proj, fmt.Sprintf("0000000%d-acme-4c1e-9d55-2f7e0c1a9b01.jsonl", i)), []byte(rec.String()), 0o600) + } + bx := &Box{Name: "devbox", Sessions: s, Events: &events.Bus{}} + poll := func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/v1/sessions/acme-1/transcript", nil) + r.SetPathValue("name", "acme-1") + if err := bx.transcript(w, r); err != nil || !strings.Contains(w.Body.String(), `"source":"claude"`) { + b.Fatalf("transcript: %v %s", err, w.Body.String()) + } + } + poll() + execs.Store(0) + b.ReportAllocs() + defer cpuPerOp(b)() + n := 0 + for b.Loop() { + poll() + n++ + } + b.ReportMetric(float64(execs.Load())/float64(n), "execs/op") +} + +// BenchmarkDraftPoll is a working chat's poll of GET …/draft, the reply +// being written read off the agent's screen. +func BenchmarkDraftPoll(b *testing.B) { + ctx := context.Background() + s, execs := benchSessions(b) + if _, err := s.create(ctx, "acme-0", "acme", b.TempDir(), "printf 'acme ready\\n'; sleep 600", "claude", nil, nil); err != nil { + b.Fatal(err) + } + bx := &Box{Name: "devbox", Sessions: s, Events: &events.Bus{}} + poll := func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/v1/sessions/acme-0/draft", nil) + r.SetPathValue("name", "acme-0") + if err := bx.draft(w, r); err != nil || !strings.Contains(w.Body.String(), `"agent":"claude"`) { + b.Fatalf("draft: %v %s", err, w.Body.String()) + } + } + poll() + execs.Store(0) + b.ReportAllocs() + defer cpuPerOp(b)() + n := 0 + for b.Loop() { + poll() + n++ + } + b.ReportMetric(float64(execs.Load())/float64(n), "execs/op") +} + +// BenchmarkPollScreens is the 2-second look at the screens of agents no +// hook reports for: four of them, each mid-turn. +func BenchmarkPollScreens(b *testing.B) { + ctx := context.Background() + s, execs := benchSessions(b) + dir := b.TempDir() + t := &Turns{} + t.init() + for i := range 4 { + name := fmt.Sprintf("acme-%d", i) + if _, err := s.create(ctx, name, "acme", dir, "sleep 600", "", nil, nil); err != nil { + b.Fatal(err) + } + t.sess[name] = &sessTrack{Name: name, Agent: "acme-cli", Dir: dir, State: "running", Turns: []*Turn{{ID: name + "#1", Session: name, N: 1, State: "running", Fidelity: "screen"}}} + } + bx := &Box{Name: "devbox", Sessions: s, Events: &events.Bus{}, Turns: t} + bx.pollScreens(ctx) + execs.Store(0) + b.ReportAllocs() + defer cpuPerOp(b)() + n := 0 + for b.Loop() { + bx.pollScreens(ctx) + n++ + } + b.ReportMetric(float64(execs.Load())/float64(n), "execs/op") +} + +// BenchmarkTurnsSave is the ledger written after a change, on a box with +// 20 agent sessions of 50 turns each. +func BenchmarkTurnsSave(b *testing.B) { + dir := b.TempDir() + t := &Turns{Path: filepath.Join(dir, "turns.json"), LegacyPath: filepath.Join(dir, "agent-states.json")} + t.init() + at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + for i := range 20 { + name := fmt.Sprintf("acme-%d", i) + s := &sessTrack{Name: name, Agent: "claude", Dir: "/home/acme/w" + fmt.Sprint(i), State: "running", Since: at, N: 50} + for n := 1; n <= 50; n++ { + s.Turns = append(s.Turns, &Turn{ID: fmt.Sprintf("%s#%d", name, n), Session: name, N: n, State: "finished", Sent: at, Started: at, Ended: at, Fidelity: "hooks", Origin: "laptop:acme"}) + } + t.sess[name] = s + } + b.ReportAllocs() + defer cpuPerOp(b)() + for b.Loop() { + // A tool started: the ledger moved on, no agent changed state. + t.mu.Lock() + t.applied++ + t.dirty = true + t.mu.Unlock() + t.save() + } +} + +// statsBox is a box with four locations of two worktrees each, and an +// agent's directory in the last one's second worktree. +func statsBox(tb testing.TB) (*Box, string) { + tb.Helper() + if _, err := exec.LookPath("git"); err != nil { + tb.Skip("git not installed") + } + root, err := filepath.EvalSymlinks(tb.TempDir()) + if err != nil { + tb.Fatal(err) + } + run := func(dir string, args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com") + if out, err := cmd.CombinedOutput(); err != nil { + tb.Fatalf("git %v: %s", args, out) + } + } + bx := &Box{Name: "devbox", Locations: NewLocations(filepath.Join(root, "locations.json")), Events: &events.Bus{}} + var agentDir string + for i := range 4 { + repo := filepath.Join(root, fmt.Sprintf("acme%d", i)) + os.MkdirAll(repo, 0o755) + run(repo, "init", "-q", "-b", "main") + run(repo, "commit", "-q", "--allow-empty", "-m", "init") + agentDir = filepath.Join(root, fmt.Sprintf("acme%d-feat", i)) + run(repo, "worktree", "add", "-q", "-b", "feat", agentDir) + if _, err := bx.Locations.Add(context.Background(), fmt.Sprintf("acme%d", i), repo); err != nil { + tb.Fatal(err) + } + } + return bx, filepath.Join(agentDir, "src") +} + +// An agent is placed in the same location and worktree as the full list +// of locations names. +func TestPlaceAgentsAsLocationsList(t *testing.T) { + bx, dir := statsBox(t) + agents := []Agent{{Tool: "claude", PID: 1, Path: dir}} + bx.placeAgents(context.Background(), agents) + locs, err := bx.Locations.List(context.Background()) + if err != nil { + t.Fatal(err) + } + loc, wt := worktreeFor(locs, dir) + if agents[0].Location != loc || agents[0].Worktree != wt || loc != "acme3" || wt == "" || agents[0].State != "running" { + t.Fatalf("placed in %q/%q (%s), want %q/%q", agents[0].Location, agents[0].Worktree, agents[0].State, loc, wt) + } +} + +// BenchmarkPlaceAgents is GET /v1/stats naming the worktree of an agent, +// on a box with four locations: the app reads stats on every agent event. +func BenchmarkPlaceAgents(b *testing.B) { + bx, dir := statsBox(b) + b.ReportAllocs() + defer cpuPerOp(b)() + for b.Loop() { + agents := []Agent{{Tool: "claude", PID: 1, Path: dir}} + bx.placeAgents(context.Background(), agents) + if agents[0].Location != "acme3" { + b.Fatalf("placed in %q", agents[0].Location) + } + } +} + +// writeTurnRecord writes a Claude Code record whose latest turn edited +// three files of about 20 KB each, as Claude Code keeps them. +func writeTurnRecord(tb testing.TB, p string) { + tb.Helper() + orig := strings.Repeat("export const acme = 1 // a line of the file as it was\n", 380) + var rec strings.Builder + rec.WriteString(`{"type":"user","timestamp":"2026-01-02T03:04:05Z","message":{"role":"user","content":"Fix the acme widgets"}}` + "\n") + for i := range 3 { + f := fmt.Sprintf("/home/acme/w/src/f%d.ts", i) + fmt.Fprintf(&rec, `{"type":"assistant","timestamp":"2026-01-02T03:04:06Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_%d","name":"Edit","input":{"file_path":%q,"old_string":"1","new_string":"2"}}]}}`+"\n", i, f) + fmt.Fprintf(&rec, `{"type":"user","timestamp":"2026-01-02T03:04:07Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_%d","content":"ok"}]},"toolUseResult":{"filePath":%q,"originalFile":%q}}`+"\n", i, f, orig) + } + if err := os.WriteFile(p, []byte(rec.String()), 0o600); err != nil { + tb.Fatal(err) + } +} + +// BenchmarkTurnCacheRetained is the memory the touched-files cache keeps +// once the box has shown 500 conversations' latest turns (MB-kept/op). +func BenchmarkTurnCacheRetained(b *testing.B) { + dir := b.TempDir() + paths := make([]string, 500) + for i := range paths { + paths[i] = filepath.Join(dir, fmt.Sprintf("%08d-acme.jsonl", i)) + writeTurnRecord(b, paths[i]) + } + var kept float64 + for b.Loop() { + resetTurnCache() + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + for _, p := range paths { + if _, ok := cachedTurn("claude", p, "/home/acme/w"); !ok { + b.Fatal("no turn") + } + } + runtime.GC() + runtime.ReadMemStats(&after) + kept += float64(int64(after.HeapAlloc)-int64(before.HeapAlloc)) / (1 << 20) + } + resetTurnCache() + b.ReportMetric(kept/float64(b.N), "MB-kept/op") +} + +// resetTurnCache forgets every cached turn. +func resetTurnCache() { + turnCache.Lock() + clear(turnCache.m) + turnCache.Unlock() +} + +// The cache of latest turns keeps the conversations read most recently, +// never all of them. +func TestTurnCacheIsBounded(t *testing.T) { + defer resetTurnCache() + dir := t.TempDir() + var last string + for i := range maxTurnCache + 20 { + last = filepath.Join(dir, fmt.Sprintf("%08d-acme.jsonl", i)) + writeTurnRecord(t, last) + turn, ok := cachedTurn("claude", last, "/home/acme/w") + if !ok || len(turn.Files) != 3 || turn.Files[0].Original == nil { + t.Fatalf("turn = %+v", turn) + } + } + turnCache.Lock() + n := len(turnCache.m) + _, kept := turnCache.m[last] + turnCache.Unlock() + if n != maxTurnCache || !kept { + t.Fatalf("cache holds %d (latest kept: %v), want %d", n, kept, maxTurnCache) + } +} diff --git a/internal/box/sessions.go b/internal/box/sessions.go index 44591b7d..719a418c 100644 --- a/internal/box/sessions.go +++ b/internal/box/sessions.go @@ -1,6 +1,7 @@ package box import ( + "bytes" "context" "encoding/base64" "errors" @@ -130,7 +131,13 @@ func (s *Sessions) EnvVar(ctx context.Context, sess Session, key string) string } out, err := s.tmux(ctx, "show-environment", "-t", "="+sess.Name, key) if err != nil { - // Unset, or the session is gone: nothing to remember. + // Unset is remembered too: it is the usual answer (no account + // folder of its own), and every chat's poll asks it again for + // each agent in the folder. A session that is gone, or a tmux + // that failed, leaves nothing to remember. + if strings.Contains(string(out), "unknown variable") { + s.envs.Store(cacheKey, "") + } return "" } v, ok := strings.CutPrefix(strings.TrimSpace(string(out)), key+"=") @@ -420,16 +427,23 @@ func (s *Sessions) SetTitle(ctx context.Context, name, title string) error { } func (s *Sessions) Get(ctx context.Context, name string) (Session, error) { + sess, _, err := s.getWithAll(ctx, name) + return sess, err +} + +// getWithAll is Get that also returns every session it listed, for a +// caller that needs the others too without asking tmux again. +func (s *Sessions) getWithAll(ctx context.Context, name string) (Session, []Session, error) { all, err := s.List(ctx) if err != nil { - return Session{}, err + return Session{}, nil, err } for _, sess := range all { if sess.Name == name { - return sess, nil + return sess, all, nil } } - return Session{}, ErrUnknownSession + return Session{}, nil, ErrUnknownSession } // Kill ends a session and, in the background, everything it started @@ -486,9 +500,48 @@ func (s *Sessions) Screen(ctx context.Context, name string, history int) (string if _, err := s.Get(ctx, name); err != nil { return "", err } + return s.capture(ctx, name, history) +} + +// capture is Screen for a session the caller has just found in a list: +// without asking tmux for the list again. +func (s *Sessions) capture(ctx context.Context, name string, history int) (string, error) { out, err := s.tmux(ctx, "capture-pane", "-p", "-J", "-t", "="+name+":", "-S", "-"+strconv.Itoa(max(history, 0))) if err != nil { return "", tmuxError("capture-pane", out, err) } return strings.TrimRight(string(out), "\n") + "\n", nil } + +// getAndCapture is Get and a capture-pane of the session's pane (with +// args, such as -e) in one tmux command rather than two: display-message +// prints the session's line as list-sessions would, then capture-pane its +// pane. A session that is gone, or a tmux that answers otherwise, is +// asked again the long way, so its error is Get's. +func (s *Sessions) getAndCapture(ctx context.Context, name string, args ...string) (Session, []byte, error) { + target := "=" + name + ":" + cmd := append([]string{"display-message", "-p", "-t", target, listFormat, ";", "capture-pane", "-p"}, args...) + out, err := s.tmux(ctx, append(cmd, "-t", target)...) + if err == nil { + if line, rest, ok := bytes.Cut(out, []byte("\n")); ok { + if all := parseSessions(line); len(all) == 1 && all[0].Name == name { + sess := all[0] + if sess.commandFile != "" { + if command, err := readCommand(sess.commandFile); err == nil { + sess.Command = command + } + } + return sess, rest, nil + } + } + } + sess, err := s.Get(ctx, name) + if err != nil { + return Session{}, nil, err + } + out, err = s.tmux(ctx, append(append([]string{"capture-pane", "-p"}, args...), "-t", target)...) + if err != nil { + return sess, out, tmuxError("capture-pane", out, err) + } + return sess, out, nil +} diff --git a/internal/box/sessions_test.go b/internal/box/sessions_test.go index 78641f9a..2b63245f 100644 --- a/internal/box/sessions_test.go +++ b/internal/box/sessions_test.go @@ -234,3 +234,95 @@ func TestScreenShowsTheSessionsOutput(t *testing.T) { time.Sleep(50 * time.Millisecond) } } + +// getAndCapture reads a session as Get does, and its pane, in one tmux +// command. +func TestGetAndCaptureIsGetAndAScreen(t *testing.T) { + s := testSessions(t) + ctx := context.Background() + // Set before any list starts the background sweep, which reads it. + var mu sync.Mutex + asked := map[string]int{} + s.trace = func(args []string) { mu.Lock(); asked[args[0]]++; mu.Unlock() } + if _, err := s.create(ctx, "both", "acme", t.TempDir(), "printf 'acme ready\\n'; sleep 30", "claude", nil, nil); err != nil { + t.Fatal(err) + } + want, err := s.Get(ctx, "both") + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for { + got, raw, err := s.getAndCapture(ctx, "both", "-J", "-e") + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("session = %+v, want %+v", got, want) + } + if strings.Contains(string(raw), "acme ready") { + break + } + if time.Now().After(deadline) { + t.Fatalf("screen = %q", raw) + } + time.Sleep(50 * time.Millisecond) + } + // One read is one display-message, the capture riding along in it. + mu.Lock() + clear(asked) + mu.Unlock() + s.getAndCapture(ctx, "both") + mu.Lock() + if asked["display-message"] != 1 || asked["capture-pane"] != 0 { + t.Fatalf("one read asked tmux %v", asked) + } + mu.Unlock() + if _, _, err := s.getAndCapture(ctx, "gone"); !errors.Is(err, ErrUnknownSession) { + t.Fatalf("gone: %v", err) + } +} + +// A session's environment never changes, so EnvVar asks tmux once per +// variable, an unset one included: every chat poll asks it for each agent +// in the folder. +func TestEnvVarAsksTmuxOnce(t *testing.T) { + s := testSessions(t) + ctx := context.Background() + // Set before any list starts the background sweep, which reads it. + var mu sync.Mutex + asked := 0 + s.trace = func(args []string) { + mu.Lock() + defer mu.Unlock() + if len(args) > 0 && args[0] == "show-environment" { + asked++ + } + } + sess, err := s.Create(ctx, "envs", "acme", t.TempDir(), "sleep 30", []string{"ACME_HOME=/srv/acme"}) + if err != nil { + t.Fatal(err) + } + for range 3 { + if v := s.EnvVar(ctx, sess, "ACME_HOME"); v != "/srv/acme" { + t.Fatalf("ACME_HOME = %q", v) + } + if v := s.EnvVar(ctx, sess, "ACME_UNSET"); v != "" { + t.Fatalf("ACME_UNSET = %q", v) + } + } + mu.Lock() + if asked != 2 { + t.Fatalf("asked tmux %d times, want 2", asked) + } + mu.Unlock() + // A session that is gone is not remembered as having it unset. + gone := Session{Name: "gone", Created: sess.Created} + s.EnvVar(ctx, gone, "ACME_HOME") + s.EnvVar(ctx, gone, "ACME_HOME") + mu.Lock() + defer mu.Unlock() + if asked != 4 { + t.Fatalf("asked tmux %d times for a gone session, want 2 more", asked-2) + } +} diff --git a/internal/box/startup.go b/internal/box/startup.go index 83f87b4b..5be18ca8 100644 --- a/internal/box/startup.go +++ b/internal/box/startup.go @@ -177,7 +177,7 @@ func (b *Box) atStartupQuestion(ctx context.Context, sess Session) bool { s, _ := v.(*startup).get() return s == startAsking } - screen, err := b.Sessions.Screen(ctx, sess.Name, 0) + screen, err := b.Sessions.capture(ctx, sess.Name, 0) if err != nil { return false } @@ -227,7 +227,7 @@ func (b *Box) watchStartup(from string, sess Session, st *startup) { if err != nil || cur.Exited { return } - screen, err := b.Sessions.Screen(ctx, sess.Name, 0) + screen, err := b.Sessions.capture(ctx, sess.Name, 0) if err != nil { return } diff --git a/internal/box/stats.go b/internal/box/stats.go index c514227a..932fcd23 100644 --- a/internal/box/stats.go +++ b/internal/box/stats.go @@ -3,6 +3,7 @@ package box import ( "bufio" "bytes" + "context" "net/http" "os" "path/filepath" @@ -58,9 +59,24 @@ var agentTools = map[string]string{"claude": "claude", "codex": "codex", "cursor func (b *Box) handleStats(w http.ResponseWriter, r *http.Request) error { s := collectStats("/proc") - locs, _ := b.Locations.List(r.Context()) - for i := range s.Agents { - ag := &s.Agents[i] + b.placeAgents(r.Context(), s.Agents) + home, _ := os.UserHomeDir() + s.Hooks = hooksInstalled(home) + writeJSON(w, s) + return nil +} + +// placeAgents names the location and worktree each agent works in, and +// its state. It needs only where the worktrees are: one git command a +// location, where Locations.List runs four and reads every config (the +// app reads stats on every agent event), and none when no agent runs. +func (b *Box) placeAgents(ctx context.Context, agents []Agent) { + if len(agents) == 0 { + return + } + locs := b.Locations.worktreePaths(ctx) + for i := range agents { + ag := &agents[i] ag.Location, ag.Worktree = worktreeFor(locs, ag.Path) ag.State = "running" if b.Turns != nil { @@ -69,10 +85,6 @@ func (b *Box) handleStats(w http.ResponseWriter, r *http.Request) error { } } } - home, _ := os.UserHomeDir() - s.Hooks = hooksInstalled(home) - writeJSON(w, s) - return nil } // worktreeFor names the location and worktree a directory is in, choosing diff --git a/internal/box/touched.go b/internal/box/touched.go index ac50409d..cbb95a64 100644 --- a/internal/box/touched.go +++ b/internal/box/touched.go @@ -107,7 +107,7 @@ func (b *Box) touchedIn(r *http.Request, wt Worktree) []FileTurn { if _, inside := relInside(root, dir); !inside && dir != root { continue } - agent, path, _ := b.transcriptFile(r, s) + agent, path, _ := b.transcriptFile(r, s, sessions) if path == "" || (agent != "claude" && agent != "codex") { continue } @@ -201,14 +201,23 @@ func touchedFile(ctx context.Context, root, dir string, f transcript.TurnFile) ( return ft, true } -// turnCache keeps each record's latest turn until the record grows. -var turnCache sync.Map // record path → turnEntry +// turnCache keeps each record's latest turn until the record grows, for +// the maxTurnCache records read most recently. A turn holds every file it +// edited as it found it, so a box that runs for weeks must not keep one +// for every conversation it ever showed. +var turnCache = struct { + sync.Mutex + m map[string]turnEntry // record path → its latest turn +}{m: map[string]turnEntry{}} + +const maxTurnCache = 64 type turnEntry struct { size int64 mtime time.Time dir string turn transcript.Turn + used time.Time } func cachedTurn(agent, path, dir string) (transcript.Turn, bool) { @@ -216,15 +225,30 @@ func cachedTurn(agent, path, dir string) (transcript.Turn, bool) { if err != nil { return transcript.Turn{}, false } - if v, ok := turnCache.Load(path); ok { - if e := v.(turnEntry); e.size == st.Size() && e.mtime.Equal(st.ModTime()) && e.dir == dir { - return e.turn, true - } + turnCache.Lock() + if e, ok := turnCache.m[path]; ok && e.size == st.Size() && e.mtime.Equal(st.ModTime()) && e.dir == dir { + e.used = time.Now() + turnCache.m[path] = e + turnCache.Unlock() + return e.turn, true } + turnCache.Unlock() turn, err := transcript.LastTurn(agent, path, dir) if err != nil { return transcript.Turn{}, false } - turnCache.Store(path, turnEntry{size: st.Size(), mtime: st.ModTime(), dir: dir, turn: turn}) + turnCache.Lock() + defer turnCache.Unlock() + turnCache.m[path] = turnEntry{size: st.Size(), mtime: st.ModTime(), dir: dir, turn: turn, used: time.Now()} + for len(turnCache.m) > maxTurnCache { + var oldest string + var at time.Time + for p, e := range turnCache.m { + if oldest == "" || e.used.Before(at) { + oldest, at = p, e.used + } + } + delete(turnCache.m, oldest) + } return turn, true } diff --git a/internal/box/transcriptapi.go b/internal/box/transcriptapi.go index 93d173b2..cc2d39ef 100644 --- a/internal/box/transcriptapi.go +++ b/internal/box/transcriptapi.go @@ -13,7 +13,9 @@ import ( // transcriptFile is which agent a session runs and the file holding its // conversation, for the conversation and for a tool call's details alike. -func (b *Box) transcriptFile(r *http.Request, sess Session) (agent, path, where string) { +// all is the box's sessions when the caller has just listed them (each +// list is a tmux command); nil lists them here. +func (b *Box) transcriptFile(r *http.Request, sess Session, all []Session) (agent, path, where string) { // Sessions.Get doesn't name the agent (the list does, in enrich): the // preset a session was started with, else its command's first word, // which is all a session from before presets has. @@ -41,22 +43,23 @@ func (b *Box) transcriptFile(r *http.Request, sess Session) (agent, path, where // accounts set CLAUDE_CONFIG_DIR per box or project). configDir := b.Sessions.EnvVar(r.Context(), sess, "CLAUDE_CONFIG_DIR") claims := []transcript.Claim{{Name: sess.Name, ID: id, Started: sess.Created, ConfigDir: configDir}} - if all, err := b.Sessions.List(r.Context()); err == nil { - for _, o := range all { - if o.Name == sess.Name || o.Dir != sess.Dir || o.Exited || o.Service != "" { - continue - } - if a := firstNonEmpty(o.Preset, firstNonEmpty(o.Agent, agentOf(o.Command))); a != "claude" { - continue - } - var oid string - if b.Turns != nil { - if st, ok := b.Turns.State(o.Name); ok { - oid = st.AgentSessionID - } + if all == nil { + all, _ = b.Sessions.List(r.Context()) + } + for _, o := range all { + if o.Name == sess.Name || o.Dir != sess.Dir || o.Exited || o.Service != "" { + continue + } + if a := firstNonEmpty(o.Preset, firstNonEmpty(o.Agent, agentOf(o.Command))); a != "claude" { + continue + } + var oid string + if b.Turns != nil { + if st, ok := b.Turns.State(o.Name); ok { + oid = st.AgentSessionID } - claims = append(claims, transcript.Claim{Name: o.Name, ID: oid, Started: o.Created, ConfigDir: b.Sessions.EnvVar(r.Context(), o, "CLAUDE_CONFIG_DIR")}) } + claims = append(claims, transcript.Claim{Name: o.Name, ID: oid, Started: o.Created, ConfigDir: b.Sessions.EnvVar(r.Context(), o, "CLAUDE_CONFIG_DIR")}) } path = transcript.AssignClaude(sess.Dir, claims)[sess.Name] where = transcript.ClaudeDirIn(configDir, sess.Dir) @@ -80,12 +83,14 @@ var ( func (b *Box) transcript(w http.ResponseWriter, r *http.Request) error { transcriptsOnce.Do(func() { transcripts = transcript.NewReader() }) - sess, err := b.Sessions.Get(r.Context(), r.PathValue("name")) + // One list of the sessions finds this one and the others in its + // folder: a chat polls this every few seconds. + sess, all, err := b.Sessions.getWithAll(r.Context(), r.PathValue("name")) if err != nil { return err } since, _ := strconv.Atoi(r.URL.Query().Get("since")) - agent, path, where := b.transcriptFile(r, sess) + agent, path, where := b.transcriptFile(r, sess, all) none := func(reason string) error { writeJSON(w, transcript.Result{Source: "none", Items: []transcript.Item{}, Crew: []transcript.CrewMember{}, Reason: reason}) return nil @@ -115,11 +120,11 @@ func (b *Box) transcript(w http.ResponseWriter, r *http.Request) error { // call opened up (the full command and its output, an edit's exact change), // read when someone expands it and never kept. func (b *Box) toolDetail(w http.ResponseWriter, r *http.Request) error { - sess, err := b.Sessions.Get(r.Context(), r.PathValue("name")) + sess, all, err := b.Sessions.getWithAll(r.Context(), r.PathValue("name")) if err != nil { return err } - agent, path, _ := b.transcriptFile(r, sess) + agent, path, _ := b.transcriptFile(r, sess, all) if path == "" { return httpError{http.StatusNotFound, "this session's conversation can't be read"} } diff --git a/internal/box/turns.go b/internal/box/turns.go index b5da72dd..6e3af0ee 100644 --- a/internal/box/turns.go +++ b/internal/box/turns.go @@ -2,6 +2,7 @@ package box import ( "bufio" + "bytes" "context" "encoding/json" "fmt" @@ -135,6 +136,11 @@ type Turns struct { saveSoon chan struct{} loaded bool stop func() + + // writeMu orders the ledger's writes; legacyOut is what LegacyPath + // was last written with. + writeMu sync.Mutex + legacyOut []byte } type sessTrack struct { @@ -1358,12 +1364,19 @@ func (t *Turns) save() { archive := t.archive t.archive = nil t.mu.Unlock() + t.writeMu.Lock() + defer t.writeMu.Unlock() if err == nil && t.Path != "" { statefile.Write(t.Path, b) } if t.LegacyPath != "" { - if lb, err := json.Marshal(legacy); err == nil { - statefile.Write(t.LegacyPath, lb) + // Its states change far less often than the ledger: each write + // is a full fsync, so one that would write the same bytes is + // left out. + if lb, err := json.Marshal(legacy); err == nil && !bytes.Equal(lb, t.legacyOut) { + if statefile.Write(t.LegacyPath, lb) == nil { + t.legacyOut = lb + } } } if inbox != nil && t.InboxPath != "" { @@ -1562,7 +1575,8 @@ func (b *Box) pollScreens(ctx context.Context) { // not a turn that ended. continue } - screen, err := b.Sessions.Screen(ctx, name, 0) + // Found in the list just read: capture it without listing again. + screen, err := b.Sessions.capture(ctx, name, 0) if err != nil { continue } diff --git a/internal/copybuf/copybuf.go b/internal/copybuf/copybuf.go new file mode 100644 index 00000000..a4aec3a9 --- /dev/null +++ b/internal/copybuf/copybuf.go @@ -0,0 +1,47 @@ +// Package copybuf copies between connections with pooled buffers. io.Copy +// between a TCP connection and anything but another socket (an HTTP/2 +// stream, a hijacked HTTP connection) makes a fresh 32 KB buffer each time, +// and so does httputil.ReverseProxy for every request it relays: the +// *.localhost proxy, port forwards and box streams do that for every +// connection and request, so the buffers are kept here and reused. +package copybuf + +import ( + "io" + "net" + "sync" +) + +// Size is the buffer size, io.Copy's own. +const Size = 32 << 10 + +var pool = sync.Pool{New: func() any { return new([Size]byte) }} + +// Copy is io.Copy with a pooled buffer. Between two TCP connections it is +// io.Copy itself, which on Linux moves the data in the kernel instead. +func Copy(dst io.Writer, src io.Reader) (int64, error) { + if _, ok := dst.(*net.TCPConn); ok { + if _, ok := src.(*net.TCPConn); ok { + return io.Copy(dst, src) + } + } + buf := pool.Get().(*[Size]byte) + defer pool.Put(buf) + // Hiding ReadFrom and WriteTo makes CopyBuffer use buf: the + // connections' own fallbacks would make a buffer of their own. + return io.CopyBuffer(writerOnly{dst}, readerOnly{src}, buf[:]) +} + +type writerOnly struct{ io.Writer } +type readerOnly struct{ io.Reader } + +// Pool is a httputil.BufferPool over the same buffers. +type Pool struct{} + +func (Pool) Get() []byte { return pool.Get().(*[Size]byte)[:] } + +func (Pool) Put(b []byte) { + if cap(b) >= Size { + pool.Put((*[Size]byte)(b[:Size])) + } +} diff --git a/internal/copybuf/copybuf_test.go b/internal/copybuf/copybuf_test.go new file mode 100644 index 00000000..e83180c9 --- /dev/null +++ b/internal/copybuf/copybuf_test.go @@ -0,0 +1,75 @@ +package copybuf + +import ( + "bytes" + "io" + "net" + "runtime" + "strings" + "testing" +) + +func TestCopyCopiesEverything(t *testing.T) { + src := strings.Repeat("shipyard ", 20000) // several buffers' worth + var dst bytes.Buffer + n, err := Copy(&dst, strings.NewReader(src)) + if err != nil || n != int64(len(src)) || dst.String() != src { + t.Fatalf("copied %d, %v", n, err) + } +} + +func TestCopyBetweenConnectionsReusesItsBuffer(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { io.Copy(c, bytes.NewReader(make([]byte, 4096))); c.Close() }() + } + }() + copyOne := func() { + c, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if n, err := Copy(io.Discard, c); err != nil || n != 4096 { + t.Fatalf("copied %d, %v", n, err) + } + } + copyOne() + if raceEnabled { + return + } + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + const runs = 20 + for range runs { + copyOne() + } + runtime.ReadMemStats(&after) + // io.Copy from a TCP connection makes a 32 KB buffer every time; the + // pooled one is reused (the dial itself allocates a little). + if per := (after.TotalAlloc - before.TotalAlloc) / runs; per > 16<<10 { + t.Errorf("%d bytes allocated per copy", per) + } +} + +func TestPoolHandsBackFullBuffers(t *testing.T) { + var p Pool + b := p.Get() + if len(b) != Size { + t.Fatalf("got %d bytes", len(b)) + } + p.Put(b) + p.Put(make([]byte, 10)) // too small: dropped, not kept + if b := p.Get(); len(b) != Size { + t.Fatalf("got %d bytes", len(b)) + } +} diff --git a/internal/copybuf/race_off_test.go b/internal/copybuf/race_off_test.go new file mode 100644 index 00000000..02a452e6 --- /dev/null +++ b/internal/copybuf/race_off_test.go @@ -0,0 +1,5 @@ +//go:build !race + +package copybuf + +const raceEnabled = false diff --git a/internal/copybuf/race_on_test.go b/internal/copybuf/race_on_test.go new file mode 100644 index 00000000..c8189145 --- /dev/null +++ b/internal/copybuf/race_on_test.go @@ -0,0 +1,7 @@ +//go:build race + +package copybuf + +// The race detector drops pooled items at random, so pooling cannot be +// measured under it. +const raceEnabled = true diff --git a/internal/forward/bench_test.go b/internal/forward/bench_test.go new file mode 100644 index 00000000..9b1531f0 --- /dev/null +++ b/internal/forward/bench_test.go @@ -0,0 +1,95 @@ +package forward + +import ( + "context" + "io" + "net" + "os" + "path/filepath" + "testing" +) + +// pairs hands out both ends of Unix socket connections: like loopback TCP +// connections, with half-close, but using no ephemeral ports, which a +// benchmark opening thousands would run out of. +type pairs struct{ ln net.Listener } + +func newPairs(b *testing.B) pairs { + b.Helper() + dir, err := os.MkdirTemp("", "fwd") + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { os.RemoveAll(dir) }) + ln, err := net.Listen("unix", filepath.Join(dir, "s")) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { ln.Close() }) + return pairs{ln} +} + +func (p pairs) next(b *testing.B) (net.Conn, net.Conn) { + c, err := net.Dial("unix", p.ln.Addr().String()) + if err != nil { + b.Fatal(err) + } + s, err := p.ln.Accept() + if err != nil { + b.Fatal(err) + } + return c, s +} + +// BenchmarkBridgeConn bridges one short connection per op (a request and +// its answer), as a forwarded port's clients make them. +func BenchmarkBridgeConn(b *testing.B) { + p := newPairs(b) + msg := make([]byte, 4<<10) + b.SetBytes(int64(len(msg))) + b.ReportAllocs() + for range b.N { + client, local := p.next(b) + upstream, server := p.next(b) + done := make(chan struct{}) + go func() { Bridge(context.Background(), local, upstream); close(done) }() + go func() { + // The far end answers what it reads, with a buffer of its own. + buf := make([]byte, len(msg)) + for { + n, err := server.Read(buf) + server.Write(buf[:n]) + if err != nil { + break + } + } + server.Close() + }() + client.Write(msg) + client.(*net.UnixConn).CloseWrite() + io.Copy(io.Discard, client) + client.Close() + <-done + } +} + +// BenchmarkBridgeStream pushes data one way through a long bridge. +func BenchmarkBridgeStream(b *testing.B) { + p := newPairs(b) + client, local := p.next(b) + upstream, server := p.next(b) + go Bridge(context.Background(), local, upstream) + go io.Copy(io.Discard, server) + chunk := make([]byte, 64<<10) + b.SetBytes(int64(len(chunk))) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, err := client.Write(chunk); err != nil { + b.Fatal(err) + } + } + b.StopTimer() + client.Close() + server.Close() +} diff --git a/internal/forward/forward.go b/internal/forward/forward.go index 177a3600..d94d5bcf 100644 --- a/internal/forward/forward.go +++ b/internal/forward/forward.go @@ -5,11 +5,12 @@ import ( "context" "errors" "fmt" - "io" "net" "strconv" "sync" "syscall" + + "github.com/cosscom/shipyard/internal/copybuf" ) // DialFunc opens a stream to a port on the box. It is looked up per @@ -79,7 +80,7 @@ func Bridge(ctx context.Context, a, b net.Conn) { wg.Add(2) pipe := func(dst, src net.Conn) { defer wg.Done() - _, err := io.Copy(dst, src) + _, err := copybuf.Copy(dst, src) if cw, ok := dst.(closeWriter); ok && err == nil { cw.CloseWrite() return diff --git a/internal/proxy/bench_test.go b/internal/proxy/bench_test.go new file mode 100644 index 00000000..1eaeca9d --- /dev/null +++ b/internal/proxy/bench_test.go @@ -0,0 +1,111 @@ +package proxy + +import ( + "bytes" + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "strconv" + "testing" +) + +// benchUpstream is a dev server on the "box" that answers every request +// with size bytes. +func benchUpstream(b *testing.B, size int) int { + b.Helper() + body := bytes.Repeat([]byte("x"), size) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(io.Discard, r.Body) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + w.Write(body) + })) + b.Cleanup(srv.Close) + return srv.Listener.Addr().(*net.TCPAddr).Port +} + +// benchProxy is a proxy whose one box, devl, is this machine's loopback. +func benchProxy(b *testing.B) string { + b.Helper() + var d net.Dialer + p := &Proxy{Dialer: func(box string) (DialFunc, bool) { + if box != "devl" { + return nil, false + } + return func(ctx context.Context, port int) (net.Conn, error) { + return d.DialContext(ctx, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + }, true + }} + srv := httptest.NewServer(p) + b.Cleanup(srv.Close) + return srv.Listener.Addr().String() +} + +func benchClient() *http.Client { + return &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 64, DisableCompression: true}} +} + +// BenchmarkProxyGet relays GETs of a few sizes through the proxy to a dev +// server, as a page's assets are. +func BenchmarkProxyGet(b *testing.B) { + for _, size := range []int{1 << 10, 64 << 10, 1 << 20} { + b.Run(strconv.Itoa(size>>10)+"KB", func(b *testing.B) { + port := benchUpstream(b, size) + addr := benchProxy(b) + c := benchClient() + host := strconv.Itoa(port) + ".devl.localhost" + b.SetBytes(int64(size)) + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + req, _ := http.NewRequest(http.MethodGet, "http://"+addr+"/asset.js", nil) + req.Host = host + resp, err := c.Do(req) + if err != nil { + b.Error(err) + return + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + }) + }) + } +} + +// BenchmarkRequestLogAdd logs requests for one busy page host, past the +// per-host limit, as a dev server's live page does all day. +func BenchmarkRequestLogAdd(b *testing.B) { + var l requestLog + req := Request{Method: "GET", Host: "checkout.shop.devl.localhost", Path: "/assets/app.js", Status: 200, Type: "script", MIME: "text/javascript"} + for range logLimit { + l.add(req, req.Host, "") + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + l.add(req, req.Host, "shop.devl.localhost") + } +} + +// BenchmarkRequests reads a full host's log after the app's last seq, as +// the Network drawer polls it. +func BenchmarkRequests(b *testing.B) { + p := &Proxy{} + req := Request{Method: "GET", Host: "checkout.shop.devl.localhost", Path: "/assets/app.js", Status: 200} + for range 3 * logLimit { + p.requests.add(req, req.Host) + } + _, last := p.Requests(req.Host, 0) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + list, _ := p.Requests(req.Host, last-10) + if len(list) != 10 { + b.Fatalf("got %d", len(list)) + } + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index c32df199..27dde12f 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -11,6 +11,8 @@ import ( "strings" "sync" "time" + + "github.com/cosscom/shipyard/internal/copybuf" ) // DialFunc opens a stream to a port on a box. @@ -107,7 +109,8 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request) { // (devtools.go). devtools := preview == "" && devtoolsPage(r) rp := &httputil.ReverseProxy{ - Transport: p.transport(target.Box), + Transport: p.transport(target.Box), + BufferPool: copybuf.Pool{}, Rewrite: func(pr *httputil.ProxyRequest) { pr.SetXForwarded() pr.Out.URL.Scheme = "http" @@ -165,7 +168,8 @@ func (p *Proxy) passThrough(w http.ResponseWriter, r *http.Request, box string, return } rp := &httputil.ReverseProxy{ - Transport: p.transport(box), + Transport: p.transport(box), + BufferPool: copybuf.Pool{}, Rewrite: func(pr *httputil.ProxyRequest) { pr.SetXForwarded() pr.Out.URL.Scheme = "http" diff --git a/internal/proxy/requestlog.go b/internal/proxy/requestlog.go index c8be231b..1b44af3d 100644 --- a/internal/proxy/requestlog.go +++ b/internal/proxy/requestlog.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "net/url" + "slices" "strings" "sync" "time" @@ -60,10 +61,21 @@ type requestLog struct { } type hostLog struct { + // list holds the host's requests, oldest first: the last logLimit of + // them are its log (recent). It grows to twice that before the old half + // is dropped, in place, so logging a request costs no copy of the log. list []Request used time.Time } +// recent is the host's log: its last logLimit requests, oldest first. +func (hl *hostLog) recent() []Request { + if n := len(hl.list); n > logLimit { + return hl.list[n-logLimit:] + } + return hl.list +} + func (l *requestLog) add(req Request, hosts ...string) { l.mu.Lock() defer l.mu.Unlock() @@ -73,12 +85,10 @@ func (l *requestLog) add(req Request, hosts ...string) { l.hosts = map[string]*hostLog{} } now := time.Now() - seen := map[string]bool{} - for _, h := range hosts { - if h == "" || seen[h] { + for i, h := range hosts { + if h == "" || slices.Contains(hosts[:i], h) { continue } - seen[h] = true hl := l.hosts[h] if hl == nil { if len(l.hosts) >= logHosts { @@ -88,10 +98,16 @@ func (l *requestLog) add(req Request, hosts ...string) { l.hosts[h] = hl } hl.used = now - hl.list = append(hl.list, req) - if len(hl.list) > logLimit { - hl.list = append([]Request(nil), hl.list[len(hl.list)-logLimit:]...) + switch n := len(hl.list); { + case n == 2*logLimit: + copy(hl.list, hl.list[logLimit:]) + clear(hl.list[logLimit:]) + hl.list = hl.list[:logLimit] + case n == cap(hl.list): + // Grown by hand, to never more than the 2*logLimit it needs. + hl.list = append(make([]Request, 0, min(max(2*n, 16), 2*logLimit)), hl.list...) } + hl.list = append(hl.list, req) } } @@ -115,7 +131,7 @@ func (p *Proxy) Requests(host string, after int64) ([]Request, int64) { defer l.mu.Unlock() out := []Request{} if hl := l.hosts[hostOnly(host)]; hl != nil { - for _, r := range hl.list { + for _, r := range hl.recent() { if r.Seq > after { out = append(out, r) } diff --git a/internal/proxy/requestlog_test.go b/internal/proxy/requestlog_test.go index 99a76963..d8ed2aa5 100644 --- a/internal/proxy/requestlog_test.go +++ b/internal/proxy/requestlog_test.go @@ -137,15 +137,38 @@ func TestRequestLogMarksACanceledRequest(t *testing.T) { } } +func TestRequestLogKeepsTheLatestAcrossManyTrims(t *testing.T) { + p := &Proxy{} + const total = 5*logLimit + 7 + for i := range total { + p.requests.add(Request{Path: "/" + strconv.Itoa(i)}, "a.localhost") + if hl := p.requests.hosts["a.localhost"]; cap(hl.list) > 2*logLimit { + t.Fatalf("log grew to %d", cap(hl.list)) + } + } + list, last := p.Requests("a.localhost", 0) + if len(list) != logLimit || last != total { + t.Fatalf("got %d requests, last %d", len(list), last) + } + for i, r := range list { + if want := "/" + strconv.Itoa(total-logLimit+i); r.Path != want { + t.Fatalf("request %d is %q, want %q", i, r.Path, want) + } + } + if list, _ := p.Requests("a.localhost", total-3); len(list) != 3 || list[2].Path != "/"+strconv.Itoa(total-1) { + t.Fatalf("after: %+v", list) + } +} + func TestRequestLogIsBounded(t *testing.T) { var l requestLog for i := range logLimit + 50 { l.add(Request{Path: "/" + strconv.Itoa(i)}, "a.localhost") } - if got := len(l.hosts["a.localhost"].list); got != logLimit { + if got := len(l.hosts["a.localhost"].recent()); got != logLimit { t.Fatalf("kept %d", got) } - if first := l.hosts["a.localhost"].list[0].Path; first != "/50" { + if first := l.hosts["a.localhost"].recent()[0].Path; first != "/50" { t.Errorf("oldest kept %q", first) } for i := range logHosts + 5 { diff --git a/internal/statefile/cache.go b/internal/statefile/cache.go new file mode 100644 index 00000000..b5181bfc --- /dev/null +++ b/internal/statefile/cache.go @@ -0,0 +1,70 @@ +package statefile + +import ( + "errors" + "io/fs" + "os" + "sync" +) + +// Cache keeps what was read from one small state file and reads it again +// only once the file has changed. Some files are read on every request +// (the proxy's routes on each one it relays), so edits made by another +// process (the CLI) still apply at once, without a read of the file each +// time: a stat says whether it changed. +// +// Files here are replaced whole (Write renames a new file over the old), +// so a change shows as a new inode, size, modification or change time. +type Cache[T any] struct { + mu sync.Mutex + key fileKey + ok bool + val T +} + +// fileKey is what tells one version of a file from another. missing is a +// file that isn't there. +type fileKey struct { + missing bool + dev, ino uint64 + size int64 + mtime, ctim int64 // nanoseconds +} + +func keyOf(path string) (fileKey, error) { + fi, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return fileKey{missing: true}, nil + } + if err != nil { + return fileKey{}, err + } + k := fileKey{size: fi.Size(), mtime: fi.ModTime().UnixNano()} + statKey(fi, &k) + return k, nil +} + +// Load returns what read made of path, from the last time it ran if the +// file is as it was then. read's result is shared by every caller until +// the file changes: callers must not change it. Errors are not kept, so a +// file that could not be read is tried again next time. +func (c *Cache[T]) Load(path string, read func() (T, error)) (T, error) { + // The stat comes before the read: a file replaced in between is read + // again next time, rather than its old version kept under its new key. + k, err := keyOf(path) + if err != nil { + return read() + } + c.mu.Lock() + defer c.mu.Unlock() + if c.ok && c.key == k { + return c.val, nil + } + v, err := read() + if err != nil { + c.ok = false + return v, err + } + c.key, c.val, c.ok = k, v, true + return v, nil +} diff --git a/internal/statefile/cache_darwin.go b/internal/statefile/cache_darwin.go new file mode 100644 index 00000000..c0e4c5a7 --- /dev/null +++ b/internal/statefile/cache_darwin.go @@ -0,0 +1,13 @@ +package statefile + +import ( + "io/fs" + "syscall" +) + +func statKey(fi fs.FileInfo, k *fileKey) { + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + k.dev, k.ino = uint64(st.Dev), st.Ino + k.ctim = st.Ctimespec.Nano() + } +} diff --git a/internal/statefile/cache_linux.go b/internal/statefile/cache_linux.go new file mode 100644 index 00000000..62b91e7d --- /dev/null +++ b/internal/statefile/cache_linux.go @@ -0,0 +1,13 @@ +package statefile + +import ( + "io/fs" + "syscall" +) + +func statKey(fi fs.FileInfo, k *fileKey) { + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + k.dev, k.ino = uint64(st.Dev), st.Ino + k.ctim = st.Ctim.Nano() + } +} diff --git a/internal/statefile/cache_test.go b/internal/statefile/cache_test.go new file mode 100644 index 00000000..7837d3fc --- /dev/null +++ b/internal/statefile/cache_test.go @@ -0,0 +1,74 @@ +package statefile + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestCacheReadsAFileAgainOnlyOnceItChanges(t *testing.T) { + path := filepath.Join(t.TempDir(), "routes.json") + var c Cache[string] + reads := 0 + load := func() string { + t.Helper() + v, err := c.Load(path, func() (string, error) { + reads++ + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "none", nil + } + return string(b), err + }) + if err != nil { + t.Fatal(err) + } + return v + } + if v := load(); v != "none" || reads != 1 { + t.Fatalf("missing file: %q after %d reads", v, reads) + } + if v := load(); v != "none" || reads != 1 { + t.Fatalf("still missing: %q after %d reads", v, reads) + } + if err := Write(path, []byte("aaaa")); err != nil { + t.Fatal(err) + } + if v := load(); v != "aaaa" || reads != 2 { + t.Fatalf("new file: %q after %d reads", v, reads) + } + for range 3 { + if v := load(); v != "aaaa" || reads != 2 { + t.Fatalf("unchanged file: %q after %d reads", v, reads) + } + } + // The same size, written straight after: still a new version. + if err := Write(path, []byte("bbbb")); err != nil { + t.Fatal(err) + } + if v := load(); v != "bbbb" || reads != 3 { + t.Fatalf("rewritten file: %q after %d reads", v, reads) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if v := load(); v != "none" || reads != 4 { + t.Fatalf("removed file: %q after %d reads", v, reads) + } +} + +func TestCacheKeepsNoErrors(t *testing.T) { + path := filepath.Join(t.TempDir(), "routes.json") + if err := Write(path, []byte("x")); err != nil { + t.Fatal(err) + } + var c Cache[int] + broken := errors.New("unreadable") + if _, err := c.Load(path, func() (int, error) { return 0, broken }); err != broken { + t.Fatalf("err = %v", err) + } + if v, err := c.Load(path, func() (int, error) { return 7, nil }); err != nil || v != 7 { + t.Fatalf("after an error: %d, %v", v, err) + } +} diff --git a/internal/transcript/bench_test.go b/internal/transcript/bench_test.go new file mode 100644 index 00000000..f878f7b5 --- /dev/null +++ b/internal/transcript/bench_test.go @@ -0,0 +1,200 @@ +package transcript + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +// Benchmarks on a synthetic Claude Code record shaped like a real one: each +// step an assistant line (thinking, text, a tool call) and the user line +// with its result, whose toolUseResult carries what Claude Code keeps +// beside it (a Read's whole file, an Edit's original file and patch). +// Every name and path is made up ("acme"). + +// synthClaude writes a record of about n lines to a temp file and returns +// its path and the ID of a tool call near its end. +func synthClaude(tb testing.TB, n int) (string, string) { + tb.Helper() + dir := tb.TempDir() + p := filepath.Join(dir, "0b8f3a52-acme-4c1e-9d55-2f7e0c1a9b01.jsonl") + f, err := os.Create(p) + if err != nil { + tb.Fatal(err) + } + defer f.Close() + at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + file := strings.Repeat("export function acme(x: number): number {\n return x * 2 // keep it simple\n}\n", 60) // ~5KB + out := strings.Repeat("ok github.com/acme/widgets/pkg/thing\t0.123s\n", 40) + parent := "" + uuid := 0 + line := func(v map[string]any) { + uuid++ + id := fmt.Sprintf("00000000-0000-4000-8000-%012d", uuid) + v["uuid"], v["parentUuid"], v["isSidechain"] = id, parent, false + v["timestamp"] = at.Format(time.RFC3339Nano) + v["cwd"], v["sessionId"], v["version"], v["gitBranch"], v["userType"] = "/home/acme/widgets", "0b8f3a52-acme", "2.1.0", "main", "external" + at = at.Add(1500 * time.Millisecond) + parent = id + b, _ := json.Marshal(v) + f.Write(append(b, '\n')) + } + usage := map[string]any{"input_tokens": 12, "cache_creation_input_tokens": 3400, "cache_read_input_tokens": 88000, "output_tokens": 420} + assistant := func(blocks ...map[string]any) { + line(map[string]any{"type": "assistant", "requestId": "req_acme", "message": map[string]any{ + "id": "msg_acme", "type": "message", "role": "assistant", "model": "claude-acme-1", "content": blocks, "usage": usage}}) + } + result := func(tool string, content string, extra any) { + line(map[string]any{"type": "user", "message": map[string]any{"role": "user", "content": []map[string]any{ + {"type": "tool_result", "tool_use_id": tool, "content": content}}}, "toolUseResult": extra}) + } + lastTool := "" + for i := 0; uuid < n; i++ { + if i%25 == 0 { + line(map[string]any{"type": "user", "permissionMode": "default", "message": map[string]any{"role": "user", "content": fmt.Sprintf("Please fix widget %d in the acme repo and run the tests", i)}}) + } + assistant(map[string]any{"type": "thinking", "thinking": "", "signature": strings.Repeat("Q2xhdWRl", 120)}) + assistant(map[string]any{"type": "text", "text": fmt.Sprintf("Looking at widget %d now; I'll read the file first.", i)}) + tool := fmt.Sprintf("toolu_%06d", i) + lastTool = tool + path := fmt.Sprintf("/home/acme/widgets/src/widget%d.ts", i%40) + switch i % 4 { + case 0: + assistant(map[string]any{"type": "tool_use", "id": tool, "name": "Read", "input": map[string]any{"file_path": path}}) + result(tool, file, map[string]any{"type": "text", "file": map[string]any{"filePath": path, "content": file, "numLines": 180, "startLine": 1, "totalLines": 180}}) + case 1: + assistant(map[string]any{"type": "tool_use", "id": tool, "name": "Edit", "input": map[string]any{"file_path": path, "old_string": "x * 2", "new_string": "x * 3"}}) + result(tool, "The file "+path+" has been updated.", map[string]any{"filePath": path, "oldString": "x * 2", "newString": "x * 3", "originalFile": file, + "structuredPatch": []map[string]any{{"oldStart": 2, "oldLines": 1, "newStart": 2, "newLines": 1, "lines": []string{"- return x * 2", "+ return x * 3"}}}}) + case 2: + assistant(map[string]any{"type": "tool_use", "id": tool, "name": "Bash", "input": map[string]any{"command": "go test ./...", "description": "Run tests"}}) + result(tool, out, map[string]any{"stdout": out, "stderr": "", "interrupted": false, "isImage": false}) + case 3: + assistant(map[string]any{"type": "tool_use", "id": tool, "name": "Grep", "input": map[string]any{"pattern": "acme", "path": "/home/acme/widgets"}}) + result(tool, "Found 3 files\nsrc/a.ts\nsrc/b.ts\nsrc/c.ts", map[string]any{"mode": "files_with_matches", "filenames": []string{"src/a.ts", "src/b.ts", "src/c.ts"}, "numFiles": 3}) + } + } + return p, lastTool +} + +// cpuPerOp reports the process's CPU time per op (cpu-ns/op) when the +// benchmark ends: on a busy machine it moves far less than wall time. +func cpuPerOp(b *testing.B) func() { + start := cpuTime() + return func() { + if b.N > 0 { + b.ReportMetric(float64(cpuTime()-start)/float64(b.N), "cpu-ns/op") + } + } +} + +func cpuTime() int64 { + var ru syscall.Rusage + syscall.Getrusage(syscall.RUSAGE_SELF, &ru) + return ru.Utime.Nano() + ru.Stime.Nano() +} + +func fileSize(tb testing.TB, p string) int64 { + st, err := os.Stat(p) + if err != nil { + tb.Fatal(err) + } + return st.Size() +} + +// BenchmarkFollowCold is a chat opening a long conversation: the last +// maxStart bytes read and parsed from scratch. +func BenchmarkFollowCold(b *testing.B) { + p, _ := synthClaude(b, 20000) + b.SetBytes(min(fileSize(b, p), maxStart)) + b.ReportAllocs() + defer cpuPerOp(b)() + for b.Loop() { + if _, err := NewReader().Read("claude", p, "/home/acme/widgets", 0); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkFollowWarm is the 2-second poll of an open chat with nothing +// new: the answer is built from what is kept. +func BenchmarkFollowWarm(b *testing.B) { + p, _ := synthClaude(b, 5000) + r := NewReader() + res, err := r.Read("claude", p, "/home/acme/widgets", 0) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + defer cpuPerOp(b)() + for b.Loop() { + if _, err := r.Follow("claude", p, "/home/acme/widgets", res.Next, res.Gen); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkBefore is a page of 80 older items from the end of a long file. +func BenchmarkBefore(b *testing.B) { + p, _ := synthClaude(b, 20000) + b.ReportAllocs() + defer cpuPerOp(b)() + for b.Loop() { + if _, err := Before("claude", p, "/home/acme/widgets", 0, 80); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkLastTurn finds the latest prompt and reads its edits. +func BenchmarkLastTurn(b *testing.B) { + p, _ := synthClaude(b, 20000) + b.ReportAllocs() + defer cpuPerOp(b)() + for b.Loop() { + if _, err := LastTurn("claude", p, "/home/acme/widgets"); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkDetail opens one tool call near the end of a long file. +func BenchmarkDetail(b *testing.B) { + p, id := synthClaude(b, 20000) + b.SetBytes(fileSize(b, p)) + b.ReportAllocs() + defer cpuPerOp(b)() + for b.Loop() { + if _, err := Detail("claude", p, "/home/acme/widgets", id); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkAssignClaude assigns a folder's transcripts to its sessions, +// as every chat poll does, in a project folder holding 200 records. +func BenchmarkAssignClaude(b *testing.B) { + cfg := b.TempDir() + b.Setenv("CLAUDE_CONFIG_DIR", cfg) + dir := "/home/acme/widgets" + proj := ClaudeDir(dir) + if err := os.MkdirAll(proj, 0o755); err != nil { + b.Fatal(err) + } + start := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + for i := range 200 { + ts := start.Add(time.Duration(i) * time.Minute).Format(time.RFC3339Nano) + os.WriteFile(filepath.Join(proj, fmt.Sprintf("%08d-acme-4c1e-9d55-2f7e0c1a9b01.jsonl", i)), []byte(`{"type":"user","timestamp":"`+ts+`"}`+"\n"), 0o600) + } + claims := []Claim{{Name: "a", Started: start.Add(150 * time.Minute)}, {Name: "b", Started: start.Add(170 * time.Minute)}} + b.ReportAllocs() + defer cpuPerOp(b)() + for b.Loop() { + AssignClaude(dir, claims) + } +} diff --git a/internal/transcript/claude.go b/internal/transcript/claude.go index 026483dc..ed9893cf 100644 --- a/internal/transcript/claude.go +++ b/internal/transcript/claude.go @@ -1,7 +1,9 @@ package transcript import ( + "bytes" "encoding/json" + "errors" "path/filepath" "regexp" "strings" @@ -33,20 +35,54 @@ type claudeLine struct { ParentUUID string `json:"parentUuid"` // Attachment is what Claude Code hands the model mid-turn: a message // typed while it worked arrives as a queued_command, at the point the - // model reads it, and is not written as a user line. - Attachment *struct { - Type string `json:"type"` - Prompt json.RawMessage `json:"prompt"` - Origin *Origin `json:"origin"` - } `json:"attachment"` + // model reads it, and is not written as a user line (claudeAttached). + // The signals read its other kinds. + Attachment json.RawMessage `json:"attachment"` // Origin says who a user turn is from (Claude Code 2.1): the person // (human), another agent (peer), the lead (coordinator) or a task's // notification (peer.go). Origin *Origin `json:"origin"` + + // The signals' own fields (claudeExtra), read in the same pass: a + // line is decoded once, not again for them. + Level string `json:"level"` + PermissionMode string `json:"permissionMode"` + Effort string `json:"effort"` + IsAPIError bool `json:"isApiErrorMessage"` + Error json.RawMessage `json:"error"` + RetryAttempt int `json:"retryAttempt"` + MaxRetries int `json:"maxRetries"` +} + +// signalFields are claudeLine's fields only the signals read: one of a +// type they never had leaves the line to the conversation, unread by the +// signals, as when they read the line apart. +var signalFields = map[string]bool{"level": true, "permissionMode": true, "effort": true, "isApiErrorMessage": true, "retryAttempt": true, "maxRetries": true} + +// claudeAttached is an attachment line's queued_command. +type claudeAttached struct { + Type string `json:"type"` + Prompt json.RawMessage `json:"prompt"` + Origin *Origin `json:"origin"` } type claudeMessage struct { Content json.RawMessage `json:"content"` + // Model and Usage are the signals' (an assistant line's). + Model string `json:"model"` + Usage *struct { + Input int `json:"input_tokens"` + CacheCreate int `json:"cache_creation_input_tokens"` + CacheRead int `json:"cache_read_input_tokens"` + } `json:"usage"` +} + +// isArray says whether raw JSON is an array: content is a string or an +// array of blocks, and which is told by its first byte without trying to +// decode it as the other. +func isArray(raw []byte) bool { + raw = bytes.TrimLeft(raw, " \t\r\n") + return len(raw) > 0 && raw[0] == '[' } type claudeBlock struct { @@ -62,22 +98,36 @@ type claudeBlock struct { func (claudeParser) line(c *conv, b []byte) { var l claudeLine + sigOK := true + if err := json.Unmarshal(b, &l); err != nil { + var te *json.UnmarshalTypeError + if !errors.As(err, &te) || !signalFields[te.Field] { + return + } + sigOK = false + } // A helper's own record (a subagent's file) is all sidechain. - if json.Unmarshal(b, &l) != nil || (l.Sidechain && !c.side) { + if l.Sidechain && !c.side { return } + // The message is decoded once, for the conversation and the signals. + var msg claudeMessage + msgOK := len(l.Message) > 0 && json.Unmarshal(l.Message, &msg) == nil + if !msgOK { + msg = claudeMessage{} + } c.lineUUID, c.lineParent = l.UUID, l.ParentUUID if l.IsMeta { // A message from another agent, Claude Code's notification or a // note around the person's mid-turn message, marked meta: read for // what it is (peer.go), not dropped. if l.Type == "user" && l.Origin != nil && metaOrigins[l.Origin.Kind] { - if s := userString(l.Message); s != "" { + if s := userString(msg.Content); s != "" { c.userTurn(s, l.Origin, turnMeta, parseTime(l.Timestamp)) } return } - commandMeta(c, l) + commandMeta(c, l.Type, msg.Content) return } at := parseTime(l.Timestamp) @@ -87,7 +137,12 @@ func (claudeParser) line(c *conv, b []byte) { } // Its mode, model, context, errors and background work (signals.go): // an API error's synthetic reply is a notice, not the agent's words. - if claudeSignals(c, l.Type, b, at) { + var x *claudeExtra + if sigOK && (len(l.Message) == 0 || msgOK) { + x = &claudeExtra{Subtype: l.Subtype, Level: l.Level, PermissionMode: l.PermissionMode, Effort: l.Effort, IsAPIError: l.IsAPIError, + Error: l.Error, RetryAttempt: l.RetryAttempt, MaxRetries: l.MaxRetries, Attachment: l.Attachment, Message: msg} + } + if claudeSignals(c, l.Type, x, b, at) { return } switch { @@ -111,31 +166,33 @@ func (claudeParser) line(c *conv, b []byte) { helperDone(c, l.Content, at) return } - if l.Type == "attachment" && l.Attachment != nil && l.Attachment.Type == "queued_command" { - // Shown where the model read it, so a reply never sits above the - // message it answers. - if t := strings.TrimSpace(unwrapPasted(resultFull(l.Attachment.Prompt))); t != "" { - c.userTurn(t, l.Attachment.Origin, turnQueued, at) + if l.Type == "attachment" && len(l.Attachment) > 0 { + var a claudeAttached + if json.Unmarshal(l.Attachment, &a) == nil && a.Type == "queued_command" { + // Shown where the model read it, so a reply never sits above + // the message it answers. + if t := strings.TrimSpace(unwrapPasted(resultFull(a.Prompt))); t != "" { + c.userTurn(t, a.Origin, turnQueued, at) + } + return } - return } if l.Type != "user" && l.Type != "assistant" { return } - var m claudeMessage - if json.Unmarshal(l.Message, &m) != nil || len(m.Content) == 0 { + if !msgOK || len(msg.Content) == 0 { return } // A prompt can be a plain string. - var s string - if json.Unmarshal(m.Content, &s) == nil { - if l.Type == "user" { + if !isArray(msg.Content) { + var s string + if json.Unmarshal(msg.Content, &s) == nil && l.Type == "user" { c.userTurn(s, l.Origin, turnPrompt, at) } return } var blocks []claudeBlock - if json.Unmarshal(m.Content, &blocks) != nil { + if json.Unmarshal(msg.Content, &blocks) != nil { return } var typed []string @@ -206,18 +263,16 @@ func unwrapPasted(s string) string { // metaOrigins are the origins of the isMeta lines worth reading. var metaOrigins = map[string]bool{"human": true, "peer": true, "coordinator": true, "task-notification": true} -// userString is a user line's text: a plain string, or its text blocks. -func userString(raw json.RawMessage) string { - var m claudeMessage - if json.Unmarshal(raw, &m) != nil { - return "" - } - var s string - if json.Unmarshal(m.Content, &s) == nil { +// userString is a user line's text, from its message's content: a plain +// string, or its text blocks. +func userString(content json.RawMessage) string { + if !isArray(content) { + var s string + _ = json.Unmarshal(content, &s) return s } var blocks []claudeBlock - if json.Unmarshal(m.Content, &blocks) != nil { + if json.Unmarshal(content, &blocks) != nil { return "" } var parts []string @@ -350,8 +405,9 @@ func claudeTool(c *conv, bl claudeBlock, at int64) { // resultText is a tool result's text: a string, or its first text block. func resultText(raw json.RawMessage) string { - var s string - if json.Unmarshal(raw, &s) == nil { + if !isArray(raw) { + var s string + _ = json.Unmarshal(raw, &s) return s } var blocks []claudeBlock @@ -503,13 +559,12 @@ var hookReports = regexp.MustCompile(`(?m)^(?:Pre|Post|Session|Stop|User|Notific // commandMeta keeps the Markdown Claude Code writes for its model after a // command's output (/context's table) as that output, which reads better // than the terminal's drawing of it. -func commandMeta(c *conv, l claudeLine) { - if l.Type != "user" { +func commandMeta(c *conv, typ string, content json.RawMessage) { + if typ != "user" { return } - var m claudeMessage var s string - if json.Unmarshal(l.Message, &m) != nil || json.Unmarshal(m.Content, &s) != nil { + if json.Unmarshal(content, &s) != nil { return } s = strings.TrimSpace(s) diff --git a/internal/transcript/detail.go b/internal/transcript/detail.go index 732788ef..91c4c7c6 100644 --- a/internal/transcript/detail.go +++ b/internal/transcript/detail.go @@ -83,8 +83,10 @@ func Detail(source, path, dir, id string) (ToolDetail, error) { needle := []byte(`"` + id + `"`) d := ToolDetail{ID: id} found, answered := false, false + var line []byte for { - line, err := readLine(r) + var err error + line, err = readLineInto(r, line) if len(line) > 0 && bytes.Contains(line, needle) { var call, result bool if source == "codex" { @@ -109,14 +111,19 @@ func Detail(source, path, dir, id string) (ToolDetail, error) { // readLine reads one line, skipping (not keeping) any longer than // detailLine. -func readLine(r *bufio.Reader) ([]byte, error) { - var buf []byte +func readLine(r *bufio.Reader) ([]byte, error) { return readLineInto(r, nil) } + +// readLineInto is readLine into buf's space: a scan of a long record reuses +// one buffer rather than allocating every line again. The line is good +// until the next call. +func readLineInto(r *bufio.Reader, buf []byte) ([]byte, error) { + buf = buf[:0] over := false for { chunk, err := r.ReadSlice('\n') if !over { if len(buf)+len(chunk) > detailLine { - over, buf = true, nil + over, buf = true, buf[:0] } else { buf = append(buf, chunk...) } diff --git a/internal/transcript/signals.go b/internal/transcript/signals.go index b5c93af5..456bb097 100644 --- a/internal/transcript/signals.go +++ b/internal/transcript/signals.go @@ -174,40 +174,33 @@ func (c *conv) interrupted(text string) bool { // --- Claude Code --- // claudeExtra are the fields of a Claude Code line the signals read, past -// the ones the conversation does. +// the ones the conversation does. The parser decodes them with the line +// (claudeLine). type claudeExtra struct { - Subtype string `json:"subtype"` - Level string `json:"level"` - PermissionMode string `json:"permissionMode"` - Effort string `json:"effort"` - IsAPIError bool `json:"isApiErrorMessage"` - Error json.RawMessage `json:"error"` - RetryAttempt int `json:"retryAttempt"` - MaxRetries int `json:"maxRetries"` - Attachment json.RawMessage `json:"attachment"` - ToolUseResult json.RawMessage `json:"toolUseResult"` - Message struct { - Model string `json:"model"` - Content json.RawMessage - Usage *struct { - Input int `json:"input_tokens"` - CacheCreate int `json:"cache_creation_input_tokens"` - CacheRead int `json:"cache_read_input_tokens"` - } `json:"usage"` - } `json:"message"` + Subtype string + Level string + PermissionMode string + Effort string + IsAPIError bool + Error json.RawMessage + RetryAttempt int + MaxRetries int + Attachment json.RawMessage + Message claudeMessage } -// claudeSignals reads what a Claude Code line says about the agent. It -// reports true when the line is wholly a signal and the conversation -// should not read it as a message too (an API error's synthetic reply). -func claudeSignals(c *conv, typ string, b []byte, at int64) bool { +// claudeSignals reads what a Claude Code line says about the agent: x is +// the line's fields, or nil when one is not of the type they read (the line +// is no signal then). It reports true when the line is wholly a signal and +// the conversation should not read it as a message too (an API error's +// synthetic reply). +func claudeSignals(c *conv, typ string, x *claudeExtra, b []byte, at int64) bool { // A tool's result or a prompt is read only when it holds what the // signals want: most are long and say nothing about the agent. if typ == "user" && !bytes.Contains(b, []byte(`"permissionMode"`)) && !bytes.Contains(b, []byte("[Request interrupted by user")) && !bytes.Contains(b, []byte("")) { return false } - var x claudeExtra - if json.Unmarshal(b, &x) != nil { + if x == nil { return false } if x.PermissionMode != "" { diff --git a/internal/transcript/signals_test.go b/internal/transcript/signals_test.go index 493e678f..4e06df82 100644 --- a/internal/transcript/signals_test.go +++ b/internal/transcript/signals_test.go @@ -47,6 +47,24 @@ func TestClaudeSignals(t *testing.T) { } } +// The signals are read in the same pass as the conversation: a signal's +// field of a shape they don't know leaves that line's signals unread, never +// its words. +func TestClaudeSignalOfAnotherShape(t *testing.T) { + p := filepath.Join(t.TempDir(), "s.jsonl") + write(t, p, + m{"type": "assistant", "effort": "high", "message": m{"model": "claude-acme-1", "role": "assistant", "content": []m{{"type": "text", "text": "First."}}}}, + m{"type": "assistant", "effort": m{"level": "max"}, "message": m{"model": "claude-acme-2", "role": "assistant", "content": []m{{"type": "text", "text": "Second."}}}}, + ) + res := read(t, "claude", p) + if n := len(res.Items); n != 2 || res.Items[1].Text != "Second." { + t.Fatalf("items = %+v", res.Items) + } + if s := res.Signals; s.Model != "claude-acme-1" || s.Effort != "high" { + t.Fatalf("model/effort = %q %q", s.Model, s.Effort) + } +} + func TestClaudeTasks(t *testing.T) { p := filepath.Join(t.TempDir(), "s.jsonl") write(t, p, diff --git a/internal/transcript/transcript.go b/internal/transcript/transcript.go index 6687b8fc..9d74fd2d 100644 --- a/internal/transcript/transcript.go +++ b/internal/transcript/transcript.go @@ -257,13 +257,21 @@ func (c *conv) add(it Item) int { c.items[n-1].pending = nil } it.Off = c.lineOff - c.items = append(c.items, it) n := keep if c.limit > 0 { n = c.limit } + // The window slides along a backing array with room for n more: the + // oldest item drops off by reslicing, and only when the array is full + // do the kept items move to a fresh one. Copying the whole window for + // every item past n made opening a long conversation quadratic. + if len(c.items) == cap(c.items) && len(c.items) >= n { + c.items = append(make([]Item, 0, 2*n), c.items...) + } + c.items = append(c.items, it) if over := len(c.items) - n; over > 0 { - c.items = append(c.items[:0:0], c.items[over:]...) + clear(c.items[:over]) // let what they hold go + c.items = c.items[over:] c.base += over } return c.base + len(c.items) - 1 @@ -359,8 +367,16 @@ func (c *conv) helper(m CrewMember) { } } +// readers are the 64 KB buffers files are followed through, kept between +// reads: every open chat reads its file every few seconds. +var readers = sync.Pool{New: func() any { return bufio.NewReaderSize(nil, 64<<10) }} + // read follows the file from where it left off. func (c *conv) read(path string) error { + // Nothing new, the usual answer to a chat's poll, needs no open. + if st, err := os.Stat(path); err == nil && st.Size() == c.offset { + return nil + } f, err := os.Open(path) if err != nil { return err @@ -370,6 +386,9 @@ func (c *conv) read(path string) error { if err != nil { return err } + if st.Size() == c.offset { + return nil + } if st.Size() < c.offset { // replaced or truncated: start again *c = conv{source: c.source, dir: c.dir, p: c.p, side: c.side, byTool: map[string]int{}, crewByID: map[string]int{}, gen: gens.Add(1)} } @@ -380,7 +399,12 @@ func (c *conv) read(path string) error { if _, err := f.Seek(c.offset, io.SeekStart); err != nil { return err } - r := bufio.NewReaderSize(f, 64<<10) + r := readers.Get().(*bufio.Reader) + r.Reset(f) + defer func() { + r.Reset(nil) + readers.Put(r) + }() skipFirst := c.truncated && c.offset > 0 && len(c.items) == 0 && c.partial == nil for { if len(c.partial) == 0 { diff --git a/internal/transcript/turn.go b/internal/transcript/turn.go index 81146b47..c301c8a9 100644 --- a/internal/transcript/turn.go +++ b/internal/transcript/turn.go @@ -107,8 +107,10 @@ func claudeTurn(path string, off int64) (Turn, error) { byFile := map[string]int{} r := bufio.NewReaderSize(f, 256<<10) first := true + var line []byte for { - line, err := readLine(r) + var err error + line, err = readLineInto(r, line) if len(line) > 0 { if first { var l struct { diff --git a/internal/trust/bench_test.go b/internal/trust/bench_test.go new file mode 100644 index 00000000..b2849363 --- /dev/null +++ b/internal/trust/bench_test.go @@ -0,0 +1,32 @@ +package trust + +import ( + "crypto/sha256" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/cosscom/shipyard/internal/identity" +) + +// BenchmarkTrusted looks a key up in a store of a few peers, as a box does +// for every request a laptop makes. +func BenchmarkTrusted(b *testing.B) { + s := NewStore(filepath.Join(b.TempDir(), "clients.json")) + var last identity.Fingerprint + for i := range 4 { + last = identity.Fingerprint(sha256.Sum256([]byte{byte(i)})) + if err := s.Add(Peer{Name: "laptop-" + strconv.Itoa(i), Fingerprint: last, PairedAt: time.Now()}); err != nil { + b.Fatal(err) + } + } + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if _, ok, err := s.Trusted(last); !ok || err != nil { + b.Fatal(ok, err) + } + } + }) +} diff --git a/internal/trust/store.go b/internal/trust/store.go index dfdabb1b..cf5a8d67 100644 --- a/internal/trust/store.go +++ b/internal/trust/store.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "regexp" + "slices" "sort" "strings" "time" @@ -35,22 +36,39 @@ var ( // ValidName reports whether name is safe to show in listings and use in URLs. func ValidName(name string) bool { return validName.MatchString(name) } -type Store struct{ path string } +type Store struct { + path string + // cache keeps the peers until the file changes. A box looks the + // caller up for every request; reading the file each time took a + // file lock that every request queued behind. + cache statefile.Cache[[]Peer] +} func NewStore(path string) *Store { return &Store{path: path} } func (s *Store) List() ([]Peer, error) { - unlock, err := statefile.Lock(s.path) - if err != nil { - return nil, err - } - defer unlock() - return s.read() + peers, err := s.peers() + return slices.Clone(peers), err +} + +// peers is the store as last read, read again once the file changes (a +// pairing, a revoke, by this process or another). It is shared: callers +// must not change it. Errors are never kept: a store that cannot be read +// fails every lookup until it can. +func (s *Store) peers() ([]Peer, error) { + return s.cache.Load(s.path, func() ([]Peer, error) { + unlock, err := statefile.Lock(s.path) + if err != nil { + return nil, err + } + defer unlock() + return s.read() + }) } // Trusted looks a peer up by the key it presented. func (s *Store) Trusted(fp identity.Fingerprint) (Peer, bool, error) { - peers, err := s.List() + peers, err := s.peers() if err != nil { return Peer{}, false, err } @@ -63,7 +81,7 @@ func (s *Store) Trusted(fp identity.Fingerprint) (Peer, bool, error) { } func (s *Store) ByName(name string) (Peer, bool, error) { - peers, err := s.List() + peers, err := s.peers() if err != nil { return Peer{}, false, err } diff --git a/internal/trust/store_test.go b/internal/trust/store_test.go index 835228f3..18a091fa 100644 --- a/internal/trust/store_test.go +++ b/internal/trust/store_test.go @@ -132,6 +132,64 @@ func TestACorruptStoreFailsClosedAndIsNotOverwritten(t *testing.T) { } } +// A box keeps its laptops between requests, yet a laptop that another +// process (berthd revoke) removes is refused on its very next request, and +// a store that turns unreadable fails closed at once. +func TestARevokeByAnotherProcessAppliesAtOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), "clients.json") + box, cli := NewStore(path), NewStore(path) + alex := Peer{Name: "alex", Fingerprint: fp()} + sam := Peer{Name: "sam1", Fingerprint: fp()} // same length: same file size + if err := cli.Add(alex); err != nil { + t.Fatal(err) + } + for range 3 { + if _, ok, err := box.Trusted(alex.Fingerprint); !ok || err != nil { + t.Fatalf("trusted = %v, %v", ok, err) + } + } + if _, err := cli.Remove("alex"); err != nil { + t.Fatal(err) + } + if _, ok, _ := box.Trusted(alex.Fingerprint); ok { + t.Fatal("a revoked laptop was still trusted") + } + if err := cli.Add(alex); err != nil { + t.Fatal(err) + } + if _, ok, _ := box.Trusted(alex.Fingerprint); !ok { + t.Fatal("a paired laptop was not trusted") + } + // One peer swapped for another of the same size, straight after. + if _, err := cli.Remove("alex"); err != nil { + t.Fatal(err) + } + if err := cli.Add(Peer{Name: sam.Name, Fingerprint: sam.Fingerprint, PairedAt: alex.PairedAt}); err != nil { + t.Fatal(err) + } + if _, ok, _ := box.Trusted(alex.Fingerprint); ok { + t.Fatal("a replaced laptop was still trusted") + } + if _, ok, _ := box.Trusted(sam.Fingerprint); !ok { + t.Fatal("its replacement was not trusted") + } + if list, _ := box.List(); len(list) == 1 { + list[0].Fingerprint = alex.Fingerprint + } + if _, ok, _ := box.Trusted(sam.Fingerprint); !ok { + t.Fatal("changing a listing changed the store") + } + if err := os.WriteFile(path+".tmp", []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Rename(path+".tmp", path); err != nil { + t.Fatal(err) + } + if _, _, err := box.Trusted(sam.Fingerprint); err == nil { + t.Fatal("a corrupt store answered a trust lookup") + } +} + func TestInvalidNamesAreRefused(t *testing.T) { s := NewStore(filepath.Join(t.TempDir(), "boxes.json")) for _, name := range []string{"", "-leading", "has space", "a/b", "ünïcode", string(make([]byte, 64))} { diff --git a/internal/wire/bench_test.go b/internal/wire/bench_test.go new file mode 100644 index 00000000..615278f1 --- /dev/null +++ b/internal/wire/bench_test.go @@ -0,0 +1,74 @@ +package wire + +import ( + "context" + "io" + "net" + "testing" +) + +// benchEcho is a server on the box's loopback that echoes what it reads. +func benchEcho(b *testing.B) int { + b.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { ln.Close() }) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { defer c.Close(); io.Copy(c, c) }() + } + }() + return ln.Addr().(*net.TCPAddr).Port +} + +// BenchmarkDialPortRoundTrip opens a stream to a box port, sends a small +// request, reads the answer and closes it: one proxied connection. +func BenchmarkDialPortRoundTrip(b *testing.B) { + c := paired(b, startBox(b)) + port := benchEcho(b) + msg := make([]byte, 1<<10) + buf := make([]byte, len(msg)) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + conn, err := c.DialPort(context.Background(), port) + if err != nil { + b.Fatal(err) + } + if _, err := conn.Write(msg); err != nil { + b.Fatal(err) + } + if _, err := io.ReadFull(conn, buf); err != nil { + b.Fatal(err) + } + conn.Close() + } +} + +// BenchmarkStreamEcho pushes data through one long stream and back. +func BenchmarkStreamEcho(b *testing.B) { + c := paired(b, startBox(b)) + port := benchEcho(b) + conn, err := c.DialPort(context.Background(), port) + if err != nil { + b.Fatal(err) + } + defer conn.Close() + chunk := make([]byte, 64<<10) + buf := make([]byte, len(chunk)) + b.SetBytes(int64(len(chunk))) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + go conn.Write(chunk) + if _, err := io.ReadFull(conn, buf); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/wire/server.go b/internal/wire/server.go index e5de1226..2bceb40a 100644 --- a/internal/wire/server.go +++ b/internal/wire/server.go @@ -16,6 +16,7 @@ import ( "sync/atomic" "time" + "github.com/cosscom/shipyard/internal/copybuf" "github.com/cosscom/shipyard/internal/identity" "github.com/cosscom/shipyard/internal/pairing" "github.com/cosscom/shipyard/internal/trust" @@ -341,12 +342,12 @@ func (s *Server) handleTCP(w http.ResponseWriter, r *http.Request) { done := make(chan struct{}) go func() { defer close(done) - io.Copy(upstream, r.Body) + copybuf.Copy(upstream, r.Body) if tc, ok := upstream.(*net.TCPConn); ok { tc.CloseWrite() } }() - io.Copy(flushWriter{w: w, rc: rc}, upstream) + copybuf.Copy(flushWriter{w: w, rc: rc}, upstream) upstream.Close() r.Body.Close() <-done diff --git a/internal/wire/wire_test.go b/internal/wire/wire_test.go index 5ecc9406..f172018b 100644 --- a/internal/wire/wire_test.go +++ b/internal/wire/wire_test.go @@ -47,9 +47,9 @@ func (l countingListener) Accept() (net.Conn, error) { return c, err } -func startBox(t *testing.T) *box { return startBoxWith(t, nil) } +func startBox(t testing.TB) *box { return startBoxWith(t, nil) } -func startBoxWith(t *testing.T, configure func(*Server)) *box { +func startBoxWith(t testing.TB, configure func(*Server)) *box { t.Helper() dir := t.TempDir() id, err := identity.LoadOrCreate(filepath.Join(dir, "identity.pem")) @@ -82,7 +82,7 @@ func startBoxWith(t *testing.T, configure func(*Server)) *box { return &box{server: s, address: ln.Addr().String(), dir: dir, accepted: accepted} } -func (b *box) issue(t *testing.T) pairing.Token { +func (b *box) issue(t testing.TB) pairing.Token { t.Helper() code, err := b.server.Pending.Issue(10*time.Minute, time.Now()) if err != nil { @@ -95,7 +95,7 @@ func (b *box) peer() trust.Peer { return trust.Peer{Name: "dev-test", Address: b.address, Fingerprint: b.server.Identity.Fingerprint()} } -func laptop(t *testing.T) *identity.Identity { +func laptop(t testing.TB) *identity.Identity { t.Helper() id, err := identity.LoadOrCreate(filepath.Join(t.TempDir(), "identity.pem")) if err != nil { @@ -105,7 +105,7 @@ func laptop(t *testing.T) *identity.Identity { } // paired returns a client for a laptop that has completed pairing with b. -func paired(t *testing.T, b *box) *Client { +func paired(t testing.TB, b *box) *Client { t.Helper() me := laptop(t) if _, err := Pair(context.Background(), me, b.issue(t), "alex-mbp"); err != nil { diff --git a/plugins/dev-servers/src/index.tsx b/plugins/dev-servers/src/index.tsx index 5257b61b..31a16c2e 100644 --- a/plugins/dev-servers/src/index.tsx +++ b/plugins/dev-servers/src/index.tsx @@ -60,8 +60,10 @@ interface Group { } // useServers polls the listening servers of every online box, and reads the -// repositories' declared services when worktrees or services change. -function useServers(berth: BerthPluginContext) { +// repositories' declared services when worktrees or services change. That +// is a request per worktree on every box, so only the screen that lists +// them asks (withConfigured); the status bar's count needs none of it. +function useServers(berth: BerthPluginContext, withConfigured = true) { const boxes = useBoxes(); const online = useMemo(() => boxes.filter((b) => b.state === "online").map((b) => b.name), [boxes]); const key = online.join(","); @@ -94,6 +96,7 @@ function useServers(berth: BerthPluginContext) { }, [key, stamp, berth]); useEffect(() => { + if (!withConfigured) return; let live = true; void (async () => { const found: Configured[] = []; @@ -116,7 +119,7 @@ function useServers(berth: BerthPluginContext) { live = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [key, stamp, berth]); + }, [key, stamp, berth, withConfigured]); useEvent("service.*", reload); useEvent("worktree.created", reload); @@ -125,7 +128,7 @@ function useServers(berth: BerthPluginContext) { } function ServersStatus({ berth }: ScreenProps) { - const { listening } = useServers(berth); + const { listening } = useServers(berth, false); if (!listening?.length) return null; return (