diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index a1fb192c6d3..78b10bb7c4d 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -261,12 +261,15 @@ describe('registerSkillsHandlers', () => { planned: true }) await handlerFor('skills:bundledPlan')(null, { force }) - await expect(handlerFor('skills:bundledExecute')(null, { force, planDigest })).resolves.toEqual( - { executed: true } - ) + await expect( + handlerFor('skills:bundledExecute')(null, { planDigest, request: { force } }) + ).resolves.toEqual({ executed: true }) expect(runtime.planLocalBundledSkills.mock.calls).toEqual([[{}], [{ force }]]) - expect(runtime.executeLocalBundledSkills).toHaveBeenCalledWith({ force, planDigest }) + expect(runtime.executeLocalBundledSkills).toHaveBeenCalledWith({ + planDigest, + request: { force } + }) expect(getWslHomeMock).not.toHaveBeenCalled() }) @@ -275,8 +278,17 @@ describe('registerSkillsHandlers', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the members this code path reads. registerSkillsHandlers(store as never, runtime as never) + await expect( + handlerFor('skills:bundledPlan')(null, { wsl: { distro: 'Ubuntu' } }) + ).rejects.toThrow() + await expect( + handlerFor('skills:bundledExecute')(null, { + planDigest: 'd'.repeat(64), + request: { wsl: { distro: 'Ubuntu' } } + }) + ).rejects.toThrow() await expect(handlerFor('skills:bundledPlan')(null, { force: 'all' })).rejects.toThrow() - await expect(handlerFor('skills:bundledPlan')(null, { names: ['x'] })).rejects.toThrow() + await expect(handlerFor('skills:bundledPlan')(null, { names: ['../x'] })).rejects.toThrow() await expect(handlerFor('skills:bundledExecute')(null, {})).rejects.toThrow() await expect( handlerFor('skills:bundledExecute')(null, { planDigest: 'not-a-digest' }) diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index 21aa4ce8cf3..dce8fe675eb 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -38,7 +38,7 @@ export function registerSkillsHandlers(store: Store, runtime?: OrcaRuntimeServic } registerSkillCloudIpcHandlers(runtime, discover) - // Local host only; WSL and paired hosts plan Orca's skills on their own host. + // Local host only; paired hosts plan Orca's skills over RPC. handleMainWindowSkillIpc( 'skills:bundledPlan', async (_event, value: unknown): Promise => diff --git a/src/main/runtime/rpc/methods/skills-bundled-orca.test.ts b/src/main/runtime/rpc/methods/skills-bundled-orca.test.ts new file mode 100644 index 00000000000..8a8ff3ec815 --- /dev/null +++ b/src/main/runtime/rpc/methods/skills-bundled-orca.test.ts @@ -0,0 +1,176 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + BundledSkillInstallResult, + BundledSkillPlan, + BundledSkillPlanRequest +} from '../../../../shared/bundled-skill-plan' +import { + installBundledSkills, + previewBundledSkillInstall +} from '../../../skills/bundled-skill-install/service' +import { + makeSkillHome, + NAME, + NEW_TEXT, + OLD_TEXT, + removeSkillHomes, + serviceInput, + writeCopy, + type SkillHome +} from '../../../skills/bundled-skill-install/skill-home.test-support' +import { eraseRpcMethods, type RpcContext } from '../core' + +vi.mock('electron', () => ({ + app: { getPath: () => '/orca-state', isPackaged: true } +})) + +const { SKILL_METHODS } = await import('./skills') + +function method(name: string) { + const value = eraseRpcMethods(SKILL_METHODS).find((entry) => entry.name === name) + if (!value) { + throw new Error(`${name} method not registered`) + } + return value +} + +/** Calls a method as the dispatcher does: parse the wire params, then run the handler. */ +async function call(name: string, params: unknown, context: RpcContext): Promise { + const entry = method(name) + const parsed = entry.params ? entry.params.parse(params) : undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `parsed` came from this method's own params schema, which is what the erased handler accepts. + const result = await entry.handler(parsed as never, context) + // Why JSON: the result crosses the wire, so the client only ever sees its serialised form. + const wire: T = JSON.parse(JSON.stringify(result)) + return wire +} + +/** The host's runtime, planning its own (temp) home as `planLocalBundledSkills` does. */ +function hostContext(home: SkillHome): RpcContext { + const input = (request: BundledSkillPlanRequest) => ({ + ...serviceInput(home, { + installedOnly: !request.names, + ...(request.force ? { force: request.force } : {}) + }), + ...(request.names ? { names: request.names } : {}) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers read only the two runtime methods stubbed here. + return { + runtime: { + planLocalBundledSkills: (request: BundledSkillPlanRequest) => + previewBundledSkillInstall(input(request)), + executeLocalBundledSkills: ({ + planDigest, + request + }: { + planDigest: string + request: BundledSkillPlanRequest + }) => installBundledSkills(input(request), planDigest) + } + } as unknown as RpcContext +} + +afterEach(async () => { + await removeSkillHomes() +}) + +describe('skills.planBundledOrca / skills.executeBundledOrca', () => { + it('plans read-only and executes behind skills-admin', () => { + expect(method('skills.planBundledOrca').permission).toBe('workspace') + expect(method('skills.executeBundledOrca').permission).toBe('skills-admin') + }) + + it('never lets a paired client point the host at a WSL guest or send ops', () => { + const plan = method('skills.planBundledOrca').params + const execute = method('skills.executeBundledOrca').params + expect(() => plan?.parse({ wsl: { distro: 'Ubuntu' } })).toThrow() + expect(() => execute?.parse({ planDigest: 'a'.repeat(64), request: {}, ops: [] })).toThrow() + expect(() => execute?.parse({ planDigest: 'a'.repeat(64), force: [] })).toThrow() + expect(execute?.parse({ planDigest: 'a'.repeat(64), request: { force: ['/x'] } })).toEqual({ + planDigest: 'a'.repeat(64), + request: { force: ['/x'] } + }) + }) + + it('executes the plan the client previewed, identified by its digest', async () => { + const home = await makeSkillHome() + await writeCopy(home.claude, OLD_TEXT) + const context = hostContext(home) + + const plan = await call('skills.planBundledOrca', {}, context) + expect(plan.skills[0].ops.map((op) => [op.path, op.kind])).toEqual([[home.claude, 'update']]) + const result = await call( + 'skills.executeBundledOrca', + { planDigest: plan.planDigest, request: {} }, + context + ) + + expect(result.skills[0]).toMatchObject({ + status: 'ran', + ops: [{ path: home.claude, kind: 'update', status: 'done' }] + }) + expect(await readFile(join(home.claude, 'SKILL.md'), 'utf8')).toBe(NEW_TEXT) + }) + + it('writes nothing when the disk changed after the preview', async () => { + const home = await makeSkillHome() + await writeCopy(home.claude, OLD_TEXT) + const context = hostContext(home) + const plan = await call('skills.planBundledOrca', {}, context) + + await writeFile(join(home.claude, 'SKILL.md'), 'edited meanwhile\n') + const result = await call( + 'skills.executeBundledOrca', + { planDigest: plan.planDigest, request: {} }, + context + ) + + expect(result.skills.map((skill) => skill.status)).toEqual(['changed']) + expect(await readFile(join(home.claude, 'SKILL.md'), 'utf8')).toBe('edited meanwhile\n') + }) + + it('installs named skills for the host’s agents, as `orca skills install` does', async () => { + const home = await makeSkillHome() + const context = hostContext(home) + const request = { names: [NAME] } + + const plan = await call('skills.planBundledOrca', request, context) + expect(plan.skills[0].ops.map((op) => [op.path, op.kind])).toEqual([ + [home.shared, 'write'], + [home.claude, 'link'] + ]) + const result = await call( + 'skills.executeBundledOrca', + { planDigest: plan.planDigest, request }, + context + ) + + expect(result.skills[0].ops.map((op) => op.status)).toEqual(['done', 'done']) + expect(await readFile(join(home.claude, 'SKILL.md'), 'utf8')).toBe(NEW_TEXT) + }) + + it('rejects a name the host does not ship', async () => { + const home = await makeSkillHome() + await expect( + call('skills.planBundledOrca', { names: ['not-an-orca-skill'] }, hostContext(home)) + ).rejects.toThrow('bundled-skill-unknown') + expect(() => method('skills.planBundledOrca').params?.parse({ names: ['../x'] })).toThrow() + }) + + it('runs nothing when the executed request names different skills than the preview', async () => { + const home = await makeSkillHome() + const context = hostContext(home) + const plan = await call('skills.planBundledOrca', { names: [NAME] }, context) + + const result = await call( + 'skills.executeBundledOrca', + { planDigest: plan.planDigest, request: {} }, + context + ) + + expect(result.skills.map((skill) => skill.status)).toEqual(['changed']) + expect(await readFile(join(home.shared, 'SKILL.md'), 'utf8').catch(() => null)).toBeNull() + }) +}) diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 7866de18eb8..b245cd84113 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -2,6 +2,10 @@ import { defineMethod } from '../core' import type { z } from 'zod' import { getAppEnvironment } from '../../../../shared/app-environment' import { SkillDeleteRequestSchema } from '../../../../shared/skill-delete-contract' +import { + BundledSkillExecuteRequestSchema, + BundledSkillPlanRequestSchema +} from '../../../../shared/bundled-skill-plan' import { previewSkillDeleteRequest, runSkillDeleteRequest, @@ -102,6 +106,19 @@ export const SKILL_METHODS = [ skillDeleteDependencies(runtime) ) }), + // The host plans Orca's skills on its own disk; a paired client never sends a WSL target. + defineMethod({ + name: 'skills.planBundledOrca', + permission: 'workspace', + params: BundledSkillPlanRequestSchema, + handler: (params, { runtime }) => runtime.planLocalBundledSkills(params) + }), + defineMethod({ + name: 'skills.executeBundledOrca', + permission: 'skills-admin', + params: BundledSkillExecuteRequestSchema, + handler: (params, { runtime }) => runtime.executeLocalBundledSkills(params) + }), defineMethod({ name: 'skills.share', permission: 'skills-admin', diff --git a/src/main/runtime/runtime-bundled-skill-install.ts b/src/main/runtime/runtime-bundled-skill-install.ts index 63af09d96f4..9f5708f852f 100644 --- a/src/main/runtime/runtime-bundled-skill-install.ts +++ b/src/main/runtime/runtime-bundled-skill-install.ts @@ -1,10 +1,13 @@ import type { - BundledSkillExecuteRequest, BundledSkillInstallResult, + BundledSkillExecuteRequest, BundledSkillPlan, BundledSkillPlanRequest } from '../../shared/bundled-skill-plan' -import { localBundledSkillInput } from '../skills/bundled-skill-install/local-host' +import { + localBundledSkillInput, + type BundledSkillSelection +} from '../skills/bundled-skill-install/local-host' import { installBundledSkills, previewBundledSkillInstall, @@ -15,11 +18,20 @@ import { resolveSkillInstallDestination } from '../skills/skill-install-destinat import type { RuntimeSkillCommandHost } from './runtime-skill-command-contract' import { createSkillInstallAuthority } from './runtime-skill-install-authority' -// Why global and installed-only: this is the app's Update skills, which never adds a skill -// the user did not install. WSL and remote hosts plan on their own host (not here). +/** Named skills are an install for the host's agents; otherwise update what is installed. */ +function bundledSkillSelection(request: BundledSkillPlanRequest): BundledSkillSelection { + return { + ...(request.names ? { names: request.names } : { installedOnly: true }), + ...(request.force ? { force: request.force } : {}) + } +} + +// Why global: these are the user's own agents. Without names this is the app's Update skills, +// which never adds a skill the user did not install. A paired server or SSH host plans this on +// its own host over RPC. async function appInput( host: RuntimeSkillCommandHost, - force: readonly string[] | undefined + request: BundledSkillPlanRequest ): Promise { const destination = await resolveSkillInstallDestination( { scope: 'global' }, @@ -31,7 +43,7 @@ async function appInput( userDataPath: host.getUserDataPath(), destination, claudeConfigDirectory: host.getClaudeConfigDirectory?.({ runtime: 'host' }), - selection: { installedOnly: true, ...(force ? { force } : {}) } + selection: bundledSkillSelection(request) }) } @@ -39,14 +51,14 @@ export async function planLocalBundledSkills( host: RuntimeSkillCommandHost, request: BundledSkillPlanRequest ): Promise { - return previewBundledSkillInstall(await appInput(host, request.force)) + return previewBundledSkillInstall(await appInput(host, request)) } export async function executeLocalBundledSkills( host: RuntimeSkillCommandHost, - request: BundledSkillExecuteRequest + { planDigest, request }: BundledSkillExecuteRequest ): Promise { - const result = await installBundledSkills(await appInput(host, request.force), request.planDigest) + const result = await installBundledSkills(await appInput(host, request), planDigest) // Discovery would otherwise serve its pre-install answer from cache. clearSkillDiscoveryCaches() return result diff --git a/src/preload/api/agent-skill-api.ts b/src/preload/api/agent-skill-api.ts index ce4a3706743..0f54e764a52 100644 --- a/src/preload/api/agent-skill-api.ts +++ b/src/preload/api/agent-skill-api.ts @@ -41,7 +41,7 @@ import type { export type SkillsApi = { discover: (target?: SkillDiscoveryTarget) => Promise - /** This machine's plan for Orca's installed skills; WSL and remote hosts are not planned here. */ + /** This machine's plan for Orca's installed skills; paired hosts plan over RPC. */ bundledPlan: (request?: BundledSkillPlanRequest) => Promise bundledExecute: (request: BundledSkillExecuteRequest) => Promise prepareShare: (input: { diff --git a/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx b/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx index 98d96be08fe..1f2ce71cb01 100644 --- a/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx +++ b/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx @@ -52,9 +52,7 @@ export function BrowserUseSkillSetupCard(props: { onBeforeOpenTerminal={handleBeforeOpenTerminal} showRecheckWhenInstalled={false} onRecheck={skill.refresh} - freshnessSkillName={ - activeSkillRuntime.canUseLocalSkillFreshness ? ORCA_CLI_SKILL_NAME : undefined - } + freshnessSkillName={activeSkillRuntime.canUseSkillFreshness ? ORCA_CLI_SKILL_NAME : undefined} /> ) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx index 3561bf37c49..6629925ae52 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx @@ -9,7 +9,7 @@ import { _skillFreshnessCacheForTests } from '@/hooks/useSkillFreshness' import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog' const mocks = vi.hoisted(() => ({ - canUseLocalSkillFreshness: true, + canUseSkillFreshness: true, refreshOrchestrationSkill: vi.fn(async () => true), recordFeatureInteraction: vi.fn() })) @@ -20,7 +20,8 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ discoveryTarget: undefined, terminalShellOverride: undefined, installDisabledReason: null, - canUseLocalSkillFreshness: mocks.canUseLocalSkillFreshness + canUseSkillFreshness: mocks.canUseSkillFreshness, + bundledSkillTarget: mocks.canUseSkillFreshness ? { kind: 'local' } : null }) })) @@ -82,7 +83,7 @@ async function renderDialog(): Promise { describe('FloatingTerminalOrchestrationDialog freshness', () => { beforeEach(() => { _skillFreshnessCacheForTests.reset() - mocks.canUseLocalSkillFreshness = true + mocks.canUseSkillFreshness = true mocks.refreshOrchestrationSkill.mockClear() container = document.createElement('div') document.body.appendChild(container) @@ -125,7 +126,7 @@ describe('FloatingTerminalOrchestrationDialog freshness', () => { }) it('never reads client freshness for an SSH-owned skill', async () => { - mocks.canUseLocalSkillFreshness = false + mocks.canUseSkillFreshness = false const bundledPlan = vi.fn().mockResolvedValue(plan([])) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the preload methods this code path calls. window.api = { diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx index 36dd3eb2e92..9d8ffbb452f 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx @@ -62,7 +62,7 @@ export function FloatingTerminalOrchestrationDialog({ const recheckOrchestrationSkill = async (): Promise => { const installed = await refreshOrchestrationSkill() - if (activeSkillRuntime.canUseLocalSkillFreshness) { + if (activeSkillRuntime.bundledSkillTarget) { await refreshSkillFreshness() } return installed @@ -89,7 +89,7 @@ export function FloatingTerminalOrchestrationDialog({ )} ) : orchestrationSkillDetected ? ( - activeSkillRuntime.canUseLocalSkillFreshness ? ( + activeSkillRuntime.canUseSkillFreshness ? ( ) : ( diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx index 4be58a24dc3..e4355b77605 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx @@ -18,6 +18,10 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({ notifyInstalledAgentSkillsRefreshed: mocks.skillsRefreshed })) +vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ + useActiveProjectSkillRuntime: () => ({ bundledSkillTarget: { kind: 'local' } }) +})) + vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({ OnboardingInlineCommandTerminal: () => null })) diff --git a/src/renderer/src/components/settings/CliSection.install-failure.test.tsx b/src/renderer/src/components/settings/CliSection.install-failure.test.tsx index 414a5ace240..0b8d34bc808 100644 --- a/src/renderer/src/components/settings/CliSection.install-failure.test.tsx +++ b/src/renderer/src/components/settings/CliSection.install-failure.test.tsx @@ -24,7 +24,7 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({ })) vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ - useActiveProjectSkillRuntime: () => ({ canUseLocalSkillFreshness: true }) + useActiveProjectSkillRuntime: () => ({ canUseSkillFreshness: true }) })) vi.mock('./AgentSkillSetupPanel', () => ({ diff --git a/src/renderer/src/components/settings/CliSection.test.tsx b/src/renderer/src/components/settings/CliSection.test.tsx index 9b28fe258c3..a7009e28f21 100644 --- a/src/renderer/src/components/settings/CliSection.test.tsx +++ b/src/renderer/src/components/settings/CliSection.test.tsx @@ -18,11 +18,11 @@ type CapturedPanelProps = { const capturedPanel = vi.hoisted( (): { - canUseLocalSkillFreshness: boolean + canUseSkillFreshness: boolean props: CapturedPanelProps | null useInstalledAgentSkill: ReturnType } => ({ - canUseLocalSkillFreshness: true, + canUseSkillFreshness: true, props: null, useInstalledAgentSkill: vi.fn() }) @@ -38,7 +38,8 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ useActiveProjectSkillRuntime: () => ({ - canUseLocalSkillFreshness: capturedPanel.canUseLocalSkillFreshness + canUseSkillFreshness: capturedPanel.canUseSkillFreshness, + bundledSkillTarget: capturedPanel.canUseSkillFreshness ? { kind: 'local' } : null }) })) @@ -51,7 +52,7 @@ capturedPanel.useInstalledAgentSkill.mockReturnValue({ afterEach(() => { cleanup() - capturedPanel.canUseLocalSkillFreshness = true + capturedPanel.canUseSkillFreshness = true toastError.mockReset() vi.unstubAllGlobals() }) @@ -81,11 +82,11 @@ describe('CliSection project runtime defaults', () => { renderToStaticMarkup() expect(capturedPanel.props?.freshnessSkillName).toBe('orca-cli') - capturedPanel.canUseLocalSkillFreshness = false + capturedPanel.canUseSkillFreshness = false renderToStaticMarkup() expect(capturedPanel.props?.freshnessSkillName).toBeUndefined() - capturedPanel.canUseLocalSkillFreshness = true + capturedPanel.canUseSkillFreshness = true renderToStaticMarkup( ) diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.tsx index 47dcc5713f5..11db3ed94a5 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.tsx @@ -173,7 +173,7 @@ export function EphemeralVmsPane(): React.JSX.Element { icon={} onRecheck={refreshSkill} freshnessSkillName={ - activeSkillRuntime.canUseLocalSkillFreshness ? EPHEMERAL_VMS_SKILL_NAME : undefined + activeSkillRuntime.canUseSkillFreshness ? EPHEMERAL_VMS_SKILL_NAME : undefined } /> diff --git a/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx b/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx index 9fa4cd5540c..a315b67d0e9 100644 --- a/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx +++ b/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx @@ -74,7 +74,7 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ agentRuntime: { runtime: mocks.runtime }, terminalShellOverride: undefined, installDisabledReason: null, - canUseLocalSkillFreshness: mocks.runtime !== 'wsl' + canUseSkillFreshness: mocks.runtime !== 'wsl' }) })) diff --git a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.test.tsx b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.test.tsx index 13473c00dbd..0f16a217e80 100644 --- a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.test.tsx +++ b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.test.tsx @@ -4,11 +4,11 @@ import { MobileEmulatorAgentControlRow } from './MobileEmulatorAgentControlRow' const mocks = vi.hoisted( (): { - canUseLocalSkillFreshness: boolean + canUseSkillFreshness: boolean freshnessSkillName: string | undefined panelProps: Record } => ({ - canUseLocalSkillFreshness: true, + canUseSkillFreshness: true, freshnessSkillName: undefined, panelProps: {} }) @@ -16,7 +16,7 @@ const mocks = vi.hoisted( vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ useActiveProjectSkillRuntime: () => ({ - canUseLocalSkillFreshness: mocks.canUseLocalSkillFreshness, + canUseSkillFreshness: mocks.canUseSkillFreshness, terminalShellOverride: undefined }) })) @@ -46,7 +46,7 @@ vi.mock('./MobileEmulatorExamples', () => ({ MobileEmulatorExamples: () => null describe('MobileEmulatorAgentControlRow freshness authority', () => { beforeEach(() => { - mocks.canUseLocalSkillFreshness = true + mocks.canUseSkillFreshness = true mocks.freshnessSkillName = undefined }) @@ -54,7 +54,7 @@ describe('MobileEmulatorAgentControlRow freshness authority', () => { renderToStaticMarkup() expect(mocks.freshnessSkillName).toBe('orca-cli') - mocks.canUseLocalSkillFreshness = false + mocks.canUseSkillFreshness = false renderToStaticMarkup() expect(mocks.freshnessSkillName).toBeUndefined() }) diff --git a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx index 6a036f85c0d..d143124cb5c 100644 --- a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx +++ b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx @@ -65,7 +65,7 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element { error={setup.cliSkillError} onRecheck={setup.refreshCliSkill} freshnessSkillName={ - activeSkillRuntime.canUseLocalSkillFreshness ? ORCA_CLI_SKILL_NAME : undefined + activeSkillRuntime.canUseSkillFreshness ? ORCA_CLI_SKILL_NAME : undefined } /> diff --git a/src/renderer/src/components/settings/OrchestrationPane.tsx b/src/renderer/src/components/settings/OrchestrationPane.tsx index ef6cb6e2edb..edc94dcc561 100644 --- a/src/renderer/src/components/settings/OrchestrationPane.tsx +++ b/src/renderer/src/components/settings/OrchestrationPane.tsx @@ -152,7 +152,7 @@ export function OrchestrationPane({ } onRecheck={refreshOrchestrationSkill} freshnessSkillName={ - activeSkillRuntime.canUseLocalSkillFreshness ? ORCHESTRATION_SKILL_NAME : undefined + activeSkillRuntime.canUseSkillFreshness ? ORCHESTRATION_SKILL_NAME : undefined } /> diff --git a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx index 1bf8d6a6c3b..6eb43b99bb4 100644 --- a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx +++ b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx @@ -51,7 +51,7 @@ export function OrchestrationSetupCard(props: { }} onRecheck={skill.refresh} freshnessSkillName={ - activeSkillRuntime.canUseLocalSkillFreshness ? ORCHESTRATION_SKILL_NAME : undefined + activeSkillRuntime.canUseSkillFreshness ? ORCHESTRATION_SKILL_NAME : undefined } /> ) diff --git a/src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx b/src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx index 938c788a6ec..0cb953652fa 100644 --- a/src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx +++ b/src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx @@ -49,7 +49,7 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ agentRuntime: { runtime: 'native' }, terminalShellOverride: undefined, installDisabledReason: null, - canUseLocalSkillFreshness: true + canUseSkillFreshness: true }) })) diff --git a/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts b/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts index 59f18dfe4cd..e1ed2034788 100644 --- a/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts +++ b/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts @@ -60,7 +60,7 @@ export function useLinearAgentSkillSetup(): { : buildSkillCommandForRuntime(updateTarget.command, activeSkillRuntime.agentRuntime) // Freshness cannot verify WSL, so report presence there. - const freshnessSkillName = activeSkillRuntime.canUseLocalSkillFreshness + const freshnessSkillName = activeSkillRuntime.canUseSkillFreshness ? updateTarget.skillName : undefined diff --git a/src/renderer/src/components/settings/use-local-cli-skill-freshness-name.ts b/src/renderer/src/components/settings/use-local-cli-skill-freshness-name.ts index a1475a2fd04..b39689c33e3 100644 --- a/src/renderer/src/components/settings/use-local-cli-skill-freshness-name.ts +++ b/src/renderer/src/components/settings/use-local-cli-skill-freshness-name.ts @@ -4,7 +4,8 @@ import type { LocalAgentRuntime } from './CliSkillRuntimeSetup' export function useLocalCliSkillFreshnessName(agentRuntime: LocalAgentRuntime): string | undefined { const activeSkillRuntime = useActiveProjectSkillRuntime() - return agentRuntime.runtime === 'host' && activeSkillRuntime.canUseLocalSkillFreshness + // Why local: this section installs the CLI on this machine, never a paired host. + return agentRuntime.runtime === 'host' && activeSkillRuntime.bundledSkillTarget?.kind === 'local' ? ORCA_CLI_SKILL_NAME : undefined } diff --git a/src/renderer/src/components/settings/use-settings-store-model.ts b/src/renderer/src/components/settings/use-settings-store-model.ts index 67d3f042b89..94f355ee902 100644 --- a/src/renderer/src/components/settings/use-settings-store-model.ts +++ b/src/renderer/src/components/settings/use-settings-store-model.ts @@ -128,8 +128,8 @@ export function useSettingsStoreModel() { discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) - const skillFreshnessApplies = activeSkillRuntime.canUseLocalSkillFreshness - const { plan: skillPlan } = useSkillFreshness(skillFreshnessApplies) + const skillFreshnessApplies = activeSkillRuntime.bundledSkillTarget !== null + const { plan: skillPlan } = useSkillFreshness(activeSkillRuntime.bundledSkillTarget) 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') diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx index 3e7595eead6..626fbf0dd06 100644 --- a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx @@ -15,7 +15,7 @@ const mocks = vi.hoisted(() => ({ toastDismiss: vi.fn(), requestDialog: vi.fn(), settingsLoaded: true, - canUseLocalSkillFreshness: true, + canUseSkillFreshness: true, freshnessEnabled: true, plan: ((): BundledSkillPlan | null => null)(), error: null as string | null @@ -26,10 +26,11 @@ function updatePlan(): BundledSkillPlan { } vi.mock('@/hooks/useSkillFreshness', () => ({ - useSkillFreshness: (enabled = true) => { - mocks.freshnessEnabled = enabled + useSkillFreshness: (target: unknown) => { + mocks.freshnessEnabled = target !== null return { plan: mocks.plan, + unsupported: null, loading: false, error: mocks.error, refresh: vi.fn() @@ -39,7 +40,7 @@ vi.mock('@/hooks/useSkillFreshness', () => ({ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ useActiveProjectSkillRuntime: () => ({ - canUseLocalSkillFreshness: mocks.canUseLocalSkillFreshness + bundledSkillTarget: mocks.canUseSkillFreshness ? { kind: 'local' } : null }) })) @@ -87,7 +88,7 @@ describe('SkillFreshnessNudge', () => { beforeEach(() => { mocks.dismissed = [] mocks.settingsLoaded = true - mocks.canUseLocalSkillFreshness = true + mocks.canUseSkillFreshness = true mocks.freshnessEnabled = true mocks.plan = updatePlan() mocks.error = null @@ -187,7 +188,7 @@ describe('SkillFreshnessNudge', () => { await renderNudge() const options = mocks.toastInfo.mock.calls[0]?.[1] - mocks.canUseLocalSkillFreshness = false + mocks.canUseSkillFreshness = false await rerenderNudge() options.onDismiss() diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx index 5addd3c2f7e..510286938f7 100644 --- a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx @@ -3,6 +3,7 @@ import { RefreshCw } from 'lucide-react' import { toast } from 'sonner' import { useSkillFreshness } from '@/hooks/useSkillFreshness' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { bundledSkillTargetKey } from '@/lib/bundled-skill-target' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { @@ -10,7 +11,6 @@ import { summarizeBundledSkillPlan } from '../../../../shared/bundled-skill-summary' import { requestSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' -import { LOCAL_SKILL_UPDATE_TARGET } from './skill-update-run-store' const MAX_DISMISSED_FRESHNESS_NUDGES = 512 const NO_DISMISSED_FRESHNESS_NUDGES: string[] = [] @@ -22,8 +22,9 @@ type ActiveFreshnessNudge = { } export function SkillFreshnessNudge(): null { - const activeSkillRuntime = useActiveProjectSkillRuntime() - const state = useSkillFreshness(activeSkillRuntime.canUseLocalSkillFreshness) + const target = useActiveProjectSkillRuntime().bundledSkillTarget + const targetKey = target ? bundledSkillTargetKey(target) : null + const state = useSkillFreshness(target) const settingsLoaded = useAppStore((store) => store.settings !== null) const dismissed = useAppStore( (store) => store.settings?.dismissedSkillFreshnessNudges ?? NO_DISMISSED_FRESHNESS_NUDGES @@ -34,7 +35,7 @@ export function SkillFreshnessNudge(): null { const activeNudgeRef = useRef(null) useEffect(() => { - if (!activeSkillRuntime.canUseLocalSkillFreshness) { + if (!targetKey) { const active = activeNudgeRef.current if (active) { active.persistDismissal = false @@ -60,7 +61,7 @@ export function SkillFreshnessNudge(): null { } // Why the summary's key: it changes with the host, the skills the badge counts and the // bytes they would get, so the next Orca version nudges again. - const fingerprint = bundledSkillNudgeKey(LOCAL_SKILL_UPDATE_TARGET, plan) + const fingerprint = bundledSkillNudgeKey(targetKey, plan) if (!fingerprint || dismissed.includes(fingerprint)) { const active = activeNudgeRef.current if (active) { @@ -167,14 +168,7 @@ export function SkillFreshnessNudge(): null { } ) activeNudgeRef.current = nextActive - }, [ - activeSkillRuntime.canUseLocalSkillFreshness, - dismissed, - settingsLoaded, - state.error, - state.plan, - updateSettings - ]) + }, [targetKey, dismissed, settingsLoaded, state.error, state.plan, updateSettings]) return null } diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx index 157c8da75d2..361ac5be002 100644 --- a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx @@ -1,4 +1,5 @@ import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { translate } from '@/i18n/i18n' import { Button } from '@/components/ui/button' import { IntegrationStatusPill } from '@/components/integration-status-pill' @@ -51,7 +52,24 @@ function statusPill(status: SkillFreshnessDisplayStatus): React.JSX.Element { // update exists, that every copy is current, or that a copy is left for the user — and // green must never stand in for that last case. export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): React.JSX.Element { - const { plan, loading, error } = useSkillFreshness() + const { plan, unsupported, loading, error } = useSkillFreshness( + useActiveProjectSkillRuntime().bundledSkillTarget + ) + if (unsupported) { + return ( + + {unsupported === 'connect-host' + ? translate( + 'auto.components.skills.SkillFreshnessStatusPill.connectHost', + 'Connect the SSH host' + ) + : translate( + 'auto.components.skills.SkillFreshnessStatusPill.updateServer', + 'Update Orca on the server' + )} + + ) + } if (loading && !plan) { return ( diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx index 669a273186e..79c946081d1 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx @@ -4,21 +4,19 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { - BundledSkillExecuteRequest, BundledSkillInstallResult, - BundledSkillPlan, - BundledSkillPlanRequest + BundledSkillExecuteRequest, + BundledSkillPlanRequest, + BundledSkillPlan } from '../../../../shared/bundled-skill-plan' import { SkillFreshnessUpdateDialog } from './SkillFreshnessUpdateDialog' import { consumeSkillFreshnessUpdateDialogRequest, requestSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' -import { - _resetSkillUpdateRunStore, - getSkillUpdateRun, - LOCAL_SKILL_UPDATE_TARGET -} from './skill-update-run-store' +import { _resetSkillUpdateRunStore, getSkillUpdateRun } from './skill-update-run-store' + +const LOCAL_SKILL_UPDATE_TARGET = 'local' import { doneResult, planEntry, @@ -29,6 +27,7 @@ import { const mocks = vi.hoisted(() => ({ plan: ((): BundledSkillPlan | null => null)(), + unsupported: ((): string | null => null)(), loading: false, error: null as string | null, refresh: vi.fn(), @@ -38,6 +37,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/hooks/useSkillFreshness', () => ({ useSkillFreshness: () => ({ plan: mocks.plan, + unsupported: mocks.unsupported, loading: mocks.loading, error: mocks.error, refresh: mocks.refresh @@ -45,7 +45,7 @@ vi.mock('@/hooks/useSkillFreshness', () => ({ })) vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ - useActiveProjectSkillRuntime: () => ({ canUseLocalSkillFreshness: true }) + useActiveProjectSkillRuntime: () => ({ bundledSkillTarget: { kind: 'local' } }) })) vi.mock('@/hooks/useInstalledAgentSkills', () => ({ @@ -154,6 +154,7 @@ function replaceCheckbox(): HTMLElement { beforeEach(() => { mocks.plan = null + mocks.unsupported = null mocks.loading = false mocks.error = null mocks.refresh.mockReset() @@ -210,6 +211,17 @@ describe('SkillFreshnessUpdateDialog', () => { expect(container.textContent).not.toContain('~/.agents/skills/orca-cli') }) + it.each([ + ['update-server', 'Update Orca on the server to update its Orca skills.'], + ['connect-host', 'Connect to this SSH host to check its Orca skills.'] + ])('explains a %s host instead of offering Update', (reason, text) => { + mocks.unsupported = reason + render() + + expect(container.textContent).toContain(text) + expect(container.textContent).not.toMatch(/Update \d+ skills?/) + }) + it('labels a link-only plan as installing for the agent, not an update', () => { mocks.plan = planOf([ planEntry('orca-cli', [ @@ -256,7 +268,7 @@ describe('SkillFreshnessUpdateDialog', () => { expect(skillsApi.bundledExecute).toHaveBeenCalledWith({ planDigest: '2'.repeat(64), - force: [EDITED_PATH] + request: { force: [EDITED_PATH] } }) expect(container.querySelector('[data-skill-row="orca-cli"]')?.textContent).toBe( 'orca-cliReplaced · Your copy was saved as a backup' @@ -308,7 +320,7 @@ describe('SkillFreshnessUpdateDialog', () => { expect(skillsApi.bundledExecute).toHaveBeenCalledWith({ planDigest: plan.planDigest, - force: [] + request: { force: [] } }) expect(container.textContent).toContain('Updated 1 skill · 1 skipped') const row = container.querySelector('[data-skill-row="orca-cli"]')?.textContent ?? '' @@ -336,7 +348,7 @@ describe('SkillFreshnessUpdateDialog', () => { expect(container.querySelector('.lucide-triangle-alert')?.parentElement?.textContent).toBe( 'Needs attention' ) - expect(getSkillUpdateRun()).toMatchObject({ + expect(getSkillUpdateRun(LOCAL_SKILL_UPDATE_TARGET)).toMatchObject({ state: 'done', summary: { ok: true, exitCode: 0, changedCount: mixed ? 1 : 0 } }) @@ -354,7 +366,7 @@ describe('SkillFreshnessUpdateDialog', () => { expect(skillsApi.bundledExecute).toHaveBeenLastCalledWith({ planDigest: update.planDigest, - force: [] + request: { force: [] } }) expect(container.textContent).toContain('Updated 1 skill') expect(button('Done')).toBeTruthy() diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx index 1e29b547de0..c9c7f3d163b 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx @@ -4,6 +4,8 @@ import type { BundledSkillOutcome, BundledSkillPlan } from '../../../../shared/b import { summarizeBundledSkillPlan } from '../../../../shared/bundled-skill-summary' import { useSkillFreshness } from '@/hooks/useSkillFreshness' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { bundledSkillTargetKey } from '@/lib/bundled-skill-target' +import type { BundledSkillUnsupportedReason } from '@/runtime/runtime-bundled-skills-client' import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' import { translate } from '@/i18n/i18n' import { Button } from '@/components/ui/button' @@ -19,8 +21,8 @@ import { SkillUpdateRows } from './SkillUpdateRows' import { SummaryHeadline } from './skill-freshness-summary-headline' import { updateActionLabel, updatedSkillsLabel } from './skill-plan-op-copy' import { + acknowledgeLatestSkillUpdateRun, acknowledgeSkillUpdateRun, - LOCAL_SKILL_UPDATE_TARGET, startSkillUpdateRun, useSkillUpdateRun, type SkillUpdateRun @@ -99,27 +101,51 @@ function runOutcomes(run: SkillUpdateRun): ReadonlyMap void] { - const [heldPlan, setHeldPlan] = useState(null) - if (plan && plan !== heldPlan) { - setHeldPlan(plan) +function useHeldSkillPlan( + plan: BundledSkillPlan | null, + targetKey: string | null +): [BundledSkillPlan | null, () => void] { + const [held, setHeld] = useState<{ plan: BundledSkillPlan; targetKey: string | null } | null>( + null + ) + if (plan && (plan !== held?.plan || targetKey !== held.targetKey)) { + setHeld({ plan, targetKey }) } - return [plan ?? heldPlan, () => setHeldPlan(null)] + // Why the key: another host's rows must never stand in for this one's. + const heldPlan = held?.targetKey === targetKey ? held.plan : null + return [plan ?? heldPlan, () => setHeld(null)] } /** A run's progress or result once Update starts; until then the plan's headline or error. */ function DialogHeadline({ run, + unsupported, error, plan }: { run: SkillUpdateRun + unsupported: BundledSkillUnsupportedReason | null error: string | null plan: BundledSkillPlan | null }): React.JSX.Element | null { if (run.state !== 'idle') { return } + if (unsupported) { + return ( +

+ {unsupported === 'connect-host' + ? translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.connectHost', + 'Connect to this SSH host to check its Orca skills.' + ) + : translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.updateServer', + 'Update Orca on the server to update its Orca skills.' + )} +

+ ) + } return error ? (

{error}

) : ( @@ -187,18 +213,25 @@ function SkillUpdateFooter({ } export function SkillFreshnessUpdateDialog(): React.JSX.Element { - const activeSkillRuntime = useActiveProjectSkillRuntime() - const state = useSkillFreshness(activeSkillRuntime.canUseLocalSkillFreshness) - const run = useSkillUpdateRun(LOCAL_SKILL_UPDATE_TARGET) + const target = useActiveProjectSkillRuntime().bundledSkillTarget + const targetKey = target ? bundledSkillTargetKey(target) : null + const state = useSkillFreshness(target) + const run = useSkillUpdateRun(targetKey) const open = useSyncExternalStore( subscribeSkillFreshnessUpdateDialog, getSkillFreshnessUpdateDialogRequest, getSkillFreshnessUpdateDialogRequest ) const [force, setForce] = useState([]) - const reviewed = useReviewedSkillPlan(state.plan, force) + // Why: Replace choices name one host's paths, so another host starts without them. + const [forceTargetKey, setForceTargetKey] = useState(targetKey) + if (forceTargetKey !== targetKey) { + setForceTargetKey(targetKey) + setForce([]) + } + const reviewed = useReviewedSkillPlan(target, state.plan, force) - const [shownPlan, releaseHeldPlan] = useHeldSkillPlan(reviewed.plan) + const [shownPlan, releaseHeldPlan] = useHeldSkillPlan(reviewed.plan, targetKey) const shownSummary = shownPlan ? summarizeBundledSkillPlan(shownPlan) : null const [showLocations, setShowLocations] = useState(false) const phase = skillRowPhase(run) @@ -224,18 +257,20 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element { if (run.state === 'idle') { releaseHeldPlan() } - if (showResult) { - acknowledgeSkillUpdateRun(LOCAL_SKILL_UPDATE_TARGET) + if (showResult && targetKey) { + acknowledgeSkillUpdateRun(targetKey) } + // A settled run on another host would otherwise hold the status bar, which opens this one. + acknowledgeLatestSkillUpdateRun() notifyInstalledAgentSkillsChanged() } const handleUpdate = (): void => { - if (!livePlan) { + if (!livePlan || !target) { return } void startSkillUpdateRun({ - targetKey: LOCAL_SKILL_UPDATE_TARGET, + target, // Why: only skills with work describe the in-flight run. names: summarizeBundledSkillPlan(livePlan) .skills.filter((skill) => skill.state !== 'current') @@ -246,7 +281,9 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element { } const handleRecheck = (): void => { - acknowledgeSkillUpdateRun(LOCAL_SKILL_UPDATE_TARGET) + if (targetKey) { + acknowledgeSkillUpdateRun(targetKey) + } setForce([]) void state.refresh() } @@ -255,8 +292,8 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element { isReplaced: (path: string) => force.includes(path), onReplaceChange: (path: string, next: boolean) => { // Why: a choice made on the result starts a new review of the re-planned skills. - if (showResult) { - acknowledgeSkillUpdateRun(LOCAL_SKILL_UPDATE_TARGET) + if (showResult && targetKey) { + acknowledgeSkillUpdateRun(targetKey) } setForce((previous) => next ? [...new Set([...previous, path])].sort() : previous.filter((item) => item !== path) @@ -278,7 +315,7 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element { - + {shownSummary && shownSummary.skills.length > 0 ? ( ({ + notifyInstalledAgentSkillsChanged: vi.fn() +})) + +vi.mock('@/runtime/runtime-bundled-skills-client', () => ({ + executeBundledSkillsOnTarget: vi.fn() +})) + +const LOCAL = { kind: 'local' } as const +const SERVER = { kind: 'environment', environmentId: 'server-1' } as const + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + _resetSkillUpdateRunStore() + consumeSkillFreshnessUpdateDialogRequest() +}) + +afterEach(() => { + _resetSkillUpdateRunStore() + vi.useRealTimers() +}) + +describe('skill update runs by target', () => { + it.each([false, true])( + 'keeps attention and result counts on their own host (remote attention: %s)', + async (remoteAttention) => { + const attentionTarget = remoteAttention ? SERVER : LOCAL + const successTarget = remoteAttention ? LOCAL : SERVER + const attentionKey = bundledSkillTargetKey(attentionTarget) + const successKey = bundledSkillTargetKey(successTarget) + const attentionResult: BundledSkillInstallResult = { + scope: 'global', + skills: [ + { + name: 'orca-cli', + status: 'ran', + ops: [{ path: '/skills/orca-cli', kind: 'restore', status: 'done' }] + } + ] + } + const successResult: BundledSkillInstallResult = { + scope: 'global', + skills: [ + { + name: 'orchestration', + status: 'ran', + ops: [{ path: '/skills/orchestration', kind: 'update', status: 'done' }] + }, + { + name: 'orca-linear', + status: 'ran', + ops: [{ path: '/skills/orca-linear', kind: 'leave', reason: 'edited', status: 'done' }] + } + ] + } + let settleAttention = (_result: BundledSkillInstallResult): void => { + throw new Error('Attention run has not started') + } + vi.mocked(executeBundledSkillsOnTarget) + .mockImplementationOnce( + () => + new Promise((resolve) => { + settleAttention = resolve + }) + ) + .mockResolvedValueOnce(successResult) + + const attentionRun = startSkillUpdateRun({ + target: attentionTarget, + names: ['orca-cli'], + planDigest: 'a'.repeat(64), + force: [] + }) + await startSkillUpdateRun({ + target: successTarget, + names: ['orchestration', 'orca-linear'], + planDigest: 'b'.repeat(64), + force: [] + }) + expect(getSkillUpdateRun(attentionKey)).toMatchObject({ + state: 'running', + targetKey: attentionKey + }) + settleAttention(attentionResult) + await attentionRun + + expect(executeBundledSkillsOnTarget).toHaveBeenNthCalledWith( + 1, + attentionTarget, + 'a'.repeat(64), + { force: [] } + ) + expect(executeBundledSkillsOnTarget).toHaveBeenNthCalledWith( + 2, + successTarget, + 'b'.repeat(64), + { force: [] } + ) + expect(getSkillUpdateRun(attentionKey)).toMatchObject({ + state: 'done', + targetKey: attentionKey, + needsAttention: true, + summary: { ok: true, changedCount: 0, leftAloneCount: 0 } + }) + expect(getSkillUpdateRun(successKey)).toMatchObject({ + state: 'done', + targetKey: successKey, + needsAttention: false, + summary: { ok: true, changedCount: 1, leftAloneCount: 1 } + }) + + vi.advanceTimersByTime(SKILL_UPDATE_SUCCESS_LINGER_MS) + + expect(getSkillUpdateRun(successKey).state).toBe('idle') + expect(getSkillUpdateRun(attentionKey).state).toBe('done') + } + ) +}) diff --git a/src/renderer/src/components/skills/skill-update-run-store.ts b/src/renderer/src/components/skills/skill-update-run-store.ts index 877dc1c60f1..ac5d68e021b 100644 --- a/src/renderer/src/components/skills/skill-update-run-store.ts +++ b/src/renderer/src/components/skills/skill-update-run-store.ts @@ -5,14 +5,13 @@ import { type BundledSkillResultSummary } from '../../../../shared/bundled-skill-summary' import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' +import { bundledSkillTargetKey, type BundledSkillTarget } from '@/lib/bundled-skill-target' +import { executeBundledSkillsOnTarget } from '@/runtime/runtime-bundled-skills-client' import { getSkillFreshnessUpdateDialogRequest, subscribeSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' -/** The host whose skills a run changed; WSL and paired hosts get their own key later. */ -export const LOCAL_SKILL_UPDATE_TARGET = 'local' - export type SkillUpdateRun = | { state: 'idle' } | { state: 'running'; targetKey: string; names: string[]; startedAt: number } @@ -39,6 +38,8 @@ const IDLE: SkillUpdateRun = Object.freeze({ state: 'idle' }) const slots = new Map() const listeners = new Set<() => void>() let nextToken = 0 +// The status bar reports the run the user started last, whichever host it ran on. +let latestTargetKey: string | null = null /** How long a finished run keeps its green check in the status bar. */ export const SKILL_UPDATE_SUCCESS_LINGER_MS = 4000 @@ -103,54 +104,61 @@ export function subscribeSkillUpdateRun(listener: () => void): () => void { return () => listeners.delete(listener) } -export function getSkillUpdateRun(targetKey: string = LOCAL_SKILL_UPDATE_TARGET): SkillUpdateRun { - return slots.get(targetKey)?.run ?? IDLE +export function getSkillUpdateRun(targetKey: string | null): SkillUpdateRun { + return (targetKey && slots.get(targetKey)?.run) || IDLE } -export function useSkillUpdateRun(targetKey: string = LOCAL_SKILL_UPDATE_TARGET): SkillUpdateRun { +/** `targetKey` is `bundledSkillTargetKey` of the host; null reads idle. */ +export function useSkillUpdateRun(targetKey: string | null): SkillUpdateRun { const read = (): SkillUpdateRun => getSkillUpdateRun(targetKey) return useSyncExternalStore(subscribeSkillUpdateRun, read, read) } -/** Executes the plan the user reviewed, identified by its digest. */ +export function useLatestSkillUpdateRun(): SkillUpdateRun { + const read = (): SkillUpdateRun => getSkillUpdateRun(latestTargetKey) + return useSyncExternalStore(subscribeSkillUpdateRun, read, read) +} + +/** Executes the plan the user reviewed on `target`, identified by its digest. */ export async function startSkillUpdateRun(request: { - /** Only the local host has a plan route in this build. */ - targetKey: typeof LOCAL_SKILL_UPDATE_TARGET + target: BundledSkillTarget names: string[] planDigest: string force: string[] }): Promise { - const { targetKey, names } = request + const { target, names } = request + const targetKey = bundledSkillTargetKey(target) const slot = slotFor(targetKey) if (slot.run.state === 'running') { return } const token = ++nextToken slot.token = token + latestTargetKey = targetKey setRun(targetKey, { state: 'running', targetKey, names, startedAt: Date.now() }) - const settled = await window.api.skills - .bundledExecute({ planDigest: request.planDigest, force: request.force }) - .then( - (result): SkillUpdateRun => { - const summary = summarizeBundledSkillOutcomes(result) - return { - state: 'done', - targetKey, - names, - finishedAt: Date.now(), - result, - summary, - needsAttention: summary.skills.some((skill) => skill.text === 'Needs attention') - } - }, - (error: unknown): SkillUpdateRun => ({ - state: 'error', + const settled = await executeBundledSkillsOnTarget(target, request.planDigest, { + force: request.force + }).then( + (result): SkillUpdateRun => { + const summary = summarizeBundledSkillOutcomes(result) + return { + state: 'done', targetKey, names, finishedAt: Date.now(), - message: error instanceof Error ? error.message : String(error) - }) - ) + result, + summary, + needsAttention: summary.skills.some((skill) => skill.text === 'Needs attention') + } + }, + (error: unknown): SkillUpdateRun => ({ + state: 'error', + targetKey, + names, + finishedAt: Date.now(), + message: error instanceof Error ? error.message : String(error) + }) + ) // Why: a completion for a run that was acknowledged or replaced is stale. if (slots.get(targetKey)?.token !== token) { return @@ -161,7 +169,7 @@ export async function startSkillUpdateRun(request: { } /** Clears a settled run so the status-bar segment can retire itself. */ -export function acknowledgeSkillUpdateRun(targetKey: string = LOCAL_SKILL_UPDATE_TARGET): void { +export function acknowledgeSkillUpdateRun(targetKey: string): void { const slot = slots.get(targetKey) if (!slot || slot.run.state === 'idle' || slot.run.state === 'running') { return @@ -171,6 +179,13 @@ export function acknowledgeSkillUpdateRun(targetKey: string = LOCAL_SKILL_UPDATE setRun(targetKey, IDLE) } +/** Clears the status bar's run when it settled; a running one keeps it. */ +export function acknowledgeLatestSkillUpdateRun(): void { + if (latestTargetKey) { + acknowledgeSkillUpdateRun(latestTargetKey) + } +} + /** @internal - tests need a clean module between cases. */ export function _resetSkillUpdateRunStore(): void { for (const slot of slots.values()) { @@ -178,4 +193,5 @@ export function _resetSkillUpdateRunStore(): void { } slots.clear() listeners.clear() + latestTargetKey = null } diff --git a/src/renderer/src/components/skills/use-reviewed-skill-plan.ts b/src/renderer/src/components/skills/use-reviewed-skill-plan.ts index 7288d077ac0..a1e4db00247 100644 --- a/src/renderer/src/components/skills/use-reviewed-skill-plan.ts +++ b/src/renderer/src/components/skills/use-reviewed-skill-plan.ts @@ -1,5 +1,8 @@ import { useEffect, useState } from 'react' import type { BundledSkillPlan } from '../../../../shared/bundled-skill-plan' +import { BUNDLED_ORCA_SKILL_UPDATE_REQUIRED_MESSAGE } from '../../../../shared/skill-install-capability' +import { bundledSkillTargetKey, type BundledSkillTarget } from '@/lib/bundled-skill-target' +import { planBundledSkillsOnTarget } from '@/runtime/runtime-bundled-skills-client' type ReviewedPlan = { plan: BundledSkillPlan | null; loading: boolean; error: string | null } @@ -10,24 +13,31 @@ type ForcedPlanResult = { request: string; plan: BundledSkillPlan | null; error: * decides every op; the renderer never edits one. Without toggles it is the shared plan. */ export function useReviewedSkillPlan( + target: BundledSkillTarget | null, sharedPlan: BundledSkillPlan | null, force: readonly string[] ): ReviewedPlan { // Why the shared digest: a re-check of the shared plan must re-plan the forced one too. const request = - force.length > 0 && sharedPlan ? `${sharedPlan.planDigest}\n${force.join('\n')}` : '' + force.length > 0 && sharedPlan && target + ? [bundledSkillTargetKey(target), sharedPlan.planDigest, ...force].join('\n') + : '' const [result, setResult] = useState(null) useEffect(() => { - if (!request) { + if (!request || !target) { return } let cancelled = false - const forced = request.split('\n').slice(1) - window.api.skills.bundledPlan({ force: forced }).then( - (plan) => { + const forced = request.split('\n').slice(2) + planBundledSkillsOnTarget(target, { force: forced }).then( + (result) => { if (!cancelled) { - setResult({ request, plan, error: null }) + setResult( + result.kind === 'plan' + ? { request, plan: result.plan, error: null } + : { request, plan: null, error: BUNDLED_ORCA_SKILL_UPDATE_REQUIRED_MESSAGE } + ) } }, (error: unknown) => { @@ -43,6 +53,7 @@ export function useReviewedSkillPlan( return () => { cancelled = true } + // oxlint-disable-next-line react-hooks/exhaustive-deps -- `request` names the target. }, [request]) if (!request) { diff --git a/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.test.tsx b/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.test.tsx index 65cde5d0655..6729fa273a8 100644 --- a/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.test.tsx +++ b/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.test.tsx @@ -7,7 +7,6 @@ import type { BundledSkillInstallResult } from '../../../../shared/bundled-skill import { SkillUpdateStatusSegment } from './SkillUpdateStatusSegment' import { _resetSkillUpdateRunStore, - LOCAL_SKILL_UPDATE_TARGET, SKILL_UPDATE_SUCCESS_LINGER_MS, startSkillUpdateRun } from '../skills/skill-update-run-store' @@ -53,7 +52,7 @@ let running: Promise | null = null async function start(names: string[]): Promise { await act(async () => { running = startSkillUpdateRun({ - targetKey: LOCAL_SKILL_UPDATE_TARGET, + target: { kind: 'local' }, names, planDigest: 'a'.repeat(64), force: [] diff --git a/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.tsx b/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.tsx index 065aadc02de..226b6605a94 100644 --- a/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.tsx @@ -2,7 +2,7 @@ import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { requestSkillFreshnessUpdateDialog } from '../skills/skill-freshness-update-dialog' -import { useSkillUpdateRun } from '../skills/skill-update-run-store' +import { useLatestSkillUpdateRun } from '../skills/skill-update-run-store' import { updatedSkillsLabel } from '../skills/skill-plan-op-copy' // Why: always rendered (not gated by `statusBarItems`). The dialog can be closed @@ -12,7 +12,7 @@ export function SkillUpdateStatusSegment({ }: { iconOnly: boolean }): React.JSX.Element | null { - const run = useSkillUpdateRun() + const run = useLatestSkillUpdateRun() if (run.state === 'idle') { return null diff --git a/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx b/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx index 2d92fc42ebd..82178f08dc9 100644 --- a/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx +++ b/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx @@ -1,11 +1,13 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { renderHook } from '@testing-library/react' -import { getDefaultSettings } from '../../../shared/constants' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../shared/constants' +import type { ExecutionHostId } from '../../../shared/execution-host' import { useAppStore } from '@/store' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' import { hasLocalSkillRuntimeAuthority, - shouldUseLocalSkillFreshness, + shouldUseSkillFreshness, useActiveProjectSkillRuntime } from './useActiveProjectSkillRuntime' @@ -34,7 +36,19 @@ describe('useActiveProjectSkillRuntime', () => { beforeEach(() => { setPlatform('win32') setWindowsShell('git-bash') - useAppStore.setState({ runtimeEnvironmentCatalogSettled: true, runtimeEnvironments: [] }) + useAppStore.setState({ + activeRepoId: null, + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + projects: [], + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + restoredRuntimeHostIdByWorkspaceSessionKey: {}, + runtimeEnvironmentCatalogSettled: true, + runtimeEnvironments: [] + }) }) afterEach(() => { @@ -59,6 +73,8 @@ describe('useActiveProjectSkillRuntime', () => { wslDistro: 'Ubuntu', label: 'WSL Ubuntu' }) + expect(result.current.bundledSkillTarget).toBeNull() + expect(result.current.canUseSkillFreshness).toBe(false) }) it('ignores a windows-host global default so skill discovery keeps no target', () => { @@ -99,21 +115,75 @@ 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 + it.each<{ name: string; workspaceId: string; hostId: ExecutionHostId }>([ + { name: 'SSH worktree', workspaceId: 'repo::/remote/ssh', hostId: 'ssh:box' }, + { name: 'paired worktree', workspaceId: 'repo::/remote/paired', hostId: 'runtime:paired' }, + { name: 'folder workspace', workspaceId: 'folder:restored', hostId: 'runtime:paired' } + ])( + 'keeps a restored $name unresolved while owner rows are missing', + ({ workspaceId, hostId }) => { + useAppStore.setState({ + activeRepoId: 'repo', + activeWorktreeId: workspaceId, + activeWorkspaceExecutionHostId: hostId + }) + + const { result } = renderHook(() => useActiveProjectSkillRuntime()) + + expect(result.current.bundledSkillTarget).toBeNull() + expect(result.current.canUseSkillFreshness).toBe(false) + } + ) + + it('keeps a local target when no workspace is active', () => { + const { result } = renderHook(() => useActiveProjectSkillRuntime()) + + expect(result.current.bundledSkillTarget).toEqual({ kind: 'local' }) + expect(result.current.canUseSkillFreshness).toBe(true) + }) + + it('keeps a proven local target for floating terminals', () => { + useAppStore.setState({ activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + const { result } = renderHook(() => useActiveProjectSkillRuntime()) + + expect(result.current.bundledSkillTarget).toEqual({ kind: 'local' }) + expect(result.current.canUseSkillFreshness).toBe(true) + }) + + it('keeps proven local workspace ownership ahead of a focused paired transport', () => { + const worktree = makeWorktree({ id: 'repo::/local', repoId: 'repo', hostId: 'local' }) + useAppStore.setState({ + activeWorktreeId: worktree.id, + activeWorkspaceExecutionHostId: 'local', + worktreesByRepo: { repo: [worktree] }, + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'paired' }, + runtimeEnvironments: [ + { + id: 'paired', + name: 'Paired', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: 'endpoint' + } + ] + }) + const { result } = renderHook(() => useActiveProjectSkillRuntime()) + expect(result.current.bundledSkillTarget).toEqual({ kind: 'local' }) + }) + + it('limits freshness pills to this machine and paired hosts', () => { + expect(shouldUseSkillFreshness({ kind: 'local' }, undefined)).toBe(true) + expect(shouldUseSkillFreshness({ kind: 'local' }, { runtime: 'host', label: 'Host' })).toBe( + true ) + expect(shouldUseSkillFreshness({ kind: 'local' }, { runtime: 'wsl', label: 'WSL' })).toBe(false) expect( - shouldUseLocalSkillFreshness( - { kind: 'environment', environmentId: 'ssh-production' }, - undefined - ) - ).toBe(false) - expect(shouldUseLocalSkillFreshness(null, undefined)).toBe(false) + shouldUseSkillFreshness({ kind: 'environment', environmentId: 'ssh-production' }, undefined) + ).toBe(true) + expect(shouldUseSkillFreshness(null, undefined)).toBe(false) }) it('limits the no-project Windows fallback to local runtime authority', () => { diff --git a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts index 230a43991fd..43e8e1b59f5 100644 --- a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts +++ b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts @@ -4,6 +4,7 @@ import type { ProjectExecutionRuntimeResolution } from '../../../shared/project- import type { SkillDiscoveryTarget } from '../../../shared/skills' import { useActiveSkillDiscoveryRuntimeTarget } from './use-active-skill-discovery-runtime-target' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { getGlobalWindowsExecutionRuntimeContext, getLocalProjectExecutionRuntimeContext @@ -16,6 +17,12 @@ import { type ProjectAgentSkillRuntime } from '@/lib/project-skill-runtime' import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' +import { + resolveBundledSkillTarget, + servingExecutionHostId, + type BundledSkillTarget +} from '@/lib/bundled-skill-target' +import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' import { useAppStore } from '@/store' import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' @@ -25,19 +32,22 @@ type ActiveProjectSkillRuntime = { agentRuntime?: ProjectAgentSkillRuntime terminalShellOverride?: string installDisabledReason: string | null - canUseLocalSkillFreshness: boolean + canUseSkillFreshness: boolean + /** Where Update skills plans Orca's skills; null while that host is unknown. */ + bundledSkillTarget: BundledSkillTarget | null } -const EMPTY_ACTIVE_PROJECT_SKILL_RUNTIME: ActiveProjectSkillRuntime = Object.freeze({ - installDisabledReason: null, - canUseLocalSkillFreshness: false -}) +const LOCAL_RUNTIME_TARGET = { kind: 'local' } as const -export function shouldUseLocalSkillFreshness( +/** Whether setup surfaces show the plan's pill: this machine or a paired host, not WSL yet. */ +export function shouldUseSkillFreshness( runtimeTarget: RuntimeClientTarget | null, agentRuntime?: ProjectAgentSkillRuntime ): boolean { - return runtimeTarget?.kind === 'local' && agentRuntime?.runtime !== 'wsl' + return ( + runtimeTarget?.kind === 'environment' || + (runtimeTarget?.kind === 'local' && agentRuntime?.runtime !== 'wsl') + ) } export function hasLocalSkillRuntimeAuthority(runtimeTarget: RuntimeClientTarget | null): boolean { @@ -76,6 +86,12 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { worktreesByRepo: selectWorktreesByRepo(state) })) ) + const activeWorkspaceHostId = useAppStore((state) => + servingExecutionHostId( + getResolvedExecutionHostIdForWorktree(state, state.activeWorktreeId), + state.runtimeEnvironments + ) + ) const currentPlatform = getCurrentPlatform() const windowsCapabilities = useWindowsTerminalCapabilities(currentPlatform === 'win32') const runtimeTarget = useActiveSkillDiscoveryRuntimeTarget() @@ -85,6 +101,10 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { wslAvailable: windowsCapabilities.isLoading ? undefined : windowsCapabilities.wslAvailable, availableWslDistros: windowsCapabilities.isLoading ? null : windowsCapabilities.wslDistros } + const ownerRuntimeTarget = + runtimeState.activeWorktreeId && activeWorkspaceHostId === LOCAL_EXECUTION_HOST_ID + ? LOCAL_RUNTIME_TARGET + : runtimeTarget const projectRuntime = getLocalProjectExecutionRuntimeContext( runtimeState, @@ -93,7 +113,7 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { wslContext ) ?? // Global WSL is the only no-project default that changes the install target (#12103). - (hasLocalSkillRuntimeAuthority(runtimeTarget) + (hasLocalSkillRuntimeAuthority(ownerRuntimeTarget) ? wslOnly( getGlobalWindowsExecutionRuntimeContext( runtimeState, @@ -103,21 +123,29 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { ) ) : undefined) + const bundledSkillTarget = resolveBundledSkillTarget( + ownerRuntimeTarget, + projectRuntime, + runtimeState.activeWorktreeId ? activeWorkspaceHostId : undefined + ) if (!projectRuntime) { // Why: buildSkillCommandForRuntime still builds a Windows host command // without a project runtime, so the terminal has to match that shell. - const terminalShellOverride = hasLocalSkillRuntimeAuthority(runtimeTarget) + const terminalShellOverride = hasLocalSkillRuntimeAuthority(ownerRuntimeTarget) ? getProjectAgentSkillTerminalShellOverride( currentPlatform, runtimeState.settings, undefined ) : undefined - const canUseLocalSkillFreshness = shouldUseLocalSkillFreshness(runtimeTarget) - if (!terminalShellOverride && !canUseLocalSkillFreshness) { - return EMPTY_ACTIVE_PROJECT_SKILL_RUNTIME + const canUseSkillFreshness = + bundledSkillTarget !== null && shouldUseSkillFreshness(ownerRuntimeTarget) + return { + installDisabledReason: null, + ...(terminalShellOverride ? { terminalShellOverride } : {}), + canUseSkillFreshness, + bundledSkillTarget } - return { installDisabledReason: null, terminalShellOverride, canUseLocalSkillFreshness } } const agentRuntime = getProjectAgentSkillRuntime(projectRuntime, currentPlatform) @@ -131,9 +159,11 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { agentRuntime ), installDisabledReason: getProjectSkillInstallDisabledReason(projectRuntime), - canUseLocalSkillFreshness: shouldUseLocalSkillFreshness(runtimeTarget, agentRuntime) + canUseSkillFreshness: + bundledSkillTarget !== null && shouldUseSkillFreshness(ownerRuntimeTarget, agentRuntime), + bundledSkillTarget } - }, [currentPlatform, runtimeState, runtimeTarget, windowsCapabilities]) + }, [activeWorkspaceHostId, 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. diff --git a/src/renderer/src/hooks/useSkillFreshness.test.tsx b/src/renderer/src/hooks/useSkillFreshness.test.tsx index b9c3f9fa317..d74a716a22a 100644 --- a/src/renderer/src/hooks/useSkillFreshness.test.tsx +++ b/src/renderer/src/hooks/useSkillFreshness.test.tsx @@ -4,6 +4,7 @@ import { act, StrictMode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { BundledSkillPlan } from '../../../shared/bundled-skill-plan' +import type { BundledSkillTarget } from '@/lib/bundled-skill-target' import { planOf, sharedEntry } from '../components/skills/bundled-skill-plan-fixtures.test-support' import { _skillFreshnessCacheForTests, @@ -43,8 +44,18 @@ let state: SkillFreshnessState | null = null const states = new Map() const renderedPlans: (BundledSkillPlan | null)[] = [] -function Probe({ id = 'default', enabled = true }: { id?: string; enabled?: boolean }): null { - state = useSkillFreshness(enabled) +const LOCAL: BundledSkillTarget = { kind: 'local' } + +function Probe({ + id = 'default', + enabled = true, + target = LOCAL +}: { + id?: string + enabled?: boolean + target?: BundledSkillTarget +}): null { + state = useSkillFreshness(enabled ? target : null) states.set(id, state) renderedPlans.push(state.plan) return null @@ -305,4 +316,17 @@ describe('useSkillFreshness', () => { ) ).toHaveLength(1) }) + + it('re-plans a new host without ever showing the previous host’s plan', async () => { + const bundledPlan = vi.fn().mockResolvedValueOnce(plan(1, ['orca-cli'])) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the preload methods this code path calls. + window.api = { skills: { bundledPlan } } as never + + await act(async () => root?.render()) + expect(state?.plan?.planDigest).toBe(digest(1)) + + await act(async () => root?.render()) + expect(state).toMatchObject({ plan: null, loading: false, unsupported: 'connect-host' }) + expect(bundledPlan).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/hooks/useSkillFreshness.ts b/src/renderer/src/hooks/useSkillFreshness.ts index 9f4d50bf723..99034530b7d 100644 --- a/src/renderer/src/hooks/useSkillFreshness.ts +++ b/src/renderer/src/hooks/useSkillFreshness.ts @@ -1,46 +1,56 @@ import { useEffect, useRef, useSyncExternalStore } from 'react' import type { BundledSkillPlan } from '../../../shared/bundled-skill-plan' import { summarizeBundledSkillPlan } from '../../../shared/bundled-skill-summary' +import { bundledSkillTargetKey, type BundledSkillTarget } from '@/lib/bundled-skill-target' +import { + planBundledSkillsOnTarget, + type BundledSkillPlanResult, + type BundledSkillUnsupportedReason +} from '@/runtime/runtime-bundled-skills-client' import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' // Why: window focus fires on every alt-tab, and each plan re-reads and re-hashes every // target; a just-completed plan stays authoritative briefly. const FOCUS_RESCAN_COOLDOWN_MS = 15_000 -let cachedPlan: BundledSkillPlan | null = null -let pendingPlan: Promise | null = null +// Why one target: every surface derives it from the same store, so they all plan one host. +let activeTarget: BundledSkillTarget | null = null +let cachedPlan: { targetKey: string; result: BundledSkillPlanResult; revision: number } | null = + null +let pendingPlan: { + targetKey: string + request: Promise +} | null = null let invalidationRevision = 0 -let completedRevision = -1 let lastCompletedScanAt = 0 let refreshSequence = 0 let scheduledFocusRescan: number | null = null type SkillFreshnessSnapshot = { + /** The host this snapshot describes; a surface on another target never reads it. */ + targetKey: string | null plan: BundledSkillPlan | null + /** Why this host can't be planned: an older server, or an SSH host no server serves yet. */ + unsupported: BundledSkillUnsupportedReason | null loading: boolean error: string | null } -let snapshot: SkillFreshnessSnapshot = { - plan: null, - loading: false, - error: null -} -const DISABLED_SNAPSHOT: SkillFreshnessSnapshot = Object.freeze({ - plan: null, - loading: false, - error: null -}) +const IDLE = { plan: null, unsupported: null, loading: false, error: null } as const +let snapshot: SkillFreshnessSnapshot = { ...IDLE, targetKey: null } +const DISABLED_SNAPSHOT: SkillFreshnessSnapshot = Object.freeze({ ...IDLE, targetKey: null }) const REENABLING_SNAPSHOT: SkillFreshnessSnapshot = Object.freeze({ - plan: null, - loading: true, - error: null + ...IDLE, + targetKey: null, + loading: true }) const subscribers = new Set<() => void>() let pendingReenableRefresh: Promise | null = null function publishSnapshot(next: SkillFreshnessSnapshot): void { if ( + snapshot.targetKey === next.targetKey && snapshot.plan === next.plan && + snapshot.unsupported === next.unsupported && snapshot.loading === next.loading && snapshot.error === next.error ) { @@ -52,67 +62,100 @@ function publishSnapshot(next: SkillFreshnessSnapshot): void { } } -async function loadPlan(force: boolean): Promise { +async function loadPlan( + target: BundledSkillTarget, + force: boolean +): Promise { + const targetKey = bundledSkillTargetKey(target) if (force) { invalidationRevision += 1 } const targetRevision = invalidationRevision for (;;) { - if (cachedPlan && completedRevision >= targetRevision) { - return cachedPlan + if (cachedPlan?.targetKey === targetKey && cachedPlan.revision >= targetRevision) { + return cachedPlan.result } - if (!pendingPlan) { + if (pendingPlan?.targetKey !== targetKey) { const requestRevision = invalidationRevision - const request = window.api.skills - .bundledPlan() - .then((plan) => { - cachedPlan = plan - completedRevision = Math.max(completedRevision, requestRevision) + const request = planBundledSkillsOnTarget(target, {}) + .then((result) => { + // Why per target: a late answer from the previous host must not satisfy this one. + cachedPlan = { + targetKey, + result, + revision: + cachedPlan?.targetKey === targetKey + ? Math.max(cachedPlan.revision, requestRevision) + : requestRevision + } lastCompletedScanAt = Date.now() - return plan + return result }) .finally(() => { - if (pendingPlan === request) { + if (pendingPlan?.request === request) { pendingPlan = null } }) - pendingPlan = request + pendingPlan = { targetKey, request } } - await pendingPlan + await pendingPlan.request } } /** Re-plan without going through the hook, for surfaces whose explicit refresh affordance * must move the shared verdict. */ -export async function refreshSkillFreshness(force = true): Promise { +export function refreshSkillFreshness(force = true): Promise { + return refreshPlan(force) +} + +async function refreshPlan(force: boolean): Promise { if (scheduledFocusRescan !== null) { window.clearTimeout(scheduledFocusRescan) scheduledFocusRescan = null } + const target = activeTarget + if (!target) { + return + } + const targetKey = bundledSkillTargetKey(target) const sequence = ++refreshSequence // Why: the plan's digest is what Update executes. Once invalidated, a stale plan must // stop authorizing UI even if the replacement plan fails. - publishSnapshot({ plan: null, loading: true, error: null }) + publishSnapshot({ ...IDLE, targetKey, loading: true }) try { - const plan = await loadPlan(force) + const result = await loadPlan(target, force) if (sequence === refreshSequence) { - publishSnapshot({ plan, loading: false, error: null }) + publishSnapshot( + result.kind === 'plan' + ? { ...IDLE, targetKey, plan: result.plan } + : { ...IDLE, targetKey, unsupported: result.reason } + ) } } catch (cause) { if (sequence === refreshSequence) { publishSnapshot({ - plan: null, - loading: false, + ...IDLE, + targetKey, error: cause instanceof Error ? cause.message : 'Could not inspect Orca skills.' }) } } } +/** Points every surface at `target`; a new host drops the old one's plan and re-plans. */ +function selectSkillFreshnessTarget(target: BundledSkillTarget): boolean { + if (activeTarget && bundledSkillTargetKey(activeTarget) === bundledSkillTargetKey(target)) { + return false + } + activeTarget = target + void refreshPlan(true) + return true +} + function onWindowFocus(): void { const cooldownRemaining = FOCUS_RESCAN_COOLDOWN_MS - (Date.now() - lastCompletedScanAt) if (cooldownRemaining <= 0) { - void refreshSkillFreshness(true) + void refreshPlan(true) return } if ( @@ -124,18 +167,18 @@ function onWindowFocus(): void { } // Why: a focus event can follow an external edit. Retract stale update // authority immediately, but keep rapid alt-tabs to one trailing disk scan. - publishSnapshot({ plan: null, loading: true, error: null }) + publishSnapshot({ ...IDLE, targetKey: snapshot.targetKey, loading: true }) scheduledFocusRescan = window.setTimeout( () => { scheduledFocusRescan = null - void refreshSkillFreshness(true) + void refreshPlan(true) }, Math.min(cooldownRemaining, FOCUS_RESCAN_COOLDOWN_MS) ) } function onInstalledSkillsChanged(): void { - void refreshSkillFreshness(true) + void refreshPlan(true) } function subscribe(subscriber: () => void): () => void { @@ -172,8 +215,8 @@ function getDisabledSnapshot(): SkillFreshnessSnapshot { } function ensurePlanLoaded(): void { - if (!snapshot.plan && !snapshot.loading) { - void refreshSkillFreshness(false) + if (!snapshot.plan && !snapshot.unsupported && !snapshot.loading) { + void refreshPlan(false) } } @@ -184,46 +227,52 @@ export type SkillFreshnessState = SkillFreshnessSnapshot & { async function skipSkillFreshnessRefresh(): Promise {} function refreshSkillFreshnessAfterReenable(): Promise { - pendingReenableRefresh ??= refreshSkillFreshness(true).finally(() => { + pendingReenableRefresh ??= refreshPlan(true).finally(() => { pendingReenableRefresh = null }) return pendingReenableRefresh } -export function useSkillFreshness(enabled = true): SkillFreshnessState { - const previousEnabledRef = useRef(enabled) - const reenabled = enabled && !previousEnabledRef.current +/** Plans `target`'s Orca skills; null (host unknown or not plannable) plans nothing. */ +export function useSkillFreshness(target: BundledSkillTarget | null): SkillFreshnessState { + const targetKey = target ? bundledSkillTargetKey(target) : null + const previousKeyRef = useRef(targetKey) + const reenabled = targetKey !== null && previousKeyRef.current === null const current = useSyncExternalStore( - enabled ? subscribe : subscribeDisabled, - enabled ? getSnapshot : getDisabledSnapshot, - enabled ? getSnapshot : getDisabledSnapshot + target ? subscribe : subscribeDisabled, + target ? getSnapshot : getDisabledSnapshot, + target ? getSnapshot : getDisabledSnapshot ) useEffect(() => { - const wasEnabled = previousEnabledRef.current - previousEnabledRef.current = enabled - if (!enabled) { + const previousKey = previousKeyRef.current + previousKeyRef.current = targetKey + if (!target) { return } - if (!wasEnabled) { + if (selectSkillFreshnessTarget(target)) { + return + } + if (previousKey === null) { void refreshSkillFreshnessAfterReenable() return } ensurePlanLoaded() - }, [enabled]) + // oxlint-disable-next-line react-hooks/exhaustive-deps -- `targetKey` is the target's identity. + }, [targetKey]) return { - ...(reenabled ? REENABLING_SNAPSHOT : current), - refresh: enabled ? refreshSkillFreshness : skipSkillFreshnessRefresh + ...(reenabled || (target && current.targetKey !== targetKey) ? REENABLING_SNAPSHOT : current), + refresh: target ? refreshSkillFreshness : skipSkillFreshnessRefresh } } export const _skillFreshnessCacheForTests = { reset(): void { + activeTarget = null cachedPlan = null pendingPlan = null invalidationRevision = 0 - completedRevision = -1 lastCompletedScanAt = 0 refreshSequence = 0 pendingReenableRefresh = null @@ -231,6 +280,6 @@ export const _skillFreshnessCacheForTests = { window.clearTimeout(scheduledFocusRescan) scheduledFocusRescan = null } - snapshot = { plan: null, loading: false, error: null } + snapshot = { ...IDLE, targetKey: null } } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d50482e5c79..6b2726ea0e6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5004,7 +5004,9 @@ "updatedSkipped": "{{value0}} · {{value1}} skipped", "currentOne": "1 skill is up to date", "currentMany": "{{value0}} skills are up to date", - "noUpdates": "No updates available" + "noUpdates": "No updates available", + "updateServer": "Update Orca on the server to update its Orca skills.", + "connectHost": "Connect to this SSH host to check its Orca skills." }, "SkillUpdateResultRows": {}, "SkillUpdateRow": { @@ -5017,7 +5019,9 @@ "details": "Details", "needsAttention": "Review skill", "checking": "Checking...", - "checkFailed": "Check failed" + "checkFailed": "Check failed", + "updateServer": "Update Orca on the server", + "connectHost": "Connect the SSH host" }, "SkillShareDialog": { "reconnect": "Reconnect your Orca account before sharing.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 14e9fb6ee44..b1ea58d38a5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4791,7 +4791,9 @@ "updatedSkipped": "{{value0}} · {{value1}} omitidas", "currentOne": "1 skill está actualizada", "currentMany": "{{value0}} skills están actualizadas", - "noUpdates": "No hay actualizaciones disponibles" + "noUpdates": "No hay actualizaciones disponibles", + "updateServer": "Actualiza Orca en el servidor para actualizar sus skills de Orca.", + "connectHost": "Conéctate a este host SSH para revisar sus skills de Orca." }, "SkillFreshnessStatusPill": { "updateAvailable": "Actualización disponible", @@ -4800,7 +4802,9 @@ "details": "Details", "needsAttention": "Review skill", "checking": "De cheques...", - "checkFailed": "Verificación fallida" + "checkFailed": "Verificación fallida", + "updateServer": "Actualiza Orca en el servidor", + "connectHost": "Conecta el host SSH" }, "SkillUpdateResultRows": {}, "SkillUpdateRow": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 3c8e1cdbf96..a179758598f 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -4835,7 +4835,9 @@ "updatedSkipped": "{{value0}} · {{value1}} ignorées", "currentOne": "1 skill est à jour", "currentMany": "{{value0}} skills sont à jour", - "noUpdates": "Aucune mise à jour disponible" + "noUpdates": "Aucune mise à jour disponible", + "updateServer": "Mettez à jour Orca sur le serveur pour mettre à jour ses skills Orca.", + "connectHost": "Connectez-vous à cet hôte SSH pour vérifier ses skills Orca." }, "SkillUpdateResultRows": {}, "SkillUpdateRow": { @@ -4848,7 +4850,9 @@ "details": "Détails", "needsAttention": "Examiner le skill", "checking": "Vérification...", - "checkFailed": "Échec de la vérification" + "checkFailed": "Échec de la vérification", + "updateServer": "Mettez à jour Orca sur le serveur", + "connectHost": "Connectez l’hôte SSH" }, "SkillShareDialog": { "reconnect": "Reconnectez votre compte Orca avant de partager.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 26d5bc23087..15debe123e1 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4781,7 +4781,9 @@ "updatedSkipped": "{{value0}} · {{value1}} 件をスキップ", "currentOne": "1 個のスキルは最新です", "currentMany": "{{value0}} 個のスキルは最新です", - "noUpdates": "利用可能な更新はありません" + "noUpdates": "利用可能な更新はありません", + "updateServer": "サーバーの Orca を更新すると、Orca スキルを更新できます。", + "connectHost": "この SSH ホストに接続すると、Orca スキルを確認できます。" }, "SkillFreshnessStatusPill": { "updateAvailable": "更新があります", @@ -4790,7 +4792,9 @@ "details": "詳細", "needsAttention": "スキルを確認", "checking": "確認中…", - "checkFailed": "チェックに失敗しました" + "checkFailed": "チェックに失敗しました", + "updateServer": "サーバーの Orca を更新してください", + "connectHost": "SSH ホストに接続してください" }, "SkillUpdateResultRows": {}, "SkillUpdateRow": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8d4a817ed99..6cdce31ce55 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4781,7 +4781,9 @@ "updatedSkipped": "{{value0}} · {{value1}}개 건너뜀", "currentOne": "스킬 1개가 최신 상태입니다", "currentMany": "스킬 {{value0}}개가 최신 상태입니다", - "noUpdates": "사용 가능한 업데이트가 없습니다" + "noUpdates": "사용 가능한 업데이트가 없습니다", + "updateServer": "서버의 Orca를 업데이트하면 Orca 스킬을 업데이트할 수 있습니다.", + "connectHost": "이 SSH 호스트에 연결하면 Orca 스킬을 확인할 수 있습니다." }, "SkillFreshnessStatusPill": { "updateAvailable": "업데이트 가능", @@ -4790,7 +4792,9 @@ "details": "Details", "needsAttention": "스킬 검토", "checking": "확인 중...", - "checkFailed": "확인 실패" + "checkFailed": "확인 실패", + "updateServer": "서버의 Orca 업데이트 필요", + "connectHost": "SSH 호스트 연결 필요" }, "SkillUpdateResultRows": {}, "SkillUpdateRow": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 9a22ca95a48..1da48d066b7 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -12693,7 +12693,9 @@ "updatedSkipped": "{{value0}} · {{value1}} ignoradas", "currentOne": "1 skill está atualizada", "currentMany": "{{value0}} skills estão atualizadas", - "noUpdates": "Nenhuma atualização disponível" + "noUpdates": "Nenhuma atualização disponível", + "updateServer": "Atualize o Orca no servidor para atualizar as skills do Orca.", + "connectHost": "Conecte-se a este host SSH para verificar as skills do Orca." }, "SkillFreshnessRow": {}, "SkillShareReviewContent": { @@ -13018,7 +13020,9 @@ "details": "Detalhes", "needsAttention": "Revisar skill", "checking": "Verificando...", - "checkFailed": "Falha na verificação" + "checkFailed": "Falha na verificação", + "updateServer": "Atualize o Orca no servidor", + "connectHost": "Conecte o host SSH" }, "SkillSharedLinkRow": { "contentsUnavailable": "Não foi possível carregar o que este link contém.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2871ea73442..86968d38a22 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4781,7 +4781,9 @@ "updatedSkipped": "{{value0}} · 已跳过 {{value1}} 处", "currentOne": "1 个技能已是最新", "currentMany": "{{value0}} 个技能已是最新", - "noUpdates": "没有可用更新" + "noUpdates": "没有可用更新", + "updateServer": "请在服务器上更新 Orca,才能更新其 Orca 技能。", + "connectHost": "请连接此 SSH 主机,才能检查其 Orca 技能。" }, "SkillFreshnessStatusPill": { "updateAvailable": "有可用更新", @@ -4790,7 +4792,9 @@ "details": "Details", "needsAttention": "Review skill", "checking": "检查中...", - "checkFailed": "检查失败" + "checkFailed": "检查失败", + "updateServer": "请在服务器上更新 Orca", + "connectHost": "请连接 SSH 主机" }, "SkillUpdateResultRows": {}, "SkillUpdateRow": { diff --git a/src/renderer/src/lib/bundled-skill-target.test.ts b/src/renderer/src/lib/bundled-skill-target.test.ts new file mode 100644 index 00000000000..c75d594a92e --- /dev/null +++ b/src/renderer/src/lib/bundled-skill-target.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import { + bundledSkillTargetKey, + resolveBundledSkillTarget, + servingExecutionHostId +} from './bundled-skill-target' + +const WSL_PROJECT: ProjectExecutionRuntimeResolution = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'p', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'p:wsl:Ubuntu' + } +} + +describe('resolveBundledSkillTarget', () => { + it('plans nothing until the owning runtime is known', () => { + expect(resolveBundledSkillTarget(null, undefined)).toBeNull() + }) + + it('sends a paired server or SSH host to that host, whatever the local project says', () => { + const target = resolveBundledSkillTarget( + { kind: 'environment', environmentId: 'env-1' }, + WSL_PROJECT + ) + expect(target).toEqual({ kind: 'environment', environmentId: 'env-1' }) + expect(bundledSkillTargetKey(target!)).toBe('runtime:env-1') + }) + + it('plans nothing for a Windows project that runs in WSL', () => { + const target = resolveBundledSkillTarget({ kind: 'local' }, WSL_PROJECT) + expect(target).toBeNull() + }) + + it('plans nothing while a WSL project needs its distro repaired, never the Windows home', () => { + expect( + resolveBundledSkillTarget( + { kind: 'local' }, + { + status: 'repair-required', + repair: { + projectId: 'p', + preferredRuntime: { kind: 'wsl', distro: 'Gone' }, + reason: 'wsl-distro-missing', + source: 'project-override', + cacheKey: 'p:repair' + } + } + ) + ).toBeNull() + }) + + it('follows the active workspace to its paired host, and never plans this machine for direct SSH', () => { + expect(resolveBundledSkillTarget({ kind: 'local' }, undefined, 'runtime:env-2')).toEqual({ + kind: 'environment', + environmentId: 'env-2' + }) + expect(resolveBundledSkillTarget({ kind: 'local' }, undefined, 'ssh:target-1')).toEqual({ + kind: 'ssh', + targetId: 'target-1' + }) + }) + + it('plans nothing when an active workspace owner is unresolved after the catalog settles', () => { + expect(resolveBundledSkillTarget({ kind: 'local' }, undefined, null)).toBeNull() + expect( + resolveBundledSkillTarget({ kind: 'environment', environmentId: 'focused' }, undefined, null) + ).toBeNull() + }) + + it('keeps a proven local workspace target', () => { + expect(resolveBundledSkillTarget({ kind: 'local' }, undefined, 'local')).toEqual({ + kind: 'local' + }) + }) + + it('plans this machine otherwise', () => { + expect(resolveBundledSkillTarget({ kind: 'local' }, undefined)).toEqual({ kind: 'local' }) + }) +}) + +describe('servingExecutionHostId', () => { + const environments = [ + { id: 'paired', orcadDeployment: null }, + { id: 'box-server', orcadDeployment: { sshTargetId: 'box' } }, + { id: 'wsl-server', orcadDeployment: { kind: 'wsl' as const } } + ] + + it('sends an SSH workspace to the managed server that serves its host', () => { + const hostId = servingExecutionHostId('ssh:box', environments) + expect(hostId).toBe('runtime:box-server') + expect(resolveBundledSkillTarget({ kind: 'local' }, undefined, hostId)).toEqual({ + kind: 'environment', + environmentId: 'box-server' + }) + }) + + it('keeps an SSH host no server serves as SSH, which never plans this machine', () => { + const hostId = servingExecutionHostId('ssh:other', environments) + expect(hostId).toBe('ssh:other') + const target = resolveBundledSkillTarget({ kind: 'local' }, undefined, hostId) + expect(target).toEqual({ kind: 'ssh', targetId: 'other' }) + expect(bundledSkillTargetKey(target!)).toBe('ssh:other') + }) + + it('leaves local and paired owners alone', () => { + expect(servingExecutionHostId('local', environments)).toBe('local') + expect(servingExecutionHostId('runtime:paired', environments)).toBe('runtime:paired') + expect(servingExecutionHostId(null, environments)).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/bundled-skill-target.ts b/src/renderer/src/lib/bundled-skill-target.ts new file mode 100644 index 00000000000..dadb94295b0 --- /dev/null +++ b/src/renderer/src/lib/bundled-skill-target.ts @@ -0,0 +1,79 @@ +import { + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { + getManagedOrcadSshTargetId, + type ManagedOrcadEnvironmentSummary +} from '../../../shared/managed-orcad-execution-host' +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' + +/** The host whose Orca skills the Update dialog, badge and nudge plan and update. */ +export type BundledSkillTarget = + | { kind: 'local' } + /** A paired server or the managed server on an SSH host; it plans its own disk. */ + | { kind: 'environment'; environmentId: string } + /** An SSH host no Orca server serves yet: nothing can plan it until it connects. */ + | { kind: 'ssh'; targetId: string } + +/** Keys runs and nudge dismissals, so one host's result never reads as another's. */ +export function bundledSkillTargetKey(target: BundledSkillTarget): string { + switch (target.kind) { + case 'local': + return 'local' + case 'environment': + return `runtime:${target.environmentId}` + case 'ssh': + return `ssh:${target.targetId}` + } +} + +/** An SSH host's owner id, rewritten to the managed server that serves that host, if any. */ +export function servingExecutionHostId( + hostId: ExecutionHostId | null, + runtimeEnvironments: readonly ManagedOrcadEnvironmentSummary[] | undefined +): string | null { + const owner = parseExecutionHostId(hostId) + if (owner?.kind !== 'ssh') { + return hostId + } + const server = runtimeEnvironments?.find( + (environment) => getManagedOrcadSshTargetId(environment) === owner.targetId + ) + return server ? toRuntimeExecutionHostId(server.id.trim()) : hostId +} + +/** + * Where the agents run, never the client by default: null while the owning runtime is + * unknown or the project runs in WSL. Pass the active workspace's owner + * through `servingExecutionHostId`, so an SSH host with a managed server plans on that server. + * An omitted owner means no active workspace; null means its owner is unresolved. + */ +export function resolveBundledSkillTarget( + runtimeTarget: RuntimeClientTarget | null, + projectRuntime: ProjectExecutionRuntimeResolution | undefined, + activeWorkspaceHostId?: string | null +): BundledSkillTarget | null { + const owner = parseExecutionHostId(activeWorkspaceHostId) + if (activeWorkspaceHostId !== undefined && !owner) { + return null + } + if (owner?.kind === 'ssh') { + return { kind: 'ssh', targetId: owner.targetId } + } + if (owner?.kind === 'runtime') { + return { kind: 'environment', environmentId: owner.environmentId } + } + if (!runtimeTarget) { + return null + } + if (runtimeTarget.kind === 'environment') { + return { kind: 'environment', environmentId: runtimeTarget.environmentId } + } + if (projectRuntime?.status === 'repair-required') { + return null + } + return projectRuntime?.runtime.kind === 'wsl' ? null : { kind: 'local' } +} diff --git a/src/renderer/src/runtime/runtime-bundled-skills-client.test.ts b/src/renderer/src/runtime/runtime-bundled-skills-client.test.ts new file mode 100644 index 00000000000..2be2915b879 --- /dev/null +++ b/src/renderer/src/runtime/runtime-bundled-skills-client.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BUNDLED_ORCA_SKILL_CAPABILITY } from '../../../shared/skill-install-capability' + +const callRuntimeRpc = vi.fn() +const runtimeEnvironmentSupportsCapability = vi.fn() + +vi.mock('./runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args), + runtimeEnvironmentSupportsCapability: (...args: unknown[]) => + runtimeEnvironmentSupportsCapability(...args) +})) + +const { executeBundledSkillsOnTarget, planBundledSkillsOnTarget } = + await import('./runtime-bundled-skills-client') + +const PLAN = { scope: 'global', homeDirectory: '/home/u', skills: [], planDigest: 'd'.repeat(64) } +const SERVER = { kind: 'environment', environmentId: 'env-1' } as const +const bundledPlan = vi.fn() +const bundledExecute = vi.fn() + +beforeEach(() => { + vi.clearAllMocks() + bundledPlan.mockResolvedValue(PLAN) + vi.stubGlobal('window', { api: { skills: { bundledPlan, bundledExecute } } }) +}) + +describe('planBundledSkillsOnTarget', () => { + it('plans a paired host over RPC and never this machine', async () => { + runtimeEnvironmentSupportsCapability.mockResolvedValue(true) + callRuntimeRpc.mockResolvedValue(PLAN) + + expect(await planBundledSkillsOnTarget(SERVER, { force: ['/x'] })).toEqual({ + kind: 'plan', + plan: PLAN + }) + expect(runtimeEnvironmentSupportsCapability).toHaveBeenCalledWith( + 'env-1', + BUNDLED_ORCA_SKILL_CAPABILITY + ) + expect(callRuntimeRpc).toHaveBeenCalledWith( + SERVER, + 'skills.planBundledOrca', + { force: ['/x'] }, + expect.anything() + ) + expect(bundledPlan).not.toHaveBeenCalled() + }) + + it('reads an older server that answered its status without the capability as unsupported', async () => { + runtimeEnvironmentSupportsCapability.mockResolvedValue(false) + + expect(await planBundledSkillsOnTarget(SERVER, {})).toEqual({ + kind: 'unsupported', + reason: 'update-server' + }) + expect(callRuntimeRpc).not.toHaveBeenCalled() + expect(bundledPlan).not.toHaveBeenCalled() + }) + + it('reports a failed status probe as an error, not as an older server', async () => { + runtimeEnvironmentSupportsCapability.mockRejectedValue(new Error('status.get timed out')) + + await expect(planBundledSkillsOnTarget(SERVER, {})).rejects.toThrow('status.get timed out') + expect(bundledPlan).not.toHaveBeenCalled() + }) + + it('asks an SSH host no server serves to connect, and never plans this machine', async () => { + expect(await planBundledSkillsOnTarget({ kind: 'ssh', targetId: 'box' }, {})).toEqual({ + kind: 'unsupported', + reason: 'connect-host' + }) + await expect( + executeBundledSkillsOnTarget({ kind: 'ssh', targetId: 'box' }, 'd'.repeat(64), {}) + ).rejects.toThrow('skill-install-ssh-host-not-served') + expect(bundledPlan).not.toHaveBeenCalled() + expect(bundledExecute).not.toHaveBeenCalled() + expect(runtimeEnvironmentSupportsCapability).not.toHaveBeenCalled() + }) + + it('plans this machine over IPC', async () => { + await planBundledSkillsOnTarget({ kind: 'local' }, {}) + + expect(bundledPlan).toHaveBeenCalledWith({}) + }) +}) + +describe('executeBundledSkillsOnTarget', () => { + it('sends the previewed digest and request to the host that planned it', async () => { + await executeBundledSkillsOnTarget(SERVER, 'd'.repeat(64), { force: ['/x'] }) + await executeBundledSkillsOnTarget({ kind: 'local' }, 'e'.repeat(64), {}) + + expect(callRuntimeRpc).toHaveBeenCalledWith( + SERVER, + 'skills.executeBundledOrca', + { planDigest: 'd'.repeat(64), request: { force: ['/x'] } }, + expect.anything() + ) + expect(bundledExecute).toHaveBeenCalledTimes(1) + expect(bundledExecute).toHaveBeenCalledWith({ + planDigest: 'e'.repeat(64), + request: {} + }) + }) +}) diff --git a/src/renderer/src/runtime/runtime-bundled-skills-client.ts b/src/renderer/src/runtime/runtime-bundled-skills-client.ts new file mode 100644 index 00000000000..0446ad5a310 --- /dev/null +++ b/src/renderer/src/runtime/runtime-bundled-skills-client.ts @@ -0,0 +1,72 @@ +import type { + BundledSkillInstallResult, + BundledSkillPlan, + BundledSkillPlanRequest +} from '../../../shared/bundled-skill-plan' +import { BUNDLED_ORCA_SKILL_CAPABILITY } from '../../../shared/skill-install-capability' +import type { BundledSkillTarget } from '@/lib/bundled-skill-target' +import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' + +const BUNDLED_SKILL_PLAN_TIMEOUT_MS = 60_000 +const BUNDLED_SKILL_EXECUTE_TIMEOUT_MS = 5 * 60_000 + +/** `unsupported`: the host answered its status and predates the capability ("Update Orca on + * the server"). A failed probe is not this; it rejects like any other error. */ +export type BundledSkillPlanResult = + | { kind: 'plan'; plan: BundledSkillPlan } + /** `connect-host`: an SSH host no Orca server serves yet; never planned on this machine. */ + | { kind: 'unsupported'; reason: BundledSkillUnsupportedReason } + +export type BundledSkillUnsupportedReason = 'update-server' | 'connect-host' + +/** Plans on the host that runs the agents; a paired host plans its own disk, never this one's. */ +export async function planBundledSkillsOnTarget( + target: BundledSkillTarget, + request: BundledSkillPlanRequest +): Promise { + if (target.kind === 'ssh') { + return { kind: 'unsupported', reason: 'connect-host' } + } + if (target.kind !== 'environment') { + const plan = await window.api.skills.bundledPlan(request) + return { kind: 'plan', plan } + } + if ( + !(await runtimeEnvironmentSupportsCapability( + target.environmentId, + BUNDLED_ORCA_SKILL_CAPABILITY + )) + ) { + return { kind: 'unsupported', reason: 'update-server' } + } + const plan = await callRuntimeRpc( + { kind: 'environment', environmentId: target.environmentId }, + 'skills.planBundledOrca', + request, + { timeoutMs: BUNDLED_SKILL_PLAN_TIMEOUT_MS } + ) + return { kind: 'plan', plan } +} + +/** Runs the plan `request` produced on `target`, identified by its digest. */ +export function executeBundledSkillsOnTarget( + target: BundledSkillTarget, + planDigest: string, + request: BundledSkillPlanRequest +): Promise { + if (target.kind === 'ssh') { + return Promise.reject(new Error('skill-install-ssh-host-not-served')) + } + if (target.kind !== 'environment') { + return window.api.skills.bundledExecute({ + planDigest, + request + }) + } + return callRuntimeRpc( + { kind: 'environment', environmentId: target.environmentId }, + 'skills.executeBundledOrca', + { planDigest, request }, + { timeoutMs: BUNDLED_SKILL_EXECUTE_TIMEOUT_MS } + ) +} diff --git a/src/shared/bundled-skill-plan.ts b/src/shared/bundled-skill-plan.ts index 516389ee63f..ecf62959d9f 100644 --- a/src/shared/bundled-skill-plan.ts +++ b/src/shared/bundled-skill-plan.ts @@ -128,14 +128,30 @@ export type BundledSkillInstallResult = { const BundledSkillForceSchema = z.array(z.string().min(1).max(4096)).max(256) -/** The local app's request: this machine's global homes, Orca's installed skills only. */ +/** + * What the app asks a host to plan: its own global homes, Orca's installed skills only. + * Frozen with `skills.bundled-orca.v1`: the schema is strict, so an older host rejects any + * new field; a field needs a new capability. + */ export const BundledSkillPlanRequestSchema = z.strictObject({ - force: BundledSkillForceSchema.optional() + force: BundledSkillForceSchema.optional(), + /** + * Install these bundled skills for the host's agents, like `orca skills install `. + * Absent: update only what is already installed. The host rejects a name it does not ship. + */ + names: z + .array(z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/u)) + .min(1) + .max(64) + .optional() }) export type BundledSkillPlanRequest = z.infer +const PlanDigestSchema = z.string().regex(/^[0-9a-f]{64}$/u) + +/** Runs the plan `request` produced, identified by its digest; the ops are never re-sent. */ export const BundledSkillExecuteRequestSchema = z.strictObject({ - force: BundledSkillForceSchema.optional(), - planDigest: z.string().regex(/^[0-9a-f]{64}$/u) + planDigest: PlanDigestSchema, + request: BundledSkillPlanRequestSchema }) export type BundledSkillExecuteRequest = z.infer diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index deb76d198b4..6caf287dc62 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -18,6 +18,10 @@ import { BrowserNetworkTunnelAttachParams } from '../browser-client-host-protocol' import { BrowserClientPageMetadataParams } from '../browser-client-page-metadata-protocol' +import { + BundledSkillExecuteRequestSchema, + BundledSkillPlanRequestSchema +} from '../bundled-skill-plan' import { DelegatedMobileDeviceSyncParamsSchema } from '../delegated-mobile-device-contract' import { DesktopBrowserBridgeAttachParams, @@ -1322,10 +1326,12 @@ export const RPC_PARAMS_BY_METHOD = { 'skills.commitUpload': SkillUploadCommitRequestSchema, 'skills.delete': SkillDeleteRequestSchema, 'skills.discover': SkillsDiscoverParams, + 'skills.executeBundledOrca': BundledSkillExecuteRequestSchema, 'skills.getInstallProgress': SkillsGetInstallProgressParams, 'skills.install': SkillInstallRequestSchema, 'skills.installBundle': SkillBundleInstallRequestSchema, 'skills.listManagedInstalls': null, + 'skills.planBundledOrca': BundledSkillPlanRequestSchema, 'skills.previewDelete': SkillDeleteRequestSchema, 'skills.previewInstall': SkillInstallPreviewRequestSchema, 'skills.removeInstall': SkillRemoveRequestSchema, diff --git a/src/shared/skill-install-capability.ts b/src/shared/skill-install-capability.ts index 797187cd09c..cae3a07615c 100644 --- a/src/shared/skill-install-capability.ts +++ b/src/shared/skill-install-capability.ts @@ -20,6 +20,13 @@ export const SKILL_DELETE_CAPABILITY = 'skills.delete.v1' as const export const SKILL_DELETE_UPDATE_REQUIRED_MESSAGE = 'Update Orca on the selected machine to delete skills.' +/** `skills.planBundledOrca` / `skills.executeBundledOrca`; their op and reason enums are frozen + * for v1, and a client renders a value it does not know as "Needs attention". */ +export const BUNDLED_ORCA_SKILL_CAPABILITY = 'skills.bundled-orca.v1' as const + +export const BUNDLED_ORCA_SKILL_UPDATE_REQUIRED_MESSAGE = + 'Update Orca on the server to update its Orca skills.' + export const SKILL_RUNTIME_CAPABILITIES = [ SKILL_INSTALL_CAPABILITY, SKILL_BUNDLE_INSTALL_CAPABILITY, @@ -29,5 +36,6 @@ export const SKILL_RUNTIME_CAPABILITIES = [ SKILL_UPLOAD_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY, SKILL_INSTALL_PROVIDERS_CAPABILITY, - SKILL_DELETE_CAPABILITY + SKILL_DELETE_CAPABILITY, + BUNDLED_ORCA_SKILL_CAPABILITY ] as const