mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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' })
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
+5
-4
@@ -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 = {
|
||||
|
||||
+2
-2
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user