Install and update Orca's skills on paired servers and SSH hosts (#31867)

* Add Orca's own bundled-skill planner and installer (not wired in yet)

One planner decides what installing Orca's bundled skills does to each
target folder; execute re-plans under per-path locks and runs only a plan
whose digest still matches; a shared summary derives badge, wording and
exit code. A copy counts as Orca's untouched one only when its SKILL.md
bytes match a version some build shipped (resources/skills/released-digests.json,
generated from git history and checked add-only).

Nothing calls this yet; the npx path is unchanged.

* Report left-alone folders as warnings and keep backups in the home

A leave is no longer a failure: the outcome line says what happened plus
"N left alone: <reason>", and the exit code is 1 only for a failed or
changed op. Backups for every scope go to <home>/.agents/.orca-skill-backups
on the executing host, so a project Replace never writes into the repo;
moving there falls back to copy, verify, then remove across filesystems.

* Update Orca's skills with Orca's own installer instead of npx skills

The app's Update skills dialog, badge, nudge and status pills now read the
bundled-skill plan and summary, and Update executes the reviewed plan's
digest. `orca skills install|update` runs the same planner in-process with
--dry-run, --force, --json and per-summary exit codes. Removes both npx
spawn sites, the freshness inventory modules and the npx round-trip CI.

* Update only where the skill already is; one provider-root rule for app and CLI

installedOnly now drops every location that holds nothing (keeping the shared slot
only when an existing agent link points at it), so Update never adds a skill for
another agent. The app and CLI resolve the Claude skills folder through the same
rule in localBundledSkillInput; a test asserts both plan identically.

* Compact Update skills dialog: one row per skill, locations named by agent

summarizeBundledSkillPlan now returns each skill's locations, labelled by
agent (or "Shared folder") with a ~-collapsed path; the dialog shows one row
per skill that needs something, collapses up-to-date skills into one line, and
lists every location behind "Show all locations". The result reads "Updated N
skills · M left alone". Under installedOnly a Replace with no shared copy
replaces in place instead of adding one.

* fix(skills): keep location paths readable by putting the state under the path

* fix(skills): hold the last plan in state, not a ref mutated during render

Split the update dialog's rows, headline and footer into components, move the Update label
next to the other plan copy, and use z.strictObject for the request schemas.

* Escape non-ASCII characters in bundled-skill regex and wording

The orcad bundle rejects a literal non-ASCII character in a regex.

* fix(cli): escape non-ASCII apostrophes in the skills install help text

* Plan and update Orca's skills on paired servers, SSH hosts and WSL

* Show the skills pill for paired hosts; real-WSL installer test

* test(orcad): check the bundled CLI plans a skill install without npx

Drop the deleted roundtrip workflow's group from the closed-PR cancellation fixture.

* Let the WSL installer create the backups folder itself

* Review fixes: follow the active workspace's host, keep hosts' plans and runs apart, never boot WSL for a background check

* test: execute requests carry the plan request; CLI pill follows the local target

* Write large WSL skill files in chunks that stay under the command-line limit

* Make bundled-skill test fixtures link folders the way the product does on Windows

Directory links in fixtures are junctions on Windows, which need no
Developer Mode; the one file-link fixture is skipped there.

* Let a client ask a host to install named Orca skills, not only update installed ones

* Plan an SSH workspace on the managed server that serves its host; ask to connect otherwise

* Translate the remote skill states into every locale

* Resolve legacy skill agent keys canonically

* Reject stale skill replacements when auxiliary content changes

* Remove the Windows-hosted WSL bundled skill installer

* Wait for active workspace ownership before planning bundled skills

* Keep ordinary bundled skill classification shallow

* Leave unreadable skill replacements alone without aborting plans

* Pass force-all through shallow planning and describe unreadable copies

* Clarify skill update statuses and folder details

* Keep selected skill replacements in place with one status

* Report completed skill replacements with their backup confirmation

* Clarify available skill updates and skipped copies

* Keep skill update results in preview order

* Show each skill skip reason once

* Count skipped skills instead of skipped folders

* Make installer input and link tests work on Windows

* Bound bundled skill classification reads and parse names linearly

* Keep workspace skill roots inside the resolved workspace

* Compare workspace skill roots with native path semantics

* Restrict bundled skill backups to their owner on POSIX

* Keep bundled skill updates on the proven local workspace host

* Keep recovered skills needing attention until they are updated

* Count completed skill updates in the status tooltip

* Sync the runtime localization catalog
This commit is contained in:
Jinwoo Hong
2026-10-22 22:50:05 -04:00
committed by GitHub
parent 2f2a0bca5e
commit fa71556085
51 changed files with 1293 additions and 250 deletions
+17 -5
View File
@@ -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' })
+1 -1
View File
@@ -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<BundledSkillPlan> =>
@@ -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<T>(name: string, params: unknown, context: RpcContext): Promise<T> {
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<BundledSkillPlan>('skills.planBundledOrca', {}, context)
expect(plan.skills[0].ops.map((op) => [op.path, op.kind])).toEqual([[home.claude, 'update']])
const result = await call<BundledSkillInstallResult>(
'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<BundledSkillPlan>('skills.planBundledOrca', {}, context)
await writeFile(join(home.claude, 'SKILL.md'), 'edited meanwhile\n')
const result = await call<BundledSkillInstallResult>(
'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<BundledSkillPlan>('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<BundledSkillInstallResult>(
'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<BundledSkillPlan>('skills.planBundledOrca', { names: [NAME] }, context)
const result = await call<BundledSkillInstallResult>(
'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()
})
})
+17
View File
@@ -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',
@@ -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<BundledSkillServiceInput> {
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<BundledSkillPlan> {
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<BundledSkillInstallResult> {
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
+1 -1
View File
@@ -41,7 +41,7 @@ import type {
export type SkillsApi = {
discover: (target?: SkillDiscoveryTarget) => Promise<SkillDiscoveryResult>
/** 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<BundledSkillPlan>
bundledExecute: (request: BundledSkillExecuteRequest) => Promise<BundledSkillInstallResult>
prepareShare: (input: {
@@ -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}
/>
)
@@ -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<void> {
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 = {
@@ -62,7 +62,7 @@ export function FloatingTerminalOrchestrationDialog({
const recheckOrchestrationSkill = async (): Promise<boolean> => {
const installed = await refreshOrchestrationSkill()
if (activeSkillRuntime.canUseLocalSkillFreshness) {
if (activeSkillRuntime.bundledSkillTarget) {
await refreshSkillFreshness()
}
return installed
@@ -89,7 +89,7 @@ export function FloatingTerminalOrchestrationDialog({
)}
</IntegrationStatusPill>
) : orchestrationSkillDetected ? (
activeSkillRuntime.canUseLocalSkillFreshness ? (
activeSkillRuntime.canUseSkillFreshness ? (
<SkillFreshnessStatusPill skillName={ORCHESTRATION_SKILL_NAME} />
) : (
<IntegrationStatusPill tone="connected">
@@ -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
}))
@@ -24,7 +24,7 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({
}))
vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
useActiveProjectSkillRuntime: () => ({ canUseLocalSkillFreshness: true })
useActiveProjectSkillRuntime: () => ({ canUseSkillFreshness: true })
}))
vi.mock('./AgentSkillSetupPanel', () => ({
@@ -18,11 +18,11 @@ type CapturedPanelProps = {
const capturedPanel = vi.hoisted(
(): {
canUseLocalSkillFreshness: boolean
canUseSkillFreshness: boolean
props: CapturedPanelProps | null
useInstalledAgentSkill: ReturnType<typeof vi.fn>
} => ({
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(<CliSection currentPlatform="darwin" settings={settings} />)
expect(capturedPanel.props?.freshnessSkillName).toBe('orca-cli')
capturedPanel.canUseLocalSkillFreshness = false
capturedPanel.canUseSkillFreshness = false
renderToStaticMarkup(<CliSection currentPlatform="darwin" settings={settings} />)
expect(capturedPanel.props?.freshnessSkillName).toBeUndefined()
capturedPanel.canUseLocalSkillFreshness = true
capturedPanel.canUseSkillFreshness = true
renderToStaticMarkup(
<CliSection
currentPlatform="win32"
@@ -54,7 +54,7 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element {
}}
onRecheck={refreshComputerUseSkill}
freshnessSkillName={
activeSkillRuntime.canUseLocalSkillFreshness ? COMPUTER_USE_SKILL_NAME : undefined
activeSkillRuntime.canUseSkillFreshness ? COMPUTER_USE_SKILL_NAME : undefined
}
/>
)
@@ -173,7 +173,7 @@ export function EphemeralVmsPane(): React.JSX.Element {
icon={<Server className="size-5" />}
onRecheck={refreshSkill}
freshnessSkillName={
activeSkillRuntime.canUseLocalSkillFreshness ? EPHEMERAL_VMS_SKILL_NAME : undefined
activeSkillRuntime.canUseSkillFreshness ? EPHEMERAL_VMS_SKILL_NAME : undefined
}
/>
@@ -74,7 +74,7 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
agentRuntime: { runtime: mocks.runtime },
terminalShellOverride: undefined,
installDisabledReason: null,
canUseLocalSkillFreshness: mocks.runtime !== 'wsl'
canUseSkillFreshness: mocks.runtime !== 'wsl'
})
}))
@@ -4,11 +4,11 @@ import { MobileEmulatorAgentControlRow } from './MobileEmulatorAgentControlRow'
const mocks = vi.hoisted(
(): {
canUseLocalSkillFreshness: boolean
canUseSkillFreshness: boolean
freshnessSkillName: string | undefined
panelProps: Record<string, unknown>
} => ({
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(<MobileEmulatorAgentControlRow />)
expect(mocks.freshnessSkillName).toBe('orca-cli')
mocks.canUseLocalSkillFreshness = false
mocks.canUseSkillFreshness = false
renderToStaticMarkup(<MobileEmulatorAgentControlRow />)
expect(mocks.freshnessSkillName).toBeUndefined()
})
@@ -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
}
/>
</div>
@@ -152,7 +152,7 @@ export function OrchestrationPane({
}
onRecheck={refreshOrchestrationSkill}
freshnessSkillName={
activeSkillRuntime.canUseLocalSkillFreshness ? ORCHESTRATION_SKILL_NAME : undefined
activeSkillRuntime.canUseSkillFreshness ? ORCHESTRATION_SKILL_NAME : undefined
}
/>
@@ -51,7 +51,7 @@ export function OrchestrationSetupCard(props: {
}}
onRecheck={skill.refresh}
freshnessSkillName={
activeSkillRuntime.canUseLocalSkillFreshness ? ORCHESTRATION_SKILL_NAME : undefined
activeSkillRuntime.canUseSkillFreshness ? ORCHESTRATION_SKILL_NAME : undefined
}
/>
)
@@ -49,7 +49,7 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
agentRuntime: { runtime: 'native' },
terminalShellOverride: undefined,
installDisabledReason: null,
canUseLocalSkillFreshness: true
canUseSkillFreshness: true
})
}))
@@ -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
@@ -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
}
@@ -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')
@@ -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()
@@ -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<ActiveFreshnessNudge | null>(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
}
@@ -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 (
<IntegrationStatusPill tone="neutral">
{unsupported === 'connect-host'
? translate(
'auto.components.skills.SkillFreshnessStatusPill.connectHost',
'Connect the SSH host'
)
: translate(
'auto.components.skills.SkillFreshnessStatusPill.updateServer',
'Update Orca on the server'
)}
</IntegrationStatusPill>
)
}
if (loading && !plan) {
return (
<IntegrationStatusPill tone="neutral">
@@ -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()
@@ -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<string, BundledSkillOutco
* the disk. Holding the last plan keeps the rows on screen as the result appears; only the live
* plan can authorize Update.
*/
function useHeldSkillPlan(plan: BundledSkillPlan | null): [BundledSkillPlan | null, () => void] {
const [heldPlan, setHeldPlan] = useState<BundledSkillPlan | null>(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 <RunHeadline run={run} />
}
if (unsupported) {
return (
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
)
}
return error ? (
<p className="min-w-0 [overflow-wrap:anywhere] text-xs text-destructive">{error}</p>
) : (
@@ -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<string[]>([])
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 {
</DialogTitle>
</DialogHeader>
<DialogHeadline run={run} error={error} plan={livePlan} />
<DialogHeadline run={run} unsupported={state.unsupported} error={error} plan={livePlan} />
{shownSummary && shownSummary.skills.length > 0 ? (
<SkillUpdateRows
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BundledSkillInstallResult } from '../../../../shared/bundled-skill-plan'
import { bundledSkillTargetKey } from '@/lib/bundled-skill-target'
import { executeBundledSkillsOnTarget } from '@/runtime/runtime-bundled-skills-client'
import { consumeSkillFreshnessUpdateDialogRequest } from './skill-freshness-update-dialog'
import {
_resetSkillUpdateRunStore,
getSkillUpdateRun,
SKILL_UPDATE_SUCCESS_LINGER_MS,
startSkillUpdateRun
} from './skill-update-run-store'
vi.mock('@/hooks/useInstalledAgentSkills', () => ({
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<BundledSkillInstallResult>((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')
}
)
})
@@ -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<string, RunSlot>()
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<void> {
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
}
@@ -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<ForcedPlanResult | null>(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) {
@@ -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<void> | null = null
async function start(names: string[]): Promise<void> {
await act(async () => {
running = startSkillUpdateRun({
targetKey: LOCAL_SKILL_UPDATE_TARGET,
target: { kind: 'local' },
names,
planDigest: 'a'.repeat(64),
force: []
@@ -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
@@ -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', () => {
@@ -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.
@@ -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<string, SkillFreshnessState>()
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(<Probe />))
expect(state?.plan?.planDigest).toBe(digest(1))
await act(async () => root?.render(<Probe target={{ kind: 'ssh', targetId: 'box' }} />))
expect(state).toMatchObject({ plan: null, loading: false, unsupported: 'connect-host' })
expect(bundledPlan).toHaveBeenCalledTimes(1)
})
})
+106 -57
View File
@@ -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<BundledSkillPlan> | 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<BundledSkillPlanResult>
} | 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<void> | 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<BundledSkillPlan> {
async function loadPlan(
target: BundledSkillTarget,
force: boolean
): Promise<BundledSkillPlanResult> {
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<void> {
export function refreshSkillFreshness(force = true): Promise<void> {
return refreshPlan(force)
}
async function refreshPlan(force: boolean): Promise<void> {
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<void> {}
function refreshSkillFreshnessAfterReenable(): Promise<void> {
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 }
}
}
+6 -2
View File
@@ -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.",
+6 -2
View File
@@ -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": {
+6 -2
View File
@@ -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.",
+6 -2
View File
@@ -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": {
+6 -2
View File
@@ -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": {
+6 -2
View File
@@ -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.",
+6 -2
View File
@@ -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": {
@@ -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()
})
})
@@ -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' }
}
@@ -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: {}
})
})
})
@@ -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<BundledSkillPlanResult> {
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<BundledSkillPlan>(
{ 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<BundledSkillInstallResult> {
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<BundledSkillInstallResult>(
{ kind: 'environment', environmentId: target.environmentId },
'skills.executeBundledOrca',
{ planDigest, request },
{ timeoutMs: BUNDLED_SKILL_EXECUTE_TIMEOUT_MS }
)
}
+20 -4
View File
@@ -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 <names>`.
* 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<typeof BundledSkillPlanRequestSchema>
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<typeof BundledSkillExecuteRequestSchema>
+6
View File
@@ -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,
+9 -1
View File
@@ -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