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:
parent
f102972cc1
commit
68fca0b076
|
|
@ -15,6 +15,11 @@ jobs:
|
|||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why: the freshness registry is derived from immutable release tags,
|
||||
# so shallow PR checkouts cannot verify historical official identities.
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
|
||||
|
|
@ -82,6 +87,9 @@ jobs:
|
|||
- name: Verify bundled skill guides
|
||||
run: pnpm run verify:bundled-skill-guides
|
||||
|
||||
- name: Verify skill freshness manifest
|
||||
run: pnpm run verify:skill-bundle-manifest
|
||||
|
||||
# Why: project-owned type declarations must live in .ts so tsc
|
||||
# actually checks them. TypeScript's skipLibCheck: true (inherited
|
||||
# from @electron-toolkit/tsconfig) silently widens unresolved names
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
name: Skill update round trip
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'skills/**'
|
||||
- 'resources/skills/**'
|
||||
- 'config/scripts/verify-skill-update-roundtrip.mjs'
|
||||
- 'src/main/skills/skill-freshness-eligibility.ts'
|
||||
- 'src/shared/skill-freshness.ts'
|
||||
- '.github/workflows/skill-update-roundtrip.yml'
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
roundtrip:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest, windows-latest]
|
||||
shape: [symlink, copy]
|
||||
autocrlf: ['false', 'true']
|
||||
skills-cli: ['1.5.17']
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
shape: symlink
|
||||
autocrlf: 'false'
|
||||
skills-cli: latest
|
||||
continue-on-error: ${{ matrix.skills-cli == 'latest' }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- name: Verify targeted update convergence and copy behavior
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
SKILL_UPDATE_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: >-
|
||||
node config/scripts/verify-skill-update-roundtrip.mjs
|
||||
--cli=${{ matrix.skills-cli }}
|
||||
--autocrlf=${{ matrix.autocrlf }}
|
||||
--shape=${{ matrix.shape }}
|
||||
|
|
@ -18,6 +18,12 @@ const featureWallResources = {
|
|||
from: 'resources/onboarding/feature-wall',
|
||||
to: 'onboarding/feature-wall'
|
||||
}
|
||||
// Why: freshness detection needs immutable identity metadata from this exact
|
||||
// app build, but never needs the skill package bytes or a runtime network read.
|
||||
const skillFreshnessResources = {
|
||||
from: 'resources/skills',
|
||||
to: 'skills'
|
||||
}
|
||||
// Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged
|
||||
// apps. Keeping relay assets as extraResources makes them real directories
|
||||
// instead of paths hidden inside app.asar.
|
||||
|
|
@ -31,7 +37,11 @@ const relayExtraResource = {
|
|||
// do not fall through to a developer checkout's node_modules.
|
||||
const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources()
|
||||
|
||||
const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources]
|
||||
const commonExtraResources = [
|
||||
relayExtraResource,
|
||||
...packagedRuntimeNodeModuleResources,
|
||||
skillFreshnessResources
|
||||
]
|
||||
const macSpeechNativeResource = {
|
||||
from: 'node_modules/sherpa-onnx-darwin-${arch}',
|
||||
to: 'node_modules/sherpa-onnx-darwin-${arch}'
|
||||
|
|
@ -79,7 +89,8 @@ module.exports = {
|
|||
'!tsconfig.json',
|
||||
// Why: feature-wall media is copied via extraResources so runtime can read
|
||||
// it from process.resourcesPath; exclude the source copy from app.asar.
|
||||
'!resources/onboarding/feature-wall/**'
|
||||
'!resources/onboarding/feature-wall/**',
|
||||
'!resources/skills/**'
|
||||
],
|
||||
// Why: the CLI entry-point lives in out/cli/ but imports shared modules
|
||||
// from out/shared/ and local hook mutators from out/main/. These paths must be
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ describe('electron-builder config', () => {
|
|||
'!native{,/**/*}',
|
||||
'!skills{,/**/*}',
|
||||
'!skill-guides{,/**/*}',
|
||||
'!resources/skills/**',
|
||||
'!tests{,/**/*}',
|
||||
'!pr-evidence{,/**/*}',
|
||||
'!Casks{,/**/*}',
|
||||
|
|
@ -39,6 +40,12 @@ describe('electron-builder config', () => {
|
|||
})
|
||||
|
||||
it('keeps runtime resources available through extraResources', () => {
|
||||
for (const platform of ['mac', 'linux', 'win']) {
|
||||
expect(electronBuilderConfig[platform].extraResources).toContainEqual({
|
||||
from: 'resources/skills',
|
||||
to: 'skills'
|
||||
})
|
||||
}
|
||||
expect(electronBuilderConfig.mac.extraResources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,522 @@
|
|||
import { createHash } from 'node:crypto'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { constants } from 'node:fs'
|
||||
import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
|
||||
const SCHEMA_VERSION = 1
|
||||
const SCRIPT_DIR = import.meta.dirname
|
||||
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
|
||||
const SKILLS_ROOT = path.join(REPO_ROOT, 'skills')
|
||||
const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills')
|
||||
const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json')
|
||||
const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json')
|
||||
const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json')
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
function compareCodeUnits(left, right) {
|
||||
return left === right ? 0 : left < right ? -1 : 1
|
||||
}
|
||||
|
||||
function gitObjectSha(kind, bytes) {
|
||||
return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest()
|
||||
}
|
||||
|
||||
function normalizeText(bytes) {
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
||||
return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8')
|
||||
}
|
||||
|
||||
function classifyFile(bytes) {
|
||||
if (bytes.includes(0)) {
|
||||
return 'binary'
|
||||
}
|
||||
try {
|
||||
normalizeText(bytes)
|
||||
return 'text'
|
||||
} catch {
|
||||
return 'binary'
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeRelativePath(relativePath) {
|
||||
if (
|
||||
path.isAbsolute(relativePath) ||
|
||||
relativePath === '..' ||
|
||||
relativePath.startsWith(`..${path.sep}`)
|
||||
) {
|
||||
throw new Error(`Unsafe skill package path: ${relativePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
function describeFile(manifestPath, bytes, executable) {
|
||||
const classification = classifyFile(bytes)
|
||||
const exactSha256 = sha256(bytes)
|
||||
const textNormalizedSha256 = classification === 'text' ? sha256(normalizeText(bytes)) : null
|
||||
return {
|
||||
path: manifestPath,
|
||||
size: bytes.length,
|
||||
executable,
|
||||
classification,
|
||||
exactSha256,
|
||||
textNormalizedSha256,
|
||||
identitySha256: classification === 'text' && !executable ? textNormalizedSha256 : exactSha256,
|
||||
gitBlobSha: gitObjectSha('blob', bytes).toString('hex')
|
||||
}
|
||||
}
|
||||
|
||||
function gitTreeSha(entries) {
|
||||
const root = { directories: new Map(), files: [] }
|
||||
for (const entry of entries) {
|
||||
const parts = entry.path.split('/')
|
||||
const filename = parts.pop()
|
||||
let directory = root
|
||||
for (const part of parts) {
|
||||
let child = directory.directories.get(part)
|
||||
if (!child) {
|
||||
child = { directories: new Map(), files: [] }
|
||||
directory.directories.set(part, child)
|
||||
}
|
||||
directory = child
|
||||
}
|
||||
directory.files.push({ filename, ...entry })
|
||||
}
|
||||
|
||||
function hashDirectory(directory) {
|
||||
const children = [
|
||||
...[...directory.directories].map(([name, child]) => ({
|
||||
mode: '40000',
|
||||
name,
|
||||
hash: hashDirectory(child)
|
||||
})),
|
||||
...directory.files.map((file) => ({
|
||||
mode: file.executable ? '100755' : '100644',
|
||||
name: file.filename,
|
||||
hash: Buffer.from(file.gitBlobSha, 'hex')
|
||||
}))
|
||||
].sort((left, right) => {
|
||||
const leftName = left.mode === '40000' ? `${left.name}/` : left.name
|
||||
const rightName = right.mode === '40000' ? `${right.name}/` : right.name
|
||||
return Buffer.from(leftName).compare(Buffer.from(rightName))
|
||||
})
|
||||
const body = Buffer.concat(
|
||||
children.map(({ mode, name, hash }) =>
|
||||
Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash])
|
||||
)
|
||||
)
|
||||
return gitObjectSha('tree', body)
|
||||
}
|
||||
|
||||
return hashDirectory(root).toString('hex')
|
||||
}
|
||||
|
||||
async function collectPackageFiles(packageRoot) {
|
||||
const files = []
|
||||
const caseFoldedPaths = new Map()
|
||||
|
||||
async function visit(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
// Why: build-time Node and packaged Electron may ship different ICU data;
|
||||
// package identity order must use the same locale-independent comparison.
|
||||
entries.sort((left, right) => compareCodeUnits(left.name, right.name))
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(directory, entry.name)
|
||||
const relativePath = path.relative(packageRoot, absolutePath)
|
||||
assertSafeRelativePath(relativePath)
|
||||
const manifestPath = relativePath.split(path.sep).join('/')
|
||||
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
|
||||
const collision = caseFoldedPaths.get(foldedPath)
|
||||
if (collision && collision !== manifestPath) {
|
||||
throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`)
|
||||
}
|
||||
caseFoldedPaths.set(foldedPath, manifestPath)
|
||||
const fileStat = await lstat(absolutePath)
|
||||
if (fileStat.isSymbolicLink()) {
|
||||
throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`)
|
||||
}
|
||||
if (fileStat.isDirectory()) {
|
||||
await visit(absolutePath)
|
||||
continue
|
||||
}
|
||||
if (!fileStat.isFile()) {
|
||||
throw new Error(`Special file is not allowed in a shipped skill: ${manifestPath}`)
|
||||
}
|
||||
// Why: Windows observation cannot see execute bits, so an executable file in
|
||||
// a shipped skill would misclassify every pristine Windows install as unrecognized.
|
||||
if ((fileStat.mode & 0o111) !== 0) {
|
||||
throw new Error(`Executable file is not allowed in a shipped skill: ${manifestPath}`)
|
||||
}
|
||||
files.push(describeFile(manifestPath, await readFile(absolutePath), false))
|
||||
}
|
||||
}
|
||||
|
||||
await visit(packageRoot)
|
||||
return sortManifestFiles(files)
|
||||
}
|
||||
|
||||
function collectGitSkillTreeEntries(treeSha) {
|
||||
const output = execFileSync('git', ['ls-tree', '-r', '-z', treeSha])
|
||||
.toString('utf8')
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
const packages = new Map()
|
||||
for (const line of output) {
|
||||
const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line)
|
||||
if (!match) {
|
||||
throw new Error(`Unexpected git tree entry in ${treeSha}: ${line}`)
|
||||
}
|
||||
const [, mode, type, objectSha, sourcePath] = match
|
||||
const separator = sourcePath.indexOf('/')
|
||||
if (separator <= 0 || separator === sourcePath.length - 1) {
|
||||
throw new Error(`Unsupported shipped skill path in ${treeSha}: ${sourcePath}`)
|
||||
}
|
||||
const name = sourcePath.slice(0, separator)
|
||||
const manifestPath = sourcePath.slice(separator + 1)
|
||||
const entries = packages.get(name) ?? []
|
||||
entries.push({ mode, type, objectSha, manifestPath })
|
||||
packages.set(name, entries)
|
||||
}
|
||||
return packages
|
||||
}
|
||||
|
||||
function readGitBlobs(objectShas) {
|
||||
const uniqueShas = [...new Set(objectShas)]
|
||||
if (uniqueShas.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
// Why: released history spans hundreds of tags. Batch mode avoids a Git
|
||||
// subprocess per historical file while remaining available on Git 2.25.
|
||||
const output = execFileSync('git', ['cat-file', '--batch'], {
|
||||
input: `${uniqueShas.join('\n')}\n`,
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
const blobs = new Map()
|
||||
let offset = 0
|
||||
for (const requestedSha of uniqueShas) {
|
||||
const headerEnd = output.indexOf(10, offset)
|
||||
if (headerEnd < 0) {
|
||||
throw new Error(`Missing git cat-file header for ${requestedSha}`)
|
||||
}
|
||||
const header = output.subarray(offset, headerEnd).toString('utf8')
|
||||
const match = /^([a-f0-9]+) blob (\d+)$/.exec(header)
|
||||
if (!match || match[1] !== requestedSha) {
|
||||
throw new Error(`Unexpected git cat-file header for ${requestedSha}: ${header}`)
|
||||
}
|
||||
const size = Number(match[2])
|
||||
const contentStart = headerEnd + 1
|
||||
const contentEnd = contentStart + size
|
||||
if (contentEnd >= output.length || output[contentEnd] !== 10) {
|
||||
throw new Error(`Truncated git blob for ${requestedSha}`)
|
||||
}
|
||||
blobs.set(requestedSha, Buffer.from(output.subarray(contentStart, contentEnd)))
|
||||
offset = contentEnd + 1
|
||||
}
|
||||
return blobs
|
||||
}
|
||||
|
||||
function collectGitPackageFiles(treeSha, name, entries, blobs) {
|
||||
const caseFoldedPaths = new Map()
|
||||
const files = entries.map(({ mode, type, objectSha, manifestPath }) => {
|
||||
if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) {
|
||||
throw new Error(`Unsupported shipped skill entry in ${treeSha}: ${name}/${manifestPath}`)
|
||||
}
|
||||
assertSafeRelativePath(manifestPath)
|
||||
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
|
||||
const collision = caseFoldedPaths.get(foldedPath)
|
||||
if (collision && collision !== manifestPath) {
|
||||
throw new Error(`Case-colliding skill paths in ${treeSha}: ${collision} and ${manifestPath}`)
|
||||
}
|
||||
caseFoldedPaths.set(foldedPath, manifestPath)
|
||||
const bytes = blobs.get(objectSha)
|
||||
if (!bytes) {
|
||||
throw new Error(`Missing git blob ${objectSha} for ${name}/${manifestPath}`)
|
||||
}
|
||||
return describeFile(manifestPath, bytes, mode === '100755')
|
||||
})
|
||||
// Why: git ls-tree emits git byte-order, not the canonical walk order.
|
||||
return sortManifestFiles(files)
|
||||
}
|
||||
|
||||
// Why: snapshot matching compares files by array index, so every producer —
|
||||
// working-tree walk, git history, and runtime observation — must emit one
|
||||
// canonical order. This mirrors the sorted depth-first filesystem walk.
|
||||
function compareManifestPaths(left, right) {
|
||||
const leftParts = left.split('/')
|
||||
const rightParts = right.split('/')
|
||||
const shared = Math.min(leftParts.length, rightParts.length)
|
||||
for (let index = 0; index < shared; index += 1) {
|
||||
const order = compareCodeUnits(leftParts[index], rightParts[index])
|
||||
if (order !== 0) {
|
||||
return order
|
||||
}
|
||||
}
|
||||
return leftParts.length - rightParts.length
|
||||
}
|
||||
|
||||
function sortManifestFiles(files) {
|
||||
return [...files].sort((left, right) => compareManifestPaths(left.path, right.path))
|
||||
}
|
||||
|
||||
function packageDigest(files) {
|
||||
return sha256(
|
||||
Buffer.from(
|
||||
JSON.stringify(
|
||||
files.map((file) => ({
|
||||
path: file.path,
|
||||
executable: file.executable,
|
||||
classification: file.classification,
|
||||
identitySha256: file.identitySha256
|
||||
}))
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function releaseTags() {
|
||||
return execFileSync(
|
||||
'git',
|
||||
['for-each-ref', '--sort=creatordate', '--format=%(refname:short)', 'refs/tags/v*'],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
.split('\n')
|
||||
.filter((tag) => /^v\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(tag))
|
||||
}
|
||||
|
||||
function skillsTreeShasAtRefs(refs) {
|
||||
if (refs.length === 0) {
|
||||
return []
|
||||
}
|
||||
const output = execFileSync('git', ['cat-file', '--batch-check=%(objectname) %(objecttype)'], {
|
||||
input: `${refs.map((ref) => `${ref}:skills`).join('\n')}\n`,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
const lines = output.trimEnd().split('\n')
|
||||
if (lines.length !== refs.length) {
|
||||
throw new Error(`Expected ${refs.length} skills tree identities, received ${lines.length}`)
|
||||
}
|
||||
return lines.map((line, index) => {
|
||||
if (line.endsWith(' missing')) {
|
||||
return null
|
||||
}
|
||||
const match = /^([a-f0-9]+) tree$/.exec(line)
|
||||
if (!match) {
|
||||
throw new Error(`Unexpected skills tree identity at ${refs[index]}: ${line}`)
|
||||
}
|
||||
return match[1]
|
||||
})
|
||||
}
|
||||
|
||||
function buildReleasedHistory() {
|
||||
const registry = { schemaVersion: SCHEMA_VERSION, skills: {} }
|
||||
const mapping = { schemaVersion: SCHEMA_VERSION, releases: [] }
|
||||
const tags = releaseTags()
|
||||
const treeShas = skillsTreeShasAtRefs(tags)
|
||||
const distinctTreeShas = [...new Set(treeShas.filter(Boolean))]
|
||||
const packagesByTree = new Map(
|
||||
distinctTreeShas.map((treeSha) => [treeSha, collectGitSkillTreeEntries(treeSha)])
|
||||
)
|
||||
const blobs = readGitBlobs(
|
||||
[...packagesByTree.values()].flatMap((packages) =>
|
||||
[...packages.values()].flatMap((entries) => entries.map((entry) => entry.objectSha))
|
||||
)
|
||||
)
|
||||
let previousSkillsTreeSha = null
|
||||
for (const [index, tag] of tags.entries()) {
|
||||
const skillsTreeSha = treeShas[index]
|
||||
if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) {
|
||||
continue
|
||||
}
|
||||
previousSkillsTreeSha = skillsTreeSha
|
||||
const revisions = {}
|
||||
const packages = packagesByTree.get(skillsTreeSha)
|
||||
if (!packages) {
|
||||
throw new Error(`Missing released skill tree ${skillsTreeSha} at ${tag}`)
|
||||
}
|
||||
for (const name of [...packages.keys()].sort(compareCodeUnits)) {
|
||||
const entries = packages.get(name)
|
||||
const filesWithGitHashes = collectGitPackageFiles(skillsTreeSha, name, entries, blobs)
|
||||
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
|
||||
continue
|
||||
}
|
||||
const digest = packageDigest(filesWithGitHashes)
|
||||
const snapshots = registry.skills[name] ?? []
|
||||
const latest = snapshots.at(-1)
|
||||
if (!latest || latest.packageDigest !== digest) {
|
||||
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
|
||||
snapshots.push({
|
||||
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
|
||||
packageDigest: digest,
|
||||
gitTreeSha: gitTreeSha(filesWithGitHashes),
|
||||
files
|
||||
})
|
||||
registry.skills[name] = snapshots
|
||||
}
|
||||
revisions[name] = snapshots.at(-1).releaseRevision
|
||||
}
|
||||
if (Object.keys(revisions).length > 0) {
|
||||
mapping.releases.push({ appVersion: tag.slice(1), skills: revisions })
|
||||
}
|
||||
}
|
||||
return { registry, mapping }
|
||||
}
|
||||
|
||||
async function buildArtifacts(appVersion) {
|
||||
const { registry, mapping } = buildReleasedHistory()
|
||||
const releasedSnapshotCounts = Object.fromEntries(
|
||||
Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length])
|
||||
)
|
||||
const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort(compareCodeUnits)
|
||||
const currentSkills = []
|
||||
for (const name of skillDirectories) {
|
||||
const filesWithGitHashes = await collectPackageFiles(path.join(SKILLS_ROOT, name))
|
||||
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
|
||||
throw new Error(`Skill package ${name} has no top-level SKILL.md`)
|
||||
}
|
||||
const digest = packageDigest(filesWithGitHashes)
|
||||
const snapshots = registry.skills[name] ?? []
|
||||
const latest = snapshots.at(-1)
|
||||
let snapshot = latest
|
||||
if (!latest || latest.packageDigest !== digest) {
|
||||
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
|
||||
snapshot = {
|
||||
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
|
||||
packageDigest: digest,
|
||||
gitTreeSha: gitTreeSha(filesWithGitHashes),
|
||||
files
|
||||
}
|
||||
snapshots.push(snapshot)
|
||||
registry.skills[name] = snapshots
|
||||
}
|
||||
currentSkills.push({
|
||||
name,
|
||||
sourcePath: `skills/${name}`,
|
||||
appVersion,
|
||||
...snapshot
|
||||
})
|
||||
}
|
||||
return {
|
||||
currentManifest: {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
appVersion,
|
||||
skills: currentSkills
|
||||
},
|
||||
snapshotRegistry: registry,
|
||||
releaseMapping: mapping,
|
||||
releasedSnapshotCounts
|
||||
}
|
||||
}
|
||||
|
||||
// Why: released snapshots are the detection ground truth for existing installs,
|
||||
// so a generation-logic change must not rewrite them silently. Only the one
|
||||
// unreleased working-tree append per skill may change between runs.
|
||||
function assertReleasedHistoryPreserved(committedRegistry, artifacts) {
|
||||
if (!committedRegistry || committedRegistry.schemaVersion !== SCHEMA_VERSION) {
|
||||
return
|
||||
}
|
||||
for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) {
|
||||
const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0
|
||||
const regenerated = artifacts.snapshotRegistry.skills[name] ?? []
|
||||
if (releasedCount < Math.max(0, committedSnapshots.length - 1)) {
|
||||
throw new Error(
|
||||
`Released snapshot history is incomplete for ${name}. ` +
|
||||
'Fetch all release tags before regenerating skill artifacts.'
|
||||
)
|
||||
}
|
||||
const protectedCount = Math.min(committedSnapshots.length, releasedCount)
|
||||
for (let index = 0; index < protectedCount; index += 1) {
|
||||
const committed = committedSnapshots[index]
|
||||
const rebuilt = regenerated[index]
|
||||
if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) {
|
||||
throw new Error(
|
||||
`Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` +
|
||||
'Released snapshots are append-only; a deliberate identity migration must update this check.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readCommittedRegistry() {
|
||||
try {
|
||||
return JSON.parse(await readFile(SNAPSHOT_REGISTRY_PATH, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function serialized(value) {
|
||||
return `${JSON.stringify(value, null, 2)}\n`
|
||||
}
|
||||
|
||||
async function writeArtifacts(artifacts) {
|
||||
await mkdir(OUTPUT_ROOT, { recursive: true })
|
||||
await Promise.all([
|
||||
writeFile(CURRENT_MANIFEST_PATH, serialized(artifacts.currentManifest)),
|
||||
writeFile(SNAPSHOT_REGISTRY_PATH, serialized(artifacts.snapshotRegistry)),
|
||||
writeFile(RELEASE_MAPPING_PATH, serialized(artifacts.releaseMapping))
|
||||
])
|
||||
}
|
||||
|
||||
async function verifyArtifacts(artifacts) {
|
||||
const expected = [
|
||||
[CURRENT_MANIFEST_PATH, artifacts.currentManifest],
|
||||
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry],
|
||||
[RELEASE_MAPPING_PATH, artifacts.releaseMapping]
|
||||
]
|
||||
const stale = []
|
||||
for (const [filePath, value] of expected) {
|
||||
try {
|
||||
await access(filePath, constants.R_OK)
|
||||
if ((await readFile(filePath, 'utf8')) !== serialized(value)) {
|
||||
stale.push(filePath)
|
||||
}
|
||||
} catch {
|
||||
stale.push(filePath)
|
||||
}
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
throw new Error(
|
||||
`Generated skill artifacts are stale:\n${stale
|
||||
.map((filePath) => path.relative(REPO_ROOT, filePath))
|
||||
.join('\n')}\nRun pnpm generate:skill-bundle-manifest.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8'))
|
||||
const artifacts = await buildArtifacts(packageJson.version)
|
||||
assertReleasedHistoryPreserved(await readCommittedRegistry(), artifacts)
|
||||
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
assertReleasedHistoryPreserved,
|
||||
buildArtifacts,
|
||||
buildReleasedHistory,
|
||||
classifyFile,
|
||||
collectPackageFiles,
|
||||
describeFile,
|
||||
gitTreeSha,
|
||||
normalizeText,
|
||||
packageDigest,
|
||||
sortManifestFiles,
|
||||
verifyArtifacts,
|
||||
writeArtifacts
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import { execFileSync } from 'node:child_process'
|
||||
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertReleasedHistoryPreserved,
|
||||
classifyFile,
|
||||
collectPackageFiles,
|
||||
describeFile,
|
||||
gitTreeSha,
|
||||
normalizeText,
|
||||
packageDigest,
|
||||
sortManifestFiles
|
||||
} from './generate-skill-bundle-manifest.mjs'
|
||||
|
||||
const temporaryDirectories = []
|
||||
|
||||
async function createPackage() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'orca-skill-manifest-'))
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('skill bundle manifest generator', () => {
|
||||
it('folds platform line endings for text identity', () => {
|
||||
const lf = Buffer.from('first\nsecond\n')
|
||||
const crlf = Buffer.from('first\r\nsecond\r\n')
|
||||
|
||||
expect(classifyFile(lf)).toBe('text')
|
||||
expect(normalizeText(crlf)).toEqual(lf)
|
||||
})
|
||||
|
||||
it('classifies null-containing and invalid UTF-8 content as binary', () => {
|
||||
expect(classifyFile(Buffer.from([0, 1, 2]))).toBe('binary')
|
||||
expect(classifyFile(Buffer.from([0xc3, 0x28]))).toBe('binary')
|
||||
})
|
||||
|
||||
it('uses normalized text identity but exact executable identity', () => {
|
||||
const skillFile = describeFile('SKILL.md', Buffer.from('line one\r\nline two\r\n'), false)
|
||||
const executable = describeFile('run.sh', Buffer.from('#!/bin/sh\r\necho ok\r\n'), true)
|
||||
|
||||
expect(skillFile.identitySha256).toBe(skillFile.textNormalizedSha256)
|
||||
expect(skillFile.identitySha256).not.toBe(skillFile.exactSha256)
|
||||
expect(executable.exactSha256).not.toBe(executable.textNormalizedSha256)
|
||||
expect(executable.identitySha256).toBe(executable.exactSha256)
|
||||
expect(packageDigest([skillFile, executable])).toMatch(/^[a-f0-9]{64}$/)
|
||||
})
|
||||
|
||||
it('orders git-history files identically to the filesystem walk', async () => {
|
||||
const packageRoot = await createPackage()
|
||||
await mkdir(path.join(packageRoot, 'sub'))
|
||||
for (const name of ['apple.md', 'sub.md', 'Zebra.md', path.join('sub', 'inner.txt')]) {
|
||||
await writeFile(path.join(packageRoot, name), `${name}\n`)
|
||||
}
|
||||
const walked = await collectPackageFiles(packageRoot)
|
||||
|
||||
// Why: git ls-tree emits [Zebra.md, apple.md, sub.md, sub/inner.txt]; index-based
|
||||
// snapshot matching requires history and observation to share one order.
|
||||
const gitOrdered = ['Zebra.md', 'apple.md', 'sub.md', 'sub/inner.txt'].map((manifestPath) =>
|
||||
walked.find((file) => file.path === manifestPath)
|
||||
)
|
||||
|
||||
expect(sortManifestFiles(gitOrdered)).toEqual(walked)
|
||||
expect(packageDigest(sortManifestFiles(gitOrdered))).toBe(packageDigest(walked))
|
||||
expect(walked.map((file) => file.path)).toEqual([
|
||||
'Zebra.md',
|
||||
'apple.md',
|
||||
'sub/inner.txt',
|
||||
'sub.md'
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects rewrites of released snapshots and allows floating-tail replacement', () => {
|
||||
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
|
||||
const artifacts = {
|
||||
releasedSnapshotCounts: { 'orca-cli': 2 },
|
||||
snapshotRegistry: {
|
||||
schemaVersion: 1,
|
||||
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'ccc')] }
|
||||
}
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
assertReleasedHistoryPreserved(
|
||||
{ schemaVersion: 1, skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb')] } },
|
||||
artifacts
|
||||
)
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
assertReleasedHistoryPreserved(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] }
|
||||
},
|
||||
artifacts
|
||||
)
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
assertReleasedHistoryPreserved(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'rewritten')] }
|
||||
},
|
||||
artifacts
|
||||
)
|
||||
).toThrow('Released snapshot history changed for orca-cli at revision 2')
|
||||
expect(() =>
|
||||
assertReleasedHistoryPreserved(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
skills: {
|
||||
'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }]
|
||||
}
|
||||
},
|
||||
artifacts
|
||||
)
|
||||
).toThrow('Released snapshot history changed for orca-cli at revision 2')
|
||||
expect(() =>
|
||||
assertReleasedHistoryPreserved(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
skills: {
|
||||
'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')]
|
||||
}
|
||||
},
|
||||
{ ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } }
|
||||
)
|
||||
).toThrow('Released snapshot history is incomplete for orca-cli')
|
||||
expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow()
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'rejects executable files in shipped skill packages',
|
||||
async () => {
|
||||
const packageRoot = await createPackage()
|
||||
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill\n')
|
||||
await writeFile(path.join(packageRoot, 'run.sh'), '#!/bin/sh\necho ok\n')
|
||||
await chmod(path.join(packageRoot, 'run.sh'), 0o755)
|
||||
|
||||
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
|
||||
'Executable file is not allowed in a shipped skill: run.sh'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'linux')('rejects case-colliding paths', async () => {
|
||||
const packageRoot = await createPackage()
|
||||
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
|
||||
await writeFile(path.join(packageRoot, 'Readme.md'), 'one')
|
||||
await writeFile(path.join(packageRoot, 'README.md'), 'two')
|
||||
|
||||
await expect(collectPackageFiles(packageRoot)).rejects.toThrow('Case-colliding skill paths')
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')('rejects symlinks inside shipped packages', async () => {
|
||||
const packageRoot = await createPackage()
|
||||
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
|
||||
await symlink('SKILL.md', path.join(packageRoot, 'linked.md'))
|
||||
|
||||
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
|
||||
'Symlink is not allowed in a shipped skill'
|
||||
)
|
||||
})
|
||||
|
||||
it('computes the same Git tree identity as Git', async () => {
|
||||
const packageRoot = path.resolve('skills', 'orca-cli')
|
||||
const files = await collectPackageFiles(packageRoot)
|
||||
const expected = execFileSync('git', ['ls-tree', 'HEAD:skills', 'orca-cli'], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
.trim()
|
||||
.split(/\s+/)[2]
|
||||
|
||||
expect(gitTreeSha(files)).toBe(expected)
|
||||
})
|
||||
|
||||
it('matches Git when a directory and file share a name prefix', async () => {
|
||||
const packageRoot = await createPackage()
|
||||
await mkdir(path.join(packageRoot, 'sub'))
|
||||
await writeFile(path.join(packageRoot, 'sub', 'inner.txt'), 'nested\n')
|
||||
await writeFile(path.join(packageRoot, 'sub.md'), 'sibling\n')
|
||||
const files = await collectPackageFiles(packageRoot)
|
||||
execFileSync('git', ['init', '--quiet'], { cwd: packageRoot })
|
||||
execFileSync('git', ['add', '-A'], { cwd: packageRoot })
|
||||
const expected = execFileSync('git', ['write-tree'], {
|
||||
cwd: packageRoot,
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
|
||||
expect(gitTreeSha(files)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
chmod,
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { collectPackageFiles, packageDigest } from './generate-skill-bundle-manifest.mjs'
|
||||
|
||||
function option(name) {
|
||||
return process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3)
|
||||
}
|
||||
|
||||
const cliVersion = option('cli')
|
||||
const autocrlf = option('autocrlf')
|
||||
const shape = option('shape')
|
||||
// Why: PR branch names are untrusted workflow input. Keep them out of the
|
||||
// generated shell command and pass them to Node through the environment.
|
||||
const source = option('source') ?? process.env.SKILL_UPDATE_SOURCE
|
||||
const ref = option('ref') ?? process.env.SKILL_UPDATE_REF
|
||||
if (
|
||||
!cliVersion ||
|
||||
(autocrlf !== 'true' && autocrlf !== 'false') ||
|
||||
(shape !== 'symlink' && shape !== 'copy') ||
|
||||
!source ||
|
||||
!ref ||
|
||||
!/^[^/\s]+\/[^/\s]+$/.test(source)
|
||||
) {
|
||||
throw new Error(
|
||||
'Usage: verify-skill-update-roundtrip.mjs --cli=<version> --autocrlf=true|false --shape=symlink|copy --source=<owner/repo> --ref=<git-ref>'
|
||||
)
|
||||
}
|
||||
|
||||
const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-update-roundtrip-'))
|
||||
const home = path.join(sandbox, 'home')
|
||||
const stateHome = path.join(home, '.state')
|
||||
const fakeBin = path.join(sandbox, 'bin')
|
||||
const targetName = 'orca-cli'
|
||||
const controlName = 'orchestration'
|
||||
const manifest = JSON.parse(await readFile('resources/skills/current-manifest.json', 'utf8'))
|
||||
const registry = JSON.parse(await readFile('resources/skills/snapshot-registry.json', 'utf8'))
|
||||
const releaseMapping = JSON.parse(await readFile('resources/skills/release-mapping.json', 'utf8'))
|
||||
|
||||
function currentSkill(name) {
|
||||
const skill = manifest.skills.find((entry) => entry.name === name)
|
||||
if (!skill) {
|
||||
throw new Error(`Current manifest is missing ${name}`)
|
||||
}
|
||||
return skill
|
||||
}
|
||||
|
||||
function historicalRelease(name) {
|
||||
const current = currentSkill(name)
|
||||
for (const release of releaseMapping.releases.toReversed()) {
|
||||
const revision = release.skills[name]
|
||||
if (typeof revision !== 'number' || revision >= current.releaseRevision) {
|
||||
continue
|
||||
}
|
||||
const snapshot = registry.skills[name]?.find((entry) => entry.releaseRevision === revision)
|
||||
if (snapshot) {
|
||||
return { tag: `v${release.appVersion}`, snapshot }
|
||||
}
|
||||
}
|
||||
throw new Error(`No historical released snapshot is available for ${name}`)
|
||||
}
|
||||
|
||||
async function materializePackage(name, tag, destination) {
|
||||
const prefix = `skills/${name}/`
|
||||
const entries = execFileSync('git', ['ls-tree', '-r', '-z', tag, '--', `skills/${name}`])
|
||||
.toString('utf8')
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
if (entries.length === 0) {
|
||||
throw new Error(`${tag} does not contain ${name}`)
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(entry)
|
||||
if (!match || match[2] !== 'blob') {
|
||||
throw new Error(`Unsupported historical tree entry: ${entry}`)
|
||||
}
|
||||
const relativePath = match[4].slice(prefix.length)
|
||||
const destinationPath = path.join(destination, ...relativePath.split('/'))
|
||||
await mkdir(path.dirname(destinationPath), { recursive: true })
|
||||
await writeFile(destinationPath, execFileSync('git', ['cat-file', 'blob', match[3]]))
|
||||
if (process.platform !== 'win32' && match[1] === '100755') {
|
||||
await chmod(destinationPath, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function seedPlacement(name, tag) {
|
||||
const canonical = path.join(home, '.agents', 'skills', name)
|
||||
await materializePackage(name, tag, canonical)
|
||||
const providerRoot = path.join(home, '.claude', 'skills')
|
||||
const provider = path.join(providerRoot, name)
|
||||
await mkdir(providerRoot, { recursive: true })
|
||||
await (shape === 'copy'
|
||||
? cp(canonical, provider, { recursive: true })
|
||||
: symlink(canonical, provider, process.platform === 'win32' ? 'junction' : 'dir'))
|
||||
}
|
||||
|
||||
async function installFakeAgentCommands() {
|
||||
await mkdir(fakeBin, { recursive: true })
|
||||
for (const name of ['codex', 'claude']) {
|
||||
const executable = path.join(fakeBin, process.platform === 'win32' ? `${name}.cmd` : name)
|
||||
await writeFile(
|
||||
executable,
|
||||
process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n'
|
||||
)
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(executable, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function packageDigestAt(pathValue) {
|
||||
return packageDigest(await collectPackageFiles(pathValue))
|
||||
}
|
||||
|
||||
async function assertCurrentCanonical(name) {
|
||||
const expected = currentSkill(name).packageDigest
|
||||
const canonical = path.join(home, '.agents', 'skills', name)
|
||||
if ((await packageDigestAt(canonical)) !== expected) {
|
||||
throw new Error(`${name} canonical placement did not update to the PR content`)
|
||||
}
|
||||
}
|
||||
|
||||
function execSkills(args) {
|
||||
const executable = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'npx'
|
||||
const cliArgs = ['--yes', `skills@${cliVersion}`, ...args]
|
||||
execFileSync(
|
||||
executable,
|
||||
process.platform === 'win32' ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
CODEX_HOME: path.join(home, '.codex'),
|
||||
CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
|
||||
XDG_STATE_HOME: stateHome,
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'core.autocrlf',
|
||||
GIT_CONFIG_VALUE_0: autocrlf,
|
||||
PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`,
|
||||
CI: '1'
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const targetHistorical = historicalRelease(targetName)
|
||||
const controlHistorical = historicalRelease(controlName)
|
||||
await installFakeAgentCommands()
|
||||
await mkdir(path.join(home, '.codex'), { recursive: true })
|
||||
await mkdir(path.join(home, '.claude'), { recursive: true })
|
||||
await seedPlacement(targetName, targetHistorical.tag)
|
||||
await seedPlacement(controlName, controlHistorical.tag)
|
||||
const targetProvider = path.join(home, '.claude', 'skills', targetName)
|
||||
const controlCanonical = path.join(home, '.agents', 'skills', controlName)
|
||||
const controlProvider = path.join(home, '.claude', 'skills', controlName)
|
||||
const targetProviderBefore = await packageDigestAt(await realpath(targetProvider))
|
||||
const controlBefore = await packageDigestAt(controlCanonical)
|
||||
const controlProviderBefore = await packageDigestAt(await realpath(controlProvider))
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
const lock = {
|
||||
version: 3,
|
||||
skills: {
|
||||
[targetName]: {
|
||||
source,
|
||||
sourceType: 'github',
|
||||
sourceUrl: `https://github.com/${source}.git`,
|
||||
ref,
|
||||
skillPath: `skills/${targetName}/SKILL.md`,
|
||||
skillFolderHash: targetHistorical.snapshot.gitTreeSha,
|
||||
installedAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
},
|
||||
[controlName]: {
|
||||
source,
|
||||
sourceType: 'github',
|
||||
sourceUrl: `https://github.com/${source}.git`,
|
||||
ref,
|
||||
skillPath: `skills/${controlName}/SKILL.md`,
|
||||
skillFolderHash: controlHistorical.snapshot.gitTreeSha,
|
||||
installedAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
const lockPath = path.join(stateHome, 'skills', '.skill-lock.json')
|
||||
await mkdir(path.dirname(lockPath), { recursive: true })
|
||||
await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`)
|
||||
|
||||
// Why: this is the exact user-visible rail. A bare update would include
|
||||
// unrelated vendors, while this command must leave the control skill alone.
|
||||
execSkills(['update', targetName, '--global'])
|
||||
await assertCurrentCanonical(targetName)
|
||||
const targetProviderAfter = await packageDigestAt(await realpath(targetProvider))
|
||||
const targetProviderStat = await lstat(targetProvider)
|
||||
if (shape === 'symlink' && !targetProviderStat.isSymbolicLink()) {
|
||||
throw new Error(`${targetName} provider alias was replaced with an independent copy`)
|
||||
}
|
||||
if (shape === 'symlink' && targetProviderAfter !== currentSkill(targetName).packageDigest) {
|
||||
throw new Error(`${targetName} provider alias did not converge with the canonical update`)
|
||||
}
|
||||
if (
|
||||
shape === 'copy' &&
|
||||
targetProviderAfter !== targetProviderBefore &&
|
||||
targetProviderAfter !== currentSkill(targetName).packageDigest
|
||||
) {
|
||||
throw new Error('Independent provider copy changed to an unexpected package identity')
|
||||
}
|
||||
if (shape === 'copy') {
|
||||
// Why: hosted 1.5.17 replaces copies with aliases while equivalent local runs
|
||||
// retain the copy. Both prove this input topology must remain ineligible.
|
||||
const outcome = targetProviderStat.isSymbolicLink()
|
||||
? 'converged to an alias'
|
||||
: targetProviderAfter === targetProviderBefore
|
||||
? 'remained a historical copy'
|
||||
: 'converged as a copy'
|
||||
console.log(`[skill-update-roundtrip] independent copy ${outcome}`)
|
||||
}
|
||||
if ((await packageDigestAt(controlCanonical)) !== controlBefore) {
|
||||
throw new Error('Targeted update changed the non-targeted control skill')
|
||||
}
|
||||
if ((await packageDigestAt(await realpath(controlProvider))) !== controlProviderBefore) {
|
||||
throw new Error('Targeted update changed the non-targeted control provider placement')
|
||||
}
|
||||
const controlProviderStat = await lstat(controlProvider)
|
||||
if (shape === 'symlink' && !controlProviderStat.isSymbolicLink()) {
|
||||
throw new Error('Targeted update changed the non-targeted control topology')
|
||||
}
|
||||
} finally {
|
||||
await rm(sandbox, { recursive: true, force: true })
|
||||
}
|
||||
|
|
@ -126,6 +126,7 @@ Rules:
|
|||
### C. Read-only detection (kept from Phase 1, slimmed)
|
||||
|
||||
Kept as-is:
|
||||
|
||||
- Bundled `skills/` packages + current manifest + released-snapshot registry + release
|
||||
mapping, with the generation script and merge-queue monotonicity gate (static data + CI,
|
||||
not runtime machinery).
|
||||
|
|
@ -135,9 +136,12 @@ Kept as-is:
|
|||
plugin caches and repo scopes excluded), and the launch / focus / post-install triggers.
|
||||
- The skills-CLI round-trip CI on macOS/Linux/Windows — extended from current-install tests
|
||||
to historical-fat-install → targeted global update → stub migration. The matrix covers
|
||||
copy/symlink shapes, LF/CRLF, supported lock migrations, and post-update identity.
|
||||
LF/CRLF and provider aliases as a positive convergence contract, plus independent-copy
|
||||
observation that accepts only unchanged historical or exact-current bytes. Post-update
|
||||
bytes, not exit status, decide.
|
||||
|
||||
Slimmed:
|
||||
|
||||
- Statuses collapse to: `current`, `outdated` (exact match of an older released snapshot),
|
||||
`newer-known`, `unrecognized`, and `inaccessible`. Without a ledger, Orca cannot honestly
|
||||
distinguish a locally modified official copy from unrelated same-named content;
|
||||
|
|
@ -151,32 +155,51 @@ Slimmed:
|
|||
|
||||
### D. Surfacing
|
||||
|
||||
- **Settings rows** (read-only): name, status badge, one-line explanation. `newer-known`,
|
||||
`unrecognized`, `inaccessible`, and unsupported-topology rows are informational.
|
||||
- **Name-scoped update eligibility:** the skills CLI reinstalls every placement of a selected
|
||||
skill name, so eligibility is computed across all discovered placements of that name, not
|
||||
per row. Offer a name only when at least one placement is `outdated` and every placement is
|
||||
an exact `current` or `outdated` official snapshot in a supported global topology. One
|
||||
`newer-known`, unrecognized, external, read-only, inaccessible, or otherwise unsupported
|
||||
provider copy poisons the update offer for that name entirely.
|
||||
- **Surfaces (venue decision 2026-07-14):** a lingering toast, an update modal, and the
|
||||
existing Settings setup rails for CLI, Orchestration, Computer Use, and Per-Workspace
|
||||
Environments. Their installed pills carry safe freshness status, while their existing
|
||||
Update and Re-check actions remain the per-skill path. The Skills page was
|
||||
de-linked by #4535 (2026-06-02) — its only entry, the sidebar toolbox menu, was removed —
|
||||
so it is no longer a venue; the freshness surface moved off it entirely. The behavior
|
||||
contracts below (name-scoped eligibility, no auto-run, dismissal keys, re-inventory
|
||||
triggers) are unchanged; only the venue moved.
|
||||
- **Per-placement rows** (read-only): name, status badge, one-line explanation. `newer-known`,
|
||||
`unrecognized`, `inaccessible`, and unsupported-topology rows are informational. They live in
|
||||
the modal's collapsed **Details** section (auto-expanded when a placement is blocked).
|
||||
- **Name-scoped update eligibility:** eligibility is computed across all discovered placements
|
||||
of a name, not per row. Offer a name only when at least one placement is `outdated` and every
|
||||
placement is an exact `current` or `outdated` official snapshot in a topology the validated
|
||||
rail actually converges. With skills CLI 1.5.17 that means the canonical global copy and
|
||||
provider aliases to it. Independent provider copies are informational and poison the offer:
|
||||
empirical copy-mode testing produced both stale and converged provider copies in otherwise
|
||||
equivalent 1.5.17 environments, so that topology is not deterministic enough to offer. One
|
||||
`newer-known`, unrecognized, external, read-only,
|
||||
inaccessible, repo/plugin, independent-copy, or otherwise unsupported placement poisons the
|
||||
update offer for that name entirely.
|
||||
- The action combines only eligible outdated Orca names into
|
||||
`npx skills update <names...> --global`, opens the existing run-command terminal with that
|
||||
command pre-filled, and leaves execution to the user. Never use an unscoped bulk update and
|
||||
never auto-submit the command. Re-inventory after terminal exit or focus; only observed
|
||||
bytes, not the skills CLI exit status, determine success.
|
||||
- **One non-repeating nudge**: count only eligible outdated skill names and offer the same
|
||||
targeted run-the-command action. An outdated name poisoned by another placement remains
|
||||
visible in settings but never produces an unsafe nudge action. Dismissal is recorded per
|
||||
(install, bundled revision), so a newly outdated official placement or genuinely newer
|
||||
stub revision may prompt once more. No toggle — nothing automatic happens that would need
|
||||
one.
|
||||
`npx skills update <names...> --global` and opens the update modal's editable terminal with
|
||||
that command pre-filled, leaving execution to the user. Never use an unscoped bulk update and
|
||||
never auto-submit the command. Re-inventory after terminal exit, modal close, or focus; only
|
||||
observed bytes, not the skills CLI exit status, determine success. When the eligible set
|
||||
empties and every placement is `current`, the modal shows an up-to-date state; if placements
|
||||
remain outdated-but-blocked or unrecognized, it says so honestly instead.
|
||||
- **One lingering, non-repeating nudge**: count only eligible outdated skill names and offer the
|
||||
same targeted action, which opens the update modal. The toast lingers (no auto-close) until the
|
||||
user opens the modal or explicitly dismisses it; ignoring it (app quit) records nothing, so a
|
||||
still-outdated skill may prompt once more next launch. A later inventory that resolves or blocks
|
||||
the offered tuple retracts the stale toast without recording a dismissal. An outdated name
|
||||
poisoned by another placement remains visible in the modal's Details but never produces an
|
||||
unsafe nudge action. Dismissal is recorded per (physical identity, name, bundled revision) only
|
||||
on explicit dismissal, so a newly outdated official placement or genuinely newer stub revision
|
||||
may prompt once more. No
|
||||
toggle — nothing automatic happens that would need one.
|
||||
|
||||
### E. Migration (fat → stub)
|
||||
|
||||
1. **Implemented, pending release:** from a fresh main-based PR, add authoritative guide
|
||||
sources, generated embedded data, `orca skills list/get`, aliases, generated-output checks,
|
||||
and local/SSH/WSL/dev tests. Keep distributed skills fat and ship this release first.
|
||||
2. From a separate PR, land slim read-only detection and settings/nudge UI, including the
|
||||
2. From a separate PR, land slim read-only detection and Skills-page/nudge UI, including the
|
||||
name-scoped targeted update action and the real migration-rail CI. Keep distributed
|
||||
skills fat.
|
||||
3. Run the pointer-compliance spike against the released guide-serving binary, not a checkout
|
||||
|
|
@ -211,15 +234,17 @@ not thin, until the relevant variant passes.
|
|||
global Windows failures, missing global lock tracking, lossy lock migration, and copy-mode
|
||||
topology changes. The historical-fat → targeted-global-update → stub CI is a release gate,
|
||||
not an early-warning job. Detection always re-checks bytes after the user updates, so a
|
||||
failed or no-op update re-surfaces `outdated` instead of lying. Choose and document a
|
||||
validated CLI-version policy before rollout; monitor and contribute upstream fixes.
|
||||
failed or no-op update re-surfaces `outdated` instead of lying. Minimum validated version is
|
||||
1.5.17: 1.5.16 failed the provider-alias convergence contract, while 1.5.17 copy convergence
|
||||
still varies by environment. CI pins 1.5.17 and probes latest; monitor and contribute upstream
|
||||
fixes before broadening eligibility.
|
||||
- **Trigger-copy iteration slows.** Improvements to stub descriptions reach existing
|
||||
installs only when users run the npx command. Acceptable at stub-change cadence; the
|
||||
compiled guides (the content that matters) are exempt by construction.
|
||||
- **Multi-file skills.** Current shipped packages are single-file. If a future skill needs
|
||||
scripts/assets, either the binary serves them (`--full` / `--script`) or that skill
|
||||
accepts the fat-file decay model. Decide when it happens.
|
||||
- **Remote hosts.** Detection ships local-host-only. Stubs make remote *content* a non-issue:
|
||||
- **Remote hosts.** Detection ships local-host-only. Stubs make remote _content_ a non-issue:
|
||||
SSH/WSL launchers forward to the host's bundled CLI, so the guide matches the command
|
||||
surface that will handle subsequent requests. Remote stub installs can lag on trigger
|
||||
copy, which is the accepted residual. The WSL/SSH reconciler phases of the old design are
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"format": "oxfmt --write .",
|
||||
"lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
|
||||
"lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
|
||||
"lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json",
|
||||
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
|
||||
"lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet",
|
||||
|
|
@ -23,6 +23,8 @@
|
|||
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
|
||||
"generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write",
|
||||
"verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check",
|
||||
"generate:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write",
|
||||
"verify:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs",
|
||||
"verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs",
|
||||
"vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs",
|
||||
"tc:node": "pnpm run typecheck:node",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"appVersion": "1.4.144-rc.1",
|
||||
"skills": [
|
||||
{
|
||||
"name": "computer-use",
|
||||
"sourcePath": "skills/computer-use",
|
||||
"appVersion": "1.4.144-rc.1",
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68",
|
||||
"gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 11241,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39",
|
||||
"textNormalizedSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39",
|
||||
"identitySha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "linear-tickets",
|
||||
"sourcePath": "skills/linear-tickets",
|
||||
"appVersion": "1.4.144-rc.1",
|
||||
"releaseRevision": 4,
|
||||
"packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b",
|
||||
"gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 10596,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5",
|
||||
"textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5",
|
||||
"identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "orca-cli",
|
||||
"sourcePath": "skills/orca-cli",
|
||||
"appVersion": "1.4.144-rc.1",
|
||||
"releaseRevision": 32,
|
||||
"packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f",
|
||||
"gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 21557,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be",
|
||||
"textNormalizedSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be",
|
||||
"identitySha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "orca-emulator",
|
||||
"sourcePath": "skills/orca-emulator",
|
||||
"appVersion": "1.4.144-rc.1",
|
||||
"releaseRevision": 4,
|
||||
"packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b",
|
||||
"gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 11527,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429",
|
||||
"textNormalizedSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429",
|
||||
"identitySha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "orca-emulator-android",
|
||||
"sourcePath": "skills/orca-emulator-android",
|
||||
"appVersion": "1.4.144-rc.1",
|
||||
"releaseRevision": 2,
|
||||
"packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5",
|
||||
"gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 8886,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332",
|
||||
"textNormalizedSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332",
|
||||
"identitySha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "orca-linear",
|
||||
"sourcePath": "skills/orca-linear",
|
||||
"appVersion": "1.4.144-rc.1",
|
||||
"releaseRevision": 2,
|
||||
"packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f",
|
||||
"gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 10320,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5",
|
||||
"textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5",
|
||||
"identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "orca-per-workspace-env",
|
||||
"sourcePath": "skills/orca-per-workspace-env",
|
||||
"appVersion": "1.4.144-rc.1",
|
||||
"releaseRevision": 2,
|
||||
"packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d",
|
||||
"gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 43769,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
|
||||
"textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
|
||||
"identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "orchestration",
|
||||
"sourcePath": "skills/orchestration",
|
||||
"appVersion": "1.4.144-rc.1",
|
||||
"releaseRevision": 24,
|
||||
"packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9",
|
||||
"gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 22861,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5",
|
||||
"textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5",
|
||||
"identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,501 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"appVersion": "1.0.81",
|
||||
"skills": {
|
||||
"orca-cli": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.0.86",
|
||||
"skills": {
|
||||
"orca-cli": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.0.94-rc.0",
|
||||
"skills": {
|
||||
"orca-cli": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.3.8-rc.1",
|
||||
"skills": {
|
||||
"orca-cli": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.3.12-rc.4",
|
||||
"skills": {
|
||||
"orca-cli": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.3.24-rc.2",
|
||||
"skills": {
|
||||
"orca-cli": 6,
|
||||
"orchestration": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.3.33-rc.3",
|
||||
"skills": {
|
||||
"mobile-fit-debug": 1,
|
||||
"orca-cli": 6,
|
||||
"orchestration": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "0.0.1",
|
||||
"skills": {
|
||||
"orca-cli": 6,
|
||||
"orchestration": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.3.35-rc.0",
|
||||
"skills": {
|
||||
"mobile-fit-debug": 1,
|
||||
"orca-cli": 6,
|
||||
"orchestration": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.3.38-rc.3",
|
||||
"skills": {
|
||||
"orca-cli": 6,
|
||||
"orchestration": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.3.38",
|
||||
"skills": {
|
||||
"mobile-fit-debug": 1,
|
||||
"orca-cli": 6,
|
||||
"orchestration": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.3.39",
|
||||
"skills": {
|
||||
"orca-cli": 6,
|
||||
"orchestration": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.3.46-rc.3",
|
||||
"skills": {
|
||||
"orca-cli": 7,
|
||||
"orchestration": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.3.47-rc.0",
|
||||
"skills": {
|
||||
"orca-cli": 8,
|
||||
"orchestration": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.3.49-rc.0",
|
||||
"skills": {
|
||||
"computer-use": 1,
|
||||
"orca-cli": 8,
|
||||
"orchestration": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.2-rc.10",
|
||||
"skills": {
|
||||
"computer-use": 1,
|
||||
"orca-cli": 9,
|
||||
"orchestration": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.2",
|
||||
"skills": {
|
||||
"computer-use": 1,
|
||||
"orca-cli": 10,
|
||||
"orchestration": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.3-rc.0",
|
||||
"skills": {
|
||||
"computer-use": 1,
|
||||
"orca-cli": 11,
|
||||
"orchestration": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.9-rc.1",
|
||||
"skills": {
|
||||
"computer-use": 1,
|
||||
"orca-cli": 12,
|
||||
"orchestration": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.10-rc.3",
|
||||
"skills": {
|
||||
"computer-use": 1,
|
||||
"orca-cli": 13,
|
||||
"orchestration": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.11-rc.0",
|
||||
"skills": {
|
||||
"computer-use": 1,
|
||||
"orca-cli": 14,
|
||||
"orchestration": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.18-rc.2",
|
||||
"skills": {
|
||||
"computer-use": 1,
|
||||
"orca-cli": 15,
|
||||
"orchestration": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.18-rc.3",
|
||||
"skills": {
|
||||
"computer-use": 1,
|
||||
"orca-cli": 16,
|
||||
"orchestration": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.22",
|
||||
"skills": {
|
||||
"computer-use": 1,
|
||||
"orca-cli": 16,
|
||||
"orchestration": 7
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.36-rc.2",
|
||||
"skills": {
|
||||
"computer-use": 1,
|
||||
"orca-cli": 17,
|
||||
"orchestration": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.36-rc.6",
|
||||
"skills": {
|
||||
"computer-use": 1,
|
||||
"orca-cli": 18,
|
||||
"orchestration": 9
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.42-rc.8",
|
||||
"skills": {
|
||||
"computer-use": 2,
|
||||
"orca-cli": 19,
|
||||
"orchestration": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.46-rc.0",
|
||||
"skills": {
|
||||
"computer-use": 2,
|
||||
"orca-cli": 19,
|
||||
"orchestration": 11
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.51-rc.8",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"orca-cli": 19,
|
||||
"orchestration": 11
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.51-rc.9",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"orca-cli": 20,
|
||||
"orca-emulator": 1,
|
||||
"orchestration": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.65-rc.3",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 1,
|
||||
"orca-cli": 20,
|
||||
"orca-emulator": 1,
|
||||
"orchestration": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.65",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"orca-cli": 20,
|
||||
"orca-emulator": 1,
|
||||
"orchestration": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.66-rc.0",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 1,
|
||||
"orca-cli": 20,
|
||||
"orca-emulator": 1,
|
||||
"orchestration": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.67-rc.0",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 2,
|
||||
"orca-cli": 20,
|
||||
"orca-emulator": 1,
|
||||
"orchestration": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.68",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 2,
|
||||
"orca-cli": 20,
|
||||
"orca-emulator": 1,
|
||||
"orchestration": 13
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.74-rc.0",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 2,
|
||||
"orca-cli": 21,
|
||||
"orca-emulator": 1,
|
||||
"orchestration": 14
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.78-rc.1",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 2,
|
||||
"orca-cli": 22,
|
||||
"orca-emulator": 1,
|
||||
"orchestration": 15
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.78-rc.2",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 2,
|
||||
"orca-cli": 23,
|
||||
"orca-emulator": 1,
|
||||
"orchestration": 16
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.81-rc.2",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 2,
|
||||
"orca-cli": 24,
|
||||
"orca-emulator": 1,
|
||||
"orchestration": 16
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.81-rc.3",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 2,
|
||||
"orca-cli": 25,
|
||||
"orca-emulator": 1,
|
||||
"orchestration": 16
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.88",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 2,
|
||||
"orca-cli": 26,
|
||||
"orca-emulator": 1,
|
||||
"orchestration": 17
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.90",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 3,
|
||||
"orca-cli": 26,
|
||||
"orca-emulator": 1,
|
||||
"orca-linear": 1,
|
||||
"orchestration": 17
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.92-rc.1",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 3,
|
||||
"orca-cli": 27,
|
||||
"orca-emulator": 1,
|
||||
"orca-linear": 1,
|
||||
"orchestration": 17
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.96-rc.1",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 3,
|
||||
"orca-cli": 28,
|
||||
"orca-emulator": 1,
|
||||
"orca-linear": 1,
|
||||
"orchestration": 17
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.105-rc.2",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 3,
|
||||
"orca-cli": 29,
|
||||
"orca-emulator": 2,
|
||||
"orca-emulator-android": 1,
|
||||
"orca-linear": 1,
|
||||
"orchestration": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.111-rc.0",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 3,
|
||||
"orca-cli": 29,
|
||||
"orca-emulator": 2,
|
||||
"orca-emulator-android": 1,
|
||||
"orca-linear": 1,
|
||||
"orca-per-workspace-env": 1,
|
||||
"orchestration": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.124-rc.6",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 3,
|
||||
"orca-cli": 29,
|
||||
"orca-emulator": 2,
|
||||
"orca-emulator-android": 1,
|
||||
"orca-linear": 1,
|
||||
"orca-per-workspace-env": 2,
|
||||
"orchestration": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.124-rc.9",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 3,
|
||||
"orca-cli": 29,
|
||||
"orca-emulator": 2,
|
||||
"orca-emulator-android": 1,
|
||||
"orca-linear": 1,
|
||||
"orca-per-workspace-env": 2,
|
||||
"orchestration": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.124",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 4,
|
||||
"orca-cli": 29,
|
||||
"orca-emulator": 2,
|
||||
"orca-emulator-android": 1,
|
||||
"orca-linear": 2,
|
||||
"orca-per-workspace-env": 2,
|
||||
"orchestration": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.137-rc.1",
|
||||
"skills": {
|
||||
"computer-use": 3,
|
||||
"linear-tickets": 4,
|
||||
"orca-cli": 30,
|
||||
"orca-emulator": 2,
|
||||
"orca-emulator-android": 1,
|
||||
"orca-linear": 2,
|
||||
"orca-per-workspace-env": 2,
|
||||
"orchestration": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.138-rc.0",
|
||||
"skills": {
|
||||
"computer-use": 4,
|
||||
"linear-tickets": 4,
|
||||
"orca-cli": 31,
|
||||
"orca-emulator": 3,
|
||||
"orca-emulator-android": 1,
|
||||
"orca-linear": 2,
|
||||
"orca-per-workspace-env": 2,
|
||||
"orchestration": 22
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.138-rc.3",
|
||||
"skills": {
|
||||
"computer-use": 4,
|
||||
"linear-tickets": 4,
|
||||
"orca-cli": 31,
|
||||
"orca-emulator": 3,
|
||||
"orca-emulator-android": 1,
|
||||
"orca-linear": 2,
|
||||
"orca-per-workspace-env": 2,
|
||||
"orchestration": 23
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.138-rc.4",
|
||||
"skills": {
|
||||
"computer-use": 4,
|
||||
"linear-tickets": 4,
|
||||
"orca-cli": 31,
|
||||
"orca-emulator": 3,
|
||||
"orca-emulator-android": 1,
|
||||
"orca-linear": 2,
|
||||
"orca-per-workspace-env": 2,
|
||||
"orchestration": 24
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.141",
|
||||
"skills": {
|
||||
"computer-use": 5,
|
||||
"linear-tickets": 4,
|
||||
"orca-cli": 32,
|
||||
"orca-emulator": 4,
|
||||
"orca-emulator-android": 2,
|
||||
"orca-linear": 2,
|
||||
"orca-per-workspace-env": 2,
|
||||
"orchestration": 24
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue