Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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 without walking project repos', async () => {
const handler = getFreshnessHandler()
await handler(null, undefined)
expect(checkFreshnessMock).toHaveBeenCalledWith({ homeDir: undefined, repos: [] })
})

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

Expand Down
55 changes: 38 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,46 @@ 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)
// Why: freshness only hashes home installs — skip repo trees entirely (M4).
return checkOrcaSkillFreshness({
homeDir: context.homeDir,
repos: []
})
}
)
}
7 changes: 7 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,11 @@ export const SKILL_METHODS: RpcMethod[] = [
? discoverSkills({ repos: [], cwd })
: discoverSkills({ repos: runtime.listRepos() })
}
}),
defineMethod({
name: 'skills.checkFreshness',
params: SkillDiscoveryParams,
// Why: freshness only needs home skill roots; never walk project repos.
handler: async () => checkOrcaSkillFreshness({ repos: [] })
})
]
63 changes: 63 additions & 0 deletions src/main/skills/bundled-skills-root.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'

const { resolveBundledSkillsRoot } = await import('./bundled-skills-root')

vi.mock('electron', () => ({
app: {
isPackaged: false,
getAppPath: () => tmpdir()
}
}))

describe('resolveBundledSkillsRoot', () => {
const created: string[] = []

afterEach(async () => {
await Promise.all(created.map((dir) => rm(dir, { recursive: true, force: true })))
created.length = 0
})

it('returns packaged resources/orca-skills when present', async () => {
const resourcesPath = await mkdtemp(join(tmpdir(), 'orca-resources-'))
created.push(resourcesPath)
const skillsRoot = join(resourcesPath, 'orca-skills')
await mkdir(skillsRoot, { recursive: true })
await writeFile(join(skillsRoot, 'README'), 'x', 'utf8')

expect(
resolveBundledSkillsRoot({
isPackaged: true,
resourcesPath,
appPath: tmpdir()
})
).toBe(skillsRoot)
})

it('returns null when packaged resources are missing', () => {
expect(
resolveBundledSkillsRoot({
isPackaged: true,
resourcesPath: join(tmpdir(), 'missing-resources-dir'),
appPath: tmpdir()
})
).toBeNull()
})

it('falls back to a skills directory next to appPath in dev', async () => {
const appPath = await mkdtemp(join(tmpdir(), 'orca-app-'))
created.push(appPath)
const skillsRoot = join(appPath, 'skills')
await mkdir(skillsRoot, { recursive: true })

expect(
resolveBundledSkillsRoot({
isPackaged: false,
resourcesPath: join(tmpdir(), 'unused'),
appPath
})
).toBe(skillsRoot)
})
})
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
}
1 change: 1 addition & 0 deletions src/main/skills/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export async function discoverSkills(args: {
repos?: Repo[]
homeDir?: string
cwd?: string
includeProjectRoots?: boolean
}): Promise<SkillDiscoveryResult> {
const roots = buildSkillDiscoverySources(args)
const sources: SkillDiscoverySource[] = []
Expand Down
Loading