Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion config/electron-builder.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ const featureWallResources = {
from: 'resources/onboarding/feature-wall',
to: 'onboarding/feature-wall'
}
// Why: packaged apps compare installed agent skills against these references
// after an Orca update. Ship SKILL.md only (not full skill packages) under
// process.resourcesPath/orca-skills so freshness checks work offline.
const orcaSkillsReferenceResources = {
from: 'skills',
to: 'orca-skills',
filter: ['**/SKILL.md']
}
// Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged
// apps. Keeping relay assets as extraResources makes them real directories
// instead of paths hidden inside app.asar.
Expand All @@ -31,7 +39,11 @@ const relayExtraResource = {
// do not fall through to a developer checkout's node_modules.
const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources()

const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources]
const commonExtraResources = [
relayExtraResource,
orcaSkillsReferenceResources,
...packagedRuntimeNodeModuleResources
]
const macSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-darwin-${arch}',
to: 'node_modules/sherpa-onnx-darwin-${arch}'
Expand Down
48 changes: 40 additions & 8 deletions src/main/ipc/skills.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { handleMock, discoverSkillsMock, getDefaultWslDistroMock, getWslHomeMock } = vi.hoisted(
() => ({
handleMock: vi.fn(),
discoverSkillsMock: vi.fn(),
getDefaultWslDistroMock: vi.fn(),
getWslHomeMock: vi.fn()
})
)
const {
handleMock,
discoverSkillsMock,
checkFreshnessMock,
getDefaultWslDistroMock,
getWslHomeMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
discoverSkillsMock: vi.fn(),
checkFreshnessMock: vi.fn(),
getDefaultWslDistroMock: vi.fn(),
getWslHomeMock: vi.fn()
}))

