Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ jobs:
- name: Test storage restart and editor recovery
run: >-
pnpm -C app exec vitest run
src/storage/storageRecovery.test.ts src/lib/storageRecoveryDrafts.test.ts
src/storage/storageRecovery.test.ts src/storage/editorSaveSession.test.ts
src/storage/tabRecoveryCheckpoint.test.ts src/storage/storageOwner.test.ts
src/releases/prepareTabReload.test.ts
src/storage/storageWorkerControl.test.ts src/lib/storageRecoveryDrafts.test.ts
src/components/StorageRecoveryBanner.test.tsx
--maxWorkers=2 --minWorkers=1
- name: Test explorer folder refresh
Expand Down Expand Up @@ -123,12 +126,28 @@ jobs:
src/releases/releaseDatabase.test.ts src/releases/startupRecovery.test.ts src/releases/recoverControl.test.ts src/releases/controlChunks.test.ts src/releases/recoveryOwnerLock.test.ts src/storage/releaseOwnerFailure.test.ts
src/releases/startupDiagnostics.test.ts
src/pwa/loader-worker.test.ts src/components/ReleaseControls.test.tsx
src/components/ReleaseRecoveryControls.test.tsx src/components/StorageWorkerControls.test.tsx src/releases/releaseCatalog.test.ts
- name: Use the primary Ubuntu package mirror
shell: bash
run: |
set -euo pipefail
# Match app-tests: the Azure mirror can stall browser dependencies for
# several minutes before apt falls back to the primary archive.
if [[ -f /etc/apt/apt-mirrors.txt ]]; then
sudo sed -i 's|http://azure.archive.ubuntu.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' /etc/apt/apt-mirrors.txt
fi
- run: pnpm -C app exec playwright-core install --with-deps chromium
- name: Test independent storage reconnect
if: matrix.baseline == 'current'
run: pnpm -C app run cuj:storage
- run: pnpm -C app run cuj:releases
env:
RUNME_RELEASE_BASELINE_COMMIT: ${{ matrix.baseline != 'current' && matrix.baseline || '' }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: controlled-release-evidence-${{ matrix.baseline }}
path: app/test/browser/test-output/controlled-releases/
path: |
app/test/browser/test-output/controlled-releases/
app/test/browser/test-output/storage-worker-startup/
app/test/browser/test-output/storage-worker-reconnect/
3 changes: 2 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"format": "prettier --ignore-path .prettierignore --write .",
"preview": "vite preview",
"test": "pnpm run check:documentation && vitest",
"cuj:releases": "tsc --target es2020 --module nodenext --moduleResolution nodenext --esModuleInterop --skipLibCheck --outDir test/browser/.generated test/browser/run-controlled-releases.ts test/browser/controlled-releases.ts && node test/browser/.generated/run-controlled-releases.js"
"cuj:storage": "tsc --target es2022 --module nodenext --moduleResolution nodenext --esModuleInterop --skipLibCheck --outDir test/browser/.generated test/browser/run-storage-worker-reconnect.ts test/browser/storage-worker-reconnect.ts && node test/browser/.generated/run-storage-worker-reconnect.js",
"cuj:releases": "tsc --target es2020 --module nodenext --moduleResolution nodenext --esModuleInterop --skipLibCheck --outDir test/browser/.generated test/browser/run-controlled-releases.ts test/browser/controlled-releases.ts test/browser/storage-worker-startup.ts && node test/browser/.generated/run-controlled-releases.js"
},
"dependencies": {
"@buf/googleapis_googleapis.bufbuild_es": "2.6.0-20250411203938-61b203b9a916.1",
Expand Down
9 changes: 9 additions & 0 deletions app/src/components/Actions/Actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2767,6 +2767,15 @@ describe('Actions tabs', () => {
})
})

it('renders the Pin Release flow as its own workspace tab', () => {
contextMocks.currentDoc = 'app://pin-release'
contextMocks.workspaceDocuments = [{ uri: 'app://pin-release', title: 'Pin Release' }]
render(<Actions />)
expect(screen.getByRole('tab', { name: 'Pin Release' })).toBeTruthy()
expect(screen.getByRole('button', { name: /Choose another release/ })).toBeTruthy()
expect(screen.queryByText('Recovery and rollback')).toBeNull()
})

