[P2] fix(release,settings): restore signing preflight portability, bootstrap diagnostics, and skill re-check (#11692)

* fix(release): restore the SignPath composite action when cutting from an older ref

Co-authored-by: Orca <help@stably.ai>

* fix(startup): record a durable diagnostic before the bootstrap fatal-exit guard exits

Co-authored-by: Orca <help@stably.ai>

* fix(settings): make agent-skill Re-check rescan skill freshness

Co-authored-by: Orca <help@stably.ai>

* fix(startup): keep the bootstrap fatal diagnostic when the log override is unwritable

Create the parent directory an overridden ORCA_BOOTSTRAP_FATAL_LOG names and fall
back to the default location when that path still cannot be opened, so a missing
parent no longer costs the only account of the failure. Also pins the Re-check
freshness rescan to the completed install scan rather than the click.

Co-authored-by: Orca <help@stably.ai>

* refactor(settings): move the post-recheck surface sync out of the panel

Co-authored-by: Orca <help@stably.ai>

* fix(startup): retain diagnostics without node fs

* fix(skills): keep freshness scoped to the local runtime

* fix(settings): register freshness status translations

* fix(settings): scope and sequence skill freshness refreshes

* fix(settings): refresh freshness across runtime transitions

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-31 06:08:26 -07:00 committed by GitHub
parent 886fa7b438
commit 79251d7a98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1122 additions and 100 deletions

View File

@ -1015,6 +1015,27 @@ jobs:
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
# Why: `uses: ./…` resolves from the checked-out tag, not from the workflow
# ref, so cutting from an older/off-main ref whose tree predates a composite
# action would fail the step with "Can't find 'action.yml'". Restore the
# actions directory from the commit this workflow file itself came from.
- name: Restore composite actions from the workflow ref
if: matrix.platform == 'win'
shell: bash
env:
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
action_path=".github/actions/install-signpath-module/action.yml"
if [ -f "$action_path" ]; then
echo "Composite actions already present at the cut ref."
exit 0
fi
echo "Cut ref predates $action_path; restoring it from $WORKFLOW_SHA."
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
git checkout "$WORKFLOW_SHA" -- .github/actions
test -f "$action_path"
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
- name: Setup pnpm
uses: pnpm/action-setup@v6

View File

@ -0,0 +1,107 @@
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../src/main/startup/bootstrap-fatal-exit-guard'
export const BOOTSTRAP_FATAL_LOG_ENV_VAR = 'ORCA_BOOTSTRAP_FATAL_LOG'
export const BOOTSTRAP_FATAL_LOG_FILE_NAME = 'bootstrap-fatal.log'
const BOOTSTRAP_FATAL_LOG_MAX_BYTES = 262_144
export function createBootstrapFatalExitBanner(): string {
// Why: Electron's pre-import error dialog can leave main resident and block NSIS replacement.
// Suppressing that dialog also removes the only account of the failure, so record one first:
// a partially copied resources tree otherwise exits silently on every launch, with nothing
// for the user or a support log to name the module that was missing.
return `
;(() => {
const guardKey = ${JSON.stringify(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)}
if (typeof globalThis[guardKey] === 'function') {
return
}
const describeBootstrapError = (error) => {
try {
const detail = error && typeof error === 'object' && error.stack ? error.stack : error
return String(detail).split('\\n').slice(0, 12).join(' | ').slice(0, 4000)
} catch {
return '<unprintable error>'
}
}
const readBootstrapFatalLogOverride = () => {
const override = typeof process.env === 'object' && process.env ? process.env.${BOOTSTRAP_FATAL_LOG_ENV_VAR} : undefined
return typeof override === 'string' && override.length > 0 ? override : undefined
}
const resolveDefaultBootstrapFatalLogPath = () => {
let directory
try {
directory = require('electron').app.getPath('userData')
} catch {
// Why: a bootstrap fault can predate a usable app object; temp still outlives the process.
directory = require('node:os').tmpdir()
}
return require('node:path').join(directory, ${JSON.stringify(BOOTSTRAP_FATAL_LOG_FILE_NAME)})
}
const appendBootstrapFatalLine = (fs, logPath, entry) => {
try {
// Why: an override can name a directory nothing has created yet, and a missing
// parent would drop the only account of the failure.
fs.mkdirSync(require('node:path').dirname(logPath), { recursive: true })
// Why: a broken install repeats this on every relaunch; keep the trail bounded.
let flags = 'a'
try {
flags = fs.statSync(logPath).size > ${BOOTSTRAP_FATAL_LOG_MAX_BYTES} ? 'w' : 'a'
} catch {
flags = 'a'
}
const descriptor = fs.openSync(logPath, flags, 0o600)
try {
fs.writeSync(descriptor, entry)
} finally {
fs.closeSync(descriptor)
}
return true
} catch {
return false
}
}
const recordBootstrapFailure = (error) => {
const line = '[bootstrap] fatal-exit pid=' + process.pid + ' error=' + describeBootstrapError(error) + '\\n'
let fs
try {
fs = require('node:fs')
} catch {
fs = undefined
}
try {
fs.writeSync(2, line)
} catch {
try {
process.stderr.write(line)
} catch {
// Diagnostics must never replace the exit below.
}
}
try {
const entry = new Date().toISOString() + ' ' + line
const override = readBootstrapFatalLogOverride()
// Why: an unwritable override must cost the user a location, not the diagnostic.
if (!override || !appendBootstrapFatalLine(fs, override, entry)) {
appendBootstrapFatalLine(fs, resolveDefaultBootstrapFatalLogPath(), entry)
}
} catch {
// Diagnostics must never replace the exit below.
}
}
let exitScheduled = false
const exitAfterBootstrapFailure = (error) => {
if (exitScheduled) {
return
}
exitScheduled = true
recordBootstrapFailure(error)
process.exitCode = 1
setImmediate(() => process.exit(1))
}
globalThis[guardKey] = () => {
process.off('uncaughtException', exitAfterBootstrapFailure)
delete globalThis[guardKey]
}
process.once('uncaughtException', exitAfterBootstrapFailure)
})();
`
}

View File

@ -1,13 +1,72 @@
import { readFileSync } from 'node:fs'
import * as nodeFs from 'node:fs'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import * as nodePath from 'node:path'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { EventEmitter } from 'node:events'
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { createBootstrapFatalExitBanner, electronViteConfig } from '../../electron.vite.config'
import {
BOOTSTRAP_FATAL_LOG_ENV_VAR,
BOOTSTRAP_FATAL_LOG_FILE_NAME,
createBootstrapFatalExitBanner
} from '../../build-plugins/bootstrap-fatal-exit-banner'
import { electronViteConfig } from '../../electron.vite.config'
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard'
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8')
type BootstrapProcessMock = EventEmitter & {
env: Record<string, string>
pid: number
exit: (code: number) => void
exitCode?: number
}
/** Runs the banner in a bare context and raises the bootstrap fault it guards against. */
function failBootstrapWithBanner(options: {
env: Record<string, string>
tmpdir?: string
stderrWrites?: string[]
}): BootstrapProcessMock {
const processMock = new EventEmitter() as BootstrapProcessMock
processMock.env = options.env
processMock.pid = 4242
processMock.exit = () => {}
const fsShim = {
...nodeFs,
writeSync: (descriptor: number, data: string) => {
if (descriptor === 2) {
options.stderrWrites?.push(data)
return data.length
}
return nodeFs.writeSync(descriptor, data)
}
}
const context = {
process: processMock,
setImmediate: () => {},
require: (specifier: string) => {
if (specifier === 'node:fs') {
return fsShim
}
if (specifier === 'node:path') {
return nodePath
}
if (specifier === 'node:os' && options.tmpdir !== undefined) {
return { tmpdir: () => options.tmpdir }
}
// Electron's own module is unreachable from a bootstrap fault this early.
throw new Error(`unexpected require: ${specifier}`)
}
}
runInNewContext(createBootstrapFatalExitBanner(), context)
processMock.emit('uncaughtException', new Error("Cannot find module 'ws'"))
return processMock
}
describe('Electron Vite output contract', () => {
it('keeps main-process and plain-Node entries at stable CommonJS paths', () => {
const output = electronViteConfig.main?.build?.rollupOptions?.output
@ -40,12 +99,20 @@ describe('Electron Vite output contract', () => {
const processMock = new EventEmitter() as EventEmitter & {
exit: (code: number) => void
exitCode?: number
stderr: { write: (chunk: string) => boolean }
}
let scheduledExit: (() => void) | null = null
let exitedWith: number | null = null
const stderrWrites: string[] = []
processMock.exit = (code) => {
exitedWith = code
}
processMock.stderr = {
write: (chunk) => {
stderrWrites.push(chunk)
return true
}
}
const context = {
process: processMock,
setImmediate: (callback: () => void) => {
@ -61,6 +128,63 @@ describe('Electron Vite output contract', () => {
scheduledExit?.()
expect(exitedWith).toBe(1)
expect(context).toHaveProperty(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)
expect(stderrWrites.join('')).toContain("Cannot find module 'zod'")
})
it('records the bootstrap failure it exits on, since the guard hides Electron dialog', () => {
const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-'))
const logPath = join(logDirectory, 'fatal.log')
const stderrWrites: string[] = []
try {
const processMock = failBootstrapWithBanner({
env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logPath },
stderrWrites
})
expect(stderrWrites.join('')).toContain("Cannot find module 'ws'")
const recorded = readFileSync(logPath, 'utf8')
expect(recorded).toContain("Cannot find module 'ws'")
expect(recorded).toContain('pid=4242')
expect(processMock.exitCode).toBe(1)
} finally {
rmSync(logDirectory, { recursive: true, force: true })
}
})
it('creates the parent directory an overridden log path names but does not have', () => {
const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-'))
const logPath = join(logDirectory, 'nested', 'diagnostics', 'fatal.log')
try {
const processMock = failBootstrapWithBanner({
env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logPath }
})
expect(readFileSync(logPath, 'utf8')).toContain("Cannot find module 'ws'")
expect(processMock.exitCode).toBe(1)
} finally {
rmSync(logDirectory, { recursive: true, force: true })
}
})
it('falls back to the default location when the overridden log path is unwritable', () => {
const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-'))
const fallbackDirectory = join(logDirectory, 'fallback')
try {
const processMock = failBootstrapWithBanner({
// A directory can never be opened as the log file, so the override must yield.
env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logDirectory },
tmpdir: fallbackDirectory
})
const recorded = readFileSync(join(fallbackDirectory, BOOTSTRAP_FATAL_LOG_FILE_NAME), 'utf8')
expect(recorded).toContain("Cannot find module 'ws'")
expect(processMock.exitCode).toBe(1)
} finally {
rmSync(logDirectory, { recursive: true, force: true })
}
})
it('isolates renderer entry side effects behind strict facades', () => {

View File

@ -104,6 +104,41 @@ describe('Windows signing workflow contract', () => {
expect(installRun).toContain('throw "Unable to install the SignPath PowerShell module')
})
it('still installs SignPath when the cut ref predates the composite action', () => {
const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml')
const steps = parsedWorkflow.jobs.build.steps
const stepNames = steps.map((step) => step.name)
const checkoutIndex = stepNames.indexOf('Checkout')
const restoreIndex = stepNames.indexOf('Restore composite actions from the workflow ref')
const installIndex = stepNames.indexOf('Install SignPath PowerShell module')
// Why: the build job checks out the cut tag, which for a hotfix cut from an
// older ref can predate `.github/actions/install-signpath-module`; without
// this restore the `uses: ./…` step dies on a missing action.yml.
expect(restoreIndex).toBeGreaterThan(checkoutIndex)
expect(restoreIndex).toBeLessThan(installIndex)
const restoreStep = steps[restoreIndex]
const restoreRun = restoreStep.run
expect(restoreStep.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
expect(restoreRun).toContain('.github/actions/install-signpath-module/action.yml')
expect(restoreRun).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
expect(restoreRun).toContain('git checkout "$WORKFLOW_SHA" -- .github/actions')
// Why: restoring the action must not turn signing into a soft dependency —
// a missing module still has to fail the Windows job, and the CDN fallback
// still has to reject an unexpected payload.
expect(steps[installIndex]['continue-on-error']).toBeUndefined()
expect(restoreStep['continue-on-error']).toBeUndefined()
const installRun = readWorkflow('.github/actions/install-signpath-module/action.yml').runs
.steps[0].run
expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()')
expect(installRun).toContain('throw "SHA-256 mismatch for $source')
})
it('shares one SignPath module install path between release and rehearsal', () => {
const rehearsalWorkflow = readWorkflow('.github/workflows/windows-signing-rehearsal.yml')
const stepNames = rehearsalWorkflow.jobs.rehearse.steps.map((step) => step.name)

View File

@ -3,6 +3,7 @@ import { resolve } from 'node:path'
import { defineConfig, type UserConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { createBootstrapFatalExitBanner } from './build-plugins/bootstrap-fatal-exit-banner'
import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard'
import packageJson from './package.json' with { type: 'json' }
@ -16,7 +17,6 @@ const BUNDLED_MAIN_DEPENDENCIES = new Set([
const EXTERNAL_MAIN_DEPENDENCIES = Object.keys(packageJson.dependencies).filter(
(dependency) => !BUNDLED_MAIN_DEPENDENCIES.has(dependency)
)
const BOOTSTRAP_FATAL_EXIT_GUARD_KEY = '__ORCA_BOOTSTRAP_FATAL_EXIT_GUARD__'
function isExternalMainModule(source: string): boolean {
if (isBuiltin(source) || source === 'electron' || source.startsWith('electron/')) {
@ -171,32 +171,6 @@ function createStartupDiagnosticsBanner(chunkName: string): string {
`
}
export function createBootstrapFatalExitBanner(): string {
// Why: Electron's pre-import error dialog can leave main resident and block NSIS replacement.
return `
;(() => {
const guardKey = ${JSON.stringify(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)}
if (typeof globalThis[guardKey] === 'function') {
return
}
let exitScheduled = false
const exitAfterBootstrapFailure = () => {
if (exitScheduled) {
return
}
exitScheduled = true
process.exitCode = 1
setImmediate(() => process.exit(1))
}
globalThis[guardKey] = () => {
process.off('uncaughtException', exitAfterBootstrapFailure)
delete globalThis[guardKey]
}
process.once('uncaughtException', exitAfterBootstrapFailure)
})();
`
}
function createMainBootstrapPlugin() {
return {
name: 'orca-main-bootstrap',

View File

@ -75,10 +75,8 @@ export function BrowserUseSkillSetupCard(props: {
onBeforeOpenTerminal={handleBeforeOpenTerminal}
showRecheckWhenInstalled={false}
onRecheck={skill.refresh}
// Why: the local-host-only freshness scan cannot vouch for a WSL runtime,
// so fall back to the presence-only pill there (mirrors the settings cards).
freshnessSkillName={
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : ORCA_CLI_SKILL_NAME
activeSkillRuntime.canUseLocalSkillFreshness ? ORCA_CLI_SKILL_NAME : undefined
}
/>
)

View File

@ -0,0 +1,143 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness'
import { _skillFreshnessCacheForTests } from '@/hooks/useSkillFreshness'
import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog'
const mocks = vi.hoisted(() => ({
canUseLocalSkillFreshness: true,
refreshOrchestrationSkill: vi.fn(async () => true),
recordFeatureInteraction: vi.fn()
}))
vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
useActiveProjectSkillRuntime: () => ({
agentRuntime: undefined,
discoveryTarget: undefined,
terminalShellOverride: undefined,
installDisabledReason: null,
canUseLocalSkillFreshness: mocks.canUseLocalSkillFreshness
})
}))
vi.mock('@/hooks/useInstalledAgentSkills', () => ({
GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['home'],
useInstalledAgentSkill: () => ({
installed: true,
loading: false,
error: null,
refresh: mocks.refreshOrchestrationSkill
}),
notifyInstalledAgentSkillsChanged: vi.fn(),
notifyInstalledAgentSkillsRefreshed: vi.fn()
}))
vi.mock('@/components/settings/CliSkillRuntimeSetup', () => ({
buildSkillCommandForRuntime: (command: string) => command,
ensureWslCliAvailableForAgentSkillTerminal: vi.fn(),
getWslCliDistroRequest: () => undefined
}))
vi.mock('@/components/onboarding/OnboardingInlineCommandTerminal', () => ({
OnboardingInlineCommandTerminal: () => null
}))
vi.mock('@/store', () => ({
useAppStore: Object.assign(() => undefined, {
getState: () => ({ recordFeatureInteraction: mocks.recordFeatureInteraction })
})
}))
function inventory(eligibleUpdateNames: string[]): SkillFreshnessInventory {
return { schemaVersion: 1, installations: [], eligibleUpdateNames, scanIssues: [], scannedAt: 1 }
}
let root: Root | null = null
let container: HTMLDivElement | null = null
function recheckButton(): HTMLButtonElement | undefined {
return Array.from(document.body.querySelectorAll('button')).find(
(candidate) => candidate.textContent?.trim() === 'Re-check'
)
}
async function renderDialog(): Promise<void> {
await act(async () => {
root?.render(
<FloatingTerminalOrchestrationDialog
open
onOpenChange={vi.fn()}
onSetupStateChange={vi.fn()}
/>
)
})
await act(async () => {})
}
describe('FloatingTerminalOrchestrationDialog freshness', () => {
beforeEach(() => {
_skillFreshnessCacheForTests.reset()
mocks.canUseLocalSkillFreshness = true
mocks.refreshOrchestrationSkill.mockClear()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
if (root) {
await act(async () => root?.unmount())
}
root = null
container?.remove()
container = null
Reflect.deleteProperty(window, 'api')
})
it('refreshes the local freshness verdict after presence re-check completes', async () => {
const freshnessInventory = vi
.fn()
.mockResolvedValueOnce(inventory(['orchestration']))
.mockResolvedValueOnce(inventory([]))
window.api = {
skills: { freshnessInventory },
cli: { getInstallStatus: vi.fn().mockResolvedValue({ onPath: true }) }
} as never
await renderDialog()
expect(freshnessInventory).toHaveBeenCalledOnce()
expect(document.body.textContent).toContain('Update available')
await act(async () => {
recheckButton()?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await act(async () => {})
expect(mocks.refreshOrchestrationSkill).toHaveBeenCalledOnce()
expect(freshnessInventory).toHaveBeenCalledTimes(2)
expect(document.body.textContent).not.toContain('Update available')
})
it('never reads client freshness for an SSH-owned skill', async () => {
mocks.canUseLocalSkillFreshness = false
const freshnessInventory = vi.fn().mockResolvedValue(inventory([]))
window.api = {
skills: { freshnessInventory },
cli: { getInstallStatus: vi.fn().mockResolvedValue({ onPath: true }) }
} as never
await renderDialog()
expect(document.body.textContent).toContain('Installed')
await act(async () => {
recheckButton()?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await act(async () => {})
expect(mocks.refreshOrchestrationSkill).toHaveBeenCalledOnce()
expect(freshnessInventory).not.toHaveBeenCalled()
})
})

View File

@ -23,6 +23,7 @@ import {
useInstalledAgentSkill
} from '@/hooks/useInstalledAgentSkills'
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
import { refreshSkillFreshness } from '@/hooks/useSkillFreshness'
import { useAppStore } from '@/store'
import {
buildSkillCommandForRuntime,
@ -74,6 +75,14 @@ export function FloatingTerminalOrchestrationDialog({
}
}, [orchestrationSkillDetected, onSetupStateChange])
const recheckOrchestrationSkill = async (): Promise<boolean> => {
const installed = await refreshOrchestrationSkill()
if (activeSkillRuntime.canUseLocalSkillFreshness) {
await refreshSkillFreshness()
}
return installed
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="gap-4 sm:max-w-[620px]">
@ -95,18 +104,15 @@ export function FloatingTerminalOrchestrationDialog({
)}
</IntegrationStatusPill>
) : orchestrationSkillDetected ? (
// Why: the modal owns the status pill, so it must carry the same
// freshness signal — and route to the same review dialog — as the
// settings card for this skill; WSL falls back to presence-only.
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? (
activeSkillRuntime.canUseLocalSkillFreshness ? (
<SkillFreshnessStatusPill skillName={ORCHESTRATION_SKILL_NAME} />
) : (
<IntegrationStatusPill tone="connected">
{translate(
'auto.components.floating.terminal.FloatingTerminalOrchestrationDialog.630c0ac8c8',
'Installed'
)}
</IntegrationStatusPill>
) : (
<SkillFreshnessStatusPill skillName={ORCHESTRATION_SKILL_NAME} />
)
) : (
<IntegrationStatusPill tone="attention">
@ -161,7 +167,7 @@ export function FloatingTerminalOrchestrationDialog({
? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime)
: ensureOrcaCliAvailableForAgentSkillTerminal())
}}
onRecheck={refreshOrchestrationSkill}
onRecheck={recheckOrchestrationSkill}
/>
</DialogContent>
</Dialog>

View File

@ -0,0 +1,154 @@
// @vitest-environment happy-dom
import { act, type ComponentProps } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness'
import { _skillFreshnessCacheForTests } from '@/hooks/useSkillFreshness'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
const mocks = vi.hoisted(() => ({
skillsChanged: vi.fn(),
skillsRefreshed: vi.fn()
}))
vi.mock('@/hooks/useInstalledAgentSkills', () => ({
notifyInstalledAgentSkillsChanged: mocks.skillsChanged,
notifyInstalledAgentSkillsRefreshed: mocks.skillsRefreshed
}))
vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({
OnboardingInlineCommandTerminal: () => null
}))
function inventory(eligibleUpdateNames: string[]): SkillFreshnessInventory {
return { schemaVersion: 1, installations: [], eligibleUpdateNames, scanIssues: [], scannedAt: 1 }
}
function panelProps(
onRecheck: () => void | Promise<unknown> = vi.fn()
): ComponentProps<typeof AgentSkillSetupPanel> {
return {
title: 'Linear skill',
description: null,
command: 'npx skills add orca-linear --global',
terminalTitle: 'Linear skill setup',
terminalAriaLabel: 'Linear skill install terminal',
terminalWorktreeId: 'settings-linear-skill-terminal',
installed: true,
loading: false,
error: null,
hideHeader: true,
showRecheckWhenInstalled: true,
freshnessSkillName: 'orca-linear',
onRecheck
}
}
let root: Root | null = null
let container: HTMLDivElement | null = null
describe('AgentSkillSetupPanel freshness re-check', () => {
beforeEach(() => {
_skillFreshnessCacheForTests.reset()
mocks.skillsChanged.mockReset()
mocks.skillsRefreshed.mockReset()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
if (root) {
await act(async () => root?.unmount())
}
root = null
container?.remove()
container = null
Reflect.deleteProperty(window, 'api')
})
it('rescans skill freshness and updates the rendered verdict on re-check', async () => {
let completeRecheck: (() => void) | null = null
// Why: a rescan started before the install scan finishes would re-read the same
// pre-update disk state, so the boundary is what the assertions below pin.
const onRecheck = vi.fn(
() =>
new Promise<void>((resolve) => {
completeRecheck = resolve
})
)
let completeRescan: ((value: SkillFreshnessInventory) => void) | null = null
const freshnessInventory = vi
.fn()
.mockResolvedValueOnce(inventory(['orca-linear']))
.mockImplementationOnce(
() =>
new Promise<SkillFreshnessInventory>((resolve) => {
completeRescan = resolve
})
)
window.api = { skills: { freshnessInventory } } as never
await act(async () => root?.render(<AgentSkillSetupPanel {...panelProps(onRecheck)} />))
await act(async () => {})
expect(freshnessInventory).toHaveBeenCalledTimes(1)
expect(container?.textContent).toContain('Update available')
const recheck = Array.from(container?.querySelectorAll('button') ?? []).find(
(candidate) => candidate.textContent?.trim() === 'Re-check'
)
expect(recheck).toBeDefined()
await act(async () => {
recheck?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await act(async () => {})
expect(onRecheck).toHaveBeenCalledOnce()
expect(mocks.skillsRefreshed).not.toHaveBeenCalled()
expect(freshnessInventory).toHaveBeenCalledTimes(1)
await act(async () => {
completeRecheck?.()
})
await act(async () => {})
expect(mocks.skillsRefreshed).toHaveBeenCalledOnce()
expect(freshnessInventory).toHaveBeenCalledTimes(2)
expect(container?.textContent).toContain('Checking...')
expect(container?.textContent).not.toContain('Update available')
await act(async () => {
completeRescan?.(inventory([]))
})
await act(async () => {})
expect(container?.textContent).not.toContain('Checking...')
expect(container?.textContent).not.toContain('Update available')
})
it('shows a failed verdict when the post-recheck inventory scan fails', async () => {
const freshnessInventory = vi
.fn()
.mockResolvedValueOnce(inventory(['orca-linear']))
.mockRejectedValueOnce(new Error('inventory unavailable'))
window.api = { skills: { freshnessInventory } } as never
await act(async () => root?.render(<AgentSkillSetupPanel {...panelProps()} />))
await act(async () => {})
const recheck = Array.from(container?.querySelectorAll('button') ?? []).find(
(candidate) => candidate.textContent?.trim() === 'Re-check'
)
await act(async () => {
recheck?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await act(async () => {})
expect(container?.textContent).toContain('Check failed')
expect(container?.textContent).not.toContain('Installed')
expect(container?.textContent).not.toContain('Up to date')
})
})

View File

@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({
toastSuccess: vi.fn(),
skillsChanged: vi.fn(),
skillsRefreshed: vi.fn(),
freshnessRefresh: vi.fn(),
terminalInstanceCount: 0
}))
@ -37,6 +38,10 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({
notifyInstalledAgentSkillsRefreshed: mocks.skillsRefreshed
}))
vi.mock('@/hooks/useSkillFreshness', () => ({
refreshSkillFreshness: mocks.freshnessRefresh
}))
vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({
OnboardingInlineCommandTerminal: (props: {
command: string
@ -155,6 +160,8 @@ describe('AgentSkillSetupPanel', () => {
mocks.toastSuccess.mockReset()
mocks.skillsChanged.mockReset()
mocks.skillsRefreshed.mockReset()
mocks.freshnessRefresh.mockReset()
mocks.freshnessRefresh.mockResolvedValue(undefined)
mocks.terminalInstanceCount = 0
Object.defineProperty(window, 'api', {
configurable: true,
@ -288,6 +295,50 @@ describe('AgentSkillSetupPanel', () => {
expect(mocks.skillsChanged).not.toHaveBeenCalled()
})
it('rechecks presence without invalidating local freshness when no local verdict exists', async () => {
const onRecheck = vi.fn(async () => {})
await renderInteractivePanel({ onRecheck })
await clickButton('Install')
await act(async () => {
mocks.terminalProps.at(-1)?.onCommandFinished?.(0)
mocks.terminalProps.at(-1)?.onTerminalExit?.()
})
await act(async () => {})
expect(onRecheck).toHaveBeenCalledOnce()
expect(mocks.skillsRefreshed).toHaveBeenCalledOnce()
expect(mocks.skillsChanged).not.toHaveBeenCalled()
expect(mocks.freshnessRefresh).not.toHaveBeenCalled()
})
it('refreshes first-install freshness only after the terminal re-check finishes', async () => {
let finishRecheck: (() => void) | null = null
const recheck = new Promise<void>((resolve) => {
finishRecheck = resolve
})
const onRecheck = vi.fn(() => recheck)
await renderInteractivePanel({ freshnessSkillName: 'orca-cli', onRecheck })
await clickButton('Install')
await act(async () => {
mocks.terminalProps.at(-1)?.onTerminalExit?.()
})
expect(onRecheck).toHaveBeenCalledOnce()
expect(mocks.freshnessRefresh).not.toHaveBeenCalled()
expect(mocks.skillsRefreshed).not.toHaveBeenCalled()
await act(async () => {
finishRecheck?.()
await recheck
})
expect(mocks.skillsChanged).not.toHaveBeenCalled()
expect(mocks.skillsRefreshed).toHaveBeenCalledOnce()
expect(mocks.freshnessRefresh).toHaveBeenCalledOnce()
})
it('opens not-installed setup with the install command for preview, copy, and terminal', async () => {
await renderInteractivePanel({ installedCommand: UPDATE_COMMAND })
@ -499,9 +550,12 @@ describe('AgentSkillSetupPanel', () => {
expect(mocks.terminalProps.at(-1)).toMatchObject({ command: UPDATE_COMMAND })
})
it('invalidates shared skill state before the direct completion re-check', async () => {
it('refreshes shared skill state after the direct completion re-check', async () => {
const calls: string[] = []
mocks.skillsChanged.mockImplementation(() => calls.push('invalidate'))
mocks.skillsRefreshed.mockImplementation(() => calls.push('presence'))
mocks.freshnessRefresh.mockImplementation(async () => {
calls.push('freshness')
})
const onRecheck = vi.fn(() => {
calls.push('recheck')
})
@ -513,7 +567,25 @@ describe('AgentSkillSetupPanel', () => {
mocks.terminalProps.at(-1)?.onCommandFinished?.(0)
})
expect(calls).toEqual(['invalidate', 'recheck'])
expect(calls).toEqual(['recheck', 'presence', 'freshness'])
})
it('rechecks once when command completion is followed by terminal exit', async () => {
const onRecheck = vi.fn()
await renderInteractivePanel({ freshnessSkillName: 'orca-cli', onRecheck })
await clickButton('Install')
await act(async () => {
mocks.terminalProps.at(-1)?.onCommandFinished?.(0)
})
await act(async () => {
mocks.terminalProps.at(-1)?.onTerminalExit?.()
})
await act(async () => {})
expect(onRecheck).toHaveBeenCalledOnce()
expect(mocks.skillsRefreshed).toHaveBeenCalledOnce()
expect(mocks.freshnessRefresh).toHaveBeenCalledOnce()
})
it('re-enables Install after the setup shell exits so a failed attempt can retry', async () => {

View File

@ -8,11 +8,11 @@ import { AgentSkillSetupFailureNotice } from './AgentSkillSetupFailureNotice'
import type { AgentSkillSetupPanelProps } from './agent-skill-setup-panel-props'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import {
notifyInstalledAgentSkillsChanged,
notifyInstalledAgentSkillsRefreshed
} from '@/hooks/useInstalledAgentSkills'
import { useMountedRef } from '@/hooks/useMountedRef'
import {
recheckSurfacesAfterAgentSkillTerminal,
syncSurfacesAfterAgentSkillRecheck
} from './agent-skill-recheck-surface-sync'
import { isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@ -122,22 +122,20 @@ export function AgentSkillSetupPanel({
if (bestEffortExitCode !== null) {
setSetupCommandFailedCode(bestEffortExitCode === 0 ? null : bestEffortExitCode)
}
if (freshnessSkillName) {
notifyInstalledAgentSkillsChanged()
}
void onRecheck()
recheckSurfacesAfterAgentSkillTerminal(onRecheck, freshnessSkillName)
},
[freshnessSkillName, onRecheck]
)
const handleTerminalExit = useCallback((): void => {
const shouldRecheck = setupAttemptRunningRef.current
if (mountedRef.current) {
setupAttemptRunningRef.current = false
setTerminalOpen(false)
setSetupAttemptRunning(false)
}
notifyInstalledAgentSkillsChanged()
}, [mountedRef])
void (shouldRecheck && recheckSurfacesAfterAgentSkillTerminal(onRecheck, freshnessSkillName))
}, [freshnessSkillName, mountedRef, onRecheck])
useEffect(() => {
if (!preInstallNotice) {
@ -235,8 +233,7 @@ export function AgentSkillSetupPanel({
return
}
void Promise.resolve(onRecheck()).then(() => {
// Reuse the completed scan so sibling surfaces sync without rediscovery.
notifyInstalledAgentSkillsRefreshed()
syncSurfacesAfterAgentSkillRecheck(freshnessSkillName)
})
}}
disabled={

View File

@ -7,9 +7,11 @@ import { getDefaultSettings } from '../../../../shared/constants'
import { CliSection } from './CliSection'
const capturedPanel = vi.hoisted(() => ({
canUseLocalSkillFreshness: true,
props: null as null | {
command: string
installedCommand: string
freshnessSkillName?: string
getPrerequisiteStatus: () => Promise<unknown>
onBeforeOpenTerminal: () => Promise<void>
},
@ -24,6 +26,12 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({
useInstalledAgentSkill: capturedPanel.useInstalledAgentSkill
}))
vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
useActiveProjectSkillRuntime: () => ({
canUseLocalSkillFreshness: capturedPanel.canUseLocalSkillFreshness
})
}))
capturedPanel.useInstalledAgentSkill.mockReturnValue({
installed: false,
loading: false,
@ -33,6 +41,7 @@ capturedPanel.useInstalledAgentSkill.mockReturnValue({
afterEach(() => {
cleanup()
capturedPanel.canUseLocalSkillFreshness = true
toastError.mockReset()
vi.unstubAllGlobals()
})
@ -41,6 +50,7 @@ vi.mock('./AgentSkillSetupPanel', () => ({
AgentSkillSetupPanel: function AgentSkillSetupPanel(props: {
command: string
installedCommand: string
freshnessSkillName?: string
getPrerequisiteStatus: () => Promise<unknown>
onBeforeOpenTerminal: () => Promise<void>
}) {
@ -62,6 +72,30 @@ vi.mock('./WslCliRegistration', () => ({
}))
describe('CliSection project runtime defaults', () => {
it('exposes freshness only for a resolved local host runtime', () => {
const settings = getDefaultSettings('/tmp')
renderToStaticMarkup(<CliSection currentPlatform="darwin" settings={settings} />)
expect(capturedPanel.props?.freshnessSkillName).toBe('orca-cli')
capturedPanel.canUseLocalSkillFreshness = false
renderToStaticMarkup(<CliSection currentPlatform="darwin" settings={settings} />)
expect(capturedPanel.props?.freshnessSkillName).toBeUndefined()
capturedPanel.canUseLocalSkillFreshness = true
renderToStaticMarkup(
<CliSection
currentPlatform="win32"
settings={{
...settings,
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
}}
wslSupportedPlatform
wslAvailable
/>
)
expect(capturedPanel.props?.freshnessSkillName).toBeUndefined()
})
it('passes the default project WSL distro to CLI skill prerequisite checks', async () => {
const getWslInstallStatus = vi
.fn()

View File

@ -32,6 +32,7 @@ import {
getWslCliDistroRequest
} from './CliSkillRuntimeSetup'
import { WslCliRegistration } from './WslCliRegistration'
import { useLocalCliSkillFreshnessName } from './use-local-cli-skill-freshness-name'
import { translate } from '@/i18n/i18n'
type CliSectionProps = {
@ -86,6 +87,7 @@ export function CliSection({
getSelectedAgentRuntime(settings, wslSupportedPlatform, wslAvailable, wslCapabilitiesLoading),
[settings, wslAvailable, wslCapabilitiesLoading, wslSupportedPlatform]
)
const cliSkillFreshnessName = useLocalCliSkillFreshnessName(agentRuntime)
const cliSkillDiscoveryTarget = useMemo(
() => getSkillDiscoveryTargetForRuntime(agentRuntime),
[agentRuntime]
@ -395,7 +397,7 @@ export function CliSection({
}))
}}
onRecheck={refreshCliSkill}
freshnessSkillName={agentRuntime.runtime === 'host' ? ORCA_CLI_SKILL_NAME : undefined}
freshnessSkillName={cliSkillFreshnessName}
/>
</div>
) : null}

View File

@ -80,7 +80,7 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element {
}}
onRecheck={refreshComputerUseSkill}
freshnessSkillName={
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : COMPUTER_USE_SKILL_NAME
activeSkillRuntime.canUseLocalSkillFreshness ? COMPUTER_USE_SKILL_NAME : undefined
}
/>
)

View File

@ -183,7 +183,7 @@ export function EphemeralVmsPane(): React.JSX.Element {
}}
onRecheck={refreshSkill}
freshnessSkillName={
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : EPHEMERAL_VMS_SKILL_NAME
activeSkillRuntime.canUseLocalSkillFreshness ? EPHEMERAL_VMS_SKILL_NAME : undefined
}
/>

View File

@ -69,7 +69,8 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
discoveryTarget: undefined,
agentRuntime: { runtime: mocks.runtime },
terminalShellOverride: undefined,
installDisabledReason: null
installDisabledReason: null,
canUseLocalSkillFreshness: mocks.runtime !== 'wsl'
})
}))

View File

@ -0,0 +1,59 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MobileEmulatorAgentControlRow } from './MobileEmulatorAgentControlRow'
const mocks = vi.hoisted(() => ({
canUseLocalSkillFreshness: true,
freshnessSkillName: undefined as string | undefined
}))
vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
useActiveProjectSkillRuntime: () => ({
canUseLocalSkillFreshness: mocks.canUseLocalSkillFreshness,
terminalShellOverride: undefined
})
}))
vi.mock('../emulator-pane/use-mobile-emulator-agent-setup-state', () => ({
useMobileEmulatorAgentSetupState: () => ({
cliActionLabel: 'Enable',
cliBusy: false,
cliEnabled: true,
cliInstallStatus: null,
cliLoading: false,
cliSkillError: null,
cliSkillInstalled: true,
cliSkillLoading: false,
cliSupported: true,
completedCount: 2,
handleEnableCli: vi.fn(),
refreshCliSkill: vi.fn(),
step2Blocked: false
})
}))
vi.mock('./AgentSkillSetupPanel', () => ({
AgentSkillSetupPanel: ({ freshnessSkillName }: { freshnessSkillName?: string }) => {
mocks.freshnessSkillName = freshnessSkillName
return null
}
}))
vi.mock('./SetupStepBadge', () => ({ StepBadge: () => null }))
vi.mock('./MobileEmulatorExamples', () => ({ MobileEmulatorExamples: () => null }))
describe('MobileEmulatorAgentControlRow freshness authority', () => {
beforeEach(() => {
mocks.canUseLocalSkillFreshness = true
mocks.freshnessSkillName = undefined
})
it('exposes local freshness only for a resolved local non-WSL runtime', () => {
renderToStaticMarkup(<MobileEmulatorAgentControlRow />)
expect(mocks.freshnessSkillName).toBe('orca-cli')
mocks.canUseLocalSkillFreshness = false
renderToStaticMarkup(<MobileEmulatorAgentControlRow />)
expect(mocks.freshnessSkillName).toBeUndefined()
})
})

View File

@ -177,9 +177,9 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element {
await ensureOrcaCliAvailableForAgentSkillTerminal()
}}
onRecheck={setup.refreshCliSkill}
// Why: this row builds its commands for the local host only, so the
// local-host freshness scan can vouch for the copy it points at.
freshnessSkillName={ORCA_CLI_SKILL_NAME}
freshnessSkillName={
activeSkillRuntime.canUseLocalSkillFreshness ? ORCA_CLI_SKILL_NAME : undefined
}
/>
</div>

View File

@ -156,10 +156,8 @@ export function OrchestrationPane(): React.JSX.Element {
/>
}
onRecheck={refreshOrchestrationSkill}
// Why: the local-host-only freshness scan cannot vouch for a WSL runtime,
// so fall back to the presence-only pill there (mirrors the Computer Use card).
freshnessSkillName={
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : ORCHESTRATION_SKILL_NAME
activeSkillRuntime.canUseLocalSkillFreshness ? ORCHESTRATION_SKILL_NAME : undefined
}
/>

View File

@ -77,7 +77,7 @@ export function OrchestrationSetupCard(props: {
}}
onRecheck={skill.refresh}
freshnessSkillName={
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : ORCHESTRATION_SKILL_NAME
activeSkillRuntime.canUseLocalSkillFreshness ? ORCHESTRATION_SKILL_NAME : undefined
}
/>
)

View File

@ -351,9 +351,8 @@ function Settings(): React.JSX.Element {
discoveryTarget: activeSkillRuntime.discoveryTarget,
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
// Why: skill freshness only covers the validated global rail (not WSL), so the nav pill stays presence-only under WSL.
const { inventory: skillFreshnessInventory } = useSkillFreshness()
const skillFreshnessApplies = activeSkillRuntime.agentRuntime?.runtime !== 'wsl'
const skillFreshnessApplies = activeSkillRuntime.canUseLocalSkillFreshness
const { inventory: skillFreshnessInventory } = useSkillFreshness(skillFreshnessApplies)
const [voiceModelStatesLoading, setVoiceModelStatesLoading] = useState(showDesktopOnlySettings)
// Why: trim platform-only Terminal entries from the shared search index so search never reveals hidden controls.
const [scrollbackMode, setScrollbackMode] = useState<'preset' | 'custom'>('preset')

View File

@ -48,7 +48,8 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
discoveryTarget: undefined,
agentRuntime: { runtime: 'native' },
terminalShellOverride: undefined,
installDisabledReason: null
installDisabledReason: null,
canUseLocalSkillFreshness: true
})
}))

View File

@ -180,6 +180,35 @@ describe('AgentSkillSetupPanel installed-command call sites', () => {
)
})
it('keeps client freshness behind resolved local runtime authority', () => {
const expectedGates = new Map<string, string>([
[
'src/renderer/src/components/settings/use-local-cli-skill-freshness-name.ts',
"agentRuntime.runtime === 'host' && activeSkillRuntime.canUseLocalSkillFreshness"
],
[
'src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx',
'activeSkillRuntime.canUseLocalSkillFreshness ? ORCA_CLI_SKILL_NAME : undefined'
],
[
'src/renderer/src/components/settings/Settings.tsx',
'useSkillFreshness(skillFreshnessApplies)'
],
[
'src/renderer/src/components/skills/SkillFreshnessNudge.tsx',
'useSkillFreshness(activeSkillRuntime.canUseLocalSkillFreshness)'
],
[
'src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx',
'useSkillFreshness(activeSkillRuntime.canUseLocalSkillFreshness)'
]
])
for (const [relativePath, expectedGate] of expectedGates) {
expect(readRepoFile(relativePath), relativePath).toContain(expectedGate)
}
})
it('fails when a production caller can show the default Update action without installedCommand', () => {
const productionCallers = findProductionPanelCallers(componentsRoot)

View File

@ -0,0 +1,19 @@
import { notifyInstalledAgentSkillsRefreshed } from '@/hooks/useInstalledAgentSkills'
import { refreshSkillFreshness } from '@/hooks/useSkillFreshness'
/** Publishes a completed re-check to presence and optional local-freshness surfaces. */
export function syncSurfacesAfterAgentSkillRecheck(freshnessSkillName?: string): void {
notifyInstalledAgentSkillsRefreshed()
if (freshnessSkillName) {
void refreshSkillFreshness()
}
}
export function recheckSurfacesAfterAgentSkillTerminal(
onRecheck: () => void | Promise<unknown>,
freshnessSkillName?: string
): void {
void Promise.resolve(onRecheck()).then(() => {
syncSurfacesAfterAgentSkillRecheck(freshnessSkillName)
})
}

View File

@ -67,8 +67,9 @@ export function useLinearAgentSkillSetup(): {
: buildSkillCommandForRuntime(updateTarget.command, activeSkillRuntime.agentRuntime)
// Freshness cannot verify WSL, so report presence there.
const freshnessSkillName =
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : updateTarget.skillName
const freshnessSkillName = activeSkillRuntime.canUseLocalSkillFreshness
? updateTarget.skillName
: undefined
const getPrerequisiteStatus = useCallback(
() =>

View File

@ -0,0 +1,10 @@
import { ORCA_CLI_SKILL_NAME } from '@/lib/agent-feature-install-commands'
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
import type { LocalAgentRuntime } from './CliSkillRuntimeSetup'
export function useLocalCliSkillFreshnessName(agentRuntime: LocalAgentRuntime): string | undefined {
const activeSkillRuntime = useActiveProjectSkillRuntime()
return agentRuntime.runtime === 'host' && activeSkillRuntime.canUseLocalSkillFreshness
? ORCA_CLI_SKILL_NAME
: undefined
}

View File

@ -16,6 +16,8 @@ const mocks = vi.hoisted(() => ({
toastDismiss: vi.fn(),
requestDialog: vi.fn(),
settingsLoaded: true,
canUseLocalSkillFreshness: true,
freshnessEnabled: true,
inventory: null as SkillFreshnessInventory | null,
error: null as string | null
}))
@ -57,11 +59,20 @@ function eligibleInventory(): SkillFreshnessInventory {
}
vi.mock('@/hooks/useSkillFreshness', () => ({
useSkillFreshness: () => ({
inventory: mocks.inventory,
loading: false,
error: mocks.error,
refresh: vi.fn()
useSkillFreshness: (enabled = true) => {
mocks.freshnessEnabled = enabled
return {
inventory: mocks.inventory,
loading: false,
error: mocks.error,
refresh: vi.fn()
}
}
}))
vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
useActiveProjectSkillRuntime: () => ({
canUseLocalSkillFreshness: mocks.canUseLocalSkillFreshness
})
}))
@ -107,6 +118,8 @@ describe('SkillFreshnessNudge', () => {
beforeEach(() => {
mocks.dismissed = []
mocks.settingsLoaded = true
mocks.canUseLocalSkillFreshness = true
mocks.freshnessEnabled = true
mocks.inventory = eligibleInventory()
mocks.error = null
mocks.updateSettings.mockReset()
@ -179,6 +192,19 @@ describe('SkillFreshnessNudge', () => {
expect(mocks.updateSettings).not.toHaveBeenCalled()
})
it('retracts an active local nudge when runtime freshness becomes unavailable', async () => {
await renderNudge()
const options = mocks.toastInfo.mock.calls[0]?.[1]
mocks.canUseLocalSkillFreshness = false
await rerenderNudge()
options.onDismiss()
expect(mocks.freshnessEnabled).toBe(false)
expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast')
expect(mocks.updateSettings).not.toHaveBeenCalled()
})
it('persists the exact placement/revision key once on explicit dismissal', async () => {
await renderNudge()

View File

@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import { Terminal } from 'lucide-react'
import { toast } from 'sonner'
import { useSkillFreshness } from '@/hooks/useSkillFreshness'
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { skillPlacementParticipatesInGlobalFreshness } from '../../../../shared/skill-freshness'
@ -25,7 +26,8 @@ function candidateKey(args: {
}
export function SkillFreshnessNudge(): null {
const state = useSkillFreshness()
const activeSkillRuntime = useActiveProjectSkillRuntime()
const state = useSkillFreshness(activeSkillRuntime.canUseLocalSkillFreshness)
const settingsLoaded = useAppStore((store) => store.settings !== null)
const dismissed = useAppStore(
(store) => store.settings?.dismissedSkillFreshnessNudges ?? NO_DISMISSED_FRESHNESS_NUDGES
@ -36,6 +38,15 @@ export function SkillFreshnessNudge(): null {
const activeNudgeRef = useRef<ActiveFreshnessNudge | null>(null)
useEffect(() => {
if (!activeSkillRuntime.canUseLocalSkillFreshness) {
const active = activeNudgeRef.current
if (active) {
active.persistDismissal = false
activeNudgeRef.current = null
toast.dismiss(active.id)
}
return
}
const inventory = state.inventory
if (!settingsLoaded) {
return
@ -182,7 +193,14 @@ export function SkillFreshnessNudge(): null {
}
)
activeNudgeRef.current = nextActive
}, [dismissed, settingsLoaded, state.error, state.inventory, updateSettings])
}, [
activeSkillRuntime.canUseLocalSkillFreshness,
dismissed,
settingsLoaded,
state.error,
state.inventory,
updateSettings
])
return null
}

View File

@ -8,14 +8,16 @@ import { SkillFreshnessStatusPill } from './SkillFreshnessStatusPill'
import { consumeSkillFreshnessUpdateDialogRequest } from './skill-freshness-update-dialog'
const mocks = vi.hoisted(() => ({
inventory: null as SkillFreshnessInventory | null
inventory: null as SkillFreshnessInventory | null,
loading: false,
error: null as string | null
}))
vi.mock('@/hooks/useSkillFreshness', () => ({
useSkillFreshness: () => ({
inventory: mocks.inventory,
loading: false,
error: null,
loading: mocks.loading,
error: mocks.error,
refresh: vi.fn()
})
}))
@ -76,6 +78,8 @@ async function renderPill(skillName: string): Promise<HTMLDivElement> {
describe('SkillFreshnessStatusPill', () => {
beforeEach(() => {
mocks.inventory = null
mocks.loading = false
mocks.error = null
// Why: the dialog request is module-level state shared across tests.
consumeSkillFreshnessUpdateDialogRequest()
})
@ -127,6 +131,22 @@ describe('SkillFreshnessStatusPill', () => {
expect(detailsButton(rendered)).toBeNull()
})
it('shows a neutral verdict while freshness is being checked', async () => {
mocks.loading = true
const rendered = await renderPill('orca-cli')
expect(pillText(rendered)).toBe('Checking...')
expect(detailsButton(rendered)).toBeNull()
})
it('does not report success after a freshness check fails', async () => {
mocks.error = 'Could not read skills.'
const rendered = await renderPill('orca-cli')
expect(pillText(rendered)).toBe('Check failed')
expect(detailsButton(rendered)).toBeNull()
})
it('opens the freshness review dialog from Details', async () => {
mocks.inventory = inventory([{ name: 'orca-cli', status: 'outdated' }], ['orca-cli'])
const rendered = await renderPill('orca-cli')

View File

@ -51,7 +51,21 @@ function statusPill(status: SkillFreshnessDisplayStatus): React.JSX.Element {
// somewhere the update cannot reach — and green must never stand in for that last
// case, which is real drift the user would otherwise have no way to see.
export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): React.JSX.Element {
const { inventory } = useSkillFreshness()
const { inventory, loading, error } = useSkillFreshness()
if (loading && !inventory) {
return (
<IntegrationStatusPill tone="neutral">
{translate('auto.components.skills.SkillFreshnessStatusPill.checking', 'Checking...')}
</IntegrationStatusPill>
)
}
if (error && !inventory) {
return (
<IntegrationStatusPill tone="attention">
{translate('auto.components.skills.SkillFreshnessStatusPill.checkFailed', 'Check failed')}
</IntegrationStatusPill>
)
}
const status = getSkillFreshnessDisplayStatus(inventory, skillName)
// Why: the dialog lists every placement, so Details is offered whenever a placement
// is what drove the status — an available update, or a copy that blocked one.

View File

@ -32,6 +32,10 @@ vi.mock('@/hooks/useSkillFreshness', () => ({
})
}))
vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
useActiveProjectSkillRuntime: () => ({ canUseLocalSkillFreshness: true })
}))
vi.mock('@/hooks/useInstalledAgentSkills', () => ({
notifyInstalledAgentSkillsChanged: mocks.notifyChanged
}))

View File

@ -7,6 +7,7 @@ import {
type SkillFreshnessInventory
} from '../../../../shared/skill-freshness'
import { useSkillFreshness } from '@/hooks/useSkillFreshness'
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
@ -63,7 +64,8 @@ function RunLog({ output }: { output: string }): React.JSX.Element | null {
}
export function SkillFreshnessUpdateDialog(): React.JSX.Element {
const state = useSkillFreshness()
const activeSkillRuntime = useActiveProjectSkillRuntime()
const state = useSkillFreshness(activeSkillRuntime.canUseLocalSkillFreshness)
const run = useSkillUpdateRun()
const open = useSyncExternalStore(
subscribeSkillFreshnessUpdateDialog,

View File

@ -3,7 +3,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { renderHook } from '@testing-library/react'
import { getDefaultSettings } from '../../../shared/constants'
import { useAppStore } from '@/store'
import { useActiveProjectSkillRuntime } from './useActiveProjectSkillRuntime'
import {
shouldUseLocalSkillFreshness,
useActiveProjectSkillRuntime
} from './useActiveProjectSkillRuntime'
function setPlatform(platform: NodeJS.Platform): void {
;(window as unknown as { api: unknown }).api = {
@ -42,4 +45,21 @@ describe('useActiveProjectSkillRuntime', () => {
expect(result.current.terminalShellOverride).toBeUndefined()
})
it('limits local freshness to resolved host runtimes', () => {
expect(shouldUseLocalSkillFreshness({ kind: 'local' }, undefined)).toBe(true)
expect(
shouldUseLocalSkillFreshness({ kind: 'local' }, { runtime: 'host', label: 'Host' })
).toBe(true)
expect(shouldUseLocalSkillFreshness({ kind: 'local' }, { runtime: 'wsl', label: 'WSL' })).toBe(
false
)
expect(
shouldUseLocalSkillFreshness(
{ kind: 'environment', environmentId: 'ssh-production' },
undefined
)
).toBe(false)
expect(shouldUseLocalSkillFreshness(null, undefined)).toBe(false)
})
})

View File

@ -2,6 +2,8 @@ import { useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
import type { SkillDiscoveryTarget } from '../../../shared/skills'
import { useActiveSkillDiscoveryRuntimeTarget } from './use-active-skill-discovery-runtime-target'
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import {
getProjectAgentSkillRuntime,
@ -19,12 +21,21 @@ type ActiveProjectSkillRuntime = {
agentRuntime?: ProjectAgentSkillRuntime
terminalShellOverride?: string
installDisabledReason: string | null
canUseLocalSkillFreshness: boolean
}
const EMPTY_ACTIVE_PROJECT_SKILL_RUNTIME: ActiveProjectSkillRuntime = Object.freeze({
installDisabledReason: null
installDisabledReason: null,
canUseLocalSkillFreshness: false
})
export function shouldUseLocalSkillFreshness(
runtimeTarget: RuntimeClientTarget | null,
agentRuntime?: ProjectAgentSkillRuntime
): boolean {
return runtimeTarget?.kind === 'local' && agentRuntime?.runtime !== 'wsl'
}
// Why: on Windows the runtime resolution is rebuilt from scratch on every
// worktree-store change, so a same-runtime result still arrives with a fresh
// identity. Downstream skill discovery keys effects off `discoveryTarget`, so
@ -48,6 +59,7 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime {
)
const currentPlatform = getCurrentPlatform()
const windowsCapabilities = useWindowsTerminalCapabilities(currentPlatform === 'win32')
const runtimeTarget = useActiveSkillDiscoveryRuntimeTarget()
const resolved = useMemo(() => {
const projectRuntime = getLocalProjectExecutionRuntimeContext(
@ -67,9 +79,11 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime {
runtimeState.settings,
undefined
)
return terminalShellOverride
? { installDisabledReason: null, terminalShellOverride }
: EMPTY_ACTIVE_PROJECT_SKILL_RUNTIME
const canUseLocalSkillFreshness = shouldUseLocalSkillFreshness(runtimeTarget)
if (!terminalShellOverride && !canUseLocalSkillFreshness) {
return EMPTY_ACTIVE_PROJECT_SKILL_RUNTIME
}
return { installDisabledReason: null, terminalShellOverride, canUseLocalSkillFreshness }
}
const agentRuntime = getProjectAgentSkillRuntime(projectRuntime, currentPlatform)
@ -82,9 +96,10 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime {
runtimeState.settings,
agentRuntime
),
installDisabledReason: getProjectSkillInstallDisabledReason(projectRuntime)
installDisabledReason: getProjectSkillInstallDisabledReason(projectRuntime),
canUseLocalSkillFreshness: shouldUseLocalSkillFreshness(runtimeTarget, agentRuntime)
}
}, [currentPlatform, runtimeState, windowsCapabilities])
}, [currentPlatform, runtimeState, runtimeTarget, windowsCapabilities])
// Content-equal runtimes keep one reference so effect keys do not thrash.
// Adjust during render (not a ref write) when serialized identity changes.

View File

@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { act, StrictMode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SkillFreshnessInventory } from '../../../shared/skill-freshness'
@ -32,10 +32,12 @@ let root: Root | null = null
let container: HTMLDivElement | null = null
let state: SkillFreshnessState | null = null
const states = new Map<string, SkillFreshnessState>()
const renderedInventories: (SkillFreshnessInventory | null)[] = []
function Probe({ id = 'default' }: { id?: string }): null {
state = useSkillFreshness()
function Probe({ id = 'default', enabled = true }: { id?: string; enabled?: boolean }): null {
state = useSkillFreshness(enabled)
states.set(id, state)
renderedInventories.push(state.inventory)
return null
}
@ -44,6 +46,7 @@ describe('useSkillFreshness', () => {
_skillFreshnessCacheForTests.reset()
state = null
states.clear()
renderedInventories.length = 0
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
@ -51,6 +54,7 @@ describe('useSkillFreshness', () => {
afterEach(async () => {
vi.useRealTimers()
vi.restoreAllMocks()
if (root) {
await act(async () => root?.unmount())
}
@ -79,6 +83,65 @@ describe('useSkillFreshness', () => {
expect(state?.inventory?.scannedAt).toBe(2)
})
it('does not scan, subscribe, or refresh while local freshness is disabled', async () => {
const freshnessInventory = vi.fn().mockResolvedValue(inventory(1))
const addEventListener = vi.spyOn(window, 'addEventListener')
window.api = { skills: { freshnessInventory } } as never
await act(async () => root?.render(<Probe enabled={false} />))
await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed')))
await act(async () => state?.refresh())
expect(freshnessInventory).not.toHaveBeenCalled()
expect(state).toMatchObject({ inventory: null, loading: false, error: null })
expect(
addEventListener.mock.calls.filter(
([name]) => name === 'focus' || name === 'orca:installed-agent-skills-changed'
)
).toHaveLength(0)
})
it('forces one fresh scan before restoring authority after re-enable', async () => {
const second = deferred<SkillFreshnessInventory>()
const freshnessInventory = vi
.fn()
.mockResolvedValueOnce(inventory(1, ['orca-cli']))
.mockReturnValueOnce(second.promise)
window.api = { skills: { freshnessInventory } } as never
const renderProbes = (enabled: boolean): void => {
root?.render(
<StrictMode>
<Probe id="one" enabled={enabled} />
<Probe id="two" enabled={enabled} />
</StrictMode>
)
}
await act(async () => renderProbes(true))
expect(freshnessInventory).toHaveBeenCalledTimes(1)
expect(state?.inventory?.eligibleUpdateNames).toEqual(['orca-cli'])
await act(async () => renderProbes(true))
expect(freshnessInventory).toHaveBeenCalledTimes(1)
await act(async () => renderProbes(false))
await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed')))
expect(freshnessInventory).toHaveBeenCalledTimes(1)
expect(state).toMatchObject({ inventory: null, loading: false })
const reenableRenderStart = renderedInventories.length
await act(async () => renderProbes(true))
expect(freshnessInventory).toHaveBeenCalledTimes(2)
expect(state).toMatchObject({ inventory: null, loading: true })
expect(renderedInventories.slice(reenableRenderStart).every((value) => value === null)).toBe(
true
)
await act(async () => second.resolve(inventory(2)))
expect(state?.inventory?.scannedAt).toBe(2)
expect(state?.inventory?.eligibleUpdateNames).toEqual([])
})
it('skips focus rescans inside the cooldown but honors install-change events', async () => {
const first = deferred<SkillFreshnessInventory>()
const second = deferred<SkillFreshnessInventory>()

View File

@ -1,4 +1,4 @@
import { useEffect, useSyncExternalStore } from 'react'
import { useEffect, useRef, useSyncExternalStore } from 'react'
import type { SkillFreshnessInventory } from '../../../shared/skill-freshness'
import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event'
@ -24,7 +24,18 @@ let snapshot: SkillFreshnessSnapshot = {
loading: false,
error: null
}
const DISABLED_SNAPSHOT: SkillFreshnessSnapshot = Object.freeze({
inventory: null,
loading: false,
error: null
})
const REENABLING_SNAPSHOT: SkillFreshnessSnapshot = Object.freeze({
inventory: null,
loading: true,
error: null
})
const subscribers = new Set<() => void>()
let pendingReenableRefresh: Promise<void> | null = null
function publishSnapshot(next: SkillFreshnessSnapshot): void {
if (
@ -70,7 +81,10 @@ async function loadInventory(force: boolean): Promise<SkillFreshnessInventory> {
}
}
async function refreshSkillFreshness(force = true): Promise<void> {
/** Rescan freshness without going through the hook, for surfaces whose explicit
* refresh affordance must move the shared verdict (the installed-skills refreshed
* event reuses a completed scan and deliberately does not reach this store). */
export async function refreshSkillFreshness(force = true): Promise<void> {
if (scheduledFocusRescan !== null) {
window.clearTimeout(scheduledFocusRescan)
scheduledFocusRescan = null
@ -133,14 +147,26 @@ function subscribe(subscriber: () => void): () => void {
if (subscribers.size === 0) {
window.removeEventListener('focus', onWindowFocus)
window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged)
if (scheduledFocusRescan !== null) {
window.clearTimeout(scheduledFocusRescan)
scheduledFocusRescan = null
}
}
}
}
function subscribeDisabled(): () => void {
return () => {}
}
function getSnapshot(): SkillFreshnessSnapshot {
return snapshot
}
function getDisabledSnapshot(): SkillFreshnessSnapshot {
return DISABLED_SNAPSHOT
}
function ensureInventoryLoaded(): void {
if (!snapshot.inventory && !snapshot.loading) {
void refreshSkillFreshness(false)
@ -151,14 +177,41 @@ export type SkillFreshnessState = SkillFreshnessSnapshot & {
refresh: () => Promise<void>
}
export function useSkillFreshness(): SkillFreshnessState {
const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
async function skipSkillFreshnessRefresh(): Promise<void> {}
function refreshSkillFreshnessAfterReenable(): Promise<void> {
pendingReenableRefresh ??= refreshSkillFreshness(true).finally(() => {
pendingReenableRefresh = null
})
return pendingReenableRefresh
}
export function useSkillFreshness(enabled = true): SkillFreshnessState {
const previousEnabledRef = useRef(enabled)
const reenabled = enabled && !previousEnabledRef.current
const current = useSyncExternalStore(
enabled ? subscribe : subscribeDisabled,
enabled ? getSnapshot : getDisabledSnapshot,
enabled ? getSnapshot : getDisabledSnapshot
)
useEffect(() => {
const wasEnabled = previousEnabledRef.current
previousEnabledRef.current = enabled
if (!enabled) {
return
}
if (!wasEnabled) {
void refreshSkillFreshnessAfterReenable()
return
}
ensureInventoryLoaded()
}, [])
}, [enabled])
return { ...current, refresh: refreshSkillFreshness }
return {
...(reenabled ? REENABLING_SNAPSHOT : current),
refresh: enabled ? refreshSkillFreshness : skipSkillFreshnessRefresh
}
}
export const _skillFreshnessCacheForTests = {
@ -169,6 +222,7 @@ export const _skillFreshnessCacheForTests = {
completedRevision = -1
lastCompletedScanAt = 0
refreshSequence = 0
pendingReenableRefresh = null
if (scheduledFocusRescan !== null) {
window.clearTimeout(scheduledFocusRescan)
scheduledFocusRescan = null

View File

@ -3929,7 +3929,9 @@
"upToDate": "Up to date",
"installed": "Installed",
"details": "Details",
"needsAttention": "Review skill"
"needsAttention": "Review skill",
"checking": "Checking...",
"checkFailed": "Check failed"
}
},
"sidebar": {