vi.mock('electron', () => ({
ipcMain: {
Expand All @@ -19,6 +24,10 @@ vi.mock('../skills/discovery', () => ({
discoverSkills: discoverSkillsMock
}))

vi.mock('../skills/freshness', () => ({
checkOrcaSkillFreshness: checkFreshnessMock
}))

vi.mock('../wsl', () => ({
getDefaultWslDistro: getDefaultWslDistroMock,
getWslHome: getWslHomeMock
Expand All @@ -36,9 +45,15 @@ describe('registerSkillsHandlers', () => {
beforeEach(() => {
handleMock.mockReset()
discoverSkillsMock.mockReset()
checkFreshnessMock.mockReset()
getDefaultWslDistroMock.mockReset()
getWslHomeMock.mockReset()
discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 })
checkFreshnessMock.mockResolvedValue({
skills: [],
scannedAt: 1,
referenceRoot: null
})
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice')
Object.defineProperty(process, 'platform', {
configurable: true,
Expand All @@ -61,6 +76,23 @@ describe('registerSkillsHandlers', () => {
return call[1] as (_event: unknown, target?: unknown) => Promise<unknown>
}

function getFreshnessHandler() {
registerSkillsHandlers(store as never)
const call = handleMock.mock.calls.find(
(entry: unknown[]) => entry[0] === 'skills:checkFreshness'
)
if (!call) {
throw new Error('skills:checkFreshness handler was not registered')
}
return call[1] as (_event: unknown, target?: unknown) => Promise<unknown>
}

it('registers a host freshness check against the shared scan context', async () => {
const handler = getFreshnessHandler()
await handler(null, undefined)
expect(checkFreshnessMock).toHaveBeenCalledWith({ repos })
})

it('uses host skill discovery when resolved project runtime overrides stale WSL target state', async () => {
const handler = getDiscoverHandler()

Expand Down
51 changes: 34 additions & 17 deletions src/main/ipc/skills.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { ipcMain } from 'electron'
import type { Store } from '../persistence'
import { discoverSkills } from '../skills/discovery'
import { checkOrcaSkillFreshness } from '../skills/freshness'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills'
import type { SkillFreshnessResult } from '../../shared/skill-freshness'
import { getDefaultWslDistro, getWslHome } from '../wsl'

type SkillDiscoveryRuntimeTarget =
Expand Down Expand Up @@ -32,27 +34,42 @@ function getSkillDiscoveryRuntimeTarget(
}

export function registerSkillsHandlers(store: Store): void {
const resolveScanContext = async (
target?: SkillDiscoveryTarget
): Promise<{ homeDir?: string; cwd?: string; repos: ReturnType<Store['getRepos']> }> => {
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}.`)
}
return { homeDir, cwd: homeDir, repos: [] }
}

const cwd = target?.cwd?.trim() || undefined
return cwd ? { cwd, repos: [] } : { repos: store.getRepos() }
}

ipcMain.handle(
'skills:discover',
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}.`)
}
return discoverSkills({ repos: [], homeDir, cwd: homeDir })
}
const context = await resolveScanContext(target)
return discoverSkills(context)
}
)

const cwd = target?.cwd?.trim() || undefined
return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() })
ipcMain.handle(
'skills:checkFreshness',
async (_event, target?: SkillDiscoveryTarget): Promise<SkillFreshnessResult> => {
const context = await resolveScanContext(target)
return checkOrcaSkillFreshness(context)
}
)
}
11 changes: 11 additions & 0 deletions src/main/runtime/rpc/methods/skills.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { discoverSkills } from '../../../skills/discovery'
import { checkOrcaSkillFreshness } from '../../../skills/freshness'

const SkillDiscoveryParams = z.object({
cwd: z.string().optional().nullable()
Expand All @@ -16,5 +17,15 @@ export const SKILL_METHODS: RpcMethod[] = [
? discoverSkills({ repos: [], cwd })
: discoverSkills({ repos: runtime.listRepos() })
}
}),
defineMethod({
name: 'skills.checkFreshness',
params: SkillDiscoveryParams,
handler: async (params, { runtime }) => {
const cwd = params.cwd?.trim() || undefined
return cwd
? checkOrcaSkillFreshness({ repos: [], cwd })
: checkOrcaSkillFreshness({ repos: runtime.listRepos() })
}
})
]
36 changes: 36 additions & 0 deletions src/main/skills/bundled-skills-root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { app } from 'electron'

/**
* Resolve the on-disk root of Orca-shipped skill references used for
* freshness checks. Packaged apps read `process.resourcesPath/orca-skills`;
* dev/unpackaged builds fall back to the repo `skills/` directory.
*/
export function resolveBundledSkillsRoot(options?: {
isPackaged?: boolean
resourcesPath?: string
appPath?: string
}): string | null {
const isPackaged = options?.isPackaged ?? app.isPackaged
const resourcesPath = options?.resourcesPath ?? process.resourcesPath
const appPath = options?.appPath ?? app.getAppPath()

if (isPackaged) {
const packagedRoot = join(resourcesPath, 'orca-skills')
return existsSync(packagedRoot) ? packagedRoot : null
}

// electron-vite may set getAppPath() to the project root or out/.
const candidates = [
join(appPath, 'skills'),
join(appPath, '..', 'skills'),
join(process.cwd(), 'skills')
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
return null
}
146 changes: 146 additions & 0 deletions src/main/skills/freshness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
checkOrcaSkillFreshness,
hashSkillMarkdown,
normalizeSkillMarkdownForHash
} from './freshness'

const tempDirs: string[] = []

afterEach(async () => {
// Temp dirs are disposable process artifacts; leave them for OS cleanup.
tempDirs.length = 0
})

async function makeTempDir(prefix: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), prefix))
tempDirs.push(dir)
return dir
}

async function writeSkill(root: string, skillName: string, body: string): Promise<string> {
const skillDir = join(root, skillName)
await mkdir(skillDir, { recursive: true })
const skillFilePath = join(skillDir, 'SKILL.md')
await writeFile(skillFilePath, body, 'utf8')
return skillFilePath
}

describe('skill freshness hashing', () => {
it('normalizes CRLF so Windows and Unix installs compare equal', () => {
expect(hashSkillMarkdown('a\r\nb\n')).toBe(hashSkillMarkdown('a\nb\n'))
expect(normalizeSkillMarkdownForHash('x\r\ny')).toBe('x\ny')
})
})

describe('checkOrcaSkillFreshness', () => {
it('marks an installed skill outdated when content diverges from the reference', async () => {
const referenceRoot = await makeTempDir('orca-skill-ref-')
const homeDir = await makeTempDir('orca-skill-home-')
await writeSkill(referenceRoot, 'orca-cli', '---\nname: orca-cli\n---\nexpected\n')
await mkdir(join(homeDir, '.agents', 'skills'), { recursive: true })
await writeSkill(
join(homeDir, '.agents', 'skills'),
'orca-cli',
'---\nname: orca-cli\n---\nstale\n'
)

const result = await checkOrcaSkillFreshness({
repos: [],
homeDir,
referenceRoot
})

const orcaCli = result.skills.find((skill) => skill.skillName === 'orca-cli')
expect(orcaCli?.status).toBe('outdated')
expect(orcaCli?.expectedHash).toBeTruthy()
expect(orcaCli?.installedHash).toBeTruthy()
expect(orcaCli?.expectedHash).not.toBe(orcaCli?.installedHash)
})

it('marks an installed skill current when content matches the reference', async () => {
const referenceRoot = await makeTempDir('orca-skill-ref-')
const homeDir = await makeTempDir('orca-skill-home-')
const content = '---\nname: orchestration\n---\ncurrent body\n'
await writeSkill(referenceRoot, 'orchestration', content)
await mkdir(join(homeDir, '.agents', 'skills'), { recursive: true })
await writeSkill(join(homeDir, '.agents', 'skills'), 'orchestration', content)

const result = await checkOrcaSkillFreshness({
repos: [],
homeDir,
referenceRoot
})

const orchestration = result.skills.find((skill) => skill.skillName === 'orchestration')
expect(orchestration?.status).toBe('current')
})

it('marks a managed skill missing when it is not installed', async () => {
const referenceRoot = await makeTempDir('orca-skill-ref-')
const homeDir = await makeTempDir('orca-skill-home-')
await writeSkill(referenceRoot, 'computer-use', '---\nname: computer-use\n---\nbody\n')
await mkdir(join(homeDir, '.agents', 'skills'), { recursive: true })

const result = await checkOrcaSkillFreshness({
repos: [],
homeDir,
referenceRoot
})

const computerUse = result.skills.find((skill) => skill.skillName === 'computer-use')
expect(computerUse?.status).toBe('missing')
})

it('returns unknown when an installed skill file cannot be hashed', async () => {
const referenceRoot = await makeTempDir('orca-skill-ref-')
const homeDir = await makeTempDir('orca-skill-home-')
await writeSkill(referenceRoot, 'orca-cli', '---\nname: orca-cli\n---\nexpected\n')
await mkdir(join(homeDir, '.agents', 'skills', 'orca-cli'), { recursive: true })
// Directory where SKILL.md should be — leave it empty so discovery still
// finds the skill folder via other roots? Discovery needs SKILL.md present.
// Create a skill file then make it unreadable by replacing with a directory.
const skillFilePath = join(homeDir, '.agents', 'skills', 'orca-cli', 'SKILL.md')
await writeFile(skillFilePath, '---\nname: orca-cli\n---\nbody\n', 'utf8')
// Oversized content past the 256 KiB guard.
await writeFile(skillFilePath, 'x'.repeat(300 * 1024), 'utf8')

const result = await checkOrcaSkillFreshness({
repos: [],
homeDir,
referenceRoot
})

const orcaCli = result.skills.find((skill) => skill.skillName === 'orca-cli')
expect(orcaCli?.status).toBe('unknown')
})

it('ignores repo-local skill copies when deciding global freshness', async () => {
const referenceRoot = await makeTempDir('orca-skill-ref-')
const homeDir = await makeTempDir('orca-skill-home-')
const repoDir = await makeTempDir('orca-skill-repo-')
const content = '---\nname: orca-cli\n---\nreference\n'
await writeSkill(referenceRoot, 'orca-cli', content)
await mkdir(join(homeDir, '.agents', 'skills'), { recursive: true })
// Stale copy only under the repo — must not count as a global install.
await mkdir(join(repoDir, '.agents', 'skills'), { recursive: true })
await writeSkill(
join(repoDir, '.agents', 'skills'),
'orca-cli',
'---\nname: orca-cli\n---\nstale\n'
)

const result = await checkOrcaSkillFreshness({
repos: [],
homeDir,
cwd: repoDir,
referenceRoot
})

const orcaCli = result.skills.find((skill) => skill.skillName === 'orca-cli')
expect(orcaCli?.status).toBe('missing')
})
})
Loading
Loading