it('renders the version information workspace document as a tab', () => {
contextMocks.currentDoc = VERSION_INFO_DOCUMENT_URI
contextMocks.workspaceDocuments = [
Expand Down
4 changes: 4 additions & 0 deletions app/src/components/Actions/Actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ import RunnerStatusTab from '../RunnerStatusTab'
import KernelStatusTab from '../KernelStatusTab'
import { NotebookDiffContent } from '../NotebookDiff/NotebookDiffView'
import VersionInfoTab from '../VersionInfoTab'
import { ReleaseRecoveryControls } from '../ReleaseRecoveryControls'
import { PIN_RELEASE_DOCUMENT_URI } from '../../lib/workspaceDocuments/workspaceDocumentTypes'
import { NotebookCommentsPanel } from '../NotebookCommentsPanel'
import AppConsole from '../AppConsole/AppConsole'
import LogsPane from '../Logs/LogsPane'
Expand Down Expand Up @@ -4533,6 +4535,8 @@ function renderWorkspaceDocument({

if (isOidcStatusUri(document.uri)) return <OidcStatusTab />

if (document.uri === PIN_RELEASE_DOCUMENT_URI) return <ReleaseRecoveryControls />

if (isVersionInfoUri(document.uri)) {
return <VersionInfoTab />
}
Expand Down
8 changes: 6 additions & 2 deletions app/src/components/ReleaseControls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,16 @@ describe('Information release controls', () => {
expect(
(
screen.getByRole('button', {
name: 'Upgrade all tabs',
name: 'Activate release and update this tab',
}) as HTMLButtonElement
).disabled
).toBe(false)
)
fireEvent.click(screen.getByRole('button', { name: 'Upgrade all tabs' }))
fireEvent.click(
screen.getByRole('button', {
name: 'Activate release and update this tab',
})
)
await waitFor(() => expect(api.upgrade).toHaveBeenCalledTimes(1))
})
it('keeps update errors visible in Information and permits retry', async () => {
Expand Down
167 changes: 65 additions & 102 deletions app/src/components/ReleaseControls.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { StorageWorkerControls } from './StorageWorkerControls'
import { showWorkspaceDocument } from '../lib/workspaceDocuments/workspaceDocumentController'
import { PIN_RELEASE_DOCUMENT_URI } from '../lib/workspaceDocuments/workspaceDocumentTypes'
import { Button } from '@radix-ui/themes'
import { useCallback, useEffect, useState } from 'react'

Expand All @@ -6,7 +9,6 @@ import {
type Control,
type Manifest,
type ReleaseProgress,
releaseIdValid,
} from '../releases/control'
import '../releases/runtime'
import type { OfflineDownloadStatus } from '../releases/runtime'
Expand All @@ -17,16 +19,41 @@ import type { OfflineDownloadStatus } from '../releases/runtime'
* the common commands and can use the independent recovery page for rollback.
*/
export function ReleaseControls() {
return (
<>
<section
aria-label="Application versions"
className="space-y-4 rounded-lg border border-nb-border bg-white p-4"
>
<div
id="release-controls-heading"
className="flex items-center justify-between gap-4"
>
<h2 className="text-base font-semibold">Application versions</h2>
<Button
onClick={() => showWorkspaceDocument(PIN_RELEASE_DOCUMENT_URI)}
>
Pin Release
</Button>
</div>
<InstalledReleaseControls />
</section>
<StorageWorkerControls />
</>
)
}

/** Installed-state controls may load independently of recovery controls. */
function InstalledReleaseControls() {
const api = window.runmeVersions
const [state, setState] = useState<Control>()
const [message, setMessage] = useState('')
const [progress, setProgress] = useState<ReleaseProgress>()
const [offline, setOffline] = useState<OfflineDownloadStatus>()
const [error, setError] = useState('')
const [readError, setReadError] = useState('')
const [busy, setBusy] = useState(false)
const [exact, setExact] = useState('')
const [cached, setCached] = useState<Manifest[]>([])
const [published, setPublished] = useState<string[]>([])

// Polling also observes other tabs and older loaders without change events.
// Ignore late responses after unmount so a closed workspace tab stays closed.
Expand All @@ -41,6 +68,7 @@ export function ReleaseControls() {
const next = await api.get()
if (!disposed) {
setState(next)
setReadError('')
setProgress(api.progress?.())
setOffline(
next.active && api.offline?.(next.active.manifest.releaseId)
Expand All @@ -53,7 +81,7 @@ export function ReleaseControls() {
)
}
} catch (e) {
if (!disposed) setError(String(e))
if (!disposed) setReadError(String(e))
} finally {
reading = false
}
Expand Down Expand Up @@ -107,20 +135,19 @@ export function ReleaseControls() {

if (!api) return <p>Release channels are available in published builds.</p>
if (!state)
return <p role="status">{error || 'Loading release information…'}</p>
return (
<p role="status">
{readError || error || 'Loading release information…'}
</p>
)
const active = state.active?.manifest
const candidate = state.candidate
const hasUpdate = candidate && candidate.releaseId !== active?.releaseId
const selection =
state.selection.kind === 'channel' ? state.selection.name : 'Pinned release'
const recoveryUrl = '/recovery.html' + location.search + location.hash

return (
<section
aria-label="Application versions"
className="space-y-4 rounded-lg border border-nb-border bg-white p-4"
>
<h2 className="text-base font-semibold">Application versions</h2>
<div id="installed-release-controls" className="space-y-4">
<dl className="grid grid-cols-[120px_1fr] gap-2">
<dt>Current channel</dt>
<dd>{selection}</dd>
Expand All @@ -145,9 +172,28 @@ export function ReleaseControls() {
</>
)}
</dl>
{window.runmeRelease &&
active &&
window.runmeRelease.active.manifest.releaseId !== active.releaseId && (
<div id="release-tab-update" role="status">
<p>
This tab is running{' '}
{window.runmeRelease.active.manifest.webCommit.slice(0, 8)}. The
selected worker uses a different version; other tabs can update
independently.
</p>
<Button
disabled={busy || !api.reloadTab}
onClick={() => void run(() => api.reloadTab!())}
>
Preserve edits and reload this tab
</Button>
</div>
)}
<p className="text-nb-text-muted">
Choose a channel to download its release in the background. Your
installed version stays active until you choose Upgrade all tabs.
selected worker version stays active until you activate the release.
Other tabs keep their current UI until you reload them individually.
</p>
<div id="release-channel-actions" className="flex flex-wrap gap-2">
<Button
Expand All @@ -172,34 +218,21 @@ export function ReleaseControls() {
>
Check for updates
</Button>
{active && (
<Button
disabled={busy || !!state.transition}
onClick={() =>
void run(() =>
api.pin
? api.pin()
: api.check({ kind: 'pinned', releaseId: active.releaseId })
)
}
>
Pin installed release
</Button>
)}
</div>
{hasUpdate && (
<Button
disabled={busy || !!state.transition}
onClick={() => void run(() => api.upgrade())}
>
Upgrade all tabs
Activate release and update this tab
</Button>
)}
{state.transition && (
<div id="release-transition-actions" className="space-y-2">
<p>
Upgrade {state.transition.phase}. Each tab must preserve its edits
before the upgrade can finish.
{state.transition.phase === 'prepare'
? 'An older upgrade is pending. Cancel it to start a new upgrade.'
: 'Starting the selected storage worker. Other tabs stay open.'}
</p>
{state.transition.phase === 'prepare' && (
<Button
Expand All @@ -214,9 +247,9 @@ export function ReleaseControls() {
</Button>
</div>
)}
{error && (
{(readError || error) && (
<p role="alert" className="text-red-700">
{error}
{readError || error}
</p>
)}
<p role="status" className="break-words text-nb-text-muted">
Expand Down Expand Up @@ -271,68 +304,6 @@ export function ReleaseControls() {
id="release-other-actions"
className="mt-3 flex flex-col items-start gap-3"
>
<label className="w-full">
Exact release ID
<input
className="mt-1 w-full rounded border border-nb-border p-2 font-mono text-xs"
value={exact}
onChange={(e) => setExact(e.target.value)}
placeholder="<commit>-<build ID>"
/>
</label>
<Button
disabled={
busy || !!state.transition || !releaseIdValid(exact.trim())
}
onClick={() =>
void run(() =>
api.check({ kind: 'pinned', releaseId: exact.trim() })
)
}
>
Download exact release
</Button>
<Button
disabled={busy}
onClick={() =>
void run(async () => {
const response = await fetch('/releases/catalog.json', {
cache: 'no-store',
credentials: 'omit',
redirect: 'error',
})
if (!response.ok)
throw new Error(
'Published releases are unavailable. Cached releases remain available below.'
)
const catalog = await response.json()
if (catalog.format !== 1 || !Array.isArray(catalog.releases))
throw new Error('Unsupported release catalog')
setPublished(
catalog.releases
.slice(-100)
.reverse()
.map(
(release: { releaseId?: unknown }) => release?.releaseId
)
.filter(releaseIdValid)
)
})
}
>
Browse published releases
</Button>
{published.map((id) => (
<Button
key={id}
disabled={busy || !!state.transition}
onClick={() =>
void run(() => api.check({ kind: 'pinned', releaseId: id }))
}
>
Download {id.slice(0, 8)} / {id.slice(41, 49)}
</Button>
))}
{api.activate &&
cached
.filter((m) => m.releaseId !== active?.releaseId)
Expand All @@ -351,16 +322,8 @@ export function ReleaseControls() {
>
Remove unused downloads
</Button>
<a
className="text-blue-600 underline"
href={recoveryUrl}
target="_blank"
rel="noopener"
>
Open recovery in another tab
</a>
</div>
</details>
</section>
</div>
)
}
Loading
Loading