From 6506f428a1956ec99d4609bc17803bcccec2c009 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 13 Jul 2026 02:08:54 -0700 Subject: [PATCH] feat(skills): auto-update managed skills behind a default-on setting Add managedSkillAutoUpdateEnabled (default on) and a background batch that updates only already-managed, unmodified destinations on the local host. The main-process handler gates on the persisted setting and build authority, declines silently, coalesces duplicate triggers, and records failures durably; a failed destination surfaces as update-failed and is never retried automatically. An always-mounted renderer runner reuses the shared inventory cache, requests one batch per bundled release, and shows one aggregated success toast. Settings gains an "Update automatically" switch, and copy across the section, setup panels, and adoption nudge now describes automatic updates. Skill IPC host-context and write-authorization plumbing moves into named modules, and the skill-detection E2E pins the management inventory instead of reading the developer's real skill homes. --- src/main/ipc/skill-management-host-context.ts | 56 +++++ src/main/ipc/skill-write-authorization.ts | 68 ++++++ src/main/ipc/skills.test.ts | 102 +++++++++ src/main/ipc/skills.ts | 191 +++++------------ src/main/skills/skill-auto-update.test.ts | 196 ++++++++++++++++++ src/main/skills/skill-auto-update.ts | 79 +++++++ src/preload/api-types.ts | 3 + src/preload/index.ts | 4 + src/renderer/src/App.tsx | 2 + .../ManagedSkillAdoptionNudge.tsx | 18 +- .../ManagedSkillAutoUpdate.test.tsx | 157 ++++++++++++++ .../managed-skills/ManagedSkillAutoUpdate.tsx | 62 ++++++ .../src/components/settings/AgentsPane.tsx | 2 +- .../settings/ManagedOrcaSkillsSection.tsx | 28 ++- .../managed-skill-status-copy.test.ts | 9 + .../settings/managed-skill-status-copy.ts | 16 +- .../use-agent-skill-management-actions.ts | 6 +- src/renderer/src/i18n/locales/en.json | 22 +- src/renderer/src/i18n/locales/es.json | 22 +- src/renderer/src/i18n/locales/ja.json | 22 +- src/renderer/src/i18n/locales/ko.json | 12 +- src/renderer/src/i18n/locales/zh.json | 12 +- src/shared/constants.ts | 1 + src/shared/skill-management.ts | 13 ++ src/shared/types.ts | 4 + tests/e2e/settings-skill-detection.spec.ts | 95 ++++++++- 26 files changed, 1017 insertions(+), 185 deletions(-) create mode 100644 src/main/ipc/skill-management-host-context.ts create mode 100644 src/main/ipc/skill-write-authorization.ts create mode 100644 src/main/skills/skill-auto-update.test.ts create mode 100644 src/main/skills/skill-auto-update.ts create mode 100644 src/renderer/src/components/managed-skills/ManagedSkillAutoUpdate.test.tsx create mode 100644 src/renderer/src/components/managed-skills/ManagedSkillAutoUpdate.tsx diff --git a/src/main/ipc/skill-management-host-context.ts b/src/main/ipc/skill-management-host-context.ts new file mode 100644 index 00000000000..8b485476d2d --- /dev/null +++ b/src/main/ipc/skill-management-host-context.ts @@ -0,0 +1,56 @@ +import { homedir } from 'node:os' +import type { SkillDiscoveryTarget } from '../../shared/skills' +import { getDefaultWslDistro, getWslHome } from '../wsl' +import { LOCAL_EXECUTION_HOST_ID, toWslExecutionHostId } from '../../shared/execution-host' + +export type SkillDiscoveryRuntimeTarget = + | { runtime: 'host' } + | { runtime: 'wsl'; wslDistro: string | null | undefined } + +export function getSkillDiscoveryRuntimeTarget( + target: SkillDiscoveryTarget | undefined +): SkillDiscoveryRuntimeTarget { + if (target?.executionHostId && target.executionHostId !== LOCAL_EXECUTION_HOST_ID) { + throw new Error('Skill management is not available for remote execution hosts in Phase 1.') + } + const projectRuntime = target?.projectRuntime + if (!projectRuntime) { + return target?.runtime === 'wsl' + ? { runtime: 'wsl', wslDistro: target.wslDistro } + : { runtime: 'host' } + } + + if (projectRuntime.status === 'repair-required') { + throw new Error( + `Project runtime requires repair before skill discovery: ${projectRuntime.repair.reason}` + ) + } + + if (projectRuntime.runtime.kind === 'wsl') { + return { runtime: 'wsl', wslDistro: projectRuntime.runtime.distro } + } + + return { runtime: 'host' } +} + +export function getSkillManagementHostContext(target: SkillDiscoveryTarget | undefined): { + hostId: ReturnType | typeof LOCAL_EXECUTION_HOST_ID + homeDir: string +} { + const runtimeTarget = getSkillDiscoveryRuntimeTarget(target) + if (runtimeTarget.runtime === 'host') { + return { hostId: LOCAL_EXECUTION_HOST_ID, homeDir: homedir() } + } + if (process.platform !== 'win32') { + throw new Error('WSL skill management is only available on Windows.') + } + const distro = runtimeTarget.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + throw new Error('No WSL distribution is available for skill management.') + } + const homeDir = getWslHome(distro) + if (!homeDir) { + throw new Error(`Could not resolve the WSL home directory for ${distro}.`) + } + return { hostId: toWslExecutionHostId(distro), homeDir } +} diff --git a/src/main/ipc/skill-write-authorization.ts b/src/main/ipc/skill-write-authorization.ts new file mode 100644 index 00000000000..8c6e919353f --- /dev/null +++ b/src/main/ipc/skill-write-authorization.ts @@ -0,0 +1,68 @@ +import { app } from 'electron' +import { tmpdir } from 'node:os' +import type { Store } from '../persistence' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { buildSkillDiscoverySources } from '../skills/skill-discovery-sources' +import { sweepOrphanedSkillTransactions } from '../skills/skill-orphan-transaction-sweep' +import { resolveDiagnosticBuildIdentity } from '../observability/diagnostic-upload-endpoint' +import { + canMutateManagedSkills, + type SkillMutationRuntimeFacts +} from '../skills/skill-mutation-authority' + +export type SkillHandlerRuntimeFacts = Omit + +export function productionSkillHandlerRuntimeFacts(): SkillHandlerRuntimeFacts { + return { + isPackaged: app.isPackaged, + buildIdentity: resolveDiagnosticBuildIdentity(), + userDataDir: app.getPath('userData'), + isolatedDevHomeDir: process.env.ORCA_E2E_SKILL_HOME_DIR, + isolatedDevUserDataDir: process.env.ORCA_E2E_USER_DATA_DIR, + temporaryRoot: tmpdir() + } +} + +export function requireSkillMutation( + context: { hostId: string; homeDir: string }, + runtimeFacts: SkillHandlerRuntimeFacts +): void { + if (context.hostId !== LOCAL_EXECUTION_HOST_ID) { + // Why: WSL publication belongs inside the distro runtime; UNC mutation + // would apply Windows lock and mode semantics to Linux-owned files. + throw new Error('Skill management actions are not available on this host yet.') + } + if (!canMutateManagedSkills({ ...runtimeFacts, homeDir: context.homeDir })) { + throw new Error( + 'Skill management actions require an official stable build or isolated dev roots.' + ) + } +} + +export async function recoverSkillTransactionsBeforeWrites( + context: { hostId: string; homeDir: string }, + store: Store, + runtimeFacts: SkillHandlerRuntimeFacts +): Promise { + requireSkillMutation(context, runtimeFacts) + const roots = buildSkillDiscoverySources({ + homeDir: context.homeDir, + cwd: context.homeDir + }).filter((root) => root.sourceKind === 'home') + // Why: orphan recovery can write, so it runs only inside a write-authorized + // path — an explicit management action or an enabled auto-update batch with + // pending work — never plain inventory or the App-mounted nudge. + await Promise.all( + roots.map((root) => sweepOrphanedSkillTransactions(root.path, store.getSkillManagementLedger())) + ) +} + +export function skillFailureCategory(error: unknown): string { + if (error instanceof Error && /^skill-[a-z-]+$/.test(error.message)) { + return error.message + } + if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') { + return `filesystem-${error.code.toLowerCase()}` + } + return 'unknown-error' +} diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index aaa56d4426a..ad03263d438 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -66,6 +66,7 @@ describe('registerSkillsHandlers', () => { const repos = [{ id: 'repo-1', path: 'C:\\Users\\alice\\repo' }] const store = { getRepos: vi.fn(() => repos), + getSettings: vi.fn(), getSkillManagementLedger: vi.fn(), setManagedSkillDestination: vi.fn(), dismissSkillAdoptionCandidate: vi.fn() @@ -80,9 +81,11 @@ describe('registerSkillsHandlers', () => { adoptExactSkillSnapshotMock.mockReset() updateManagedSkillMock.mockReset() statMock.mockReset() + store.getSettings.mockReset() store.getSkillManagementLedger.mockReset() store.setManagedSkillDestination.mockReset() store.dismissSkillAdoptionCandidate.mockReset() + store.getSettings.mockReturnValue({}) store.getSkillManagementLedger.mockReturnValue({ schemaVersion: 1, destinations: {}, @@ -339,6 +342,105 @@ describe('registerSkillsHandlers', () => { expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled() }) + it('declines auto-update without inventory work when the setting is off', async () => { + store.getSettings.mockReturnValue({ managedSkillAutoUpdateEnabled: false }) + const handler = getHandler('skills:autoUpdateManaged') + + await expect(handler(null, undefined)).resolves.toEqual({ + updatedSkillNames: [], + failedSkillNames: [], + inventory: null + }) + expect(inventoryManagedSkillsMock).not.toHaveBeenCalled() + }) + + it('declines auto-update silently on an unauthorized build instead of throwing', async () => { + const handler = getHandler('skills:autoUpdateManaged', { + isPackaged: false, + buildIdentity: null, + userDataDir: '/real/user-data', + temporaryRoot: '/tmp' + }) + + await expect(handler(null, undefined)).resolves.toEqual({ + updatedSkillNames: [], + failedSkillNames: [], + inventory: null + }) + expect(inventoryManagedSkillsMock).not.toHaveBeenCalled() + }) + + it('coalesces overlapping auto-update triggers into one batch', async () => { + let resolveInventory: (value: unknown) => void = () => undefined + inventoryManagedSkillsMock.mockReturnValue( + new Promise((resolve) => { + resolveInventory = resolve + }) + ) + const handler = getHandler('skills:autoUpdateManaged') + + const first = handler(null, undefined) + const second = handler(null, undefined) + resolveInventory({ + schemaVersion: 1, + hostId: 'local', + installations: [], + adoptionCandidateCount: 0, + scannedAt: 1 + }) + + expect(await second).toBe(await first) + expect(inventoryManagedSkillsMock).toHaveBeenCalledTimes(1) + }) + + it('auto-updates only managed candidates and records failures durably', async () => { + const prior = { id: 'managed', hostId: 'local', lastOutcome: 'adopted' } + store.getSkillManagementLedger.mockReturnValue({ + schemaVersion: 1, + destinations: { managed: prior }, + dismissedAdoptionCandidates: [] + }) + inventoryManagedSkillsMock.mockResolvedValue({ + schemaVersion: 1, + hostId: 'local', + installations: [ + { + id: 'managed', + name: 'orca-cli', + status: 'managed-update-available', + managed: true, + actionsSupported: true + }, + { + id: 'unmanaged', + name: 'orchestration', + status: 'known-update-available', + managed: false, + actionsSupported: true + } + ], + adoptionCandidateCount: 0, + scannedAt: 1 + }) + updateManagedSkillMock.mockRejectedValue(Object.assign(new Error('busy'), { code: 'EBUSY' })) + const handler = getHandler('skills:autoUpdateManaged') + + const result = await handler(null, undefined) + + expect(result).toMatchObject({ updatedSkillNames: [], failedSkillNames: ['orca-cli'] }) + expect(updateManagedSkillMock).toHaveBeenCalledTimes(1) + expect(updateManagedSkillMock).toHaveBeenCalledWith( + expect.objectContaining({ installationId: 'managed' }) + ) + expect(store.setManagedSkillDestination).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'managed', + lastOutcome: 'failed', + lastErrorCategory: 'filesystem-ebusy' + }) + ) + }) + it('persists a failed managed update for retry diagnostics', async () => { const prior = { id: 'managed', hostId: 'local', lastOutcome: 'adopted' } store.getSkillManagementLedger.mockReturnValue({ diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index c3b5e75e583..34348d62854 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -1,5 +1,4 @@ -import { app, ipcMain } from 'electron' -import { homedir, tmpdir } from 'node:os' +import { ipcMain } from 'electron' import { stat } from 'node:fs/promises' import { join } from 'node:path' import type { Store } from '../persistence' @@ -8,12 +7,24 @@ import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/sk import type { OrcaSkillInstallResultArgs, SkillAdoptionDismissalArgs, + SkillAutoUpdateBatchArgs, + SkillAutoUpdateBatchResult, SkillManagementActionArgs, SkillManagementInventory, SkillReplacementPreview } from '../../shared/skill-management' -import { getDefaultWslDistro, getWslHome } from '../wsl' -import { LOCAL_EXECUTION_HOST_ID, toWslExecutionHostId } from '../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { + getSkillDiscoveryRuntimeTarget, + getSkillManagementHostContext +} from './skill-management-host-context' +import { + productionSkillHandlerRuntimeFacts, + recoverSkillTransactionsBeforeWrites, + requireSkillMutation, + skillFailureCategory, + type SkillHandlerRuntimeFacts +} from './skill-write-authorization' import { inventoryManagedSkills } from '../skills/skill-management-inventory' import { adoptExactSkillSnapshot } from '../skills/skill-adoption' import { @@ -21,125 +32,12 @@ import { replaceSkillWithCurrentBundle } from '../skills/skill-explicit-replacement' import { updateManagedSkill } from '../skills/skill-managed-update' +import { autoUpdateManagedSkills } from '../skills/skill-auto-update' import { createSkillAdoptionDismissal } from '../skills/skill-adoption-dismissal' -import { buildSkillDiscoverySources } from '../skills/skill-discovery-sources' -import { sweepOrphanedSkillTransactions } from '../skills/skill-orphan-transaction-sweep' -import { resolveDiagnosticBuildIdentity } from '../observability/diagnostic-upload-endpoint' -import { - canMutateManagedSkills, - type SkillMutationRuntimeFacts -} from '../skills/skill-mutation-authority' - -type SkillDiscoveryRuntimeTarget = - | { runtime: 'host' } - | { runtime: 'wsl'; wslDistro: string | null | undefined } +import { canMutateManagedSkills } from '../skills/skill-mutation-authority' const MAX_ORCA_SKILL_INSTALL_ATTEMPT_MS = 15 * 60 * 1_000 -type SkillHandlerRuntimeFacts = Omit - -function productionSkillHandlerRuntimeFacts(): SkillHandlerRuntimeFacts { - return { - isPackaged: app.isPackaged, - buildIdentity: resolveDiagnosticBuildIdentity(), - userDataDir: app.getPath('userData'), - isolatedDevHomeDir: process.env.ORCA_E2E_SKILL_HOME_DIR, - isolatedDevUserDataDir: process.env.ORCA_E2E_USER_DATA_DIR, - temporaryRoot: tmpdir() - } -} - -function getSkillDiscoveryRuntimeTarget( - target: SkillDiscoveryTarget | undefined -): SkillDiscoveryRuntimeTarget { - if (target?.executionHostId && target.executionHostId !== LOCAL_EXECUTION_HOST_ID) { - throw new Error('Skill management is not available for remote execution hosts in Phase 1.') - } - const projectRuntime = target?.projectRuntime - if (!projectRuntime) { - return target?.runtime === 'wsl' - ? { runtime: 'wsl', wslDistro: target.wslDistro } - : { runtime: 'host' } - } - - if (projectRuntime.status === 'repair-required') { - throw new Error( - `Project runtime requires repair before skill discovery: ${projectRuntime.repair.reason}` - ) - } - - if (projectRuntime.runtime.kind === 'wsl') { - return { runtime: 'wsl', wslDistro: projectRuntime.runtime.distro } - } - - return { runtime: 'host' } -} - -function getSkillManagementHostContext(target: SkillDiscoveryTarget | undefined): { - hostId: ReturnType | typeof LOCAL_EXECUTION_HOST_ID - homeDir: string -} { - const runtimeTarget = getSkillDiscoveryRuntimeTarget(target) - if (runtimeTarget.runtime === 'host') { - return { hostId: LOCAL_EXECUTION_HOST_ID, homeDir: homedir() } - } - if (process.platform !== 'win32') { - throw new Error('WSL skill management is only available on Windows.') - } - const distro = runtimeTarget.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - throw new Error('No WSL distribution is available for skill management.') - } - const homeDir = getWslHome(distro) - if (!homeDir) { - throw new Error(`Could not resolve the WSL home directory for ${distro}.`) - } - return { hostId: toWslExecutionHostId(distro), homeDir } -} - -function requireSkillMutation( - context: { hostId: string; homeDir: string }, - runtimeFacts: SkillHandlerRuntimeFacts -): void { - if (context.hostId !== LOCAL_EXECUTION_HOST_ID) { - // Why: WSL publication belongs inside the distro runtime; UNC mutation - // would apply Windows lock and mode semantics to Linux-owned files. - throw new Error('Skill management actions are not available on this host yet.') - } - if (!canMutateManagedSkills({ ...runtimeFacts, homeDir: context.homeDir })) { - throw new Error( - 'Skill management actions require an official stable build or isolated dev roots.' - ) - } -} - -async function recoverSkillTransactionsForExplicitAction( - context: { hostId: string; homeDir: string }, - store: Store, - runtimeFacts: SkillHandlerRuntimeFacts -): Promise { - requireSkillMutation(context, runtimeFacts) - const roots = buildSkillDiscoverySources({ - homeDir: context.homeDir, - cwd: context.homeDir - }).filter((root) => root.sourceKind === 'home') - // Why: orphan recovery can write, so it runs only inside an explicit - // foreground management action, never inventory or the App-mounted nudge. - await Promise.all( - roots.map((root) => sweepOrphanedSkillTransactions(root.path, store.getSkillManagementLedger())) - ) -} - -function skillFailureCategory(error: unknown): string { - if (error instanceof Error && /^skill-[a-z-]+$/.test(error.message)) { - return error.message - } - if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') { - return `filesystem-${error.code.toLowerCase()}` - } - return 'unknown-error' -} - export function registerSkillsHandlers( store: Store, runtimeFacts: SkillHandlerRuntimeFacts = productionSkillHandlerRuntimeFacts() @@ -149,17 +47,7 @@ export function registerSkillsHandlers( async (_event, target?: SkillDiscoveryTarget): Promise => { const runtimeTarget = getSkillDiscoveryRuntimeTarget(target) if (runtimeTarget.runtime === 'wsl') { - if (process.platform !== 'win32') { - throw new Error('WSL skill discovery is only available on Windows.') - } - const distro = runtimeTarget.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - throw new Error('No WSL distribution is available for skill discovery.') - } - const homeDir = getWslHome(distro) - if (!homeDir) { - throw new Error(`Could not resolve the WSL home directory for ${distro}.`) - } + const { homeDir } = getSkillManagementHostContext(target) return discoverSkills({ repos: [], homeDir, cwd: homeDir }) } @@ -184,7 +72,7 @@ export function registerSkillsHandlers( 'skills:adopt', async (_event, args: SkillManagementActionArgs): Promise => { const context = getSkillManagementHostContext(args.target) - await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts) + await recoverSkillTransactionsBeforeWrites(context, store, runtimeFacts) await adoptExactSkillSnapshot({ ...context, ledger: store.getSkillManagementLedger(), @@ -223,7 +111,7 @@ export function registerSkillsHandlers( 'skills:replace', async (_event, args: SkillManagementActionArgs): Promise => { const context = getSkillManagementHostContext(args.target) - await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts) + await recoverSkillTransactionsBeforeWrites(context, store, runtimeFacts) await replaceSkillWithCurrentBundle({ ...context, ledger: store.getSkillManagementLedger(), @@ -238,7 +126,7 @@ export function registerSkillsHandlers( 'skills:updateManaged', async (_event, args: SkillManagementActionArgs): Promise => { const context = getSkillManagementHostContext(args.target) - await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts) + await recoverSkillTransactionsBeforeWrites(context, store, runtimeFacts) const ledger = store.getSkillManagementLedger() try { await updateManagedSkill({ @@ -263,6 +151,41 @@ export function registerSkillsHandlers( } ) + let autoUpdateBatch: Promise | null = null + ipcMain.handle( + 'skills:autoUpdateManaged', + async (_event, args?: SkillAutoUpdateBatchArgs): Promise => { + const context = getSkillManagementHostContext(args?.target) + if ( + context.hostId !== LOCAL_EXECUTION_HOST_ID || + store.getSettings().managedSkillAutoUpdateEnabled === false || + !canMutateManagedSkills({ ...runtimeFacts, homeDir: context.homeDir }) + ) { + // Why: a background batch declines silently; only explicit user + // actions surface host or build-authority errors. + return { updatedSkillNames: [], failedSkillNames: [], inventory: null } + } + // Why: launch, focus, and post-install triggers coalesce into one batch + // so duplicate signals cannot start overlapping transactions. + autoUpdateBatch ??= autoUpdateManagedSkills({ + ...context, + getLedger: () => store.getSkillManagementLedger(), + commit: (next) => store.setManagedSkillDestination(next), + recordFailure: (prior, error) => + store.setManagedSkillDestination({ + ...prior, + lastOutcome: 'failed', + lastErrorCategory: skillFailureCategory(error), + updatedAt: Date.now() + }), + beforeWrites: () => recoverSkillTransactionsBeforeWrites(context, store, runtimeFacts) + }).finally(() => { + autoUpdateBatch = null + }) + return autoUpdateBatch + } + ) + ipcMain.handle( 'skills:recordOrcaInstall', async (_event, args: OrcaSkillInstallResultArgs): Promise => { @@ -276,7 +199,7 @@ export function registerSkillsHandlers( ) { throw new Error('Invalid skill installation start time.') } - await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts) + await recoverSkillTransactionsBeforeWrites(context, store, runtimeFacts) let inventory = await inventoryManagedSkills({ ...context, ledger: store.getSkillManagementLedger() diff --git a/src/main/skills/skill-auto-update.test.ts b/src/main/skills/skill-auto-update.test.ts new file mode 100644 index 00000000000..b1a941fb413 --- /dev/null +++ b/src/main/skills/skill-auto-update.test.ts @@ -0,0 +1,196 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { ManagedSkillDestination, SkillManagementLedger } from '../../shared/skill-management' +import { adoptExactSkillSnapshot } from './skill-adoption' +import { autoUpdateManagedSkills } from './skill-auto-update' +import { emptySkillManagementLedger } from './skill-management-ledger' +import { inventoryManagedSkills } from './skill-management-inventory' + +const currentResourceRoot = resolve('resources') +const temporaryRoots: string[] = [] + +// Why: adopting against a bundle whose current manifest IS the first release +// records revision 1 without publishing, leaving a genuine stale-but-managed +// destination for the current bundle to update. +async function adoptedHistoricalInstall(): Promise<{ + home: string + ledger: SkillManagementLedger +}> { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-auto-update-')) + temporaryRoots.push(root) + const home = join(root, 'home') + const destination = join(home, '.agents', 'skills', 'orca-cli') + await mkdir(destination, { recursive: true }) + const releaseMapping = JSON.parse( + await readFile(resolve('resources/skills/release-mapping.json'), 'utf8') + ) + const firstRelease = releaseMapping.releases.find( + (release: { skills: Record }) => release.skills['orca-cli'] === 1 + ) + const historicalSource = execFileSync( + 'git', + ['show', `v${firstRelease.appVersion}:skills/orca-cli/SKILL.md`], + { encoding: 'utf8' } + ) + await writeFile(join(destination, 'SKILL.md'), historicalSource) + + const registry = JSON.parse( + await readFile(resolve('resources/skills/snapshot-registry.json'), 'utf8') + ) + const historicalSnapshot = registry.skills['orca-cli'].find( + (snapshot: { releaseRevision: number }) => snapshot.releaseRevision === 1 + ) + const historicalResources = join(root, 'historical-resources') + await mkdir(join(historicalResources, 'skills'), { recursive: true }) + await writeFile( + join(historicalResources, 'skills', 'current-manifest.json'), + JSON.stringify({ + schemaVersion: 1, + appVersion: firstRelease.appVersion, + skills: [ + { + name: 'orca-cli', + sourcePath: 'skills/orca-cli', + appVersion: firstRelease.appVersion, + ...historicalSnapshot + } + ] + }) + ) + await writeFile( + join(historicalResources, 'skills', 'snapshot-registry.json'), + JSON.stringify(registry) + ) + await writeFile( + join(historicalResources, 'skills', 'release-mapping.json'), + JSON.stringify(releaseMapping) + ) + + const ledger = emptySkillManagementLedger() + const inventory = await inventoryManagedSkills({ + ledger, + hostId: 'local', + homeDir: home, + resourceRoot: historicalResources + }) + const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')! + await adoptExactSkillSnapshot({ + ledger, + hostId: 'local', + homeDir: home, + installationId: installation.id, + resourceRoot: historicalResources, + commit: (next) => { + ledger.destinations[next.id] = next + }, + now: 1 + }) + return { home, ledger } +} + +function batchArgs(home: string, ledger: SkillManagementLedger) { + return { + hostId: 'local' as const, + homeDir: home, + resourceRoot: currentResourceRoot, + getLedger: () => ledger, + commit: (next: ManagedSkillDestination) => { + ledger.destinations[next.id] = next + }, + recordFailure: (prior: ManagedSkillDestination, error: unknown) => { + ledger.destinations[prior.id] = { + ...prior, + lastOutcome: 'failed', + lastErrorCategory: error instanceof Error ? error.message : 'unknown-error', + updatedAt: 2 + } + } + } +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('managed skill auto-update batch', () => { + it('updates an adopted stale destination to the bundled release', async () => { + const { home, ledger } = await adoptedHistoricalInstall() + let beforeWritesCalls = 0 + + const outcome = await autoUpdateManagedSkills({ + ...batchArgs(home, ledger), + beforeWrites: async () => { + beforeWritesCalls += 1 + } + }) + + expect(outcome.updatedSkillNames).toEqual(['orca-cli']) + expect(outcome.failedSkillNames).toEqual([]) + expect(beforeWritesCalls).toBe(1) + expect(await readFile(join(home, '.agents', 'skills', 'orca-cli', 'SKILL.md'), 'utf8')).toBe( + await readFile(resolve('skills/orca-cli/SKILL.md'), 'utf8') + ) + expect( + outcome.inventory.installations.find((entry) => entry.name === 'orca-cli') + ).toMatchObject({ status: 'managed-current', managed: true }) + }) + + it('never writes a locally modified copy and performs no recovery work for it', async () => { + const { home, ledger } = await adoptedHistoricalInstall() + const skillFile = join(home, '.agents', 'skills', 'orca-cli', 'SKILL.md') + const modified = `${await readFile(skillFile, 'utf8')}\nlocal note\n` + await writeFile(skillFile, modified) + let beforeWritesCalls = 0 + + const outcome = await autoUpdateManagedSkills({ + ...batchArgs(home, ledger), + beforeWrites: async () => { + beforeWritesCalls += 1 + } + }) + + expect(outcome.updatedSkillNames).toEqual([]) + expect(outcome.failedSkillNames).toEqual([]) + expect(beforeWritesCalls).toBe(0) + expect(await readFile(skillFile, 'utf8')).toBe(modified) + expect( + outcome.inventory.installations.find((entry) => entry.name === 'orca-cli') + ).toMatchObject({ status: 'modified' }) + }) + + it('records one failure, restores the prior package, and never re-attempts in a later batch', async () => { + const { home, ledger } = await adoptedHistoricalInstall() + const skillFile = join(home, '.agents', 'skills', 'orca-cli', 'SKILL.md') + const historical = await readFile(skillFile, 'utf8') + + const failing = await autoUpdateManagedSkills({ + ...batchArgs(home, ledger), + commit: () => { + throw new Error('persistence-writes-frozen') + } + }) + + expect(failing.updatedSkillNames).toEqual([]) + expect(failing.failedSkillNames).toEqual(['orca-cli']) + expect(await readFile(skillFile, 'utf8')).toBe(historical) + expect( + failing.inventory.installations.find((entry) => entry.name === 'orca-cli') + ).toMatchObject({ status: 'update-failed' }) + + let beforeWritesCalls = 0 + const retry = await autoUpdateManagedSkills({ + ...batchArgs(home, ledger), + beforeWrites: async () => { + beforeWritesCalls += 1 + } + }) + + expect(retry.updatedSkillNames).toEqual([]) + expect(retry.failedSkillNames).toEqual([]) + expect(beforeWritesCalls).toBe(0) + expect(await readFile(skillFile, 'utf8')).toBe(historical) + }) +}) diff --git a/src/main/skills/skill-auto-update.ts b/src/main/skills/skill-auto-update.ts new file mode 100644 index 00000000000..e1bb15e1899 --- /dev/null +++ b/src/main/skills/skill-auto-update.ts @@ -0,0 +1,79 @@ +import type { ExecutionHostId } from '../../shared/execution-host' +import type { + ManagedSkillDestination, + SkillManagementInventory, + SkillManagementLedger +} from '../../shared/skill-management' +import { inventoryManagedSkills } from './skill-management-inventory' +import { updateManagedSkill } from './skill-managed-update' + +export type SkillAutoUpdateOutcome = { + updatedSkillNames: string[] + failedSkillNames: string[] + inventory: SkillManagementInventory +} + +export async function autoUpdateManagedSkills(args: { + hostId: ExecutionHostId + homeDir: string + resourceRoot?: string + now?: number + getLedger: () => SkillManagementLedger + commit: (destination: ManagedSkillDestination) => void | Promise + recordFailure: (prior: ManagedSkillDestination, error: unknown) => void | Promise + beforeWrites?: () => Promise +}): Promise { + const inventory = await inventoryManagedSkills({ + ledger: args.getLedger(), + hostId: args.hostId, + homeDir: args.homeDir, + resourceRoot: args.resourceRoot + }) + // Why: auto-update may touch only destinations the user already manages; + // a failed destination stays update-failed until an explicit retry, so a + // background batch can never loop on the same broken package. + const candidates = inventory.installations.filter( + (installation) => + installation.managed && + installation.status === 'managed-update-available' && + installation.actionsSupported + ) + if (candidates.length === 0) { + return { updatedSkillNames: [], failedSkillNames: [], inventory } + } + await args.beforeWrites?.() + const updatedSkillNames: string[] = [] + const failedSkillNames: string[] = [] + // Why: destinations publish serially so one stalled filesystem cannot fan + // out concurrent transactions across every managed skill. + for (const candidate of candidates) { + try { + await updateManagedSkill({ + ledger: args.getLedger(), + hostId: args.hostId, + homeDir: args.homeDir, + installationId: candidate.id, + resourceRoot: args.resourceRoot, + now: args.now, + commit: args.commit + }) + updatedSkillNames.push(candidate.name) + } catch (error) { + failedSkillNames.push(candidate.name) + const prior = args.getLedger().destinations[candidate.id] + if (prior) { + await args.recordFailure(prior, error) + } + } + } + return { + updatedSkillNames, + failedSkillNames, + inventory: await inventoryManagedSkills({ + ledger: args.getLedger(), + hostId: args.hostId, + homeDir: args.homeDir, + resourceRoot: args.resourceRoot + }) + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0cac555347b..36d76a0e3f1 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -318,6 +318,8 @@ import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skill import type { OrcaSkillInstallResultArgs, SkillAdoptionDismissalArgs, + SkillAutoUpdateBatchArgs, + SkillAutoUpdateBatchResult, SkillManagementActionArgs, SkillManagementInventory, SkillReplacementPreview @@ -2223,6 +2225,7 @@ export type PreloadApi = { previewReplacement: (args: SkillManagementActionArgs) => Promise replace: (args: SkillManagementActionArgs) => Promise updateManaged: (args: SkillManagementActionArgs) => Promise + autoUpdateManaged: (args?: SkillAutoUpdateBatchArgs) => Promise recordOrcaInstall: (args: OrcaSkillInstallResultArgs) => Promise } pet: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 24b35e39ef1..6e343dfd0a2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -73,6 +73,8 @@ import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skill import type { OrcaSkillInstallResultArgs, SkillAdoptionDismissalArgs, + SkillAutoUpdateBatchArgs, + SkillAutoUpdateBatchResult, SkillManagementActionArgs, SkillManagementInventory, SkillReplacementPreview @@ -2211,6 +2213,8 @@ const api = { ipcRenderer.invoke('skills:replace', args), updateManaged: (args: SkillManagementActionArgs): Promise => ipcRenderer.invoke('skills:updateManaged', args), + autoUpdateManaged: (args?: SkillAutoUpdateBatchArgs): Promise => + ipcRenderer.invoke('skills:autoUpdateManaged', args), recordOrcaInstall: (args: OrcaSkillInstallResultArgs): Promise => ipcRenderer.invoke('skills:recordOrcaInstall', args) }, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 32e9a0e3c0d..e5ffddb4a29 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -60,6 +60,7 @@ import { StarNagCard } from './components/StarNagCard' import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver' import { StarNagToastHost } from './components/star-nag/StarNagToastHost' import { ManagedSkillAdoptionNudge } from './components/managed-skills/ManagedSkillAdoptionNudge' +import { ManagedSkillAutoUpdate } from './components/managed-skills/ManagedSkillAutoUpdate' import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from './components/ZoomOverlay' import { onOnboardingReopened } from './components/onboarding/show-onboarding-event' @@ -2829,6 +2830,7 @@ function App(): React.JSX.Element { + {/* Why: rendered last so it sits after all -webkit-app-region:drag elements in DOM order. Electron's hit-test for drag regions is DOM-order-based and diff --git a/src/renderer/src/components/managed-skills/ManagedSkillAdoptionNudge.tsx b/src/renderer/src/components/managed-skills/ManagedSkillAdoptionNudge.tsx index 3548216f099..c1ff7f8dc7e 100644 --- a/src/renderer/src/components/managed-skills/ManagedSkillAdoptionNudge.tsx +++ b/src/renderer/src/components/managed-skills/ManagedSkillAdoptionNudge.tsx @@ -10,6 +10,9 @@ export function ManagedSkillAdoptionNudge(): null { const persistedKeys = useRef(new Set()) const openSettingsPage = useAppStore((store) => store.openSettingsPage) const openSettingsTarget = useAppStore((store) => store.openSettingsTarget) + const autoUpdateEnabled = useAppStore( + (store) => store.settings?.managedSkillAutoUpdateEnabled !== false + ) useEffect(() => { const candidates = @@ -59,10 +62,15 @@ export function ManagedSkillAdoptionNudge(): null { { value0: count } ), { - description: translate( - 'auto.components.managedSkills.adoptionNudge.description', - 'These match Orca’s official versions. Orca shows when updates are available and only installs one after you approve it.' - ), + description: autoUpdateEnabled + ? translate( + 'auto.components.managedSkills.adoptionNudge.descriptionAuto', + 'These match Orca’s official versions. Orca will keep them up to date automatically.' + ) + : translate( + 'auto.components.managedSkills.adoptionNudge.description', + 'These match Orca’s official versions. Orca will show when updates are available.' + ), duration: 12_000, onDismiss: persistDismissal, onAutoClose: persistDismissal, @@ -76,7 +84,7 @@ export function ManagedSkillAdoptionNudge(): null { } } ) - }, [openSettingsPage, openSettingsTarget, state]) + }, [autoUpdateEnabled, openSettingsPage, openSettingsTarget, state]) return null } diff --git a/src/renderer/src/components/managed-skills/ManagedSkillAutoUpdate.test.tsx b/src/renderer/src/components/managed-skills/ManagedSkillAutoUpdate.test.tsx new file mode 100644 index 00000000000..3915f3148f5 --- /dev/null +++ b/src/renderer/src/components/managed-skills/ManagedSkillAutoUpdate.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ManagedSkillAutoUpdate } from './ManagedSkillAutoUpdate' + +const mocks = vi.hoisted(() => { + const baselineInventory = () => ({ + schemaVersion: 1, + hostId: 'local', + installations: [ + { + id: 'managed-a', + name: 'orca-cli', + status: 'managed-update-available', + managed: true, + actionsSupported: true, + currentPackageDigest: 'a'.repeat(64) + }, + { + id: 'unmanaged-b', + name: 'orchestration', + status: 'known-update-available', + managed: false, + actionsSupported: true, + currentPackageDigest: 'b'.repeat(64) + } + ], + adoptionCandidateCount: 0, + scannedAt: 1 + }) + return { + autoUpdate: vi.fn(async () => ({ + updatedSkillNames: [] as string[], + failedSkillNames: [] as string[], + inventory: null + })), + success: vi.fn(), + notify: vi.fn(), + settings: { managedSkillAutoUpdateEnabled: true } as { + managedSkillAutoUpdateEnabled: boolean + } | null, + baselineInventory, + inventory: baselineInventory() + } +}) + +vi.mock('sonner', () => ({ toast: { success: mocks.success } })) +vi.mock('@/hooks/useManagedAgentSkills', () => ({ + useManagedAgentSkills: () => ({ inventory: mocks.inventory }) +})) +vi.mock('@/hooks/useInstalledAgentSkills', () => ({ + notifyInstalledAgentSkillsChanged: mocks.notify +})) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => selector({ settings: mocks.settings }) +})) + +async function mount() { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + await act(async () => root.render()) + return root +} + +describe('ManagedSkillAutoUpdate', () => { + beforeEach(() => { + mocks.autoUpdate.mockReset() + mocks.autoUpdate.mockResolvedValue({ + updatedSkillNames: [], + failedSkillNames: [], + inventory: null + }) + mocks.success.mockClear() + mocks.notify.mockClear() + mocks.settings = { managedSkillAutoUpdateEnabled: true } + mocks.inventory = mocks.baselineInventory() + Object.assign(window, { api: { skills: { autoUpdateManaged: mocks.autoUpdate } } }) + }) + + afterEach(() => { + document.body.replaceChildren() + }) + + it('runs one batch per bundled release and shows one aggregated toast', async () => { + mocks.autoUpdate.mockResolvedValue({ + updatedSkillNames: ['orca-cli'], + failedSkillNames: [], + inventory: null + }) + const root = await mount() + + await vi.waitFor(() => expect(mocks.autoUpdate).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(mocks.success).toHaveBeenCalledTimes(1)) + expect(mocks.success).toHaveBeenCalledWith('Updated the Orca CLI skill to the latest version.') + expect(mocks.notify).toHaveBeenCalledTimes(1) + + // A focus-driven re-scan of the same bundle must not start a second batch. + mocks.inventory = { ...mocks.inventory, scannedAt: Date.now() + 1 } + await act(async () => root.render()) + expect(mocks.autoUpdate).toHaveBeenCalledTimes(1) + root.unmount() + }) + + it('does not request a batch when auto-update is turned off', async () => { + mocks.settings = { managedSkillAutoUpdateEnabled: false } + const root = await mount() + + expect(mocks.autoUpdate).not.toHaveBeenCalled() + root.unmount() + }) + + it('ignores unmanaged, failed, and action-unsupported rows', async () => { + mocks.inventory = { + ...mocks.inventory, + installations: [ + { ...mocks.inventory.installations[1]! }, + { + id: 'failed-c', + name: 'computer-use', + status: 'update-failed', + managed: true, + actionsSupported: true, + currentPackageDigest: 'c'.repeat(64) + }, + { + id: 'remote-d', + name: 'orca-linear', + status: 'managed-update-available', + managed: true, + actionsSupported: false, + currentPackageDigest: 'd'.repeat(64) + } + ] + } + const root = await mount() + + expect(mocks.autoUpdate).not.toHaveBeenCalled() + root.unmount() + }) + + it('stays quiet when the batch reports only failures', async () => { + mocks.autoUpdate.mockResolvedValue({ + updatedSkillNames: [], + failedSkillNames: ['orca-cli'], + inventory: null + }) + const root = await mount() + + await vi.waitFor(() => expect(mocks.autoUpdate).toHaveBeenCalledTimes(1)) + expect(mocks.success).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + root.unmount() + }) +}) diff --git a/src/renderer/src/components/managed-skills/ManagedSkillAutoUpdate.tsx b/src/renderer/src/components/managed-skills/ManagedSkillAutoUpdate.tsx new file mode 100644 index 00000000000..26ddf98d0d2 --- /dev/null +++ b/src/renderer/src/components/managed-skills/ManagedSkillAutoUpdate.tsx @@ -0,0 +1,62 @@ +import { useEffect, useRef } from 'react' +import { toast } from 'sonner' +import { useManagedAgentSkills } from '@/hooks/useManagedAgentSkills' +import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' +import { managedSkillDisplayName } from '@/components/settings/managed-skill-status-copy' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' + +export function ManagedSkillAutoUpdate(): null { + const state = useManagedAgentSkills() + const settings = useAppStore((store) => store.settings) + const attemptedBundleKeys = useRef(new Set()) + + useEffect(() => { + // Why: hydration must finish before honoring the default, or a disabled + // toggle could race one batch request on every launch. + if (!settings || settings.managedSkillAutoUpdateEnabled === false) { + return + } + const pending = (state.inventory?.installations ?? []).filter( + (installation) => + installation.managed && + installation.status === 'managed-update-available' && + installation.actionsSupported && + !attemptedBundleKeys.current.has(`${installation.id}\0${installation.currentPackageDigest}`) + ) + if (pending.length === 0) { + return + } + // Why: one attempt per destination per bundled release; outcomes stay + // visible in settings, so focus-driven refreshes never re-run a batch. + for (const installation of pending) { + attemptedBundleKeys.current.add(`${installation.id}\0${installation.currentPackageDigest}`) + } + void window.api.skills + .autoUpdateManaged() + .then((result) => { + if (result.updatedSkillNames.length === 0) { + return + } + toast.success( + result.updatedSkillNames.length === 1 + ? translate( + 'auto.components.managedSkills.autoUpdate.updated_one', + 'Updated the {{value0}} skill to the latest version.', + { value0: managedSkillDisplayName(result.updatedSkillNames[0]!) } + ) + : translate( + 'auto.components.managedSkills.autoUpdate.updated_other', + 'Updated {{value0}} Orca skills to their latest versions.', + { value0: result.updatedSkillNames.length } + ) + ) + notifyInstalledAgentSkillsChanged() + }) + // Why: failures surface as update-failed rows in settings; repeating + // them as toasts on every trigger would nag without adding a fix path. + .catch(() => undefined) + }, [settings, state.inventory]) + + return null +} diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index f7dca23ac63..7ea2145595b 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -830,7 +830,7 @@ export function AgentsPane({ wslCapabilitiesLoading={wslCapabilitiesLoading} /> - + diff --git a/src/renderer/src/components/settings/ManagedOrcaSkillsSection.tsx b/src/renderer/src/components/settings/ManagedOrcaSkillsSection.tsx index f4aa5071073..746f7ee2ca3 100644 --- a/src/renderer/src/components/settings/ManagedOrcaSkillsSection.tsx +++ b/src/renderer/src/components/settings/ManagedOrcaSkillsSection.tsx @@ -6,11 +6,12 @@ import type { SkillManagementStatus, SkillReplacementPreview } from '../../../../shared/skill-management' +import type { GlobalSettings } from '../../../../shared/types' import { useManagedAgentSkills } from '@/hooks/useManagedAgentSkills' import { translate } from '@/i18n/i18n' import { Button } from '../ui/button' import { ManagedSkillReplacementDialog } from './ManagedSkillReplacementDialog' -import { SettingsBadge, SettingsSubsectionHeader } from './SettingsFormControls' +import { SettingsBadge, SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls' import { managedSkillDisplayName, managedSkillStatusCopy, @@ -55,8 +56,15 @@ function missingVisibilityCopy(installation: SkillManagementInstallation): strin : null } -export function ManagedOrcaSkillsSection(): React.JSX.Element { +export function ManagedOrcaSkillsSection({ + settings, + updateSettings +}: { + settings: GlobalSettings + updateSettings: (updates: Partial) => void | Promise +}): React.JSX.Element { const state = useManagedAgentSkills() + const autoUpdateEnabled = settings.managedSkillAutoUpdateEnabled !== false const [preview, setPreview] = useState(null) const [previewLoadingId, setPreviewLoadingId] = useState(null) const previewLoadingIdRef = useRef(null) @@ -105,7 +113,7 @@ export function ManagedOrcaSkillsSection(): React.JSX.Element { title={translate('auto.components.settings.ManagedOrcaSkills.title', 'Orca skill updates')} description={translate( 'auto.components.settings.ManagedOrcaSkills.description', - 'Track new versions of official Orca skills and choose when to update them.' + 'Orca can keep the official skills you’ve installed up to date.' )} action={