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.
This commit is contained in:
Brennan Benson
2026-07-13 02:08:54 -07:00
parent 5ebd83aadd
commit 6506f428a1
26 changed files with 1017 additions and 185 deletions
@@ -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 toWslExecutionHostId> | 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 }
}
+68
View File
@@ -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<SkillMutationRuntimeFacts, 'homeDir'>
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<void> {
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'
}
+102
View File
@@ -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({
+57 -134
View File
@@ -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<SkillMutationRuntimeFacts, 'homeDir'>
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 toWslExecutionHostId> | 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<void> {
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<SkillDiscoveryResult> => {
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<SkillManagementInventory> => {
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<SkillManagementInventory> => {
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<SkillManagementInventory> => {
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<SkillAutoUpdateBatchResult> | null = null
ipcMain.handle(
'skills:autoUpdateManaged',
async (_event, args?: SkillAutoUpdateBatchArgs): Promise<SkillAutoUpdateBatchResult> => {
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<SkillManagementInventory> => {
@@ -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()
+196
View File
@@ -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<string, number> }) => 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)
})
})
+79
View File
@@ -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<void>
recordFailure: (prior: ManagedSkillDestination, error: unknown) => void | Promise<void>
beforeWrites?: () => Promise<void>
}): Promise<SkillAutoUpdateOutcome> {
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
})
}
}
+3
View File
@@ -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<SkillReplacementPreview>
replace: (args: SkillManagementActionArgs) => Promise<SkillManagementInventory>
updateManaged: (args: SkillManagementActionArgs) => Promise<SkillManagementInventory>
autoUpdateManaged: (args?: SkillAutoUpdateBatchArgs) => Promise<SkillAutoUpdateBatchResult>
recordOrcaInstall: (args: OrcaSkillInstallResultArgs) => Promise<SkillManagementInventory>
}
pet: {
+4
View File
@@ -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<SkillManagementInventory> =>
ipcRenderer.invoke('skills:updateManaged', args),
autoUpdateManaged: (args?: SkillAutoUpdateBatchArgs): Promise<SkillAutoUpdateBatchResult> =>
ipcRenderer.invoke('skills:autoUpdateManaged', args),
recordOrcaInstall: (args: OrcaSkillInstallResultArgs): Promise<SkillManagementInventory> =>
ipcRenderer.invoke('skills:recordOrcaInstall', args)
},
+2
View File
@@ -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 {
</TooltipProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
<ManagedSkillAdoptionNudge />
<ManagedSkillAutoUpdate />
<PinnedTabCloseDialog />
{/* 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
@@ -10,6 +10,9 @@ export function ManagedSkillAdoptionNudge(): null {
const persistedKeys = useRef(new Set<string>())
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
}
@@ -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(<ManagedSkillAutoUpdate />))
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(<ManagedSkillAutoUpdate />))
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()
})
})
@@ -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<string>())
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
}
@@ -830,7 +830,7 @@ export function AgentsPane({
wslCapabilitiesLoading={wslCapabilitiesLoading}
/>
<ManagedOrcaSkillsSection />
<ManagedOrcaSkillsSection settings={settings} updateSettings={updateSettings} />
<AgentStatusHooksSetting settings={settings} updateSettings={updateSettings} />
@@ -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<GlobalSettings>) => void | Promise<void>
}): React.JSX.Element {
const state = useManagedAgentSkills()
const autoUpdateEnabled = settings.managedSkillAutoUpdateEnabled !== false
const [preview, setPreview] = useState<SkillReplacementPreview | null>(null)
const [previewLoadingId, setPreviewLoadingId] = useState<string | null>(null)
const previewLoadingIdRef = useRef<string | null>(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={
<Button
@@ -119,6 +127,18 @@ export function ManagedOrcaSkillsSection(): React.JSX.Element {
</Button>
}
/>
<SettingsSwitchRow
label={translate(
'auto.components.settings.ManagedOrcaSkills.autoUpdateLabel',
'Update automatically'
)}
description={translate(
'auto.components.settings.ManagedOrcaSkills.autoUpdateDescription',
'Install new versions of tracked skills as they ship with Orca. Skills with local changes are never updated automatically.'
)}
checked={autoUpdateEnabled}
onChange={() => void updateSettings({ managedSkillAutoUpdateEnabled: !autoUpdateEnabled })}
/>
{state.error ? <p className="text-xs text-destructive">{state.error}</p> : null}
{state.inventory?.installations.length ? (
<div className="divide-y divide-border/40">
@@ -139,7 +159,7 @@ export function ManagedOrcaSkillsSection(): React.JSX.Element {
</SettingsBadge>
</div>
<p className="text-xs text-muted-foreground">
{managedSkillSummaryCopy(installation)}
{managedSkillSummaryCopy(installation, { autoUpdateEnabled })}
</p>
{visibility ? (
<p className="text-[11px] text-muted-foreground">{visibility}</p>
@@ -15,6 +15,15 @@ describe('managed skill settings copy', () => {
)
})
it('describes managed rows by whether Orca updates them automatically', () => {
expect(
managedSkillSummaryCopy({ status: 'managed-current' }, { autoUpdateEnabled: true })
).toBe('Orca keeps this skill up to date automatically.')
expect(
managedSkillSummaryCopy({ status: 'managed-current' }, { autoUpdateEnabled: false })
).toBe('Orca is tracking this skill for new versions.')
})
it('turns package identifiers into readable skill names', () => {
expect(managedSkillDisplayName('computer-use')).toBe('Computer Use')
expect(managedSkillDisplayName('orca-cli')).toBe('Orca CLI')
@@ -40,14 +40,20 @@ export function managedSkillStatusCopy(status: SkillManagementStatus): string {
}
export function managedSkillSummaryCopy(
installation: Pick<SkillManagementInstallation, 'status'>
installation: Pick<SkillManagementInstallation, 'status'>,
options?: { autoUpdateEnabled?: boolean }
): string {
switch (installation.status) {
case 'managed-current':
return translate(
'auto.components.settings.ManagedOrcaSkills.summaryManagedCurrent',
'Orca is tracking this skill for new versions.'
)
return options?.autoUpdateEnabled
? translate(
'auto.components.settings.ManagedOrcaSkills.summaryManagedCurrentAuto',
'Orca keeps this skill up to date automatically.'
)
: translate(
'auto.components.settings.ManagedOrcaSkills.summaryManagedCurrent',
'Orca is tracking this skill for new versions.'
)
case 'known-current':
return translate(
'auto.components.settings.ManagedOrcaSkills.summaryKnownCurrent',
@@ -33,11 +33,11 @@ export function useAgentSkillManagementActions(args: {
}
switch (installation?.status) {
case 'known-current':
return translate('auto.components.settings.AgentSkillSetupPanel.manage', 'Manage')
return translate('auto.components.settings.AgentSkillSetupPanel.manage', 'Track updates')
case 'known-update-available':
return translate(
'auto.components.settings.AgentSkillSetupPanel.manageUpdate',
'Manage and update'
'Track & update'
)
case 'managed-update-available':
return translate('auto.components.settings.AgentSkillSetupPanel.update', 'Update')
@@ -81,7 +81,7 @@ export function useAgentSkillManagementActions(args: {
toast.success(
translate(
'auto.components.settings.AgentSkillSetupPanel.managedCurrent',
'Skill is managed and current.'
'Orca is tracking this skill and it’s up to date.'
)
)
notifyInstalledAgentSkillsChanged()
+15 -7
View File
@@ -4903,10 +4903,10 @@
"4c05b9d7cb": "Preparing setup terminal.",
"checkingManagement": "Checking…",
"review": "Review",
"manage": "Manage",
"manageUpdate": "Manage and update",
"manage": "Track updates",
"manageUpdate": "Track & update",
"update": "Update",
"managedCurrent": "Skill is managed and current.",
"managedCurrent": "Orca is tracking this skill and it’s up to date.",
"retryUpdate": "Retry update",
"actionFailed": "Skill action failed."
},
@@ -9056,7 +9056,7 @@
"replace": "Use official version",
"actionFailed": "Skill action failed.",
"title": "Orca skill updates",
"description": "Track new versions of official Orca skills and choose when to update them.",
"description": "Orca can keep the official skills you’ve installed up to date.",
"refresh": "Check now",
"hostPending": "View only on this host; updating is not available yet.",
"managedToast": "Orca is now tracking this skill for updates.",
@@ -9087,7 +9087,10 @@
"summaryUnknown": "This installed copy does not match a known Orca version.",
"summaryExternal": "Another installer controls this copy, so Orca will not change it.",
"summaryInaccessible": "Orca could not read this installed copy.",
"summaryFailed": "The last update failed. Your previous copy was kept."
"summaryFailed": "The last update failed. Your previous copy was kept.",
"autoUpdateLabel": "Update automatically",
"autoUpdateDescription": "Install new versions of tracked skills as they ship with Orca. Skills with local changes are never updated automatically.",
"summaryManagedCurrentAuto": "Orca keeps this skill up to date automatically."
}
},
"right": {
@@ -13017,8 +13020,13 @@
"adoptionNudge": {
"title_one": "Track updates for an installed Orca skill?",
"title_other": "Track updates for {{value0}} installed Orca skills?",
"description": "These match Orca’s official versions. Orca shows when updates are available and only installs one after you approve it.",
"review": "Choose skills"
"description": "These match Orca’s official versions. Orca will show when updates are available.",
"review": "Choose skills",
"descriptionAuto": "These match Orca’s official versions. Orca will keep them up to date automatically."
},
"autoUpdate": {
"updated_one": "Updated the {{value0}} skill to the latest version.",
"updated_other": "Updated {{value0}} Orca skills to their latest versions."
}
}
},
+15 -7
View File
@@ -4903,10 +4903,10 @@
"4c05b9d7cb": "Preparando el terminal de configuración.",
"checkingManagement": "Comprobando…",
"review": "Revisar",
"manage": "Gestionar",
"manageUpdate": "Gestionar y actualizar",
"manage": "Seguir actualizaciones",
"manageUpdate": "Seguir y actualizar",
"update": "Actualizar",
"managedCurrent": "El skill está gestionado y actualizado.",
"managedCurrent": "Orca sigue este skill y está actualizado.",
"retryUpdate": "Reintentar actualización",
"actionFailed": "La acción del skill falló."
},
@@ -9054,7 +9054,7 @@
"replace": "Usar versión oficial",
"actionFailed": "La acción del skill falló.",
"title": "Actualizaciones de skills de Orca",
"description": "Sigue las nuevas versiones de los skills oficiales de Orca y elige cuándo actualizarlos.",
"description": "Orca puede mantener actualizados los skills oficiales que has instalado.",
"refresh": "Comprobar ahora",
"hostPending": "Solo lectura en este host; las actualizaciones aún no están disponibles.",
"managedToast": "Orca ahora sigue las actualizaciones de este skill.",
@@ -9087,7 +9087,10 @@
"summaryUnknown": "Esta copia instalada no coincide con ninguna versión conocida de Orca.",
"summaryExternal": "Otro instalador controla esta copia, por lo que Orca no la cambiará.",
"summaryInaccessible": "Orca no pudo leer esta copia instalada.",
"summaryFailed": "La última actualización falló. Se conservó la copia anterior."
"summaryFailed": "La última actualización falló. Se conservó la copia anterior.",
"autoUpdateLabel": "Actualizar automáticamente",
"autoUpdateDescription": "Instala nuevas versiones de los skills seguidos cuando lleguen con Orca. Los skills con cambios locales nunca se actualizan automáticamente.",
"summaryManagedCurrentAuto": "Orca mantiene este skill actualizado automáticamente."
}
},
"right": {
@@ -13017,8 +13020,13 @@
"adoptionNudge": {
"title_one": "¿Seguir las actualizaciones de un skill de Orca instalado?",
"title_other": "¿Seguir las actualizaciones de {{value0}} skills de Orca instalados?",
"description": "Coinciden con versiones oficiales de Orca. Orca muestra cuándo hay actualizaciones y solo instala una después de tu aprobación.",
"review": "Elegir skills"
"description": "Coinciden con las versiones oficiales de Orca. Orca mostrará cuándo hay actualizaciones disponibles.",
"review": "Elegir skills",
"descriptionAuto": "Coinciden con las versiones oficiales de Orca. Orca los mantendrá actualizados automáticamente."
},
"autoUpdate": {
"updated_one": "Se actualizó el skill {{value0}} a la última versión.",
"updated_other": "Se actualizaron {{value0}} skills de Orca a sus últimas versiones."
}
}
},
+15 -7
View File
@@ -4888,10 +4888,10 @@
"4c05b9d7cb": "セットアップ用ターミナルを準備しています。",
"checkingManagement": "確認中…",
"review": "確認",
"manage": "管理",
"manageUpdate": "管理して更新",
"manage": "更新を追跡",
"manageUpdate": "追跡して更新",
"update": "更新",
"managedCurrent": "スキルは管理対象で、最新です。",
"managedCurrent": "Orca がこのスキルを追跡しており、最新の状態です。",
"retryUpdate": "更新を再試行",
"actionFailed": "スキル操作に失敗しました。"
},
@@ -9054,7 +9054,7 @@
"replace": "公式版を使用",
"actionFailed": "スキル操作に失敗しました。",
"title": "Orca スキルの更新",
"description": "Orca 公式スキルの新しいバージョンを追跡し、更新するタイミングを選べます。",
"description": "インストール済みの公式 Orca スキルを最新の状態に保てます。",
"refresh": "今すぐ確認",
"hostPending": "このホストでは表示のみ可能です。更新はまだ利用できません。",
"managedToast": "Orca がこのスキルの更新を追跡するようになりました。",
@@ -9087,7 +9087,10 @@
"summaryUnknown": "インストール済みのコピーは既知の Orca バージョンと一致しません。",
"summaryExternal": "別のインストーラーがこのコピーを管理しているため、Orca は変更しません。",
"summaryInaccessible": "Orca はインストール済みのコピーを読み取れませんでした。",
"summaryFailed": "前回の更新に失敗しました。以前のコピーは保持されています。"
"summaryFailed": "前回の更新に失敗しました。以前のコピーは保持されています。",
"autoUpdateLabel": "自動更新",
"autoUpdateDescription": "Orca に同梱された新しいバージョンを、追跡中のスキルに自動でインストールします。ローカル変更のあるスキルが自動更新されることはありません。",
"summaryManagedCurrentAuto": "Orca がこのスキルを自動的に最新の状態に保ちます。"
}
},
"right": {
@@ -13017,8 +13020,13 @@
"adoptionNudge": {
"title_one": "インストール済み Orca スキルの更新を追跡しますか?",
"title_other": "インストール済みの {{value0}} 件の Orca スキルの更新を追跡しますか?",
"description": "Orca の公式版と一致しています。更新があると表示され、承認後にのみインストールされます。",
"review": "スキルを選択"
"description": "Orca の公式版と一致しています。更新が利用可能になると表示されます。",
"review": "スキルを選択",
"descriptionAuto": "Orca の公式版と一致しています。Orca が自動的に最新の状態に保ちます。"
},
"autoUpdate": {
"updated_one": "{{value0}} スキルを最新バージョンに更新しました。",
"updated_other": "{{value0}} 件の Orca スキルを最新バージョンに更新しました。"
}
}
},
+10 -2
View File
@@ -9087,7 +9087,10 @@
"summaryUnknown": "설치된 복사본이 알려진 Orca 버전과 일치하지 않습니다.",
"summaryExternal": "다른 설치 프로그램이 이 복사본을 관리하므로 Orca는 변경하지 않습니다.",
"summaryInaccessible": "Orca가 설치된 복사본을 읽지 못했습니다.",
"summaryFailed": "마지막 업데이트에 실패했습니다. 이전 복사본은 유지되었습니다."
"summaryFailed": "마지막 업데이트에 실패했습니다. 이전 복사본은 유지되었습니다.",
"autoUpdateLabel": "Update automatically",
"autoUpdateDescription": "Install new versions of tracked skills as they ship with Orca. Skills with local changes are never updated automatically.",
"summaryManagedCurrentAuto": "Orca keeps this skill up to date automatically."
}
},
"right": {
@@ -13018,7 +13021,12 @@
"title_one": "설치된 Orca 스킬의 업데이트를 추적할까요?",
"title_other": "설치된 Orca 스킬 {{value0}}개의 업데이트를 추적할까요?",
"description": "Orca의 공식 버전과 일치합니다. 업데이트가 있으면 표시되며 승인한 후에만 설치됩니다.",
"review": "스킬 선택"
"review": "스킬 선택",
"descriptionAuto": "These match Orca’s official versions. Orca will keep them up to date automatically."
},
"autoUpdate": {
"updated_one": "Updated the {{value0}} skill to the latest version.",
"updated_other": "Updated {{value0}} Orca skills to their latest versions."
}
}
},
+10 -2
View File
@@ -9087,7 +9087,10 @@
"summaryUnknown": "已安装的副本与任何已知 Orca 版本都不一致。",
"summaryExternal": "另一个安装程序正在管理此副本,因此 Orca 不会更改它。",
"summaryInaccessible": "Orca 无法读取已安装的副本。",
"summaryFailed": "上次更新失败。已保留之前的副本。"
"summaryFailed": "上次更新失败。已保留之前的副本。",
"autoUpdateLabel": "Update automatically",
"autoUpdateDescription": "Install new versions of tracked skills as they ship with Orca. Skills with local changes are never updated automatically.",
"summaryManagedCurrentAuto": "Orca keeps this skill up to date automatically."
}
},
"right": {
@@ -13018,7 +13021,12 @@
"title_one": "要跟踪已安装 Orca 技能的更新吗?",
"title_other": "要跟踪 {{value0}} 个已安装 Orca 技能的更新吗?",
"description": "这些技能与 Orca 官方版本一致。有更新时 Orca 会显示,并且只会在你批准后安装。",
"review": "选择技能"
"review": "选择技能",
"descriptionAuto": "These match Orca’s official versions. Orca will keep them up to date automatically."
},
"autoUpdate": {
"updated_one": "Updated the {{value0}} skill to the latest version.",
"updated_other": "Updated {{value0}} Orca skills to their latest versions."
}
}
},
+1
View File
@@ -343,6 +343,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV },
agentYoloDefaultsMigrated: true,
agentStatusHooksEnabled: true,
managedSkillAutoUpdateEnabled: true,
tabAutoGenerateTitle: false,
confirmClosePinnedTab: true,
keepComputerAwakeWhileAgentsRun: false,
+13
View File
@@ -150,6 +150,19 @@ export type OrcaSkillInstallResultArgs = {
target?: SkillDiscoveryTarget
}
export type SkillAutoUpdateBatchArgs = {
target?: SkillDiscoveryTarget
}
export type SkillAutoUpdateBatchResult = {
/** Skill names whose managed destination now matches the bundled release. */
updatedSkillNames: string[]
/** Skill names whose update failed; they surface in settings as update-failed. */
failedSkillNames: string[]
/** Post-batch inventory, or null when auto-update was disabled or unavailable. */
inventory: SkillManagementInventory | null
}
export type SkillReplacementFilePreview = {
path: string
change: 'added' | 'removed' | 'modified'
+4
View File
@@ -2863,6 +2863,10 @@ export type GlobalSettings = {
/** Why: disabling must persist so startup does not reinstall global agent
* hook entries right after the user removes them from Settings or CLI. */
agentStatusHooksEnabled: boolean
/** Why: background skill writes stay consent-gated. This defaults on but
* authorizes updating only destinations the user already manages; it never
* claims new paths or touches locally modified copies. */
managedSkillAutoUpdateEnabled?: boolean
/** Why: generated tab titles are semantic but subjective, so they stay opt-in
* and manual renames remain the stronger user intent. */
tabAutoGenerateTitle: boolean
+87 -8
View File
@@ -85,6 +85,30 @@ async function installMockSkillManagement(
}, inventory)
}
async function installMockSkillAutoUpdate(
app: ElectronApplication,
updatedSkillNames: string[]
): Promise<void> {
await app.evaluate((electron, names) => {
electron.ipcMain.removeHandler('skills:autoUpdateManaged')
electron.ipcMain.handle('skills:autoUpdateManaged', () => {
const current = (globalThis as MockSkillDiscoveryGlobal)
.__orcaSettingsSkillManagementInventory!
// The real batch leaves updated rows managed-current; mirror that so the
// post-update refresh shows the settled state.
;(globalThis as MockSkillDiscoveryGlobal).__orcaSettingsSkillManagementInventory = {
...current,
installations: current.installations.map((installation) =>
names.includes(installation.name)
? { ...installation, status: 'managed-current' as const }
: installation
)
}
return { updatedSkillNames: names, failedSkillNames: [], inventory: null }
})
}, updatedSkillNames)
}
function makeSkill(sourceKind: SkillSourceKind, directoryPath: string): DiscoveredSkill {
return {
id: `${sourceKind}-orca-cli`,
@@ -174,6 +198,19 @@ test.describe('Settings skill detection', () => {
makeSkill('plugin', '/Users/test/.codex/plugins/cache/vendor/orchestration')
])
)
// Why: the setup panel's action label reads the management inventory, so
// pin it too — the developer's real global skill homes must not steer
// this assertion.
await installMockSkillManagement(electronApp, {
schemaVersion: 1,
hostId: 'local',
installations: [managedInstallation('orchestration', 'known-update-available')],
adoptionCandidateCount: 0,
scannedAt: Date.now()
})
await orcaPage.evaluate(() => {
window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))
})
await openOrchestrationSettings(orcaPage)
const section = orcaPage.locator('[data-settings-section="orchestration"]')
@@ -190,7 +227,7 @@ test.describe('Settings skill detection', () => {
)
await section.getByRole('button', { name: 'Re-check' }).click()
await expect(section.getByText('Manage and update', { exact: true }).first()).toBeVisible()
await expect(section.getByText('Track & update', { exact: true }).first()).toBeVisible()
await expect(
section.getByText('Enables agents to hand off context and coordinate work through Orca.')
).toBeVisible()
@@ -220,15 +257,57 @@ test.describe('Settings skill detection', () => {
window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))
})
await expect(orcaPage.getByText('Orca agent skills', { exact: true })).toBeVisible()
await expect(orcaPage.getByRole('button', { name: 'Manage', exact: true })).toBeVisible()
await expect(orcaPage.getByText('Orca skill updates', { exact: true })).toBeVisible()
await expect(orcaPage.getByText('Update automatically', { exact: true })).toBeVisible()
await expect(orcaPage.getByRole('button', { name: 'Track updates', exact: true })).toBeVisible()
await expect(orcaPage.getByRole('button', { name: 'Update', exact: true })).toBeVisible()
await expect(orcaPage.getByRole('button', { name: 'Retry', exact: true })).toBeVisible()
await expect(orcaPage.getByText('1 installed Orca skills can be kept up to date')).toBeVisible()
await orcaPage.locator('#agents').getByRole('button', { name: 'Review', exact: true }).click()
await expect(orcaPage.getByRole('heading', { name: 'Replace computer-use?' })).toBeVisible()
await orcaPage.getByRole('button', { name: 'Replace skill' }).click()
await expect(orcaPage.getByRole('heading', { name: 'Replace computer-use?' })).toBeVisible()
await expect(orcaPage.getByText('Track updates for an installed Orca skill?')).toBeVisible()
await orcaPage
.locator('#agents')
.getByRole('button', { name: 'Review changes', exact: true })
.click()
await expect(
orcaPage.getByRole('heading', { name: 'Review local changes to Computer Use' })
).toBeVisible()
await orcaPage.getByRole('button', { name: 'Use official version' }).click()
await expect(
orcaPage.getByRole('heading', { name: 'Review local changes to Computer Use' })
).toBeVisible()
await expect(orcaPage.locator('#agents').getByText('injected rollback')).toBeVisible()
})
test('auto-updates a managed skill in the background and reports one toast', async ({
electronApp,
orcaPage
}) => {
const stale = managedInstallation('orchestration', 'managed-update-available')
// A digest the always-mounted runner has not attempted in this session.
stale.currentPackageDigest = 'e'.repeat(64)
await installMockSkillManagement(electronApp, {
schemaVersion: 1,
hostId: 'local',
installations: [stale],
adoptionCandidateCount: 0,
scannedAt: Date.now()
})
await installMockSkillAutoUpdate(electronApp, ['orchestration'])
await orcaPage.evaluate(() => {
window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))
})
await expect(
orcaPage.getByText('Updated the Orchestration skill to the latest version.')
).toBeVisible()
await orcaPage.evaluate(() => {
const state = window.__store!.getState()
state.openSettingsTarget({ pane: 'agents', repoId: null })
state.openSettingsPage()
})
await expect(orcaPage.getByText('Up to date', { exact: true })).toBeVisible()
await expect(
orcaPage.getByText('Orca keeps this skill up to date automatically.')
).toBeVisible()
})
})