From 3f49caed849f803a7f8eb8038a2f2499fa14d38b Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 9 Sep 2026 01:32:20 +0000 Subject: [PATCH] Split UI updates 6/6: ui --- scripts/test-updates-browser.mjs | 237 ++++++++++++++ scripts/test-updates-native-browser.mjs | 354 ++++++++++++++++++++ src/components/conversation/composer.tsx | 27 +- src/components/ui/button.tsx | 16 +- src/components/update-notice.tsx | 50 +++ src/components/update-setting.tsx | 401 +++++++++++++++++++++++ src/features/settings/navigation.ts | 11 + src/features/updates/drafts.ts | 63 ++++ src/features/updates/state.ts | 108 ++++++ src/routes/__root.tsx | 2 + src/routes/agents.$agentId_.jobs.tsx | 1 + src/routes/settings.tsx | 4 + src/server/auth/client.js | 17 +- tests/settings-navigation.test.ts | 3 +- tests/update-drafts.test.ts | 93 ++++++ 15 files changed, 1381 insertions(+), 6 deletions(-) create mode 100644 scripts/test-updates-browser.mjs create mode 100644 scripts/test-updates-native-browser.mjs create mode 100644 src/components/update-notice.tsx create mode 100644 src/components/update-setting.tsx create mode 100644 src/features/updates/drafts.ts create mode 100644 src/features/updates/state.ts create mode 100644 tests/update-drafts.test.ts diff --git a/scripts/test-updates-browser.mjs b/scripts/test-updates-browser.mjs new file mode 100644 index 0000000..b66cede --- /dev/null +++ b/scripts/test-updates-browser.mjs @@ -0,0 +1,237 @@ +/** Run against a disposable development preview. The qualified API responses are + * fixtures: this verifies rendered browser behavior, not release qualification. + * PLAYWRIGHT_MODULE may point to an existing Playwright installation. */ +import assert from "node:assert/strict"; + +const { chromium } = await import( + process.env.PLAYWRIGHT_MODULE ?? "playwright" +); +const base = process.env.UPDATE_TEST_URL ?? "http://127.0.0.1:4191"; +if (!/^http:\/\/(127\.0\.0\.1|localhost):\d+$/.test(base)) + throw new Error("Browser harness requires a disposable loopback preview."); +const browser = await chromium.launch({ + ...(process.env.CHROME_BINARY + ? { executablePath: process.env.CHROME_BINARY } + : {}), + args: ["--no-sandbox"], +}); +try { + for (const viewport of [ + { width: 1440, height: 1000 }, + { width: 390, height: 844 }, + ]) { + const context = await browser.newContext({ viewport }); + let posts = 0, + cancels = 0, + offline = false, + unauthorized = false; + let acceptedKey; + let historical; + const status = { + capability: { + code: "supported", + reason: "Qualified updater fixture.", + canActivate: true, + }, + version: "0.1.40", + latest: { + id: "a".repeat(64), + version: "0.1.41", + notes: "", + checkedAt: Date.now(), + expiresAt: Date.now() + 600000, + }, + operation: null, + csrf: "fixture", + recent: true, + canCheck: true, + enrolled: true, + }; + await context.route("**/api/updates**", async (route) => { + const request = route.request(); + if (request.method() === "POST") { + if (request.url().endsWith("/cancel")) { + cancels++; + status.operation.phase = "cancelled"; + status.operation.cancellable = false; + return route.fulfill({ json: { operation: status.operation } }); + } + posts++; + acceptedKey = request.postDataJSON().key; + return route.abort("connectionreset"); // acceptance response was lost + } + if (offline) return route.abort("connectionrefused"); + if (unauthorized) + return route.fulfill({ status: 401, json: { error: "Sign in" } }); + if (new URL(request.url()).searchParams.has("key") && historical) + return route.fulfill({ json: { ...status, operation: historical } }); + return route.fulfill({ json: status }); + }); + const page = await context.newPage(); + const open = async (p) => { + await p.goto(`${base}/settings?group=updates`); + await p.getByRole("heading", { name: "Software updates" }).waitFor(); + await p.waitForTimeout(2500); + }; + await open(page); + await page + .getByRole("button", { name: "Update to 0.1.41", exact: true }) + .click(); + if (process.env.UPDATE_SCREENSHOT_DIR) { + await page.waitForTimeout(500); + await page.screenshot({ + path: `${process.env.UPDATE_SCREENSHOT_DIR}/confirmation-fixture-${viewport.width === 390 ? "mobile" : "desktop"}.png`, + fullPage: true, + }); + } + const confirm = page.getByRole("button", { + name: "Confirm update and outage", + }); + assert.equal(await confirm.isDisabled(), true); + await page.getByLabel("Type 0.1.41 to confirm").fill("0.1.40"); + assert.equal(await confirm.isDisabled(), true); + await page.getByLabel("Type 0.1.41 to confirm").press("Escape"); + assert.equal( + await page + .getByRole("button", { name: "Update to 0.1.41", exact: true }) + .evaluate((element) => element === document.activeElement), + true, + ); + await page + .getByRole("button", { name: "Update to 0.1.41", exact: true }) + .click(); + await page.getByLabel("Type 0.1.41 to confirm").fill("0.1.41"); + await confirm.click(); + await page + .getByText("Acceptance response was lost or refused.", { exact: false }) + .waitFor(); + assert.equal(posts, 1); + await page.reload(); + await page + .getByText("Checking an earlier confirmation.", { exact: false }) + .waitFor(); + assert.equal( + await page + .getByRole("button", { name: "Update to 0.1.41", exact: true }) + .isDisabled(), + true, + ); + const other = await context.newPage(); + await open(other); + assert.equal( + await other + .getByRole("button", { name: "Update to 0.1.41", exact: true }) + .isDisabled(), + true, + ); + status.operation = { + id: "11111111-1111-4111-8111-111111111111", + requestKey: acceptedKey, + phase: "draining", + version: "0.1.41", + previous: "0.1.40", + updatedAt: Date.now(), + cancellable: true, + committed: false, + blockers: ["A coding worker is uncertain."], + }; + await page + .getByRole("button", { name: "Cancel update", exact: true }) + .waitFor(); + await other + .getByRole("button", { name: "Cancel update", exact: true }) + .waitFor(); + offline = true; + await page + .getByText("Reconnecting…", { exact: false }) + .waitFor({ timeout: 15000 }); + assert.equal(posts, 1); + offline = false; + await page + .getByRole("button", { name: "Cancel update", exact: true }) + .click(); + await page + .getByText("Update cancelled", { exact: true }) + .waitFor({ timeout: 30000 }); + assert.equal(cancels, 1); + assert.equal(posts, 1); + await other.close(); + historical = { ...status.operation }; + status.operation = { + ...status.operation, + id: "22222222-2222-4222-8222-222222222222", + requestKey: "later-request-key-1234", + phase: "draining", + cancellable: true, + }; + await page.evaluate( + (key) => + localStorage.setItem( + "roost-update-pending-v1", + JSON.stringify({ key, version: "0.1.41" }), + ), + acceptedKey, + ); + await page.reload(); + await page + .getByText("Earlier request: Update cancelled.", { exact: false }) + .waitFor({ timeout: 15000 }); + await page.getByText("Waiting for work", { exact: true }).waitFor(); + assert.equal( + await page.evaluate(() => + localStorage.getItem("roost-update-pending-v1"), + ), + null, + ); + assert.equal(posts, 1); + unauthorized = true; + await page + .getByText("Sign in again to read the durable update result.", { + exact: false, + }) + .waitFor({ timeout: 15000 }); + if (process.env.UPDATE_SCREENSHOT_DIR) { + await page + .getByText("Sign in again to read the durable update result.", { + exact: false, + }) + .evaluate((element) => { + for ( + let node = element.parentElement; + node; + node = node.parentElement + ) + if (node.scrollHeight > node.clientHeight) + node.scrollTop = node.scrollHeight; + }); + await page.screenshot({ + path: `${process.env.UPDATE_SCREENSHOT_DIR}/reauth-fixture-${viewport.width === 390 ? "mobile" : "desktop"}.png`, + fullPage: true, + }); + } + assert.equal( + await page + .getByRole("link", { name: "Sign in again", exact: true }) + .getAttribute("href"), + "/auth?updates=1", + ); + assert.equal( + await page + .getByRole("button", { name: "Cancel update", exact: true }) + .isDisabled(), + true, + ); + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + true, + ); + await context.close(); + console.log( + `PASS rendered update confirmation/lost response/reload/two tabs/cancel/reauth ${viewport.width}x${viewport.height}`, + ); + } +} finally { + await browser.close(); +} diff --git a/scripts/test-updates-native-browser.mjs b/scripts/test-updates-native-browser.mjs new file mode 100644 index 0000000..2795890 --- /dev/null +++ b/scripts/test-updates-native-browser.mjs @@ -0,0 +1,354 @@ +/** Real native-auth acceptance against an explicitly selected disposable guest. + * The runner supplies a private TLS release fixture, enrolled systemd helper, + * inert draft agent, and a Chromium virtual-authenticator credential file. + * No API responses, authentication, artifact validation, or services are mocked. */ +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const [version, outcome, runningVersion] = process.argv.slice(2); +const origin = process.env.UPDATE_TEST_ORIGIN; +const credentialsPath = process.env.UPDATE_TEST_PASSKEYS; +const agent = process.env.UPDATE_TEST_AGENT; +const mobileActivation = process.env.UPDATE_TEST_MOBILE === "yes"; +if ( + process.env.UPDATE_TEST_DISPOSABLE !== "yes" || + !origin || + new URL(origin).hostname !== "localhost" || + !credentialsPath || + !agent || + !/^\d+\.\d+\.\d+$/.test(version ?? "") || + !["succeeded", "rolled-back", "cancelled", "deferred"].includes(outcome) || + !runningVersion +) + throw new Error( + "Select an explicitly disposable localhost guest, test passkeys, agent, candidate, outcome and running version.", + ); +const { chromium } = await import( + process.env.PLAYWRIGHT_MODULE ?? "playwright" +); +const browser = await chromium.launch({ + executablePath: process.env.CHROME_BINARY, + args: ["--no-sandbox"], +}); +const context = await browser.newContext({ + viewport: { width: 1440, height: 1000 }, +}); +context.setDefaultTimeout(60000); +const page = await context.newPage(); +const cdp = await context.newCDPSession(page); +await cdp.send("WebAuthn.enable"); +const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, +}); +for (const credential of JSON.parse(await readFile(credentialsPath, "utf8")) + .credentials) + await cdp.send("WebAuthn.addCredential", { authenticatorId, credential }); +const saveAuthenticator = async () => + writeFile( + credentialsPath, + JSON.stringify( + await cdp.send("WebAuthn.getCredentials", { authenticatorId }), + ), + { mode: 0o600 }, + ); +const screenshot = async (label) => { + if (process.env.UPDATE_SCREENSHOT_DIR) + await page.screenshot({ + path: join(process.env.UPDATE_SCREENSHOT_DIR, `${label}.png`), + fullPage: true, + }); +}; +const draftText = "Unsent native update acceptance draft"; +let submissions = 0; +let updatePosts = 0; +context.on("request", (request) => { + if ( + request.method() === "POST" && + new URL(request.url()).pathname === "/api/updates" + ) + updatePosts++; + if (request.method() === "POST" && request.postData()?.includes(draftText)) + submissions++; +}); +try { + await page.goto(`${origin}/auth`); + await page + .getByRole("button", { name: "Sign in with a passkey", exact: true }) + .click(); + await page.waitForURL((url) => url.pathname === "/"); + await saveAuthenticator(); + console.log("Native passkey authenticated"); + const draft = await context.newPage(); + await draft.goto(`${origin}/agents/${agent}`); + await draft.locator("textarea").fill(draftText); + await draft.waitForTimeout(500); + const observer = await context.newPage(); + await observer.goto(`${origin}/settings?group=updates`); + await page.goto(`${origin}/settings?group=updates`); + await page + .getByRole("button", { name: "Check for updates", exact: true }) + .click(); + await page + .getByRole("button", { name: `Update to ${version}`, exact: true }) + .click(); + const confirmationInput = page.getByRole("textbox", { + name: `Type ${version} to confirm`, + }); + assert.equal( + await confirmationInput.evaluate( + (element) => element === document.activeElement, + ), + true, + ); + await confirmationInput.press("Escape"); + const trigger = page.getByRole("button", { + name: `Update to ${version}`, + exact: true, + }); + assert.equal( + await trigger.evaluate((element) => element === document.activeElement), + true, + ); + await trigger.press("Enter"); + const tree = await cdp.send("Accessibility.getFullAXTree"); + assert.ok( + tree.nodes.some( + (node) => + node.role?.value === "textbox" && + node.name?.value === `Type ${version} to confirm`, + ), + ); + await page + .getByRole("textbox", { name: `Type ${version} to confirm` }) + .fill(version); + await screenshot("native-confirm-desktop"); + await page.setViewportSize({ width: 390, height: 844 }); + await screenshot("native-confirm-mobile"); + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth > innerWidth, + ), + false, + ); + if (!mobileActivation) + await page.setViewportSize({ width: 1440, height: 1000 }); + let accepted; + // Deliver the real POST, then lose only its response. Polling must discover + // the durable operation without automatically submitting another mutation. + await page.route("**/api/updates", async (route) => { + if (route.request().method() !== "POST") return route.continue(); + assert.equal(accepted, undefined, "Acceptance must not be submitted twice"); + const response = await route.fetch(); + assert.equal(response.status(), 202); + accepted = (await response.json()).operation; + console.log("Durable acceptance", accepted.id); + await route.abort("connectionreset"); + }); + await page + .getByRole("button", { name: "Confirm update and outage", exact: true }) + .click(); + const observed = new Set(); + let cancellationSent = false; + let result; + const deadline = Date.now() + 900000; + while (Date.now() < deadline) { + await page.waitForTimeout(2000); + try { + const status = await page.evaluate(async () => { + const response = await fetch("/api/updates"); + return response.ok ? response.json() : { http: response.status }; + }); + if (status.http) observed.add(`HTTP ${status.http}`); + const phase = status.operation?.phase; + if ( + accepted && + status.operation?.id === accepted.id && + phase && + !observed.has(phase) + ) { + observed.add(phase); + console.log("Observed phase", phase); + } + if ( + outcome === "cancelled" && + phase === "draining" && + !cancellationSent + ) { + await page + .getByRole("button", { name: "Cancel update", exact: true }) + .click(); + cancellationSent = true; + } + if ( + accepted && + status.operation?.id === accepted.id && + [ + "succeeded", + "rolled-back", + "failed", + "deferred", + "cancelled", + "manual-recovery", + ].includes(phase) + ) { + result = status; + break; + } + } catch { + observed.add("disconnected"); + } + } + assert.equal( + result?.operation?.phase, + outcome, + JSON.stringify({ + version: result?.version, + capability: result?.capability, + operation: result?.operation, + }), + ); + assert.equal(result.version, runningVersion); + assert.equal(result.operation.id, accepted?.id); + await draft.reload(); + await draft.waitForFunction( + (text) => document.querySelector("textarea")?.value === text, + draftText, + ); + await observer.reload(); + await observer + .getByRole("heading", { name: "Software updates", exact: true }) + .waitFor(); + const observerResult = await observer.evaluate(async () => + (await fetch("/api/updates")).json(), + ); + assert.equal(observerResult.operation.id, accepted.id); + await observer + .getByRole("status") + .filter({ + hasText: { + succeeded: "Update succeeded", + "rolled-back": "Previous version and data restored", + cancelled: "Update cancelled", + deferred: "Update deferred", + }[outcome], + }) + .waitFor(); + // A stale shell can request an unavailable module after restart. Lose one + // real module load, then recover with a browser reload; storage must retain + // drafts and the durable request key must never become a second POST. + await page.reload(); + await page + .getByRole("heading", { name: "Software updates", exact: true }) + .waitFor(); + const script = await page + .locator('script[type="module"][src]') + .first() + .getAttribute("src"); + assert.ok(script); + const asset = new URL(script, origin).href; + assert.ok(new URL(asset).pathname.startsWith("/assets/")); + // The real service worker may already hold the immutable module. Bypass its + // cache only for this explicit missing-network-asset injection, then restore + // normal service-worker behavior for recovery. + await cdp.send("Network.enable"); + await cdp.send("Network.setBypassServiceWorker", { bypass: true }); + await cdp.send("Network.setCacheDisabled", { cacheDisabled: true }); + let missingAsset = false; + await page.route(asset, async (route) => { + missingAsset = true; + await route.abort("failed"); + }); + await page.reload({ waitUntil: "domcontentloaded" }); + assert.equal(missingAsset, true); + await page.unroute(asset); + await cdp.send("Network.setBypassServiceWorker", { bypass: false }); + await cdp.send("Network.setCacheDisabled", { cacheDisabled: false }); + await page.reload(); + await page + .getByRole("heading", { name: "Software updates", exact: true }) + .waitFor(); + await page + .getByRole("button", { name: "Reload versioned app assets", exact: true }) + .waitFor(); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.waitForTimeout(1000); + await screenshot("native-result-desktop"); + await page.setViewportSize({ width: 390, height: 844 }); + await screenshot("native-result-mobile"); + await draft.setViewportSize({ width: 390, height: 844 }); + await draft.reload(); + await draft.waitForFunction( + (text) => document.querySelector("textarea")?.value === text, + draftText, + ); + await page.goto(`${origin}/auth?updates=1`); + await page.getByRole("button", { name: "Sign out", exact: true }).click(); + await page + .getByRole("button", { name: "Sign in with a passkey", exact: true }) + .click(); + await page.waitForURL( + (url) => + url.pathname === "/settings" && + url.searchParams.get("group") === "updates", + ); + await saveAuthenticator(); + const authenticated = await page.evaluate(async () => + (await fetch("/api/updates")).json(), + ); + assert.equal(authenticated.recent, true); + const recovered = await page.evaluate( + async (key) => + (await fetch(`/api/updates?key=${encodeURIComponent(key)}`)).json(), + accepted.requestKey, + ); + assert.equal(recovered.operation.id, accepted.id); + await draft.reload(); + await draft.waitForFunction( + (text) => document.querySelector("textarea")?.value === text, + draftText, + ); + await draft.close(); + const reopened = await context.newPage(); + await reopened.goto(`${origin}/agents/${agent}`); + await reopened.waitForFunction( + (text) => document.querySelector("textarea")?.value === text, + draftText, + ); + await reopened.goto(`${origin}/settings?group=updates`); + await reopened + .getByRole("button", { name: "Reload versioned app assets", exact: true }) + .waitFor(); + assert.equal(submissions, 0); + assert.equal(updatePosts, 1); + console.log( + JSON.stringify({ + version, + outcome, + operation: result.operation.id, + observed: [...observed], + nativePasskey: true, + reauthenticated: true, + returnedToUpdates: true, + retainedDraft: true, + submissions, + multiTab: true, + mobile: true, + mobileActivation, + keyboardAndAccessibilityTree: true, + missingAssetReload: true, + closedTabDraft: true, + updatePosts, + }), + ); +} finally { + await saveAuthenticator(); + await browser.close(); +} diff --git a/src/components/conversation/composer.tsx b/src/components/conversation/composer.tsx index debdaaa..5fcea00 100644 --- a/src/components/conversation/composer.tsx +++ b/src/components/conversation/composer.tsx @@ -15,6 +15,8 @@ import { MAX_FILE_BYTES, } from "../../features/chat/files"; import { usePreferences } from "../../features/settings/preferences"; +import { readDraft, saveDraft } from "../../features/updates/drafts"; +import { useUpdateBlocked } from "../../features/updates/state"; import { motion } from "../../styles/motion.stylex"; import { colors } from "../../styles/tokens.stylex"; import { Appear } from "../ui/appear"; @@ -38,11 +40,29 @@ export function Composer({ onSend: (text: string, files: readonly FileAttachment[]) => Promise; onStop: () => void; }) { + const updating = useUpdateBlocked(); const [hydrated, setHydrated] = useState(false); useEffect(() => setHydrated(true), []); const [text, setText] = useState(""); const hasText = text.trim().length > 0; const [files, setFiles] = useState([]); + const [draftLoaded, setDraftLoaded] = useState(null); + useEffect(() => { + const draft = readDraft(agentId); + setText(draft?.text ?? ""); + setFiles(draft?.files ?? []); + setDraftLoaded(agentId); + }, [agentId]); + useEffect(() => { + if (draftLoaded === agentId) + try { + saveDraft(agentId, text, files); + } catch { + setUploadError( + "Draft could not be retained locally. Keep this tab open during an update.", + ); + } + }, [agentId, text, files, draftLoaded]); const [uploading, setUploading] = useState(false); const [sending, setSending] = useState(false); const submitting = useRef(false); @@ -81,10 +101,11 @@ export function Composer({ // away from the tap. Keep focus; the native click still submits the form. if (event.button === 0 && document.activeElement === input.current) event.preventDefault(); + if (updating) return; } async function attach(selected: FileList | readonly File[] | null) { - if (!selected?.length) return; + if (!selected?.length || updating) return; if (loading || submitting.current || uploading) { setUploadError( uploading @@ -140,6 +161,7 @@ export function Composer({ async function submit(event: FormEvent) { event.preventDefault(); + if (updating) return; if ( loading || submitting.current || @@ -236,6 +258,7 @@ export function Composer({ uploading || files.length >= MAX_ATTACHMENTS } + blockDuringUpdate onClick={() => fileInput.current?.click()} xstyle={styles.attachButton} > @@ -268,6 +291,7 @@ export function Composer({ !event.nativeEvent.isComposing ) { event.preventDefault(); + if (updating) return; event.currentTarget.form?.requestSubmit(); } }} @@ -279,6 +303,7 @@ export function Composer({ key="stop" type="button" onMouseDown={keepInputFocus} + allowDuringUpdate onClick={onStop} disabled={!hydrated} aria-label="Stop response" diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index dbaae96..532fc8a 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,20 +1,34 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button"; import * as stylex from "@stylexjs/stylex"; +import { useUpdateBlocked } from "../../features/updates/state"; import { motion } from "../../styles/motion.stylex"; import { colors } from "../../styles/tokens.stylex"; // Adapted from shadcn/ui's Base UI button; see THIRD_PARTY_NOTICES.md. export function Button({ + allowDuringUpdate = false, + blockDuringUpdate = false, xstyle, className, ...props -}: ButtonPrimitive.Props & { xstyle?: stylex.StyleXStyles }) { +}: ButtonPrimitive.Props & { + xstyle?: stylex.StyleXStyles; + allowDuringUpdate?: boolean; + blockDuringUpdate?: boolean; +}) { + const updating = useUpdateBlocked(); const base = stylex.props(styles.button, xstyle); return ( [ base.className, diff --git a/src/components/update-notice.tsx b/src/components/update-notice.tsx new file mode 100644 index 0000000..bbf27f6 --- /dev/null +++ b/src/components/update-notice.tsx @@ -0,0 +1,50 @@ +import { Link } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { + isUpdateActive, + phaseLabel, + publishUpdate, + type UpdateStatus, + useUpdateBlocked, +} from "../features/updates/state"; +export function UpdateNotice() { + const blocked = useUpdateBlocked(); + const [status, setStatus] = useState(null); + useEffect(() => { + let stopped = false; + let timer: ReturnType; + const poll = async () => { + try { + const response = await fetch("/api/updates", { + cache: "no-store", + signal: AbortSignal.timeout(5000), + }); + if (response.ok) { + const next = (await response.json()) as UpdateStatus; + if (!stopped) { + setStatus(next); + publishUpdate(next); + } + } + } catch { + /* Keep the last known admission notice during an outage. */ + } finally { + if (!stopped) timer = setTimeout(poll, 4000); + } + }; + void poll(); + return () => { + stopped = true; + clearTimeout(timer); + }; + }, []); + return status && isUpdateActive(status.operation) ? ( + + ) : null; +} diff --git a/src/components/update-setting.tsx b/src/components/update-setting.tsx new file mode 100644 index 0000000..24d8284 --- /dev/null +++ b/src/components/update-setting.tsx @@ -0,0 +1,401 @@ +import * as stylex from "@stylexjs/stylex"; +import { useEffect, useRef, useState } from "react"; +import { + isUpdateActive, + pendingAcceptance, + pendingKey, + phaseLabel, + publishUpdate, + rememberAcceptance, + rememberResult, + type UpdateStatus, +} from "../features/updates/state"; +import { colors } from "../styles/tokens.stylex"; +import { compareVersions, stableVersion } from "../updater/contract"; +import { Button } from "./ui/button"; + +export function UpdateSetting() { + const [status, setStatus] = useState(null); + const [message, setMessage] = useState("Loading update availability…"); + const [checking, setChecking] = useState(false); + const [confirm, setConfirm] = useState(false); + const [version, setVersion] = useState(""); + const [sending, setSending] = useState(false); + const [uncertain, setUncertain] = useState(false); + const [needsAuth, setNeedsAuth] = useState(false); + const newer = + !!status?.latest && + stableVersion.test(status.version) && + compareVersions(status.latest.version, status.version) > 0; + const confirmation = useRef(null); + const confirmationTrigger = useRef(null); + function closeConfirmation() { + setConfirm(false); + confirmationTrigger.current?.focus(); + } + useEffect(() => { + let stopped = false; + let timer: ReturnType; + let attempt = 0; + setUncertain(!!pendingAcceptance()); + const poll = async () => { + try { + const response = await fetch("/api/updates", { + cache: "no-store", + signal: AbortSignal.timeout(10000), + }); + if (response.status === 401) { + setNeedsAuth(true); + setMessage( + "Sign in again to read the durable update result. Do not resubmit the update.", + ); + return; + } + if (!response.ok) throw new Error(); + const value = (await response.json()) as UpdateStatus; + if (stopped) return; + setNeedsAuth(false); + setStatus(value); + publishUpdate(value); + if (!rememberResult(value.operation)) { + setMessage( + "Browser storage is unavailable. Keep this tab open to observe the operation.", + ); + return; + } + attempt = 0; + let pending = pendingAcceptance(); + if (pending && pending.key !== value.operation?.requestKey) { + // Another browser may have completed a later operation while this + // browser was offline. Resolve this key without replacing live status + // or publishing historical admission state to the rest of the app. + const lookup = await fetch( + `/api/updates?key=${encodeURIComponent(pending.key)}`, + { + cache: "no-store", + signal: AbortSignal.timeout(10000), + }, + ); + if (!lookup.ok) throw new Error(); + const historical = (await lookup.json()) as UpdateStatus; + if (stopped) return; + if ( + historical.operation?.requestKey === pending.key && + !isUpdateActive(historical.operation) + ) { + if (!rememberResult(historical.operation)) throw new Error(); + pending = pendingAcceptance(); + setUncertain(!!pending); + setMessage( + `Earlier request: ${phaseLabel[historical.operation.phase]}. Current installation status is shown below.`, + ); + return; + } + } + setUncertain(!!pending && pending.key !== value.operation?.requestKey); + if ( + value.operation && + (!pending || pending.key === value.operation.requestKey) + ) { + setMessage(value.error ?? ""); + } else if (!pending) setMessage(value.error ?? ""); + } catch { + if (!stopped) + setMessage( + "Reconnecting… Roost may be restarting. No result has been confirmed; no request will be replayed.", + ); + attempt++; + } finally { + if (!stopped) + timer = setTimeout( + poll, + Math.min(30000, 2000 * 2 ** Math.min(attempt, 4)) + + Math.random() * 500, + ); + } + }; + void poll(); + const changed = () => { + setUncertain(!!pendingAcceptance()); + }; + window.addEventListener("storage", changed); + return () => { + stopped = true; + clearTimeout(timer); + window.removeEventListener("storage", changed); + }; + }, []); + useEffect(() => { + if (confirm) confirmation.current?.focus(); + }, [confirm]); + async function post(path: string, body: unknown) { + const response = await fetch(path, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Roost-CSRF": status?.csrf ?? "", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(22000), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error ?? "Request not confirmed."); + return result; + } + async function check() { + setChecking(true); + try { + const result = await post("/api/updates/check", {}); + setStatus((s) => (s ? { ...s, latest: result.latest } : s)); + setMessage( + "Release metadata checked. The updater verifies artifact and migration compatibility before stopping Roost.", + ); + } catch (e) { + setMessage(e instanceof Error ? e.message : "Check failed."); + } finally { + setChecking(false); + } + } + async function activate() { + if (!status?.latest || sending || version !== status.latest.version) return; + const key = crypto.randomUUID(); + try { + rememberAcceptance(key, version); + } catch { + setMessage( + "Browser storage is unavailable. Enable it before updating so acceptance can be reconciled after a disconnect.", + ); + return; + } + setSending(true); + setUncertain(true); + setConfirm(false); + try { + const result = await post("/api/updates", { + offerId: status.latest.id, + version, + key, + }); + setStatus({ ...status, operation: result.operation }); + if (!rememberResult(result.operation)) + setMessage( + "The update was accepted, but browser storage is unavailable. Keep this tab open to observe it.", + ); + setUncertain(false); + } catch { + setMessage( + "Acceptance response was lost or refused. Reading durable status; do not submit again. If no operation appears, inspect updater status before a new confirmation.", + ); + } finally { + setSending(false); + } + } + return ( +
+

Software updates

+ {status && ( + <> +

+ Running version:{" "} + + {status.version === "dev" ? "Source build" : status.version} + +

+

{status.capability.reason}

+ {status.latest && ( + <> +

+ Latest published stable release:{" "} + {status.latest.version} +

+

+ Checked {new Date(status.latest.checkedAt).toLocaleString()}. +

+
+ Release notes +

+ {status.latest.notes || "No release notes provided."} +

+
+ + )} + + {!status.canCheck && ( +

+ Release checks require a packaged installation with a configured + repository and native passkey sign-in. +

+ )} + {status.latest && !newer && stableVersion.test(status.version) && ( +

No newer stable release was found.

+ )} + {status.capability.canActivate && + status.latest && + newer && + !isUpdateActive(status.operation) && ( + + )} + {confirm && ( +
+

+ Roost will be unavailable briefly. Active or uncertain work + defers the update. Startup failure may restore the previous + release and matching data. External actions cannot be undone. +

+ + + +
+ )} + {status.operation && ( +
+

+ {phaseLabel[status.operation.phase] ?? status.operation.phase} +

+ {status.operation.bytes !== undefined && ( +

+ {status.operation.bytes.toLocaleString()} bytes downloaded +

+ )} + {status.operation.blockers?.map((b) => ( +

{b}

+ ))} + {status.operation.error &&

{status.operation.error}

} + {status.operation.cancellable && ( + + )} + {!isUpdateActive(status.operation) && ( + + )} +
+ )} + {!status.capability.canActivate && ( +

+ UI installation and restart are not enabled for this installation. + Updates require an outage; external actions cannot be undone. +

+ )} + + )} + {uncertain && ( +

+ Checking an earlier confirmation. No activation request will be + automatically repeated. +

+ )} + {uncertain && status && !isUpdateActive(status.operation) && ( + + )} +

+ {message} +

+ {(needsAuth || (status && !status.recent && status.canCheck)) && ( +

+ Sign in again to return to Updates and + read the durable result before confirming another action. +

+ )} +
+ ); +} +const styles = stylex.create({ + card: { + padding: 20, + borderWidth: 1, + borderStyle: "solid", + borderColor: colors.border, + borderRadius: 12, + backgroundColor: colors.surface, + overflowWrap: "anywhere", + }, + title: { marginTop: 0, fontSize: 18 }, + muted: { color: colors.muted, fontSize: 14 }, + notes: { whiteSpace: "pre-wrap" }, +}); diff --git a/src/features/settings/navigation.ts b/src/features/settings/navigation.ts index 61146d8..8637e54 100644 --- a/src/features/settings/navigation.ts +++ b/src/features/settings/navigation.ts @@ -14,6 +14,11 @@ export const settingsGroups = [ label: "Account & security", description: "Codex sign-in and access to Roost.", }, + { + id: "updates", + label: "Updates", + description: "Software version, release checks, and update availability.", + }, ] as const; export type SettingsGroup = (typeof settingsGroups)[number]["id"]; @@ -25,6 +30,12 @@ export function readSettingsGroup(value: unknown): SettingsGroup { // Index the controls and their vocabulary, including options revealed by another // setting. Keep related controls together so their dependencies remain clear. export const settingsEntries = [ + { + id: "updates", + group: "updates", + terms: + "Software update upgrade release version restart recovery installation systemd setup externally managed", + }, { id: "theme", group: "appearance", diff --git a/src/features/updates/drafts.ts b/src/features/updates/drafts.ts new file mode 100644 index 0000000..9808ef8 --- /dev/null +++ b/src/features/updates/drafts.ts @@ -0,0 +1,63 @@ +import { type FileAttachment, MAX_FILE_BYTES } from "../chat/files"; + +const prefix = "roost-composer-draft-v2:"; +function tab() { + let value = sessionStorage.getItem("roost-draft-tab"); + if (!value) { + value = crypto.randomUUID(); + sessionStorage.setItem("roost-draft-tab", value); + } + return value; +} +export function readDraft( + agentId: string, +): { text: string; files: FileAttachment[] } | null { + try { + const validFile = (file: FileAttachment | null) => + file && + typeof file.id === "string" && + /^[a-f0-9-]{36}$/.test(file.id) && + typeof file.name === "string" && + file.name.length <= 1000 && + typeof file.mimeType === "string" && + file.mimeType.length <= 256 && + ["attachment", "artifact"].includes(file.kind) && + Number.isSafeInteger(file.size) && + file.size >= 0 && + file.size <= MAX_FILE_BYTES && + file.url === `/api/files?agentId=${agentId}&id=${file.id}`; + const own = localStorage.getItem(`${prefix}${agentId}:${tab()}`); + const entries = own + ? [own] + : Object.keys(localStorage) + .filter((key) => key.startsWith(`${prefix}${agentId}:`)) + .map((key) => localStorage.getItem(key)!); + const values = entries + .map((value) => JSON.parse(value)) + .filter( + (value) => + typeof value.text === "string" && + value.text.length < 1024 * 1024 && + Array.isArray(value.files) && + value.files.length <= 5 && + value.files.every(validFile), + ) + .sort((a, b) => b.updatedAt - a.updatedAt); + return values[0] ?? null; + } catch { + return null; + } +} +export function saveDraft( + agentId: string, + text: string, + files: readonly FileAttachment[], +) { + const value = JSON.stringify({ text, files, updatedAt: Date.now() }); + if (value.length > 2 * 1024 * 1024) + throw new Error("Draft exceeds local storage limit."); + const key = `${prefix}${agentId}:${tab()}`; + // Empty drafts are tombstones for this tab, so reload cannot revive another + // tab's already-sent draft. Other tabs' independent drafts are preserved. + localStorage.setItem(key, value); +} diff --git a/src/features/updates/state.ts b/src/features/updates/state.ts new file mode 100644 index 0000000..f610ee6 --- /dev/null +++ b/src/features/updates/state.ts @@ -0,0 +1,108 @@ +import { useSyncExternalStore } from "react"; +import type { UpdateSummary } from "../../updater/daemon"; +export type UpdateStatus = { + capability: { code: string; reason: string; canActivate: boolean }; + version: string; + latest: { + id: string; + version: string; + notes: string; + checkedAt: number; + expiresAt: number; + } | null; + operation: UpdateSummary | null; + csrf: string | null; + recent: boolean; + canCheck: boolean; + enrolled: boolean; + error?: string; +}; +let blocked = false; +const subscribers = new Set<() => void>(); +export const isUpdateActive = (operation: UpdateSummary | null) => + !!operation && + !["succeeded", "rolled-back", "failed", "cancelled", "deferred"].includes( + operation.phase, + ); +export function publishUpdate(status: UpdateStatus) { + const next = + !!status.operation && + [ + "draining", + "stopping", + "snapshot-complete", + "activating", + "verifying", + "committed", + "restoring", + "manual-recovery", + ].includes(status.operation.phase); + if (next !== blocked) { + blocked = next; + for (const listener of subscribers) listener(); + } +} +export function useUpdateBlocked() { + return useSyncExternalStore( + (listener) => { + subscribers.add(listener); + return () => { + subscribers.delete(listener); + }; + }, + () => blocked, + () => false, + ); +} +export const pendingKey = "roost-update-pending-v1"; +export function rememberAcceptance(key: string, version: string) { + localStorage.setItem( + pendingKey, + JSON.stringify({ key, version, created: Date.now() }), + ); +} +export function pendingAcceptance(): { key: string; version: string } | null { + try { + const value = JSON.parse(localStorage.getItem(pendingKey) ?? "null"); + return value && + typeof value.key === "string" && + /^[a-zA-Z0-9_-]{16,100}$/.test(value.key) && + typeof value.version === "string" && + value.version.length <= 32 + ? value + : null; + } catch { + return null; + } +} +export function rememberResult(operation: UpdateSummary | null) { + if (!operation) return true; + try { + localStorage.setItem("roost-update-operation-v1", operation.id); + if ( + !isUpdateActive(operation) && + pendingAcceptance()?.key === operation.requestKey + ) + localStorage.removeItem(pendingKey); + return true; + } catch { + return false; + } +} +export const phaseLabel: Record = { + accepted: "Downloading", + staged: "Verifying", + draining: "Waiting for work", + stopping: "Stopping writers and backing up", + "snapshot-complete": "Backup complete", + activating: "Switching release", + verifying: "Checking startup", + committed: "Committing update", + restoring: "Restoring previous version and data", + "rolled-back": "Previous version and data restored", + succeeded: "Update succeeded", + cancelled: "Update cancelled", + deferred: "Update deferred — work is still active", + failed: "Update failed before activation", + "manual-recovery": "Manual recovery required", +}; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 4d42e84..d88e64c 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -2,6 +2,7 @@ import * as stylex from "@stylexjs/stylex"; import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router"; import type { ReactNode } from "react"; import { App } from "../app"; +import { UpdateNotice } from "../components/update-notice"; import { getAgents } from "../features/agents/functions"; import { getComputerStatus } from "../features/computer/functions"; import { getDashboardSetting } from "../features/dashboards/functions"; @@ -93,6 +94,7 @@ function RootDocument({ children }: { children: ReactNode }) { + {children} diff --git a/src/routes/agents.$agentId_.jobs.tsx b/src/routes/agents.$agentId_.jobs.tsx index 4bf4980..44c5ef1 100644 --- a/src/routes/agents.$agentId_.jobs.tsx +++ b/src/routes/agents.$agentId_.jobs.tsx @@ -217,6 +217,7 @@ function AgentJobs({