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={