[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:
parent
886fa7b438
commit
79251d7a98
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})();
|
||||
`
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,8 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
|
|||
discoveryTarget: undefined,
|
||||
agentRuntime: { runtime: mocks.runtime },
|
||||
terminalShellOverride: undefined,
|
||||
installDisabledReason: null
|
||||
installDisabledReason: null,
|
||||
canUseLocalSkillFreshness: mocks.runtime !== 'wsl'
|
||||
})
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
|
|||
discoveryTarget: undefined,
|
||||
agentRuntime: { runtime: 'native' },
|
||||
terminalShellOverride: undefined,
|
||||
installDisabledReason: null
|
||||
installDisabledReason: null,
|
||||
canUseLocalSkillFreshness: true
|
||||
})
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ vi.mock('@/hooks/useSkillFreshness', () => ({
|
|||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
|
||||
useActiveProjectSkillRuntime: () => ({ canUseLocalSkillFreshness: true })
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useInstalledAgentSkills', () => ({
|
||||
notifyInstalledAgentSkillsChanged: mocks.notifyChanged
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3929,7 +3929,9 @@
|
|||
"upToDate": "Up to date",
|
||||
"installed": "Installed",
|
||||
"details": "Details",
|
||||
"needsAttention": "Review skill"
|
||||
"needsAttention": "Review skill",
|
||||
"checking": "Checking...",
|
||||
"checkFailed": "Check failed"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue