From 5a56636f6679071d6ec68b851ef7932cd3222560 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:03:48 -0700 Subject: [PATCH 01/38] Bound E2E package setup and retain cancellation traces (#24617) * test: align source-control fixtures with current store contracts * Bound E2E package setup and retain cancelled-job traces --- .github/workflows/e2e.yml | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8dad06a1faf..517c77c9ddd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -176,7 +176,21 @@ jobs: # Native cache misses need the compiler, Electron needs Xvfb, and paired # Quick Open needs ripgrep. Install them in one apt transaction per shard. - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep xvfb zsh openbox x11-utils + # The Azure archive took 16 minutes for one font package; bound setup + # separately so a slow mirror cannot consume the shard's test budget. + run: &install_e2e_tools | + for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do + if [ -f "$source" ]; then + sudo sed -i 's|https*://azure\.archive\.ubuntu\.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source" + fi + done + sudo tee /etc/apt/apt.conf.d/99-orca-e2e >/dev/null <<'APTCONF' + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + Acquire::Retries "1"; + APTCONF + timeout 120 sudo apt-get update + timeout 300 sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils - uses: ./.github/actions/install-node-dependencies with: @@ -241,7 +255,7 @@ jobs: # them as an artifact makes post-mortem debugging on CI possible without # re-running locally. - name: Upload Playwright traces - if: failure() + if: failure() || cancelled() uses: actions/upload-artifact@v7 with: name: playwright-traces-${{ matrix.shard_name }} @@ -267,7 +281,7 @@ jobs: # unbounded inventory fallback; the paired fixture exercises that real boundary. # Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this # lane now receives those specs from pr.yml's SSH source mapping. - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils + run: *install_e2e_tools - uses: ./.github/actions/install-node-dependencies with: @@ -344,7 +358,7 @@ jobs: pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}" - name: Upload Playwright traces - if: failure() + if: failure() || cancelled() uses: actions/upload-artifact@v7 with: name: playwright-traces-changed @@ -403,7 +417,7 @@ jobs: ref: ${{ inputs.ref || github.ref }} - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils + run: *install_e2e_tools - uses: ./.github/actions/install-node-dependencies with: @@ -459,7 +473,7 @@ jobs: fi - name: Upload watcher isolation traces - if: failure() + if: failure() || cancelled() uses: actions/upload-artifact@v7 with: name: playwright-traces-ssh-docker-watcher-isolation-${{ matrix.shard }} @@ -544,7 +558,7 @@ jobs: ORCA_E2E_FORWARD_APP_LOGS: '1' run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-localhost.spec.ts --project=electron-headless --workers=1 - uses: actions/upload-artifact@v7 - if: failure() + if: failure() || cancelled() with: name: localhost-ssh-traces path: test-results/ From a050afbe8668ac308c3b09cf368a8f6bafca238f Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 2 Oct 2026 01:06:15 -0700 Subject: [PATCH 02/38] fix(terminal): stop parking pass queueing no-op renders that trip React #185 during worktree removal (#23636) * fix(terminal): stop parking pass queueing no-op renders during pane-close bursts Removing an active worktree with many terminal panes closed each pane in its own commit. Every commit re-ran the parking pass, whose functional setters queued a render even when the sets were unchanged, so each commit left work pending and React's nested-update counter climbed past 50 (#185), taking down the terminal workbench boundary. Dispatch only when a set actually changes. * fix: initialize parking state mirror lazily and verify transitions --------- Co-authored-by: m4air Co-authored-by: m4air --- .../use-terminal-parking-pass.test.tsx | 102 ++++++++++++++++++ .../components/use-terminal-parking-pass.ts | 39 ++++--- 2 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/use-terminal-parking-pass.test.tsx diff --git a/src/renderer/src/components/use-terminal-parking-pass.test.tsx b/src/renderer/src/components/use-terminal-parking-pass.test.tsx new file mode 100644 index 00000000000..d6ed94e73b3 --- /dev/null +++ b/src/renderer/src/components/use-terminal-parking-pass.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { StrictMode, useState } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalParkingFoundation } from './use-terminal-parking-foundation' +import { useTerminalParkingPass } from './use-terminal-parking-pass' + +const parkingPass = vi.hoisted(() => ({ parkedIds: ['wt-parked'] })) + +vi.mock('./terminal-parking-pass-candidates', () => ({ + canOrdinarilyParkRetentionCandidate: () => true, + collectTerminalParkingPassCandidates: () => ({ + retentionCandidates: [], + // Fresh Set each pass, like the real selector. + nextParkedTerminalWorktreeIds: new Set(parkingPass.parkedIds), + nowMs: 0, + overrides: {}, + parkingTimers: new Map() + }) +})) +vi.mock('./terminal-pane/parked-terminal-buffer-capture', () => ({ + captureParkedTerminalBuffers: () => true +})) + +function useParkingPassHost() { + // Why own state: like the workbench's store subscriptions, an update on this fiber leaves lanes on + // its alternate, which defeats React's eager same-state bailout for the setters below. + const [revision, setRevision] = useState(0) + const [parked, setParked] = useState>(() => new Set()) + const [forceParked, setForceParked] = useState>(() => new Set()) + const [exempt, setExempt] = useState>(() => new Set()) + const controller = { + activeView: 'terminal', + activityTerminalPortals: [], + backgroundMountRevision: 0, + parkedCaptureDoneRef: { current: new Set() }, + pairedRuntimeParkingEnvironmentIds: [], + pendingStartupByTabId: {}, + renderedActiveWorktreeId: 'wt-active', + setEvictionExemptTerminalTabIds: setExempt, + setForceParkedTerminalWorktreeIds: setForceParked, + setParkedTerminalWorktreeIds: setParked, + setTerminalParkingRevision: () => {}, + tabsByWorktree: {}, + terminalParkingEnabled: true, + terminalParkingRevision: revision, + terminalProviderSnapshotCapabilityRevision: 0, + terminalRetentionBudgetEnabled: false, + terminalSshParkingEnabled: false, + workspaceSurfaceIds: [] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pass reads only the fields stubbed above. + useTerminalParkingPass(controller as unknown as TerminalParkingFoundation) + return { parked, forceParked, exempt, bump: () => setRevision((r) => r + 1) } +} + +describe('useTerminalParkingPass', () => { + beforeEach(() => { + parkingPass.parkedIds = ['wt-parked'] + }) + + it('does not queue a render when a pass produces the same parking sets', () => { + let renders = 0 + const { result } = renderHook(() => { + renders += 1 + return useParkingPassHost() + }) + expect([...result.current.parked]).toEqual(['wt-parked']) + + // Why: during a worktree removal every pty-exit commit re-runs this pass; each no-op render it + // queued kept React's nested-update counter climbing until #185. + const settled = renders + for (let i = 0; i < 5; i++) { + act(() => result.current.bump()) + } + expect(renders - settled).toBe(5) + expect([...result.current.parked]).toEqual(['wt-parked']) + }) + + it('applies changed parking decisions after unchanged passes', () => { + const { result } = renderHook(() => useParkingPassHost()) + act(() => result.current.bump()) + parkingPass.parkedIds = ['wt-other'] + act(() => result.current.bump()) + expect([...result.current.parked]).toEqual(['wt-other']) + parkingPass.parkedIds = [] + act(() => result.current.bump()) + expect([...result.current.parked]).toEqual([]) + }) + + it('keeps parking transitions working under StrictMode', () => { + const { result } = renderHook(() => useParkingPassHost(), { wrapper: StrictMode }) + expect([...result.current.parked]).toEqual(['wt-parked']) + parkingPass.parkedIds = [] + act(() => result.current.bump()) + expect([...result.current.parked]).toEqual([]) + parkingPass.parkedIds = ['wt-parked'] + act(() => result.current.bump()) + expect([...result.current.parked]).toEqual(['wt-parked']) + }) +}) diff --git a/src/renderer/src/components/use-terminal-parking-pass.ts b/src/renderer/src/components/use-terminal-parking-pass.ts index 7dde35d68cb..3bb09d67d54 100644 --- a/src/renderer/src/components/use-terminal-parking-pass.ts +++ b/src/renderer/src/components/use-terminal-parking-pass.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useAppStore } from '../store' import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, @@ -43,6 +43,12 @@ export function useTerminalParkingPass(controller: TerminalParkingFoundation): v terminalSshParkingEnabled, workspaceSurfaceIds } = controller + // Why: this effect is the sole writer, so these mirror the queued state without a render. + const dispatchedIdSetsRef = useRef<{ + parked: ReadonlySet + forceParked: ReadonlySet + evictionExempt: ReadonlySet + } | null>(null) useEffect(() => { const pass = collectTerminalParkingPassCandidates(controller) @@ -143,17 +149,26 @@ export function useTerminalParkingPass(controller: TerminalParkingFoundation): v } pass.nextParkedTerminalWorktreeIds.add(worktreeId) } - setParkedTerminalWorktreeIds((current) => - haveSameIdSet(current, pass.nextParkedTerminalWorktreeIds) - ? current - : pass.nextParkedTerminalWorktreeIds - ) - setForceParkedTerminalWorktreeIds((current) => - haveSameIdSet(current, forceParkedWorktreeIds) ? current : forceParkedWorktreeIds - ) - setEvictionExemptTerminalTabIds((current) => - haveSameIdSet(current, nextEvictionExemptTabIds) ? current : nextEvictionExemptTabIds - ) + // Why: a functional updater that returns `current` still queues a render. This pass runs in the + // synchronously flushed effects of every pty-exit commit, so those no-op renders chained a + // worktree removal's pane-close burst past React's nested-update limit (#185). + const dispatched = (dispatchedIdSetsRef.current ??= { + parked: new Set(), + forceParked: new Set(), + evictionExempt: new Set() + }) + if (!haveSameIdSet(dispatched.parked, pass.nextParkedTerminalWorktreeIds)) { + dispatched.parked = pass.nextParkedTerminalWorktreeIds + setParkedTerminalWorktreeIds(pass.nextParkedTerminalWorktreeIds) + } + if (!haveSameIdSet(dispatched.forceParked, forceParkedWorktreeIds)) { + dispatched.forceParked = forceParkedWorktreeIds + setForceParkedTerminalWorktreeIds(forceParkedWorktreeIds) + } + if (!haveSameIdSet(dispatched.evictionExempt, nextEvictionExemptTabIds)) { + dispatched.evictionExempt = nextEvictionExemptTabIds + setEvictionExemptTerminalTabIds(nextEvictionExemptTabIds) + } const retentionTtlEligibleIds = new Set( retentionBudgetCandidates .filter((candidate) => !candidate.ordinaryParkingCovers && !candidate.hasPendingSpawnWork) From 54be527fd15ed310de428f8b7a370adcbae33595 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 2 Oct 2026 01:06:19 -0700 Subject: [PATCH 03/38] fix(markdown): cap rendered size of large Markdown previews to stop renderer freezes (#23634) * fix(markdown): cap rendered markdown size in preview tab and rich override The Open Preview tab rendered any file synchronously through react-markdown with no size check, and 'Open anyway' removed the rich-editor cap entirely. A 2 MB file blocked the renderer 7.8 s at 2.3 GB (5 MB: 38 s, 4 GB), matching scan29 crash reports where users killed a frozen Orca after opening a large .md. - Preview tab: over 600 KB shows a 'Render anyway' gate instead of rendering. - Both overrides stop at a 1 MiB hard cap; above it no render is offered. * fix(markdown): gate diff-tab markdown preview by size and localize gate copy The single-file diff Preview toggle sent the whole modified side to MarkdownPreview with only the 6 MB large-diff limit, so a multi-MB .md diff still froze the renderer. Wrap it in MarkdownPreviewSizeGate keyed by the diff tab id, and add the gate's strings to all locale catalogs. --------- Co-authored-by: m4air Co-authored-by: m4air --- ...orContent.markdown-classification.test.tsx | 1 + .../src/components/editor/EditorContent.tsx | 32 +-- .../editor/EditorDiffFileSurface.tsx | 31 ++- .../editor/EditorMarkdownFileSurface.tsx | 5 +- .../editor/MarkdownPreviewSizeGate.tsx | 58 +++++ .../editor/editor-panel-render-model.ts | 8 +- .../large-markdown-render-freeze.test.tsx | 230 ++++++++++++++++++ .../components/editor/markdown-rich-mode.ts | 4 +- .../editor/markdown-rich-size-limit.test.ts | 35 ++- .../editor/markdown-rich-size-limit.ts | 42 +++- src/renderer/src/i18n/locales/en.json | 5 + src/renderer/src/i18n/locales/es.json | 7 + src/renderer/src/i18n/locales/fr.json | 5 + src/renderer/src/i18n/locales/ja.json | 5 + src/renderer/src/i18n/locales/ko.json | 5 + src/renderer/src/i18n/locales/zh.json | 5 + 16 files changed, 440 insertions(+), 38 deletions(-) create mode 100644 src/renderer/src/components/editor/MarkdownPreviewSizeGate.tsx create mode 100644 src/renderer/src/components/editor/large-markdown-render-freeze.test.tsx diff --git a/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx b/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx index 23531fc95f0..919118208cd 100644 --- a/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx +++ b/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx @@ -62,6 +62,7 @@ vi.mock('./useEditorConflictNavigation', () => ({ vi.mock('@/store', () => { const state = { markdownRichModeSizeOverridden: false, + markdownRichModeSizeOverride: {}, setMarkdownRichModeSizeOverride: () => {}, reloadOpenCheckRunDetailsTab: () => {} } diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index c488bc1d25d..74e9e6d4086 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -8,6 +8,7 @@ import { EditorConflictReviewSurface } from './EditorConflictReviewSurface' import { EditorDiffFileSurface } from './EditorDiffFileSurface' import { EditorEditFileSurface } from './EditorEditFileSurface' import { EditorFileLoadErrorView } from './EditorFileLoadErrorView' +import { MarkdownPreviewSizeGate } from './MarkdownPreviewSizeGate' import type { FileContent } from './editor-panel-content-types' import { buildPdfScalePreferenceKey } from './pdf-scale-preference-storage' import { translate } from '@/i18n/i18n' @@ -208,22 +209,25 @@ export function EditorContent({ ) } const previewSourceFileId = activeFile.markdownPreviewSourceFileId ?? activeFile.filePath + const previewContent = editBuffers[previewSourceFileId] ?? fileContent.content return (
- + + +
) } diff --git a/src/renderer/src/components/editor/EditorDiffFileSurface.tsx b/src/renderer/src/components/editor/EditorDiffFileSurface.tsx index 1597e3cef27..1a9660d2316 100644 --- a/src/renderer/src/components/editor/EditorDiffFileSurface.tsx +++ b/src/renderer/src/components/editor/EditorDiffFileSurface.tsx @@ -4,6 +4,7 @@ import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' import { getDiffContentSignature } from './diff-content-signature' import { DiffViewer, ImageDiffViewer, MarkdownPreview } from './editor-lazy-views' import { ExternalFileChangeBanner } from './ExternalFileChangeBanner' +import { MarkdownPreviewSizeGate } from './MarkdownPreviewSizeGate' import type { useMarkdownDocuments } from './useMarkdownDocuments' type MarkdownDocumentsController = ReturnType @@ -117,19 +118,25 @@ export function EditorDiffFileSurface({ )}
- + isDiff + > + +
) diff --git a/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx b/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx index 4db7c9b5045..35a840cb284 100644 --- a/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx +++ b/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx @@ -6,6 +6,7 @@ import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' import { formatBytes } from '../status-bar/workspace-space-format' import { MarkdownPreview, RichMarkdownEditor } from './editor-lazy-views' import { extractFrontMatter, prependFrontMatter } from './markdown-frontmatter' +import { exceedsMarkdownRenderOverrideSizeLimit } from './markdown-rich-size-limit' import type { MarkdownRenderState } from './markdown-render-mode' import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary' import type { useMarkdownDocuments } from './useMarkdownDocuments' @@ -57,6 +58,8 @@ export function EditorMarkdownFileSurface({ if (renderMode === 'source' && mdViewMode === 'rich') { // Why: only a size fallback is recoverable — unsupported syntax would round-trip badly, so it gets no override. const isSizeFallback = richModeUnsupportedMessage === null + const canOverrideSize = + isSizeFallback && !exceedsMarkdownRenderOverrideSizeLimit(currentContent) const richFallbackMessage = richModeUnsupportedMessage ?? translate( @@ -68,7 +71,7 @@ export function EditorMarkdownFileSurface({
{richFallbackMessage} - {isSizeFallback ? ( + {canOverrideSize ? ( + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index 97b4492750e..e7c01b40201 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -14,6 +14,7 @@ import type { FileContent } from './editor-panel-content-types' import { canUseChangesModeForFile } from './editor-panel-file-mode' import { getMarkdownRenderMode, type MarkdownRenderState } from './markdown-render-mode' import { getCachedMarkdownRichModeEligibility } from './markdown-rich-mode-eligibility-cache' +import { canRenderMarkdownAtSize } from './markdown-rich-size-limit' type StoreState = ReturnType @@ -163,7 +164,12 @@ export function getEditorPanelRenderModel({ ((activeFile.mode === 'markdown-preview' && fileContents[activeFile.id] !== undefined && fileContents[activeFile.id]?.isBinary !== true && - !fileContents[activeFile.id]?.loadError) || + !fileContents[activeFile.id]?.loadError && + canRenderMarkdownAtSize( + editorDrafts[activeFile.markdownPreviewSourceFileId ?? activeFile.filePath] ?? + fileContents[activeFile.id].content, + markdownRichModeSizeOverridden + )) || (activeFile.mode === 'edit' && fileContents[activeFile.id] !== undefined && !isChangesMode && diff --git a/src/renderer/src/components/editor/large-markdown-render-freeze.test.tsx b/src/renderer/src/components/editor/large-markdown-render-freeze.test.tsx new file mode 100644 index 00000000000..fa03f02fa00 --- /dev/null +++ b/src/renderer/src/components/editor/large-markdown-render-freeze.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' +import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' + +const store = vi.hoisted(() => { + const initialOverride: Record = {} + const state = { + markdownRichModeSizeOverride: initialOverride, + setMarkdownRichModeSizeOverride: (fileId: string, enabled: boolean): void => { + state.markdownRichModeSizeOverride = { + ...state.markdownRichModeSizeOverride, + [fileId]: enabled + } + }, + reloadOpenCheckRunDetailsTab: () => {} + } + return state +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (storeState: typeof store) => unknown) => selector(store), { + getState: () => store + }) +})) + +vi.mock('./editor-lazy-views', () => { + const view = (name: string) => () =>
+ return { + MonacoEditor: view('source'), + CombinedDiffViewer: view('combined-diff'), + RichMarkdownEditor: view('rich-editor'), + MarkdownPreview: view('preview'), + DiffViewer: view('diff'), + ImageDiffViewer: view('image-diff') + } +}) + +vi.mock('./useMarkdownDocuments', () => ({ + useMarkdownDocuments: () => ({ + markdownDocuments: [], + onOpenDocLink: () => {}, + previewProps: { markdownDocuments: [], onOpenDocument: async () => {} }, + mdSave: async () => true + }) +})) + +vi.mock('./useEditorConflictNavigation', () => ({ + useEditorConflictNavigation: () => () => undefined +})) + +import { EditorContent } from './EditorContent' +import { EditorDiffFileSurface } from './EditorDiffFileSurface' +import type { useMarkdownDocuments } from './useMarkdownDocuments' +import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' +import { MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES } from './markdown-rich-size-limit' +import { getEditorPanelRenderModel } from './editor-panel-render-model' + +// Why: preview/rich surfaces build the whole document in one task; 2 MB blocked +// the renderer 7.8 s at 2.3 GB and 5 MB 38 s at 4 GB (scan29 crash reports). +const mediumDoc = 'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES + 1024) +const hugeDoc = 'a'.repeat(MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES + 1) +// Multibyte text whose UTF-16 length is under the cap but UTF-8 size is over it. +const hugeCjkDoc = '中'.repeat(Math.ceil((MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES + 1) / 3)) + +const sourceFile: OpenFile = { + id: '/repo/BIG.md', + filePath: '/repo/BIG.md', + relativePath: 'BIG.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + isDirty: false +} +const previewTab: OpenFile = { + ...sourceFile, + id: `markdown-preview::${sourceFile.id}`, + mode: 'markdown-preview', + markdownPreviewSourceFileId: sourceFile.id +} + +function richRenderMode(content: string, sizeOverridden: boolean) { + return getEditorPanelRenderModel({ + activeFile: sourceFile, + fileContents: { [sourceFile.id]: { content, isBinary: false } }, + editorDrafts: {}, + gitStatusEntries: undefined, + gitBranchEntries: undefined, + markdownViewMode: { [sourceFile.id]: 'rich' }, + markdownRichModeSizeOverridden: sizeOverridden, + isChangesMode: false, + canOpenWorkspaceFileBrowser: true + }).inlineMarkdownRenderState?.renderMode +} + +function renderPreviewTab(content: string) { + const fileContents = { [previewTab.id]: { content, isBinary: false } } + const props = { + activeFile: previewTab, + viewStateScopeId: previewTab.id, + fileContents, + diffContents: {}, + editBuffers: {}, + openFiles: [sourceFile, previewTab], + worktreeEntries: [], + resolvedLanguage: 'markdown', + isMarkdown: true, + isMermaid: false, + isCsv: false, + isNotebook: false, + mdViewMode: 'rich' as const, + inlineMarkdownRenderState: null, + isChangesMode: false, + sideBySide: false, + pendingEditorReveal: null, + handleContentChange: vi.fn(), + handleContentChangeForFile: vi.fn(), + handleDirtyStateHint: vi.fn(), + handleSave: vi.fn(), + handleSaveForFile: vi.fn(), + reloadContent: vi.fn() + } + const view = render() + return { + view, + rerender: () => view.rerender(), + isPreviewRendered: () => view.container.querySelector('[data-editor-view="preview"]') !== null + } +} + +const diffTab: OpenFile = { + ...sourceFile, + id: 'diff::unstaged::/repo/BIG.md', + mode: 'diff', + diffSource: 'unstaged' +} + +const markdownDocumentsStub: ReturnType = { + markdownDocuments: [], + openMarkdownDocument: async () => {}, + onOpenDocLink: () => {}, + previewProps: { markdownDocuments: [], onOpenDocument: async () => {} }, + mdSave: async () => true +} + +function renderDiffPreview(content: string) { + const diffContent: GitDiffResult = { + kind: 'text', + originalContent: '', + modifiedContent: content, + originalIsBinary: false, + modifiedIsBinary: false + } + const props = { + activeFile: diffTab, + diffContent, + editBuffer: undefined, + resolvedLanguage: 'markdown', + sideBySide: false, + viewStateScopeId: diffTab.id, + diffViewStateKey: diffTab.id, + mdViewMode: 'preview' as const, + isMarkdown: true, + showMarkdownTableOfContents: false, + onCloseMarkdownTableOfContents: vi.fn(), + markdownAnnotationsEnabled: false, + markdownDocuments: markdownDocumentsStub, + onContentChange: vi.fn(), + onSave: vi.fn(), + reloadContent: vi.fn() + } + const view = render() + return { + rerender: () => view.rerender(), + isPreviewRendered: () => view.container.querySelector('[data-editor-view="preview"]') !== null + } +} + +afterEach(() => { + cleanup() + store.markdownRichModeSizeOverride = {} +}) + +describe('large markdown render guard', () => { + it('"Open anyway" still reaches the rich editor below the hard cap', () => { + expect(richRenderMode(mediumDoc, false)).toBe('source') + expect(richRenderMode(mediumDoc, true)).toBe('rich-editor') + }) + + it('"Open anyway" cannot push a document past the hard cap into the rich editor', () => { + expect(richRenderMode(hugeDoc, true)).toBe('source') + expect(richRenderMode(hugeCjkDoc, true)).toBe('source') + }) + + it('preview tab renders small documents directly', () => { + expect(renderPreviewTab('# Small').isPreviewRendered()).toBe(true) + }) + + it('preview tab gates documents over the preview limit behind "Render anyway"', () => { + const tab = renderPreviewTab(mediumDoc) + expect(tab.isPreviewRendered()).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Render anyway' })) + tab.rerender() + expect(tab.isPreviewRendered()).toBe(true) + }) + + it('preview tab never renders documents over the hard cap', () => { + store.markdownRichModeSizeOverride = { [previewTab.id]: true } + const tab = renderPreviewTab(hugeDoc) + expect(tab.isPreviewRendered()).toBe(false) + expect(screen.queryByRole('button', { name: 'Render anyway' })).toBeNull() + }) + + it('diff preview toggle gates the modified side over the preview limit', () => { + expect(renderDiffPreview('# Small').isPreviewRendered()).toBe(true) + cleanup() + const diff = renderDiffPreview(mediumDoc) + expect(diff.isPreviewRendered()).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Render anyway' })) + diff.rerender() + expect(diff.isPreviewRendered()).toBe(true) + }) + + it('diff preview toggle never renders a modified side over the hard cap', () => { + store.markdownRichModeSizeOverride = { [diffTab.id]: true } + expect(renderDiffPreview(hugeDoc).isPreviewRendered()).toBe(false) + expect(screen.queryByRole('button', { name: 'Render anyway' })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/editor/markdown-rich-mode.ts b/src/renderer/src/components/editor/markdown-rich-mode.ts index a499acd02dd..cced3ef52c1 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.ts @@ -2,7 +2,7 @@ import { defaultSchema } from 'rehype-sanitize' import { normalizeDetailsOpeningTag } from './details-markdown-html' import { getRichMarkdownRoundTripOutput } from './markdown-round-trip' import { extractFrontMatter } from './markdown-frontmatter' -import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit' +import { canRenderMarkdownAtSize } from './markdown-rich-size-limit' import { translate } from '@/i18n/i18n' export type MarkdownRichModeUnsupportedReason = @@ -141,7 +141,7 @@ export function getMarkdownRichModeEligibilityDecision({ sizeOverridden: boolean }): MarkdownRichModeEligibilityDecision { return { - exceedsSizeLimit: !sizeOverridden && exceedsMarkdownRichModeSizeLimit(content), + exceedsSizeLimit: !canRenderMarkdownAtSize(content, sizeOverridden), unsupportedReason: getMarkdownRichModeUnsupportedReason(content) } } diff --git a/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts b/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts index 3539ffc565f..47a9447ce56 100644 --- a/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts +++ b/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' -import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit' +import { + MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES, + canRenderMarkdownAtSize, + exceedsMarkdownRenderOverrideSizeLimit, + exceedsMarkdownRichModeSizeLimit +} from './markdown-rich-size-limit' describe('exceedsMarkdownRichModeSizeLimit', () => { it('uses a 600 KB default rich-mode ceiling', () => { @@ -23,3 +28,31 @@ describe('exceedsMarkdownRichModeSizeLimit', () => { ).toBe(true) }) }) + +describe('exceedsMarkdownRenderOverrideSizeLimit', () => { + it('allows exactly the override limit and rejects one byte over', () => { + const limit = MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES + expect(exceedsMarkdownRenderOverrideSizeLimit('a'.repeat(limit))).toBe(false) + expect(exceedsMarkdownRenderOverrideSizeLimit('a'.repeat(limit + 1))).toBe(true) + }) + + it('counts UTF-8 bytes, not UTF-16 length', () => { + const limit = MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES + // 3-byte CJK chars: floor(limit / 3) fit, one more does not. + expect(exceedsMarkdownRenderOverrideSizeLimit('中'.repeat(Math.floor(limit / 3)))).toBe(false) + expect(exceedsMarkdownRenderOverrideSizeLimit('中'.repeat(Math.floor(limit / 3) + 1))).toBe( + true + ) + // Surrogate pairs are 2 UTF-16 units but 4 bytes. + expect(exceedsMarkdownRenderOverrideSizeLimit('😀'.repeat(limit / 4))).toBe(false) + expect(exceedsMarkdownRenderOverrideSizeLimit(`${'😀'.repeat(limit / 4)}a`)).toBe(true) + }) + + it('lets the override lift only the rich limit, never the hard cap', () => { + const medium = 'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES + 1) + const huge = 'a'.repeat(MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES + 1) + expect(canRenderMarkdownAtSize(medium, false)).toBe(false) + expect(canRenderMarkdownAtSize(medium, true)).toBe(true) + expect(canRenderMarkdownAtSize(huge, true)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-rich-size-limit.ts b/src/renderer/src/components/editor/markdown-rich-size-limit.ts index b6ec31ee8fb..934d6e20d93 100644 --- a/src/renderer/src/components/editor/markdown-rich-size-limit.ts +++ b/src/renderer/src/components/editor/markdown-rich-size-limit.ts @@ -1,14 +1,42 @@ import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' -const richMarkdownSizeEncoder = new TextEncoder() -// Why: rich-mode eligibility is checked during render-model work, so this -// avoids allocating a large Uint8Array every time markdown content changes. -const richMarkdownSizeBuffer = new Uint8Array(RICH_MARKDOWN_MAX_SIZE_BYTES + 1) +// Why: "Open anyway"/"Render anyway" still build the whole document in one task; +// in Electron 43 on M-series a ~1 MB preview blocks 3.8 s at 1.3 GB and 2 MB +// blocks 7.8 s at 2.3 GB, so no override renders past this. +export const MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES = 1024 * 1024 -export function exceedsMarkdownRichModeSizeLimit(markdownContent: string): boolean { - const probe = richMarkdownSizeEncoder.encodeInto(markdownContent, richMarkdownSizeBuffer) +const markdownSizeEncoder = new TextEncoder() +// Why: size checks run during render-model work, so this avoids allocating a +// large Uint8Array every time markdown content changes. +const markdownSizeBuffer = new Uint8Array( + Math.max(RICH_MARKDOWN_MAX_SIZE_BYTES, MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES) + 1 +) +function exceedsUtf8ByteLimit(content: string, limit: number): boolean { + // Why: each UTF-16 unit encodes to 1-3 bytes, so most sizes resolve without encoding. + if (content.length > limit) { + return true + } + if (content.length * 3 <= limit) { + return false + } + const probe = markdownSizeEncoder.encodeInto(content, markdownSizeBuffer.subarray(0, limit + 1)) // Why: encodeInto() never writes partial UTF-8 sequences. A multibyte // character can leave written at the exact limit while unread content remains. - return probe.written > RICH_MARKDOWN_MAX_SIZE_BYTES || probe.read < markdownContent.length + return probe.written > limit || probe.read < content.length +} + +export function exceedsMarkdownRichModeSizeLimit(markdownContent: string): boolean { + return exceedsUtf8ByteLimit(markdownContent, RICH_MARKDOWN_MAX_SIZE_BYTES) +} + +export function exceedsMarkdownRenderOverrideSizeLimit(markdownContent: string): boolean { + return exceedsUtf8ByteLimit(markdownContent, MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES) +} + +/** Whether a rendered (rich or preview) markdown surface may build this document. */ +export function canRenderMarkdownAtSize(markdownContent: string, sizeOverridden: boolean): boolean { + return sizeOverridden + ? !exceedsMarkdownRenderOverrideSizeLimit(markdownContent) + : !exceedsMarkdownRichModeSizeLimit(markdownContent) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 21fcdf4beea..e2a1494d6eb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -119,6 +119,11 @@ "tooLarge": "File is larger than the {{limit}} rich editing limit. Showing source mode instead.", "openAnyway": "Open anyway" }, + "markdownPreview": { + "tooLarge": "File is larger than the {{limit}} preview limit. Open the file to view its source.", + "tooLargeInDiff": "File is larger than the {{limit}} preview limit. Switch to source mode to view the diff.", + "renderAnyway": "Render anyway" + }, "fileLoad": { "hostUnresolved": "The host couldn't find this file's workspace. It may have been removed, or the host may not know about it yet. Retry, or close this tab from the tab strip." } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c1dc27fd97d..45afe6f04a0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15543,5 +15543,12 @@ "addTypedPath": "Add", "noDirectories": "No folders added yet.", "removePath": "Remove {{name}}" + }, + "editor": { + "markdownPreview": { + "tooLarge": "El archivo supera el límite de vista previa de {{limit}}. Abre el archivo para ver su código fuente.", + "tooLargeInDiff": "El archivo supera el límite de vista previa de {{limit}}. Cambia al modo fuente para ver las diferencias.", + "renderAnyway": "Renderizar de todos modos" + } } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index cc9d5683689..376d83373bb 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18464,6 +18464,11 @@ }, "fileLoad": { "hostUnresolved": "L'hôte n'a pas pu trouver l'espace de travail de ce fichier. Il a peut-être été supprimé ou l'hôte n'en est peut-être pas encore au courant. Réessayez ou fermez cet onglet à partir de la bande d'onglets." + }, + "markdownPreview": { + "tooLarge": "Le fichier dépasse la limite d'aperçu de {{limit}}. Ouvrez le fichier pour afficher sa source.", + "tooLargeInDiff": "Le fichier dépasse la limite d'aperçu de {{limit}}. Passez en mode source pour afficher les différences.", + "renderAnyway": "Afficher quand même" } }, "checksPanel": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 485f45e6e6f..84cc645254d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18416,6 +18416,11 @@ }, "fileLoad": { "hostUnresolved": "ホストはこのファイルのワークスペースを見つけることができませんでした。削除されたか、ホストがまだそれを認識していない可能性があります。再試行するか、タブストリップからこのタブを閉じてください。" + }, + "markdownPreview": { + "tooLarge": "ファイルがプレビュー上限の {{limit}} を超えています。ソースを表示するにはファイルを開いてください。", + "tooLargeInDiff": "ファイルがプレビュー上限の {{limit}} を超えています。差分を表示するにはソースモードに切り替えてください。", + "renderAnyway": "それでも表示" } }, "featureTips": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 23f07a9d2e9..1e340fdc837 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18416,6 +18416,11 @@ }, "fileLoad": { "hostUnresolved": "호스트가 이 파일의 워크스페이스를 찾을 수 없습니다. 삭제되었거나 호스트가 아직 이에 대해 알지 못할 수도 있습니다. 다시 시도하거나 탭 표시줄에서 이 탭을 닫으세요." + }, + "markdownPreview": { + "tooLarge": "파일이 미리보기 한도 {{limit}}보다 큽니다. 소스를 보려면 파일을 여세요.", + "tooLargeInDiff": "파일이 미리보기 한도 {{limit}}보다 큽니다. 차이를 보려면 소스 모드로 전환하세요.", + "renderAnyway": "그래도 렌더링" } }, "featureTips": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6b13b751dd6..b0b9759f4fa 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18416,6 +18416,11 @@ }, "fileLoad": { "hostUnresolved": "主机找不到该文件的工作区。它可能已被删除,或者主机可能还不知道。重试,或从选项卡栏中关闭此选项卡。" + }, + "markdownPreview": { + "tooLarge": "文件超过 {{limit}} 预览限制。请打开文件查看源代码。", + "tooLargeInDiff": "文件超过 {{limit}} 预览限制。请切换到源代码模式查看差异。", + "renderAnyway": "仍然渲染" } }, "featureTips": { From e3621295e67b4e19e97c13cf1754b32b07f7debe Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:09:06 -0700 Subject: [PATCH 04/38] Remove empty passing sentinels from opt-in socket tests (#24621) * test: align source-control fixtures with current store contracts * Bound E2E package setup and retain cancelled-job traces * Remove empty passing sentinels from opt-in socket tests --- .../transport/cellular-handshake-stall-real-socket.test.ts | 7 ------- mobile/src/transport/rpc-client-live-recovery.test.ts | 7 ------- 2 files changed, 14 deletions(-) diff --git a/mobile/src/transport/cellular-handshake-stall-real-socket.test.ts b/mobile/src/transport/cellular-handshake-stall-real-socket.test.ts index 3cb87609c16..f5a1afda061 100644 --- a/mobile/src/transport/cellular-handshake-stall-real-socket.test.ts +++ b/mobile/src/transport/cellular-handshake-stall-real-socket.test.ts @@ -127,10 +127,3 @@ describe.runIf(RUN_LIVE)('issue #10119 — real socket, handshake slower than th expect(labels.slice(firstEscalated).every((l) => l !== 'Connecting…')).toBe(true) }, 60_000) }) - -// Why: vitest fails a file with zero tests; keep a sentinel for default runs. -describe.runIf(!RUN_LIVE)('real-socket handshake stall (skipped)', () => { - it('is opt-in via ORCA_MOBILE_LIVE_REPRO=1', () => { - expect(true).toBe(true) - }) -}) diff --git a/mobile/src/transport/rpc-client-live-recovery.test.ts b/mobile/src/transport/rpc-client-live-recovery.test.ts index bef276f918d..d2eea169c9f 100644 --- a/mobile/src/transport/rpc-client-live-recovery.test.ts +++ b/mobile/src/transport/rpc-client-live-recovery.test.ts @@ -199,10 +199,3 @@ describe.runIf(RUN_LIVE)('live foreground recovery (issue #5049)', () => { } ) }) - -// Why: vitest fails a file with zero tests; keep a sentinel for default runs. -describe.runIf(!RUN_LIVE)('live foreground recovery (skipped)', () => { - it('is opt-in via ORCA_MOBILE_LIVE_REPRO=1', () => { - expect(true).toBe(true) - }) -}) From 76c79f473ad2eb71caa8f91e84119c0e373f751d Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 2 Oct 2026 01:13:27 -0700 Subject: [PATCH 05/38] fix(linux): move Chromium shared memory off a tiny /dev/shm (#23751) * fix(linux): move Chromium shared memory off a tiny /dev/shm Containers such as GitHub Codespaces mount a 64 MB /dev/shm by default. When it fills, Chromium aborts the renderer (IMMEDIATE_CRASH, SIGILL/SIGTRAP) on every reload, trapping Orca in a renderer crash loop. On Linux, stat /dev/shm before app ready and append --disable-dev-shm-usage when it is under 512 MB or unreadable (ORCA_DEV_SHM=off|force overrides). Records a dev_shm_policy crash breadcrumb so future container crashes are diagnosable. * docs(linux): correct dev-shm hasSwitch rationale --------- Co-authored-by: m4air Co-authored-by: m4air --- src/main/startup/linux-dev-shm-policy.test.ts | 159 ++++++++++++++++++ src/main/startup/linux-dev-shm-policy.ts | 54 ++++++ src/main/startup/main-process-preflight.ts | 2 + 3 files changed, 215 insertions(+) create mode 100644 src/main/startup/linux-dev-shm-policy.test.ts create mode 100644 src/main/startup/linux-dev-shm-policy.ts diff --git a/src/main/startup/linux-dev-shm-policy.test.ts b/src/main/startup/linux-dev-shm-policy.test.ts new file mode 100644 index 00000000000..e3c6a6de6a7 --- /dev/null +++ b/src/main/startup/linux-dev-shm-policy.test.ts @@ -0,0 +1,159 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { appMock, statfsSyncMock, breadcrumbMock } = vi.hoisted(() => ({ + appMock: { + commandLine: { + appendSwitch: vi.fn(), + hasSwitch: vi.fn(() => false) + } + }, + statfsSyncMock: vi.fn(), + breadcrumbMock: vi.fn() +})) + +vi.mock('electron', () => ({ app: appMock })) +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal>()), + statfsSync: statfsSyncMock +})) +vi.mock('../crash-reporting/crash-breadcrumb-store', () => ({ + recordCrashBreadcrumb: breadcrumbMock +})) + +const MIB = 1024 * 1024 +const originalPlatform = process.platform +const originalOverride = process.env.ORCA_DEV_SHM + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +function mockDevShm(totalMib: number, freeMib: number): void { + // statfs reports sizes in blocks; use 4 KiB blocks like tmpfs. + const bsize = 4096 + statfsSyncMock.mockReturnValue({ + bsize, + blocks: (totalMib * MIB) / bsize, + bavail: (freeMib * MIB) / bsize + }) +} + +beforeEach(() => { + vi.resetModules() + appMock.commandLine.appendSwitch.mockReset() + appMock.commandLine.hasSwitch.mockReset() + appMock.commandLine.hasSwitch.mockReturnValue(false) + statfsSyncMock.mockReset() + breadcrumbMock.mockReset() + delete process.env.ORCA_DEV_SHM +}) + +afterEach(() => { + setPlatform(originalPlatform) + if (originalOverride === undefined) { + delete process.env.ORCA_DEV_SHM + } else { + process.env.ORCA_DEV_SHM = originalOverride + } +}) + +describe('configureLinuxDevShmUsage', () => { + it('moves Chromium shared memory off a Docker-default 64 MB /dev/shm', async () => { + setPlatform('linux') + mockDevShm(64, 60) + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + + configureLinuxDevShmUsage() + + expect(statfsSyncMock).toHaveBeenCalledWith('/dev/shm') + expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-dev-shm-usage') + expect(breadcrumbMock).toHaveBeenCalledWith('dev_shm_policy', { + devShmTotalMB: 64, + devShmFreeMB: 60, + devShmUsageDisabled: true, + reason: 'small' + }) + }) + + it('leaves a normally sized /dev/shm on the fast shared-memory path', async () => { + setPlatform('linux') + mockDevShm(8192, 8000) + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + + configureLinuxDevShmUsage() + + expect(appMock.commandLine.appendSwitch).not.toHaveBeenCalled() + expect(breadcrumbMock).toHaveBeenCalledWith('dev_shm_policy', { + devShmTotalMB: 8192, + devShmFreeMB: 8000, + devShmUsageDisabled: false, + reason: 'ok' + }) + }) + + it('disables /dev/shm usage when the mount is missing', async () => { + setPlatform('linux') + statfsSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + + configureLinuxDevShmUsage() + + expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-dev-shm-usage') + expect(breadcrumbMock).toHaveBeenCalledWith('dev_shm_policy', { + devShmUsageDisabled: true, + reason: 'unreadable' + }) + }) + + it('honors ORCA_DEV_SHM=off on a small mount and =force on a large one', async () => { + setPlatform('linux') + mockDevShm(64, 60) + process.env.ORCA_DEV_SHM = 'off' + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + configureLinuxDevShmUsage() + expect(appMock.commandLine.appendSwitch).not.toHaveBeenCalled() + + mockDevShm(8192, 8000) + process.env.ORCA_DEV_SHM = 'force' + configureLinuxDevShmUsage() + expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-dev-shm-usage') + }) + + it('does not append the switch twice when it is already on the command line', async () => { + setPlatform('linux') + mockDevShm(64, 60) + appMock.commandLine.hasSwitch.mockReturnValue(true) + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + + configureLinuxDevShmUsage() + + expect(appMock.commandLine.appendSwitch).not.toHaveBeenCalled() + }) + + it('is a no-op off Linux', async () => { + setPlatform('darwin') + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + + configureLinuxDevShmUsage() + + expect(statfsSyncMock).not.toHaveBeenCalled() + expect(appMock.commandLine.appendSwitch).not.toHaveBeenCalled() + expect(breadcrumbMock).not.toHaveBeenCalled() + }) +}) + +describe('desktop startup wiring', () => { + it('runs the /dev/shm policy for every launch before app ready, GPU fallback included', () => { + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ).replace(/\r\n/g, '\n') + const call = source.indexOf('\n configureLinuxDevShmUsage()\n') + expect(call).toBeGreaterThan(-1) + expect(call).toBeLessThan(source.indexOf('\n maybeApplyGpuFallbackForThisLaunch()\n')) + }) +}) diff --git a/src/main/startup/linux-dev-shm-policy.ts b/src/main/startup/linux-dev-shm-policy.ts new file mode 100644 index 00000000000..8f707e3e64c --- /dev/null +++ b/src/main/startup/linux-dev-shm-policy.ts @@ -0,0 +1,54 @@ +import { statfsSync } from 'node:fs' +import { app } from 'electron' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' + +const DEV_SHM_PATH = '/dev/shm' +const DEV_SHM_OVERRIDE_ENV_VAR = 'ORCA_DEV_SHM' +// Why 512: Docker/Codespaces default to 64 MB, where Chromium's ring-buffer and +// texture allocations fail and IMMEDIATE_CRASH the renderer on every reload. +const MIN_DEV_SHM_TOTAL_MIB = 512 +const MIB = 1024 * 1024 + +type DevShmReading = { totalMib: number; freeMib: number } | null + +function readDevShm(): DevShmReading { + try { + const stats = statfsSync(DEV_SHM_PATH) + return { + totalMib: Math.floor((stats.blocks * stats.bsize) / MIB), + freeMib: Math.floor((stats.bavail * stats.bsize) / MIB) + } + } catch { + return null + } +} + +/** + * On Linux hosts with a tiny or missing /dev/shm (containers, Codespaces), + * back Chromium shared memory with /tmp files instead of letting renderers abort. + */ +export function configureLinuxDevShmUsage(): void { + if (process.platform !== 'linux') { + return + } + const override = (process.env[DEV_SHM_OVERRIDE_ENV_VAR] ?? '').trim().toLowerCase() + const reading = readDevShm() + const reason = + override === 'off' || override === 'force' + ? override + : reading === null + ? 'unreadable' + : reading.totalMib < MIN_DEV_SHM_TOTAL_MIB + ? 'small' + : 'ok' + const disable = reason === 'force' || reason === 'small' || reason === 'unreadable' + // Why hasSwitch: user argv may already carry it (headless serve appends its own later). + if (disable && !app.commandLine.hasSwitch('disable-dev-shm-usage')) { + app.commandLine.appendSwitch('disable-dev-shm-usage') + } + recordCrashBreadcrumb('dev_shm_policy', { + ...(reading ? { devShmTotalMB: reading.totalMib, devShmFreeMB: reading.freeMib } : {}), + devShmUsageDisabled: disable, + reason + }) +} diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index be0254a9d43..67be830901f 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -33,6 +33,7 @@ import { } from '../updater' import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './dev-instance-identity' import { enableRendererHeapHeadroom } from './renderer-heap-headroom' +import { configureLinuxDevShmUsage } from './linux-dev-shm-policy' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup-diagnostics' import { startEventLoopStallProbe } from './event-loop-stall-probe' import { @@ -364,6 +365,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b optOutOfHiddenPageWakeUpThrottling() configureElectronNetworkCompatibility() enableRendererHeapHeadroom() + configureLinuxDevShmUsage() maybeApplyGpuFallbackForThisLaunch() if (!state.gpuFallbackActiveThisLaunch) { enableMainProcessGpuFeatures() From f20836c2962fd98350736552fa1f89c9346c2950 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 2 Oct 2026 01:20:03 -0700 Subject: [PATCH 06/38] fix(recovery): prompt instead of reloading into a repeat Windows OOM when commit is exhausted (#23886) * fix(recovery): ask instead of reloading into a repeat Windows OOM with exhausted commit When another program exhausts Windows commit (RAM + page file), the renderer OOMs, Orca auto-reloads 250 ms later, and the new renderer OOMs again within seconds (launch 13084: 3.5 s after the reload; launch 22912: 34 s). The crash-loop breaker (3 in 60 s) never opens for this cadence, so the user is never told the machine is out of memory. Keep the first automatic reload, but when a win32 reason=oom death follows another OOM within 5 minutes and the pre-gone host sample shows under 512 MB of available commit, escalate to the existing recovery prompt with a new 'low-commit' cause that names the MB left and suggests closing apps or growing the page file. Records renderer_recovery_low_commit_prompt. No-op on macOS/Linux and when commit is healthy. * fix(recovery): gate low-commit prompt on post-OOM readings and recovered deaths only - Reject pre-gone samples taken at or before the previous OOM; they miss the commit that corpse released. - Record an OOM for the repeat window only once recovery actually runs, so skipped teardown OOMs cannot suppress the next first reload. - Skip the install-ACL diagnosis on the low-commit prompt, whose text would not explain Copy Commands. * fix(recovery): read commit at gone time when no sampler tick followed the previous OOM The 10 s pre-gone sampler lands between ~3.5 s repeat OOMs only ~35% of the time, so the gate usually fell back to a silent reload. A gone-time read can only over-report free commit (the corpse already released its pages), so it can miss a prompt but never raise a false one. * fix: reject invalid low-commit readings and clarify recovery advice --------- Co-authored-by: m4air Co-authored-by: m4air --- .../low-commit-oom-recovery-gate.test.ts | 151 ++++++++++++++ .../low-commit-oom-recovery-gate.ts | 94 +++++++++ src/main/startup/main-window-actions.ts | 4 +- src/main/startup/main-window-controller.ts | 18 +- ...MainWindow-low-commit-oom-recovery.test.ts | 186 ++++++++++++++++++ ...MainWindow-renderer-crash-recovery.test.ts | 38 +--- .../window/createMainWindow-test-harness.ts | 43 ++++ src/main/window/main-window-contracts.ts | 3 + .../window/main-window-focus-lifecycle.ts | 18 ++ .../window/renderer-recovery-prompt.test.ts | 28 +++ src/main/window/renderer-recovery-prompt.ts | 69 ++++--- .../renderer-recovery-reload-watchdog.ts | 16 +- src/renderer/src/i18n/locales/en.json | 5 +- 13 files changed, 600 insertions(+), 73 deletions(-) create mode 100644 src/main/crash-reporting/low-commit-oom-recovery-gate.test.ts create mode 100644 src/main/crash-reporting/low-commit-oom-recovery-gate.ts create mode 100644 src/main/window/createMainWindow-low-commit-oom-recovery.test.ts diff --git a/src/main/crash-reporting/low-commit-oom-recovery-gate.test.ts b/src/main/crash-reporting/low-commit-oom-recovery-gate.test.ts new file mode 100644 index 00000000000..f3fe3c3904e --- /dev/null +++ b/src/main/crash-reporting/low-commit-oom-recovery-gate.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest' +import { withPlatform } from '../window/createMainWindow-test-harness' +import { + createLowCommitOomRecoveryGate, + LOW_COMMIT_REPEAT_OOM_WINDOW_MS +} from './low-commit-oom-recovery-gate' + +const OOM: Electron.RenderProcessGoneDetails = { reason: 'oom', exitCode: -536870904 } +const CRASHED: Electron.RenderProcessGoneDetails = { reason: 'crashed', exitCode: 5 } +// Launch 22912 (Scan-30 1790622432/1790622459): OOM at 19:06:54.6, reload OOMed again at 19:07:28.7. +const FIRST_OOM = Date.parse('2026-09-28T19:06:54.600Z') +const RELOAD_OOM = Date.parse('2026-09-28T19:07:28.700Z') + +function sample(swapFreeMB: number | undefined, ageMs = 4_000) { + return () => ({ + systemMemoryPreGoneSampleAgeMs: ageMs, + ...(swapFreeMB === undefined ? {} : { systemMemoryPreGoneSwapFreeMB: swapFreeMB }) + }) +} + +// A gone-time read that resolved no commit field, as off-Electron. +const NO_GONE_TIME_READING = () => ({}) + +function goneTime(swapFreeMB: number) { + return () => ({ systemMemorySwapFreeMB: swapFreeMB }) +} + +function observeTwice( + read: ReturnType, + second = OOM, + gapMs = RELOAD_OOM - FIRST_OOM, + platform: NodeJS.Platform = 'win32', + readGoneTime: () => Record = NO_GONE_TIME_READING +) { + return withPlatform(platform, () => { + const gate = createLowCommitOomRecoveryGate(read, readGoneTime) + const first = gate.assess(OOM, FIRST_OOM) + gate.recordRecoveredDeath(OOM, FIRST_OOM) + return [first, gate.assess(second, FIRST_OOM + gapMs)] + }) +} + +describe('createLowCommitOomRecoveryGate', () => { + it('holds the reload of a repeat OOM with 60 MB of commit left', () => { + expect(observeTwice(sample(60))).toEqual([ + null, + { availableCommitMB: 60, sincePreviousOomMs: 34_100, commitReading: 'pre-gone' } + ]) + }) + + it('always lets the first OOM of the launch auto-reload, even with 5 MB left', () => { + expect(observeTwice(sample(5))[0]).toBeNull() + }) + + it.each([ + ['commit is healthy (744 MB)', sample(744), OOM, 34_100, 'win32'], + [ + 'the previous OOM was over 5 minutes ago', + sample(60), + OOM, + LOW_COMMIT_REPEAT_OOM_WINDOW_MS + 1, + 'win32' + ], + ['the death is not an OOM', sample(60), CRASHED, 34_100, 'win32'], + ['no commit reading exists', sample(undefined), OOM, 34_100, 'win32'], + ['the reading is stale', sample(60, 31_000), OOM, 34_100, 'win32'], + ['the host is macOS', sample(60), OOM, 34_100, 'darwin'], + ['the host is Linux', sample(60), OOM, 34_100, 'linux'] + ] as const)('reloads when %s', (_label, read, second, gapMs, platform) => { + expect(observeTwice(read, second, gapMs, platform)[1]).toBeNull() + }) + + // Launch 13084 (Scan-31): 12:20:37.207 then 12:22:59.975; each OOM restarts the window. + it('measures the window from the most recent OOM', () => { + const verdicts = withPlatform('win32', () => { + const gate = createLowCommitOomRecoveryGate(sample(60, 2_000), NO_GONE_TIME_READING) + return [ + '2026-09-29T12:06:19.869Z', + '2026-09-29T12:20:37.207Z', + '2026-09-29T12:20:40.665Z', + '2026-09-29T12:22:59.975Z' + ].map((iso) => { + const verdict = gate.assess(OOM, Date.parse(iso)) + gate.recordRecoveredDeath(OOM, Date.parse(iso)) + return verdict + }) + }) + expect(verdicts.map((v) => v?.sincePreviousOomMs ?? null)).toEqual([null, null, 3_458, 139_310]) + }) + + // Launch 13084: the repeat OOM came 3.458 s after the previous one, inside one 10 s sampler tick. + describe('when no sampler tick landed since the previous OOM', () => { + const gapMs = 3_458 + + it.each([ + ['taken before the previous OOM', 5_000], + ['taken exactly at the previous OOM', gapMs], + ['stale', 31_000] + ])('reads commit at gone time instead of trusting a reading %s', (_label, ageMs) => { + expect(observeTwice(sample(744, ageMs), OOM, gapMs, 'win32', goneTime(60))[1]).toEqual({ + availableCommitMB: 60, + sincePreviousOomMs: gapMs, + commitReading: 'gone-time' + }) + }) + + it('reloads when the gone-time reading shows commit recovered (2029 MB)', () => { + expect(observeTwice(sample(60, 5_000), OOM, gapMs, 'win32', goneTime(2_029))[1]).toBeNull() + }) + + it('prefers a reading taken after the previous OOM over the gone-time one', () => { + expect(observeTwice(sample(60, 1_000), OOM, gapMs, 'win32', goneTime(2_029))[1]).toEqual({ + availableCommitMB: 60, + sincePreviousOomMs: gapMs, + commitReading: 'pre-gone' + }) + }) + + it('reads nothing on macOS or Linux', () => { + const readGoneTime = vi.fn(goneTime(60)) + for (const platform of ['darwin', 'linux'] as const) { + expect(observeTwice(sample(60, 5_000), OOM, gapMs, platform, readGoneTime)[1]).toBeNull() + } + expect(readGoneTime).not.toHaveBeenCalled() + }) + }) + + it.each([Number.NaN, Infinity, -1])( + 'does not block recovery on an invalid commit reading (%s)', + (commitMB) => { + expect(observeTwice(sample(commitMB), OOM, 34_100, 'win32', goneTime(commitMB))[1]).toBeNull() + } + ) + + it.each([Number.NaN, Infinity, -1])('ignores an invalid sample age (%s)', (ageMs) => { + expect(observeTwice(sample(60, ageMs), OOM, 34_100, 'win32', goneTime(2_029))[1]).toBeNull() + }) + + it.each([0, -1])('does not block recovery when the clock fails to advance (%s)', (gapMs) => { + expect(observeTwice(sample(60), OOM, gapMs, 'win32', goneTime(60))[1]).toBeNull() + }) + + it('does not start the repeat window for an OOM that was never recovered', () => { + const verdict = withPlatform('win32', () => { + const gate = createLowCommitOomRecoveryGate(sample(60, 2_000), NO_GONE_TIME_READING) + gate.assess(OOM, FIRST_OOM) + return gate.assess(OOM, RELOAD_OOM) + }) + expect(verdict).toBeNull() + }) +}) diff --git a/src/main/crash-reporting/low-commit-oom-recovery-gate.ts b/src/main/crash-reporting/low-commit-oom-recovery-gate.ts new file mode 100644 index 00000000000..f9362822745 --- /dev/null +++ b/src/main/crash-reporting/low-commit-oom-recovery-gate.ts @@ -0,0 +1,94 @@ +import type { CrashReportDetailValue } from '../../shared/crash-reporting' +import { preGoneSystemMemoryDetails } from './pre-gone-host-memory' +import { getSystemMemoryDetails, SYSTEM_MEMORY_KEY_PREFIX } from './system-memory-details' + +// Why: when Windows commit is exhausted by another program, a recovery reload OOMs again within seconds +// (launch 13084: 3.5 s after the reload; launch 22912: 34 s), so a repeat OOM on a starved host asks the user instead. +export const LOW_COMMIT_REPEAT_OOM_WINDOW_MS = 5 * 60_000 +export const LOW_COMMIT_AVAILABLE_MB_THRESHOLD = 512 +// Two missed 10 s sampler ticks: an older reading may predate the squeeze or its relief. +const LOW_COMMIT_MAX_SAMPLE_AGE_MS = 30_000 + +export type LowCommitOomVerdict = { + /** Pre-gone MEMORYSTATUSEX.ullAvailPageFile, i.e. commit still available. */ + availableCommitMB: number + sincePreviousOomMs: number + /** 'gone-time' when no sampler tick landed since the previous OOM and the gate read commit itself. */ + commitReading: 'pre-gone' | 'gone-time' +} + +export type LowCommitOomRecoveryGate = { + /** Read at gone time; returns a verdict only when auto-reload would run straight back into the OOM. */ + assess: (details: Electron.RenderProcessGoneDetails, now: number) => LowCommitOomVerdict | null + /** Call only once the death is actually recovered, so a skipped teardown OOM cannot start the repeat window. */ + recordRecoveredDeath: (details: Electron.RenderProcessGoneDetails, goneAt: number) => void +} + +type MemoryDetails = Record + +function usablePreGoneCommitMB(sample: MemoryDetails, sincePreviousOomMs: number): number | null { + const availableCommitMB = sample[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSwapFreeMB`] + const sampleAgeMs = sample[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSampleAgeMs`] + if ( + typeof availableCommitMB !== 'number' || + typeof sampleAgeMs !== 'number' || + !Number.isFinite(availableCommitMB) || + availableCommitMB < 0 || + !Number.isFinite(sampleAgeMs) || + sampleAgeMs < 0 || + sampleAgeMs > LOW_COMMIT_MAX_SAMPLE_AGE_MS || + // Why: a reading from before the previous OOM misses the commit that corpse released. + sampleAgeMs >= sincePreviousOomMs + ) { + return null + } + return availableCommitMB +} + +export function createLowCommitOomRecoveryGate( + readPreGoneDetails: (now: number) => MemoryDetails = preGoneSystemMemoryDetails, + readGoneTimeDetails: () => MemoryDetails = getSystemMemoryDetails +): LowCommitOomRecoveryGate { + let previousOomAt: number | null = null + return { + assess: (details, now) => { + const previous = previousOomAt + if ( + // Only win32 swapFree is available commit; elsewhere it is not a verdict. + process.platform !== 'win32' || + details.reason !== 'oom' || + previous === null || + !Number.isFinite(now) || + now <= previous || + now - previous > LOW_COMMIT_REPEAT_OOM_WINDOW_MS + ) { + return null + } + const sincePreviousOomMs = now - previous + const preGoneMB = usablePreGoneCommitMB(readPreGoneDetails(now), sincePreviousOomMs) + // Why fall back: a 10 s sampler misses most ~3.5 s repeat loops. A gone-time read sees commit the corpse + // already released, so it can only over-report and miss a prompt, never raise a false one. + const goneTimeMB = + preGoneMB === null ? readGoneTimeDetails()[`${SYSTEM_MEMORY_KEY_PREFIX}SwapFreeMB`] : null + const availableCommitMB = preGoneMB ?? goneTimeMB + if ( + typeof availableCommitMB !== 'number' || + !Number.isFinite(availableCommitMB) || + availableCommitMB < 0 || + availableCommitMB >= LOW_COMMIT_AVAILABLE_MB_THRESHOLD + ) { + return null + } + return { + availableCommitMB, + sincePreviousOomMs, + commitReading: preGoneMB === null ? 'gone-time' : 'pre-gone' + } + }, + recordRecoveredDeath: (details, goneAt) => { + if (details.reason === 'oom') { + previousOomAt = goneAt + } + } + } +} diff --git a/src/main/startup/main-window-actions.ts b/src/main/startup/main-window-actions.ts index 0acc8d1a074..30cdfc9c477 100644 --- a/src/main/startup/main-window-actions.ts +++ b/src/main/startup/main-window-actions.ts @@ -153,11 +153,13 @@ export function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { export async function showRendererRecoveryPrompt( recentRecoveryCount: number, failure?: RendererRecoveryPromptFailure, - retry?: () => void + retry?: () => void, + availableCommitMB?: number ): Promise { await presentRendererRecoveryPrompt({ recentRecoveryCount, ...(failure ? { failure } : {}), + ...(availableCommitMB === undefined ? {} : { availableCommitMB }), isQuitting: () => state.isQuitting, diagnose: describeInstallDirAclPoison, showMessageBox: (options) => { diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts index e2741b5d005..70f06235019 100644 --- a/src/main/startup/main-window-controller.ts +++ b/src/main/startup/main-window-controller.ts @@ -114,19 +114,27 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} reason: details.reason, expectedTeardown: getExpectedTeardownScope(webContentsId, false) }), - onRendererRecoveryExhausted: ({ details, recentRecoveryCount, cause, retry }) => { - // Why two names: a stalled reload never opened the breaker, and a bundle that says it did misreads the failure. + onRendererRecoveryExhausted: ({ details, recentRecoveryCount, cause, lowCommit, retry }) => { + // Why distinct names: a stalled reload or a low-commit hold never opened the breaker, and a bundle that says it did misreads the failure. recordDurableCrashBreadcrumb( cause === 'reload-stalled' ? 'renderer_recovery_reload_exhausted' - : 'renderer_recovery_circuit_breaker_open', + : cause === 'low-commit' + ? 'renderer_recovery_low_commit_prompt' + : 'renderer_recovery_circuit_breaker_open', { reason: details.reason, exitCode: details.exitCode ?? null, - recentRecoveryCount + recentRecoveryCount, + ...lowCommit } ) - void showRendererRecoveryPrompt(recentRecoveryCount, cause, retry) + void showRendererRecoveryPrompt( + recentRecoveryCount, + cause, + retry, + lowCommit?.availableCommitMB + ) }, deferLoad: true, ...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}), diff --git a/src/main/window/createMainWindow-low-commit-oom-recovery.test.ts b/src/main/window/createMainWindow-low-commit-oom-recovery.test.ts new file mode 100644 index 00000000000..218f48c5606 --- /dev/null +++ b/src/main/window/createMainWindow-low-commit-oom-recovery.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', async () => + (await import('./createMainWindow-test-harness')).electronModuleMock() +) +vi.mock('@electron-toolkit/utils', async () => + (await import('./createMainWindow-test-harness')).electronToolkitUtilsMock() +) +vi.mock('./macos-tahoe-release', async () => + (await import('./createMainWindow-test-harness')).macosTahoeReleaseMock() +) +vi.mock('../app-icon', async () => (await import('./createMainWindow-test-harness')).appIconMock()) +vi.mock('../browser/browser-manager', async () => + (await import('./createMainWindow-test-harness')).browserManagerMock() +) +vi.mock('../browser/browser-client-page-renderer-runtime', async () => { + const harness = await import('./createMainWindow-test-harness') + return { + attachBrowserClientPageRenderer: harness.attachClientPageRendererMock, + retireBrowserClientPageRenderer: harness.retireClientPageRendererMock + } +}) + +import { createMainWindow } from './createMainWindow' +import { resetExpectedTeardownStateForTest } from '../crash-reporting/expected-teardown-state' +import { + resetPreGoneSystemMemorySamplingForTest, + samplePreGoneSystemMemory +} from '../crash-reporting/pre-gone-host-memory' +import { setSystemMemoryInfoReaderForTest } from '../crash-reporting/system-memory-details' +import { setSwapVolumeFreeSpaceReaderForTest } from '../crash-reporting/swap-volume-free-space' +import { + createRendererRecoveryWindowHarness, + resetMainWindowMocks, + withPlatform +} from './createMainWindow-test-harness' + +// Launch 13084 (Scan-31 1790683596/1790684449/1790684587): the second reload OOMed 3.458 s after the first. +const OOM_AT = [ + '2026-09-29T12:06:19.869Z', + '2026-09-29T12:20:37.207Z', + '2026-09-29T12:20:40.665Z', + '2026-09-29T12:22:59.975Z' +].map((iso) => Date.parse(iso)) +// Commit at the two OOMs of launch 22912 (Scan-30 1790622432/1790622459): 744 MB, then 60 MB of a 130 GB limit. +const HEALTHY_FIRST_OOM_COMMIT_MB = 744 +const EXHAUSTED_COMMIT_MB = 60 +const RECOVERED_COMMIT_MB = 2_029 + +const OOM: Electron.RenderProcessGoneDetails = { reason: 'oom', exitCode: -536870904 } + +function hostWithAvailableCommit(swapFreeMB: number): void { + setSystemMemoryInfoReaderForTest(() => ({ + total: 32_000 * 1024, + free: 976 * 1024, + swapTotal: 130_000 * 1024, + swapFree: swapFreeMB * 1024 + })) + void samplePreGoneSystemMemory(Date.now()) +} + +async function runOomSequence( + platform: NodeJS.Platform, + commitAtEachOomMB: readonly (number | null)[], + goneTimeCommitMB?: number +): Promise<{ reloads: number; onRendererRecoveryExhausted: ReturnType }> { + const onRendererRecoveryExhausted = vi.fn() + const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() + createMainWindow(null, { onRendererRecoveryExhausted }) + for (const [index, goneAt] of OOM_AT.entries()) { + const commitMB = commitAtEachOomMB[index] + // null: no sampler tick since the previous OOM. + if (commitMB !== null) { + vi.setSystemTime(goneAt - 2_000) + withPlatform(platform, () => hostWithAvailableCommit(commitMB)) + } + vi.setSystemTime(goneAt) + if (goneTimeCommitMB !== undefined) { + // Only the host changes; no sampler tick commits it. + setSystemMemoryInfoReaderForTest(() => ({ swapFree: goneTimeCommitMB * 1024 })) + } + withPlatform(platform, () => windowHandlers['render-process-gone']?.({}, OOM)) + await vi.advanceTimersByTimeAsync(250) + } + // Minus the initial load. + return { + reloads: browserWindowInstance.loadFile.mock.calls.length - 1, + onRendererRecoveryExhausted + } +} + +describe('Windows renderer OOM recovery under exhausted commit', () => { + beforeEach(() => { + resetMainWindowMocks() + resetExpectedTeardownStateForTest() + resetPreGoneSystemMemorySamplingForTest() + setSwapVolumeFreeSpaceReaderForTest(async () => undefined) + vi.useFakeTimers() + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + setSystemMemoryInfoReaderForTest(null) + setSwapVolumeFreeSpaceReaderForTest(null) + resetPreGoneSystemMemorySamplingForTest() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('stops reloading into a repeat OOM and tells the user how much commit is left', async () => { + const { reloads, onRendererRecoveryExhausted } = await runOomSequence('win32', [ + HEALTHY_FIRST_OOM_COMMIT_MB, + EXHAUSTED_COMMIT_MB, + EXHAUSTED_COMMIT_MB, + EXHAUSTED_COMMIT_MB + ]) + // The first OOM and the one 14 min later each still get their automatic reload. + expect(reloads).toBe(2) + expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ + details: OOM, + cause: 'low-commit', + lowCommit: { + availableCommitMB: EXHAUSTED_COMMIT_MB, + sincePreviousOomMs: 3_458, + commitReading: 'pre-gone' + } + }) + ) + }) + + // The common field case: the 10 s sampler has no tick in the 3.458 s between the two OOMs. + it('reads commit at gone time when no sampler tick landed since the previous OOM', async () => { + const { reloads, onRendererRecoveryExhausted } = await runOomSequence('win32', [ + HEALTHY_FIRST_OOM_COMMIT_MB, + EXHAUSTED_COMMIT_MB, + null, + EXHAUSTED_COMMIT_MB + ]) + expect(reloads).toBe(2) + expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ + cause: 'low-commit', + lowCommit: { + availableCommitMB: EXHAUSTED_COMMIT_MB, + sincePreviousOomMs: 3_458, + commitReading: 'gone-time' + } + }) + ) + }) + + it('keeps reloading when the gone-time read shows commit recovered and no tick landed', async () => { + const { reloads, onRendererRecoveryExhausted } = await runOomSequence( + 'win32', + [HEALTHY_FIRST_OOM_COMMIT_MB, EXHAUSTED_COMMIT_MB, null, null], + RECOVERED_COMMIT_MB + ) + expect(reloads).toBe(4) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) + + it('keeps auto-reloading repeat OOMs once commit has recovered', async () => { + const { reloads, onRendererRecoveryExhausted } = await runOomSequence('win32', [ + HEALTHY_FIRST_OOM_COMMIT_MB, + RECOVERED_COMMIT_MB, + RECOVERED_COMMIT_MB, + RECOVERED_COMMIT_MB + ]) + expect(reloads).toBe(4) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) + + it.each(['darwin', 'linux'] as const)('is a no-op on %s', async (platform) => { + const { reloads, onRendererRecoveryExhausted } = await runOomSequence(platform, [ + EXHAUSTED_COMMIT_MB, + EXHAUSTED_COMMIT_MB, + EXHAUSTED_COMMIT_MB, + EXHAUSTED_COMMIT_MB + ]) + expect(reloads).toBe(4) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts index b3fc210be2c..b9f425f027b 100644 --- a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts +++ b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts @@ -31,6 +31,7 @@ import { import { attachClientPageRendererMock, browserWindowMock, + createRendererRecoveryWindowHarness, resetMainWindowMocks, retireClientPageRendererMock, withPlatform @@ -349,43 +350,6 @@ describe('createMainWindow', () => { consoleError.mockRestore() }) - const createRendererRecoveryWindowHarness = () => { - const windowHandlers: Record void> = {} - const webContents = { - id: 143, - getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), - isDestroyed: vi.fn(() => false), - on: vi.fn((event, handler) => { - windowHandlers[event] = handler - }), - setZoomLevel: vi.fn(), - setBackgroundThrottling: vi.fn(), - invalidate: vi.fn(), - setWindowOpenHandler: vi.fn(), - send: vi.fn() - } - const browserWindowInstance = { - webContents, - on: vi.fn((event, handler) => { - windowHandlers[event] = handler - }), - isDestroyed: vi.fn(() => false), - isMaximized: vi.fn(() => true), - isFullScreen: vi.fn(() => false), - getSize: vi.fn(() => [1200, 800]), - setSize: vi.fn(), - maximize: vi.fn(), - show: vi.fn(), - loadFile: vi.fn(() => Promise.resolve()), - loadURL: vi.fn(() => Promise.resolve()) - } - browserWindowMock.mockImplementation(function () { - return browserWindowInstance - }) - - return { browserWindowInstance, windowHandlers } - } - it('reloads the app shell after an unexpected renderer process loss', () => { vi.useFakeTimers() diff --git a/src/main/window/createMainWindow-test-harness.ts b/src/main/window/createMainWindow-test-harness.ts index 6f7ae25da67..abf2dd8c829 100644 --- a/src/main/window/createMainWindow-test-harness.ts +++ b/src/main/window/createMainWindow-test-harness.ts @@ -146,3 +146,46 @@ export function withPlatform(platform: NodeJS.Platform, run: () => T): T { Object.defineProperty(process, 'platform', { configurable: true, value: original }) } } + +export type RendererRecoveryWindowHarness = { + browserWindowInstance: { loadFile: Mock<() => Promise>; loadURL: Mock<() => Promise> } + windowHandlers: Record void> +} + +/** A main window whose webContents events land in `windowHandlers` for suites that drive renderer recovery. */ +export function createRendererRecoveryWindowHarness(): RendererRecoveryWindowHarness { + const windowHandlers: RendererRecoveryWindowHarness['windowHandlers'] = {} + const webContents = { + id: 143, + getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), + isDestroyed: vi.fn(() => false), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(() => Promise.resolve()), + loadURL: vi.fn(() => Promise.resolve()) + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + return { browserWindowInstance, windowHandlers } +} diff --git a/src/main/window/main-window-contracts.ts b/src/main/window/main-window-contracts.ts index 5135be0fbe6..2b2e8282304 100644 --- a/src/main/window/main-window-contracts.ts +++ b/src/main/window/main-window-contracts.ts @@ -1,4 +1,5 @@ import type { KeybindingOverrides } from '../../shared/keybindings' +import type { LowCommitOomVerdict } from '../crash-reporting/low-commit-oom-recovery-gate' import type { RecoveryExhaustionCause, RecoveryReloadMilestone, @@ -31,6 +32,8 @@ export type CreateMainWindowOptions = { webContentsId: number recentRecoveryCount: number cause?: RecoveryExhaustionCause + /** Set with cause 'low-commit': the Windows commit reading that stopped the auto-reload. */ + lowCommit?: LowCommitOomVerdict /** Watched manual retry for the recovery prompt; an unwatched one cannot re-raise the prompt when it stalls too. */ retry?: () => void }) => void diff --git a/src/main/window/main-window-focus-lifecycle.ts b/src/main/window/main-window-focus-lifecycle.ts index d494992e692..44e8924cc1d 100644 --- a/src/main/window/main-window-focus-lifecycle.ts +++ b/src/main/window/main-window-focus-lifecycle.ts @@ -9,6 +9,7 @@ import { DEFAULT_RENDERER_RECOVERY_WINDOW_MS, RendererRecoveryCircuitBreaker } from '../crash-reporting/renderer-recovery-circuit-breaker' +import { createLowCommitOomRecoveryGate } from '../crash-reporting/low-commit-oom-recovery-gate' import { buildEditableContextMenuTemplate, matchingRichMarkdownContextMenuTableTarget, @@ -159,6 +160,7 @@ export function installMainWindowFocusLifecycle(args: { windowMs: DEFAULT_RENDERER_RECOVERY_WINDOW_MS, maxRecoveries: DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES }) + const lowCommitOomGate = createLowCommitOomRecoveryGate() const clearRendererRecoveryTimer = (): void => { if (rendererRecoveryTimer) { clearTimeout(rendererRecoveryTimer) @@ -187,6 +189,9 @@ export function installMainWindowFocusLifecycle(args: { ) { return } + const goneAt = Date.now() + // Why read at gone time: the sampler's next tick would see commit the corpse just released. + const lowCommit = lowCommitOomGate.assess(details, goneAt) rendererRecoveryTimer = setTimeout(() => { rendererRecoveryTimer = null if ( @@ -197,6 +202,19 @@ export function installMainWindowFocusLifecycle(args: { ) { return } + lowCommitOomGate.recordRecoveredDeath(details, goneAt) + if (lowCommit) { + // Why: a reload would OOM again on the starved host; only the user can free commit. + recoveryReloadWatchdog.escalate( + { + details, + recentRecoveryCount: rendererRecoveryCircuitBreaker.recentRecoveryCount(Date.now()) + }, + 'low-commit', + lowCommit + ) + return + } const recovery = rendererRecoveryCircuitBreaker.registerRecoveryAttempt(Date.now()) if (!recovery.allowed) { // Why: too many reloads means it will just crash again; stop and let the host surface a recovery prompt. diff --git a/src/main/window/renderer-recovery-prompt.test.ts b/src/main/window/renderer-recovery-prompt.test.ts index a26700720eb..32f6e927b56 100644 --- a/src/main/window/renderer-recovery-prompt.test.ts +++ b/src/main/window/renderer-recovery-prompt.test.ts @@ -100,6 +100,34 @@ describe('presentRendererRecoveryPrompt', () => { expect(shown[0].detail).not.toContain('times in a row') }) + it('says Windows is out of memory with the commit left, instead of blaming drivers', async () => { + const { run, shown, reload } = harness({ + failure: 'low-commit', + availableCommitMB: 60, + responses: [0] + }) + await run() + expect(shown[0].message).toBe('Windows is out of memory.') + expect(shown[0].detail).toContain('only 60 MB of memory left') + expect(shown[0].detail).toContain('increase the Windows page file size') + expect(shown[0].detail).not.toContain('graphics') + expect(shown[0].buttons).toEqual(['Reload', 'Quit']) + expect(reload).toHaveBeenCalledOnce() + }) + + it('omits Copy Commands on low commit, since its detail would not be shown', async () => { + const { run, shown, quit, copied } = harness({ + failure: 'low-commit', + availableCommitMB: 60, + diagnose: () => POISON, + responses: [1] + }) + await run() + expect(shown[0].buttons).toEqual(['Reload', 'Quit']) + expect(copied).toEqual([]) + expect(quit).toHaveBeenCalledOnce() + }) + it('quits on the last button', async () => { const { run, reload, quit } = harness({ responses: [1] }) await run() diff --git a/src/main/window/renderer-recovery-prompt.ts b/src/main/window/renderer-recovery-prompt.ts index 18ab02a8eca..bd8b3b56b52 100644 --- a/src/main/window/renderer-recovery-prompt.ts +++ b/src/main/window/renderer-recovery-prompt.ts @@ -8,6 +8,8 @@ export type RendererRecoveryPromptFailure = RecoveryExhaustionCause export type RendererRecoveryPromptDeps = { recentRecoveryCount: number failure?: RendererRecoveryPromptFailure + /** Shown with failure 'low-commit'. */ + availableCommitMB?: number isQuitting: () => boolean diagnose: () => InstallDirAclPoisonDiagnosis | null showMessageBox: (options: MessageBoxOptions) => Promise @@ -20,33 +22,46 @@ export async function presentRendererRecoveryPrompt( deps: RendererRecoveryPromptDeps ): Promise { const stalled = deps.failure === 'reload-stalled' + const lowCommit = deps.failure === 'low-commit' // Copying must preserve the only available recovery surface. while (!deps.isQuitting()) { - const diagnosis = deps.diagnose() + // Why skip: the low-commit text replaces diagnosis.detail, which would leave Copy Commands unexplained. + const diagnosis = lowCommit ? null : deps.diagnose() const buttons = [translateMain('rendererRecovery.reload', 'Reload')] if (diagnosis) { buttons.push(translateMain('rendererRecovery.copyCommands', 'Copy Commands')) } buttons.push(translateMain('rendererRecovery.quit', 'Quit')) - const recoveryDetail = stalled + const recoveryDetail = lowCommit ? translateMain( - 'rendererRecovery.stalledDetail', - 'Orca reloaded the window after a crash, but it never finished loading.' + 'rendererRecovery.lowCommitDetail', + 'Windows has only {{availableMB}} MB of memory left for apps, so the window ran out of memory again after reloading.', + { availableMB: deps.availableCommitMB ?? 0 } ) - : translateMain( - 'rendererRecovery.crashLoopDetail', - 'Orca tried to recover {{recoveryCount}} times in a row without success.', - { recoveryCount: deps.recentRecoveryCount } - ) - const causeDetail = diagnosis - ? `${diagnosis.detail}\n\n${translateMain( - 'rendererRecovery.driverFallback', - 'If that does not help, the cause is usually a graphics driver.' - )}` - : translateMain( - 'rendererRecovery.genericDetail', - 'This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.' + : stalled + ? translateMain( + 'rendererRecovery.stalledDetail', + 'Orca reloaded the window after a crash, but it never finished loading.' + ) + : translateMain( + 'rendererRecovery.crashLoopDetail', + 'Orca tried to recover {{recoveryCount}} times in a row without success.', + { recoveryCount: deps.recentRecoveryCount } + ) + const causeDetail = lowCommit + ? translateMain( + 'rendererRecovery.lowCommitAdvice', + 'Free memory by closing unused apps or Orca workspaces, or increase the Windows page file size, then click Reload.' ) + : diagnosis + ? `${diagnosis.detail}\n\n${translateMain( + 'rendererRecovery.driverFallback', + 'If that does not help, the cause is usually a graphics driver.' + )}` + : translateMain( + 'rendererRecovery.genericDetail', + 'This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.' + ) const { response } = await deps.showMessageBox({ type: 'error', buttons, @@ -54,15 +69,17 @@ export async function presentRendererRecoveryPrompt( // Escape retries instead of destroying the session. cancelId: 0, title: translateMain('rendererRecovery.title', 'Orca keeps failing to load'), - message: stalled - ? translateMain( - 'rendererRecovery.stalledMessage', - 'The app window stopped responding while reloading after a crash.' - ) - : translateMain( - 'rendererRecovery.crashLoopMessage', - 'The app window crashed repeatedly and stopped reloading automatically.' - ), + message: lowCommit + ? translateMain('rendererRecovery.lowCommitMessage', 'Windows is out of memory.') + : stalled + ? translateMain( + 'rendererRecovery.stalledMessage', + 'The app window stopped responding while reloading after a crash.' + ) + : translateMain( + 'rendererRecovery.crashLoopMessage', + 'The app window crashed repeatedly and stopped reloading automatically.' + ), detail: `${recoveryDetail}\n\n${causeDetail}` }) if (response === 1 && diagnosis) { diff --git a/src/main/window/renderer-recovery-reload-watchdog.ts b/src/main/window/renderer-recovery-reload-watchdog.ts index 1295ca13ac4..f2f013d18e0 100644 --- a/src/main/window/renderer-recovery-reload-watchdog.ts +++ b/src/main/window/renderer-recovery-reload-watchdog.ts @@ -1,6 +1,7 @@ import { is } from '@electron-toolkit/utils' import type { BrowserWindow } from 'electron' import { isSystemSessionEnding } from '../crash-reporting/expected-teardown-state' +import type { LowCommitOomVerdict } from '../crash-reporting/low-commit-oom-recovery-gate' import type { CreateMainWindowOptions, MainWindowLoadObserver } from './main-window-contracts' import { mainWindowLoadErrorCode } from './main-window-load-error-code' @@ -24,7 +25,7 @@ const MILESTONE_RANK: Record = { 'dom-ready': 2 } -export type RecoveryExhaustionCause = 'crash-loop' | 'reload-stalled' +export type RecoveryExhaustionCause = 'crash-loop' | 'reload-stalled' | 'low-commit' export type RendererRecoveryReloadWatchdog = { /** Issues a recovery reload and arms the stall watchdog. */ @@ -34,7 +35,11 @@ export type RendererRecoveryReloadWatchdog = { trigger?: RecoveryReloadTrigger ) => void /** Raises the recovery prompt at most once: a native message box cannot be dismissed, so a second one stacks. */ - escalate: (subject: RecoveryPromptSubject, cause: RecoveryExhaustionCause) => void + escalate: ( + subject: RecoveryPromptSubject, + cause: RecoveryExhaustionCause, + lowCommit?: LowCommitOomVerdict + ) => void /** * A main-frame document finished loading. Only an attempt whose load was superseded takes this as its outcome; * every other attempt settles through its own load promise, which an error page or a later navigation cannot fool. @@ -197,7 +202,11 @@ export function createRendererRecoveryReloadWatchdog(args: { ) } - const escalate = (subject: RecoveryPromptSubject, cause: RecoveryExhaustionCause): void => { + const escalate = ( + subject: RecoveryPromptSubject, + cause: RecoveryExhaustionCause, + lowCommit?: LowCommitOomVerdict + ): void => { // A new crash invalidates any document that landed while the prompt was open. documentLanded = false if (prompt) { @@ -209,6 +218,7 @@ export function createRendererRecoveryReloadWatchdog(args: { webContentsId: rendererWebContentsId, recentRecoveryCount: subject.recentRecoveryCount, cause, + ...(lowCommit ? { lowCommit } : {}), // Watch manual retries too, so another stall can offer recovery again. retry: () => retryFrom(subject) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e2a1494d6eb..1fd38350c69 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18539,7 +18539,10 @@ "genericDetail": "This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.", "title": "Orca keeps failing to load", "stalledMessage": "The app window stopped responding while reloading after a crash.", - "crashLoopMessage": "The app window crashed repeatedly and stopped reloading automatically." + "crashLoopMessage": "The app window crashed repeatedly and stopped reloading automatically.", + "lowCommitMessage": "Windows is out of memory.", + "lowCommitDetail": "Windows has only {{availableMB}} MB of memory left for apps, so the window ran out of memory again after reloading.", + "lowCommitAdvice": "Free memory by closing unused apps or Orca workspaces, or increase the Windows page file size, then click Reload." }, "notifications": { "agentStatus": { From 53930a161be4dd4420e9c71be0dadac6e9388752 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:21:11 -0700 Subject: [PATCH 07/38] Keep SSH typing replies visible during background pressure (#24629) * test: align source-control fixtures with current store contracts * Bound E2E package setup and retain cancelled-job traces * Remove empty passing sentinels from opt-in socket tests * Make SSH typing pressure fixture readiness and replies observable --- .../e2e/helpers/remote-typing-load-script.ts | 22 ++++++++ .../remote-typing-load-script.unit.test.ts | 56 +++++++++++++++++++ tests/e2e/ssh-docker-relay-perf.spec.ts | 32 ++--------- 3 files changed, 84 insertions(+), 26 deletions(-) create mode 100644 tests/e2e/helpers/remote-typing-load-script.ts create mode 100644 tests/e2e/helpers/remote-typing-load-script.unit.test.ts diff --git a/tests/e2e/helpers/remote-typing-load-script.ts b/tests/e2e/helpers/remote-typing-load-script.ts new file mode 100644 index 00000000000..f2e2c296c53 --- /dev/null +++ b/tests/e2e/helpers/remote-typing-load-script.ts @@ -0,0 +1,22 @@ +export function remoteTypingLoadScript(runId: string): string { + return [ + "process.stdin.setEncoding('utf8')", + 'if (process.stdin.isTTY) process.stdin.setRawMode(true)', + 'process.stdin.resume()', + 'const statusRow = Math.max(2, process.stdout.rows || 24)', + "process.stdout.write('\\x1b[1;' + (statusRow - 1) + 'r')", + 'let seq = 0', + 'let frame = 0', + 'let bg = null', + `process.stdout.write('REMOTE_TUI_READY_${runId}\\n')`, + "setTimeout(() => { bg = setInterval(() => { frame += 1; process.stdout.write('\\x1b[1;1HBG_' + frame + '_' + 'x'.repeat(4096) + '\\n') }, 8) }, 500)", + "process.stdin.on('data', (chunk) => {", + ' if (chunk.includes(String.fromCharCode(3))) { if (bg) clearInterval(bg); process.exit(0) }', + ' for (const char of chunk) {', + " if (char === '\\r' || char === '\\n') continue", + ' seq += 1', + ` process.stdout.write('\\x1b[' + statusRow + ';2H\\x1b[2KREMOTE_KEY_${runId}_' + seq + '_' + char)`, + ' }', + '})' + ].join(';') +} diff --git a/tests/e2e/helpers/remote-typing-load-script.unit.test.ts b/tests/e2e/helpers/remote-typing-load-script.unit.test.ts new file mode 100644 index 00000000000..2b06a5897ca --- /dev/null +++ b/tests/e2e/helpers/remote-typing-load-script.unit.test.ts @@ -0,0 +1,56 @@ +import '../../../src/main/daemon/xterm-env-polyfill' +import { EventEmitter } from 'node:events' +import { runInNewContext } from 'node:vm' +import { Terminal } from '@xterm/headless' +import { expect, it } from 'vitest' +import { remoteTypingLoadScript } from './remote-typing-load-script' + +it.each([12, 24, 40])( + 'keeps typed output visible through background pressure at %i rows', + async (rows) => { + const terminal = new Terminal({ rows, cols: 80, scrollback: 1000, allowProposedApi: true }) + const input = new EventEmitter() + const chunks: string[] = [] + let background = (): void => { + throw new Error('Background pressure did not start') + } + try { + runInNewContext(remoteTypingLoadScript('test'), { + process: { + stdin: { + isTTY: true, + setEncoding() {}, + setRawMode() {}, + resume() {}, + on: input.on.bind(input) + }, + stdout: { rows, write: (chunk: string) => chunks.push(chunk) }, + exit() {} + }, + setTimeout: (callback: () => void) => callback(), + setInterval: (callback: () => void) => { + background = callback + return 1 + }, + clearInterval() {} + }) + input.emit('data', 'ab\r\ncd') + for (let frame = 0; frame < 40; frame += 1) { + background() + } + const output = chunks.join('') + expect(output.length).toBeGreaterThan(160_000) + await new Promise((resolve) => terminal.write(output, resolve)) + const screen = Array.from( + { length: rows }, + (_, row) => + terminal.buffer.active + .getLine(terminal.buffer.active.baseY + row) + ?.translateToString(true) ?? '' + ).join('\n') + expect(screen).toContain('REMOTE_KEY_test_4_d') + } finally { + terminal.dispose() + } + } +) diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index cc7f5efbc38..2a1e00ba265 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -1,3 +1,4 @@ +import { remoteTypingLoadScript } from './helpers/remote-typing-load-script' import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' @@ -56,27 +57,6 @@ function encodedRemoteNodeCommand(script: string): string { return `node -e ${shellQuote(`eval(Buffer.from('${encoded}', 'base64').toString('utf8'))`)}` } -function remoteTypingLoadScript(runId: string): string { - return [ - "process.stdin.setEncoding('utf8')", - 'if (process.stdin.isTTY) process.stdin.setRawMode(true)', - 'process.stdin.resume()', - 'let seq = 0', - 'let frame = 0', - 'let bg = null', - `process.stdout.write('REMOTE_TUI_READY_${runId}\\n')`, - "setTimeout(() => { bg = setInterval(() => { frame += 1; process.stdout.write('BG_' + frame + '_' + 'x'.repeat(4096) + '\\n') }, 8) }, 500)", - "process.stdin.on('data', (chunk) => {", - ' if (chunk.includes(String.fromCharCode(3))) { if (bg) clearInterval(bg); process.exit(0) }', - ' for (const char of chunk) {', - " if (char === '\\r' || char === '\\n') continue", - ' seq += 1', - ` process.stdout.write('\\x1b[20;2HREMOTE_KEY_${runId}_' + seq + '_' + char + '\\n')`, - ' }', - '})' - ].join(';') -} - function remoteBackgroundFloodScript(runId: string): string { return [ "process.stdin.setEncoding('utf8')", @@ -167,7 +147,7 @@ test.describe('Docker SSH relay perf', () => { const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) const runId = String(Date.now()) - await execInTerminal(orcaPage, ptyId, `node -e ${shellQuote(remoteTypingLoadScript(runId))}`) + await execInTerminal(orcaPage, ptyId, encodedRemoteNodeCommand(remoteTypingLoadScript(runId))) await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${runId}`, 30_000, 80_000) const measurement = await measureRemoteTyping(orcaPage, ptyId, runId) const summary = `median=${measurement.medianLatencyMs.toFixed( @@ -208,7 +188,7 @@ test.describe('Docker SSH relay perf', () => { await execInTerminal( orcaPage, backgroundPtyId, - `node -e ${shellQuote(remoteBackgroundFloodScript(runId))}` + encodedRemoteNodeCommand(remoteBackgroundFloodScript(runId)) ) await waitForTerminalOutput(orcaPage, `REMOTE_ACK_FLOOD_READY_${runId}`, 30_000, 80_000) await holdSshPtyAckGate(orcaPage, [backgroundPtyId]) @@ -226,7 +206,7 @@ test.describe('Docker SSH relay perf', () => { await execInTerminal( orcaPage, activePtyId, - `node -e ${shellQuote(remoteTypingLoadScript(activeRunId))}` + encodedRemoteNodeCommand(remoteTypingLoadScript(activeRunId)) ) await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${activeRunId}`, 30_000, 80_000) const heldAckPressure = expect.poll( @@ -294,11 +274,11 @@ test.describe('Docker SSH relay perf', () => { orcaPage, ptyId, `dd if=/dev/urandom of=${shellQuote(loadFile)} bs=1M count=8 status=none && ` + - `echo LOAD_FILES_READY_${runId}` + `echo LOAD_FILES_READY_'${runId}'` ) await waitForTerminalOutput(orcaPage, `LOAD_FILES_READY_${runId}`, 60_000, 80_000) - await execInTerminal(orcaPage, ptyId, `node -e ${shellQuote(remoteTypingLoadScript(runId))}`) + await execInTerminal(orcaPage, ptyId, encodedRemoteNodeCommand(remoteTypingLoadScript(runId))) await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${runId}`, 30_000, 80_000) // Background relay pressure: continuous large file reads plus git status From 6153fbcfe465e2539b6fb17a9202ac19a4bb4d3a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:42:41 -0700 Subject: [PATCH 08/38] Reduce redundant headless server CI work (#24527) * ci: avoid unrelated headless server qualification * ci: skip headless detection for ineligible draft PRs * ci: preserve cross-host qualification and skip supplied prerequisites * ci: include Windows server cache validation in change detection --- .github/workflows/node-server-tests.yml | 55 +++++++++--- config/scripts/node-server-change-scope.mjs | 5 +- .../scripts/node-server-change-scope.test.mjs | 11 ++- .../scripts/node-server-concurrency.test.mjs | 85 +++++++++++++++++++ config/scripts/node-server-qualification.mjs | 74 ++++++++++------ .../node-server-qualification.test.mjs | 85 ++++++++++++++++++- .../orcad-template-release-workflow.test.mjs | 4 +- .../orcad-windows-prebuild-workflow.test.mjs | 2 +- docs/reference/ci-runner-efficiency.md | 41 ++++++++- 9 files changed, 317 insertions(+), 45 deletions(-) create mode 100644 config/scripts/node-server-concurrency.test.mjs diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 0a2840c60a1..449ad212864 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -18,8 +18,7 @@ on: - '.github/actions/install-node-dependencies/**' - '.github/actions/prepare-native-runtime/**' - '.github/workflows/node-server-tests.yml' - # The pull request qualifies one platform for an unflavoured change; this is where all six - # are re-qualified, so a platform break surfaces minutes after merge instead of next cron. + # Relevant main pushes qualify every platform after the dependency check. push: branches: [main] paths: @@ -60,15 +59,16 @@ on: permissions: contents: read -# Why a run-scoped group for template builds: a release call shares github.ref with main's push -# runs, and cancelling either would drop a release's template or a main qualification. +# Main pushes must finish detection before they can supersede relevant qualification. concurrency: - group: node-server-${{ inputs.build_template && format('template-{0}', github.run_id) || github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ !inputs.build_template }} + group: node-server-${{ (inputs.build_template || github.event_name == 'push') && format('run-{0}', github.run_id) || github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ !inputs.build_template && github.event_name != 'push' }} jobs: changes: - if: github.event_name == 'pull_request' + if: >- + github.event_name == 'push' || + (github.event_name == 'pull_request' && github.event.pull_request.draft != true) runs-on: ubuntu-latest timeout-minutes: 5 outputs: @@ -84,7 +84,20 @@ jobs: - name: Detect headless-server build and test inputs id: scope shell: bash + env: + PUSH_BASE: ${{ github.event.before }} + EVENT_NAME: ${{ github.event_name }} run: | + if [ "$EVENT_NAME" = push ]; then + # Compare the entire push, including multi-commit pushes and removed files. + if git fetch --no-tags --depth=1 origin "$PUSH_BASE" && + git diff --name-only --no-renames -z "$PUSH_BASE" HEAD > "$RUNNER_TEMP/node-server-changes"; then + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification + else + echo 'should_run=true' >> "$GITHUB_OUTPUT" + fi + exit 0 + fi # Compare the tested merge with its base, retaining both sides of renames. if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-changes"; then node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" @@ -94,6 +107,9 @@ jobs: persistence: needs: changes + concurrency: + group: node-server-persistence-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }} # Missing/failed detection runs the full matrix; manual runs remain unconditional. # A draft carries no platform verdict; readiness re-triggers this workflow. Spelled against # the event name so the push and schedule paths do not rest on a null property comparison. @@ -126,10 +142,10 @@ jobs: continue-on-error: true shell: bash run: node config/scripts/orcad-windows-prebuild-cache.mjs --fingerprint - - name: Restore the exact Windows server prebuild for this pull request + - name: Restore the exact Windows server prebuild id: orcad-prebuild-cache-restore if: >- - github.event_name == 'pull_request' && + (github.event_name == 'pull_request' || github.event_name == 'push') && steps.orcad-prebuild-cache-identity.outcome == 'success' && steps.orcad-prebuild-cache-identity.outputs.key != '' continue-on-error: true @@ -234,6 +250,9 @@ jobs: linux_glibc_floor: needs: [changes, persistence] + concurrency: + group: node-server-linux_glibc_floor-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }} # A failed smoke already blocks qualification; missing scope still selects every platform. if: >- ${{ !cancelled() && needs.persistence.result == 'success' && @@ -262,7 +281,17 @@ jobs: PYTHON: /opt/python/cp312-cp312/bin/python3 steps: - name: Install glibc 2.28 prerequisites - run: dnf install -y git procps-ng unzip which xz + run: | + missing_tool=false + for tool in git ps unzip which xz; do + if ! command -v "$tool" >/dev/null 2>&1; then + missing_tool=true + fi + done + if [ "$missing_tool" = true ]; then + # The image's source-built Git needs no RPM; missing tools come from AlmaLinux. + dnf --disablerepo='epel*' install -y git procps-ng unzip which xz + fi - uses: actions/checkout@v6 with: ref: ${{ inputs.ref }} @@ -289,6 +318,9 @@ jobs: linux_glibc217_compat: needs: [changes, persistence] + concurrency: + group: node-server-linux_glibc217_compat-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }} # A failed smoke already blocks qualification; missing scope still selects every platform. if: >- ${{ !cancelled() && needs.persistence.result == 'success' && @@ -341,6 +373,9 @@ jobs: linux_musl: needs: [changes, persistence] + concurrency: + group: node-server-linux_musl-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }} # A failed smoke already blocks qualification; missing scope still selects every platform. if: >- ${{ !cancelled() && needs.persistence.result == 'success' && diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index d190855f7c0..342e5ad7c3a 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -17,6 +17,7 @@ const BUILD_SCRIPTS = [ 'config/scripts/pinned-node-downloads.mjs', 'config/scripts/build-orcad.mjs', 'config/scripts/build-orcad-prebuilds.mjs', + 'config/scripts/orcad-windows-prebuild-cache.mjs', 'config/scripts/orcad-prebuild-smoke-child.cjs', 'config/scripts/build-windows-process-tree-relay-addon.mjs', 'config/scripts/run-node-server-tests.mjs', @@ -133,7 +134,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean) const result = await classifyNodeServerChanges(changedFiles) console.log(result.reason) - const policy = nodeServerQualification(changedFiles, result) + const policy = nodeServerQualification(changedFiles, result, { + fullQualification: process.argv.includes('--full-qualification') + }) const output = `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n` if (process.env.GITHUB_OUTPUT) { appendFileSync(process.env.GITHUB_OUTPUT, output) diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 78b511dc1a8..6c1903f8031 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -113,6 +113,7 @@ describe('the actual Bun build and profile-test dependency graph', () => { 'src/main/worker-thread-entry-path.ts', 'config/scripts/zip-extractor-command.mjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', + 'config/scripts/orcad-windows-prebuild-cache.mjs', 'config/scripts/profile-state-worker-smoke.mjs', 'config/scripts/vitest-host-ports-setup.ts', 'tests/e2e/daemon-running-work-probe.unit.test.ts' @@ -131,16 +132,22 @@ describe('the actual Bun build and profile-test dependency graph', () => { }) }) -it('keeps all ten platform jobs and runs them when detection is skipped or fails', () => { +it('keeps every platform job and runs them when detection is skipped or fails', () => { const workflow = parse( readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8') ) expect(workflow.on).toHaveProperty('workflow_dispatch') - expect(workflow.jobs.changes.if).toBe("github.event_name == 'pull_request'") + expect(workflow.jobs.changes.if).toBe( + "github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.draft != true)" + ) expect(workflow.jobs.changes.steps[0].with['fetch-depth']).toBe(2) expect(workflow.jobs.changes.steps[0].with['persist-credentials']).toBe(false) const detect = workflow.jobs.changes.steps.find((step) => step.id === 'scope') expect(detect.run).toContain('git diff --name-only --no-renames -z HEAD^1 HEAD') + expect(detect.env.PUSH_BASE).toBe('${{ github.event.before }}') + expect(detect.run).toContain('git fetch --no-tags --depth=1 origin "$PUSH_BASE"') + expect(detect.run).toContain('git diff --name-only --no-renames -z "$PUSH_BASE" HEAD') + expect(detect.run).toContain('node-server-changes" --full-qualification') expect(workflow.on.pull_request.types).toContain('ready_for_review') expect(workflow.on.schedule).toHaveLength(1) // A pull request may qualify one platform, so the merged commit must re-qualify all six. diff --git a/config/scripts/node-server-concurrency.test.mjs b/config/scripts/node-server-concurrency.test.mjs new file mode 100644 index 00000000000..92c863361d5 --- /dev/null +++ b/config/scripts/node-server-concurrency.test.mjs @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import { expect, it } from 'vitest' +import { parse } from 'yaml' + +const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8')) + +function context(event, runId, inputs = {}) { + return { + github: { + event_name: event, + run_id: runId, + ref: 'refs/heads/main', + event: { pull_request: { number: 123 } } + }, + inputs: { build_template: false, ref: '', ...inputs }, + matrix: { os: 'windows-2022' }, + format: (template, value) => template.replace('{0}', value) + } +} + +function expression(source, ctx) { + return runInNewContext(source.slice(3, -2).trim(), ctx) +} + +function group(policy, ctx) { + return policy.group.replace(/\$\{\{([\s\S]*?)\}\}/g, (_match, source) => + String(runInNewContext(source, ctx)) + ) +} + +it('skips draft detection and rechecks the same draft once it is ready', () => { + const draft = context('pull_request', 1) + draft.github.event.pull_request.draft = true + expect(runInNewContext(workflow.jobs.changes.if, draft)).toBe(false) + draft.github.event.pull_request.draft = false + expect(runInNewContext(workflow.jobs.changes.if, draft)).toBe(true) + expect(workflow.on.pull_request.types).toContain('ready_for_review') + expect(runInNewContext(workflow.jobs.changes.if, context('push', 2))).toBe(true) + for (const event of ['schedule', 'workflow_dispatch', 'workflow_call']) { + expect(runInNewContext(workflow.jobs.changes.if, context(event, 2))).toBe(false) + } +}) + +it('lets main pushes finish detection without cancelling another push', () => { + const first = context('push', 1) + const second = context('push', 2) + expect(group(workflow.concurrency, first)).not.toBe(group(workflow.concurrency, second)) + expect(expression(workflow.concurrency['cancel-in-progress'], first)).toBe(false) +}) + +it('still replaces superseded pull requests at workflow level', () => { + const first = context('pull_request', 1) + const second = context('pull_request', 2) + expect(group(workflow.concurrency, first)).toBe(group(workflow.concurrency, second)) + expect(expression(workflow.concurrency['cancel-in-progress'], first)).toBe(true) +}) + +it.each(['persistence', 'linux_glibc_floor', 'linux_glibc217_compat', 'linux_musl'])( + '%s only supersedes eligible main qualification, isolating releases and nightly runs', + (name) => { + const job = workflow.jobs[name] + expect(job.if).toContain("needs.changes.outputs.should_run != 'false'") + const policy = job.concurrency + const first = context('push', 1) + const second = context('push', 2) + expect(group(policy, first)).toBe(group(policy, second)) + expect(expression(policy['cancel-in-progress'], first)).toBe(true) + for (const [event, inputs] of [ + ['schedule', {}], + ['workflow_dispatch', {}], + ['push', { build_template: true }], + ['push', { ref: 'refs/tags/v1' }] + ]) { + const isolated = context(event, 2, inputs) + expect(group(policy, isolated)).not.toBe(group(policy, first)) + expect(expression(policy['cancel-in-progress'], isolated)).toBe(false) + expect(group(policy, isolated)).not.toBe(group(policy, context(event, 3, inputs))) + } + if (job.strategy?.matrix) { + second.matrix.os = 'windows-11-arm' + expect(group(policy, second)).not.toBe(group(policy, first)) + } + } +) diff --git a/config/scripts/node-server-qualification.mjs b/config/scripts/node-server-qualification.mjs index b1faf6eef17..dc409a1c84d 100644 --- a/config/scripts/node-server-qualification.mjs +++ b/config/scripts/node-server-qualification.mjs @@ -7,42 +7,66 @@ export const NODE_SERVER_RUNNERS = [ 'windows-11-arm' ] -// Only surfaces whose behaviour actually differs per platform. Escalating on `config/`, -// `resources/` and `.github/` wholesale took 36.5% of the last 1100 commits through all six -// platforms where a platform-flavoured predicate takes 19%. -const PLATFORM_PREFIXES = [ +// Shared execution and storage changes need every host; explicit platform paths need their family. +const BUILD_PREFIXES = [ 'native/', 'config/patches/', '.github/actions/install-node-dependencies/', - '.github/actions/prepare-native-runtime/', + '.github/actions/prepare-native-runtime/' +] +// A remote target's OS does not identify the client platform that builds its commands. +const CROSS_HOST_PREFIXES = ['src/main/ssh/', 'src/main/providers/', 'src/relay/'] +const PLATFORM_PREFIXES = [ 'src/main/persistence/', 'src/main/sqlite/', 'src/main/orcad/', - 'src/main/providers/', 'src/main/daemon/', - 'src/main/ssh/', 'src/main/wsl/', - 'src/relay/', - 'src/shared/child-process/', - // Every native prebuild slot is compiled and smoked against the pinned runtime. - 'src/shared/node-runtime-pin.ts' + 'src/shared/child-process/' ] -export function nodeServerQualification(changedFiles, scope) { - const platformSpecific = changedFiles.some( - (file) => - // A root manifest can move a native dependency on every platform at once. +const PLATFORM_FAMILIES = [ + { pattern: /(?:^|[/.-])(?:windows|win32|wsl)(?:[/.-]|$)/i, prefix: 'windows-' }, + { pattern: /(?:^|[/.-])(?:macos|darwin|posix)(?:[/.-]|$)/i, prefix: 'macos-' }, + { pattern: /(?:^|[/.-])(?:linux|posix)(?:[/.-]|$)/i, prefix: 'ubuntu-' } +] + +export function nodeServerQualification(changedFiles, scope, { fullQualification = false } = {}) { + const selected = new Set(['ubuntu-22.04']) + let qualification = false + let full = fullQualification || changedFiles.length === 0 || scope.graphUnavailable === true + for (const file of changedFiles) { + // Build policy and native sources can change every slot, even with a platform in the name. + if ( !file.includes('/') || - PLATFORM_PREFIXES.some((prefix) => file.startsWith(prefix)) || - /(?:^|[/.-])(?:windows|win32|wsl|macos|darwin|linux|posix|bun|prebuilds?)(?:[/.-]|$)/i.test( - file - ) - ) - // A pull request qualifies one platform unless the change is platform-flavoured; the push to - // main re-qualifies all six, so an unescalated miss surfaces minutes after merge, not a day. - const full = changedFiles.length === 0 || scope.graphUnavailable === true || platformSpecific + BUILD_PREFIXES.some((prefix) => file.startsWith(prefix)) || + CROSS_HOST_PREFIXES.some((prefix) => file.startsWith(prefix)) || + (/(?:^|[/.-])(?:remote|ssh)(?:[/.-]|$)/i.test(file) && + PLATFORM_FAMILIES.some(({ pattern }) => pattern.test(file))) || + file === 'src/shared/node-runtime-pin.ts' || + file === '.github/workflows/node-server-tests.yml' || + file.startsWith('config/scripts/node-server-') || + /(?:^|[/.-])(?:bun|prebuilds?)(?:[/.-]|$)/i.test(file) + ) { + full = true + continue + } + const families = PLATFORM_FAMILIES.filter(({ pattern }) => pattern.test(file)) + if (families.length > 0) { + for (const { prefix } of families) { + for (const runner of NODE_SERVER_RUNNERS.filter((runner) => runner.startsWith(prefix))) { + selected.add(runner) + } + qualification ||= prefix === 'ubuntu-' + } + } else if (PLATFORM_PREFIXES.some((prefix) => file.startsWith(prefix))) { + full = true + } + } return { - qualification: full, - runners: full ? NODE_SERVER_RUNNERS : ['ubuntu-22.04'] + qualification: full || qualification, + runners: full + ? NODE_SERVER_RUNNERS + : NODE_SERVER_RUNNERS.filter((runner) => selected.has(runner)) } } diff --git a/config/scripts/node-server-qualification.test.mjs b/config/scripts/node-server-qualification.test.mjs index 480d7e45912..9c3c0bd7b27 100644 --- a/config/scripts/node-server-qualification.test.mjs +++ b/config/scripts/node-server-qualification.test.mjs @@ -34,11 +34,8 @@ it.each([ 'src/main/persistence/profile-state/store.ts', 'src/main/sqlite/database.ts', 'src/main/orcad/entry.ts', - 'src/main/runtime/windows-terminal.ts', - 'src/shared/linux-glibc.ts', 'src/main/daemon/entry.ts', 'src/relay/index.ts', - 'src/main/wsl/runner.ts', 'config/scripts/build-orcad-prebuilds.mjs', 'config/scripts/orcad-prebuild-slot-contents.mjs', 'src/shared/node-runtime-pin.ts' @@ -58,3 +55,85 @@ it('fails closed to every platform when the evidence is incomplete', () => { }).qualification ).toBe(true) }) + +it.each([ + [ + 'src/main/runtime/windows-terminal.ts', + ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], + false + ], + [ + 'src/main/windows/windows-process-table.ts', + ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], + false + ], + ['src/main/wsl/runner.ts', ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], false], + [ + 'src/main/orcad/orcad-launcher.win32.test.ts', + ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], + false + ], + ['src/main/daemon/darwin-process.ts', ['ubuntu-22.04', 'macos-14', 'macos-15-intel'], false], + ['src/shared/linux-glibc.ts', ['ubuntu-22.04', 'ubuntu-24.04-arm'], true], + [ + 'src/main/daemon/posix-process.ts', + ['ubuntu-22.04', 'ubuntu-24.04-arm', 'macos-14', 'macos-15-intel'], + true + ] +])('selects both architectures and a Linux smoke for %s', (file, runners, qualification) => { + expect(nodeServerQualification([file], scope)).toEqual({ runners, qualification }) +}) + +it('combines platform families without adding Linux compatibility work', () => { + expect( + nodeServerQualification( + ['src/main/windows/windows-process-table.ts', 'src/main/daemon/darwin-process.ts'], + scope + ) + ).toEqual({ + runners: ['ubuntu-22.04', 'macos-14', 'macos-15-intel', 'windows-2022', 'windows-11-arm'], + qualification: false + }) +}) + +it('keeps all hosts for shared changes alongside a platform-specific change', () => { + expect( + nodeServerQualification( + ['src/main/windows/windows-process-table.ts', 'src/main/daemon/entry.ts'], + scope + ) + ).toEqual({ runners: NODE_SERVER_RUNNERS, qualification: true }) +}) + +it.each([ + 'src/main/ssh/remote-node-runtime-store-windows.ts', + 'src/main/ssh/orcad-remote-node-runtime-windows.ts', + 'src/main/ssh/ssh-posix-command-wrapper.test.ts', + 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', + 'src/relay/windows-port-scan.ts', + 'src/main/runtime/windows-firewall-remote-scope.ts', + 'src/shared/remote-windows-path.ts' +])('qualifies every client platform for a remote execution input: %s', (file) => { + expect(nodeServerQualification([file], scope)).toEqual({ + runners: NODE_SERVER_RUNNERS, + qualification: true + }) +}) + +it.each([ + '.github/workflows/node-server-tests.yml', + 'config/scripts/node-server-qualification.mjs' +])('qualifies all hosts when the selection policy changes: %s', (file) => { + expect(nodeServerQualification([file], scope)).toEqual({ + runners: NODE_SERVER_RUNNERS, + qualification: true + }) +}) + +it('fully qualifies relevant main pushes even for an unflavoured change', () => { + expect( + nodeServerQualification(['src/main/runtime/rpc/methods/example.ts'], scope, { + fullQualification: true + }) + ).toEqual({ runners: NODE_SERVER_RUNNERS, qualification: true }) +}) diff --git a/config/scripts/orcad-template-release-workflow.test.mjs b/config/scripts/orcad-template-release-workflow.test.mjs index 87ea8570767..eee8d451fc3 100644 --- a/config/scripts/orcad-template-release-workflow.test.mjs +++ b/config/scripts/orcad-template-release-workflow.test.mjs @@ -25,7 +25,9 @@ describe('orcad template release wiring (design D2)', () => { build_template: { type: 'boolean', default: false } }) // A release call shares github.ref with main's push runs; neither may cancel the other. - expect(nodeServer.concurrency['cancel-in-progress']).toBe('${{ !inputs.build_template }}') + expect(nodeServer.concurrency['cancel-in-progress']).toBe( + "${{ !inputs.build_template && github.event_name != 'push' }}" + ) expect(nodeServer.concurrency.group).toContain('github.run_id') for (const lane of LANES) { const steps = nodeServer.jobs[lane].steps diff --git a/config/scripts/orcad-windows-prebuild-workflow.test.mjs b/config/scripts/orcad-windows-prebuild-workflow.test.mjs index ef2371d86e5..3d42ffd8df3 100644 --- a/config/scripts/orcad-windows-prebuild-workflow.test.mjs +++ b/config/scripts/orcad-windows-prebuild-workflow.test.mjs @@ -40,7 +40,7 @@ function context(os, arch, event, ref, template = false) { describe('Windows server prebuild cache workflow', () => { it.each([ ['pull_request', 'refs/pull/1/merge', false, true, false], - ['push', 'refs/heads/main', false, false, true], + ['push', 'refs/heads/main', false, true, true], ['schedule', 'refs/heads/main', false, false, true], ['workflow_dispatch', 'refs/heads/main', false, false, true], ['workflow_call', 'refs/heads/main', false, false, false], diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index f4443b05c43..966cbcdbb10 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -3,6 +3,43 @@ The [September 28 demand rollout](ci-demand-rollout.md) documents staged checks, unit-selection evidence, headless runtime qualification, review cancellation and daily occupancy reports. +## Headless server follow-up + +[PR #24527](https://github.com/stablyai/orca/pull/24527) adds dependency detection to +main pushes. Unrelated pushes skip qualification; relevant pushes still run all +six persistence targets and five Linux compatibility jobs. Explicit Windows or +Mac PR paths select both architectures plus a Linux smoke, while shared +execution/storage changes, SSH/provider/relay inputs, native inputs, manifests, and incomplete evidence +retain the full matrix. Main pushes use the same validated exact Windows slot +cache as PRs; nightly and release builds still compile freshly. + +Main detection runs cannot cancel each other. Only eligible qualification jobs +share main concurrency groups, so an unrelated push cannot cancel needed tests. +Release templates, explicit refs, and nightly runs remain isolated. + +Draft PRs have no server verdict, so their detector is also skipped. The existing +`ready_for_review` event performs detection and qualification once the PR is ready. +This removes the checkout and dependency installation for a result whose platform +jobs were already ineligible. + +The glibc 2.28 prerequisite step checks all five tools before installing anything. +The pinned ARM image already supplies them, including Git 2.55.0 built under +`/usr/local/bin`; installing the Git RPM does not change the Git on PATH. A +missing-tool fallback still installs the original package list and disables EPEL +for that one command. In +the baseline x64 log, EPEL metadata took 4 minutes 50 seconds to download although +every installed package came from AlmaLinux BaseOS or AppStream. The package list, +compiler image, libc floor, native smoke, and persistence tests stay unchanged. +The [DNF command reference](https://dnf.readthedocs.io/en/stable/command_ref.html) +defines `--disablerepo` as a temporary command-level filter, so later commands +retain the image's repository configuration. + +A [completed main run](https://github.com/stablyai/orca/actions/runs/36962172614) +used 42 aggregate runner-minutes across 11 test jobs. The +[latest daily demand report](https://github.com/stablyai/orca/actions/runs/36965354205) +estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are +baseline observations; post-merge savings have not yet been measured. + ## October 1 Windows and dependency cache follow-up [PR #24355](https://github.com/stablyai/orca/pull/24355) merged at `197ea3a3`. @@ -17,8 +54,8 @@ tooling is removed from ordinary PR CI. The existing dependency-native cache and the server's N-API 8 slot serve different consumers. Cache the small server slot separately, using the exact compiler image, architecture, dependency/patch/runtime inputs and compilation/validation source. -Only PR qualification restores it. Main qualification still compiles freshly and -saves after persistence/lifecycle tests and the existing x64 Node 18 handoff. +PR and main-push qualification restore it. Nightly qualification still compiles +freshly; main saves after persistence/lifecycle tests and the existing x64 Node 18 handoff. Templates and explicit-ref calls continue to compile freshly. | Hosted runner | Fresh build median | Restore median | Difference | From b49abdb1f4da6b3d62dfa9ccf3c74dc9e74d291c Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 2 Oct 2026 01:50:08 -0700 Subject: [PATCH 09/38] fix: recover renderer launch failures in the running app (#24250) * fix(recovery): back off a launch-failed renderer instead of tripping the crash breaker A renderer that the OS refused to spawn (macOS exit 1003 = LAUNCH_RESULT_FAILURE; field cause: per-user process limit, posix_spawn EAGAIN) burned the 3-reload crash-loop budget in ~750ms and raised a "graphics driver" prompt, while the condition lasted minutes. - launch-failed retries in place on a 250ms..60s backoff (~2 min), outside the breaker; a loaded document resets it. Other crash reasons keep the breaker. - Each launch failure records renderer_launch_failed_probe {spawnError} from a cheap spawn probe, so bundles name EAGAIN/EACCES/ENOENT directly. - The exhausted prompt says the process limit was hit (probe EAGAIN), drops the graphics-driver wording, keeps Try Again as default, and offers no Restart: app.relaunch also needs a free process slot and silently fails without one. * fix(recovery): skip the launch probe on Windows and probe the prompt once - Re-check quitting after the prompt's probe; don't re-probe on Copy Commands. - recordRendererLaunchFailureProbe never rejects (breadcrumb write guarded). - Windows: no spawn probe; a child per failed launch is the per-operation burst EDR scores. * test: cover quitting and duplicate renderer launch failures * test: use typed access in PTY delay regression fixture * fix: scope extended launch retries to POSIX hosts * test: cover launch probe behavior on native Windows --------- Co-authored-by: m4air Co-authored-by: m4air --- src/main/startup/main-window-actions.ts | 4 +- src/main/startup/main-window-controller.ts | 18 +- ...MainWindow-renderer-crash-recovery.test.ts | 42 ---- ...MainWindow-renderer-launch-failure.test.ts | 237 ++++++++++++++++++ .../window/main-window-focus-lifecycle.ts | 20 +- .../renderer-launch-failure-backoff.test.ts | 25 ++ .../window/renderer-launch-failure-backoff.ts | 31 +++ ...enderer-launch-failure-probe.spawn.test.ts | 8 + .../renderer-launch-failure-probe.test.ts | 109 ++++++++ .../window/renderer-launch-failure-probe.ts | 69 +++++ .../window/renderer-recovery-prompt.test.ts | 87 +++++++ src/main/window/renderer-recovery-prompt.ts | 149 +++++++---- .../renderer-recovery-reload-watchdog.ts | 6 +- src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/fr.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- .../terminal-pty-write-spy.unit.test.ts | 11 +- 19 files changed, 748 insertions(+), 103 deletions(-) create mode 100644 src/main/window/createMainWindow-renderer-launch-failure.test.ts create mode 100644 src/main/window/renderer-launch-failure-backoff.test.ts create mode 100644 src/main/window/renderer-launch-failure-backoff.ts create mode 100644 src/main/window/renderer-launch-failure-probe.spawn.test.ts create mode 100644 src/main/window/renderer-launch-failure-probe.test.ts create mode 100644 src/main/window/renderer-launch-failure-probe.ts diff --git a/src/main/startup/main-window-actions.ts b/src/main/startup/main-window-actions.ts index 30cdfc9c477..c4303b79986 100644 --- a/src/main/startup/main-window-actions.ts +++ b/src/main/startup/main-window-actions.ts @@ -20,6 +20,7 @@ import { presentRendererRecoveryPrompt, type RendererRecoveryPromptFailure } from '../window/renderer-recovery-prompt' +import { probeRendererLaunchCapacity } from '../window/renderer-launch-failure-probe' // The window module injects this callback to avoid a cycle between actions and lifecycle code. let openWindow: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow @@ -149,7 +150,7 @@ export function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { webContents?.send('ui:openCrashReport') } -// Why: on renderer crash-loop the breaker stops auto-reloading and the window goes blank, so a main-process dialog is the only retry/quit surface. +// Why: once auto-recovery gives up the window stays blank, so a main-process dialog is the only retry/quit surface. export async function showRendererRecoveryPrompt( recentRecoveryCount: number, failure?: RendererRecoveryPromptFailure, @@ -162,6 +163,7 @@ export async function showRendererRecoveryPrompt( ...(availableCommitMB === undefined ? {} : { availableCommitMB }), isQuitting: () => state.isQuitting, diagnose: describeInstallDirAclPoison, + probeLaunchCapacity: () => probeRendererLaunchCapacity(), showMessageBox: (options) => { const window = state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : undefined diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts index 70f06235019..941b3654f65 100644 --- a/src/main/startup/main-window-controller.ts +++ b/src/main/startup/main-window-controller.ts @@ -44,6 +44,7 @@ import { stopSyntheticTitleSpinnerTimer } from './synthetic-title-runtime' import { requireMainWindowServices } from './main-window-service-readiness' +import { recordRendererLaunchFailureProbe } from '../window/renderer-launch-failure-probe' const TRAY_CREATE_FALLBACK_MS = 12_000 const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 @@ -100,7 +101,7 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} state.isQuitting = false clearExpectedRendererReload() }, - onRendererProcessGone: (details, webContentsId) => + onRendererProcessGone: (details, webContentsId) => { recordProcessGoneCrash( 'renderer', 'renderer', @@ -108,7 +109,12 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} details.exitCode ?? null, { processType: 'renderer' }, webContentsId - ), + ) + // Why: launch-failed only says a spawn failed; the probe's errno (EAGAIN = process limit) names the cause. + if (details.reason === 'launch-failed' && !state.isQuitting) { + void recordRendererLaunchFailureProbe(details) + } + }, shouldRecoverRenderer: (details, webContentsId) => shouldRecoverRendererAfterProcessGone({ reason: details.reason, @@ -119,9 +125,11 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} recordDurableCrashBreadcrumb( cause === 'reload-stalled' ? 'renderer_recovery_reload_exhausted' - : cause === 'low-commit' - ? 'renderer_recovery_low_commit_prompt' - : 'renderer_recovery_circuit_breaker_open', + : cause === 'launch-failed' + ? 'renderer_recovery_launch_backoff_exhausted' + : cause === 'low-commit' + ? 'renderer_recovery_low_commit_prompt' + : 'renderer_recovery_circuit_breaker_open', { reason: details.reason, exitCode: details.exitCode ?? null, diff --git a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts index b9f425f027b..0f22c3cc644 100644 --- a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts +++ b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts @@ -598,46 +598,4 @@ describe('createMainWindow', () => { consoleError.mockRestore() }) - - it('bounds renderer launch-failed recovery with the crash-loop breaker', () => { - vi.useFakeTimers() - - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - const onRendererRecoveryExhausted = vi.fn() - const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() - - try { - createMainWindow(null, { - onRendererRecoveryExhausted, - shouldRecoverRenderer: (details) => - shouldRecoverRendererAfterProcessGone({ - reason: details.reason, - expectedTeardown: 'none' - }) - }) - - const details = { - reason: 'launch-failed', - exitCode: 18 - } as Electron.RenderProcessGoneDetails - const driveLaunchFailure = (): void => { - windowHandlers['render-process-gone']?.({} as never, details) - vi.advanceTimersByTime(250) - } - - driveLaunchFailure() - driveLaunchFailure() - driveLaunchFailure() - expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(4) - - driveLaunchFailure() - expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(4) - expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() - expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( - expect.objectContaining({ details, recentRecoveryCount: 3 }) - ) - } finally { - consoleError.mockRestore() - } - }) }) diff --git a/src/main/window/createMainWindow-renderer-launch-failure.test.ts b/src/main/window/createMainWindow-renderer-launch-failure.test.ts new file mode 100644 index 00000000000..7befc2fe588 --- /dev/null +++ b/src/main/window/createMainWindow-renderer-launch-failure.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', async () => + (await import('./createMainWindow-test-harness')).electronModuleMock() +) +vi.mock('@electron-toolkit/utils', async () => + (await import('./createMainWindow-test-harness')).electronToolkitUtilsMock() +) +vi.mock('./macos-tahoe-release', async () => + (await import('./createMainWindow-test-harness')).macosTahoeReleaseMock() +) +vi.mock('../app-icon', async () => (await import('./createMainWindow-test-harness')).appIconMock()) +vi.mock('../browser/browser-manager', async () => + (await import('./createMainWindow-test-harness')).browserManagerMock() +) +vi.mock('../browser/browser-client-page-renderer-runtime', async () => { + const harness = await import('./createMainWindow-test-harness') + return { + attachBrowserClientPageRenderer: harness.attachClientPageRendererMock, + retireBrowserClientPageRenderer: harness.retireClientPageRendererMock + } +}) + +import { createMainWindow } from './createMainWindow' +import type { CreateMainWindowOptions } from './main-window-contracts' +import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' +import { + browserWindowMock, + resetMainWindowMocks, + withPlatform +} from './createMainWindow-test-harness' +import { RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS } from './renderer-launch-failure-backoff' + +// macOS LAUNCH_RESULT_FAILURE: posix_spawn of the Renderer helper failed (field: EAGAIN, per-user process limit). +const LAUNCH_FAILED: Electron.RenderProcessGoneDetails = { reason: 'launch-failed', exitCode: 1003 } +const CRASHED: Electron.RenderProcessGoneDetails = { reason: 'crashed', exitCode: 5 } + +/** + * Field shape (v1.4.218, bundle F0C6NHQF4C8): while the OS refuses spawns, every load emits launch-failed and then + * rejects ERR_FAILED within ~10ms, before any did-finish-load. Once headroom returns the same webContents loads. + */ +function createSpawnRefusingWindow(platform: NodeJS.Platform = 'darwin') { + const handlers: Record void> = {} + const spawn = { refused: true } + const webContents = { + id: 143, + getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), + isDestroyed: vi.fn(() => false), + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + handlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn() + } + const load = vi.fn((): Promise => { + if (!spawn.refused) { + queueMicrotask(() => handlers['did-finish-load']?.()) + return Promise.resolve() + } + queueMicrotask(() => + withPlatform(platform, () => handlers['render-process-gone']?.({}, LAUNCH_FAILED)) + ) + return Promise.reject( + new Error("ERR_FAILED (-2) loading 'file:///opt/orca/renderer/index.html'") + ) + }) + browserWindowMock.mockImplementation(function () { + return { + webContents, + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + handlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: load, + loadURL: load + } + }) + return { handlers, load, spawn } +} + +function open(overrides: CreateMainWindowOptions = {}) { + const onRendererRecoveryExhausted = vi.fn() + const onRecoveryReloadOutcome = vi.fn() + createMainWindow(null, { + onRendererRecoveryExhausted, + onRecoveryReloadOutcome, + shouldRecoverRenderer: (details) => + shouldRecoverRendererAfterProcessGone({ reason: details.reason, expectedTeardown: 'none' }), + ...overrides + }) + return { onRendererRecoveryExhausted, onRecoveryReloadOutcome } +} + +const BACKOFF_TOTAL_MS = RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.reduce((sum, ms) => sum + ms, 0) + +describe('renderer launch-failed recovery', () => { + beforeEach(() => { + resetMainWindowMocks() + vi.useFakeTimers() + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('backs off instead of opening the crash-loop breaker after 3 quick failures', async () => { + const { load } = createSpawnRefusingWindow() + const { onRendererRecoveryExhausted } = open() + await vi.advanceTimersByTimeAsync(0) + expect(load).toHaveBeenCalledTimes(1) + + // Field: 3 reloads in ~750ms, then the breaker opened. Now retries spread out: 250ms, 1s, 2s, 4s, ... + await vi.advanceTimersByTimeAsync(250) + expect(load).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(999) + expect(load).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(load).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(2_000) + expect(load).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(4_000) + expect(load).toHaveBeenCalledTimes(5) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) + + it('recovers in place once spawn headroom returns, without any prompt', async () => { + const { handlers, load, spawn } = createSpawnRefusingWindow() + const { onRendererRecoveryExhausted, onRecoveryReloadOutcome } = open() + // Field: the process table stayed full for minutes. + await vi.advanceTimersByTimeAsync(250 + 1_000 + 2_000 + 4_000 + 8_000 + 15_000) + expect(load).toHaveBeenCalledTimes(7) + spawn.refused = false + + await vi.advanceTimersByTimeAsync(30_000) + expect(load).toHaveBeenCalledTimes(8) + expect(onRecoveryReloadOutcome).toHaveBeenLastCalledWith( + expect.objectContaining({ status: 'loaded' }) + ) + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(load).toHaveBeenCalledTimes(8) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + + // A loaded document starts a fresh schedule: the next launch failure retries after 250ms again. + spawn.refused = true + handlers['render-process-gone']?.({}, LAUNCH_FAILED) + await vi.advanceTimersByTimeAsync(250) + expect(load).toHaveBeenCalledTimes(9) + }) + + it('prompts only after the ~2 minute schedule is spent, then a failed manual retry re-prompts', async () => { + const { load } = createSpawnRefusingWindow() + const { onRendererRecoveryExhausted } = open() + await vi.advanceTimersByTimeAsync(BACKOFF_TOTAL_MS - 1) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + + // The last retry fails too; its prompt follows on the usual 250ms recovery tick. + await vi.advanceTimersByTimeAsync(251) + expect(BACKOFF_TOTAL_MS).toBeLessThanOrEqual(125_000) + expect(load).toHaveBeenCalledTimes(1 + RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length) + expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ + details: LAUNCH_FAILED, + cause: 'launch-failed', + recentRecoveryCount: RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length + }) + ) + + onRendererRecoveryExhausted.mock.calls[0]?.[0].retry() + expect(load).toHaveBeenCalledTimes(2 + RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length) + await vi.advanceTimersByTimeAsync(250) + expect(onRendererRecoveryExhausted).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(load).toHaveBeenCalledTimes(2 + RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length) + }) + + it('stops recovery when quitting during the long backoff', async () => { + const { load } = createSpawnRefusingWindow() + let quitting = false + const { onRendererRecoveryExhausted } = open({ getIsQuitting: () => quitting }) + await vi.advanceTimersByTimeAsync(250 + 1_000 + 2_000 + 4_000) + const loadsBeforeQuit = load.mock.calls.length + quitting = true + await vi.advanceTimersByTimeAsync(BACKOFF_TOTAL_MS * 2) + expect(load).toHaveBeenCalledTimes(loadsBeforeQuit) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) + + it('does not spend retry attempts on duplicate failure events', async () => { + const { handlers, load } = createSpawnRefusingWindow() + const { onRendererRecoveryExhausted } = open() + await vi.advanceTimersByTimeAsync(0) + handlers['render-process-gone']?.({}, LAUNCH_FAILED) + handlers['render-process-gone']?.({}, LAUNCH_FAILED) + await vi.advanceTimersByTimeAsync(250) + expect(load).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(999) + expect(load).toHaveBeenCalledTimes(2) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) + + it('keeps Windows launch failures on the short recovery schedule', async () => { + const { load } = createSpawnRefusingWindow('win32') + const { onRendererRecoveryExhausted } = open() + await vi.advanceTimersByTimeAsync(1_000) + expect(load).toHaveBeenCalledTimes(4) + expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'launch-failed', recentRecoveryCount: 3 }) + ) + }) + + it('keeps the crash-loop breaker for renderers that actually crash', async () => { + const { handlers, load, spawn } = createSpawnRefusingWindow() + spawn.refused = false + const { onRendererRecoveryExhausted } = open() + await vi.advanceTimersByTimeAsync(0) + for (let i = 0; i < 4; i += 1) { + handlers['render-process-gone']?.({}, CRASHED) + await vi.advanceTimersByTimeAsync(250) + } + expect(load).toHaveBeenCalledTimes(4) + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ details: CRASHED, cause: 'crash-loop', recentRecoveryCount: 3 }) + ) + }) +}) diff --git a/src/main/window/main-window-focus-lifecycle.ts b/src/main/window/main-window-focus-lifecycle.ts index 44e8924cc1d..6b0f1fb1002 100644 --- a/src/main/window/main-window-focus-lifecycle.ts +++ b/src/main/window/main-window-focus-lifecycle.ts @@ -23,6 +23,7 @@ import { } from '../browser/browser-client-page-renderer-runtime' import { registerRendererDocumentNavigation } from './renderer-document-navigation' import { createRendererRecoveryReloadWatchdog } from './renderer-recovery-reload-watchdog' +import { createRendererLaunchFailureBackoff } from './renderer-launch-failure-backoff' export type MainWindowFocusLifecycle = { dispose: () => void @@ -160,6 +161,7 @@ export function installMainWindowFocusLifecycle(args: { windowMs: DEFAULT_RENDERER_RECOVERY_WINDOW_MS, maxRecoveries: DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES }) + const launchFailureBackoff = createRendererLaunchFailureBackoff() const lowCommitOomGate = createLowCommitOomRecoveryGate() const clearRendererRecoveryTimer = (): void => { if (rendererRecoveryTimer) { @@ -189,6 +191,9 @@ export function installMainWindowFocusLifecycle(args: { ) { return } + const launchFailed = details.reason === 'launch-failed' + const useLaunchBackoff = launchFailed && process.platform !== 'win32' + const launchRetryDelayMs = useLaunchBackoff ? launchFailureBackoff.nextDelayMs() : null const goneAt = Date.now() // Why read at gone time: the sampler's next tick would see commit the corpse just released. const lowCommit = lowCommitOomGate.assess(details, goneAt) @@ -215,6 +220,16 @@ export function installMainWindowFocusLifecycle(args: { ) return } + // Why outside the breaker: a refused spawn is not a crash loop; its own bounded backoff owns the budget. + if (useLaunchBackoff) { + const subject = { details, recentRecoveryCount: launchFailureBackoff.attempts() } + if (launchRetryDelayMs === null) { + recoveryReloadWatchdog.escalate(subject, 'launch-failed') + } else { + recoveryReloadWatchdog.issue(details, subject.recentRecoveryCount) + } + return + } const recovery = rendererRecoveryCircuitBreaker.registerRecoveryAttempt(Date.now()) if (!recovery.allowed) { // Why: too many reloads means it will just crash again; stop and let the host surface a recovery prompt. @@ -222,13 +237,13 @@ export function installMainWindowFocusLifecycle(args: { // recovery reload too — unwatched, one that stalls leaves a blank window and no further prompt. recoveryReloadWatchdog.escalate( { details, recentRecoveryCount: recovery.recentRecoveryCount }, - 'crash-loop' + launchFailed ? 'launch-failed' : 'crash-loop' ) return } // Why: a transient renderer/Network Service loss can blank Chromium; reload the app document once to recover. recoveryReloadWatchdog.issue(details, recovery.recentRecoveryCount) - }, 250) + }, launchRetryDelayMs ?? 250) } mainWindow.webContents.on('render-process-gone', (_event, details) => { rendererProcessGone = true @@ -259,6 +274,7 @@ export function installMainWindowFocusLifecycle(args: { rendererProcessGone = false attachBrowserClientPageRenderer(rendererWebContents) clearRendererRecoveryTimer() + launchFailureBackoff.reset() recoveryReloadWatchdog.notifyDocumentLoaded() }) diff --git a/src/main/window/renderer-launch-failure-backoff.test.ts b/src/main/window/renderer-launch-failure-backoff.test.ts new file mode 100644 index 00000000000..193e6bd4d20 --- /dev/null +++ b/src/main/window/renderer-launch-failure-backoff.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { + createRendererLaunchFailureBackoff, + RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS +} from './renderer-launch-failure-backoff' + +describe('createRendererLaunchFailureBackoff', () => { + it('keeps the 250ms first retry and spreads the rest over about two minutes', () => { + expect(RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS[0]).toBe(250) + const total = RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.reduce((sum, ms) => sum + ms, 0) + expect(total).toBeGreaterThanOrEqual(90_000) + expect(total).toBeLessThanOrEqual(125_000) + }) + + it('walks the schedule, then reports it spent until reset', () => { + const backoff = createRendererLaunchFailureBackoff([10, 20]) + expect(backoff.nextDelayMs()).toBe(10) + expect(backoff.nextDelayMs()).toBe(20) + expect(backoff.nextDelayMs()).toBeNull() + expect(backoff.attempts()).toBe(2) + backoff.reset() + expect(backoff.attempts()).toBe(0) + expect(backoff.nextDelayMs()).toBe(10) + }) +}) diff --git a/src/main/window/renderer-launch-failure-backoff.ts b/src/main/window/renderer-launch-failure-backoff.ts new file mode 100644 index 00000000000..f815a0d9597 --- /dev/null +++ b/src/main/window/renderer-launch-failure-backoff.ts @@ -0,0 +1,31 @@ +// Why: a refused renderer spawn (e.g. macOS 1003 at the process limit) lasts minutes, so retry over ~2 min, not the breaker's 750ms. +export const RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS: readonly number[] = [ + 250, 1_000, 2_000, 4_000, 8_000, 15_000, 30_000, 60_000 +] + +export type RendererLaunchFailureBackoff = { + /** Delay before the next retry, or null once the schedule is spent. */ + nextDelayMs: () => number | null + attempts: () => number + reset: () => void +} + +export function createRendererLaunchFailureBackoff( + delaysMs: readonly number[] = RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS +): RendererLaunchFailureBackoff { + let attempts = 0 + return { + nextDelayMs: () => { + const delay = delaysMs[attempts] + if (delay === undefined) { + return null + } + attempts += 1 + return delay + }, + attempts: () => attempts, + reset: () => { + attempts = 0 + } + } +} diff --git a/src/main/window/renderer-launch-failure-probe.spawn.test.ts b/src/main/window/renderer-launch-failure-probe.spawn.test.ts new file mode 100644 index 00000000000..aa6c13e609f --- /dev/null +++ b/src/main/window/renderer-launch-failure-probe.spawn.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest' +import { probeRendererLaunchCapacity } from './renderer-launch-failure-probe' + +describe.skipIf(process.platform === 'win32')('probeRendererLaunchCapacity (real spawn)', () => { + it('reports ok when the host can start a process', async () => { + await expect(probeRendererLaunchCapacity()).resolves.toBe('ok') + }) +}) diff --git a/src/main/window/renderer-launch-failure-probe.test.ts b/src/main/window/renderer-launch-failure-probe.test.ts new file mode 100644 index 00000000000..29714f84dad --- /dev/null +++ b/src/main/window/renderer-launch-failure-probe.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { runProcessMock, recordDurableCrashBreadcrumbMock } = vi.hoisted(() => ({ + runProcessMock: vi.fn(), + recordDurableCrashBreadcrumbMock: vi.fn() +})) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('../crash-reporting/durable-crash-breadcrumb', () => ({ + recordDurableCrashBreadcrumb: recordDurableCrashBreadcrumbMock +})) + +import { withPlatform } from './createMainWindow-test-harness' + +import { + classifyLaunchProbeError, + probeRendererLaunchCapacity, + recordRendererLaunchFailureProbe +} from './renderer-launch-failure-probe' + +function spawnError(code: string): Error { + return Object.assign(new Error(`spawn /bin/sh ${code}`), { code, errno: -35, syscall: 'spawn' }) +} + +function recordPosixFailure(now: number) { + return withPlatform('darwin', () => recordRendererLaunchFailureProbe({ exitCode: 1003 }, now)) +} + +describe('classifyLaunchProbeError', () => { + it.each(['EAGAIN', 'EACCES', 'ENOENT', 'EMFILE'])('reports the spawn errno %s', (code) => { + expect(classifyLaunchProbeError(spawnError(code))).toBe(code) + }) + + it.each([ + ['a plain error', new Error('boom')], + ['a non-errno code', Object.assign(new Error('x'), { code: 'ERR_INVALID_ARG_TYPE' })], + ['a numeric code', Object.assign(new Error('x'), { code: 11 })], + ['a non-error throw', 'EAGAIN'] + ])('reports unknown for %s', (_label, error) => { + expect(classifyLaunchProbeError(error)).toBe('unknown') + }) +}) + +describe('probeRendererLaunchCapacity', () => { + afterEach(() => { + runProcessMock.mockReset() + recordDurableCrashBreadcrumbMock.mockReset() + }) + + it('reports ok for any child that started, whatever its exit', async () => { + runProcessMock.mockResolvedValue({ + code: 1, + signal: null, + stdout: '', + stderr: '', + timedOut: false + }) + await expect(probeRendererLaunchCapacity('darwin')).resolves.toBe('ok') + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ program: '/bin/sh', args: ['-c', 'exit 0'] }) + ) + }) + + // Windows EDR scores a short-lived child per operation; the breadcrumb still records the launch failure. + it('skips the spawn on Windows', async () => { + await expect(probeRendererLaunchCapacity('win32')).resolves.toBe('skipped') + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it('records a skipped Windows probe without spawning a child', async () => { + await expect( + withPlatform('win32', () => recordRendererLaunchFailureProbe({ exitCode: 18 }, 100_000)) + ).resolves.toBe('skipped') + expect(runProcessMock).not.toHaveBeenCalled() + expect(recordDurableCrashBreadcrumbMock).toHaveBeenCalledWith('renderer_launch_failed_probe', { + spawnError: 'skipped', + exitCode: 18 + }) + }) + + it('never rejects when the breadcrumb write throws', async () => { + runProcessMock.mockRejectedValue(spawnError('EAGAIN')) + recordDurableCrashBreadcrumbMock.mockImplementation(() => { + throw new Error('disk full') + }) + await expect(recordPosixFailure(90_000)).resolves.toBe('EAGAIN') + }) + + it('records the refused spawn as a durable breadcrumb', async () => { + runProcessMock.mockRejectedValue(spawnError('EAGAIN')) + await expect(recordPosixFailure(10_000)).resolves.toBe('EAGAIN') + expect(recordDurableCrashBreadcrumbMock).toHaveBeenCalledWith('renderer_launch_failed_probe', { + spawnError: 'EAGAIN', + exitCode: 1003 + }) + }) + + it('probes once for the duplicate render-process-gone of one failed launch', async () => { + runProcessMock.mockRejectedValue(spawnError('EAGAIN')) + await recordPosixFailure(50_000) + await recordPosixFailure(50_010) + expect(runProcessMock).toHaveBeenCalledOnce() + expect(recordDurableCrashBreadcrumbMock).toHaveBeenCalledOnce() + + // The next backoff retry fails again: a fresh probe and breadcrumb. + await recordPosixFailure(50_250) + expect(runProcessMock).toHaveBeenCalledTimes(2) + expect(recordDurableCrashBreadcrumbMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/window/renderer-launch-failure-probe.ts b/src/main/window/renderer-launch-failure-probe.ts new file mode 100644 index 00000000000..fd81b419c6f --- /dev/null +++ b/src/main/window/renderer-launch-failure-probe.ts @@ -0,0 +1,69 @@ +import { runProcess } from '../../shared/child-process/run-process' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' + +/** 'ok' when a throwaway child could start, the spawn errno (EAGAIN = per-user process limit), or 'skipped' on Windows. */ +export type RendererLaunchProbeResult = string + +const PROBE_TIMEOUT_MS = 5_000 +const ERRNO_CODE = /^E[A-Z0-9]{1,15}$/ + +export function classifyLaunchProbeError(error: unknown): RendererLaunchProbeResult { + const code = error instanceof Error && 'code' in error ? error.code : undefined + return typeof code === 'string' && ERRNO_CODE.test(code) ? code : 'unknown' +} + +/** + * Chromium reports any failed helper spawn as launch-failed (macOS exit 1003 = LAUNCH_RESULT_FAILURE), + * so spawn an unrelated child to learn whether the OS is refusing every new process. + */ +export async function probeRendererLaunchCapacity( + platform: NodeJS.Platform = process.platform +): Promise { + // Why skip Windows: a short-lived child per failed launch is the per-operation spawn burst EDR scores (windows-edr-posture.md). + if (platform === 'win32') { + return 'skipped' + } + try { + // Exit status is irrelevant: a child that started proves spawn headroom. /bin/sh is at a fixed path on every POSIX host. + await runProcess({ + program: '/bin/sh', + args: ['-c', 'exit 0'], + timeoutMs: PROBE_TIMEOUT_MS, + maxOutputBytes: 4096 + }) + return 'ok' + } catch (error) { + return classifyLaunchProbeError(error) + } +} + +// Electron emits render-process-gone twice (~10ms apart) for one failed launch; probe and record it once. +const PROBE_COALESCE_MS = 100 +let lastProbe: { startedAt: number; result: Promise } | null = null + +export function recordRendererLaunchFailureProbe( + details: Pick, + now: number = Date.now() +): Promise { + if ( + lastProbe && + now - lastProbe.startedAt >= 0 && + now - lastProbe.startedAt < PROBE_COALESCE_MS + ) { + return lastProbe.result + } + // Never rejects: callers fire-and-forget it from the render-process-gone handler. + const result = probeRendererLaunchCapacity().then((spawnError) => { + try { + recordDurableCrashBreadcrumb('renderer_launch_failed_probe', { + spawnError, + exitCode: details.exitCode ?? null + }) + } catch { + // Diagnostics only. + } + return spawnError + }) + lastProbe = { startedAt: now, result } + return result +} diff --git a/src/main/window/renderer-recovery-prompt.test.ts b/src/main/window/renderer-recovery-prompt.test.ts index 32f6e927b56..2415fadac58 100644 --- a/src/main/window/renderer-recovery-prompt.test.ts +++ b/src/main/window/renderer-recovery-prompt.test.ts @@ -183,4 +183,91 @@ describe('presentRendererRecoveryPrompt', () => { expect(shown[0].detail).toContain('repairing now') expect(shown[1].detail).toContain('repaired') }) + + describe('after a renderer launch failure', () => { + it('names the process limit when the probe is refused with EAGAIN', async () => { + const probeLaunchCapacity = vi.fn(async () => 'EAGAIN') + const { run, shown, reload, quit } = harness({ + failure: 'launch-failed', + recentRecoveryCount: 8, + probeLaunchCapacity + }) + await run() + expect(probeLaunchCapacity).toHaveBeenCalledOnce() + // No Restart: app.relaunch needs a free process slot too and silently fails without one. + expect(shown[0].buttons).toEqual(['Try Again', 'Quit']) + expect(shown[0].defaultId).toBe(0) + expect(shown[0].cancelId).toBe(0) + expect(shown[0].message).toContain("couldn't start the process") + expect(shown[0].detail).toContain('retried 8 times') + expect(shown[0].detail).toContain('process limit') + expect(shown[0].detail).not.toMatch(/graphics|driver/i) + expect(reload).toHaveBeenCalledOnce() + expect(quit).not.toHaveBeenCalled() + }) + + it('drops the graphics-driver blame when the probe could spawn', async () => { + const { run, shown, quit } = harness({ + failure: 'launch-failed', + probeLaunchCapacity: async () => 'ok', + responses: [1] + }) + await run() + expect(shown[0].detail).not.toContain('process limit') + expect(shown[0].detail).not.toMatch(/graphics|driver/i) + expect(shown[0].detail).toContain('Try Again') + expect(quit).toHaveBeenCalledOnce() + }) + + it('probes once even when Copy Commands re-shows the box', async () => { + const probeLaunchCapacity = vi.fn(async () => 'ok') + const { run, shown, reload } = harness({ + failure: 'launch-failed', + diagnose: () => POISON, + probeLaunchCapacity, + responses: [1, 1, 0] + }) + await run() + expect(shown).toHaveLength(3) + expect(probeLaunchCapacity).toHaveBeenCalledOnce() + expect(reload).toHaveBeenCalledOnce() + }) + + it('shows nothing when the app starts quitting during the probe', async () => { + let quitting = false + const { run, shown, reload } = harness({ + failure: 'launch-failed', + isQuitting: () => quitting, + probeLaunchCapacity: async () => { + quitting = true + return 'EAGAIN' + } + }) + await run() + expect(shown).toEqual([]) + expect(reload).not.toHaveBeenCalled() + }) + + it('does not probe once the app is already quitting', async () => { + const probeLaunchCapacity = vi.fn(async () => 'ok') + const { run } = harness({ + failure: 'launch-failed', + isQuitting: () => true, + probeLaunchCapacity + }) + await run() + expect(probeLaunchCapacity).not.toHaveBeenCalled() + }) + + it('keeps the install-permission diagnosis and its copy button', async () => { + const { run, shown } = harness({ + failure: 'launch-failed', + diagnose: () => POISON, + probeLaunchCapacity: async () => 'ok' + }) + await run() + expect(shown[0].buttons).toEqual(['Try Again', 'Copy Commands', 'Quit']) + expect(shown[0].detail).toContain(POISON.detail) + }) + }) }) diff --git a/src/main/window/renderer-recovery-prompt.ts b/src/main/window/renderer-recovery-prompt.ts index bd8b3b56b52..73992b666e6 100644 --- a/src/main/window/renderer-recovery-prompt.ts +++ b/src/main/window/renderer-recovery-prompt.ts @@ -2,6 +2,7 @@ import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' import { translateMain } from '../i18n/main-i18n' import type { InstallDirAclPoisonDiagnosis } from '../startup/windows-install-dir-acl-recovery' import type { RecoveryExhaustionCause } from './renderer-recovery-reload-watchdog' +import type { RendererLaunchProbeResult } from './renderer-launch-failure-probe' export type RendererRecoveryPromptFailure = RecoveryExhaustionCause @@ -12,6 +13,8 @@ export type RendererRecoveryPromptDeps = { availableCommitMB?: number isQuitting: () => boolean diagnose: () => InstallDirAclPoisonDiagnosis | null + /** Re-checks spawn headroom so a launch-failed prompt can name the process limit. */ + probeLaunchCapacity?: () => Promise showMessageBox: (options: MessageBoxOptions) => Promise copyToClipboard: (text: string) => void reload: () => void @@ -22,46 +25,31 @@ export async function presentRendererRecoveryPrompt( deps: RendererRecoveryPromptDeps ): Promise { const stalled = deps.failure === 'reload-stalled' + const launchFailed = deps.failure === 'launch-failed' const lowCommit = deps.failure === 'low-commit' + if (deps.isQuitting()) { + return + } + // Probed once: Copy Commands re-shows the box and must not spawn again. The loop re-checks quitting after it. + const launchProbe = launchFailed ? await deps.probeLaunchCapacity?.() : undefined // Copying must preserve the only available recovery surface. while (!deps.isQuitting()) { - // Why skip: the low-commit text replaces diagnosis.detail, which would leave Copy Commands unexplained. const diagnosis = lowCommit ? null : deps.diagnose() - const buttons = [translateMain('rendererRecovery.reload', 'Reload')] + // Why no Restart button: relaunching needs a free process slot too, and app.relaunch fails silently without one. + const buttons = [ + launchFailed + ? translateMain('rendererRecovery.tryAgain', 'Try Again') + : translateMain('rendererRecovery.reload', 'Reload') + ] if (diagnosis) { buttons.push(translateMain('rendererRecovery.copyCommands', 'Copy Commands')) } buttons.push(translateMain('rendererRecovery.quit', 'Quit')) - const recoveryDetail = lowCommit - ? translateMain( - 'rendererRecovery.lowCommitDetail', - 'Windows has only {{availableMB}} MB of memory left for apps, so the window ran out of memory again after reloading.', - { availableMB: deps.availableCommitMB ?? 0 } - ) - : stalled - ? translateMain( - 'rendererRecovery.stalledDetail', - 'Orca reloaded the window after a crash, but it never finished loading.' - ) - : translateMain( - 'rendererRecovery.crashLoopDetail', - 'Orca tried to recover {{recoveryCount}} times in a row without success.', - { recoveryCount: deps.recentRecoveryCount } - ) - const causeDetail = lowCommit - ? translateMain( - 'rendererRecovery.lowCommitAdvice', - 'Free memory by closing unused apps or Orca workspaces, or increase the Windows page file size, then click Reload.' - ) - : diagnosis - ? `${diagnosis.detail}\n\n${translateMain( - 'rendererRecovery.driverFallback', - 'If that does not help, the cause is usually a graphics driver.' - )}` - : translateMain( - 'rendererRecovery.genericDetail', - 'This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.' - ) + const content = launchFailed + ? describeLaunchFailure(deps.recentRecoveryCount, launchProbe, diagnosis) + : lowCommit + ? describeLowCommit(deps.availableCommitMB ?? 0) + : describeRendererCrash(stalled, deps.recentRecoveryCount, diagnosis) const { response } = await deps.showMessageBox({ type: 'error', buttons, @@ -69,18 +57,7 @@ export async function presentRendererRecoveryPrompt( // Escape retries instead of destroying the session. cancelId: 0, title: translateMain('rendererRecovery.title', 'Orca keeps failing to load'), - message: lowCommit - ? translateMain('rendererRecovery.lowCommitMessage', 'Windows is out of memory.') - : stalled - ? translateMain( - 'rendererRecovery.stalledMessage', - 'The app window stopped responding while reloading after a crash.' - ) - : translateMain( - 'rendererRecovery.crashLoopMessage', - 'The app window crashed repeatedly and stopped reloading automatically.' - ), - detail: `${recoveryDetail}\n\n${causeDetail}` + ...content }) if (response === 1 && diagnosis) { deps.copyToClipboard(diagnosis.commands.join('\r\n')) @@ -94,3 +71,87 @@ export async function presentRendererRecoveryPrompt( return } } + +type PromptContent = { message: string; detail: string } + +function describeLowCommit(availableMB: number): PromptContent { + const recoveryDetail = translateMain( + 'rendererRecovery.lowCommitDetail', + 'Windows has only {{availableMB}} MB of memory left for apps, so the window ran out of memory again after reloading.', + { availableMB } + ) + const advice = translateMain( + 'rendererRecovery.lowCommitAdvice', + 'Free memory by closing unused apps or Orca workspaces, or increase the Windows page file size, then click Reload.' + ) + return { + message: translateMain('rendererRecovery.lowCommitMessage', 'Windows is out of memory.'), + detail: `${recoveryDetail}\n\n${advice}` + } +} + +function describeLaunchFailure( + attempts: number, + probe: RendererLaunchProbeResult | undefined, + diagnosis: InstallDirAclPoisonDiagnosis | null +): PromptContent { + const message = translateMain( + 'rendererRecovery.launchFailedMessage', + "Orca couldn't start the process that draws its window." + ) + const retried = translateMain( + 'rendererRecovery.launchFailedDetail', + 'Orca retried {{recoveryCount}} times without success.', + { recoveryCount: attempts } + ) + const cause = + probe === 'EAGAIN' + ? translateMain( + 'rendererRecovery.processLimitDetail', + 'The system refused to create a new process because your user account reached its process limit. This is often caused by runaway terminals, dev servers, or agents. Close some of them, then click Try Again.' + ) + : (diagnosis?.detail ?? + translateMain( + 'rendererRecovery.launchFailedGenericDetail', + 'The system refused to start it. Free up memory or close other apps, then click Try Again. If this keeps happening, reinstall Orca.' + )) + return { message, detail: `${retried}\n\n${cause}` } +} + +function describeRendererCrash( + stalled: boolean, + recoveryCount: number, + diagnosis: InstallDirAclPoisonDiagnosis | null +): PromptContent { + const recoveryDetail = stalled + ? translateMain( + 'rendererRecovery.stalledDetail', + 'Orca reloaded the window after a crash, but it never finished loading.' + ) + : translateMain( + 'rendererRecovery.crashLoopDetail', + 'Orca tried to recover {{recoveryCount}} times in a row without success.', + { recoveryCount } + ) + const causeDetail = diagnosis + ? `${diagnosis.detail}\n\n${translateMain( + 'rendererRecovery.driverFallback', + 'If that does not help, the cause is usually a graphics driver.' + )}` + : translateMain( + 'rendererRecovery.genericDetail', + 'This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.' + ) + return { + message: stalled + ? translateMain( + 'rendererRecovery.stalledMessage', + 'The app window stopped responding while reloading after a crash.' + ) + : translateMain( + 'rendererRecovery.crashLoopMessage', + 'The app window crashed repeatedly and stopped reloading automatically.' + ), + detail: `${recoveryDetail}\n\n${causeDetail}` + } +} diff --git a/src/main/window/renderer-recovery-reload-watchdog.ts b/src/main/window/renderer-recovery-reload-watchdog.ts index f2f013d18e0..4d21da2a2cc 100644 --- a/src/main/window/renderer-recovery-reload-watchdog.ts +++ b/src/main/window/renderer-recovery-reload-watchdog.ts @@ -25,7 +25,11 @@ const MILESTONE_RANK: Record = { 'dom-ready': 2 } -export type RecoveryExhaustionCause = 'crash-loop' | 'reload-stalled' | 'low-commit' +export type RecoveryExhaustionCause = + | 'crash-loop' + | 'reload-stalled' + | 'launch-failed' + | 'low-commit' export type RendererRecoveryReloadWatchdog = { /** Issues a recovery reload and arms the stall watchdog. */ diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1fd38350c69..2f29e560803 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18542,7 +18542,12 @@ "crashLoopMessage": "The app window crashed repeatedly and stopped reloading automatically.", "lowCommitMessage": "Windows is out of memory.", "lowCommitDetail": "Windows has only {{availableMB}} MB of memory left for apps, so the window ran out of memory again after reloading.", - "lowCommitAdvice": "Free memory by closing unused apps or Orca workspaces, or increase the Windows page file size, then click Reload." + "lowCommitAdvice": "Free memory by closing unused apps or Orca workspaces, or increase the Windows page file size, then click Reload.", + "tryAgain": "Try Again", + "launchFailedMessage": "Orca couldn't start the process that draws its window.", + "launchFailedDetail": "Orca retried {{recoveryCount}} times without success.", + "processLimitDetail": "The system refused to create a new process because your user account reached its process limit. This is often caused by runaway terminals, dev servers, or agents. Close some of them, then click Try Again.", + "launchFailedGenericDetail": "The system refused to start it. Free up memory or close other apps, then click Try Again. If this keeps happening, reinstall Orca." }, "notifications": { "agentStatus": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 376d83373bb..85a1b64c154 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18508,7 +18508,12 @@ "genericDetail": "Il s'agit souvent d'un problème de pilote graphique ou d'installation. Rechargez pour réessayer, ou quittez et relancez Orca.", "title": "Orca ne parvient toujours pas à charger", "stalledMessage": "La fenêtre de l'application a cessé de répondre lors du rechargement après un crash.", - "crashLoopMessage": "La fenêtre de l'application s'est écrasée à plusieurs reprises et a cessé de se recharger automatiquement." + "crashLoopMessage": "La fenêtre de l'application s'est écrasée à plusieurs reprises et a cessé de se recharger automatiquement.", + "tryAgain": "Réessayer", + "launchFailedMessage": "Orca n'a pas pu démarrer le processus qui affiche sa fenêtre.", + "launchFailedDetail": "Orca a réessayé {{recoveryCount}} fois sans succès.", + "processLimitDetail": "Le système a refusé de créer un nouveau processus, car votre compte utilisateur a atteint sa limite de processus. Cela vient souvent de terminaux, de serveurs de développement ou d'agents emballés. Fermez-en quelques-uns, puis cliquez sur Réessayer.", + "launchFailedGenericDetail": "Le système a refusé de le démarrer. Libérez de la mémoire ou fermez d'autres applications, puis cliquez sur Réessayer. Si le problème persiste, réinstallez Orca." }, "sessionSearch": { "panel": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 84cc645254d..0bed9bab422 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18508,7 +18508,12 @@ "genericDetail": "これは多くの場合、グラフィックスドライバーまたはインストールの問題です。リロードして再試行するか、Orca を終了して再起動してください。", "title": "Orca がロードに失敗し続ける", "stalledMessage": "クラッシュ後のリロード中にアプリウィンドウが応答を停止しました。", - "crashLoopMessage": "アプリウィンドウが繰り返しクラッシュし、自動的にリロードされなくなりました。" + "crashLoopMessage": "アプリウィンドウが繰り返しクラッシュし、自動的にリロードされなくなりました。", + "tryAgain": "再試行", + "launchFailedMessage": "Orca はウィンドウを描画するプロセスを起動できませんでした。", + "launchFailedDetail": "Orca は {{recoveryCount}} 回再試行しましたが、成功しませんでした。", + "processLimitDetail": "ユーザーアカウントのプロセス数が上限に達したため、システムが新しいプロセスの作成を拒否しました。暴走したターミナル、開発サーバー、Agent が原因であることがよくあります。いくつか終了してから「再試行」をクリックしてください。", + "launchFailedGenericDetail": "システムが起動を拒否しました。メモリを空けるか他のアプリを終了してから「再試行」をクリックしてください。繰り返し発生する場合は Orca を再インストールしてください。" }, "sessionSearch": { "panel": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1e340fdc837..a503c3347d8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18508,7 +18508,12 @@ "genericDetail": "이는 종종 그래픽 드라이버 또는 설치 문제입니다. 새로고침하여 다시 시도하거나 Orca를 종료하고 다시 시작하세요.", "title": "Orca가 계속 로드되지 않습니다.", "stalledMessage": "충돌 후 다시 로드하는 동안 앱 창이 응답을 멈췄습니다.", - "crashLoopMessage": "앱 창이 반복적으로 충돌하고 자동으로 다시 로드되지 않았습니다." + "crashLoopMessage": "앱 창이 반복적으로 충돌하고 자동으로 다시 로드되지 않았습니다.", + "tryAgain": "다시 시도", + "launchFailedMessage": "Orca가 창을 그리는 프로세스를 시작하지 못했습니다.", + "launchFailedDetail": "Orca가 {{recoveryCount}}번 다시 시도했지만 성공하지 못했습니다.", + "processLimitDetail": "사용자 계정이 프로세스 수 한도에 도달하여 시스템이 새 프로세스 생성을 거부했습니다. 폭주한 터미널, 개발 서버 또는 에이전트가 원인인 경우가 많습니다. 일부를 종료한 다음 다시 시도를 클릭하세요.", + "launchFailedGenericDetail": "시스템이 시작을 거부했습니다. 메모리를 확보하거나 다른 앱을 종료한 다음 다시 시도를 클릭하세요. 계속 발생하면 Orca를 다시 설치하세요." }, "sessionSearch": { "panel": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b0b9759f4fa..63fc2b87de5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18508,7 +18508,12 @@ "genericDetail": "这通常是图形驱动程序或安装问题。重新加载以重试,或退出并重新启动 Orca。", "title": "Orca 一直无法加载", "stalledMessage": "崩溃后重新加载时应用程序窗口停止响应。", - "crashLoopMessage": "应用程序窗口反复崩溃并自动停止重新加载。" + "crashLoopMessage": "应用程序窗口反复崩溃并自动停止重新加载。", + "tryAgain": "重试", + "launchFailedMessage": "Orca 无法启动绘制其窗口的进程。", + "launchFailedDetail": "Orca 重试了 {{recoveryCount}} 次,但未成功。", + "processLimitDetail": "由于你的用户帐户已达到进程数上限,系统拒绝创建新进程。这通常由失控的终端、开发服务器或代理导致。请关闭其中一些,然后点击“重试”。", + "launchFailedGenericDetail": "系统拒绝启动它。请释放内存或关闭其他应用,然后点击“重试”。如果问题持续出现,请重新安装 Orca。" }, "sessionSearch": { "panel": { diff --git a/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts b/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts index 5c922608008..c72ea255e98 100644 --- a/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts +++ b/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts @@ -1,18 +1,23 @@ import { afterEach, expect, it, vi } from 'vitest' import { setTerminalPtyWriteDelay } from './terminal-pty-write-spy' +declare global { + var __terminalPtyWriteDelayMs: number | undefined +} + afterEach(() => vi.unstubAllGlobals()) it('sets and clears paste backpressure using the main-process evaluate argument', async () => { vi.stubGlobal('__terminalPtyWriteDelayMs', 0) const evaluate = vi.fn() evaluate.mockImplementation( - (callback: (electron: object, argument: number) => void, delay: number) => callback({}, delay) + (callback: (electron: unknown, argument: number) => void, delay: number) => + callback(undefined, delay) ) const app = { evaluate } await setTerminalPtyWriteDelay(app, 35) - expect(Reflect.get(globalThis, '__terminalPtyWriteDelayMs')).toBe(35) + expect(globalThis.__terminalPtyWriteDelayMs).toBe(35) await setTerminalPtyWriteDelay(app, 0) - expect(Reflect.get(globalThis, '__terminalPtyWriteDelayMs')).toBe(0) + expect(globalThis.__terminalPtyWriteDelayMs).toBe(0) }) From 66799f7e8f06fd09c432932586cea460801919f6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:34:40 -0700 Subject: [PATCH 10/38] Keep paired browser terminal insertion in the host's requested position (#24676) * test: align source-control fixtures with current store contracts * Bound E2E package setup and retain cancelled-job traces * Remove empty passing sentinels from opt-in socket tests * Make SSH typing pressure fixture readiness and replies observable * Advertise browser support for anchored terminal placement --- .../src/web/web-runtime-client.test.ts | 2 + .../web-runtime-connection-frame-router.ts | 2 + ...ed-terminal-after-anchor-placement.spec.ts | 150 ++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 tests/e2e/paired-terminal-after-anchor-placement.spec.ts diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index cd290606fb2..787e130821d 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -16,6 +16,7 @@ import { AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, @@ -94,6 +95,7 @@ describe('WebRuntimeClient', () => { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, diff --git a/src/renderer/src/web/web-runtime-connection-frame-router.ts b/src/renderer/src/web/web-runtime-connection-frame-router.ts index 626b49f5612..78dbf6f0553 100644 --- a/src/renderer/src/web/web-runtime-connection-frame-router.ts +++ b/src/renderer/src/web/web-runtime-connection-frame-router.ts @@ -5,6 +5,7 @@ import { AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, @@ -66,6 +67,7 @@ export async function routeWebRuntimeConnectionFrame( AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, diff --git a/tests/e2e/paired-terminal-after-anchor-placement.spec.ts b/tests/e2e/paired-terminal-after-anchor-placement.spec.ts new file mode 100644 index 00000000000..8583b58c587 --- /dev/null +++ b/tests/e2e/paired-terminal-after-anchor-placement.spec.ts @@ -0,0 +1,150 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedWebClient +} from './helpers/paired-electron-client' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { toHostSessionTabId, toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' + +async function createTerminal(page: Page, worktreeId: string, afterTabId?: string) { + const tabs = page.locator('[data-testid="sortable-tab"]') + const before = await tabs.evaluateAll((tabs) => + tabs.map((tab) => tab.getAttribute('data-tab-id')) + ) + const outcome = await page.evaluate( + async ({ worktreeId, afterTabId }) => { + const environment = (await window.api.runtimeEnvironments.list())[0] + const bridge: unknown = + '__webRuntimeSessionE2E' in window ? window.__webRuntimeSessionE2E : undefined + if ( + !environment || + !bridge || + typeof bridge !== 'object' || + !('createTerminal' in bridge) || + typeof bridge.createTerminal !== 'function' + ) { + throw new Error('Paired terminal creation is unavailable') + } + return bridge.createTerminal({ + worktreeId, + environmentId: environment.id, + activate: false, + ...(afterTabId ? { afterTabId } : {}) + }) + }, + { worktreeId, afterTabId } + ) + expect(outcome).toEqual({ status: 'created' }) + let created: string[] = [] + await expect + .poll(async () => { + const ids = await tabs.evaluateAll((tabs) => + tabs.map((tab) => tab.getAttribute('data-tab-id')) + ) + created = ids.filter((id): id is string => id !== null && !before.includes(id)) + return created.length + }) + .toBe(1) + const parentTabId = toHostSessionTabId(created[0]) + const surfaceId = await page.evaluate( + async ({ worktreeId, parentTabId }) => { + const response = await window.api.runtime.call({ + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` } + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = response.result + if ( + !result || + typeof result !== 'object' || + !('tabs' in result) || + !Array.isArray(result.tabs) + ) { + throw new Error('Terminal inventory is missing') + } + const surface: unknown = result.tabs.find((tab) => tab.parentTabId === parentTabId) + if ( + !surface || + typeof surface !== 'object' || + !('id' in surface) || + typeof surface.id !== 'string' + ) { + throw new Error('Created terminal surface is missing') + } + return surface.id + }, + { worktreeId, parentTabId } + ) + return { id: toWebTerminalSurfaceTabId(surfaceId), parentTabId } +} + +async function tabWindow(page: Page, anchor: string) { + return page.locator('[data-testid="sortable-tab"]').evaluateAll((tabs, anchor) => { + const ids = tabs.map((tab) => tab.getAttribute('data-tab-id')) + const index = ids.indexOf(anchor) + return index === -1 ? [] : ids.slice(index, index + 3) + }, anchor) +} + +test('paired web creation places a terminal after its anchor on both host and client', async ({ + electronApp, + orcaPage +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const client = await launchPairedWebClient(electronApp, offer) + try { + const worktreeId = await orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId) + if (!worktreeId) { + throw new Error('Paired worktree is missing') + } + await client.page.evaluate((id) => window.__store?.getState().setActiveWorktree(id), worktreeId) + await expect( + client.page.locator('[data-testid="sortable-tab"][data-active="true"]') + ).toBeVisible() + const first = await createTerminal(client.page, worktreeId) + const second = await createTerminal(client.page, worktreeId) + const inserted = await createTerminal(client.page, worktreeId, first.id) + const labels = [first, second, inserted].map((tab, index) => ({ + id: tab.parentTabId, + webId: toWebTerminalSurfaceTabId(tab.parentTabId), + title: ['Anchor A', 'Successor B', 'Inserted C'][index] + })) + for (const [page, web] of [ + [orcaPage, false], + [client.page, true] + ] as const) { + await page.evaluate( + ({ labels, web }) => { + for (const { id, webId, title } of labels) { + window.__store?.getState().setTabCustomTitle(web ? webId : id, title) + } + }, + { labels, web } + ) + } + await expect + .poll(() => tabWindow(orcaPage, first.parentTabId), { timeout: 15_000 }) + .toEqual([first.parentTabId, inserted.parentTabId, second.parentTabId]) + const clientIds = [first, inserted, second].map((tab) => + toWebTerminalSurfaceTabId(tab.parentTabId) + ) + await expect + .poll(() => tabWindow(client.page, clientIds[0]), { timeout: 15_000 }) + .toEqual(clientIds) + } finally { + await testInfo.attach('host-tab-placement', { + body: await orcaPage.screenshot({ path: testInfo.outputPath('host-tab-placement.png') }), + contentType: 'image/png' + }) + await testInfo.attach('client-tab-placement', { + body: await client.page.screenshot({ path: testInfo.outputPath('client-tab-placement.png') }), + contentType: 'image/png' + }) + await client.dispose() + } +}) From d9fbb4eecf68db99ec9d893c71066d64c4ac86a2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:39:34 -0700 Subject: [PATCH 11/38] Keep SSH typing replies inside narrow split terminal panes (#24682) --- .../e2e/helpers/remote-typing-load-script.ts | 2 +- .../remote-typing-load-script.unit.test.ts | 29 ++++++++++++++----- tests/e2e/ssh-docker-relay-perf.spec.ts | 2 +- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/tests/e2e/helpers/remote-typing-load-script.ts b/tests/e2e/helpers/remote-typing-load-script.ts index f2e2c296c53..8eba98454ab 100644 --- a/tests/e2e/helpers/remote-typing-load-script.ts +++ b/tests/e2e/helpers/remote-typing-load-script.ts @@ -15,7 +15,7 @@ export function remoteTypingLoadScript(runId: string): string { ' for (const char of chunk) {', " if (char === '\\r' || char === '\\n') continue", ' seq += 1', - ` process.stdout.write('\\x1b[' + statusRow + ';2H\\x1b[2KREMOTE_KEY_${runId}_' + seq + '_' + char)`, + ` process.stdout.write('\\x1b[' + statusRow + ';2H\\x1b[2KKEY_${runId}_' + seq + '_' + char)`, ' }', '})' ].join(';') diff --git a/tests/e2e/helpers/remote-typing-load-script.unit.test.ts b/tests/e2e/helpers/remote-typing-load-script.unit.test.ts index 2b06a5897ca..ae9c55cd847 100644 --- a/tests/e2e/helpers/remote-typing-load-script.unit.test.ts +++ b/tests/e2e/helpers/remote-typing-load-script.unit.test.ts @@ -2,20 +2,31 @@ import '../../../src/main/daemon/xterm-env-polyfill' import { EventEmitter } from 'node:events' import { runInNewContext } from 'node:vm' import { Terminal } from '@xterm/headless' +import { SerializeAddon } from '@xterm/addon-serialize' import { expect, it } from 'vitest' import { remoteTypingLoadScript } from './remote-typing-load-script' -it.each([12, 24, 40])( - 'keeps typed output visible through background pressure at %i rows', - async (rows) => { - const terminal = new Terminal({ rows, cols: 80, scrollback: 1000, allowProposedApi: true }) +it.each([ + { rows: 12, cols: 80 }, + { rows: 24, cols: 80 }, + { rows: 40, cols: 80 }, + { rows: 12, cols: 35 }, + { rows: 24, cols: 35 }, + { rows: 40, cols: 35 } +])( + 'keeps typed output visible through background pressure at $rows rows and $cols columns', + async ({ rows, cols }) => { + const terminal = new Terminal({ rows, cols, scrollback: 1000, allowProposedApi: true }) + const serializer = new SerializeAddon() + terminal.loadAddon(serializer) + const runId = cols === 80 ? 'test' : '1790930671681_active' const input = new EventEmitter() const chunks: string[] = [] let background = (): void => { throw new Error('Background pressure did not start') } try { - runInNewContext(remoteTypingLoadScript('test'), { + runInNewContext(remoteTypingLoadScript(runId), { process: { stdin: { isTTY: true, @@ -24,7 +35,7 @@ it.each([12, 24, 40])( resume() {}, on: input.on.bind(input) }, - stdout: { rows, write: (chunk: string) => chunks.push(chunk) }, + stdout: { rows, cols, write: (chunk: string) => chunks.push(chunk) }, exit() {} }, setTimeout: (callback: () => void) => callback(), @@ -34,7 +45,7 @@ it.each([12, 24, 40])( }, clearInterval() {} }) - input.emit('data', 'ab\r\ncd') + input.emit('data', cols === 80 ? 'ab\r\ncd' : 'abcdefghij\r\n') for (let frame = 0; frame < 40; frame += 1) { background() } @@ -48,7 +59,9 @@ it.each([12, 24, 40])( .getLine(terminal.buffer.active.baseY + row) ?.translateToString(true) ?? '' ).join('\n') - expect(screen).toContain('REMOTE_KEY_test_4_d') + const reply = cols === 80 ? 'KEY_test_4_d' : `KEY_${runId}_10_j` + expect(screen).toContain(reply) + expect(serializer.serialize()).toContain(reply) } finally { terminal.dispose() } diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index 2a1e00ba265..e29376c1e89 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -86,7 +86,7 @@ async function measureRemoteTyping( const latencies: number[] = [] for (let index = 0; index < KEY_LATENCY_SAMPLES.length; index += 1) { const char = KEY_LATENCY_SAMPLES[index] - const marker = `REMOTE_KEY_${runId}_${index + 1}_${char}` + const marker = `KEY_${runId}_${index + 1}_${char}` const started = performance.now() await page.evaluate(({ ptyId, char }) => window.api.pty.write(ptyId, char, 'driving'), { ptyId, From efbf651c7bb2eec778daf1844f8228e70809ec9f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:46:02 -0700 Subject: [PATCH 12/38] Reduce CI setup costs and fixture failures (#24537) * Let scheduled CI warmers wait and measure WebRTC startup * Measure a smaller daemon shutdown fixture image * Counterbalance WebRTC startup and verify retained fixture files * Record CI fixture measurements and remove temporary pilots * Clarify fixture build dependency cleanup evidence * Make coalesced snapshot fixture delivery deterministic * test: type the PTY write delay observer --- .github/workflows/ci-cache-warmup.yml | 3 +- .../daemon-shutdown-descendants/Dockerfile | 22 +++- .../scripts/ci-cache-warmup-workflow.test.mjs | 9 +- docs/reference/ci-runner-efficiency.md | 124 ++++++++++++++++++ ...owser-route-webrtc-egress.electron.test.ts | 2 + .../ipc/remote-workspace-stale-resync.test.ts | 4 + 6 files changed, 158 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index bdd3e185638..84ed72cc68b 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -51,7 +51,8 @@ permissions: concurrency: group: ci-cache-warmup-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + # Hourly retries must let an active warmer finish publishing its caches. + cancel-in-progress: ${{ github.event_name != 'schedule' }} jobs: warm: diff --git a/config/docker/daemon-shutdown-descendants/Dockerfile b/config/docker/daemon-shutdown-descendants/Dockerfile index b012d5738e3..298678c6bc6 100644 --- a/config/docker/daemon-shutdown-descendants/Dockerfile +++ b/config/docker/daemon-shutdown-descendants/Dockerfile @@ -3,10 +3,26 @@ FROM node:22-bookworm-slim ENV DEBIAN_FRONTEND=noninteractive ENV NODE_PATH=/usr/local/lib/node_modules -RUN apt-get update \ +RUN set -eu \ + && savedAptMark="$(apt-mark showmanual)" \ + && apt-get update \ && apt-get install -y --no-install-recommends build-essential python3 procps util-linux \ - && rm -rf /var/lib/apt/lists/* \ - && npm install --global --no-fund --no-audit node-pty@1.1.0 + && npm install --global --no-fund --no-audit node-pty@1.1.0 \ + && ptyModule="$(node -e "require('node-pty'); const addon = Object.keys(require.cache).find((path) => path.endsWith('/pty.node')); if (!addon) process.exit(1); process.stdout.write(addon)")" \ + && ldd /usr/local/bin/node "$ptyModule" > /tmp/daemon-runtime-libraries \ + && ! grep -q 'not found' /tmp/daemon-runtime-libraries \ + && apt-mark auto '.*' > /dev/null \ + && { [ -z "$savedAptMark" ] || apt-mark manual $savedAptMark > /dev/null; } \ + && apt-mark manual procps util-linux > /dev/null \ + && awk '/=>/ { so = $(NF-1); if (index(so, "/usr/local/") == 1) next; gsub("^/(usr/)?", "", so); print so }' /tmp/daemon-runtime-libraries \ + | sort -u > /tmp/daemon-runtime-library-paths \ + && xargs -r dpkg-query --search < /tmp/daemon-runtime-library-paths > /tmp/daemon-runtime-packages \ + && cut -d: -f1 /tmp/daemon-runtime-packages | sort -u | xargs -r apt-mark manual \ + && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ + && ldd /usr/local/bin/node "$ptyModule" > /tmp/daemon-runtime-libraries \ + && ! grep -q 'not found' /tmp/daemon-runtime-libraries \ + && ORCA_BACKGROUND_LAUNCH=1 node -e "const pty = require('node-pty'); const terminal = pty.spawn('/bin/sh', ['-c', 'exit 0'], { cwd: '/tmp', env: process.env }); const timer = setTimeout(() => process.exit(1), 5000); terminal.onExit(({ exitCode }) => { clearTimeout(timer); process.exit(exitCode); });" \ + && rm -rf /root/.npm /root/.cache/node-gyp /var/lib/apt/lists/* /tmp/daemon-runtime-libraries /tmp/daemon-runtime-library-paths /tmp/daemon-runtime-packages COPY run-case.sh /usr/local/bin/run-daemon-shutdown-descendants COPY fixture.cjs /opt/daemon-shutdown-descendants/fixture.cjs diff --git a/config/scripts/ci-cache-warmup-workflow.test.mjs b/config/scripts/ci-cache-warmup-workflow.test.mjs index 07c207bddce..74b15e9b267 100644 --- a/config/scripts/ci-cache-warmup-workflow.test.mjs +++ b/config/scripts/ci-cache-warmup-workflow.test.mjs @@ -74,11 +74,16 @@ it('bounds warming to the required platforms and validates changes without grant expect(workflow.on.push.paths).toContain('.github/actions/prepare-native-runtime/**') expect(workflow.on.schedule).toEqual([{ cron: '41 * * * *' }]) expect(workflow.on.pull_request.paths).toContain('.github/workflows/ci-cache-warmup.yml') - expect(workflow.concurrency['cancel-in-progress']).toBe(true) - expect(workflow.concurrency.group).toContain('github.event.pull_request.number || github.ref') expect(steps[0].with['persist-credentials']).toBe(false) }) +it('lets hourly warmers wait while pushes, PR updates, and manual runs can replace active work', () => { + expect(workflow.concurrency).toEqual({ + group: 'ci-cache-warmup-${{ github.event.pull_request.number || github.ref }}', + 'cancel-in-progress': "${{ github.event_name != 'schedule' }}" + }) +}) + it('warms and probes both Windows images with the persistence job runtime', () => { const job = workflow.jobs['warm-windows'] expect(job.strategy.matrix.os).toEqual( diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 966cbcdbb10..0b7936aa32d 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1232,3 +1232,127 @@ crash. The last control also proved that crash returns enter the capture. This measures the three-file oracle cohort. Whole-shard timings include other test bodies, imports and transforms, so a whole-suite saving needs separate measurement. + +## Cache warming: let hourly ticks wait for active work + +The hourly warmer previously cancelled an active warmer, even when both used +the same source. On October 2, the [merge-triggered run](https://github.com/stablyai/orca/actions/runs/36965832780) +at 8ff6296 was interrupted by the [hourly run](https://github.com/stablyai/orca/actions/runs/36966367896) +at the same commit. The Windows ARM dependency installation had run for 356 +seconds before cancellation; its native verification was skipped. The other +four lanes had already succeeded. + +Scheduled events now wait in the existing concurrency group. Push, PR and manual +events still replace active work. This keeps one active workflow and the default +single pending slot, using GitHub's documented +[conditional cancellation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency). +All cache probes, platforms and publication rules remain. + +This avoids the observed discarded installation. It does not remove the next +scheduled run or its repeated successful lanes, and pending replacement still +applies regardless of the cancellation expression. The bounded 20-run sample +contains this collision; it does not establish a recurring or whole-CI saving. + +## Daemon shutdown fixture: remove build tools after compilation + +The fixture now removes compiler and Python build dependencies, plus npm and +node-gyp caches, in the same Docker layer that installs node-pty. It restores the base image's manual +package marks, keeps procps and util-linux, and retains the packages owning the +shared libraries used by Node and the actually loaded PTY addon. This extends +the [official Node image's package-ownership approach](https://github.com/nodejs/docker-node/blob/main/22/bookworm-slim/Dockerfile) +to native addons. Dependency checks and a real PTY spawn fail the build if cleanup +breaks the runtime. + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/36969120786) +used Ubuntu x64, Docker 28.0.4 and the same resolved Node 22.23.3 base digest for +both images. All 3,418 common entries under `/usr/local` retained their bytes, +modes and symlink targets; all seven resolved runtime libraries matched. Only +two directory-only Python paths disappeared. The 52 removed Debian packages +were build dependencies; retained package versions stayed identical. + +| Image payload | Baseline | Candidate | Reduction | +| ------------------ | ------------- | ------------- | --------- | +| Docker archive | 714,643,456 B | 329,967,616 B | 53.8% | +| Compressed archive | 205,819,558 B | 91,888,840 B | 55.4% | + +Each timed arm started a separate Docker daemon with an empty image store, +decompressed the archive, loaded it, rebuilt from its inline cache and ran the +unchanged descendant/canary shutdown check. Every provisioning layer was cached. + +| Pair/order | Baseline | Candidate | Saving | +| ------------------ | -------- | --------- | ------- | +| 1: baseline first | 16.320 s | 10.198 s | 6.122 s | +| 2: candidate first | 16.389 s | 10.141 s | 6.248 s | +| 3: baseline first | 16.351 s | 10.223 s | 6.129 s | +| Median | 16.351 s | 10.198 s | 6.153 s | + +Median decompression fell from 1.059 to 0.493 seconds and loading from 10.723 to +5.125 seconds. Cached rebuild and shutdown times stayed close. Both seed images +and all six restored consumers passed the original shutdown/canary assertions; +both deliberate no-op disposal controls failed with the descendant still live. +Byte and retained-directory mode faults also failed the inventory comparator. +All six owned daemons stopped gracefully without a forced kill. + +These private daemons used separate classic overlay2 stores and the untouched +host containerd service. Production storage settings were not captured, the +filesystem cache was not flushed, and network transfer is excluded. Production +restores overlap dependency installation, so this 37.6% fixture-sequence saving +does not establish a six-second PR wall-time improvement. Single cold image +builds took 19.313 and 22.623 seconds. The Dockerfile change creates one new +fixture key; the existing main warmer seeds it after merge. + +## WebRTC egress fixture: avoid GPU initialization for the data channel + +The Linux-only probe disables hardware acceleration before Electron readiness. +It still creates two independent processes/profiles, a real data channel, offer +and local description, and checks the exact proxy and UDP policy. The three-second +host observation, 500 ms drain and 20/30/45-second deadlines remain unchanged. + +Two fresh Ubuntu x64 runners compared three alternating pairs each, with identical +phase instrumentation. The [first trial](https://github.com/stablyai/orca/actions/runs/36967511524) +started with the baseline; the [second trial](https://github.com/stablyai/orca/actions/runs/36969120786) +started with the candidate. Their first baseline peer constructors took 4.059 +and 2.546 seconds and logged the GPU command-buffer error seen in an earlier +package timeout. In the second trial, that baseline delay followed the cold +candidate's 2.3 ms constructor. Every candidate constructor took 2.0–2.6 ms. + +Typical process time stayed near 8.3 seconds: baseline/candidate medians were +8.288/8.273 seconds in the first trial and 8.298/8.380 in the reverse trial. +The evidence supports removing an avoidable startup delay, without a measured +typical throughput gain or an estimate of future timeout frequency. + +All 12 full case invocations preserved actual unprotected UDP and zero protected +UDP. Both trials rejected seven faults: missing policy, a packet at 2.9 seconds, +a packet during the drain, a missing peer factory or local description, a broken +packet counter and a hung renderer. The original assertions and deadlines caught +each fault. The normal package gate runs the uninstrumented fixture. + +## Remote resync fixture: keep coalesced frames in one decoder pass + +The first [full PR run](https://github.com/stablyai/orca/actions/runs/36971375340) +passed both package jobs but failed one remote-workspace ordering assertion: +it observed revisions `[2, 3]` where the fixture expected `[3]`. The decoder can +yield between two frames after its four-millisecond work budget. Under slow +scheduling, the first response's promise can publish revision 2 before the +second frame's revision 3 notification is dispatched. + +The fixture now holds its delivery clock at the actual timestamp from multiplexer +construction through the first coalesced-buffer delivery, following the existing +decoder test pattern. It restores the clock before asynchronous assertions and +again before disposal. All four source/order cases retain their exact cache, +publication, client-identity and follow-up-read assertions. Production decoding, +its fairness budget and remote messages are unchanged. + +Normal focused runs passed all 18 tests. Advancing the clock by four milliseconds +per call reproduced `[2, 3]` in both original response-first cases; the fixed +fixture passed all 18 under the same control. Removing the freeze reproduced both +failures. Bypassing the production read-safety guard still caused `[3, 2]` rollback +in all four ordering cases and eight failing tests overall. All controls preserved +the same 18 test identities. This corrects a reproducible fixture assumption; +one CI failure does not establish a failure-rate reduction. + +## Windows server cache metadata: retain the current key + +The bounded 50-head main sample ending at 8ff6296 contained no root package +metadata changes. Removing app-version metadata from the Windows server cache +key would not improve reuse in that sample, so the key remains unchanged. diff --git a/src/main/browser/browser-route-webrtc-egress.electron.test.ts b/src/main/browser/browser-route-webrtc-egress.electron.test.ts index f561ec500ee..1dc9b5ac9a1 100644 --- a/src/main/browser/browser-route-webrtc-egress.electron.test.ts +++ b/src/main/browser/browser-route-webrtc-egress.electron.test.ts @@ -32,6 +32,8 @@ afterAll(() => { function probeMain(resultPath: string, protectedGuest: boolean): string { return ` const { app, BrowserWindow, session } = require('electron') +// This data-channel probe does not need Linux GPU initialization. +if (process.platform === 'linux') app.disableHardwareAcceleration() const dgram = require('node:dgram') const net = require('node:net') const os = require('node:os') diff --git a/src/main/ipc/remote-workspace-stale-resync.test.ts b/src/main/ipc/remote-workspace-stale-resync.test.ts index 07950563a94..b87a9a9893f 100644 --- a/src/main/ipc/remote-workspace-stale-resync.test.ts +++ b/src/main/ipc/remote-workspace-stale-resync.test.ts @@ -321,6 +321,8 @@ describe('workspace.stale resync', () => { 'never publishes an older read after a newer $source notification ($order)', async ({ source, order }) => { let receive: ((data: Buffer) => void) | undefined + // Keep both frames in one decoder pass for the ordering assertions. + const deliveryClock = vi.spyOn(Date, 'now').mockReturnValue(Date.now()) const mux = new SshChannelMultiplexer({ write: () => {}, onData: (callback) => { @@ -349,6 +351,7 @@ describe('workspace.stale resync', () => { receive?.( Buffer.concat(messages.map((message, index) => encodeJsonRpcFrame(message, index + 1, 0))) ) + deliveryClock.mockRestore() if (source === 'own') { expect(getCachedRemoteWorkspaceSnapshot('target-1')?.hostObservationToken).toBe( initial.hostObservationToken @@ -365,6 +368,7 @@ describe('workspace.stale resync', () => { expect(sent.map((event) => event.snapshot.revision)).toEqual([3]) expect(sent[0].sourceClientId).toBe(sourceClientId) } finally { + deliveryClock.mockRestore() mux.dispose() } } From 13ecf051c3d14ceb5e5c798a06a660be27d6a93b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:31:57 -0700 Subject: [PATCH 13/38] Reuse prepared Windows native builds in SSH CI (#24555) * ci: reuse qualified Windows server slots for SSH host tests * ci: reuse prepared relay addons after an exact native cache hit --- .../prepare-orcad-prebuilds/action.yml | 67 ++++++++ .github/workflows/node-server-tests.yml | 59 ++----- .github/workflows/ssh-windows-hosts.yml | 23 ++- ...build-windows-process-tree-relay-addon.mjs | 26 +++- config/scripts/node-server-change-scope.mjs | 1 + .../scripts/node-server-change-scope.test.mjs | 2 + config/scripts/node-server-qualification.mjs | 3 +- .../node-server-qualification.test.mjs | 1 + .../scripts/orcad-windows-prebuild-cache.mjs | 1 + .../orcad-windows-prebuild-workflow.test.mjs | 123 ++++++++++++--- ...ay-windows-process-tree-prepared-addon.mjs | 37 +++++ ...ndows-process-tree-prepared-addon.test.mjs | 145 ++++++++++++++++++ docs/reference/ci-runner-efficiency.md | 36 +++++ 13 files changed, 446 insertions(+), 78 deletions(-) create mode 100644 .github/actions/prepare-orcad-prebuilds/action.yml create mode 100644 config/scripts/relay-windows-process-tree-prepared-addon.mjs create mode 100644 config/scripts/relay-windows-process-tree-prepared-addon.test.mjs diff --git a/.github/actions/prepare-orcad-prebuilds/action.yml b/.github/actions/prepare-orcad-prebuilds/action.yml new file mode 100644 index 00000000000..635fb9d6d54 --- /dev/null +++ b/.github/actions/prepare-orcad-prebuilds/action.yml @@ -0,0 +1,67 @@ +name: Prepare server native prebuilds +description: Builds and smokes the current server slot, optionally restoring a validated Windows slot. + +inputs: + resolve-windows-cache: + description: Resolve the exact Windows cache identity for restoration or later qualified publication. + default: 'false' + restore-windows-cache: + description: Restore the exact Windows slot when its identity is available. + default: 'false' + +outputs: + cache-key: + description: Exact Windows server cache key. + value: ${{ steps.orcad-prebuild-cache-identity.outputs.key }} + cache-path: + description: Directory containing the Windows slot and its manifest. + value: ${{ steps.orcad-prebuild-cache-identity.outputs.path }} + cache-identity-outcome: + description: Whether Windows cache identity resolution succeeded. + value: ${{ steps.orcad-prebuild-cache-identity.outcome }} + cache-hit: + description: Whether the exact Windows slot was restored. + value: ${{ steps.orcad-prebuild-cache-restore.outputs.cache-hit }} + +runs: + using: composite + steps: + - name: Resolve this Windows server prebuild cache + id: orcad-prebuild-cache-identity + if: >- + inputs.resolve-windows-cache == 'true' && runner.os == 'Windows' && + (runner.arch == 'X64' || runner.arch == 'ARM64') + continue-on-error: true + shell: bash + run: node config/scripts/orcad-windows-prebuild-cache.mjs --fingerprint + + - name: Restore the exact Windows server prebuild + id: orcad-prebuild-cache-restore + if: >- + inputs.restore-windows-cache == 'true' && + steps.orcad-prebuild-cache-identity.outcome == 'success' && + steps.orcad-prebuild-cache-identity.outputs.key != '' + continue-on-error: true + uses: actions/cache/restore@v5 + with: + path: ${{ steps.orcad-prebuild-cache-identity.outputs.path }} + key: ${{ steps.orcad-prebuild-cache-identity.outputs.key }} + + - name: Build and smoke this runner's node-pty prebuild slot under the pinned Node + shell: bash + env: + WINDOWS_PREBUILD_CACHE_HIT: ${{ steps.orcad-prebuild-cache-restore.outputs.cache-hit }} + WINDOWS_PREBUILD_CACHE_RESTORE_OUTCOME: ${{ steps.orcad-prebuild-cache-restore.outcome }} + run: | + if [ "$RUNNER_OS" = Windows ] && [ "$WINDOWS_PREBUILD_CACHE_HIT" = true ] && + [ "$WINDOWS_PREBUILD_CACHE_RESTORE_OUTCOME" = success ] && + node config/scripts/orcad-windows-prebuild-cache.mjs --validate; then + echo 'Using the validated Windows server prebuild' + else + if [ "$RUNNER_OS" = Windows ]; then + rm -rf -- out/orcad-prebuilds + fi + pnpm build:orcad-prebuilds + fi + pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)" + pnpm build:orcad-prebuilds --smoke diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 449ad212864..d8d6802576c 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -17,6 +17,7 @@ on: - '.pnpmfile.cjs' - '.github/actions/install-node-dependencies/**' - '.github/actions/prepare-native-runtime/**' + - '.github/actions/prepare-orcad-prebuilds/**' - '.github/workflows/node-server-tests.yml' # Relevant main pushes qualify every platform after the dependency check. push: @@ -35,6 +36,7 @@ on: - '.pnpmfile.cjs' - '.github/actions/install-node-dependencies/**' - '.github/actions/prepare-native-runtime/**' + - '.github/actions/prepare-orcad-prebuilds/**' - '.github/workflows/node-server-tests.yml' workflow_dispatch: inputs: @@ -132,48 +134,15 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: ${{ runner.os == 'Windows' && 'node' || 'none' }} - - name: Resolve this Windows server prebuild cache - id: orcad-prebuild-cache-identity - if: >- - runner.os == 'Windows' && (runner.arch == 'X64' || runner.arch == 'ARM64') && - !inputs.build_template && inputs.ref == '' && - (github.event_name == 'pull_request' || - (github.ref == 'refs/heads/main' && contains(fromJSON('["push","schedule","workflow_dispatch"]'), github.event_name))) - continue-on-error: true - shell: bash - run: node config/scripts/orcad-windows-prebuild-cache.mjs --fingerprint - - name: Restore the exact Windows server prebuild - id: orcad-prebuild-cache-restore - if: >- - (github.event_name == 'pull_request' || github.event_name == 'push') && - steps.orcad-prebuild-cache-identity.outcome == 'success' && - steps.orcad-prebuild-cache-identity.outputs.key != '' - continue-on-error: true - uses: actions/cache/restore@v5 + # Linux release slots come from the floor and Alpine lanes; this slot serves local tests. + - uses: ./.github/actions/prepare-orcad-prebuilds + id: orcad-prebuild with: - path: ${{ steps.orcad-prebuild-cache-identity.outputs.path }} - key: ${{ steps.orcad-prebuild-cache-identity.outputs.key }} - # Before setup-node 18: the scripts import the TypeScript runtime pin. - # Linux release slots come from the Ubuntu 20.04 and Alpine lanes below, where their libc - # floors live; this runner's slot only packages orcad for its own tests. - - name: Build and smoke this runner's node-pty prebuild slot under the pinned Node - shell: bash - env: - WINDOWS_PREBUILD_CACHE_HIT: ${{ steps.orcad-prebuild-cache-restore.outputs.cache-hit }} - WINDOWS_PREBUILD_CACHE_RESTORE_OUTCOME: ${{ steps.orcad-prebuild-cache-restore.outcome }} - run: | - if [ "$RUNNER_OS" = Windows ] && [ "$WINDOWS_PREBUILD_CACHE_HIT" = true ] && - [ "$WINDOWS_PREBUILD_CACHE_RESTORE_OUTCOME" = success ] && - node config/scripts/orcad-windows-prebuild-cache.mjs --validate; then - echo 'Using the validated Windows server prebuild' - else - if [ "$RUNNER_OS" = Windows ]; then - rm -rf -- out/orcad-prebuilds - fi - pnpm build:orcad-prebuilds - fi - pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)" - pnpm build:orcad-prebuilds --smoke + resolve-windows-cache: >- + ${{ !inputs.build_template && inputs.ref == '' && + (github.event_name == 'pull_request' || + (github.ref == 'refs/heads/main' && contains(fromJSON('["push","schedule","workflow_dispatch"]'), github.event_name))) }} + restore-windows-cache: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} - run: pnpm build:orcad # Design D7 upgrade and rollback: the last Bun orcad, built from a main commit that shipped # it, beside this checkout's Node slot; the live-terminal hand-over skips once PROTOCOL_VERSION @@ -229,13 +198,13 @@ jobs: success() && runner.os == 'Windows' && (runner.arch == 'X64' || runner.arch == 'ARM64') && !inputs.build_template && inputs.ref == '' && github.ref == 'refs/heads/main' && contains(fromJSON('["push","schedule","workflow_dispatch"]'), github.event_name) && - steps.orcad-prebuild-cache-identity.outcome == 'success' && - steps.orcad-prebuild-cache-identity.outputs.key != '' + steps.orcad-prebuild.outputs.cache-identity-outcome == 'success' && + steps.orcad-prebuild.outputs.cache-key != '' continue-on-error: true uses: actions/cache/save@v5 with: - path: ${{ steps.orcad-prebuild-cache-identity.outputs.path }} - key: ${{ steps.orcad-prebuild-cache-identity.outputs.key }} + path: ${{ steps.orcad-prebuild.outputs.cache-path }} + key: ${{ steps.orcad-prebuild.outputs.cache-key }} # Linux release slots come from the floor and Alpine lanes; these runners own the rest. - name: Keep this runner's qualified slot for the desktop template if: inputs.build_template && runner.os != 'Linux' diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index fc427e28940..a677bd8589e 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -28,12 +28,15 @@ on: - 'config/patches/@vscode__windows-process-tree*' - 'config/scripts/build-windows-process-tree-relay-addon.mjs' - 'config/scripts/relay-windows-process-tree-staging.mjs' + - 'config/scripts/relay-windows-process-tree-prepared-addon*.mjs' - 'config/scripts/windows-process-tree-gyp-rebuild.mjs' - 'src/shared/relay-windows-breakaway-launch.ts' - '!src/**/*.test.ts' - 'src/main/ssh/ssh-relay-windows-host-lane.test.ts' - 'config/ci/windows-ssh-provider/**' - '.github/workflows/ssh-windows-hosts.yml' + - '.github/actions/prepare-orcad-prebuilds/**' + - 'config/scripts/orcad-windows-prebuild-cache.mjs' workflow_dispatch: inputs: cells: @@ -81,6 +84,7 @@ jobs: with: persist-credentials: false - uses: ./.github/actions/install-node-dependencies + id: dependencies with: native-runtime: node - name: Self-test the provisioning scripts before touching the machine @@ -95,15 +99,26 @@ jobs: # The deploy materializes rung A from this template; only this runner's slot exists here. # The process-tree addon carries the launcher that starts the relay outside sshd's job; the # orcad slot and the relay both stage it, and a standard-user host has no other launch route. - - name: Build this runner's orcad slot, the win32 template and the relay + - name: Build this runner's Windows process-table addon + shell: bash + env: + REUSE_PREPARED_RUNTIME: ${{ github.event_name == 'pull_request' && steps.dependencies.outputs.native-cache-hit == 'true' }} + run: | + reuse_args=() + if [ "$REUSE_PREPARED_RUNTIME" = true ]; then + reuse_args+=(--reuse-prepared-runtime) + fi + node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=${{ matrix.arch }} "${reuse_args[@]}" + - uses: ./.github/actions/prepare-orcad-prebuilds + with: + resolve-windows-cache: ${{ github.event_name == 'pull_request' }} + restore-windows-cache: ${{ github.event_name == 'pull_request' }} + - name: Build the win32 template and the relay shell: bash env: ORCA_REQUIRE_RELAY_NATIVE_ADDONS: ${{ matrix.arch }} run: | - node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=${{ matrix.arch }} - pnpm build:orcad-prebuilds pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}" - pnpm build:orcad-prebuilds --smoke node config/scripts/build-orcad-template.mjs --targets "win32-${{ matrix.arch }}" pnpm run build:relay - name: Run the Windows host cells against a private ${{ matrix.server }} sshd diff --git a/config/scripts/build-windows-process-tree-relay-addon.mjs b/config/scripts/build-windows-process-tree-relay-addon.mjs index bd810144b8a..7ce52837c18 100644 --- a/config/scripts/build-windows-process-tree-relay-addon.mjs +++ b/config/scripts/build-windows-process-tree-relay-addon.mjs @@ -22,6 +22,7 @@ import { execFileSync } from 'node:child_process' import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { join, resolve } from 'node:path' +import { canReusePreparedRelayAddon } from './relay-windows-process-tree-prepared-addon.mjs' import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts' import { ensureWindowsProcessTreeCommandLinePatch, @@ -410,14 +411,15 @@ function applyWindowsProcessTreeBuildFixes() { const repairedCreationTime = repairCreationTimeSources() stageWindowsProcessTreeNodeAddonApiHeaders(PACKAGE_DIR) const repairedCommandLine = ensureWindowsProcessTreeCommandLinePatch(PACKAGE_DIR) - if ( + const repaired = bindingGyp !== originalBinding || processCc !== originalProcess || repairedCommandLine || repairedCreationTime - ) { + if (repaired) { console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.') } + return repaired } function main() { @@ -431,14 +433,24 @@ function main() { if (!existsSync(PACKAGE_DIR)) { throw new Error(`${PACKAGE_DIR} is missing. Run pnpm install first.`) } - applyWindowsProcessTreeBuildFixes() + const sourceRepaired = applyWindowsProcessTreeBuildFixes() assertPatchApplied() - const gyp = nodeGypRebuildInvocation(arch) - console.log(`[windows-process-tree] building ${arch} from ${gyp.cwd}`) - execFileSync(process.execPath, gyp.args, { cwd: gyp.cwd, stdio: 'inherit' }) - const built = join(PACKAGE_DIR, 'build', 'Release', 'windows_process_tree.node') + if ( + canReusePreparedRelayAddon({ + enabled: process.argv.includes('--reuse-prepared-runtime'), + arch, + addonPath: built, + sourceRepaired + }) + ) { + console.log(`[windows-process-tree] reusing the prepared ${arch} addon`) + } else { + const gyp = nodeGypRebuildInvocation(arch) + console.log(`[windows-process-tree] building ${arch} from ${gyp.cwd}`) + execFileSync(process.execPath, gyp.args, { cwd: gyp.cwd, stdio: 'inherit' }) + } if (!existsSync(built)) { throw new Error(`node-gyp reported success but ${built} is missing.`) } diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index 342e5ad7c3a..7badba9fa98 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -43,6 +43,7 @@ const ALWAYS_FILES = new Set([ const ALWAYS_PREFIXES = [ '.github/actions/install-node-dependencies/', '.github/actions/prepare-native-runtime/', + '.github/actions/prepare-orcad-prebuilds/', // These areas also contain worker paths and fixtures opened without an import. 'src/main/persistence/', 'src/main/sqlite/', diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 6c1903f8031..0ff118ed678 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -84,6 +84,7 @@ it.each([ 'native/windows-registry/src/addon.cc', '.github/actions/install-node-dependencies/action.yml', '.github/actions/prepare-native-runtime/action.yml', + '.github/actions/prepare-orcad-prebuilds/action.yml', '.github/workflows/node-server-tests.yml', 'src/main/persistence/profile-state/new-worker.ts' ])('always selects build, native and dynamically opened inputs: %s', async (file) => { @@ -113,6 +114,7 @@ describe('the actual Bun build and profile-test dependency graph', () => { 'src/main/worker-thread-entry-path.ts', 'config/scripts/zip-extractor-command.mjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', + 'config/scripts/relay-windows-process-tree-prepared-addon.mjs', 'config/scripts/orcad-windows-prebuild-cache.mjs', 'config/scripts/profile-state-worker-smoke.mjs', 'config/scripts/vitest-host-ports-setup.ts', diff --git a/config/scripts/node-server-qualification.mjs b/config/scripts/node-server-qualification.mjs index dc409a1c84d..9901b609a9b 100644 --- a/config/scripts/node-server-qualification.mjs +++ b/config/scripts/node-server-qualification.mjs @@ -12,7 +12,8 @@ const BUILD_PREFIXES = [ 'native/', 'config/patches/', '.github/actions/install-node-dependencies/', - '.github/actions/prepare-native-runtime/' + '.github/actions/prepare-native-runtime/', + '.github/actions/prepare-orcad-prebuilds/' ] // A remote target's OS does not identify the client platform that builds its commands. const CROSS_HOST_PREFIXES = ['src/main/ssh/', 'src/main/providers/', 'src/relay/'] diff --git a/config/scripts/node-server-qualification.test.mjs b/config/scripts/node-server-qualification.test.mjs index 9c3c0bd7b27..6018fe6a7e4 100644 --- a/config/scripts/node-server-qualification.test.mjs +++ b/config/scripts/node-server-qualification.test.mjs @@ -28,6 +28,7 @@ it.each([ 'config/patches/node-pty.patch', '.github/actions/install-node-dependencies/action.yml', '.github/actions/prepare-native-runtime/action.yml', + '.github/actions/prepare-orcad-prebuilds/action.yml', 'src/main/ssh/ssh-provider.ts', 'src/main/providers/local-pty-provider.ts', 'src/shared/child-process/run-process.ts', diff --git a/config/scripts/orcad-windows-prebuild-cache.mjs b/config/scripts/orcad-windows-prebuild-cache.mjs index 31c617ea1a6..2cd7d7636b1 100644 --- a/config/scripts/orcad-windows-prebuild-cache.mjs +++ b/config/scripts/orcad-windows-prebuild-cache.mjs @@ -19,6 +19,7 @@ const root = resolve(import.meta.dirname, '../..') const ownership = require('./node-pty-job-ownership.cjs') export const WINDOWS_PREBUILD_CACHE_INPUTS = [ 'package.json', + '.github/actions/prepare-orcad-prebuilds/action.yml', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'config/patches/node-pty@1.1.0.patch', diff --git a/config/scripts/orcad-windows-prebuild-workflow.test.mjs b/config/scripts/orcad-windows-prebuild-workflow.test.mjs index 3d42ffd8df3..3e6c1199e3c 100644 --- a/config/scripts/orcad-windows-prebuild-workflow.test.mjs +++ b/config/scripts/orcad-windows-prebuild-workflow.test.mjs @@ -8,28 +8,54 @@ import { runProcessSync } from './script-child-process.mjs' const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8')) const steps = workflow.jobs.persistence.steps -const identity = steps.find((step) => step.id === 'orcad-prebuild-cache-identity') -const restore = steps.find((step) => step.id === 'orcad-prebuild-cache-restore') -const build = steps.find((step) => step.name?.startsWith('Build and smoke this runner')) +const prepare = steps.find((step) => step.id === 'orcad-prebuild') +const action = parse(readFileSync('.github/actions/prepare-orcad-prebuilds/action.yml', 'utf8')) +const actionSteps = action.runs.steps +const identity = actionSteps.find((step) => step.id === 'orcad-prebuild-cache-identity') +const restore = actionSteps.find((step) => step.id === 'orcad-prebuild-cache-restore') +const build = actionSteps.find((step) => step.name?.startsWith('Build and smoke this runner')) const save = steps.find((step) => step.uses === 'actions/cache/save@v5') function evaluate(expression, context) { return runInNewContext( - expression.replaceAll( - 'steps.orcad-prebuild-cache-identity', - 'steps["orcad-prebuild-cache-identity"]' - ), + expression + .replaceAll('steps.orcad-prebuild-cache-identity', 'steps["orcad-prebuild-cache-identity"]') + .replaceAll('steps.orcad-prebuild', 'steps["orcad-prebuild"]') + .replace( + /\.(resolve-windows-cache|restore-windows-cache|cache-identity-outcome|cache-key)\b/g, + (_match, name) => `["${name}"]` + ), context ) } +function actionContext(ctx, caller = prepare) { + return { + ...ctx, + inputs: Object.fromEntries( + Object.entries(caller.with).map(([key, value]) => [ + key, + value.startsWith('${{') ? String(evaluate(value.slice(3, -2).trim(), ctx)) : value + ]) + ) + } +} + +function setIdentity(ctx, outcome, key = 'exact-key') { + ctx.steps['orcad-prebuild-cache-identity'] = { outcome, outputs: { key } } + ctx.steps['orcad-prebuild'].outputs = { 'cache-identity-outcome': outcome, 'cache-key': key } +} + function context(os, arch, event, ref, template = false) { return { runner: { os, arch }, github: { event_name: event, ref }, inputs: { build_template: template, ref: '' }, steps: { - 'orcad-prebuild-cache-identity': { outcome: 'success', outputs: { key: 'exact-key' } } + 'orcad-prebuild-cache-identity': { outcome: 'success', outputs: { key: 'exact-key' } }, + 'orcad-prebuild': { + outputs: { 'cache-identity-outcome': 'success', 'cache-key': 'exact-key' } + } }, success: () => true, fromJSON: JSON.parse, @@ -58,10 +84,10 @@ describe('Windows server prebuild cache workflow', () => { ]) { const ctx = context(os, arch, event, ref, template) const windows = os === 'Windows' && ['X64', 'ARM64'].includes(arch) - const resolves = evaluate(identity.if, ctx) + const resolves = evaluate(identity.if, actionContext(ctx)) expect(resolves).toBe(windows && (reads || writes)) - ctx.steps['orcad-prebuild-cache-identity'].outcome = resolves ? 'success' : 'skipped' - expect(evaluate(restore.if, ctx)).toBe(windows && reads) + setIdentity(ctx, resolves ? 'success' : 'skipped') + expect(evaluate(restore.if, actionContext(ctx))).toBe(windows && reads) expect(evaluate(save.if, ctx)).toBe(windows && writes) ctx.success = () => false expect(evaluate(save.if, ctx)).toBe(false) @@ -72,15 +98,15 @@ describe('Windows server prebuild cache workflow', () => { for (const event of ['pull_request', 'push']) { const ctx = context('Windows', 'X64', event, 'refs/heads/main') for (const outcome of ['failure', 'skipped']) { - ctx.steps['orcad-prebuild-cache-identity'].outcome = outcome - expect(evaluate(restore.if, ctx)).toBe(false) + setIdentity(ctx, outcome) + expect(evaluate(restore.if, actionContext(ctx))).toBe(false) expect(evaluate(save.if, ctx)).toBe(false) } - ctx.steps['orcad-prebuild-cache-identity'] = { outcome: 'success', outputs: { key: '' } } - expect(evaluate(restore.if, ctx)).toBe(false) + setIdentity(ctx, 'success', '') + expect(evaluate(restore.if, actionContext(ctx))).toBe(false) expect(evaluate(save.if, ctx)).toBe(false) ctx.inputs.ref = 'refs/tags/v1' - expect(evaluate(identity.if, ctx)).toBe(false) + expect(evaluate(identity.if, actionContext(ctx))).toBe(false) expect(evaluate(save.if, ctx)).toBe(false) } }) @@ -95,10 +121,10 @@ describe('Windows server prebuild cache workflow', () => { ]) { const ctx = context('Windows', 'X64', event, 'refs/heads/main') ctx.inputs = inputs - expect(evaluate(identity.if, ctx)).toBe(false) + expect(evaluate(identity.if, actionContext(ctx))).toBe(false) expect(evaluate(save.if, ctx)).toBe(false) - ctx.steps['orcad-prebuild-cache-identity'].outcome = 'skipped' - expect(evaluate(restore.if, ctx)).toBe(false) + setIdentity(ctx, 'skipped') + expect(evaluate(restore.if, actionContext(ctx))).toBe(false) } } ) @@ -109,11 +135,17 @@ describe('Windows server prebuild cache workflow', () => { expect(restore.uses).toBe('actions/cache/restore@v5') expect(restore['continue-on-error']).toBe(true) expect(restore.with['restore-keys']).toBeUndefined() - expect(restore.with).toEqual(save.with) + expect(action.outputs['cache-path'].value).toBe(restore.with.path) + expect(action.outputs['cache-key'].value).toBe(restore.with.key) + expect(save.with.path).toBe('${{ steps.orcad-prebuild.outputs.cache-path }}') + expect(save.with.key).toBe('${{ steps.orcad-prebuild.outputs.cache-key }}') + expect(prepare.uses).toBe('./.github/actions/prepare-orcad-prebuilds') expect(restore.with.key).toBe('${{ steps.orcad-prebuild-cache-identity.outputs.key }}') expect(restore.with.path).toBe('${{ steps.orcad-prebuild-cache-identity.outputs.path }}') - expect(steps.indexOf(restore)).toBeLessThan(steps.indexOf(build)) + expect(actionSteps.indexOf(restore)).toBeLessThan(actionSteps.indexOf(build)) expect(save['continue-on-error']).toBe(true) + expect(steps.indexOf(save)).toBeGreaterThan(steps.indexOf(prepare)) + expect(actionSteps.some((step) => step.uses === 'actions/cache/save@v5')).toBe(false) expect(build.if).toBeUndefined() expect(build['continue-on-error']).toBeUndefined() for (const gate of steps.filter((step) => @@ -137,6 +169,55 @@ describe('Windows server prebuild cache workflow', () => { }) }) +describe('SSH Windows consumers of qualified server slots', () => { + const sshWorkflow = parse(readFileSync('.github/workflows/ssh-windows-hosts.yml', 'utf8')) + const sshSteps = sshWorkflow.jobs.hosts.steps + const sshPrepare = sshSteps.find((step) => step.uses === prepare.uses) + + it.each(['pull_request', 'workflow_dispatch'])( + 'restores only PR slots and keeps manual %s qualification fresh', + (event) => { + for (const arch of ['X64', 'ARM64']) { + const ctx = context('Windows', arch, event, 'refs/heads/main') + expect(evaluate(identity.if, actionContext(ctx, sshPrepare))).toBe(event === 'pull_request') + expect(evaluate(restore.if, actionContext(ctx, sshPrepare))).toBe(event === 'pull_request') + } + expect(sshSteps.some((step) => step.uses === 'actions/cache/save@v5')).toBe(false) + } + ) + + it('keeps both architectures, both sshd versions and the existing build order', () => { + expect( + sshWorkflow.jobs.hosts.strategy.matrix.include.map(({ arch, server }) => [arch, server]) + ).toEqual([ + ['x64', 'inbox'], + ['x64', 'preview'], + ['arm64', 'inbox'], + ['arm64', 'preview'] + ]) + const addon = sshSteps.findIndex((step) => + step.run?.includes('build-windows-process-tree-relay-addon.mjs') + ) + const template = sshSteps.findIndex((step) => step.run?.includes('build-orcad-template.mjs')) + const hosts = sshSteps.findIndex((step) => step.name?.startsWith('Run the Windows host cells')) + expect(addon).toBeLessThan(sshSteps.indexOf(sshPrepare)) + expect(sshSteps.indexOf(sshPrepare)).toBeLessThan(template) + expect(template).toBeLessThan(hosts) + expect(sshSteps[template].env.ORCA_REQUIRE_RELAY_NATIVE_ADDONS).toBe('${{ matrix.arch }}') + expect(sshSteps[template].run).toContain('--require-slots "win32-${{ matrix.arch }}"') + expect(sshSteps[hosts].run).toContain("@('pinned-cmd','pinned-powershell','legacy-opt-out')") + for (const workflowPaths of [ + workflow.on.pull_request.paths, + sshWorkflow.on.pull_request.paths + ]) { + expect(workflowPaths).toContain('.github/actions/prepare-orcad-prebuilds/**') + } + expect(sshWorkflow.on.pull_request.paths).toContain( + 'config/scripts/orcad-windows-prebuild-cache.mjs' + ) + }) +}) + const commandMocks = ` node() { echo "node $*" >> "$COMMAND_LOG" diff --git a/config/scripts/relay-windows-process-tree-prepared-addon.mjs b/config/scripts/relay-windows-process-tree-prepared-addon.mjs new file mode 100644 index 00000000000..b4d9ff4ced5 --- /dev/null +++ b/config/scripts/relay-windows-process-tree-prepared-addon.mjs @@ -0,0 +1,37 @@ +import { join } from 'node:path' +import { runProcessSync } from './script-child-process.mjs' +import { relayWindowsProcessTreeAddonDefect } from './windows-process-tree-gyp-rebuild.mjs' + +const root = join(import.meta.dirname, '../..') + +function checkPreparedRuntime() { + return runProcessSync({ + program: process.execPath, + args: [join(root, 'config/scripts/ensure-native-runtime.mjs'), '--check-only'], + cwd: root, + timeoutMs: 30_000 + }) +} + +export function canReusePreparedRelayAddon({ + enabled = false, + arch, + addonPath, + sourceRepaired, + hostArch = process.arch, + platform = process.platform, + checkRuntime = checkPreparedRuntime +}) { + if (!enabled || sourceRepaired !== false || platform !== 'win32' || arch !== hostArch) { + return false + } + try { + if (relayWindowsProcessTreeAddonDefect(addonPath, arch) !== null) { + return false + } + const result = checkRuntime() + return result.code === 0 && !result.timedOut && !result.outputTruncated + } catch { + return false + } +} diff --git a/config/scripts/relay-windows-process-tree-prepared-addon.test.mjs b/config/scripts/relay-windows-process-tree-prepared-addon.test.mjs new file mode 100644 index 00000000000..106147f9cf5 --- /dev/null +++ b/config/scripts/relay-windows-process-tree-prepared-addon.test.mjs @@ -0,0 +1,145 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runInNewContext } from 'node:vm' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { peImage } from './windows-pe-image-fixture.mjs' +import { canReusePreparedRelayAddon } from './relay-windows-process-tree-prepared-addon.mjs' +import { runProcessSync } from './script-child-process.mjs' + +const directories = [] +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function prepared(arch = 'x64', suffix = 'spawnOutsideJob') { + const directory = mkdtempSync(join(tmpdir(), 'orca-prepared-relay-addon-')) + directories.push(directory) + const addonPath = join(directory, 'process-tree.node') + writeFileSync(addonPath, Buffer.concat([peImage({ arch }), Buffer.from(suffix)])) + return { + enabled: true, + arch, + hostArch: arch, + platform: 'win32', + sourceRepaired: false, + addonPath, + checkRuntime: vi.fn(() => ({ code: 0, timedOut: false, outputTruncated: false })) + } +} + +describe('reusing a prepared Windows relay addon', () => { + it.each(['x64', 'arm64'])( + 'accepts a clean launcher-capable %s addon after its runtime probe', + (arch) => { + const options = prepared(arch) + expect(canReusePreparedRelayAddon(options)).toBe(true) + expect(options.checkRuntime).toHaveBeenCalledOnce() + } + ) + + it.each([ + { enabled: false }, + { enabled: undefined }, + { platform: 'darwin' }, + { platform: 'linux' }, + { hostArch: 'arm64' }, + { sourceRepaired: true }, + { sourceRepaired: undefined } + ])('compiles freshly without current same-host preparation: %j', (overrides) => { + const options = { ...prepared(), ...overrides } + expect(canReusePreparedRelayAddon(options)).toBe(false) + expect(options.checkRuntime).not.toHaveBeenCalled() + }) + + it.each([ + ['upstream reader', 'ReadProcessMemory spawnOutsideJob'], + ['pre-launcher addon', 'getProcessCreationTime'] + ])('refuses the %s before loading it', (_label, suffix) => { + const options = prepared('x64', suffix) + expect(canReusePreparedRelayAddon(options)).toBe(false) + expect(options.checkRuntime).not.toHaveBeenCalled() + }) + + it('refuses missing, truncated and wrong-architecture binaries', () => { + const options = prepared() + for (const bytes of [Buffer.from('spawnOutsideJob'), peImage({ arch: 'arm64' })]) { + writeFileSync(options.addonPath, bytes) + expect(canReusePreparedRelayAddon(options)).toBe(false) + } + rmSync(options.addonPath) + expect(canReusePreparedRelayAddon(options)).toBe(false) + expect(options.checkRuntime).not.toHaveBeenCalled() + }) + + it.each([ + { code: 1, timedOut: false, outputTruncated: false }, + { code: null, timedOut: false, outputTruncated: false }, + { code: 0, timedOut: true, outputTruncated: false }, + { code: 0, timedOut: false, outputTruncated: true } + ])('falls back when native loading or CreationTime validation fails: %j', (result) => { + const options = prepared() + options.checkRuntime.mockReturnValue(result) + expect(canReusePreparedRelayAddon(options)).toBe(false) + }) + + it('falls back on probe exceptions', () => { + const options = prepared() + options.checkRuntime.mockImplementation(() => { + throw new Error('probe spawn failed') + }) + expect(canReusePreparedRelayAddon(options)).toBe(false) + }) +}) + +const workflow = parse(readFileSync('.github/workflows/ssh-windows-hosts.yml', 'utf8')) +const steps = workflow.jobs.hosts.steps +const preparation = steps.find((step) => step.id === 'dependencies') +const build = steps.find((step) => step.name === "Build this runner's Windows process-table addon") + +it.each(['pull_request', 'workflow_dispatch'])( + 'requests reuse only after an exact prepared PR hit under %s', + (event) => { + expect(workflow.on.pull_request.paths).toContain( + 'config/scripts/relay-windows-process-tree-prepared-addon*.mjs' + ) + expect(preparation.with['native-runtime']).toBe('node') + expect(steps.indexOf(preparation)).toBeLessThan(steps.indexOf(build)) + for (const cacheHit of ['true', 'false', '', undefined]) { + const expression = build.env.REUSE_PREPARED_RUNTIME.slice(3, -2).replace( + 'outputs.native-cache-hit', + 'outputs["native-cache-hit"]' + ) + const enabled = runInNewContext(expression, { + github: { event_name: event }, + steps: { dependencies: { outputs: { 'native-cache-hit': cacheHit } } } + }) + expect(enabled).toBe(event === 'pull_request' && cacheHit === 'true') + } + } +) + +it.each(['x64', 'arm64'])('passes the optional reuse flag safely to the %s builder', (arch) => { + for (const enabled of ['true', 'false']) { + const directory = mkdtempSync(join(tmpdir(), 'orca-relay-build-args-')) + directories.push(directory) + const output = join(directory, 'arguments.txt') + const script = `node() { printf '%s\n' "$@" > "$ARGUMENTS_FILE"; }\n${build.run.replaceAll('${{ matrix.arch }}', arch)}` + const result = runProcessSync({ + program: 'bash', + args: ['-e', '-c', script], + cwd: directory, + env: { ...process.env, ARGUMENTS_FILE: output, REUSE_PREPARED_RUNTIME: enabled }, + timeoutMs: 10_000 + }) + expect(result.code, result.stderr).toBe(0) + expect(readFileSync(output, 'utf8').trim().split('\n')).toEqual([ + 'config/scripts/build-windows-process-tree-relay-addon.mjs', + `--arch=${arch}`, + ...(enabled === 'true' ? ['--reuse-prepared-runtime'] : []) + ]) + } +}) diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 0b7936aa32d..6c7f0d059cf 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -34,12 +34,48 @@ The [DNF command reference](https://dnf.readthedocs.io/en/stable/command_ref.htm defines `--disablerepo` as a temporary command-level filter, so later commands retain the image's repository configuration. +The [PR qualification run](https://github.com/stablyai/orca/actions/runs/36972824276/job/110734327685) +passed the x64 floor native smoke and persistence suite. Its prerequisite step +finished within GitHub's one-second timing resolution, compared with 5 minutes +37 seconds in the baseline job; the supplied tools needed no package install. +This measures that step, not the complete workflow or its queue time. + A [completed main run](https://github.com/stablyai/orca/actions/runs/36962172614) used 42 aggregate runner-minutes across 11 test jobs. The [latest daily demand report](https://github.com/stablyai/orca/actions/runs/36965354205) estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are baseline observations; post-merge savings have not yet been measured. +## SSH Windows slot reuse + +The SSH Windows host workflow uses the same server-slot preparation action as +headless qualification. Its four PR jobs can restore the exact slot published by +fully qualified main runs, then validate its inventory and hashes and run the +required-slot and pinned-Node smoke checks. Misses or invalid payloads compile +freshly. Only main headless qualification publishes; manual SSH qualification +still builds freshly. Both sshd versions, both architectures, all three host +cells, the process-table addon build, and the template/relay builds remain. + +The action is part of the cache fingerprint, so this extraction starts a new +namespace that needs a successful main seed. The existing hosted measurements +below suggest about 160 aggregate runner-seconds saved across four warm SSH jobs; +that is a conditional estimate, not a measured improvement of this consumer. +Private sshd installation and host execution still dominate this workflow. + +## Prepared relay addon reuse + +A [completed SSH Windows run](https://github.com/stablyai/orca/actions/runs/36972043877) +rebuilt the process-table relay addon after native dependency preparation. From +its builder's start message to the validated staged artifact, x64 took 85.5 seconds +and ARM64 took 135.6 seconds. These are single-run observations, not medians. + +An opt-in reuse path checks the same binary architecture, patched reader and +launcher exports as staging, then runs the existing native-load and CreationTime +probe. Repaired source or incomplete evidence requires a fresh build. SSH PRs +request reuse only following an exact prepared native-cache hit; manual SSH and +all release builders retain fresh compilation. Subsequent staging checks still +run. Hosted validation and the reuse interval remain to be measured. + ## October 1 Windows and dependency cache follow-up [PR #24355](https://github.com/stablyai/orca/pull/24355) merged at `197ea3a3`. From 7b1449acadc91e4401fe2135efab15743da3c39f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:43:55 -0700 Subject: [PATCH 14/38] fix(ai-vault): honor ZCode transcript sequence order (#24584) --- ...session-scanner-opencode-sqlite-capture.ts | 8 +- .../session-scanner-opencode-sqlite.ts | 19 +-- .../ai-vault/session-scanner-zcode-order.ts | 18 +++ .../ai-vault/session-scanner-zcode.test.ts | 116 ++++++++++++++++++ 4 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-zcode-order.ts diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts index ef15b368ef3..818a40ed874 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts @@ -11,6 +11,7 @@ import { canReadOpenCodeMessageParts } from './session-scanner-opencode-sqlite-s import type { TranscriptMessage, TranscriptMessageRole } from './session-transcript-consumers' import { boundedText, toolCallText } from './session-transcript-message-content' import { zcodeVisibleMessageFilter } from './session-scanner-zcode-visibility' +import { zcodeTranscriptOrder } from './session-scanner-zcode-order' import type SyncDatabase from '../sqlite/sync-database' // Why: the session list needs the newest few messages, and the search index @@ -120,7 +121,7 @@ function captureRole(role: string | null): TranscriptMessageRole | null { return role === 'user' || role === 'assistant' ? role : null } -function buildCaptureQuery(agent: 'opencode' | 'zcode'): string { +function buildCaptureQuery(db: SyncDatabase, agent: 'opencode' | 'zcode'): string { // Message order, then part order within a message: the same key the preview // read uses, run forwards and without the newest-N window. return `SELECT m.id AS message_id, @@ -134,7 +135,8 @@ function buildCaptureQuery(agent: 'opencode' | 'zcode'): string { ${zcodeVisibleMessageFilter(agent)} AND json_extract(m.data, '$.role') IN ('user','assistant') AND json_extract(p.data, '$.type') IN ${OPENCODE_CAPTURE_PART_TYPES} - ORDER BY m.time_created ASC, m.id ASC, p.time_created ASC, p.rowid ASC + ORDER BY ${zcodeTranscriptOrder(db, agent, 'message', 'm', 'ASC')}, + ${zcodeTranscriptOrder(db, agent, 'part', 'p', 'ASC')} LIMIT ?` } @@ -164,7 +166,7 @@ export function readOpenCodeSessionMessages( ) } const rows = db - .prepare(buildCaptureQuery(agent)) + .prepare(buildCaptureQuery(db, agent)) .all(sessionId, OPENCODE_CAPTURE_RECORD_LIMIT + 1) if (rows.length > OPENCODE_CAPTURE_RECORD_LIMIT) { throw new Error( diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index 4e3d8bc74b8..3e2052b2aab 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -17,6 +17,7 @@ import { } from './session-scanner-opencode-sqlite-schema' import { normalizeTitleText } from './session-scanner-values' import { zcodeVisibleMessageFilter } from './session-scanner-zcode-visibility' +import { zcodeTranscriptOrder } from './session-scanner-zcode-order' import type SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' @@ -162,11 +163,11 @@ function readFirstUserPromptFromOpenCodeDb( ${zcodeVisibleMessageFilter(agent)} AND json_extract(m.data, '$.role') = 'user' AND json_extract(fp.data, '$.type') = 'text' - ORDER BY m.time_created ASC, m.id ASC + ORDER BY ${zcodeTranscriptOrder(db, agent, 'message', 'm', 'ASC')} LIMIT 1 ) AND json_extract(p.data, '$.type') = 'text' - ORDER BY p.time_created ASC, p.rowid ASC + ORDER BY ${zcodeTranscriptOrder(db, agent, 'part', 'p', 'ASC')} LIMIT ${FIRST_USER_PROMPT_PART_LIMIT}` ) .all(sessionId) @@ -198,15 +199,19 @@ function buildPreviewQuery(db: SyncDatabase, agent: 'opencode' | 'zcode'): strin p.time_created, json_extract(m.data, '$.summary.title') AS summary_title, json_extract(m.data, '$.summary.body') AS summary_body - FROM (SELECT id, data FROM message - WHERE session_id = ? - ${zcodeVisibleMessageFilter(agent, 'data')} - ORDER BY time_created DESC, id DESC + FROM (SELECT ${agent === 'zcode' ? 'm.rowid AS rowid, m.*' : 'm.id, m.data'} FROM message m + WHERE m.session_id = ? + ${zcodeVisibleMessageFilter(agent)} + ORDER BY ${zcodeTranscriptOrder(db, agent, 'message', 'm', 'DESC')} LIMIT ${OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW}) m JOIN part p ON p.message_id = m.id WHERE json_extract(m.data, '$.role') IN ('user','assistant') AND json_extract(p.data, '$.type') = 'text' - ORDER BY p.time_created DESC + ORDER BY ${ + agent === 'zcode' + ? `${zcodeTranscriptOrder(db, agent, 'message', 'm', 'DESC')}, ${zcodeTranscriptOrder(db, agent, 'part', 'p', 'DESC')}` + : 'p.time_created DESC' + } LIMIT ?` } diff --git a/src/main/ai-vault/session-scanner-zcode-order.ts b/src/main/ai-vault/session-scanner-zcode-order.ts new file mode 100644 index 00000000000..958f73ff7ca --- /dev/null +++ b/src/main/ai-vault/session-scanner-zcode-order.ts @@ -0,0 +1,18 @@ +import { columnExists } from '../opencode-usage/schema-helpers' +import type SyncDatabase from '../sqlite/sync-database' + +export function zcodeTranscriptOrder( + db: SyncDatabase, + agent: 'opencode' | 'zcode', + table: 'message' | 'part', + alias: 'm' | 'p', + direction: 'ASC' | 'DESC' +): string { + const sequenced = agent === 'zcode' && columnExists(db, table, 'sequence') + const sequence = sequenced + ? `${alias}.sequence IS NULL ${direction}, ${alias}.sequence ${direction}, ` + : '' + // ZCode orders imported/forked transcripts by sequence, with legacy NULL rows last. + const tieBreaker = table === 'message' ? (sequenced ? 'rowid' : 'id') : sequenced ? 'id' : 'rowid' + return `${sequence}${alias}.time_created ${direction}, ${alias}.${tieBreaker} ${direction}` +} diff --git a/src/main/ai-vault/session-scanner-zcode.test.ts b/src/main/ai-vault/session-scanner-zcode.test.ts index 4783b213450..1acade5af17 100644 --- a/src/main/ai-vault/session-scanner-zcode.test.ts +++ b/src/main/ai-vault/session-scanner-zcode.test.ts @@ -57,6 +57,122 @@ afterEach(() => { }) describe('ZCode AI Vault SQLite worker', () => { + it.each(['message', 'part', 'both'] as const)( + 'reads partially migrated %s sequence columns and keeps legacy NULL rows last', + async (tables) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-legacy-sequence-')) + directories.push(directory) + const dbPath = join(directory, 'db.sqlite') + writeOpenCodeSqliteDatabase(dbPath, [ + { + id: 'legacy-session', + turns: [ + { role: 'user', parts: ['first', 'second', 'legacy part'] }, + { role: 'user', parts: ['legacy message'] } + ] + } + ]) + const db = new SyncDatabase(dbPath) + try { + if (tables !== 'part') { + db.exec(`ALTER TABLE message ADD COLUMN sequence INTEGER; + UPDATE message SET sequence = 0 WHERE rowid = 1; + UPDATE message SET time_created = 1 WHERE rowid = 2;`) + } + if (tables !== 'message') { + db.exec(`ALTER TABLE part ADD COLUMN sequence INTEGER; + UPDATE part SET sequence = rowid WHERE rowid <= 2; + UPDATE part SET time_created = 1 WHERE rowid = 3;`) + } + } finally { + db.close() + } + const captured = await handleOpenCodeSqliteRequest({ + id: 8, + kind: 'capture', + agent: 'zcode', + dbPath, + sessionId: 'legacy-session', + platform: 'win32' + }) + expect(captured.ok).toBe(true) + if (!captured.ok) { + return + } + expect(readMessageTexts(captured.value)).toEqual([ + { text: 'first\nsecond\nlegacy part' }, + { text: 'legacy message' } + ]) + } + ) + + it('uses stored message and part sequence for first prompt, preview, and search', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-sequence-')) + directories.push(directory) + const dbPath = join(directory, 'db.sqlite') + writeOpenCodeSqliteDatabase(dbPath, [ + { + id: 'ordered-session', + turns: [ + { role: 'user', parts: ['first prompt start', 'first prompt end'] }, + { role: 'assistant', parts: ['first response'] }, + { role: 'user', parts: ['second prompt'] }, + { role: 'assistant', parts: ['second response start', 'second response end'] } + ] + } + ]) + const db = new SyncDatabase(dbPath) + try { + db.exec(`ALTER TABLE message ADD COLUMN sequence INTEGER; + ALTER TABLE part ADD COLUMN sequence INTEGER; + UPDATE message SET sequence = rowid, time_created = time_created - rowid * 120000; + UPDATE part SET sequence = rowid, time_created = time_created - rowid * 120000;`) + } finally { + db.close() + } + const parsed = await handleOpenCodeSqliteRequest({ + id: 6, + kind: 'parse', + agent: 'zcode', + dbPath, + sessionId: 'ordered-session', + platform: 'darwin', + fullFirstUserPrompt: true + }) + expect(parsed).toMatchObject({ + ok: true, + value: { + firstUserPrompt: 'first prompt start\nfirst prompt end', + previewMessagesTruncated: true, + previewMessages: [ + { text: 'first prompt end' }, + { text: 'first response' }, + { text: 'second prompt' }, + { text: 'second response start' }, + { text: 'second response end' } + ] + } + }) + const captured = await handleOpenCodeSqliteRequest({ + id: 7, + kind: 'capture', + agent: 'zcode', + dbPath, + sessionId: 'ordered-session', + platform: 'darwin' + }) + expect(captured.ok).toBe(true) + if (!captured.ok) { + return + } + expect(readMessageTexts(captured.value)).toEqual([ + { text: 'first prompt start\nfirst prompt end' }, + { text: 'first response' }, + { text: 'second prompt' }, + { text: 'second response start\nsecond response end' } + ]) + }) + it('lists, parses, and captures ZCode sessions without labelling them OpenCode', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-ai-vault-')) directories.push(directory) From 0a81211994348e63e040a3af6d3fe1f885858d98 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:48:33 -0700 Subject: [PATCH 15/38] fix(cursor): read desktop login outside the main thread (#24572) * fix(cursor): read desktop login outside the main thread * fix(cursor): await native worker retirement before respawning --- .../build-plugins/plain-node-entry-guard.ts | 1 + config/electron-builder.config.cjs | 1 + config/knip.json | 1 + electron.vite.config.ts | 3 + src/main/rate-limits/cursor-auth.test.ts | 4 +- src/main/rate-limits/cursor-auth.ts | 5 +- .../cursor-desktop-profile-worker-entry.ts | 19 +++ .../cursor-desktop-profile-worker.test.ts | 115 ++++++++++++++++++ .../cursor-desktop-profile-worker.ts | 106 ++++++++++++++++ 9 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 src/main/rate-limits/cursor-desktop-profile-worker-entry.ts create mode 100644 src/main/rate-limits/cursor-desktop-profile-worker.test.ts create mode 100644 src/main/rate-limits/cursor-desktop-profile-worker.ts diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index e0765a4873b..31340869e22 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -55,6 +55,7 @@ const PLAIN_NODE_ENTRY_NAMES = [ const WORKER_THREAD_ENTRY_NAMES = [ 'stt-worker', 'warp-theme-parser-worker', + 'cursor-desktop-profile-worker-entry', 'session-scanner-opencode-sqlite-worker-entry', 'session-scanner-worker-entry', 'main-thread-hang-watchdog-entry', diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 86bf988bff9..20c3334bbdb 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -310,6 +310,7 @@ module.exports = { 'out/main/daemon-entry.js', 'out/main/session-scanner-service-entry.js', 'out/main/wsl-transcript-fs-process-entry.js', + 'out/main/cursor-desktop-profile-worker-entry.js', 'out/main/session-scanner-opencode-sqlite-worker-entry.js', 'out/main/plugin-host-entry.js', 'out/main/computer-sidecar.js', diff --git a/config/knip.json b/config/knip.json index 9af0b8d1a74..92c55c8ac7d 100644 --- a/config/knip.json +++ b/config/knip.json @@ -9,6 +9,7 @@ "src/main/computer/sidecar-entry.ts", "src/main/speech/stt-worker.ts", "src/main/warp-themes/warp-theme-parser-worker.ts", + "src/main/rate-limits/cursor-desktop-profile-worker-entry.ts", "src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts", "src/main/ai-vault/session-scanner-worker-entry.ts", "src/main/ports/port-scan-command-worker-entry.ts", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 221ac77b69f..d1b4e2d7b97 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -233,6 +233,9 @@ export const electronViteConfig: UserConfig = { 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), + 'cursor-desktop-profile-worker-entry': resolve( + 'src/main/rate-limits/cursor-desktop-profile-worker-entry.ts' + ), 'stt-worker': resolve('src/main/speech/stt-worker.ts'), 'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'), 'session-scanner-opencode-sqlite-worker-entry': resolve( diff --git a/src/main/rate-limits/cursor-auth.test.ts b/src/main/rate-limits/cursor-auth.test.ts index 8280bf984cd..15566c32963 100644 --- a/src/main/rate-limits/cursor-auth.test.ts +++ b/src/main/rate-limits/cursor-auth.test.ts @@ -35,8 +35,8 @@ vi.mock('node:fs', () => ({ } })) -vi.mock('./cursor-desktop-state-db', () => ({ - readCursorDesktopProfile: () => desktopState.result +vi.mock('./cursor-desktop-profile-worker', () => ({ + readCursorDesktopProfileViaWorker: () => desktopState.result })) import { readCursorAuthSession, readCursorCliIdentity } from './cursor-auth' diff --git a/src/main/rate-limits/cursor-auth.ts b/src/main/rate-limits/cursor-auth.ts index 8ef86f0ba73..8288444b4f3 100644 --- a/src/main/rate-limits/cursor-auth.ts +++ b/src/main/rate-limits/cursor-auth.ts @@ -7,7 +7,8 @@ import { getCursorDesktopStateDbPath, type CursorAuthSource } from './cursor-auth-paths' -import { readCursorDesktopProfile, type CursorDesktopProfile } from './cursor-desktop-state-db' +import type { CursorDesktopProfile } from './cursor-desktop-state-db' +import { readCursorDesktopProfileViaWorker } from './cursor-desktop-profile-worker' import { isCursorSessionTokenExpired, parseCursorSessionToken, @@ -190,7 +191,7 @@ export async function readCursorAuthSession( // Why not pushed to `errors`: the IDE holds a lock on state.vscdb while it runs, // so a busy open is transient. Reporting it would pin an alert-triangle bar on // every Cursor IDE user who never set Cursor up in Orca. - const desktopRead = readCursorDesktopProfile(desktopDbPath) + const desktopRead = await readCursorDesktopProfileViaWorker(desktopDbPath) if (desktopRead.status === 'ok' && desktopRead.profile.accessToken) { const session = takeLive( sessionFrom(desktopRead.profile.accessToken, 'desktop', desktopIdentity(desktopRead.profile)) diff --git a/src/main/rate-limits/cursor-desktop-profile-worker-entry.ts b/src/main/rate-limits/cursor-desktop-profile-worker-entry.ts new file mode 100644 index 00000000000..f1a02157e1f --- /dev/null +++ b/src/main/rate-limits/cursor-desktop-profile-worker-entry.ts @@ -0,0 +1,19 @@ +import { parentPort } from 'node:worker_threads' +import { readCursorDesktopProfile } from './cursor-desktop-state-db' +import type { + CursorDesktopProfileRequest, + CursorDesktopProfileResponse +} from './cursor-desktop-profile-worker' + +const port = parentPort +if (!port) { + throw new Error('Cursor desktop login worker requires a parent port') +} + +port.on('message', ({ id, dbPath }: CursorDesktopProfileRequest) => { + const response: CursorDesktopProfileResponse = { + id, + result: readCursorDesktopProfile(dbPath) + } + port.postMessage(response) +}) diff --git a/src/main/rate-limits/cursor-desktop-profile-worker.test.ts b/src/main/rate-limits/cursor-desktop-profile-worker.test.ts new file mode 100644 index 00000000000..3da54b8fa34 --- /dev/null +++ b/src/main/rate-limits/cursor-desktop-profile-worker.test.ts @@ -0,0 +1,115 @@ +import { Worker } from 'node:worker_threads' +import { EventEmitter } from 'node:events' +import { afterEach, describe, expect, it } from 'vitest' +import { CursorDesktopProfileWorker } from './cursor-desktop-profile-worker' + +const clients: CursorDesktopProfileWorker[] = [] +afterEach(() => clients.splice(0).forEach((client) => client.dispose())) + +describe('Cursor desktop login worker', () => { + it('keeps the caller responsive during a slow read and coalesces concurrent probes', async () => { + let spawns = 0 + const client = new CursorDesktopProfileWorker(() => { + spawns++ + return new Worker( + `const { parentPort } = require('node:worker_threads'); + parentPort.on('message', ({id}) => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250); + parentPort.postMessage({id, result: {status: 'missing'}}); + });`, + { eval: true } + ) + }) + clients.push(client) + const pending = client.read('/synthetic/state.vscdb') + expect(client.read('/synthetic/state.vscdb')).toBe(pending) + let completed = false + void pending.then(() => { + completed = true + }) + await new Promise((resolve) => setTimeout(resolve, 30)) + expect(completed).toBe(false) + expect(await pending).toEqual({ status: 'missing' }) + expect(spawns).toBe(1) + }) + + it('fails closed on unavailable workers without exposing filesystem details', async () => { + const client = new CursorDesktopProfileWorker(() => { + throw new Error('/private/account/path') + }) + clients.push(client) + expect(await client.read('/private/account/state.vscdb')).toEqual({ + status: 'error', + error: 'Unable to read the Cursor desktop login' + }) + }) + + it('terminates a stalled worker and allows a subsequent read to recover', async () => { + let spawns = 0 + const workers: Worker[] = [] + const client = new CursorDesktopProfileWorker(() => { + spawns++ + const worker = new Worker( + spawns === 1 + ? `setInterval(() => {}, 1000)` + : `const {parentPort} = require('node:worker_threads'); + parentPort.on('message', ({id}) => parentPort.postMessage({id, result: {status: 'missing'}}));`, + { eval: true } + ) + workers.push(worker) + return worker + }, 200) + clients.push(client) + expect((await client.read('/synthetic/state.vscdb')).status).toBe('error') + await workers[0]?.terminate() + expect(await client.read('/synthetic/state.vscdb')).toEqual({ + status: 'missing' + }) + expect(spawns).toBe(2) + }) + + it('refuses new native workers until the retired worker positively terminates', async () => { + let spawns = 0 + let finishRetirement: ((code: number) => void) | undefined + const retirement = new Promise((resolve) => { + finishRetirement = resolve + }) + const events = new EventEmitter() + const client = new CursorDesktopProfileWorker(() => { + spawns++ + return { + on: (...args) => events.on(...args), + off: (...args) => events.off(...args), + removeAllListeners: () => events.removeAllListeners(), + unref: () => undefined, + terminate: () => retirement, + postMessage: (request) => { + if (spawns > 1) { + queueMicrotask(() => + events.emit('message', { id: request.id, result: { status: 'missing' } }) + ) + } + } + } + }, 20) + clients.push(client) + expect((await client.read('/synthetic/state.vscdb')).status).toBe('error') + for (let i = 0; i < 3; i++) { + expect((await client.read('/synthetic/state.vscdb')).status).toBe('error') + } + expect(spawns).toBe(1) + finishRetirement?.(1) + await retirement + expect(await client.read('/synthetic/state.vscdb')).toEqual({ status: 'missing' }) + expect(spawns).toBe(2) + }) + + it('settles pending reads when disposed', async () => { + const client = new CursorDesktopProfileWorker( + () => new Worker('setInterval(() => {}, 1000)', { eval: true }) + ) + const pending = client.read('/synthetic/state.vscdb') + client.dispose() + expect((await pending).status).toBe('error') + }) +}) diff --git a/src/main/rate-limits/cursor-desktop-profile-worker.ts b/src/main/rate-limits/cursor-desktop-profile-worker.ts new file mode 100644 index 00000000000..754cd799853 --- /dev/null +++ b/src/main/rate-limits/cursor-desktop-profile-worker.ts @@ -0,0 +1,106 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { Worker } from 'node:worker_threads' +import type { WorkerRequestTransport, WorkerThreadFactory } from '../lazy-worker-thread-host' +import { WorkerThreadRequestQueue } from '../worker-thread-request-queue' +import type { CursorDesktopProfileReadResult } from './cursor-desktop-state-db' + +export type CursorDesktopProfileRequest = { id: number; dbPath: string } +export type CursorDesktopProfileResponse = { + id: number + result: CursorDesktopProfileReadResult +} + +const ENTRY = 'cursor-desktop-profile-worker-entry.js' +const READ_ERROR = 'Unable to read the Cursor desktop login' + +export function resolveCursorDesktopProfileWorkerPath(runtimeDir = __dirname): string { + for (const candidate of [join(runtimeDir, ENTRY), join(runtimeDir, '..', ENTRY)]) { + if (existsSync(candidate)) { + return candidate + } + } + throw new Error('Cursor desktop login worker is unavailable') +} + +export class CursorDesktopProfileWorker { + private readonly queue: WorkerThreadRequestQueue< + CursorDesktopProfileRequest, + CursorDesktopProfileResponse + > + private readonly inflight = new Map>() + private retiring = false + + constructor( + factory: WorkerThreadFactory, + private readonly timeoutMs = 10_000 + ) { + this.queue = new WorkerThreadRequestQueue({ + factory: () => this.createWorker(factory), + idleTeardownMs: 30_000, + maxConsecutiveDeaths: 2, + queueCap: { maxQueuedCalls: 8, describeFull: () => READ_ERROR }, + createUnavailableError: () => new Error(READ_ERROR), + describeTimeout: () => READ_ERROR, + describeExit: () => READ_ERROR, + describeCrashLoop: () => READ_ERROR, + onUnavailable: () => undefined + }) + } + + read(dbPath: string): Promise { + const pending = this.inflight.get(dbPath) + if (pending) { + return pending + } + const read = this.queue + .dispatch((id) => ({ id, dbPath }), this.timeoutMs) + .then(({ result }) => result) + .catch((): CursorDesktopProfileReadResult => ({ + status: 'error', + error: READ_ERROR + })) + .finally(() => this.inflight.delete(dbPath)) + this.inflight.set(dbPath, read) + return read + } + + dispose(): void { + this.queue.dispose() + this.inflight.clear() + } + + private createWorker(factory: WorkerThreadFactory): WorkerRequestTransport { + if (this.retiring) { + throw new Error(READ_ERROR) + } + const worker = factory() + return { + on: (...args) => worker.on(...args), + off: (...args) => worker.off(...args), + removeAllListeners: () => worker.removeAllListeners(), + unref: () => worker.unref(), + postMessage: (...args) => worker.postMessage(...args), + terminate: () => { + // Native SQLite work can delay termination; never overlap retired workers. + this.retiring = true + return worker.terminate().then((code) => { + this.retiring = false + return code + }) + } + } + } +} + +let client: CursorDesktopProfileWorker | undefined + +export function readCursorDesktopProfileViaWorker( + dbPath: string +): Promise { + // WAL recovery must never fall back to the Electron main thread. + client ??= new CursorDesktopProfileWorker( + () => new Worker(resolveCursorDesktopProfileWorkerPath()) + ) + return client.read(dbPath) +} From d627b13cdd69b208901a9b2b984518d136fa0b9d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:48:35 -0700 Subject: [PATCH 16/38] fix(cursor): distinguish usage failures from expired login (#24575) --- src/main/rate-limits/cursor-fetcher.test.ts | 22 ++++++++++++++------- src/main/rate-limits/cursor-fetcher.ts | 6 ++---- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/main/rate-limits/cursor-fetcher.test.ts b/src/main/rate-limits/cursor-fetcher.test.ts index b334713a9af..bb07fb90b05 100644 --- a/src/main/rate-limits/cursor-fetcher.test.ts +++ b/src/main/rate-limits/cursor-fetcher.test.ts @@ -119,14 +119,22 @@ describe('fetchCursorRateLimits', () => { expect(limited.usageMetadata?.retryAtMs).toBeGreaterThan(Date.now()) }) - it('treats a redirect to the login page as an expired sign-in', async () => { - // Why: the dashboard bounces an unusable session to /login. With - // redirect:'error' that surfaced as a generic network failure, hiding the - // one message that tells the user what to do. - netFetchMock.mockResolvedValueOnce(jsonResponse({}, 302, { location: '/login' })) + it.each([403, 302, 304])('reports HTTP %i without claiming the login expired', async (status) => { + netFetchMock.mockResolvedValueOnce(jsonResponse({}, status, { location: '/login' })) const limits = await fetchCursorRateLimits({ authReadResult: session() }) - expect(limits.usageMetadata?.failureKind).toBe('stale-token') - expect(limits.error).toContain('cursor-agent login') + expect(limits.usageMetadata?.failureKind).toBe('server') + expect(limits.error).toBe(`Cursor usage request failed (HTTP ${status})`) + expect(limits.usageMetadata?.authProvenance).toBeTruthy() + expect(netFetchMock.mock.calls[0]?.[1]?.redirect).toBe('manual') + }) + + it('preserves a forbidden response from the legacy usage endpoint', async () => { + netFetchMock + .mockResolvedValueOnce(jsonResponse({ membershipType: 'free' })) + .mockResolvedValueOnce(jsonResponse({}, 403)) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.error).toBe('Cursor usage request failed (HTTP 403)') + expect(limits.usageMetadata?.failureKind).toBe('server') }) it('names the account on failures too, so an account switch can clear stale figures', async () => { diff --git a/src/main/rate-limits/cursor-fetcher.ts b/src/main/rate-limits/cursor-fetcher.ts index d188127a19a..82bbf1c0e48 100644 --- a/src/main/rate-limits/cursor-fetcher.ts +++ b/src/main/rate-limits/cursor-fetcher.ts @@ -82,14 +82,12 @@ async function fetchDashboardJson( ? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)]) : AbortSignal.timeout(API_TIMEOUT_MS) const res = await net.fetch(url, { - // Why manual: the dashboard bounces an unusable session to /login, and - // 'error' would surface that as a generic network failure instead of the - // actionable sign-in message below. + // Keep dashboard redirects visible without forwarding session credentials. redirect: 'manual', headers: requestHeaders(session), signal: requestSignal }) - if (res.status === 401 || res.status === 403 || (res.status >= 300 && res.status < 400)) { + if (res.status === 401) { return { kind: 'result', result: result('error', EXPIRED_MESSAGE, { From 4e46969b7c17b3fb5988751797bdf3dd9f2f9333 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:50:43 -0700 Subject: [PATCH 17/38] fix(cursor): preserve UTF-8 in Windows hook transport (#24585) * fix(cursor): preserve Windows hook input across profile paths Adapt the reviewed direct-command approach from PR #24381, and set UTF-8 input/output encoding for profiles requiring PowerShell. Co-authored-by: Vladimir Kurgansky * fix(cursor): preserve missing-script permission replies Keep the established encoded launcher for every event and explicitly use UTF-8 input/output. Preserve original missing-script acceptance and verify ordinary/spaced profiles rather than adding shell-specific direct guards. --------- Co-authored-by: Vladimir Kurgansky --- src/main/cursor/hook-script.ts | 22 +++++---- src/main/cursor/hook-service.test.ts | 72 ++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/src/main/cursor/hook-script.ts b/src/main/cursor/hook-script.ts index 739026aa6a8..bbc40bf2587 100644 --- a/src/main/cursor/hook-script.ts +++ b/src/main/cursor/hook-script.ts @@ -1,7 +1,8 @@ import { buildWindowsAgentHookPostCommand, wrapPosixHookCommand, - wrapWindowsHookCommand + buildWindowsHookPowerShellCommand, + wrapWindowsPowerShellEncodedCommand } from '../agent-hooks/installer-utils' import { buildPosixHookPayloadCapture, @@ -27,14 +28,19 @@ export function getPosixManagedCommand(scriptPath: string, eventName: CursorEven } export function getManagedCommand(scriptPath: string, eventName: CursorEvent): string { + if (process.platform !== 'win32') { + return getPosixManagedCommand(scriptPath, eventName) + } const response = getCursorHookResponse(eventName) - return process.platform === 'win32' - ? wrapWindowsHookCommand( - scriptPath, - { [CURSOR_HOOK_RESPONSE_ENV]: response }, - { fallbackStdout: response } - ) - : getPosixManagedCommand(scriptPath, eventName) + const command = buildWindowsHookPowerShellCommand( + scriptPath, + { [CURSOR_HOOK_RESPONSE_ENV]: response }, + { fallbackStdout: response } + ) + // PowerShell 5.1 defaults to the ANSI code page when translating hook stdin. + return wrapWindowsPowerShellEncodedCommand( + `[Console]::InputEncoding = New-Object System.Text.UTF8Encoding; $OutputEncoding = [Console]::InputEncoding; ${command}` + ) } export function getManagedScript(target: 'local' | 'posix' = 'local'): string { diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index 3f93e91f7fe..e1c335e7bb9 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -4,6 +4,8 @@ import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { spawnSync } from 'node:child_process' +import { createServer, type Server } from 'node:http' +import { runProcess } from '../../shared/child-process/run-process' import { findGitBash } from '../agent-hooks/windows-git-bash-path.test-fixture' const { homedirMock } = vi.hoisted(() => ({ @@ -241,12 +243,15 @@ describe('CursorHookService', () => { HOOK_CASE_TIMEOUT_MS ) - it( - 'emits protocol-valid JSON when the managed Cursor script is missing (#15462)', - () => { + it.each(['ordinary', 'spaced'] as const)( + 'emits protocol-valid JSON when the script is missing in a %s profile (#15462)', + (profileKind) => { + const installHome = profileKind === 'spaced' ? join(homeDir, 'profile with spaces') : homeDir + mkdirSync(installHome, { recursive: true }) + homedirMock.mockReturnValue(installHome) expect(new CursorHookService().install().state).toBe('installed') - const config = readInstalledCursorHooks(homeDir) - unlinkSync(join(homeDir, '.orca', 'agent-hooks', CURSOR_SCRIPT_FILE_NAME)) + const config = readInstalledCursorHooks(installHome) + unlinkSync(join(installHome, '.orca', 'agent-hooks', CURSOR_SCRIPT_FILE_NAME)) for (const eventName of CURSOR_EVENTS) { const command = requireRegisteredCommand(config, eventName) @@ -287,6 +292,63 @@ describe('CursorHookService', () => { HOOK_CASE_TIMEOUT_MS ) + it.skipIf(process.platform !== 'win32').each(['ordinary', 'spaced'] as const)( + 'posts UTF-8 stdin unchanged for a %s profile path', + async (profileKind) => { + const installHome = profileKind === 'spaced' ? join(homeDir, 'profile with spaces') : homeDir + mkdirSync(installHome, { recursive: true }) + homedirMock.mockReturnValue(installHome) + expect(new CursorHookService().install().state).toBe('installed') + const command = requireRegisteredCommand( + readInstalledCursorHooks(installHome), + 'afterAgentResponse' + ) + expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER) + const payload = JSON.stringify({ text: 'Сердце, почта в ЛК — 日本語 中文 한국어 😀 café' }) + const posts: string[] = [] + const server: Server = createServer((req, res) => { + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer) => chunks.push(chunk)) + req.on('end', () => { + const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8')) + const posted = form.get('payload') + if (posted !== null) { + posts.push(posted) + } + res.writeHead(204) + res.end() + }) + }) + const port = await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const address = server.address() + resolve(typeof address === 'object' && address ? address.port : 0) + }) + }) + try { + const result = await runProcess({ + program: 'cmd.exe', + args: ['/d', '/c', command], + input: payload, + timeoutMs: HOOK_RUN_TIMEOUT_MS, + env: { + ...process.env, + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_AGENT_HOOK_PORT: String(port), + ORCA_AGENT_HOOK_TOKEN: 'token', + ORCA_PANE_KEY: 'tab:leaf' + } + }) + expect(result.code).toBe(0) + expect(result.timedOut).toBe(false) + expect(JSON.parse(result.stdout)).toEqual({}) + expect(posts).toEqual([payload]) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } + } + ) + it.skipIf(process.platform !== 'win32')( 'emits parseable JSON through cmd.exe and Git Bash (#14825/#15462)', () => { From 99b2628aef29ca578bc241815e774c86b9c2c718 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:12:10 -0700 Subject: [PATCH 18/38] test: update sidebar setup and remove obsolete permission sentinel (#24734) --- .../e2e/issue-12907-pi-eof-agent-row.spec.ts | 2 +- tests/e2e/onboarding.spec.ts | 30 ------------------- tests/e2e/sidebar-agent-row-identity.spec.ts | 2 +- 3 files changed, 2 insertions(+), 32 deletions(-) diff --git a/tests/e2e/issue-12907-pi-eof-agent-row.spec.ts b/tests/e2e/issue-12907-pi-eof-agent-row.spec.ts index a85e41c0708..5f9c427bce8 100644 --- a/tests/e2e/issue-12907-pi-eof-agent-row.spec.ts +++ b/tests/e2e/issue-12907-pi-eof-agent-row.spec.ts @@ -27,7 +27,7 @@ test('Pi EOF removes the completed agent row from the live Electron sidebar (#12 } state.setAgentActivityDisplayMode('full') if (!state.worktreeCardProperties.includes('inline-agents')) { - state.toggleWorktreeCardProperty('inline-agents') + state.setWorktreeCardProperties([...state.worktreeCardProperties, 'inline-agents']) } }) diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index 94dba0c6732..a5a6557b4b9 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -548,36 +548,6 @@ test.describe('Onboarding flow', () => { expect((await getOnboardingState(orcaPage)).closedAt).not.toBeNull() }) - test('Skip from notifications does not request permission', async ({ orcaPage }) => { - await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ - timeout: 15_000 - }) - await continueOnboarding(orcaPage) - await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible() - await continueFromThemeToNotifications(orcaPage) - - await orcaPage.evaluate(() => { - localStorage.removeItem('orca.e2e.notificationPermissionRequested') - window.api.notifications.requestPermission = async () => { - localStorage.setItem('orca.e2e.notificationPermissionRequested', '1') - return { supported: true, platform: 'darwin', requested: true } - } - }) - await expectOnboardingNotificationSound(orcaPage, /System Default/i) - - await expect(onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON)).toHaveCount(0) - await continueOnboarding(orcaPage) - - await expectAddProjectDialog(orcaPage) - await expect - .poll( - async () => - orcaPage.evaluate(() => localStorage.getItem('orca.e2e.notificationPermissionRequested')), - { timeout: 5_000 } - ) - .toBeNull() - }) - test('selected agent button reports aria-pressed=true', async ({ orcaPage }) => { await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ timeout: 15_000 diff --git a/tests/e2e/sidebar-agent-row-identity.spec.ts b/tests/e2e/sidebar-agent-row-identity.spec.ts index a221c7bf156..7eb48348ff3 100644 --- a/tests/e2e/sidebar-agent-row-identity.spec.ts +++ b/tests/e2e/sidebar-agent-row-identity.spec.ts @@ -50,7 +50,7 @@ async function useFullAgentActivityRows(page: Page): Promise { // reads off the rendered sidebar instead of a collapsed summary pill. state.setAgentActivityDisplayMode('full') if (!state.worktreeCardProperties.includes('inline-agents')) { - state.toggleWorktreeCardProperty('inline-agents') + state.setWorktreeCardProperties([...state.worktreeCardProperties, 'inline-agents']) } }) } From cd8d03bc06398b856f6db734aef11df4e2afe15a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:26:00 -0700 Subject: [PATCH 19/38] fix(dsh): recognize 0.2 profiles and open workspace composer (#24589) --- docs/reference/dsh-harness-integration.md | 44 +++++++++++++++++++ src/shared/dsh-launch-command.test.ts | 24 ++++++++++ src/shared/dsh-launch-command.ts | 45 +++++++------------ src/shared/dsh-real-cli.test.ts | 53 +++++++++++++++++++++++ src/shared/dsh-startup.test.ts | 21 +++++++++ src/shared/tui-agent-config.ts | 13 ++---- 6 files changed, 160 insertions(+), 40 deletions(-) create mode 100644 docs/reference/dsh-harness-integration.md create mode 100644 src/shared/dsh-real-cli.test.ts create mode 100644 src/shared/dsh-startup.test.ts diff --git a/docs/reference/dsh-harness-integration.md b/docs/reference/dsh-harness-integration.md new file mode 100644 index 00000000000..9a850e06900 --- /dev/null +++ b/docs/reference/dsh-harness-integration.md @@ -0,0 +1,44 @@ +# DeepSeek Harness integration + +Orca detects the community `@deepseek-harness-tui/dsh-tui` launcher (`dsh-tui`, alias +`dst`) and requires the official `@deepseek-ai/dsh` executable too. The launcher +boots the `dsh-tui` profile; Orca passes `.` to select the current workspace and +reach its composer on the first launch. The official Harness does not bundle this community TUI. +DSH Console and DeepSeek Build are separate products and are not interchangeable +with this launch contract. + +The official DSH 0.2 CLI accepts both `dsh --profile headless` and `dsh headless`. +Orca excludes the known `web`, `headless`, `sdk`, `sdk-minimal`, `acp`, and `desktop` +profiles from interactive process recognition, along with plugin management and +configuration dumps. Custom profile names remain eligible because profiles are +user configurable. Only launcher arguments are inspected; app prompts, resume IDs, +and patch filenames cannot change the selected profile's identity. + +Status hooks use the official `@deepseek-ai/dsh-hooks-claude-code` plugin, installed +as an owned block in `$DSH_HOME/cordis.patch.yml`. User entries outside the block are +preserved. Local installation respects `DSH_HOME`; the existing SSH installer uses +the execution host's default `~/.dsh` because SFTP cannot read its environment. +Hooks report session start, prompt submission, tool start/end, and stopping through +Orca's host status store. Approval has no dedicated hook; it is not inferred from +an uncaptured screen. Subagent lifecycle events are ignored for parent-pane status. + +DSH 0.2 still emits an empty `transcript_path` in Claude-compatible hooks. Its +session persistence defaults to compressed JSONL under `$DSH_HOME/sessions`. +Orca can resume a hook-associated session through `dsh-tui --resume `, but +currently does not discover DSH logs in Agent Session History. Resume support alone +does not establish transcript-history support. + +## Reproduce the official launcher check + +Install `@deepseek-ai/dsh@0.2.0-rc.2` into a disposable prefix, then run: + +```sh +ORCA_BACKGROUND_LAUNCH=1 ORCA_REAL_DSH_CLI=/path/to/prefix/node_modules/.bin/dsh \ + pnpm test src/shared/dsh-real-cli.test.ts +``` + +The opt-in test checks published version, composed profile configurations, and +headless help in an isolated home and working folder without a model request. +Interactive readiness is separately pinned to the captured community TUI transcript +in `src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt`; that older capture is +not proof of current TUI compatibility or paid generation. diff --git a/src/shared/dsh-launch-command.test.ts b/src/shared/dsh-launch-command.test.ts index 6585318cc98..f8bf718a211 100644 --- a/src/shared/dsh-launch-command.test.ts +++ b/src/shared/dsh-launch-command.test.ts @@ -9,6 +9,14 @@ function tokens(commandLine: string): string[] { describe('isDshNonInteractiveCommand', () => { it.each([ 'dsh web', + 'dsh headless "run the tests"', + 'dsh sdk', + 'dsh sdk-minimal', + 'dsh acp', + 'dsh desktop', + 'dsh dsh-tui --dump-config', + 'dsh dsh-tui --dump-default-config', + 'dsh dsh-tui --dump-config-schema', 'dsh --profile web', 'dsh --profile=web --port 8080', 'dsh --profile headless "run the tests"', @@ -23,6 +31,14 @@ describe('isDshNonInteractiveCommand', () => { it.each([ 'dsh --profile dsh-tui', + 'dsh dsh-tui', + 'dsh tui --resume web', + 'dsh tui --patch web --resume sdk', + 'dsh tui --patch=web --resume sdk', + 'dsh --profile tui --patch headless --resume sdk', + 'dsh tui --from-default-profile web --resume sdk', + 'dsh tui --resume session --dump-config', + 'dsh --profile tui --resume session --dump-config', 'dsh --profile=dsh-tui', 'dsh --profile dsh-tui --resume 9478e2d8-29bc-4009-ab32-657efa2bd763', 'dsh-tui', @@ -66,6 +82,14 @@ describe('dsh foreground process recognition', () => { it.each([ 'dsh web', + 'dsh headless "summarize the diff"', + 'dsh sdk', + 'dsh sdk-minimal', + 'dsh acp', + 'node /usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js headless "go"', + 'node /usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js sdk', + 'node --no-warnings /usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js sdk', + 'C:\\Tools\\dsh.cmd headless go', 'dsh --profile headless "summarize the diff"', 'node /usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js --profile acp' ])('does not claim %s as an interactive agent pane', (commandLine) => { diff --git a/src/shared/dsh-launch-command.ts b/src/shared/dsh-launch-command.ts index 60100a9823b..4c44fbf9c4d 100644 --- a/src/shared/dsh-launch-command.ts +++ b/src/shared/dsh-launch-command.ts @@ -1,11 +1,6 @@ -// DeepSeek Harness ships one binary, `dsh`, and boots a *profile* with it. Only the -// `dsh-tui` profile paints an interactive composer; `web` serves HTTP, `headless` answers -// one task and exits, `sdk`/`sdk-minimal`/`acp` speak JSON-RPC on stdio, and `plugin` is -// package management. All five are `dsh` in the process table, so foreground recognition -// has to read the profile to tell an agent pane from a server or a one-shot. -// -// `dsh-tui` (and its `dst` alias) forward straight to `dsh --profile dsh-tui`, so they are -// always interactive and never reach this matcher. +import { findInterpreterEntrypointToken } from './agent-command-line-entrypoint' + +// DSH 0.2 accepts positional profiles; community dsh-tui/dst choose their own TUI profile. /** Profiles that boot something other than the interactive terminal UI. */ const NON_INTERACTIVE_PROFILES = new Set([ @@ -17,9 +12,8 @@ const NON_INTERACTIVE_PROFILES = new Set([ 'desktop' ]) -/** Bare-word subcommands the launcher accepts: `plugin` boots no profile at all, and `web` - * is the documented alias for `--profile web`. Neither hosts an interactive pane. */ -const SUBCOMMANDS = new Set(['plugin', 'web']) +/** Plugin management never boots an interactive pane. */ +const SUBCOMMANDS = new Set(['plugin']) /** Launcher flags that print a composed config and exit. */ const DUMP_FLAGS = new Set(['--dump-config', '--dump-default-config', '--dump-config-schema']) @@ -69,30 +63,21 @@ function isLauncherToken(token: string): boolean { * and a prompt or session id is free text that must never be read as a launcher flag. */ export function isDshNonInteractiveCommand(tokens: readonly string[]): boolean { - // Why first: `dsh-tui`/`dst` have already chosen the interactive profile, and everything - // after them is the TERMINAL APP's argv — a `--resume` id or a workspace target. A - // workspace folder named `web` or `plugin` is an ordinary directory name, and reading it - // as a `dsh` subcommand would mark a live agent pane non-interactive, costing it status - // hooks and prompt delivery. Index 1 as well as 0 because a node shim puts the launcher - // script path there. - if ( - TUI_LAUNCHER_NAMES.has(programBasename(tokens[0])) || - TUI_LAUNCHER_NAMES.has(programBasename(tokens[1])) - ) { + const firstProgram = programBasename(tokens[0]) + const entrypoint = findInterpreterEntrypointToken([...tokens], firstProgram) + const programIndex = entrypoint === null ? 0 : tokens.indexOf(entrypoint) + if (TUI_LAUNCHER_NAMES.has(programBasename(tokens[programIndex]))) { return false } + const indexOfArgs = programIndex + 1 + let index = indexOfArgs let profile: string | null = null - let index = 1 - // Skip the leading non-flag tokens: an interpreter invocation puts the script path here. - while (index < tokens.length && !isLauncherToken(tokens[index])) { - const token = tokens[index] - if (SUBCOMMANDS.has(token)) { + const first = tokens[index] + if (first !== undefined && !first.startsWith('-')) { + if (SUBCOMMANDS.has(first)) { return true } - if (!token.startsWith('-') && index > 1) { - // A bare word that is neither a subcommand nor a flag: the app's arguments start here. - return false - } + profile = first index += 1 } for (; index < tokens.length; index += 1) { diff --git a/src/shared/dsh-real-cli.test.ts b/src/shared/dsh-real-cli.test.ts new file mode 100644 index 00000000000..6422b9884fa --- /dev/null +++ b/src/shared/dsh-real-cli.test.ts @@ -0,0 +1,53 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcess } from './child-process/run-process' +import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition' + +const binary = process.env.ORCA_REAL_DSH_CLI + +// No model call: published profile configuration and help establish the launcher contract. +describe.skipIf(!binary)('published DSH 0.2 CLI', () => { + it('keeps positional and explicit headless profiles outside interactive recognition', async () => { + if (!binary) { + throw new Error('Set ORCA_REAL_DSH_CLI to the official dsh executable') + } + const root = await mkdtemp(join(tmpdir(), 'orca-dsh-cli-')) + const env = { ...process.env, DSH_HOME: root, DEEPSEEK_API_KEY: '' } + try { + const version = await runProcess({ program: binary, args: ['--version'], cwd: root, env }) + expect(version.code).toBe(0) + expect(version.stdout.trim()).toMatch(/^0\.2\./) + for (const profile of ['headless', 'sdk', 'sdk-minimal', 'acp']) { + for (const args of [[profile], ['--profile', profile]]) { + const result = await runProcess({ + program: binary, + args: [...args, '--dump-default-config'], + cwd: root, + env, + timeoutMs: 30_000 + }) + expect(result.code).toBe(0) + expect(result.stdout).toContain('@deepseek-ai/dsh-') + const command = `"${binary}" ${args.join(' ')}` + expect(recognizeAgentProcessFromCommandLine(command)).toBeNull() + expect( + recognizeAgentProcessFromCommandLine(command, { includeHeadlessOneShot: true })?.agent + ).toBe('dsh') + } + } + const help = await runProcess({ + program: binary, + args: ['headless', '--help'], + cwd: root, + env, + timeoutMs: 30_000 + }) + expect(help.code).toBe(0) + expect(help.stdout).toContain('Answer one task and exit') + } finally { + await rm(root, { recursive: true, force: true }) + } + }, 120_000) +}) diff --git a/src/shared/dsh-startup.test.ts b/src/shared/dsh-startup.test.ts new file mode 100644 index 00000000000..0a99d2cc67b --- /dev/null +++ b/src/shared/dsh-startup.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan } from './tui-agent-startup' +import { TUI_AGENT_CONFIG } from './tui-agent-config' + +describe('DSH first launch', () => { + it.each(['darwin', 'linux', 'win32'] as const)( + 'selects the current workspace before pasting the task on %s', + (platform) => { + const plan = buildAgentStartupPlan({ + agent: 'dsh', + prompt: 'Review this folder', + cmdOverrides: {}, + platform + }) + expect(plan?.launchCommand).toContain('dsh-tui .') + expect(plan?.launchCommand).not.toContain('Review this folder') + expect(TUI_AGENT_CONFIG.dsh.promptInjectionMode).toBe('stdin-after-start') + expect(TUI_AGENT_CONFIG.dsh.draftPasteReadySignal).toBe('dsh-composer-prompt') + } + ) +}) diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index d9fdabb14c3..a7be259df2c 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -290,21 +290,14 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { promptInjectionMode: 'stdin-after-start' }, dsh: { - // Why: DeepSeek Harness publishes one binary (`dsh`) that boots a profile, and only the - // `dsh-tui` profile paints a composer. `dsh-tui` (alias `dst`) is the launcher that - // selects it, so detect that and require `dsh` too — the launcher delegates to it and - // fails without it. detectCmd: 'dsh-tui', detectCmdAliases: ['dst'], detectRequiredCommands: ['dsh'], - // Why: the launcher re-execs `dsh --profile dsh-tui`, so the pane's foreground process - // is `dsh`, never `dsh-tui`. Readiness and follow-up delivery key off this name. + // A first bare launch opens the session browser instead of the composer. + launchCmd: 'dsh-tui .', expectedProcess: 'dsh', - // Why: the terminal app parses only `--resume`/`--continue` and a workspace target; it - // has no prompt flag, so the first prompt is pasted into the composer after startup. promptInjectionMode: 'stdin-after-start', - // Why: DSH-TUI animates a whale intro continuously behind its composer, so the default - // quiet window never settles (the grok failure mode). See dsh-tui-ready-no-key.txt. + // The whale intro keeps repainting behind the ready composer. draftPasteReadySignal: 'dsh-composer-prompt' }, zcode: { From adf447d958b2e7eb9c85b0e9b12f634683c4ecbf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:35:01 -0700 Subject: [PATCH 20/38] test: classify acknowledged remount input as driving (#24750) --- tests/e2e/live-background-terminal-mount-authority.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/e2e/live-background-terminal-mount-authority.spec.ts b/tests/e2e/live-background-terminal-mount-authority.spec.ts index 28ad815f0b2..39735b0c1e8 100644 --- a/tests/e2e/live-background-terminal-mount-authority.spec.ts +++ b/tests/e2e/live-background-terminal-mount-authority.spec.ts @@ -612,6 +612,9 @@ test('adopts runtime-owned agent and Setup PTYs on first mount', async ({ expect(agent).toBeTruthy() expect(setup).toBeTruthy() expect(canary).toBeTruthy() + if (!agent?.ptyId || !setup?.ptyId || !canary?.ptyId) { + throw new Error('Agent, Setup, and canary terminals must have live PTY identities') + } await expect.poll(readSpawnLedger).toHaveLength(1) await expect.poll(() => readJsonLines<{ pid: number }>(setupLedgerPath)).toHaveLength(1) await expect.poll(() => readJsonLines<{ pid: number }>(canaryLedgerPath)).toHaveLength(1) @@ -771,7 +774,7 @@ test('adopts runtime-owned agent and Setup PTYs on first mount', async ({ const remountAgentAcceptedMarker = `AGENT_ACCEPTED_${randomUUID()}` expect( await orcaPage.evaluate( - ({ marker, ptyId }) => window.api.pty.writeAccepted(ptyId, `${marker}\r`), + ({ marker, ptyId }) => window.api.pty.writeAccepted(ptyId, `${marker}\r`, 'driving'), { marker: remountAgentAcceptedMarker, ptyId: agent!.ptyId } ) ).toBe(true) From 302526411dd11fa46d6a6e49419fbd518d20cd70 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:52:50 -0700 Subject: [PATCH 21/38] ci: share bounded apt setup with the E2E native cache job (#24758) --- .github/workflows/e2e.yml | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 517c77c9ddd..2957c8eaa6e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -119,7 +119,22 @@ jobs: ref: ${{ inputs.ref || github.ref }} - name: Install native build tools - run: sudo apt-get update && sudo apt-get install -y build-essential python3 + env: + ORCA_E2E_APT_PACKAGES: build-essential python3 + run: &install_e2e_tools | + read -r -a packages <<< "$ORCA_E2E_APT_PACKAGES" + for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do + if [ -f "$source" ]; then + sudo sed -i 's|https*://azure\.archive\.ubuntu\.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source" + fi + done + sudo tee /etc/apt/apt.conf.d/99-orca-e2e >/dev/null <<'APTCONF' + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + Acquire::Retries "1"; + APTCONF + timeout 120 sudo apt-get update + timeout 300 sudo apt-get install -y "${packages[@]}" - uses: ./.github/actions/install-node-dependencies with: @@ -178,19 +193,9 @@ jobs: - name: Install native build and headless UI tools # The Azure archive took 16 minutes for one font package; bound setup # separately so a slow mirror cannot consume the shard's test budget. - run: &install_e2e_tools | - for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do - if [ -f "$source" ]; then - sudo sed -i 's|https*://azure\.archive\.ubuntu\.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source" - fi - done - sudo tee /etc/apt/apt.conf.d/99-orca-e2e >/dev/null <<'APTCONF' - Acquire::http::Timeout "15"; - Acquire::https::Timeout "15"; - Acquire::Retries "1"; - APTCONF - timeout 120 sudo apt-get update - timeout 300 sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils + env: &e2e_tool_packages + ORCA_E2E_APT_PACKAGES: build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils + run: *install_e2e_tools - uses: ./.github/actions/install-node-dependencies with: @@ -281,6 +286,7 @@ jobs: # unbounded inventory fallback; the paired fixture exercises that real boundary. # Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this # lane now receives those specs from pr.yml's SSH source mapping. + env: *e2e_tool_packages run: *install_e2e_tools - uses: ./.github/actions/install-node-dependencies @@ -417,6 +423,7 @@ jobs: ref: ${{ inputs.ref || github.ref }} - name: Install native build and headless UI tools + env: *e2e_tool_packages run: *install_e2e_tools - uses: ./.github/actions/install-node-dependencies From cb03a0545a0e3bb781c72c6a654a38cb207c9445 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:56:12 -0700 Subject: [PATCH 22/38] fix(opencode): keep absent session stores quiet (#24577) * fix(opencode): skip absent session stores without warning * fix(opencode): retain warnings for inaccessible session stores --------- Co-authored-by: drakeo338 --- .../server-opencode-binder.test.ts | 45 ++++++++++++++++++- src/main/opencode/opencode-session-binder.ts | 10 +++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/main/agent-hooks/server-opencode-binder.test.ts b/src/main/agent-hooks/server-opencode-binder.test.ts index eaa5dff073e..ab6ed0ad0ba 100644 --- a/src/main/agent-hooks/server-opencode-binder.test.ts +++ b/src/main/agent-hooks/server-opencode-binder.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -317,4 +317,47 @@ describe('listOpenCodeDbSessions', () => { it('returns [] for a missing database instead of throwing', () => { expect(listOpenCodeDbSessions(join(dir, 'absent.db'), { ms: 0, id: '' })).toEqual([]) }) + + it('stays quiet for a missing database and detects it once it appears', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const absent = join(dir, 'later.db') + expect(listOpenCodeDbSessions(absent, { ms: 0, id: '' })).toEqual([]) + expect(listOpenCodeDbSessions(absent, { ms: 0, id: '' })).toEqual([]) + expect(warn).not.toHaveBeenCalled() + writeDb(absent, 'session') + expect(listOpenCodeDbSessions(absent, { ms: 0, id: '' })).toHaveLength(1) + expect(warn).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) + + it('still warns when the database exists but cannot be read', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + writeFileSync(dbPath, 'this is not a sqlite database'.repeat(100)) + expect(listOpenCodeDbSessions(dbPath, { ms: 0, id: '' })).toEqual([]) + expect(warn).toHaveBeenCalledTimes(1) + } finally { + warn.mockRestore() + } + }) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'warns when an existing database is behind an inaccessible directory', + () => { + writeDb(dbPath, 'session') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + chmodSync(dir, 0o000) + expect(listOpenCodeDbSessions(dbPath, { ms: 0, id: '' })).toEqual([]) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[1]).toMatchObject({ code: 'EACCES' }) + } finally { + chmodSync(dir, 0o700) + warn.mockRestore() + } + } + ) }) diff --git a/src/main/opencode/opencode-session-binder.ts b/src/main/opencode/opencode-session-binder.ts index 0a693e6e928..8b62a05fa82 100644 --- a/src/main/opencode/opencode-session-binder.ts +++ b/src/main/opencode/opencode-session-binder.ts @@ -1,3 +1,4 @@ +import { statSync } from 'node:fs' import { resolveOpenCodeDataDirectory } from './opencode-data-directory' import { bindOpenCodeSession, @@ -225,6 +226,8 @@ export function listOpenCodeDbSessions( cursor: OpenCodeSessionCursor ): BinderSessionRow[] { try { + // Check absence without hiding permission failures as an unused store. + statSync(dbPath) return readOpenCodeDatabase({ dbPath, read: (db) => { @@ -269,6 +272,13 @@ export function listOpenCodeDbSessions( } }) } catch (err) { + if ( + err instanceof Error && + 'code' in err && + (err.code === 'ENOENT' || err.code === 'ENOTDIR') + ) { + return [] + } console.warn('[opencode-binder] session store read failed; skipping round', err) return [] } From a21d328cb9aabb51d7bd2eecd387c81053a818cd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:56:59 -0700 Subject: [PATCH 23/38] fix(terminal): fill OpenCode DOM block glyphs in repainted rows (#24582) * fix(terminal): fill DOM block glyphs only in repainted rows Adapt the block-fill approach from PR #15955 and bound painting to xterm render ranges without observer or animation-frame rescans. Co-authored-by: mmarabel * fix(terminal): preserve block fills across DOM row replacements --------- Co-authored-by: mmarabel --- .../src/lib/pane-manager/pane-dom-creation.ts | 1 + .../lib/pane-manager/pane-lifecycle.test.ts | 2 + .../src/lib/pane-manager/pane-lifecycle.ts | 4 + .../lib/pane-manager/pane-manager-types.ts | 1 + .../pane-terminal-gpu-acceleration.ts | 5 + .../terminal-dom-block-fill.test.ts | 231 ++++++++++++++++++ .../pane-manager/terminal-dom-block-fill.ts | 172 +++++++++++++ 7 files changed, 416 insertions(+) create mode 100644 src/renderer/src/lib/pane-manager/terminal-dom-block-fill.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-dom-block-fill.ts diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 6313a81cca7..cb7ef826b87 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -144,6 +144,7 @@ export function createPaneDOM( paneDragCleanup, compositionHandler: null, focusClassSyncCleanup: null, + domBlockFillCleanup: null, terminalScrollIntentDisposable: null, mouseEncodingTrackerDisposable, linkifierMouseLeaveResetDisposable: null, diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index c42a56d391f..52b44eade33 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -44,10 +44,12 @@ function createPane(): ManagedPaneInternal { id: 1, leafId, stablePaneId: leafId, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stand-in for xterm's Terminal exposing only the members these lifecycle paths touch. terminal: { loadAddon: vi.fn(), attachCustomWheelEventHandler: vi.fn(), refresh: vi.fn(), + onRender: vi.fn(() => ({ dispose: vi.fn() })), cols: 80, rows: 24 } as never, diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 4f9ac4a0be9..2e1122f8852 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -15,6 +15,7 @@ import { } from './terminal-linkifier-hover-reset-on-mouseleave' import { installTerminalLinkifierHoverResetOnWrite } from './terminal-linkifier-hover-reset-on-write' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' +import { attachDomBlockFill } from './terminal-dom-block-fill' import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer' import { rebuildAttachedWebgl } from './pane-webgl-reattach' import { configureLazyArabicShapingJoiner } from './terminal-arabic-shaping-joiner' @@ -104,6 +105,7 @@ export function openTerminal( pane.compositionHandler = installTerminalImeCandidateAnchor(terminal) pane.focusClassSyncCleanup = attachDomRendererFocusClassSync(terminal.element) + pane.domBlockFillCleanup = attachDomBlockFill(terminal) // Configure the first atlas with ligatures instead of immediately rebuilding it. if (ligatures) { @@ -201,6 +203,8 @@ export function disposePane( pane.paneDragCleanup = null pane.focusClassSyncCleanup?.() pane.focusClassSyncCleanup = null + pane.domBlockFillCleanup?.() + pane.domBlockFillCleanup = null pane.terminalScrollIntentDisposable?.dispose() pane.terminalScrollIntentDisposable = null pane.mouseEncodingTrackerDisposable?.dispose() diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index fe4fcb5bc6f..3065a3c3704 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -200,6 +200,7 @@ export type ManagedPaneInternal = { compositionHandler: (() => void) | null // Stored so disposePane() can remove DOM-renderer focus synchronization. focusClassSyncCleanup?: (() => void) | null + domBlockFillCleanup?: (() => void) | null // Stored so disposePane() can remove user-scroll intent listeners. terminalScrollIntentDisposable?: IDisposable | null // Stored so disposePane() can drop the mouse-encoding parser handlers. diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts index 099e3a40c82..d096352eb4e 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts @@ -6,6 +6,7 @@ import { shouldUseTerminalWebgl } from './pane-webgl-renderer' import { safeFit } from './pane-tree-ops' +import { applyDomBlockFills } from './terminal-dom-block-fill' import { resetPaneWebglContextLosses } from './pane-webgl-context-loss-policy' export function applyTerminalGpuAcceleration( @@ -32,6 +33,10 @@ export function applyTerminalGpuAcceleration( } if (!shouldUseTerminalWebgl(pane)) { disposeWebgl(pane, { refreshDimensions: true }) + const root = pane.terminal.element + if (root) { + applyDomBlockFills(root) + } continue } if ( diff --git a/src/renderer/src/lib/pane-manager/terminal-dom-block-fill.test.ts b/src/renderer/src/lib/pane-manager/terminal-dom-block-fill.test.ts new file mode 100644 index 00000000000..3a111750268 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-dom-block-fill.test.ts @@ -0,0 +1,231 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + applyDomBlockFills, + attachDomBlockFill, + backgroundImageForUniformBlockRun +} from './terminal-dom-block-fill' + +describe('backgroundImageForUniformBlockRun', () => { + it('fills the top half of the cell for a run of upper-half blocks', () => { + expect(backgroundImageForUniformBlockRun('▀▀▀')).toBe( + 'linear-gradient(to bottom, var(--orca-block-fg) 50%, transparent 50%)' + ) + }) + + it('fills the bottom half of the cell for lower-half blocks', () => { + expect(backgroundImageForUniformBlockRun('▄▄')).toBe( + 'linear-gradient(to bottom, transparent 50%, var(--orca-block-fg) 50%)' + ) + }) + + it('fills the whole cell for full blocks', () => { + expect(backgroundImageForUniformBlockRun('█')).toBe( + 'linear-gradient(to bottom, transparent 0%, var(--orca-block-fg) 0%)' + ) + }) + + it('fills the left half for left-half blocks', () => { + expect(backgroundImageForUniformBlockRun('▌')).toBe( + 'linear-gradient(to right, var(--orca-block-fg) 50%, transparent 50%)' + ) + }) + + it('fills the right half for right-half blocks', () => { + expect(backgroundImageForUniformBlockRun('▐')).toBe( + 'linear-gradient(to right, transparent 50%, var(--orca-block-fg) 50%)' + ) + }) + + it('fills the top eighth for upper one-eighth blocks', () => { + expect(backgroundImageForUniformBlockRun('▔')).toBe( + 'linear-gradient(to bottom, var(--orca-block-fg) 12.5%, transparent 12.5%)' + ) + }) + + it('fills the right eighth for right one-eighth blocks', () => { + expect(backgroundImageForUniformBlockRun('▕')).toBe( + 'linear-gradient(to right, transparent 87.5%, var(--orca-block-fg) 87.5%)' + ) + }) + + it('skips mixed runs, spaces, and ASCII', () => { + expect(backgroundImageForUniformBlockRun('▀█')).toBeNull() + expect(backgroundImageForUniformBlockRun('▀ ▀')).toBeNull() + expect(backgroundImageForUniformBlockRun('Ask')).toBeNull() + expect(backgroundImageForUniformBlockRun('')).toBeNull() + }) +}) + +describe('applyDomBlockFills', () => { + afterEach(() => { + document.body.replaceChildren() + }) + + function mountSpan(text: string, color = 'rgb(30, 30, 30)'): HTMLSpanElement { + const rows = document.createElement('div') + rows.className = 'xterm-rows' + const span = document.createElement('span') + span.textContent = text + span.style.color = color + span.style.backgroundColor = 'rgb(10, 10, 10)' + const row = document.createElement('div') + row.appendChild(span) + rows.appendChild(row) + document.body.appendChild(rows) + return span + } + + it('paints a uniform ▀ composer border with a cell-filling gradient', () => { + const span = mountSpan('▀'.repeat(12)) + + applyDomBlockFills(document) + + expect(span.style.color).toBe('transparent') + expect(span.style.backgroundImage).toContain('linear-gradient(to bottom') + expect(span.style.backgroundSize).toBe(`${100 / 12}% 100%`) + expect(span.style.backgroundRepeat).toBe('repeat-x') + expect(span.style.getPropertyValue('--orca-block-fg')).toBe('rgb(30, 30, 30)') + }) + + it('repeats a per-cell horizontal fill across a multi-cell left-half run', () => { + const span = mountSpan('▌▌') + + applyDomBlockFills(document) + + expect(span.style.backgroundImage).toContain('linear-gradient(to right') + expect(span.style.backgroundSize).toBe('50% 100%') + expect(span.style.backgroundRepeat).toBe('repeat-x') + }) + + it('repeats a per-cell horizontal fill across a multi-cell right-half run', () => { + const span = mountSpan('▐▐') + + applyDomBlockFills(document) + + expect(span.style.backgroundImage).toBe( + 'linear-gradient(to right, transparent 50%, var(--orca-block-fg) 50%)' + ) + expect(span.style.backgroundSize).toBe('50% 100%') + expect(span.style.backgroundRepeat).toBe('repeat-x') + }) + + it('does not restyle mixed block/letter spans', () => { + const span = mountSpan('▀▀█ Ask') + applyDomBlockFills(document) + expect(span.style.color).toBe('rgb(30, 30, 30)') + expect(span.style.backgroundImage).toBe('') + }) + + it('visits only the rows xterm repainted', () => { + const first = mountSpan('▀▀▀') + const rows = first.parentElement!.parentElement! + const secondRow = document.createElement('div') + const second = document.createElement('span') + second.textContent = '▄▄▄' + second.style.color = 'rgb(30, 30, 30)' + secondRow.appendChild(second) + rows.appendChild(secondRow) + const untouched = vi.spyOn(first, 'textContent', 'get') + + applyDomBlockFills(document, { start: 1, end: 1 }) + + expect(untouched).not.toHaveBeenCalled() + expect(first.style.backgroundImage).toBe('') + expect(second.style.backgroundImage).toContain('linear-gradient') + }) + + it('clears a previous fill when the span is reused for ordinary text', () => { + const span = mountSpan('▀▀▀') + applyDomBlockFills(document) + span.textContent = 'tab agents' + applyDomBlockFills(document) + expect(span.style.backgroundImage).toBe('') + expect(span.style.backgroundSize).toBe('') + expect(span.style.backgroundRepeat).toBe('') + expect(span.style.color).toBe('rgb(30, 30, 30)') + }) + + it('uses a color set on a reused filled span instead of the saved one', () => { + const span = mountSpan('▀▀▀') + applyDomBlockFills(document) + span.style.color = 'rgb(200, 0, 0)' + applyDomBlockFills(document) + expect(span.style.color).toBe('transparent') + expect(span.style.getPropertyValue('--orca-block-fg')).toBe('rgb(200, 0, 0)') + + span.textContent = 'tab agents' + applyDomBlockFills(document) + expect(span.style.color).toBe('rgb(200, 0, 0)') + }) + + it('keeps a color set after the fill when the span becomes ordinary text', () => { + const span = mountSpan('▀▀▀') + applyDomBlockFills(document) + span.style.color = 'rgb(0, 200, 0)' + span.textContent = 'tab agents' + applyDomBlockFills(document) + expect(span.style.color).toBe('rgb(0, 200, 0)') + expect(span.style.backgroundImage).toBe('') + }) + + it('does not pin an inherited color inline when clearing a fill', () => { + const span = mountSpan('▀▀▀', '') + span.parentElement!.style.color = 'rgb(5, 5, 5)' + applyDomBlockFills(document) + expect(span.style.getPropertyValue('--orca-block-fg')).toBe('rgb(5, 5, 5)') + + span.textContent = 'tab agents' + applyDomBlockFills(document) + expect(span.style.color).toBe('') + }) +}) + +describe('attachDomBlockFill', () => { + it('fills row replacements from focus/selection without rescanning untouched rows', async () => { + const element = document.createElement('div') + const rows = document.createElement('div') + rows.className = 'xterm-rows' + element.appendChild(rows) + const untouched = document.createElement('div') + const original = document.createElement('span') + original.textContent = 'ordinary text' + untouched.appendChild(original) + rows.appendChild(untouched) + const changed = document.createElement('div') + rows.appendChild(changed) + const detach = attachDomBlockFill({ element }) + const unread = vi.spyOn(original, 'textContent', 'get') + const span = document.createElement('span') + span.textContent = '▀▀' + span.style.color = 'rgb(1, 2, 3)' + changed.replaceChildren(span) + + await vi.waitFor(() => expect(span.style.color).toBe('transparent')) + expect(unread).not.toHaveBeenCalled() + detach() + const replacement = document.createElement('span') + replacement.textContent = '▄▄' + replacement.style.color = 'rgb(1, 2, 3)' + changed.replaceChildren(replacement) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(replacement.style.color).toBe('rgb(1, 2, 3)') + }) + + it('fills a new DOM renderer container after a GPU mode change', async () => { + const element = document.createElement('div') + const detach = attachDomBlockFill({ element }) + const rows = document.createElement('div') + rows.className = 'xterm-rows' + const row = document.createElement('div') + const span = document.createElement('span') + span.textContent = '▀▀' + span.style.color = 'rgb(1, 2, 3)' + row.appendChild(span) + rows.appendChild(row) + element.appendChild(rows) + + await vi.waitFor(() => expect(span.style.color).toBe('transparent')) + detach() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-dom-block-fill.ts b/src/renderer/src/lib/pane-manager/terminal-dom-block-fill.ts new file mode 100644 index 00000000000..989d4f3c9d7 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-dom-block-fill.ts @@ -0,0 +1,172 @@ +const BLOCK_FG_VAR = '--orca-block-fg' +const FILL_ATTR = 'data-orca-block-fill' +// The span's inline color before the fill ('' when it had none), restored on clear. +const INLINE_FG_ATTR = 'data-orca-block-inline-fg' + +function gradient(axis: 'bottom' | 'right', solidPercent: number, fromStart: boolean): string { + const dir = axis === 'bottom' ? 'to bottom' : 'to right' + const fg = `var(${BLOCK_FG_VAR})` + if (fromStart) { + return `linear-gradient(${dir}, ${fg} ${solidPercent}%, transparent ${solidPercent}%)` + } + const gap = 100 - solidPercent + return `linear-gradient(${dir}, transparent ${gap}%, ${fg} ${gap}%)` +} + +/** CSS background-image that fills the cell the way WebGL custom glyphs do. */ +export function backgroundImageForUniformBlockRun(text: string): string | null { + if (text.length === 0) { + return null + } + const ch = text[0]! + for (let i = 1; i < text.length; i++) { + if (text[i] !== ch) { + return null + } + } + const code = ch.codePointAt(0) ?? 0 + if (code === 0x2580) { + return gradient('bottom', 50, true) + } + if (code >= 0x2581 && code <= 0x2588) { + const eighths = code - 0x2580 + return gradient('bottom', eighths * 12.5, false) + } + if (code >= 0x2589 && code <= 0x258f) { + const eighths = 8 - (code - 0x2588) + return gradient('right', eighths * 12.5, true) + } + if (code === 0x2590) { + return gradient('right', 50, false) + } + if (code === 0x2594) { + return gradient('bottom', 12.5, true) + } + if (code === 0x2595) { + return gradient('right', 12.5, false) + } + return null +} + +function clearBlockFill(span: HTMLElement): void { + if (!span.hasAttribute(FILL_ATTR)) { + return + } + const originalColor = span.getAttribute(INLINE_FG_ATTR) + // Any other inline color was set after the fill and is the span's current foreground. + const ownsColor = span.style.color === 'transparent' + span.removeAttribute(FILL_ATTR) + span.removeAttribute(INLINE_FG_ATTR) + span.style.removeProperty('background-image') + span.style.removeProperty('background-size') + span.style.removeProperty('background-repeat') + span.style.removeProperty(BLOCK_FG_VAR) + if (!ownsColor) { + return + } + if (originalColor) { + span.style.color = originalColor + } else { + span.style.removeProperty('color') + } +} + +type RenderedRows = { start: number; end: number } + +export function applyDomBlockFills(root: ParentNode, range?: RenderedRows): void { + if (typeof root.querySelectorAll !== 'function') { + return + } + const rows = root.querySelector('.xterm-rows') + if (!rows) { + return + } + const start = range?.start ?? 0 + const end = Math.min(range?.end ?? rows.children.length - 1, rows.children.length - 1) + for (let row = start; row <= end; row++) { + fillBlockRow(rows.children[row]!) + } +} + +function fillBlockRow(row: Element): void { + for (const span of row.querySelectorAll('span')) { + fillBlockSpan(span) + } +} + +function fillBlockSpan(span: HTMLElement): void { + const text = span.textContent ?? '' + const fill = backgroundImageForUniformBlockRun(text) + if (!fill) { + clearBlockFill(span) + return + } + const inlineFg = span.style.color + const ownsColor = span.hasAttribute(FILL_ATTR) && inlineFg === 'transparent' + if (ownsColor && span.getAttribute(FILL_ATTR) === text) { + return + } + const fg = ownsColor + ? span.style.getPropertyValue(BLOCK_FG_VAR) + : inlineFg || (typeof getComputedStyle === 'function' ? getComputedStyle(span).color : '') + if (!fg) { + return + } + if (!ownsColor) { + span.setAttribute(INLINE_FG_ATTR, inlineFg) + } + span.setAttribute(FILL_ATTR, text) + span.style.setProperty(BLOCK_FG_VAR, fg) + span.style.color = 'transparent' + span.style.backgroundImage = fill + span.style.backgroundSize = `${100 / text.length}% 100%` + span.style.backgroundRepeat = 'repeat-x' +} + +function collectAddedBlockRows(node: Node, rows: Set): void { + if (!(node instanceof HTMLElement)) { + return + } + if (node.parentElement?.classList.contains('xterm-rows')) { + rows.add(node) + return + } + const containers = node.matches('.xterm-rows') ? [node] : node.querySelectorAll('.xterm-rows') + for (const container of containers) { + for (const row of container.children) { + if (row instanceof HTMLElement) { + rows.add(row) + } + } + } +} + +export function attachDomBlockFill(terminal: { element?: HTMLElement | undefined }): () => void { + const root = terminal.element + if (!root || typeof MutationObserver === 'undefined') { + return () => undefined + } + applyDomBlockFills(root) + // DOM focus, selection and link paints replace rows without xterm's public onRender event. + const observer = new MutationObserver((records) => { + const rows = new Set() + for (const record of records) { + const row = + record.target instanceof HTMLElement ? record.target.closest('.xterm-rows > div') : null + if (row instanceof HTMLElement) { + rows.add(row) + continue + } + for (const node of record.addedNodes) { + collectAddedBlockRows(node, rows) + } + } + for (const row of rows) { + if (root.contains(row)) { + fillBlockRow(row) + } + } + }) + observer.observe(root, { childList: true, subtree: true }) + return () => observer.disconnect() +} From d140b72c29903bdbd2a73e05019ff832ec6ecd39 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:57:03 -0700 Subject: [PATCH 24/38] fix(terminal): pass single-pane TUI tab clicks through drag strip (#24591) --- src/renderer/src/assets/terminal.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 7b470a252ed..5d6335ebb8d 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -118,6 +118,7 @@ /* ── Pane drag handle ─────────────────────────────────── */ .pane-drag-handle { + pointer-events: none; position: absolute; top: 0; left: 0; From 8186ded0bd5549c7bcca6f69d83e18565e1c8648 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:57:06 -0700 Subject: [PATCH 25/38] fix(mobile): preserve terminal mode query variables in WebView builds (#24626) --- .../scripts/build-terminal-webview-engine.mjs | 5 +++- .../terminal/terminal-webview-engine.test.ts | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/mobile/scripts/build-terminal-webview-engine.mjs b/mobile/scripts/build-terminal-webview-engine.mjs index da81686671f..c6188c14b5a 100644 --- a/mobile/scripts/build-terminal-webview-engine.mjs +++ b/mobile/scripts/build-terminal-webview-engine.mjs @@ -81,7 +81,10 @@ async function buildEngineJs() { }, bundle: true, format: 'iife', - minify: true, + // esbuild 0.25 syntax folding drops xterm's local DECRQM enum declaration. + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: false, platform: 'browser', target, legalComments: 'none', diff --git a/mobile/src/terminal/terminal-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts index 96a4327f090..bea9e2e1ca0 100644 --- a/mobile/src/terminal/terminal-webview-engine.test.ts +++ b/mobile/src/terminal/terminal-webview-engine.test.ts @@ -91,6 +91,35 @@ describe('terminal WebView bundled engine', () => { expect(() => parse(XTERM_ENGINE_JS, { ecmaVersion: 2019 })).not.toThrow() }) + it('answers the mode query OpenCode sends during startup', async () => { + vi.useFakeTimers() + const reply = vi.fn() + const window = {} + try { + new Script( + `${XTERM_ENGINE_JS}; const terminal = new window.Terminal(); + terminal.onData(reply); terminal.write('\\x1b[?1004$p');` + ).runInNewContext({ + window, + self: window, + document: {}, + navigator: { platform: 'Linux', userAgent: 'Chrome/74' }, + structuredClone, + performance, + setTimeout, + clearTimeout, + queueMicrotask, + console, + URL, + reply + }) + await vi.runAllTimersAsync() + expect(reply).toHaveBeenCalledWith('\x1b[?1004;2$y') + } finally { + vi.useRealTimers() + } + }) + // Why: the context deliberately omits WeakRef (Chrome 84+) / structuredClone // (Chrome 98+) and supplies an Element without replaceChildren (Chrome 86+) — // the engine must evaluate on older WebViews via its own guarded runtime shims, From ff452661e77fdf0494df76efb1ce70be1d363568 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:01:18 -0700 Subject: [PATCH 26/38] Stop stalled unit jobs after an hour (#24583) * ci: bound unit jobs to one hour of execution * docs: keep CI budget notes clear of the headless follow-up * docs: keep CI deadline evidence in the pull request --- .github/workflows/unit-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index abe9cf28a41..2e6f173acc4 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -26,6 +26,7 @@ jobs: test: name: tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }} runs-on: ${{ inputs.runner }} + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -82,6 +83,7 @@ jobs: matrix: node: ${{ fromJSON(inputs.node_versions) }} runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout From 8765f8c9b1f4f83b7b6d058d03e089627fb93d5f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:02:03 -0700 Subject: [PATCH 27/38] fix(opencode): preserve turn outcomes and avoid auto permission attention (#24612) * fix(opencode): retain failed and stopped TUI turn outcomes Adapt the root verdict proposal from brennanb2025 in PR #23105 to the current TUI-owned lifecycle, keeping hook-store authority and existing mainAgent semantics. * fix(opencode): let auto-approved permissions settle before attention * fix(opencode): reconcile cached outcomes with completed session turns * fix(opencode): bind terminal verdicts to ending event timestamps * fix(opencode): publish approval cards for permission requests --- .../server-opencode-normalization.test.ts | 40 +++ ...ook-plugin-opencode2-tui-ownership.test.ts | 254 +++++++++--------- .../opencode/opencode-tui-session-fixture.ts | 156 +++++++++++ .../opencode2/status-plugin-tui-source.ts | 71 +++-- .../providers/opencode-family-events.ts | 27 +- .../providers/opencode-family-tool-fields.ts | 7 +- src/shared/opencode-permission-status.test.ts | 19 +- 7 files changed, 428 insertions(+), 146 deletions(-) create mode 100644 src/main/opencode/opencode-tui-session-fixture.ts diff --git a/src/main/agent-hooks/server-opencode-normalization.test.ts b/src/main/agent-hooks/server-opencode-normalization.test.ts index 56bb0b063b8..e1ff6dfbcc6 100644 --- a/src/main/agent-hooks/server-opencode-normalization.test.ts +++ b/src/main/agent-hooks/server-opencode-normalization.test.ts @@ -63,6 +63,46 @@ describe.each(['opencode', 'opencode2'] as const)('%s hook normalization', (sour expect(result?.payload.agentType).toBe(source) }) + it.each([ + ['UnknownError', 'failure', undefined], + ['MessageAbortedError', 'cancellation', true] + ])('retains the reported root outcome for %s', (errorName, outcome, interrupted) => { + const result = _internals.normalizeHookPayload( + source, + buildBody({ + hook_event_name: 'SessionIdle', + root_state: 'done', + root_turn_error_name: errorName + }), + 'production' + ) + expect(result?.payload.mainAgent).toMatchObject({ state: 'done', outcome }) + expect(result?.payload.interrupted).toBe(interrupted) + }) + + it('does not infer an outcome for an older plugin', () => { + const result = _internals.normalizeHookPayload( + source, + buildBody({ hook_event_name: 'SessionIdle' }), + 'production' + ) + expect(result?.payload.mainAgent).toBeUndefined() + }) + + it('keeps a root failure beside still-working child work', () => { + const result = _internals.normalizeHookPayload( + source, + buildBody({ + hook_event_name: 'SessionBusy', + root_state: 'done', + root_turn_error_name: 'api' + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.mainAgent).toMatchObject({ state: 'done', outcome: 'failure' }) + }) + it('PermissionRequest maps to waiting', () => { const result = _internals.normalizeHookPayload( source, diff --git a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts index 29eefd77482..cca4712e971 100644 --- a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts @@ -15,13 +15,18 @@ vi.mock('electron', () => ({ })) import { _internals } from './hook-service' +import { fakeTui, type BusEvent } from './opencode-tui-session-fixture' type Post = { paneKey?: string opencodeMajor?: number - payload?: { hook_event_name?: string; sessionID?: string } + payload?: { + hook_event_name?: string + sessionID?: string + root_state?: string + root_turn_error_name?: string + } } -type BusEvent = { type: string; data: Record } type PluginModule = { default?: { setup?: (ctx: unknown) => Promise<(() => Promise) | undefined> } } @@ -70,130 +75,6 @@ function turn(sessionID: string, text: string): { start: BusEvent[]; finish: Bus } } -type Blocker = { id: string; sessionID: string; [key: string]: unknown } - -function toBlocker(value: unknown): Blocker { - const record = typeof value === 'object' && value !== null ? { ...value } : {} - const id = 'id' in record ? String(record.id) : '' - const sessionID = 'sessionID' in record ? String(record.sessionID) : '' - return { ...record, id, sessionID } -} - -/** One pane's TUI: its route, its view of the shared session store, and the shared event bus. */ -function fakeTui(version = '2.0.14') { - const listeners = new Set<(event: { details: BusEvent }) => void>() - const sessions = new Map() - const running = new Set() - const permissions = new Map() - const forms = new Map() - // What the server answers when the TUI re-fetches a permission list (reconnect). - const serverPermissions = new Map() - let permissionFetch: Promise = Promise.resolve() - // Why: OpenCode keeps storage.memory across plugin hot reloads within one TUI process. - const memories = new Map() - let route: { type: string; sessionID?: string } = { type: 'home' } - const rootOf = (id: string): string => { - let current = sessions.get(id) - while (current?.parentID && sessions.has(current.parentID)) { - current = sessions.get(current.parentID) - } - return current?.id ?? id - } - const without = (map: Map, sessionID: string, id: unknown): void => { - map.set( - sessionID, - (map.get(sessionID) ?? []).filter((item) => item.id !== id) - ) - } - const listen = vi.fn((handler: (event: { details: BusEvent }) => void) => { - listeners.add(handler) - return () => listeners.delete(handler) - }) - const ctx = { - app: { version, channel: 'latest' }, - ui: { router: { current: () => route } }, - storage: { - memory: (key: string, options: { initial: Record }) => { - if (!memories.has(key)) { - const value = structuredClone(options.initial) - memories.set(key, [value, (mutate: (draft: typeof value) => void) => mutate(value)]) - } - return memories.get(key) - } - }, - client: { - session: { get: async ({ sessionID }: { sessionID: string }) => sessions.get(sessionID) } - }, - data: { - listen, - session: { - get: (id: string) => sessions.get(id), - root: rootOf, - family: (id: string) => - [...sessions.keys()].filter((member) => rootOf(member) === rootOf(id)), - status: (id: string) => (running.has(id) ? 'running' : 'idle'), - permission: { - list: (id: string) => permissions.get(id), - sync: async (id: string) => { - await permissionFetch - permissions.set(id, [...(serverPermissions.get(id) ?? [])]) - } - }, - form: { list: (id: string) => forms.get(id), sync: async () => {} } - } - } - } - return { - ctx, - listen, - serverPermissions, - permissions, - navigate(sessionID: string) { - route = { type: 'session', sessionID } - }, - // The session data stops reporting a run without this TUI seeing its end event. - loseEnd(sessionID: string) { - running.delete(sessionID) - }, - // The session data reports a run whose start this TUI never saw. - loseStart(sessionID: string) { - running.add(sessionID) - }, - // Holds the next permission list fetch until the returned release is called. - holdPermissionFetch(): () => void { - let release = (): void => {} - permissionFetch = new Promise((resolve) => { - release = resolve - }) - return release - }, - // Applies an event to the session data first, then to plugin listeners, as OpenCode does. - emit(event: BusEvent) { - const sessionID = String(event.data.sessionID) - if (event.type === 'session.created') { - const parentID = typeof event.data.parentID === 'string' ? event.data.parentID : undefined - sessions.set(sessionID, { id: sessionID, parentID }) - } else if (event.type === 'session.execution.started') { - running.add(sessionID) - } else if (event.type === 'session.execution.succeeded') { - running.delete(sessionID) - } else if (event.type === 'permission.asked') { - permissions.set(sessionID, [...(permissions.get(sessionID) ?? []), toBlocker(event.data)]) - } else if (event.type === 'permission.replied') { - without(permissions, sessionID, event.data.requestID) - } else if (event.type === 'form.created') { - const form = toBlocker(event.data.form) - forms.set(form.sessionID, [...(forms.get(form.sessionID) ?? []), form]) - } else if (event.type === 'form.replied' || event.type === 'form.cancelled') { - without(forms, sessionID, event.data.id) - } - for (const handler of listeners) { - handler({ details: event }) - } - } - } -} - describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { let tempDir: string let savedFetch: typeof globalThis.fetch @@ -290,6 +171,33 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { } } + it.each([ + ['session.execution.failed', { error: { type: 'api' } }, 'api'], + ['session.execution.failed', {}, 'UnknownError'], + ['session.execution.interrupted', { reason: 'user' }, 'MessageAbortedError'], + ['session.execution.interrupted', { reason: 'pause' }, undefined], + ['session.execution.succeeded', {}, undefined] + ])('carries the root verdict for %s', async (type, fields, errorName) => { + const reported = await runPane(PANE_A, SES_A, async (tui) => { + tui.navigate(SES_A) + await pump(tui, turn(SES_A, 'root').start) + await pump(tui, [{ type, data: { sessionID: SES_A, ...fields } }]) + }) + expect(reported.at(-1)?.payload).toMatchObject({ root_state: 'done' }) + expect(reported.at(-1)?.payload?.root_turn_error_name).toBe(errorName) + }) + + it('clears a failed root verdict on its next turn', async () => { + const reported = await runPane(PANE_A, SES_A, async (tui) => { + tui.navigate(SES_A) + await pump(tui, turn(SES_A, 'first').start) + await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }]) + await pump(tui, [{ type: 'session.execution.started', data: { sessionID: SES_A } }]) + await pump(tui, turn(SES_A, 'next').finish) + }) + expect(reported.at(-1)?.payload?.root_turn_error_name).toBeUndefined() + }) + // Captured s2 shape: pane A's long turn overlapped by pane B's short one on one server. it('gives each overlapping pane only its own session and its own Idle', async () => { const a = turn(SES_A, 'A long SLEEP=12') @@ -446,6 +354,75 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { data: { id: 'per_1', sessionID, action: 'bash', resources: ['rm -rf build'] } }) + it('publishes a root failure while its child keeps the aggregate Working', async () => { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + await pump(tui, [...turn(SES_A, 'root').start, ...childStart(SES_A)]) + await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }]) + await vi.waitFor(() => { + expect(posts.at(-1)?.payload).toMatchObject({ + hook_event_name: 'SessionBusy', + root_state: 'done', + root_turn_error_name: 'UnknownError' + }) + }) + await pump(tui, [{ type: 'session.execution.succeeded', data: { sessionID: SES_CHILD } }]) + expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError') + await cleanup?.() + }) + + it('does not turn a child failure into a root failure', async () => { + const reported = await runPane(PANE_A, SES_A, async (tui) => { + tui.navigate(SES_A) + await pump(tui, [...turn(SES_A, 'root').start, ...childStart(SES_A)]) + await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_CHILD } }]) + await pump(tui, turn(SES_A, 'root').finish) + }) + expect(reported.at(-1)?.payload?.root_turn_error_name).toBeUndefined() + }) + + it('keeps a failed terminal verdict through plugin hot reload', async () => { + const tui = fakeTui() + const first = await start(tui) + tui.navigate(SES_A) + await pump(tui, turn(SES_A, 'root').start) + await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }]) + await vi.waitFor(() => + expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError') + ) + await first?.() + const before = posts.length + const second = await start(tui) + await tick(150) + expect(posts).toHaveLength(before) + expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError') + await second?.() + }) + + it.each(['session.execution.succeeded', 'session.execution.interrupted'])( + 'repairs a failed verdict when %s follows a missed turn start during unload', + async (finishType) => { + const tui = fakeTui() + const first = await start(tui) + tui.navigate(SES_A) + await pump(tui, turn(SES_A, 'first').start) + await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }]) + await first?.() + await pump(tui, [ + { type: 'session.execution.started', data: { sessionID: SES_A } }, + { type: finishType, data: { sessionID: SES_A, reason: 'user' } } + ]) + const second = await start(tui) + await vi.waitFor(() => expect(posts.at(-1)?.payload?.root_turn_error_name).toBeUndefined()) + expect(posts.at(-1)?.payload).toMatchObject({ + hook_event_name: 'SessionIdle', + root_state: 'done' + }) + await second?.() + } + ) + // Why: #23700 left this for TUI panes; a reload that misses the end must still reach Done. it('shows Done for a turn that ended while the plugin was reloading', async () => { const tui = fakeTui() @@ -591,13 +568,36 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const a = turn(SES_A, 'A spawns a background task') await pump(tui, [...a.start, ...childStart(SES_A), ...a.finish]) await tick(250) - expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`]) + expect(posts.at(-1)?.payload).toMatchObject({ root_state: 'done' }) tui.loseEnd(SES_CHILD) await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await cleanup?.() expect(posts.every((post) => post.payload?.sessionID === SES_A)).toBe(true) }) + it('keeps automatically answered permissions Working without an attention post', async () => { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + await pump(tui, turn(SES_A, 'auto').start) + for (let index = 0; index < 3; index += 1) { + tui.emit({ + type: 'permission.asked', + data: { ...permission(SES_A).data, id: `auto_${index}` } + }) + await tick(20) + tui.emit({ + type: 'permission.replied', + data: { sessionID: SES_A, requestID: `auto_${index}` } + }) + } + await tick(650) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) + await pump(tui, turn(SES_A, 'auto').finish) + await cleanup?.() + }) + it("shows a child's permission as Needs input on the root, then Working after the reply", async () => { const tui = fakeTui() const cleanup = await start(tui) diff --git a/src/main/opencode/opencode-tui-session-fixture.ts b/src/main/opencode/opencode-tui-session-fixture.ts new file mode 100644 index 00000000000..2b87635a523 --- /dev/null +++ b/src/main/opencode/opencode-tui-session-fixture.ts @@ -0,0 +1,156 @@ +import { vi } from 'vitest' + +export type BusEvent = { type: string; data: Record; created?: number } + +type Blocker = { id: string; sessionID: string; [key: string]: unknown } + +function toBlocker(value: unknown): Blocker { + const record = typeof value === 'object' && value !== null ? { ...value } : {} + const id = 'id' in record ? String(record.id) : '' + const sessionID = 'sessionID' in record ? String(record.sessionID) : '' + return { ...record, id, sessionID } +} + +/** One pane's TUI: its route, its view of the shared session store, and the shared event bus. */ +export function fakeTui(version = '2.0.14') { + const listeners = new Set<(event: { details: BusEvent }) => void>() + const sessions = new Map< + string, + { id: string; parentID?: string; outcome?: string; time?: { idle: number } } + >() + let idleClock = 1 + const running = new Set() + const permissions = new Map() + const forms = new Map() + // What the server answers when the TUI re-fetches a permission list (reconnect). + const serverPermissions = new Map() + let permissionFetch: Promise = Promise.resolve() + // Why: OpenCode keeps storage.memory across plugin hot reloads within one TUI process. + const memories = new Map() + let route: { type: string; sessionID?: string } = { type: 'home' } + const rootOf = (id: string): string => { + let current = sessions.get(id) + while (current?.parentID && sessions.has(current.parentID)) { + current = sessions.get(current.parentID) + } + return current?.id ?? id + } + const without = (map: Map, sessionID: string, id: unknown): void => { + map.set( + sessionID, + (map.get(sessionID) ?? []).filter((item) => item.id !== id) + ) + } + const listen = vi.fn((handler: (event: { details: BusEvent }) => void) => { + listeners.add(handler) + return () => listeners.delete(handler) + }) + const ctx = { + app: { version, channel: 'latest' }, + ui: { router: { current: () => route } }, + storage: { + memory: (key: string, options: { initial: Record }) => { + if (!memories.has(key)) { + const value = structuredClone(options.initial) + memories.set(key, [value, (mutate: (draft: typeof value) => void) => mutate(value)]) + } + return memories.get(key) + } + }, + client: { + session: { get: async ({ sessionID }: { sessionID: string }) => sessions.get(sessionID) } + }, + data: { + listen, + session: { + get: (id: string) => sessions.get(id), + root: rootOf, + family: (id: string) => + [...sessions.keys()].filter((member) => rootOf(member) === rootOf(id)), + status: (id: string) => (running.has(id) ? 'running' : 'idle'), + permission: { + list: (id: string) => permissions.get(id), + sync: async (id: string) => { + await permissionFetch + permissions.set(id, [...(serverPermissions.get(id) ?? [])]) + } + }, + form: { list: (id: string) => forms.get(id), sync: async () => {} } + } + } + } + return { + ctx, + listen, + serverPermissions, + permissions, + navigate(sessionID: string) { + route = { type: 'session', sessionID } + }, + // The session data stops reporting a run without this TUI seeing its end event. + loseEnd(sessionID: string) { + running.delete(sessionID) + }, + // The session data reports a run whose start this TUI never saw. + loseStart(sessionID: string) { + running.add(sessionID) + const session = sessions.get(sessionID) + if (session) { + session.outcome = undefined + } + }, + // Holds the next permission list fetch until the returned release is called. + holdPermissionFetch(): () => void { + let release = (): void => {} + permissionFetch = new Promise((resolve) => { + release = resolve + }) + return release + }, + // Ending events reach listeners before the session snapshot catches up. + emit(event: BusEvent) { + const sessionID = String(event.data.sessionID) + let notified = false + if (event.type === 'session.created') { + const parentID = typeof event.data.parentID === 'string' ? event.data.parentID : undefined + sessions.set(sessionID, { id: sessionID, parentID }) + } else if (event.type === 'session.execution.started') { + running.add(sessionID) + const session = sessions.get(sessionID) + if (session) { + session.outcome = undefined + } + } else if ( + event.type === 'session.execution.succeeded' || + event.type === 'session.execution.failed' || + event.type === 'session.execution.interrupted' + ) { + event = { ...event, created: ++idleClock } + for (const handler of listeners) { + handler({ details: event }) + } + notified = true + running.delete(sessionID) + const session = sessions.get(sessionID) + if (session) { + session.outcome = event.type.slice('session.execution.'.length) + session.time = { idle: idleClock } + } + } else if (event.type === 'permission.asked') { + permissions.set(sessionID, [...(permissions.get(sessionID) ?? []), toBlocker(event.data)]) + } else if (event.type === 'permission.replied') { + without(permissions, sessionID, event.data.requestID) + } else if (event.type === 'form.created') { + const form = toBlocker(event.data.form) + forms.set(form.sessionID, [...(forms.get(form.sessionID) ?? []), form]) + } else if (event.type === 'form.replied' || event.type === 'form.cancelled') { + without(forms, sessionID, event.data.id) + } + if (!notified) { + for (const handler of listeners) { + handler({ details: event }) + } + } + } + } +} diff --git a/src/main/opencode2/status-plugin-tui-source.ts b/src/main/opencode2/status-plugin-tui-source.ts index aff056d45de..f3d7db13a1e 100644 --- a/src/main/opencode2/status-plugin-tui-source.ts +++ b/src/main/opencode2/status-plugin-tui-source.ts @@ -8,6 +8,7 @@ export function getOpenCode2TuiSource(): string[] { return String.raw` const TUI_TICK_MS = 100; +const TUI_PERMISSION_SETTLE_MS = 500; const TUI_EARLY_ROOTS_MAX = 32; const TUI_RESOLVED_REQUESTS_MAX = 256; const TUI_ENDPOINT_CHECK_TICKS = 50; @@ -22,10 +23,9 @@ function boundedSet(set, value, max) { if (set.size > max) set.delete(set.values().next().value); } -// Why storage.memory: OpenCode keeps it across plugin hot reloads and drops it when the TUI -// exits, so a reload mid-turn keeps this pane's sessions and what it last reported. +// Memory survives hot reloads and ends with the TUI. function paneStatusMemory(ctx) { - const initial = { owned: [], last: "idle:", lastRoot: "", started: false }; + const initial = { owned: [], last: "idle:", lastRoot: "", started: false, endings: [] }; if (typeof ctx.storage?.memory === "function") return ctx.storage.memory("pane-status", { initial }); // Why started: without memory a reload looks like a TUI start, and must not reset the pane. const local = { ...initial, started: true }; @@ -50,10 +50,10 @@ async function setupOpenCode2Tui(ctx) { let disposed = false; let lastLevel = null; let ticks = 0; - // Root sessions this TUI saw start before it owned them: their SessionStart and prompt. const early = new Map(); // Why: a permission/form list fetched on reconnect can land after the reply event and restore it. const resolved = new Set(); + const permissionSeenAt = new Map(); const rootOf = (sessionID) => data.root(sessionID) || sessionID; const family = (root) => new Set([root, ...(typeof data.family === "function" ? data.family(root) || [] : [])]); @@ -62,18 +62,41 @@ async function setupOpenCode2Tui(ctx) { const route = ctx.ui.router.current(); return route?.type === "session" && typeof route.sessionID === "string" ? rootOf(route.sessionID) : undefined; }; - // First open permission, else first form, across the root's family. - const blocker = (root) => { + const blocker = (root, seenPermissions) => { let form; for (const member of family(root)) { - const permission = (data.permission?.list?.(member) || []).find((request) => !resolved.has(request.id)); + const permission = (data.permission?.list?.(member) || []).find((request) => { + if (resolved.has(request.id)) return false; + seenPermissions.add(request.id); + if (!permissionSeenAt.has(request.id)) permissionSeenAt.set(request.id, Date.now()); + // Auto replies arrive after the request; only an unanswered request needs attention. + return Date.now() - permissionSeenAt.get(request.id) >= TUI_PERMISSION_SETTLE_MS; + }); if (permission) return { request: permission, isPermission: true }; form ??= (data.form?.list?.(member) || []).find((request) => !resolved.has(request.id)); } return form ? { request: form, isPermission: false } : null; }; const levelKey = (level) => - level.kind === "waiting" ? "waiting:" + level.blocker.request.id + ":" + level.root : level.kind + ":" + level.root; + (level.kind === "waiting" ? "waiting:" + level.blocker.request.id + ":" + level.root : level.kind + ":" + level.root) + + (level.rootFields.root_state ? ":" + JSON.stringify(level.rootFields) : ""); + + function rootFields(level) { + if (!level.root) return {}; + const rootRunning = data.status(level.root) === "running"; + const rootState = rootRunning + ? level.kind === "waiting" && level.blocker.request.sessionID === level.root ? "waiting" : "working" + : "done"; + const session = data.get?.(level.root); + const ending = (memory.endings || []).find(([id]) => id === level.root); + const idleAt = session?.time?.idle; + const sameTurn = Number.isFinite(idleAt) && ending?.[2] === idleAt; + // Current session data repairs a whole turn missed while the plugin was unloaded. + const errorName = !rootRunning && (session?.outcome === "failed" + ? (sameTurn && ending[1]) || "UnknownError" + : session?.outcome === "interrupted" && sameTurn ? ending[1] : ""); + return { root_state: rootState, ...(errorName ? { root_turn_error_name: errorName } : {}) }; + } function own(root, owned) { const seen = early.get(root); @@ -94,11 +117,17 @@ async function setupOpenCode2Tui(ctx) { owned = owned.filter((root) => root === route || running(root)); if (owned.join("\n") !== memory.owned.join("\n")) setMemory((draft) => { draft.owned = owned; }); const active = owned.filter(running); + const seenPermissions = new Set(); + let waiting; for (const root of active) { // Why only while running: a blocker the session data kept after its turn ended is stale. - const found = blocker(root); - if (found) return { kind: "waiting", root, blocker: found }; + const found = blocker(root, seenPermissions); + if (found && !waiting) waiting = { kind: "waiting", root, blocker: found }; } + for (const id of permissionSeenAt.keys()) { + if (!seenPermissions.has(id)) permissionSeenAt.delete(id); + } + if (waiting) return waiting; const busy = active.at(-1); return busy ? { kind: "busy", root: busy } : { kind: "idle", root: memory.lastRoot }; } @@ -108,6 +137,7 @@ async function setupOpenCode2Tui(ctx) { let level; try { level = derive(); + level.rootFields = rootFields(level); } catch { // Why: a data read that throws must not kill the listener or the tick. return; @@ -119,14 +149,13 @@ async function setupOpenCode2Tui(ctx) { if (level.kind !== "idle") draft.lastRoot = level.root; }); lastLevel = level; - // Why the lifecycle queue: posts keep the order the levels were derived in. void enqueueLifecycle(() => deliver(level, true)); } async function deliver(level, changed) { // Retires assistant text queued under the previous level (see flushPendingAssistantPart). if (changed) stateArrivalRevision += 1; - const properties = level.root ? { sessionID: level.root } : {}; + const properties = level.root ? { sessionID: level.root, ...level.rootFields } : {}; if (level.kind === "waiting") { const { request, isPermission } = level.blocker; const translated = isPermission @@ -134,12 +163,12 @@ async function setupOpenCode2Tui(ctx) { : translateOpenCode2Event("form.created", { form: request }); if (!translated) return; const hookEventName = isPermission ? "PermissionRequest" : "AskUserQuestion"; - await setAttention(hookEventName, { ...translated.properties, ...properties }, factoryID, request.sessionID); + await flushPendingAssistantPart(true); + await setDeliveryTarget("waiting", levelKey(level), hookEventName, { ...translated.properties, ...properties }, factoryID); return; } - // Why flush first: the done-state preview must show the completed reply. if (level.kind === "idle") await flushPendingAssistantPart(true); - await setStatus(level.kind, properties, factoryID); + await setDeliveryTarget(level.kind, levelKey(level), level.kind === "busy" ? "SessionBusy" : "SessionIdle", properties, factoryID); } function postPrompt(root, prompt) { @@ -188,6 +217,18 @@ async function setupOpenCode2Tui(ctx) { } const root = rootOf(sessionID); const isOwned = memory.owned.includes(root); + if (sessionID === root && (isOwned || currentRoute() === root)) { + if (event.type === "session.execution.started" || event.type === "session.execution.succeeded" || event.type === "session.execution.failed" || event.type === "session.execution.interrupted") { + const errorName = event.type === "session.execution.failed" + ? (typeof properties.error?.type === "string" && properties.error.type) || "UnknownError" + : event.type === "session.execution.interrupted" && properties.reason === "user" ? "MessageAbortedError" : ""; + // A hot reload keeps the terminal verdict; only this root's next turn replaces it. + setMemory((draft) => { + draft.endings = (draft.endings || []).filter(([id]) => id !== root); + if (errorName) draft.endings = [...draft.endings, [root, errorName, event.created ?? data.get?.(root)?.time?.idle]].slice(-TUI_EARLY_ROOTS_MAX); + }); + } + } if (event.type === "session.inbox.enqueued") { // Why TUI-only: the server takes the prompt from session.hook("prompt"), which a TUI lacks. const item = properties.item; diff --git a/src/shared/agent-hook-listener/providers/opencode-family-events.ts b/src/shared/agent-hook-listener/providers/opencode-family-events.ts index e030276b08b..ba64837e42b 100644 --- a/src/shared/agent-hook-listener/providers/opencode-family-events.ts +++ b/src/shared/agent-hook-listener/providers/opencode-family-events.ts @@ -1,3 +1,4 @@ +import { continueMainAgentStatus } from '../../agent-lead-status-fold' import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload @@ -41,6 +42,28 @@ export function normalizeOpenCodeFamilyEvent( } ) + const rootState = hookPayload.root_state + const errorName = hookPayload.root_turn_error_name + const mainAgent = + (source === 'opencode' || source === 'opencode2') && + (rootState === 'working' || rootState === 'waiting' || rootState === 'done') + ? continueMainAgentStatus( + eventName === 'SessionStart' + ? undefined + : state.lastStatusByPaneKey.get(paneKey)?.payload.mainAgent, + { + state: rootState, + outcome: + rootState === 'done' && typeof errorName === 'string' && errorName + ? errorName === 'MessageAbortedError' + ? 'cancellation' + : 'failure' + : undefined + }, + Date.now() + ) + : undefined + return normalizeAgentStatusPayload({ state: stateName, prompt: resolvePrompt(state, paneKey, promptText, { @@ -55,6 +78,8 @@ export function normalizeOpenCodeFamilyEvent( sessionBoundary: (source === 'opencode' || source === 'opencode2') && eventName === 'SessionStart' ? true - : undefined + : undefined, + ...(mainAgent ? { mainAgent } : {}), + ...(stateName === 'done' && mainAgent?.outcome === 'cancellation' ? { interrupted: true } : {}) }) } diff --git a/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts b/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts index 4236cace716..b28aa552d76 100644 --- a/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts @@ -63,10 +63,13 @@ export function extractOpenCodeToolFields( (pattern): pattern is string => typeof pattern === 'string' && pattern.length > 0 ) : [] + const toolName = readString(hookPayload, 'permission') + const toolInput = metadataInput ?? (patterns.length > 0 ? patterns.join(', ') : undefined) return toolUpdate( { - toolName: readString(hookPayload, 'permission'), - toolInput: metadataInput ?? (patterns.length > 0 ? patterns.join(', ') : undefined) + toolName, + toolInput, + interactivePrompt: deriveInteractivePrompt(toolName, { command: toolInput }, eventName) }, { hasToolInputField: hasAnyOwnField(hookPayload, ['metadata', 'patterns']) } ) diff --git a/src/shared/opencode-permission-status.test.ts b/src/shared/opencode-permission-status.test.ts index 31316e30391..5d56134681b 100644 --- a/src/shared/opencode-permission-status.test.ts +++ b/src/shared/opencode-permission-status.test.ts @@ -27,7 +27,7 @@ describe('OpenCode-family permission request status', () => { state = createHookListenerState() }) - const SOURCES = ['opencode', 'mimo-code'] as const + const SOURCES = ['opencode', 'opencode2', 'mimo-code'] as const function permissionEvent( source: (typeof SOURCES)[number], @@ -87,6 +87,21 @@ describe('OpenCode-family permission request status', () => { expect(event?.payload.toolInput).toBe('rm -rf build/') }) + it.each(SOURCES)('carries the real v2 shell request as an approval card for %s', (source) => { + const event = permissionEvent(source, { + id: 'per_native', + sessionID: 'ses_native', + permission: 'shell', + patterns: ['printf PERMISSION_NATIVE_OK'], + source: { type: 'tool', messageID: 'msg_native', id: 'functions.shell:0' } + }) + expect(event?.payload.interactivePrompt).toBe( + JSON.stringify({ + approval: { tool: 'shell', summary: 'printf PERMISSION_NATIVE_OK' } + }) + ) + }) + it.each(SOURCES)('names the edited file for %s', (source) => { // Captured verbatim from opencode 1.18.18 — an `edit` request keys its path as // `filepath` (one word) and ships the whole patch alongside it. @@ -189,6 +204,7 @@ describe('OpenCode-family permission request status', () => { expect(resumed?.payload.state).toBe('working') expect(resumed?.payload.toolName).toBeUndefined() expect(resumed?.payload.toolInput).toBeUndefined() + expect(resumed?.payload.interactivePrompt).toBeUndefined() }) it.each(SOURCES)('retires the permission when the session goes idle for %s', (source) => { @@ -198,6 +214,7 @@ describe('OpenCode-family permission request status', () => { expect(idle?.payload.state).toBe('done') expect(idle?.payload.toolName).toBeUndefined() expect(idle?.payload.toolInput).toBeUndefined() + expect(idle?.payload.interactivePrompt).toBeUndefined() }) it.each(SOURCES)( From 76b1a90ff69544a96eef19997c72abf58fe7a5df Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:05:43 -0700 Subject: [PATCH 28/38] chore(deps): update reviewed dependencies across Orca (#24561) * chore(deps): update reviewed desktop dependencies and tooling * chore(deps): update compatible mobile packages and Fastlane * chore(deps): update cloud transports and enforce release age * chore(deps): patch documentation dependencies and record review * chore: remove dependency review reports * test(linear): smoke-load resolved SDK through CommonJS loader * fix(deps): keep native rebuilds from reinstalling addon dependencies * fix(native): invoke installed node-gyp directly for Node rebuilds * test(cloud): exclude observer probes from row-lock timing budget * test(mobile): preserve the CSS writer receiver in viewport spy * test(native): remove obsolete batch-shim fixture exception * Stream native rebuild output through the process wrapper --- .github/workflows/docs.yml | 4 +- .github/workflows/unit-tests.yml | 4 +- cloud/apps/push/package.json | 8 +- cloud/apps/relay-fence-broker/package.json | 8 +- cloud/apps/relay-ops/package.json | 8 +- cloud/apps/relay/package.json | 12 +- ...al-rehome-target-row-lock-postgres.test.ts | 18 +- cloud/package.json | 4 +- cloud/packages/postgres-schema/package.json | 2 +- cloud/packages/push-contract/package.json | 2 +- cloud/packages/relay-contract/package.json | 2 +- cloud/pnpm-lock.yaml | 503 +-- cloud/pnpm-workspace.yaml | 1 + .../docker/headless-pairing/Dockerfile.build | 2 +- config/scripts/check-react-doctor-changed.mjs | 2 +- config/scripts/ensure-native-runtime.mjs | 37 +- config/scripts/ensure-native-runtime.test.mjs | 169 +- config/scripts/linear-sdk-runtime.test.mjs | 78 + ...tched-dependencies-frozen-install.test.mjs | 5 + .../scripts/pr-workflow-parallelism.test.mjs | 2 +- .../windows-cmd-shim-spawn-boundary.test.mjs | 1 - .../windows-process-tree-gyp-rebuild.mjs | 12 +- .../windows-process-tree-gyp-rebuild.test.mjs | 8 + docs/site/.gitignore | 2 +- docs/site/.npmrc | 1 + docs/site/package.json | 32 +- docs/site/pnpm-lock.yaml | 1562 +++---- .../site/src/components/layout/DocsFooter.tsx | 4 +- docs/site/tests/docs-package.test.mjs | 2 +- mobile/Gemfile | 2 +- mobile/Gemfile.lock | 28 +- mobile/package.json | 30 +- .../packages/expo-two-way-audio/package.json | 6 +- mobile/pnpm-lock.yaml | 2278 +++++----- mobile/pnpm-workspace.yaml | 3 + .../terminal/document/viewport-frame.test.ts | 13 +- .../terminal-web-document-mount.test.ts | 21 +- package.json | 62 +- pnpm-lock.yaml | 3860 +++++++++-------- pnpm-workspace.yaml | 9 +- .../claude-agent-sdk-contract-pins.test.ts | 2 +- .../claude/claude-structured-real-cli.test.ts | 12 +- ...rminal-ime-xterm-preedit-cell-grid.test.ts | 18 +- 43 files changed, 4564 insertions(+), 4275 deletions(-) create mode 100644 config/scripts/linear-sdk-runtime.test.mjs create mode 100644 docs/site/.npmrc diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 69a487607b0..7830df9eacf 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -49,7 +49,7 @@ jobs: # v5 avoids the v6 bootstrap/shim regression when pinning pnpm 10. uses: pnpm/action-setup@v5 with: - version: 10.24.0 + version: 10.34.6 package_json_file: docs/site/package.json run_install: false @@ -224,7 +224,7 @@ jobs: # v5 avoids the v6 bootstrap/shim regression when pinning pnpm 10. uses: pnpm/action-setup@v5 with: - version: 10.24.0 + version: 10.34.6 package_json_file: docs/site/package.json run_install: false diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 2e6f173acc4..fed0c4c31d1 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -105,8 +105,8 @@ jobs: - name: Install relay integration dependencies working-directory: cloud run: | - npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts - npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build + npx --yes pnpm@10.34.6 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts + npx --yes pnpm@10.34.6 --filter '@orca-cloud/relay^...' build - name: Test relay integration contracts env: diff --git a/cloud/apps/push/package.json b/cloud/apps/push/package.json index b47964587ac..a4ba2462f5b 100644 --- a/cloud/apps/push/package.json +++ b/cloud/apps/push/package.json @@ -15,20 +15,20 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@hono/node-server": "^1.19.17", + "@hono/node-server": "^2.1.2", "@orca-cloud/postgres-schema": "workspace:*", "@orca-cloud/push-contract": "workspace:*", "google-auth-library": "^10.5.0", - "hono": "^4.13.7", + "hono": "^4.13.10", "pg": "^8.22.0", "pg-connection-string": "2.14.0", "tweetnacl": "^1.0.3", "zod": "^3.25.76" }, "devDependencies": { - "@types/node": "^24.10.0", + "@types/node": "^24.19.0", "@types/pg": "^8.20.0", - "tsx": "^4.21.0", + "tsx": "^4.23.15", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/apps/relay-fence-broker/package.json b/cloud/apps/relay-fence-broker/package.json index b14f162d77d..6e95ece0581 100644 --- a/cloud/apps/relay-fence-broker/package.json +++ b/cloud/apps/relay-fence-broker/package.json @@ -14,13 +14,13 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@hono/node-server": "^1.19.17", - "hono": "^4.13.7", + "@hono/node-server": "^2.1.2", + "hono": "^4.13.10", "zod": "^3.25.76" }, "devDependencies": { - "@types/node": "^24.10.0", - "tsx": "^4.21.0", + "@types/node": "^24.19.0", + "tsx": "^4.23.15", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/apps/relay-ops/package.json b/cloud/apps/relay-ops/package.json index 5865d6c441d..d3961198665 100644 --- a/cloud/apps/relay-ops/package.json +++ b/cloud/apps/relay-ops/package.json @@ -17,13 +17,13 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@hono/node-server": "^1.19.17", - "hono": "^4.13.7", + "@hono/node-server": "^2.1.2", + "hono": "^4.13.10", "zod": "^3.25.76" }, "devDependencies": { - "@types/node": "^24.10.0", - "tsx": "^4.21.0", + "@types/node": "^24.19.0", + "tsx": "^4.23.15", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/apps/relay/package.json b/cloud/apps/relay/package.json index e9624bc3080..0a85bc9343e 100644 --- a/cloud/apps/relay/package.json +++ b/cloud/apps/relay/package.json @@ -15,21 +15,21 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@hono/node-server": "^1.19.17", + "@hono/node-server": "^2.1.2", "@orca-cloud/postgres-schema": "workspace:*", "@orca-cloud/relay-contract": "workspace:*", - "hono": "^4.13.7", - "jose": "^6.1.3", + "hono": "^4.13.10", + "jose": "^6.2.12", "pg": "^8.22.0", "tweetnacl": "^1.0.3", - "ws": "^8.21.3", + "ws": "^8.22.0", "zod": "^3.25.76" }, "devDependencies": { - "@types/node": "^24.10.0", + "@types/node": "^24.19.0", "@types/pg": "^8.20.0", "@types/ws": "^8.18.1", - "tsx": "^4.21.0", + "tsx": "^4.23.15", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/apps/relay/src/regional-rehome-target-row-lock-postgres.test.ts b/cloud/apps/relay/src/regional-rehome-target-row-lock-postgres.test.ts index 314b758b17f..c61c6b3ff0f 100644 --- a/cloud/apps/relay/src/regional-rehome-target-row-lock-postgres.test.ts +++ b/cloud/apps/relay/src/regional-rehome-target-row-lock-postgres.test.ts @@ -32,7 +32,7 @@ const CELL_TABLES = [ // The target-row statement locks exactly these, held from it to COMMIT. const TARGET_LOCKED = ['target:relay_cells', 'target:relay_cell_admission'] -type Trip = { sql: string; lockable: Record } +type Trip = { sql: string; lockable: Record; probeMs: number } type DelayControl = StatementDelay & { beforeTrip: (sql: string) => Promise } @@ -159,11 +159,12 @@ describePostgres('PostgreSQL regional rehome target-row lock', () => { bystander: context.bystander.id } control.beforeTrip = async (sql) => { + const probeStartedAt = performance.now() const state: Record = {} for (const [role, cellId] of Object.entries(probed)) { for (const table of CELL_TABLES) state[`${role}:${table}`] = await lockable(table, cellId) } - trips.push({ sql, lockable: state }) + trips.push({ sql, lockable: state, probeMs: performance.now() - probeStartedAt }) } consumeRelayCellInventoryHold(delayed) control.enabled = true @@ -188,13 +189,22 @@ describePostgres('PostgreSQL regional rehome target-row lock', () => { ]) } const counts = consumeRelayCellInventoryHold(delayed) + const commitProbeMs = trips.at(-1)!.probeMs console.info( - JSON.stringify({ event: 'rehome_target_row_hold', trips: trips.length, ...counts }) + JSON.stringify({ + event: 'rehome_target_row_hold', + trips: trips.length, + commitProbeMs, + ...counts + }) ) expect(counts.rehomeTargetRowHolds).toBe(1) expect(counts.cellInventoryHoldMaxSite).toBe('rehome-target-row') expect(counts.rehomeTargetRowHoldMsMax).toBeGreaterThanOrEqual(STATEMENT_DELAY_MS) - expect(counts.rehomeTargetRowHoldMsMax).toBeLessThanOrEqual(2 * STATEMENT_DELAY_MS) + // The COMMIT observer probes run under the lock, but are absent in production. + expect(counts.rehomeTargetRowHoldMsMax - commitProbeMs).toBeLessThanOrEqual( + 2 * STATEMENT_DELAY_MS + ) expect(await reservedRequests(context.target.id)).toBe(context.targetReservedBefore + 2) }) diff --git a/cloud/package.json b/cloud/package.json index 1770e91a7b1..e2c02ba2416 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -2,7 +2,7 @@ "name": "orca-cloud", "private": true, "version": "0.0.0", - "packageManager": "pnpm@10.24.0", + "packageManager": "pnpm@10.34.6", "engines": { "node": ">=24 <27", "pnpm": ">=10" @@ -26,7 +26,7 @@ "typecheck": "pnpm -r typecheck" }, "devDependencies": { - "@types/node": "^24.10.0", + "@types/node": "^24.19.0", "tsx": "^4.23.15", "typescript": "^5.9.3", "vitest": "^4.1.11" diff --git a/cloud/packages/postgres-schema/package.json b/cloud/packages/postgres-schema/package.json index 05cd4bd7221..594c799a5b5 100644 --- a/cloud/packages/postgres-schema/package.json +++ b/cloud/packages/postgres-schema/package.json @@ -13,7 +13,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "devDependencies": { - "@types/node": "^24.10.0", + "@types/node": "^24.19.0", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/packages/push-contract/package.json b/cloud/packages/push-contract/package.json index 75698aa46f9..19f4941ba4a 100644 --- a/cloud/packages/push-contract/package.json +++ b/cloud/packages/push-contract/package.json @@ -16,7 +16,7 @@ "zod": "^3.25.76" }, "devDependencies": { - "@types/node": "^24.10.0", + "@types/node": "^24.19.0", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/packages/relay-contract/package.json b/cloud/packages/relay-contract/package.json index 976840da87d..5aa329a3d81 100644 --- a/cloud/packages/relay-contract/package.json +++ b/cloud/packages/relay-contract/package.json @@ -16,7 +16,7 @@ "zod": "^3.25.76" }, "devDependencies": { - "@types/node": "^24.10.0", + "@types/node": "^24.19.0", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/pnpm-lock.yaml b/cloud/pnpm-lock.yaml index 6042ee85cd4..c688889cb50 100644 --- a/cloud/pnpm-lock.yaml +++ b/cloud/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 tsx: specifier: ^4.23.15 version: 4.23.15 @@ -19,13 +19,13 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) apps/push: dependencies: '@hono/node-server': - specifier: ^1.19.17 - version: 1.19.17(hono@4.13.7) + specifier: ^2.1.2 + version: 2.1.2(hono@4.13.10) '@orca-cloud/postgres-schema': specifier: workspace:* version: link:../../packages/postgres-schema @@ -36,8 +36,8 @@ importers: specifier: ^10.5.0 version: 10.9.1 hono: - specifier: ^4.13.7 - version: 4.13.7 + specifier: ^4.13.10 + version: 4.13.10 pg: specifier: ^8.22.0 version: 8.22.0 @@ -52,26 +52,26 @@ importers: version: 3.25.76 devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 '@types/pg': specifier: ^8.20.0 version: 8.20.0 tsx: - specifier: ^4.21.0 - version: 4.22.4 + specifier: ^4.23.15 + version: 4.23.15 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) apps/relay: dependencies: '@hono/node-server': - specifier: ^1.19.17 - version: 1.19.17(hono@4.13.7) + specifier: ^2.1.2 + version: 2.1.2(hono@4.13.10) '@orca-cloud/postgres-schema': specifier: workspace:* version: link:../../packages/postgres-schema @@ -79,11 +79,11 @@ importers: specifier: workspace:* version: link:../../packages/relay-contract hono: - specifier: ^4.13.7 - version: 4.13.7 + specifier: ^4.13.10 + version: 4.13.10 jose: - specifier: ^6.1.3 - version: 6.2.3 + specifier: ^6.2.12 + version: 6.2.12 pg: specifier: ^8.22.0 version: 8.22.0 @@ -91,15 +91,15 @@ importers: specifier: ^1.0.3 version: 1.0.3 ws: - specifier: ^8.21.3 - version: 8.21.3 + specifier: ^8.22.0 + version: 8.22.0 zod: specifier: ^3.25.76 version: 3.25.76 devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 '@types/pg': specifier: ^8.20.0 version: 8.20.0 @@ -107,76 +107,76 @@ importers: specifier: ^8.18.1 version: 8.18.1 tsx: - specifier: ^4.21.0 - version: 4.22.4 + specifier: ^4.23.15 + version: 4.23.15 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) apps/relay-fence-broker: dependencies: '@hono/node-server': - specifier: ^1.19.17 - version: 1.19.17(hono@4.13.7) + specifier: ^2.1.2 + version: 2.1.2(hono@4.13.10) hono: - specifier: ^4.13.7 - version: 4.13.7 + specifier: ^4.13.10 + version: 4.13.10 zod: specifier: ^3.25.76 version: 3.25.76 devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 tsx: - specifier: ^4.21.0 - version: 4.22.4 + specifier: ^4.23.15 + version: 4.23.15 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) apps/relay-ops: dependencies: '@hono/node-server': - specifier: ^1.19.17 - version: 1.19.17(hono@4.13.7) + specifier: ^2.1.2 + version: 2.1.2(hono@4.13.10) hono: - specifier: ^4.13.7 - version: 4.13.7 + specifier: ^4.13.10 + version: 4.13.10 zod: specifier: ^3.25.76 version: 3.25.76 devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 tsx: - specifier: ^4.21.0 - version: 4.22.4 + specifier: ^4.23.15 + version: 4.23.15 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) packages/postgres-schema: devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) packages/push-contract: dependencies: @@ -185,14 +185,14 @@ importers: version: 3.25.76 devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) packages/relay-contract: dependencies: @@ -201,14 +201,14 @@ importers: version: 3.25.76 devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) packages: @@ -221,321 +221,165 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@hono/node-server@1.19.17': - resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.1.2': + resolution: {integrity: sha512-w0tqJ/ilmggKokOSvbp9y+F91Y4ROTyCTL/8EKj9B4qOhOZzNm/tyKElzxrz+9+4fJKQrBSaahIuCGRye5RJYg==} + engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -587,36 +431,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} @@ -659,8 +509,8 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/node@24.13.2': - resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + '@types/node@24.19.0': + resolution: {integrity: sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==} '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -744,11 +594,6 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -802,16 +647,16 @@ packages: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} - hono@4.13.7: - resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} + hono@4.13.10: + resolution: {integrity: sha512-dQuLsa5oO+47QVMVMaaD9cIv8ctmVtK1iRvwWngkfloFJMeFeuoUFDswIqZGxmGX3hrRzREgEArjkK9OgsQEhA==} engines: {node: '>=16.9.0'} https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - jose@6.2.3: - resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} @@ -857,24 +702,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.33.0: resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} @@ -1027,11 +876,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.22.4: - resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} - engines: {node: '>=18.0.0'} - hasBin: true - tsx@4.23.15: resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} engines: {node: '>=18.0.0'} @@ -1045,8 +889,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} @@ -1141,8 +985,8 @@ packages: engines: {node: '>=8'} hasBin: true - ws@8.21.3: - resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -1178,165 +1022,87 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.28.1': - optional: true - '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': - optional: true - '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': - optional: true - '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': - optional: true - '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': - optional: true - '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': - optional: true - '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': - optional: true - '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': - optional: true - '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': - optional: true - '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': - optional: true - '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': - optional: true - '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': - optional: true - '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': - optional: true - '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': - optional: true - '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': - optional: true - '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': - optional: true - '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': - optional: true - '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': - optional: true - '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': - optional: true - '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': - optional: true - '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': - optional: true - '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': - optional: true - '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': - optional: true - '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': - optional: true - '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': - optional: true - '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': - optional: true - '@esbuild/win32-x64@0.28.2': optional: true - '@hono/node-server@1.19.17(hono@4.13.7)': + '@hono/node-server@2.1.2(hono@4.13.10)': dependencies: - hono: 4.13.7 + hono: 4.13.10 '@jridgewell/sourcemap-codec@1.5.5': {} @@ -1416,19 +1182,19 @@ snapshots: '@types/estree@1.0.9': {} - '@types/node@24.13.2': + '@types/node@24.19.0': dependencies: - undici-types: 7.18.2 + undici-types: 7.24.6 '@types/pg@8.20.0': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.19.0 pg-protocol: 1.15.0 pg-types: 2.2.0 '@types/ws@8.18.1': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.19.0 '@vitest/expect@4.1.11': dependencies: @@ -1439,21 +1205,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4))': + '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4) - - '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15))': - dependencies: - '@vitest/spy': 4.1.11 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15) + vite: 8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15) '@vitest/pretty-format@4.1.11': dependencies: @@ -1507,35 +1265,6 @@ snapshots: es-module-lexer@2.1.0: {} - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -1618,7 +1347,7 @@ snapshots: google-logging-utils@1.1.3: {} - hono@4.13.7: {} + hono@4.13.10: {} https-proxy-agent@7.0.6: dependencies: @@ -1627,7 +1356,7 @@ snapshots: transitivePeerDependencies: - supports-color - jose@6.2.3: {} + jose@6.2.12: {} json-bigint@1.0.0: dependencies: @@ -1817,12 +1546,6 @@ snapshots: tslib@2.8.1: optional: true - tsx@4.22.4: - dependencies: - esbuild: 0.28.1 - optionalDependencies: - fsevents: 2.3.3 - tsx@4.23.15: dependencies: esbuild: 0.28.2 @@ -1833,9 +1556,9 @@ snapshots: typescript@5.9.3: {} - undici-types@7.18.2: {} + undici-types@7.24.6: {} - vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4): + vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -1843,28 +1566,15 @@ snapshots: rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 24.13.2 - esbuild: 0.28.2 - fsevents: 2.3.3 - tsx: 4.22.4 - - vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15): - dependencies: - lightningcss: 1.33.0 - picomatch: 4.0.7 - postcss: 8.5.28 - rolldown: 1.0.3 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 24.19.0 esbuild: 0.28.2 fsevents: 2.3.3 tsx: 4.23.15 - vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)): + vitest@4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) + '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -1881,37 +1591,10 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4) + vite: 8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.13.2 - transitivePeerDependencies: - - msw - - vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)): - dependencies: - '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) - '@vitest/pretty-format': 4.1.11 - '@vitest/runner': 4.1.11 - '@vitest/snapshot': 4.1.11 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.3 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 24.19.0 transitivePeerDependencies: - msw @@ -1922,7 +1605,7 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - ws@8.21.3: {} + ws@8.22.0: {} xtend@4.0.2: {} diff --git a/cloud/pnpm-workspace.yaml b/cloud/pnpm-workspace.yaml index cc61e60464c..97bf7cd8c2e 100644 --- a/cloud/pnpm-workspace.yaml +++ b/cloud/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - apps/* - packages/* +minimumReleaseAge: 4320 diff --git a/config/docker/headless-pairing/Dockerfile.build b/config/docker/headless-pairing/Dockerfile.build index 19f76290c75..0300d8a82d4 100644 --- a/config/docker/headless-pairing/Dockerfile.build +++ b/config/docker/headless-pairing/Dockerfile.build @@ -26,7 +26,7 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* RUN corepack enable \ - && corepack prepare pnpm@12.0.0 --activate \ + && corepack prepare pnpm@12.8.1 --activate \ && pnpm --version WORKDIR /workspace diff --git a/config/scripts/check-react-doctor-changed.mjs b/config/scripts/check-react-doctor-changed.mjs index 743a64eee04..92a62b461f1 100644 --- a/config/scripts/check-react-doctor-changed.mjs +++ b/config/scripts/check-react-doctor-changed.mjs @@ -26,7 +26,7 @@ const result = spawnSync( [ ...prefixArgs, 'dlx', - 'react-doctor@0.9.1', + 'react-doctor@0.9.14', '.', '--yes', '--scope', diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index c93b6a19d62..3d701b23944 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -8,9 +8,11 @@ import { basename, dirname, resolve } from 'node:path' import { ensureWindowsProcessTreeCommandLinePatch, inspectWindowsProcessTreeAddon, + nodeGypRebuildInvocation, stageWindowsProcessTreeNodeAddonApiHeaders, windowsProcessTreeAddonPath } from './windows-process-tree-gyp-rebuild.mjs' +import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' const require = createRequire(import.meta.url) const { assertNodePtyJobOwnership, nodePtyAddonPath } = require('./node-pty-job-ownership.cjs') @@ -400,33 +402,38 @@ function rebuildNodeRuntimeModules(moduleNames) { moduleDir = realpathSync(moduleDir) } console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`) - runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir }) + // pnpm exec inside an installed addon cannot discover the root build tool. + runNodeGyp( + nodeGypRebuildInvocation( + process.arch, + moduleDir, + process.env.npm_config_node_gyp || undefined + ) + ) if (moduleName === 'node-pty' && process.platform === 'win32') { runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')]) } } } -function runPnpm(args, { cwd = projectDir } = {}) { - // cmd.exe resolves both Corepack's pnpm.cmd and pnpm 12's native pnpm.exe. - const command = 'pnpm' +function runNodeGyp({ args, cwd }) { const env = - process.platform === 'linux' && args.includes('node-gyp') + process.platform === 'linux' ? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() } : process.env - const result = spawnSync(command, args, { + const result = runProcessSync({ + program: process.execPath, + args, cwd, + env, stdio: 'inherit', - shell: process.platform === 'win32', - env + timeoutMs: 300_000 }) - - if (result.error || result.status !== 0) { - console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`) - if (result.error) { - console.error(formatError(result.error)) - } - process.exit(result.status ?? 1) + if (result.code !== 0) { + console.error( + `[native-runtime] node-gyp rebuild failed in ${cwd}: ${describeProcessFailure(result)}` + ) + process.exit(result.code ?? 1) } } diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 47fe435edcc..4d6e022b185 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -1,7 +1,7 @@ import { spawnSync } from 'node:child_process' import { - chmodSync, copyFileSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -9,10 +9,18 @@ import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' +import { isAbsolute, join, parse } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' +import { + DEFAULT_MAX_OUTPUT_BYTES, + runProcessSync +} from '../../src/shared/child-process/run-process.ts' +import { resolveCliCommand } from '../../src/shared/node-cli-command-resolution.ts' +import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts' +import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' import { copyScriptWithLocalModules } from './script-module-dependencies.mjs' +import { nodeGypRebuildInvocation } from './windows-process-tree-gyp-rebuild.mjs' const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url)) // The import walk sees `from './x.mjs'` only, so the createRequire'd CJS @@ -30,23 +38,28 @@ describe('ensure-native-runtime', () => { const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') - const binDir = join(projectDir, 'bin') writeFakeNativeModules(projectDir) writeNodePtyPatchFile(projectDir) - writeFakePnpm(binDir) + writeFakeNodeGyp(projectDir) + const verboseOutputBytes = DEFAULT_MAX_OUTPUT_BYTES + 1024 * 1024 const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { cwd: projectDir, encoding: 'utf8', - env: envWithPrependedPath(binDir, { + maxBuffer: DEFAULT_MAX_OUTPUT_BYTES * 4, + env: envForNativeFixture(projectDir, { ORCA_NATIVE_TEST_LOG: logPath, - ORCA_NATIVE_TEST_MARKER: markerPath + ORCA_NATIVE_TEST_MARKER: markerPath, + ORCA_NATIVE_TEST_VERBOSE_OUTPUT_BYTES: String(verboseOutputBytes) }) }) expect(result.status, result.stderr).toBe(0) + expect(result.stdout.indexOf('node-gyp stdout complete\n')).toBe(verboseOutputBytes) + expect(/^x+$/.test(result.stdout.slice(0, verboseOutputBytes))).toBe(true) + expect(result.stderr).toContain('node-gyp stderr complete\n') const log = readFileSync(logPath, 'utf8') - expect(log).toContain('pnpm exec node-gyp rebuild\n') + expect(log).toContain(`node-gyp rebuild --arch=${process.arch}\n`) expect(log).toContain(join('node_modules', 'node-pty')) if (process.platform === 'linux') { expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m) @@ -69,15 +82,14 @@ describe('ensure-native-runtime', () => { const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') - const binDir = join(projectDir, 'bin') writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true }) writeNodePtyPatchFile(projectDir) - writeFakePnpm(binDir) + writeFakeNodeGyp(projectDir) const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { cwd: projectDir, encoding: 'utf8', - env: envWithPrependedPath(binDir, { + env: envForNativeFixture(projectDir, { ORCA_NATIVE_TEST_LOG: logPath, ORCA_NATIVE_TEST_MARKER: markerPath }) @@ -85,7 +97,9 @@ describe('ensure-native-runtime', () => { expect(result.status, result.stderr).toBe(0) const log = readFileSync(logPath, 'utf8') - expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2) + expect( + log.split('\n').filter((line) => line === `node-gyp rebuild --arch=${process.arch}`) + ).toHaveLength(2) expect(log).toContain(join('node_modules', 'node-pty')) expect(log).toContain(join('node_modules', '@orca', 'windows-registry')) } finally { @@ -103,15 +117,14 @@ describe('ensure-native-runtime', () => { const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') - const binDir = join(projectDir, 'bin') writeLoadableNativeModules(projectDir) writeNodePtyPatchFile(projectDir) - writeFakePnpm(binDir) + writeFakeNodeGyp(projectDir) const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { cwd: projectDir, encoding: 'utf8', - env: envWithPrependedPath(binDir, { + env: envForNativeFixture(projectDir, { ORCA_NATIVE_TEST_LOG: logPath, ORCA_NATIVE_TEST_MARKER: markerPath }) @@ -121,7 +134,7 @@ describe('ensure-native-runtime', () => { expect(result.stderr).toContain( 'Patched node-pty build artifacts are missing; rebuilding native deps.' ) - expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n') + expect(readFileSync(logPath, 'utf8')).toContain(`node-gyp rebuild --arch=${process.arch}\n`) } finally { rmSync(projectDir, { recursive: true, force: true }) } @@ -137,16 +150,15 @@ describe('ensure-native-runtime', () => { const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') - const binDir = join(projectDir, 'bin') writeLoadableNativeModules(projectDir) writeNodePtyPatchFile(projectDir) writePatchedNodePtyBuildArtifacts(projectDir) - writeFakePnpm(binDir) + writeFakeNodeGyp(projectDir) const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { cwd: projectDir, encoding: 'utf8', - env: envWithPrependedPath(binDir, { + env: envForNativeFixture(projectDir, { ORCA_NATIVE_TEST_LOG: logPath, ORCA_NATIVE_TEST_MARKER: markerPath }) @@ -154,7 +166,7 @@ describe('ensure-native-runtime', () => { expect(result.status, result.stderr).toBe(0) expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active") - expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n') + expect(readFileSync(logPath, 'utf8')).toContain(`node-gyp rebuild --arch=${process.arch}\n`) } finally { rmSync(projectDir, { recursive: true, force: true }) } @@ -170,16 +182,15 @@ describe('ensure-native-runtime', () => { const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') - const binDir = join(projectDir, 'bin') writeLoadableNativeModules(projectDir, { nativeDir: '../build/Release/' }) writeNodePtyPatchFile(projectDir) writePatchedNodePtyBuildArtifacts(projectDir) - writeFakePnpm(binDir) + writeFakeNodeGyp(projectDir) const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { cwd: projectDir, encoding: 'utf8', - env: envWithPrependedPath(binDir, { + env: envForNativeFixture(projectDir, { ORCA_NATIVE_TEST_LOG: logPath, ORCA_NATIVE_TEST_MARKER: markerPath }) @@ -187,12 +198,64 @@ describe('ensure-native-runtime', () => { expect(result.status, result.stderr).toBe(0) expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing') - expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild') + expect(readFileSync(logPath, 'utf8')).not.toContain('node-gyp rebuild') } finally { rmSync(projectDir, { recursive: true, force: true }) } } ) + it('finds the installed node-gyp entry from an addon without build tools on PATH', () => { + const { command, prefixArgs } = resolvePnpmCliInvocation() + const program = isAbsolute(command) ? command : resolveCliCommand(parse(command).name) + expect(isAbsolute(program), 'pnpm must be installed for the native rebuild contract').toBe(true) + const projectDir = mkTempProject() + try { + writeFakeNativeModules(projectDir) + const addonDir = join(projectDir, 'node_modules', 'node-pty') + const env = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + for (const key of Object.keys(env)) { + if (key.toLowerCase() === 'path') { + env[key] = '' + } + } + const oldInvocation = runProcessSync({ + program, + args: [ + ...prefixArgs, + '--config.verify-deps-before-run=false', + 'exec', + 'node-gyp', + '--version' + ], + cwd: addonDir, + env, + timeoutMs: 20_000 + }) + expect(oldInvocation.code).not.toBe(0) + expect(`${oldInvocation.stdout}\n${oldInvocation.stderr}`).toContain( + 'Command "node-gyp" not found' + ) + const { args, cwd } = nodeGypRebuildInvocation(process.arch, addonDir) + const installedInvocation = runProcessSync({ + program: process.execPath, + args: [args[0], '--version'], + cwd, + env, + timeoutMs: 20_000 + }) + expect( + installedInvocation.code, + `${installedInvocation.stdout}\n${installedInvocation.stderr}` + ).toBe(0) + const manifest = JSON.parse( + readFileSync(new URL('../../node_modules/node-gyp/package.json', import.meta.url), 'utf8') + ) + expect(installedInvocation.stdout.trim()).toBe(`v${manifest.version}`) + expect(existsSync(join(addonDir, 'pnpm-lock.yaml'))).toBe(false) + } finally { + removeTreeSync(projectDir) + } + }) }) function mkTempProject() { @@ -200,6 +263,10 @@ function mkTempProject() { // Walked, not listed: the script imports windows-process-tree-gyp-rebuild.mjs, and a fixture // missing it fails every case with a module-resolution error instead of the defect under test. copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts')) + writeFileSync( + join(projectDir, 'config', 'scripts', 'script-child-process.mjs'), + `export { describeProcessFailure, runProcessSync } from ${JSON.stringify(new URL('./script-child-process.mjs', import.meta.url).href)}\n` + ) for (const name of REQUIRED_CJS_SIBLINGS) { copyFileSync( fileURLToPath(new URL(`./${name}`, import.meta.url)), @@ -209,15 +276,11 @@ function mkTempProject() { return projectDir } -function envWithPrependedPath(binDir, extraEnv) { - const pathKey = - process.platform === 'win32' - ? (Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'Path') - : 'PATH' +function envForNativeFixture(projectDir, extraEnv) { return { ...process.env, ...extraEnv, - [pathKey]: `${binDir}${delimiter}${process.env[pathKey] ?? ''}` + npm_config_node_gyp: join(projectDir, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js') } } @@ -258,6 +321,11 @@ exports.loadNativeModule = function loadNativeModule(nativeName) { ` ) writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker }) + if (process.platform === 'win32') { + const buildDir = join(nodePtyDir, 'build', 'Release') + mkdirSync(buildDir, { recursive: true }) + writeFileSync(join(buildDir, 'conpty.node'), Buffer.from('msys-2.0.dll', 'utf16le')) + } } function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) { @@ -314,7 +382,10 @@ function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) { ) const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') mkdirSync(processTreeDir, { recursive: true }) - writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') + writeFileSync( + join(processTreeDir, 'index.js'), + 'exports.supportedProcessDataFlags = 4; exports.getProcessCreationTime = () => 1\n' + ) } function writeNodePtyPatchFile(projectDir) { @@ -338,33 +409,25 @@ function writePatchedNodePtyBuildArtifacts(projectDir) { } } -function writeFakePnpm(binDir) { - mkdirSync(binDir, { recursive: true }) - const shimPath = join(binDir, 'pnpm-shim.cjs') +function writeFakeNodeGyp(projectDir) { + const toolDir = join(projectDir, 'node_modules', 'node-gyp', 'bin') + mkdirSync(toolDir, { recursive: true }) writeFileSync( - shimPath, + join(toolDir, 'node-gyp.js'), ` -const { appendFileSync, writeFileSync } = require('node:fs') - -appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`) +const { appendFileSync, writeFileSync, writeSync } = require('node:fs') +appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-gyp \${process.argv.slice(2).join(' ')}\\n\`) appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`) -appendFileSync( - process.env.ORCA_NATIVE_TEST_LOG, - \`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\` -) -appendFileSync( - process.env.ORCA_NATIVE_TEST_LOG, - \`cxxflags=\${process.env.CXXFLAGS || ''}\\n\` -) +appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`) +if (process.env.ORCA_NATIVE_TEST_VERBOSE_OUTPUT_BYTES) { + const output = Buffer.alloc(Number(process.env.ORCA_NATIVE_TEST_VERBOSE_OUTPUT_BYTES), 'x') + for (let offset = 0; offset < output.length;) { + offset += writeSync(1, output.subarray(offset)) + } + writeSync(1, 'node-gyp stdout complete\\n') + writeSync(2, 'node-gyp stderr complete\\n') +} writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt') ` ) - - const posixPnpmPath = join(binDir, 'pnpm') - writeFileSync(posixPnpmPath, `#!/usr/bin/env node\nrequire(${JSON.stringify(shimPath)})\n`) - chmodSync(posixPnpmPath, 0o755) - writeFileSync( - join(binDir, 'pnpm.cmd'), - `@echo off\r\n"${process.execPath}" "%~dp0\\pnpm-shim.cjs" %*\r\n` - ) } diff --git a/config/scripts/linear-sdk-runtime.test.mjs b/config/scripts/linear-sdk-runtime.test.mjs new file mode 100644 index 00000000000..896b8c7b7d5 --- /dev/null +++ b/config/scripts/linear-sdk-runtime.test.mjs @@ -0,0 +1,78 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { build } from 'vite' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { electronViteConfig } from '../../electron.vite.config' +import { runProcess } from '../../src/shared/child-process/run-process' + +const projectDir = resolve(import.meta.dirname, '../..') +const require = createRequire(import.meta.url) +let outputDir + +const smokeScript = ` + const assert = require('node:assert/strict') + const { createRequire } = require('node:module') + const loaderPath = process.argv[1] + const requireFromLoader = createRequire(loaderPath) + const sdkEntry = requireFromLoader.resolve('@linear/sdk') + const { loadLinearSdk } = require(loaderPath) + assert.equal(require.cache[sdkEntry], undefined, 'SDK must remain lazy') + + const sdk = loadLinearSdk() + assert.equal(sdk, requireFromLoader('@linear/sdk')) + assert.equal(loadLinearSdk(), sdk, 'repeat loads must reuse the SDK') + const client = new sdk.LinearClient({ apiKey: 'orca-offline-smoke-test' }) + assert.equal(typeof client.issues, 'function') + assert.equal(typeof client.teams, 'function') + assert.ok(sdk.AuthenticationLinearError.prototype instanceof Error) + console.log('Linear SDK loaded') +` + +beforeAll(async () => { + // Keep normal dependency resolution while executing outside Vitest's module loader. + outputDir = await mkdtemp(join(projectDir, 'node_modules', 'orca-linear-sdk-runtime-')) + const mainBuild = electronViteConfig.main.build + await build({ + configFile: false, + publicDir: false, + logLevel: 'silent', + build: { + ssr: true, + minify: mainBuild.minify, + outDir: outputDir, + rollupOptions: { + external: mainBuild.rollupOptions.external, + input: join(projectDir, 'src/main/linear/linear-sdk.ts'), + output: { format: 'cjs', entryFileNames: 'linear-sdk.cjs' } + } + } + }) +}) + +afterAll(async () => { + if (outputDir) { + await rm(outputDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +}) + +describe('resolved Linear SDK through the production CommonJS loader', () => { + it.each([ + { name: 'Node', program: process.execPath }, + { name: 'Electron', program: require('electron') } + ])( + 'loads and constructs the real SDK under $name without network access', + async ({ program }) => { + const result = await runProcess({ + program, + args: ['-e', smokeScript, join(outputDir, 'linear-sdk.cjs')], + cwd: projectDir, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ELECTRON_RUN_AS_NODE: '1' }, + timeoutMs: 20000 + }) + expect(result.code, result.stderr).toBe(0) + expect(result.timedOut).toBe(false) + expect(result.stdout.trim()).toBe('Linear SDK loaded') + } + ) +}) diff --git a/config/scripts/patched-dependencies-frozen-install.test.mjs b/config/scripts/patched-dependencies-frozen-install.test.mjs index 89f98ef074b..53061c1dbd7 100644 --- a/config/scripts/patched-dependencies-frozen-install.test.mjs +++ b/config/scripts/patched-dependencies-frozen-install.test.mjs @@ -61,6 +61,11 @@ describe('patched dependencies', () => { for (const file of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']) { copyFileSync(join(PROJECT_DIR, file), join(scratch, file)) } + mkdirSync(join(scratch, 'native', 'windows-registry'), { recursive: true }) + copyFileSync( + join(PROJECT_DIR, 'native', 'windows-registry', 'package.json'), + join(scratch, 'native', 'windows-registry', 'package.json') + ) mkdirSync(join(scratch, 'config'), { recursive: true }) cpSync(join(PROJECT_DIR, 'config', 'patches'), join(scratch, 'config', 'patches'), { recursive: true diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 9f841ffb1be..611dd974b79 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -285,7 +285,7 @@ describe('PR workflow parallelism', () => { expect(pnpmIndex).toBeLessThan(nodeIndex) expect(pnpmIndex).toBeLessThan(requestedNodeIndex) const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1] - expect(packageManagerVersion).toBe('12.0.0') + expect(packageManagerVersion).toBe('12.8.1') expect(steps[pnpmIndex].uses).toBe('pnpm/setup@v2') expect(steps[pnpmIndex].with.version).toBeUndefined() expect(steps[pnpmIndex].with.install).toBe(false) diff --git a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs index 80263ec4b65..a4a267b45ce 100644 --- a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs +++ b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs @@ -29,7 +29,6 @@ const WINDOWS_SHIM_SPAWN_ALLOWLIST = [ 'config/scripts/dev-cli-terminal-wrapper.mjs', 'config/scripts/dev-cli-terminal-wrapper.test.mjs', 'config/scripts/electron-builder-config.test.mjs', - 'config/scripts/ensure-native-runtime.test.mjs', 'config/scripts/live-remote-freeze-rpc.mjs', 'config/scripts/pty-transcript-secret-scan.test.mjs', 'config/scripts/remote-agent-session-authority-repro.mjs', diff --git a/config/scripts/windows-process-tree-gyp-rebuild.mjs b/config/scripts/windows-process-tree-gyp-rebuild.mjs index ba3c699a7a6..c64df18fa89 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.mjs @@ -54,13 +54,13 @@ export const WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS = [ 'napi-inl.deprecated.h' ] -export function nodeGypRebuildInvocation(arch, packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR) { +export function nodeGypRebuildInvocation( + arch, + packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR, + nodeGypEntry = join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js') +) { return { - args: [ - join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'), - 'rebuild', - `--arch=${arch}` - ], + args: [nodeGypEntry, 'rebuild', `--arch=${arch}`], cwd: realpathSync(packageDir) } } diff --git a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs index a33506116f1..73756af1659 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs @@ -46,6 +46,14 @@ describe('windows-process-tree node-gyp rebuild', () => { expect(args).toContain('--arch=arm64') }) + it('preserves an external node-gyp entry and the physical addon cwd', () => { + const entry = join(tmpdir(), 'external-node-gyp', 'bin', 'node-gyp.js') + expect(nodeGypRebuildInvocation('arm64', import.meta.dirname, entry)).toEqual({ + args: [entry, 'rebuild', '--arch=arm64'], + cwd: realpathSync(import.meta.dirname) + }) + }) + it('copies node-addon-api headers into the patched include dir', () => { const packageDir = mkdtempSync(join(tmpdir(), 'orca-windows-process-tree-headers-')) try { diff --git a/docs/site/.gitignore b/docs/site/.gitignore index 104c6398aca..61917c0abcb 100644 --- a/docs/site/.gitignore +++ b/docs/site/.gitignore @@ -8,4 +8,4 @@ next-env.d.ts # Keep local deployment credentials out of the publication boundary. .env .env.* -.npmrc +# Project .npmrc contains package policy; keep credentials in user-level npm config. diff --git a/docs/site/.npmrc b/docs/site/.npmrc new file mode 100644 index 00000000000..d7913f44df7 --- /dev/null +++ b/docs/site/.npmrc @@ -0,0 +1 @@ +minimum-release-age=4320 diff --git a/docs/site/package.json b/docs/site/package.json index 89a977c3d40..010405f418e 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -18,21 +18,21 @@ "fumadocs-mdx": "^14.3.1", "fumadocs-ui": "^16.8.4", "lucide-react": "^1.6.0", - "next": "16.3.4", - "react": "19.2.4", - "react-dom": "19.2.4", - "tailwind-merge": "^3.5.0", + "next": "16.3.6", + "react": "19.2.8", + "react-dom": "19.2.8", + "tailwind-merge": "^3.7.0", "tw-animate-css": "^1.4.0", "zod": "^4" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/mdx": "^2.0.13", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", + "@types/node": "^22.20.4", + "@types/react": "~19.2.18", + "@types/react-dom": "~19.2.7", "eslint": "^9", - "eslint-config-next": "16.3.4", + "eslint-config-next": "16.3.6", "tailwindcss": "^4", "typescript": "^5", "vercel": "59.11.1" @@ -40,7 +40,7 @@ "engines": { "node": "22.x" }, - "packageManager": "pnpm@10.24.0", + "packageManager": "pnpm@10.34.6", "pnpm": { "onlyBuiltDependencies": [ "esbuild", @@ -50,13 +50,21 @@ "overrides": { "@vercel/fun>tar": "7.5.22", "@vercel/fun>@tootallnate/once": "2.0.1", - "@vercel/node>undici": "6.28.0", + "@vercel/node>undici": "6.28.1", "@vercel/python-analysis>js-yaml": "4.3.2", "@vercel/python-analysis>minimatch": "10.2.6", "vercel>smol-toml": "1.8.0", - "vercel>undici": "6.28.0", + "vercel>undici": "6.28.1", "@vercel/python-analysis>smol-toml": "1.8.0", - "@vercel/rust>smol-toml": "1.8.0" + "@vercel/rust>smol-toml": "1.8.0", + "@vercel/sandbox>undici": "7.29.1", + "@vercel/static-config>ajv": "8.18.0", + "@vercel/backends>path-to-regexp": "8.4.0", + "@vercel/fun>path-to-regexp": "8.4.0", + "brace-expansion@<2": "1.1.21", + "brace-expansion@>=4 <5.0.12": "5.0.12", + "@vercel/express>path-to-regexp": "8.4.0", + "@vercel/hono>path-to-regexp": "8.4.0" } } } diff --git a/docs/site/pnpm-lock.yaml b/docs/site/pnpm-lock.yaml index 0d496ad7c6c..c69df84feae 100644 --- a/docs/site/pnpm-lock.yaml +++ b/docs/site/pnpm-lock.yaml @@ -7,13 +7,21 @@ settings: overrides: '@vercel/fun>tar': 7.5.22 '@vercel/fun>@tootallnate/once': 2.0.1 - '@vercel/node>undici': 6.28.0 + '@vercel/node>undici': 6.28.1 '@vercel/python-analysis>js-yaml': 4.3.2 '@vercel/python-analysis>minimatch': 10.2.6 vercel>smol-toml: 1.8.0 - vercel>undici: 6.28.0 + vercel>undici: 6.28.1 '@vercel/python-analysis>smol-toml': 1.8.0 '@vercel/rust>smol-toml': 1.8.0 + '@vercel/sandbox>undici': 7.29.1 + '@vercel/static-config>ajv': 8.18.0 + '@vercel/backends>path-to-regexp': 8.4.0 + '@vercel/fun>path-to-regexp': 8.4.0 + brace-expansion@<2: 1.1.21 + brace-expansion@>=4 <5.0.12: 5.0.12 + '@vercel/express>path-to-regexp': 8.4.0 + '@vercel/hono>path-to-regexp': 8.4.0 importers: @@ -24,28 +32,28 @@ importers: version: 2.1.1 fumadocs-core: specifier: ^16.8.4 - version: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) + version: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) fumadocs-mdx: specifier: ^14.3.1 - version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) fumadocs-ui: specifier: ^16.8.4 - version: 16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3) + version: 16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) lucide-react: specifier: ^1.6.0 - version: 1.26.0(react@19.2.4) + version: 1.26.0(react@19.2.8) next: - specifier: 16.3.4 - version: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.3.6 + version: 16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: - specifier: 19.2.4 - version: 19.2.4 + specifier: 19.2.8 + version: 19.2.8 react-dom: - specifier: 19.2.4 - version: 19.2.4(react@19.2.4) + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) tailwind-merge: - specifier: ^3.5.0 - version: 3.6.0 + specifier: ^3.7.0 + version: 3.7.0 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -60,20 +68,20 @@ importers: specifier: ^2.0.13 version: 2.0.14 '@types/node': - specifier: ^20 - version: 20.19.43 + specifier: ^22.20.4 + version: 22.20.4 '@types/react': - specifier: ^19 - version: 19.2.17 + specifier: ~19.2.18 + version: 19.2.18 '@types/react-dom': - specifier: ^19 - version: 19.2.3(@types/react@19.2.17) + specifier: ~19.2.7 + version: 19.2.7(@types/react@19.2.18) eslint: specifier: ^9 version: 9.39.5(jiti@2.7.0) eslint-config-next: - specifier: 16.3.4 - version: 16.3.4(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + specifier: 16.3.6 + version: 16.3.6(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) tailwindcss: specifier: ^4 version: 4.3.3 @@ -102,8 +110,8 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} '@babel/helper-compilation-targets@7.29.7': @@ -140,8 +148,8 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} engines: {node: '>=6.0.0'} hasBin: true @@ -149,12 +157,12 @@ packages: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} '@bytecodealliance/preview2-shim@0.17.6': @@ -605,144 +613,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] + libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] + libc: [musl] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [glibc] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] + libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] + libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] + libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] + libc: [glibc] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [musl] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -804,30 +828,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/keyring-linux-arm64-musl@1.2.0': resolution: {integrity: sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/keyring-linux-riscv64-gnu@1.2.0': resolution: {integrity: sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/keyring-linux-x64-gnu@1.2.0': resolution: {integrity: sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/keyring-linux-x64-musl@1.2.0': resolution: {integrity: sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/keyring-win32-arm64-msvc@1.2.0': resolution: {integrity: sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ==} @@ -857,56 +886,67 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/env@16.3.4': - resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - '@next/eslint-plugin-next@16.3.4': - resolution: {integrity: sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA==} + '@next/env@16.3.6': + resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==} - '@next/swc-darwin-arm64@16.3.4': - resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} + '@next/eslint-plugin-next@16.3.6': + resolution: {integrity: sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng==} + + '@next/swc-darwin-arm64@16.3.6': + resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.4': - resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} + '@next/swc-darwin-x64@16.3.6': + resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.4': - resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} + '@next/swc-linux-arm64-gnu@16.3.6': + resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.4': - resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} + '@next/swc-linux-arm64-musl@16.3.6': + resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] - '@next/swc-linux-x64-gnu@16.3.4': - resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} + '@next/swc-linux-x64-gnu@16.3.6': + resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] - '@next/swc-linux-x64-musl@16.3.4': - resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} + '@next/swc-linux-x64-musl@16.3.6': + resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.4': - resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} + '@next/swc-win32-arm64-msvc@16.3.6': + resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.4': - resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} + '@next/swc-win32-x64-msvc@16.3.6': + resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -978,48 +1018,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.121.0': resolution: {integrity: sha512-qT663J/W8yQFw3dtscbEi9LKJevr20V7uWs2MPGTnvNZ3rm8anhhE16gXGpxDOHeg9raySaSHKhd4IGa3YZvuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.121.0': resolution: {integrity: sha512-mYNe4NhVvDBbPkAP8JaVS8lC1dsoJZWH5WCjpw5E+sjhk1R08wt3NnXYUzum7tIiWPfgQxbCMcoxgeemFASbRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.121.0': resolution: {integrity: sha512-+QiFoGxhAbaI/amqX567784cDyyuZIpinBrJNxUzb+/L2aBRX67mN6Jv40pqduHf15yYByI+K5gUEygCuv0z9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.121.0': resolution: {integrity: sha512-9ykEgyTa5JD/Uhv2sttbKnCfl2PieUfOjyxJC/oDL2UO0qtXOtjPLl7H8Kaj5G7p3hIvFgu3YWvAxvE0sqY+hQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.121.0': resolution: {integrity: sha512-DB1EW5VHZdc1lIRjOI3bW/wV6R6y0xlfvdVrqj6kKi7Ayu2U3UqUBdq9KviVkcUGd5Oq+dROqvUEEFRXGAM7EQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.121.0': resolution: {integrity: sha512-s4lfobX9p4kPTclvMiH3gcQUd88VlnkMTF6n2MTMDAyX5FPNRhhRSFZK05Ykhf8Zy5NibV4PbGR6DnK7FGNN6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.121.0': resolution: {integrity: sha512-P9KlyTpuBuMi3NRGpJO8MicuGZfOoqZVRP1WjOecwx8yk4L/+mrCRNc5egSi0byhuReblBF2oVoDSMgV9Bj4Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.121.0': resolution: {integrity: sha512-R+4jrWOfF2OAPPhj3Eb3U5CaKNAH9/btMveMULIrcNW/hjfysFQlF8wE0GaVBr81dWz8JLgQlsxwctoL78JwXw==} @@ -1103,48 +1151,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-arm64-musl@0.111.0': resolution: {integrity: sha512-ARyfcMCIxVLDgLf6FQ8Oo1/TFySpnquV+vuSb4SFQZfYDqgMklzwv0NYXxWD0aB6enElyMDs6pQJBzusEKCkOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-transform/binding-linux-ppc64-gnu@0.111.0': resolution: {integrity: sha512-PKpVRrSvBNK3tv9vwxn7Fay+QWZmprPGlEqJcseBJllQc5mFMD4Q/w44chu5iR9ZLsDeSHzmNWrgMLo4J0sP2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-riscv64-gnu@0.111.0': resolution: {integrity: sha512-9bUml6rMgk+8GF5rvNMweFspkzSiCjqpV6HduwiUyexqfGKrmjq9IZOxxvnzkE2RGdQzP507NNDoVNYIoGQYuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-riscv64-musl@0.111.0': resolution: {integrity: sha512-tzGCohGxaeH6KRJjfYZd4mHCoGjCai6N+zZi1Oj+tSDMAAdyvs1dRzYb8PNUGnybCg3Te4M0jLPzWZaSmnKraQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-transform/binding-linux-s390x-gnu@0.111.0': resolution: {integrity: sha512-sRG1KIfZ0ML9ToEygm5aM/5GJeBA05uHlgW3M0Rx/DNWMJhuahLmqWuB02aWSmijndLfEKXLLXIWhvWupRG8lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-x64-gnu@0.111.0': resolution: {integrity: sha512-T0Kmvk+OdlUdABdXlDIf3MQReMzFfC75NEI9x8jxy5pKooACEFg0k0V8gyR3gq4DzbDCfucqFQDWNvSgIopAbQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-x64-musl@0.111.0': resolution: {integrity: sha512-EgoutsP3YfqzN8a9vpc9+XLr0bmBl0dA3uOMiP77+exATCPxJBkJErGmQkqk6RtTp5XqX6q6mB45qWQyKk6+pA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-transform/binding-openharmony-arm64@0.111.0': resolution: {integrity: sha512-d8J+ejc0j5WODbVwR/QxFaI65YMwvG0W53vcVCHwa6ja1QI5lpe7sislrefG2EFYgnY47voMRzlXab5d4gEcDw==} @@ -1570,24 +1626,28 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.1': resolution: {integrity: sha512-UvApLEGholmxw/HIwmUnLq3CwdydbhaHHllvWiCTNbyGom7wTwOtz5OAQbAKZYyiEOeIXZNPkM7nA4Dtng7CLw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.1': resolution: {integrity: sha512-uVctNgZHiGnJx5Fij7wHLhgw4uyZBVi6mykeWKOqE7bVy9Hcxn0fM/IuqdMwk6hXlaf9fFShDTFz2+YejP+x0A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.1': resolution: {integrity: sha512-T6Eg0xWwcxd/MzBcuv4Z37YVbUbJxy5cMNnbIt/Yr99wFwli30O4BPlY8hKeGyn6lWNtU0QioBS46lVzDN38bg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.1': resolution: {integrity: sha512-PuGZVS2xNJyLADeh2F04b+Cz4NwvpglbtWACgrDOa5YDTEHKwmiTDjoD5eZ9/ptXtcpeFrMqD2H4Zn33KAh1Eg==} @@ -1705,24 +1765,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.3': resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.3': resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.3': resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.3': resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} @@ -1765,6 +1829,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -1795,16 +1862,16 @@ packages: '@types/node@20.11.0': resolution: {integrity: sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==} - '@types/node@20.19.43': - resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/node@22.20.4': + resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1812,63 +1879,63 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@typescript-eslint/eslint-plugin@8.65.0': - resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + '@typescript-eslint/eslint-plugin@8.71.0': + resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.65.0 + '@typescript-eslint/parser': ^8.71.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.65.0': - resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + '@typescript-eslint/parser@8.71.0': + resolution: {integrity: sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.65.0': - resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + '@typescript-eslint/project-service@8.71.0': + resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.65.0': - resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + '@typescript-eslint/scope-manager@8.71.0': + resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.65.0': - resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + '@typescript-eslint/tsconfig-utils@8.71.0': + resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.65.0': - resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + '@typescript-eslint/type-utils@8.71.0': + resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.65.0': - resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + '@typescript-eslint/types@8.71.0': + resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.65.0': - resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + '@typescript-eslint/typescript-estree@8.71.0': + resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.65.0': - resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + '@typescript-eslint/utils@8.71.0': + resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.65.0': - resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + '@typescript-eslint/visitor-keys@8.71.0': + resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.3': @@ -1913,51 +1980,61 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -2191,8 +2268,8 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ajv@8.6.3: - resolution: {integrity: sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} @@ -2219,8 +2296,8 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + array-includes@3.2.0: + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==} engines: {node: '>= 0.4'} array.prototype.findlast@1.2.5: @@ -2279,8 +2356,8 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axe-core@4.12.1: - resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} engines: {node: '>=4'} axobject-query@4.1.0: @@ -2313,27 +2390,27 @@ packages: bare-abort-controller: optional: true - baseline-browser-mapping@2.11.1: - resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} + baseline-browser-mapping@2.11.26: + resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==} engines: {node: '>=6.0.0'} hasBin: true bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - brace-expansion@1.1.18: - resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} - brace-expansion@5.0.9: - resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.7: - resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + browserslist@4.29.2: + resolution: {integrity: sha512-/u9r8k8ue4ZhHCw9ovDtltpWdXeXzjhdxCGj6vm1RGLPXb8lu33EMdoCHF5Sx8oYBS+Q/FYYNmAqlHncxR3RmA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2360,8 +2437,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001806: - resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + caniuse-lite@1.0.30001813: + resolution: {integrity: sha512-zfjJo4rM0+fUomGDBW/xcDjhIwz/210DGvip2MAMDZ8KHcRPnOHmEgHPZP0UHlZoxr8fYKVJOqcORhYQcG4FKQ==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2545,8 +2622,8 @@ packages: engines: {node: '>=16'} hasBin: true - electron-to-chromium@1.5.396: - resolution: {integrity: sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==} + electron-to-chromium@1.5.440: + resolution: {integrity: sha512-SghDzqdJokdz8zP8YNlvS74+CwLRoUPDGvP/gFA+HrKVw+pOshUAgx8pXR37xl/u16zGae746rFNWvZ/22kU2Q==} emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -2635,8 +2712,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-config-next@16.3.4: - resolution: {integrity: sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA==} + eslint-config-next@16.3.6: + resolution: {integrity: sha512-1Upt3U7BDwU+ilpe2byZjAfts9oNq4d4fv/zXEvs8/4yS+cwOQW/WCxUNy8gCDquX67SzeehDvKblVC6ZBMocQ==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -2823,6 +2900,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3051,6 +3131,9 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -3172,8 +3255,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.6: - resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + ignore@7.0.10: + resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} engines: {node: '>= 4'} import-fresh@3.3.1: @@ -3227,8 +3310,8 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.16.2: - resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + is-core-module@2.17.0: + resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} engines: {node: '>= 0.4'} is-data-view@1.0.2: @@ -3455,24 +3538,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -3783,6 +3870,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -3797,8 +3889,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.3.4: - resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} + next@16.3.6: + resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -3853,8 +3945,8 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true - node-releases@2.0.51: - resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + node-releases@2.0.57: + resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} engines: {node: '>=18'} nopt@8.1.0: @@ -3992,12 +4084,8 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - path-to-regexp@8.2.0: - resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} - engines: {node: '>=16'} - - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.0: + resolution: {integrity: sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==} pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -4059,10 +4147,10 @@ packages: resolution: {integrity: sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==} engines: {node: '>= 0.8'} - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: - react: ^19.2.4 + react: ^19.2.8 react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -4097,8 +4185,8 @@ packages: '@types/react': optional: true - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} readdirp@4.1.2: @@ -4258,8 +4346,8 @@ packages: setprototypeof@1.1.1: resolution: {integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==} - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -4350,8 +4438,8 @@ packages: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} - string.prototype.matchall@4.0.12: - resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + string.prototype.matchall@4.1.0: + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==} engines: {node: '>= 0.4'} string.prototype.repeat@1.0.0: @@ -4411,8 +4499,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - tailwind-merge@3.6.0: - resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -4509,16 +4597,16 @@ packages: resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} engines: {node: '>= 0.4'} - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + typed-array-byte-offset@1.0.5: + resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==} engines: {node: '>= 0.4'} typed-array-length@1.0.8: resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} - typescript-eslint@8.65.0: - resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + typescript-eslint@8.71.0: + resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4542,12 +4630,12 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@6.28.0: - resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + undici@6.28.1: + resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} engines: {node: '>=18.17'} - undici@7.29.0: - resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} engines: {node: '>=20.18.1'} unified@11.0.5: @@ -4585,8 +4673,8 @@ packages: unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -4656,8 +4744,8 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} - which-typed-array@1.1.22: - resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + which-typed-array@1.1.24: + resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==} engines: {node: '>= 0.4'} which@2.0.2: @@ -4755,14 +4843,14 @@ snapshots: '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.9 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -4772,10 +4860,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.7': + '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -4784,7 +4872,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.7 + browserslist: 4.29.2 lru-cache: 5.1.1 semver: 6.3.1 @@ -4792,8 +4880,8 @@ snapshots: '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -4802,7 +4890,7 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color @@ -4815,31 +4903,31 @@ snapshots: '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 - '@babel/parser@7.29.7': + '@babel/parser@7.29.9': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.8': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.9 '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.29.7': + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 @@ -5095,20 +5183,20 @@ snapshots: '@floating-ui/core': 1.8.0 '@floating-ui/utils': 0.2.12 - '@floating-ui/react-dom@2.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@floating-ui/dom': 1.8.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) '@floating-ui/utils@0.2.12': {} - '@fuma-translate/react@1.0.2(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@fuma-translate/react@1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 '@fumadocs/tailwind@0.1.1(tailwindcss@4.3.3)': optionalDependencies: @@ -5133,108 +5221,108 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-ia32@0.35.4': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.35.5': optional: true '@isaacs/fs-minipass@4.0.1': @@ -5354,13 +5442,6 @@ snapshots: '@napi-rs/keyring-win32-ia32-msvc': 1.2.0 '@napi-rs/keyring-win32-x64-msvc': 1.2.0 - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.3 - optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: '@emnapi/core': 1.10.0 @@ -5368,37 +5449,44 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@16.3.4': {} + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.4 + optional: true - '@next/eslint-plugin-next@16.3.4(eslint@9.39.5(jiti@2.7.0))': + '@next/env@16.3.6': {} + + '@next/eslint-plugin-next@16.3.6(eslint@9.39.5(jiti@2.7.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) fast-glob: 3.3.1 transitivePeerDependencies: - eslint - '@next/swc-darwin-arm64@16.3.4': + '@next/swc-darwin-arm64@16.3.6': optional: true - '@next/swc-darwin-x64@16.3.4': + '@next/swc-darwin-x64@16.3.6': optional: true - '@next/swc-linux-arm64-gnu@16.3.4': + '@next/swc-linux-arm64-gnu@16.3.6': optional: true - '@next/swc-linux-arm64-musl@16.3.4': + '@next/swc-linux-arm64-musl@16.3.6': optional: true - '@next/swc-linux-x64-gnu@16.3.4': + '@next/swc-linux-x64-gnu@16.3.6': optional: true - '@next/swc-linux-x64-musl@16.3.4': + '@next/swc-linux-x64-musl@16.3.6': optional: true - '@next/swc-win32-arm64-msvc@16.3.4': + '@next/swc-win32-arm64-msvc@16.3.6': optional: true - '@next/swc-win32-x64-msvc@16.3.4': + '@next/swc-win32-x64-msvc@16.3.6': optional: true '@nodelib/fs.scandir@2.1.5': @@ -5555,352 +5643,352 @@ snapshots: '@radix-ui/primitive@1.1.7': {} - '@radix-ui/react-accordion@1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-accordion@1.2.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collapsible': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-collapsible': 1.1.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-arrow@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-arrow@1.1.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-collapsible@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-collapsible@1.1.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-collection@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-collection@1.1.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-compose-refs@1.1.4(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-compose-refs@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-context@1.2.1(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-context@1.2.1(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-dialog@1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-dialog@1.1.21(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-direction@1.1.3(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-direction@1.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-dismissable-layer@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-dismissable-layer@1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-focus-guards@1.1.5(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-focus-guards@1.1.5(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-focus-scope@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-focus-scope@1.1.14(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-id@1.1.3(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-id@1.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-navigation-menu@1.2.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-navigation-menu@1.2.20(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-popover@1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-popover@1.1.21(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-popper': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-popper': 1.3.5(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-popper@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-popper@1.3.5(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@floating-ui/react-dom': 2.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-arrow': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-rect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.3(@types/react@19.2.17)(react@19.2.4) + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-arrow': 1.1.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-rect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.3(@types/react@19.2.18)(react@19.2.8) '@radix-ui/rect': 1.1.3 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-portal@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-portal@1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-presence@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-presence@1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-primitive@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-primitive@2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-roving-focus@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-roving-focus@1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.2(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-scroll-area@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-scroll-area@1.2.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/number': 1.1.3 '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-slot@1.3.1(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-slot@1.3.1(@types/react@19.2.18)(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-tabs@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-tabs@1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-use-callback-ref@1.1.3(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-callback-ref@1.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-controllable-state@1.2.5(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-controllable-state@1.2.5(@types/react@19.2.18)(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-effect-event@0.0.4(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-effect-event@0.0.4(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-is-hydrated@0.1.2(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-is-hydrated@0.1.2(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-layout-effect@1.1.3(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-layout-effect@1.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-previous@1.1.3(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-previous@1.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-rect@1.1.3(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-rect@1.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: '@radix-ui/rect': 1.1.3 - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-size@1.1.3(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-size@1.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-visually-hidden@1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-visually-hidden@1.2.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) '@radix-ui/rect@1.1.3': {} @@ -6091,6 +6179,11 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -6121,15 +6214,15 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@20.19.43': + '@types/node@22.20.4': dependencies: undici-types: 6.21.0 - '@types/react-dom@19.2.3(@types/react@19.2.17)': + '@types/react-dom@19.2.7(@types/react@19.2.18)': dependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@types/react@19.2.17': + '@types/react@19.2.18': dependencies: csstype: 3.2.3 @@ -6137,57 +6230,57 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/type-utils': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.0 eslint: 9.39.5(jiti@2.7.0) - ignore: 7.0.6 + ignore: 7.0.10 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.0 debug: 4.4.3 eslint: 9.39.5(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.71.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@5.9.3) + '@typescript-eslint/types': 8.71.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.65.0': + '@typescript-eslint/scope-manager@8.71.0': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 - '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.5(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) @@ -6195,14 +6288,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.65.0': {} + '@typescript-eslint/types@8.71.0': {} - '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.71.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/project-service': 8.71.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@5.9.3) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 debug: 4.4.3 minimatch: 10.2.6 semver: 7.8.5 @@ -6212,20 +6305,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/utils@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.65.0': + '@typescript-eslint/visitor-keys@8.71.0': dependencies: - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/types': 8.71.0 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.3': {} @@ -6288,7 +6381,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': @@ -6309,7 +6402,7 @@ snapshots: fs-extra: 11.1.0 get-port: 5.1.1 oxc-transform: 0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.0 resolve.exports: 2.0.3 rolldown: 1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) srvx: 0.11.16 @@ -6330,7 +6423,7 @@ snapshots: is-buffer: 2.0.5 is-node-process: 1.2.0 throttleit: 2.1.0 - undici: 6.28.0 + undici: 6.28.1 '@vercel/build-utils@14.9.0': dependencies: @@ -6393,7 +6486,7 @@ snapshots: '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) fs-extra: 11.1.0 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.0 ts-morph: 12.0.0 zod: 3.22.4 transitivePeerDependencies: @@ -6424,7 +6517,7 @@ snapshots: micro: 9.3.5-canary.3 ms: 2.1.1 node-fetch: 2.6.7 - path-to-regexp: 8.2.0 + path-to-regexp: 8.4.0 promisepipe: 3.0.0 semver: 7.5.4 stat-mode: 0.3.0 @@ -6474,7 +6567,7 @@ snapshots: '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) fs-extra: 11.1.0 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.0 ts-morph: 12.0.0 zod: 3.22.4 transitivePeerDependencies: @@ -6568,7 +6661,7 @@ snapshots: ts-morph: 12.0.0 tsx: 4.21.0 typescript: 5.9.3 - undici: 6.28.0 + undici: 6.28.1 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -6653,7 +6746,7 @@ snapshots: ms: 2.1.3 picocolors: 1.1.1 tar-stream: 3.1.7 - undici: 7.29.0 + undici: 7.29.1 xdg-app-paths: 5.1.0 zod: 4.4.3 transitivePeerDependencies: @@ -6673,7 +6766,7 @@ snapshots: '@vercel/static-config@3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: - ajv: 8.6.3 + ajv: 8.18.0 json-schema-to-ts: 1.6.4 oxc-parser: 0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) ts-morph: 12.0.0 @@ -6716,12 +6809,12 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.6.3: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - uri-js: 4.4.1 ansi-styles@4.3.0: dependencies: @@ -6744,14 +6837,14 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 - array-includes@3.1.9: + array-includes@3.2.0: dependencies: call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 es-abstract: 1.24.2 es-object-atoms: 1.1.2 - get-intrinsic: 1.3.0 + es-shim-unscopables: 1.1.0 is-string: 1.1.1 math-intrinsics: 1.1.0 @@ -6828,7 +6921,7 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axe-core@4.12.1: {} + axe-core@4.13.0: {} axobject-query@4.1.0: {} @@ -6842,18 +6935,18 @@ snapshots: bare-events@2.9.2: {} - baseline-browser-mapping@2.11.1: {} + baseline-browser-mapping@2.11.26: {} bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 - brace-expansion@1.1.18: + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.9: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -6861,13 +6954,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.7: + browserslist@4.29.2: dependencies: - baseline-browser-mapping: 2.11.1 - caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.396 - node-releases: 2.0.51 - update-browserslist-db: 1.2.3(browserslist@4.28.7) + baseline-browser-mapping: 2.11.26 + caniuse-lite: 1.0.30001813 + electron-to-chromium: 1.5.440 + node-releases: 2.0.57 + update-browserslist-db: 1.3.3(browserslist@4.29.2) buffer-crc32@0.2.13: {} @@ -6892,7 +6985,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001806: {} + caniuse-lite@1.0.30001813: {} ccount@2.0.1: {} @@ -7049,7 +7142,7 @@ snapshots: signal-exit: 4.0.2 time-span: 4.0.0 - electron-to-chromium@1.5.396: {} + electron-to-chromium@1.5.440: {} emoji-regex@9.2.2: {} @@ -7127,10 +7220,10 @@ snapshots: string.prototype.trimstart: 1.0.8 typed-array-buffer: 1.0.3 typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 + typed-array-byte-offset: 1.0.5 typed-array-length: 1.0.8 unbox-primitive: 1.1.0 - which-typed-array: 1.1.22 + which-typed-array: 1.1.24 es-define-property@1.0.1: {} @@ -7261,18 +7354,18 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.3.4(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): + eslint-config-next@16.3.6(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@next/eslint-plugin-next': 16.3.4(eslint@9.39.5(jiti@2.7.0)) + '@next/eslint-plugin-next': 16.3.6(eslint@9.39.5(jiti@2.7.0)) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0)) globals: 16.4.0 - typescript-eslint: 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + typescript-eslint: 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -7284,7 +7377,7 @@ snapshots: eslint-import-resolver-node@0.3.10: dependencies: debug: 3.2.7 - is-core-module: 2.16.2 + is-core-module: 2.17.0 resolve: 2.0.0-next.7 transitivePeerDependencies: - supports-color @@ -7294,31 +7387,31 @@ snapshots: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 eslint: 9.39.5(jiti@2.7.0) - get-tsconfig: 4.14.0 + get-tsconfig: 4.14.3 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 @@ -7326,9 +7419,9 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) hasown: 2.0.4 - is-core-module: 2.16.2 + is-core-module: 2.17.0 is-glob: 4.0.3 minimatch: 3.1.5 object.fromentries: 2.0.8 @@ -7338,7 +7431,7 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -7347,10 +7440,10 @@ snapshots: eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)): dependencies: aria-query: 5.3.2 - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.flatmap: 1.3.3 ast-types-flow: 0.0.8 - axe-core: 4.12.1 + axe-core: 4.13.0 axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 @@ -7366,7 +7459,7 @@ snapshots: eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)): dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.9 eslint: 9.39.5(jiti@2.7.0) hermes-parser: 0.25.1 zod: 4.4.3 @@ -7376,7 +7469,7 @@ snapshots: eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)): dependencies: - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.findlast: 1.2.5 array.prototype.flatmap: 1.3.3 array.prototype.tosorted: 1.1.4 @@ -7393,7 +7486,7 @@ snapshots: prop-types: 15.8.1 resolve: 2.0.0-next.7 semver: 6.3.1 - string.prototype.matchall: 4.0.12 + string.prototype.matchall: 4.1.0 string.prototype.repeat: 1.0.0 eslint-scope@8.4.0: @@ -7566,6 +7659,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.8: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -7604,14 +7699,14 @@ snapshots: dependencies: is-callable: 1.2.7 - framer-motion@12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + framer-motion@12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: motion-dom: 12.42.2 motion-utils: 12.39.0 tslib: 2.8.1 optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) fs-extra@11.1.0: dependencies: @@ -7628,7 +7723,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3): + fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -7653,23 +7748,23 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 - '@types/react': 19.2.17 - lucide-react: 1.26.0(react@19.2.4) - next: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@types/react': 19.2.18 + lucide-react: 1.26.0(react@19.2.8) + next: 16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) zod: 4.4.3 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.1 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) js-yaml: 4.3.2 mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 @@ -7685,43 +7780,43 @@ snapshots: optionalDependencies: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 - '@types/react': 19.2.17 - next: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 + '@types/react': 19.2.18 + next: 16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 transitivePeerDependencies: - supports-color - fumadocs-ui@16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3): + fumadocs-ui@16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): dependencies: - '@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@fuma-translate/react': 1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) - '@radix-ui/react-accordion': 1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-collapsible': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-dialog': 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-navigation-menu': 1.2.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-popover': 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-scroll-area': 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-tabs': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-accordion': 1.2.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collapsible': 1.1.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dialog': 1.1.21(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-navigation-menu': 1.2.20(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popover': 1.1.21(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-scroll-area': 1.2.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-tabs': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: 0.7.1 cnfast: 0.0.8 - fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) - lucide-react: 1.26.0(react@19.2.4) - motion: 12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.4) + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + lucide-react: 1.26.0(react@19.2.8) + motion: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) rehype-raw: 7.0.0 scroll-into-view-if-needed: 3.1.0 shiki: 4.3.1 unist-util-visit: 5.1.0 optionalDependencies: '@types/mdx': 2.0.14 - '@types/react': 19.2.17 - next: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/react': 19.2.18 + next: 16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - '@emotion/is-prop-valid' - '@types/react-dom' @@ -7787,6 +7882,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -7979,7 +8078,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.6: {} + ignore@7.0.10: {} import-fresh@3.3.1: dependencies: @@ -8036,7 +8135,7 @@ snapshots: is-callable@1.2.7: {} - is-core-module@2.16.2: + is-core-module@2.17.0: dependencies: hasown: 2.0.4 @@ -8122,7 +8221,7 @@ snapshots: is-typed-array@1.1.15: dependencies: - which-typed-array: 1.1.22 + which-typed-array: 1.1.24 is-weakmap@2.0.2: {} @@ -8197,7 +8296,7 @@ snapshots: jsx-ast-utils@3.3.5: dependencies: - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.flat: 1.3.3 object.assign: 4.1.7 object.values: 1.2.1 @@ -8290,9 +8389,9 @@ snapshots: dependencies: yallist: 4.0.0 - lucide-react@1.26.0(react@19.2.4): + lucide-react@1.26.0(react@19.2.8): dependencies: - react: 19.2.4 + react: 19.2.8 luxon@3.7.2: {} @@ -8758,11 +8857,11 @@ snapshots: minimatch@10.2.6: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.18 + brace-expansion: 1.1.21 minimist@1.2.8: {} @@ -8780,13 +8879,13 @@ snapshots: motion-utils@12.39.0: {} - motion@12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + motion@12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - framer-motion: 12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + framer-motion: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) tslib: 2.8.1 optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) mri@1.2.0: {} @@ -8798,35 +8897,37 @@ snapshots: nanoid@3.3.18: {} + nanoid@3.3.19: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} - next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) - next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.3.4 + '@next/env': 16.3.6 '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.11.1 - caniuse-lite: 1.0.30001806 + baseline-browser-mapping: 2.11.26 + caniuse-lite: 1.0.30001813 postcss: 8.5.23 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.4 - '@next/swc-darwin-x64': 16.3.4 - '@next/swc-linux-arm64-gnu': 16.3.4 - '@next/swc-linux-arm64-musl': 16.3.4 - '@next/swc-linux-x64-gnu': 16.3.4 - '@next/swc-linux-x64-musl': 16.3.4 - '@next/swc-win32-arm64-msvc': 16.3.4 - '@next/swc-win32-x64-msvc': 16.3.4 - sharp: 0.35.4(@types/node@20.19.43) + '@next/swc-darwin-arm64': 16.3.6 + '@next/swc-darwin-x64': 16.3.6 + '@next/swc-linux-arm64-gnu': 16.3.6 + '@next/swc-linux-arm64-musl': 16.3.6 + '@next/swc-linux-x64-gnu': 16.3.6 + '@next/swc-linux-x64-musl': 16.3.6 + '@next/swc-win32-arm64-msvc': 16.3.6 + '@next/swc-win32-x64-msvc': 16.3.6 + sharp: 0.35.5(@types/node@22.20.4) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -8853,7 +8954,7 @@ snapshots: node-gyp-build@4.8.4: {} - node-releases@2.0.51: {} + node-releases@2.0.57: {} nopt@8.1.0: dependencies: @@ -9052,9 +9153,7 @@ snapshots: path-to-regexp@6.3.0: {} - path-to-regexp@8.2.0: {} - - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.0: {} pend@1.2.0: {} @@ -9070,7 +9169,7 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -9112,41 +9211,41 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 - react-dom@19.2.4(react@19.2.4): + react-dom@19.2.8(react@19.2.8): dependencies: - react: 19.2.4 + react: 19.2.8 scheduler: 0.27.0 react-is@16.13.1: {} - react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.4): + react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8): dependencies: - react: 19.2.4 - react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.4) + react: 19.2.8 + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8) tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.4): + react-remove-scroll@2.7.2(@types/react@19.2.18)(react@19.2.8): dependencies: - react: 19.2.4 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.4) - react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.4) + react: 19.2.8 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.18)(react@19.2.8) + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.4) - use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.4) + use-callback-ref: 1.3.3(@types/react@19.2.18)(react@19.2.8) + use-sidecar: 1.1.3(@types/react@19.2.18)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.4): + react-style-singleton@2.2.3(@types/react@19.2.18)(react@19.2.8): dependencies: get-nonce: 1.0.1 - react: 19.2.4 + react: 19.2.8 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - react@19.2.4: {} + react@19.2.8: {} readdirp@4.1.2: {} @@ -9288,7 +9387,7 @@ snapshots: resolve@2.0.0-next.7: dependencies: es-errors: 1.3.0 - is-core-module: 2.16.2 + is-core-module: 2.17.0 node-exports-info: 1.6.2 object-keys: 1.1.1 path-parse: 1.0.7 @@ -9397,38 +9496,38 @@ snapshots: setprototypeof@1.1.1: {} - sharp@0.35.4(@types/node@20.19.43): + sharp@0.35.5(@types/node@22.20.4): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 - '@types/node': 20.19.43 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 22.20.4 optional: true shebang-command@2.0.0: @@ -9526,7 +9625,7 @@ snapshots: define-properties: 1.2.1 es-abstract: 1.24.2 - string.prototype.matchall@4.0.12: + string.prototype.matchall@4.1.0: dependencies: call-bind: 1.0.9 call-bound: 1.0.4 @@ -9590,10 +9689,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4): + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.8): dependencies: client-only: 0.0.1 - react: 19.2.4 + react: 19.2.8 optionalDependencies: '@babel/core': 7.29.7 @@ -9603,7 +9702,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - tailwind-merge@3.6.0: {} + tailwind-merge@3.7.0: {} tailwindcss@4.3.3: {} @@ -9708,13 +9807,12 @@ snapshots: has-proto: 1.2.0 is-typed-array: 1.1.15 - typed-array-byte-offset@1.0.4: + typed-array-byte-offset@1.0.5: dependencies: available-typed-arrays: 1.0.7 call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 - has-proto: 1.2.0 is-typed-array: 1.1.15 reflect.getprototypeof: 1.0.10 @@ -9727,12 +9825,12 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): + typescript-eslint@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: @@ -9753,9 +9851,9 @@ snapshots: undici-types@6.21.0: {} - undici@6.28.0: {} + undici@6.28.1: {} - undici@7.29.0: {} + undici@7.29.1: {} unified@11.0.5: dependencies: @@ -9830,9 +9928,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 - update-browserslist-db@1.2.3(browserslist@4.28.7): + update-browserslist-db@1.3.3(browserslist@4.29.2): dependencies: - browserslist: 4.28.7 + browserslist: 4.29.2 escalade: 3.2.0 picocolors: 1.1.1 @@ -9840,20 +9938,20 @@ snapshots: dependencies: punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.4): + use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.8): dependencies: - react: 19.2.4 + react: 19.2.8 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.4): + use-sidecar@1.1.3(@types/react@19.2.18)(react@19.2.8): dependencies: detect-node-es: 1.1.0 - react: 19.2.4 + react: 19.2.8 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 uuid@14.0.1: {} @@ -9893,7 +9991,7 @@ snapshots: luxon: 3.7.2 sandbox: 4.1.0 smol-toml: 1.8.0 - undici: 6.28.0 + undici: 6.28.1 uuid: 14.0.1 zod: 4.1.11 optionalDependencies: @@ -9960,7 +10058,7 @@ snapshots: isarray: 2.0.5 which-boxed-primitive: 1.1.1 which-collection: 1.0.2 - which-typed-array: 1.1.22 + which-typed-array: 1.1.24 which-collection@1.0.2: dependencies: @@ -9969,7 +10067,7 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 - which-typed-array@1.1.22: + which-typed-array@1.1.24: dependencies: available-typed-arrays: 1.0.7 call-bind: 1.0.9 diff --git a/docs/site/src/components/layout/DocsFooter.tsx b/docs/site/src/components/layout/DocsFooter.tsx index e8aed683f5e..084f2e56c66 100644 --- a/docs/site/src/components/layout/DocsFooter.tsx +++ b/docs/site/src/components/layout/DocsFooter.tsx @@ -1,6 +1,8 @@ import Link from 'next/link' import Image from 'next/image' +const currentYear = new Date().getFullYear() + export function DocsFooter() { return (