mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
Add safe skill freshness detection and update rail (#8637)
* Add safe skill freshness detection * Accept observed copy-mode rail outcomes * chore(skills): regenerate snapshot artifacts for the merged guide content The rebase onto main picked up the reviewed guide fixes (#8624), so the current manifest hashes and a new appended snapshot generation must match those bytes; the registry keeps all prior snapshots so existing installs classify as outdated rather than unrecognized. * fix(skills): canonicalize snapshot file order and guard released history Historical snapshots kept git ls-tree byte-order while the working-tree walk and runtime observation use the sorted depth-first order, so any future multi-file skill would misclassify older installs as unrecognized and churn spurious registry revisions; all producers now share one canonical order (no digest changes for today's single-file packages). Also rejects executable files from shipped skills (Windows observation cannot see execute bits, which would misclassify pristine Windows installs) and adds an explicit append-only invariant for released snapshots so a generation-logic change cannot rewrite them silently. * fix(skills): throttle focus rescans and correct self-blocked placement copy Every window focus re-read and re-hashed all installed packages, and the nudge and panel each forced their own trailing rescan for one event; a 15s cooldown plus a shared invalidation latch keep one bounded scan per event while install-change events stay immediate. Bundle artifacts are now loaded once per run instead of re-parsed on every scan. A read-only or otherwise unsupported outdated placement now explains that it blocks itself instead of blaming a phantom sibling placement; the supported topology set moved to shared so eligibility and copy cannot drift. * feat(skills): move freshness surfacing to a lingering toast and update modal The Skills page has been unreachable since its toolbox menu entry was removed (#4535), so surfacing freshness there buried the feature behind its own nudge. The nudge now lingers until acted on (ignoring it records nothing; only the explicit close persists dismissal keys) and opens an update modal hosting the pre-filled editable terminal, an honest current/blocked summary, and the per-placement rows in a collapsed Details section. A compact 'Check for skill updates' row in CLI settings is the manual re-entry point. Skills page restored to main; design-doc surfacing section records the venue decision. * fix(skills): mount update dialog inside the link-routing provider and fold freshness into the setup rails The dialog hosts a live terminal pane that requires the link-routing preference context; mounted outside the provider it crashed the renderer the moment an eligible update existed (caught by live QA — unit tests mock the terminal). It now mounts inside the provider behind its own recoverable boundary. The separate 'Check for skill updates' settings button is gone: the setup rails' own pill now carries freshness (Update available / Up to date, falling back to Installed for blocked or unrecognized copies and for non-local runtimes the local-only scan cannot vouch for), and Re-check refreshes both installation detection and the freshness inventory. Wired for the CLI, Orchestration, Computer Use, and Per-Workspace Environments rails. * fix(skills): use the sleek scrollbar style in the update dialog * chore(skills): regenerate manifest for merged main (v1.4.142-rc.1) Main advanced to 1.4.142-rc.1 with a v1.4.141 release, so the embedded appVersion and release mapping were stale on the PR's merged tree. Only appVersion and the new release entry change; no snapshot digests move (released history preserved). * fix(skills): bound and batch freshness work * fix(skills): harden freshness integrity checks * fix(skills): accept observed copy topology outcomes * chore(skills): regenerate manifest for current main * fix(skills): preserve update terminal lifecycle * chore(skills): regenerate manifest for current main * fix(skills): fail closed on stale freshness scans * chore(skills): regenerate manifest for current main * fix(skills): preserve freshness safety under focus churn * feat(skills): group the update modal by skill with plain-language status The Update skills modal now lists only skills that will update or that can't (with why), grouped by skill with their install locations nested underneath — no more one row per placement. - Statuses collapse to "Update available" / "Can't update" at the skill level. - A location's problem is a chip (Duplicate, Unrecognized, Inaccessible, Read only, In a repo, External/Broken link, Plugin cache) with a hover tooltip that explains what it means for the user and what to do. - Up-to-date, unrecognized-only, and unreadable-only skills are hidden; a current/unrecognized/etc. location only appears when it explains a shown skill. - Copy is de-jargoned (drops "copy"/"placement"/"snapshot"/"official copy") and names the mechanism as the npx skills update command, not "Orca's update". - Rename the section to "Update details"; drop the unreachable newer-known state. Renderer-only: derivation is a pure module (groupSkillFreshness) with unit tests; no IPC or main-process change. Locales updated for all five languages. * chore(skills): regenerate manifest for current main (v1.4.143-rc.0) * feat(skills): don't let a duplicate block the update; clearer skipped copy - Eligibility: a clean standalone duplicate no longer poisons the whole name — the canonical copy still updates and the duplicate is flagged; a duplicate-only skill stays unoffered. - Update modal: "Can't update" -> "Skipped" with a reason-specific sentence (edited/read-only/in-a-repo/plugin/link); chips describe only the location state; footer "Check now" -> "Re-check". - Settings sidebar nav pills go amber "Update available" when a skill is updatable, matching the setup cards. - Localized new strings across en/es/ja/ko/zh. * chore(skills): regenerate manifest for merged main (v1.4.144-rc.1)
This commit is contained in:
@@ -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,
|
||||
inventorySkillFreshnessMock,
|
||||
getDefaultWslDistroMock,
|
||||
getWslHomeMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
discoverSkillsMock: vi.fn(),
|
||||
inventorySkillFreshnessMock: vi.fn(),
|
||||
getDefaultWslDistroMock: vi.fn(),
|
||||
getWslHomeMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
@@ -19,6 +24,10 @@ vi.mock('../skills/discovery', () => ({
|
||||
discoverSkills: discoverSkillsMock
|
||||
}))
|
||||
|
||||
vi.mock('../skills/skill-freshness-inventory', () => ({
|
||||
inventorySkillFreshness: inventorySkillFreshnessMock
|
||||
}))
|
||||
|
||||
vi.mock('../wsl', () => ({
|
||||
getDefaultWslDistro: getDefaultWslDistroMock,
|
||||
getWslHome: getWslHomeMock
|
||||
@@ -39,6 +48,12 @@ describe('registerSkillsHandlers', () => {
|
||||
getDefaultWslDistroMock.mockReset()
|
||||
getWslHomeMock.mockReset()
|
||||
discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 })
|
||||
inventorySkillFreshnessMock.mockResolvedValue({
|
||||
schemaVersion: 1,
|
||||
installations: [],
|
||||
eligibleUpdateNames: [],
|
||||
scannedAt: 1
|
||||
})
|
||||
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice')
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
@@ -61,6 +76,17 @@ 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:freshnessInventory'
|
||||
)
|
||||
if (!call) {
|
||||
throw new Error('skills:freshnessInventory handler was not registered')
|
||||
}
|
||||
return call[1] as (_event: unknown) => Promise<unknown>
|
||||
}
|
||||
|
||||
it('uses host skill discovery when resolved project runtime overrides stale WSL target state', async () => {
|
||||
const handler = getDiscoverHandler()
|
||||
|
||||
@@ -136,4 +162,13 @@ describe('registerSkillsHandlers', () => {
|
||||
).rejects.toThrow('Project runtime requires repair before skill discovery')
|
||||
expect(discoverSkillsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps freshness inventory local and read-only over known repositories', async () => {
|
||||
const handler = getFreshnessHandler()
|
||||
|
||||
await handler(null)
|
||||
|
||||
expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({ repos })
|
||||
expect(getWslHomeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,9 @@ import { ipcMain } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import { discoverSkills } from '../skills/discovery'
|
||||
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills'
|
||||
import type { SkillFreshnessInventory } from '../../shared/skill-freshness'
|
||||
import { getDefaultWslDistro, getWslHome } from '../wsl'
|
||||
import { inventorySkillFreshness } from '../skills/skill-freshness-inventory'
|
||||
|
||||
type SkillDiscoveryRuntimeTarget =
|
||||
| { runtime: 'host' }
|
||||
@@ -55,4 +57,10 @@ export function registerSkillsHandlers(store: Store): void {
|
||||
return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() })
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('skills:freshnessInventory', async (): Promise<SkillFreshnessInventory> => {
|
||||
// Why: the update command targets this machine's global homes. WSL and SSH
|
||||
// inventories stay out until their installer rail has an equivalent proof.
|
||||
return inventorySkillFreshness({ repos: store.getRepos() })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -83,6 +83,41 @@ describe('skill discovery', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not add runtime-owned repository paths to local scan roots', () => {
|
||||
const runtimeRepo = makeRepo('/runtime/repo')
|
||||
runtimeRepo.executionHostId = 'runtime:environment-1'
|
||||
|
||||
const roots = buildSkillDiscoverySources({
|
||||
homeDir: '/home/test',
|
||||
cwd: '/workspace/current',
|
||||
repos: [runtimeRepo]
|
||||
})
|
||||
|
||||
expect(roots.map((root) => root.path.replace(/\\/g, '/'))).not.toContain(
|
||||
'/runtime/repo/.agents/skills'
|
||||
)
|
||||
})
|
||||
|
||||
it('can exclude the implicit cwd without excluding explicit local repositories', () => {
|
||||
const defaultRoots = buildSkillDiscoverySources({
|
||||
homeDir: '/home/test',
|
||||
cwd: '/workspace/current',
|
||||
repos: [makeRepo('/workspace/known')]
|
||||
})
|
||||
const explicitRoots = buildSkillDiscoverySources({
|
||||
homeDir: '/home/test',
|
||||
cwd: '/workspace/current',
|
||||
repos: [makeRepo('/workspace/known')],
|
||||
includeCwd: false
|
||||
})
|
||||
|
||||
const normalizedDefaultPaths = defaultRoots.map((root) => root.path.replace(/\\/g, '/'))
|
||||
const normalizedExplicitPaths = explicitRoots.map((root) => root.path.replace(/\\/g, '/'))
|
||||
expect(normalizedDefaultPaths).toContain('/workspace/current/.agents/skills')
|
||||
expect(normalizedExplicitPaths).not.toContain('/workspace/current/.agents/skills')
|
||||
expect(normalizedExplicitPaths).toContain('/workspace/known/.agents/skills')
|
||||
})
|
||||
|
||||
it('discovers skill packages through symlinked skill directories', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
|
||||
const home = join(root, 'home')
|
||||
|
||||
@@ -234,6 +234,7 @@ export async function discoverSkills(args: {
|
||||
repos?: Repo[]
|
||||
homeDir?: string
|
||||
cwd?: string
|
||||
includeCwd?: boolean
|
||||
}): Promise<SkillDiscoveryResult> {
|
||||
const roots = buildSkillDiscoverySources(args)
|
||||
const sources: SkillDiscoverySource[] = []
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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 { loadSkillBundleArtifacts } from './skill-bundle-artifacts'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
|
||||
})
|
||||
|
||||
describe('skill bundle artifacts', () => {
|
||||
it('rejects malformed nested release entries before building provenance', async () => {
|
||||
const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-'))
|
||||
temporaryDirectories.push(resourceRoot)
|
||||
const target = join(resourceRoot, 'skills')
|
||||
const source = resolve('resources', 'skills')
|
||||
await mkdir(target, { recursive: true })
|
||||
const [manifest, registry, releaseMapping] = await Promise.all(
|
||||
['current-manifest.json', 'snapshot-registry.json', 'release-mapping.json'].map((name) =>
|
||||
readFile(join(source, name), 'utf8')
|
||||
)
|
||||
)
|
||||
const malformedMapping = JSON.parse(releaseMapping)
|
||||
malformedMapping.releases[0] = { appVersion: 'invalid' }
|
||||
await Promise.all([
|
||||
writeFile(join(target, 'current-manifest.json'), manifest),
|
||||
writeFile(join(target, 'snapshot-registry.json'), registry),
|
||||
writeFile(join(target, 'release-mapping.json'), JSON.stringify(malformedMapping))
|
||||
])
|
||||
|
||||
await expect(loadSkillBundleArtifacts(resourceRoot)).rejects.toThrow(
|
||||
'Invalid skill release mapping'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { app } from 'electron'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { z, type ZodType } from 'zod'
|
||||
import type {
|
||||
SkillBundleManifest,
|
||||
SkillKnownSnapshot,
|
||||
SkillReleaseMapping,
|
||||
SkillSnapshotRegistry
|
||||
} from '../../shared/skill-freshness'
|
||||
|
||||
export type SkillBundleArtifacts = {
|
||||
manifest: SkillBundleManifest
|
||||
registry: SkillSnapshotRegistry
|
||||
releaseMapping: SkillReleaseMapping
|
||||
knownSnapshots: Record<string, SkillKnownSnapshot[]>
|
||||
releasedAppVersions: Record<string, Record<number, string>>
|
||||
}
|
||||
|
||||
const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/)
|
||||
const snapshotShape = {
|
||||
releaseRevision: z.number().int().positive(),
|
||||
packageDigest: sha256Schema,
|
||||
gitTreeSha: z.string().regex(/^[a-f0-9]{40}$/),
|
||||
files: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
size: z.number().int().nonnegative(),
|
||||
executable: z.boolean(),
|
||||
classification: z.enum(['text', 'binary']),
|
||||
exactSha256: sha256Schema,
|
||||
textNormalizedSha256: sha256Schema.nullable(),
|
||||
identitySha256: sha256Schema
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.min(1)
|
||||
}
|
||||
const knownSnapshotSchema = z.object(snapshotShape).strict()
|
||||
const manifestSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
appVersion: z.string().min(1),
|
||||
skills: z.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/),
|
||||
sourcePath: z.string().min(1),
|
||||
appVersion: z.string().min(1),
|
||||
...snapshotShape
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
})
|
||||
.strict()
|
||||
const registrySchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
skills: z.record(z.string().min(1), z.array(knownSnapshotSchema).min(1))
|
||||
})
|
||||
.strict()
|
||||
const releaseMappingSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
releases: z.array(
|
||||
z
|
||||
.object({
|
||||
appVersion: z.string().min(1),
|
||||
skills: z.record(z.string().min(1), z.number().int().positive())
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
})
|
||||
.strict()
|
||||
|
||||
function parseArtifact<T>(schema: ZodType<T>, value: unknown, label: string): T {
|
||||
const result = schema.safeParse(value)
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid ${label}: ${result.error.issues[0]?.message ?? 'schema mismatch'}`)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
const artifactsByResourceRoot = new Map<string, Promise<SkillBundleArtifacts>>()
|
||||
|
||||
// Why: the artifacts ship with the binary and never change within a run, while
|
||||
// focus-triggered rescans would otherwise re-read and re-parse them every time.
|
||||
export function loadSkillBundleArtifacts(
|
||||
resourceRoot = app.isPackaged ? process.resourcesPath : resolve(process.cwd(), 'resources')
|
||||
): Promise<SkillBundleArtifacts> {
|
||||
const cached = artifactsByResourceRoot.get(resourceRoot)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const loading = readSkillBundleArtifacts(resourceRoot)
|
||||
artifactsByResourceRoot.set(resourceRoot, loading)
|
||||
loading.catch(() => {
|
||||
artifactsByResourceRoot.delete(resourceRoot)
|
||||
})
|
||||
return loading
|
||||
}
|
||||
|
||||
async function readSkillBundleArtifacts(resourceRoot: string): Promise<SkillBundleArtifacts> {
|
||||
const bundleRoot = join(resourceRoot, 'skills')
|
||||
const [manifestValue, registryValue, releaseMappingValue] = await Promise.all([
|
||||
readFile(join(bundleRoot, 'current-manifest.json'), 'utf8').then(JSON.parse),
|
||||
readFile(join(bundleRoot, 'snapshot-registry.json'), 'utf8').then(JSON.parse),
|
||||
readFile(join(bundleRoot, 'release-mapping.json'), 'utf8').then(JSON.parse)
|
||||
])
|
||||
const manifest: SkillBundleManifest = parseArtifact(
|
||||
manifestSchema,
|
||||
manifestValue,
|
||||
'skill bundle manifest'
|
||||
)
|
||||
const registry: SkillSnapshotRegistry = parseArtifact(
|
||||
registrySchema,
|
||||
registryValue,
|
||||
'skill snapshot registry'
|
||||
)
|
||||
const releaseMapping: SkillReleaseMapping = parseArtifact(
|
||||
releaseMappingSchema,
|
||||
releaseMappingValue,
|
||||
'skill release mapping'
|
||||
)
|
||||
for (const current of manifest.skills) {
|
||||
if (
|
||||
current.appVersion !== manifest.appVersion ||
|
||||
!registry.skills[current.name]?.some(
|
||||
(snapshot) =>
|
||||
snapshot.releaseRevision === current.releaseRevision &&
|
||||
snapshot.packageDigest === current.packageDigest
|
||||
)
|
||||
) {
|
||||
throw new Error(`Inconsistent current skill snapshot: ${current.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const releasedAppVersions: Record<string, Record<number, string>> = {}
|
||||
for (const release of releaseMapping.releases) {
|
||||
for (const [name, revision] of Object.entries(release.skills)) {
|
||||
if (!registry.skills[name]?.some((snapshot) => snapshot.releaseRevision === revision)) {
|
||||
throw new Error(`Unknown released skill revision: ${name}@${revision}`)
|
||||
}
|
||||
releasedAppVersions[name] ??= {}
|
||||
releasedAppVersions[name][revision] ??= release.appVersion
|
||||
}
|
||||
}
|
||||
for (const current of manifest.skills) {
|
||||
releasedAppVersions[current.name] ??= {}
|
||||
releasedAppVersions[current.name][current.releaseRevision] = current.appVersion
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
registry,
|
||||
releaseMapping,
|
||||
// Why: newer-known classification needs every identity packaged with this
|
||||
// build, while release mapping remains the provenance record for shipped revisions.
|
||||
knownSnapshots: registry.skills,
|
||||
releasedAppVersions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
const MAX_CONCURRENT_SKILL_CANDIDATES = 4
|
||||
|
||||
export async function runSkillCandidateTasks<T>(
|
||||
tasks: readonly (() => Promise<T>)[]
|
||||
): Promise<T[]> {
|
||||
const results = Array.from<T>({ length: tasks.length })
|
||||
let nextIndex = 0
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
for (;;) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
if (index >= tasks.length) {
|
||||
return
|
||||
}
|
||||
results[index] = await tasks[index]()
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(MAX_CONCURRENT_SKILL_CANDIDATES, tasks.length) }, () => worker())
|
||||
)
|
||||
return results
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { homedir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import type { SkillDiscoverySource, SkillProvider, SkillSourceKind } from '../../shared/skills'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
|
||||
export type SkillScanRoot = Omit<SkillDiscoverySource, 'exists' | 'skippedReason'>
|
||||
|
||||
@@ -25,6 +26,7 @@ export function buildSkillDiscoverySources(
|
||||
homeDir?: string
|
||||
cwd?: string
|
||||
repos?: Repo[]
|
||||
includeCwd?: boolean
|
||||
} = {}
|
||||
): SkillScanRoot[] {
|
||||
const home = args.homeDir ?? homedir()
|
||||
@@ -62,12 +64,16 @@ export function buildSkillDiscoverySources(
|
||||
|
||||
const projectPaths = new Set<string>()
|
||||
for (const repo of args.repos ?? []) {
|
||||
if (repo.connectionId) {
|
||||
// Why: runtime-owned repos can have no legacy connectionId while their
|
||||
// paths are meaningful only on a remote host.
|
||||
if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) {
|
||||
continue
|
||||
}
|
||||
projectPaths.add(repo.path)
|
||||
}
|
||||
projectPaths.add(cwd)
|
||||
if (args.includeCwd !== false) {
|
||||
projectPaths.add(cwd)
|
||||
}
|
||||
|
||||
for (const repoPath of projectPaths) {
|
||||
const label = `Repo ${basename(repoPath)}`
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildTargetedSkillUpdateCommand,
|
||||
type SkillFreshnessInstallation
|
||||
} from '../../shared/skill-freshness'
|
||||
import { eligibleSkillUpdateNames } from './skill-freshness-eligibility'
|
||||
|
||||
function placement(
|
||||
name: string,
|
||||
overrides: Partial<SkillFreshnessInstallation> = {}
|
||||
): SkillFreshnessInstallation {
|
||||
return {
|
||||
id: `${name}-${overrides.rootId ?? 'home-agents'}`,
|
||||
name,
|
||||
rootId: 'home-agents',
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Agent skills home',
|
||||
unresolvedPath: `/home/.agents/skills/${name}`,
|
||||
resolvedPath: `/home/.agents/skills/${name}`,
|
||||
physicalIdentity: `physical-${name}`,
|
||||
topology: 'canonical-copy',
|
||||
status: 'outdated',
|
||||
installedReleaseRevision: 1,
|
||||
installedAppVersion: '1.0.0',
|
||||
currentReleaseRevision: 2,
|
||||
currentPackageDigest: 'current',
|
||||
currentAppVersion: '2.0.0',
|
||||
observedPackageDigest: 'old',
|
||||
errorCategory: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('skill freshness name-scoped update eligibility', () => {
|
||||
it('offers a name when at least one supported placement is outdated and all are official', () => {
|
||||
expect(
|
||||
eligibleSkillUpdateNames([
|
||||
placement('orca-cli'),
|
||||
placement('orca-cli', {
|
||||
id: 'orca-cli-claude',
|
||||
rootId: 'home-claude',
|
||||
topology: 'provider-alias',
|
||||
status: 'current'
|
||||
})
|
||||
])
|
||||
).toEqual(['orca-cli'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['newer-known', 'independent-copy'],
|
||||
['unrecognized', 'independent-copy'],
|
||||
['inaccessible', 'broken-link'],
|
||||
['current', 'external-link'],
|
||||
['current', 'read-only'],
|
||||
['current', 'repo-scope'],
|
||||
['current', 'plugin-cache']
|
||||
] as const)('poisons a name for a %s placement in %s topology', (status, topology) => {
|
||||
expect(
|
||||
eligibleSkillUpdateNames([
|
||||
placement('orca-cli'),
|
||||
placement('orca-cli', { id: `poison-${status}-${topology}`, status, topology })
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('still updates the canonical copy when a clean standalone duplicate exists', () => {
|
||||
// Why: a duplicate no longer omits the whole name — the canonical copy converges
|
||||
// and the duplicate row is flagged as maybe-not-reached rather than blocking.
|
||||
expect(
|
||||
eligibleSkillUpdateNames([
|
||||
placement('orca-cli'),
|
||||
placement('orca-cli', {
|
||||
id: 'orca-cli-gemini',
|
||||
rootId: 'home-gemini',
|
||||
unresolvedPath: '/home/.gemini/skills/orca-cli',
|
||||
resolvedPath: '/home/.gemini/skills/orca-cli',
|
||||
topology: 'independent-copy',
|
||||
status: 'current'
|
||||
})
|
||||
])
|
||||
).toEqual(['orca-cli'])
|
||||
})
|
||||
|
||||
it('does not offer a skill that exists only as a standalone copy', () => {
|
||||
// Why: with no canonical or alias to anchor `--global`, the command has no
|
||||
// reliable target, so a duplicate-only skill stays unoffered.
|
||||
expect(
|
||||
eligibleSkillUpdateNames([
|
||||
placement('orca-cli', {
|
||||
rootId: 'home-gemini',
|
||||
unresolvedPath: '/home/.gemini/skills/orca-cli',
|
||||
resolvedPath: '/home/.gemini/skills/orca-cli',
|
||||
topology: 'independent-copy',
|
||||
status: 'outdated'
|
||||
})
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('does not offer an all-current name or let another safe name hide a poisoned one', () => {
|
||||
expect(
|
||||
eligibleSkillUpdateNames([
|
||||
placement('computer-use', { status: 'current' }),
|
||||
placement('orchestration'),
|
||||
placement('orchestration', {
|
||||
id: 'orchestration-project',
|
||||
status: 'unrecognized',
|
||||
topology: 'repo-scope'
|
||||
})
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('builds only an explicit, deterministic global command', () => {
|
||||
expect(buildTargetedSkillUpdateCommand(['orchestration', 'orca-cli', 'orca-cli'])).toBe(
|
||||
'npx skills update orca-cli orchestration --global'
|
||||
)
|
||||
expect(buildTargetedSkillUpdateCommand([])).toBeNull()
|
||||
expect(buildTargetedSkillUpdateCommand(['orca-cli;echo unsafe'])).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES,
|
||||
type SkillFreshnessInstallation
|
||||
} from '../../shared/skill-freshness'
|
||||
|
||||
export function eligibleSkillUpdateNames(
|
||||
installations: readonly SkillFreshnessInstallation[]
|
||||
): string[] {
|
||||
const byName = new Map<string, SkillFreshnessInstallation[]>()
|
||||
for (const installation of installations) {
|
||||
const entries = byName.get(installation.name) ?? []
|
||||
entries.push(installation)
|
||||
byName.set(installation.name, entries)
|
||||
}
|
||||
|
||||
const eligible: string[] = []
|
||||
for (const [name, entries] of byName) {
|
||||
const hasOutdated = entries.some((entry) => entry.status === 'outdated')
|
||||
const everyPlacementIsOfficialAndUpdatable = entries.every(
|
||||
(entry) =>
|
||||
(entry.status === 'current' || entry.status === 'outdated') &&
|
||||
// Why: the rail reliably converges the canonical copy and its symlink aliases.
|
||||
// A standalone duplicate no longer blocks the whole name — the canonical copy
|
||||
// still updates and the duplicate row is flagged as maybe-not-reached — while
|
||||
// data-loss topologies (unrecognized/read-only/etc.) still poison via these checks.
|
||||
(SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) ||
|
||||
entry.topology === 'independent-copy') &&
|
||||
Boolean(entry.resolvedPath && entry.physicalIdentity)
|
||||
)
|
||||
// Why: only offer the global command when a reliably-convergent placement anchors it,
|
||||
// so a skill that exists solely as a standalone copy never draws a command that could
|
||||
// no-op or error against a canonical install that isn't there.
|
||||
const hasReliableTarget = entries.some((entry) =>
|
||||
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology)
|
||||
)
|
||||
if (hasOutdated && everyPlacementIsOfficialAndUpdatable && hasReliableTarget) {
|
||||
eligible.push(name)
|
||||
}
|
||||
}
|
||||
return eligible.sort((left, right) => left.localeCompare(right, 'en'))
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
boundRepositorySkillRoots,
|
||||
MAXIMUM_REPOSITORY_SKILL_ROOTS
|
||||
} from './skill-freshness-inventory'
|
||||
import type { SkillScanRoot } from './skill-discovery-sources'
|
||||
|
||||
describe('skill freshness inventory limits', () => {
|
||||
it('caps repository roots before creating candidate probes', () => {
|
||||
const roots = Array.from(
|
||||
{ length: MAXIMUM_REPOSITORY_SKILL_ROOTS + 3 },
|
||||
(_, index): SkillScanRoot => ({
|
||||
id: `repo-${index}`,
|
||||
label: `Repo ${index}`,
|
||||
path: `/repo-${index}/.agents/skills`,
|
||||
sourceKind: 'repo',
|
||||
providers: ['agent-skills']
|
||||
})
|
||||
)
|
||||
|
||||
const bounded = boundRepositorySkillRoots(roots)
|
||||
|
||||
expect(bounded.scanned).toHaveLength(MAXIMUM_REPOSITORY_SKILL_ROOTS)
|
||||
expect(bounded.omitted).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,363 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import type {
|
||||
SkillBundleFileIdentity,
|
||||
SkillCurrentBundleEntry,
|
||||
SkillKnownSnapshot
|
||||
} from '../../shared/skill-freshness'
|
||||
import {
|
||||
inventorySkillFreshness,
|
||||
MAXIMUM_REPOSITORY_SKILL_ROOTS
|
||||
} from './skill-freshness-inventory'
|
||||
import { describeObservedSkillFile, skillPackageDigest } from './skill-package-identity'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
function snapshot(releaseRevision: number, markdown: string): SkillKnownSnapshot {
|
||||
const observed = describeObservedSkillFile('SKILL.md', Buffer.from(markdown), false)
|
||||
const file: SkillBundleFileIdentity = {
|
||||
path: observed.path,
|
||||
size: observed.size,
|
||||
executable: observed.executable,
|
||||
classification: observed.classification,
|
||||
exactSha256: observed.exactSha256,
|
||||
textNormalizedSha256: observed.textNormalizedSha256,
|
||||
identitySha256: observed.identitySha256
|
||||
}
|
||||
return {
|
||||
releaseRevision,
|
||||
packageDigest: skillPackageDigest([file]),
|
||||
gitTreeSha: releaseRevision.toString(16).padStart(40, '0'),
|
||||
files: [file]
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-inventory-'))
|
||||
temporaryDirectories.push(root)
|
||||
const homeDir = join(root, 'home')
|
||||
const resourceRoot = join(root, 'resources')
|
||||
const skillResourceRoot = join(resourceRoot, 'skills')
|
||||
await mkdir(skillResourceRoot, { recursive: true })
|
||||
|
||||
const oldMarkdown = '---\nname: orca-cli\ndescription: Old official guide.\n---\n\n# Old\n'
|
||||
const currentMarkdown =
|
||||
'---\nname: orca-cli\ndescription: Current official guide.\n---\n\n# Current\n'
|
||||
const newerMarkdown = '---\nname: orca-cli\ndescription: Newer official guide.\n---\n\n# Newer\n'
|
||||
const snapshots = [
|
||||
snapshot(1, oldMarkdown),
|
||||
snapshot(2, currentMarkdown),
|
||||
snapshot(3, newerMarkdown)
|
||||
]
|
||||
const current: SkillCurrentBundleEntry = {
|
||||
name: 'orca-cli',
|
||||
sourcePath: 'skills/orca-cli',
|
||||
appVersion: '2.0.0',
|
||||
...snapshots[1]
|
||||
}
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(skillResourceRoot, 'current-manifest.json'),
|
||||
`${JSON.stringify({ schemaVersion: 1, appVersion: '2.0.0', skills: [current] }, null, 2)}\n`
|
||||
),
|
||||
writeFile(
|
||||
join(skillResourceRoot, 'snapshot-registry.json'),
|
||||
`${JSON.stringify({ schemaVersion: 1, skills: { 'orca-cli': snapshots } }, null, 2)}\n`
|
||||
),
|
||||
writeFile(
|
||||
join(skillResourceRoot, 'release-mapping.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
releases: [
|
||||
{ appVersion: '1.0.0', skills: { 'orca-cli': 1 } },
|
||||
{ appVersion: '2.0.0', skills: { 'orca-cli': 2 } },
|
||||
{ appVersion: '3.0.0', skills: { 'orca-cli': 3 } }
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
])
|
||||
|
||||
const writeSkill = async (rootPath: string, markdown: string): Promise<string> => {
|
||||
const directory = join(rootPath, 'orca-cli')
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'SKILL.md'), markdown)
|
||||
return directory
|
||||
}
|
||||
return {
|
||||
root,
|
||||
homeDir,
|
||||
resourceRoot,
|
||||
oldMarkdown,
|
||||
currentMarkdown,
|
||||
newerMarkdown,
|
||||
writeSkill
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
|
||||
})
|
||||
|
||||
describe('read-only skill freshness inventory', () => {
|
||||
it('offers an exact older official name only when all global placements are safe', async () => {
|
||||
const test = await fixture()
|
||||
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
})
|
||||
|
||||
expect(inventory.installations.map((entry) => entry.status)).toEqual(['outdated'])
|
||||
expect(inventory.installations[0]?.installedAppVersion).toBe('1.0.0')
|
||||
expect(inventory.eligibleUpdateNames).toEqual(['orca-cli'])
|
||||
})
|
||||
|
||||
it('labels newer known and unrecognized bytes honestly without calling them modified', async () => {
|
||||
const test = await fixture()
|
||||
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.newerMarkdown)
|
||||
await test.writeSkill(
|
||||
join(test.homeDir, '.claude', 'skills'),
|
||||
'---\nname: orca-cli\ndescription: User copy.\n---\n'
|
||||
)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
})
|
||||
|
||||
expect(inventory.installations.map((entry) => entry.status)).toEqual([
|
||||
'newer-known',
|
||||
'unrecognized'
|
||||
])
|
||||
expect(inventory.eligibleUpdateNames).toEqual([])
|
||||
})
|
||||
|
||||
it('retains full-file identity without projecting unused metadata', async () => {
|
||||
const test = await fixture()
|
||||
const lateDescription = 'Description beyond the metadata parsing budget.'
|
||||
await test.writeSkill(
|
||||
join(test.homeDir, '.agents', 'skills'),
|
||||
`${' '.repeat(256 * 1024)}\n${lateDescription}`
|
||||
)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
})
|
||||
|
||||
expect(inventory.installations[0]).toMatchObject({
|
||||
status: 'unrecognized'
|
||||
})
|
||||
expect(inventory.installations[0]).not.toHaveProperty('description')
|
||||
expect(inventory.installations[0]?.observedPackageDigest).toMatch(/^[a-f0-9]{64}$/)
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'deduplicates a provider alias to the canonical copy',
|
||||
async () => {
|
||||
const test = await fixture()
|
||||
const canonical = await test.writeSkill(
|
||||
join(test.homeDir, '.agents', 'skills'),
|
||||
test.oldMarkdown
|
||||
)
|
||||
const claudeRoot = join(test.homeDir, '.claude', 'skills')
|
||||
await mkdir(claudeRoot, { recursive: true })
|
||||
await symlink(canonical, join(claudeRoot, 'orca-cli'))
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
})
|
||||
|
||||
expect(inventory.installations).toHaveLength(1)
|
||||
expect(inventory.installations[0]?.providers).toEqual(['agent-skills', 'claude'])
|
||||
expect(inventory.installations[0]?.topology).toBe('canonical-copy')
|
||||
expect(inventory.eligibleUpdateNames).toEqual(['orca-cli'])
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'deduplicates aliases within an unsupported topology without hiding its poison',
|
||||
async () => {
|
||||
const test = await fixture()
|
||||
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown)
|
||||
const shared = await test.writeSkill(join(test.root, 'shared'), test.currentMarkdown)
|
||||
const repos = await Promise.all(
|
||||
['one', 'two'].map(async (id) => {
|
||||
const repoPath = join(test.root, `repo-${id}`)
|
||||
const root = join(repoPath, '.agents', 'skills')
|
||||
await mkdir(root, { recursive: true })
|
||||
await symlink(shared, join(root, 'orca-cli'))
|
||||
return { id, path: repoPath } as unknown as Repo
|
||||
})
|
||||
)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
homeDir: test.homeDir,
|
||||
repos,
|
||||
resourceRoot: test.resourceRoot
|
||||
})
|
||||
|
||||
expect(
|
||||
inventory.installations.filter((entry) => entry.topology === 'repo-scope')
|
||||
).toHaveLength(1)
|
||||
expect(inventory.eligibleUpdateNames).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps inaccessible placements visible and lets them poison the name', async () => {
|
||||
const test = await fixture()
|
||||
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown)
|
||||
const inaccessiblePath = join(test.homeDir, '.codex', 'skills', 'orca-cli')
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot,
|
||||
candidateLstat: async (path) => {
|
||||
if (path === inaccessiblePath) {
|
||||
throw Object.assign(new Error('permission denied'), { code: 'EACCES' })
|
||||
}
|
||||
return import('node:fs/promises').then(({ lstat }) => lstat(path))
|
||||
}
|
||||
})
|
||||
|
||||
expect(inventory.installations.map((entry) => entry.status)).toEqual([
|
||||
'outdated',
|
||||
'inaccessible'
|
||||
])
|
||||
expect(inventory.eligibleUpdateNames).toEqual([])
|
||||
})
|
||||
|
||||
it('does not lose an inaccessible known repository placement', async () => {
|
||||
const test = await fixture()
|
||||
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown)
|
||||
const repoPath = join(test.root, 'repo')
|
||||
const inaccessiblePath = join(repoPath, '.agents', 'skills', 'orca-cli')
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
homeDir: test.homeDir,
|
||||
repos: [{ id: 'repo', path: repoPath }] as unknown as Repo[],
|
||||
resourceRoot: test.resourceRoot,
|
||||
candidateLstat: async (path) => {
|
||||
if (path === inaccessiblePath) {
|
||||
throw Object.assign(new Error('permission denied'), { code: 'EACCES' })
|
||||
}
|
||||
return import('node:fs/promises').then(({ lstat }) => lstat(path))
|
||||
}
|
||||
})
|
||||
|
||||
expect(inventory.installations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
unresolvedPath: inaccessiblePath,
|
||||
topology: 'repo-scope',
|
||||
status: 'inaccessible'
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(inventory.eligibleUpdateNames).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['repo', 'repo-scope'],
|
||||
['plugin', 'plugin-cache']
|
||||
] as const)(
|
||||
'keeps an official %s placement informational and name-poisoning',
|
||||
async (kind, topology) => {
|
||||
const test = await fixture()
|
||||
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown)
|
||||
let repos: Repo[] = []
|
||||
if (kind === 'repo') {
|
||||
const repoPath = join(test.root, 'repo')
|
||||
await test.writeSkill(join(repoPath, '.agents', 'skills'), test.currentMarkdown)
|
||||
repos = [{ id: 'repo', path: repoPath }] as unknown as Repo[]
|
||||
} else {
|
||||
await test.writeSkill(
|
||||
join(test.homeDir, '.codex', 'plugins', 'cache', 'vendor', 'skills'),
|
||||
test.currentMarkdown
|
||||
)
|
||||
}
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
homeDir: test.homeDir,
|
||||
repos,
|
||||
resourceRoot: test.resourceRoot
|
||||
})
|
||||
|
||||
expect(inventory.installations.some((entry) => entry.topology === topology)).toBe(true)
|
||||
expect(inventory.eligibleUpdateNames).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('accepts CRLF as the same official text identity', async () => {
|
||||
const test = await fixture()
|
||||
await test.writeSkill(
|
||||
join(test.homeDir, '.agents', 'skills'),
|
||||
test.oldMarkdown.replaceAll('\n', '\r\n')
|
||||
)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
})
|
||||
expect(inventory.installations[0]?.status).toBe('outdated')
|
||||
})
|
||||
|
||||
it('classifies exact current bytes as current when a later snapshot reuses them', async () => {
|
||||
const test = await fixture()
|
||||
const resourceRoot = join(test.resourceRoot, 'skills')
|
||||
const registryPath = join(resourceRoot, 'snapshot-registry.json')
|
||||
const registry = JSON.parse(await readFile(registryPath, 'utf8'))
|
||||
registry.skills['orca-cli'].push(snapshot(4, test.currentMarkdown))
|
||||
await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`)
|
||||
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
})
|
||||
|
||||
expect(inventory.installations[0]).toMatchObject({
|
||||
status: 'current',
|
||||
installedReleaseRevision: 2,
|
||||
installedAppVersion: '2.0.0'
|
||||
})
|
||||
})
|
||||
|
||||
it('withholds updates when stored repositories exceed the probe budget', async () => {
|
||||
const test = await fixture()
|
||||
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown)
|
||||
const repos = Array.from(
|
||||
{ length: MAXIMUM_REPOSITORY_SKILL_ROOTS / 2 + 1 },
|
||||
(_, index) => ({ id: `repo-${index}`, path: join(test.root, `repo-${index}`) }) as Repo
|
||||
)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
homeDir: test.homeDir,
|
||||
repos,
|
||||
resourceRoot: test.resourceRoot
|
||||
})
|
||||
|
||||
expect(inventory.installations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ errorCategory: 'repository-scan-limit', status: 'inaccessible' })
|
||||
])
|
||||
)
|
||||
expect(inventory.eligibleUpdateNames).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { lstat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import type {
|
||||
SkillFreshnessInstallation,
|
||||
SkillFreshnessInventory
|
||||
} from '../../shared/skill-freshness'
|
||||
import { buildSkillDiscoverySources, type SkillScanRoot } from './skill-discovery-sources'
|
||||
import { loadSkillBundleArtifacts } from './skill-bundle-artifacts'
|
||||
import { eligibleSkillUpdateNames } from './skill-freshness-eligibility'
|
||||
import { runSkillCandidateTasks } from './skill-candidate-concurrency'
|
||||
import {
|
||||
classifyHomeSkillCandidate,
|
||||
classifyUnsupportedSkillCandidate,
|
||||
dedupeSkillFreshnessPlacements,
|
||||
observeSkillFreshnessInstallation,
|
||||
type CandidateLstat
|
||||
} from './skill-freshness-placement-observation'
|
||||
import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan'
|
||||
|
||||
export const MAXIMUM_REPOSITORY_SKILL_ROOTS = 128
|
||||
|
||||
export function boundRepositorySkillRoots(roots: readonly SkillScanRoot[]): {
|
||||
scanned: SkillScanRoot[]
|
||||
omitted: SkillScanRoot[]
|
||||
} {
|
||||
return {
|
||||
scanned: roots.slice(0, MAXIMUM_REPOSITORY_SKILL_ROOTS),
|
||||
omitted: roots.slice(MAXIMUM_REPOSITORY_SKILL_ROOTS)
|
||||
}
|
||||
}
|
||||
|
||||
export async function inventorySkillFreshness(
|
||||
args: {
|
||||
homeDir?: string
|
||||
cwd?: string
|
||||
repos?: Repo[]
|
||||
resourceRoot?: string
|
||||
candidateLstat?: CandidateLstat
|
||||
} = {}
|
||||
): Promise<SkillFreshnessInventory> {
|
||||
const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
|
||||
const currentByName = new Map(artifacts.manifest.skills.map((skill) => [skill.name, skill]))
|
||||
const discoveryArgs = {
|
||||
homeDir: args.homeDir,
|
||||
cwd: args.cwd,
|
||||
repos: args.repos,
|
||||
// Why: freshness scans known repositories explicitly; treating the app's
|
||||
// launch cwd as another repo would create phantom poison placements.
|
||||
includeCwd: false
|
||||
}
|
||||
const roots = buildSkillDiscoverySources(discoveryArgs)
|
||||
const homeRoots = roots.filter((root) => root.sourceKind === 'home')
|
||||
const allRepoRoots = roots.filter((root) => root.sourceKind === 'repo')
|
||||
const { scanned: repoRoots, omitted: omittedRepoRoots } = boundRepositorySkillRoots(allRepoRoots)
|
||||
const pluginRoots = roots.filter((root) => root.sourceKind === 'plugin')
|
||||
const canonicalRootPath = homeRoots.find((root) => root.id === 'home-agents')?.path
|
||||
if (!canonicalRootPath) {
|
||||
throw new Error('Missing canonical agent skills root')
|
||||
}
|
||||
|
||||
const homeTasks = artifacts.manifest.skills.flatMap((current) =>
|
||||
homeRoots.map(
|
||||
(root) => () =>
|
||||
classifyHomeSkillCandidate({
|
||||
root,
|
||||
current,
|
||||
artifacts,
|
||||
canonicalRootPath,
|
||||
candidateLstat: args.candidateLstat ?? ((path) => lstat(path))
|
||||
})
|
||||
)
|
||||
)
|
||||
// Why: each observation may retain the package byte ceiling while hashing;
|
||||
// launch/focus scans must not fan out across every known placement.
|
||||
const homeInstallations = (await runSkillCandidateTasks(homeTasks)).filter(
|
||||
(installation): installation is SkillFreshnessInstallation => installation !== null
|
||||
)
|
||||
|
||||
const candidateLstat = args.candidateLstat ?? ((path) => lstat(path))
|
||||
const repoTasks = artifacts.manifest.skills.flatMap((current) =>
|
||||
repoRoots.map(
|
||||
(root) => () =>
|
||||
classifyUnsupportedSkillCandidate({
|
||||
root,
|
||||
current,
|
||||
artifacts,
|
||||
unresolvedPath: join(root.path, current.name),
|
||||
candidateLstat
|
||||
})
|
||||
)
|
||||
)
|
||||
// Why: stored repositories can grow without bound. If the probe budget is
|
||||
// exhausted, one sentinel per name preserves safety without hashing more packages.
|
||||
const omittedRepoTasks =
|
||||
omittedRepoRoots.length === 0
|
||||
? []
|
||||
: artifacts.manifest.skills.map(
|
||||
(current) => () =>
|
||||
observeSkillFreshnessInstallation({
|
||||
current,
|
||||
artifacts,
|
||||
rootId: 'repo-scan-limit',
|
||||
providers: [...new Set(omittedRepoRoots.flatMap((root) => root.providers))],
|
||||
sourceKind: 'repo',
|
||||
sourceLabel: 'Additional repositories',
|
||||
unresolvedPath: omittedRepoRoots[0]?.path ?? 'repo-scan-limit',
|
||||
topology: {
|
||||
topology: 'repo-scope',
|
||||
resolvedPath: null,
|
||||
identity: null,
|
||||
errorCategory: 'repository-scan-limit'
|
||||
}
|
||||
})
|
||||
)
|
||||
const pluginScans = await Promise.all(
|
||||
pluginRoots.map(async (root) => ({
|
||||
root,
|
||||
scan: await scanKnownPluginSkillCandidates(root.path, new Set(currentByName.keys()))
|
||||
}))
|
||||
)
|
||||
const pluginTasks = pluginScans.flatMap(({ root, scan }) => [
|
||||
...scan.candidates.flatMap((candidate) => {
|
||||
const current = currentByName.get(candidate.name)
|
||||
return current
|
||||
? [
|
||||
() =>
|
||||
classifyUnsupportedSkillCandidate({
|
||||
root,
|
||||
current,
|
||||
artifacts,
|
||||
unresolvedPath: candidate.path,
|
||||
candidateLstat
|
||||
})
|
||||
]
|
||||
: []
|
||||
}),
|
||||
// Why: unreadable plugin subtrees could hide any official name. An
|
||||
// incomplete scan must conservatively poison every name rather than imply absence.
|
||||
...scan.incompletePaths.flatMap((incompletePath) =>
|
||||
artifacts.manifest.skills.map(
|
||||
(current) => () =>
|
||||
observeSkillFreshnessInstallation({
|
||||
current,
|
||||
artifacts,
|
||||
rootId: root.id,
|
||||
providers: root.providers,
|
||||
sourceKind: 'plugin',
|
||||
sourceLabel: root.label,
|
||||
unresolvedPath: join(incompletePath, current.name),
|
||||
topology: {
|
||||
topology: 'plugin-cache',
|
||||
resolvedPath: null,
|
||||
identity: null,
|
||||
errorCategory: 'plugin-cache-scan-incomplete'
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
])
|
||||
const unsupportedInstallations = (
|
||||
await runSkillCandidateTasks([...repoTasks, ...omittedRepoTasks, ...pluginTasks])
|
||||
).filter((installation): installation is SkillFreshnessInstallation => installation !== null)
|
||||
const installations = dedupeSkillFreshnessPlacements([
|
||||
...homeInstallations,
|
||||
...unsupportedInstallations
|
||||
]).sort(
|
||||
(left, right) =>
|
||||
left.name.localeCompare(right.name, 'en') ||
|
||||
left.unresolvedPath.localeCompare(right.unresolvedPath, 'en')
|
||||
)
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
installations,
|
||||
eligibleUpdateNames: eligibleSkillUpdateNames(installations),
|
||||
scannedAt: Date.now()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { Stats } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
SkillCurrentBundleEntry,
|
||||
SkillFreshnessInstallation,
|
||||
SkillFreshnessStatus,
|
||||
SkillKnownSnapshot
|
||||
} from '../../shared/skill-freshness'
|
||||
import type { SkillScanRoot } from './skill-discovery-sources'
|
||||
import type { SkillBundleArtifacts } from './skill-bundle-artifacts'
|
||||
import { matchingKnownSnapshot, observeSkillPackage } from './skill-package-identity'
|
||||
import {
|
||||
classifyHomeSkillTopology,
|
||||
classifyUnsupportedSkillTopology,
|
||||
skillPlacementId,
|
||||
skillTopologyPriority,
|
||||
type ClassifiedSkillTopology
|
||||
} from './skill-installation-topology'
|
||||
|
||||
export type CandidateLstat = (path: string) => Promise<Stats>
|
||||
|
||||
function freshnessStatus(
|
||||
snapshot: SkillKnownSnapshot | null,
|
||||
current: SkillCurrentBundleEntry
|
||||
): SkillFreshnessStatus {
|
||||
if (!snapshot) {
|
||||
return 'unrecognized'
|
||||
}
|
||||
if (snapshot.releaseRevision > current.releaseRevision) {
|
||||
return 'newer-known'
|
||||
}
|
||||
return snapshot.packageDigest === current.packageDigest ? 'current' : 'outdated'
|
||||
}
|
||||
|
||||
function errorCategory(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback
|
||||
}
|
||||
|
||||
function isInaccessibleError(error: unknown): boolean {
|
||||
return Boolean(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
(error.code === 'EACCES' || error.code === 'EPERM')
|
||||
)
|
||||
}
|
||||
|
||||
function knownSnapshots(
|
||||
artifacts: SkillBundleArtifacts,
|
||||
current: SkillCurrentBundleEntry
|
||||
): SkillKnownSnapshot[] {
|
||||
const snapshots = artifacts.knownSnapshots[current.name] ?? []
|
||||
return snapshots.some((snapshot) => snapshot.packageDigest === current.packageDigest)
|
||||
? snapshots
|
||||
: [...snapshots, current]
|
||||
}
|
||||
|
||||
export async function observeSkillFreshnessInstallation(args: {
|
||||
current: SkillCurrentBundleEntry
|
||||
artifacts: SkillBundleArtifacts
|
||||
rootId: string
|
||||
providers: SkillFreshnessInstallation['providers']
|
||||
sourceKind: SkillFreshnessInstallation['sourceKind']
|
||||
sourceLabel: string
|
||||
unresolvedPath: string
|
||||
topology: ClassifiedSkillTopology
|
||||
}): Promise<SkillFreshnessInstallation> {
|
||||
const base = {
|
||||
id: skillPlacementId(args.unresolvedPath, args.current.name),
|
||||
name: args.current.name,
|
||||
rootId: args.rootId,
|
||||
providers: args.providers,
|
||||
sourceKind: args.sourceKind,
|
||||
sourceLabel: args.sourceLabel,
|
||||
unresolvedPath: args.unresolvedPath,
|
||||
resolvedPath: args.topology.resolvedPath,
|
||||
physicalIdentity: args.topology.identity,
|
||||
topology: args.topology.topology,
|
||||
currentReleaseRevision: args.current.releaseRevision,
|
||||
currentPackageDigest: args.current.packageDigest,
|
||||
currentAppVersion: args.current.appVersion,
|
||||
errorCategory: args.topology.errorCategory
|
||||
}
|
||||
if (!args.topology.resolvedPath || !args.topology.identity) {
|
||||
return {
|
||||
...base,
|
||||
status: 'inaccessible',
|
||||
installedReleaseRevision: null,
|
||||
installedAppVersion: null,
|
||||
observedPackageDigest: null
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const observed = await observeSkillPackage(args.topology.resolvedPath)
|
||||
const matchedSnapshot = matchingKnownSnapshot(
|
||||
observed,
|
||||
knownSnapshots(args.artifacts, args.current)
|
||||
)
|
||||
// Why: a later release can reintroduce identical bytes. Exact current
|
||||
// identity is still current, and cannot honestly be attributed to the later tag.
|
||||
const snapshot =
|
||||
observed.observedDigest === args.current.packageDigest ? args.current : matchedSnapshot
|
||||
return {
|
||||
...base,
|
||||
status: freshnessStatus(snapshot, args.current),
|
||||
installedReleaseRevision: snapshot?.releaseRevision ?? null,
|
||||
installedAppVersion: snapshot
|
||||
? (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ??
|
||||
null)
|
||||
: null,
|
||||
observedPackageDigest: observed.observedDigest
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
...base,
|
||||
status: isInaccessibleError(error) ? 'inaccessible' : 'unrecognized',
|
||||
installedReleaseRevision: null,
|
||||
installedAppVersion: null,
|
||||
observedPackageDigest: null,
|
||||
errorCategory: errorCategory(error, 'skill-package-read-failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function classifyHomeSkillCandidate(args: {
|
||||
root: SkillScanRoot
|
||||
current: SkillCurrentBundleEntry
|
||||
artifacts: SkillBundleArtifacts
|
||||
canonicalRootPath: string
|
||||
candidateLstat: CandidateLstat
|
||||
}): Promise<SkillFreshnessInstallation | null> {
|
||||
const unresolvedPath = join(args.root.path, args.current.name)
|
||||
try {
|
||||
await args.candidateLstat(unresolvedPath)
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
return observeSkillFreshnessInstallation({
|
||||
current: args.current,
|
||||
artifacts: args.artifacts,
|
||||
rootId: args.root.id,
|
||||
providers: args.root.providers,
|
||||
sourceKind: args.root.sourceKind,
|
||||
sourceLabel: args.root.label,
|
||||
unresolvedPath,
|
||||
topology: {
|
||||
topology: 'broken-link',
|
||||
resolvedPath: null,
|
||||
identity: null,
|
||||
errorCategory: errorCategory(error, 'skill-candidate-inaccessible')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let topology: ClassifiedSkillTopology
|
||||
try {
|
||||
topology = await classifyHomeSkillTopology(args.root, unresolvedPath, args.canonicalRootPath)
|
||||
} catch (error) {
|
||||
topology = {
|
||||
topology: 'broken-link',
|
||||
resolvedPath: null,
|
||||
identity: null,
|
||||
errorCategory: errorCategory(error, 'skill-candidate-topology-failed')
|
||||
}
|
||||
}
|
||||
return observeSkillFreshnessInstallation({
|
||||
current: args.current,
|
||||
artifacts: args.artifacts,
|
||||
rootId: args.root.id,
|
||||
providers: args.root.providers,
|
||||
sourceKind: args.root.sourceKind,
|
||||
sourceLabel: args.root.label,
|
||||
unresolvedPath,
|
||||
topology
|
||||
})
|
||||
}
|
||||
|
||||
export async function classifyUnsupportedSkillCandidate(args: {
|
||||
root: SkillScanRoot
|
||||
current: SkillCurrentBundleEntry
|
||||
artifacts: SkillBundleArtifacts
|
||||
unresolvedPath: string
|
||||
candidateLstat: CandidateLstat
|
||||
}): Promise<SkillFreshnessInstallation | null> {
|
||||
try {
|
||||
await args.candidateLstat(args.unresolvedPath)
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
return observeSkillFreshnessInstallation({
|
||||
current: args.current,
|
||||
artifacts: args.artifacts,
|
||||
rootId: args.root.id,
|
||||
providers: args.root.providers,
|
||||
sourceKind: args.root.sourceKind,
|
||||
sourceLabel: args.root.label,
|
||||
unresolvedPath: args.unresolvedPath,
|
||||
topology: {
|
||||
topology: args.root.sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache',
|
||||
resolvedPath: null,
|
||||
identity: null,
|
||||
errorCategory: errorCategory(error, 'unsupported-candidate-inaccessible')
|
||||
}
|
||||
})
|
||||
}
|
||||
return observeSkillFreshnessInstallation({
|
||||
current: args.current,
|
||||
artifacts: args.artifacts,
|
||||
rootId: args.root.id,
|
||||
providers: args.root.providers,
|
||||
sourceKind: args.root.sourceKind,
|
||||
sourceLabel: args.root.label,
|
||||
unresolvedPath: args.unresolvedPath,
|
||||
topology: await classifyUnsupportedSkillTopology(
|
||||
args.unresolvedPath,
|
||||
args.root.sourceKind === 'repo' ? 'repo' : 'plugin'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function topologyDedupeBucket(installation: SkillFreshnessInstallation): string {
|
||||
return installation.topology === 'canonical-copy' || installation.topology === 'provider-alias'
|
||||
? 'managed-global'
|
||||
: installation.topology
|
||||
}
|
||||
|
||||
export function dedupeSkillFreshnessPlacements(
|
||||
installations: readonly SkillFreshnessInstallation[]
|
||||
): SkillFreshnessInstallation[] {
|
||||
const deduped = new Map<string, SkillFreshnessInstallation>()
|
||||
for (const installation of installations) {
|
||||
const key = installation.physicalIdentity
|
||||
? `${installation.name}\0${installation.physicalIdentity}\0${topologyDedupeBucket(installation)}`
|
||||
: `logical\0${installation.id}`
|
||||
const existing = deduped.get(key)
|
||||
if (!existing) {
|
||||
deduped.set(key, installation)
|
||||
continue
|
||||
}
|
||||
const providers = [...new Set([...existing.providers, ...installation.providers])]
|
||||
if (skillTopologyPriority(installation.topology) > skillTopologyPriority(existing.topology)) {
|
||||
deduped.set(key, { ...installation, providers })
|
||||
} else {
|
||||
existing.providers = providers
|
||||
}
|
||||
}
|
||||
return [...deduped.values()]
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { constants } from 'node:fs'
|
||||
import { access, lstat, realpath, stat } from 'node:fs/promises'
|
||||
import { dirname, normalize, resolve } from 'node:path'
|
||||
import type { SkillInstallationTopology } from '../../shared/skill-freshness'
|
||||
import type { SkillScanRoot } from './skill-discovery-sources'
|
||||
|
||||
export type ClassifiedSkillTopology = {
|
||||
topology: SkillInstallationTopology
|
||||
resolvedPath: string | null
|
||||
identity: string | null
|
||||
errorCategory: string | null
|
||||
}
|
||||
|
||||
export function skillPlacementId(unresolvedPath: string, name: string): string {
|
||||
return createHash('sha256')
|
||||
.update(normalizedSkillIdentityPath(unresolvedPath))
|
||||
.update('\0')
|
||||
.update(name)
|
||||
.digest('hex')
|
||||
.slice(0, 24)
|
||||
}
|
||||
|
||||
export function normalizedSkillIdentityPath(value: string): string {
|
||||
const normalized = normalize(value)
|
||||
return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
|
||||
}
|
||||
|
||||
export function skillPhysicalIdentity(
|
||||
resolvedPath: string,
|
||||
fileStat: Awaited<ReturnType<typeof stat>>
|
||||
): string {
|
||||
const inodeIdentity = fileStat.dev || fileStat.ino ? `${fileStat.dev}:${fileStat.ino}` : null
|
||||
return inodeIdentity ?? normalizedSkillIdentityPath(resolvedPath)
|
||||
}
|
||||
|
||||
export function skillTopologyPriority(topology: SkillInstallationTopology): number {
|
||||
switch (topology) {
|
||||
case 'canonical-copy':
|
||||
return 3
|
||||
case 'independent-copy':
|
||||
return 2
|
||||
case 'provider-alias':
|
||||
return 1
|
||||
case 'external-link':
|
||||
case 'broken-link':
|
||||
case 'read-only':
|
||||
case 'repo-scope':
|
||||
case 'plugin-cache':
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function writableDestination(path: string): Promise<boolean> {
|
||||
try {
|
||||
await Promise.all([
|
||||
access(path, constants.R_OK | constants.W_OK),
|
||||
access(dirname(path), constants.W_OK)
|
||||
])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function hasSymlinkedAncestor(path: string, boundary: string): Promise<boolean> {
|
||||
let current = resolve(path)
|
||||
const stop = resolve(boundary)
|
||||
for (;;) {
|
||||
const entry = await lstat(current).catch(() => null)
|
||||
if (!entry || entry.isSymbolicLink()) {
|
||||
return true
|
||||
}
|
||||
const parent = dirname(current)
|
||||
if (current === stop) {
|
||||
return false
|
||||
}
|
||||
if (parent === current) {
|
||||
return true
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
export async function classifyHomeSkillTopology(
|
||||
root: SkillScanRoot,
|
||||
unresolvedPath: string,
|
||||
canonicalRootPath: string
|
||||
): Promise<ClassifiedSkillTopology> {
|
||||
let logicalStat: Awaited<ReturnType<typeof lstat>>
|
||||
try {
|
||||
logicalStat = await lstat(unresolvedPath)
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
return {
|
||||
topology: 'broken-link',
|
||||
resolvedPath: null,
|
||||
identity: null,
|
||||
errorCategory: 'missing'
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const linked = logicalStat.isSymbolicLink()
|
||||
let resolvedPath: string
|
||||
let resolvedStat: Awaited<ReturnType<typeof stat>>
|
||||
try {
|
||||
resolvedPath = await realpath(unresolvedPath)
|
||||
resolvedStat = await stat(resolvedPath)
|
||||
} catch {
|
||||
return {
|
||||
topology: 'broken-link',
|
||||
resolvedPath: null,
|
||||
identity: null,
|
||||
errorCategory: 'dangling-link'
|
||||
}
|
||||
}
|
||||
if (!resolvedStat.isDirectory()) {
|
||||
return {
|
||||
topology: 'broken-link',
|
||||
resolvedPath,
|
||||
identity: null,
|
||||
errorCategory: 'not-directory'
|
||||
}
|
||||
}
|
||||
|
||||
const identity = skillPhysicalIdentity(resolvedPath, resolvedStat)
|
||||
const canonicalRoot = await realpath(canonicalRootPath).catch(() => resolve(canonicalRootPath))
|
||||
const homeBoundary = dirname(dirname(canonicalRootPath))
|
||||
const rootOrProviderParentLinked = await hasSymlinkedAncestor(root.path, homeBoundary)
|
||||
const isCanonicalTarget =
|
||||
normalizedSkillIdentityPath(dirname(resolvedPath)) ===
|
||||
normalizedSkillIdentityPath(canonicalRoot)
|
||||
let topology: SkillInstallationTopology
|
||||
if (linked) {
|
||||
topology = isCanonicalTarget ? 'provider-alias' : 'external-link'
|
||||
} else if (rootOrProviderParentLinked) {
|
||||
topology = 'external-link'
|
||||
} else {
|
||||
topology = root.id === 'home-agents' ? 'canonical-copy' : 'independent-copy'
|
||||
}
|
||||
if (topology !== 'external-link' && !(await writableDestination(resolvedPath))) {
|
||||
topology = 'read-only'
|
||||
}
|
||||
return { topology, resolvedPath, identity, errorCategory: null }
|
||||
}
|
||||
|
||||
export async function classifyUnsupportedSkillTopology(
|
||||
directoryPath: string,
|
||||
sourceKind: 'repo' | 'plugin'
|
||||
): Promise<ClassifiedSkillTopology> {
|
||||
try {
|
||||
const resolvedPath = await realpath(directoryPath)
|
||||
const resolvedStat = await stat(resolvedPath)
|
||||
if (!resolvedStat.isDirectory()) {
|
||||
throw new Error('not-directory')
|
||||
}
|
||||
return {
|
||||
topology: sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache',
|
||||
resolvedPath,
|
||||
identity: skillPhysicalIdentity(resolvedPath, resolvedStat),
|
||||
errorCategory: null
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
topology: sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache',
|
||||
resolvedPath: null,
|
||||
identity: null,
|
||||
errorCategory: error instanceof Error ? error.message : 'read-failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
describeObservedSkillFile,
|
||||
matchingKnownSnapshot,
|
||||
observeSkillPackage,
|
||||
skillPackageDigest
|
||||
} from './skill-package-identity'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
async function temporarySkill(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-freshness-'))
|
||||
temporaryDirectories.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
|
||||
})
|
||||
|
||||
describe('skill package identity', () => {
|
||||
it('matches CRLF installed text to an LF official snapshot', async () => {
|
||||
const root = await temporarySkill()
|
||||
await writeFile(join(root, 'SKILL.md'), 'first\r\nsecond\r\n')
|
||||
const observed = await observeSkillPackage(root)
|
||||
const expected = describeObservedSkillFile('SKILL.md', Buffer.from('first\nsecond\n'), false)
|
||||
|
||||
// Why: scans can observe several package byte budgets concurrently; only
|
||||
// hashes, not raw file buffers, should survive each file's identity pass.
|
||||
expect(observed.files[0]).not.toHaveProperty('bytes')
|
||||
expect(
|
||||
matchingKnownSnapshot(observed, [
|
||||
{
|
||||
releaseRevision: 1,
|
||||
packageDigest: skillPackageDigest([expected]),
|
||||
gitTreeSha: 'tree',
|
||||
files: [expected]
|
||||
}
|
||||
])?.releaseRevision
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('uses exact bytes for executable and binary files', async () => {
|
||||
const executable = describeObservedSkillFile('run.sh', Buffer.from('#!/bin/sh\r\n'), true)
|
||||
const binary = describeObservedSkillFile('asset.bin', Buffer.from([0, 13, 10]), false)
|
||||
expect(executable.identitySha256).toBe(executable.exactSha256)
|
||||
expect(binary.identitySha256).toBe(binary.exactSha256)
|
||||
expect(binary.classification).toBe('binary')
|
||||
})
|
||||
|
||||
it('orders package files by locale-independent code units', async () => {
|
||||
const root = await temporarySkill()
|
||||
await writeFile(join(root, 'apple.md'), 'apple')
|
||||
await writeFile(join(root, 'Zebra.md'), 'zebra')
|
||||
|
||||
const observed = await observeSkillPackage(root)
|
||||
|
||||
expect(observed.files.map((file) => file.path)).toEqual(['Zebra.md', 'apple.md'])
|
||||
})
|
||||
|
||||
it('rejects links and bounded-observation overflows', async () => {
|
||||
const root = await temporarySkill()
|
||||
await writeFile(join(root, 'SKILL.md'), 'skill')
|
||||
if (process.platform !== 'win32') {
|
||||
await symlink(join(root, 'SKILL.md'), join(root, 'linked.md'))
|
||||
await expect(observeSkillPackage(root)).rejects.toThrow('skill-package-link')
|
||||
await rm(join(root, 'linked.md'))
|
||||
}
|
||||
await expect(
|
||||
observeSkillPackage(root, {
|
||||
maximumDepth: 1,
|
||||
maximumEntries: 0,
|
||||
maximumFiles: 1,
|
||||
maximumSingleFileBytes: 10,
|
||||
maximumTotalBytes: 10
|
||||
})
|
||||
).rejects.toThrow('skill-package-entry-limit')
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')('tracks executable mode in package identity', async () => {
|
||||
const root = await temporarySkill()
|
||||
await mkdir(join(root, 'scripts'))
|
||||
const script = join(root, 'scripts', 'run.sh')
|
||||
await writeFile(script, '#!/bin/sh\n')
|
||||
await chmod(script, 0o755)
|
||||
const observed = await observeSkillPackage(root)
|
||||
expect(observed.files[0]?.executable).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { lstat, open, opendir } from 'node:fs/promises'
|
||||
import { isAbsolute, join, relative, sep } from 'node:path'
|
||||
import type { SkillBundleFileIdentity, SkillKnownSnapshot } from '../../shared/skill-freshness'
|
||||
|
||||
type ObservedSkillFile = SkillBundleFileIdentity
|
||||
|
||||
export type ObservedSkillPackage = {
|
||||
files: ObservedSkillFile[]
|
||||
observedDigest: string
|
||||
}
|
||||
|
||||
export const SKILL_PACKAGE_OBSERVATION_LIMITS = {
|
||||
maximumDepth: 16,
|
||||
maximumEntries: 2_048,
|
||||
maximumFiles: 512,
|
||||
maximumSingleFileBytes: 4 * 1024 * 1024,
|
||||
maximumTotalBytes: 32 * 1024 * 1024
|
||||
} as const
|
||||
|
||||
type SkillPackageObservationLimits = {
|
||||
maximumDepth: number
|
||||
maximumEntries: number
|
||||
maximumFiles: number
|
||||
maximumSingleFileBytes: number
|
||||
maximumTotalBytes: number
|
||||
}
|
||||
|
||||
async function readBoundedSkillFile(
|
||||
path: string,
|
||||
remainingTotalBytes: number,
|
||||
maximumSingleFileBytes: number
|
||||
): Promise<Buffer> {
|
||||
const handle = await open(path, 'r')
|
||||
try {
|
||||
const before = await handle.stat()
|
||||
if (before.size > maximumSingleFileBytes) {
|
||||
throw new Error('skill-package-file-size-limit')
|
||||
}
|
||||
if (before.size > remainingTotalBytes) {
|
||||
throw new Error('skill-package-total-size-limit')
|
||||
}
|
||||
const bytes = Buffer.alloc(before.size)
|
||||
let offset = 0
|
||||
while (offset < bytes.length) {
|
||||
const result = await handle.read(bytes, offset, bytes.length - offset, offset)
|
||||
if (result.bytesRead === 0) {
|
||||
throw new Error('skill-package-changed-during-read')
|
||||
}
|
||||
offset += result.bytesRead
|
||||
}
|
||||
if ((await handle.stat()).size !== before.size) {
|
||||
throw new Error('skill-package-changed-during-read')
|
||||
}
|
||||
return bytes
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(bytes: Buffer): string {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
function compareCodeUnits(left: string, right: string): number {
|
||||
return left === right ? 0 : left < right ? -1 : 1
|
||||
}
|
||||
|
||||
function normalizedText(bytes: Buffer): Buffer {
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
||||
return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8')
|
||||
}
|
||||
|
||||
export function describeObservedSkillFile(
|
||||
path: string,
|
||||
bytes: Buffer,
|
||||
executable: boolean
|
||||
): ObservedSkillFile {
|
||||
let normalized: Buffer | null = null
|
||||
if (!bytes.includes(0)) {
|
||||
try {
|
||||
normalized = normalizedText(bytes)
|
||||
} catch {
|
||||
normalized = null
|
||||
}
|
||||
}
|
||||
const classification = normalized ? 'text' : 'binary'
|
||||
const exactSha256 = sha256(bytes)
|
||||
const textNormalizedSha256 = normalized ? sha256(normalized) : null
|
||||
return {
|
||||
path,
|
||||
size: bytes.length,
|
||||
executable,
|
||||
classification,
|
||||
exactSha256,
|
||||
textNormalizedSha256,
|
||||
identitySha256:
|
||||
textNormalizedSha256 !== null && !executable ? textNormalizedSha256 : exactSha256
|
||||
}
|
||||
}
|
||||
|
||||
export function skillPackageDigest(files: readonly SkillBundleFileIdentity[]): string {
|
||||
return sha256(
|
||||
Buffer.from(
|
||||
JSON.stringify(
|
||||
files.map((file) => ({
|
||||
path: file.path,
|
||||
executable: file.executable,
|
||||
classification: file.classification,
|
||||
identitySha256: file.identitySha256
|
||||
}))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function matchesFileIdentity(
|
||||
actual: ObservedSkillFile,
|
||||
expected: SkillBundleFileIdentity
|
||||
): boolean {
|
||||
if (
|
||||
actual.path !== expected.path ||
|
||||
actual.executable !== expected.executable ||
|
||||
actual.classification !== expected.classification
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return expected.classification === 'text' && !expected.executable
|
||||
? actual.textNormalizedSha256 === expected.textNormalizedSha256
|
||||
: actual.exactSha256 === expected.exactSha256
|
||||
}
|
||||
|
||||
export async function observeSkillPackage(
|
||||
packageRoot: string,
|
||||
limits: SkillPackageObservationLimits = SKILL_PACKAGE_OBSERVATION_LIMITS
|
||||
): Promise<ObservedSkillPackage> {
|
||||
const files: ObservedSkillFile[] = []
|
||||
const caseFoldedPaths = new Map<string, string>()
|
||||
let entryCount = 0
|
||||
let totalBytes = 0
|
||||
|
||||
async function visit(directory: string, depth: number): Promise<void> {
|
||||
const directoryHandle = await opendir(directory)
|
||||
const entries: Dirent[] = []
|
||||
try {
|
||||
for (;;) {
|
||||
const entry = await directoryHandle.read()
|
||||
if (!entry) {
|
||||
break
|
||||
}
|
||||
entryCount += 1
|
||||
if (entryCount > limits.maximumEntries) {
|
||||
throw new Error('skill-package-entry-limit')
|
||||
}
|
||||
entries.push(entry)
|
||||
}
|
||||
} finally {
|
||||
await directoryHandle.close().catch(() => undefined)
|
||||
}
|
||||
// Why: runtime Electron and the build's Node may carry different ICU data;
|
||||
// identity order must match the generator without locale-sensitive collation.
|
||||
entries.sort((left, right) => compareCodeUnits(left.name, right.name))
|
||||
for (const entry of entries) {
|
||||
const absolutePath = join(directory, entry.name)
|
||||
const relativePath = relative(packageRoot, absolutePath)
|
||||
if (
|
||||
isAbsolute(relativePath) ||
|
||||
relativePath === '..' ||
|
||||
relativePath.startsWith(`..${sep}`)
|
||||
) {
|
||||
throw new Error('skill-path-escape')
|
||||
}
|
||||
const manifestPath = relativePath.split(sep).join('/')
|
||||
const folded = manifestPath.toLocaleLowerCase('en-US')
|
||||
const collision = caseFoldedPaths.get(folded)
|
||||
if (collision && collision !== manifestPath) {
|
||||
throw new Error('skill-case-collision')
|
||||
}
|
||||
caseFoldedPaths.set(folded, manifestPath)
|
||||
const fileStat = await lstat(absolutePath)
|
||||
if (fileStat.isSymbolicLink()) {
|
||||
throw new Error('skill-package-link')
|
||||
}
|
||||
if (fileStat.isDirectory()) {
|
||||
if (depth >= limits.maximumDepth) {
|
||||
throw new Error('skill-package-depth-limit')
|
||||
}
|
||||
await visit(absolutePath, depth + 1)
|
||||
} else if (fileStat.isFile()) {
|
||||
if (files.length >= limits.maximumFiles) {
|
||||
throw new Error('skill-package-file-count-limit')
|
||||
}
|
||||
const bytes = await readBoundedSkillFile(
|
||||
absolutePath,
|
||||
limits.maximumTotalBytes - totalBytes,
|
||||
limits.maximumSingleFileBytes
|
||||
)
|
||||
totalBytes += bytes.length
|
||||
files.push(describeObservedSkillFile(manifestPath, bytes, (fileStat.mode & 0o111) !== 0))
|
||||
} else {
|
||||
throw new Error('skill-package-special-file')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await visit(packageRoot, 0)
|
||||
return { files, observedDigest: skillPackageDigest(files) }
|
||||
}
|
||||
|
||||
export function matchingKnownSnapshot(
|
||||
observed: ObservedSkillPackage,
|
||||
snapshots: readonly SkillKnownSnapshot[]
|
||||
): SkillKnownSnapshot | null {
|
||||
for (const snapshot of snapshots.toReversed()) {
|
||||
if (snapshot.files.length !== observed.files.length) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
snapshot.files.every((expected, index) => {
|
||||
const actual = observed.files[index]
|
||||
return Boolean(actual && matchesFileIdentity(actual, expected))
|
||||
})
|
||||
) {
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
|
||||
})
|
||||
|
||||
describe('plugin skill candidate scan', () => {
|
||||
it('stops at the package candidate budget and marks the scan incomplete', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-scan-'))
|
||||
temporaryDirectories.push(root)
|
||||
await Promise.all(
|
||||
['one', 'two'].map((vendor) => mkdir(join(root, vendor, 'orca-cli'), { recursive: true }))
|
||||
)
|
||||
|
||||
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']), 1)
|
||||
|
||||
expect(result.candidates).toHaveLength(1)
|
||||
expect(result.incompletePaths).toEqual([root])
|
||||
})
|
||||
|
||||
it('marks depth-truncated subtrees incomplete so hidden skills poison eligibility', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-depth-'))
|
||||
temporaryDirectories.push(root)
|
||||
const segments = Array.from({ length: 11 }, (_, index) => `level-${index}`)
|
||||
const hiddenSkill = join(root, ...segments, 'orca-cli')
|
||||
await mkdir(hiddenSkill, { recursive: true })
|
||||
|
||||
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
|
||||
|
||||
expect(result.candidates).toEqual([])
|
||||
expect(result.incompletePaths).toHaveLength(1)
|
||||
expect(hiddenSkill.startsWith(result.incompletePaths[0] ?? '')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { opendir, realpath, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const MAXIMUM_PLUGIN_SCAN_DEPTH = 9
|
||||
const MAXIMUM_PLUGIN_SCAN_ENTRIES = 4_096
|
||||
export const MAXIMUM_PLUGIN_SKILL_CANDIDATES = 64
|
||||
const MAXIMUM_PLUGIN_INCOMPLETE_PATHS = 16
|
||||
|
||||
export type KnownPluginSkillCandidate = {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export type KnownPluginSkillScan = {
|
||||
candidates: KnownPluginSkillCandidate[]
|
||||
incompletePaths: string[]
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | null {
|
||||
return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
|
||||
? error.code
|
||||
: null
|
||||
}
|
||||
|
||||
export async function scanKnownPluginSkillCandidates(
|
||||
rootPath: string,
|
||||
knownNames: ReadonlySet<string>,
|
||||
maximumCandidates = MAXIMUM_PLUGIN_SKILL_CANDIDATES
|
||||
): Promise<KnownPluginSkillScan> {
|
||||
const candidates: KnownPluginSkillCandidate[] = []
|
||||
const incompletePaths = new Set<string>()
|
||||
const visited = new Set<string>()
|
||||
let entryCount = 0
|
||||
let limitReached = false
|
||||
|
||||
function recordIncomplete(path: string): void {
|
||||
if (incompletePaths.has(path)) {
|
||||
return
|
||||
}
|
||||
if (incompletePaths.size >= MAXIMUM_PLUGIN_INCOMPLETE_PATHS) {
|
||||
// Why: each incomplete path expands to one conservative row per official
|
||||
// skill. Collapse a hostile cache into one poison sentinel before IPC/render fanout.
|
||||
incompletePaths.clear()
|
||||
incompletePaths.add(rootPath)
|
||||
limitReached = true
|
||||
return
|
||||
}
|
||||
incompletePaths.add(path)
|
||||
}
|
||||
|
||||
async function visit(directory: string, depth: number): Promise<void> {
|
||||
if (limitReached) {
|
||||
return
|
||||
}
|
||||
if (depth > MAXIMUM_PLUGIN_SCAN_DEPTH) {
|
||||
recordIncomplete(directory)
|
||||
return
|
||||
}
|
||||
let resolved: string
|
||||
try {
|
||||
resolved = await realpath(directory)
|
||||
} catch (error) {
|
||||
if (errorCode(error) !== 'ENOENT') {
|
||||
recordIncomplete(directory)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (visited.has(resolved)) {
|
||||
return
|
||||
}
|
||||
visited.add(resolved)
|
||||
|
||||
let handle: Awaited<ReturnType<typeof opendir>>
|
||||
try {
|
||||
handle = await opendir(directory)
|
||||
} catch {
|
||||
recordIncomplete(directory)
|
||||
return
|
||||
}
|
||||
const entries: Dirent[] = []
|
||||
try {
|
||||
for (;;) {
|
||||
const entry = await handle.read()
|
||||
if (!entry) {
|
||||
break
|
||||
}
|
||||
entryCount += 1
|
||||
if (entryCount > MAXIMUM_PLUGIN_SCAN_ENTRIES) {
|
||||
limitReached = true
|
||||
recordIncomplete(rootPath)
|
||||
break
|
||||
}
|
||||
entries.push(entry)
|
||||
}
|
||||
} catch {
|
||||
recordIncomplete(directory)
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined)
|
||||
}
|
||||
|
||||
entries.sort((left, right) => (left.name === right.name ? 0 : left.name < right.name ? -1 : 1))
|
||||
for (const entry of entries) {
|
||||
if (limitReached) {
|
||||
return
|
||||
}
|
||||
const entryPath = join(directory, entry.name)
|
||||
let directoryEntry = entry.isDirectory()
|
||||
if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
directoryEntry = (await stat(entryPath)).isDirectory()
|
||||
} catch {
|
||||
if (knownNames.has(entry.name)) {
|
||||
if (candidates.length >= maximumCandidates) {
|
||||
limitReached = true
|
||||
recordIncomplete(rootPath)
|
||||
return
|
||||
}
|
||||
candidates.push({ name: entry.name, path: entryPath })
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (!directoryEntry) {
|
||||
continue
|
||||
}
|
||||
if (knownNames.has(entry.name)) {
|
||||
if (candidates.length >= maximumCandidates) {
|
||||
limitReached = true
|
||||
recordIncomplete(rootPath)
|
||||
return
|
||||
}
|
||||
candidates.push({ name: entry.name, path: entryPath })
|
||||
continue
|
||||
}
|
||||
await visit(entryPath, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
await visit(rootPath, 0)
|
||||
return { candidates, incompletePaths: [...incompletePaths] }
|
||||
}
|
||||
@@ -321,6 +321,7 @@ import type { ResolvedSourceControlAiGenerationParams } from '../shared/source-c
|
||||
import type { SourceControlAiSettings } from '../shared/source-control-ai-types'
|
||||
import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
|
||||
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
|
||||
import type { SkillFreshnessInventory } from '../shared/skill-freshness'
|
||||
import type {
|
||||
CrashReportBreadcrumbData,
|
||||
CrashReportCopyDiagnosticsArgs,
|
||||
@@ -2255,6 +2256,7 @@ export type PreloadApi = {
|
||||
}
|
||||
skills: {
|
||||
discover: (target?: SkillDiscoveryTarget) => Promise<SkillDiscoveryResult>
|
||||
freshnessInventory: () => Promise<SkillFreshnessInventory>
|
||||
}
|
||||
pet: {
|
||||
import: () => Promise<CustomPet | null>
|
||||
|
||||
@@ -72,6 +72,7 @@ import type {
|
||||
import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
|
||||
import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
|
||||
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
|
||||
import type { SkillFreshnessInventory } from '../shared/skill-freshness'
|
||||
import type {
|
||||
RuntimeBrowserDriverState,
|
||||
RuntimeMobileSessionTabMove,
|
||||
@@ -2216,7 +2217,9 @@ const api = {
|
||||
|
||||
skills: {
|
||||
discover: (target?: SkillDiscoveryTarget): Promise<SkillDiscoveryResult> =>
|
||||
ipcRenderer.invoke('skills:discover', target)
|
||||
ipcRenderer.invoke('skills:discover', target),
|
||||
freshnessInventory: (): Promise<SkillFreshnessInventory> =>
|
||||
ipcRenderer.invoke('skills:freshnessInventory')
|
||||
},
|
||||
|
||||
pet: {
|
||||
|
||||
@@ -59,6 +59,8 @@ import RightSidebar from './components/right-sidebar'
|
||||
import { StarNagCard } from './components/StarNagCard'
|
||||
import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver'
|
||||
import { StarNagToastHost } from './components/star-nag/StarNagToastHost'
|
||||
import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge'
|
||||
import { SkillFreshnessUpdateDialog } from './components/skills/SkillFreshnessUpdateDialog'
|
||||
import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface'
|
||||
import { ZoomOverlay } from './components/ZoomOverlay'
|
||||
import { onOnboardingReopened } from './components/onboarding/show-onboarding-event'
|
||||
@@ -2828,10 +2830,20 @@ function App(): React.JSX.Element {
|
||||
>
|
||||
<RecentTabSwitcher />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
{/* Why: the dialog hosts a live terminal pane, which requires the
|
||||
link-routing preference context; mounting outside crashes it. */}
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.skill-freshness-update-dialog"
|
||||
surface="overlay"
|
||||
compact
|
||||
>
|
||||
<SkillFreshnessUpdateDialog />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</LinkRoutingPreferenceDialogProvider>
|
||||
</ConfirmationDialogProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
|
||||
<SkillFreshnessNudge />
|
||||
<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
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||
import { Copy, Loader2, RefreshCw, Terminal } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { IntegrationStatusPill } from '../integration-status-pill'
|
||||
import { SkillFreshnessStatusPill } from '../skills/SkillFreshnessStatusPill'
|
||||
import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal'
|
||||
import { Button } from '../ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
@@ -47,6 +48,10 @@ type AgentSkillSetupPanelProps = {
|
||||
openingHint?: ReactNode
|
||||
footer?: ReactNode
|
||||
onRecheck: () => void | Promise<unknown>
|
||||
// Why: when set, the installed pill reflects skill freshness and Re-check also
|
||||
// refreshes the freshness inventory. Callers omit it for non-local runtimes,
|
||||
// which the local-host-only freshness scan cannot vouch for.
|
||||
freshnessSkillName?: string
|
||||
}
|
||||
|
||||
export function AgentSkillSetupPanel({
|
||||
@@ -79,7 +84,8 @@ export function AgentSkillSetupPanel({
|
||||
actionHint,
|
||||
openingHint,
|
||||
footer,
|
||||
onRecheck
|
||||
onRecheck,
|
||||
freshnessSkillName
|
||||
}: AgentSkillSetupPanelProps): React.JSX.Element {
|
||||
const [terminalOpen, setTerminalOpen] = useState(false)
|
||||
const [terminalCommand, setTerminalCommand] = useState<string | null>(null)
|
||||
@@ -211,7 +217,12 @@ export function AgentSkillSetupPanel({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => void onRecheck()}
|
||||
onClick={() => {
|
||||
void onRecheck()
|
||||
if (freshnessSkillName) {
|
||||
notifyInstalledAgentSkillsChanged()
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
|
||||
@@ -264,12 +275,16 @@ export function AgentSkillSetupPanel({
|
||||
)}
|
||||
</IntegrationStatusPill>
|
||||
) : installed ? (
|
||||
<IntegrationStatusPill tone="connected">
|
||||
{translate(
|
||||
'auto.components.settings.AgentSkillSetupPanel.9fcebceb2a',
|
||||
'Installed'
|
||||
)}
|
||||
</IntegrationStatusPill>
|
||||
freshnessSkillName ? (
|
||||
<SkillFreshnessStatusPill skillName={freshnessSkillName} />
|
||||
) : (
|
||||
<IntegrationStatusPill tone="connected">
|
||||
{translate(
|
||||
'auto.components.settings.AgentSkillSetupPanel.9fcebceb2a',
|
||||
'Installed'
|
||||
)}
|
||||
</IntegrationStatusPill>
|
||||
)
|
||||
) : (
|
||||
<IntegrationStatusPill tone="attention">
|
||||
{translate(
|
||||
|
||||
@@ -390,6 +390,7 @@ export function CliSection({
|
||||
}))
|
||||
}}
|
||||
onRecheck={refreshCliSkill}
|
||||
freshnessSkillName={agentRuntime.runtime === 'host' ? ORCA_CLI_SKILL_NAME : undefined}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -79,6 +79,9 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element {
|
||||
: ensureOrcaCliAvailableForAgentSkillTerminal())
|
||||
}}
|
||||
onRecheck={refreshComputerUseSkill}
|
||||
freshnessSkillName={
|
||||
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : COMPUTER_USE_SKILL_NAME
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -163,6 +163,9 @@ export function EphemeralVmsPane(): React.JSX.Element {
|
||||
: ensureOrcaCliAvailableForAgentSkillTerminal())
|
||||
}}
|
||||
onRecheck={refreshSkill}
|
||||
freshnessSkillName={
|
||||
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : EPHEMERAL_VMS_SKILL_NAME
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border/60 bg-card/30 p-4">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ORCHESTRATION_SKILL_INSTALL_COMMAND,
|
||||
ORCHESTRATION_SKILL_UPDATE_COMMAND
|
||||
} from '@/lib/orchestration-install-command'
|
||||
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
|
||||
import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills'
|
||||
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
|
||||
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
|
||||
@@ -75,6 +76,9 @@ export function OrchestrationSetupCard(props: {
|
||||
: ensureOrcaCliAvailableForAgentSkillTerminal())
|
||||
}}
|
||||
onRecheck={skill.refresh}
|
||||
freshnessSkillName={
|
||||
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : ORCHESTRATION_SKILL_NAME
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ import {
|
||||
useInstalledAgentSkill
|
||||
} from '@/hooks/useInstalledAgentSkills'
|
||||
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
|
||||
import { useSkillFreshness } from '@/hooks/useSkillFreshness'
|
||||
import { deriveNeededSectionIds, getInitialMountedSectionIds } from './settings-load-performance'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getProjectHostSetupProjectionFromState } from '../../store/selectors'
|
||||
@@ -207,11 +208,15 @@ function getSettingsNavGroupDefinitionsForSearch(
|
||||
function getSkillNavInstallStatus(skill: {
|
||||
installed: boolean
|
||||
loading: boolean
|
||||
updateAvailable?: boolean
|
||||
}): SettingsNavInstallStatus {
|
||||
if (skill.loading) {
|
||||
return 'checking'
|
||||
}
|
||||
return skill.installed ? 'installed' : 'install'
|
||||
if (!skill.installed) {
|
||||
return 'install'
|
||||
}
|
||||
return skill.updateAvailable ? 'update-available' : 'installed'
|
||||
}
|
||||
|
||||
function hasReadyVoiceModel(
|
||||
@@ -346,6 +351,10 @@ function Settings(): React.JSX.Element {
|
||||
discoveryTarget: activeSkillRuntime.discoveryTarget,
|
||||
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
|
||||
})
|
||||
// Why: mirror the setup cards — freshness only speaks for the validated global
|
||||
// rail, which doesn't run under WSL, so the nav pill stays presence-only there.
|
||||
const { inventory: skillFreshnessInventory } = useSkillFreshness()
|
||||
const skillFreshnessApplies = activeSkillRuntime.agentRuntime?.runtime !== 'wsl'
|
||||
const [voiceModelStatesLoading, setVoiceModelStatesLoading] = useState(showDesktopOnlySettings)
|
||||
// Why: the Terminal settings section shares one search index with the
|
||||
// sidebar. We trim platform-only entries on other platforms so search never
|
||||
@@ -724,13 +733,16 @@ function Settings(): React.JSX.Element {
|
||||
orchestrationSkill
|
||||
const { installed: computerUseSkillInstalled, loading: computerUseSkillLoading } =
|
||||
computerUseSkill
|
||||
const eligibleUpdateSkillNames = skillFreshnessInventory?.eligibleUpdateNames
|
||||
const capabilityInstallStatusBySectionId = useMemo(() => {
|
||||
const eligibleUpdates = new Set(skillFreshnessApplies ? (eligibleUpdateSkillNames ?? []) : [])
|
||||
const next = new Map<string, SettingsNavInstallStatus>([
|
||||
[
|
||||
'orchestration',
|
||||
getSkillNavInstallStatus({
|
||||
installed: orchestrationSkillInstalled,
|
||||
loading: orchestrationSkillLoading
|
||||
loading: orchestrationSkillLoading,
|
||||
updateAvailable: eligibleUpdates.has(ORCHESTRATION_SKILL_NAME)
|
||||
})
|
||||
]
|
||||
])
|
||||
@@ -739,7 +751,8 @@ function Settings(): React.JSX.Element {
|
||||
'computer-use',
|
||||
getSkillNavInstallStatus({
|
||||
installed: computerUseSkillInstalled,
|
||||
loading: computerUseSkillLoading
|
||||
loading: computerUseSkillLoading,
|
||||
updateAvailable: eligibleUpdates.has(COMPUTER_USE_SKILL_NAME)
|
||||
})
|
||||
)
|
||||
if (settings) {
|
||||
@@ -757,11 +770,13 @@ function Settings(): React.JSX.Element {
|
||||
}, [
|
||||
computerUseSkillInstalled,
|
||||
computerUseSkillLoading,
|
||||
eligibleUpdateSkillNames,
|
||||
modelStates,
|
||||
orchestrationSkillInstalled,
|
||||
orchestrationSkillLoading,
|
||||
settings,
|
||||
showDesktopOnlySettings,
|
||||
skillFreshnessApplies,
|
||||
voiceModelStatesLoading
|
||||
])
|
||||
const navSections = useMemo(
|
||||
|
||||
@@ -155,6 +155,11 @@ export function SettingsSidebar({
|
||||
)
|
||||
case 'installed':
|
||||
return translate('auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', 'Installed')
|
||||
case 'update-available':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessStatusPill.updateAvailable',
|
||||
'Update available'
|
||||
)
|
||||
case 'checking':
|
||||
return translate('auto.components.settings.AgentSkillSetupPanel.68a468752e', 'Checking...')
|
||||
}
|
||||
@@ -164,9 +169,11 @@ export function SettingsSidebar({
|
||||
'ml-auto shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-medium leading-none',
|
||||
status === 'installed'
|
||||
? 'border-status-success-border bg-status-success-background text-status-success'
|
||||
: status === 'install'
|
||||
? 'border-foreground/15 bg-foreground/10 text-foreground'
|
||||
: 'border-border/50 bg-muted/30 text-muted-foreground'
|
||||
: status === 'update-available'
|
||||
? 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300'
|
||||
: status === 'install'
|
||||
? 'border-foreground/15 bg-foreground/10 text-foreground'
|
||||
: 'border-border/50 bg-muted/30 text-muted-foreground'
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
SkillFreshnessInstallation,
|
||||
SkillFreshnessInventory
|
||||
} from '../../../../shared/skill-freshness'
|
||||
import { SkillFreshnessNudge } from './SkillFreshnessNudge'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dismissed: [] as string[],
|
||||
updateSettings: vi.fn(),
|
||||
toastInfo: vi.fn(),
|
||||
toastDismiss: vi.fn(),
|
||||
requestDialog: vi.fn(),
|
||||
settingsLoaded: true,
|
||||
inventory: null as SkillFreshnessInventory | null,
|
||||
error: null as string | null
|
||||
}))
|
||||
|
||||
function placement(
|
||||
overrides: Partial<SkillFreshnessInstallation> = {}
|
||||
): SkillFreshnessInstallation {
|
||||
return {
|
||||
id: 'orca-cli',
|
||||
name: 'orca-cli',
|
||||
rootId: 'home-agents',
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Agent skills home',
|
||||
unresolvedPath: '/home/.agents/skills/orca-cli',
|
||||
resolvedPath: '/home/.agents/skills/orca-cli',
|
||||
physicalIdentity: 'physical-orca-cli',
|
||||
topology: 'canonical-copy',
|
||||
status: 'outdated',
|
||||
installedReleaseRevision: 1,
|
||||
installedAppVersion: '1.0.0',
|
||||
currentReleaseRevision: 2,
|
||||
currentPackageDigest: 'current',
|
||||
currentAppVersion: '2.0.0',
|
||||
observedPackageDigest: 'old',
|
||||
errorCategory: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function eligibleInventory(): SkillFreshnessInventory {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
installations: [placement()],
|
||||
eligibleUpdateNames: ['orca-cli'],
|
||||
scannedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/hooks/useSkillFreshness', () => ({
|
||||
useSkillFreshness: () => ({
|
||||
inventory: mocks.inventory,
|
||||
loading: false,
|
||||
error: mocks.error,
|
||||
refresh: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: mocks.toastInfo, dismiss: mocks.toastDismiss }
|
||||
}))
|
||||
|
||||
vi.mock('./skill-freshness-update-dialog', () => ({
|
||||
requestSkillFreshnessUpdateDialog: mocks.requestDialog
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const state = () => ({
|
||||
settings: mocks.settingsLoaded ? { dismissedSkillFreshnessNudges: mocks.dismissed } : null,
|
||||
updateSettings: mocks.updateSettings
|
||||
})
|
||||
const useAppStore = (selector: (value: ReturnType<typeof state>) => unknown) => selector(state())
|
||||
useAppStore.getState = state
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
async function renderNudge(): Promise<void> {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
await act(async () => {
|
||||
root?.render(<SkillFreshnessNudge />)
|
||||
})
|
||||
}
|
||||
|
||||
async function rerenderNudge(): Promise<void> {
|
||||
await act(async () => {
|
||||
root?.render(<SkillFreshnessNudge />)
|
||||
})
|
||||
}
|
||||
|
||||
const DISMISSAL_KEY = ['physical-orca-cli', 'orca-cli', '2'].join('\0')
|
||||
|
||||
describe('SkillFreshnessNudge', () => {
|
||||
beforeEach(() => {
|
||||
mocks.dismissed = []
|
||||
mocks.settingsLoaded = true
|
||||
mocks.inventory = eligibleInventory()
|
||||
mocks.error = null
|
||||
mocks.updateSettings.mockReset()
|
||||
mocks.updateSettings.mockResolvedValue(undefined)
|
||||
mocks.toastInfo.mockReset()
|
||||
mocks.toastInfo.mockReturnValue('freshness-toast')
|
||||
mocks.toastDismiss.mockReset()
|
||||
mocks.requestDialog.mockReset()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => root?.unmount())
|
||||
}
|
||||
root = null
|
||||
container?.remove()
|
||||
container = null
|
||||
})
|
||||
|
||||
it('lingers without an auto-close timer or auto-close dismissal write', async () => {
|
||||
await renderNudge()
|
||||
|
||||
expect(mocks.toastInfo).toHaveBeenCalledTimes(1)
|
||||
const options = mocks.toastInfo.mock.calls[0]?.[1]
|
||||
expect(options.duration).toBe(Number.POSITIVE_INFINITY)
|
||||
expect(options.onAutoClose).toBeUndefined()
|
||||
expect(mocks.updateSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the update dialog on action click without persisting a dismissal', async () => {
|
||||
await renderNudge()
|
||||
|
||||
const options = mocks.toastInfo.mock.calls[0]?.[1]
|
||||
options.action.onClick()
|
||||
options.onDismiss()
|
||||
|
||||
expect(mocks.requestDialog).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.updateSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retracts a resolved nudge without recording a dismissal', async () => {
|
||||
await renderNudge()
|
||||
const options = mocks.toastInfo.mock.calls[0]?.[1]
|
||||
|
||||
mocks.inventory = {
|
||||
schemaVersion: 1,
|
||||
installations: [placement({ status: 'current', observedPackageDigest: 'current' })],
|
||||
eligibleUpdateNames: [],
|
||||
scannedAt: 2
|
||||
}
|
||||
await rerenderNudge()
|
||||
// Sonner invokes onDismiss for programmatic dismissals on its next render.
|
||||
options.onDismiss()
|
||||
|
||||
expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast')
|
||||
expect(mocks.updateSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retracts a stale nudge when re-inventory fails', async () => {
|
||||
await renderNudge()
|
||||
const options = mocks.toastInfo.mock.calls[0]?.[1]
|
||||
|
||||
mocks.inventory = null
|
||||
mocks.error = 'scan failed'
|
||||
await rerenderNudge()
|
||||
options.onDismiss()
|
||||
|
||||
expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast')
|
||||
expect(mocks.updateSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists the exact placement/revision key once on explicit dismissal', async () => {
|
||||
await renderNudge()
|
||||
|
||||
const options = mocks.toastInfo.mock.calls[0]?.[1]
|
||||
options.onDismiss()
|
||||
options.onDismiss()
|
||||
|
||||
expect(mocks.updateSettings).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.updateSettings).toHaveBeenCalledWith({
|
||||
dismissedSkillFreshnessNudges: [DISMISSAL_KEY]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not repeat the same nudge within a session', async () => {
|
||||
await renderNudge()
|
||||
// A fresh inventory object with identical content re-runs the effect.
|
||||
mocks.inventory = eligibleInventory()
|
||||
await rerenderNudge()
|
||||
|
||||
expect(mocks.toastInfo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not repeat a nudge for an already dismissed exact tuple', async () => {
|
||||
mocks.dismissed = [DISMISSAL_KEY]
|
||||
|
||||
await renderNudge()
|
||||
|
||||
expect(mocks.toastInfo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not nudge for a poisoned name with no eligible update', async () => {
|
||||
mocks.inventory = {
|
||||
schemaVersion: 1,
|
||||
installations: [placement(), placement({ id: 'repo-copy', topology: 'repo-scope' })],
|
||||
eligibleUpdateNames: [],
|
||||
scannedAt: 1
|
||||
}
|
||||
|
||||
await renderNudge()
|
||||
|
||||
expect(mocks.toastInfo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for persisted settings before deciding whether to nudge', async () => {
|
||||
mocks.settingsLoaded = false
|
||||
|
||||
await renderNudge()
|
||||
|
||||
expect(mocks.toastInfo).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Terminal } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useSkillFreshness } from '@/hooks/useSkillFreshness'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import { requestSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog'
|
||||
|
||||
const MAX_DISMISSED_FRESHNESS_NUDGES = 512
|
||||
const NO_DISMISSED_FRESHNESS_NUDGES: string[] = []
|
||||
|
||||
type ActiveFreshnessNudge = {
|
||||
id: string | number
|
||||
fingerprint: string
|
||||
persistDismissal: boolean
|
||||
}
|
||||
|
||||
function candidateKey(args: {
|
||||
physicalIdentity: string
|
||||
name: string
|
||||
currentReleaseRevision: number
|
||||
}): string {
|
||||
return [args.physicalIdentity, args.name, args.currentReleaseRevision].join('\0')
|
||||
}
|
||||
|
||||
export function SkillFreshnessNudge(): null {
|
||||
const state = useSkillFreshness()
|
||||
const settingsLoaded = useAppStore((store) => store.settings !== null)
|
||||
const dismissed = useAppStore(
|
||||
(store) => store.settings?.dismissedSkillFreshnessNudges ?? NO_DISMISSED_FRESHNESS_NUDGES
|
||||
)
|
||||
const updateSettings = useAppStore((store) => store.updateSettings)
|
||||
const shownFingerprints = useRef(new Set<string>())
|
||||
const persistedFingerprints = useRef(new Set<string>())
|
||||
const activeNudgeRef = useRef<ActiveFreshnessNudge | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const inventory = state.inventory
|
||||
if (!settingsLoaded) {
|
||||
return
|
||||
}
|
||||
if (!inventory) {
|
||||
const active = activeNudgeRef.current
|
||||
if (state.error && active) {
|
||||
// Why: a failed re-check cannot keep advertising authority derived from
|
||||
// old bytes; retract without turning the scan failure into a dismissal.
|
||||
active.persistDismissal = false
|
||||
activeNudgeRef.current = null
|
||||
toast.dismiss(active.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
const eligibleNames = new Set(inventory.eligibleUpdateNames)
|
||||
const candidates = inventory.installations.flatMap((installation) =>
|
||||
installation.status === 'outdated' &&
|
||||
eligibleNames.has(installation.name) &&
|
||||
installation.physicalIdentity
|
||||
? [
|
||||
{
|
||||
key: candidateKey({
|
||||
physicalIdentity: installation.physicalIdentity,
|
||||
name: installation.name,
|
||||
currentReleaseRevision: installation.currentReleaseRevision
|
||||
}),
|
||||
name: installation.name
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
const dismissedKeys = new Set(dismissed)
|
||||
const unseen = candidates.filter((candidate) => !dismissedKeys.has(candidate.key))
|
||||
if (unseen.length === 0) {
|
||||
const active = activeNudgeRef.current
|
||||
if (active) {
|
||||
// Why: a resolved/replaced nudge is stale presentation, not an explicit
|
||||
// user dismissal, so retract it without persisting its tuple keys.
|
||||
active.persistDismissal = false
|
||||
activeNudgeRef.current = null
|
||||
toast.dismiss(active.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
const fingerprint = unseen
|
||||
.map((candidate) => candidate.key)
|
||||
.sort((left, right) => left.localeCompare(right, 'en'))
|
||||
.join('\n')
|
||||
const active = activeNudgeRef.current
|
||||
if (active?.fingerprint === fingerprint) {
|
||||
return
|
||||
}
|
||||
if (active) {
|
||||
active.persistDismissal = false
|
||||
activeNudgeRef.current = null
|
||||
toast.dismiss(active.id)
|
||||
}
|
||||
if (shownFingerprints.current.has(fingerprint)) {
|
||||
return
|
||||
}
|
||||
shownFingerprints.current.add(fingerprint)
|
||||
|
||||
const persistDismissal = (): void => {
|
||||
if (persistedFingerprints.current.has(fingerprint)) {
|
||||
return
|
||||
}
|
||||
persistedFingerprints.current.add(fingerprint)
|
||||
const current = useAppStore.getState().settings?.dismissedSkillFreshnessNudges ?? []
|
||||
const next = [...new Set([...current, ...unseen.map((candidate) => candidate.key)])].slice(
|
||||
-MAX_DISMISSED_FRESHNESS_NUDGES
|
||||
)
|
||||
void updateSettings({ dismissedSkillFreshnessNudges: next }).catch(() => {
|
||||
persistedFingerprints.current.delete(fingerprint)
|
||||
})
|
||||
}
|
||||
const names = new Set(unseen.map((candidate) => candidate.name))
|
||||
// Why: name the outdated skills so the nudge is actionable without opening
|
||||
// the modal; the sentence is translatable but the identifiers interpolate as-is.
|
||||
const outdatedNames = [...names]
|
||||
.sort((left, right) => left.localeCompare(right, 'en'))
|
||||
.join(', ')
|
||||
const nextActive: ActiveFreshnessNudge = {
|
||||
id: '',
|
||||
fingerprint,
|
||||
persistDismissal: true
|
||||
}
|
||||
nextActive.id = toast.info(
|
||||
names.size === 1
|
||||
? translate(
|
||||
'auto.components.skills.SkillFreshnessNudge.titleOne',
|
||||
'An installed Orca skill is out of date'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillFreshnessNudge.titleMany',
|
||||
'{{value0}} installed Orca skills are out of date',
|
||||
{ value0: names.size }
|
||||
),
|
||||
{
|
||||
description: translate(
|
||||
'auto.components.skills.SkillFreshnessNudge.description',
|
||||
'Update {{value0}} so agents follow the current instructions for this version of Orca.',
|
||||
{ value0: outdatedNames }
|
||||
),
|
||||
// Why: the nudge lingers until the user acts. Ignoring it (app quit)
|
||||
// records nothing, so a still-outdated skill may prompt once next launch.
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
// Why: only an explicit dismissal (the close button) records the keys;
|
||||
// opening the review dialog is engagement, not a decision to hide it.
|
||||
onDismiss: () => {
|
||||
if (nextActive.persistDismissal) {
|
||||
persistDismissal()
|
||||
}
|
||||
if (activeNudgeRef.current === nextActive) {
|
||||
activeNudgeRef.current = null
|
||||
}
|
||||
},
|
||||
action: {
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Terminal className="size-3.5" />
|
||||
{names.size === 1
|
||||
? translate('auto.components.skills.SkillFreshnessNudge.updateOne', 'Update skill')
|
||||
: translate(
|
||||
'auto.components.skills.SkillFreshnessNudge.updateMany',
|
||||
'Update skills'
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
onClick: () => {
|
||||
// Sonner closes action toasts without onDismiss; clear ownership so
|
||||
// a later inventory cannot treat the already-closed toast as active.
|
||||
nextActive.persistDismissal = false
|
||||
if (activeNudgeRef.current === nextActive) {
|
||||
activeNudgeRef.current = null
|
||||
}
|
||||
requestSkillFreshnessUpdateDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
activeNudgeRef.current = nextActive
|
||||
}, [dismissed, settingsLoaded, state.error, state.inventory, updateSettings])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness'
|
||||
import { SkillFreshnessStatusPill } from './SkillFreshnessStatusPill'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
inventory: null as SkillFreshnessInventory | null
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useSkillFreshness', () => ({
|
||||
useSkillFreshness: () => ({
|
||||
inventory: mocks.inventory,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
function inventory(
|
||||
entries: { name: string; status: 'current' | 'outdated' | 'unrecognized' }[],
|
||||
eligibleUpdateNames: string[]
|
||||
): SkillFreshnessInventory {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
installations: entries.map((entry, index) => ({
|
||||
id: `${entry.name}-${index}`,
|
||||
name: entry.name,
|
||||
rootId: 'home-agents',
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Agent skills home',
|
||||
unresolvedPath: `/home/.agents/skills/${entry.name}`,
|
||||
resolvedPath: `/home/.agents/skills/${entry.name}`,
|
||||
physicalIdentity: `physical-${entry.name}-${index}`,
|
||||
topology: 'canonical-copy',
|
||||
status: entry.status,
|
||||
installedReleaseRevision: 1,
|
||||
installedAppVersion: '1.0.0',
|
||||
currentReleaseRevision: 2,
|
||||
currentPackageDigest: 'current',
|
||||
currentAppVersion: '2.0.0',
|
||||
observedPackageDigest: 'old',
|
||||
errorCategory: null
|
||||
})),
|
||||
eligibleUpdateNames,
|
||||
scannedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
async function renderPill(skillName: string): Promise<HTMLDivElement> {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
await act(async () => {
|
||||
root?.render(<SkillFreshnessStatusPill skillName={skillName} />)
|
||||
})
|
||||
return container
|
||||
}
|
||||
|
||||
describe('SkillFreshnessStatusPill', () => {
|
||||
beforeEach(() => {
|
||||
mocks.inventory = null
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => root?.unmount())
|
||||
}
|
||||
root = null
|
||||
container?.remove()
|
||||
container = null
|
||||
})
|
||||
|
||||
it('shows Update available for an eligible outdated skill', async () => {
|
||||
mocks.inventory = inventory([{ name: 'orca-cli', status: 'outdated' }], ['orca-cli'])
|
||||
|
||||
expect((await renderPill('orca-cli')).textContent).toBe('Update available')
|
||||
})
|
||||
|
||||
it('shows Up to date when every placement is current', async () => {
|
||||
mocks.inventory = inventory([{ name: 'orca-cli', status: 'current' }], [])
|
||||
|
||||
expect((await renderPill('orca-cli')).textContent).toBe('Up to date')
|
||||
})
|
||||
|
||||
it('falls back to Installed for a blocked outdated placement', async () => {
|
||||
mocks.inventory = inventory(
|
||||
[
|
||||
{ name: 'orca-cli', status: 'outdated' },
|
||||
{ name: 'orca-cli', status: 'unrecognized' }
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
expect((await renderPill('orca-cli')).textContent).toBe('Installed')
|
||||
})
|
||||
|
||||
it('falls back to Installed before the inventory loads', async () => {
|
||||
expect((await renderPill('orca-cli')).textContent).toBe('Installed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useSkillFreshness } from '@/hooks/useSkillFreshness'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { IntegrationStatusPill } from '@/components/integration-status-pill'
|
||||
|
||||
// Why: the setup rails' Installed pill is presence-only; when freshness knows a
|
||||
// safe update exists (or that every copy is current) the pill should say so.
|
||||
// Falls back to plain Installed for blocked/unrecognized copies so an unsafe
|
||||
// placement is never advertised as updatable here.
|
||||
export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): React.JSX.Element {
|
||||
const { inventory } = useSkillFreshness()
|
||||
if (inventory?.eligibleUpdateNames.includes(skillName)) {
|
||||
return (
|
||||
<IntegrationStatusPill tone="attention">
|
||||
{translate(
|
||||
'auto.components.skills.SkillFreshnessStatusPill.updateAvailable',
|
||||
'Update available'
|
||||
)}
|
||||
</IntegrationStatusPill>
|
||||
)
|
||||
}
|
||||
const placements = inventory?.installations.filter(
|
||||
(installation) => installation.name === skillName
|
||||
)
|
||||
if (
|
||||
placements &&
|
||||
placements.length > 0 &&
|
||||
placements.every((installation) => installation.status === 'current')
|
||||
) {
|
||||
return (
|
||||
<IntegrationStatusPill tone="connected">
|
||||
{translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date')}
|
||||
</IntegrationStatusPill>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<IntegrationStatusPill tone="connected">
|
||||
{translate('auto.components.skills.SkillFreshnessStatusPill.installed', 'Installed')}
|
||||
</IntegrationStatusPill>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, useState, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
SkillFreshnessInstallation,
|
||||
SkillFreshnessInventory
|
||||
} from '../../../../shared/skill-freshness'
|
||||
import { SkillFreshnessUpdateDialog } from './SkillFreshnessUpdateDialog'
|
||||
import {
|
||||
consumeSkillFreshnessUpdateDialogRequest,
|
||||
requestSkillFreshnessUpdateDialog
|
||||
} from './skill-freshness-update-dialog'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
inventory: null as SkillFreshnessInventory | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
refresh: vi.fn(),
|
||||
terminalProps: [] as {
|
||||
command: string
|
||||
description: string
|
||||
onInteracted?: (method: 'keyboard' | 'pointer', event?: { key?: string }) => void
|
||||
onTerminalExit?: () => void
|
||||
}[],
|
||||
notifyChanged: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useSkillFreshness', () => ({
|
||||
useSkillFreshness: () => ({
|
||||
inventory: mocks.inventory,
|
||||
loading: mocks.loading,
|
||||
error: mocks.error,
|
||||
refresh: mocks.refresh
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useInstalledAgentSkills', () => ({
|
||||
notifyInstalledAgentSkillsChanged: mocks.notifyChanged
|
||||
}))
|
||||
|
||||
vi.mock('@/components/onboarding/OnboardingInlineCommandTerminal', () => ({
|
||||
OnboardingInlineCommandTerminal: (props: (typeof mocks.terminalProps)[number]) => {
|
||||
mocks.terminalProps.push(props)
|
||||
return <div data-testid="update-terminal">{props.command}</div>
|
||||
}
|
||||
}))
|
||||
|
||||
// Radix Dialog/Collapsible internals (portal, focus-scope) are exercised in
|
||||
// Electron QA; here the content logic is what matters, so use plain wrappers.
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children?: ReactNode }) =>
|
||||
open ? <div data-dialog-open="true">{children}</div> : null,
|
||||
DialogContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: { children?: ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/collapsible', () => ({
|
||||
Collapsible: ({
|
||||
children,
|
||||
defaultOpen = false
|
||||
}: {
|
||||
children?: ReactNode
|
||||
defaultOpen?: boolean
|
||||
}) => {
|
||||
// Model Radix's uncontrolled default: changing defaultOpen only matters
|
||||
// when the keyed disclosure remounts.
|
||||
const [open] = useState(defaultOpen)
|
||||
return <div data-collapsible-open={String(open)}>{children}</div>
|
||||
},
|
||||
CollapsibleTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
CollapsibleContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
function placement(
|
||||
name: string,
|
||||
overrides: Partial<SkillFreshnessInstallation> = {}
|
||||
): SkillFreshnessInstallation {
|
||||
return {
|
||||
id: `${name}-${overrides.rootId ?? 'home-agents'}`,
|
||||
name,
|
||||
rootId: 'home-agents',
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Agent skills home',
|
||||
unresolvedPath: `/home/.agents/skills/${name}`,
|
||||
resolvedPath: `/home/.agents/skills/${name}`,
|
||||
physicalIdentity: `physical-${name}`,
|
||||
topology: 'canonical-copy',
|
||||
status: 'outdated',
|
||||
installedReleaseRevision: 1,
|
||||
installedAppVersion: '1.0.0',
|
||||
currentReleaseRevision: 2,
|
||||
currentPackageDigest: 'current',
|
||||
currentAppVersion: '2.0.0',
|
||||
observedPackageDigest: 'old',
|
||||
errorCategory: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function eligibleInventory(): SkillFreshnessInventory {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
installations: [placement('orca-cli')],
|
||||
eligibleUpdateNames: ['orca-cli'],
|
||||
scannedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
async function renderDialog(): Promise<void> {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
await act(async () => {
|
||||
root?.render(<SkillFreshnessUpdateDialog />)
|
||||
})
|
||||
}
|
||||
|
||||
async function rerender(): Promise<void> {
|
||||
await act(async () => {
|
||||
root?.render(<SkillFreshnessUpdateDialog />)
|
||||
})
|
||||
}
|
||||
|
||||
async function openViaRequest(): Promise<void> {
|
||||
await act(async () => {
|
||||
requestSkillFreshnessUpdateDialog()
|
||||
})
|
||||
}
|
||||
|
||||
async function clickButton(label: string): Promise<void> {
|
||||
const button = Array.from(container?.querySelectorAll('button') ?? []).find(
|
||||
(candidate) => candidate.textContent?.trim() === label
|
||||
)
|
||||
expect(button).toBeDefined()
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
describe('SkillFreshnessUpdateDialog', () => {
|
||||
beforeEach(() => {
|
||||
consumeSkillFreshnessUpdateDialogRequest()
|
||||
mocks.inventory = eligibleInventory()
|
||||
mocks.loading = false
|
||||
mocks.error = null
|
||||
mocks.refresh.mockReset()
|
||||
mocks.notifyChanged.mockReset()
|
||||
mocks.terminalProps.length = 0
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => root?.unmount())
|
||||
}
|
||||
root = null
|
||||
container?.remove()
|
||||
container = null
|
||||
})
|
||||
|
||||
it('stays closed until an open request arrives', async () => {
|
||||
await renderDialog()
|
||||
expect(container?.querySelector('[data-dialog-open]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the eligible summary and the exact pre-filled draft command', async () => {
|
||||
await renderDialog()
|
||||
await openViaRequest()
|
||||
|
||||
expect(container?.textContent).toContain('Update skills')
|
||||
expect(container?.textContent).toContain('1 skill can be updated safely')
|
||||
expect(mocks.terminalProps.at(-1)).toMatchObject({
|
||||
command: 'npx skills update orca-cli --global',
|
||||
description: 'Review the pre-filled command, then press Enter to run it.'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a request made before inventory loads once a safe command arrives', async () => {
|
||||
mocks.inventory = null
|
||||
await openViaRequest()
|
||||
await renderDialog()
|
||||
|
||||
expect(container?.querySelector('[data-dialog-open]')).not.toBeNull()
|
||||
expect(mocks.terminalProps).toEqual([])
|
||||
|
||||
mocks.inventory = eligibleInventory()
|
||||
await rerender()
|
||||
|
||||
expect(mocks.terminalProps.at(-1)?.command).toBe('npx skills update orca-cli --global')
|
||||
})
|
||||
|
||||
it('shows the up-to-date state once every installation is current', async () => {
|
||||
await renderDialog()
|
||||
await openViaRequest()
|
||||
|
||||
mocks.inventory = {
|
||||
schemaVersion: 1,
|
||||
installations: [
|
||||
placement('orca-cli', { status: 'current', observedPackageDigest: 'current' })
|
||||
],
|
||||
eligibleUpdateNames: [],
|
||||
scannedAt: 2
|
||||
}
|
||||
await rerender()
|
||||
|
||||
expect(container?.textContent).toContain('All installed Orca skills are up to date.')
|
||||
expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('auto-expands Details when a rescan changes the result to blocked', async () => {
|
||||
await renderDialog()
|
||||
await openViaRequest()
|
||||
expect(container?.querySelector('[data-collapsible-open="false"]')).not.toBeNull()
|
||||
|
||||
mocks.inventory = {
|
||||
schemaVersion: 1,
|
||||
installations: [placement('orca-cli', { topology: 'read-only' })],
|
||||
eligibleUpdateNames: [],
|
||||
scannedAt: 2
|
||||
}
|
||||
await rerender()
|
||||
|
||||
expect(container?.querySelector('[data-collapsible-open="true"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not replace or tear down a submitted command during a rescan', async () => {
|
||||
await renderDialog()
|
||||
await openViaRequest()
|
||||
|
||||
await act(async () => {
|
||||
mocks.terminalProps.at(-1)?.onInteracted?.('keyboard', { key: 'Enter' })
|
||||
})
|
||||
mocks.inventory = null
|
||||
mocks.loading = true
|
||||
await rerender()
|
||||
|
||||
expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe(
|
||||
'npx skills update orca-cli --global'
|
||||
)
|
||||
|
||||
mocks.inventory = {
|
||||
schemaVersion: 1,
|
||||
installations: [
|
||||
placement('orca-cli', { status: 'current', observedPackageDigest: 'current' })
|
||||
],
|
||||
eligibleUpdateNames: [],
|
||||
scannedAt: 2
|
||||
}
|
||||
mocks.loading = false
|
||||
await rerender()
|
||||
|
||||
expect(container?.textContent).toContain('All installed Orca skills are up to date.')
|
||||
expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe(
|
||||
'npx skills update orca-cli --global'
|
||||
)
|
||||
})
|
||||
|
||||
it('removes an unsubmitted draft as soon as its inventory is invalidated', async () => {
|
||||
await renderDialog()
|
||||
await openViaRequest()
|
||||
expect(container?.querySelector('[data-testid="update-terminal"]')).not.toBeNull()
|
||||
|
||||
mocks.inventory = null
|
||||
mocks.loading = true
|
||||
await rerender()
|
||||
|
||||
expect(container?.textContent).toContain('Checking installed Orca skills')
|
||||
expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a failed scan as an error instead of indefinite progress', async () => {
|
||||
mocks.inventory = null
|
||||
mocks.error = 'Could not inspect installed skills.'
|
||||
await renderDialog()
|
||||
await openViaRequest()
|
||||
|
||||
expect(container?.textContent).toContain('Could not inspect installed skills.')
|
||||
expect(container?.textContent).not.toContain('Checking installed Orca skills')
|
||||
expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('creates a fresh draft after a terminal exits and the rescan still finds an update', async () => {
|
||||
await renderDialog()
|
||||
await openViaRequest()
|
||||
const firstTerminal = mocks.terminalProps.at(-1)
|
||||
|
||||
await act(async () => {
|
||||
firstTerminal?.onTerminalExit?.()
|
||||
})
|
||||
expect(mocks.notifyChanged).toHaveBeenCalledTimes(1)
|
||||
expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
|
||||
|
||||
mocks.inventory = { ...eligibleInventory(), scannedAt: 2 }
|
||||
await rerender()
|
||||
|
||||
expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe(
|
||||
'npx skills update orca-cli --global'
|
||||
)
|
||||
expect(mocks.terminalProps.at(-1)).not.toBe(firstTerminal)
|
||||
})
|
||||
|
||||
it('explains a poisoned sibling in Details without offering a command', async () => {
|
||||
mocks.inventory = {
|
||||
schemaVersion: 1,
|
||||
installations: [
|
||||
placement('orca-cli'),
|
||||
placement('orca-cli', {
|
||||
id: 'repo-copy',
|
||||
rootId: 'repo',
|
||||
sourceKind: 'repo',
|
||||
topology: 'repo-scope',
|
||||
status: 'unrecognized',
|
||||
unresolvedPath: '/repo/.agents/skills/orca-cli'
|
||||
})
|
||||
],
|
||||
eligibleUpdateNames: [],
|
||||
scannedAt: 1
|
||||
}
|
||||
await renderDialog()
|
||||
await openViaRequest()
|
||||
|
||||
expect(container?.textContent).toContain(
|
||||
'it may be modified, or a different skill with the same name'
|
||||
)
|
||||
expect(container?.textContent).toContain('Unrecognized')
|
||||
expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('explains a self-blocked read-only placement without blaming a sibling', async () => {
|
||||
mocks.inventory = {
|
||||
schemaVersion: 1,
|
||||
installations: [placement('orca-cli', { topology: 'read-only' })],
|
||||
eligibleUpdateNames: [],
|
||||
scannedAt: 1
|
||||
}
|
||||
await renderDialog()
|
||||
await openViaRequest()
|
||||
|
||||
expect(container?.textContent).toContain(
|
||||
'read-only location, so Orca left it out of the update'
|
||||
)
|
||||
expect(container?.textContent).toContain('Read only')
|
||||
})
|
||||
|
||||
it('re-inventories installed skills when the dialog closes', async () => {
|
||||
await renderDialog()
|
||||
await openViaRequest()
|
||||
|
||||
await clickButton('Close')
|
||||
|
||||
expect(mocks.notifyChanged).toHaveBeenCalledTimes(1)
|
||||
expect(container?.querySelector('[data-dialog-open]')).toBeNull()
|
||||
})
|
||||
|
||||
it('forces a re-check from the Re-check affordance', async () => {
|
||||
await renderDialog()
|
||||
await openViaRequest()
|
||||
|
||||
await clickButton('Re-check')
|
||||
|
||||
expect(mocks.refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,310 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type KeyboardEvent
|
||||
} from 'react'
|
||||
import { AlertTriangle, CheckCircle2, ChevronDown, Loader2, RefreshCw } from 'lucide-react'
|
||||
import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness'
|
||||
import { buildTargetedSkillUpdateCommand } from '../../../../shared/skill-freshness'
|
||||
import { useSkillFreshness } from '@/hooks/useSkillFreshness'
|
||||
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { OnboardingInlineCommandTerminal } from '@/components/onboarding/OnboardingInlineCommandTerminal'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { groupSkillFreshness } from './skill-freshness-grouping'
|
||||
import { SkillFreshnessGroup } from './skill-freshness-group'
|
||||
import {
|
||||
consumeSkillFreshnessUpdateDialogRequest,
|
||||
getSkillFreshnessUpdateDialogRequest,
|
||||
subscribeSkillFreshnessUpdateDialog
|
||||
} from './skill-freshness-update-dialog'
|
||||
|
||||
type FreshnessSummaryKind = 'loading' | 'empty' | 'eligible' | 'current' | 'attention'
|
||||
|
||||
function summarizeInventory(
|
||||
inventory: SkillFreshnessInventory | null,
|
||||
hasBlockedGroup: boolean
|
||||
): FreshnessSummaryKind {
|
||||
if (!inventory) {
|
||||
return 'loading'
|
||||
}
|
||||
if (inventory.installations.length === 0) {
|
||||
return 'empty'
|
||||
}
|
||||
if (inventory.eligibleUpdateNames.length > 0) {
|
||||
return 'eligible'
|
||||
}
|
||||
// Why: with nothing eligible, the modal is either genuinely all-clear or has
|
||||
// out-of-date skills it can't safely update; the group filter already dropped
|
||||
// the up-to-date and unrecognized-only noise, so a blocked group is the signal.
|
||||
return hasBlockedGroup ? 'attention' : 'current'
|
||||
}
|
||||
|
||||
function SummaryHeadline({
|
||||
kind,
|
||||
eligibleCount
|
||||
}: {
|
||||
kind: FreshnessSummaryKind
|
||||
eligibleCount: number
|
||||
}): React.JSX.Element {
|
||||
if (kind === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.checking',
|
||||
'Checking installed Orca skills…'
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (kind === 'empty') {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.none',
|
||||
'No installed Orca skills found.'
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (kind === 'current') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<CheckCircle2 className="size-4 text-emerald-600 dark:text-emerald-400" />
|
||||
{translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.success',
|
||||
'All installed Orca skills are up to date.'
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (kind === 'attention') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-400" />
|
||||
{translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.attention',
|
||||
'Some installed Orca skills were left out of the update.'
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.attentionDescription',
|
||||
'Open Update details to see why each one was skipped.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{eligibleCount === 1
|
||||
? translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.updateOne',
|
||||
'1 skill can be updated safely'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.updateMany',
|
||||
'{{value0}} skills can be updated safely',
|
||||
{ value0: eligibleCount }
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkillFreshnessUpdateDialog(): React.JSX.Element {
|
||||
const state = useSkillFreshness()
|
||||
const open = useSyncExternalStore(
|
||||
subscribeSkillFreshnessUpdateDialog,
|
||||
getSkillFreshnessUpdateDialogRequest,
|
||||
getSkillFreshnessUpdateDialogRequest
|
||||
)
|
||||
const [terminalCommand, setTerminalCommand] = useState<string | null>(null)
|
||||
const [awaitingExitRefresh, setAwaitingExitRefresh] = useState(false)
|
||||
const terminalSubmittedRef = useRef(false)
|
||||
const inventoryAtTerminalExitRef = useRef<SkillFreshnessInventory | null>(null)
|
||||
const inventory = state.inventory
|
||||
const eligibleNames = useMemo(() => inventory?.eligibleUpdateNames ?? [], [inventory])
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
inventory ? groupSkillFreshness(inventory.installations, inventory.eligibleUpdateNames) : [],
|
||||
[inventory]
|
||||
)
|
||||
const hasBlockedGroup = groups.some((group) => group.status === 'cannot-update')
|
||||
const updateCommand = buildTargetedSkillUpdateCommand(eligibleNames)
|
||||
const summaryKind = summarizeInventory(inventory, hasBlockedGroup)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
if (state.loading || state.error || !inventory) {
|
||||
// Why: a scan invalidates the authorization behind an unsubmitted draft.
|
||||
// A running command keeps its PTY until exit, but stale drafts fail closed.
|
||||
if (!terminalSubmittedRef.current && terminalCommand !== null) {
|
||||
setTerminalCommand(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (awaitingExitRefresh) {
|
||||
if (inventory === inventoryAtTerminalExitRef.current) {
|
||||
return
|
||||
}
|
||||
inventoryAtTerminalExitRef.current = null
|
||||
setAwaitingExitRefresh(false)
|
||||
return
|
||||
}
|
||||
if (terminalSubmittedRef.current || terminalCommand === updateCommand) {
|
||||
return
|
||||
}
|
||||
// Why: changing the shared onboarding terminal's command pastes it again.
|
||||
// Replace only an unsubmitted draft; a submitted command owns its PTY until exit.
|
||||
setTerminalCommand(updateCommand)
|
||||
}, [
|
||||
awaitingExitRefresh,
|
||||
inventory,
|
||||
open,
|
||||
state.error,
|
||||
state.loading,
|
||||
terminalCommand,
|
||||
updateCommand
|
||||
])
|
||||
|
||||
const handleOpenChange = (next: boolean): void => {
|
||||
// Why: closing is the natural point to re-observe bytes so a completed update
|
||||
// clears the state and the lingering nudge does not fire again.
|
||||
if (!next) {
|
||||
consumeSkillFreshnessUpdateDialogRequest()
|
||||
terminalSubmittedRef.current = false
|
||||
inventoryAtTerminalExitRef.current = null
|
||||
setAwaitingExitRefresh(false)
|
||||
setTerminalCommand(null)
|
||||
notifyInstalledAgentSkillsChanged()
|
||||
}
|
||||
}
|
||||
|
||||
const handleTerminalInteraction = (
|
||||
method: 'keyboard' | 'pointer',
|
||||
event?: KeyboardEvent<HTMLElement>
|
||||
): void => {
|
||||
if (method === 'keyboard' && event?.key === 'Enter') {
|
||||
terminalSubmittedRef.current = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleTerminalExit = (): void => {
|
||||
terminalSubmittedRef.current = false
|
||||
inventoryAtTerminalExitRef.current = inventory
|
||||
setAwaitingExitRefresh(true)
|
||||
setTerminalCommand(null)
|
||||
notifyInstalledAgentSkillsChanged()
|
||||
}
|
||||
|
||||
const hasVisibleGroups = groups.length > 0
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
className="scrollbar-sleek max-h-[85vh] overflow-y-auto sm:max-w-xl"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate('auto.components.skills.SkillFreshnessUpdateDialog.title', 'Update skills')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{state.error ? (
|
||||
<p className="text-xs text-destructive">{state.error}</p>
|
||||
) : (
|
||||
<SummaryHeadline kind={summaryKind} eligibleCount={eligibleNames.length} />
|
||||
)}
|
||||
|
||||
{terminalCommand ? (
|
||||
<OnboardingInlineCommandTerminal
|
||||
key={terminalCommand}
|
||||
command={terminalCommand}
|
||||
title={translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.terminalTitle',
|
||||
'Update Orca skills'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.terminalDescription',
|
||||
'Review the pre-filled command, then press Enter to run it.'
|
||||
)}
|
||||
ariaLabel={translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.terminalAria',
|
||||
'Orca skill update terminal'
|
||||
)}
|
||||
worktreeId="skill-freshness-update-terminal"
|
||||
terminalHeightPx={200}
|
||||
terminalTopMarginPx={0}
|
||||
autoScrollIntoView={false}
|
||||
onInteracted={handleTerminalInteraction}
|
||||
onTerminalExit={handleTerminalExit}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Why: Radix reads defaultOpen only on mount. Remount when a scan
|
||||
becomes blocked so the required diagnostic details actually open. */}
|
||||
{hasVisibleGroups ? (
|
||||
<Collapsible key={hasBlockedGroup ? 'blocked' : 'default'} defaultOpen={hasBlockedGroup}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="group -ml-2 gap-1.5 text-muted-foreground"
|
||||
>
|
||||
<ChevronDown className="size-3.5 transition-transform group-data-[state=open]:rotate-180" />
|
||||
{translate(
|
||||
'auto.components.skills.SkillFreshnessUpdateDialog.details',
|
||||
'Update details'
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1 divide-y divide-border/40 rounded-md border border-border/60 p-3">
|
||||
<TooltipProvider>
|
||||
{groups.map((group) => (
|
||||
<SkillFreshnessGroup key={group.name} group={group} />
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={state.loading}
|
||||
onClick={() => void state.refresh()}
|
||||
>
|
||||
<RefreshCw className={state.loading ? 'animate-spin' : undefined} />
|
||||
{translate('auto.components.skills.SkillFreshnessUpdateDialog.checkNow', 'Re-check')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => handleOpenChange(false)}>
|
||||
{translate('auto.components.skills.SkillFreshnessUpdateDialog.close', 'Close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type {
|
||||
SkillFreshnessGroupModel,
|
||||
SkillLocationChip,
|
||||
SkillLocationRow
|
||||
} from './skill-freshness-grouping'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
function chipLabel(chip: SkillLocationChip): string {
|
||||
switch (chip) {
|
||||
case 'current':
|
||||
return translate('auto.components.skills.SkillFreshnessRow.chipCurrent', 'Current')
|
||||
case 'unrecognized':
|
||||
return translate('auto.components.skills.SkillFreshnessRow.chipUnrecognized', 'Unrecognized')
|
||||
case 'inaccessible':
|
||||
return translate('auto.components.skills.SkillFreshnessRow.chipInaccessible', 'Inaccessible')
|
||||
case 'duplicate':
|
||||
return translate('auto.components.skills.SkillFreshnessRow.chipDuplicate', 'Duplicate')
|
||||
case 'external-link':
|
||||
return translate('auto.components.skills.SkillFreshnessRow.chipExternalLink', 'External link')
|
||||
case 'broken-link':
|
||||
return translate('auto.components.skills.SkillFreshnessRow.chipBrokenLink', 'Broken link')
|
||||
case 'read-only':
|
||||
return translate('auto.components.skills.SkillFreshnessRow.chipReadOnly', 'Read only')
|
||||
case 'in-a-repo':
|
||||
return translate('auto.components.skills.SkillFreshnessRow.chipInRepo', 'In a repo')
|
||||
case 'plugin-cache':
|
||||
return translate('auto.components.skills.SkillFreshnessRow.chipPluginCache', 'Plugin cache')
|
||||
}
|
||||
}
|
||||
|
||||
// Why: chips describe only what a location *is*; the effect on the update
|
||||
// command lives in the per-skill sentence, so the two never say it twice.
|
||||
function chipTooltip(chip: SkillLocationChip): string {
|
||||
switch (chip) {
|
||||
case 'current':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.tipCurrent',
|
||||
'This copy matches the current official version.'
|
||||
)
|
||||
case 'unrecognized':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.tipUnrecognized',
|
||||
'This copy doesn’t match any official version — it may be modified, or a different skill with the same name.'
|
||||
)
|
||||
case 'inaccessible':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.tipInaccessible',
|
||||
'Orca couldn’t read this copy (a permissions or file error).'
|
||||
)
|
||||
case 'duplicate':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.tipDuplicate',
|
||||
'A separate copy of this skill, installed apart from the main one.'
|
||||
)
|
||||
case 'external-link':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.tipExternalLink',
|
||||
'A shortcut pointing outside Orca’s skill folders.'
|
||||
)
|
||||
case 'broken-link':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.tipBrokenLink',
|
||||
'A shortcut to something that no longer exists.'
|
||||
)
|
||||
case 'read-only':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.tipReadOnly',
|
||||
'This copy is in a read-only location.'
|
||||
)
|
||||
case 'in-a-repo':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.tipInRepo',
|
||||
'This copy lives inside a project, not your global skills.'
|
||||
)
|
||||
case 'plugin-cache':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.tipPluginCache',
|
||||
'This copy is managed by a plugin.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a skill is skipped for one concrete reason; lead with the highest-priority
|
||||
// blocking placement so the sentence explains the real cause (an edited copy is
|
||||
// more useful to surface than a downstream symptom).
|
||||
const SKIPPED_REASON_PRIORITY: SkillLocationChip[] = [
|
||||
'unrecognized',
|
||||
'read-only',
|
||||
'inaccessible',
|
||||
'in-a-repo',
|
||||
'plugin-cache',
|
||||
'external-link',
|
||||
'broken-link'
|
||||
]
|
||||
|
||||
function skippedReason(locations: readonly SkillLocationRow[]): string {
|
||||
const present = new Set(locations.map((location) => location.chip))
|
||||
const chip = SKIPPED_REASON_PRIORITY.find((candidate) => present.has(candidate))
|
||||
switch (chip) {
|
||||
case 'unrecognized':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.skippedReasonUnrecognized',
|
||||
'The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.'
|
||||
)
|
||||
case 'read-only':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.skippedReasonReadOnly',
|
||||
'This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.'
|
||||
)
|
||||
case 'inaccessible':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.skippedReasonInaccessible',
|
||||
'Orca couldn’t read this copy, so it left the skill out of the update.'
|
||||
)
|
||||
case 'in-a-repo':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.skippedReasonInRepo',
|
||||
'This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.'
|
||||
)
|
||||
case 'plugin-cache':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.skippedReasonPluginCache',
|
||||
'A plugin manages this skill, so Orca left it out of the update — update the plugin instead.'
|
||||
)
|
||||
case 'external-link':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.skippedReasonExternalLink',
|
||||
'This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.'
|
||||
)
|
||||
case 'broken-link':
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.skippedReasonBrokenLink',
|
||||
'This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.'
|
||||
)
|
||||
default:
|
||||
return translate(
|
||||
'auto.components.skills.SkillFreshnessRow.cantUpdateReason',
|
||||
'Orca left this skill out of the update command.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function SkillFreshnessGroup({
|
||||
group
|
||||
}: {
|
||||
group: SkillFreshnessGroupModel
|
||||
}): React.JSX.Element {
|
||||
const isBlocked = group.status === 'cannot-update'
|
||||
return (
|
||||
<div className="space-y-2 py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{group.name}</span>
|
||||
{isBlocked ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-600/50 text-amber-700 dark:border-amber-400/40 dark:text-amber-400"
|
||||
>
|
||||
{translate('auto.components.skills.SkillFreshnessRow.statusCantUpdate', 'Skipped')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
{translate(
|
||||
'auto.components.skills.SkillFreshnessRow.statusUpdateAvailable',
|
||||
'Update available'
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isBlocked ? (
|
||||
<p className="text-xs leading-5 text-muted-foreground">{skippedReason(group.locations)}</p>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
{group.locations.map((location) => (
|
||||
<div
|
||||
key={location.id}
|
||||
className="flex min-w-0 flex-wrap items-center gap-2 border-l-2 border-border/60 pl-3"
|
||||
>
|
||||
<span
|
||||
className="truncate font-mono text-[11px] text-muted-foreground"
|
||||
title={location.path}
|
||||
>
|
||||
{location.path}
|
||||
</span>
|
||||
{location.chip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="cursor-help border-dashed">
|
||||
{chipLabel(location.chip)}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-pretty">
|
||||
{chipTooltip(location.chip)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SkillFreshnessInstallation } from '../../../../shared/skill-freshness'
|
||||
import { groupSkillFreshness } from './skill-freshness-grouping'
|
||||
|
||||
function placement(
|
||||
name: string,
|
||||
overrides: Partial<SkillFreshnessInstallation> = {}
|
||||
): SkillFreshnessInstallation {
|
||||
return {
|
||||
id: `${name}-${overrides.rootId ?? 'home-agents'}-${overrides.unresolvedPath ?? 'a'}`,
|
||||
name,
|
||||
rootId: 'home-agents',
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Agent skills home',
|
||||
unresolvedPath: `/home/.agents/skills/${name}`,
|
||||
resolvedPath: `/home/.agents/skills/${name}`,
|
||||
physicalIdentity: `physical-${name}`,
|
||||
topology: 'canonical-copy',
|
||||
status: 'outdated',
|
||||
installedReleaseRevision: 1,
|
||||
installedAppVersion: '1.0.0',
|
||||
currentReleaseRevision: 2,
|
||||
currentPackageDigest: 'current',
|
||||
currentAppVersion: '2.0.0',
|
||||
observedPackageDigest: 'old',
|
||||
errorCategory: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('groupSkillFreshness', () => {
|
||||
it('marks an eligible outdated skill as update-available with one location', () => {
|
||||
const groups = groupSkillFreshness([placement('orca-cli')], ['orca-cli'])
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]).toMatchObject({ name: 'orca-cli', status: 'update-available' })
|
||||
expect(groups[0]?.locations).toEqual([
|
||||
{ id: expect.any(String), path: '/home/.agents/skills/orca-cli', chip: null }
|
||||
])
|
||||
})
|
||||
|
||||
it('hides skills with nothing out of date (current, unrecognized-only, unreadable-only)', () => {
|
||||
const groups = groupSkillFreshness(
|
||||
[
|
||||
placement('orca-cli', { status: 'current' }),
|
||||
placement('dataviz', { status: 'unrecognized', topology: 'independent-copy' }),
|
||||
placement('linear-tickets', { status: 'inaccessible' })
|
||||
],
|
||||
[]
|
||||
)
|
||||
expect(groups).toEqual([])
|
||||
})
|
||||
|
||||
it('groups a blocked skill and flags the culprit location, not the main copy', () => {
|
||||
const groups = groupSkillFreshness(
|
||||
[
|
||||
placement('orchestration'),
|
||||
placement('orchestration', {
|
||||
rootId: 'home-claude',
|
||||
unresolvedPath: '/home/.claude/skills/orchestration',
|
||||
status: 'unrecognized',
|
||||
topology: 'independent-copy'
|
||||
})
|
||||
],
|
||||
[]
|
||||
)
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]?.status).toBe('cannot-update')
|
||||
// Why: the out-of-date main copy is bare; only the poisoning copy carries a chip.
|
||||
expect(groups[0]?.locations).toEqual([
|
||||
{ id: expect.any(String), path: '/home/.agents/skills/orchestration', chip: null },
|
||||
{ id: expect.any(String), path: '/home/.claude/skills/orchestration', chip: 'unrecognized' }
|
||||
])
|
||||
})
|
||||
|
||||
it('prefers a location status over its topology and maps every topology to a chip', () => {
|
||||
const chipFor = (overrides: Partial<SkillFreshnessInstallation>): string | null =>
|
||||
groupSkillFreshness(
|
||||
[placement('s', { status: 'outdated' }), placement('s', overrides)],
|
||||
['s']
|
||||
)[0]?.locations.find((location) => location.path.includes('culprit'))?.chip ?? null
|
||||
const at = (path: string, rest: Partial<SkillFreshnessInstallation>) => ({
|
||||
unresolvedPath: `/culprit/${path}`,
|
||||
...rest
|
||||
})
|
||||
expect(chipFor(at('a', { status: 'unrecognized', topology: 'independent-copy' }))).toBe(
|
||||
'unrecognized'
|
||||
)
|
||||
expect(chipFor(at('b', { status: 'inaccessible', topology: 'read-only' }))).toBe('inaccessible')
|
||||
expect(chipFor(at('c', { topology: 'independent-copy' }))).toBe('duplicate')
|
||||
expect(chipFor(at('d', { topology: 'external-link' }))).toBe('external-link')
|
||||
expect(chipFor(at('e', { topology: 'broken-link' }))).toBe('broken-link')
|
||||
expect(chipFor(at('f', { topology: 'read-only' }))).toBe('read-only')
|
||||
expect(chipFor(at('g', { topology: 'repo-scope' }))).toBe('in-a-repo')
|
||||
expect(chipFor(at('h', { topology: 'plugin-cache' }))).toBe('plugin-cache')
|
||||
expect(chipFor(at('i', { status: 'current', topology: 'provider-alias' }))).toBe('current')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { SkillFreshnessInstallation } from '../../../../shared/skill-freshness'
|
||||
|
||||
export type SkillGroupStatus = 'update-available' | 'cannot-update'
|
||||
|
||||
export type SkillLocationChip =
|
||||
| 'current'
|
||||
| 'unrecognized'
|
||||
| 'inaccessible'
|
||||
| 'duplicate'
|
||||
| 'external-link'
|
||||
| 'broken-link'
|
||||
| 'read-only'
|
||||
| 'in-a-repo'
|
||||
| 'plugin-cache'
|
||||
|
||||
export type SkillLocationRow = {
|
||||
id: string
|
||||
path: string
|
||||
chip: SkillLocationChip | null
|
||||
}
|
||||
|
||||
export type SkillFreshnessGroupModel = {
|
||||
name: string
|
||||
status: SkillGroupStatus
|
||||
locations: SkillLocationRow[]
|
||||
}
|
||||
|
||||
function locationChip(installation: SkillFreshnessInstallation): SkillLocationChip | null {
|
||||
// Why: a location's own status wins over its topology — "the contents don't
|
||||
// match" is more useful to the user than "it's a duplicate".
|
||||
if (installation.status === 'unrecognized') {
|
||||
return 'unrecognized'
|
||||
}
|
||||
if (installation.status === 'inaccessible') {
|
||||
return 'inaccessible'
|
||||
}
|
||||
switch (installation.topology) {
|
||||
case 'independent-copy':
|
||||
return 'duplicate'
|
||||
case 'external-link':
|
||||
return 'external-link'
|
||||
case 'broken-link':
|
||||
return 'broken-link'
|
||||
case 'read-only':
|
||||
return 'read-only'
|
||||
case 'repo-scope':
|
||||
return 'in-a-repo'
|
||||
case 'plugin-cache':
|
||||
return 'plugin-cache'
|
||||
case 'canonical-copy':
|
||||
case 'provider-alias':
|
||||
// Why: a supported location only needs a chip when it's already up to date,
|
||||
// to explain why the update won't touch it; the out-of-date main copy is bare.
|
||||
return installation.status === 'current' ? 'current' : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups installations by skill for the update modal and derives each skill's
|
||||
* update disposition. Only skills with an out-of-date official copy are returned —
|
||||
* up-to-date, unrecognized-only, and unreadable-only skills have nothing to change
|
||||
* here, so they are omitted entirely.
|
||||
*/
|
||||
export function groupSkillFreshness(
|
||||
installations: readonly SkillFreshnessInstallation[],
|
||||
eligibleUpdateNames: readonly string[]
|
||||
): SkillFreshnessGroupModel[] {
|
||||
const eligible = new Set(eligibleUpdateNames)
|
||||
const byName = new Map<string, SkillFreshnessInstallation[]>()
|
||||
for (const installation of installations) {
|
||||
const entries = byName.get(installation.name) ?? []
|
||||
entries.push(installation)
|
||||
byName.set(installation.name, entries)
|
||||
}
|
||||
const groups: SkillFreshnessGroupModel[] = []
|
||||
for (const [name, entries] of byName) {
|
||||
if (!entries.some((entry) => entry.status === 'outdated')) {
|
||||
continue
|
||||
}
|
||||
const locations = entries
|
||||
.map((entry) => ({ id: entry.id, path: entry.unresolvedPath, chip: locationChip(entry) }))
|
||||
.sort((left, right) => left.path.localeCompare(right.path, 'en'))
|
||||
groups.push({
|
||||
name,
|
||||
status: eligible.has(name) ? 'update-available' : 'cannot-update',
|
||||
locations
|
||||
})
|
||||
}
|
||||
return groups.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
let pendingOpen = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
// Why: the nudge action can fire before the dialog subscribes. Keeping the
|
||||
// request as an external snapshot prevents mount ordering from losing it.
|
||||
export function requestSkillFreshnessUpdateDialog(): void {
|
||||
pendingOpen = true
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeSkillFreshnessUpdateDialogRequest(): boolean {
|
||||
const requested = pendingOpen
|
||||
pendingOpen = false
|
||||
if (requested) {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
export function getSkillFreshnessUpdateDialogRequest(): boolean {
|
||||
return pendingOpen
|
||||
}
|
||||
|
||||
export function subscribeSkillFreshnessUpdateDialog(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed'
|
||||
@@ -7,9 +7,9 @@ import type {
|
||||
} from '../../../shared/skills'
|
||||
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
|
||||
import { markOrchestrationSetupComplete } from '@/lib/orchestration-setup-state'
|
||||
import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event'
|
||||
import { useMountedRef } from './useMountedRef'
|
||||
|
||||
const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed'
|
||||
export const GLOBAL_AGENT_SKILL_SOURCE_KINDS = [
|
||||
'home'
|
||||
] as const satisfies readonly SkillSourceKind[]
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SkillFreshnessInventory } from '../../../shared/skill-freshness'
|
||||
import {
|
||||
_skillFreshnessCacheForTests,
|
||||
type SkillFreshnessState,
|
||||
useSkillFreshness
|
||||
} from './useSkillFreshness'
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (cause: unknown) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (cause: unknown) => void
|
||||
const promise = new Promise<T>((complete, fail) => {
|
||||
resolve = complete
|
||||
reject = fail
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function inventory(scannedAt: number, eligibleUpdateNames: string[] = []): SkillFreshnessInventory {
|
||||
return { schemaVersion: 1, installations: [], eligibleUpdateNames, scannedAt }
|
||||
}
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
let state: SkillFreshnessState | null = null
|
||||
const states = new Map<string, SkillFreshnessState>()
|
||||
|
||||
function Probe({ id = 'default' }: { id?: string }): null {
|
||||
state = useSkillFreshness()
|
||||
states.set(id, state)
|
||||
return null
|
||||
}
|
||||
|
||||
describe('useSkillFreshness', () => {
|
||||
beforeEach(() => {
|
||||
_skillFreshnessCacheForTests.reset()
|
||||
state = null
|
||||
states.clear()
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
if (root) {
|
||||
await act(async () => root?.unmount())
|
||||
}
|
||||
root = null
|
||||
container?.remove()
|
||||
container = null
|
||||
})
|
||||
|
||||
it('runs a follow-up scan when invalidated during an in-flight request', async () => {
|
||||
const first = deferred<SkillFreshnessInventory>()
|
||||
const second = deferred<SkillFreshnessInventory>()
|
||||
const freshnessInventory = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise)
|
||||
window.api = { skills: { freshnessInventory } } as never
|
||||
|
||||
await act(async () => root?.render(<Probe />))
|
||||
expect(freshnessInventory).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => window.dispatchEvent(new Event('focus')))
|
||||
await act(async () => first.resolve(inventory(1)))
|
||||
expect(freshnessInventory).toHaveBeenCalledTimes(2)
|
||||
|
||||
await act(async () => second.resolve(inventory(2)))
|
||||
expect(state?.inventory?.scannedAt).toBe(2)
|
||||
})
|
||||
|
||||
it('skips focus rescans inside the cooldown but honors install-change events', async () => {
|
||||
const first = deferred<SkillFreshnessInventory>()
|
||||
const second = deferred<SkillFreshnessInventory>()
|
||||
const freshnessInventory = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise)
|
||||
window.api = { skills: { freshnessInventory } } as never
|
||||
|
||||
await act(async () => root?.render(<Probe />))
|
||||
await act(async () => first.resolve(inventory(1)))
|
||||
expect(freshnessInventory).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => window.dispatchEvent(new Event('focus')))
|
||||
expect(freshnessInventory).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed')))
|
||||
await act(async () => second.resolve(inventory(2)))
|
||||
expect(freshnessInventory).toHaveBeenCalledTimes(2)
|
||||
expect(state?.inventory?.scannedAt).toBe(2)
|
||||
})
|
||||
|
||||
it('retracts stale update authority during the cooldown and runs one trailing focus scan', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T00:00:00Z'))
|
||||
const second = deferred<SkillFreshnessInventory>()
|
||||
const freshnessInventory = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(inventory(1, ['orca-cli']))
|
||||
.mockReturnValueOnce(second.promise)
|
||||
window.api = { skills: { freshnessInventory } } as never
|
||||
|
||||
await act(async () => root?.render(<Probe />))
|
||||
expect(state?.inventory?.eligibleUpdateNames).toEqual(['orca-cli'])
|
||||
|
||||
await act(async () => window.dispatchEvent(new Event('focus')))
|
||||
expect(freshnessInventory).toHaveBeenCalledTimes(1)
|
||||
expect(state?.inventory).toBeNull()
|
||||
expect(state?.loading).toBe(true)
|
||||
|
||||
await act(async () => vi.advanceTimersByTimeAsync(15_000))
|
||||
expect(freshnessInventory).toHaveBeenCalledTimes(2)
|
||||
await act(async () => second.resolve(inventory(2)))
|
||||
expect(state?.inventory?.scannedAt).toBe(2)
|
||||
})
|
||||
|
||||
it('coalesces multiple consumers into one rescan per invalidation event', async () => {
|
||||
const first = deferred<SkillFreshnessInventory>()
|
||||
const second = deferred<SkillFreshnessInventory>()
|
||||
const freshnessInventory = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValue(second.promise)
|
||||
window.api = { skills: { freshnessInventory } } as never
|
||||
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<>
|
||||
<Probe />
|
||||
<Probe />
|
||||
</>
|
||||
)
|
||||
)
|
||||
await act(async () => first.resolve(inventory(1)))
|
||||
expect(freshnessInventory).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed')))
|
||||
await act(async () => second.resolve(inventory(2)))
|
||||
expect(freshnessInventory).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('publishes a manual refresh to every consumer', async () => {
|
||||
const second = deferred<SkillFreshnessInventory>()
|
||||
const freshnessInventory = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(inventory(1))
|
||||
.mockReturnValueOnce(second.promise)
|
||||
window.api = { skills: { freshnessInventory } } as never
|
||||
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<>
|
||||
<Probe id="one" />
|
||||
<Probe id="two" />
|
||||
</>
|
||||
)
|
||||
)
|
||||
expect(states.get('one')?.inventory?.scannedAt).toBe(1)
|
||||
expect(states.get('two')?.inventory?.scannedAt).toBe(1)
|
||||
|
||||
let refresh: Promise<void> | undefined
|
||||
await act(async () => {
|
||||
refresh = states.get('one')?.refresh()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(states.get('one')?.inventory).toBeNull()
|
||||
expect(states.get('two')?.inventory).toBeNull()
|
||||
|
||||
await act(async () => second.resolve(inventory(2)))
|
||||
await refresh
|
||||
expect(states.get('one')?.inventory?.scannedAt).toBe(2)
|
||||
expect(states.get('two')?.inventory?.scannedAt).toBe(2)
|
||||
expect(freshnessInventory).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('fails closed when an invalidation scan rejects', async () => {
|
||||
const second = deferred<SkillFreshnessInventory>()
|
||||
const freshnessInventory = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(inventory(1))
|
||||
.mockReturnValueOnce(second.promise)
|
||||
window.api = { skills: { freshnessInventory } } as never
|
||||
|
||||
await act(async () => root?.render(<Probe />))
|
||||
expect(state?.inventory?.scannedAt).toBe(1)
|
||||
|
||||
await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed')))
|
||||
expect(state?.inventory).toBeNull()
|
||||
expect(state?.loading).toBe(true)
|
||||
|
||||
await act(async () => second.reject(new Error('scan failed')))
|
||||
expect(state?.inventory).toBeNull()
|
||||
expect(state?.loading).toBe(false)
|
||||
expect(state?.error).toBe('scan failed')
|
||||
})
|
||||
|
||||
it('installs one event-listener pair for multiple consumers and cleans it up', async () => {
|
||||
const addEventListener = vi.spyOn(window, 'addEventListener')
|
||||
const removeEventListener = vi.spyOn(window, 'removeEventListener')
|
||||
window.api = {
|
||||
skills: { freshnessInventory: vi.fn().mockResolvedValue(inventory(1)) }
|
||||
} as never
|
||||
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<>
|
||||
<Probe id="one" />
|
||||
<Probe id="two" />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
expect(addEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1)
|
||||
expect(
|
||||
addEventListener.mock.calls.filter(([name]) => name === 'orca:installed-agent-skills-changed')
|
||||
).toHaveLength(1)
|
||||
|
||||
await act(async () => root?.unmount())
|
||||
root = null
|
||||
expect(removeEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1)
|
||||
expect(
|
||||
removeEventListener.mock.calls.filter(
|
||||
([name]) => name === 'orca:installed-agent-skills-changed'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import type { SkillFreshnessInventory } from '../../../shared/skill-freshness'
|
||||
import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event'
|
||||
|
||||
// Why: window focus fires on every alt-tab, and each scan re-reads and re-hashes
|
||||
// every installed package; a just-completed scan stays authoritative briefly.
|
||||
const FOCUS_RESCAN_COOLDOWN_MS = 15_000
|
||||
let cachedInventory: SkillFreshnessInventory | null = null
|
||||
let pendingInventory: Promise<SkillFreshnessInventory> | null = null
|
||||
let invalidationRevision = 0
|
||||
let completedRevision = -1
|
||||
let lastCompletedScanAt = 0
|
||||
let refreshSequence = 0
|
||||
let scheduledFocusRescan: number | null = null
|
||||
|
||||
type SkillFreshnessSnapshot = {
|
||||
inventory: SkillFreshnessInventory | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
let snapshot: SkillFreshnessSnapshot = {
|
||||
inventory: null,
|
||||
loading: false,
|
||||
error: null
|
||||
}
|
||||
const subscribers = new Set<() => void>()
|
||||
|
||||
function publishSnapshot(next: SkillFreshnessSnapshot): void {
|
||||
if (
|
||||
snapshot.inventory === next.inventory &&
|
||||
snapshot.loading === next.loading &&
|
||||
snapshot.error === next.error
|
||||
) {
|
||||
return
|
||||
}
|
||||
snapshot = next
|
||||
for (const subscriber of subscribers) {
|
||||
subscriber()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInventory(force: boolean): Promise<SkillFreshnessInventory> {
|
||||
if (force) {
|
||||
invalidationRevision += 1
|
||||
}
|
||||
const targetRevision = invalidationRevision
|
||||
for (;;) {
|
||||
if (cachedInventory && completedRevision >= targetRevision) {
|
||||
return cachedInventory
|
||||
}
|
||||
if (!pendingInventory) {
|
||||
const requestRevision = invalidationRevision
|
||||
const request = window.api.skills
|
||||
.freshnessInventory()
|
||||
.then((inventory) => {
|
||||
cachedInventory = inventory
|
||||
completedRevision = Math.max(completedRevision, requestRevision)
|
||||
lastCompletedScanAt = Date.now()
|
||||
return inventory
|
||||
})
|
||||
.finally(() => {
|
||||
if (pendingInventory === request) {
|
||||
pendingInventory = null
|
||||
}
|
||||
})
|
||||
pendingInventory = request
|
||||
}
|
||||
await pendingInventory
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSkillFreshness(force = true): Promise<void> {
|
||||
if (scheduledFocusRescan !== null) {
|
||||
window.clearTimeout(scheduledFocusRescan)
|
||||
scheduledFocusRescan = null
|
||||
}
|
||||
const sequence = ++refreshSequence
|
||||
// Why: eligibility is write authority for the draft command. Once invalidated,
|
||||
// stale bytes must stop authorizing UI even if the replacement scan fails.
|
||||
publishSnapshot({ inventory: null, loading: true, error: null })
|
||||
try {
|
||||
const inventory = await loadInventory(force)
|
||||
if (sequence === refreshSequence) {
|
||||
publishSnapshot({ inventory, loading: false, error: null })
|
||||
}
|
||||
} catch (cause) {
|
||||
if (sequence === refreshSequence) {
|
||||
publishSnapshot({
|
||||
inventory: null,
|
||||
loading: false,
|
||||
error: cause instanceof Error ? cause.message : 'Could not inspect Orca skills.'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowFocus(): void {
|
||||
const cooldownRemaining = FOCUS_RESCAN_COOLDOWN_MS - (Date.now() - lastCompletedScanAt)
|
||||
if (cooldownRemaining <= 0) {
|
||||
void refreshSkillFreshness(true)
|
||||
return
|
||||
}
|
||||
if (!snapshot.inventory?.eligibleUpdateNames.length || scheduledFocusRescan !== null) {
|
||||
return
|
||||
}
|
||||
// Why: a focus event can follow an external edit. Retract stale update
|
||||
// authority immediately, but keep rapid alt-tabs to one trailing disk scan.
|
||||
publishSnapshot({ inventory: null, loading: true, error: null })
|
||||
scheduledFocusRescan = window.setTimeout(
|
||||
() => {
|
||||
scheduledFocusRescan = null
|
||||
void refreshSkillFreshness(true)
|
||||
},
|
||||
Math.min(cooldownRemaining, FOCUS_RESCAN_COOLDOWN_MS)
|
||||
)
|
||||
}
|
||||
|
||||
function onInstalledSkillsChanged(): void {
|
||||
void refreshSkillFreshness(true)
|
||||
}
|
||||
|
||||
function subscribe(subscriber: () => void): () => void {
|
||||
subscribers.add(subscriber)
|
||||
if (subscribers.size === 1) {
|
||||
// Why: every consumer reads one external snapshot, so focus/install events
|
||||
// install one listener and trigger one shared IPC scan regardless of UI count.
|
||||
window.addEventListener('focus', onWindowFocus)
|
||||
window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged)
|
||||
}
|
||||
return () => {
|
||||
subscribers.delete(subscriber)
|
||||
if (subscribers.size === 0) {
|
||||
window.removeEventListener('focus', onWindowFocus)
|
||||
window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSnapshot(): SkillFreshnessSnapshot {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function ensureInventoryLoaded(): void {
|
||||
if (!snapshot.inventory && !snapshot.loading) {
|
||||
void refreshSkillFreshness(false)
|
||||
}
|
||||
}
|
||||
|
||||
export type SkillFreshnessState = SkillFreshnessSnapshot & {
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useSkillFreshness(): SkillFreshnessState {
|
||||
const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
|
||||
useEffect(() => {
|
||||
ensureInventoryLoaded()
|
||||
}, [])
|
||||
|
||||
return { ...current, refresh: refreshSkillFreshness }
|
||||
}
|
||||
|
||||
export const _skillFreshnessCacheForTests = {
|
||||
reset(): void {
|
||||
cachedInventory = null
|
||||
pendingInventory = null
|
||||
invalidationRevision = 0
|
||||
completedRevision = -1
|
||||
lastCompletedScanAt = 0
|
||||
refreshSequence = 0
|
||||
if (scheduledFocusRescan !== null) {
|
||||
window.clearTimeout(scheduledFocusRescan)
|
||||
scheduledFocusRescan = null
|
||||
}
|
||||
snapshot = { inventory: null, loading: false, error: null }
|
||||
}
|
||||
}
|
||||
@@ -3605,6 +3605,64 @@
|
||||
"cd7893fbc1": "Scanning skills",
|
||||
"35b9a724a0": "Available",
|
||||
"0c74e7ff34": "Local"
|
||||
},
|
||||
"SkillFreshnessNudge": {
|
||||
"titleOne": "An installed Orca skill is out of date",
|
||||
"titleMany": "{{value0}} installed Orca skills are out of date",
|
||||
"description": "Update {{value0}} so agents follow the current instructions for this version of Orca.",
|
||||
"updateOne": "Update skill",
|
||||
"updateMany": "Update skills"
|
||||
},
|
||||
"SkillFreshnessRow": {
|
||||
"statusUpdateAvailable": "Update available",
|
||||
"statusCantUpdate": "Skipped",
|
||||
"cantUpdateReason": "Orca left this skill out of the update command.",
|
||||
"skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.",
|
||||
"skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.",
|
||||
"skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.",
|
||||
"skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.",
|
||||
"skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.",
|
||||
"skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.",
|
||||
"skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.",
|
||||
"chipCurrent": "Current",
|
||||
"chipUnrecognized": "Unrecognized",
|
||||
"chipInaccessible": "Inaccessible",
|
||||
"chipDuplicate": "Duplicate",
|
||||
"chipExternalLink": "External link",
|
||||
"chipBrokenLink": "Broken link",
|
||||
"chipReadOnly": "Read only",
|
||||
"chipInRepo": "In a repo",
|
||||
"chipPluginCache": "Plugin cache",
|
||||
"tipCurrent": "This copy matches the current official version.",
|
||||
"tipUnrecognized": "This copy doesn’t match any official version — it may be modified, or a different skill with the same name.",
|
||||
"tipInaccessible": "Orca couldn’t read this copy (a permissions or file error).",
|
||||
"tipDuplicate": "A separate copy of this skill, installed apart from the main one.",
|
||||
"tipExternalLink": "A shortcut pointing outside Orca’s skill folders.",
|
||||
"tipBrokenLink": "A shortcut to something that no longer exists.",
|
||||
"tipReadOnly": "This copy is in a read-only location.",
|
||||
"tipInRepo": "This copy lives inside a project, not your global skills.",
|
||||
"tipPluginCache": "This copy is managed by a plugin."
|
||||
},
|
||||
"SkillFreshnessUpdateDialog": {
|
||||
"title": "Update skills",
|
||||
"checking": "Checking installed Orca skills…",
|
||||
"none": "No installed Orca skills found.",
|
||||
"updateOne": "1 skill can be updated safely",
|
||||
"updateMany": "{{value0}} skills can be updated safely",
|
||||
"success": "All installed Orca skills are up to date.",
|
||||
"attention": "Some installed Orca skills were left out of the update.",
|
||||
"attentionDescription": "Open Update details to see why each one was skipped.",
|
||||
"details": "Update details",
|
||||
"terminalTitle": "Update Orca skills",
|
||||
"terminalDescription": "Review the pre-filled command, then press Enter to run it.",
|
||||
"terminalAria": "Orca skill update terminal",
|
||||
"checkNow": "Re-check",
|
||||
"close": "Close"
|
||||
},
|
||||
"SkillFreshnessStatusPill": {
|
||||
"updateAvailable": "Update available",
|
||||
"upToDate": "Up to date",
|
||||
"installed": "Installed"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -3605,6 +3605,64 @@
|
||||
"cd7893fbc1": "Escaneando skills",
|
||||
"35b9a724a0": "Disponible",
|
||||
"0c74e7ff34": "Local"
|
||||
},
|
||||
"SkillFreshnessNudge": {
|
||||
"titleOne": "Una skill de Orca instalada está desactualizada",
|
||||
"titleMany": "{{value0}} skills de Orca instaladas están desactualizadas",
|
||||
"updateOne": "Actualizar skill",
|
||||
"updateMany": "Actualizar skills",
|
||||
"description": "Actualiza {{value0}} para que los Agents sigan las instrucciones actuales de esta versión de Orca."
|
||||
},
|
||||
"SkillFreshnessRow": {
|
||||
"statusUpdateAvailable": "Actualización disponible",
|
||||
"statusCantUpdate": "No se puede actualizar",
|
||||
"cantUpdateReason": "Esta skill está instalada en un lugar que Orca no puede actualizar de forma segura, así que el comando npx skills update la deja sin cambios.",
|
||||
"chipCurrent": "Actual",
|
||||
"chipUnrecognized": "No reconocida",
|
||||
"chipInaccessible": "Inaccesible",
|
||||
"chipDuplicate": "Duplicada",
|
||||
"chipExternalLink": "Enlace externo",
|
||||
"chipBrokenLink": "Enlace roto",
|
||||
"chipReadOnly": "Solo lectura",
|
||||
"chipInRepo": "En un repo",
|
||||
"chipPluginCache": "Caché de plugin",
|
||||
"tipCurrent": "La skill aquí ya está actualizada; la actualización no la cambiará.",
|
||||
"tipUnrecognized": "El contenido de la skill aquí no coincide con ninguna versión oficial, así que Orca no puede actualizarla de forma segura. Elimina o reemplaza lo que hay aquí para permitir las actualizaciones.",
|
||||
"tipInaccessible": "Orca no pudo leer la skill aquí (un error de permisos o de archivo), así que no puede comprobarla ni actualizarla.",
|
||||
"tipDuplicate": "La skill también está instalada aquí, aparte de la principal, así que el comando npx skills update no puede alcanzarla. Elimínala para permitir las actualizaciones.",
|
||||
"tipExternalLink": "Es un acceso directo que apunta fuera de las carpetas de skills de Orca; la actualización no lo seguirá.",
|
||||
"tipBrokenLink": "Es un acceso directo a algo que ya no existe; puedes eliminarlo sin problema.",
|
||||
"tipReadOnly": "La skill aquí está en una ubicación de solo lectura, así que no se puede actualizar hasta que cambies sus permisos.",
|
||||
"tipInRepo": "La skill aquí vive dentro de un proyecto, no en tus skills globales; Orca solo actualiza las globales.",
|
||||
"tipPluginCache": "La skill aquí la gestiona un plugin; actualiza el plugin en su lugar.",
|
||||
"skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.",
|
||||
"skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.",
|
||||
"skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.",
|
||||
"skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.",
|
||||
"skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.",
|
||||
"skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.",
|
||||
"skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it."
|
||||
},
|
||||
"SkillFreshnessUpdateDialog": {
|
||||
"title": "Actualizar skills",
|
||||
"checking": "Comprobando las skills de Orca instaladas…",
|
||||
"none": "No se encontraron skills de Orca instaladas.",
|
||||
"updateOne": "Se puede actualizar 1 skill de forma segura",
|
||||
"updateMany": "Se pueden actualizar {{value0}} skills de forma segura",
|
||||
"success": "Todas las skills de Orca instaladas están actualizadas.",
|
||||
"attention": "Algunas skills de Orca instaladas no se pueden actualizar automáticamente.",
|
||||
"attentionDescription": "Abre Detalles de la actualización para ver por qué no se puede actualizar cada una.",
|
||||
"details": "Detalles de la actualización",
|
||||
"terminalTitle": "Actualizar skills de Orca",
|
||||
"terminalDescription": "Revisa el comando pre-rellenado y pulsa Intro para ejecutarlo.",
|
||||
"terminalAria": "Terminal de actualización de skills de Orca",
|
||||
"checkNow": "Comprobar ahora",
|
||||
"close": "Cerrar"
|
||||
},
|
||||
"SkillFreshnessStatusPill": {
|
||||
"updateAvailable": "Actualización disponible",
|
||||
"upToDate": "Actualizado",
|
||||
"installed": "Instalado"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -3605,6 +3605,64 @@
|
||||
"cd7893fbc1": "スキャンスキル",
|
||||
"35b9a724a0": "利用可能",
|
||||
"0c74e7ff34": "ローカル"
|
||||
},
|
||||
"SkillFreshnessNudge": {
|
||||
"titleOne": "インストール済みの Orca スキルが古くなっています",
|
||||
"titleMany": "インストール済みの Orca スキル {{value0}} 件が古くなっています",
|
||||
"updateOne": "スキルを更新",
|
||||
"updateMany": "スキルを更新",
|
||||
"description": "Agents がこのバージョンの Orca の最新の手順に従うように、{{value0}} を更新してください。"
|
||||
},
|
||||
"SkillFreshnessRow": {
|
||||
"statusUpdateAvailable": "更新あり",
|
||||
"statusCantUpdate": "更新できません",
|
||||
"cantUpdateReason": "このスキルは Orca が安全に更新できない場所にインストールされているため、npx skills update コマンドは変更しません。",
|
||||
"chipCurrent": "最新",
|
||||
"chipUnrecognized": "未認識",
|
||||
"chipInaccessible": "アクセス不可",
|
||||
"chipDuplicate": "重複",
|
||||
"chipExternalLink": "外部リンク",
|
||||
"chipBrokenLink": "リンク切れ",
|
||||
"chipReadOnly": "読み取り専用",
|
||||
"chipInRepo": "リポジトリ内",
|
||||
"chipPluginCache": "プラグインキャッシュ",
|
||||
"tipCurrent": "ここのスキルはすでに最新です。更新しても変更されません。",
|
||||
"tipUnrecognized": "ここのスキルの内容が公式バージョンのいずれとも一致しないため、Orca は安全に更新できません。更新を許可するには、ここにあるものを削除または置き換えてください。",
|
||||
"tipInaccessible": "Orca はここのスキルを読み取れなかったため(権限またはファイルのエラー)、確認も更新もできません。",
|
||||
"tipDuplicate": "このスキルはメインとは別に、ここにもインストールされています。そのため npx skills update コマンドは到達できません。更新を許可するには削除してください。",
|
||||
"tipExternalLink": "これは Orca のスキルフォルダーの外を指すショートカットです。更新では追跡されません。",
|
||||
"tipBrokenLink": "これは存在しないものを指すショートカットです。削除しても問題ありません。",
|
||||
"tipReadOnly": "ここのスキルは読み取り専用の場所にあるため、権限を変更するまで更新できません。",
|
||||
"tipInRepo": "ここのスキルはグローバルスキルではなくプロジェクト内にあります。Orca はグローバルなものだけを更新します。",
|
||||
"tipPluginCache": "ここのスキルはプラグインによって管理されています。代わりにプラグインを更新してください。",
|
||||
"skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.",
|
||||
"skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.",
|
||||
"skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.",
|
||||
"skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.",
|
||||
"skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.",
|
||||
"skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.",
|
||||
"skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it."
|
||||
},
|
||||
"SkillFreshnessUpdateDialog": {
|
||||
"title": "スキルを更新",
|
||||
"checking": "インストール済みの Orca スキルを確認中…",
|
||||
"none": "インストール済みの Orca スキルは見つかりませんでした。",
|
||||
"updateOne": "1 件のスキルを安全に更新できます",
|
||||
"updateMany": "{{value0}} 件のスキルを安全に更新できます",
|
||||
"success": "インストール済みの Orca スキルはすべて最新です。",
|
||||
"attention": "一部のインストール済み Orca スキルは自動的に更新できません。",
|
||||
"attentionDescription": "更新できない理由は、更新の詳細を開いて確認してください。",
|
||||
"details": "更新の詳細",
|
||||
"terminalTitle": "Orca スキルを更新",
|
||||
"terminalDescription": "入力済みのコマンドを確認し、Enter キーを押して実行してください。",
|
||||
"terminalAria": "Orca スキル更新ターミナル",
|
||||
"checkNow": "今すぐ確認",
|
||||
"close": "閉じる"
|
||||
},
|
||||
"SkillFreshnessStatusPill": {
|
||||
"updateAvailable": "更新があります",
|
||||
"upToDate": "最新です",
|
||||
"installed": "インストール済み"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -3605,6 +3605,64 @@
|
||||
"cd7893fbc1": "스킬 스캔 중",
|
||||
"35b9a724a0": "사용 가능",
|
||||
"0c74e7ff34": "로컬"
|
||||
},
|
||||
"SkillFreshnessNudge": {
|
||||
"titleOne": "설치된 Orca 스킬이 오래되었습니다",
|
||||
"titleMany": "설치된 Orca 스킬 {{value0}}개가 오래되었습니다",
|
||||
"updateOne": "스킬 업데이트",
|
||||
"updateMany": "스킬 업데이트",
|
||||
"description": "Agents가 이 버전의 Orca에 대한 최신 지침을 따르도록 {{value0}}을(를) 업데이트하세요."
|
||||
},
|
||||
"SkillFreshnessRow": {
|
||||
"statusUpdateAvailable": "업데이트 있음",
|
||||
"statusCantUpdate": "업데이트할 수 없음",
|
||||
"cantUpdateReason": "이 스킬은 Orca가 안전하게 업데이트할 수 없는 위치에 설치되어 있어, npx skills update 명령이 변경하지 않습니다.",
|
||||
"chipCurrent": "최신",
|
||||
"chipUnrecognized": "인식 안 됨",
|
||||
"chipInaccessible": "액세스 불가",
|
||||
"chipDuplicate": "중복",
|
||||
"chipExternalLink": "외부 링크",
|
||||
"chipBrokenLink": "깨진 링크",
|
||||
"chipReadOnly": "읽기 전용",
|
||||
"chipInRepo": "저장소 내",
|
||||
"chipPluginCache": "플러그인 캐시",
|
||||
"tipCurrent": "여기 있는 스킬은 이미 최신이므로 업데이트해도 변경되지 않습니다.",
|
||||
"tipUnrecognized": "여기 있는 스킬의 콘텐츠가 어떤 공식 버전과도 일치하지 않아 Orca가 안전하게 업데이트할 수 없습니다. 업데이트를 허용하려면 여기 있는 항목을 제거하거나 교체하세요.",
|
||||
"tipInaccessible": "Orca가 여기 있는 스킬을 읽지 못해(권한 또는 파일 오류) 확인하거나 업데이트할 수 없습니다.",
|
||||
"tipDuplicate": "이 스킬은 기본 위치와 별개로 여기에도 설치되어 있어 npx skills update 명령이 접근할 수 없습니다. 업데이트를 허용하려면 제거하세요.",
|
||||
"tipExternalLink": "Orca의 스킬 폴더 바깥을 가리키는 바로 가기입니다. 업데이트가 이를 따라가지 않습니다.",
|
||||
"tipBrokenLink": "더 이상 존재하지 않는 대상을 가리키는 바로 가기입니다. 삭제해도 됩니다.",
|
||||
"tipReadOnly": "여기 있는 스킬은 읽기 전용 위치에 있어 권한을 변경해야 업데이트할 수 있습니다.",
|
||||
"tipInRepo": "여기 있는 스킬은 전역 스킬이 아니라 프로젝트 안에 있습니다. Orca는 전역 스킬만 업데이트합니다.",
|
||||
"tipPluginCache": "여기 있는 스킬은 플러그인이 관리합니다. 대신 플러그인을 업데이트하세요.",
|
||||
"skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.",
|
||||
"skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.",
|
||||
"skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.",
|
||||
"skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.",
|
||||
"skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.",
|
||||
"skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.",
|
||||
"skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it."
|
||||
},
|
||||
"SkillFreshnessUpdateDialog": {
|
||||
"title": "스킬 업데이트",
|
||||
"checking": "설치된 Orca 스킬을 확인하는 중…",
|
||||
"none": "설치된 Orca 스킬을 찾을 수 없습니다.",
|
||||
"updateOne": "스킬 1개를 안전하게 업데이트할 수 있습니다",
|
||||
"updateMany": "스킬 {{value0}}개를 안전하게 업데이트할 수 있습니다",
|
||||
"success": "설치된 Orca 스킬이 모두 최신 상태입니다.",
|
||||
"attention": "일부 설치된 Orca 스킬은 자동으로 업데이트할 수 없습니다.",
|
||||
"attentionDescription": "각 항목을 업데이트할 수 없는 이유는 업데이트 세부 정보를 열어 확인하세요.",
|
||||
"details": "업데이트 세부 정보",
|
||||
"terminalTitle": "Orca 스킬 업데이트",
|
||||
"terminalDescription": "미리 입력된 명령을 검토한 후 Enter 키를 눌러 실행하세요.",
|
||||
"terminalAria": "Orca 스킬 업데이트 터미널",
|
||||
"checkNow": "지금 확인",
|
||||
"close": "닫기"
|
||||
},
|
||||
"SkillFreshnessStatusPill": {
|
||||
"updateAvailable": "업데이트 가능",
|
||||
"upToDate": "최신 상태",
|
||||
"installed": "설치됨"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -3605,6 +3605,64 @@
|
||||
"cd7893fbc1": "正在扫描技能",
|
||||
"35b9a724a0": "可用的",
|
||||
"0c74e7ff34": "当地的"
|
||||
},
|
||||
"SkillFreshnessNudge": {
|
||||
"titleOne": "已安装的 Orca 技能已过期",
|
||||
"titleMany": "{{value0}} 个已安装的 Orca 技能已过期",
|
||||
"updateOne": "更新技能",
|
||||
"updateMany": "更新技能",
|
||||
"description": "更新 {{value0}},让 Agents 遵循此版本 Orca 的最新说明。"
|
||||
},
|
||||
"SkillFreshnessRow": {
|
||||
"statusUpdateAvailable": "有更新",
|
||||
"statusCantUpdate": "无法更新",
|
||||
"cantUpdateReason": "此技能安装在 Orca 无法安全更新的位置,因此 npx skills update 命令不会更改它。",
|
||||
"chipCurrent": "最新",
|
||||
"chipUnrecognized": "无法识别",
|
||||
"chipInaccessible": "无法访问",
|
||||
"chipDuplicate": "重复",
|
||||
"chipExternalLink": "外部链接",
|
||||
"chipBrokenLink": "失效链接",
|
||||
"chipReadOnly": "只读",
|
||||
"chipInRepo": "在仓库中",
|
||||
"chipPluginCache": "插件缓存",
|
||||
"tipCurrent": "这里的技能已是最新,更新不会更改它。",
|
||||
"tipUnrecognized": "这里的技能内容与任何官方版本都不匹配,因此 Orca 无法安全更新它。请移除或替换此处的内容以允许更新。",
|
||||
"tipInaccessible": "Orca 无法读取这里的技能(权限或文件错误),因此无法检查或更新它。",
|
||||
"tipDuplicate": "此技能除主副本外还安装在这里,因此 npx skills update 命令无法访问它。请移除它以允许更新。",
|
||||
"tipExternalLink": "这是一个指向 Orca 技能文件夹之外的快捷方式;更新不会跟随它。",
|
||||
"tipBrokenLink": "这是一个指向已不存在内容的快捷方式,可以安全删除。",
|
||||
"tipReadOnly": "这里的技能位于只读位置,需更改其权限后才能更新。",
|
||||
"tipInRepo": "这里的技能位于某个项目内,而非全局技能;Orca 只更新全局技能。",
|
||||
"tipPluginCache": "这里的技能由插件管理;请改为更新插件。",
|
||||
"skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.",
|
||||
"skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.",
|
||||
"skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.",
|
||||
"skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.",
|
||||
"skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.",
|
||||
"skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.",
|
||||
"skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it."
|
||||
},
|
||||
"SkillFreshnessUpdateDialog": {
|
||||
"title": "更新技能",
|
||||
"checking": "正在检查已安装的 Orca 技能…",
|
||||
"none": "未找到已安装的 Orca 技能。",
|
||||
"updateOne": "可以安全更新 1 个技能",
|
||||
"updateMany": "可以安全更新 {{value0}} 个技能",
|
||||
"success": "所有已安装的 Orca 技能均为最新。",
|
||||
"attention": "部分已安装的 Orca 技能无法自动更新。",
|
||||
"attentionDescription": "打开“更新详情”查看为何每一项都无法更新。",
|
||||
"details": "更新详情",
|
||||
"terminalTitle": "更新 Orca 技能",
|
||||
"terminalDescription": "检查预填的命令,然后按 Enter 键运行。",
|
||||
"terminalAria": "Orca 技能更新终端",
|
||||
"checkNow": "立即检查",
|
||||
"close": "关闭"
|
||||
},
|
||||
"SkillFreshnessStatusPill": {
|
||||
"updateAvailable": "有可用更新",
|
||||
"upToDate": "已是最新",
|
||||
"installed": "已安装"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { LucideProps } from 'lucide-react'
|
||||
import type { SettingsSearchEntry } from '@/components/settings/settings-search'
|
||||
|
||||
export type SettingsNavIcon = ComponentType<LucideProps>
|
||||
export type SettingsNavInstallStatus = 'install' | 'installed' | 'checking'
|
||||
export type SettingsNavInstallStatus = 'install' | 'installed' | 'update-available' | 'checking'
|
||||
|
||||
export type SettingsNavTarget =
|
||||
| 'general'
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
WorkspaceSessionState
|
||||
} from '../../../shared/types'
|
||||
import type { SkillDiscoveryResult } from '../../../shared/skills'
|
||||
import type { SkillFreshnessInventory } from '../../../shared/skill-freshness'
|
||||
import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types'
|
||||
import {
|
||||
getDefaultOnboardingState,
|
||||
@@ -2743,7 +2744,16 @@ function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
|
||||
skills: [],
|
||||
sources: [],
|
||||
scannedAt: Date.now()
|
||||
}))
|
||||
})),
|
||||
// Why: browser clients have no local skill homes, and remote-host
|
||||
// freshness stays disabled until its update rail has equivalent coverage.
|
||||
freshnessInventory: (): Promise<SkillFreshnessInventory> =>
|
||||
Promise.resolve({
|
||||
schemaVersion: 1,
|
||||
installations: [],
|
||||
eligibleUpdateNames: [],
|
||||
scannedAt: Date.now()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { SkillProvider, SkillSourceKind } from './skills'
|
||||
|
||||
export type SkillBundleFileIdentity = {
|
||||
path: string
|
||||
size: number
|
||||
executable: boolean
|
||||
classification: 'text' | 'binary'
|
||||
exactSha256: string
|
||||
textNormalizedSha256: string | null
|
||||
identitySha256: string
|
||||
}
|
||||
|
||||
export type SkillKnownSnapshot = {
|
||||
releaseRevision: number
|
||||
packageDigest: string
|
||||
gitTreeSha: string
|
||||
files: SkillBundleFileIdentity[]
|
||||
}
|
||||
|
||||
export type SkillCurrentBundleEntry = SkillKnownSnapshot & {
|
||||
name: string
|
||||
sourcePath: string
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
export type SkillBundleManifest = {
|
||||
schemaVersion: 1
|
||||
appVersion: string
|
||||
skills: SkillCurrentBundleEntry[]
|
||||
}
|
||||
|
||||
export type SkillSnapshotRegistry = {
|
||||
schemaVersion: 1
|
||||
skills: Record<string, SkillKnownSnapshot[]>
|
||||
}
|
||||
|
||||
export type SkillReleaseMapping = {
|
||||
schemaVersion: 1
|
||||
releases: { appVersion: string; skills: Record<string, number> }[]
|
||||
}
|
||||
|
||||
export type SkillFreshnessStatus =
|
||||
| 'current'
|
||||
| 'outdated'
|
||||
| 'newer-known'
|
||||
| 'unrecognized'
|
||||
| 'inaccessible'
|
||||
|
||||
export type SkillInstallationTopology =
|
||||
| 'canonical-copy'
|
||||
| 'provider-alias'
|
||||
| 'independent-copy'
|
||||
| 'external-link'
|
||||
| 'broken-link'
|
||||
| 'read-only'
|
||||
| 'repo-scope'
|
||||
| 'plugin-cache'
|
||||
|
||||
// Why: eligibility and the explanation copy must agree on which placements the
|
||||
// validated npx rail can converge; a drifted copy would blame a phantom sibling.
|
||||
export const SUPPORTED_GLOBAL_SKILL_TOPOLOGIES: ReadonlySet<SkillInstallationTopology> = new Set([
|
||||
'canonical-copy',
|
||||
'provider-alias'
|
||||
])
|
||||
|
||||
export type SkillFreshnessInstallation = {
|
||||
id: string
|
||||
name: string
|
||||
rootId: string
|
||||
providers: SkillProvider[]
|
||||
sourceKind: SkillSourceKind
|
||||
sourceLabel: string
|
||||
unresolvedPath: string
|
||||
resolvedPath: string | null
|
||||
physicalIdentity: string | null
|
||||
topology: SkillInstallationTopology
|
||||
status: SkillFreshnessStatus
|
||||
installedReleaseRevision: number | null
|
||||
installedAppVersion: string | null
|
||||
currentReleaseRevision: number
|
||||
currentPackageDigest: string
|
||||
currentAppVersion: string
|
||||
observedPackageDigest: string | null
|
||||
errorCategory: string | null
|
||||
}
|
||||
|
||||
export type SkillFreshnessInventory = {
|
||||
schemaVersion: 1
|
||||
installations: SkillFreshnessInstallation[]
|
||||
eligibleUpdateNames: string[]
|
||||
scannedAt: number
|
||||
}
|
||||
|
||||
export function buildTargetedSkillUpdateCommand(names: readonly string[]): string | null {
|
||||
const canonicalNames = [...new Set(names)].sort((left, right) => left.localeCompare(right, 'en'))
|
||||
// Why: names become editable shell input. Official manifests use this
|
||||
// restricted package-name grammar so no entry can introduce shell syntax.
|
||||
if (canonicalNames.some((name) => !/^[a-z0-9][a-z0-9._-]*$/.test(name))) {
|
||||
return null
|
||||
}
|
||||
return canonicalNames.length > 0 ? `npx skills update ${canonicalNames.join(' ')} --global` : null
|
||||
}
|
||||
@@ -2875,6 +2875,9 @@ 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
|
||||
/** Dismissed freshness tuples grant no write authority; they only keep the
|
||||
* same exact official placement/revision from nudging more than once. */
|
||||
dismissedSkillFreshnessNudges?: string[]
|
||||
/** Why: generated tab titles are semantic but subjective, so they stay opt-in
|
||||
* and manual renames remain the stronger user intent. */
|
||||
tabAutoGenerateTitle: boolean
|
||||
|
||||
Reference in New Issue
Block a user