fix(skills): read installed skills from the connected remote runtime (#6887)
* fix(skills): read installed skills from the connected remote runtime The "Not installed" badge stayed on in Settings even after a skill was installed against a remote `orca serve`. Skill discovery always ran through the local `skills:discover` IPC, so it scanned the client's home dir while the install (and the skill files) landed on the server. The skills browser had the same blind spot. Route discovery to the runtime that owns it, mirroring git/hooks/terminal: local IPC by default, remote runtime RPC (`skills.discover`) when an Orca runtime environment is active. The discovery cache is now scoped per runtime so local and remote results never collide. Extract the discovery cache/transport into a store-free module (`installed-agent-skill-discovery.ts`) and a shared runtime-target hook, so the React hook stays under `max-lines` and store slices can import the change-notifier without pulling the app store into a circular import. Independent of the terminal selector fix (#6816); addresses the still-broken install-status half of #6789. * fix(skills): runtime-agnostic scan-error toast + docstrings Address review on #6887: - The skills-scan error toast said "Could not scan local skills", but discovery can now target a remote runtime; drop "local" (source + locales). - Add short JSDoc to the discovery helpers and skill hooks to clear the docstring-coverage gate. * fix(skills): route discovery through the active runtime and scope its cache Rebuilt on the repo's standard runtime-client pattern (getActiveRuntimeTarget + callRuntimeRpc) instead of a bespoke transport, and keeps the renderer discovery cache keyed per runtime so a remote result never leaks into the local host's badge after switching environments. * fix(skills): keep the discovery cache store-free to break the import cycle Reading the active runtime from the app store inside the hook module closed a cycle (store -> repos slice -> hook -> store) that broke module init in three suites. The cache/transport moves to a store-free module the repos slice can import; only the hook itself touches the store. * fix(skills): scan the host the install actually lands on Reviewer round 1 found the badge could scan a different machine than the Install button writes to: skill install terminals route through getSingleFocusedRuntimeEnvironmentId, which declines to guess an owner while several runtimes are saved, so a two-environment user installed locally while discovery scanned the remote and the badge never flipped. Discovery now resolves through that same resolver, and holds a loading state until settings and the runtime catalog have hydrated instead of flashing 'Not installed'. Also drops the unreachable cwd/worktreeId forwarding (no caller can produce it) and retires three SkillsPage strings that a remote scan makes false. * fix(skills): close the round-2 review findings - SkillsPage had no generation guard, so a slow local scan could land after a newer remote scan and silently redisplay the client's skills. - The round-2 selector took a useShallow object including runtimeEnvironments, whose identity churns on every status refresh; that re-fired every consumer's scan. Select the resolved id instead. - A failed runtime-environment catalog read never set the hydrated flag, so discovery would have spun for the whole session with no retry affordance. An unreadable catalog is settled, which is what terminal routing assumes. - Remote cache keys no longer fragment on a client-side target the remote call discards, which was issuing the same RPC once per target shape. - Cover each hydration conjunct separately; the combined test covered neither. - First tests for SkillsPage, which had none. * fix(skills): settle the runtime catalog without loosening host routing Round 3 set runtimeEnvironmentCatalogHydrated on a failed catalog read so skill discovery would stop waiting. That flag also gates fail-closed host routing (worktree-operation-route mayBeLegacyLocal), so flipping it on failure would have routed ownerless legacy worktrees — including removals — to the local host off a stale empty list. Add a separate 'settled' flag for surfaces that only need to stop waiting, and leave 'hydrated' meaning what its doc says. fetchSettings now probes the catalog even when the settings read fails, so a rejected settings.get cannot strand every skill badge on a spinner. * test(skills): pin the settings-failure runtime catalog probe The only hunk in the review no mutation could kill. --------- Co-authored-by: vladmesh <vladmesh@gmail.com> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
parent
fe6f929c6e
commit
8f36cd9baf
|
|
@ -0,0 +1,113 @@
|
|||
import { BookOpen, Clock, FolderOpen } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { DiscoveredSkill, SkillProvider } from '../../../../shared/skills'
|
||||
import { pluralize, sourceLabels } from './skill-display-labels'
|
||||
|
||||
const providerLabels: Record<SkillProvider, string> = {
|
||||
codex: 'Codex',
|
||||
claude: 'Claude',
|
||||
'agent-skills': 'Agent Skills'
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
function formatUpdatedAt(value: number | null): string {
|
||||
return value ? dateFormatter.format(new Date(value)) : 'Unknown'
|
||||
}
|
||||
|
||||
export function SkillCard({ skill }: { skill: DiscoveredSkill }): React.JSX.Element {
|
||||
const revealSkill = async (): Promise<void> => {
|
||||
const result = await window.api.shell.openInFileManager(skill.skillFilePath)
|
||||
if (!result.ok) {
|
||||
toast.error(
|
||||
translate('auto.components.skills.SkillsPage.995fde8337', 'Could not reveal skill file')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="rounded-lg">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background">
|
||||
<BookOpen className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="min-w-0 truncate text-sm font-semibold">{skill.name}</h3>
|
||||
<Badge
|
||||
variant={skill.installed ? 'secondary' : 'outline'}
|
||||
className="h-5 text-[10px]"
|
||||
>
|
||||
{skill.installed
|
||||
? translate('auto.components.skills.SkillsPage.0c74e7ff34', 'Installed')
|
||||
: translate('auto.components.skills.SkillsPage.35b9a724a0', 'Available')}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="h-5 text-[10px]">
|
||||
{sourceLabels[skill.sourceKind]}
|
||||
</Badge>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{skill.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.skills.SkillsPage.9963dff6d3', 'No description found.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
void revealSkill()
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{translate('auto.components.skills.SkillsPage.dc4c3328ee', 'Reveal file')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 text-[11px] text-muted-foreground md:grid-cols-[1fr_auto_auto] md:items-center">
|
||||
<div className="min-w-0 truncate font-mono" title={skill.skillFilePath}>
|
||||
{skill.skillFilePath}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{skill.providers.map((provider) => (
|
||||
<Badge key={provider} variant="outline" className="h-5 text-[10px]">
|
||||
{providerLabels[provider]}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||
<span>{skill.sourceLabel}</span>
|
||||
<span>{pluralize(skill.fileCount, 'file')}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{formatUpdatedAt(skill.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
// @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 { GlobalSettings } from '../../../../shared/types'
|
||||
import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills'
|
||||
import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { useAppStore } from '@/store'
|
||||
import SkillsPage from './SkillsPage'
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
function skill(name: string): DiscoveredSkill {
|
||||
return {
|
||||
id: `skill-${name}`,
|
||||
name,
|
||||
description: null,
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Agent skills home',
|
||||
rootPath: `/home/dev/.agents/skills`,
|
||||
directoryPath: `/home/dev/.agents/skills/${name}`,
|
||||
skillFilePath: `/home/dev/.agents/skills/${name}/SKILL.md`,
|
||||
installed: true,
|
||||
fileCount: 1,
|
||||
updatedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
function discoveryResult(names: string[]): SkillDiscoveryResult {
|
||||
return { skills: names.map(skill), sources: [], scannedAt: 1 }
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function setRuntimeOwner(environmentId: string | null): void {
|
||||
useAppStore.setState({
|
||||
settings: { activeRuntimeEnvironmentId: environmentId } as GlobalSettings,
|
||||
runtimeEnvironments: (environmentId ? [{ id: environmentId }] : []) as never,
|
||||
runtimeEnvironmentCatalogSettled: true
|
||||
})
|
||||
}
|
||||
|
||||
async function renderPage(): Promise<void> {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<SkillsPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await act(async () => {
|
||||
for (let tick = 0; tick < 8; tick += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Skill names currently rendered as cards. */
|
||||
function renderedSkillNames(): string[] {
|
||||
return [...(container?.querySelectorAll('h3') ?? [])].map((node) => node.textContent ?? '')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setRuntimeOwner(null)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount()
|
||||
})
|
||||
}
|
||||
root = null
|
||||
container?.remove()
|
||||
container = null
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
useAppStore.setState({
|
||||
settings: null,
|
||||
runtimeEnvironments: [],
|
||||
runtimeEnvironmentCatalogSettled: false
|
||||
})
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(window, 'api')
|
||||
})
|
||||
|
||||
describe('SkillsPage', () => {
|
||||
it('scans the connected remote runtime instead of the client disk', async () => {
|
||||
const discover = vi.fn().mockResolvedValue(discoveryResult(['local-only']))
|
||||
const call = vi.fn(
|
||||
async (args: { method: string; selector?: string }) =>
|
||||
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
|
||||
id: 'skills',
|
||||
ok: true,
|
||||
result: discoveryResult(['remote-only'])
|
||||
}
|
||||
)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { skills: { discover }, runtimeEnvironments: { call } }
|
||||
})
|
||||
setRuntimeOwner('env-1')
|
||||
|
||||
await renderPage()
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(discover).not.toHaveBeenCalled()
|
||||
expect(renderedSkillNames()).toContain('remote-only')
|
||||
})
|
||||
|
||||
// Why: a cold local scan walks every skill root, so it can land after a newer
|
||||
// remote scan. Without a generation guard it overwrites the remote list and
|
||||
// the page silently shows the client's skills again — #6789 all over.
|
||||
it('does not let a slow local scan overwrite a newer remote scan', async () => {
|
||||
const localScan = deferred<SkillDiscoveryResult>()
|
||||
const discover = vi.fn().mockReturnValue(localScan.promise)
|
||||
const call = vi.fn(
|
||||
async (args: { method: string; selector?: string }) =>
|
||||
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
|
||||
id: 'skills',
|
||||
ok: true,
|
||||
result: discoveryResult(['remote-only'])
|
||||
}
|
||||
)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { skills: { discover }, runtimeEnvironments: { call } }
|
||||
})
|
||||
|
||||
await renderPage()
|
||||
await act(async () => {
|
||||
setRuntimeOwner('env-1')
|
||||
})
|
||||
await flushMicrotasks()
|
||||
expect(renderedSkillNames()).toContain('remote-only')
|
||||
|
||||
localScan.resolve(discoveryResult(['local-only']))
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(renderedSkillNames()).toContain('remote-only')
|
||||
expect(renderedSkillNames()).not.toContain('local-only')
|
||||
})
|
||||
|
||||
it('keeps scanning rather than listing client skills before the owner is known', async () => {
|
||||
const discover = vi.fn().mockResolvedValue(discoveryResult(['local-only']))
|
||||
const call = vi.fn()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { skills: { discover }, runtimeEnvironments: { call } }
|
||||
})
|
||||
useAppStore.setState({ runtimeEnvironmentCatalogSettled: false })
|
||||
|
||||
await renderPage()
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(discover).not.toHaveBeenCalled()
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
expect(container?.textContent).toContain('Scanning skills')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ArrowLeft, BookOpen, Clock, FolderOpen, Loader2, RefreshCw, Search } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowLeft, BookOpen, Loader2, RefreshCw, Search } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -12,136 +11,19 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/store'
|
||||
import { discoverSkillsForRuntimeTarget } from '@/runtime/runtime-skills-client'
|
||||
import { useActiveSkillDiscoveryRuntimeTarget } from '@/hooks/use-active-skill-discovery-runtime-target'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import type {
|
||||
DiscoveredSkill,
|
||||
SkillDiscoveryResult,
|
||||
SkillProvider,
|
||||
SkillSourceKind
|
||||
} from '../../../../shared/skills'
|
||||
import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills'
|
||||
import { SkillCard } from './SkillCard'
|
||||
import { pluralize, sourceLabels } from './skill-display-labels'
|
||||
import { countSkillsBySource, filterSkills, type SkillsFilterState } from './skills-filter'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const providerLabels: Record<SkillProvider, string> = {
|
||||
codex: 'Codex',
|
||||
claude: 'Claude',
|
||||
'agent-skills': 'Agent Skills'
|
||||
}
|
||||
|
||||
const sourceLabels: Record<SkillSourceKind, string> = {
|
||||
home: 'Home',
|
||||
repo: 'Repository',
|
||||
bundled: 'Bundled',
|
||||
plugin: 'Plugin'
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
const EMPTY_SKILLS: DiscoveredSkill[] = []
|
||||
|
||||
function formatUpdatedAt(value: number | null): string {
|
||||
return value ? dateFormatter.format(new Date(value)) : 'Unknown'
|
||||
}
|
||||
|
||||
function pluralize(count: number, singular: string): string {
|
||||
return `${count} ${singular}${count === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
function SkillCard({ skill }: { skill: DiscoveredSkill }): React.JSX.Element {
|
||||
const revealSkill = async (): Promise<void> => {
|
||||
const result = await window.api.shell.openInFileManager(skill.skillFilePath)
|
||||
if (!result.ok) {
|
||||
toast.error(
|
||||
translate('auto.components.skills.SkillsPage.995fde8337', 'Could not reveal skill file')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="rounded-lg">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background">
|
||||
<BookOpen className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="min-w-0 truncate text-sm font-semibold">{skill.name}</h3>
|
||||
<Badge
|
||||
variant={skill.installed ? 'secondary' : 'outline'}
|
||||
className="h-5 text-[10px]"
|
||||
>
|
||||
{skill.installed
|
||||
? translate('auto.components.skills.SkillsPage.0c74e7ff34', 'Local')
|
||||
: translate('auto.components.skills.SkillsPage.35b9a724a0', 'Available')}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="h-5 text-[10px]">
|
||||
{sourceLabels[skill.sourceKind]}
|
||||
</Badge>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{skill.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.skills.SkillsPage.9963dff6d3', 'No description found.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
void revealSkill()
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{translate('auto.components.skills.SkillsPage.dc4c3328ee', 'Reveal file')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 text-[11px] text-muted-foreground md:grid-cols-[1fr_auto_auto] md:items-center">
|
||||
<div className="min-w-0 truncate font-mono" title={skill.skillFilePath}>
|
||||
{skill.skillFilePath}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{skill.providers.map((provider) => (
|
||||
<Badge key={provider} variant="outline" className="h-5 text-[10px]">
|
||||
{providerLabels[provider]}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||
<span>{skill.sourceLabel}</span>
|
||||
<span>{pluralize(skill.fileCount, 'file')}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{formatUpdatedAt(skill.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
loading,
|
||||
hasSkills,
|
||||
|
|
@ -165,10 +47,7 @@ function EmptyState({
|
|||
? translate('auto.components.skills.SkillsPage.cd7893fbc1', 'Scanning skills')
|
||||
: hasSkills
|
||||
? translate('auto.components.skills.SkillsPage.6a62a0168c', 'No matches')
|
||||
: translate(
|
||||
'auto.components.skills.SkillsPage.4acd6d68ec',
|
||||
'No local skills found'
|
||||
)}
|
||||
: translate('auto.components.skills.SkillsPage.4acd6d68ec', 'No skills found')}
|
||||
</h3>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{hasSkills
|
||||
|
|
@ -178,7 +57,7 @@ function EmptyState({
|
|||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillsPage.ab5b777350',
|
||||
'Checked local home, repository, bundled, and plugin skill folders.'
|
||||
'Checked home, repository, bundled, and plugin skill folders.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -195,6 +74,7 @@ function EmptyState({
|
|||
|
||||
export default function SkillsPage(): React.JSX.Element {
|
||||
const closeSkillsPage = useAppStore((s) => s.closeSkillsPage)
|
||||
const runtimeTarget = useActiveSkillDiscoveryRuntimeTarget()
|
||||
const [result, setResult] = useState<SkillDiscoveryResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filters, setFilters] = useState<SkillsFilterState>({
|
||||
|
|
@ -203,27 +83,38 @@ export default function SkillsPage(): React.JSX.Element {
|
|||
provider: 'all'
|
||||
})
|
||||
const mountedRef = useMountedRef()
|
||||
const scanGenerationRef = useRef(0)
|
||||
|
||||
const loadSkills = useCallback(async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
// Why: a cold local scan walks every skill root, so switching runtimes can
|
||||
// land a stale result after a newer one. Only the newest scan may write.
|
||||
const scanGeneration = ++scanGenerationRef.current
|
||||
const isCurrentScan = (): boolean =>
|
||||
mountedRef.current && scanGeneration === scanGenerationRef.current
|
||||
if (!runtimeTarget) {
|
||||
// Why: keep scanning until the owning runtime is known, rather than
|
||||
// showing the client's skills to someone whose skills live remotely.
|
||||
return
|
||||
}
|
||||
try {
|
||||
const nextResult = await window.api.skills.discover()
|
||||
if (mountedRef.current) {
|
||||
const nextResult = await discoverSkillsForRuntimeTarget(runtimeTarget)
|
||||
if (isCurrentScan()) {
|
||||
setResult(nextResult)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to discover skills:', error)
|
||||
if (mountedRef.current) {
|
||||
if (isCurrentScan()) {
|
||||
toast.error(
|
||||
translate('auto.components.skills.SkillsPage.ea72d6185b', 'Could not scan local skills')
|
||||
translate('auto.components.skills.SkillsPage.ea72d6185b', 'Could not scan skills')
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
if (isCurrentScan()) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [mountedRef])
|
||||
}, [mountedRef, runtimeTarget])
|
||||
|
||||
useEffect(() => {
|
||||
void loadSkills()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import type { SkillSourceKind } from '../../../../shared/skills'
|
||||
|
||||
export const sourceLabels: Record<SkillSourceKind, string> = {
|
||||
home: 'Home',
|
||||
repo: 'Repository',
|
||||
bundled: 'Bundled',
|
||||
plugin: 'Plugin'
|
||||
}
|
||||
|
||||
export function pluralize(count: number, singular: string): string {
|
||||
return `${count} ${singular}${count === 1 ? '' : 's'}`
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../../shared/skills'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { discoverSkillsForRuntimeTarget } from '@/runtime/runtime-skills-client'
|
||||
import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event'
|
||||
|
||||
export const LOCAL_RUNTIME_TARGET: RuntimeClientTarget = { kind: 'local' }
|
||||
|
||||
let cachedDiscoveryByTarget = new Map<string, SkillDiscoveryResult>()
|
||||
let pendingDiscoveryByTarget = new Map<string, Promise<SkillDiscoveryResult>>()
|
||||
let pendingDiscoverySatisfiesForcedRefreshByTarget = new Map<string, boolean>()
|
||||
|
||||
/** Last completed scan for a runtime-scoped key, for a synchronous first render. */
|
||||
export function getCachedSkillDiscovery(key: string): SkillDiscoveryResult | null {
|
||||
return cachedDiscoveryByTarget.get(key) ?? null
|
||||
}
|
||||
|
||||
/** Invalidate every cached scan and tell mounted hooks to re-scan (e.g. after an install). */
|
||||
export function notifyInstalledAgentSkillsChanged(): void {
|
||||
cachedDiscoveryByTarget.clear()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent(INSTALLED_AGENT_SKILLS_CHANGED_EVENT))
|
||||
}
|
||||
}
|
||||
|
||||
export function resetSkillDiscoveryCacheForTests(): void {
|
||||
cachedDiscoveryByTarget = new Map()
|
||||
pendingDiscoveryByTarget = new Map()
|
||||
pendingDiscoverySatisfiesForcedRefreshByTarget = new Map()
|
||||
}
|
||||
|
||||
function normalizeSkillDiscoveryTarget(
|
||||
target: SkillDiscoveryTarget | undefined
|
||||
): SkillDiscoveryTarget | undefined {
|
||||
const projectRuntime = target?.projectRuntime
|
||||
if (projectRuntime) {
|
||||
if (projectRuntime.status === 'repair-required') {
|
||||
return { projectRuntime }
|
||||
}
|
||||
if (projectRuntime.runtime.kind === 'wsl') {
|
||||
return {
|
||||
runtime: 'wsl',
|
||||
wslDistro: projectRuntime.runtime.distro,
|
||||
projectRuntime
|
||||
}
|
||||
}
|
||||
return {
|
||||
runtime: 'host',
|
||||
projectRuntime
|
||||
}
|
||||
}
|
||||
|
||||
if (target?.runtime !== 'wsl') {
|
||||
return undefined
|
||||
}
|
||||
return { runtime: 'wsl', wslDistro: target.wslDistro?.trim() || null }
|
||||
}
|
||||
|
||||
export function getSkillDiscoveryTargetKey(target: SkillDiscoveryTarget | undefined): string {
|
||||
if (target?.projectRuntime) {
|
||||
return target.projectRuntime.status === 'resolved'
|
||||
? target.projectRuntime.runtime.cacheKey
|
||||
: target.projectRuntime.repair.cacheKey
|
||||
}
|
||||
const normalizedTarget = normalizeSkillDiscoveryTarget(target)
|
||||
return normalizedTarget?.runtime === 'wsl' ? `wsl:${normalizedTarget.wslDistro ?? ''}` : 'host'
|
||||
}
|
||||
|
||||
// Why: a connected remote runtime scans its own disk. Sharing the local key
|
||||
// would keep showing the client's skills after switching environments. The
|
||||
// caller's target is dropped for a remote scan (it describes the client's WSL /
|
||||
// project runtime), so it must not fragment the key either — otherwise the same
|
||||
// remote gets rescanned once per client-side target shape.
|
||||
export function getRuntimeScopedSkillDiscoveryKey(
|
||||
runtimeTarget: RuntimeClientTarget,
|
||||
target: SkillDiscoveryTarget | undefined
|
||||
): string {
|
||||
return runtimeTarget.kind === 'environment'
|
||||
? `runtime:${runtimeTarget.environmentId}`
|
||||
: getSkillDiscoveryTargetKey(target)
|
||||
}
|
||||
|
||||
function startInstalledAgentSkillDiscovery(
|
||||
force: boolean,
|
||||
target: SkillDiscoveryTarget | undefined,
|
||||
runtimeTarget: RuntimeClientTarget
|
||||
): Promise<SkillDiscoveryResult> {
|
||||
const key = getRuntimeScopedSkillDiscoveryKey(runtimeTarget, target)
|
||||
const normalizedTarget = normalizeSkillDiscoveryTarget(target)
|
||||
const discovery = discoverSkillsForRuntimeTarget(runtimeTarget, normalizedTarget)
|
||||
.then((result) => {
|
||||
cachedDiscoveryByTarget.set(key, result)
|
||||
return result
|
||||
})
|
||||
.finally(() => {
|
||||
if (pendingDiscoveryByTarget.get(key) === discovery) {
|
||||
pendingDiscoveryByTarget.delete(key)
|
||||
pendingDiscoverySatisfiesForcedRefreshByTarget.delete(key)
|
||||
}
|
||||
})
|
||||
pendingDiscoveryByTarget.set(key, discovery)
|
||||
pendingDiscoverySatisfiesForcedRefreshByTarget.set(key, force)
|
||||
return discovery
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached, de-duplicated skill scan for one runtime. Concurrent callers share a
|
||||
* single in-flight scan per key; `force` bypasses the cache to re-read disk.
|
||||
*/
|
||||
export async function discoverInstalledAgentSkills(
|
||||
force: boolean,
|
||||
target?: SkillDiscoveryTarget,
|
||||
runtimeTarget: RuntimeClientTarget = LOCAL_RUNTIME_TARGET
|
||||
): Promise<SkillDiscoveryResult> {
|
||||
const key = getRuntimeScopedSkillDiscoveryKey(runtimeTarget, target)
|
||||
const cachedDiscovery = cachedDiscoveryByTarget.get(key)
|
||||
if (!force && cachedDiscovery) {
|
||||
return cachedDiscovery
|
||||
}
|
||||
|
||||
const inFlightDiscovery = pendingDiscoveryByTarget.get(key)
|
||||
if (inFlightDiscovery) {
|
||||
if (!force || pendingDiscoverySatisfiesForcedRefreshByTarget.get(key)) {
|
||||
return inFlightDiscovery
|
||||
}
|
||||
try {
|
||||
await inFlightDiscovery
|
||||
} catch {
|
||||
// Why: an explicit re-check should still read current disk state even if
|
||||
// the older background scan failed.
|
||||
}
|
||||
const nextPendingDiscovery = pendingDiscoveryByTarget.get(key)
|
||||
if (nextPendingDiscovery && nextPendingDiscovery !== inFlightDiscovery) {
|
||||
return nextPendingDiscovery
|
||||
}
|
||||
}
|
||||
|
||||
return startInstalledAgentSkillDiscovery(force, target, runtimeTarget)
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { useMemo } from 'react'
|
||||
import { getSingleFocusedRuntimeEnvironmentId } from '@/lib/single-runtime-legacy-owner'
|
||||
import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
/** Distinguishes "not known yet" from "known to be the local host", without
|
||||
* colliding with an environment id that happens to be named the same. */
|
||||
const UNRESOLVED = Symbol('skill-discovery-runtime-unresolved')
|
||||
|
||||
/**
|
||||
* Runtime that owns skill discovery, or `null` while the store still cannot say.
|
||||
*
|
||||
* Resolved through `getSingleFocusedRuntimeEnvironmentId` rather than raw
|
||||
* `activeRuntimeEnvironmentId` on purpose: the skill *install* terminal routes
|
||||
* through that same resolver (`terminal-worktree-route.ts`), which declines to
|
||||
* guess an owner while several runtimes are saved. Scanning a host the install
|
||||
* cannot reach would leave the badge stuck on "Not installed" forever — #6789
|
||||
* again, just inverted. Scan and install must always name the same host.
|
||||
*/
|
||||
export function useActiveSkillDiscoveryRuntimeTarget(): RuntimeClientTarget | null {
|
||||
// Why: select the resolved id (a string) rather than its inputs. Selecting
|
||||
// `runtimeEnvironments` would churn identity every time a status refresh
|
||||
// restores an equal-but-new array, re-firing every consumer's scan.
|
||||
const environmentId = useAppStore((state) =>
|
||||
// Why: resolving to "local" before the catalog settles caches a client scan
|
||||
// under the local key and flashes "Not installed" at a user whose skills live
|
||||
// remotely. Settled rather than hydrated, so a failed catalog read degrades to
|
||||
// the local host instead of leaving every badge pending for the session.
|
||||
state.runtimeEnvironmentCatalogSettled
|
||||
? getSingleFocusedRuntimeEnvironmentId(state)
|
||||
: UNRESOLVED
|
||||
)
|
||||
return useMemo(
|
||||
() =>
|
||||
environmentId === UNRESOLVED
|
||||
? null
|
||||
: getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }),
|
||||
[environmentId]
|
||||
)
|
||||
}
|
||||
|
|
@ -2,13 +2,17 @@
|
|||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
DiscoveredSkill,
|
||||
SkillDiscoveryResult,
|
||||
SkillDiscoveryTarget
|
||||
} from '../../../shared/skills'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
|
||||
import type { GlobalSettings } from '../../../shared/types'
|
||||
import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
|
||||
type InstalledAgentSkillState,
|
||||
|
|
@ -104,10 +108,44 @@ afterEach(async () => {
|
|||
container = null
|
||||
latestState = null
|
||||
_installedAgentSkillDiscoveryInternalsForTests.reset()
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
useAppStore.setState({
|
||||
settings: null,
|
||||
runtimeEnvironments: [],
|
||||
runtimeEnvironmentCatalogSettled: false
|
||||
})
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(window, 'api')
|
||||
})
|
||||
|
||||
/** Drain the compat probe + RPC promise chain a remote scan walks before it lands in state. */
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await act(async () => {
|
||||
for (let tick = 0; tick < 8; tick += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the store the way a running app does. `savedEnvironmentIds` defaults to
|
||||
* just the focused one, which is the only shape that resolves to a remote owner.
|
||||
*/
|
||||
function setRuntimeOwner(
|
||||
environmentId: string | null,
|
||||
savedEnvironmentIds: readonly string[] = environmentId ? [environmentId] : []
|
||||
): void {
|
||||
useAppStore.setState({
|
||||
settings: { activeRuntimeEnvironmentId: environmentId } as GlobalSettings,
|
||||
runtimeEnvironments: savedEnvironmentIds.map((id) => ({ id })) as never,
|
||||
runtimeEnvironmentCatalogSettled: true
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setRuntimeOwner(null)
|
||||
})
|
||||
|
||||
describe('useInstalledAgentSkill', () => {
|
||||
it('ignores stale discovery results after the discovery target changes', async () => {
|
||||
const hostScan = deferred<SkillDiscoveryResult>()
|
||||
|
|
@ -245,4 +283,115 @@ describe('useInstalledAgentSkill', () => {
|
|||
projectRuntime: projectWslRuntime
|
||||
})
|
||||
})
|
||||
|
||||
it('scans the connected remote runtime and keeps that result out of the local cache', async () => {
|
||||
const discover = vi
|
||||
.fn<(target?: SkillDiscoveryTarget) => Promise<SkillDiscoveryResult>>()
|
||||
.mockResolvedValue(discoveryResult([]))
|
||||
const call = vi.fn(
|
||||
async (args: { method: string; selector?: string }) =>
|
||||
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
|
||||
id: 'skills',
|
||||
ok: true,
|
||||
result: discoveryResult([skill({ name: 'linear-tickets' })])
|
||||
}
|
||||
)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { skills: { discover }, runtimeEnvironments: { call } }
|
||||
})
|
||||
setRuntimeOwner('env-1')
|
||||
|
||||
await renderProbe()
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(latestState?.installed).toBe(true)
|
||||
expect(discover).not.toHaveBeenCalled()
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ selector: 'env-1', method: 'skills.discover' })
|
||||
)
|
||||
|
||||
// Why: the remote hit is keyed per environment, so switching back to the
|
||||
// local host must re-scan the client instead of replaying the server's list.
|
||||
await act(async () => {
|
||||
setRuntimeOwner(null)
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(discover).toHaveBeenCalledTimes(1)
|
||||
expect(latestState?.installed).toBe(false)
|
||||
})
|
||||
|
||||
// Why: the skill INSTALL terminal routes through getSingleFocusedRuntimeEnvironmentId,
|
||||
// which refuses to guess an owner while several runtimes are saved. Scanning the
|
||||
// focused remote here would leave the badge stuck on "Not installed" forever,
|
||||
// because the install actually lands on the local client.
|
||||
it('scans the local host when several saved runtimes make the install host ambiguous', async () => {
|
||||
const discover = vi
|
||||
.fn<(target?: SkillDiscoveryTarget) => Promise<SkillDiscoveryResult>>()
|
||||
.mockResolvedValue(discoveryResult([skill({ name: 'linear-tickets' })]))
|
||||
const call = vi.fn()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { skills: { discover }, runtimeEnvironments: { call } }
|
||||
})
|
||||
setRuntimeOwner('env-1', ['env-1', 'env-2'])
|
||||
|
||||
await renderProbe()
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
expect(discover).toHaveBeenCalledTimes(1)
|
||||
expect(latestState?.installed).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps loading instead of scanning the wrong host before the catalog settles', async () => {
|
||||
const discover = vi
|
||||
.fn<(target?: SkillDiscoveryTarget) => Promise<SkillDiscoveryResult>>()
|
||||
.mockResolvedValue(discoveryResult([]))
|
||||
const call = vi.fn()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { skills: { discover }, runtimeEnvironments: { call } }
|
||||
})
|
||||
// Why: a focused remote is already known here, so a missing gate resolves to
|
||||
// the local host and caches a client scan under the local key.
|
||||
useAppStore.setState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' } as GlobalSettings,
|
||||
runtimeEnvironments: [{ id: 'env-1' }] as never,
|
||||
runtimeEnvironmentCatalogSettled: false
|
||||
})
|
||||
|
||||
await renderProbe()
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(discover).not.toHaveBeenCalled()
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
expect(latestState?.loading).toBe(true)
|
||||
expect(latestState?.installed).toBe(false)
|
||||
})
|
||||
|
||||
// Why: a failed catalog read must degrade to the local host, not strand every
|
||||
// skill badge on a spinner with no retry affordance for the whole session.
|
||||
it('falls back to the local host once an unreadable catalog settles', async () => {
|
||||
const discover = vi
|
||||
.fn<(target?: SkillDiscoveryTarget) => Promise<SkillDiscoveryResult>>()
|
||||
.mockResolvedValue(discoveryResult([skill({ name: 'linear-tickets' })]))
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { skills: { discover }, runtimeEnvironments: { call: vi.fn() } }
|
||||
})
|
||||
useAppStore.setState({
|
||||
settings: null,
|
||||
runtimeEnvironments: [],
|
||||
runtimeEnvironmentCatalogSettled: true
|
||||
})
|
||||
|
||||
await renderProbe()
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(discover).toHaveBeenCalledTimes(1)
|
||||
expect(latestState?.loading).toBe(false)
|
||||
expect(latestState?.installed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,9 +8,22 @@ import type {
|
|||
} from '../../../shared/skills'
|
||||
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
|
||||
import { markOrchestrationSetupComplete } from '@/lib/orchestration-setup-state'
|
||||
import {
|
||||
discoverInstalledAgentSkills,
|
||||
getCachedSkillDiscovery,
|
||||
getRuntimeScopedSkillDiscoveryKey,
|
||||
getSkillDiscoveryTargetKey,
|
||||
resetSkillDiscoveryCacheForTests
|
||||
} from './installed-agent-skill-discovery'
|
||||
import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event'
|
||||
import { useActiveSkillDiscoveryRuntimeTarget } from './use-active-skill-discovery-runtime-target'
|
||||
import { useMountedRef } from './useMountedRef'
|
||||
|
||||
/** Placeholder key while the owning runtime is unknown; nothing is cached under it. */
|
||||
const UNRESOLVED_RUNTIME_DISCOVERY_KEY = 'runtime:unresolved'
|
||||
|
||||
export { notifyInstalledAgentSkillsChanged } from './installed-agent-skill-discovery'
|
||||
|
||||
export const GLOBAL_AGENT_SKILL_SOURCE_KINDS = [
|
||||
'home'
|
||||
] as const satisfies readonly SkillSourceKind[]
|
||||
|
|
@ -34,10 +47,6 @@ export type InstalledAgentSkillState = {
|
|||
refresh: () => Promise<boolean>
|
||||
}
|
||||
|
||||
let cachedDiscoveryByTarget = new Map<string, SkillDiscoveryResult>()
|
||||
let pendingDiscoveryByTarget = new Map<string, Promise<SkillDiscoveryResult>>()
|
||||
let pendingDiscoverySatisfiesForcedRefreshByTarget = new Map<string, boolean>()
|
||||
|
||||
function normalizeSkillName(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
|
@ -78,112 +87,11 @@ export function hasInstalledAgentSkillNamed(
|
|||
})
|
||||
}
|
||||
|
||||
export function notifyInstalledAgentSkillsChanged(): void {
|
||||
cachedDiscoveryByTarget.clear()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent(INSTALLED_AGENT_SKILLS_CHANGED_EVENT))
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSkillDiscoveryTarget(
|
||||
target: SkillDiscoveryTarget | undefined
|
||||
): SkillDiscoveryTarget | undefined {
|
||||
const projectRuntime = target?.projectRuntime
|
||||
if (projectRuntime) {
|
||||
if (projectRuntime.status === 'repair-required') {
|
||||
return { projectRuntime }
|
||||
}
|
||||
if (projectRuntime.runtime.kind === 'wsl') {
|
||||
return {
|
||||
runtime: 'wsl',
|
||||
wslDistro: projectRuntime.runtime.distro,
|
||||
projectRuntime
|
||||
}
|
||||
}
|
||||
return {
|
||||
runtime: 'host',
|
||||
projectRuntime
|
||||
}
|
||||
}
|
||||
|
||||
if (target?.runtime !== 'wsl') {
|
||||
return undefined
|
||||
}
|
||||
return { runtime: 'wsl', wslDistro: target.wslDistro?.trim() || null }
|
||||
}
|
||||
|
||||
function getSkillDiscoveryTargetKey(target: SkillDiscoveryTarget | undefined): string {
|
||||
if (target?.projectRuntime) {
|
||||
return target.projectRuntime.status === 'resolved'
|
||||
? target.projectRuntime.runtime.cacheKey
|
||||
: target.projectRuntime.repair.cacheKey
|
||||
}
|
||||
const normalizedTarget = normalizeSkillDiscoveryTarget(target)
|
||||
return normalizedTarget?.runtime === 'wsl' ? `wsl:${normalizedTarget.wslDistro ?? ''}` : 'host'
|
||||
}
|
||||
|
||||
function startInstalledAgentSkillDiscovery(
|
||||
force: boolean,
|
||||
target: SkillDiscoveryTarget | undefined
|
||||
): Promise<SkillDiscoveryResult> {
|
||||
const key = getSkillDiscoveryTargetKey(target)
|
||||
const normalizedTarget = normalizeSkillDiscoveryTarget(target)
|
||||
const discovery = window.api.skills
|
||||
.discover(normalizedTarget)
|
||||
.then((result) => {
|
||||
cachedDiscoveryByTarget.set(key, result)
|
||||
return result
|
||||
})
|
||||
.finally(() => {
|
||||
if (pendingDiscoveryByTarget.get(key) === discovery) {
|
||||
pendingDiscoveryByTarget.delete(key)
|
||||
pendingDiscoverySatisfiesForcedRefreshByTarget.delete(key)
|
||||
}
|
||||
})
|
||||
pendingDiscoveryByTarget.set(key, discovery)
|
||||
pendingDiscoverySatisfiesForcedRefreshByTarget.set(key, force)
|
||||
return discovery
|
||||
}
|
||||
|
||||
async function discoverInstalledAgentSkills(
|
||||
force: boolean,
|
||||
target?: SkillDiscoveryTarget
|
||||
): Promise<SkillDiscoveryResult> {
|
||||
const key = getSkillDiscoveryTargetKey(target)
|
||||
const cachedDiscovery = cachedDiscoveryByTarget.get(key)
|
||||
if (!force && cachedDiscovery) {
|
||||
return cachedDiscovery
|
||||
}
|
||||
|
||||
const inFlightDiscovery = pendingDiscoveryByTarget.get(key)
|
||||
if (inFlightDiscovery) {
|
||||
if (!force || pendingDiscoverySatisfiesForcedRefreshByTarget.get(key)) {
|
||||
return inFlightDiscovery
|
||||
}
|
||||
try {
|
||||
await inFlightDiscovery
|
||||
} catch {
|
||||
// Why: an explicit re-check should still read current disk state even if
|
||||
// the older background scan failed.
|
||||
}
|
||||
const nextPendingDiscovery = pendingDiscoveryByTarget.get(key)
|
||||
if (nextPendingDiscovery && nextPendingDiscovery !== inFlightDiscovery) {
|
||||
return nextPendingDiscovery
|
||||
}
|
||||
}
|
||||
|
||||
return startInstalledAgentSkillDiscovery(force, target)
|
||||
}
|
||||
|
||||
export const _installedAgentSkillDiscoveryInternalsForTests = {
|
||||
discoverInstalledAgentSkills,
|
||||
getSkillDiscoveryTargetKey,
|
||||
isOrchestrationSkillName,
|
||||
reset(): void {
|
||||
cachedDiscoveryByTarget = new Map()
|
||||
pendingDiscoveryByTarget = new Map()
|
||||
pendingDiscoverySatisfiesForcedRefreshByTarget = new Map()
|
||||
}
|
||||
reset: resetSkillDiscoveryCacheForTests
|
||||
}
|
||||
|
||||
export function useInstalledAgentSkill(
|
||||
|
|
@ -200,8 +108,11 @@ export function useInstalledAgentSkillNames(
|
|||
const { enabled = true, discoveryTarget, sourceKinds } = options
|
||||
const skillNamesKey = skillNames.map(normalizeSkillName).join('\n')
|
||||
const candidateSkillNames = useMemo(() => skillNamesKey.split('\n'), [skillNamesKey])
|
||||
const discoveryTargetKey = getSkillDiscoveryTargetKey(discoveryTarget)
|
||||
const cachedDiscovery = cachedDiscoveryByTarget.get(discoveryTargetKey) ?? null
|
||||
const runtimeTarget = useActiveSkillDiscoveryRuntimeTarget()
|
||||
const discoveryTargetKey = runtimeTarget
|
||||
? getRuntimeScopedSkillDiscoveryKey(runtimeTarget, discoveryTarget)
|
||||
: UNRESOLVED_RUNTIME_DISCOVERY_KEY
|
||||
const cachedDiscovery = getCachedSkillDiscovery(discoveryTargetKey)
|
||||
const [result, setResult] = useState<SkillDiscoveryResult | null>(cachedDiscovery)
|
||||
const [loading, setLoading] = useState(enabled && !cachedDiscovery)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -219,7 +130,7 @@ export function useInstalledAgentSkillNames(
|
|||
stateResetInputRef.current.discoveryTargetKey !== discoveryTargetKey ||
|
||||
stateResetInputRef.current.enabled !== enabled
|
||||
) {
|
||||
const nextCachedDiscovery = cachedDiscoveryByTarget.get(discoveryTargetKey) ?? null
|
||||
const nextCachedDiscovery = getCachedSkillDiscovery(discoveryTargetKey)
|
||||
const nextLoading = enabled && !nextCachedDiscovery
|
||||
stateResetInputRef.current = { discoveryTargetKey, enabled }
|
||||
resultForRender = nextCachedDiscovery
|
||||
|
|
@ -253,9 +164,14 @@ export function useInstalledAgentSkillNames(
|
|||
writeIfCurrent(() => {
|
||||
setLoading(true)
|
||||
})
|
||||
if (!runtimeTarget) {
|
||||
// Why: stay in the loading state rather than scanning the wrong host and
|
||||
// reporting "not installed" before the owning runtime is known.
|
||||
return false
|
||||
}
|
||||
let installedAfterRefresh = false
|
||||
try {
|
||||
const next = await discoverInstalledAgentSkills(force, discoveryTarget)
|
||||
const next = await discoverInstalledAgentSkills(force, discoveryTarget, runtimeTarget)
|
||||
installedAfterRefresh = hasInstalledAgentSkillNamed(next.skills, candidateSkillNames, {
|
||||
sourceKinds
|
||||
})
|
||||
|
|
@ -278,7 +194,15 @@ export function useInstalledAgentSkillNames(
|
|||
}
|
||||
return installedAfterRefresh
|
||||
},
|
||||
[candidateSkillNames, discoveryTarget, discoveryTargetKey, enabled, mountedRef, sourceKinds]
|
||||
[
|
||||
candidateSkillNames,
|
||||
discoveryTarget,
|
||||
discoveryTargetKey,
|
||||
enabled,
|
||||
mountedRef,
|
||||
runtimeTarget,
|
||||
sourceKinds
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -3786,17 +3786,17 @@
|
|||
"b088e0785d": "Beta",
|
||||
"f43ad6edf3": "Skills",
|
||||
"7e828fb2c6": "Back",
|
||||
"ea72d6185b": "Could not scan local skills",
|
||||
"ea72d6185b": "Could not scan skills",
|
||||
"dc4c3328ee": "Reveal file",
|
||||
"9963dff6d3": "No description found.",
|
||||
"995fde8337": "Could not reveal skill file",
|
||||
"ab5b777350": "Checked local home, repository, bundled, and plugin skill folders.",
|
||||
"ab5b777350": "Checked home, repository, bundled, and plugin skill folders.",
|
||||
"08a321a984": "Adjust the search or filters.",
|
||||
"4acd6d68ec": "No local skills found",
|
||||
"4acd6d68ec": "No skills found",
|
||||
"6a62a0168c": "No matches",
|
||||
"cd7893fbc1": "Scanning skills",
|
||||
"35b9a724a0": "Available",
|
||||
"0c74e7ff34": "Local"
|
||||
"0c74e7ff34": "Installed"
|
||||
},
|
||||
"SkillFreshnessNudge": {
|
||||
"titleOne": "An installed Orca skill is out of date",
|
||||
|
|
|
|||
|
|
@ -3763,17 +3763,17 @@
|
|||
"b088e0785d": "Beta",
|
||||
"f43ad6edf3": "Skills",
|
||||
"7e828fb2c6": "Volver",
|
||||
"ea72d6185b": "No se pudieron escanear los skills locales",
|
||||
"ea72d6185b": "No se pudieron escanear los skills",
|
||||
"dc4c3328ee": "Mostrar archivo",
|
||||
"9963dff6d3": "No se encontró ninguna descripción.",
|
||||
"995fde8337": "No se pudo mostrar el archivo del skill",
|
||||
"ab5b777350": "Se comprobaron las carpetas de skills locales, del repositorio, incluidas y de plugins.",
|
||||
"ab5b777350": "Se comprobaron las carpetas de skills del home, del repositorio, incluidas y de plugins.",
|
||||
"08a321a984": "Ajusta la búsqueda o los filtros.",
|
||||
"4acd6d68ec": "No se encontraron skills locales",
|
||||
"4acd6d68ec": "No se encontraron skills",
|
||||
"6a62a0168c": "No hay coincidencias",
|
||||
"cd7893fbc1": "Escaneando skills",
|
||||
"35b9a724a0": "Disponible",
|
||||
"0c74e7ff34": "Local"
|
||||
"0c74e7ff34": "Instalado"
|
||||
},
|
||||
"SkillFreshnessNudge": {
|
||||
"titleOne": "Una skill de Orca instalada está desactualizada",
|
||||
|
|
|
|||
|
|
@ -3763,17 +3763,17 @@
|
|||
"b088e0785d": "ベータ",
|
||||
"f43ad6edf3": "スキル",
|
||||
"7e828fb2c6": "戻る",
|
||||
"ea72d6185b": "ローカルスキルをスキャンできませんでした",
|
||||
"ea72d6185b": "スキルをスキャンできませんでした",
|
||||
"dc4c3328ee": "ファイルを公開する",
|
||||
"9963dff6d3": "説明が見つかりませんでした。",
|
||||
"995fde8337": "スキルファイルを公開できませんでした",
|
||||
"ab5b777350": "ローカル ホーム、repos、バンドル、プラグイン スキル フォルダを確認しました。",
|
||||
"ab5b777350": "ホーム、repos、バンドル、プラグイン スキル フォルダを確認しました。",
|
||||
"08a321a984": "検索またはフィルターを調整します。",
|
||||
"4acd6d68ec": "ローカルスキルが見つかりません",
|
||||
"4acd6d68ec": "スキルが見つかりません",
|
||||
"6a62a0168c": "一致なし",
|
||||
"cd7893fbc1": "スキャンスキル",
|
||||
"35b9a724a0": "利用可能",
|
||||
"0c74e7ff34": "ローカル"
|
||||
"0c74e7ff34": "インストール済み"
|
||||
},
|
||||
"SkillFreshnessNudge": {
|
||||
"titleOne": "インストール済みの Orca スキルが古くなっています",
|
||||
|
|
|
|||
|
|
@ -3763,17 +3763,17 @@
|
|||
"b088e0785d": "베타",
|
||||
"f43ad6edf3": "스킬",
|
||||
"7e828fb2c6": "뒤로",
|
||||
"ea72d6185b": "로컬 스킬을 스캔할 수 없습니다.",
|
||||
"ea72d6185b": "스킬을 스캔할 수 없습니다.",
|
||||
"dc4c3328ee": "파일 표시",
|
||||
"9963dff6d3": "설명을 찾을 수 없습니다.",
|
||||
"995fde8337": "스킬 파일을 공개할 수 없습니다.",
|
||||
"ab5b777350": "로컬 홈, repos, 번들, 플러그인 스킬 폴더를 확인했습니다.",
|
||||
"ab5b777350": "홈, repos, 번들, 플러그인 스킬 폴더를 확인했습니다.",
|
||||
"08a321a984": "검색 또는 필터를 조정합니다.",
|
||||
"4acd6d68ec": "로컬 스킬을 찾을 수 없습니다.",
|
||||
"4acd6d68ec": "스킬을 찾을 수 없습니다.",
|
||||
"6a62a0168c": "일치하는 항목 없음",
|
||||
"cd7893fbc1": "스킬 스캔 중",
|
||||
"35b9a724a0": "사용 가능",
|
||||
"0c74e7ff34": "로컬"
|
||||
"0c74e7ff34": "설치됨"
|
||||
},
|
||||
"SkillFreshnessNudge": {
|
||||
"titleOne": "설치된 Orca 스킬이 오래되었습니다",
|
||||
|
|
|
|||
|
|
@ -3763,17 +3763,17 @@
|
|||
"b088e0785d": "测试版",
|
||||
"f43ad6edf3": "技能",
|
||||
"7e828fb2c6": "返回",
|
||||
"ea72d6185b": "无法扫描本地技能",
|
||||
"ea72d6185b": "无法扫描技能",
|
||||
"dc4c3328ee": "显示文件",
|
||||
"9963dff6d3": "没有找到描述。",
|
||||
"995fde8337": "无法显示技能文件",
|
||||
"ab5b777350": "检查本地主目录、存储库、捆绑和插件技能文件夹。",
|
||||
"ab5b777350": "检查主目录、存储库、捆绑和插件技能文件夹。",
|
||||
"08a321a984": "调整搜索或筛选条件。",
|
||||
"4acd6d68ec": "未找到本地技能",
|
||||
"4acd6d68ec": "未找到技能",
|
||||
"6a62a0168c": "没有匹配项",
|
||||
"cd7893fbc1": "正在扫描技能",
|
||||
"35b9a724a0": "可用的",
|
||||
"0c74e7ff34": "当地的"
|
||||
"0c74e7ff34": "已安装"
|
||||
},
|
||||
"SkillFreshnessNudge": {
|
||||
"titleOne": "已安装的 Orca 技能已过期",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../../shared/skills'
|
||||
import {
|
||||
createCompatibleRuntimeStatusResponseIfNeeded,
|
||||
type RuntimeEnvironmentCallRequest
|
||||
} from './runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
|
||||
import { discoverSkillsForRuntimeTarget } from './runtime-skills-client'
|
||||
|
||||
function discoveryResult(skillName: string): SkillDiscoveryResult {
|
||||
return {
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-1',
|
||||
name: skillName,
|
||||
description: null,
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Agent skills home',
|
||||
rootPath: '/home/dev/.agents/skills',
|
||||
directoryPath: `/home/dev/.agents/skills/${skillName}`,
|
||||
skillFilePath: `/home/dev/.agents/skills/${skillName}/SKILL.md`,
|
||||
installed: true,
|
||||
fileCount: 1,
|
||||
updatedAt: null
|
||||
}
|
||||
],
|
||||
sources: [],
|
||||
scannedAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
const discover = vi.fn<(target?: SkillDiscoveryTarget) => Promise<SkillDiscoveryResult>>()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
discover.mockReset()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
skills: { discover },
|
||||
runtimeEnvironments: {
|
||||
call: (args: RuntimeEnvironmentCallRequest) =>
|
||||
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('discoverSkillsForRuntimeTarget', () => {
|
||||
it('scans the local host through the skills IPC for a local target', async () => {
|
||||
const result = discoveryResult('orchestration')
|
||||
discover.mockResolvedValueOnce(result)
|
||||
const target: SkillDiscoveryTarget = { runtime: 'host' }
|
||||
|
||||
await expect(discoverSkillsForRuntimeTarget({ kind: 'local' }, target)).resolves.toBe(result)
|
||||
|
||||
expect(discover).toHaveBeenCalledWith(target)
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes to the remote runtime and drops the local-only target', async () => {
|
||||
const result = discoveryResult('orchestration')
|
||||
runtimeEnvironmentCall.mockResolvedValueOnce({ id: 'skills', ok: true, result })
|
||||
|
||||
await expect(
|
||||
discoverSkillsForRuntimeTarget(
|
||||
{ kind: 'environment', environmentId: 'env-1' },
|
||||
{ runtime: 'wsl', wslDistro: 'Ubuntu' }
|
||||
)
|
||||
).resolves.toBe(result)
|
||||
|
||||
expect(discover).not.toHaveBeenCalled()
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ selector: 'env-1', method: 'skills.discover', params: {} })
|
||||
)
|
||||
})
|
||||
|
||||
// Why: no caller can produce these yet, so the remote params must stay empty
|
||||
// rather than shipping a client-host target the server would misread.
|
||||
it('sends no target at all to a remote runtime', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValueOnce({
|
||||
id: 'skills',
|
||||
ok: true,
|
||||
result: discoveryResult('orchestration')
|
||||
})
|
||||
|
||||
await discoverSkillsForRuntimeTarget(
|
||||
{ kind: 'environment', environmentId: 'env-1' },
|
||||
{ cwd: '/workspace/app', worktreeId: 'wt-1' }
|
||||
)
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'skills.discover', params: {} })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../../shared/skills'
|
||||
import { callRuntimeRpc, type RuntimeClientTarget } from './runtime-rpc-client'
|
||||
|
||||
const SKILL_DISCOVERY_TIMEOUT_MS = 15_000
|
||||
|
||||
/**
|
||||
* Discover skills on the runtime that actually runs them: the local desktop host
|
||||
* (or its WSL/project runtime) by default, or a connected remote Orca runtime
|
||||
* when one is active. This keeps install badges in sync with where the skill
|
||||
* files land instead of always reading the client's disk (#6789).
|
||||
*
|
||||
* The target is dropped entirely for a remote call. Every target any caller can
|
||||
* currently produce describes the *client's* host — a WSL distro or a local
|
||||
* project-runtime resolution — and forwarding those would ask a Linux server to
|
||||
* resolve a WSL distro it does not have. The server does honour `cwd` and
|
||||
* `worktreeId` (see `main/runtime/rpc/methods/skills.ts`), so if a caller ever
|
||||
* supplies workspace identity, forward those two fields rather than widening
|
||||
* this to the whole target.
|
||||
*/
|
||||
export async function discoverSkillsForRuntimeTarget(
|
||||
runtimeTarget: RuntimeClientTarget,
|
||||
target?: SkillDiscoveryTarget
|
||||
): Promise<SkillDiscoveryResult> {
|
||||
if (runtimeTarget.kind === 'local') {
|
||||
return window.api.skills.discover(target)
|
||||
}
|
||||
return callRuntimeRpc<SkillDiscoveryResult>(
|
||||
runtimeTarget,
|
||||
'skills.discover',
|
||||
{},
|
||||
{ timeoutMs: SKILL_DISCOVERY_TIMEOUT_MS }
|
||||
)
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-select
|
|||
import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup'
|
||||
import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist'
|
||||
import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt'
|
||||
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
|
||||
import { notifyInstalledAgentSkillsChanged } from '@/hooks/installed-agent-skill-discovery'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
|
|
|
|||
|
|
@ -373,4 +373,31 @@ describe('runtime-status slice', () => {
|
|||
'runtime-a'
|
||||
)
|
||||
})
|
||||
|
||||
// Why: skill discovery waits for the catalog to settle. A rejected read must
|
||||
// release that wait without claiming the catalog is hydrated — host routing
|
||||
// uses `runtimeEnvironmentCatalogHydrated` to fail closed on an unknown
|
||||
// catalog, and an empty stale list must not be mistaken for "no runtimes".
|
||||
it('settles but does not hydrate the catalog when the read fails', async () => {
|
||||
const list = vi.fn().mockRejectedValue(new Error('unreadable environments.json'))
|
||||
stubRuntimeEnvironmentApi({ getStatus: vi.fn(), list })
|
||||
const store = createSliceStore()
|
||||
|
||||
await store.getState().hydrateRuntimeEnvironmentStatuses()
|
||||
|
||||
expect(store.getState().runtimeEnvironmentCatalogSettled).toBe(true)
|
||||
expect(store.getState().runtimeEnvironmentCatalogHydrated).toBe(false)
|
||||
expect(store.getState().runtimeEnvironments).toEqual([])
|
||||
})
|
||||
|
||||
it('both settles and hydrates the catalog on a successful read', async () => {
|
||||
const list = vi.fn().mockResolvedValue([])
|
||||
stubRuntimeEnvironmentApi({ getStatus: vi.fn(), list })
|
||||
const store = createSliceStore()
|
||||
|
||||
await store.getState().hydrateRuntimeEnvironmentStatuses()
|
||||
|
||||
expect(store.getState().runtimeEnvironmentCatalogSettled).toBe(true)
|
||||
expect(store.getState().runtimeEnvironmentCatalogHydrated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,8 +23,14 @@ export type RuntimeStatusSlice = {
|
|||
/** Saved remote Orca servers. Host pickers use this to show user-chosen names
|
||||
* instead of opaque runtime ids. */
|
||||
runtimeEnvironments: PublicKnownRuntimeEnvironment[]
|
||||
/** True only after the saved-runtime catalog has loaded successfully. */
|
||||
/** True only after the saved-runtime catalog has loaded successfully. Gates
|
||||
* fail-closed host routing, so a failed read must NOT flip it. */
|
||||
runtimeEnvironmentCatalogHydrated: boolean
|
||||
/** True once the catalog read has finished, successfully or not. Surfaces that
|
||||
* only need to stop waiting (skill discovery) read this instead of
|
||||
* `runtimeEnvironmentCatalogHydrated`, so a failed read degrades rather than
|
||||
* leaving them pending for the whole session. */
|
||||
runtimeEnvironmentCatalogSettled: boolean
|
||||
/** Keyed by runtime environment id. Fed into buildExecutionHostRegistry so
|
||||
* compat verdicts/blocked health show live in the sidebar host pickers. */
|
||||
runtimeStatusByEnvironmentId: Map<string, RuntimeEnvironmentStatus>
|
||||
|
|
@ -68,6 +74,7 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
|
|||
) => ({
|
||||
runtimeEnvironments: [],
|
||||
runtimeEnvironmentCatalogHydrated: false,
|
||||
runtimeEnvironmentCatalogSettled: false,
|
||||
runtimeStatusByEnvironmentId: new Map(),
|
||||
removedRuntimeEnvironmentIds: new Set(),
|
||||
|
||||
|
|
@ -131,6 +138,7 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
|
|||
return {
|
||||
runtimeEnvironments: environments,
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
runtimeEnvironmentCatalogSettled: true,
|
||||
...(statusesChanged ? { runtimeStatusByEnvironmentId: nextStatuses } : {}),
|
||||
...(removedChanged ? { removedRuntimeEnvironmentIds: nextRemoved } : {})
|
||||
}
|
||||
|
|
@ -230,6 +238,10 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
|
|||
environments = await window.api.runtimeEnvironments.list()
|
||||
} catch (err) {
|
||||
console.error('Failed to list runtime environments for status hydration:', err)
|
||||
// Why: settled, not hydrated. Skill discovery must stop waiting and fall
|
||||
// back to the local host, but host routing keeps failing closed on an
|
||||
// unknown catalog rather than acting on a stale empty list.
|
||||
set({ runtimeEnvironmentCatalogSettled: true })
|
||||
return
|
||||
}
|
||||
get().setRuntimeEnvironments(environments)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ vi.mock('@/lib/agent-status', async (importOriginal) => {
|
|||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentGetStatus = vi.fn()
|
||||
const settingsSet = vi.fn().mockResolvedValue(undefined)
|
||||
const settingsGet = vi.fn()
|
||||
const runtimeEnvironmentList = vi.fn()
|
||||
const setActiveRuntimeEnvironmentPreference = vi.fn().mockResolvedValue(undefined)
|
||||
const worktreesListDetected = vi.fn()
|
||||
|
||||
|
|
@ -50,6 +52,8 @@ beforeEach(() => {
|
|||
},
|
||||
_meta: { runtimeId: 'runtime-2' }
|
||||
})
|
||||
settingsGet.mockResolvedValue({ notifications: {} })
|
||||
runtimeEnvironmentList.mockResolvedValue([])
|
||||
runtimeEnvironmentCall.mockImplementation(
|
||||
({ method, params }: { method: string; params?: { repo?: string } }) => {
|
||||
const detectedRepoId = params?.repo ?? 'repo-env-2'
|
||||
|
|
@ -133,8 +137,12 @@ beforeEach(() => {
|
|||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
settings: { set: settingsSet, setActiveRuntimeEnvironmentPreference },
|
||||
runtimeEnvironments: { call: runtimeEnvironmentCall, getStatus: runtimeEnvironmentGetStatus },
|
||||
settings: { get: settingsGet, set: settingsSet, setActiveRuntimeEnvironmentPreference },
|
||||
runtimeEnvironments: {
|
||||
call: runtimeEnvironmentCall,
|
||||
getStatus: runtimeEnvironmentGetStatus,
|
||||
list: runtimeEnvironmentList
|
||||
},
|
||||
worktrees: { listDetected: worktreesListDetected }
|
||||
}
|
||||
})
|
||||
|
|
@ -648,3 +656,29 @@ describe('createSettingsSlice runtime switching', () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchSettings runtime catalog probe', () => {
|
||||
// Why: skill discovery waits for the runtime catalog to settle. If a rejected
|
||||
// settings read skipped the probe, every skill badge would sit on a spinner
|
||||
// for the whole session with no retry affordance.
|
||||
it('still probes the runtime catalog when the settings read fails', async () => {
|
||||
settingsGet.mockRejectedValueOnce(new Error('unreadable settings.json'))
|
||||
const store = createTestStore()
|
||||
|
||||
await store.getState().fetchSettings()
|
||||
await vi.waitFor(() => expect(runtimeEnvironmentList).toHaveBeenCalled())
|
||||
|
||||
expect(store.getState().settings).toBeNull()
|
||||
expect(store.getState().runtimeEnvironmentCatalogSettled).toBe(true)
|
||||
})
|
||||
|
||||
it('probes the runtime catalog after a successful settings read', async () => {
|
||||
const store = createTestStore()
|
||||
|
||||
await store.getState().fetchSettings()
|
||||
await vi.waitFor(() => expect(runtimeEnvironmentList).toHaveBeenCalled())
|
||||
|
||||
expect(store.getState().settings).not.toBeNull()
|
||||
expect(store.getState().runtimeEnvironmentCatalogSettled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -144,13 +144,14 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
|
|||
try {
|
||||
const settings = await window.api.settings.get()
|
||||
set({ settings })
|
||||
// Why: best-effort boot probe so sidebar host pickers show live runtime
|
||||
// health before the settings pane is ever opened. Fire-and-forget to keep
|
||||
// startup off the network round-trips.
|
||||
void get().hydrateRuntimeEnvironmentStatuses()
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch settings:', err)
|
||||
}
|
||||
// Why: best-effort boot probe so sidebar host pickers show live runtime
|
||||
// health before the settings pane is ever opened. Fire-and-forget to keep
|
||||
// startup off the network round-trips. Runs even when settings fail to load,
|
||||
// so surfaces waiting on the catalog settling are never stranded pending.
|
||||
void get().hydrateRuntimeEnvironmentStatuses()
|
||||
},
|
||||
|
||||
updateSettings: async (updates) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue