diff --git a/apps/app/index.html b/apps/app/index.html index 20197c5f4f..61e7ec00cb 100644 --- a/apps/app/index.html +++ b/apps/app/index.html @@ -81,6 +81,30 @@
+ diff --git a/apps/app/src/components/ui/app-toast.tsx b/apps/app/src/components/ui/app-toast.tsx index b9ac105a59..b32257b90d 100644 --- a/apps/app/src/components/ui/app-toast.tsx +++ b/apps/app/src/components/ui/app-toast.tsx @@ -34,6 +34,9 @@ export interface AppToastOptions action?: Action; cancel?: Action; description?: ReactNode; + /** Render a corner close button. For persistent toasts whose button slots + * are taken by real actions, so plain dismissal stays one click. */ + showCloseButton?: boolean; } export interface AppToastContentProps { @@ -41,6 +44,7 @@ export interface AppToastContentProps { cancel?: Action; description?: ReactNode; id?: number | string; + showCloseButton?: boolean; title: ReactNode; tone: AppToastTone; } @@ -123,6 +127,7 @@ export function AppToastContent({ cancel, description, id, + showCloseButton, title, tone, }: AppToastContentProps) { @@ -133,6 +138,20 @@ export function AppToastContent({
+ {showCloseButton ? ( + + ) : null}
diff --git a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts index 73a8379067..8df1021ddf 100644 --- a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts +++ b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts @@ -204,6 +204,9 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "threadsQueryKey", ], "hooks/cache-owners/system-config-cache-owner.ts": ["systemConfigQueryKey"], + "hooks/cache-owners/system-version-cache-owner.ts": [ + "systemVersionQueryKey", + ], "hooks/cache-owners/terminal-cache-owner.ts": [ "allTerminalsQueryKeyPrefix", "TerminalQueryScope", diff --git a/apps/app/src/hooks/cache-owners/system-version-cache-owner.ts b/apps/app/src/hooks/cache-owners/system-version-cache-owner.ts new file mode 100644 index 0000000000..a4d0fd3048 --- /dev/null +++ b/apps/app/src/hooks/cache-owners/system-version-cache-owner.ts @@ -0,0 +1,24 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { + SystemSelfUpdateState, + SystemVersionResponse, +} from "@bb/server-contract"; +import { systemVersionQueryKey } from "../queries/query-keys"; + +export interface ApplySelfUpdateStateToVersionCacheArgs { + queryClient: QueryClient; + selfUpdate: SystemSelfUpdateState; +} + +/** Fold a schedule/cancel mutation result into the cached version response. */ +export function applySelfUpdateStateToVersionCache( + args: ApplySelfUpdateStateToVersionCacheArgs, +): void { + args.queryClient.setQueryData( + systemVersionQueryKey(), + (previous) => + previous === undefined + ? previous + : { ...previous, selfUpdate: args.selfUpdate }, + ); +} diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index b5cabf7ab8..9f8527d4e8 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -58,6 +58,7 @@ export const SYSTEM_PROVIDERS_QUERY_KEY = "systemProviders"; export const SYSTEM_CONFIG_QUERY_KEY = "systemConfig"; export const SYSTEM_EXECUTION_OPTIONS_QUERY_KEY = "systemExecutionOptions"; export const SYSTEM_VERSION_QUERY_KEY = "systemVersion"; +export const SYSTEM_AGENT_ACTIVITY_QUERY_KEY = "systemAgentActivity"; export const HOST_PROVIDER_CLI_STATUS_QUERY_KEY = "hostProviderCliStatus"; export const SYSTEM_USAGE_LIMITS_QUERY_KEY = "systemUsageLimits"; export const HOST_PATH_EXISTENCE_QUERY_KEY = "hostPathExistence"; @@ -422,6 +423,9 @@ export type SystemProvidersQueryKey = readonly [ ]; export type SystemConfigQueryKey = readonly [typeof SYSTEM_CONFIG_QUERY_KEY]; export type SystemVersionQueryKey = readonly [typeof SYSTEM_VERSION_QUERY_KEY]; +export type SystemAgentActivityQueryKey = readonly [ + typeof SYSTEM_AGENT_ACTIVITY_QUERY_KEY, +]; export type HostProviderCliStatusQueryKey = readonly [ typeof HOST_PROVIDER_CLI_STATUS_QUERY_KEY, string | null, @@ -1017,6 +1021,10 @@ export function systemVersionQueryKey(): SystemVersionQueryKey { return [SYSTEM_VERSION_QUERY_KEY]; } +export function systemAgentActivityQueryKey(): SystemAgentActivityQueryKey { + return [SYSTEM_AGENT_ACTIVITY_QUERY_KEY]; +} + export function hostProviderCliStatusQueryKey( hostId: string | null, ): HostProviderCliStatusQueryKey { diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts index 505b64081c..1275893237 100644 --- a/apps/app/src/hooks/queries/system-queries.ts +++ b/apps/app/src/hooks/queries/system-queries.ts @@ -1,16 +1,21 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toRecord } from "@bb/core-ui"; import type { + SystemAgentActivityResponse, SystemConfigResponse, SystemExecutionOptionsResponse, + SystemSelfUpdateMode, + SystemSelfUpdateState, SystemVersionResponse, } from "@bb/server-contract"; import type { ProviderCliStatusResponse } from "@bb/host-daemon-contract"; import type { ProviderUsageResponse } from "@bb/host-daemon-contract"; import * as api from "@/lib/api"; +import { applySelfUpdateStateToVersionCache } from "@/hooks/cache-owners/system-version-cache-owner"; import { useSystemRealtimeSubscription } from "@/hooks/useRealtimeSubscription"; import { hostProviderCliStatusQueryKey, + systemAgentActivityQueryKey, systemConfigQueryKey, systemExecutionOptionsQueryKey, systemUsageLimitsQueryKey, @@ -96,12 +101,79 @@ export function useSystemConfig(options?: QueryOptions) { }); } +/** Poll cadence while a scheduled self-update is pending, so the UI notices + * staging failures and the post-update version bump. */ +const SCHEDULED_SELF_UPDATE_REFETCH_MS = 30_000; + export function useSystemVersion(options?: QueryOptions) { return useQuery({ queryKey: systemVersionQueryKey(), queryFn: ({ signal }) => api.getSystemVersion(signal), enabled: options?.enabled ?? true, ...SERVER_SESSION_QUERY_POLICY, + refetchInterval: (query) => + query.state.data?.selfUpdate.scheduled != null + ? SCHEDULED_SELF_UPDATE_REFETCH_MS + : false, + }); +} + +/** + * Poll cadence for the update toast's busy/idle action label. 1s keeps the + * label effectively live; the endpoint is a single indexed count on a small + * table, and the poll only runs while the update choice is on screen (an + * update is available, capable, and not yet scheduled or dismissed). + */ +const AGENT_ACTIVITY_REFETCH_MS = 1_000; + +/** + * Live agent load, used to pick "Update now" vs "Update when agents finish" + * on the update toast. Unlike the sidebar's thread cache this counts every + * thread — side-chats and archived included — so the label is global truth. + */ +export function useSystemAgentActivity(options?: QueryOptions) { + return useQuery({ + queryKey: systemAgentActivityQueryKey(), + queryFn: ({ signal }) => api.getSystemAgentActivity(signal), + enabled: options?.enabled ?? true, + refetchInterval: AGENT_ACTIVITY_REFETCH_MS, + staleTime: 0, + }); +} + +/** + * Busy/idle for the update toasts, with the shared safe default: unknown + * activity reads as busy, since deferring is always the harmless choice. + * Queued follow-ups count as busy — an update would orphan them, so + * "Update now" would not actually be immediate. + */ +export function useAgentsBusy(options?: QueryOptions): boolean { + const { data } = useSystemAgentActivity(options); + return data === undefined + ? true + : data.busyThreadCount > 0 || data.queuedThreadCount > 0; +} + +function useApplySelfUpdateState() { + const queryClient = useQueryClient(); + return (selfUpdate: SystemSelfUpdateState): void => { + applySelfUpdateStateToVersionCache({ queryClient, selfUpdate }); + }; +} + +export function useScheduleSelfUpdate() { + const applySelfUpdateState = useApplySelfUpdateState(); + return useMutation({ + mutationFn: (mode: SystemSelfUpdateMode) => api.scheduleSelfUpdate(mode), + onSuccess: applySelfUpdateState, + }); +} + +export function useCancelSelfUpdate() { + const applySelfUpdateState = useApplySelfUpdateState(); + return useMutation({ + mutationFn: () => api.cancelSelfUpdate(), + onSuccess: applySelfUpdateState, }); } diff --git a/apps/app/src/hooks/useUpdateAvailableToast.ts b/apps/app/src/hooks/useUpdateAvailableToast.ts index 5c5a16521c..052a51a937 100644 --- a/apps/app/src/hooks/useUpdateAvailableToast.ts +++ b/apps/app/src/hooks/useUpdateAvailableToast.ts @@ -1,10 +1,18 @@ import { useEffect, useRef, useState } from "react"; import { appToast } from "@/components/ui/app-toast"; import type { BbDesktopApi, BbDesktopInfo } from "@bb/desktop-contract"; +import type { SystemSelfUpdateScheduled } from "@bb/server-contract"; import { getBbDesktopInfo } from "@/lib/bb-desktop"; -import { useSystemVersion } from "./queries/system-queries"; +import { + useAgentsBusy, + useCancelSelfUpdate, + useScheduleSelfUpdate, + useSystemVersion, +} from "./queries/system-queries"; const DISMISSED_STORAGE_KEY_PREFIX = "bb:update-toast:dismissed:"; +/** Set just before the post-update reload so the fresh page can announce it. */ +const UPDATED_SESSION_KEY = "bb:update-toast:completed"; interface VersionDismissalArgs { latestVersion: string; @@ -55,12 +63,65 @@ function markDismissedForVersion(args: VersionDismissalArgs): void { } } -function appUpdateDescription(latestVersion: string): string { - return `${latestVersion} is available. Restart bb-app to update.`; +/** + * Scheduling dismisses the toast programmatically, which also fires + * onDismiss and records a dismissal; clear it so cancelling the schedule + * brings the update offer back. + */ +function clearDismissedForVersion(args: VersionDismissalArgs): void { + const storage = getLocalStorage(); + if (storage === null) { + return; + } + try { + storage.removeItem(`${args.storageKeyPrefix}${args.latestVersion}`); + } catch { + // Ignore; worst case the toast stays hidden until reload. + } +} + +function availableToastTitle(latestVersion: string): string { + return `bb-app ${latestVersion} available`; +} + +/** + * Reload into the post-update frontend, but only once the just-restarted + * server actually serves the app shell — reloading mid-restart fails module + * fetches, which never retry and strand a blank page (index.html's boot + * watchdog is the second line of defense). + */ +async function reloadWhenShellServable(): Promise { + for (let attempt = 0; attempt < 15; attempt += 1) { + try { + const response = await fetch("/", { cache: "no-store" }); + if (response.ok) { + break; + } + } catch { + // Server still restarting; retry below. + } + await new Promise((resolve) => { + setTimeout(resolve, 1_000); + }); + } + window.location.reload(); } -function desktopReadyToastDescription(latestVersion: string): string { - return `bb desktop ${latestVersion} is ready to install.`; +function scheduledUpdateToastId(targetVersion: string): string { + return `bb-update-scheduled:${targetVersion}`; +} + +function scheduledUpdateTitle(scheduled: SystemSelfUpdateScheduled): string { + if (scheduled.mode === "now") { + return `Updating to ${scheduled.targetVersion} now…`; + } + return scheduled.phase === "staging" + ? `Preparing update to ${scheduled.targetVersion}…` + : `Updating to ${scheduled.targetVersion} when idle`; +} + +function desktopDeferredToastId(latestVersion: string): string { + return `bb-desktop-update-deferred:${latestVersion}`; } function relaunchDesktopUpdate(args: DesktopToastActionArgs): void { @@ -68,9 +129,56 @@ function relaunchDesktopUpdate(args: DesktopToastActionArgs): void { appToast.dismiss(`bb-desktop-update-ready:${args.latestVersion}`); } +function deferDesktopUpdate(args: DesktopToastActionArgs): void { + void args.desktopApi.installUpdateWhenIdle?.().catch(() => undefined); + appToast.dismiss(`bb-desktop-update-ready:${args.latestVersion}`); +} + +function cancelDeferredDesktopUpdate(args: DesktopToastActionArgs): void { + void args.desktopApi.cancelDeferredInstall?.().catch(() => undefined); + appToast.dismiss(desktopDeferredToastId(args.latestVersion)); +} + export function useUpdateAvailableToast(): void { const { data } = useSystemVersion(); + const scheduleSelfUpdate = useScheduleSelfUpdate(); + const cancelSelfUpdate = useCancelSelfUpdate(); + // Live agent load decides the action label: "Update now" when nothing is + // running, "Update when idle" otherwise. Only polled while the + // choice is actually on screen. + const activityEnabled = + getBbDesktopInfo() === null && + data !== undefined && + !data.isDevelopment && + data.updateAvailable && + data.selfUpdate.capable && + data.selfUpdate.scheduled === null; + const agentsBusy = useAgentsBusy({ enabled: activityEnabled }); const shownForVersionRef = useRef(null); + /** id of the scheduled-update toast currently on screen (version + phase). */ + const scheduledToastKeyRef = useRef(null); + /** Target of the schedule we last saw, to detect completion and failure. */ + const watchedTargetVersionRef = useRef(null); + const reportedErrorRef = useRef(null); + + const scheduleMutate = scheduleSelfUpdate.mutate; + const cancelMutate = cancelSelfUpdate.mutate; + + // Announce a completed update after the post-update reload, from the fresh + // bundle. Session storage survives the reload but not the tab. + useEffect(() => { + try { + const updatedVersion = sessionStorage.getItem(UPDATED_SESSION_KEY); + if (updatedVersion !== null) { + sessionStorage.removeItem(UPDATED_SESSION_KEY); + appToast.success("bb-app updated", { + description: `bb is now running ${updatedVersion}.`, + }); + } + } catch { + // Session storage may be unavailable; skip the announcement. + } + }, []); useEffect(() => { if (!data) { @@ -82,6 +190,75 @@ export function useUpdateAvailableToast(): void { if (data.isDevelopment) { return; } + + const { selfUpdate } = data; + const watchedTargetVersion = watchedTargetVersionRef.current; + + if (selfUpdate.scheduled !== null) { + const scheduled = selfUpdate.scheduled; + watchedTargetVersionRef.current = scheduled.targetVersion; + reportedErrorRef.current = null; + // The plain "update available" toast is superseded by the scheduled one. + if (data.latestVersion !== null) { + appToast.dismiss(`bb-update-available:${data.latestVersion}`); + } + // Allow the "update available" toast to come back if this schedule is + // cancelled later. + shownForVersionRef.current = null; + const toastKey = `${scheduled.targetVersion}:${scheduled.phase}:${scheduled.mode}`; + if (scheduledToastKeyRef.current === toastKey) { + return; + } + scheduledToastKeyRef.current = toastKey; + const toastId = scheduledUpdateToastId(scheduled.targetVersion); + appToast.message(scheduledUpdateTitle(scheduled), { + id: toastId, + duration: Infinity, + // X hides the toast; the update itself continues. + showCloseButton: true, + cancel: { + label: "Cancel update", + onClick: () => { + cancelMutate(); + appToast.dismiss(toastId); + }, + }, + }); + return; + } + + scheduledToastKeyRef.current = null; + + if (watchedTargetVersion !== null) { + appToast.dismiss(scheduledUpdateToastId(watchedTargetVersion)); + if (data.currentVersion === watchedTargetVersion) { + watchedTargetVersionRef.current = null; + // The page is still running the pre-update frontend bundle; reload to + // pick up the new one. The session flag re-announces after reload. + try { + sessionStorage.setItem(UPDATED_SESSION_KEY, data.currentVersion); + } catch { + // Reload anyway; only the announcement is lost. + } + void reloadWhenShellServable(); + return; + } + if ( + selfUpdate.lastError !== null && + reportedErrorRef.current !== selfUpdate.lastError + ) { + watchedTargetVersionRef.current = null; + reportedErrorRef.current = selfUpdate.lastError; + appToast.error("bb-app update failed", { + description: selfUpdate.lastError, + }); + return; + } + // Cancelled (here or from another client): fall through so the plain + // "update available" toast can show again. + watchedTargetVersionRef.current = null; + } + if (!data.updateAvailable) { return; } @@ -89,7 +266,12 @@ export function useUpdateAvailableToast(): void { if (latestVersion === null) { return; } - if (shownForVersionRef.current === latestVersion) { + // Keyed on busy state too, so the action label flips live when agents + // start or finish while the toast is up. + const availableKey = selfUpdate.capable + ? `${latestVersion}:${agentsBusy ? "busy" : "idle"}` + : latestVersion; + if (shownForVersionRef.current === availableKey) { return; } if ( @@ -98,24 +280,62 @@ export function useUpdateAvailableToast(): void { storageKeyPrefix: DISMISSED_STORAGE_KEY_PREFIX, }) ) { - shownForVersionRef.current = latestVersion; + shownForVersionRef.current = availableKey; return; } - shownForVersionRef.current = latestVersion; - appToast.message("bb-app update available", { - id: `bb-update-available:${latestVersion}`, - description: appUpdateDescription(latestVersion), - duration: Infinity, - cancel: { - label: "Dismiss", - onClick: () => { - markDismissedForVersion({ - latestVersion, - storageKeyPrefix: DISMISSED_STORAGE_KEY_PREFIX, + shownForVersionRef.current = availableKey; + const availableToastId = `bb-update-available:${latestVersion}`; + const scheduleUpdate = (mode: "when-idle" | "now"): void => { + scheduleMutate(mode, { + onError: (error) => { + appToast.error("Could not schedule the update", { + description: + error instanceof Error ? error.message : String(error), }); - appToast.dismiss(`bb-update-available:${latestVersion}`); }, - }, + }); + appToast.dismiss(availableToastId); + // Programmatic dismissal above also fires onDismiss; scheduling is not + // a dismissal, so undo the recorded one. + clearDismissedForVersion({ + latestVersion, + storageKeyPrefix: DISMISSED_STORAGE_KEY_PREFIX, + }); + }; + appToast.message(availableToastTitle(latestVersion), { + id: availableToastId, + duration: Infinity, + // The corner X is the dismissal; it routes through onDismiss below. + showCloseButton: true, + ...(selfUpdate.capable + ? agentsBusy + ? { + // Defer (safe default) or explicitly interrupt running agents. + action: { + label: "Update when idle", + onClick: () => { + scheduleUpdate("when-idle"); + }, + }, + cancel: { + label: "Update now", + onClick: () => { + scheduleUpdate("now"); + }, + }, + } + : { + // At rest, when-idle's fast path already applies immediately — + // and unlike mode "now" it still backs off if an agent starts + // during staging. + action: { + label: "Update now", + onClick: () => { + scheduleUpdate("when-idle"); + }, + }, + } + : { description: "Restart bb-app to update." }), onDismiss: () => { markDismissedForVersion({ latestVersion, @@ -123,12 +343,20 @@ export function useUpdateAvailableToast(): void { }); }, }); - }, [data]); + }, [data, agentsBusy, scheduleMutate, cancelMutate]); } export function useDesktopUpdateAvailableToast(): void { const [desktopApi, setDesktopApi] = useState(null); const [desktopInfo, setDesktopInfo] = useState(null); + // The SPA in the desktop shell talks to the shell-owned server, so the same + // activity endpoint decides "Relaunch now" vs the deferred option. + const activityEnabled = + desktopInfo !== null && + desktopInfo.updateDownloaded && + desktopInfo.deferredInstall !== true && + desktopInfo.canDeferInstall === true; + const agentsBusy = useAgentsBusy({ enabled: activityEnabled }); const shownForVersionRef = useRef(null); useEffect(() => { @@ -174,20 +402,69 @@ export function useDesktopUpdateAvailableToast(): void { if (latestVersion === null) { return; } - if (shownForVersionRef.current === latestVersion) { + + if (desktopInfo.deferredInstall === true) { + const deferredKey = `${latestVersion}:deferred`; + if (shownForVersionRef.current === deferredKey) { + return; + } + shownForVersionRef.current = deferredKey; + appToast.dismiss(`bb-desktop-update-ready:${latestVersion}`); + appToast.message(`Relaunching to ${latestVersion} when idle`, { + id: desktopDeferredToastId(latestVersion), + duration: Infinity, + // X hides the toast; the deferred relaunch itself continues. + showCloseButton: true, + cancel: { + label: "Cancel update", + onClick: () => { + cancelDeferredDesktopUpdate({ desktopApi, latestVersion }); + }, + }, + }); + return; + } + + // Only offer "Relaunch when idle" when the shell owns a local runtime a + // relaunch would interrupt, it's new enough to support deferral, and + // agents are actually working; at rest a plain relaunch loses nothing. + const canDefer = + desktopInfo.canDeferInstall === true && + typeof desktopApi.installUpdateWhenIdle === "function" && + agentsBusy; + const readyKey = `${latestVersion}:ready:${canDefer ? "busy" : "idle"}`; + if (shownForVersionRef.current === readyKey) { return; } - shownForVersionRef.current = latestVersion; - appToast.message("Desktop update ready", { + shownForVersionRef.current = readyKey; + appToast.dismiss(desktopDeferredToastId(latestVersion)); + appToast.message(`bb desktop ${latestVersion} ready`, { id: `bb-desktop-update-ready:${latestVersion}`, - description: desktopReadyToastDescription(latestVersion), duration: Infinity, - action: { - label: "Relaunch", - onClick: () => { - relaunchDesktopUpdate({ desktopApi, latestVersion }); - }, - }, + showCloseButton: true, + action: canDefer + ? { + label: "Relaunch when idle", + onClick: () => { + deferDesktopUpdate({ desktopApi, latestVersion }); + }, + } + : { + label: "Relaunch", + onClick: () => { + relaunchDesktopUpdate({ desktopApi, latestVersion }); + }, + }, + ...(canDefer + ? { + cancel: { + label: "Relaunch now", + onClick: () => { + relaunchDesktopUpdate({ desktopApi, latestVersion }); + }, + }, + } + : {}), }); - }, [desktopApi, desktopInfo]); + }, [desktopApi, desktopInfo, agentsBusy]); } diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts index f1709c82af..fec71aca83 100644 --- a/apps/app/src/lib/api.ts +++ b/apps/app/src/lib/api.ts @@ -45,9 +45,12 @@ import type { SendQueuedMessageResponse, SetQueuedMessageGroupBoundaryRequest, SendMessageRequest, + SystemAgentActivityResponse, SystemConfigResponse, SystemExecutionOptionsResponse, SystemProviderInfo, + SystemSelfUpdateMode, + SystemSelfUpdateState, SystemVersionResponse, TimelinePaginationCursor, SystemVoiceTranscriptionResponse, @@ -1745,6 +1748,28 @@ export async function getSystemVersion( ); } +export async function getSystemAgentActivity( + signal?: AbortSignal, +): Promise { + return request( + apiClient.system.agents.activity.$get({}, requestOptions(signal)), + ); +} + +export async function scheduleSelfUpdate( + mode: SystemSelfUpdateMode, +): Promise { + return request( + apiClient.system.update.schedule.$post({ json: { mode } }), + ); +} + +export async function cancelSelfUpdate(): Promise { + return request( + apiClient.system.update.schedule.$delete({}), + ); +} + export async function getSystemConfig( signal?: AbortSignal, ): Promise { diff --git a/apps/desktop/src/desktop-deferred-install.ts b/apps/desktop/src/desktop-deferred-install.ts new file mode 100644 index 0000000000..d6313bc795 --- /dev/null +++ b/apps/desktop/src/desktop-deferred-install.ts @@ -0,0 +1,210 @@ +import { z } from "zod"; +import { BB_UPDATE_QUIET_PERIOD_MS } from "@bb/config/self-update"; +import type { DesktopAutoUpdateLogger } from "./desktop-auto-update.js"; + +const DEFERRED_INSTALL_POLL_INTERVAL_MS = 5_000; +const ACTIVITY_FETCH_TIMEOUT_MS = 5_000; + +const agentActivityResponseSchema = z + .object({ + busyThreadCount: z.number().int().min(0), + // Absent on older servers; treated as 0. + queuedThreadCount: z.number().int().min(0).optional(), + }) + .passthrough(); + +export interface DeferredInstallProbe { + /** /system/agents/activity URL of the shell-owned runtime. */ + activityUrl: string; +} + +export interface CreateDeferredInstallControllerArgs { + /** + * Where to poll for agent activity, or null when the shell does not own a + * local runtime (attached/remote servers keep their agents across a shell + * relaunch, so deferral is meaningless there). + */ + getProbe: () => DeferredInstallProbe | null; + /** Whether a downloaded update is ready to install right now. */ + isUpdateDownloaded: () => boolean; + /** Performs the actual quit-and-install relaunch. */ + installUpdate: () => Promise | void; + logger: DesktopAutoUpdateLogger; + /** Overrides for tests. Production uses the defaults. */ + fetchImpl?: typeof fetch; + now?: () => number; + pollIntervalMs?: number; + quietPeriodMs?: number; +} + +export interface DeferredInstallController { + /** True when a deferred install can be offered (owned runtime present). */ + canDefer(): boolean; + cancel(): void; + /** True while a deferred install is pending. */ + isPending(): boolean; + /** Returns true when the deferral was accepted and polling started. */ + request(): boolean; + stop(): void; + subscribe(listener: () => void): () => void; +} + +export function createDeferredInstallController( + args: CreateDeferredInstallControllerArgs, +): DeferredInstallController { + const fetchImpl = args.fetchImpl ?? fetch; + const now = args.now ?? (() => Date.now()); + const pollIntervalMs = args.pollIntervalMs ?? DEFERRED_INSTALL_POLL_INTERVAL_MS; + const quietPeriodMs = args.quietPeriodMs ?? BB_UPDATE_QUIET_PERIOD_MS; + + let pending = false; + let idleSince: number | null = null; + let pollHandle: ReturnType | null = null; + let installing = false; + const listeners = new Set<() => void>(); + + function notify(): void { + for (const listener of listeners) { + listener(); + } + } + + function clear(): void { + if (pollHandle !== null) { + clearInterval(pollHandle); + pollHandle = null; + } + idleSince = null; + if (pending) { + pending = false; + notify(); + } + } + + async function fetchBusyWorkCount(activityUrl: string): Promise { + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + ACTIVITY_FETCH_TIMEOUT_MS, + ); + try { + const response = await fetchImpl(activityUrl, { + headers: { accept: "application/json" }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const activity = agentActivityResponseSchema.parse( + await response.json(), + ); + // Queued follow-ups the sweep is about to start count as busy: a + // relaunch now would orphan them. + return activity.busyThreadCount + (activity.queuedThreadCount ?? 0); + } finally { + clearTimeout(timeoutHandle); + } + } + + async function tick(): Promise { + if (!pending || installing) { + return; + } + if (!args.isUpdateDownloaded()) { + // The pending update went away (e.g. a newer check reset it); the + // deferral no longer has anything to install. + args.logger.info( + "Deferred desktop install cancelled: no downloaded update is pending.", + ); + clear(); + return; + } + const probe = args.getProbe(); + if (probe === null) { + args.logger.info( + "Deferred desktop install cancelled: no owned runtime to watch.", + ); + clear(); + return; + } + + let busyWorkCount: number; + try { + busyWorkCount = await fetchBusyWorkCount(probe.activityUrl); + } catch { + // Unreachable server counts as busy: don't relaunch on missing data. + idleSince = null; + return; + } + + if (busyWorkCount > 0) { + idleSince = null; + return; + } + if (idleSince === null) { + idleSince = now(); + return; + } + if (now() - idleSince < quietPeriodMs) { + return; + } + + installing = true; + args.logger.info( + "No agents running - relaunching to install the desktop update.", + ); + try { + await args.installUpdate(); + } catch (error) { + installing = false; + idleSince = null; + args.logger.error( + `Deferred desktop install failed to relaunch; will retry when idle: ${String(error)}`, + ); + } + } + + return { + canDefer(): boolean { + return args.getProbe() !== null; + }, + cancel(): void { + if (pending) { + args.logger.info("Deferred desktop install cancelled by user."); + } + clear(); + }, + isPending(): boolean { + return pending; + }, + request(): boolean { + if (!args.isUpdateDownloaded() || args.getProbe() === null) { + return false; + } + if (pending) { + return true; + } + pending = true; + idleSince = null; + pollHandle = setInterval(() => { + void tick(); + }, pollIntervalMs); + pollHandle.unref(); + void tick(); + notify(); + return true; + }, + stop(): void { + if (pollHandle !== null) { + clearInterval(pollHandle); + pollHandle = null; + } + }, + subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/apps/desktop/src/desktop-update-ipc.ts b/apps/desktop/src/desktop-update-ipc.ts index 900ce6f24f..78b576afd7 100644 --- a/apps/desktop/src/desktop-update-ipc.ts +++ b/apps/desktop/src/desktop-update-ipc.ts @@ -3,6 +3,10 @@ export const BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL = export const BB_DESKTOP_GET_INFO_CHANNEL = "bb-desktop:get-info"; export const BB_DESKTOP_INFO_CHANGED_CHANNEL = "bb-desktop:info-changed"; export const BB_DESKTOP_INSTALL_UPDATE_CHANNEL = "bb-desktop:install-update"; +export const BB_DESKTOP_INSTALL_UPDATE_WHEN_IDLE_CHANNEL = + "bb-desktop:install-update-when-idle"; +export const BB_DESKTOP_CANCEL_DEFERRED_INSTALL_CHANNEL = + "bb-desktop:cancel-deferred-install"; export const BB_DESKTOP_SET_THEME_CHANNEL = "bb-desktop:set-theme"; export const BB_DESKTOP_OPEN_EXTERNAL_URL_CHANNEL = "bb-desktop:open-external-url"; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3079656e82..0cf1708caf 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -84,10 +84,16 @@ import { type DesktopAutoUpdateService, } from "./desktop-auto-update.js"; import { + createDeferredInstallController, + type DeferredInstallController, +} from "./desktop-deferred-install.js"; +import { + BB_DESKTOP_CANCEL_DEFERRED_INSTALL_CHANNEL, BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL, BB_DESKTOP_GET_INFO_CHANNEL, BB_DESKTOP_INFO_CHANGED_CHANNEL, BB_DESKTOP_INSTALL_UPDATE_CHANNEL, + BB_DESKTOP_INSTALL_UPDATE_WHEN_IDLE_CHANNEL, BB_DESKTOP_OPEN_EXTERNAL_URL_CHANNEL, BB_DESKTOP_SET_THEME_CHANNEL, } from "./desktop-update-ipc.js"; @@ -273,6 +279,7 @@ let desktopWindowFactory: DesktopWindowFactory | null = null; let desktopBrowserViewManager: DesktopBrowserViewManager | null = null; let desktopUpdateService: DesktopUpdateService | null = null; let desktopAutoUpdateService: DesktopAutoUpdateService | null = null; +let deferredInstallController: DeferredInstallController | null = null; let currentRuntime: DesktopRuntime | null = null; let currentWindowUrl: string | null = null; let logViewerIpcHandlersInstalled = false; @@ -394,10 +401,18 @@ function mergeDesktopUpdateInfo( } function getCurrentDesktopInfo(): BbDesktopInfo | null { - return mergeDesktopUpdateInfo({ + const merged = mergeDesktopUpdateInfo({ autoInfo: desktopAutoUpdateService?.getInfo() ?? null, feedInfo: desktopUpdateService?.getInfo() ?? null, }); + if (merged === null) { + return null; + } + return { + ...merged, + canDeferInstall: deferredInstallController?.canDefer() ?? false, + deferredInstall: deferredInstallController?.isPending() ?? false, + }; } function sendDesktopInfoChanged(): void { @@ -1120,10 +1135,25 @@ function handleBeforeQuit(event: Event): void { }); } +async function quitAndInstallDesktopUpdate(): Promise { + if (desktopAutoUpdateService === null) { + return; + } + if (!desktopAutoUpdateService.getInfo().updateDownloaded) { + desktopAutoUpdateService.installUpdate(); + return; + } + quitting = true; + stoppingForQuit = true; + await finishQuit(); + desktopAutoUpdateService.installUpdate(); +} + async function finishQuit(): Promise { stopPopoutConfigSync(); desktopUpdateService?.stop(); desktopAutoUpdateService?.stop(); + deferredInstallController?.stop(); desktopBrowserViewManager?.destroyAll(); await desktopWindowFactory?.persistOpenWindows(); await stopOwnedRuntime(); @@ -1141,17 +1171,13 @@ function registerDesktopUpdateIpc(): void { return getCurrentDesktopInfo(); }); ipcMain.handle(BB_DESKTOP_INSTALL_UPDATE_CHANNEL, async () => { - if (desktopAutoUpdateService === null) { - return; - } - if (!desktopAutoUpdateService.getInfo().updateDownloaded) { - desktopAutoUpdateService.installUpdate(); - return; - } - quitting = true; - stoppingForQuit = true; - await finishQuit(); - desktopAutoUpdateService.installUpdate(); + await quitAndInstallDesktopUpdate(); + }); + ipcMain.handle(BB_DESKTOP_INSTALL_UPDATE_WHEN_IDLE_CHANNEL, () => { + deferredInstallController?.request(); + }); + ipcMain.handle(BB_DESKTOP_CANCEL_DEFERRED_INSTALL_CHANNEL, () => { + deferredInstallController?.cancel(); }); // Renderer pushes the resolved bb theme so the NSWindow appearance — // traffic lights and inactive title-bar chrome — follows bb's theme @@ -1593,12 +1619,34 @@ async function runDesktopApp(): Promise { logger: createDesktopLogger(), updater: createElectronAutoUpdaterAdapter(autoUpdater), }); + deferredInstallController = createDeferredInstallController({ + getProbe: () => { + // Only a shell-owned runtime dies with the shell; relaunching while + // attached to a remote server never interrupts agents. + if (currentRuntime?.ownership !== "spawned") { + return null; + } + return { + activityUrl: new URL( + "/api/v1/system/agents/activity", + currentRuntime.serverUrl, + ).toString(), + }; + }, + isUpdateDownloaded: () => + desktopAutoUpdateService?.getInfo().updateDownloaded ?? false, + installUpdate: () => quitAndInstallDesktopUpdate(), + logger: createDesktopLogger(), + }); desktopUpdateService.subscribe(() => { sendDesktopInfoChanged(); }); desktopAutoUpdateService.subscribe(() => { sendDesktopInfoChanged(); }); + deferredInstallController.subscribe(() => { + sendDesktopInfoChanged(); + }); registerDesktopUpdateIpc(); registerPopoutIpc(); desktopBrowserViewManager = createDesktopBrowserViewManager(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d32fe61f59..314f255df6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -26,10 +26,12 @@ import { type BbDesktopTheme, } from "@bb/desktop-contract"; import { + BB_DESKTOP_CANCEL_DEFERRED_INSTALL_CHANNEL, BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL, BB_DESKTOP_GET_INFO_CHANNEL, BB_DESKTOP_INFO_CHANGED_CHANNEL, BB_DESKTOP_INSTALL_UPDATE_CHANNEL, + BB_DESKTOP_INSTALL_UPDATE_WHEN_IDLE_CHANNEL, BB_DESKTOP_OPEN_EXTERNAL_URL_CHANNEL, BB_DESKTOP_SET_THEME_CHANNEL, } from "./desktop-update-ipc.js"; @@ -110,9 +112,9 @@ async function invokeDesktopInfo(channel: string): Promise { } } -async function invokeInstallUpdate(): Promise { +async function invokeVoidChannel(channel: string): Promise { try { - await ipcRenderer.invoke(BB_DESKTOP_INSTALL_UPDATE_CHANNEL); + await ipcRenderer.invoke(channel); } catch { return; } @@ -246,6 +248,12 @@ const bbDesktopApi: BbDesktopApi = { get updateDownloaded() { return currentInfo.updateDownloaded; }, + get canDeferInstall() { + return currentInfo.canDeferInstall; + }, + get deferredInstall() { + return currentInfo.deferredInstall; + }, version: currentInfo.version, checkForUpdates() { return invokeDesktopInfo(BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL); @@ -254,7 +262,13 @@ const bbDesktopApi: BbDesktopApi = { return invokeDesktopInfo(BB_DESKTOP_GET_INFO_CHANNEL); }, installUpdate() { - return invokeInstallUpdate(); + return invokeVoidChannel(BB_DESKTOP_INSTALL_UPDATE_CHANNEL); + }, + installUpdateWhenIdle() { + return invokeVoidChannel(BB_DESKTOP_INSTALL_UPDATE_WHEN_IDLE_CHANNEL); + }, + cancelDeferredInstall() { + return invokeVoidChannel(BB_DESKTOP_CANCEL_DEFERRED_INSTALL_CHANNEL); }, onChange(listener: BbDesktopInfoChangeHandler): BbDesktopInfoUnsubscribe { listeners.add(listener); diff --git a/apps/desktop/test/desktop-deferred-install.test.ts b/apps/desktop/test/desktop-deferred-install.test.ts new file mode 100644 index 0000000000..e810ded824 --- /dev/null +++ b/apps/desktop/test/desktop-deferred-install.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createDeferredInstallController, + type CreateDeferredInstallControllerArgs, +} from "../src/desktop-deferred-install.js"; + +const ACTIVITY_URL = "http://127.0.0.1:4990/api/v1/system/agents/activity"; + +const silentLogger = { + error(): void {}, + info(): void {}, + warn(): void {}, +}; + +function activityResponse(busyThreadCount: number): Response { + return new Response(JSON.stringify({ busyThreadCount }), { + headers: { "content-type": "application/json" }, + status: 200, + }); +} + +interface HarnessOverrides { + busyCounts?: () => number | Error; + hasProbe?: boolean; + updateDownloaded?: () => boolean; +} + +function createHarness(overrides: HarnessOverrides = {}) { + const installUpdate = vi.fn(() => Promise.resolve()); + const busyCounts = overrides.busyCounts ?? (() => 0); + const updateDownloaded = overrides.updateDownloaded ?? (() => true); + const fetchImpl = vi.fn(async () => { + const result = busyCounts(); + if (result instanceof Error) { + throw result; + } + return activityResponse(result); + }); + + const args: CreateDeferredInstallControllerArgs = { + getProbe: () => + (overrides.hasProbe ?? true) ? { activityUrl: ACTIVITY_URL } : null, + isUpdateDownloaded: updateDownloaded, + installUpdate, + logger: silentLogger, + fetchImpl: fetchImpl as unknown as typeof fetch, + pollIntervalMs: 5, + quietPeriodMs: 25, + }; + const controller = createDeferredInstallController(args); + return { controller, fetchImpl, installUpdate }; +} + +describe("desktop deferred install controller", () => { + it("refuses to defer without a downloaded update or an owned runtime", () => { + const noUpdate = createHarness({ updateDownloaded: () => false }); + expect(noUpdate.controller.request()).toBe(false); + expect(noUpdate.controller.isPending()).toBe(false); + + const noRuntime = createHarness({ hasProbe: false }); + expect(noRuntime.controller.canDefer()).toBe(false); + expect(noRuntime.controller.request()).toBe(false); + }); + + it("relaunches after agents stay idle for the quiet period", async () => { + const harness = createHarness(); + expect(harness.controller.request()).toBe(true); + expect(harness.controller.isPending()).toBe(true); + + await vi.waitFor(() => { + expect(harness.installUpdate).toHaveBeenCalledTimes(1); + }); + }); + + it("keeps waiting while agents are busy or the server is unreachable", async () => { + let busy: number | Error = 2; + const harness = createHarness({ busyCounts: () => busy }); + harness.controller.request(); + + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(harness.installUpdate).not.toHaveBeenCalled(); + + busy = new Error("server restarting"); + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(harness.installUpdate).not.toHaveBeenCalled(); + + busy = 0; + await vi.waitFor(() => { + expect(harness.installUpdate).toHaveBeenCalledTimes(1); + }); + harness.controller.stop(); + }); + + it("cancel clears the deferral and stops polling", async () => { + let busy = 1; + const harness = createHarness({ busyCounts: () => busy }); + const changes: boolean[] = []; + harness.controller.subscribe(() => { + changes.push(harness.controller.isPending()); + }); + + harness.controller.request(); + harness.controller.cancel(); + expect(harness.controller.isPending()).toBe(false); + expect(changes.at(-1)).toBe(false); + + busy = 0; + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(harness.installUpdate).not.toHaveBeenCalled(); + }); + + it("auto-cancels when the downloaded update goes away", async () => { + let downloaded = true; + const harness = createHarness({ + busyCounts: () => 1, + updateDownloaded: () => downloaded, + }); + harness.controller.request(); + + downloaded = false; + await vi.waitFor(() => { + expect(harness.controller.isPending()).toBe(false); + }); + expect(harness.installUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/routes/system.ts b/apps/server/src/routes/system.ts index 932a53e047..012fae3376 100644 --- a/apps/server/src/routes/system.ts +++ b/apps/server/src/routes/system.ts @@ -19,6 +19,10 @@ import { resolveVoiceTranscriptionEnabled, transcribeVoiceInput, } from "../services/ai/voice-transcription.js"; +import { + countBusyThreads, + countQueuedIdleThreads, +} from "../services/system/agent-activity.js"; import { listSystemProviderInfos, resolveSystemExecutionOptions, @@ -37,7 +41,7 @@ export function registerSystemRoutes( deps: ServerAppDeps, pluginService: PluginService, ): void { - const { get, post, put } = typedRoutes(app, { + const { del, get, post, put } = typedRoutes(app, { onValidationError: (msg) => new ApiError(400, "invalid_request", msg), }); const routes = publicApiRoutes.system; @@ -157,6 +161,24 @@ export function registerSystemRoutes( }); get(routes.version, async (context) => - context.json(await deps.appVersion.getSystemVersion()), + context.json({ + ...(await deps.appVersion.getSystemVersion()), + selfUpdate: deps.selfUpdate.getState(), + }), + ); + + post(routes.scheduleSelfUpdate, async (context, payload) => + context.json(await deps.selfUpdate.schedule(payload.mode)), + ); + + del(routes.cancelSelfUpdate, async (context) => + context.json(await deps.selfUpdate.cancel()), + ); + + get(routes.agentActivity, (context) => + context.json({ + busyThreadCount: countBusyThreads(deps.db), + queuedThreadCount: countQueuedIdleThreads(deps.db), + }), ); } diff --git a/apps/server/src/services/system/agent-activity.ts b/apps/server/src/services/system/agent-activity.ts new file mode 100644 index 0000000000..f509304c83 --- /dev/null +++ b/apps/server/src/services/system/agent-activity.ts @@ -0,0 +1,38 @@ +import { and, count, inArray, isNull } from "drizzle-orm"; +import { + listIdleThreadsWithQueuedMessages, + threads, + type DbConnection, + type DbQueryConnection, +} from "@bb/db"; + +/** Thread statuses that count as "an agent is running". */ +export const BUSY_THREAD_STATUSES = ["starting", "active", "stopping"] as const; + +/** + * Number of live threads an update-triggered restart would interrupt. Shared + * by the server's own update-when-idle watcher and the desktop shell's + * deferred-relaunch poller (via /system/agents/activity). + */ +export function countBusyThreads(db: DbQueryConnection): number { + const row = db + .select({ value: count() }) + .from(threads) + .where( + and( + inArray(threads.status, [...BUSY_THREAD_STATUSES]), + isNull(threads.deletedAt), + ), + ) + .get(); + return row?.value ?? 0; +} + +/** + * Idle threads holding queued messages that the 10s auto-send sweep is about + * to start: not busy yet, but a restart now would orphan that follow-up. The + * update watchers treat these as busy. + */ +export function countQueuedIdleThreads(db: DbConnection): number { + return listIdleThreadsWithQueuedMessages(db).length; +} diff --git a/apps/server/src/services/system/app-version.ts b/apps/server/src/services/system/app-version.ts index 2243f68459..0380adb59a 100644 --- a/apps/server/src/services/system/app-version.ts +++ b/apps/server/src/services/system/app-version.ts @@ -1,6 +1,6 @@ import semver from "semver"; import { z } from "zod"; -import type { SystemVersionResponse } from "@bb/server-contract"; +import type { SystemVersionInfo } from "@bb/server-contract"; import type { ServerLogger, ServerRuntimeConfig } from "../../types.js"; const NPM_LATEST_URL = "https://registry.npmjs.org/bb-app/latest"; @@ -15,7 +15,7 @@ const npmLatestResponseSchema = z .passthrough(); export interface AppVersionService { - getSystemVersion(): Promise; + getSystemVersion(): Promise; } export interface CreateAppVersionServiceArgs { @@ -116,8 +116,8 @@ export function createAppVersionService( } return { - async getSystemVersion(): Promise { - const baseResponse: SystemVersionResponse = { + async getSystemVersion(): Promise { + const baseResponse: SystemVersionInfo = { currentVersion: config.appVersion, latestVersion: null, source: "npm", diff --git a/apps/server/src/services/system/self-update.ts b/apps/server/src/services/system/self-update.ts new file mode 100644 index 0000000000..2724e15942 --- /dev/null +++ b/apps/server/src/services/system/self-update.ts @@ -0,0 +1,523 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import semver from "semver"; +import { z } from "zod"; +import { + BB_SELF_UPDATE_EXIT_CODE, + BB_UPDATE_QUIET_PERIOD_MS, + formatSelfUpdateSentinelPath, + formatSelfUpdateStagingDir, + formatSelfUpdateStagingRoot, + formatStagedPackageRoot, + selfUpdateSentinelSchema, + type SelfUpdateSentinel, +} from "@bb/config/self-update"; +import type { DbConnection } from "@bb/db"; +import type { + SystemSelfUpdateMode, + SystemSelfUpdateScheduled, + SystemSelfUpdateState, +} from "@bb/server-contract"; +import { ApiError } from "../../errors.js"; +import type { ServerLogger, ServerRuntimeConfig } from "../../types.js"; +import { countBusyThreads, countQueuedIdleThreads } from "./agent-activity.js"; +import type { AppVersionService } from "./app-version.js"; + +const DEFAULT_POLL_INTERVAL_MS = 5_000; +// Shared with the desktop shell's deferred relaunch; see the constant's doc. +const DEFAULT_QUIET_PERIOD_MS = BB_UPDATE_QUIET_PERIOD_MS; +const STAGING_INSTALL_TIMEOUT_MS = 10 * 60 * 1000; +const INSTALL_LOG_TAIL_CHARS = 2_000; + +const stagedPackageJsonSchema = z + .object({ version: z.string().min(1) }) + .passthrough(); + +export interface StagingInstallArgs { + packageSpec: string; + prefixDir: string; +} + +export type RunStagingInstallFn = (args: StagingInstallArgs) => Promise; + +export interface SelfUpdateService { + getState(): SystemSelfUpdateState; + schedule(mode: SystemSelfUpdateMode): Promise; + cancel(): Promise; + /** Adopt a sentinel left by a previous run and clean stale staged installs. */ + resume(): Promise; + stop(): void; +} + +export interface CreateSelfUpdateServiceArgs { + appVersion: AppVersionService; + config: Pick< + ServerRuntimeConfig, + "appVersion" | "dataDir" | "isDevelopment" | "selfUpdateProtocol" + >; + db: DbConnection; + logger: ServerLogger; + /** Runs the graceful server shutdown before the process exits for the swap. */ + prepareShutdown: () => Promise; + /** Overrides for tests. Production uses the defaults. */ + exitProcess?: (code: number) => void; + now?: () => number; + pollIntervalMs?: number; + quietPeriodMs?: number; + runStagingInstall?: RunStagingInstallFn; +} + +export function resolveNpmInvocation(env: NodeJS.ProcessEnv): { + command: string; + argsPrefix: string[]; +} { + // Only honor npm_execpath when it is actually npm: when bb was launched + // from a pnpm/yarn-managed process the variable points at that tool, which + // rejects the npm flags we pass. + const npmExecPath = env.npm_execpath; + if ( + npmExecPath !== undefined && + /(^|\/)npm(-cli)?\.(c|m)?js$/.test(npmExecPath) + ) { + return { command: process.execPath, argsPrefix: [npmExecPath] }; + } + return { command: "npm", argsPrefix: [] }; +} + +async function runNpmStagingInstall(args: StagingInstallArgs): Promise { + const { command, argsPrefix } = resolveNpmInvocation(process.env); + const installArgs = [ + ...argsPrefix, + "install", + args.packageSpec, + "--prefix", + args.prefixDir, + "--no-audit", + "--no-fund", + "--loglevel", + "error", + ]; + + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, installArgs, { + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + let outputTail = ""; + const collect = (chunk: Buffer): void => { + outputTail = (outputTail + chunk.toString("utf8")).slice( + -INSTALL_LOG_TAIL_CHARS, + ); + }; + child.stdout?.on("data", collect); + child.stderr?.on("data", collect); + + const timeoutHandle = setTimeout(() => { + child.kill("SIGKILL"); + }, STAGING_INSTALL_TIMEOUT_MS); + timeoutHandle.unref(); + + child.once("error", (error) => { + clearTimeout(timeoutHandle); + rejectPromise( + new Error(`Failed to run ${command} for staging install: ${error.message}`), + ); + }); + child.once("close", (code, signal) => { + clearTimeout(timeoutHandle); + if (code === 0) { + resolvePromise(); + return; + } + const exitDescription = + signal !== null ? `signal ${signal}` : `exit code ${code}`; + rejectPromise( + new Error( + `npm install ${args.packageSpec} failed (${exitDescription})${ + outputTail.trim().length > 0 ? `: ${outputTail.trim()}` : "" + }`, + ), + ); + }); + }); +} + +async function readStagedPackageVersion( + stagedPackageRoot: string, +): Promise { + const rawContents = await readFile( + join(stagedPackageRoot, "package.json"), + "utf8", + ); + return stagedPackageJsonSchema.parse(JSON.parse(rawContents)).version; +} + +async function writeSentinelFile( + dataDir: string, + sentinel: SelfUpdateSentinel, +): Promise { + const sentinelPath = formatSelfUpdateSentinelPath(dataDir); + const tempPath = join( + dataDir, + `.self-update.json.${process.pid}.${randomUUID()}.tmp`, + ); + try { + await writeFile(tempPath, `${JSON.stringify(sentinel, null, 2)}\n`, "utf8"); + await rename(tempPath, sentinelPath); + } catch (error) { + await rm(tempPath, { force: true }).catch(() => undefined); + throw error; + } +} + +async function readSentinelFile( + dataDir: string, +): Promise { + try { + const rawContents = await readFile( + formatSelfUpdateSentinelPath(dataDir), + "utf8", + ); + return selfUpdateSentinelSchema.parse(JSON.parse(rawContents)); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return null; + } + throw error; + } +} + +export function createSelfUpdateService( + args: CreateSelfUpdateServiceArgs, +): SelfUpdateService { + const { appVersion, config, db, logger } = args; + const capable = config.selfUpdateProtocol && !config.isDevelopment; + const exitProcess = args.exitProcess ?? ((code: number) => process.exit(code)); + const now = args.now ?? (() => Date.now()); + const pollIntervalMs = args.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const quietPeriodMs = args.quietPeriodMs ?? DEFAULT_QUIET_PERIOD_MS; + const runStagingInstall = args.runStagingInstall ?? runNpmStagingInstall; + + let scheduled: SystemSelfUpdateScheduled | null = null; + let lastError: string | null = null; + let idleSince: number | null = null; + // Set once any busy agent is seen after arming; from then on the full + // quiet period applies (mid-chain idle gaps look like rest otherwise). + let busyObservedSinceArm = false; + let watcherHandle: ReturnType | null = null; + let exiting = false; + // Bumped on cancel/re-schedule so an in-flight staging install of a stale + // schedule can't adopt state when it eventually finishes. + let scheduleGeneration = 0; + + function getState(): SystemSelfUpdateState { + return { capable, scheduled, lastError }; + } + + function hasBusyThreads(): boolean { + return countBusyThreads(db) > 0; + } + + function stopWatcher(): void { + if (watcherHandle !== null) { + clearInterval(watcherHandle); + watcherHandle = null; + } + idleSince = null; + } + + function startWatcher(): void { + if (watcherHandle !== null) { + return; + } + idleSince = null; + busyObservedSinceArm = false; + watcherHandle = setInterval(() => { + try { + watcherTick(); + } catch (error) { + logger.warn({ err: error }, "Self-update idle check failed"); + idleSince = null; + } + }, pollIntervalMs); + watcherHandle.unref(); + // Check immediately so an already-idle server doesn't wait a full poll + // interval before the quiet period starts counting. + watcherTick(); + } + + function watcherTick(): void { + if (scheduled === null || scheduled.phase !== "waiting" || exiting) { + stopWatcher(); + return; + } + if (hasBusyThreads()) { + busyObservedSinceArm = true; + idleSince = null; + return; + } + // A queued follow-up the auto-send sweep is about to start counts as + // busy: a restart now would orphan it. Checking it directly is what lets + // the quiet period stay short. + if (countQueuedIdleThreads(db) > 0) { + idleSince = null; + return; + } + // At rest with no busy agent seen since arming ("Update now" while + // idle): nothing to protect, skip the quiet period entirely. + if (!busyObservedSinceArm) { + triggerExitForSwap(); + return; + } + if (idleSince === null) { + idleSince = now(); + return; + } + if (now() - idleSince < quietPeriodMs) { + return; + } + triggerExitForSwap(); + } + + function triggerExitForSwap(): void { + if (exiting || scheduled === null) { + return; + } + exiting = true; + stopWatcher(); + logger.info( + { targetVersion: scheduled.targetVersion }, + "No agents running - exiting so the launcher can apply the staged update", + ); + void args + .prepareShutdown() + .catch((error) => { + logger.warn({ err: error }, "Graceful shutdown before update failed"); + }) + .finally(() => { + exitProcess(BB_SELF_UPDATE_EXIT_CODE); + }); + } + + async function removeStagedVersion(targetVersion: string): Promise { + await rm(formatSelfUpdateStagingDir(config.dataDir, targetVersion), { + force: true, + recursive: true, + }).catch(() => undefined); + } + + /** + * Remove staged installs for versions that are neither the running version + * (the server may have been started from that staged root by the launcher) + * nor a pending schedule target. + */ + async function cleanupStaleStaging(): Promise { + const stagingRoot = formatSelfUpdateStagingRoot(config.dataDir); + let entries: string[]; + try { + entries = await readdir(stagingRoot); + } catch { + return; + } + for (const entry of entries) { + if (entry === config.appVersion || entry === scheduled?.targetVersion) { + continue; + } + await rm(join(stagingRoot, entry), { + force: true, + recursive: true, + }).catch(() => undefined); + } + } + + async function stageAndArm( + targetVersion: string, + requestedAt: string, + generation: number, + ): Promise { + const stagingDir = formatSelfUpdateStagingDir(config.dataDir, targetVersion); + const stagedPackageRoot = formatStagedPackageRoot( + config.dataDir, + targetVersion, + ); + try { + await mkdir(stagingDir, { recursive: true }); + await runStagingInstall({ + packageSpec: `bb-app@${targetVersion}`, + prefixDir: stagingDir, + }); + const stagedVersion = await readStagedPackageVersion(stagedPackageRoot); + if (stagedVersion !== targetVersion) { + throw new Error( + `Staged bb-app version ${stagedVersion} does not match requested ${targetVersion}`, + ); + } + if (generation !== scheduleGeneration) { + // Cancelled (or re-scheduled) while installing; the staging dir was + // already queued for removal by cancel(). + return; + } + await writeSentinelFile(config.dataDir, { + targetVersion, + stagedPackageRoot, + requestedAt, + }); + // Re-read the mode: an "Update now" click may have escalated the + // schedule while npm was installing. + const mode = scheduled?.mode ?? "when-idle"; + scheduled = { targetVersion, phase: "waiting", mode }; + if (mode === "now") { + logger.info( + { targetVersion }, + "Self-update staged - applying immediately at user request", + ); + triggerExitForSwap(); + return; + } + logger.info( + { targetVersion }, + "Self-update staged - waiting for agents to finish", + ); + startWatcher(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn( + { err: error, targetVersion }, + "Failed to stage self-update", + ); + if (generation === scheduleGeneration) { + // Stale generations must not touch the dir: a newer schedule for the + // same version may be staging into it right now. cancel()/boot + // cleanup remove their leftovers. + await removeStagedVersion(targetVersion); + scheduled = null; + lastError = message; + } + } + } + + async function schedule( + mode: SystemSelfUpdateMode, + ): Promise { + if (!capable) { + throw new ApiError( + 409, + "self_update_unavailable", + "This server is not managed by a bb-app launcher that supports scheduled updates. Restart bb-app manually to update.", + ); + } + const versionInfo = await appVersion.getSystemVersion(); + if (!versionInfo.updateAvailable || versionInfo.latestVersion === null) { + throw new ApiError(409, "no_update_available", "bb is already up to date."); + } + const targetVersion = versionInfo.latestVersion; + if (scheduled !== null && scheduled.targetVersion === targetVersion) { + // "Update now" escalates a pending schedule; the reverse never + // downgrades an explicit immediate request. + if (mode === "now" && scheduled.mode === "when-idle") { + scheduled = { ...scheduled, mode: "now" }; + if (scheduled.phase === "waiting") { + logger.info( + { targetVersion }, + "Applying scheduled self-update immediately at user request", + ); + triggerExitForSwap(); + } + } + return getState(); + } + if (scheduled !== null) { + await clearSchedule(); + } + + lastError = null; + // Only persisted in the sentinel, as provenance for data-dir debugging. + const requestedAt = new Date(now()).toISOString(); + scheduled = { targetVersion, phase: "staging", mode }; + const generation = scheduleGeneration; + void stageAndArm(targetVersion, requestedAt, generation); + return getState(); + } + + async function clearSchedule(): Promise { + const previous = scheduled; + scheduled = null; + scheduleGeneration += 1; + stopWatcher(); + await rm(formatSelfUpdateSentinelPath(config.dataDir), { + force: true, + }).catch(() => undefined); + if (previous !== null) { + await removeStagedVersion(previous.targetVersion); + } + } + + async function cancel(): Promise { + if (scheduled !== null) { + logger.info( + { targetVersion: scheduled.targetVersion }, + "Cancelled scheduled self-update", + ); + await clearSchedule(); + } + return getState(); + } + + async function resume(): Promise { + if (!capable) { + return; + } + let sentinel: SelfUpdateSentinel | null = null; + try { + sentinel = await readSentinelFile(config.dataDir); + } catch (error) { + logger.warn({ err: error }, "Ignoring unreadable self-update sentinel"); + await rm(formatSelfUpdateSentinelPath(config.dataDir), { + force: true, + }).catch(() => undefined); + } + + if (sentinel !== null) { + const targetIsNewer = + semver.valid(sentinel.targetVersion) !== null && + semver.valid(config.appVersion) !== null && + semver.gt(sentinel.targetVersion, config.appVersion); + let stagedVersionMatches = false; + if (targetIsNewer) { + stagedVersionMatches = await readStagedPackageVersion( + sentinel.stagedPackageRoot, + ) + .then((version) => version === sentinel.targetVersion) + .catch(() => false); + } + if (targetIsNewer && stagedVersionMatches) { + // The sentinel does not persist the mode; resume conservatively as + // when-idle rather than surprise-restarting agents after a crash. + scheduled = { + targetVersion: sentinel.targetVersion, + phase: "waiting", + mode: "when-idle", + }; + logger.info( + { targetVersion: sentinel.targetVersion }, + "Resuming scheduled self-update from previous run", + ); + startWatcher(); + } else { + await rm(formatSelfUpdateSentinelPath(config.dataDir), { + force: true, + }).catch(() => undefined); + } + } + + await cleanupStaleStaging(); + } + + function stop(): void { + stopWatcher(); + } + + return { getState, schedule, cancel, resume, stop }; +} diff --git a/apps/server/src/start-server.ts b/apps/server/src/start-server.ts index cdfa200e68..fe4cd82fef 100644 --- a/apps/server/src/start-server.ts +++ b/apps/server/src/start-server.ts @@ -13,6 +13,7 @@ import { createMachineAuthService } from "./services/machine-auth.js"; import { resolveBuiltinSkillsRootPath } from "./services/skills/builtin-skills-copy.js"; import { createAppVersionService } from "./services/system/app-version.js"; import { createBbAppManagedConfigReloader } from "./services/system/bb-app-managed-config.js"; +import { createSelfUpdateService } from "./services/system/self-update.js"; import { startEventLoopStallMonitor } from "./services/system/event-loop-stall-monitor.js"; import { runPeriodicSweeps, @@ -175,6 +176,7 @@ export async function runServer(serverConfig: ServerConfig): Promise { inferenceModel: serverConfig.BB_INFERENCE, isDevelopment: !isProduction, openAiApiKey: serverConfig.OPENAI_API_KEY, + selfUpdateProtocol: serverConfig.BB_SELF_UPDATE_PROTOCOL, serverPort: serverConfig.BB_SERVER_PORT, threadStorageRootPath, transcriptionModel: serverConfig.BB_TRANSCRIPTION, @@ -235,6 +237,17 @@ export async function runServer(serverConfig: ServerConfig): Promise { logger, }); + // Late-bound so the self-update exit path can run the same graceful + // shutdown as SIGTERM; runShutdown is defined after the listener exists. + const selfUpdateShutdown = { run: (): Promise => Promise.resolve() }; + const selfUpdate = createSelfUpdateService({ + appVersion, + config: runtimeConfig, + db, + logger, + prepareShutdown: () => selfUpdateShutdown.run(), + }); + const { app, closeWebSockets, injectWebSocket, pluginService } = createApp( { appVersion, @@ -246,6 +259,7 @@ export async function runServer(serverConfig: ServerConfig): Promise { logger, machineAuth, pendingInteractions, + selfUpdate, telemetry, terminalSessions, watchInterests, @@ -313,6 +327,7 @@ export async function runServer(serverConfig: ServerConfig): Promise { shutdownPromise = (async () => { eventLoopStallMonitor.stop(); clearInterval(sweepInterval); + selfUpdate.stop(); await pluginService.stop().catch((error: unknown) => { logger.warn({ err: error }, "Plugin shutdown failed"); }); @@ -337,4 +352,10 @@ export async function runServer(serverConfig: ServerConfig): Promise { process.once("SIGTERM", () => { void runShutdown().finally(() => process.exit(0)); }); + + selfUpdateShutdown.run = runShutdown; + // Adopt a schedule that survived a restart and prune stale staged installs. + void selfUpdate.resume().catch((error) => { + logger.warn({ err: error }, "Self-update resume failed"); + }); } diff --git a/apps/server/src/types.ts b/apps/server/src/types.ts index 7fd3a1ae12..18e217484d 100644 --- a/apps/server/src/types.ts +++ b/apps/server/src/types.ts @@ -10,6 +10,7 @@ import type { PendingInteractionLifecycle } from "./services/interactions/pendin import type { MachineAuthService } from "./services/machine-auth.js"; import type { AppVersionService } from "./services/system/app-version.js"; import type { BbAppManagedConfigReloader } from "./services/system/bb-app-managed-config.js"; +import type { SelfUpdateService } from "./services/system/self-update.js"; import type { TelemetryService } from "./services/system/telemetry.js"; import type { TerminalSessionLifecycle } from "./services/terminals/terminal-session-lifecycle.js"; import type { LifecycleDedupers } from "./lifecycle-dedupers.js"; @@ -31,6 +32,8 @@ export interface ServerRuntimeConfig { inferenceModel: string; isDevelopment: boolean; openAiApiKey: string; + /** True when the parent bb-app launcher supports the self-update swap. */ + selfUpdateProtocol: boolean; serverPort: number; threadStorageRootPath: string; transcriptionModel: string; @@ -54,6 +57,7 @@ export interface AppDeps { export interface ServerAppDeps extends AppDeps { appVersion: AppVersionService; bbAppManagedConfig: BbAppManagedConfigReloader; + selfUpdate: SelfUpdateService; } export type LifecycleDeps = Pick< diff --git a/apps/server/test/helpers/test-app.ts b/apps/server/test/helpers/test-app.ts index 57d8f4ee6e..95e8b854ec 100644 --- a/apps/server/test/helpers/test-app.ts +++ b/apps/server/test/helpers/test-app.ts @@ -14,6 +14,7 @@ import { type AppVersionService, } from "../../src/services/system/app-version.js"; import { createBbAppManagedConfigReloader } from "../../src/services/system/bb-app-managed-config.js"; +import { createSelfUpdateService } from "../../src/services/system/self-update.js"; import { createNoopTelemetryService } from "../../src/services/system/telemetry.js"; import { TerminalSessionLifecycle } from "../../src/services/terminals/terminal-session-lifecycle.js"; import { resolveThreadStorageRootPath } from "../../src/services/threads/thread-storage.js"; @@ -129,6 +130,7 @@ export async function createTestAppHarness( inferenceModel: "test/mock-model", isDevelopment: true, openAiApiKey: "test-openai-key", + selfUpdateProtocol: false, serverPort: 3334, threadStorageRootPath: resolveThreadStorageRootPath({ dataDir, @@ -169,6 +171,13 @@ export async function createTestAppHarness( config, logger: testLogger, }); + const selfUpdate = createSelfUpdateService({ + appVersion, + config, + db, + logger: testLogger, + prepareShutdown: () => Promise.resolve(), + }); const deps: ServerAppDeps = { appVersion, bbAppManagedConfig, @@ -179,6 +188,7 @@ export async function createTestAppHarness( logger: testLogger, machineAuth: testMachineAuth, pendingInteractions, + selfUpdate, telemetry, terminalSessions, watchInterests, diff --git a/apps/server/test/public/public-system-version.test.ts b/apps/server/test/public/public-system-version.test.ts index e06109b9a0..72acf1b389 100644 --- a/apps/server/test/public/public-system-version.test.ts +++ b/apps/server/test/public/public-system-version.test.ts @@ -1,19 +1,22 @@ import { describe, expect, it } from "vitest"; -import type { SystemVersionResponse } from "@bb/server-contract"; +import type { + SystemVersionInfo, + SystemVersionResponse, +} from "@bb/server-contract"; import { readJson } from "../helpers/json.js"; import { withTestHarness } from "../helpers/test-app.js"; -function createStubAppVersionService(response: SystemVersionResponse) { +function createStubAppVersionService(response: SystemVersionInfo) { return { - async getSystemVersion(): Promise { + async getSystemVersion(): Promise { return response; }, }; } describe("GET /api/v1/system/version", () => { - it("returns the response from the app-version service", async () => { - const expected: SystemVersionResponse = { + it("returns the app-version info plus the self-update state", async () => { + const versionInfo: SystemVersionInfo = { currentVersion: "0.0.5", latestVersion: "0.0.6", source: "npm", @@ -23,12 +26,15 @@ describe("GET /api/v1/system/version", () => { }; await withTestHarness({ appVersion: "0.0.5", - appVersionService: createStubAppVersionService(expected), + appVersionService: createStubAppVersionService(versionInfo), isDevelopment: false, }, async (harness) => { const response = await harness.app.request("/api/v1/system/version"); expect(response.status).toBe(200); - await expect(readJson(response)).resolves.toEqual(expected); + await expect(readJson(response)).resolves.toEqual({ + ...versionInfo, + selfUpdate: { capable: false, scheduled: null, lastError: null }, + }); }); }); @@ -51,6 +57,7 @@ describe("GET /api/v1/system/version", () => { expect(body.isDevelopment).toBe(true); expect(body.updateAvailable).toBe(false); expect(body.latestVersion).toBeNull(); + expect(body.selfUpdate.capable).toBe(false); }); }); }); diff --git a/apps/server/test/system/agent-activity.test.ts b/apps/server/test/system/agent-activity.test.ts new file mode 100644 index 0000000000..319a102079 --- /dev/null +++ b/apps/server/test/system/agent-activity.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { projects, threads } from "@bb/db"; +import type { ThreadStatus } from "@bb/domain"; +import { readJson } from "../helpers/json.js"; +import { withTestHarness } from "../helpers/test-app.js"; + +describe("GET /api/v1/system/agents/activity", () => { + it("counts only live busy threads", async () => { + await withTestHarness({}, async (harness) => { + const now = Date.now(); + harness.db + .insert(projects) + .values({ id: "proj_1", name: "Test", createdAt: now, updatedAt: now }) + .run(); + const thread = ( + id: string, + status: ThreadStatus, + deletedAt: number | null, + ) => + harness.db + .insert(threads) + .values({ + id, + projectId: "proj_1", + providerId: "test-provider", + status, + deletedAt, + latestAttentionAt: now, + createdAt: now, + updatedAt: now, + }) + .run(); + thread("thr_active", "active", null); + thread("thr_starting", "starting", null); + thread("thr_stopping", "stopping", null); + thread("thr_idle", "idle", null); + thread("thr_error", "error", null); + thread("thr_deleted", "active", now); + + const response = await harness.app.request( + "/api/v1/system/agents/activity", + ); + expect(response.status).toBe(200); + await expect(readJson(response)).resolves.toEqual({ + busyThreadCount: 3, + queuedThreadCount: 0, + }); + }); + }); +}); diff --git a/apps/server/test/system/bb-app-managed-config.test.ts b/apps/server/test/system/bb-app-managed-config.test.ts index c351056c42..b87af78487 100644 --- a/apps/server/test/system/bb-app-managed-config.test.ts +++ b/apps/server/test/system/bb-app-managed-config.test.ts @@ -73,6 +73,7 @@ function createRuntimeConfig(): ServerRuntimeConfig { inferenceModel: "openai/gpt-4o-mini", isDevelopment: false, openAiApiKey: "ambient-openai-key", + selfUpdateProtocol: false, serverPort: 38886, threadStorageRootPath: "/tmp/bb-test/thread-storage", transcriptionModel: "openai/gpt-4o-transcribe", diff --git a/apps/server/test/system/self-update.test.ts b/apps/server/test/system/self-update.test.ts new file mode 100644 index 0000000000..589d3e4b96 --- /dev/null +++ b/apps/server/test/system/self-update.test.ts @@ -0,0 +1,448 @@ +import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + BB_SELF_UPDATE_EXIT_CODE, + formatSelfUpdateSentinelPath, + formatSelfUpdateStagingDir, + formatStagedPackageRoot, +} from "@bb/config/self-update"; +import { + environments, + hosts, + projects, + queuedThreadMessages, + threads, +} from "@bb/db"; +import type { ThreadStatus } from "@bb/domain"; +import type { SystemVersionInfo } from "@bb/server-contract"; +import { initDb } from "../../src/db.js"; +import { ApiError } from "../../src/errors.js"; +import { + createSelfUpdateService, + type CreateSelfUpdateServiceArgs, + type StagingInstallArgs, +} from "../../src/services/system/self-update.js"; +import { testLogger } from "../helpers/test-app.js"; + +const CURRENT_VERSION = "0.0.10"; +const TARGET_VERSION = "0.0.11"; + +interface HarnessOverrides { + latestVersion?: string | null; + quietPeriodMs?: number; + selfUpdateProtocol?: boolean; + runStagingInstall?: (args: StagingInstallArgs) => Promise; +} + +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + while (cleanups.length > 0) { + await cleanups.pop()?.(); + } +}); + +function createStubAppVersionService(latestVersion: string | null) { + return { + async getSystemVersion(): Promise { + return { + currentVersion: CURRENT_VERSION, + latestVersion, + source: "npm", + updateAvailable: + latestVersion !== null && latestVersion !== CURRENT_VERSION, + isDevelopment: false, + upgradeCommand: "npx bb-app@latest", + }; + }, + }; +} + +async function writeStagedPackage( + dataDir: string, + version: string, +): Promise { + const stagedPackageRoot = formatStagedPackageRoot(dataDir, version); + await mkdir(stagedPackageRoot, { recursive: true }); + await writeFile( + join(stagedPackageRoot, "package.json"), + JSON.stringify({ name: "bb-app", version }), + "utf8", + ); +} + +async function createHarness(overrides: HarnessOverrides = {}) { + const dataDir = await mkdtemp(join(tmpdir(), "bb-self-update-test-")); + cleanups.push(() => rm(dataDir, { force: true, recursive: true })); + const db = initDb(":memory:"); + const now = Date.now(); + db.insert(projects) + .values({ id: "proj_1", name: "Test", createdAt: now, updatedAt: now }) + .run(); + + const exitProcess = vi.fn(); + const prepareShutdown = vi.fn(() => Promise.resolve()); + const runStagingInstall = + overrides.runStagingInstall ?? + (async (args: StagingInstallArgs) => { + const version = args.packageSpec.split("@")[1]; + await writeStagedPackage(dataDir, version); + }); + + const serviceArgs: CreateSelfUpdateServiceArgs = { + appVersion: createStubAppVersionService( + overrides.latestVersion === undefined + ? TARGET_VERSION + : overrides.latestVersion, + ), + config: { + appVersion: CURRENT_VERSION, + dataDir, + isDevelopment: false, + selfUpdateProtocol: overrides.selfUpdateProtocol ?? true, + }, + db, + logger: testLogger, + prepareShutdown, + exitProcess, + pollIntervalMs: 5, + quietPeriodMs: overrides.quietPeriodMs ?? 25, + runStagingInstall, + }; + const service = createSelfUpdateService(serviceArgs); + cleanups.push(async () => service.stop()); + + function insertThread( + id: string, + status: ThreadStatus, + environmentId?: string, + ): void { + db.insert(threads) + .values({ + id, + projectId: "proj_1", + providerId: "test-provider", + status, + environmentId: environmentId ?? null, + latestAttentionAt: now, + createdAt: now, + updatedAt: now, + }) + .run(); + } + + /** An idle thread holding a queued follow-up the auto-send sweep would start. */ + function insertIdleThreadWithQueuedMessage(threadId: string): void { + db.insert(hosts) + .values({ + id: "host_1", + name: "Test Host", + type: "persistent", + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing() + .run(); + db.insert(environments) + .values({ + id: `env_${threadId}`, + projectId: "proj_1", + hostId: "host_1", + workspaceProvisionType: "unmanaged", + createdAt: now, + updatedAt: now, + }) + .run(); + insertThread(threadId, "idle", `env_${threadId}`); + db.insert(queuedThreadMessages) + .values({ + id: `qmsg_${threadId}`, + threadId, + content: "queued follow-up", + model: "test/mock-model", + reasoningLevel: "medium", + permissionMode: "workspace-write", + serviceTier: "standard", + sortKey: "V", + createdAt: now, + updatedAt: now, + }) + .run(); + } + + function setThreadStatus(id: string, status: ThreadStatus): void { + db.update(threads).set({ status }).where(eq(threads.id, id)).run(); + } + + return { + dataDir, + db, + exitProcess, + insertIdleThreadWithQueuedMessage, + insertThread, + prepareShutdown, + service, + serviceArgs, + setThreadStatus, + }; +} + +async function waitForWaitingPhase( + service: Awaited>["service"], +): Promise { + await vi.waitFor(() => { + expect(service.getState().scheduled?.phase).toBe("waiting"); + }); +} + +describe("self-update service", () => { + it("stages, writes the sentinel, and exits immediately when nothing is running or queued", async () => { + // A quiet period far longer than the test proves the at-rest fast path. + const harness = await createHarness({ quietPeriodMs: 60_000 }); + const state = await harness.service.schedule("when-idle"); + expect(state.scheduled).toEqual({ + targetVersion: TARGET_VERSION, + phase: "staging", + mode: "when-idle", + }); + + await waitForWaitingPhase(harness.service); + await expect( + stat(formatSelfUpdateSentinelPath(harness.dataDir)), + ).resolves.toBeDefined(); + + await vi.waitFor(() => { + expect(harness.exitProcess).toHaveBeenCalledWith( + BB_SELF_UPDATE_EXIT_CODE, + ); + }); + expect(harness.prepareShutdown).toHaveBeenCalledTimes(1); + }); + + it("does not skip the quiet period while a queued follow-up is pending", async () => { + const harness = await createHarness({ quietPeriodMs: 60_000 }); + harness.insertIdleThreadWithQueuedMessage("thr_queued"); + await harness.service.schedule("when-idle"); + await waitForWaitingPhase(harness.service); + + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(harness.exitProcess).not.toHaveBeenCalled(); + }); + + it("mode now applies straight after staging even while agents are busy", async () => { + const harness = await createHarness({ quietPeriodMs: 60_000 }); + harness.insertThread("thr_busy", "active"); + const state = await harness.service.schedule("now"); + expect(state.scheduled?.mode).toBe("now"); + + await vi.waitFor(() => { + expect(harness.exitProcess).toHaveBeenCalledWith( + BB_SELF_UPDATE_EXIT_CODE, + ); + }); + await expect( + stat(formatSelfUpdateSentinelPath(harness.dataDir)), + ).resolves.toBeDefined(); + }); + + it("update-now escalates a deferred schedule that is waiting on agents", async () => { + const harness = await createHarness({ quietPeriodMs: 60_000 }); + harness.insertThread("thr_busy", "active"); + await harness.service.schedule("when-idle"); + await waitForWaitingPhase(harness.service); + expect(harness.exitProcess).not.toHaveBeenCalled(); + + const state = await harness.service.schedule("now"); + expect(state.scheduled?.mode).toBe("now"); + await vi.waitFor(() => { + expect(harness.exitProcess).toHaveBeenCalledWith( + BB_SELF_UPDATE_EXIT_CODE, + ); + }); + }); + + it("does not skip the quiet period once agents have been seen working", async () => { + const harness = await createHarness({ quietPeriodMs: 60_000 }); + harness.insertThread("thr_busy", "active"); + await harness.service.schedule("when-idle"); + await waitForWaitingPhase(harness.service); + + harness.setThreadStatus("thr_busy", "idle"); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(harness.exitProcess).not.toHaveBeenCalled(); + }); + + it("waits for busy threads to finish before exiting", async () => { + const harness = await createHarness(); + harness.insertThread("thr_busy", "active"); + await harness.service.schedule("when-idle"); + await waitForWaitingPhase(harness.service); + + // Comfortably past the quiet period while the thread is still active. + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(harness.exitProcess).not.toHaveBeenCalled(); + + harness.setThreadStatus("thr_busy", "idle"); + await vi.waitFor(() => { + expect(harness.exitProcess).toHaveBeenCalledWith( + BB_SELF_UPDATE_EXIT_CODE, + ); + }); + }); + + it("does not exit while any agent is busy, even across handoffs", async () => { + const harness = await createHarness(); + harness.insertThread("thr_gate", "active"); + await harness.service.schedule("when-idle"); + await waitForWaitingPhase(harness.service); + + // One agent finishes while another starts in the same instant: the + // watcher must never observe an idle server. + harness.setThreadStatus("thr_gate", "idle"); + harness.insertThread("thr_late", "starting"); + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(harness.exitProcess).not.toHaveBeenCalled(); + + harness.setThreadStatus("thr_late", "idle"); + await vi.waitFor(() => { + expect(harness.exitProcess).toHaveBeenCalledWith( + BB_SELF_UPDATE_EXIT_CODE, + ); + }); + }); + + it("cancel removes the sentinel and staged install and stops the watcher", async () => { + const harness = await createHarness(); + harness.insertThread("thr_busy", "active"); + await harness.service.schedule("when-idle"); + await waitForWaitingPhase(harness.service); + + const state = await harness.service.cancel(); + expect(state.scheduled).toBeNull(); + await expect( + stat(formatSelfUpdateSentinelPath(harness.dataDir)), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect( + stat(formatSelfUpdateStagingDir(harness.dataDir, TARGET_VERSION)), + ).rejects.toMatchObject({ code: "ENOENT" }); + + harness.setThreadStatus("thr_busy", "idle"); + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(harness.exitProcess).not.toHaveBeenCalled(); + }); + + it("records a staging failure without leaving a sentinel behind", async () => { + const harness = await createHarness({ + runStagingInstall: async () => { + throw new Error("npm exploded"); + }, + }); + await harness.service.schedule("when-idle"); + await vi.waitFor(() => { + expect(harness.service.getState().scheduled).toBeNull(); + }); + expect(harness.service.getState().lastError).toContain("npm exploded"); + await expect( + stat(formatSelfUpdateSentinelPath(harness.dataDir)), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects scheduling when no update is available or when not capable", async () => { + const upToDate = await createHarness({ latestVersion: CURRENT_VERSION }); + await expect(upToDate.service.schedule("when-idle")).rejects.toMatchObject({ + body: { code: "no_update_available" }, + }); + + const incapable = await createHarness({ selfUpdateProtocol: false }); + await expect(incapable.service.schedule("when-idle")).rejects.toBeInstanceOf(ApiError); + expect(incapable.service.getState().capable).toBe(false); + }); + + it("resumes a pending schedule from the sentinel on boot", async () => { + const harness = await createHarness(); + harness.insertThread("thr_busy", "active"); + await writeStagedPackage(harness.dataDir, TARGET_VERSION); + await writeFile( + formatSelfUpdateSentinelPath(harness.dataDir), + JSON.stringify({ + targetVersion: TARGET_VERSION, + stagedPackageRoot: formatStagedPackageRoot( + harness.dataDir, + TARGET_VERSION, + ), + requestedAt: new Date().toISOString(), + }), + "utf8", + ); + + await harness.service.resume(); + expect(harness.service.getState().scheduled).toEqual({ + targetVersion: TARGET_VERSION, + phase: "waiting", + mode: "when-idle", + }); + + harness.setThreadStatus("thr_busy", "idle"); + await vi.waitFor(() => { + expect(harness.exitProcess).toHaveBeenCalledWith( + BB_SELF_UPDATE_EXIT_CODE, + ); + }); + }); + + it("discards a stale sentinel targeting the running version and prunes staging", async () => { + const harness = await createHarness(); + // Simulate a completed update: the sentinel targets the version that is + // now running, and an older staged install is still on disk. + await writeStagedPackage(harness.dataDir, CURRENT_VERSION); + await writeStagedPackage(harness.dataDir, "0.0.9"); + await writeFile( + formatSelfUpdateSentinelPath(harness.dataDir), + JSON.stringify({ + targetVersion: CURRENT_VERSION, + stagedPackageRoot: formatStagedPackageRoot( + harness.dataDir, + CURRENT_VERSION, + ), + requestedAt: new Date().toISOString(), + }), + "utf8", + ); + + await harness.service.resume(); + expect(harness.service.getState().scheduled).toBeNull(); + await expect( + stat(formatSelfUpdateSentinelPath(harness.dataDir)), + ).rejects.toMatchObject({ code: "ENOENT" }); + // The running version's staged install survives (we may be running from + // it); the older one is pruned. + await expect( + stat(formatSelfUpdateStagingDir(harness.dataDir, CURRENT_VERSION)), + ).resolves.toBeDefined(); + await expect( + stat(formatSelfUpdateStagingDir(harness.dataDir, "0.0.9")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); + +describe("resolveNpmInvocation", () => { + it("uses real npm from npm_execpath but never pnpm/yarn", async () => { + const { resolveNpmInvocation } = await import( + "../../src/services/system/self-update.js" + ); + expect( + resolveNpmInvocation({ npm_execpath: "/usr/lib/node_modules/npm/bin/npm-cli.js" }), + ).toEqual({ + command: process.execPath, + argsPrefix: ["/usr/lib/node_modules/npm/bin/npm-cli.js"], + }); + // pnpm sets npm_execpath to its own CLI, which rejects npm's flags. + expect( + resolveNpmInvocation({ npm_execpath: "/x/pnpm/bin/pnpm.cjs" }), + ).toEqual({ command: "npm", argsPrefix: [] }); + expect(resolveNpmInvocation({})).toEqual({ command: "npm", argsPrefix: [] }); + }); +}); diff --git a/packages/bb-app/src/launcher.ts b/packages/bb-app/src/launcher.ts index 7ba936fdad..01379fd9ab 100644 --- a/packages/bb-app/src/launcher.ts +++ b/packages/bb-app/src/launcher.ts @@ -16,9 +16,18 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { + APP_SURFACE_DESKTOP, APP_SURFACE_ENV_NAME, APP_SURFACE_WEB, + parseAppSurface, } from "@bb/config/app-surface"; +import { + BB_SELF_UPDATE_EXIT_CODE, + BB_SELF_UPDATE_PROTOCOL_ENV_NAME, + formatSelfUpdateSentinelPath, + selfUpdateSentinelSchema, + type SelfUpdateSentinel, +} from "@bb/config/self-update"; import { BB_APP_MANAGED_CONFIG_KEYS, bbAppManagedEnvFileSchema, @@ -337,6 +346,19 @@ interface RestartManagedProcessArgs { start: StartManagedProcess; } +/** Replacement start context + closures for a staged self-update. */ +export interface SelfUpdateSwap { + context: BbAppStartContext; + startDaemon: StartManagedProcess; + startServer: StartManagedProcess; +} + +/** + * Consumes the self-update sentinel (if any) and returns the swap to the + * staged version, or null when there is nothing valid to swap to. + */ +export type TrySelfUpdateSwapFn = () => Promise; + export interface SuperviseFullStackProcessesArgs { context: BbAppStartContext; delayMilliseconds: DelayMillisecondsFn; @@ -344,6 +366,8 @@ export interface SuperviseFullStackProcessesArgs { processes: ManagedFullStackProcesses; startDaemon: StartManagedProcess; startServer: StartManagedProcess; + /** Absent when the caller does not support self-update (tests, futures). */ + trySelfUpdateSwap?: TrySelfUpdateSwapFn; } export interface TerminateManagedFullStackProcessesArgs { @@ -400,6 +424,12 @@ interface CreateSharedEnvArgs { interface CreateServerEnvArgs { context: BbAppStartContext; env: NodeJS.ProcessEnv; + /** + * Whether this launcher will honor the self-update swap protocol for the + * spawned server. False for standalone bb-server (no supervision) and when + * running under the desktop shell (electron-updater owns updates there). + */ + selfUpdateProtocol: boolean; } interface CreateServerBaseEnvArgs { @@ -1109,7 +1139,23 @@ export function resolveBbAppStartContext( args: ResolveBbAppStartContextArgs, ): BbAppStartContext { const entrypointDir = dirname(fileURLToPath(args.entrypointUrl)); - const packageRoot = resolve(entrypointDir, ".."); + return resolveBbAppStartContextFromPackageRoot({ + env: args.env, + homeDir: args.homeDir, + packageRoot: resolve(entrypointDir, ".."), + }); +} + +export interface ResolveBbAppStartContextFromPackageRootArgs { + env: NodeJS.ProcessEnv; + homeDir: string; + packageRoot: string; +} + +export function resolveBbAppStartContextFromPackageRoot( + args: ResolveBbAppStartContextFromPackageRootArgs, +): BbAppStartContext { + const packageRoot = args.packageRoot; const dataDir = resolveDataDir({ env: args.env, homeDir: args.homeDir }); const serverPort = resolvePort({ defaultPort: BB_PROD_SERVER_PORT, @@ -1822,6 +1868,30 @@ async function pathExists(pathToCheck: string): Promise { } } +/** + * Read and delete the self-update sentinel. One-shot on purpose: a swap that + * fails must not retry on the next server exit, or a broken staged install + * would wedge the restart loop. + */ +async function consumeSelfUpdateSentinel( + dataDir: string, +): Promise { + const sentinelPath = formatSelfUpdateSentinelPath(dataDir); + let rawContents: string; + try { + rawContents = await readFile(sentinelPath, "utf8"); + } catch { + return null; + } + await unlink(sentinelPath).catch(() => undefined); + try { + return selfUpdateSentinelSchema.parse(JSON.parse(rawContents)); + } catch { + log(yellow("!"), "Ignoring malformed self-update sentinel"); + return null; + } +} + async function readPersistedHostId(dataDir: string): Promise { try { const value = ( @@ -2113,7 +2183,7 @@ function createSharedEnv(args: CreateSharedEnvArgs): NodeJS.ProcessEnv { } function createServerEnv(args: CreateServerEnvArgs): NodeJS.ProcessEnv { - return { + const env: NodeJS.ProcessEnv = { ...args.env, BB_APP_VERSION: args.context.appVersion, [APP_SURFACE_ENV_NAME]: APP_SURFACE_WEB, @@ -2122,6 +2192,12 @@ function createServerEnv(args: CreateServerEnvArgs): NodeJS.ProcessEnv { BB_SERVER_PORT: String(args.context.serverPort), NODE_ENV: "production", }; + if (args.selfUpdateProtocol) { + env[BB_SELF_UPDATE_PROTOCOL_ENV_NAME] = "1"; + } else { + delete env[BB_SELF_UPDATE_PROTOCOL_ENV_NAME]; + } + return env; } function createDaemonEnv( @@ -2296,6 +2372,7 @@ Usage: env: createServerEnv({ context: runtime.context, env: runtime.serverEnv, + selfUpdateProtocol: false, }), stdio: "inherit", }); @@ -2620,9 +2697,111 @@ export async function terminateManagedFullStackProcesses( await Promise.all(terminationPromises); } +/** + * The context and start closures supervision currently launches from. A + * successful self-update swap replaces all three, so later crash restarts + * relaunch the updated version. + */ +interface SupervisedStack { + context: BbAppStartContext; + startDaemon: StartManagedProcess; + startServer: StartManagedProcess; +} + +/** Restart one child from the current stack. False means shutdown began. */ +async function restartFromStack( + args: SuperviseFullStackProcessesArgs, + stack: SupervisedStack, + processName: ManagedProcessName, +): Promise { + const restarted = await restartManagedProcess({ + context: stack.context, + delayMilliseconds: args.delayMilliseconds, + isShutdownRequested: args.isShutdownRequested, + processName, + start: processName === "server" ? stack.startServer : stack.startDaemon, + }); + return restarted !== null; +} + +type SelfUpdateSwapOutcome = + | "rolled-back" + | "shutdown" + | "swapped" + | "unusable"; + +/** + * Handle a server exit that requested a self-update: consume the sentinel and + * restart both children from the staged version, mutating the stack on + * success. A staged version that fails to start is torn down and the current + * version restarted ("rolled-back"); "unusable" means there was nothing valid + * to swap to and the caller should restart the server normally. + */ +async function applySelfUpdateSwap( + args: SuperviseFullStackProcessesArgs, + stack: SupervisedStack, +): Promise { + const swap = (await args.trySelfUpdateSwap?.()) ?? null; + if (args.isShutdownRequested()) { + return "shutdown"; + } + if (swap === null) { + return "unusable"; + } + + log( + yellow("!"), + `Server stopped for a self-update - switching to bb-app ${swap.context.appVersion}`, + ); + const daemonRun = args.processes.daemonRun; + if (daemonRun !== null) { + await daemonRun.terminate("SIGTERM"); + if (args.processes.daemonRun === daemonRun) { + args.processes.daemonRun = null; + } + } + + beginStep(`Starting bb-app ${swap.context.appVersion}`); + try { + await swap.startServer(); + await swap.startDaemon(); + stack.context = swap.context; + stack.startServer = swap.startServer; + stack.startDaemon = swap.startDaemon; + endStep(green("✓"), `Updated to bb-app ${swap.context.appVersion}`); + return "swapped"; + } catch { + endStep( + red("✗"), + `bb-app ${swap.context.appVersion} failed to start - restarting the current version`, + ); + // A half-started new stack may be running (e.g. server healthy but + // daemon failed); tear it down before restarting the old version. + await terminateManagedFullStackProcesses({ + processes: args.processes, + signal: "SIGTERM", + }); + args.processes.serverRun = null; + args.processes.daemonRun = null; + if (!(await restartFromStack(args, stack, "server"))) { + return "shutdown"; + } + if (!(await restartFromStack(args, stack, "daemon"))) { + return "shutdown"; + } + return "rolled-back"; + } +} + export async function superviseFullStackProcesses( args: SuperviseFullStackProcessesArgs, ): Promise { + const stack: SupervisedStack = { + context: args.context, + startDaemon: args.startDaemon, + startServer: args.startServer, + }; + while (!args.isShutdownRequested()) { const serverRun = args.processes.serverRun; const daemonRun = args.processes.daemonRun; @@ -2635,53 +2814,47 @@ export async function superviseFullStackProcesses( return "shutdown"; } - log( - yellow("!"), - `${formatManagedProcessLabel(exitedProcess.processName)} exited with ${formatProcessExitResult( - exitedProcess.result, - )} - restarting ${formatManagedProcessLabel(exitedProcess.processName)}`, - ); - - if (exitedProcess.processName === "server") { + const exitedName = exitedProcess.processName; + if (exitedName === "server") { if (args.processes.serverRun === serverRun) { args.processes.serverRun = null; } - await args.delayMilliseconds({ - ms: MANAGED_PROCESS_RESTART_RETRY_DELAY_MS, - }); - if (args.isShutdownRequested()) { + } else if (args.processes.daemonRun === daemonRun) { + args.processes.daemonRun = null; + } + + if ( + exitedName === "server" && + exitedProcess.result.code === BB_SELF_UPDATE_EXIT_CODE && + args.trySelfUpdateSwap !== undefined + ) { + const outcome = await applySelfUpdateSwap(args, stack); + if (outcome === "shutdown") { return "shutdown"; } - const restartedServer = await restartManagedProcess({ - context: args.context, - delayMilliseconds: args.delayMilliseconds, - isShutdownRequested: args.isShutdownRequested, - processName: "server", - start: args.startServer, - }); - if (restartedServer === null) { - return "shutdown"; + if (outcome !== "unusable") { + continue; } - continue; + log( + yellow("!"), + "Server exited for a self-update but no staged update was usable - restarting server", + ); + } else { + log( + yellow("!"), + `${formatManagedProcessLabel(exitedName)} exited with ${formatProcessExitResult( + exitedProcess.result, + )} - restarting ${formatManagedProcessLabel(exitedName)}`, + ); } - if (args.processes.daemonRun === daemonRun) { - args.processes.daemonRun = null; - } await args.delayMilliseconds({ ms: MANAGED_PROCESS_RESTART_RETRY_DELAY_MS, }); if (args.isShutdownRequested()) { return "shutdown"; } - const restartedDaemon = await restartManagedProcess({ - context: args.context, - delayMilliseconds: args.delayMilliseconds, - isShutdownRequested: args.isShutdownRequested, - processName: "daemon", - start: args.startDaemon, - }); - if (restartedDaemon === null) { + if (!(await restartFromStack(args, stack, exitedName))) { return "shutdown"; } } @@ -2774,9 +2947,15 @@ export async function runBbApp( const context = runtime.context; const outputBuffer = createOutputBuffer(); + // Desktop owns updates through electron-updater; advertising the swap + // protocol there would fork the runtime version away from the bundle. + const selfUpdateProtocol = + parseAppSurface(runtime.env[APP_SURFACE_ENV_NAME] ?? "") !== + APP_SURFACE_DESKTOP; const serverEnv = createServerEnv({ context, env: runtime.serverEnv, + selfUpdateProtocol, }); const sharedEnv = createSharedEnv({ context, @@ -2883,6 +3062,57 @@ export async function runBbApp( process.stdout.write("\n"); log(" ", dim("Press Ctrl+C to stop")); + const trySelfUpdateSwap: TrySelfUpdateSwapFn = async () => { + const sentinel = await consumeSelfUpdateSentinel(context.dataDir); + if (sentinel === null) { + return null; + } + try { + // Resolve symlinks (e.g. /tmp -> /private/tmp on macOS): the daemon + // bundle only runs its main() when argv[1] string-equals its own + // resolved module path, so a symlinked spawn path exits silently. + const stagedPackageRoot = realpathSync(sentinel.stagedPackageRoot); + const stagedVersion = readBbAppPackageVersion(stagedPackageRoot); + if (stagedVersion !== sentinel.targetVersion) { + throw new Error( + `staged version ${stagedVersion} does not match target ${sentinel.targetVersion}`, + ); + } + const stagedContext = resolveBbAppStartContextFromPackageRoot({ + env: runtime.env, + homeDir: homedir(), + packageRoot: stagedPackageRoot, + }); + assertBbAppArtifacts(stagedContext); + const stagedServerEnv = createServerEnv({ + context: stagedContext, + env: runtime.serverEnv, + selfUpdateProtocol, + }); + return { + context: stagedContext, + startServer: () => + startFullStackServerProcess({ + context: stagedContext, + env: stagedServerEnv, + outputBuffer, + processes, + }), + startDaemon: () => + startFullStackDaemonProcess({ + autoJoinEnv, + context: stagedContext, + outputBuffer, + processes, + }), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(yellow("!"), `Ignoring staged self-update: ${message}`); + return null; + } + }; + outputBuffer.flush(); const supervisionResult = await superviseFullStackProcesses({ context, @@ -2891,6 +3121,7 @@ export async function runBbApp( processes, startDaemon, startServer, + trySelfUpdateSwap, }); await completeFullStackSupervision({ shutdownPromise, supervisionResult }); } catch (error) { diff --git a/packages/bb-app/test/index.test.ts b/packages/bb-app/test/index.test.ts index 862944235a..96de423eec 100644 --- a/packages/bb-app/test/index.test.ts +++ b/packages/bb-app/test/index.test.ts @@ -46,7 +46,9 @@ import type { ManagedProcessRun, NamedProcessExitResult, ProcessExitResult, + SelfUpdateSwap, } from "../src/launcher.js"; +import { BB_SELF_UPDATE_EXIT_CODE } from "@bb/config/self-update"; interface DelayArgs { ms: number; @@ -270,6 +272,43 @@ function createFakeSupervisor(): FakeSupervisor { }; } +interface FakeSwapTarget { + daemonRuns: FakeManagedProcessRun[]; + serverRuns: FakeManagedProcessRun[]; + swap: SelfUpdateSwap; +} + +function createFakeSwapTarget(supervisor: FakeSupervisor): FakeSwapTarget { + const serverRuns: FakeManagedProcessRun[] = []; + const daemonRuns: FakeManagedProcessRun[] = []; + const swap: SelfUpdateSwap = { + context: { + ...createTestStartContext(), + appVersion: "9.9.9-updated", + packageRoot: "/tmp/bb-app-test/self-update/9.9.9-updated", + }, + startServer: async () => { + const run = new FakeManagedProcessRun({ + id: `updated-server-${serverRuns.length + 1}`, + processName: "server", + }); + serverRuns.push(run); + supervisor.processes.serverRun = run; + return run; + }, + startDaemon: async () => { + const run = new FakeManagedProcessRun({ + id: `updated-daemon-${daemonRuns.length + 1}`, + processName: "daemon", + }); + daemonRuns.push(run); + supervisor.processes.daemonRun = run; + return run; + }, + }; + return { daemonRuns, serverRuns, swap }; +} + async function waitForProcessReplacement( args: WaitForProcessReplacementArgs, ): Promise { @@ -1312,6 +1351,147 @@ describe("bb-app launcher", () => { ); }); + it("swaps both children to the staged version on the self-update exit code", async () => { + const supervisor = createFakeSupervisor(); + const initialServerRun = supervisor.serverRuns[0]; + const initialDaemonRun = supervisor.daemonRuns[0]; + const updated = createFakeSwapTarget(supervisor); + let swapCalls = 0; + const supervision = superviseFullStackProcesses({ + context: createTestStartContext(), + delayMilliseconds: immediateDelay, + isShutdownRequested: supervisor.shutdownRequested, + processes: supervisor.processes, + startDaemon: supervisor.daemonStart, + startServer: supervisor.serverStart, + trySelfUpdateSwap: async () => { + swapCalls += 1; + return updated.swap; + }, + }); + + initialServerRun.exitWith({ + code: BB_SELF_UPDATE_EXIT_CODE, + signal: null, + }); + const swappedServerRun = await waitForProcessReplacement({ + currentRun: () => supervisor.processes.serverRun, + previousRun: initialServerRun, + }); + const swappedDaemonRun = await waitForProcessReplacement({ + currentRun: () => supervisor.processes.daemonRun, + previousRun: initialDaemonRun, + }); + + expect(swapCalls).toBe(1); + expect(initialDaemonRun.terminationSignals).toEqual(["SIGTERM"]); + expect(swappedServerRun).toBe(updated.serverRuns[0]); + expect(swappedDaemonRun).toBe(updated.daemonRuns[0]); + // The original closures were not used to restart anything. + expect(supervisor.serverRuns).toHaveLength(1); + expect(supervisor.daemonRuns).toHaveLength(1); + + // A later crash restarts the *updated* version. + updated.serverRuns[0].exitWith({ code: 1, signal: null }); + await waitForProcessReplacement({ + currentRun: () => supervisor.processes.serverRun, + previousRun: updated.serverRuns[0], + }); + expect(updated.serverRuns).toHaveLength(2); + expect(supervisor.serverRuns).toHaveLength(1); + + await expect(stopFakeSupervisor(supervisor, supervision)).resolves.toBe( + "shutdown", + ); + }); + + it("restarts the server normally when the self-update swap is unavailable", async () => { + const supervisor = createFakeSupervisor(); + const initialServerRun = supervisor.serverRuns[0]; + const initialDaemonRun = supervisor.daemonRuns[0]; + let swapCalls = 0; + const supervision = superviseFullStackProcesses({ + context: createTestStartContext(), + delayMilliseconds: immediateDelay, + isShutdownRequested: supervisor.shutdownRequested, + processes: supervisor.processes, + startDaemon: supervisor.daemonStart, + startServer: supervisor.serverStart, + trySelfUpdateSwap: async () => { + swapCalls += 1; + return null; + }, + }); + + initialServerRun.exitWith({ + code: BB_SELF_UPDATE_EXIT_CODE, + signal: null, + }); + await waitForProcessReplacement({ + currentRun: () => supervisor.processes.serverRun, + previousRun: initialServerRun, + }); + + expect(swapCalls).toBe(1); + expect(supervisor.serverRuns).toHaveLength(2); + expect(supervisor.daemonRuns).toHaveLength(1); + expect(initialDaemonRun.running).toBe(true); + + await expect(stopFakeSupervisor(supervisor, supervision)).resolves.toBe( + "shutdown", + ); + }); + + it("restores the current version when the staged version fails to start", async () => { + const supervisor = createFakeSupervisor(); + const initialServerRun = supervisor.serverRuns[0]; + const initialDaemonRun = supervisor.daemonRuns[0]; + const updated = createFakeSwapTarget(supervisor); + const failingSwap: SelfUpdateSwap = { + context: updated.swap.context, + startServer: async () => { + throw new Error("staged server never became healthy"); + }, + startDaemon: updated.swap.startDaemon, + }; + const supervision = superviseFullStackProcesses({ + context: createTestStartContext(), + delayMilliseconds: immediateDelay, + isShutdownRequested: supervisor.shutdownRequested, + processes: supervisor.processes, + startDaemon: supervisor.daemonStart, + startServer: supervisor.serverStart, + trySelfUpdateSwap: async () => failingSwap, + }); + + initialServerRun.exitWith({ + code: BB_SELF_UPDATE_EXIT_CODE, + signal: null, + }); + await waitForProcessReplacement({ + currentRun: () => supervisor.processes.serverRun, + previousRun: initialServerRun, + }); + await waitForProcessReplacement({ + currentRun: () => supervisor.processes.daemonRun, + previousRun: initialDaemonRun, + }); + + // The old daemon was stopped for the swap, then the *current* version's + // closures restarted both children. + expect(initialDaemonRun.terminationSignals).toEqual(["SIGTERM"]); + expect(supervisor.serverRuns).toHaveLength(2); + expect(supervisor.daemonRuns).toHaveLength(2); + expect(updated.serverRuns).toHaveLength(0); + expect(updated.daemonRuns).toHaveLength(0); + expect(supervisor.processes.serverRun).toBe(supervisor.serverRuns[1]); + expect(supervisor.processes.daemonRun).toBe(supervisor.daemonRuns[1]); + + await expect(stopFakeSupervisor(supervisor, supervision)).resolves.toBe( + "shutdown", + ); + }); + it("terminates both children without restarting during shutdown", async () => { const supervisor = createFakeSupervisor(); const initialServerRun = supervisor.serverRuns[0]; diff --git a/packages/config/package.json b/packages/config/package.json index 0bd9ae1d77..f78818a0db 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -73,6 +73,11 @@ "types": "./src/runtime.ts", "default": "./src/runtime.ts" }, + "./self-update": { + "source": "./src/self-update.ts", + "types": "./src/self-update.ts", + "default": "./src/self-update.ts" + }, "./objects": { "source": "./src/objects.ts", "types": "./src/objects.ts", diff --git a/packages/config/src/env-vars.ts b/packages/config/src/env-vars.ts index 70559e87f9..1e04bc0401 100644 --- a/packages/config/src/env-vars.ts +++ b/packages/config/src/env-vars.ts @@ -214,6 +214,13 @@ export const BB_TELEMETRY_ENV = defineEnvVar({ parse: parseBooleanEnvValue, }); +export const BB_SELF_UPDATE_PROTOCOL_ENV = defineEnvVar({ + description: + "Set by the bb-app launcher on the server child to advertise that it understands the self-update swap protocol. Internal; not a user knob.", + name: "BB_SELF_UPDATE_PROTOCOL", + parse: parseBooleanEnvValue, +}); + export const BB_FF_PLACEHOLDER_ENV = defineEnvVar({ description: "Permanent placeholder feature flag. Non-functional keep-alive so the flag system has at least one entry; do not gate behavior on it.", diff --git a/packages/config/src/self-update.ts b/packages/config/src/self-update.ts new file mode 100644 index 0000000000..4174ba3747 --- /dev/null +++ b/packages/config/src/self-update.ts @@ -0,0 +1,76 @@ +import { join } from "node:path"; +import { z } from "zod"; + +/** + * Contract between the server and the bb-app launcher for in-place self + * updates ("update when agents finish"). + * + * Flow: the server stages an `npm install` of the target bb-app version under + * the data dir, writes the sentinel file, and — once no agents are running — + * exits with the reserved exit code. The launcher, seeing that exit code plus + * a valid sentinel, restarts the server and host daemon from the staged + * package root instead of the original install. + */ + +/** Server exit code that asks the launcher to swap to the staged version. */ +export const BB_SELF_UPDATE_EXIT_CODE = 75; + +/** + * Env var the bb-app launcher sets (truthy) on the server child to advertise + * that it understands the self-update protocol. Absent under `bb-server`, + * dev runs, and the desktop shell (which updates through electron-updater + * instead), so old launchers can never be asked to perform a swap they don't + * understand. The server parses it as a boolean, so the value carries no + * version information — a protocol change needs a new variable. + */ +export const BB_SELF_UPDATE_PROTOCOL_ENV_NAME = "BB_SELF_UPDATE_PROTOCOL"; + +/** + * How long agents must stay continuously idle before a deferred update + * applies. Shared by the server's idle watcher and the desktop shell's + * deferred relaunch so the two policies cannot drift. Queued follow-ups — + * the main way a mid-chain thread looks momentarily idle — are checked + * directly on every tick rather than insured against with time, so this + * only needs to cover work with no DB trace yet (e.g. an automation + * reacting to a turn completing a beat after the thread goes idle). + */ +export const BB_UPDATE_QUIET_PERIOD_MS = 10_000; + +export const selfUpdateSentinelSchema = z.object({ + /** bb-app version staged and ready to swap to. */ + targetVersion: z.string().min(1), + /** Absolute path of the staged bb-app package root (contains package.json). */ + stagedPackageRoot: z.string().min(1), + /** ISO timestamp of when the user scheduled the update. */ + requestedAt: z.string().min(1), +}); +export type SelfUpdateSentinel = z.infer; + +export function formatSelfUpdateSentinelPath(dataDir: string): string { + return join(dataDir, "self-update.json"); +} + +/** Root directory holding one staged install per version. */ +export function formatSelfUpdateStagingRoot(dataDir: string): string { + return join(dataDir, "self-update"); +} + +/** npm --prefix target for a staged version. */ +export function formatSelfUpdateStagingDir( + dataDir: string, + version: string, +): string { + return join(formatSelfUpdateStagingRoot(dataDir), version); +} + +/** Package root of a staged install: `/node_modules/bb-app`. */ +export function formatStagedPackageRoot( + dataDir: string, + version: string, +): string { + return join( + formatSelfUpdateStagingDir(dataDir, version), + "node_modules", + "bb-app", + ); +} diff --git a/packages/config/src/server.ts b/packages/config/src/server.ts index c18da8673e..18b3fd57bb 100644 --- a/packages/config/src/server.ts +++ b/packages/config/src/server.ts @@ -16,6 +16,7 @@ import { BB_INHERITED_SKILLS_ROOTS_ENV, BB_INFERENCE_ENV, BB_POSTHOG_API_KEY_ENV, + BB_SELF_UPDATE_PROTOCOL_ENV, BB_TELEMETRY_ENV, BB_TRANSCRIPTION_ENV, DEFAULT_BB_APP_URL, @@ -45,6 +46,8 @@ export interface ServerConfig BB_INHERITED_SKILLS_ROOTS: string[]; BB_INFERENCE: string; BB_POSTHOG_API_KEY: string; + /** True when the parent bb-app launcher supports the self-update swap. */ + BB_SELF_UPDATE_PROTOCOL: boolean; BB_TELEMETRY: boolean; BB_TRANSCRIPTION: string; OPENAI_API_KEY: string; @@ -133,6 +136,12 @@ export function loadServerConfig( definition: BB_POSTHOG_API_KEY_ENV, env: loader.env, }), + BB_SELF_UPDATE_PROTOCOL: readEnvVarWithDefault({ + context: loader.context, + defaultValue: false, + definition: BB_SELF_UPDATE_PROTOCOL_ENV, + env: loader.env, + }), BB_TELEMETRY: readEnvVarWithDefault({ context: loader.context, defaultValue: DEFAULT_BB_TELEMETRY, diff --git a/packages/desktop-contract/src/info.ts b/packages/desktop-contract/src/info.ts index bd4c2e9624..50db6f38f0 100644 --- a/packages/desktop-contract/src/info.ts +++ b/packages/desktop-contract/src/info.ts @@ -12,6 +12,17 @@ export const bbDesktopInfoSchema = z.object({ updateAvailable: z.boolean(), updateDownloaded: z.boolean(), version: z.string().min(1), + /** + * True when relaunching would interrupt agents on a shell-owned local + * runtime, so a deferred "relaunch when agents finish" install is offered. + * Absent on desktop shells that predate deferred installs. + */ + canDeferInstall: z.boolean().optional(), + /** + * True while a "relaunch when agents finish" install is pending. Absent on + * desktop shells that predate deferred installs. + */ + deferredInstall: z.boolean().optional(), }); export type BbDesktopInfo = z.infer; @@ -40,6 +51,14 @@ export interface BbDesktopApi extends BbDesktopInfo { checkForUpdates(): Promise; getInfo(): Promise; installUpdate(): Promise; + /** + * Defer the downloaded update: the main process polls the owned runtime and + * relaunches once no agents have been running for a quiet period. Optional + * for desktop shells that predate deferred installs. + */ + installUpdateWhenIdle?(): Promise; + /** Cancel a pending deferred install. Optional like installUpdateWhenIdle. */ + cancelDeferredInstall?(): Promise; onChange(listener: BbDesktopInfoChangeHandler): BbDesktopInfoUnsubscribe; /** * Subscribe to native desktop requests to open the current thread's secondary diff --git a/packages/server-contract/src/api/system.ts b/packages/server-contract/src/api/system.ts index c8cffc1676..b8789d712c 100644 --- a/packages/server-contract/src/api/system.ts +++ b/packages/server-contract/src/api/system.ts @@ -105,7 +105,50 @@ export const themeCatalogResponseSchema = z.object({ }); export type ThemeCatalogResponse = z.infer; -export const systemVersionResponseSchema = z.object({ +/** + * How a scheduled self-update applies: `when-idle` waits for agents to + * finish; `now` applies as soon as staging completes, interrupting any + * running agents (the user explicitly chose that). + */ +export const systemSelfUpdateModeSchema = z.enum(["when-idle", "now"]); +export type SystemSelfUpdateMode = z.infer; + +export const systemSelfUpdateScheduleRequestSchema = z.object({ + mode: systemSelfUpdateModeSchema, +}); +export type SystemSelfUpdateScheduleRequest = z.infer< + typeof systemSelfUpdateScheduleRequestSchema +>; + +/** + * A scheduled self-update. + * `staging` = npm install of the target version is still running; + * `waiting` = staged and ready, waiting for all agents to go idle + * (mode `now` skips `waiting` and applies straight after staging). + */ +export const systemSelfUpdateScheduledSchema = z.object({ + targetVersion: z.string(), + phase: z.enum(["staging", "waiting"]), + mode: systemSelfUpdateModeSchema, +}); +export type SystemSelfUpdateScheduled = z.infer< + typeof systemSelfUpdateScheduledSchema +>; + +export const systemSelfUpdateStateSchema = z.object({ + /** + * True when the server runs under a bb-app launcher that can perform the + * staged-version swap. False under dev, standalone bb-server, and desktop. + */ + capable: z.boolean(), + scheduled: systemSelfUpdateScheduledSchema.nullable(), + /** Most recent schedule/staging failure since boot, or null. */ + lastError: z.string().nullable(), +}); +export type SystemSelfUpdateState = z.infer; + +/** Version/update info owned by the app-version service (no self-update state). */ +export const systemVersionInfoSchema = z.object({ /** Version of the running bb-app package, read from package.json. */ currentVersion: z.string(), /** Latest version published to npm, or null when the lookup is unavailable. */ @@ -119,8 +162,31 @@ export const systemVersionResponseSchema = z.object({ /** Command users should run to upgrade. Server-owned product policy. */ upgradeCommand: z.string(), }); +export type SystemVersionInfo = z.infer; + +export const systemVersionResponseSchema = systemVersionInfoSchema.extend({ + selfUpdate: systemSelfUpdateStateSchema, +}); export type SystemVersionResponse = z.infer; +/** + * Live agent load, for update tooling that must wait for agents to finish + * (the desktop shell's deferred relaunch polls this). + */ +export const systemAgentActivityResponseSchema = z.object({ + /** Threads currently starting, active, or stopping. */ + busyThreadCount: z.number().int().min(0), + /** + * Idle threads holding queued follow-ups the auto-send sweep is about to + * start. Not busy yet, but a restart now would orphan that work, so update + * tooling treats them as busy. + */ + queuedThreadCount: z.number().int().min(0), +}); +export type SystemAgentActivityResponse = z.infer< + typeof systemAgentActivityResponseSchema +>; + export const systemConfigReloadResponseSchema = z.object({ ok: z.literal(true), }); diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index 38cf3031ab..ea73f08a86 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -105,7 +105,10 @@ import type { SystemConfigResponse, SystemExecutionOptionsQuery, SystemExecutionOptionsResponse, + SystemAgentActivityResponse, SystemProviderInfo, + SystemSelfUpdateScheduleRequest, + SystemSelfUpdateState, SystemVersionResponse, SystemVoiceTranscriptionForm, SystemVoiceTranscriptionResponse, @@ -198,6 +201,7 @@ import { setQueuedMessageGroupBoundaryRequestSchema, sendQueuedMessageRequestSchema, systemExecutionOptionsQuerySchema, + systemSelfUpdateScheduleRequestSchema, threadEventWaitQuerySchema, threadEventsQuerySchema, threadFilesRawQuerySchema, @@ -1048,6 +1052,31 @@ export const publicApiRoutes = { request: noRequest(), response: jsonResponse(), }), + // Stage the latest bb-app version and apply it either once no agents are + // running (mode "when-idle") or as soon as it is staged (mode "now", + // interrupting running agents). POST schedules (idempotent per version; + // re-posting "now" escalates a waiting schedule); DELETE cancels a + // pending schedule. Both return the resulting state. + scheduleSelfUpdate: defineRoute({ + path: "/system/update/schedule", + method: "post", + request: jsonRequest( + systemSelfUpdateScheduleRequestSchema, + ), + response: jsonResponse(), + }), + cancelSelfUpdate: defineRoute({ + path: "/system/update/schedule", + method: "delete", + request: noRequest(), + response: jsonResponse(), + }), + agentActivity: defineRoute({ + path: "/system/agents/activity", + method: "get", + request: noRequest(), + response: jsonResponse(), + }), }, }; diff --git a/tests/integration/helpers/harness.ts b/tests/integration/helpers/harness.ts index 10d2541141..4486f5dacd 100644 --- a/tests/integration/helpers/harness.ts +++ b/tests/integration/helpers/harness.ts @@ -31,6 +31,7 @@ import { } from "../../../apps/server/src/services/skills/builtin-skills-copy.js"; import { createAppVersionService } from "../../../apps/server/src/services/system/app-version.js"; import { createBbAppManagedConfigReloader } from "../../../apps/server/src/services/system/bb-app-managed-config.js"; +import { createSelfUpdateService } from "../../../apps/server/src/services/system/self-update.js"; import { createNoopTelemetryService } from "../../../apps/server/src/services/system/telemetry.js"; import { TerminalSessionLifecycle } from "../../../apps/server/src/services/terminals/terminal-session-lifecycle.js"; import type { @@ -229,6 +230,7 @@ async function startIntegrationServer( inheritedSkillsRootPaths: [], openAiApiKey: process.env.OPENAI_API_KEY ?? "test-openai-key", appUrl: "https://bb.example.test", + selfUpdateProtocol: false, serverPort: 0, threadStorageRootPath, transcriptionModel: "test/mock-transcription", @@ -270,6 +272,13 @@ async function startIntegrationServer( config, logger: testLogger, }); + const selfUpdate = createSelfUpdateService({ + appVersion, + config, + db, + logger: testLogger, + prepareShutdown: () => Promise.resolve(), + }); const { app, injectWebSocket } = createApp({ appVersion, bbAppManagedConfig, @@ -280,6 +289,7 @@ async function startIntegrationServer( logger: testLogger, machineAuth, pendingInteractions, + selfUpdate, telemetry, terminalSessions, watchInterests,